2026-06-21 12:50:31 +00:00
|
|
|
|
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(),
|
2026-06-21 16:28:26 +00:00
|
|
|
|
confidence: z.enum(["high", "medium", "low"]).optional(),
|
|
|
|
|
|
lastActiveAt: z.string().optional(),
|
2026-06-21 12:50:31 +00:00
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
.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 搜尋標籤品質(短詞優先、像真人會搜的字、扣緊種子關鍵字)
|
|
|
|
|
|
- 用戶沒要求刪的標籤盡量保留;複合主題不可拆成過寬單字(如寵物洗毛精≠只搜寵物或洗碗精)
|
2026-06-21 16:28:26 +00:00
|
|
|
|
- reply 簡潔說明變更重點,繁體中文台灣用語
|
|
|
|
|
|
|
|
|
|
|
|
回傳 JSON 格式,須包含兩個欄位:
|
|
|
|
|
|
1. reply:字串,2~4 句繁體中文說明本次變更重點
|
|
|
|
|
|
2. researchMap:研究地圖物件,格式與「目前研究地圖草稿」相同`;
|
2026-06-21 12:50:31 +00:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|