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; 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 { const root = parsed && typeof parsed === "object" ? (parsed as Record) : {}; const nested = root.researchMap && typeof root.researchMap === "object" ? (root.researchMap as Record) : 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); }