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

171 lines
5.5 KiB
TypeScript

import { z } from "zod";
import type { SearchIntent, SearchTagType, SuggestedTag } from "@/lib/types/research";
const SEARCH_INTENTS = ["痛點", "知識", "經驗", "對比", "工具", "語錄", "需求", "求助"] as const;
const SEARCH_TYPES = ["短詞", "情境", "帳號", "語錄"] as const;
export const researchMapLooseSchema = z.object({
audienceSummary: z.string().min(1),
contentGoal: z.string().min(1),
questions: z.array(z.string()).min(1),
pillars: z.array(z.string()).min(1),
suggestedTags: z.array(
z.object({
tag: z.string(),
reason: z.string(),
searchIntent: z.enum(SEARCH_INTENTS),
searchType: z.enum(SEARCH_TYPES).optional(),
})
).min(1),
exclusions: z.array(z.string()).min(1),
});
function asString(value: unknown): string {
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (typeof item === "string") return item.trim();
if (item && typeof item === "object" && "text" in item) {
return asString((item as { text?: unknown }).text);
}
return "";
})
.filter(Boolean);
}
function normalizeIntent(value: unknown): SearchIntent {
const raw = asString(value);
if ((SEARCH_INTENTS as readonly string[]).includes(raw)) return raw as SearchIntent;
return "知識";
}
function normalizeSearchType(value: unknown, tag: string): SearchTagType | undefined {
const raw = asString(value);
if ((SEARCH_TYPES as readonly string[]).includes(raw)) return raw as SearchTagType;
if (tag.startsWith("@")) return "帳號";
if (tag.length <= 4) return "短詞";
if (tag.includes("語錄")) return "語錄";
if (tag.length <= 8) return "情境";
return "短詞";
}
function coerceSuggestedTags(value: unknown): SuggestedTag[] {
if (!Array.isArray(value)) return [];
const out: SuggestedTag[] = [];
for (const item of value) {
if (typeof item === "string") {
const tag = item.trim().replace(/^@/, "");
if (!tag) continue;
out.push({
tag,
reason: "AI 建議的搜尋標籤",
searchIntent: "知識",
searchType: normalizeSearchType(undefined, tag),
});
continue;
}
if (!item || typeof item !== "object") continue;
const obj = item as Record<string, unknown>;
const tag = asString(obj.tag ?? obj.label ?? obj.name ?? obj.keyword);
if (!tag) continue;
const cleanTag = tag.replace(/^@/, "");
out.push({
tag: cleanTag,
reason: asString(obj.reason ?? obj.description ?? obj.why) || "AI 建議的搜尋標籤",
searchIntent: normalizeIntent(obj.searchIntent ?? obj.intent),
searchType: normalizeSearchType(obj.searchType ?? obj.type, cleanTag),
});
}
return out;
}
export function extractJsonFromText(text: string): unknown {
const cleaned = text
.replace(/```json\s*/gi, "")
.replace(/```\s*/g, "")
.trim();
try {
return JSON.parse(cleaned);
} catch {
const objStart = cleaned.indexOf("{");
const objEnd = cleaned.lastIndexOf("}");
if (objStart >= 0 && objEnd > objStart) {
try {
return JSON.parse(cleaned.slice(objStart, objEnd + 1));
} catch {
/* fall through */
}
}
const arrStart = cleaned.indexOf("[");
const arrEnd = cleaned.lastIndexOf("]");
if (arrStart >= 0 && arrEnd > arrStart) {
try {
return JSON.parse(cleaned.slice(arrStart, arrEnd + 1));
} catch {
/* fall through */
}
}
throw new Error("AI 回傳的不是合法 JSON");
}
}
export function coerceResearchMapRaw(
parsed: unknown,
fallback: { label: string; query: string; brief: string }
): z.infer<typeof researchMapLooseSchema> {
const root =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
const nested =
root.researchMap && typeof root.researchMap === "object"
? (root.researchMap as Record<string, unknown>)
: root;
const audienceSummary =
asString(nested.audienceSummary ?? nested.audience ?? nested.) ||
`關注「${fallback.label}」的 Threads 受眾,想從 ${fallback.query} 相關內容找到共鳴與實用資訊。`;
const contentGoal =
asString(nested.contentGoal ?? nested.goal ?? nested.) ||
`產出準確、有故事感的新型小語,服務 ${fallback.query} 主題受眾。`;
const questions = asStringArray(nested.questions ?? nested.questionsList);
const pillars = asStringArray(nested.pillars ?? nested.contentPillars);
const exclusions = asStringArray(nested.exclusions ?? nested.exclude);
const suggestedTags = coerceSuggestedTags(
nested.suggestedTags ?? nested.tags ?? nested.searchTags
);
const coerced = {
audienceSummary,
contentGoal,
questions: questions.length > 0 ? questions : [`${fallback.query} 相關受眾最常問什麼?`],
pillars: pillars.length > 0 ? pillars : ["實用知識", "同溫經驗"],
suggestedTags:
suggestedTags.length > 0
? suggestedTags
: [
{
tag: fallback.query.slice(0, 4) || fallback.label.slice(0, 4),
reason: "主題種子字衍生的短詞",
searchIntent: "知識" as const,
searchType: "短詞" as const,
},
],
exclusions: exclusions.length > 0 ? exclusions : ["業配硬銷", "未查證偏方"],
};
return researchMapLooseSchema.parse(coerced);
}