152 lines
4.4 KiB
TypeScript
152 lines
4.4 KiB
TypeScript
import { z } from "zod";
|
||
import type { ProviderApiKeys } from "./keys";
|
||
import { generateStructuredObject } from "./generate-structured";
|
||
import { getModel } from "./provider";
|
||
import { filterSuggestedTags, preserveTagsFromPreviousMap } from "./normalize-suggested-tags";
|
||
import { buildResearchMapSystemPrompt } from "./prompts/research-map";
|
||
import { buildTopicAnchorPromptBlock } from "@/lib/topic-anchor";
|
||
import type { ResearchMap } from "@/lib/types/research";
|
||
|
||
const searchIntentSchema = z.enum([
|
||
"痛點",
|
||
"知識",
|
||
"經驗",
|
||
"對比",
|
||
"工具",
|
||
"語錄",
|
||
"需求",
|
||
"求助",
|
||
]);
|
||
const searchTypeSchema = z.enum(["短詞", "情境", "帳號", "語錄"]);
|
||
|
||
const researchMapSchema = z.object({
|
||
audienceSummary: z.string(),
|
||
contentGoal: z.string(),
|
||
questions: z.array(z.string()).min(1).max(12),
|
||
pillars: z.array(z.string()).min(1).max(8),
|
||
suggestedTags: z
|
||
.array(
|
||
z.object({
|
||
tag: z.string(),
|
||
reason: z.string(),
|
||
searchIntent: searchIntentSchema,
|
||
searchType: searchTypeSchema.optional(),
|
||
})
|
||
)
|
||
.min(1)
|
||
.max(15),
|
||
similarAccounts: z
|
||
.array(
|
||
z.object({
|
||
username: z.string(),
|
||
reason: z.string(),
|
||
postUrl: z.string().optional(),
|
||
profileUrl: z.string().optional(),
|
||
source: z.enum(["web", "threads", "scan"]).optional(),
|
||
confidence: z.enum(["high", "medium", "low"]).optional(),
|
||
lastActiveAt: z.string().optional(),
|
||
})
|
||
)
|
||
.optional(),
|
||
exclusions: z.array(z.string()).min(1).max(12),
|
||
});
|
||
|
||
const refineResponseSchema = z.object({
|
||
reply: z.string(),
|
||
researchMap: researchMapSchema,
|
||
});
|
||
|
||
export interface RefineChatMessage {
|
||
role: "user" | "assistant";
|
||
content: string;
|
||
}
|
||
|
||
export interface RefineResearchMapInput {
|
||
label: string;
|
||
query: string;
|
||
brief?: string | null;
|
||
persona?: string | null;
|
||
currentMap: ResearchMap;
|
||
message: string;
|
||
history?: RefineChatMessage[];
|
||
aiProvider: string;
|
||
aiModel: string;
|
||
apiKeys?: ProviderApiKeys;
|
||
}
|
||
|
||
function buildRefineUserPrompt(input: RefineResearchMapInput): string {
|
||
const historyBlock =
|
||
input.history && input.history.length > 0
|
||
? `\n【先前對話】\n${input.history
|
||
.map((m) => `${m.role === "user" ? "用戶" : "助理"}:${m.content}`)
|
||
.join("\n")}\n`
|
||
: "";
|
||
|
||
return `【主題】${input.label}(種子字:${input.query})
|
||
【Brief】${input.brief?.trim() || "(無)"}
|
||
${buildTopicAnchorPromptBlock({ label: input.label, query: input.query, brief: input.brief })}
|
||
${historyBlock}
|
||
【目前研究地圖草稿】
|
||
${JSON.stringify(input.currentMap, null, 2)}
|
||
|
||
【用戶這次的調整請求】
|
||
${input.message}
|
||
|
||
請依請求微調研究地圖。只改用戶提到的部分,其餘盡量保留。reply 用 2~4 句繁體中文說明改了什麼。`;
|
||
}
|
||
|
||
export async function refineResearchMap(input: RefineResearchMapInput): Promise<{
|
||
reply: string;
|
||
researchMap: ResearchMap;
|
||
}> {
|
||
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
|
||
|
||
const system = `${buildResearchMapSystemPrompt()}
|
||
|
||
你現在進入「微調模式」。用戶已有一份研究地圖,會告訴你想改什麼、補什麼、刪什麼。
|
||
- 精準執行用戶意圖,不要整份重寫除非用戶明確要求
|
||
- 維持 Threads 搜尋標籤品質(短詞優先、像真人會搜的字、扣緊種子關鍵字)
|
||
- 用戶沒要求刪的標籤盡量保留;複合主題不可拆成過寬單字(如寵物洗毛精≠只搜寵物或洗碗精)
|
||
- reply 簡潔說明變更重點,繁體中文台灣用語
|
||
|
||
回傳 JSON 格式,須包含兩個欄位:
|
||
1. reply:字串,2~4 句繁體中文說明本次變更重點
|
||
2. researchMap:研究地圖物件,格式與「目前研究地圖草稿」相同`;
|
||
|
||
const prompt = buildRefineUserPrompt(input);
|
||
|
||
const object = await generateStructuredObject({
|
||
model,
|
||
provider: input.aiProvider,
|
||
modelId: input.aiModel,
|
||
schema: refineResponseSchema,
|
||
system,
|
||
prompt,
|
||
});
|
||
|
||
const tagInput = {
|
||
label: input.label,
|
||
query: input.query,
|
||
brief: input.brief,
|
||
exclusions: input.currentMap.exclusions,
|
||
};
|
||
|
||
let researchMap = preserveTagsFromPreviousMap(
|
||
object.researchMap as ResearchMap,
|
||
input.currentMap,
|
||
tagInput
|
||
);
|
||
|
||
researchMap = {
|
||
...researchMap,
|
||
suggestedTags: filterSuggestedTags(researchMap.suggestedTags, tagInput),
|
||
similarAccounts: input.currentMap.similarAccounts,
|
||
};
|
||
|
||
return {
|
||
reply: object.reply,
|
||
researchMap,
|
||
};
|
||
}
|
||
|