+103
@@ -0,0 +1,103 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user