haixunMaster/lib/ai/generate-replies.ts

161 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
})),
};
}