Skip to content

Commit de86971

Browse files
feat(graph): 建图报「这一轮建了多少」—— 绝对值回答不了这个问题
## 为什么需要它 界面上「实体 618 / 事实 814」说的是图里有多少。而用户问的是"刚才那一轮 干了什么" —— 增量建图下一轮可能只新增几十个实体、总数几乎不变, 于是**每轮看起来都像没跑**,而那恰恰让人以为增量没生效。 ## 加了三段,各回答一个问题 实测一轮真实的增量建图(CDP 触发,与按钮同一条 IPC): before: {entities:660, facts:1079, edges:55194} after: {entities:695, facts:1154, edges:56553} volume: {entities:+35, facts:+75, edges:+1359, unitsDiscovered:36654, unitsSkipped:36613, unitsProcessed:41, chunksCreated:449} 界面: 上一轮 · 新增 +35 实体 · +75 事实 · +1,359 关系 · 处理 41 条语料 (切 449 块) · 增量跳过 36,613 条(已抽过) · 新增了什么 —— 净增,**带符号且允许负数**(fresh 重建先清空、或上游合并了 重复实体都会让某项减少;夹到 0 会把"合并生效了"说成"没变化"); · 处理了多少 —— unitsProcessed / chunksCreated; · 增量省了多少 —— unitsSkipped。这一轮 36613/36654 跳过,也就是省下了 99.9% 的 LLM 抽取(钱与时间),而那是绝对值完全看不出来的。 处理量四项直接来自上游 `/status.ingest`(units_discovered 等), 不解析 stdout —— 那条路格式一变就静默归零(CLAUDE.md §4)。 ## 实现要点 · 基线在 `graph build started` **之前**取:fresh 会删掉整个 kl 目录, 之后就再读不到"原来有多少";跑到一半读到的是中间值,减出来是错的; · edges 走 `lastKnownEdges`(/status 的真实值)而不是 SQL —— 那张表在 ladybug 后端下按设计恒空; · 差值只在**成功**路径记:失败/被打断时没有可信的差值,保留上一次成功的 那份比显示半截数字好; · 全 0 时说「本轮没有新增」而不是「+0 实体 +0 事实」(后者读起来像坏了, 而它是正常状态:语料全命中缓存); · 跳过那句要说清「已抽过」—— 光报数字会被读成"漏了 36613 条"。 ## ★ 反证暴露的第三处裸接线 把差值那三行改成直接用绝对值(= 修复前的信息量),全仓 976 条**一条都不红**。 所以把算术也提成纯函数 `computeBuildVolume`(与 buildAutoBuildSnapshot / buildForecastInput 同一款做法:算术留在方法里只能靠端到端撞上)。 反证:改成绝对值 → 2 红;负数夹到 0 → 1 红。 全量 3856 条通过;typecheck / lint 干净。**不含 kl-graph 的任何改动。** Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7640b8b commit de86971

6 files changed

Lines changed: 482 additions & 5 deletions

File tree

‎apps/desktop/src/main/services/kl-server.service.ts‎

Lines changed: 210 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,49 @@ export interface KlIngestSnapshot {
115115
phase: string
116116
percent: number
117117
error: string
118+
/** 图库当前的**绝对**规模(不是这一轮的增量,见 `KlBuildVolume`) */
118119
counts: { entities: number; facts: number; edges: number }
120+
/**
121+
* 这一轮**处理了多少语料** —— 上游 `/status.ingest` 直接给的四个数。
122+
*
123+
* ★ 这几个是「干了多少活」,与 `counts`(「现在有多少东西」)是两件事:
124+
* 增量建图时 `units_skipped` 往往远大于 `units_processed`
125+
* (实测 36613 发现 / 2589 跳过 / 34024 处理),而那个比例正是
126+
* "增量到底省了多少"的唯一证据。
127+
*/
128+
volume: {
129+
/** 发现的语料单元总数 */
130+
unitsDiscovered: number
131+
/** 命中缓存跳过的(增量的收益就在这个数上) */
132+
unitsSkipped: number
133+
/** 真的处理了的 */
134+
unitsProcessed: number
135+
/** 切出的 chunk 数 */
136+
chunksCreated: number
137+
}
138+
}
139+
140+
/**
141+
* 一轮建图**产出了多少** —— 前后差值,而不是绝对值。
142+
*
143+
* ## ★★ 为什么要单独一个类型而不是直接报 counts
144+
*
145+
* 界面上「实体 618」回答的是"图里有多少",而用户问的是"这一轮干了什么"。
146+
* 增量建图下两者差别很大:一轮可能只新增几十个实体,而总数是几百 ——
147+
* 只报总数的话每轮看起来都"没动"(数字几乎不变),
148+
* 而那恰恰让人以为增量没生效。
149+
*
150+
* ★ 允许**负数**:`fresh=true` 重建会先清空,或上游合并了重复实体,
151+
* 都会让某一项减少。夹到 0 会把"合并生效了"显示成"没变化"。
152+
*/
153+
export interface KlBuildVolume {
154+
entities: number
155+
facts: number
156+
edges: number
157+
unitsDiscovered: number
158+
unitsSkipped: number
159+
unitsProcessed: number
160+
chunksCreated: number
119161
}
120162

