Skip to content

Commit 45c414e

Browse files
committed
feat(settling): 知识入籍闭环阶段A与审查修复
- 服务端 POST /api/v1/ai/import/concepts + ImportConceptChain(JSON Mode + 降级链) - 主进程 import:* handlers(PDF 文本层/URL 抓取/imports 记录)+ SSRF 重定向逐跳校验与 IPv4-mapped IPv6 拦截 - schema v8:imports 表 + notes/flashcards source_ref 溯源列(幂等迁移) - SettlingPage 四步入籍向导 + useSettleConcepts 批量安放 + settling 签名时刻(星域成片点亮) - 审查修复:AI 失败注入手动建卡入口、总量预算与服务端对齐、空概念过滤、部分失败提示
1 parent d85836d commit 45c414e

32 files changed

Lines changed: 2550 additions & 14 deletions
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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+
};

‎client/electron/ai/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { feature as contentTierFeature } from './handlers/contentTierHandler.js'
3838
import { feature as conflictDetectFeature } from './handlers/conflictDetectHandler.js';
3939
import { feature as conceptPrecheckFeature } from './handlers/conceptPrecheckHandler.js';
4040
import { feature as progressNarrativeFeature } from './handlers/progressNarratorHandler.js';
41+
import { feature as importConceptFeature } from './handlers/importConceptHandler.js';
4142

4243
// ================================================================
4344
// 功能注册表
@@ -66,6 +67,7 @@ const features: AIFeatureDef[] = [
6667
conflictDetectFeature,
6768
conceptPrecheckFeature,
6869
progressNarrativeFeature,
70+
importConceptFeature,
6971
];
7072

7173
// ================================================================

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const ALLOWED_TABLES = new Set([
4242
'crdt_changes',
4343
// implementation_intentions — A4 实施意图(v6 迁移新增)
4444
'implementation_intentions',
45+
// imports — 知识入籍记录(v8 迁移新增)
46+
'imports',
4547
]);
4648

4749
/** camelCase → snake_case(用于表名映射) */
@@ -79,6 +81,8 @@ const TABLE_NAME_MAP: Record<string, string> = {
7981
implementationIntentions: 'implementation_intentions',
8082
// v7 新增:世界状态快照(MCP 记忆接口跨进程桥)
8183
worldSnapshots: 'world_snapshots',
84+
// v8 新增:知识入籍记录(阶段 A 入口问题)
85+
imports: 'imports',
8286
};
8387

8488
function resolveTable(table: string): string {

‎client/electron/db/schema.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import type Database from 'better-sqlite3';
88

9-
export const SCHEMA_VERSION = 7;
9+
export const SCHEMA_VERSION = 8;
1010

1111
export const SCHEMA_DDL = /* sql */ `
1212
CREATE TABLE IF NOT EXISTS pomodoro_sessions (
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS notes (
2727
template TEXT NOT NULL DEFAULT 'free' CHECK (template IN ('outline','cornell','mindmap','free','qa','blank','video')),
2828
folder_id TEXT, tags TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
2929
word_count INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, video_note_type TEXT,
30+
source_ref TEXT,
3031
FOREIGN KEY (folder_id) REFERENCES note_folders(id) ON DELETE SET NULL
3132
);
3233
CREATE TABLE IF NOT EXISTS note_folders (
@@ -45,7 +46,7 @@ CREATE TABLE IF NOT EXISTS flashcards (
4546
ease_factor REAL NOT NULL DEFAULT 2.5, "interval" REAL NOT NULL DEFAULT 0,
4647
repetitions INTEGER NOT NULL DEFAULT 0, lapses INTEGER NOT NULL DEFAULT 0,
4748
due_date TEXT NOT NULL, last_review_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
48-
source_note_id TEXT, "order" INTEGER NOT NULL DEFAULT 0,
49+
source_note_id TEXT, source_ref TEXT, "order" INTEGER NOT NULL DEFAULT 0,
4950
FOREIGN KEY (deck_id) REFERENCES flashcard_decks(id) ON DELETE CASCADE,
5051
FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE SET NULL
5152
);
@@ -193,6 +194,15 @@ CREATE INDEX IF NOT EXISTS idx_impl_intention_status ON implementation_intention
193194
CREATE TABLE IF NOT EXISTS world_snapshots (
194195
id TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL
195196
);
197+
-- v8:知识入籍记录(阶段 A 入口问题;source_ref 溯源列见 v8 迁移块)
198+
CREATE TABLE IF NOT EXISTS imports (
199+
id TEXT PRIMARY KEY,
200+
source TEXT NOT NULL CHECK (source IN ('text','pdf','url','clipboard')),
201+
raw_name TEXT NOT NULL DEFAULT '',
202+
concept_count INTEGER NOT NULL DEFAULT 0,
203+
settled_at TEXT NOT NULL
204+
);
205+
CREATE INDEX IF NOT EXISTS idx_imports_settled_at ON imports(settled_at);
196206
`;
197207

198208
/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
@@ -253,5 +263,16 @@ export function initializeSchema(db: Database.Database): void {
253263
// v7 迁移:世界状态快照表 world_snapshots
254264
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
255265

266+
// v8 迁移:知识入籍——imports 表(DDL 已含在 SCHEMA_DDL);
267+
// notes/flashcards 增加 source_ref 溯源列(条件 ALTER TABLE,幂等,不破坏存量)
268+
if (currentVersion < 8) {
269+
try {
270+
db.exec(`ALTER TABLE notes ADD COLUMN source_ref TEXT`);
271+
} catch { /* 列已存在 */ }
272+
try {
273+
db.exec(`ALTER TABLE flashcards ADD COLUMN source_ref TEXT`);
274+
} catch { /* 列已存在 */ }
275+
}
276+
256277
db.pragma(`user_version = ${SCHEMA_VERSION}`);
257278
}

0 commit comments

Comments
 (0)