package generation import ( "context" "encoding/json" "fmt" "github.com/openai/openai-go/v2" ) var ConversationSchema = GenerateSchema[Conversation]() // normalizeMaxMessages clamps the reply-turn count into the // range the warmup prompt is tuned for. Shared so the sync and batch paths // produce identical thread shapes. func normalizeMaxMessages(maxMessages int) int { if maxMessages <= 0 { return 4 } if maxMessages > 5 { return 5 } return maxMessages } // warmupSystemPrompt builds the deliverability-tuned system prompt for one // theme. Extracted so the sync path (GenerateConversation) and the Batch API // path (SubmitBatch) send the exact same instruction, keeping the two // generation modes byte-for-byte identical. func warmupSystemPrompt(theme string, maxMessages int) string { // The prompt is grounded in the ACTUAL signals AI-text detectors key on // (burstiness, contractions, AI-accent vocabulary, stock openers, symmetric // templates), not a vague "sound human". A deterministic post-processing // pass (internal/pkg/humanlint) then strips residual tells before the // content is cached, so this is the first of two coordinated layers. return fmt.Sprintf(`Output valid JSON only. Write a short, real message a busy colleague would actually send to someone they already know, about: %s. You are that person, NOT an assistant. Return: - subject: 2-6 words, lowercase-natural, never prefixed with "Re:" or "Fwd:". - description: the OPENING body only (no greeting, sign-off, signature, or the subject) — 1 to 3 sentences. - messages: %d ORDERED reply bodies (1-2 short sentences each). They alternate naturally between the two people and each one must logically continue the turn before it. Do not write interchangeable options. Write like a person, by following how people actually write — not by trying to "sound human": - Use contractions naturally (I'm, don't, you're, it's, can't, I'll, that's, we've, didn't). Don't contract every single one; leave a couple expanded. - Vary sentence length hard. Put at least one very short line or fragment (2-5 words, e.g. "Makes sense." / "No rush.") next to a longer one. Never let every sentence land at the same length. - Include exactly one concrete, specific detail a stranger couldn't guess — a day ("Tuesday"), a time ("after lunch"), a named thing ("the second draft", "the deck"), or a small number. - Start with the actual point or a bare "hey" — never a stock opener ("I hope this email finds you well", "I wanted to reach out", "I just wanted to"). - No intro-body-conclusion shape, no restating, no "in conclusion". Hard bans (these are the strongest AI tells): - NO AI-accent words: delve, leverage, utilize, harness, robust, seamless, underscore, showcase, foster, streamline, elevate, pivotal, comprehensive, testament, tapestry, realm, synergy, paradigm, furthermore, moreover, additionally. Use plain words (use, show, solid, smooth, key, full, also). - NO "not only X but also Y", NO "it's not X, it's Y", NO rule-of-three lists ("fast, reliable, and affordable"). Use at most one dash; prefer none. - NO hedging/corporate filler: "it's worth noting", "in order to", "circle back", "touch base", "at the end of the day". It's fine to start with "And" or "But". - Plain text only. No links, URLs, phone numbers, attachments, emoji, ALL-CAPS, or marketing/sales language. At most one "!", prefer zero. Vary the shape across turns. Sometimes use a fragment plus a question, sometimes one longer line. Keep the exchange coherent without inventing a document, promise, or event that was not established in an earlier turn. Each body must stand on its own when a normal email sign-off is appended.`, theme, maxMessages) } // conversationResponseFormat returns the strict JSON-schema response format // shared by sync and batch generation. func conversationResponseFormat() openai.ChatCompletionNewParamsResponseFormatUnion { schemaParam := openai.ResponseFormatJSONSchemaJSONSchemaParam{ Name: "conversation", Description: openai.String("Realistic ordered email warmup thread"), Schema: ConversationSchema, Strict: openai.Bool(true), } return openai.ChatCompletionNewParamsResponseFormatUnion{ OfJSONSchema: &openai.ResponseFormatJSONSchemaParam{JSONSchema: schemaParam}, } } // buildConversationParams assembles the chat-completion request for one warmup // thread. Both GenerateConversation (sync) and SubmitBatch (Batch API) use this // so the request bodies are identical; the only difference is the transport. func buildConversationParams(theme, model string, maxMessages int) openai.ChatCompletionNewParams { chatModel := openai.ChatModelGPT4oMini if model != "" { chatModel = openai.ChatModel(model) } return openai.ChatCompletionNewParams{ Model: chatModel, Messages: []openai.ChatCompletionMessageParamUnion{ openai.SystemMessage(warmupSystemPrompt(theme, maxMessages)), openai.UserMessage("Generate one complete thread now."), }, ResponseFormat: conversationResponseFormat(), } } // GenerateConversation produces one realistic warmup email thread for a theme. // // The prompt is tuned for warmup deliverability: plaintext, a few short // sentences, one natural question, and explicitly NO links, phone numbers, // attachments, emoji, or marketing language (all of which raise spam scores). // Empty model falls back to gpt-4o-mini for direct callers. func (c *GenerationClient) GenerateConversation(ctx context.Context, theme, model string, maxMessages int) (*Conversation, error) { req := buildConversationParams(theme, model, normalizeMaxMessages(maxMessages)) resp, err := c.client.Chat.Completions.New(ctx, req) if err != nil { return nil, err } if len(resp.Choices) == 0 { return nil, fmt.Errorf("generation returned no choices") } var parsed Conversation if err := json.Unmarshal([]byte(resp.Choices[0].Message.Content), &parsed); err != nil { return nil, err } return &parsed, nil }