import { z } from "zod"; import { THREADS_MAX_CHARS } from "@/lib/utils"; import type { ProviderApiKeys } from "./keys"; import { coerceInboundReplyDraftsRaw, coerceOutreachDraftsRaw, } from "./coerce-reply-drafts"; import { generateStructuredObject } from "./generate-structured"; import { buildPlacementOutreachSystemPrompt, buildPlacementOutreachUserPrompt, } from "./prompts/outreach-placement"; import { getModel } from "./provider"; import { buildPersonaPromptBlock } from "./persona"; import type { CtaType } from "@/lib/types/product-context"; const OUTREACH_JSON_FORMAT = `{"relevance":0.8,"reason":"一句話評估","drafts":[{"text":"留言正文(必填)","angle":"切角","rationale":"為何這樣寫"}]}`; const INBOUND_JSON_FORMAT = `{"sentiment":"neutral","intent":"提問","drafts":[{"text":"回覆正文(必填)","rationale":"為何這樣回"}]}`; function buildReplyDraftSchema(count: number) { return z.preprocess( (value) => coerceInboundReplyDraftsRaw(value, count), z.object({ sentiment: z.enum(["positive", "neutral", "negative", "question", "lead"]), intent: z.string(), drafts: z .array( z.object({ text: z.string().min(1).max(THREADS_MAX_CHARS), rationale: z.string(), }) ) .min(1), }) ); } function buildOutreachDraftSchema(count: number) { return z.preprocess( (value) => coerceOutreachDraftsRaw(value, count), z.object({ relevance: z.number().min(0).max(1), reason: z.string(), drafts: z .array( z.object({ text: z.string().min(1).max(THREADS_MAX_CHARS), angle: z.string(), rationale: z.string(), }) ) .min(1), }) ); } export async function generateInboundReplyDrafts(input: { persona?: string | null; aiProvider: string; aiModel: string; apiKeys?: ProviderApiKeys; publishedText?: string | null; replyText: string; authorName?: string | null; count?: number; }) { const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {}); const count = input.count ?? 2; const object = await generateStructuredObject({ model, provider: input.aiProvider, modelId: input.aiModel, schema: buildReplyDraftSchema(count), jsonPromptSuffix: `\n\n只回傳 JSON,不要 markdown。格式範例:\n${INBOUND_JSON_FORMAT}\n每則 drafts 必須有 text 字串。`, system: `你是 Threads 社群互動小編。請幫創作者回覆自己貼文底下的留言。 ${buildPersonaPromptBlock(input.persona)} 規則: - 繁體中文台灣語感,像真人回覆,不要像客服模板 - 回覆需自然、短、具體,避免過度推銷 - 若留言是提問,先回答重點;若不確定,禮貌承認並引導補充 - 不要承諾療效、收益或無法驗證的結果 - 每則回覆 ≤ ${THREADS_MAX_CHARS} 字`, prompt: `原貼文: ${input.publishedText ?? "(無)"} 留言者:@${input.authorName ?? "匿名"} 留言內容:${input.replyText} 請判斷留言情緒與意圖,產生 ${count} 則可審核回覆草稿。`, }); return { ...object, drafts: object.drafts.map((draft) => ({ ...draft, text: draft.text.slice(0, THREADS_MAX_CHARS), })), }; } export async function generateOutreachCommentDrafts(input: { persona?: string | null; aiProvider: string; aiModel: string; apiKeys?: ProviderApiKeys; targetText: string; authorName?: string | null; topicLabel?: string | null; audienceBrief?: string | null; productBrief?: string | null; placementReason?: string | null; ctaType?: CtaType | null; ctaUrl?: string | null; replies?: Array<{ text: string; authorName?: string | null; likeCount?: number | null }>; count?: number; }) { const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {}); const replies = (input.replies ?? []) .slice(0, 5) .map((reply) => `- @${reply.authorName ?? "匿名"}(${reply.likeCount ?? 0} 讚):${reply.text}`) .join("\n"); const productBrief = input.productBrief?.trim() || "(尚未填寫品牌與產品,請先給實用建議,不要捏造品牌)"; const count = input.count ?? 2; const object = await generateStructuredObject({ model, provider: input.aiProvider, modelId: input.aiModel, schema: buildOutreachDraftSchema(count), jsonPromptSuffix: `\n\n只回傳 JSON,不要 markdown。格式範例:\n${OUTREACH_JSON_FORMAT}\n每則 drafts 必須有 text 字串(留言正文),不可用 comment/reply 等其他欄位名稱。`, system: buildPlacementOutreachSystemPrompt(input.persona), prompt: `${buildPlacementOutreachUserPrompt({ topicLabel: input.topicLabel, audienceBrief: input.audienceBrief, productBrief, placementReason: input.placementReason, targetText: input.targetText, authorName: input.authorName, repliesBlock: replies, count, ctaType: input.ctaType, ctaUrl: input.ctaUrl, })} 另外請評估這篇是否值得留言置入(relevance 0-1)與 reason(繁體中文一句話)。`, }); return { ...object, drafts: object.drafts.map((draft) => ({ ...draft, text: draft.text.slice(0, THREADS_MAX_CHARS), })), }; }