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(), }) ) .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 簡潔說明變更重點,繁體中文台灣用語`; 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, }; }