|
| 1 | +/** |
| 2 | + * AI 知识入籍概念化 Handler(阶段 A 入口问题) |
| 3 | + * |
| 4 | + * 处理 ai_import_concept IPC 请求,调用 AI 网关将切块文本 |
| 5 | + * 提炼为概念候选(名称/摘要/卡片正反面),供入籍预览编辑。 |
| 6 | + * |
| 7 | + * @ai-context: 知识入籍概念化 IPC handler——AIFeatureDef 注册表模式, |
| 8 | + * 经 callWithLocalFallback 支持云端网关优先/本地 Ollama 降级; |
| 9 | + * 服务端 ImportConceptResult 的 snake_case 字段在此边界转为 camelCase。 |
| 10 | + */ |
| 11 | +import { requireText, safeHandle } from '../../ipcUtils.js'; |
| 12 | +import { logger } from '../../logger.js'; |
| 13 | +import { callWithLocalFallback, gatewayUrl, parseModelJson, type AIFeatureDef } from '../utils.js'; |
| 14 | +import { generateText } from '../ollama/OllamaProvider.js'; |
| 15 | +import type { ConceptCandidate, TextChunk } from '../../../src/features/settling/types.js'; |
| 16 | + |
| 17 | +// ================================================================ |
| 18 | +// IPC Handler |
| 19 | +// ================================================================ |
| 20 | + |
| 21 | +/** |
| 22 | + * ai_import_concept — POST /api/v1/ai/import/concepts |
| 23 | + */ |
| 24 | +function register(): void { |
| 25 | + safeHandle( |
| 26 | + 'ai_import_concept', |
| 27 | + async ( |
| 28 | + _event, |
| 29 | + args: { |
| 30 | + title: string; |
| 31 | + textChunks: TextChunk[]; |
| 32 | + authToken?: string; |
| 33 | + }, |
| 34 | + ) => { |
| 35 | + requireText(args?.title, 'title'); |
| 36 | + if (!Array.isArray(args?.textChunks) || args.textChunks.length === 0) { |
| 37 | + throw new Error('textChunks 必须是非空数组'); |
| 38 | + } |
| 39 | + const startMs = Date.now(); |
| 40 | + logger.info( |
| 41 | + `[AI] [importConcept] IPC received: title=${args.title.slice(0, 50)}, chunks=${args.textChunks.length}, hasAuth=${!!args.authToken}`, |
| 42 | + ); |
| 43 | + |
| 44 | + // 边界切片:与服务端预算对齐(单块 ≤3000 字、≤50 块、总量 ≤50000 字) |
| 45 | + // 防超长材料被网关 400 拒绝;非字符串块直接丢弃(类型守卫) |
| 46 | + const MAX_TOTAL_CHARS = 50000; |
| 47 | + const sliced = args.textChunks |
| 48 | + .filter((c) => c && typeof c.text === 'string') |
| 49 | + .map((c) => ({ index: c.index, text: c.text.slice(0, 3000) })) |
| 50 | + .slice(0, 50); |
| 51 | + const kept: Array<{ index: number; text: string }> = []; |
| 52 | + let budget = 0; |
| 53 | + for (const c of sliced) { |
| 54 | + if (budget + c.text.length > MAX_TOTAL_CHARS) break; |
| 55 | + kept.push(c); |
| 56 | + budget += c.text.length; |
| 57 | + } |
| 58 | + if (kept.length === 0) { |
| 59 | + throw new Error('文本块内容为空'); |
| 60 | + } |
| 61 | + const reqBody = { title: args.title.slice(0, 200), text_chunks: kept.map((c) => c.text) }; |
| 62 | + |
| 63 | + logger.info(`[AI] [importConcept] Target: ${gatewayUrl()}/api/v1/ai/import/concepts`); |
| 64 | + |
| 65 | + interface ImportConceptResp { |
| 66 | + concepts: Array<{ name: string; summary: string; card_front: string; card_back: string }>; |
| 67 | + model: string; |
| 68 | + tokens_used: number; |
| 69 | + } |
| 70 | + |
| 71 | + try { |
| 72 | + const localHandler = async (): Promise<ImportConceptResp> => { |
| 73 | + const prompt = `从以下资料中提炼 3-6 个值得长期记忆的核心概念。每个概念给出:名称(简洁名词短语)、一句话摘要、复习提问(问题形式,缺省由名称派生)、答案要点。仅返回JSON: {"concepts": [{"name": "...", "summary": "...", "card_front": "...", "card_back": "..."}]}\n\n【资料标题】\n${reqBody.title}\n\n【资料内容】\n${reqBody.text_chunks.join('\n\n')}`; |
| 74 | + const result = await generateText(prompt, '你是一位善于把资料提炼成可复习概念的图书管理员。请仅返回JSON。', { temperature: 0.5, maxTokens: 1500 }); |
| 75 | + // 宽松解析:本地小模型常输出围栏/解释文字,裸 parse 会误降级到云端 |
| 76 | + const parsed = parseModelJson<Partial<ImportConceptResp>>(result.content, {}); |
| 77 | + const concepts = Array.isArray(parsed.concepts) |
| 78 | + ? parsed.concepts |
| 79 | + .filter((c) => c && typeof c.name === 'string' && c.name.trim().length > 0) |
| 80 | + .map((c) => ({ |
| 81 | + name: c.name.trim().slice(0, 60), |
| 82 | + summary: (c.summary ?? '').slice(0, 200), |
| 83 | + card_front: (c.card_front ?? '').slice(0, 200), |
| 84 | + card_back: (c.card_back ?? '').slice(0, 500), |
| 85 | + })) |
| 86 | + .slice(0, 10) // 与服务端 MAX_CONCEPTS=10 对齐 |
| 87 | + : []; |
| 88 | + return { concepts, model: result.model, tokens_used: result.tokens_used }; |
| 89 | + }; |
| 90 | + |
| 91 | + const { data: resp, source, requestId } = await callWithLocalFallback<typeof reqBody, ImportConceptResp>( |
| 92 | + '/api/v1/ai/import/concepts', |
| 93 | + reqBody, |
| 94 | + localHandler, |
| 95 | + args.authToken, |
| 96 | + 60000, |
| 97 | + ); |
| 98 | + |
| 99 | + const elapsed = Date.now() - startMs; |
| 100 | + logger.info( |
| 101 | + `[AI] [importConcept] ✔ Success (${source}): concepts=${resp.concepts.length}, model=${resp.model}, total=${elapsed}ms, reqId=${requestId ?? 'N/A'}`, |
| 102 | + ); |
| 103 | + |
| 104 | + // snake_case → camelCase(边界转换,遵循 api-design.md) |
| 105 | + const concepts: ConceptCandidate[] = resp.concepts.map((c) => ({ |
| 106 | + name: c.name, |
| 107 | + summary: c.summary ?? '', |
| 108 | + cardFront: c.card_front ?? '', |
| 109 | + cardBack: c.card_back ?? '', |
| 110 | + })); |
| 111 | + |
| 112 | + return { |
| 113 | + concepts, |
| 114 | + model: resp.model, |
| 115 | + tokensUsed: resp.tokens_used, |
| 116 | + requestId, |
| 117 | + source, |
| 118 | + }; |
| 119 | + } catch (err) { |
| 120 | + const elapsed = Date.now() - startMs; |
| 121 | + const error = err instanceof Error ? err : new Error(String(err)); |
| 122 | + logger.error(`[AI] [importConcept] ✖ Failed after ${elapsed}ms: ${error.message}`); |
| 123 | + if (error.cause) logger.error(`[AI] [importConcept] Error cause: ${error.cause}`); |
| 124 | + throw error; |
| 125 | + } |
| 126 | + }, |
| 127 | + ); |
| 128 | +} |
| 129 | + |
| 130 | +// ================================================================ |
| 131 | +// 功能定义导出 |
| 132 | +// ================================================================ |
| 133 | + |
| 134 | +export const feature: AIFeatureDef = { |
| 135 | + id: 'ai_import_concept', |
| 136 | + name: 'AI 知识入籍概念化', |
| 137 | + version: '1.0.0', |
| 138 | + register, |
| 139 | +}; |
0 commit comments