171 lines
5.5 KiB
TypeScript
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);
|
|
} |