haixunMaster/lib/ai/analyze-topic.ts

371 lines
14 KiB
TypeScript
Raw Normal View History

2026-06-21 12:50:31 +00:00
import { generateObject, generateText } from "ai";
import { z } from "zod";
import {
coerceResearchMapRaw,
extractJsonFromText,
researchMapLooseSchema,
} from "./coerce-research-map";
import type { ProviderApiKeys } from "./keys";
import { getModel } from "./provider";
import {
buildResearchMapSystemPrompt,
buildResearchMapUserPrompt,
} from "./prompts/research-map";
import {
buildPlacementResearchMapSystemPrompt,
buildPlacementResearchMapUserPrompt,
} from "./prompts/research-map-placement";
import type { ResearchMap, SuggestedTag } from "@/lib/types/research";
import type { TopicGoal } from "@/lib/types/topic-goal";
import { isPlacementGoal } from "@/lib/types/topic-goal";
import {
explainProviderApiError,
getOpenCodeGenerationSettings,
prefersOpenCodeTextFirst,
} from "./opencode-go-settings";
import { formatProductContextForPrompt } from "@/lib/types/product-context";
import { filterSuggestedTags } from "./normalize-suggested-tags";
import { generateStructuredObject } from "./generate-structured";
export interface AnalyzeTopicInput {
label: string;
query: string;
brief?: string | null;
productContext?: string | null;
topicGoal?: TopicGoal | string | null;
persona?: string | null;
aiProvider: string;
aiModel: string;
apiKeys?: ProviderApiKeys;
}
const DEFAULT_QUESTIONS = [
"這個主題下,受眾最常感到焦慮的是什麼?",
"有哪些實用資訊是大家都在找的?",
"同溫層想聽什麼樣的親身經驗?",
"哪些迷思需要被釐清?",
"什麼情境下會特別想搜這個主題?",
];
const DEFAULT_PILLARS = ["實用知識", "同溫經驗", "語錄故事", "迷思破解"];
const DEFAULT_EXCLUSIONS = ["業配硬銷", "未查證偏方", "純情緒宣洩"];
function padStringList(items: string[], minimum: number, maximum: number, defaults: string[]) {
const out = items.map((s) => s.trim()).filter(Boolean);
let i = 0;
while (out.length < minimum) {
out.push(defaults[i] ?? `待補充項目 ${out.length + 1}`);
i += 1;
}
return out.slice(0, maximum);
}
function padSuggestedTags(
tags: SuggestedTag[],
input: AnalyzeTopicInput
): SuggestedTag[] {
return filterSuggestedTags(tags, {
label: input.label,
query: input.query,
brief: input.brief,
topicGoal: input.topicGoal,
});
}
function normalizeResearchMap(
raw: z.infer<typeof researchMapLooseSchema>,
input: AnalyzeTopicInput
): Omit<ResearchMap, "similarAccounts"> {
const questions = padStringList(raw.questions, 5, 8, DEFAULT_QUESTIONS);
const pillars = padStringList(raw.pillars, 4, 6, DEFAULT_PILLARS);
return {
audienceSummary: raw.audienceSummary.trim(),
contentGoal: raw.contentGoal.trim(),
questions,
pillars,
suggestedTags: padSuggestedTags(raw.suggestedTags, input),
exclusions: padStringList(raw.exclusions, 3, 8, DEFAULT_EXCLUSIONS),
};
}
const searchTagBatchSchema = z.object({
tags: z.array(z.object({
tag: z.string().min(2).max(10),
reason: z.string().min(1).max(60),
searchIntent: z.enum(["痛點", "知識", "經驗", "對比", "工具", "語錄", "需求", "求助"]),
searchType: z.enum(["短詞", "情境", "語錄"]),
})).min(6).max(8),
});
const UNNATURAL_SEARCH_TAG =
/[,。!?、:;()()「」【】]|一步步|過程中的|記錄改|的搞|舒適圈的|怎麼交到新|搜尋詞\d|^(這個|哪些|什麼情境)/;
function naturalSearchTags(tags: SuggestedTag[]): SuggestedTag[] {
const seen = new Set<string>();
return tags.filter((item) => {
const tag = item.tag.replace(/^#/, "").trim();
const key = tag.toLowerCase();
if (tag.length < 2 || tag.length > 10 || UNNATURAL_SEARCH_TAG.test(tag)) return false;
if (/的$|才$|要$|會$|怎麼$|如何$/.test(tag)) return false;
if (seen.has(key)) return false;
seen.add(key);
item.tag = tag;
return true;
});
}
async function generateNaturalSearchTags(
model: ReturnType<typeof getModel>,
input: AnalyzeTopicInput,
map: Omit<ResearchMap, "similarAccounts">
): Promise<SuggestedTag[]> {
const placementMode = isPlacementGoal(input.topicGoal);
const modeRules = placementMode
? `你正在替「找 TA」生成需求型搜尋詞
-
- 5 使
-
-
-
- searchIntent 使`
: `你正在替「拷貝忍者」生成內容型搜尋詞:
- 2 2 2 1 1
- 穿`;
const result = await generateStructuredObject({
model,
provider: input.aiProvider,
modelId: input.aiModel,
schema: searchTagBatchSchema,
system: `你是台灣 Threads 站內搜尋策略師。你只負責把研究地圖改寫成真人會輸入搜尋框的詞。
- 8 28 10
- hashtag
-
- 穿
- Brief
-
- 使 Threads
${modeRules}
`,
prompt: `主題:${input.label}
${input.query}
Brief${input.brief ?? ""}
${map.audienceSummary}
${map.questions.join("")}
${map.pillars.join("")}
${map.exclusions.join("")}
Threads `,
jsonPromptSuffix: `\n\n只回傳 JSON不要 markdown。tags 必須恰好 8 筆且 tag 不可重複:\n{"tags":[{"tag":"2到8字搜尋詞1","reason":"搜尋意圖","searchIntent":"經驗","searchType":"短詞"},{"tag":"2到8字搜尋詞2","reason":"搜尋意圖","searchIntent":"痛點","searchType":"情境"},{"tag":"2到8字搜尋詞3","reason":"搜尋意圖","searchIntent":"知識","searchType":"短詞"},{"tag":"2到8字搜尋詞4","reason":"搜尋意圖","searchIntent":"工具","searchType":"情境"},{"tag":"2到8字搜尋詞5","reason":"搜尋意圖","searchIntent":"經驗","searchType":"情境"},{"tag":"2到8字搜尋詞6","reason":"搜尋意圖","searchIntent":"對比","searchType":"短詞"},{"tag":"2到8字搜尋詞7","reason":"搜尋意圖","searchIntent":"痛點","searchType":"情境"},{"tag":"2到8字搜尋詞8","reason":"搜尋意圖","searchIntent":"語錄","searchType":"語錄"}]}`,
});
const natural = naturalSearchTags(result.tags);
return placementMode
? natural.filter((tag) => tag.searchIntent !== "語錄" && tag.searchType !== "語錄")
: natural;
}
export async function regenerateSearchTags(
input: AnalyzeTopicInput,
map: Omit<ResearchMap, "similarAccounts">
): Promise<SuggestedTag[]> {
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
const tags = await generateNaturalSearchTags(model, input, map);
if (tags.length < 6) {
throw new Error(`AI 只產出 ${tags.length} 個可用搜尋詞,未達 6 個,請重試或更換研究模型`);
}
return tags.slice(0, 8);
}
export function formatAnalyzeError(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof z.ZodError) {
const detail = error.issues
.slice(0, 4)
.map((issue) => {
const field = issue.path.join(".") || "回傳內容";
return `${field}${issue.message}`;
})
.join("");
return `AI 回傳的研究地圖格式不完整(${detail}。請再按一次「AI 分析主題」重試。`;
}
if (error instanceof Error) {
const extra = error as Error & { responseBody?: string };
const explained = explainProviderApiError(
error.message,
typeof extra.responseBody === "string" ? extra.responseBody : undefined
);
if (explained) return explained;
return error.message;
}
return "AI 分析失敗";
}
function buildJsonPromptSuffix(): string {
return `
JSON markdown
{
"audienceSummary": "string",
"contentGoal": "string",
"questions": ["string", "..."],
"pillars": ["string", "..."],
"suggestedTags": [
{"tag":"2-4字短詞","reason":"為什麼搜","searchIntent":"痛點|知識|經驗|對比|工具|語錄","searchType":"短詞|情境|語錄"}
],
"exclusions": ["string", "..."]
}
questions 5 pillars 4 suggestedTags 1014 exclusions 3 similarAccounts`;
}
function resolvePrompts(input: AnalyzeTopicInput) {
if (isPlacementGoal(input.topicGoal)) {
const brief = input.brief?.trim() || `主題是「${input.query}」。`;
const productContext = formatProductContextForPrompt(input.productContext);
const persona =
input.persona?.trim() ||
"專業、願意提供實用建議的品牌代表,語氣自然不硬銷。";
return {
system: buildPlacementResearchMapSystemPrompt(),
prompt: buildPlacementResearchMapUserPrompt({
label: input.label,
query: input.query,
brief,
productContext,
persona,
}),
};
}
const brief = input.brief?.trim()
? input.brief.trim()
: `主題是「${input.query}」。請假設受眾是台灣 2540 歲、會在 Threads 找資訊與同溫感的一般使用者。`;
const persona = input.persona?.trim()
? input.persona.trim()
: "專業、有觀點、願意分享實用資訊的 Threads 創作者,語氣自然不說教。";
return {
system: buildResearchMapSystemPrompt(),
prompt: buildResearchMapUserPrompt({
label: input.label,
query: input.query,
brief,
persona,
}),
};
}
async function generateWithText(
model: ReturnType<typeof getModel>,
prompt: string,
system: string,
fallback: { label: string; query: string; brief: string },
provider: string,
modelId: string
) {
const settings = getOpenCodeGenerationSettings(provider, modelId);
const { text } = await generateText({
model,
system: `${system}${buildJsonPromptSuffix()}`,
prompt,
...settings,
});
const parsed = extractJsonFromText(text);
return coerceResearchMapRaw(parsed, fallback);
}
async function generateWithObject(
model: ReturnType<typeof getModel>,
prompt: string,
system: string,
fallback: { label: string; query: string; brief: string },
provider: string,
modelId: string
) {
const settings = getOpenCodeGenerationSettings(provider, modelId);
const result = await generateObject({
model,
schema: researchMapLooseSchema,
system,
prompt: `${prompt}${buildJsonPromptSuffix()}`,
...settings,
});
return coerceResearchMapRaw(result.object, fallback);
}
export async function analyzeTopicIntent(
input: AnalyzeTopicInput
): Promise<Omit<ResearchMap, "similarAccounts">> {
const model = getModel(input.aiProvider, input.aiModel, input.apiKeys ?? {});
const { system, prompt } = resolvePrompts(input);
const brief = input.brief?.trim() || `主題是「${input.query}」。`;
const fallback = { label: input.label, query: input.query, brief };
const preferText = prefersOpenCodeTextFirst(input.aiProvider, input.aiModel);
let raw: z.infer<typeof researchMapLooseSchema> | null = null;
let lastError: unknown;
const attempts: Array<() => Promise<z.infer<typeof researchMapLooseSchema>>> = preferText
? [
() => generateWithText(model, prompt, system, fallback, input.aiProvider, input.aiModel),
() => generateWithObject(model, prompt, system, fallback, input.aiProvider, input.aiModel),
() =>
generateWithText(
model,
`${prompt}\n\n上次格式不完整請務必補齊所有欄位。`,
system,
fallback,
input.aiProvider,
input.aiModel
),
]
: [
() => generateWithObject(model, prompt, system, fallback, input.aiProvider, input.aiModel),
() => generateWithText(model, prompt, system, fallback, input.aiProvider, input.aiModel),
() =>
generateWithText(
model,
`${prompt}\n\n上次格式不完整請務必補齊所有欄位。`,
system,
fallback,
input.aiProvider,
input.aiModel
),
];
for (const attempt of attempts) {
try {
raw = await attempt();
break;
} catch (error) {
lastError = error;
}
}
if (!raw) {
throw formatAnalyzeError(lastError);
}
try {
let normalized = normalizeResearchMap(raw, input);
{
try {
const rewritten = await generateNaturalSearchTags(model, input, normalized);
if (rewritten.length >= 6) {
normalized = { ...normalized, suggestedTags: rewritten.slice(0, 8) };
}
} catch (error) {
console.warn("[analyze-topic] natural search tag rewrite failed:", error);
}
}
return normalized;
} catch (error) {
throw formatAnalyzeError(error);
}
}