121163
/**
@@ -327,6 +369,19 @@ export class KlServerService {
327369
* 边数变化很慢,一个稍旧的真实值远好过一个永远为 0 的假值。
328370
*/
329371
private lastKnownEdges: number | null = null
372+
/**
373+
* 最近一轮建图的产出(差值 + 处理量)。null = 这次启动还没建过。
374+
*
375+
* ## ★ 为什么留在内存而不落库
376+
*
377+
* 它回答的是"刚才那一轮干了什么" —— 一个**会话内**的问题。落库要建表、
378+
* 要考虑 per-vault、要想清楚保留多久,而收益只是"重启后还能看到上一轮"。
379+
* 而重启后真正有用的是图的**绝对规模**(那个一直有)。
380+
*
381+
* ★ 失败/被打断时**不覆盖**:那时没有可信的差值(可能建到一半),
382+
* 保留上一次成功的那份比显示一个半截数字好。
383+
*/
384+
private lastBuildVolume: KlGraphBuildResult["volume"] = undefined
330385
/** 正在进行的 start(避免并发 ensureReady 起多个进程)。 */
331386
private starting: Promise<boolean> | null = null
332387
/** 正在建图(避免并发触发;建图期间禁止 ensureReady 起 server 抢 SQLite)。 */
@@ -641,11 +696,25 @@ export class KlServerService {
641696
}
642697
}
643698

