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, input: AnalyzeTopicInput ): Omit { 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(); 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, input: AnalyzeTopicInput, map: Omit ): Promise { 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 個不重複搜尋詞,每個 2~8 個中文字為佳,最長 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 ): Promise { 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 10~14 個、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}」。請假設受眾是台灣 25~40 歲、會在 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, 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, 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> { 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 | null = null; let lastError: unknown; const attempts: Array<() => Promise>> = 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); } }