import { AI_CATEGORY_ENUM, AI_DIFFICULTY_ENUM, AI_MASTERY_ENUM, AI_TAG_ENUM, type AIDraftItem, } from "./types" export type ParseResult = | { ok: true; items: AIDraftItem[] } | { ok: false; error: string; raw: string } /** Strip markdown code fences and locate the JSON payload. */ function stripFences(input: string): string { let text = input.trim() // remove ```json ... ``` or ``` ... ``` wrappers const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i) if (fenceMatch) { text = fenceMatch[1].trim() } // fall back: slice from first { to last } if (!text.startsWith("{")) { const first = text.indexOf("{") const last = text.lastIndexOf("}") if (first !== -1 && last !== -1 && last > first) { text = text.slice(first, last + 1) } } return text } function toStr(value: unknown): string { if (typeof value === "string") return value if (value == null) return "" return String(value) } function sanitizeTags(value: unknown): string[] { if (!Array.isArray(value)) return [] const allowed = new Set(AI_TAG_ENUM) const out: string[] = [] for (const t of value) { const tag = toStr(t).trim() if (allowed.has(tag) && !out.includes(tag)) out.push(tag) } return out } function sanitizeItem(raw: Record): AIDraftItem { const category = toStr(raw.category).trim() const difficulty = toStr(raw.difficulty).trim() const mastery = toStr(raw.mastery).trim() return { title: toStr(raw.title).trim(), summary: toStr(raw.summary).trim(), content: toStr(raw.content).trim(), code_snippet: toStr(raw.code_snippet), tags: sanitizeTags(raw.tags), category: (AI_CATEGORY_ENUM as readonly string[]).includes(category) ? category : "其他", difficulty: (AI_DIFFICULTY_ENUM as readonly string[]).includes(difficulty) ? (difficulty as AIDraftItem["difficulty"]) : "medium", mastery: (AI_MASTERY_ENUM as readonly string[]).includes(mastery) ? (mastery as AIDraftItem["mastery"]) : "new", source_url: toStr(raw.source_url).trim(), notes: toStr(raw.notes).trim(), } } /** * Safely parse AI output into a list of draft knowledge items. * - tolerant of markdown fences * - validates items is an array * - filters/normalizes enum fields with sensible fallbacks * - drops items without a title */ export function safeParseAIJson(input: string): ParseResult { const cleaned = stripFences(input) let data: unknown try { data = JSON.parse(cleaned) } catch { return { ok: false, error: "无法解析 AI 返回的 JSON", raw: input } } const itemsRaw = (data as { items?: unknown })?.items if (!Array.isArray(itemsRaw)) { return { ok: false, error: 'JSON 顶层缺少数组字段 "items"', raw: input } } const items = itemsRaw .filter((it): it is Record => !!it && typeof it === "object") .map(sanitizeItem) .filter((it) => it.title.length > 0) if (items.length === 0) { return { ok: false, error: "未能从返回内容中提取到有效条目", raw: input } } return { ok: true, items } }