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