699+
/**
700+
* ★★ 建图**之前**的图规模 —— 差值的基线。
701+
*
702+
* 必须在这里取(在 `setBuilding(true)` 与任何清库之前):
703+
* · `fresh=true` 会删掉整个 kl 目录,之后就再也读不到"原来有多少";
704+
* · 增量建图跑到一半时读到的是"跑了一半的中间值",减出来是错的。
705+
*
706+
* 读不出来(图库还不存在 = 首次建图)→ 全 0,那时差值就等于绝对值,
707+
* 语义正好对:首次建图"新增"的就是全部。
708+
*/
709+
const before = this.readGraphCounts()
710+
644711
this.options.logger.info("graph build started", {
645712
fresh,
646713
exportDir: this.exportDir,
647714
// 网关有没有 —— 只记布尔,不记 baseUrl/key(密钥不进日志是全仓规矩)
648715
hasGateway: this.hasGatewayEgress(),
716+
// 基线进日志:出问题时"这一轮从哪儿开始的"是第一个要问的
717+
before,
649718
})
650719
this.setBuilding(true)
651720
/**
@@ -819,6 +888,11 @@ export class KlServerService {
819888
entities: outcome.entities,
820889
facts: outcome.facts,
821890
edges: outcome.edges,
891+
/**
892+
* ★ 差值 = 建完 − 建前。允许负数(fresh 重建先清空、
893+
* 或上游合并了重复实体)—— 夹到 0 会把"合并生效了"显示成"没变化"。
894+
*/
895+
volume: this.rememberVolume(computeBuildVolume(before, outcome, outcome.volume)),
822896
})
823897
} catch (error) {
824898
this.setBuilding(false)
@@ -861,6 +935,12 @@ export class KlServerService {
861935
entities: result.entities,
862936
facts: result.facts,
863937
edges: result.edges,
938+
/**
939+
* ★ 差值与处理量一起记:只有绝对值的话"这一轮是不是空跑"看不出来
940+
* (增量建图下总数几乎不变),而 `unitsSkipped` 是增量真的生效了
941+
* 的唯一证据。
942+
*/
943+
volume: result.volume,
864944
})
865945
} else {
866946
this.options.logger.warn("graph build failed", { fresh, reason: result.reason })
@@ -1002,6 +1082,52 @@ export class KlServerService {
10021082
* 那种矛盾没有任何地方能发现。所以这里是**唯一**的实现,
10031083
* `graphOverview()` 复用它(反向依赖:轻的不依赖重的)。
10041084
*/
1085+
/**
1086+
* 读一次图规模(entities / facts / edges)—— 建图差值的基线。
1087+
*
1088+
* ## ★ 为什么 edges 走 `lastKnownEdges` 而不是 SQL
1089+
*
1090+
* SQLite 的 `edges` 表在默认后端(ladybug)下按设计恒空
1091+
* (完整推理见 `lastKnownEdges` 的注释)。所以那一项只能用从 `/status`
1092+
* 拿到的真实值;数不出来时给 0,差值那侧会把"两端都是 0"显示成"未统计"
1093+
* 而不是"没新增"。
1094+
*
1095+
* 整段吞异常:这是给差值用的诊断数字,图库不存在(首次建图)时读不到
1096+
* 是**正常**的 —— 那时全 0,差值恰好等于绝对值。
1097+
*/
1098+
private readGraphCounts(): { entities: number; facts: number; edges: number } {
1099+
const zero = { entities: 0, facts: 0, edges: 0 }
1100+
const dbPath = join(this.dataDir, "knowledge.db")
1101+
if (!existsSync(dbPath)) return zero
1102+
const open = this.options.openGraphDb ?? defaultOpenGraphDb
1103+
let db: GraphDbHandle | null = null
1104+
try {
1105+
db = open(dbPath)
1106+
const handle = db
1107+
const count = (table: string): number => {
1108+
try {
1109+
return handle.count(table)
1110+
} catch {
1111+
return 0
1112+
}
1113+
}
1114+
return {
1115+
entities: count("entities"),
1116+
facts: count("facts"),
1117+
// ★ 见上:edges 不从 SQL 数
1118+
edges: this.lastKnownEdges ?? 0,
1119+
}
1120+
} catch {
1121+
return zero
1122+
} finally {
1123+
try {
1124+
db?.close()
1125+
} catch {
1126+
// 只读连接,关不掉无需处理
1127+
}
1128+
}
1129+
}
1130+
10051131
graphExists(): boolean {
10061132
const dbPath = join(this.dataDir, "knowledge.db")
10071133
if (!existsSync(dbPath)) return false
@@ -1104,6 +1230,12 @@ export class KlServerService {
11041230
// 调度状态与图能不能读**无关**:图还没建时这一块恰恰最该显示
11051231
// (它要回答的正是"什么时候会建")。
11061232
buildSchedule: schedule,
1233+
/**
1234+
* ★ 图读不出来时**仍然报**上一轮的产出:那两件事无关 ——
1235+
* 「刚才那轮建了多少」是已经发生的事实,不该因为此刻读不到图而消失。
1236+
* 而 `fresh=true` 清库那个窗口里恰恰两者同时出现。
1237+
*/
1238+
lastBuild: this.lastBuildVolume ?? null,
11071239
})
11081240

11091241
const dbPath = join(this.dataDir, "knowledge.db")
@@ -1265,6 +1397,8 @@ export class KlServerService {
12651397
recentFacts,
12661398
// 上面取过一次(见那里:为什么不能在成功/失败两条路上各取一次)。
12671399
buildSchedule: schedule,
1400+
// ★ 「这一轮建了多少」——与上面那些绝对值是两件事(见契约里的注释)
1401+
lastBuild: this.lastBuildVolume ?? null,
12681402
}
12691403
} catch (error) {
12701404
const detail = error instanceof Error ? error.message : String(error)
@@ -1323,10 +1457,25 @@ export class KlServerService {
13231457
entities: number
13241458
facts: number
13251459
edges: number
1460+
/** 这一轮处理了多少语料(上游直接给的四个数,见 `KlIngestSnapshot.volume`) */
1461+
volume: KlIngestSnapshot["volume"]
13261462
}> {
13271463
const readStatus = this.options.readStatus ?? defaultReadStatus
13281464
const sleep = this.options.sleep ?? defaultSleep
13291465
let counts = { entities: 0, facts: 0, edges: 0 }
1466+
/**
1467+
* 处理量:**每轮轮询都覆盖**,所以循环结束时是最后一次看到的值。
1468+
*
1469+
* ★ 与 `counts` 同一款做法。上游在跑的过程中这几个数一直在涨
1470+
* (`detail: "extracting: 100/271 batches"` 那时就已经有部分值),
1471+
* 而我们要的是终态那一份。
1472+
*/
1473+
let volume: KlIngestSnapshot["volume"] = {
1474+
unitsDiscovered: 0,
1475+
unitsSkipped: 0,
1476+
unitsProcessed: 0,
1477+
chunksCreated: 0,
1478+
}
13301479
/**
13311480
* 本轮建图的起点 —— 只进 `buildProgress` 供诊断(见契约里那个字段的注释)。
13321481
*
@@ -1390,7 +1539,7 @@ export class KlServerService {
13901539
if (this.stopping) {
13911540
this.buildProgress = null
13921541
this.options.logger.info("graph build cancelled by shutdown", {})
1393-
return { error: null, cancelled: true, ...counts }
1542+
return { error: null, cancelled: true, ...counts, volume }
13941543
}
13951544

13961545
let snapshot: KlIngestSnapshot | null
@@ -1412,25 +1561,27 @@ export class KlServerService {
14121561
*/
14131562
if (consecutiveProbeFailures >= INGEST_PROBE_FAILURE_LIMIT && this.handle?.alive !== true) {
14141563
this.buildProgress = null
1415-
return { error: "建图中断:kl-server 进程已退出", cancelled: false, ...counts }
1564+
return { error: "建图中断:kl-server 进程已退出", cancelled: false, ...counts, volume }
14161565
}
14171566

14181567
if (snapshot !== null) {
14191568
counts = snapshot.counts
1569+
volume = snapshot.volume
14201570
// ★ 记下 backend-aware 的真实边数(见 lastKnownEdges 的注释)
14211571
this.lastKnownEdges = snapshot.counts.edges
14221572
this.buildProgress = { phase: snapshot.phase, percent: snapshot.percent, startedAt }
14231573
this.pushStatus()
14241574
if (snapshot.state === "done") {
14251575
this.buildProgress = null
1426-
return { error: null, cancelled: false, ...counts }
1576+
return { error: null, cancelled: false, ...counts, volume }
14271577
}
14281578
if (snapshot.state === "error") {
14291579
this.buildProgress = null
14301580
return {
14311581
error: snapshot.error === "" ? "未知错误" : snapshot.error,
14321582
cancelled: false,
14331583
...counts,
1584+
volume,
14341585
}
14351586
}
14361587
}
@@ -2199,6 +2350,14 @@ export class KlServerService {
21992350
this.pushStatus()
22002351
}
22012352

2353+
/** 记下这一轮的产出并原样返回(只在成功路径调,见字段注释)。 */
2354+
private rememberVolume(
2355+
volume: NonNullable<KlGraphBuildResult["volume"]>,
2356+
): NonNullable<KlGraphBuildResult["volume"]> {
2357+
this.lastBuildVolume = volume
2358+
return volume
2359+
}
2360+
22022361
/** 翻建图标志并推状态(建图与服务状态是两个维度,各自推)。 */
22032362
private setBuilding(next: boolean): void {
22042363
this.building = next
@@ -2230,6 +2389,34 @@ export class KlServerService {
22302389
* ★ 提出来而不是留在 `graphOverview()` 里,也是为了让测试不必开一个真 kl 库
22312390
* (那要 better-sqlite3 + 一个建好的图文件,而本项目为原生模块 ABI 反复踩过坑)。
22322391
*/
2392+
/**
2393+
* 算这一轮建图的**产出** = 建完 − 建前,再拼上处理量。
2394+
*
2395+
* ## ★★ 为什么提成导出的纯函数
2396+
*
2397+
* 反证时发现:把差值那三行改成直接用绝对值(= 修复前的信息量),
2398+
* 全仓 976 条测试**一条都不红** —— 而那正是这次要修的东西
2399+
* (增量建图下总数几乎不变,只报绝对值等于每轮都说"没动")。
2400+
*
2401+
* 与 `buildAutoBuildSnapshot` / `buildForecastInput` 同一款做法:
2402+
* 算术留在方法里就只能靠端到端撞上,提出来才锁得住。
2403+
*
2404+
* ★ 允许负数:`fresh` 重建先清空、或上游合并了重复实体,都会让某项减少。
2405+
* 夹到 0 会把"合并生效了"显示成"没变化"。
2406+
*/
2407+
export function computeBuildVolume(
2408+
before: { entities: number; facts: number; edges: number },
2409+
after: { entities: number; facts: number; edges: number },
2410+
work: KlIngestSnapshot["volume"],
2411+
): NonNullable<KlGraphBuildResult["volume"]> {
2412+
return {
2413+
entities: after.entities - before.entities,
2414+
facts: after.facts - before.facts,
2415+
edges: after.edges - before.edges,
2416+
...work,
2417+
}
2418+
}
2419+
22332420
export function describeGraphStage(input: {
22342421
entities: number
22352422
facts: number
@@ -2439,7 +2626,16 @@ async function defaultReadStatus(port: number): Promise<KlIngestSnapshot | null>
24392626
})
24402627
if (!response.ok) return null
24412628
const body = (await response.json()) as {
2442-
ingest?: { state?: string; phase?: string; percent?: number; error?: string }
2629+
ingest?: {
2630+
state?: string
2631+
phase?: string
2632+
percent?: number
2633+
error?: string
2634+
units_discovered?: number
2635+
units_skipped?: number
2636+
units_processed?: number
2637+
chunks_created?: number
2638+
}
24432639
sqlite?: { entities?: number; facts?: number; edges?: number }
24442640
}
24452641
const ingest = body.ingest ?? {}
@@ -2458,6 +2654,16 @@ async function defaultReadStatus(port: number): Promise<KlIngestSnapshot | null>
24582654
facts: sqlite.facts ?? 0,
24592655
edges: sqlite.edges ?? 0,
24602656
},
2657+
/**
2658+
* ★ 上游是 snake_case(`units_discovered`),这里转成我们的 camelCase。
2659+
* 字段名照抄上游的语义,不改口径 —— 改了就没法与 kl 的日志对照。
2660+
*/
2661+
volume: {
2662+
unitsDiscovered: ingest.units_discovered ?? 0,
2663+
unitsSkipped: ingest.units_skipped ?? 0,
2664+
unitsProcessed: ingest.units_processed ?? 0,
2665+
chunksCreated: ingest.chunks_created ?? 0,
2666+
},
24612667
}
24622668
}
24632669

‎apps/desktop/src/renderer/features/dashboard/dashboard-data.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,53 @@ export function formatEta(ms: number): string {
370370
return `约 ${String(Math.round(ms / 86_400_000))} 天`
371371
}
372372

373+
/**
374+
* 最近一轮建图**产出了多少** → 一句人能读的话。`null` = 还没建过 / 没测到。
375+
*
376+
* ## ★★ 为什么需要它(绝对值回答不了这个问题)
377+
*
378+
* 界面上「实体 618 / 事实 814」说的是"图里有多少"。而用户问的是
379+
* "刚才那一轮干了什么" —— 增量建图下一轮可能只新增几十个实体,
380+
* 总数几乎不变,于是**每轮看起来都像没跑**。而那恰恰让人以为增量没生效。
381+
*
382+
* ## ★ 三段各自回答一个问题
383+
*
384+
* · 新增了什么(实体/事实/关系的**净增**);
385+
* · 处理了多少语料(`unitsProcessed` / `chunksCreated`);
386+
* · 增量省了多少(`unitsSkipped` —— 那是没白烧的 LLM 抽取,也就是钱与时间)。
387+
*
388+
* ★ 净增允许**负数**并原样显示(带符号):`fresh` 重建先清空、
389+
* 或上游合并了重复实体都会让某项减少。夹到 0 会把"合并生效了"说成"没变化"。
390+
*/
391+
export function describeBuildVolume(volume: KlGraphOverview["lastBuild"]): string | null {
392+
if (volume === null) return null
393+
const { entities, facts, edges, unitsProcessed, unitsSkipped, chunksCreated } = volume
394+
395+
/**
396+
* ★ 全 0 时说「没有新增」而不是「+0 实体 +0 事实」——
397+
* 后者读起来像坏了,而它其实是个正常状态(语料都命中了缓存)。
398+
*/
399+
const grew = entities !== 0 || facts !== 0 || edges !== 0
400+
const signed = (n: number): string => (n > 0 ? `+${formatCount(n)}` : formatCount(n))
401+
const gains = grew
402+
? `新增 ${signed(entities)} 实体 · ${signed(facts)} 事实 · ${signed(edges)} 关系`
403+
: "本轮没有新增实体/事实"
404+
405+
/** 处理量:`unitsProcessed` 为 0 时整段省掉(那时上游没给或真没处理)。 */
406+
const work =
407+
unitsProcessed > 0
408+
? ` · 处理 ${formatCount(unitsProcessed)} 条语料(切 ${formatCount(chunksCreated)} 块)`
409+
: ""
410+
411+
/**
412+
* ★ 跳过数只在 > 0 时说,并且说清它**是好事**(省了抽取)——
413+
* 光报一个"跳过 2,589"会被读成"漏了 2589 条"。
414+
*/
415+
const saved = unitsSkipped > 0 ? ` · 增量跳过 ${formatCount(unitsSkipped)} 条(已抽过)` : ""
416+
417+
return `${gains}${work}${saved}`
418+
}
419+
373420
/**
374421
* 自动建图的调度状态 → 一句人能读的话。
375422
*

0 commit comments

Comments
 (0)