Files
Next_Knowledge_Base/lib/ai/json.ts
T
jiawei 73b9c9fb7a
knowledge-base / deploy (push) Failing after 11s
feat: 初版
2026-06-23 19:09:11 +08:00

104 lines
3.1 KiB
TypeScript

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<string>(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<string, unknown>): 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<string, unknown> => !!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 }
}