@@ -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+
22332420export 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
0 commit comments