371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
|
|
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 個不重複搜尋詞,每個 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<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 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<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);
|
|||
|
|
}
|
|||
|
|
}
|