161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
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),
|
||
})),
|
||
};
|
||
}
|