104 lines
3.1 KiB
TypeScript
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 }
|
|
}
|