haixunMaster/lib/ai/generate-replies.ts

161 lines
5.2 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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),
})),
};
}