haixunMaster/lib/ai/refine-research-map.ts

152 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 用 24 句繁體中文說明改了什麼。`;
}
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,
};
}