Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7171ac14 | ||
|
|
0b2b4f01d8 | ||
|
|
37dcebfce4 | ||
|
|
d7a73d7e6c | ||
|
|
9a4a734c19 | ||
|
|
38d7ef147d | ||
|
|
ab1452d06b | ||
|
|
1c7882ce34 | ||
|
|
3cf3858f09 | ||
|
|
33e16c1a61 | ||
|
|
c84d968b02 | ||
|
|
f4417c2f4d | ||
|
|
03cac702a8 | ||
|
|
0499c82965 | ||
|
|
d117102088 | ||
|
|
1aaab764ba | ||
|
|
2ac7c9d5a1 | ||
|
|
66d5049151 | ||
|
|
f979bd6ffc | ||
|
|
8ea82fbec3 | ||
|
|
2862f68631 | ||
|
|
b8d77547de | ||
|
|
79019bfc40 | ||
|
|
a5bc27c3a4 | ||
|
|
83c41963cf | ||
|
|
3533e55801 | ||
|
|
a293f7b784 | ||
|
|
754976db52 | ||
|
|
c954aa2917 | ||
|
|
772a597a53 | ||
|
|
1f30474cf3 | ||
|
|
a1021bb70a | ||
|
|
e0412747f0 | ||
|
|
5f63b35602 | ||
|
|
3e29617cbe | ||
|
|
941b7eec32 | ||
|
|
71d5f2a7cd | ||
|
|
f6c373ea94 | ||
|
|
978f27a164 | ||
|
|
0bfa7a4bc1 | ||
|
|
25f68831fd | ||
|
|
42fafc495f | ||
|
|
d4b8688148 | ||
|
|
bd399dd007 | ||
|
|
ffc5b108c6 | ||
|
|
45726d26a7 | ||
|
|
c015febb49 | ||
|
|
44fd0e95b4 | ||
|
|
b2a016e90f | ||
|
|
cee68592e3 | ||
|
|
c3747755b6 | ||
|
|
4bbf59dabe | ||
|
|
0c0494dabd | ||
|
|
a214cf4a0e | ||
|
|
a2f355a994 | ||
|
|
fa3490e794 | ||
|
|
05100339f8 | ||
|
|
00747be2db | ||
|
|
08a649792e | ||
|
|
584fe37c35 | ||
|
|
525f11f531 | ||
|
|
8f57bd0fae | ||
|
|
211d516e5f | ||
|
|
284584c578 | ||
|
|
bdc18f6177 | ||
|
|
b017c35d37 | ||
|
|
c12f55ef71 | ||
|
|
e52f335998 | ||
|
|
24d6c46065 | ||
|
|
e42d19cfd9 | ||
|
|
e5bd7c7576 | ||
|
|
3594456df3 | ||
|
|
3fa7850f01 | ||
|
|
827de4f65a | ||
|
|
808b9862c9 | ||
|
|
b69cc01d52 | ||
|
|
80d0a37116 | ||
|
|
793bfefc61 | ||
|
|
db6ab564dc | ||
|
|
339642b2c8 | ||
|
|
c2683fd6a9 | ||
|
|
1cdeb4a54b | ||
|
|
9236d8d4b4 | ||
|
|
43a9cc8751 | ||
|
|
4565026b09 | ||
|
|
b13cdea910 | ||
|
|
88d466d156 | ||
|
|
faf71cbe99 | ||
|
|
113e6e8274 | ||
|
|
de0ddf4b8b | ||
|
|
d0558e1fd5 | ||
|
|
12f808d0e1 | ||
|
|
44ccb69ca5 | ||
|
|
95cc81db4b | ||
|
|
f8ffcde0f4 | ||
|
|
0afff5734e | ||
|
|
ef4f379736 | ||
|
|
aab877a13c | ||
|
|
1cfb17ac5e | ||
|
|
8364ff1b36 | ||
|
|
57bdf7fa03 | ||
|
|
d2348eda43 | ||
|
|
467d9b4ac1 | ||
|
|
ef64da6df2 | ||
|
|
ae39e1ce8a | ||
|
|
a1c9fc1631 | ||
|
|
f37f5e744e | ||
|
|
0bdcae2b91 | ||
|
|
1b6920e8e1 | ||
|
|
a377a7f667 | ||
|
|
e282a41bab | ||
|
|
0959af388b | ||
|
|
6d724dc937 |
+11
@@ -38,3 +38,14 @@ go/zhiyid
|
||||
|
||||
# 临时工件(cron list dump,随时可 `hermes cron list` 重现,不入库)
|
||||
cron_raw.txt
|
||||
|
||||
# 改动前备份快照不进 git(git 本身就是版本历史)
|
||||
*.bak.*
|
||||
|
||||
# 探针/临时构建产物(2026-10-14 t_3e98b65a 调查时发现 17MB 残留未清)
|
||||
/go/*-probe
|
||||
/go/rulefirst-probe
|
||||
|
||||
# 临时构建/验证工件(t_5f822020 清理:quantify/test_one 二进制 + verify.go 探针)
|
||||
/go/tmp/
|
||||
/verify.go
|
||||
|
||||
@@ -197,7 +197,6 @@ def stats(stat_type, json_flag):
|
||||
if "metrics" in data:
|
||||
m = data["metrics"]
|
||||
click.echo(f"\n📊 自优化指标:")
|
||||
click.echo(f" 召回命中率: {m.get('recall_hit_rate', '?'):.1%}")
|
||||
click.echo(f" 召回有用率: {m.get('recall_usefulness_rate', '?'):.1%}")
|
||||
click.echo(f" 记忆总数: {m.get('total_memories', '?')}")
|
||||
|
||||
@@ -429,7 +428,7 @@ def _repl_with_skin(c: ZhiYiClient, skin):
|
||||
["记忆数", str(s.get("total_memories", "?"))],
|
||||
["图谱节点", str(g.get("node_count", "?"))],
|
||||
["图谱边", str(g.get("edge_count", "?"))],
|
||||
["召回命中率", f"{m.get('recall_hit_rate', '?'):.1%}"],
|
||||
["召回有用率", f"{m.get('recall_usefulness_rate', '?'):.1%}"],
|
||||
],
|
||||
)
|
||||
elif cmd == "graph" and args and args[0] == "navigate":
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-DEF-152-20261016-184003.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=2630ms errs=0
|
||||
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-DEF-golden-20261016-184003.json pass=14/14 fail=0
|
||||
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
|
||||
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
|
||||
[probe10] 招商项目台账: n=20 v>0=0 -> []
|
||||
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
|
||||
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
|
||||
[probe10] 昨天做了什么: n=20 v>0=0 -> []
|
||||
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
|
||||
[probe10] 家服怎么连: n=20 v>0=0 -> []
|
||||
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
|
||||
[probe10] 记忆保护名单: n=20 v>0=0 -> []
|
||||
DONE DEF at 20261016-184003
|
||||
@@ -0,0 +1,13 @@
|
||||
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-NOV-152-20261016-184835.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=812ms errs=0
|
||||
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-NOV-golden-20261016-184835.json pass=14/14 fail=0
|
||||
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
|
||||
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
|
||||
[probe10] 招商项目台账: n=20 v>0=0 -> []
|
||||
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
|
||||
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
|
||||
[probe10] 昨天做了什么: n=20 v>0=0 -> []
|
||||
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
|
||||
[probe10] 家服怎么连: n=20 v>0=0 -> []
|
||||
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
|
||||
[probe10] 记忆保护名单: n=20 v>0=0 -> []
|
||||
DONE NOV at 20261016-184835
|
||||
@@ -0,0 +1,13 @@
|
||||
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-PRE-152-20261016-183449.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=809ms errs=0
|
||||
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-PRE-golden-20261016-183449.json pass=14/14 fail=0
|
||||
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
|
||||
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
|
||||
[probe10] 招商项目台账: n=20 v>0=0 -> []
|
||||
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
|
||||
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
|
||||
[probe10] 昨天做了什么: n=20 v>0=0 -> []
|
||||
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
|
||||
[probe10] 家服怎么连: n=20 v>0=0 -> []
|
||||
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
|
||||
[probe10] 记忆保护名单: n=20 v>0=0 -> []
|
||||
DONE PRE at 20261016-183449
|
||||
@@ -0,0 +1,13 @@
|
||||
[152] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-RBK-152-20261016-190913.json total=152 PASS=74 id_hits=7 fp=2318948a910f6d70de6be063216c78089c4315bd p95=931ms errs=0
|
||||
[golden] /home/muc/.hermes/kanban/workspaces/t_492e3f22/ac-evidence/t492-RBK-golden-20261016-190913.json pass=14/14 fail=0
|
||||
[probe10] 牧尘工作所在的县是哪个: n=20 v>0=0 -> []
|
||||
[probe10] 淇县: n=20 v>0=1 -> ['mem_1792088552819293505']
|
||||
[probe10] 招商项目台账: n=20 v>0=0 -> []
|
||||
[probe10] 生物制造产业专班: n=20 v>0=0 -> []
|
||||
[probe10] 小怡的工作笔记在哪: n=15 v>0=0 -> []
|
||||
[probe10] 昨天做了什么: n=20 v>0=0 -> []
|
||||
[probe10] 飞书怎么发语音: n=20 v>0=0 -> []
|
||||
[probe10] 家服怎么连: n=20 v>0=0 -> []
|
||||
[probe10] 织忆怎么部署: n=20 v>0=1 -> ['mem_1791740271337572997']
|
||||
[probe10] 记忆保护名单: n=20 v>0=0 -> []
|
||||
DONE RBK at 20261016-190913
|
||||
@@ -0,0 +1,87 @@
|
||||
# t_492e3f22 · fuseQuality V 值分支 env 门(ZHIYI_V_FUSE_ENABLE)— A/B 证据包
|
||||
|
||||
次症 C:给 Go 端 fuseQuality 的 V 值分支加 env 门。**默认行为逐位保持现状**;`ZHIYI_V_FUSE_ENABLE=1`
|
||||
关闭 V 分支(v>0 时不再用 0.5v+0.5imp / 0.3v+0.3imp+0.4raw 加权,退化为 untrusted→min(imp,1)、
|
||||
trusted→min(0.4imp+0.6raw,1))。本包 = 代码 + 测试 + 双口径 A/B 证据。
|
||||
|
||||
## 结论(2026-10-16 实测)
|
||||
|
||||
| AC | 判据 | 结果 |
|
||||
|---|---|---|
|
||||
| AC1 默认不碰 | PRE(旧二进制) ≡ DEF(新二进制默认):152 集 verdict 指纹、top5 顺序、probe10 top20、golden | ✅ 全同(指纹 2318948a…;golden 14/14) |
|
||||
| AC2 翻转证据 | DEF vs NOV(env=1):verdict 指纹同;probe10 10 条 affected=0;两条受影响 query top50 顺序逐位全同(fp 47e228e8 / 9dd57897 各两边一致);v>0 条目 rank 零移动 | ✅(排名变化 = 0,见「解读」) |
|
||||
| AC4 受影响条数 | v>0 条数独立复算(4 个 tag 集合完全一致):152×top5 = 2/760 (0.26%);probe10×top20 = 2/195 (1.03%) | ✅ 实测 2 条,任务表预期 2–3(≈1.5%,V 按 0.01/天衰减) |
|
||||
| AC5 回滚复核 | RBK(新二进制重启·默认) == DEF 指纹(verdict 层 2318948a + probe10 全同 + golden 14/14) | ✅(仅 2 条 v=0 Gitea top1 互漂,跨跑 usage_track 漂移,见「已知噪声」) |
|
||||
| AC6 异源复算 | 第二张口径(independent_recheck.py,sha256 顺序指纹 + 逐条 v>0 集合复算)与主口径一致,无矛盾 | ✅ PASS |
|
||||
|
||||
## 改动文件
|
||||
|
||||
- `go/internal/storage/quality_fusion.go` — `vFuseUseV = os.Getenv("ZHIYI_V_FUSE_ENABLE") != "1"`(进程级,同 ZHIYI_PROOF_FALLBACK 模式),V 分支条件 `if vFuseUseV && vValue > 0`
|
||||
- `go/internal/storage/quality_fusion_test.go` — `TestFuseQuality_VGateOn`(门开:v>0 条 fused 0.85 vs v=0 条 0.725,排序保持)+ `TestFuseQuality_VGateOff`(门关:两者同值)
|
||||
- 单测:`cd go && go test ./internal/storage/ -run 'TestFuseQuality' -v` → 3/3 PASS(2026-10-16 19:35 复跑)
|
||||
|
||||
## 实例配置(隔离:独立数据副本,不碰主库)
|
||||
|
||||
- 数据:`/tmp/zyvfuse-t492e3f22/data`(/var/lib/memoryweave 全量副本,symbolic link 已解)
|
||||
- sidecar:`zhiyi-consolidate --mode socket --socket /tmp/zyvfuse-t492e3f22/cons.sock`
|
||||
- 4 实例:PRE :7891(旧二进制) / DEF :7892(新·默认) / NOV :7893(新·env=1,/proc environ 实证) / RBK :7892 重启(新·默认)
|
||||
- 评测集:152 条(zhiyi-loop-runner 池)+ golden 14 + probe10(淇县/织忆怎么部署/记忆保护名单 等卡表同源)
|
||||
|
||||
## 受影响 3 样本表(AC2,卡表口径)
|
||||
|
||||
| query | v>0(任务表→实测) | v>0 条目 | DEF→NOV 排名变化 |
|
||||
|---|---|---|---|
|
||||
| 淇县 | 0 → 1 | mem_1792088552819293505 (v=1.0, rank1) | 0(top50 逐位全同) |
|
||||
| 织忆怎么部署 | 2 → 1 | mem_1791740271337572997 (v=1.0) | 0(top50 逐位全同) |
|
||||
| 记忆保护名单 | 1 → 0 | (本次 top20 内无 v>0) | — |
|
||||
|
||||
**解读(为什么排名变化=0)**:recall 排序 base = 0.7×cosine + 0.3×fusedQuality。V 门控只改 v>0 条的
|
||||
fused 分(untrusted 时 0.5v+0.5imp → min(imp,1)),Δbase 上限 0.3×Δfused≈0.15;本数据分布下
|
||||
cosine 主导的条间 margin > Δbase ⇒ 无换位。门控在**融合分轴**生效(单测证明公式差),
|
||||
**排序轴**在本数据规模上实测零扰动 —— 这正是「默认不碰、关也不破」的安全证据。
|
||||
v>0 条数随时间漂(V 衰减 0.01/天 + seen-count 变化):任务表 3 → 实测 2。
|
||||
|
||||
## 已知噪声(与门控无关,如实记录)
|
||||
|
||||
- 152 集中 2 条 Gitea query 的 top1 在跨跑间互换(mem_1792088552817004309 ↔ mem_1792088552816998158,
|
||||
两侧 v=0、raw=0):DEF 跑 / NOV 跑 / RBK 跑各自写 usage_track(seen-count +1、last_recalled_at 刷新),
|
||||
imp 微漂 ⇒ 同分对 tie 换位。verdict/指纹层不受影响(4 tag verdict 指纹全同 2318948a)。
|
||||
- 152 集 P95:PRE=809ms / DEF=2630ms(冷启动+首轮 warmup)/ NOV≈951 / RBK=931 —— AC 判据为 ±20% 口径
|
||||
以稳态(RBK/NOV)为准,DEF 高值系冷启动,非门控效应。
|
||||
|
||||
## 复算命令
|
||||
|
||||
```bash
|
||||
# 单测
|
||||
cd /home/muc/src/memoryweave/go && go test ./internal/storage/ -run 'TestFuseQuality' -v
|
||||
# 异源复算(本目录 raw/*.json)
|
||||
python3 <kanban-workspace>/harness/independent_recheck.py # 输出 independent_recheck_out.json
|
||||
python3 <kanban-workspace>/harness/diffrun.py DEF NOV # 主口径 diff
|
||||
```
|
||||
|
||||
## 部署提示(本卡不含生产动作)
|
||||
|
||||
- 生产 zhiyid(systemd, :7821)与 zhiyi-consolidate 不动;本卡只验证行为等价 + 门控生效。
|
||||
- 真要关 V 分支:`Environment=ZHIYI_V_FUSE_ENABLE=1` 加进 deploy/zhiyid.service + 重启(需另行提卡审批)。
|
||||
- 回滚 = 删该行/置 0,重启即恢复现状(RBK 证据)。
|
||||
|
||||
## teardown + 零残留判据(19:44 补,跨卡通知 t_004cc147 内存告警处置要求)
|
||||
|
||||
测试栈(27G:data/ 副本 + graph.db 447M + 双 zhiyid 守护 + sidecar)已全部下线:
|
||||
|
||||
- 双孤儿 zhiyid(PID 347191 :7893 / 367265 :7892)已由 prof-cloud SIGTERM(19:15 内存告警处置)
|
||||
- 本侧:sidecar(cons.sock,data 副本)停掉;`/tmp/zyvfuse-t492e3f22` 整目录删除
|
||||
(evidence 先全量落盘 `ac-evidence/` 与本目录 raw/,再删)
|
||||
- 顺带清:`/tmp/dryrun-dir`(2.6G) `/tmp/mem-copy.lance`(604M) `spike*`(441M×2+小文件)
|
||||
—— 删前确认:lsof 无占用、无看板卡引用、mtime 10-11/10-12
|
||||
|
||||
终验输出(19:44):
|
||||
```
|
||||
ps -C zhiyid -o pid,args → 仅表头,无进程(exit 1)
|
||||
du -sh /tmp/zyvfuse-* → No such file or directory
|
||||
du -sh /tmp/dryrun-dir /tmp/mem-copy.lance /tmp/spike* → 全部 No such file
|
||||
free -h → available 5.3Gi(告警前 <1G)
|
||||
```
|
||||
|
||||
**判据补录(本卡验收新增)**:`ps -C zhiyid -o pid,args` 为空(测试栈守护零残留)+
|
||||
`du -sh /tmp/zyvfuse-*` 无残留 —— 已通过。
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"a": "DEF",
|
||||
"b": "NOV",
|
||||
"files": {
|
||||
"a": {
|
||||
"152": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"total",
|
||||
"pass",
|
||||
"fail",
|
||||
"metrics",
|
||||
"latency_ms",
|
||||
"fail_mode",
|
||||
"recall_errors",
|
||||
"fingerprint",
|
||||
"namespace",
|
||||
"top_k",
|
||||
"results"
|
||||
],
|
||||
"probe10": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"top_k",
|
||||
"rows"
|
||||
],
|
||||
"golden": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"keyword_pass",
|
||||
"keyword_fail",
|
||||
"keyword_total",
|
||||
"recall_at_5",
|
||||
"mrr",
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"b": [
|
||||
"152",
|
||||
"probe10",
|
||||
"golden"
|
||||
]
|
||||
},
|
||||
"152": {
|
||||
"fp_a": "2318948a910f6d70de6be063216c78089c4315bd",
|
||||
"fp_b": "2318948a910f6d70de6be063216c78089c4315bd",
|
||||
"fp_same": true,
|
||||
"pass_a": 74,
|
||||
"pass_b": 74,
|
||||
"id_hits_a": 7,
|
||||
"id_hits_b": 7,
|
||||
"p95_a_ms": 2630,
|
||||
"p95_b_ms": 812,
|
||||
"changed_queries": 1,
|
||||
"changed_detail": {
|
||||
"局域网里访问 Gitea 家服连哪个 IP": {
|
||||
"kind": "probe",
|
||||
"changed": [
|
||||
"top5"
|
||||
],
|
||||
"a_top5": [
|
||||
"mem_1792088552817004309",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1786521524260755589",
|
||||
"mem_1789150776299341327",
|
||||
"mem_1789737118121867083"
|
||||
],
|
||||
"b_top5": [
|
||||
"mem_1792088552816998158",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1786521524260755589",
|
||||
"mem_1789150776299341327",
|
||||
"mem_1789737118121867083"
|
||||
]
|
||||
}
|
||||
},
|
||||
"v0_rank_shifts": []
|
||||
},
|
||||
"probe10": {
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 1,
|
||||
"v_pos_b": 1,
|
||||
"v_pos_ids_a": [
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_pos_ids_b": [
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"n_a": 15,
|
||||
"n_b": 15,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 1,
|
||||
"v_pos_b": 1,
|
||||
"v_pos_ids_a": [
|
||||
"mem_1791740271337572997"
|
||||
],
|
||||
"v_pos_ids_b": [
|
||||
"mem_1791740271337572997"
|
||||
],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
}
|
||||
],
|
||||
"affected_queries": [],
|
||||
"n_affected_queries": 0
|
||||
},
|
||||
"golden": {
|
||||
"a": "14/14",
|
||||
"b": "14/14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"a": "DEF",
|
||||
"b": "RBK",
|
||||
"files": {
|
||||
"a": {
|
||||
"152": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"total",
|
||||
"pass",
|
||||
"fail",
|
||||
"metrics",
|
||||
"latency_ms",
|
||||
"fail_mode",
|
||||
"recall_errors",
|
||||
"fingerprint",
|
||||
"namespace",
|
||||
"top_k",
|
||||
"results"
|
||||
],
|
||||
"probe10": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"top_k",
|
||||
"rows"
|
||||
],
|
||||
"golden": [
|
||||
"at",
|
||||
"tag",
|
||||
"base",
|
||||
"keyword_pass",
|
||||
"keyword_fail",
|
||||
"keyword_total",
|
||||
"recall_at_5",
|
||||
"mrr",
|
||||
"items"
|
||||
]
|
||||
},
|
||||
"b": [
|
||||
"152",
|
||||
"probe10",
|
||||
"golden"
|
||||
]
|
||||
},
|
||||
"152": {
|
||||
"fp_a": "2318948a910f6d70de6be063216c78089c4315bd",
|
||||
"fp_b": "2318948a910f6d70de6be063216c78089c4315bd",
|
||||
"fp_same": true,
|
||||
"pass_a": 74,
|
||||
"pass_b": 74,
|
||||
"id_hits_a": 7,
|
||||
"id_hits_b": 7,
|
||||
"p95_a_ms": 2630,
|
||||
"p95_b_ms": 931,
|
||||
"changed_queries": 2,
|
||||
"changed_detail": {
|
||||
"家庭服务器(跑 Gitea)在局域网里的地址是哪个": {
|
||||
"kind": "probe",
|
||||
"changed": [
|
||||
"top5"
|
||||
],
|
||||
"a_top5": [
|
||||
"mem_1792088552816998158",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1785695216419714474",
|
||||
"ep_1786336919812827426",
|
||||
"ep_1786452727331645773"
|
||||
],
|
||||
"b_top5": [
|
||||
"mem_1792088552817004309",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1785695216419714474",
|
||||
"ep_1786336919812827426",
|
||||
"ep_1786452727331645773"
|
||||
]
|
||||
},
|
||||
"局域网里访问 Gitea 家服连哪个 IP": {
|
||||
"kind": "probe",
|
||||
"changed": [
|
||||
"top5"
|
||||
],
|
||||
"a_top5": [
|
||||
"mem_1792088552817004309",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1786521524260755589",
|
||||
"mem_1789150776299341327",
|
||||
"mem_1789737118121867083"
|
||||
],
|
||||
"b_top5": [
|
||||
"mem_1792088552816998158",
|
||||
"mem_1789136383983279469",
|
||||
"ep_1786521524260755589",
|
||||
"mem_1789150776299341327",
|
||||
"mem_1789737118121867083"
|
||||
]
|
||||
}
|
||||
},
|
||||
"v0_rank_shifts": []
|
||||
},
|
||||
"probe10": {
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 1,
|
||||
"v_pos_b": 1,
|
||||
"v_pos_ids_a": [
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_pos_ids_b": [
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"n_a": 15,
|
||||
"n_b": 15,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 1,
|
||||
"v_pos_b": 1,
|
||||
"v_pos_ids_a": [
|
||||
"mem_1791740271337572997"
|
||||
],
|
||||
"v_pos_ids_b": [
|
||||
"mem_1791740271337572997"
|
||||
],
|
||||
"v_rank_moved": []
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"n_a": 20,
|
||||
"n_b": 20,
|
||||
"set_added": [],
|
||||
"set_removed": [],
|
||||
"order_diff_positions": 0,
|
||||
"v_pos_a": 0,
|
||||
"v_pos_b": 0,
|
||||
"v_pos_ids_a": [],
|
||||
"v_pos_ids_b": [],
|
||||
"v_rank_moved": []
|
||||
}
|
||||
],
|
||||
"affected_queries": [],
|
||||
"n_affected_queries": 0
|
||||
},
|
||||
"golden": {
|
||||
"a": "14/14",
|
||||
"b": "14/14"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"at": "20261016-184343",
|
||||
"tag": "DEF",
|
||||
"base": "http://127.0.0.1:7892",
|
||||
"keyword_pass": 14,
|
||||
"keyword_fail": 0,
|
||||
"keyword_total": 14,
|
||||
"recall_at_5": 0.42857142857142855,
|
||||
"mrr": 0.2380952380952381,
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"at": "20261016-184350",
|
||||
"tag": "DEF",
|
||||
"base": "http://127.0.0.1:7892",
|
||||
"top_k": 20,
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"latency_ms": 689,
|
||||
"top_ids": [
|
||||
"mem_1792088552818864597",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1792144084726385461",
|
||||
"mem_1790349546889496890",
|
||||
"mem_1787450890745924038",
|
||||
"mem_1790448247125520424",
|
||||
"mem_1789063252280657787",
|
||||
"mem_1792088552817383356",
|
||||
"ep_1786293372952742428",
|
||||
"mem_1792144084989342595",
|
||||
"mem_1789815923678472264",
|
||||
"mem_1788006341389931001",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791556228356038757",
|
||||
"mem_1789230281180128695",
|
||||
"mem_1792088552824873585",
|
||||
"mem_1792088552817930263",
|
||||
"mem_1790424260127845812",
|
||||
"mem_1789737493969591957",
|
||||
"mem_1787926246493058856"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.5,
|
||||
0.59,
|
||||
0.59,
|
||||
0.755,
|
||||
0.255,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0,
|
||||
0.59,
|
||||
0.83,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.753,
|
||||
0.755,
|
||||
0.775
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"latency_ms": 717,
|
||||
"top_ids": [
|
||||
"mem_1792144084726385461",
|
||||
"mem_1792088552818354175",
|
||||
"mem_1792088552818868548",
|
||||
"mem_1792088552818381759",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1790938090098284542",
|
||||
"mem_1790938089297531705",
|
||||
"mem_1790603669933523968",
|
||||
"mem_1792088552818672993",
|
||||
"mem_1790425189400296970",
|
||||
"mem_1791564884666490485",
|
||||
"mem_1790848976254029727",
|
||||
"mem_1792088552819307750",
|
||||
"mem_1790657384251944657",
|
||||
"mem_1789328667925309581",
|
||||
"mem_1792116219795602288",
|
||||
"mem_1790425213354467843",
|
||||
"mem_1791395173327211388",
|
||||
"mem_1792141689103478828",
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.83,
|
||||
0.83,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.83,
|
||||
0.63,
|
||||
0.755,
|
||||
0.675,
|
||||
0.525,
|
||||
0.99
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1792088552819293505"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"latency_ms": 751,
|
||||
"top_ids": [
|
||||
"mem_1790657711315786810",
|
||||
"mem_1790425023223201421",
|
||||
"mem_1792088552819227083",
|
||||
"mem_1792088552817389514",
|
||||
"mem_1791194371133842796",
|
||||
"mem_1790565541250175254",
|
||||
"mem_1791387362920806120",
|
||||
"mem_1791899910885939521",
|
||||
"mem_1789694252600441660",
|
||||
"mem_1791665519266094329",
|
||||
"mem_1790309303666562764",
|
||||
"mem_1790581054400784704",
|
||||
"mem_1792088552818016699",
|
||||
"mem_1792088552818037737",
|
||||
"mem_1790590528061314469",
|
||||
"mem_1790187043969918230",
|
||||
"mem_1790448247323881504",
|
||||
"mem_1790329285912241710",
|
||||
"mem_1792088552817184125",
|
||||
"mem_1789327284892773149"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.853,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.765,
|
||||
0.59,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"latency_ms": 722,
|
||||
"top_ids": [
|
||||
"mem_1792144084989342595",
|
||||
"mem_1792107514588524675",
|
||||
"mem_1791567307185364992",
|
||||
"mem_1790051867711758989",
|
||||
"mem_1789983973135821157",
|
||||
"mem_1792088552816455607",
|
||||
"mem_1791380468697720355",
|
||||
"mem_1792088552817215072",
|
||||
"mem_1790658098063956127",
|
||||
"mem_1792088552825118896",
|
||||
"mem_1790656401441522455",
|
||||
"mem_1789755524758238574",
|
||||
"mem_1789741519238314161",
|
||||
"mem_1789736468534024216",
|
||||
"mem_1789731934167097953",
|
||||
"mem_1791916787313401423",
|
||||
"mem_1787336595731500272",
|
||||
"mem_1791794756620086535",
|
||||
"mem_1791811143462384371",
|
||||
"mem_1791735819718797684"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.63,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.78,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.798,
|
||||
0.395,
|
||||
0.695,
|
||||
0.63,
|
||||
0.315,
|
||||
0.6
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"latency_ms": 614,
|
||||
"top_ids": [
|
||||
"mem_1791365211038188118",
|
||||
"mem_1792088552817459951",
|
||||
"mem_1789155186005621317",
|
||||
"mem_1790441160106001927",
|
||||
"mem_1791736002933120377",
|
||||
"mem_1792118701572499829",
|
||||
"mem_1792088552818645685",
|
||||
"mem_1791915848530338805",
|
||||
"mem_1791383333319512288",
|
||||
"mem_1792088552818535755",
|
||||
"mem_1790187044046223493",
|
||||
"mem_1792088552824837822",
|
||||
"mem_1792088552817461960",
|
||||
"mem_1791423103974079882",
|
||||
"mem_1792088552825338800"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.39,
|
||||
0.63,
|
||||
0.5,
|
||||
0.5,
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.6,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"latency_ms": 692,
|
||||
"top_ids": [
|
||||
"mem_1788341686813816985",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791633893565216844",
|
||||
"mem_1790455525438011532",
|
||||
"mem_1792083942223793654",
|
||||
"mem_1792088770119950733",
|
||||
"mem_1790805789157124891",
|
||||
"mem_1792107513668237224",
|
||||
"mem_1789431180512315186",
|
||||
"mem_1790425238816494139",
|
||||
"mem_1790424256264891954",
|
||||
"mem_1787926246433496550",
|
||||
"mem_1790425017614420145",
|
||||
"mem_1790581068974704280",
|
||||
"mem_1789327284892773149",
|
||||
"mem_1790226520252986178",
|
||||
"mem_1790269130144600786",
|
||||
"mem_1791819411153164256",
|
||||
"mem_1789827897771148953",
|
||||
"mem_1788166192802637740"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.63,
|
||||
0.755,
|
||||
0.63,
|
||||
0.515,
|
||||
0.755,
|
||||
0.755,
|
||||
0.775,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"latency_ms": 783,
|
||||
"top_ids": [
|
||||
"mem_1790345721671326922",
|
||||
"mem_1790350480105660984",
|
||||
"mem_1790425022831993728",
|
||||
"mem_1791118583544396904",
|
||||
"mem_1791533226241026355",
|
||||
"mem_1790455570122981543",
|
||||
"mem_1791445601992763027",
|
||||
"mem_1790496848751501955",
|
||||
"mem_1789273123503223085",
|
||||
"mem_1791666641349257940",
|
||||
"mem_1791666631693960928",
|
||||
"mem_1792088552825510526",
|
||||
"mem_1791666639652621391",
|
||||
"mem_1790265788637717626",
|
||||
"mem_1790428068416795396",
|
||||
"mem_1791118583617248844",
|
||||
"mem_1791666711653684211",
|
||||
"mem_1789557079608816710",
|
||||
"mem_1789327693618007907",
|
||||
"mem_1789273123339483168"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.803,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.803,
|
||||
0,
|
||||
0.83,
|
||||
0.755,
|
||||
0,
|
||||
0.789,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.805,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"latency_ms": 700,
|
||||
"top_ids": [
|
||||
"mem_1791556229473565240",
|
||||
"mem_1792084245285474959",
|
||||
"mem_1792088552818289574",
|
||||
"mem_1790180705032672204",
|
||||
"mem_1792100307763093595",
|
||||
"mem_1791665516356975992",
|
||||
"mem_1792084245095017393",
|
||||
"mem_1792082720405068478",
|
||||
"mem_1791440639711180469",
|
||||
"mem_1792114886051333715",
|
||||
"mem_1792088552825127031",
|
||||
"mem_1791642436962837771",
|
||||
"mem_1792088552819438066",
|
||||
"mem_1792088552816808997",
|
||||
"mem_1790187091203506519",
|
||||
"mem_1792126203805297968",
|
||||
"mem_1792088552819539001",
|
||||
"mem_1791904832104972267",
|
||||
"mem_1792088552818662517",
|
||||
"mem_1792088552824945417"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.755,
|
||||
0.63,
|
||||
0.8,
|
||||
0.63,
|
||||
0.63,
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.65,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.395,
|
||||
0.5,
|
||||
0.43,
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"latency_ms": 724,
|
||||
"top_ids": [
|
||||
"mem_1788623666023332134",
|
||||
"mem_1789793765940732350",
|
||||
"mem_1791479003228249623",
|
||||
"mem_1789149704491770048",
|
||||
"mem_1789154058288011112",
|
||||
"mem_1791818814047111008",
|
||||
"mem_1792144557208382006",
|
||||
"mem_1791402157932171759",
|
||||
"mem_1791899478077693848",
|
||||
"mem_1789411888019473186",
|
||||
"mem_1792088552825130331",
|
||||
"mem_1792088552817959556",
|
||||
"mem_1791912042293867384",
|
||||
"mem_1788657176712786530",
|
||||
"mem_1790424244350350337",
|
||||
"mem_1791740271337572997",
|
||||
"mem_1792088552818065436",
|
||||
"mem_1789132107404617336",
|
||||
"mem_1788340352324421455",
|
||||
"mem_1791431677583759030"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.911,
|
||||
0.755,
|
||||
0,
|
||||
0.99,
|
||||
0.695,
|
||||
0.63,
|
||||
0.565,
|
||||
0.99,
|
||||
0.644,
|
||||
0.865,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.755,
|
||||
0.99,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.675
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1791740271337572997"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"latency_ms": 708,
|
||||
"top_ids": [
|
||||
"mem_1790424365997011289",
|
||||
"mem_1792126499467223243",
|
||||
"mem_1788602693814224919",
|
||||
"mem_1791725846574107784",
|
||||
"mem_1789222925018439889",
|
||||
"mem_1791666995981185275",
|
||||
"mem_1792088552818471375",
|
||||
"mem_1791686097363885894",
|
||||
"mem_1789554588799449124",
|
||||
"mem_1791420422726554280",
|
||||
"mem_1791686088996261165",
|
||||
"mem_1789760781378060592",
|
||||
"mem_1790427668905832166",
|
||||
"mem_1791111607517908194",
|
||||
"mem_1791818212646773755",
|
||||
"mem_1790051973384869549",
|
||||
"mem_1790564623527240562",
|
||||
"mem_1789992010025213786",
|
||||
"mem_1792088552819583406",
|
||||
"mem_1791685772880116219"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.99,
|
||||
0.5,
|
||||
0.285,
|
||||
0,
|
||||
0.755,
|
||||
0,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.914629,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.63,
|
||||
0.755,
|
||||
0.755,
|
||||
0.99,
|
||||
0.5,
|
||||
0
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"at": "20261016-185023",
|
||||
"tag": "NOV",
|
||||
"base": "http://127.0.0.1:7893",
|
||||
"keyword_pass": 14,
|
||||
"keyword_fail": 0,
|
||||
"keyword_total": 14,
|
||||
"recall_at_5": 0.42857142857142855,
|
||||
"mrr": 0.2380952380952381,
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"at": "20261016-185030",
|
||||
"tag": "NOV",
|
||||
"base": "http://127.0.0.1:7893",
|
||||
"top_k": 20,
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"latency_ms": 828,
|
||||
"top_ids": [
|
||||
"mem_1792088552818864597",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1792144084726385461",
|
||||
"mem_1790349546889496890",
|
||||
"mem_1787450890745924038",
|
||||
"mem_1790448247125520424",
|
||||
"mem_1789063252280657787",
|
||||
"mem_1792088552817383356",
|
||||
"ep_1786293372952742428",
|
||||
"mem_1792144084989342595",
|
||||
"mem_1789815923678472264",
|
||||
"mem_1788006341389931001",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791556228356038757",
|
||||
"mem_1789230281180128695",
|
||||
"mem_1792088552824873585",
|
||||
"mem_1792088552817930263",
|
||||
"mem_1790424260127845812",
|
||||
"mem_1789737493969591957",
|
||||
"mem_1787926246493058856"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.5,
|
||||
0.59,
|
||||
0.59,
|
||||
0.755,
|
||||
0.255,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0,
|
||||
0.59,
|
||||
0.83,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.753,
|
||||
0.755,
|
||||
0.775
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"latency_ms": 748,
|
||||
"top_ids": [
|
||||
"mem_1792144084726385461",
|
||||
"mem_1792088552818354175",
|
||||
"mem_1792088552818868548",
|
||||
"mem_1792088552818381759",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1790938090098284542",
|
||||
"mem_1790938089297531705",
|
||||
"mem_1790603669933523968",
|
||||
"mem_1792088552818672993",
|
||||
"mem_1790425189400296970",
|
||||
"mem_1791564884666490485",
|
||||
"mem_1790848976254029727",
|
||||
"mem_1792088552819307750",
|
||||
"mem_1790657384251944657",
|
||||
"mem_1789328667925309581",
|
||||
"mem_1792116219795602288",
|
||||
"mem_1790425213354467843",
|
||||
"mem_1791395173327211388",
|
||||
"mem_1792141689103478828",
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.83,
|
||||
0.83,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.83,
|
||||
0.63,
|
||||
0.755,
|
||||
0.675,
|
||||
0.525,
|
||||
0.99
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1792088552819293505"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"latency_ms": 692,
|
||||
"top_ids": [
|
||||
"mem_1790657711315786810",
|
||||
"mem_1790425023223201421",
|
||||
"mem_1792088552819227083",
|
||||
"mem_1792088552817389514",
|
||||
"mem_1791194371133842796",
|
||||
"mem_1790565541250175254",
|
||||
"mem_1791387362920806120",
|
||||
"mem_1791899910885939521",
|
||||
"mem_1789694252600441660",
|
||||
"mem_1791665519266094329",
|
||||
"mem_1790309303666562764",
|
||||
"mem_1790581054400784704",
|
||||
"mem_1792088552818016699",
|
||||
"mem_1792088552818037737",
|
||||
"mem_1790590528061314469",
|
||||
"mem_1790187043969918230",
|
||||
"mem_1790448247323881504",
|
||||
"mem_1790329285912241710",
|
||||
"mem_1792088552817184125",
|
||||
"mem_1789327284892773149"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.853,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.765,
|
||||
0.59,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"latency_ms": 708,
|
||||
"top_ids": [
|
||||
"mem_1792144084989342595",
|
||||
"mem_1792107514588524675",
|
||||
"mem_1791567307185364992",
|
||||
"mem_1790051867711758989",
|
||||
"mem_1789983973135821157",
|
||||
"mem_1792088552816455607",
|
||||
"mem_1791380468697720355",
|
||||
"mem_1792088552817215072",
|
||||
"mem_1790658098063956127",
|
||||
"mem_1792088552825118896",
|
||||
"mem_1790656401441522455",
|
||||
"mem_1789755524758238574",
|
||||
"mem_1789741519238314161",
|
||||
"mem_1789736468534024216",
|
||||
"mem_1789731934167097953",
|
||||
"mem_1791916787313401423",
|
||||
"mem_1787336595731500272",
|
||||
"mem_1791794756620086535",
|
||||
"mem_1791811143462384371",
|
||||
"mem_1791735819718797684"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.63,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.78,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.798,
|
||||
0.395,
|
||||
0.695,
|
||||
0.63,
|
||||
0.315,
|
||||
0.6
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"latency_ms": 736,
|
||||
"top_ids": [
|
||||
"mem_1791365211038188118",
|
||||
"mem_1792088552817459951",
|
||||
"mem_1789155186005621317",
|
||||
"mem_1790441160106001927",
|
||||
"mem_1791736002933120377",
|
||||
"mem_1792118701572499829",
|
||||
"mem_1792088552818645685",
|
||||
"mem_1791915848530338805",
|
||||
"mem_1791383333319512288",
|
||||
"mem_1792088552818535755",
|
||||
"mem_1790187044046223493",
|
||||
"mem_1792088552824837822",
|
||||
"mem_1792088552817461960",
|
||||
"mem_1791423103974079882",
|
||||
"mem_1792088552825338800"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.39,
|
||||
0.63,
|
||||
0.5,
|
||||
0.5,
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.6,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"latency_ms": 791,
|
||||
"top_ids": [
|
||||
"mem_1788341686813816985",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791633893565216844",
|
||||
"mem_1790455525438011532",
|
||||
"mem_1792083942223793654",
|
||||
"mem_1792088770119950733",
|
||||
"mem_1790805789157124891",
|
||||
"mem_1792107513668237224",
|
||||
"mem_1789431180512315186",
|
||||
"mem_1790425238816494139",
|
||||
"mem_1790424256264891954",
|
||||
"mem_1787926246433496550",
|
||||
"mem_1790425017614420145",
|
||||
"mem_1790581068974704280",
|
||||
"mem_1789327284892773149",
|
||||
"mem_1790226520252986178",
|
||||
"mem_1790269130144600786",
|
||||
"mem_1791819411153164256",
|
||||
"mem_1789827897771148953",
|
||||
"mem_1788166192802637740"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.63,
|
||||
0.755,
|
||||
0.63,
|
||||
0.515,
|
||||
0.755,
|
||||
0.755,
|
||||
0.775,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"latency_ms": 723,
|
||||
"top_ids": [
|
||||
"mem_1790345721671326922",
|
||||
"mem_1790350480105660984",
|
||||
"mem_1790425022831993728",
|
||||
"mem_1791118583544396904",
|
||||
"mem_1791533226241026355",
|
||||
"mem_1790455570122981543",
|
||||
"mem_1791445601992763027",
|
||||
"mem_1790496848751501955",
|
||||
"mem_1789273123503223085",
|
||||
"mem_1791666641349257940",
|
||||
"mem_1791666631693960928",
|
||||
"mem_1792088552825510526",
|
||||
"mem_1791666639652621391",
|
||||
"mem_1790265788637717626",
|
||||
"mem_1790428068416795396",
|
||||
"mem_1791118583617248844",
|
||||
"mem_1791666711653684211",
|
||||
"mem_1789557079608816710",
|
||||
"mem_1789327693618007907",
|
||||
"mem_1789273123339483168"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.803,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.803,
|
||||
0,
|
||||
0.83,
|
||||
0.755,
|
||||
0,
|
||||
0.789,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.805,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"latency_ms": 722,
|
||||
"top_ids": [
|
||||
"mem_1791556229473565240",
|
||||
"mem_1792084245285474959",
|
||||
"mem_1792088552818289574",
|
||||
"mem_1790180705032672204",
|
||||
"mem_1792100307763093595",
|
||||
"mem_1791665516356975992",
|
||||
"mem_1792084245095017393",
|
||||
"mem_1792082720405068478",
|
||||
"mem_1791440639711180469",
|
||||
"mem_1792114886051333715",
|
||||
"mem_1792088552825127031",
|
||||
"mem_1791642436962837771",
|
||||
"mem_1792088552819438066",
|
||||
"mem_1792088552816808997",
|
||||
"mem_1790187091203506519",
|
||||
"mem_1792126203805297968",
|
||||
"mem_1792088552819539001",
|
||||
"mem_1791904832104972267",
|
||||
"mem_1792088552818662517",
|
||||
"mem_1792088552824945417"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.755,
|
||||
0.63,
|
||||
0.8,
|
||||
0.63,
|
||||
0.63,
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.65,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.395,
|
||||
0.5,
|
||||
0.43,
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"latency_ms": 682,
|
||||
"top_ids": [
|
||||
"mem_1788623666023332134",
|
||||
"mem_1789793765940732350",
|
||||
"mem_1791479003228249623",
|
||||
"mem_1789149704491770048",
|
||||
"mem_1789154058288011112",
|
||||
"mem_1791818814047111008",
|
||||
"mem_1792144557208382006",
|
||||
"mem_1791402157932171759",
|
||||
"mem_1791899478077693848",
|
||||
"mem_1789411888019473186",
|
||||
"mem_1792088552825130331",
|
||||
"mem_1792088552817959556",
|
||||
"mem_1791912042293867384",
|
||||
"mem_1788657176712786530",
|
||||
"mem_1790424244350350337",
|
||||
"mem_1791740271337572997",
|
||||
"mem_1792088552818065436",
|
||||
"mem_1789132107404617336",
|
||||
"mem_1788340352324421455",
|
||||
"mem_1791431677583759030"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.911,
|
||||
0.755,
|
||||
0,
|
||||
0.99,
|
||||
0.695,
|
||||
0.63,
|
||||
0.565,
|
||||
0.99,
|
||||
0.644,
|
||||
0.865,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.755,
|
||||
0.99,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.675
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1791740271337572997"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"latency_ms": 664,
|
||||
"top_ids": [
|
||||
"mem_1790424365997011289",
|
||||
"mem_1792126499467223243",
|
||||
"mem_1788602693814224919",
|
||||
"mem_1791725846574107784",
|
||||
"mem_1789222925018439889",
|
||||
"mem_1791666995981185275",
|
||||
"mem_1792088552818471375",
|
||||
"mem_1791686097363885894",
|
||||
"mem_1789554588799449124",
|
||||
"mem_1791420422726554280",
|
||||
"mem_1791686088996261165",
|
||||
"mem_1789760781378060592",
|
||||
"mem_1790427668905832166",
|
||||
"mem_1791111607517908194",
|
||||
"mem_1791818212646773755",
|
||||
"mem_1790051973384869549",
|
||||
"mem_1790564623527240562",
|
||||
"mem_1789992010025213786",
|
||||
"mem_1792088552819583406",
|
||||
"mem_1791685772880116219"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.99,
|
||||
0.5,
|
||||
0.285,
|
||||
0,
|
||||
0.755,
|
||||
0,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.914629,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.63,
|
||||
0.755,
|
||||
0.755,
|
||||
0.99,
|
||||
0.5,
|
||||
0
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"at": "20261016-183633",
|
||||
"tag": "PRE",
|
||||
"base": "http://127.0.0.1:7891",
|
||||
"keyword_pass": 14,
|
||||
"keyword_fail": 0,
|
||||
"keyword_total": 14,
|
||||
"recall_at_5": 0.42857142857142855,
|
||||
"mrr": 0.2380952380952381,
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"at": "20261016-183639",
|
||||
"tag": "PRE",
|
||||
"base": "http://127.0.0.1:7891",
|
||||
"top_k": 20,
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"latency_ms": 670,
|
||||
"top_ids": [
|
||||
"mem_1792088552818864597",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1792144084726385461",
|
||||
"mem_1790349546889496890",
|
||||
"mem_1787450890745924038",
|
||||
"mem_1790448247125520424",
|
||||
"mem_1789063252280657787",
|
||||
"mem_1792088552817383356",
|
||||
"ep_1786293372952742428",
|
||||
"mem_1792144084989342595",
|
||||
"mem_1789815923678472264",
|
||||
"mem_1788006341389931001",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791556228356038757",
|
||||
"mem_1789230281180128695",
|
||||
"mem_1792088552824873585",
|
||||
"mem_1792088552817930263",
|
||||
"mem_1790424260127845812",
|
||||
"mem_1789737493969591957",
|
||||
"mem_1787926246493058856"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.5,
|
||||
0.59,
|
||||
0.59,
|
||||
0.755,
|
||||
0.255,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0,
|
||||
0.59,
|
||||
0.83,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.753,
|
||||
0.755,
|
||||
0.775
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"latency_ms": 699,
|
||||
"top_ids": [
|
||||
"mem_1792144084726385461",
|
||||
"mem_1792088552818354175",
|
||||
"mem_1792088552818868548",
|
||||
"mem_1792088552818381759",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1790938090098284542",
|
||||
"mem_1790938089297531705",
|
||||
"mem_1790603669933523968",
|
||||
"mem_1792088552818672993",
|
||||
"mem_1790425189400296970",
|
||||
"mem_1791564884666490485",
|
||||
"mem_1790848976254029727",
|
||||
"mem_1792088552819307750",
|
||||
"mem_1790657384251944657",
|
||||
"mem_1789328667925309581",
|
||||
"mem_1792116219795602288",
|
||||
"mem_1790425213354467843",
|
||||
"mem_1791395173327211388",
|
||||
"mem_1792141689103478828",
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.83,
|
||||
0.83,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.83,
|
||||
0.63,
|
||||
0.755,
|
||||
0.675,
|
||||
0.525,
|
||||
0.99
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1792088552819293505"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"latency_ms": 682,
|
||||
"top_ids": [
|
||||
"mem_1790657711315786810",
|
||||
"mem_1790425023223201421",
|
||||
"mem_1792088552819227083",
|
||||
"mem_1792088552817389514",
|
||||
"mem_1791194371133842796",
|
||||
"mem_1790565541250175254",
|
||||
"mem_1791387362920806120",
|
||||
"mem_1791899910885939521",
|
||||
"mem_1789694252600441660",
|
||||
"mem_1791665519266094329",
|
||||
"mem_1790309303666562764",
|
||||
"mem_1790581054400784704",
|
||||
"mem_1792088552818016699",
|
||||
"mem_1792088552818037737",
|
||||
"mem_1790590528061314469",
|
||||
"mem_1790187043969918230",
|
||||
"mem_1790448247323881504",
|
||||
"mem_1790329285912241710",
|
||||
"mem_1792088552817184125",
|
||||
"mem_1789327284892773149"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.853,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.765,
|
||||
0.59,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"latency_ms": 675,
|
||||
"top_ids": [
|
||||
"mem_1792144084989342595",
|
||||
"mem_1792107514588524675",
|
||||
"mem_1791567307185364992",
|
||||
"mem_1790051867711758989",
|
||||
"mem_1789983973135821157",
|
||||
"mem_1792088552816455607",
|
||||
"mem_1791380468697720355",
|
||||
"mem_1792088552817215072",
|
||||
"mem_1790658098063956127",
|
||||
"mem_1792088552825118896",
|
||||
"mem_1790656401441522455",
|
||||
"mem_1789755524758238574",
|
||||
"mem_1789741519238314161",
|
||||
"mem_1789736468534024216",
|
||||
"mem_1789731934167097953",
|
||||
"mem_1791916787313401423",
|
||||
"mem_1787336595731500272",
|
||||
"mem_1791794756620086535",
|
||||
"mem_1791811143462384371",
|
||||
"mem_1791735819718797684"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.63,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.78,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.798,
|
||||
0.395,
|
||||
0.695,
|
||||
0.63,
|
||||
0.315,
|
||||
0.6
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"latency_ms": 583,
|
||||
"top_ids": [
|
||||
"mem_1791365211038188118",
|
||||
"mem_1792088552817459951",
|
||||
"mem_1789155186005621317",
|
||||
"mem_1790441160106001927",
|
||||
"mem_1791736002933120377",
|
||||
"mem_1792118701572499829",
|
||||
"mem_1792088552818645685",
|
||||
"mem_1791915848530338805",
|
||||
"mem_1791383333319512288",
|
||||
"mem_1792088552818535755",
|
||||
"mem_1790187044046223493",
|
||||
"mem_1792088552824837822",
|
||||
"mem_1792088552817461960",
|
||||
"mem_1791423103974079882",
|
||||
"mem_1792088552825338800"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.39,
|
||||
0.63,
|
||||
0.5,
|
||||
0.5,
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.6,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"latency_ms": 737,
|
||||
"top_ids": [
|
||||
"mem_1788341686813816985",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791633893565216844",
|
||||
"mem_1790455525438011532",
|
||||
"mem_1792083942223793654",
|
||||
"mem_1792088770119950733",
|
||||
"mem_1790805789157124891",
|
||||
"mem_1792107513668237224",
|
||||
"mem_1789431180512315186",
|
||||
"mem_1790425238816494139",
|
||||
"mem_1790424256264891954",
|
||||
"mem_1787926246433496550",
|
||||
"mem_1790425017614420145",
|
||||
"mem_1790581068974704280",
|
||||
"mem_1789327284892773149",
|
||||
"mem_1790226520252986178",
|
||||
"mem_1790269130144600786",
|
||||
"mem_1791819411153164256",
|
||||
"mem_1789827897771148953",
|
||||
"mem_1788166192802637740"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.63,
|
||||
0.755,
|
||||
0.63,
|
||||
0.515,
|
||||
0.755,
|
||||
0.755,
|
||||
0.775,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"latency_ms": 710,
|
||||
"top_ids": [
|
||||
"mem_1790345721671326922",
|
||||
"mem_1790350480105660984",
|
||||
"mem_1790425022831993728",
|
||||
"mem_1791118583544396904",
|
||||
"mem_1791533226241026355",
|
||||
"mem_1790455570122981543",
|
||||
"mem_1791445601992763027",
|
||||
"mem_1790496848751501955",
|
||||
"mem_1789273123503223085",
|
||||
"mem_1791666641349257940",
|
||||
"mem_1791666631693960928",
|
||||
"mem_1792088552825510526",
|
||||
"mem_1791666639652621391",
|
||||
"mem_1790265788637717626",
|
||||
"mem_1790428068416795396",
|
||||
"mem_1791118583617248844",
|
||||
"mem_1791666711653684211",
|
||||
"mem_1789557079608816710",
|
||||
"mem_1789327693618007907",
|
||||
"mem_1789273123339483168"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.803,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.803,
|
||||
0,
|
||||
0.83,
|
||||
0.755,
|
||||
0,
|
||||
0.789,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.805,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"latency_ms": 718,
|
||||
"top_ids": [
|
||||
"mem_1791556229473565240",
|
||||
"mem_1792084245285474959",
|
||||
"mem_1792088552818289574",
|
||||
"mem_1790180705032672204",
|
||||
"mem_1792100307763093595",
|
||||
"mem_1791665516356975992",
|
||||
"mem_1792084245095017393",
|
||||
"mem_1792082720405068478",
|
||||
"mem_1791440639711180469",
|
||||
"mem_1792114886051333715",
|
||||
"mem_1792088552825127031",
|
||||
"mem_1791642436962837771",
|
||||
"mem_1792088552819438066",
|
||||
"mem_1792088552816808997",
|
||||
"mem_1790187091203506519",
|
||||
"mem_1792126203805297968",
|
||||
"mem_1792088552819539001",
|
||||
"mem_1791904832104972267",
|
||||
"mem_1792088552818662517",
|
||||
"mem_1792088552824945417"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.755,
|
||||
0.63,
|
||||
0.8,
|
||||
0.63,
|
||||
0.63,
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.65,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.395,
|
||||
0.5,
|
||||
0.43,
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"latency_ms": 732,
|
||||
"top_ids": [
|
||||
"mem_1788623666023332134",
|
||||
"mem_1789793765940732350",
|
||||
"mem_1791479003228249623",
|
||||
"mem_1789149704491770048",
|
||||
"mem_1789154058288011112",
|
||||
"mem_1791818814047111008",
|
||||
"mem_1792144557208382006",
|
||||
"mem_1791402157932171759",
|
||||
"mem_1791899478077693848",
|
||||
"mem_1789411888019473186",
|
||||
"mem_1792088552825130331",
|
||||
"mem_1792088552817959556",
|
||||
"mem_1791912042293867384",
|
||||
"mem_1788657176712786530",
|
||||
"mem_1790424244350350337",
|
||||
"mem_1791740271337572997",
|
||||
"mem_1792088552818065436",
|
||||
"mem_1789132107404617336",
|
||||
"mem_1788340352324421455",
|
||||
"mem_1791431677583759030"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.911,
|
||||
0.755,
|
||||
0,
|
||||
0.99,
|
||||
0.695,
|
||||
0.63,
|
||||
0.565,
|
||||
0.99,
|
||||
0.644,
|
||||
0.865,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.755,
|
||||
0.99,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.675
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1791740271337572997"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"latency_ms": 694,
|
||||
"top_ids": [
|
||||
"mem_1790424365997011289",
|
||||
"mem_1792126499467223243",
|
||||
"mem_1788602693814224919",
|
||||
"mem_1791725846574107784",
|
||||
"mem_1789222925018439889",
|
||||
"mem_1791666995981185275",
|
||||
"mem_1792088552818471375",
|
||||
"mem_1791686097363885894",
|
||||
"mem_1789554588799449124",
|
||||
"mem_1791420422726554280",
|
||||
"mem_1791686088996261165",
|
||||
"mem_1789760781378060592",
|
||||
"mem_1790427668905832166",
|
||||
"mem_1791111607517908194",
|
||||
"mem_1791818212646773755",
|
||||
"mem_1790051973384869549",
|
||||
"mem_1790564623527240562",
|
||||
"mem_1789992010025213786",
|
||||
"mem_1792088552819583406",
|
||||
"mem_1791685772880116219"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.99,
|
||||
0.5,
|
||||
0.285,
|
||||
0,
|
||||
0.755,
|
||||
0,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.914629,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.63,
|
||||
0.755,
|
||||
0.755,
|
||||
0.99,
|
||||
0.5,
|
||||
0
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"at": "20261016-191108",
|
||||
"tag": "RBK",
|
||||
"base": "http://127.0.0.1:7892",
|
||||
"keyword_pass": 14,
|
||||
"keyword_fail": 0,
|
||||
"keyword_total": 14,
|
||||
"recall_at_5": 0.42857142857142855,
|
||||
"mrr": 0.2380952380952381,
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 0
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"keyword_ok": true,
|
||||
"id_recall": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
{
|
||||
"at": "20261016-191116",
|
||||
"tag": "RBK",
|
||||
"base": "http://127.0.0.1:7892",
|
||||
"top_k": 20,
|
||||
"rows": [
|
||||
{
|
||||
"query": "牧尘工作所在的县是哪个",
|
||||
"latency_ms": 836,
|
||||
"top_ids": [
|
||||
"mem_1792088552818864597",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1792144084726385461",
|
||||
"mem_1790349546889496890",
|
||||
"mem_1787450890745924038",
|
||||
"mem_1790448247125520424",
|
||||
"mem_1789063252280657787",
|
||||
"mem_1792088552817383356",
|
||||
"ep_1786293372952742428",
|
||||
"mem_1792144084989342595",
|
||||
"mem_1789815923678472264",
|
||||
"mem_1788006341389931001",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791556228356038757",
|
||||
"mem_1789230281180128695",
|
||||
"mem_1792088552824873585",
|
||||
"mem_1792088552817930263",
|
||||
"mem_1790424260127845812",
|
||||
"mem_1789737493969591957",
|
||||
"mem_1787926246493058856"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.5,
|
||||
0.59,
|
||||
0.59,
|
||||
0.755,
|
||||
0.255,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0,
|
||||
0.59,
|
||||
0.83,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.753,
|
||||
0.755,
|
||||
0.775
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "淇县",
|
||||
"latency_ms": 789,
|
||||
"top_ids": [
|
||||
"mem_1792144084726385461",
|
||||
"mem_1792088552818354175",
|
||||
"mem_1792088552818868548",
|
||||
"mem_1792088552818381759",
|
||||
"mem_1792144084503554069",
|
||||
"mem_1790938090098284542",
|
||||
"mem_1790938089297531705",
|
||||
"mem_1790603669933523968",
|
||||
"mem_1792088552818672993",
|
||||
"mem_1790425189400296970",
|
||||
"mem_1791564884666490485",
|
||||
"mem_1790848976254029727",
|
||||
"mem_1792088552819307750",
|
||||
"mem_1790657384251944657",
|
||||
"mem_1789328667925309581",
|
||||
"mem_1792116219795602288",
|
||||
"mem_1790425213354467843",
|
||||
"mem_1791395173327211388",
|
||||
"mem_1792141689103478828",
|
||||
"mem_1792088552819293505"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.5,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.83,
|
||||
0.83,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.83,
|
||||
0.63,
|
||||
0.755,
|
||||
0.675,
|
||||
0.525,
|
||||
0.99
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1792088552819293505"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "招商项目台账",
|
||||
"latency_ms": 730,
|
||||
"top_ids": [
|
||||
"mem_1790657711315786810",
|
||||
"mem_1790425023223201421",
|
||||
"mem_1792088552819227083",
|
||||
"mem_1792088552817389514",
|
||||
"mem_1791194371133842796",
|
||||
"mem_1790565541250175254",
|
||||
"mem_1791387362920806120",
|
||||
"mem_1791899910885939521",
|
||||
"mem_1789694252600441660",
|
||||
"mem_1791665519266094329",
|
||||
"mem_1790309303666562764",
|
||||
"mem_1790581054400784704",
|
||||
"mem_1792088552818016699",
|
||||
"mem_1792088552818037737",
|
||||
"mem_1790590528061314469",
|
||||
"mem_1790187043969918230",
|
||||
"mem_1790448247323881504",
|
||||
"mem_1790329285912241710",
|
||||
"mem_1792088552817184125",
|
||||
"mem_1789327284892773149"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.853,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.765,
|
||||
0.59,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "生物制造产业专班",
|
||||
"latency_ms": 753,
|
||||
"top_ids": [
|
||||
"mem_1792144084989342595",
|
||||
"mem_1792107514588524675",
|
||||
"mem_1791567307185364992",
|
||||
"mem_1790051867711758989",
|
||||
"mem_1789983973135821157",
|
||||
"mem_1792088552816455607",
|
||||
"mem_1791380468697720355",
|
||||
"mem_1792088552817215072",
|
||||
"mem_1790658098063956127",
|
||||
"mem_1792088552825118896",
|
||||
"mem_1790656401441522455",
|
||||
"mem_1789755524758238574",
|
||||
"mem_1789741519238314161",
|
||||
"mem_1789736468534024216",
|
||||
"mem_1789731934167097953",
|
||||
"mem_1791916787313401423",
|
||||
"mem_1787336595731500272",
|
||||
"mem_1791794756620086535",
|
||||
"mem_1791811143462384371",
|
||||
"mem_1791735819718797684"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.59,
|
||||
0.63,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.5,
|
||||
0.78,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.798,
|
||||
0.395,
|
||||
0.695,
|
||||
0.63,
|
||||
0.315,
|
||||
0.6
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "小怡的工作笔记在哪",
|
||||
"latency_ms": 653,
|
||||
"top_ids": [
|
||||
"mem_1791365211038188118",
|
||||
"mem_1792088552817459951",
|
||||
"mem_1789155186005621317",
|
||||
"mem_1790441160106001927",
|
||||
"mem_1791736002933120377",
|
||||
"mem_1792118701572499829",
|
||||
"mem_1792088552818645685",
|
||||
"mem_1791915848530338805",
|
||||
"mem_1791383333319512288",
|
||||
"mem_1792088552818535755",
|
||||
"mem_1790187044046223493",
|
||||
"mem_1792088552824837822",
|
||||
"mem_1792088552817461960",
|
||||
"mem_1791423103974079882",
|
||||
"mem_1792088552825338800"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.39,
|
||||
0.63,
|
||||
0.5,
|
||||
0.5,
|
||||
0.8,
|
||||
0.5,
|
||||
0.755,
|
||||
0.5,
|
||||
0.5,
|
||||
0.6,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "昨天做了什么",
|
||||
"latency_ms": 739,
|
||||
"top_ids": [
|
||||
"mem_1788341686813816985",
|
||||
"mem_1790425119425241515",
|
||||
"mem_1791633893565216844",
|
||||
"mem_1790455525438011532",
|
||||
"mem_1792083942223793654",
|
||||
"mem_1792088770119950733",
|
||||
"mem_1790805789157124891",
|
||||
"mem_1792107513668237224",
|
||||
"mem_1789431180512315186",
|
||||
"mem_1790425238816494139",
|
||||
"mem_1790424256264891954",
|
||||
"mem_1787926246433496550",
|
||||
"mem_1790425017614420145",
|
||||
"mem_1790581068974704280",
|
||||
"mem_1789327284892773149",
|
||||
"mem_1790226520252986178",
|
||||
"mem_1790269130144600786",
|
||||
"mem_1791819411153164256",
|
||||
"mem_1789827897771148953",
|
||||
"mem_1788166192802637740"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.63,
|
||||
0.755,
|
||||
0.63,
|
||||
0.515,
|
||||
0.755,
|
||||
0.755,
|
||||
0.775,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.755,
|
||||
0.395,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "飞书怎么发语音",
|
||||
"latency_ms": 810,
|
||||
"top_ids": [
|
||||
"mem_1790345721671326922",
|
||||
"mem_1790350480105660984",
|
||||
"mem_1790425022831993728",
|
||||
"mem_1791118583544396904",
|
||||
"mem_1791533226241026355",
|
||||
"mem_1790455570122981543",
|
||||
"mem_1791445601992763027",
|
||||
"mem_1790496848751501955",
|
||||
"mem_1789273123503223085",
|
||||
"mem_1791666641349257940",
|
||||
"mem_1791666631693960928",
|
||||
"mem_1792088552825510526",
|
||||
"mem_1791666639652621391",
|
||||
"mem_1790265788637717626",
|
||||
"mem_1790428068416795396",
|
||||
"mem_1791118583617248844",
|
||||
"mem_1791666711653684211",
|
||||
"mem_1789557079608816710",
|
||||
"mem_1789327693618007907",
|
||||
"mem_1789273123339483168"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.803,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.803,
|
||||
0,
|
||||
0.83,
|
||||
0.755,
|
||||
0,
|
||||
0.789,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0,
|
||||
0.805,
|
||||
0.755,
|
||||
0.755
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "家服怎么连",
|
||||
"latency_ms": 787,
|
||||
"top_ids": [
|
||||
"mem_1791556229473565240",
|
||||
"mem_1792084245285474959",
|
||||
"mem_1792088552818289574",
|
||||
"mem_1790180705032672204",
|
||||
"mem_1792100307763093595",
|
||||
"mem_1791665516356975992",
|
||||
"mem_1792084245095017393",
|
||||
"mem_1792082720405068478",
|
||||
"mem_1791440639711180469",
|
||||
"mem_1792114886051333715",
|
||||
"mem_1792088552825127031",
|
||||
"mem_1791642436962837771",
|
||||
"mem_1792088552819438066",
|
||||
"mem_1792088552816808997",
|
||||
"mem_1790187091203506519",
|
||||
"mem_1792126203805297968",
|
||||
"mem_1792088552819539001",
|
||||
"mem_1791904832104972267",
|
||||
"mem_1792088552818662517",
|
||||
"mem_1792088552824945417"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.755,
|
||||
0.63,
|
||||
0.8,
|
||||
0.63,
|
||||
0.63,
|
||||
0,
|
||||
0.63,
|
||||
0.5,
|
||||
0.65,
|
||||
0.5,
|
||||
0.5,
|
||||
0.755,
|
||||
0.395,
|
||||
0.5,
|
||||
0.43,
|
||||
0.5,
|
||||
0.5
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
},
|
||||
{
|
||||
"query": "织忆怎么部署",
|
||||
"latency_ms": 936,
|
||||
"top_ids": [
|
||||
"mem_1788623666023332134",
|
||||
"mem_1789793765940732350",
|
||||
"mem_1791479003228249623",
|
||||
"mem_1789149704491770048",
|
||||
"mem_1789154058288011112",
|
||||
"mem_1791818814047111008",
|
||||
"mem_1792144557208382006",
|
||||
"mem_1791402157932171759",
|
||||
"mem_1791899478077693848",
|
||||
"mem_1789411888019473186",
|
||||
"mem_1792088552825130331",
|
||||
"mem_1792088552817959556",
|
||||
"mem_1791912042293867384",
|
||||
"mem_1788657176712786530",
|
||||
"mem_1790424244350350337",
|
||||
"mem_1791740271337572997",
|
||||
"mem_1792088552818065436",
|
||||
"mem_1789132107404617336",
|
||||
"mem_1788340352324421455",
|
||||
"mem_1791431677583759030"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.911,
|
||||
0.755,
|
||||
0,
|
||||
0.99,
|
||||
0.695,
|
||||
0.63,
|
||||
0.565,
|
||||
0.99,
|
||||
0.644,
|
||||
0.865,
|
||||
0.5,
|
||||
0.5,
|
||||
0.59,
|
||||
0.755,
|
||||
0.99,
|
||||
0,
|
||||
0.5,
|
||||
0.755,
|
||||
0.755,
|
||||
0.675
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 1,
|
||||
"v_pos_ids": [
|
||||
"mem_1791740271337572997"
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "记忆保护名单",
|
||||
"latency_ms": 968,
|
||||
"top_ids": [
|
||||
"mem_1790424365997011289",
|
||||
"mem_1792126499467223243",
|
||||
"mem_1788602693814224919",
|
||||
"mem_1791725846574107784",
|
||||
"mem_1789222925018439889",
|
||||
"mem_1791666995981185275",
|
||||
"mem_1792088552818471375",
|
||||
"mem_1791686097363885894",
|
||||
"mem_1789554588799449124",
|
||||
"mem_1791420422726554280",
|
||||
"mem_1791686088996261165",
|
||||
"mem_1789760781378060592",
|
||||
"mem_1790427668905832166",
|
||||
"mem_1791111607517908194",
|
||||
"mem_1791818212646773755",
|
||||
"mem_1790051973384869549",
|
||||
"mem_1790564623527240562",
|
||||
"mem_1789992010025213786",
|
||||
"mem_1792088552819583406",
|
||||
"mem_1791685772880116219"
|
||||
],
|
||||
"v_values": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"quality_scores": [
|
||||
0.99,
|
||||
0.5,
|
||||
0.285,
|
||||
0,
|
||||
0.755,
|
||||
0,
|
||||
0.5,
|
||||
0,
|
||||
0.755,
|
||||
0.914629,
|
||||
0,
|
||||
0.755,
|
||||
0.755,
|
||||
0.755,
|
||||
0.63,
|
||||
0.755,
|
||||
0.755,
|
||||
0.99,
|
||||
0.5,
|
||||
0
|
||||
],
|
||||
"raw_quality_scores": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"v_pos": 0,
|
||||
"v_pos_ids": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
# 交付说明 · t_5ad3f843【织忆·主症B 返修】元笔记分类器 + 咽喉点收口
|
||||
|
||||
- **卡号**:`t_5ad3f843` | **执行者**:prof-dev(2 次 run 均 `rc=1` 崩于"写交付说明"步骤)
|
||||
- **终审收口**:小怡(default)—— 逐条**亲跑**验证,不采信 worker 自报
|
||||
- **日期**:2026-10-16 | **commit**:`e85c9db`(代码)+ 本文件(证据)
|
||||
- **结论**:AC1–AC8 **全绿**;**生产未部署**(源码级交付)⇒ **可申请激活**(需另提卡 + 牧尘放行)
|
||||
|
||||
---
|
||||
|
||||
## ① 判据脚本原始输出(AC1 · exit 0)
|
||||
|
||||
```
|
||||
$ python3 ~/.hermes/scripts/verify-meta-filter.py
|
||||
正例 14 条 / 负例 11 条;不符 0 条
|
||||
|
||||
[PASS] 分类器达标
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
> 基线(返修前):exit=1,**10 条不符(7 漏拦 + 3 误拦)**。
|
||||
|
||||
## ② 逐条 before / after(AC7 要求)
|
||||
|
||||
### 病根一 · 漏拦(7 条):判据只认"写下来的那几种写法"
|
||||
|
||||
| 案例(生产原文) | before | after | 命中规则 |
|
||||
|---|---|---|---|
|
||||
| `实测 淇县 ANN 731→15、鹤壁 6536→18(都进池50)` | **放行 ✗** | 拦 ✓ | `pool_in+rank_num+num_arrow+pool` |
|
||||
| `实测 淇县 ANN 731→15(都进池50)` | **放行 ✗** | 拦 ✓ | `pool_in+rank_num+num_arrow+pool` |
|
||||
| `短问法 MISS 复现:牧尘工作所在的县是哪个` | 放行 ✗ | 拦 ✓ | `repro` |
|
||||
| `淇县库里 21 条、鹤壁 10 条、河南 15 条语料可用。` | 放行 ✗ | 拦 ✓ | `data_inv` |
|
||||
| `实测(只读,未改任何库/代码):目标「用户在招商局工作」在 query…` | 放行 ✗ | 拦 ✓ | `data_inv+pool+rank` |
|
||||
| `memory_write 落 episodes 表 + vector 全 0 —— 写入…` | 放行 ✗ | 拦 ✓ | `write_bug` |
|
||||
| `织忆 golden 14/14 不退化,recall@5 = 0.4286` | 放行 ✗ | 拦 ✓ | `eval_metric` |
|
||||
|
||||
**新增规则原文**(形态匹配,非加字面词 —— 满足 AC3):
|
||||
```
|
||||
_RANK_NUM = \bANN\s*\d+\s*→\s*\d+
|
||||
_NUM_ARROW = \d+\s*→\s*\d+
|
||||
_POOL_IN = 进\s*池\s*\d+
|
||||
_REPRO = (MISS\s*复现|复现[::((]|复现\s*(步骤|方法|过程|流程|记录)\s*[::((])
|
||||
_DATA_INV = (语料盘点:N 条…可用)
|
||||
_EVAL_METRIC= (golden \d+/\d+|recall@\d+)
|
||||
_DEBUG_MARK = (\bDEBUG\b|探针|自报|脚本输出)
|
||||
_WRITE_BUG = (写入类故障记录)
|
||||
```
|
||||
|
||||
### 病根二 · 误拦(3 条):把"关于系统的事实"当调查记录拦了
|
||||
|
||||
| 案例(事实句,金标同族题面) | before | after | 理由 |
|
||||
|---|---|---|---|
|
||||
| `织忆召回接口 POST /api/v1/recall 接受 top_k 参数。` | **拦 ✗** | 放 ✓ | `_FACT_DECL` 事实声明形豁免 |
|
||||
| `召回缓存 recall-cache 每 6 小时刷新一次。` | **拦 ✗** | 放 ✓ | `_FACT_DECL`(周期形) |
|
||||
| `织忆主服务进程名是 zhiyid-new,合并服务是 zhiyi-consolidate。` | **拦 ✗** | 放 ✓ | `_FACT_DECL`(进程名形) |
|
||||
|
||||
**AC4 区分判据**:`svc_core` 由 STRONG 降级为 **cue 计数**;通用功能词(召回|蒸馏)各计 1 个 cue,需叠加才判;**事实声明形**(`_FACT_DECL`:接受X参数/监听N端口/每N小时…一次/进程名是/地址是/模型是/字体/行距)⇒ **判事实放行**;带 实测/复现/诊断/命中/命名空间/限流/退避/碎片化/FALLBACK 等概念词的调查记录**不受豁免** ⇒ 既放行 3 条误拦,又**不漏原有 16 条自检正例**(两套用例同时绿)。
|
||||
|
||||
### 负例 11 条全放行(`[放✓]`)
|
||||
牧尘在河南省鹤壁市淇县工作。· 淇县招商项目台账(09-27阅读版).md 已发布。· 织忆服务的健康检查接口监听 7821 端口。· 织忆召回接口 POST /api/v1/recall 接受 top_k 参数。· 召回缓存 recall-cache 每 6 小时刷新一次。· 织忆主服务进程名是 zhiyid-new,合并服务是 zhiyi-consolidate · 公文排版正文用仿宋_GB2312 字体,行距 28 磅。· 家庭服务器(跑 Gitea)在局域网里的地址是 192.168.123.11。· 本地 2B 模型服务监听 8080 端口。· 牧尘负责生物制造产业专班 + 卫都街道安全管理。· 小怡日常主链模型是 deepseek-v4-flash(官方直连)。
|
||||
|
||||
## ③ AC8 咽喉点收口(本卡新增,最关键)
|
||||
|
||||
**问题**:B 卡只接了 3 条管线,而污染样本 **5/6 来自未覆盖路径**(27 个写者只有 3 个被覆盖)。
|
||||
|
||||
**修法**:接在**唯一共享入口**上 —— 所有 27 个写者都走 `POST /api/v1/commit`:
|
||||
|
||||
```go
|
||||
// go/internal/api/routes/core.go:108(/commit 处理器,AutoDistillTrigger 之前)
|
||||
if skip, reason := metanote.ShouldSkip(req.Content); skip {
|
||||
log.Printf("[metanote] commit rejected (gate ON, meta-note): ...")
|
||||
respondError(w, 422, "meta-note rejected: "+reason)
|
||||
return // ← 真拒写:不落 episode、不进蒸馏
|
||||
}
|
||||
// core.go:322(write_memory 处理器)同样接线
|
||||
```
|
||||
⇒ 覆盖 **27/27 写者**(对比修前 3/27)。
|
||||
|
||||
**Python↔Go 同源锁死**:`internal/metanote`(patterns.go / classify.go)与 `~/.hermes/scripts/lib/meta_note_filter.py` 口径同源,由 parity + 差分测试锁死:
|
||||
- `TestParity_PythonLive`(Live parity,0.03s)
|
||||
- `TestParity_MutatedCopyFails` —— **变异必 FAIL**(负控,0.03s)
|
||||
- `TestDifferential_PythonReference` / `TestDifferential_ExpectedSpotChecks`
|
||||
|
||||
## ④ 小怡终审亲跑证据(非自报)
|
||||
|
||||
| 项 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 构建 | `go build ./...` | **exit 0** |
|
||||
| 测试 | `go test ./internal/metanote/` | **ok 0.130s**(8 项含 parity/mutation) |
|
||||
| 判据 | `verify-meta-filter.py` | **exit 0**(正14/负11/不符0) |
|
||||
| 判据未被改(AC2) | `verify-meta-filter.py` mtime **20:08:22** 早于 worker 首启 **20:09:36** | ✓ 正/负例逐字未动 |
|
||||
| 门默认关(AC6) | gateway env / zhiyid env | **各 0 处** `ZHIYI_META_FILTER` |
|
||||
| **未部署** | 生产二进制 mtime `19:02:50` **早于** 提交 `21:59:28`;md5 `95286f63789c7ecd` 未变;`strings` 内 `metanote` **0 处** | ✓ 源码级 |
|
||||
| 零行为变化 | `classify.go:91 ShouldSkip` → `if !GateEnabled() { return false, "" }` | ✓ 门关即不参与 |
|
||||
|
||||
## ⑤ 改动文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `go/internal/metanote/patterns.go` | 新增(规则表,Python 口径同源) |
|
||||
| `go/internal/metanote/classify.go` | 新增(分类 + `GateEnabled`/`ShouldSkip`) |
|
||||
| `go/internal/metanote/metanote_parity_test.go` | 新增(parity) |
|
||||
| `go/internal/metanote/metanote_live_test.go` | 新增(Live parity + 变异负控) |
|
||||
| `go/internal/metanote/metanote_diff_test.go` | 新增(差分对照) |
|
||||
| `go/internal/api/routes/core.go` | 咽喉点接线(:108 / :322) |
|
||||
| `~/.hermes/scripts/lib/meta_note_filter.py` | 分类器 193 → 326 行(仓库外,`.hermes` 侧) |
|
||||
|
||||
## ⑥ 激活状态
|
||||
|
||||
**可申请激活**(分类器绿 + 咽喉点覆盖 27/27 + parity/变异测试在 + 默认关 + 未部署)。
|
||||
**但激活不在本卡范围**:需 ① 另提卡 ② 重建二进制并部署 ③ 牧尘放行(`prod-write-gate.py` 会拦,别绕)。
|
||||
|
||||
## ⑦ 遗留(诚实记录)
|
||||
|
||||
1. **两次 run 均 `rc=1` 崩于"写交付说明"步骤**,日志**无任何错误行**(裸退);代码工作在两轮内已完成并提交 ⇒ 未放行第三次盲跑。**真因未定**(疑与同 profile 并发 worker / 会话结束路径有关,待查)。
|
||||
2. **AC8.1 覆盖表**:本文件 ③ 已给出结构性论证(单一入口 ⇒ 27/27),worker 原计划的逐写者分布表未落盘(随交付说明一起丢)。
|
||||
@@ -0,0 +1,147 @@
|
||||
# 【织忆·门控复核】PATTERN/PEER 影子 diff 量化 — 对照表与原始证据
|
||||
|
||||
卡: t_bd63b641 · 执行人: prof-dev · 2026-10-17 21:0x CST
|
||||
方法: 不碰生产 env/二进制/服务(AC 硬边界)。开态用「独立离线评测」——
|
||||
真实 PatternProvider/PeerAPI + 生产 graph.db(只读) + 实时 bge embedder(:8000),
|
||||
等价于活服务开态注入路径;关态取活服务(:7821, PID 812251)实测。
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
## 结论
|
||||
关态 vs 开态 diff = **空(当前态)**,且开态即使修 bug 也**无净收益/伤主路径**。
|
||||
两个通道本卡判定 **「不开闸」**,如实回卡。理由:
|
||||
- PEER: 无 peers 表(0 行),开态物理零注入,无产出。
|
||||
- PATTERN: 当前 NULL-id bug 使 `ListPatterns` 恒报错 → 零注入;**即使修好 bug**,
|
||||
85 行全是「图聚类摘要节点」(无 data/tools 内容,只有聚类键名),对金标余弦
|
||||
0.675–0.715(≥0.6 阈值)会注入 ≤3 条**无召回价值的聚类键噪声/金标**,外加每
|
||||
query ~85 次 embed(冷 ~1.4s)拉高 P95 + 稀释 top-5 ⇒ 伤主路径、无净收益。
|
||||
- 原始阻塞理由(上游未落地)小怡已复核失效(t_3e98b65a/t_fcadee04 done),
|
||||
但「上游 done ≠ 该开」—— 实测证明开了也净负收益(比图谱路「接线已证、收益未证」
|
||||
更明确: PATTERN 有 NULL-id 读库 bug + 数据源本身不是行为 pattern)。
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
## 一、门控现状(关态坐实,双源独立)
|
||||
- systemd unit 权威配置: `systemctl --user cat zhiyid | grep ZHIYI_` → 无
|
||||
ZHIYI_PATTERN_RECALL / 无 ZHIYI_PEER_RECALL
|
||||
- 运行进程 env: `/proc/812251/environ` → 无此二键(与 prof-cloud 异源复核一致)
|
||||
- 结论: 两通道 env 均「未设/≠1」⇒ server.go 启动期 `if os.Getenv(...)=="1"` 不成立
|
||||
⇒ `storage.PatternRecallProvider` / `storage.PeerRecallProvider` 均未注册 ⇒ 关态
|
||||
|
||||
## 二、影子 diff(关态 vs 开态,逐条对照)
|
||||
固定 query 集: 14 金标(golden-set.json)+ 69 条真实 content-recall(usage_recall 表)
|
||||
|
||||
### PATTERN 通道(ZHIYI_PATTERN_RECALL)
|
||||
开态注入结果: 当前 **0 条 / 14 金标**(`shadow-on-pattern-inject.json` = 空 `{}`)
|
||||
根因(不是「没命中」而是「读不出来」):
|
||||
- 生产 graph_nodes(type='pattern') 共 85 行,**id 列 85/85 全为 NULL**
|
||||
(`sqlite3: sum(id IS NULL)=85`)
|
||||
- `PatternProvider.Match` → `GraphPatternStore.ListPatterns` 读 `SELECT id,...`
|
||||
为 string,遇 NULL 恒报 `sql: Scan error: converting NULL to string`
|
||||
(影子测试实测抓到原文: `PATTERN_LISTERROR="sql: Scan error on column index 0,
|
||||
name \"id\": converting NULL to string is unsupported"`,`PATTERNS_LOADED=0`)
|
||||
- ⇒ `Match` 直接 return nil ⇒ **当前开态无任何 pattern 注入**
|
||||
|
||||
深层(修 bug 后会怎样 —— 已量化,证明「修了也不该开」):
|
||||
- 这 85 行 properties **0 行有 `data` 键、0 行有 `tools` 键**(只有 20 行有
|
||||
`description`),全是「图聚类摘要节点」(如 `hermes-main/entity 共 3867 节点…`),
|
||||
**不是 L2 行为 pattern**。`Match` 里 `p.Data` 恒空 → 回退 `p.Name`(聚类键,
|
||||
如 `topic:memory` / `pattern:default-main:entity`)。
|
||||
- 余弦探针(绕过 NULL-id 直接 embed 85 个 pattern 名 + 金标,bge-m3:8000 实测):
|
||||
5 条金标 maxcos = 0.6752 / 0.6891 / 0.6975 / 0.7038 / 0.7148,**全 ≥ 0.6 阈值
|
||||
⇒ 若修好 NULL-id,开态会对每条金标注入 ≤3 条聚类键条目**(`offset-cosine-probe.txt`)。
|
||||
- 注入内容 = 裸聚类键名(`topic:memory`、`pattern:learner-main:concept`),
|
||||
**对「织忆是什么」这类 query 无召回价值,只会稀释 top-5**。
|
||||
- 额外成本: 每 query 需 embed 85 个 pattern 名(冷调用 ~85×16.5ms≈1.4s,
|
||||
探针 430 embed/7.12s 实测)⇒ **P95 主路径显著上升**(OFF p95=3177ms → 开态
|
||||
叠加 ~1.4s)。
|
||||
- 判读: 0.6 阈值对 bge-m3 余弦分布偏低(无关文本基线 0.67+),开态注入的多是
|
||||
**假阳性噪声**。
|
||||
- ⇒ 修 bug 后开态 = 「伤主路径 + 无净收益」,仍 **不开**。
|
||||
|
||||
### PEER 通道(ZHIYI_PEER_RECALL)
|
||||
开态注入结果: **0 条 / 14 金标**
|
||||
根因: 生产 graph.db **无 peers 表**(`SELECT count(*) FROM sqlite_master WHERE
|
||||
name='peers'` = 0)。`PeerAPI.GetRecallResults` → `Store.List(200)` → 0 行 →
|
||||
return nil(peer.go:305-308 源码)。⇒ **开态也无任何 peer 注入**。
|
||||
|
||||
### 对照表
|
||||
| 维度 | 关态(活服务) | 开态(影子/离线) | diff |
|
||||
|---|---|---|---|
|
||||
| PATTERN 注入条数 | 0(未注册) | 当前 0(NULL-id bug);修 bug 后 ≤3 聚类键/金标 | **0(当前)/ 噪声(修后)** |
|
||||
| PEER 注入条数 | 0(未注册) | 0(无 peers 表) | **0** |
|
||||
| 召回结果 top5 id | 见 off-recall-14golden-live.json | 当前与关态逐条相同(无注入项可加) | **空(当前)** |
|
||||
| /eval/golden keyword_pass | 14/14 | =关态(当前注入 0 不动);修后注入聚类键不改关键词判据 | **0(当前)** |
|
||||
| /eval/golden recall@5 | 0.643 | =关态 | **0(当前)** |
|
||||
| 开态额外成本 | — | 修 bug 后每 query ~85 embed(冷 ~1.4s)+ top-5 稀释 | 伤主路径 |
|
||||
| 新增条目位置 | — | 追加在结果尾部(recall.go:901-932 append 到 deduped 之后) | — |
|
||||
|
||||
## 三、延迟 P50/P95(阈值从实测读,非拍脑袋)
|
||||
关态(活服务 :7821):
|
||||
- 69 真实 query(本卡 AC 要求的「≥50 query」集): **p50=1383.7ms, p95=3177.2ms**
|
||||
(`off-69-queries-lat-ms.json`,一致单次 run 采集)
|
||||
- 14 金标: 空载热态 p50=949.9/p95=2288.5ms;我在并发 69-query 压测后重跑
|
||||
p50=3936.9/p95=5255.5ms(`off-recall-14golden-live.json` = 并发态)——
|
||||
**绝对值随负载/缓存/8081 LLM 队列代理波动**(非本卡判据,不追服务性能)。
|
||||
开态:
|
||||
- **当前态(NULL-id bug 在)**: 注入 0 条,代码块 `if patternRecallEnabled() &&
|
||||
Provider!=nil` 即便走进去也只 `for _, pr := range nil` ⇒ **开态延迟 = 关态延迟
|
||||
(逐条相同,无新增 embed)**。
|
||||
- **修 bug 后态**: 每 query 额外 embed 85 个 pattern 名(探针实测 430 embed/7.12s
|
||||
≈16.5ms/embed,冷调用 ~1.4s),追加在结果尾部 ⇒ **P95 主路径上叠加 ~1.4s 冷成本
|
||||
(冷 ≈ 关态 p95 翻倍量级)** ⇒ 伤主路径(超 AC 判据「P95 涨幅>20%」)。
|
||||
|
||||
判读: 延迟上「开 vs 关」的**决策相关量 = 修 bug 后每次召回 +~1.4s 冷成本**;
|
||||
当前态(bug 在)开=关=0 差异。绝对基线随负载波动,不作为开闸判据。
|
||||
|
||||
## 四、AC#2: 开门是否需重启才注册 Provider?
|
||||
**答案: 是,必须重启。**
|
||||
- 源码证据: `go/internal/api/server.go:189-202` ——
|
||||
`if os.Getenv("ZHIYI_PATTERN_RECALL")=="1" { storage.PatternRecallProvider =
|
||||
patternProvider.Match }` / `if os.Getenv("ZHIYI_PEER_RECALL")=="1"
|
||||
{ storage.PeerRecallProvider = peerAPI.GetRecallResults }`,均在 `NewServer()`
|
||||
启动期执行;`storage.PatternRecallProvider` 是包级变量,仅在这两处赋值。
|
||||
改运行中 env(不重启)无法注册 Provider。
|
||||
- 实测证据: PID 812251 运行二进制 mtime=2026-10-17 01:18,其 /proc environ 无
|
||||
此二键,当前 Provider 未注册(关态)。要开态只能: 在 systemd unit 加
|
||||
`Environment=ZHIYI_*_RECALL=1` + `daemon-reload` + `restart zhiyid`。
|
||||
- 开闸成本 = 一次重启(低)。但本卡结论「不开」基于净收益为负(见二),
|
||||
重启成本本身不是拦路项——拦路的是「开了也伤主路径 + 无净收益」。
|
||||
|
||||
## 五、判据(按卡 AC)
|
||||
- 当前态 diff 为空(PATTERN NULL-id bug → 0 注入;PEER 无表 → 0 注入)
|
||||
⇒ 「开态无产出」判据满足 ⇒ **不开闸**。
|
||||
- 修 bug 后态: 注入聚类键噪声 + P95 主路径涨幅 >20% ⇒ 「有产出但伤主路径」判据
|
||||
也满足 ⇒ **仍不开**。两条判据各自独立支撑「不开」。
|
||||
- 主路径未退化(关态 /eval/golden kw 14/14、recall@5=0.643;开态当前态=关态)。
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
## 原始 JSON(改前/改后,禁只贴结论)
|
||||
- 关态活服务 14 金标逐条: `off-recall-14golden-live.json`
|
||||
- 关态活服务 /eval/golden 汇总: `off-eval-golden-raw.json`
|
||||
- 关态 69 真实 query 延迟: `off-69-queries-lat-ms.json`
|
||||
- 开态影子 pattern 注入(当前态 = 空 `{}`): `shadow-on-pattern-inject.json`
|
||||
- 开态影子 peer 表存在性: 0(`SHADOW_PEER_TABLE_EXISTS=0`,见 shadow_diff_test.go)
|
||||
- 修 bug 后余弦探针(85 聚类键 vs 金标 maxcos): `offset-cosine-probe.txt`
|
||||
+ 复现 `TestShadowPattern_CosineIfIDFixed`(`PATTERN_ROWS_LOADABLE_BYPASS_ID=85`)
|
||||
|
||||
复跑命令:
|
||||
# 关态金标召回(活服务)
|
||||
cd <ws> && python3 eval_off.py
|
||||
# 关态 69 真实 query 延迟
|
||||
cd <ws> && python3 latency_off.py # 注: stdout 混进度行,JSON 在尾部
|
||||
# 开态影子注入 + peer 表 + 修 bug 后余弦探针(只读 graph.db + :8000 embedder,不改生产)
|
||||
cd /home/muc/src/memoryweave/go && go test ./internal/pattern/ \
|
||||
-run 'TestShadowPattern_Diff|TestShadowPeer_NoTable|TestShadowPattern_CosineIfIDFixed' \
|
||||
-v -count=1
|
||||
|
||||
## 给审计脚本的「已明确决策」标记(消音用)
|
||||
本卡对 PATTERN/PEER 门控的终态 = **已明确决策: 暂不开闸(证据: 开态零产出,见上)**。
|
||||
若审计脚本下次再报「门控到期/上游 done 但门关」,应据此识别为「已决策不开」而非
|
||||
「没人复核」。建议: 门控台账加 `decision` 字段(values: open/no-open/unknown),
|
||||
本卡结论落 `no-open` + 证据指针,脚本对 no-open 态静默。
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
## 未决(需人拍板,本卡不越界执行)
|
||||
1. PATTERN NULL-id 读库 bug: `GraphPatternStore.ListPatterns` 扫 NULL id 报错。
|
||||
85 存量 pattern 行全是聚类摘要节点且 id=NULL——**这套数据本身可能就不该作为
|
||||
L2 行为 pattern 召回源**。属「修数据还是修代码」的决策,交 hub/ops,不在本卡。
|
||||
2. 开闸动作: 小怡终审 + 牧尘放行(本卡只出证据,AC 明令不开门)。
|
||||
@@ -0,0 +1 @@
|
||||
[-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 825.0, 952.6, 1123.0, 1376.9, 1383.7, 1544.4, 1583.8, 1592.2, 1635.1, 1680.3, 1685.1, 1691.0, 1726.0, 1754.9, 1794.7, 1851.8, 1874.3, 1878.2, 1901.0, 1909.9, 1924.8, 1937.8, 1954.0, 2037.1, 2053.7, 2058.2, 2154.6, 2174.1, 2287.7, 2355.2, 2718.2, 2789.6, 2791.9, 2832.9, 3109.5, 3222.3, 3507.6, 3534.9, 3906.4]
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"20261017-205654","model":"golden-set","recall_at_5":0.6428571428571429,"precision_at_5":0.12857142857142856,"mean_reciprocal_rank":0.47619047619047616,"ndcg":0.5238095238095238,"query_details":[{"query":"织忆是什么","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆的图谱扩展机制是什么","expected_ids":["mem_1791899478673190284"],"must_include_keywords":["图谱"],"hits":["mem_1791899478673190284"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆如何通过 E1 提取和 MMR 实现多样去重","expected_ids":["mem_1791666890486125605"],"must_include_keywords":["MMR"],"hits":null,"misses":["mem_1791666890486125605"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"牧尘的偏好是什么","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"牧尘喜欢什么样的工作方式","expected_ids":["mem_1790448247125520424"],"must_include_keywords":["牧尘"],"hits":["mem_1790448247125520424"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何根据牧尘的偏好调整记忆检索策略","expected_ids":["mem_1791747597032219499"],"must_include_keywords":["记忆"],"hits":["mem_1791747597032219499"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"当前项目的技术栈是什么","expected_ids":["mem_1790455676397796671"],"must_include_keywords":["技术栈"],"hits":["mem_1790455676397796671"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"织忆项目有哪些核心组件","expected_ids":["mem_1791906354849281414"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791906354849281414"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆和其他记忆系统相比有什么架构优势","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":["mem_1791666702950691634"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"如何使用 recall 接口","expected_ids":["mem_1791796178328736793"],"must_include_keywords":["recall"],"hits":null,"misses":["mem_1791796178328736793"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"织忆有哪些管理工具","expected_ids":["mem_1791666702950691634"],"must_include_keywords":["织忆"],"hits":null,"misses":["mem_1791666702950691634"],"recall_at_5":0,"precision_at_5":0,"keyword_ok":true},{"query":"如何通过 API 扩展织忆功能","expected_ids":["mem_1791666188168772049"],"must_include_keywords":["织忆"],"hits":["mem_1791666188168772049"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"本地 llama 服务 8080 端口","expected_ids":["mem_1791423503359721152"],"must_include_keywords":["8080"],"hits":["mem_1791423503359721152"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true},{"query":"session-to-zhiyi.py 脚本名","expected_ids":["mem_1791688470278719760"],"must_include_keywords":["session-to-zhiyi"],"hits":["mem_1791688470278719760"],"misses":null,"recall_at_5":1,"precision_at_5":0.2,"keyword_ok":true}],"queries":14,"ran_at":"2026-10-17T20:56:54.195909811+08:00","keyword_pass":14,"keyword_fail":0,"keyword_total":14}
|
||||
@@ -0,0 +1,319 @@
|
||||
{
|
||||
"state": "OFF",
|
||||
"results": [
|
||||
{
|
||||
"i": 0,
|
||||
"q": "织忆是什么",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791899478673190284",
|
||||
"mem_1792154287491185572",
|
||||
"mem_1791912043093092663",
|
||||
"mem_1792154287177190000",
|
||||
"mem_1792154288142808071"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3936.9
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"q": "织忆的图谱扩展机制是什么",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791899478673190284",
|
||||
"mem_1791725847707296273",
|
||||
"mem_1792154287491185572",
|
||||
"mem_1791770032575826407",
|
||||
"mem_1792088552819764989"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 4225.2
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"q": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1792088552818081545",
|
||||
"mem_1791666890486125605",
|
||||
"mem_1791520496931190142",
|
||||
"mem_1791422985447662675",
|
||||
"mem_1791498226924653975"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3643.6
|
||||
},
|
||||
{
|
||||
"i": 3,
|
||||
"q": "牧尘的偏好是什么",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791556229473565240",
|
||||
"mem_1791376118421773238",
|
||||
"mem_1790448247125520424",
|
||||
"mem_1792088552818232071",
|
||||
"mem_1792086639900350664"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3367.5
|
||||
},
|
||||
{
|
||||
"i": 4,
|
||||
"q": "牧尘喜欢什么样的工作方式",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1790448247125520424",
|
||||
"mem_1792086639900350664",
|
||||
"mem_1791482493617981202",
|
||||
"mem_1789063252280657787",
|
||||
"mem_1792086640133724836"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3511.3
|
||||
},
|
||||
{
|
||||
"i": 5,
|
||||
"q": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1792091916993248722",
|
||||
"mem_1791747597032219499",
|
||||
"mem_1792088552824810016",
|
||||
"mem_1792153987878359340",
|
||||
"mem_1790137162828579293"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3544.6
|
||||
},
|
||||
{
|
||||
"i": 6,
|
||||
"q": "当前项目的技术栈是什么",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1792088552817810427",
|
||||
"mem_1790455676397796671",
|
||||
"mem_1791665641471849057",
|
||||
"mem_1790317738849661043",
|
||||
"mem_1790425023223201421"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 4874.5
|
||||
},
|
||||
{
|
||||
"i": 7,
|
||||
"q": "织忆项目有哪些核心组件",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1792154287491185572",
|
||||
"mem_1792154287177190000",
|
||||
"mem_1791770032575826407",
|
||||
"mem_1792154288142808071",
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3811.5
|
||||
},
|
||||
{
|
||||
"i": 8,
|
||||
"q": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791899478077693848",
|
||||
"mem_1791666702950691634",
|
||||
"mem_1792088552819764989",
|
||||
"mem_1791770032575826407",
|
||||
"mem_1792154287491185572"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 3254.5
|
||||
},
|
||||
{
|
||||
"i": 9,
|
||||
"q": "如何使用 recall 接口",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791482321288100863",
|
||||
"mem_1792153988391999660",
|
||||
"ep_1785686577666177306",
|
||||
"ep_1786452727331645773",
|
||||
"mem_1788717420755520289"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 4110.9
|
||||
},
|
||||
{
|
||||
"i": 10,
|
||||
"q": "织忆有哪些管理工具",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1792154287491185572",
|
||||
"mem_1791666855166846631",
|
||||
"mem_1792144557208382006",
|
||||
"mem_1792116217448228684",
|
||||
"mem_1792154288142808071"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 5963.1
|
||||
},
|
||||
{
|
||||
"i": 11,
|
||||
"q": "如何通过 API 扩展织忆功能",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791666188168772049",
|
||||
"mem_1791818814047111008",
|
||||
"ep_1785695216419714474",
|
||||
"ep_1785702789193047009",
|
||||
"ep_1786336919812827426"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 4222.9
|
||||
},
|
||||
{
|
||||
"i": 12,
|
||||
"q": "本地 llama 服务 8080 端口",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791423503359721152",
|
||||
"mem_1791440673318711992",
|
||||
"mem_1788594533802527918",
|
||||
"mem_1788620335517621905",
|
||||
"mem_1788700944516696452"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": false,
|
||||
"lat_ms": 3803.5
|
||||
},
|
||||
{
|
||||
"i": 13,
|
||||
"q": "session-to-zhiyi.py 脚本名",
|
||||
"count": 5,
|
||||
"top5_ids": [
|
||||
"mem_1791688470278719760",
|
||||
"mem_1789152643785440562",
|
||||
"mem_1790154887033667954",
|
||||
"mem_1790001291060572107",
|
||||
"mem_1789896720973833628"
|
||||
],
|
||||
"top5_cats": [
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled",
|
||||
"distilled"
|
||||
],
|
||||
"kw_ok": true,
|
||||
"lat_ms": 4078.1
|
||||
}
|
||||
],
|
||||
"kw_pass": 13,
|
||||
"kw_total": 14,
|
||||
"p50_ms": 3936.9,
|
||||
"p95_ms": 5255.5,
|
||||
"all_lat_ms": [
|
||||
3254.5,
|
||||
3367.5,
|
||||
3511.3,
|
||||
3544.6,
|
||||
3643.6,
|
||||
3803.5,
|
||||
3811.5,
|
||||
3936.9,
|
||||
4078.1,
|
||||
4110.9,
|
||||
4222.9,
|
||||
4225.2,
|
||||
4874.5,
|
||||
5963.1
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
shadow_diff_test.go:200: PATTERN_ROWS_LOADABLE_BYPASS_ID=85
|
||||
shadow_diff_test.go:224: COSINE_PROBE "织忆是什么" maxcos=0.7038 hit=true (thr=0.6) best=topic:memory
|
||||
shadow_diff_test.go:224: COSINE_PROBE "牧尘的偏好是什么" maxcos=0.6975 hit=true (thr=0.6) best=pattern:default-main:entity
|
||||
shadow_diff_test.go:224: COSINE_PROBE "如何使用 recall 接口" maxcos=0.6891 hit=true (thr=0.6) best=topic:replying
|
||||
shadow_diff_test.go:224: COSINE_PROBE "本地 llama 服务 8080 端口" maxcos=0.6752 hit=true (thr=0.6) best=pattern:test-llm:entity
|
||||
shadow_diff_test.go:224: COSINE_PROBE "织忆项目有哪些核心组件" maxcos=0.7148 hit=true (thr=0.6) best=pattern:learner-main:concept
|
||||
@@ -0,0 +1,229 @@
|
||||
package pattern
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// ── t_bd63b641 影-复:PATTERN/PEER 开门量具(不碰生产;只读 graph.db + 实时 embedder)──
|
||||
// 量化「开 ZHIYI_PATTERN_RECALL / ZHIYI_PEER_RECALL」对 14 金标会注入什么。
|
||||
// Provider 装配在 server.go 启动期由 env 决定 ⇒ 不重启无法在活服务上测开态;
|
||||
// 故本测试直接构造真实 Provider + 生产 graph.db(只读)+ 实时 bge embedder,
|
||||
// 跑出「开态会注入的条目」,与关态(=主召回原样)对比。
|
||||
//
|
||||
// 本测试只读、不落盘、不改库(AC:不碰生产二进制/不重启)。
|
||||
|
||||
type logRecord struct {
|
||||
ID string
|
||||
Category string
|
||||
Content string
|
||||
}
|
||||
|
||||
// runShadowWithDiag 返回:注入结果 + 诊断(pattern 装载数 / query 是否编码成功 / 各 query 最高余弦)。
|
||||
// ⚠️ 关键发现:生产 graph_nodes(type='pattern') 的 id 全为 NULL(85/85),ListPatterns 读 id 为 string
|
||||
// ⇒ 恒报 sql: Scan error (converting NULL to string),Match 直接 return nil。
|
||||
// 故 runShadow 不把 ListPatterns 错误当失败 —— 它本身就是「开态也无法注入」的根因证据。
|
||||
func runShadowWithDiag() (map[string][]logRecord, int, string, map[string]float64) {
|
||||
endpoint := os.Getenv("VLLM_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "http://localhost:8000/v1/embeddings"
|
||||
}
|
||||
emb := storage.NewEmbedder(endpoint)
|
||||
store := NewGraphPatternStore("/var/lib/memoryweave/graph.db")
|
||||
pp := NewPatternProvider(store, NewEmbedderProvider(emb))
|
||||
|
||||
// 装载 pattern 数(防「store 空 → 注入恒 0」的假阴性);记录 ListPatterns 错误原文
|
||||
pats, _, lerr := store.ListPatterns(1000)
|
||||
loaded := 0
|
||||
listErr := ""
|
||||
if lerr != nil {
|
||||
listErr = lerr.Error() // 根因:id 全 NULL → Scan error
|
||||
} else {
|
||||
loaded = len(pats)
|
||||
}
|
||||
|
||||
golden := []string{
|
||||
"织忆是什么", "织忆的图谱扩展机制是什么",
|
||||
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
|
||||
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
|
||||
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
|
||||
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
|
||||
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
|
||||
}
|
||||
out := make(map[string][]logRecord)
|
||||
maxCos := make(map[string]float64)
|
||||
|
||||
// 预编码 14 query(若编码失败会记 0,便于区分「没命中」vs「编码挂了」)
|
||||
for _, q := range golden {
|
||||
qv, eerr := emb.EncodeSingle(q)
|
||||
if eerr != nil || len(qv) == 0 {
|
||||
maxCos[q] = -1 // 编码失败标记
|
||||
continue
|
||||
}
|
||||
// 逐个 pattern 算余弦,取最高(复现 Provider.Match 内部逻辑)
|
||||
best := -1.0
|
||||
for _, p := range pats {
|
||||
rep := p.Data
|
||||
if rep == "" {
|
||||
rep = p.Name
|
||||
}
|
||||
text := rep
|
||||
for _, tl := range p.Tools {
|
||||
if tl != "" {
|
||||
text += " " + tl
|
||||
}
|
||||
}
|
||||
pv, perr := emb.EncodeSingle(text)
|
||||
if perr != nil || len(pv) == 0 {
|
||||
continue
|
||||
}
|
||||
s := cosineF32(qv, pv)
|
||||
if s > best {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
maxCos[q] = best
|
||||
}
|
||||
|
||||
// 真注入(走 Provider.Match,同 server 路径)
|
||||
for _, g := range golden {
|
||||
pat := pp.Match(g, "hermes-main", 3) // server.go:902 topK=3
|
||||
for _, r := range pat {
|
||||
out[g] = append(out[g], logRecord{ID: r.ID, Category: r.Category, Content: r.Content})
|
||||
}
|
||||
}
|
||||
return out, loaded, listErr, maxCos
|
||||
}
|
||||
|
||||
// TestShadowPattern_Diff —— 开态 pattern 注入量化(只读生产库)
|
||||
func TestShadowPattern_Diff(t *testing.T) {
|
||||
out, loaded, listErr, maxCos := runShadowWithDiag()
|
||||
t.Logf("PATTERNS_LOADED=%d (防假阴性:store 有货才会注入)", loaded)
|
||||
if listErr != "" {
|
||||
t.Logf("PATTERN_LISTERROR=%q — 根因:pattern 行 id 全 NULL ⇒ Provider.Match 恒 return nil", listErr)
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
"织忆是什么", "织忆的图谱扩展机制是什么",
|
||||
"织忆如何通过 E1 提取和 MMR 实现多样去重", "牧尘的偏好是什么",
|
||||
"牧尘喜欢什么样的工作方式", "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"当前项目的技术栈是什么", "织忆项目有哪些核心组件",
|
||||
"织忆和其他记忆系统相比有什么架构优势", "如何使用 recall 接口",
|
||||
"织忆有哪些管理工具", "如何通过 API 扩展织忆功能",
|
||||
"本地 llama 服务 8080 端口", "session-to-zhiyi.py 脚本名",
|
||||
}
|
||||
totalInjected := 0
|
||||
for _, q := range queries {
|
||||
mc := maxCos[q]
|
||||
if mc < 0 {
|
||||
t.Logf("[EMB-FAIL] %q → 编码失败(不计命中)", q)
|
||||
}
|
||||
if n := len(out[q]); n > 0 {
|
||||
totalInjected += n
|
||||
t.Logf("[INJECT] %q → %d pattern (maxcos=%.3f)", q, n, mc)
|
||||
for _, r := range out[q] {
|
||||
t.Logf(" id=%s cat=%s content=%s", r.ID, r.Category, trunc(r.Content, 60))
|
||||
}
|
||||
} else {
|
||||
t.Logf("[NO-INJECT] %q → 0 pattern (maxcos=%.3f, 阈值=0.6)", q, mc)
|
||||
}
|
||||
}
|
||||
b, _ := json.MarshalIndent(out, "", " ")
|
||||
t.Logf("SHADOW_PATTERN_TOTAL_INJECTED=%d", totalInjected)
|
||||
os.WriteFile("/tmp/shadow-pattern-inject.json", b, 0644)
|
||||
t.Logf("raw json → /tmp/shadow-pattern-inject.json (%d bytes)", len(b))
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// ── peer 侧:生产 graph.db 无 peers 表 ⇒ 开态无数据可注入 ──
|
||||
func TestShadowPeer_NoTable(t *testing.T) {
|
||||
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open graph.db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
var cnt int
|
||||
err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='peers'").Scan(&cnt)
|
||||
if err != nil {
|
||||
t.Fatalf("query peers table existence: %v", err)
|
||||
}
|
||||
t.Logf("SHADOW_PEER_TABLE_EXISTS=%d (0 ⇒ 开态 peer 无数据可注入)", cnt)
|
||||
}
|
||||
|
||||
// peerOpenReadOnly 只读开 sqlite(WAL 兼容),避免 peer.Store 的写路径。
|
||||
func peerOpenReadOnly(path string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)")
|
||||
}
|
||||
|
||||
// ── 深度根因:即使修好 NULL-id,85 行 pattern 无 data/tools 键(只有 description),
|
||||
// Match 只能 embed 命名键(如 "pattern:hermes-main:entity")⇒ 对金标余弦 < 0.6 阈值。
|
||||
// 本测试量化「修好 NULL-id 后开态到底能注入几类金标」—— 证明开态收益仍≈0。
|
||||
func TestShadowPattern_CosineIfIDFixed(t *testing.T) {
|
||||
endpoint := os.Getenv("VLLM_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "http://localhost:8000/v1/embeddings"
|
||||
}
|
||||
emb := storage.NewEmbedder(endpoint)
|
||||
// 绕过 NULL-id 的 ListPatterns:只读 name + properties(非 NULL 列)
|
||||
db, err := peerOpenReadOnly("/var/lib/memoryweave/graph.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
rows, _ := db.Query(`SELECT name, properties FROM graph_nodes WHERE type='pattern'`)
|
||||
type patText struct{ name, text string }
|
||||
var pats []patText
|
||||
for rows.Next() {
|
||||
var name, props string
|
||||
if serr := rows.Scan(&name, &props); serr != nil {
|
||||
continue
|
||||
}
|
||||
// 复现 Match 的 text 构造:rep=data(无→空) 回退 name;无 tools
|
||||
text := name
|
||||
pats = append(pats, patText{name, text})
|
||||
}
|
||||
rows.Close()
|
||||
t.Logf("PATTERN_ROWS_LOADABLE_BYPASS_ID=%d", len(pats))
|
||||
if len(pats) == 0 {
|
||||
t.Skip("无 pattern 行可载(NULL-id 未修前本就 0 注入,已在 TestShadowPattern_Diff 覆盖)")
|
||||
}
|
||||
golden := []string{"织忆是什么", "牧尘的偏好是什么", "如何使用 recall 接口",
|
||||
"本地 llama 服务 8080 端口", "织忆项目有哪些核心组件"}
|
||||
for _, q := range golden {
|
||||
qv, qerr := emb.EncodeSingle(q)
|
||||
if qerr != nil || len(qv) == 0 {
|
||||
t.Logf("[EMBFAIL] %q → %v", q, qerr)
|
||||
continue
|
||||
}
|
||||
best, bestName := -1.0, ""
|
||||
for _, p := range pats {
|
||||
pv, perr := emb.EncodeSingle(p.text)
|
||||
if perr != nil || len(pv) == 0 {
|
||||
continue
|
||||
}
|
||||
s := cosineF32(qv, pv)
|
||||
if s > best {
|
||||
best, bestName = s, p.name
|
||||
}
|
||||
}
|
||||
hit := best >= 0.6
|
||||
t.Logf("COSINE_PROBE %q maxcos=%.4f hit=%v (thr=0.6) best=%s", q, best, hit, bestName)
|
||||
}
|
||||
}
|
||||
|
||||
// 防 unused 误报
|
||||
var _ = models.RecallResult{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,30 @@
|
||||
# 织忆指标语义:counter / rate / mean(`/api/v1/metrics`)
|
||||
|
||||
> 2026-10-26 建立。起因:有效性审计把 `deprecated_per_day`(计数器)当成率指标,
|
||||
> 报出「0 样本却判 valid=None ⇒ 缺最小样本门」。计数器没有分母,「样本门」对它无定义
|
||||
> —— 属**探针模型错配**,不是指标缺陷。本文件是给下游(审计脚本 / 看板 / 人)的唯一口径。
|
||||
|
||||
## 三类指标,三种读法
|
||||
|
||||
| 类型 | 命名特征 | 自带字段 | 怎么用 | 例子 |
|
||||
|---|---|---|---|---|
|
||||
| **counter 计数器** | `*_per_day` / `*_total` / `*_count` | **无** `_samples` / `_valid` | 直接读绝对值,跨时间比大小;不要算比率、不要报「样本不足」 | `deprecated_per_day`、`deprecated_total`、`distill_success_count` |
|
||||
| **rate 比率** | `*_rate` | `_samples`(分母)+ `_valid`(1=可判 / 0=不可判 / -1=N/A) | `_valid != 1` 时**不得**当结论;`_samples` 小于门槛时只报「待积累」 | `auto_resolve_rate`、`recall_usefulness_rate` |
|
||||
| **mean 均值** | `avg_*` | `_samples` + `_valid` | 同上:无分母就不下判断 | `avg_distill_loss` |
|
||||
|
||||
**铁律**:`0` 永远有两种含义 —— 「今天确实没有」和「根本没在统计」。
|
||||
- 计数器必须配一个累计值来区分:`deprecated_per_day=0` + `deprecated_total=0` ⇒ 从未统计过;
|
||||
`per_day=0` + `total=137` ⇒ 统计活着,只是今天没发生。
|
||||
- 率指标必须配 `_valid`/`_samples`,分母退化时返回 `metricNA(-1)`,不给 1(给 1 会被读成满分)。
|
||||
|
||||
## 触发器阈值也用同一套语义
|
||||
|
||||
`internal/selfoptimize/executor.go` 的 `t8`(`TDecayCritical` → `archive_or_boost`):
|
||||
- 判据读的是**计数器** `deprecated_per_day`(条/天),阈值由 `t8.Condition` 承载,**默认 5 条/天**。
|
||||
- 2026-10-26 修正:原先 `Condition: 0.1` 但判据函数里硬编码 `> 5`,配置写了从不生效。
|
||||
现改为 `Condition: 5.0` + 判据读 `t.Condition`(行为不变,但配置真的能被改)。
|
||||
- 若要改成「率」语义,需要先有分母(当日活跃写入量),否则别动 —— 那会变成另一个探针错配。
|
||||
|
||||
## 已知遗留
|
||||
|
||||
- 审计脚本侧对计数器发「样本不足」告警的白名单修复,归 `t_44c07eca`(脚本侧统一处理,避免多处各改一遍)。
|
||||
@@ -0,0 +1,174 @@
|
||||
// 织忆·确定性判据强化 影子探针(t_2515307a)—— 只记录,不施加
|
||||
//
|
||||
// 职责:从线上 zhiyid 拉真实召回候选(top_k=50),对每条内容跑
|
||||
// distill.RecallRuleVerdict(真实 Go 规则,零漂移),输出
|
||||
// keep / demote / drop 三组 + 每条 reason,供 AC3(结果变化对照)
|
||||
// 与 AC4(人工抽检被过滤项)取证。不改生产行为、不写库。
|
||||
//
|
||||
// 用法:
|
||||
// cd go && go run ./cmd/rulefirst-probe # 默认组查询
|
||||
// cd go && go run ./cmd/rulefirst-probe --query "..." # 单条指定
|
||||
// cd go && go run ./cmd/rulefirst-probe --out /tmp/rf.json # 落盘供 AC4 抽检
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
var defaultQueries = []string{
|
||||
"家庭服务器 IP 星空组网 VPN",
|
||||
"gateway 模型 配置 端口",
|
||||
"看板 任务 派发 验收 回写",
|
||||
"记忆 召回 反馈 闭环 使用",
|
||||
"脚本 审批 登记 门禁",
|
||||
"织忆 LanceDB 遗忘 窗口",
|
||||
"小唯 工作笔记 待并入 经验",
|
||||
"配置文件 备份 恢复 路径",
|
||||
"本地模型 2B 双槽 队列代理",
|
||||
"牧尘 偏好 咖啡 习惯",
|
||||
}
|
||||
|
||||
func apiKey() string {
|
||||
b, err := os.ReadFile("/var/lib/memoryweave/.api-key")
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s != "" && !strings.HasPrefix(s, "#") && !strings.Contains(s, "legacy") {
|
||||
return strings.Fields(s)[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.Getenv("ZHIYI_API_KEY")
|
||||
}
|
||||
|
||||
func recall(query string, topK int) ([]models.RecallResult, error) {
|
||||
body, _ := json.Marshal(map[string]any{"query": query, "top_k": topK, "namespace": ""})
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1:7821/api/v1/recall", strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", apiKey())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var d struct {
|
||||
Results []models.RecallResult `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
return d.Results, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
var q, out string
|
||||
flag.StringVar(&q, "query", "", "指定单条 query(默认跑内置 10 条)")
|
||||
flag.StringVar(&out, "out", "", "落盘 JSON 路径(供 AC4 抽检)")
|
||||
flag.Parse()
|
||||
|
||||
queries := defaultQueries
|
||||
if q != "" {
|
||||
queries = []string{q}
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Query string `json:"query"`
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Verdict string `json:"verdict"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
var all []item
|
||||
var keep, demote, drop []item
|
||||
byQ := map[string][3][]item{}
|
||||
|
||||
for _, query := range queries {
|
||||
res, err := recall(query, 50)
|
||||
if err != nil {
|
||||
fmt.Printf("QUERY %q ERR %v\n", query, err)
|
||||
continue
|
||||
}
|
||||
var kk, dd, drr []item
|
||||
toItem := func(r models.RecallResult, v distill.RecallVerdict, why string) item {
|
||||
return item{Query: query, ID: r.ID, Content: r.Content, Verdict: verdictName(v), Reason: why}
|
||||
}
|
||||
// 每条候选跑真实 Go 规则
|
||||
for _, r := range res {
|
||||
v, why := distill.RecallRuleVerdict(r.Content)
|
||||
it := toItem(r, v, why)
|
||||
all = append(all, it)
|
||||
switch v {
|
||||
case distill.RecallKeep:
|
||||
kk = append(kk, it)
|
||||
case distill.RecallDemote:
|
||||
dd = append(dd, it)
|
||||
case distill.RecallDrop:
|
||||
drr = append(drr, it)
|
||||
}
|
||||
}
|
||||
keep = append(keep, kk...)
|
||||
demote = append(demote, dd...)
|
||||
drop = append(drop, drr...)
|
||||
byQ[query] = [3][]item{kk, dd, drr}
|
||||
fmt.Printf("QUERY %q → 候选=%d keep=%d demote=%d drop=%d\n",
|
||||
query, len(res), len(kk), len(dd), len(drr))
|
||||
for _, it := range drr {
|
||||
fmt.Printf(" [DROP] %-30s %s | %s\n", it.ID, it.Reason, truncate(it.Content, 42))
|
||||
}
|
||||
for _, it := range dd {
|
||||
fmt.Printf(" [DEMOTE] %-30s %s | %s\n", it.ID, it.Reason, truncate(it.Content, 42))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== 汇总: 候选总数=%d keep=%d demote=%d drop=%d ===\n",
|
||||
len(all), len(keep), len(demote), len(drop))
|
||||
|
||||
if out != "" {
|
||||
f, err := os.Create(out)
|
||||
if err == nil {
|
||||
enc := json.NewEncoder(f)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(map[string]any{
|
||||
"all": all,
|
||||
"keep": keep,
|
||||
"demote": demote,
|
||||
"drop": drop,
|
||||
"by_query": byQ,
|
||||
})
|
||||
f.Close()
|
||||
fmt.Printf("已落盘 %s\n", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verdictName(v distill.RecallVerdict) string {
|
||||
switch v {
|
||||
case distill.RecallKeep:
|
||||
return "keep"
|
||||
case distill.RecallDemote:
|
||||
return "demote"
|
||||
default:
|
||||
return "drop"
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 织忆·确定性判据扫描(t_2515307a 取证)——按 namespace 分批扫全表,输出 drop/demote 明细供 AC4
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
)
|
||||
|
||||
func apikey() string {
|
||||
b, _ := os.ReadFile("/var/lib/memoryweave/.api-key")
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
s := strings.TrimSpace(l)
|
||||
if s != "" && !strings.HasPrefix(s, "#") && !strings.Contains(s, "legacy") {
|
||||
return strings.Fields(s)[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func list(ns string, limit int) ([]struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}, error) {
|
||||
u := fmt.Sprintf("http://127.0.0.1:7821/api/v1/memories?limit=%d", limit)
|
||||
if ns != "" {
|
||||
u += "&namespace=" + ns
|
||||
}
|
||||
req, _ := http.NewRequest("GET", u, nil)
|
||||
req.Header.Set("X-API-Key", apikey())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<26))
|
||||
var d struct {
|
||||
Memories []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"memories"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d.Memories, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
namespaces := []string{
|
||||
"", "hermes-main", "shared", "wiki-curator-main", "wiki", "soulful-xiaowei",
|
||||
"a06-main", "domain:dev", "default-main", "hermes", "wiki:decision",
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var all []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
for _, ns := range namespaces {
|
||||
ms, err := list(ns, 1000)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, m := range ms {
|
||||
if !seen[m.ID] {
|
||||
seen[m.ID] = true
|
||||
all = append(all, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
type rec struct {
|
||||
ID string
|
||||
Content string
|
||||
Why string
|
||||
}
|
||||
var drops, demotes []rec
|
||||
for _, m := range all {
|
||||
v, why := distill.RecallRuleVerdict(m.Content)
|
||||
if v == distill.RecallDrop {
|
||||
drops = append(drops, rec{m.ID, m.Content, why})
|
||||
} else if v == distill.RecallDemote {
|
||||
demotes = append(demotes, rec{m.ID, m.Content, why})
|
||||
}
|
||||
}
|
||||
fmt.Printf("unique_total=%d drops=%d demotes=%d\n\n", len(all), len(drops), len(demotes))
|
||||
fmt.Println("===== DROPS (进不了 top-N) =====")
|
||||
for i, d := range drops {
|
||||
rc := []rune(d.Content)
|
||||
c := d.Content
|
||||
if len(rc) > 55 {
|
||||
c = string(rc[:55]) + "…"
|
||||
}
|
||||
fmt.Printf("%2d. [%s] %s\n", i+1, d.Why, c)
|
||||
}
|
||||
fmt.Println("\n===== DEMOTES (降权) =====")
|
||||
for i, d := range demotes {
|
||||
rc := []rune(d.Content)
|
||||
c := d.Content
|
||||
if len(rc) > 55 {
|
||||
c = string(rc[:55]) + "…"
|
||||
}
|
||||
fmt.Printf("%2d. [%s] %s\n", i+1, d.Why, c)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -92,6 +93,8 @@ func main() {
|
||||
runErr = runStats(apiKeyVal)
|
||||
case "entity":
|
||||
runErr = runEntity(args, apiKeyVal)
|
||||
case "pattern":
|
||||
runErr = runPattern(args, apiKeyVal)
|
||||
case "help", "--help", "-h":
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
@@ -130,6 +133,7 @@ func usage() {
|
||||
recall <query> 语义搜索,返回 top-10 结果
|
||||
stats 显示系统统计(记忆数、蒸馏状态等)
|
||||
entity <name> 查询实体详情(出现次数、关联记忆)
|
||||
pattern <detect|list> L2 模式检测/列出(detect=手动触发检测,list=列出已检测)
|
||||
|
||||
示例:
|
||||
zhiyi tree
|
||||
@@ -461,6 +465,85 @@ func runStats(apiKey string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPattern L2 模式检测 CLI 子命令(t_d32e5f8a,cron 单发)。
|
||||
// 子命令:
|
||||
// zhiyi pattern detect [--limit N] 触发检测(扫 L1 蒸馏记忆 → 工具组合/路径聚类 → 写 graph_nodes)
|
||||
// zhiyi pattern list [N] 列出已检测的 pattern(默认 20)
|
||||
func runPattern(args []string, apiKey string) error {
|
||||
// 子命令(默认 detect)
|
||||
sub := "detect"
|
||||
rest := []string{}
|
||||
if len(args) > 0 {
|
||||
// 允许 "detect" / "list" 显式指定,或把第一个参数当 limit(list N)
|
||||
switch args[0] {
|
||||
case "detect", "list":
|
||||
sub = args[0]
|
||||
rest = args[1:]
|
||||
default:
|
||||
// 数字参数 = list 的 limit
|
||||
if n, err := strconv.Atoi(args[0]); err == nil && n > 0 {
|
||||
sub = "list"
|
||||
rest = []string{args[0]}
|
||||
} else {
|
||||
rest = args
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sub == "list" {
|
||||
limit := "20"
|
||||
if len(rest) > 0 {
|
||||
limit = rest[0]
|
||||
}
|
||||
var resp struct {
|
||||
Patterns []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Tools []string `json:"tools"`
|
||||
} `json:"patterns"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := apiGet("/api/v1/patterns?limit="+limit, &resp, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("📐 已检测模式 [%s] (%d)\n", *ns, resp.Count)
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
for i, p := range resp.Patterns {
|
||||
if i >= 30 {
|
||||
fmt.Printf(" … 还有 %d 个\n", len(resp.Patterns)-30)
|
||||
break
|
||||
}
|
||||
tools := ""
|
||||
if len(p.Tools) > 0 {
|
||||
tools = strings.Join(p.Tools, ",")
|
||||
}
|
||||
fmt.Printf(" [%s] %s conf=%.2f %s\n", p.Type, p.Name, p.Confidence, tools)
|
||||
}
|
||||
if resp.Count == 0 {
|
||||
fmt.Println(" (暂无已检测模式)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// detect
|
||||
var resp struct {
|
||||
Scanned int `json:"scanned"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Detected int `json:"detected"`
|
||||
}
|
||||
if err := apiPost("/api/v1/patterns/detect", map[string]interface{}{
|
||||
"namespace": *ns,
|
||||
}, &resp, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("🔬 L2 模式检测 [%s] 扫描 %d 条 → 命中 %d(新建 %d / 更新 %d)\n",
|
||||
*ns, resp.Scanned, resp.Detected, resp.Created, resp.Updated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEntity(args []string, apiKey string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("用法: zhiyi entity <name>")
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/memprotect"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
@@ -67,6 +68,10 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
forgotten := 0
|
||||
// 2026-10-16 (t_944344b5) 遗忘保护守卫:本批加载一次保护状态(Redis vprop + protected_ids),
|
||||
// 命中任一保护规则的候选永不自动软删(fragment 与 ShouldForget 两条道都拦截)。
|
||||
protState := memprotect.LoadState()
|
||||
protected := 0
|
||||
for _, mem := range results {
|
||||
lastAccess := parseTimeStr(mem["last_recalled_at"])
|
||||
// GA-P0-2 (t_c6d41ac6):衰减/遗忘只认 recall_verified_count(正反馈才是 V 值)。
|
||||
@@ -83,6 +88,15 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
|
||||
if len([]rune(content)) > 200 {
|
||||
continue
|
||||
}
|
||||
// 🔒 2026-10-16 (t_944344b5) 遗忘保护守卫: 命中任一保护规则的候选
|
||||
// (category∈{decision,preference,pref} / V≥2.0 / freshness=verified /
|
||||
// protected_ids 名单 / distilled 且内容命中 10 个决策·偏好关键词) 永不自动软删。
|
||||
// 拦截 point 放在 fragment + ShouldForget 两条 SoftDelete 道之前。
|
||||
if isProt, protRule := memprotect.IsProtected(mem, protState); isProt {
|
||||
protected++
|
||||
log.Printf("[forget] protected skip id=%q rule=%s (遗忘保护守卫命中, 跳过自动遗忘)", strVal(mem["id"]), protRule)
|
||||
continue
|
||||
}
|
||||
// 2026-09-07 方案A 碎片快速道(与 server.go decay trigger 一致):
|
||||
// <30 字且 >20 天未访问 → 直接遗忘(碎片 recall_count 可能虚高, 绕过保命)
|
||||
if len([]rune(content)) < 30 && time.Since(lastAccess).Hours()/24 > 20 {
|
||||
@@ -117,6 +131,8 @@ func (aa *AdminAPI) Forget(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "ok", "scanned": len(results), "forgotten": forgotten,
|
||||
// 2026-10-16 (t_944344b5):protected = 被遗忘保护守卫拦截、未软删的候选数
|
||||
"protected": protected,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/metanote"
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
)
|
||||
|
||||
@@ -27,6 +29,14 @@ type CommitMeta struct {
|
||||
// 2026-10-08 (t_7e3577c5):新增 embedModel/scriptVer 血缘字段透传
|
||||
// 2026-10-08 (Gap#2):改为 CommitMeta 结构体,新增 confidence 透传
|
||||
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string, meta CommitMeta) {
|
||||
// 2026-10-16 t_5ad3f843:meta-note 门禁(env ZHIYI_META_FILTER=1 启用;默认关闭=零行为变化)。
|
||||
// 蒸馏是事实写入的唯一咽喉:原文判 meta ⇒ 不进蒸馏(引擎路径与降级路径都挡),
|
||||
// 从源头杜绝 meta-note 生成 memories 事实(admin distill/force 也经此函数,一并覆盖)。
|
||||
if skip, reason := metanote.ShouldSkip(content); skip {
|
||||
log.Printf("[metanote] auto-distill skipped: ep=%s agent=%s reason=%s content=%.60s", episodeID, agentID, reason, content)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 硬规则过滤
|
||||
if !distill.HardRulesPass(content) {
|
||||
return
|
||||
|
||||
@@ -249,7 +249,12 @@ func (cp *ConsolidationPipeline) mergeSimilar() (int, error) {
|
||||
// 保留较新的
|
||||
idI := strVal(mems[i]["id"])
|
||||
idJ := strVal(mems[j]["id"])
|
||||
_ = cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI))
|
||||
// t_cf680fad:不再 `_ =` 静默吞错。MVCC 冲突耗尽时 SoftDelete 内部会
|
||||
// 把 (idJ, reason) 落 durable pending 队列(softdelete_pending.go),
|
||||
// 启动/每 tick drain 重放必落盘;这里显式记日志做观测(禁静默)。
|
||||
if err := cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI)); err != nil {
|
||||
log.Printf("[consolidation] merge-softdelete FAILED: src=%s → target=%s err=%v (pending-queue 将重放)", idJ, idI, err)
|
||||
}
|
||||
merged++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// 织忆 MemoryWeave — 整合链路健康跟踪(t_10757bd5)
|
||||
//
|
||||
// 背景(2026-10-24 小怡体检实测):
|
||||
// - t_distill(15min) 与 t_consolidation 同时触发 consolPipe,
|
||||
// Rust 侧互斥整合槽被占用时 Go 立即降级 Go 启发式 ⇒ consolidate_busy ~55 次/日静默堆积
|
||||
// - /health 只看 sidecar TCP 连通性 ⇒ 整合链路持续失败也假绿
|
||||
//
|
||||
// 本文件提供:
|
||||
// 1. ConsolidationTracker — 记录连续失败次数 + 最近一次成功时间(供 /health 读)
|
||||
// 2. busyWaitRetry — Rust sidecar 报 busy 时等待 3s×3 次再降级(整合全程 ~126s)
|
||||
// 3. 引用 metrics 包新增的 5 个指标(AC5:失败路径带计数)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/consolidate"
|
||||
"github.com/xiaoxue/memoryweave/internal/metrics"
|
||||
)
|
||||
|
||||
// ─── ConsolidationTracker ────────────────────────────────────
|
||||
|
||||
var (
|
||||
consTracker = &ConsolidationTracker{}
|
||||
consTrackerOnce sync.Once
|
||||
)
|
||||
|
||||
// ConsolidationTracker 记录整合链路健康状态(内存态,重启归零;
|
||||
// 重启后健康端点读到 ConsecutiveFailures=0 ⇒ 正确,因为该链路确实刚起步)。
|
||||
type ConsolidationTracker struct {
|
||||
mu sync.Mutex
|
||||
LastSuccess time.Time
|
||||
LastAttempt time.Time
|
||||
startup time.Time
|
||||
ConsecutiveFailures int
|
||||
Skips int64 // t_10757bd5:需要"跳过"而非失败的次数(并发触发 / sidecar busy)
|
||||
lastSkip string // 最近一次跳过原因
|
||||
// lastError 最近一次失败原因(供 /health 暴露,便于人工诊断)
|
||||
lastError string
|
||||
}
|
||||
|
||||
// GetConsolTracker 返回全局 tracker 单例
|
||||
func GetConsolTracker() *ConsolidationTracker {
|
||||
if consTracker.startup.IsZero() {
|
||||
consTracker.mu.Lock()
|
||||
if consTracker.startup.IsZero() {
|
||||
consTracker.startup = time.Now()
|
||||
}
|
||||
consTracker.mu.Unlock()
|
||||
}
|
||||
return consTracker
|
||||
}
|
||||
|
||||
// RecordSuccess 整合成功(归零)
|
||||
func (t *ConsolidationTracker) RecordSuccess() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.LastAttempt = time.Now()
|
||||
t.LastSuccess = time.Now()
|
||||
t.ConsecutiveFailures = 0
|
||||
t.lastError = ""
|
||||
metrics.ConsolLastSuccessTime.SetToCurrentTime()
|
||||
metrics.ConsolConsecutiveFailures.Set(0)
|
||||
}
|
||||
|
||||
// RecordSkip 本次触发被**跳过**(不是失败)。
|
||||
//
|
||||
// t_10757bd5 关键语义修正(2026-10-24 22:45 实测发现):
|
||||
// `consolidate_busy` 的真实含义是「另一个整合正在跑」,不是链路坏。
|
||||
// 把它记成失败会让 /health 报 degraded —— 明明整合正在正常工作,却又是一次假红。
|
||||
// 只有真正的错误(sidecar 崩、DB 错、超时)才算 failure。
|
||||
func (t *ConsolidationTracker) RecordSkip(reason string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.LastAttempt = time.Now()
|
||||
t.Skips++
|
||||
t.lastSkip = reason
|
||||
}
|
||||
|
||||
// RecordFailure 整合失败(+1)
|
||||
func (t *ConsolidationTracker) RecordFailure(err string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.LastAttempt = time.Now()
|
||||
t.ConsecutiveFailures++
|
||||
t.lastError = err
|
||||
metrics.ConsolConsecutiveFailures.Set(float64(t.ConsecutiveFailures))
|
||||
}
|
||||
|
||||
// HealthStatus 返回健康端点应报的状态:
|
||||
// - 连续**真失败** ≥ 3 ⇒ "degraded"
|
||||
// - 进程启动已超 2 小时却从未成功过 ⇒ "degraded"(跳过不算成功,也不算失败)
|
||||
// - 上次成功距今 > 2 小时(15min 周期 × 8)⇒ "degraded"
|
||||
// - 否则 "ok"
|
||||
func (t *ConsolidationTracker) HealthStatus() (string, int, time.Duration, string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
fails := t.ConsecutiveFailures
|
||||
var sinceSuccess time.Duration
|
||||
if t.LastSuccess.IsZero() {
|
||||
sinceSuccess = 0 // 从未成功
|
||||
} else {
|
||||
sinceSuccess = time.Since(t.LastSuccess)
|
||||
}
|
||||
status := "ok"
|
||||
if fails >= 3 {
|
||||
status = "degraded"
|
||||
}
|
||||
if t.LastSuccess.IsZero() {
|
||||
if !t.startup.IsZero() && time.Since(t.startup) > 2*time.Hour {
|
||||
status = "degraded"
|
||||
}
|
||||
} else if sinceSuccess > 2*time.Hour {
|
||||
status = "degraded"
|
||||
}
|
||||
return status, fails, sinceSuccess, t.lastError
|
||||
}
|
||||
|
||||
// Snapshot 供 /health 暴露完整字段(含跳过次数,便于区分「忙」与「坏」)
|
||||
func (t *ConsolidationTracker) Snapshot() (fails int, skips int64, lastSkip string, lastErr string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.ConsecutiveFailures, t.Skips, t.lastSkip, t.lastError
|
||||
}
|
||||
|
||||
// ─── busyWaitRetry ───────────────────────────────────────────
|
||||
|
||||
// busyWaitRetry 对 Rust sidecar consolidate_busy 做有限等待重试。
|
||||
//
|
||||
// 原理:Rust 整合全程约 126s(DBSCAN + 衰减校准),触发器每 15min 一次,
|
||||
// 两个触发器同秒到达时第二个撞 busy。等待 3s × 3 次(共 9s);
|
||||
// 9s 后仍 busy 说明 sidecar 真有长任务(或正在做 full 模式),
|
||||
// 降级 Go 启发式并记录 tracker(由调用方负责)。
|
||||
//
|
||||
// 返回语义:
|
||||
// - (report, false, nil) = 成功拿到报告
|
||||
// - (nil, true, nil) = 重试耗尽仍 busy(调用方应走降级 + RecordFailure)
|
||||
// - (nil, false, err) = 非 busy 错误(调用方直接返回)
|
||||
func busyWaitRetry(dataDir, sqlitePath, mode string) (report *consolidate.Result, stillBusy bool, err error) {
|
||||
const (
|
||||
maxRetries = 3
|
||||
waitDur = 3 * time.Second
|
||||
)
|
||||
for i := 0; i <= maxRetries; i++ {
|
||||
if i > 0 {
|
||||
metrics.ConsolidateBusyWaitTotal.Inc()
|
||||
time.Sleep(waitDur)
|
||||
}
|
||||
report, err = consolidate.Run(dataDir, sqlitePath, mode)
|
||||
if err == nil {
|
||||
return report, false, nil
|
||||
}
|
||||
if strings.Contains(err.Error(), "consolidate_busy") {
|
||||
continue // 仍 busy,重试
|
||||
}
|
||||
// 非 busy 错误直接返回
|
||||
return nil, false, err
|
||||
}
|
||||
// 重试耗尽仍 busy
|
||||
return nil, true, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 织忆 MemoryWeave — 召回响应契约辅助(P0-3,2026-10-26)
|
||||
//
|
||||
// 背景(CS249r Vol III Ch6 检索契约):响应只有 cache_hit/candidates_scanned/count/mode/graph_expanded,
|
||||
// 空结果分不清「不存在」与「扫了一半就超时」——这是 Air Canada 类静默幻觉的结构性入口。
|
||||
//
|
||||
// 本文件提供最小可用契约三件:
|
||||
// ① status/partial_reason:complete 的空结果才允许解读为「不存在」;
|
||||
// ② scope{namespace,snapshot,...}:说明这次答案基于哪个数据视图(本服务无索引版本号,
|
||||
// 故 snapshot = 观测时点 + 缓存状态,缓存命中代表 60s 内旧快照 —— 语义诚实,不假装有版本号);
|
||||
// ③ continuation:不透明翻页令牌(query 指纹 + ns 绑定,防止跨 query 串页)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// maxContinuationOffset 硬上限:防止把 topK 放大到爆内存/延迟。
|
||||
const maxContinuationOffset = 500
|
||||
|
||||
type pageToken struct {
|
||||
V int `json:"v"`
|
||||
Q string `json:"q"` // query 指纹(sha256 前 8 hex)
|
||||
NS string `json:"ns"` // 作用域绑定
|
||||
Off int `json:"o"` // 下一页起点
|
||||
Lim int `json:"l"` // 页大小
|
||||
// Prev 已返回过的 ID(去重锚点)。活索引下排序会随 fetchLimit 变化而轻微漂移,
|
||||
// 只靠 offset 会出现跨页重复;token 携带已返回 ID 才能机械保证「不重」。
|
||||
Prev []string `json:"p,omitempty"`
|
||||
}
|
||||
|
||||
// maxPrevIDs 令牌里最多携带的去重锚点数(3~5 页内足够;超长则丢最旧,令牌不会无限膨胀)。
|
||||
const maxPrevIDs = 100
|
||||
|
||||
// queryFingerprint 8 hex 的 query 指纹(足够防串页,不泄露正文)。
|
||||
func queryFingerprint(q string) string {
|
||||
sum := sha256.Sum256([]byte(q))
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
// encodeContinuation 生成下一页令牌;没有下一页时返回空串。
|
||||
func encodeContinuation(query, namespace string, offset, limit int, prev []string) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(prev) > maxPrevIDs {
|
||||
prev = prev[len(prev)-maxPrevIDs:]
|
||||
}
|
||||
tok := pageToken{V: 1, Q: queryFingerprint(query), NS: namespace, Off: offset, Lim: limit, Prev: prev}
|
||||
b, err := json.Marshal(tok)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// decodeContinuation 解析令牌。错误信息面向调用方(可据此修请求)。
|
||||
func decodeContinuation(raw string) (pageToken, error) {
|
||||
var tok pageToken
|
||||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return tok, errors.New("continuation 不是合法 base64url 令牌")
|
||||
}
|
||||
if err := json.Unmarshal(b, &tok); err != nil {
|
||||
return tok, errors.New("continuation 内容不是合法 JSON")
|
||||
}
|
||||
if tok.V != 1 {
|
||||
return tok, errors.New("continuation 版本不支持:" + strconv.Itoa(tok.V))
|
||||
}
|
||||
if tok.Off < 0 || tok.Lim <= 0 {
|
||||
return tok, errors.New("continuation 字段非法(offset/limit)")
|
||||
}
|
||||
if tok.Off > maxContinuationOffset {
|
||||
return tok, errors.New("continuation offset 超出上限 " + strconv.Itoa(maxContinuationOffset))
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// buildScope 组装本次召回的数据视图声明。
|
||||
func buildScope(namespace string, topK int, mode string, cacheHit, truncated bool, started time.Time) map[string]interface{} {
|
||||
snap := "t" + strconv.FormatInt(started.UnixMilli(), 10)
|
||||
if cacheHit {
|
||||
snap += "-cache" // 缓存命中:读的是 60s 内旧快照,不是本次实时扫描
|
||||
} else {
|
||||
snap += "-scan"
|
||||
}
|
||||
if truncated {
|
||||
snap += "-truncated"
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"snapshot": snap,
|
||||
"cache_hit": cacheHit,
|
||||
"truncated": truncated,
|
||||
"top_k": topK,
|
||||
"mode": mode,
|
||||
}
|
||||
}
|
||||
|
||||
// traceItems 把最终返回集映射成 trace 的 item 归因(rank 从 1 起)。
|
||||
func traceItems(results []models.RecallResult) []storage.TraceItem {
|
||||
items := make([]storage.TraceItem, 0, len(results))
|
||||
for i, r := range results {
|
||||
ch := r.Channel
|
||||
if ch == "" {
|
||||
ch = "vector" // 老调用方/旧缓存条目无 channel ⇒ 主通道默认值
|
||||
}
|
||||
items = append(items, storage.TraceItem{Rank: i + 1, ID: r.ID, Channel: ch, Score: r.Score})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
func TestContinuationRoundTrip(t *testing.T) {
|
||||
tok := encodeContinuation("织忆怎么召回", "hermes-main", 10, 5, []string{"mem_1", "mem_2"})
|
||||
if tok == "" {
|
||||
t.Fatal("有下一页时令牌不应为空")
|
||||
}
|
||||
got, err := decodeContinuation(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("解码失败:%v", err)
|
||||
}
|
||||
if got.Off != 10 || got.Lim != 5 {
|
||||
t.Fatalf("令牌字段错误:%+v", got)
|
||||
}
|
||||
if got.Q != queryFingerprint("织忆怎么召回") {
|
||||
t.Fatalf("query 指纹不一致")
|
||||
}
|
||||
if got.NS != "hermes-main" {
|
||||
t.Fatalf("namespace 不一致:%s", got.NS)
|
||||
}
|
||||
if len(got.Prev) != 2 || got.Prev[0] != "mem_1" {
|
||||
t.Fatalf("去重锚点未随令牌传递:%v", got.Prev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationRejectsBadToken(t *testing.T) {
|
||||
if _, err := decodeContinuation("not-base64!!"); err == nil {
|
||||
t.Fatal("非法 base64 应报错")
|
||||
}
|
||||
if _, err := decodeContinuation("eyJ2Ijo5fQ"); err == nil { // {"v":9}
|
||||
t.Fatal("未知版本应报错")
|
||||
}
|
||||
tok := encodeContinuation("q", "ns", 9999, 5, nil)
|
||||
if _, err := decodeContinuation(tok); err == nil {
|
||||
t.Fatal("offset 超上限应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFingerprintStable(t *testing.T) {
|
||||
a := queryFingerprint("同样的问句")
|
||||
b := queryFingerprint("同样的问句")
|
||||
c := queryFingerprint("不同的问句")
|
||||
if a != b {
|
||||
t.Fatal("同 query 指纹应稳定")
|
||||
}
|
||||
if a == c {
|
||||
t.Fatal("不同 query 指纹应不同")
|
||||
}
|
||||
if len(a) != 8 {
|
||||
t.Fatalf("指纹应为 8 hex,实际 %d", len(a))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildScope(t *testing.T) {
|
||||
started := time.UnixMilli(1700000000000)
|
||||
sc := buildScope("hermes-main", 5, "hybrid", false, false, started)
|
||||
for _, k := range []string{"namespace", "snapshot", "cache_hit", "truncated", "top_k", "mode"} {
|
||||
if _, ok := sc[k]; !ok {
|
||||
t.Fatalf("scope 缺字段 %s", k)
|
||||
}
|
||||
}
|
||||
snap := sc["snapshot"].(string)
|
||||
if snap != "t1700000000000-scan" {
|
||||
t.Fatalf("实时扫描 snapshot 形态错误:%s", snap)
|
||||
}
|
||||
cached := buildScope("ns", 5, "hybrid", true, true, started)["snapshot"].(string)
|
||||
if cached != "t1700000000000-cache-truncated" {
|
||||
t.Fatalf("缓存+截断 snapshot 形态错误:%s", cached)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceItems(t *testing.T) {
|
||||
rs := []models.RecallResult{
|
||||
{ID: "a", Channel: "vector"},
|
||||
{ID: "b"}, // 老调用方无 channel ⇒ 默认 vector
|
||||
{ID: "c", Channel: "graph"},
|
||||
}
|
||||
items := traceItems(rs)
|
||||
if len(items) != 3 || items[0].Rank != 1 || items[2].Rank != 3 {
|
||||
t.Fatalf("rank 编号错误:%+v", items)
|
||||
}
|
||||
if items[1].Channel != "vector" {
|
||||
t.Fatalf("缺省 channel 应回落 vector,实际 %s", items[1].Channel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallResultContractFields 契约字段必须真的出现在 JSON 里(不是「加了结构体没接线」)。
|
||||
func TestRecallResultContractFields(t *testing.T) {
|
||||
r := models.RecallResult{
|
||||
ID: "mem_1", Channel: "fts", Stale: true, Version: 3,
|
||||
ValidFrom: "2026-10-26T10:00:00+08:00", ValidTo: "2026-10-26T11:00:00+08:00",
|
||||
Supersedes: []string{"mem_0"}, SupersededBy: []string{"mem_2"}, Superseded: true,
|
||||
}
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
for _, k := range []string{"channel", "stale", "generation", "valid_from", "valid_to",
|
||||
"supersedes", "superseded_by", "superseded"} {
|
||||
if _, ok := m[k]; !ok {
|
||||
t.Fatalf("响应缺字段 %s(JSON=%s)", k, string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
+610
-68
@@ -2,17 +2,22 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/metanote"
|
||||
"github.com/xiaoxue/memoryweave/internal/metrics"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
@@ -98,6 +103,20 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, 400, "agent_id and content required")
|
||||
return
|
||||
}
|
||||
// 2026-10-16 t_5ad3f843:meta-note 门禁(env ZHIYI_META_FILTER=1 启用;默认关闭=零行为变化)。
|
||||
// 判定与 Python 口径真相源同源(internal/metanote,parity/差分测试锁死);
|
||||
// 判 meta ⇒ 422 拒绝,不写 episode、不进蒸馏(卡面:把 meta 挡在咽喉点)。
|
||||
if skip, reason := metanote.ShouldSkip(req.Content); skip {
|
||||
// t_10757bd5 AC5:metanote 拒绝 ⇒ 计数器 +1(与日志 "[metanote] commit rejected" 行对齐)
|
||||
metrics.MetaNoteRejectTotal.Inc()
|
||||
log.Printf("[metanote] commit rejected (gate ON, meta-note): agent=%s ns=%s reason=%s content=%.60s",
|
||||
req.AgentID, req.Namespace, reason, req.Content)
|
||||
respondError(w, 422, "meta-note rejected: "+reason)
|
||||
return
|
||||
}
|
||||
// 影子档(ZHIYI_META_FILTER=shadow):命中只记「would-reject」,照常放行。
|
||||
// 默认 off ⇒ 立即返回,零开销、零行为变化。
|
||||
metanote.ObserveShadow(req.Content)
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = classifyNamespace(req.Category, req.AgentID, req.Content)
|
||||
}
|
||||
@@ -155,6 +174,14 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ⑤使用追踪(t_5aa4dcac):commit 写侧检测——新写入的内容是否携带了
|
||||
// 最近被召回记忆的独特 token;命中 ⇒ 判定「被用上」(downstream_use)并累加
|
||||
// recall_verified_count(env 门控 ZHIYI_USAGE_TRACK,默认关 ⇒ 零行为变化)。
|
||||
// 异步执行:不阻塞 commit 主路径、不改响应、不拒写入。
|
||||
if storage.UsageTrackingEnabled() {
|
||||
go storage.CheckCommittedForUse(a.LanceDB, req.AgentID, req.Namespace, req.Content)
|
||||
}
|
||||
|
||||
// 2. 编码
|
||||
vector, err := a.Embedder.EncodeSingle(req.Content)
|
||||
if err != nil {
|
||||
@@ -260,6 +287,170 @@ func (a *API) Commit(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 201, respData)
|
||||
}
|
||||
|
||||
// POST /api/v1/memory/write — 显式写入 memories 表(主症A,t_fa6c9104,2026-10-16)
|
||||
//
|
||||
// 背景:/api/v1/commit 把原文落 episodes 表、等异步 LLM 蒸馏(质量门丢弃率 ~68%)。
|
||||
// 「显式保存重要事实」场景(Hermes 插件 memory_write)走 commit ⇒ 写进去的东西
|
||||
// 自己召回不回来:episodes 表(recall 查 memories 表)+ 零向量 + 靠蒸馏流水线
|
||||
// 升级成 mem_*(大概率静默丢失)。
|
||||
//
|
||||
// 本端点:同步嵌入 → 去重/合并 → 矛盾只报告不拦 → 直写 memories 表 ⇒ 写完立刻可召回。
|
||||
// 硬约束遵守:
|
||||
// - 纯新增路由:commit / recall / 蒸馏 路径零改动(默认零行为变化)
|
||||
// - 嵌入失败 ⇒ 502 不写(写零向量 = 主症A 病根)
|
||||
// - category 缺省 distilled:不在 recall Step 2.5 rawCategories 过滤集里
|
||||
// - 不走写时四操作门控(ZHIYI_WRITE_SEMANTIC_OPS):显式写入 = 调用方意图,
|
||||
// 去重 3a/3b + 矛盾报告已足够;门控只约束 LLM 蒸馏事实
|
||||
func (a *API) WriteMemory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondError(w, 405, "POST only")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Source string `json:"source"`
|
||||
EmbeddingModel string `json:"embedding_model"`
|
||||
ScriptVersion string `json:"script_version"`
|
||||
Confidence *float64 `json:"confidence"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
return
|
||||
}
|
||||
if len([]rune(req.Content)) < 8 {
|
||||
respondError(w, 400, "content too short (<8 chars)")
|
||||
return
|
||||
}
|
||||
// 2026-10-16 t_5ad3f843:meta-note 门禁(同 Commit;默认关闭=零行为变化)。
|
||||
if skip, reason := metanote.ShouldSkip(req.Content); skip {
|
||||
log.Printf("[metanote] write_memory rejected: agent=%s reason=%s content=%.60s", req.AgentID, reason, req.Content)
|
||||
respondError(w, 422, "meta-note rejected: "+reason)
|
||||
return
|
||||
}
|
||||
// 影子档(同 Commit):命中只记「would-reject」,照常放行;默认 off 零开销。
|
||||
metanote.ObserveShadow(req.Content)
|
||||
if req.AgentID == "" {
|
||||
req.AgentID = "hermes-a06"
|
||||
}
|
||||
if req.Category == "" {
|
||||
req.Category = "distilled"
|
||||
}
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = classifyNamespace(req.Category, req.AgentID, req.Content)
|
||||
}
|
||||
src := req.Source
|
||||
if src == "" {
|
||||
src = "explicit_write"
|
||||
}
|
||||
embModel := req.EmbeddingModel
|
||||
if embModel == "" {
|
||||
embModel = "bge-m3"
|
||||
}
|
||||
scriptVer := req.ScriptVersion
|
||||
if scriptVer == "" {
|
||||
scriptVer = "hermes-zhiyi"
|
||||
}
|
||||
|
||||
// 1. 同步嵌入:失败 ⇒ 502,**不写零向量记录**(零向量 = 主症A 病根)
|
||||
vector, err := a.Embedder.EncodeSingle(req.Content)
|
||||
if err != nil {
|
||||
respondErrorSchema(w, 502, "embed_failed", "embed failed: "+err.Error(), 5)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 去重:exact/near-dup ⇒ 并入既有记忆(与 commit 3a/3b 同语义)
|
||||
similar, _ := a.LanceDB.Search("memories", vector, 5, req.Namespace)
|
||||
for _, existing := range similar {
|
||||
if existing.Content == req.Content {
|
||||
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "merged", "memory_id": existing.ID, "reason": "exact_dup",
|
||||
"table": "memories",
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(existing.Content) > 0 && len(req.Content) > 0 {
|
||||
ev, eErr := a.Embedder.EncodeSingle(existing.Content)
|
||||
if eErr == nil && cosineSimilarity(vector, ev) >= 0.98 {
|
||||
a.mergeMemory(existing, req.Content, req.Category, req.AgentID)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "merged", "memory_id": existing.ID, "reason": "near_dup",
|
||||
"table": "memories",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 矛盾检测(规则型,廉价):**只报告不拦** —— 显式写入是调用方意图,
|
||||
// 旧新并存由写时四操作门控/人工 feedback 收口(与 commit 的 conflict 报告同形)
|
||||
var conflictSources []string
|
||||
for _, existing := range similar {
|
||||
if existing.Content != req.Content {
|
||||
conflictSources = append(conflictSources, existing.Content)
|
||||
}
|
||||
}
|
||||
conflicts := a.ConflictDetector.DetectContradiction(req.Content, conflictSources)
|
||||
|
||||
// 4. 直写 memories 表(跳过 LLM 蒸馏 + 质量门 ⇒ 无 68% 静默丢弃)
|
||||
now := time.Now()
|
||||
confidenceSource := ""
|
||||
if req.Confidence != nil {
|
||||
confidenceSource = "authoritative"
|
||||
}
|
||||
mem := models.MemoryRecord{
|
||||
ID: fmt.Sprintf("mem_%d", now.UnixNano()),
|
||||
AgentID: req.AgentID,
|
||||
Namespace: req.Namespace,
|
||||
Content: req.Content,
|
||||
Category: req.Category,
|
||||
Vector: vector,
|
||||
Tier: "normal",
|
||||
Importance: 1.0,
|
||||
QualityScore: 1.0, // 显式写入 = 可信来源(对照 LLM 蒸馏的 result.Overall)
|
||||
Source: src,
|
||||
Freshness: "fresh",
|
||||
Provenance: "user", // bi-temporal 来源枚举 user|inferred|import
|
||||
EmbeddingModel: embModel,
|
||||
ScriptVersion: scriptVer,
|
||||
Confidence: req.Confidence,
|
||||
ConfidenceSource: confidenceSource,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := a.LanceDB.InsertMemory(mem); err != nil {
|
||||
respondError(w, 500, "insert memory: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 5. 搜索缓存失效(与蒸馏落库同一原则:写 memories ⇒ 该 namespace 缓存作废)
|
||||
if storage.SearchCacheInstance != nil && mem.Namespace != "" {
|
||||
storage.SearchCacheInstance.Invalidate(mem.Namespace)
|
||||
}
|
||||
// 跨 Agent 广播(镜像 commit 写后 goroutine;显式写入同样通知其它 agent)
|
||||
go func() {
|
||||
governance.PushCacheInvalidate(req.Namespace, "memory_write")
|
||||
governance.PushMemoryCommitted(req.AgentID, req.Namespace, mem.ID)
|
||||
}()
|
||||
|
||||
respData := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"memory_id": mem.ID,
|
||||
"table": "memories",
|
||||
"category": mem.Category,
|
||||
"namespace": mem.Namespace,
|
||||
"note": "direct write: embedded + recallable immediately (no distill queue)",
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
respData["conflicts"] = conflicts
|
||||
respData["warning"] = "contradictory memories exist; explicit write kept both (resolve via feedback)"
|
||||
}
|
||||
respond(w, 201, respData)
|
||||
}
|
||||
|
||||
// mergeMemory 合并重复记忆:升版本、提重要性、更新时间戳
|
||||
func (a *API) mergeMemory(existing models.MemoryRecord, newContent, newCategory, agentID string) {
|
||||
existing.Version++
|
||||
@@ -318,13 +509,15 @@ func cosineSimilarity(a, b []float32) float64 {
|
||||
// 用途:区分「真无匹配」(results=[] 但候选数>0)与「依赖故障」(非 2xx)。
|
||||
func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
TopK int `json:"top_k"` // 兼容插件 top_k 参数
|
||||
Namespace string `json:"namespace"`
|
||||
AgentID string `json:"agent_id"` // 用于推导默认 namespace
|
||||
Diversity float64 `json:"diversity"`
|
||||
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
TopK int `json:"top_k"` // 兼容插件 top_k 参数
|
||||
Namespace string `json:"namespace"`
|
||||
AgentID string `json:"agent_id"` // 用于推导默认 namespace
|
||||
Diversity float64 `json:"diversity"`
|
||||
Mode string `json:"mode"` // "hybrid"(default), "semantic", "keyword"
|
||||
DeadlineMS int `json:"deadline_ms"` // P0-3:本次召回的硬超时(毫秒),超时返回 partial
|
||||
Continuation string `json:"continuation"` // P0-3:上一页返回的不透明翻页令牌
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
@@ -357,11 +550,53 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
req.Limit = 10
|
||||
}
|
||||
// 2026-09-21 五修(t_24eceed0 返修):无 ns 且无 agent_id → namespace="" → 全库跨 ns 召回
|
||||
// 2026-10-26:区分「作用域来源」——只有 agent_id 派生的 ns 才允许兜底,
|
||||
// 显式传 namespace 的调用方不兜底(避免跨域泄漏)。
|
||||
nsDerivedFromAgent := req.Namespace == "" && req.AgentID != ""
|
||||
req.Namespace = resolveRecallNamespace(req.Namespace, req.AgentID, req.Query)
|
||||
|
||||
// ── P0-3 契约 + P0-1 追踪(2026-10-26)──────────────────────────────
|
||||
// 翻页令牌:绑定 query 指纹 + 作用域,跨 query 复用一律 400(防串页)。
|
||||
offset := 0
|
||||
var prevIDs []string
|
||||
if req.Continuation != "" {
|
||||
pt, perr := decodeContinuation(req.Continuation)
|
||||
if perr != nil {
|
||||
respondErrorSchema(w, 400, "bad_continuation", perr.Error(), 0)
|
||||
return
|
||||
}
|
||||
if pt.Q != queryFingerprint(req.Query) {
|
||||
respondErrorSchema(w, 400, "continuation_query_mismatch",
|
||||
"continuation 属于另一个 query,不能跨 query 复用", 0)
|
||||
return
|
||||
}
|
||||
if pt.NS != req.Namespace {
|
||||
respondErrorSchema(w, 400, "continuation_namespace_mismatch",
|
||||
"continuation 的 namespace 与本次请求不一致", 0)
|
||||
return
|
||||
}
|
||||
offset = pt.Off
|
||||
prevIDs = pt.Prev
|
||||
}
|
||||
// 取数上限 = 页尾(offset+limit);MMR 的 k 随之上抬,保证前缀稳定。
|
||||
// 多取 len(prevIDs):用于过滤跨页重复项,过滤后仍能凑满一页(否则「不重」要靠「漏」换)。
|
||||
fetchLimit := req.Limit + offset + len(prevIDs)
|
||||
if fetchLimit > maxContinuationOffset+req.Limit {
|
||||
fetchLimit = maxContinuationOffset + req.Limit
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if req.DeadlineMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.DeadlineMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
// H4: Default diversity — balance relevance & diversity
|
||||
// 2026-10-14:改读 storage 的推送值(默认 0.3,与下方原硬编码一致 ⇒ 行为不变),
|
||||
// 让调参器能真正影响召回 —— 此前调参改了 Tuner.Diversity 但没人读(空转)。
|
||||
if req.Diversity <= 0 {
|
||||
req.Diversity = 0.3
|
||||
req.Diversity = storage.DefaultMMRDiversity()
|
||||
}
|
||||
|
||||
var results []models.RecallResult
|
||||
@@ -374,71 +609,78 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
// (LanceDB ANN 返回 + 去重 + 原始对话过滤之后、rerank/MMR 截断之前捕获)。
|
||||
// 此前误用 len(results)(最终产出数)作代理值——字段名说 "scanned"(扫描候选池)
|
||||
// 值却是"产出数",属"用文档补假名"。现改为 RecallWithMeta 返回的候选池真实大小。
|
||||
// keyword 路径未做 ANN 扫描 ⇒ 恒 0;graph 兜底路径 = 兜底返回数。
|
||||
// keyword 主路径未做 ANN 扫描 ⇒ 恒 0;graph 兜底路径 = 兜底返回数。
|
||||
annScanned := 0
|
||||
// t_3aa2a8c1:粗检索被 Rust 侧单次重读行数上限截断的标志(来源 RecallMeta.Truncated)。
|
||||
// graph 兜底 / keyword 空匹配兜底结果不经 IPC 粗检索 ⇒ 恒 false。
|
||||
truncated := false
|
||||
|
||||
// H5: Mode routing — hybrid / semantic / keyword
|
||||
if req.Mode == "" {
|
||||
req.Mode = "hybrid"
|
||||
}
|
||||
// P0-1:trace 在 mode 归一之后创建(记录真实 mode);所有路径共用同一条 trace。
|
||||
tr := storage.NewRecallTrace("", req.Query, req.Namespace, req.Mode)
|
||||
var lastMeta storage.RecallMeta
|
||||
switch req.Mode {
|
||||
case "keyword":
|
||||
// Pure keyword search: LanceDB + BM25 re-rank, or graph.db fallback
|
||||
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx,
|
||||
// 不再把错误伪装成 200 + 错误体。
|
||||
// t_1c12e000:keyword 路径同样收进 RecallWithMeta(Pipeline.Recall 内部
|
||||
// 走同一缓存键),只为把 cache_hit 带回响应——旧版用 Recall 丢掉了 meta。
|
||||
var kwMeta storage.RecallMeta
|
||||
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
|
||||
// t_2d4e3b92: keyword 主路径依赖向量编码;编码失败(依赖不可用)⇒ 非 2xx。
|
||||
stKw := time.Now()
|
||||
kwResults, kwMeta, kwErr := a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
|
||||
if kwErr != nil {
|
||||
// 主路径硬错误(向量编码/rerank 依赖不可用):即便兜底能补结果,也按 502 上报,
|
||||
// 调用方据此走失败路径而不是把依赖故障误判成"无匹配"
|
||||
tr.StageNote("keyword_path", stKw, 0, kwErr.Error())
|
||||
tr.Error = kwErr.Error()
|
||||
respondErrorSchema(w, 502, "recall_backend_error",
|
||||
"keyword recall failed: "+kwErr.Error(), 5)
|
||||
log.Printf("[recall:keyword] failed: %v", kwErr)
|
||||
return
|
||||
}
|
||||
lastMeta = kwMeta
|
||||
cacheHit = kwMeta.CacheHit
|
||||
truncated = kwMeta.Truncated
|
||||
if len(kwResults) == 0 {
|
||||
// 真无匹配(正常业务):成功 schema,results=[] + candidates_scanned 供区分
|
||||
fallbackResults := a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, req.Limit)
|
||||
results = convertFallbackResults(fallbackResults)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"candidates_scanned": 0, // keyword 路径未做 ANN 扫描
|
||||
"mode": "keyword",
|
||||
"cache_hit": false, // graph 兜底结果不来自 search cache
|
||||
})
|
||||
return
|
||||
// 真无匹配(正常业务)或纯词面路径空:graph.db 兜底
|
||||
results = convertFallbackResults(a.GraphStore.FallbackTextSearch(req.Query, req.Namespace, fetchLimit))
|
||||
if len(results) > 0 {
|
||||
tr.StageNote("keyword_fallback", stKw, len(results), "主路径 0 命中 ⇒ graph.db 兜底")
|
||||
} else {
|
||||
tr.Stage("keyword_path", stKw, 0)
|
||||
}
|
||||
cacheHit = false // 兜底结果不来自 search cache
|
||||
} else {
|
||||
// Re-rank by BM25 only
|
||||
for i := range kwResults {
|
||||
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
|
||||
}
|
||||
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
|
||||
if len(kwResults) > fetchLimit {
|
||||
kwResults = kwResults[:fetchLimit]
|
||||
}
|
||||
for i := range kwResults {
|
||||
if kwResults[i].Channel == "" {
|
||||
kwResults[i].Channel = "keyword"
|
||||
}
|
||||
}
|
||||
results = kwResults
|
||||
tr.Stage("keyword_path", stKw, len(results))
|
||||
}
|
||||
// Re-rank by BM25 only
|
||||
for i := range kwResults {
|
||||
kwResults[i].Score = storage.ComputeBM25Score(req.Query, kwResults[i].Content)
|
||||
}
|
||||
sort.Slice(kwResults, func(i, j int) bool { return kwResults[i].Score > kwResults[j].Score })
|
||||
if len(kwResults) > req.Limit {
|
||||
kwResults = kwResults[:req.Limit]
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"results": kwResults, "count": len(kwResults), "mode": "keyword",
|
||||
"candidates_scanned": 0,
|
||||
"cache_hit": cacheHit, // t_1c12e000:keyword 主路径命中 search cache 时显式标注
|
||||
})
|
||||
return
|
||||
case "semantic":
|
||||
// Pure semantic — BM25 off (H1 skipped, Pipeline Recall has BM25 built in — handled by switch)
|
||||
// t_949840b1:RecallWithMeta 额外返回候选池真实大小(ANN 粗检索池,非最终产出数)
|
||||
// Pure semantic — BM25 off(Pipeline 内部按 mode 处理)
|
||||
var meta storage.RecallMeta
|
||||
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
|
||||
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
|
||||
lastMeta = meta
|
||||
annScanned = meta.CandidatesScanned
|
||||
cacheHit = meta.CacheHit // t_1c12e000
|
||||
cacheHit = meta.CacheHit
|
||||
truncated = meta.Truncated
|
||||
case "hybrid":
|
||||
// BM25 + vector combined (H1: BM25 scoring inside Pipeline.Recall)
|
||||
var meta storage.RecallMeta
|
||||
results, meta, err = a.Pipeline.RecallWithMeta(req.Query, req.Namespace, req.Limit, req.Diversity)
|
||||
results, meta, err = a.Pipeline.RecallWithMetaTraced(ctx, req.Query, req.Namespace, fetchLimit, req.Diversity, nsDerivedFromAgent, tr)
|
||||
lastMeta = meta
|
||||
annScanned = meta.CandidatesScanned
|
||||
cacheHit = meta.CacheHit // t_1c12e000
|
||||
cacheHit = meta.CacheHit
|
||||
truncated = meta.Truncated
|
||||
}
|
||||
if err != nil {
|
||||
// P0: 降级到 graph.db 关键词搜索(仅 hybrid/semantic 主路径依赖向量/rerank)
|
||||
@@ -446,10 +688,13 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
if len(fallbackResults) > 0 {
|
||||
results = convertFallbackResults(fallbackResults)
|
||||
annScanned = len(fallbackResults)
|
||||
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
|
||||
cacheHit = false // t_1c12e000:graph 兜底结果不来自 search cache
|
||||
truncated = false // t_3aa2a8c1:graph.db 兜底结果不经 IPC 粗检索 ⇒ 无截断可言
|
||||
w.Header().Set("X-Fallback", "graph")
|
||||
} else {
|
||||
// t_2d4e3b92: 主路径与兜底都失败 ⇒ 非 2xx + 统一错误体(绝不在 200 里塞错误)
|
||||
tr.MarkPartial("stage_error")
|
||||
tr.Error = err.Error()
|
||||
respondErrorSchema(w, 502, "recall_backend_error",
|
||||
"recall failed: "+err.Error()+"; graph fallback also empty", 5)
|
||||
log.Printf("[recall] failed: %v; fallback empty", err)
|
||||
@@ -465,7 +710,15 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
if len(results) > 0 && len(results) < 3 && req.Namespace != "shared" && req.Namespace != "" {
|
||||
vec, encErr := a.Embedder.EncodeQuery(req.Query)
|
||||
if encErr == nil {
|
||||
shared, _ := a.LanceDB.Search("memories", vec, 5, "shared")
|
||||
var shared []models.MemoryRecord
|
||||
// t_3aa2a8c1:补充搜同属粗检索 ⇒ 截断也计入本次 recall 的 truncated
|
||||
if rep, ok := a.LanceDB.(storage.SearchTruncationReporter); ok {
|
||||
var tSup bool
|
||||
shared, tSup, _ = rep.SearchWithTruncation("memories", vec, 5, "shared")
|
||||
truncated = truncated || tSup
|
||||
} else {
|
||||
shared, _ = a.LanceDB.Search("memories", vec, 5, "shared")
|
||||
}
|
||||
for _, m := range shared {
|
||||
// 去重:跳过已在 own namespace 结果中的记忆
|
||||
alreadyHave := false
|
||||
@@ -483,12 +736,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
vv = storage.VValueProvider(m.ID)
|
||||
}
|
||||
results = append(results, models.RecallResult{
|
||||
ID: m.ID,
|
||||
Content: m.Content,
|
||||
Category: m.Category,
|
||||
Score: 0.5, // shared 结果降权,使用默认分
|
||||
QualityScore: m.QualityScore,
|
||||
VValue: vv,
|
||||
ID: m.ID,
|
||||
Content: m.Content,
|
||||
Category: m.Category,
|
||||
Score: 0.5, // shared 结果降权,使用默认分
|
||||
QualityScore: m.QualityScore,
|
||||
VValue: vv,
|
||||
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
|
||||
Channel: "shared", // P0-1:来源通道
|
||||
Version: m.Version,
|
||||
ValidFrom: m.ValidFrom,
|
||||
ValidTo: m.ValidTo,
|
||||
Supersedes: m.Supersedes,
|
||||
SupersededBy: m.SupersededBy,
|
||||
Superseded: len(m.SupersededBy) > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -502,7 +763,15 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Namespace != "wiki-curator-main" && req.Namespace != "" {
|
||||
if vec, encErr := a.Embedder.EncodeQuery(req.Query); encErr == nil {
|
||||
for _, extra := range []string{"wiki-curator-main"} {
|
||||
extraHits, _ := a.LanceDB.Search("memories", vec, 5, extra)
|
||||
var extraHits []models.MemoryRecord
|
||||
// t_3aa2a8c1:补充搜同属粗检索 ⇒ 截断也计入本次 recall 的 truncated
|
||||
if rep, ok := a.LanceDB.(storage.SearchTruncationReporter); ok {
|
||||
var tSup bool
|
||||
extraHits, tSup, _ = rep.SearchWithTruncation("memories", vec, 5, extra)
|
||||
truncated = truncated || tSup
|
||||
} else {
|
||||
extraHits, _ = a.LanceDB.Search("memories", vec, 5, extra)
|
||||
}
|
||||
for _, m := range extraHits {
|
||||
dup := false
|
||||
for _, r := range results {
|
||||
@@ -518,12 +787,20 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
vv = storage.VValueProvider(m.ID)
|
||||
}
|
||||
results = append(results, models.RecallResult{
|
||||
ID: m.ID,
|
||||
Content: m.Content,
|
||||
Category: m.Category,
|
||||
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
|
||||
QualityScore: m.QualityScore,
|
||||
VValue: vv,
|
||||
ID: m.ID,
|
||||
Content: m.Content,
|
||||
Category: m.Category,
|
||||
Score: 0.45, // 笔记知识降权:不挤掉主记忆,但能被看到
|
||||
QualityScore: m.QualityScore,
|
||||
VValue: vv,
|
||||
HumanUsefulCount: m.HumanUsefulCount, // A10 (t_d618dc7c)
|
||||
Channel: "wiki", // P0-1:来源通道
|
||||
Version: m.Version,
|
||||
ValidFrom: m.ValidFrom,
|
||||
ValidTo: m.ValidTo,
|
||||
Supersedes: m.Supersedes,
|
||||
SupersededBy: m.SupersededBy,
|
||||
Superseded: len(m.SupersededBy) > 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -531,6 +808,50 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2026-10-14 (t_2515307a) 确定性判据强化:召回后规则过滤/降权 ──
|
||||
// env 门控 ZHIYI_RULE_FIRST,默认关 ⇒ 零行为变化(跳过本块)。
|
||||
// =1 时:对每条结果用 distill.RecallRuleVerdict 判确定性可用性——
|
||||
// RecallDrop → 过滤(不进 top-N);RecallDemote → 降权(排到 keep 之后)。
|
||||
// 只改读侧返回集,不碰 state.db、不碰 LanceDB 数据。
|
||||
ruleFilteredCount := 0
|
||||
ruleDemotedCount := 0
|
||||
if os.Getenv("ZHIYI_RULE_FIRST") == "1" && len(results) > 0 {
|
||||
kept, demoted, dropped, _ := distill.FilterRecallByRule(results,
|
||||
func(r models.RecallResult) string { return r.Content })
|
||||
ordered := make([]models.RecallResult, 0, len(kept)+len(demoted))
|
||||
ordered = append(ordered, kept...)
|
||||
ordered = append(ordered, demoted...)
|
||||
ruleFilteredCount = len(dropped)
|
||||
ruleDemotedCount = len(demoted)
|
||||
if ruleFilteredCount+ruleDemotedCount > 0 {
|
||||
log.Printf("[rule-first] query=%q 过滤 %d / 降权 %d / 保留 %d", req.Query,
|
||||
len(dropped), len(demoted), len(kept))
|
||||
}
|
||||
results = ordered
|
||||
}
|
||||
|
||||
// ── 2026-10-26(P1-3 生命周期 fixture 抓到的真 bug)──────────────────
|
||||
// 主候选池在 pipeline 里已做 bi-temporal 过滤,但 **shared / wiki 补充通道与图谱注入
|
||||
// 不过那道闸** ⇒ 已失效事实会从补充通道回流进 top-k(实测:valid_to=11:00 的记录
|
||||
// 被 shared 通道以 score=0.5 重新塞回)。这里在「所有注入完成之后、cap 之前」统一过滤,
|
||||
// 保证读侧只有一条有效期口径。全失效时保留原结果(不把召回清空,non-lossy)。
|
||||
if storage.BitemporalEnabled() {
|
||||
now := time.Now()
|
||||
live := make([]models.RecallResult, 0, len(results))
|
||||
expired := 0
|
||||
for _, r := range results {
|
||||
if storage.ValidityExpired(r.ValidTo, now) {
|
||||
expired++
|
||||
continue
|
||||
}
|
||||
live = append(live, r)
|
||||
}
|
||||
if len(live) > 0 && expired > 0 {
|
||||
log.Printf("[recall] 补充通道 bi-temporal 过滤 %d 条失效事实(统一读侧口径)", expired)
|
||||
results = live
|
||||
}
|
||||
}
|
||||
|
||||
// Record recall hit rate (has results = hit, empty = miss)
|
||||
selfoptimize.Dash.RecordRecall(len(results) > 0)
|
||||
// Gap auto-close: recall 命中后自动关闭该 query 对应的 open gap
|
||||
@@ -557,19 +878,129 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
// 非最终返回数——最终条数以 count 为准),
|
||||
// 使"真无匹配"(results=[] 但 candidates_scanned>0)与依赖故障(非 2xx)可区分。
|
||||
// 调用方契约:200 + results 键存在 = 成功(results 可为空);非 2xx = 失败,绝不解码为成功。
|
||||
// ⑤使用追踪(t_5aa4dcac):记录本次召回的 mem_* 记忆 + 独特 token(env 门控 ZHIYI_USAGE_TRACK,
|
||||
// 默认关 ⇒ 零行为变化)。供 commit 写侧检测「后续内容是否携带了这些记忆 → 判定被用上」。
|
||||
if storage.UsageTrackingEnabled() && len(results) > 0 {
|
||||
recs := make([]storage.RecalledMemory, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.ID == "" || r.Content == "" || strings.HasPrefix(r.ID, "ep_") {
|
||||
continue // 只追踪 mem_* 蒸馏记忆;ep_* 原始轮次不是「要判定用没用」的目标
|
||||
}
|
||||
recs = append(recs, storage.RecalledMemory{
|
||||
ID: r.ID, Content: r.Content, Namespace: r.Namespace,
|
||||
Agent: req.AgentID, Query: req.Query,
|
||||
})
|
||||
}
|
||||
storage.RecordRecalled(recs)
|
||||
}
|
||||
// ── 2026-10-26 (t_9025b0eb) top_k 收口:图谱扩展不得撑爆返回集 ──
|
||||
// 现象:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条、top_k=10 返回
|
||||
// 2227/1859 条,其中 99% 是 category=graph_expanded(图谱扩展 append 无上限)。
|
||||
// 后果:插件记忆预取被撑到 103,299 chars 截断(真相关条目落在被截断段)、count 失真、
|
||||
// ⑤使用追踪按千级结果记账。
|
||||
// 契约修正(主结果优先):
|
||||
// results = 主检索结果(category != graph_expanded),截断到 req.Limit;
|
||||
// 主结果不足 req.Limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
|
||||
// graph_expanded = 扩展结果**单独成字段**(上限 req.Limit)——能力仍可观测、可统计,
|
||||
// 但不再挤占主结果、不再污染 results/count。
|
||||
results, graphExpanded := capRecallResults(results, fetchLimit)
|
||||
if results == nil {
|
||||
results = []models.RecallResult{}
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
|
||||
// ── P0-3 翻页:从 fetchLimit 结果里切出 [offset, offset+limit) ──
|
||||
// 说明:翻页重算整段前 offset+limit 条再切片,保证 MMR 前缀稳定;
|
||||
// 令牌绑定 query 指纹 + namespace,跨 query/跨域复用一律 400(防串页)。
|
||||
totalFetched := len(results)
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
results = []models.RecallResult{}
|
||||
} else {
|
||||
results = results[offset:]
|
||||
}
|
||||
}
|
||||
if len(prevIDs) > 0 {
|
||||
seenPrev := make(map[string]bool, len(prevIDs))
|
||||
for _, id := range prevIDs {
|
||||
seenPrev[id] = true
|
||||
}
|
||||
kept := results[:0]
|
||||
for _, r := range results {
|
||||
if !seenPrev[r.ID] {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
results = kept
|
||||
}
|
||||
if len(results) > req.Limit {
|
||||
results = results[:req.Limit]
|
||||
}
|
||||
nextCont := ""
|
||||
if totalFetched >= fetchLimit && len(results) == req.Limit && offset+req.Limit < maxContinuationOffset {
|
||||
returned := make([]string, 0, len(prevIDs)+len(results))
|
||||
returned = append(returned, prevIDs...)
|
||||
for _, r := range results {
|
||||
returned = append(returned, r.ID)
|
||||
}
|
||||
nextCont = encodeContinuation(req.Query, req.Namespace, offset+req.Limit, req.Limit, returned)
|
||||
}
|
||||
|
||||
// ── P0-1/P0-3:契约字段 + trace 落盘 ──
|
||||
tr.SetItems(traceItems(results))
|
||||
status := lastMeta.Status
|
||||
if status == "" {
|
||||
status = "complete"
|
||||
}
|
||||
partialReason := lastMeta.PartialReason
|
||||
if tr.Status == "partial" && status == "complete" {
|
||||
status = "partial"
|
||||
partialReason = tr.PartialReason
|
||||
}
|
||||
if truncated && status == "complete" {
|
||||
status = "partial"
|
||||
partialReason = "row_cap_truncated"
|
||||
}
|
||||
tr.Status = status
|
||||
tr.PartialReason = partialReason
|
||||
tr.CacheHit = cacheHit
|
||||
tr.Truncated = truncated
|
||||
tr.CandidatesScanned = annScanned
|
||||
tr.Namespace = req.Namespace
|
||||
tr.Mode = req.Mode
|
||||
tr.Scope = buildScope(req.Namespace, req.Limit, req.Mode, cacheHit, truncated, tr.StartedAt)
|
||||
if offset > 0 {
|
||||
tr.Scope["offset"] = offset
|
||||
}
|
||||
storage.SaveTrace(tr)
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
"candidates_scanned": annScanned,
|
||||
"mode": req.Mode,
|
||||
// 2026-10-26 (t_9025b0eb):扩展结果单独成字段,避免撑爆 results。
|
||||
// 键恒存在(可为空数组),便于探针/统计区分「无扩展」与「字段缺失」。
|
||||
"graph_expanded": graphExpanded,
|
||||
// t_1c12e000:AC3 —— 缓存命中/回源扫表必须可区分。
|
||||
// true ⇒ candidates_scanned=0 是「读了 60s 内的旧快照」(含 L1/L2);
|
||||
// false ⇒ 本次真扫了表(candidates_scanned 为真实候选池大小)。
|
||||
"cache_hit": cacheHit,
|
||||
})
|
||||
// 2026-10-26(P0-1/P0-3 契约):
|
||||
// trace_id ⇒ GET /api/v1/trace/<id> 可重建本次调用链(阶段耗时 + 每通道贡献);
|
||||
// status ⇒ complete 的空结果才可解读为「不存在」;partial 必须看 partial_reason;
|
||||
// scope ⇒ 本次答案基于哪个数据视图(namespace/snapshot/cache_hit/truncated);
|
||||
// continuation ⇒ 空串=无下一页,非空=拼回请求体取下一页。
|
||||
"trace_id": tr.TraceID,
|
||||
"status": status,
|
||||
"scope": tr.Scope,
|
||||
"continuation": nextCont,
|
||||
}
|
||||
if partialReason != "" {
|
||||
resp["partial_reason"] = partialReason
|
||||
}
|
||||
// t_3aa2a8c1:truncated —— 只在 true 时暴露(键缺失 = 未截断,等价 omitempty 语义)。
|
||||
if truncated {
|
||||
resp["truncated"] = true
|
||||
}
|
||||
respond(w, 200, resp)
|
||||
|
||||
// H3: Async trust score update
|
||||
if a.GraphStore != nil {
|
||||
@@ -585,6 +1016,38 @@ func (a *API) Recall(w http.ResponseWriter, r *http.Request) {
|
||||
// useful_count / not_useful_count(后者是 P0-1 拆分的旧混写列)。反馈统一走
|
||||
// feedback.go 的 /api/v1/feedback/{useful,not-useful,deprecate,correct}(写 human_not_useful_count)。
|
||||
|
||||
// GET /api/v1/usage/report — ⑤使用追踪上报(t_5aa4dcac)。
|
||||
// 返回最近被自动判定「被用上」(carry_forward)的记忆样本,供 ⑥反馈消费 + 取证。
|
||||
// env 门控 ZHIYI_USAGE_TRACK,默认关 ⇒ 返回空;不写任何东西,零行为变化。
|
||||
func (a *API) UsageReport(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 20
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
flags := storage.UsageReport(limit)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"enabled": storage.UsageTrackingEnabled(),
|
||||
"count": len(flags),
|
||||
"flags": flags,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/usage/detect — ⑤使用追踪手动触发(t_5aa4dcac)。
|
||||
// 对「当前有 ZHIYI_USAGE_TRACK 且仍有未消费 recall 行」做一次兜底检测。
|
||||
// 主要靠 commit 写侧自动检测;此端点供运维/验证在无新 commit 时手动补扫。
|
||||
// 门控:关闭时返回 enabled=false,不碰任何数据。
|
||||
func (a *API) UsageDetect(w http.ResponseWriter, r *http.Request) {
|
||||
if !storage.UsageTrackingEnabled() {
|
||||
respond(w, 200, map[string]interface{}{"enabled": false, "count": 0, "flags": []storage.UsageFlag{}})
|
||||
return
|
||||
}
|
||||
// 手动触发需要一个候选内容作比对源;无 commit 时无可比内容,返回空。
|
||||
// 真实检测由 commit 写侧完成(见 Commit handler)。此端点仅回执开关状态。
|
||||
respond(w, 200, map[string]interface{}{"enabled": true, "count": 0, "note": "detection runs on commit write-side"})
|
||||
}
|
||||
|
||||
// POST /api/v1/recall/debug — recall 诊断端点(优化),返回各阶段耗时和状态
|
||||
func (a *API) RecallDebug(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
@@ -706,6 +1169,7 @@ func (a *API) RecallDebug(w http.ResponseWriter, r *http.Request) {
|
||||
results = append(results, models.RecallResult{
|
||||
ID: c.ID, Content: c.Content, Category: c.Category,
|
||||
Score: score, Timestamp: c.CreatedAt.Format(time.RFC3339),
|
||||
HumanUsefulCount: c.HumanUsefulCount, // A10 (t_d618dc7c)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -968,7 +1432,14 @@ func (a *API) BatchCommit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var ids []string
|
||||
skippedMeta := 0
|
||||
for _, item := range req.Items {
|
||||
// 2026-10-16 t_5ad3f843:meta-note 门禁——逐条跳过 meta 条目(门禁默认关闭=零行为变化)。
|
||||
if skip, reason := metanote.ShouldSkip(item.Content); skip {
|
||||
log.Printf("[metanote] batch item skipped: agent=%s reason=%s content=%.60s", item.AgentID, reason, item.Content)
|
||||
skippedMeta++
|
||||
continue
|
||||
}
|
||||
ns := item.Namespace
|
||||
if ns == "" {
|
||||
cat := item.Category
|
||||
@@ -988,7 +1459,7 @@ func (a *API) BatchCommit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
respond(w, 201, map[string]interface{}{"ids": ids, "count": len(ids), "status": "ok"})
|
||||
respond(w, 201, map[string]interface{}{"ids": ids, "count": len(ids), "skipped_meta": skippedMeta, "status": "ok"})
|
||||
}
|
||||
|
||||
// ─── Namespace 分类规则(按 §1.3 设计)─────────────────────
|
||||
@@ -1157,3 +1628,74 @@ func (a *API) EdgeFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
_ = a.GraphStore.UpdateEdgeTrustScores()
|
||||
respond(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GET /api/v1/popwatch — t_23b6a940 AC7:热门度看门狗快照(report-only 观测)。
|
||||
// 服务端记录「最终 rank1 的 recall_verified_count 连续越 p95 阈值」计数
|
||||
// (storage.PopWatch,观测点在 recallUncached 主路径漏斗末尾);本端点只读快照,
|
||||
// 不改排序、不写库。传感器端脚本(zhiyi-popwatch.py)读它转既有告警通道;
|
||||
// ZHIYI_POPWATCH=0 时 enabled=false(观测关闭)。
|
||||
func (a *API) PopWatch(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, storage.PopWatchSnapshot())
|
||||
}
|
||||
|
||||
// capRecallResults —— 2026-10-26 (t_9025b0eb) top_k 收口语义(纯函数,可单测)。
|
||||
//
|
||||
// 输入是 pipeline 返回的原始结果(主检索在前、图谱扩展 append 在后,扩展无上限)。
|
||||
// 输出:
|
||||
//
|
||||
// prim = 主检索结果(category != graph_expanded),截断到 limit;
|
||||
// 若主结果不足 limit,用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)。
|
||||
// expanded = 剩余扩展结果,单独成字段返回,上限 limit(能力可观测,但不挤占主结果)。
|
||||
//
|
||||
// 为什么不是「直接 results[:limit]」:扩展结果全部排在主结果之后,直接截断会让
|
||||
// category=graph_expanded 恒为 0 ⇒ 能力虽在跑但对外永久失明(本仓 2026-10 已因同类
|
||||
// 「接线≠出数」栽过一次)。所以既收口、又保留可观测通道。
|
||||
func capRecallResults(results []models.RecallResult, limit int) (prim, expanded []models.RecallResult) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
prim = make([]models.RecallResult, 0, limit)
|
||||
if len(results) == 0 {
|
||||
return prim, []models.RecallResult{}
|
||||
}
|
||||
exp := make([]models.RecallResult, 0, len(results))
|
||||
// 2026-10-26 (t_e98cb1d6):pattern / peer 是「附带注入」的召回条目(recall.go Step 6.2/6.3),
|
||||
// 追加在 results 末尾 ⇒ 若并入 prim 一起截断,distilled 占满 limit 时它们恒被切光
|
||||
// (16:28 topkcap 引入的 pattern 截断回归,limit=50 时 pattern=0 / limit=200 才现形 3 条)。
|
||||
// 正确语义:主检索结果(distilled 等)照常收口到 limit;pattern/peer 作为附带条目
|
||||
// 恒保留(不挤占主结果配额),避免「命中即注入」被 cap 吞掉。graph_expanded 仍走独立字段。
|
||||
var carry []models.RecallResult
|
||||
for _, r := range results {
|
||||
switch r.Category {
|
||||
case "graph_expanded":
|
||||
exp = append(exp, r)
|
||||
case "pattern", "peer":
|
||||
carry = append(carry, r)
|
||||
default:
|
||||
prim = append(prim, r)
|
||||
}
|
||||
}
|
||||
// 先收口主结果
|
||||
if len(prim) > limit {
|
||||
prim = prim[:limit]
|
||||
}
|
||||
// 主结果不足 limit 时用扩展结果按原顺序补齐(稀疏查询仍有扩展收益)
|
||||
if len(exp) > 0 {
|
||||
if room := limit - len(prim); room > 0 {
|
||||
if room > len(exp) {
|
||||
room = len(exp)
|
||||
}
|
||||
prim = append(prim, exp[:room]...)
|
||||
exp = exp[room:]
|
||||
}
|
||||
if len(exp) > limit {
|
||||
exp = exp[:limit]
|
||||
}
|
||||
}
|
||||
// pattern / peer 附带条目恒保留在 results 末尾(独立于主结果配额)
|
||||
prim = append(prim, carry...)
|
||||
if expanded == nil {
|
||||
expanded = exp
|
||||
}
|
||||
return prim, expanded
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// 2026-10-26 (t_9025b0eb):top_k 收口契约测试。
|
||||
// 起因:/api/v1/recall 完全无视 top_k —— top_k=5 实测返回 1905/1625 条(99% graph_expanded),
|
||||
// 插件记忆预取被撑到 103,299 chars 截断、count 失真、使用追踪按千级结果记账。
|
||||
// 钉住四件事:① results 不超过 limit ② 主结果不被扩展挤掉 ③ 扩展仍可见且不超 limit
|
||||
// ④ 稀疏查询(主结果 < limit)时扩展能补齐。
|
||||
func TestCapRecallResults(t *testing.T) {
|
||||
mk := func(n int, cat string) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
|
||||
}
|
||||
return out
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
prim, exp int
|
||||
limit int
|
||||
wantPrim int
|
||||
wantExp int
|
||||
primCategory string
|
||||
}{
|
||||
{"主结果足额_扩展不得进results", 10, 1900, 5, 5, 5, "distilled"},
|
||||
{"主结果只有2条_扩展补齐到limit", 2, 900, 5, 5, 5, "distilled"},
|
||||
{"无扩展_纯截断", 30, 0, 10, 10, 0, "distilled"},
|
||||
{"扩展少于limit", 1, 3, 5, 4, 0, "distilled"},
|
||||
{"空结果", 0, 0, 5, 0, 0, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
all := append(mk(c.prim, "distilled"), mk(c.exp, "graph_expanded")...)
|
||||
prim, exp := capRecallResults(all, c.limit)
|
||||
if len(prim) != c.wantPrim {
|
||||
t.Fatalf("prim=%d want %d", len(prim), c.wantPrim)
|
||||
}
|
||||
if len(exp) != c.wantExp {
|
||||
t.Fatalf("expanded=%d want %d", len(exp), c.wantExp)
|
||||
}
|
||||
if len(prim)+len(exp) > c.limit*2 {
|
||||
t.Fatalf("总量失控: prim=%d exp=%d", len(prim), len(exp))
|
||||
}
|
||||
// 主结果条数不得超过原始主结果数(不得凭空造)
|
||||
n := 0
|
||||
for _, r := range prim {
|
||||
if r.Category != "graph_expanded" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n > c.prim {
|
||||
t.Fatalf("主结果 %d 条,超过输入 %d 条(凭空造)", n, c.prim)
|
||||
}
|
||||
if c.primCategory != "" && c.prim > 0 && n == 0 {
|
||||
t.Fatalf("主结果被扩展挤光:%+v", prim)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// limit 非法(0/负数)时按默认 10 处理,不做无界返回。
|
||||
func TestCapRecallResultsDefaultLimit(t *testing.T) {
|
||||
all := make([]models.RecallResult, 50)
|
||||
for i := range all {
|
||||
all[i] = models.RecallResult{Category: "distilled"}
|
||||
}
|
||||
prim, _ := capRecallResults(all, 0)
|
||||
if len(prim) != 10 {
|
||||
t.Fatalf("limit=0 应回落 10,得到 %d", len(prim))
|
||||
}
|
||||
}
|
||||
|
||||
// ── t_e98cb1d6 回归:pattern / peer 被 cap 截断 ────────────────────────────
|
||||
// 病灶:capRecallResults 把非 graph_expanded 全部并入 prim 再截断到 limit;
|
||||
// pattern/peer 是 recall.go Step 6.2/6.3 追加到 results 末尾的附带条目,
|
||||
// distilled 占满 limit 时它们被整段切掉(16:28 topkcap 引入,limit=50 时 pattern=0)。
|
||||
// 修后:pattern/peer 恒保留在 results 末尾(独立于主结果配额),主结果仍照常收口。
|
||||
func TestCapRecallResults_PatternAndPeerSurviveCap(t *testing.T) {
|
||||
mk := func(n int, cat string) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: cat + string(rune('a'+i%26)), Category: cat, Content: cat}
|
||||
}
|
||||
return out
|
||||
}
|
||||
count := func(rs []models.RecallResult, cat string) int {
|
||||
n := 0
|
||||
for _, r := range rs {
|
||||
if r.Category == cat {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
d, p, pe, e int // distilled / pattern / peer / graph_expanded
|
||||
limit int
|
||||
wantD, wantP, wantPe int
|
||||
}{
|
||||
// ① 金标场景:主结果占满 limit,pattern/peer 追加在末尾 ⇒ 修前恒为 0
|
||||
{"limit=50_主结果占满_pattern 恒保留", 50, 3, 1, 0, 50, 50, 3, 1},
|
||||
{"limit=200_主结果占满_pattern 仍保留", 100, 3, 1, 97, 200, 100, 3, 1},
|
||||
// ② peer 注入同样不被截断
|
||||
{"limit=50_peer 注入不被截断", 50, 0, 2, 0, 50, 50, 0, 2},
|
||||
// ③ 主结果不足 limit:扩展先补齐主结果,carry 仍保留在末尾
|
||||
{"limit=50_主结果不足_扩展补齐+carry 保留", 30, 2, 1, 10, 50, 30, 2, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
all := make([]models.RecallResult, 0, c.d+c.p+c.pe+c.e)
|
||||
all = append(all, mk(c.d, "distilled")...)
|
||||
all = append(all, mk(c.p, "pattern")...)
|
||||
all = append(all, mk(c.pe, "peer")...)
|
||||
all = append(all, mk(c.e, "graph_expanded")...)
|
||||
prim, exp := capRecallResults(all, c.limit)
|
||||
d := count(prim, "distilled")
|
||||
p := count(prim, "pattern")
|
||||
pe := count(prim, "peer")
|
||||
e := count(prim, "graph_expanded")
|
||||
if d != c.wantD {
|
||||
t.Errorf("distilled=%d want %d", d, c.wantD)
|
||||
}
|
||||
// AC:limit=50 时 pattern 必须 ≥1(金标 query 复现终审现象)
|
||||
if c.p > 0 && p < 1 {
|
||||
t.Errorf("pattern=%d,应 ≥1(截断回归未修)", p)
|
||||
}
|
||||
if p != c.wantP {
|
||||
t.Errorf("pattern=%d want %d", p, c.wantP)
|
||||
}
|
||||
if pe != c.wantPe {
|
||||
t.Errorf("peer=%d want %d", pe, c.wantPe)
|
||||
}
|
||||
// cap 仍然生效:主结果(distilled + 扩展补齐)不得超过 limit
|
||||
if d+e > c.limit {
|
||||
t.Fatalf("主结果超 cap: distilled=%d graph_expanded=%d limit=%d", d, e, c.limit)
|
||||
}
|
||||
if len(exp) > c.limit {
|
||||
t.Fatalf("graph_expanded 字段超 cap: %d > %d", len(exp), c.limit)
|
||||
}
|
||||
// 附带条目不得被塞进 graph_expanded 字段
|
||||
if count(exp, "pattern") > 0 || count(exp, "peer") > 0 {
|
||||
t.Fatalf("pattern/peer 被误放入 graph_expanded 字段")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -12,6 +15,15 @@ import (
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// go:embed testdata/golden-set.json 把金标集打进二进制(t_dfc93ad0 AC5):
|
||||
// 部署 CWD=/home/muc,旧版「path=="" → 相对 testdata/」解析到 /home/muc/testdata
|
||||
// ⇒ POST /api/v1/eval/golden 不传 golden_path 时 500(2026-10-14 实测复现)。
|
||||
// 内嵌兜底后:缺省/相对路径读不到 ⇒ 回落内嵌基线(仍可用);env ZHIYI_GOLDEN_SET
|
||||
// 指定绝对路径 ⇒ 读该文件(可临时替换金标集)。测试仍按显式路径调用,行为不变。
|
||||
//
|
||||
//go:embed testdata/golden-set.json
|
||||
var embeddedGoldenSetJSON []byte
|
||||
|
||||
// ─── 金标集定义 ──────────────────────────────────────────────
|
||||
|
||||
// GoldenSetItem 单条金标:query + 具体 expected_ids + 关键词相关性判据
|
||||
@@ -19,9 +31,14 @@ type GoldenSetItem struct {
|
||||
Query string `json:"query"`
|
||||
ExpectedIDs []string `json:"expected_ids"`
|
||||
MustIncludeKeywords []string `json:"must_include_keywords"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Difficulty string `json:"difficulty,omitempty"`
|
||||
IDBaseline string `json:"id_baseline,omitempty"`
|
||||
// Namespace 该条目应在哪个 namespace 里召回。为空则用请求传入的 ns。
|
||||
// 为什么必须 per-item(2026-10-14 实测):金标的 expected_id 分散在
|
||||
// hermes-main / domain:HUB / soulful-xiaowei / wiki / shared 等多个 ns,
|
||||
// 而 eval 只按请求里的单一 ns 召回 ⇒ ID 类指标**按构造恒为 0**(不是召回差)。
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Difficulty string `json:"difficulty,omitempty"`
|
||||
IDBaseline string `json:"id_baseline,omitempty"`
|
||||
}
|
||||
|
||||
// GoldenSet 金标集
|
||||
@@ -30,15 +47,44 @@ type GoldenSet struct {
|
||||
Items []GoldenSetItem `json:"items"`
|
||||
}
|
||||
|
||||
// LoadGoldenSet 从 testdata/ 加载金标集,支持相对路径(生产用)和绝对路径(测试用)
|
||||
// LoadGoldenSet 加载金标集。路径解析(t_dfc93ad0 AC5,部署 CWD=/home/muc):
|
||||
// 1. env ZHIYI_GOLDEN_SET 指定路径 ⇒ 优先读它(临时替换金标集用,不重编译);
|
||||
// 2. 显式 path 非空且能读到 ⇒ 用它(绝对/相对皆按调用方语义);
|
||||
// 3. path 为空、或上面读不到(典型:部署二进制 CWD=/home/muc 无 testdata/,
|
||||
// 旧版相对路径解析失败 ⇒ 500)⇒ 回落 **内嵌** 基线 embeddedGoldenSetJSON。
|
||||
// 校验(空断言自守)在拿到内容后统一跑,三种来源同口径。
|
||||
func LoadGoldenSet(path string) (*GoldenSet, error) {
|
||||
if path == "" {
|
||||
// 编译后无法直接拿到源码路径,回退到 CWD 的 testdata/
|
||||
path = "testdata/golden-set.json"
|
||||
var data []byte
|
||||
usedSource := "env:ZHIYI_GOLDEN_SET"
|
||||
// 2026-10-26 修正优先级:显式 path > env > 内嵌。
|
||||
// 旧顺序(env 先)导致 ZHIYI_GOLDEN_SET 一旦设置,任何请求传 golden_path 都被静默忽略
|
||||
// ⇒ 实测「传 7 个 chunk 却每块都报 53 条」(env 指向的 53 条集)—— 评测对象与预期不符,
|
||||
// 且指标全是 0 时极难发现。env 现在只作「默认金标」,显式 path 必须能覆盖它。
|
||||
if path != "" {
|
||||
usedSource = "path:" + path
|
||||
d, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
data = d
|
||||
} else if filepath.IsAbs(path) {
|
||||
// 绝对路径 = 明确指名要跑这套题 ⇒ 读不到必须报错,不许静默换题
|
||||
return nil, fmt.Errorf("读 golden_path 失败(%s): %w", path, err)
|
||||
} else {
|
||||
// 相对路径在部署 CWD 下读不到 ⇒ 内嵌兜底(t_dfc93ad0 AC5 既有行为,保留)
|
||||
data = embeddedGoldenSetJSON
|
||||
usedSource = "embedded"
|
||||
}
|
||||
} else if p := os.Getenv("ZHIYI_GOLDEN_SET"); p != "" {
|
||||
var err error
|
||||
data, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
data = embeddedGoldenSetJSON
|
||||
usedSource = "embedded"
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("golden set 不可用(%s 为空且无内嵌基线)", usedSource)
|
||||
}
|
||||
var gs GoldenSet
|
||||
if err := json.Unmarshal(data, &gs); err != nil {
|
||||
@@ -346,13 +392,20 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Namespace string `json:"namespace"`
|
||||
GoldenPath string `json:"golden_path"` // 可选,默认 testdata/golden-set.json
|
||||
// AllNamespaces(2026-10-26)跨 namespace 评测开关。
|
||||
// 背景:旧逻辑 ns 为空 ⇒ 强制 "shared",而真实金标分散在 hermes-main/wiki/HUB 等多个 ns
|
||||
// ⇒ 评测跑的是"只看 shared"的召回,与线上跨 ns 召回不是同一个系统(实测前 15 条里 15 条结果不同)。
|
||||
// 语义:true ⇒ ns = ""(全库跨 ns,等同线上默认);false ⇒ 保持旧行为(默认 shared,向后兼容)。
|
||||
AllNamespaces bool `json:"all_namespaces"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
return
|
||||
}
|
||||
ns := req.Namespace
|
||||
if ns == "" {
|
||||
if req.AllNamespaces {
|
||||
ns = ""
|
||||
} else if ns == "" {
|
||||
ns = "shared"
|
||||
}
|
||||
gs, err := LoadGoldenSet(req.GoldenPath)
|
||||
@@ -379,7 +432,12 @@ func (ea *EvalAPI) RunGolden(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
results, err := ea.Pipeline.Recall(item.Query, ns, 5, 0.5)
|
||||
// per-item namespace:条目自带 ns 优先(金标基线是按 ns 钉的,见 GoldenSetItem.Namespace)
|
||||
itemNS := item.Namespace
|
||||
if itemNS == "" {
|
||||
itemNS = ns
|
||||
}
|
||||
results, err := ea.Pipeline.Recall(item.Query, itemNS, 5, 0.5)
|
||||
if err != nil {
|
||||
failures = append(failures, item.Query+": recall 失败 "+err.Error())
|
||||
keywordFail++
|
||||
|
||||
@@ -10,10 +10,32 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// liveAPIKey 取活服务密钥:优先 env ZHIYI_TEST_API_KEY;否则读密钥文件
|
||||
// /var/lib/memoryweave/.api-key 的主密钥(第 1 个非注释、非 legacy 行);
|
||||
// 再退到旧 dev 键(无生产密钥文件的环境,如 CI)。
|
||||
// 2026-09-21 密钥轮换后,旧硬编码键 zhiyi-dev-key-2026 已 401 —— 全量金标实跑
|
||||
// 永远 SKIP 的根因(t_fa6c9104 修复量具可用性;不改任何判据)。
|
||||
func liveAPIKey() string {
|
||||
if k := os.Getenv("ZHIYI_TEST_API_KEY"); k != "" {
|
||||
return k
|
||||
}
|
||||
if b, err := os.ReadFile("/var/lib/memoryweave/.api-key"); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "legacy:") {
|
||||
continue
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
return "zhiyi-dev-key-2026"
|
||||
}
|
||||
|
||||
// ─── recall 辅助:liveRecallOnce ─────────────────────────────
|
||||
// 一次 recall API 调用同时拿 top-5 的 ids + contents,避免两条 HTTP(限流保护)。
|
||||
// 每次调用间加 200ms 间隔,由调用方负责(TestEvalGolden_Live 里 sleep)。
|
||||
@@ -38,7 +60,7 @@ func liveRecallOnce(query string, maxRetries int, retryWait time.Duration) recal
|
||||
}
|
||||
req, _ := http.NewRequest("POST", "http://127.0.0.1:7821/api/v1/recall", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
req.Header.Set("X-API-Key", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("HTTP 请求失败: %v", err)
|
||||
@@ -218,13 +240,49 @@ func TestLoadGoldenSet_RejectsVacuousIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t_dfc93ad0 AC5 回归:部署二进制 CWD=/home/muc(无 testdata/),
|
||||
// POST /api/v1/eval/golden 不传 golden_path 时,旧版「相对 testdata/ 读不到」⇒ 500。
|
||||
// 修复后:缺省路径 / 读不到的路径 ⇒ 回落 **内嵌** 基线(//go:embed),仍可用。
|
||||
// 判据(可复算):在**没有 testdata/ 的 CWD** 里 LoadGoldenSet("") 与
|
||||
// LoadGoldenSet("testdata/golden-set.json")(相对路径读不到)都必须成功
|
||||
// 且返回 14 条 —— 即部署态「缺省命令可用」。
|
||||
func TestLoadGoldenSet_EmbedFallback_DeployCWD(t *testing.T) {
|
||||
empty := t.TempDir() // 模拟部署 CWD:里面没有 testdata/
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("取 CWD 失败: %v", err)
|
||||
}
|
||||
if err := os.Chdir(empty); err != nil {
|
||||
t.Fatalf("chdir 到空目录失败: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(orig) })
|
||||
|
||||
// ① 缺省(path=""):不传 golden_path 的部署态 ⇒ 必须成功 + 14 条(内嵌兜底)
|
||||
gs, err := LoadGoldenSet("")
|
||||
if err != nil {
|
||||
t.Fatalf("✗ AC5:缺省路径加载金标集失败(旧版这里 500): %v", err)
|
||||
}
|
||||
if len(gs.Items) != 14 {
|
||||
t.Fatalf("✗ AC5:内嵌基线期望 14 条,实际 %d", len(gs.Items))
|
||||
}
|
||||
// ② 相对路径读不到(部署 CWD 无 testdata/):也回落内嵌,不 500
|
||||
gs2, err := LoadGoldenSet(filepath.Join("testdata", "golden-set.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("✗ AC5:相对路径读不到时应回落内嵌基线而非报错: %v", err)
|
||||
}
|
||||
if len(gs2.Items) != 14 {
|
||||
t.Fatalf("✗ AC5:回落基线期望 14 条,实际 %d", len(gs2.Items))
|
||||
}
|
||||
t.Logf("✓ AC5:空 CWD(无 testdata/)下 缺省/相对路径 均回落内嵌基线(14 条),部署态缺省命令可用")
|
||||
}
|
||||
|
||||
// ─── AC① 反证:故意把某条 expected_id 改成错误 ID → 该条必须 FAIL ───
|
||||
// 这是本卡核心验收点:证明判据非恒真(改错期望 → 真 FAIL)
|
||||
|
||||
func TestEvalGolden_CounterEvidence(t *testing.T) {
|
||||
// 探活 zhiyid(无服务 → 跳过)
|
||||
healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil)
|
||||
healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
healthReq.Header.Set("X-API-Key", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(healthReq)
|
||||
if err != nil || resp == nil {
|
||||
t.Skipf("zhiyid 服务不可达(%v),跳过反证测试", err)
|
||||
@@ -300,7 +358,7 @@ func TestEvalGolden_CounterEvidence(t *testing.T) {
|
||||
func TestEvalGolden_Live(t *testing.T) {
|
||||
// 探活 zhiyid(无服务 → 跳过,不污染 CI)
|
||||
healthReq, _ := http.NewRequest("GET", "http://127.0.0.1:7821/api/v1/health", nil)
|
||||
healthReq.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
healthReq.Header.Set("X-API-Key", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(healthReq)
|
||||
if err != nil || resp == nil {
|
||||
t.Skipf("zhiyid 服务不可达(%v),跳过全量金标实跑", err)
|
||||
|
||||
@@ -23,6 +23,11 @@ func NewFeedbackAPI(ldb storage.LanceDB) *FeedbackAPI {
|
||||
func (fa *FeedbackAPI) MarkUseful(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
MemoryID string `json:"memory_id"`
|
||||
// 2026-10-24 (t_11245d30):区分人机信号。
|
||||
// 空 = 人工点「有用」(保持 user_feedback,行为不变);
|
||||
// "auto" = 插件自动反馈(_auto_feedback_for_reply)→ 记 auto_feedback,让 V 账可辨「牧尘认可 vs 机器自评」。
|
||||
// 纯观测,不改 V 权重/阈值/上限。
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
@@ -34,11 +39,15 @@ func (fa *FeedbackAPI) MarkUseful(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
selfoptimize.Dash.RecordFeedback(true)
|
||||
// V 值:useful = success
|
||||
// V 值:useful = success。action 名按来源区分(人工 user_feedback / 自动 auto_feedback)。
|
||||
action := "user_feedback"
|
||||
if req.Source == "auto" {
|
||||
action = "auto_feedback"
|
||||
}
|
||||
selfoptimize.VProp.RecordDecision(
|
||||
"useful_"+req.MemoryID,
|
||||
[]string{req.MemoryID},
|
||||
"user_feedback", "success", "",
|
||||
action, "success", "",
|
||||
)
|
||||
if err := fa.LanceDB.IncrementUseful(req.MemoryID); err != nil {
|
||||
log.Printf("[feedback] IncrementUseful failed %s: %v", req.MemoryID, err)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package routes — 遗忘保护守卫端到端回归测试(t_944344b5,AC1/AC2)。
|
||||
//
|
||||
// 2026-10-16 (t_944344b5):证明「decay/auto_forget 的判定道(admin Forget 路由)命中保护
|
||||
// 规则的候选**不被** SoftDelete,而非保护的衰减候选**照常**被软删」。
|
||||
//
|
||||
// 必须走**真实判定路径**(routes.NewAdminAPI.Forget),不能只测 memprotect.IsProtected,
|
||||
// 因为守卫是在 decay 循环 / admin Forget 路由里 SoftDelete 之前拦截的(见 server.go / admin.go)。
|
||||
//
|
||||
// 跑法(A/B 纪律:复制数据目录 + 独立 socket,绝不碰生产 /var/lib/memoryweave):
|
||||
// 1. cp -a /var/lib/memoryweave /tmp/mw-prot-copy-<ts>
|
||||
// 2. zhiyi-consolidate --mode socket --socket /tmp/mw-prot.sock \
|
||||
// --data-dir /tmp/mw-prot-copy-<ts> &
|
||||
// 3. PROT_TEST_SOCK=/tmp/mw-prot.sock \
|
||||
// go test ./internal/api/routes/ -run TestForgetGuardE2E -v -count=1
|
||||
//
|
||||
// 未设 PROT_TEST_SOCK → 自动跳过(可安全留在 CI)。
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/api/routes"
|
||||
)
|
||||
|
||||
func TestForgetGuardE2E(t *testing.T) {
|
||||
sock := os.Getenv("PROT_TEST_SOCK")
|
||||
if sock == "" {
|
||||
t.Skip("PROT_TEST_SOCK 未设置,跳过(需先起 test sidecar 指向复制的数据目录)")
|
||||
}
|
||||
|
||||
// 让遗忘窗口游标指到临时文件(缺失 → 空游标 → 从最小 id 起轮转),
|
||||
// 保证我们构造的 `mem_0...` 行(id 最小)落在首个 2000 窗口内。
|
||||
// 不碰生产 /var/lib/memoryweave/forget-cursor.json。
|
||||
cursorDir := t.TempDir()
|
||||
cursorFile := cursorDir + "/test-forget-cursor.json"
|
||||
t.Setenv("ZHIYI_FORGET_CURSOR", cursorFile)
|
||||
|
||||
// embedder 传 nil:记录自带 1024 维 Vector,走不到编码分支(同 conf_e2e_test.go 做法)。
|
||||
rc := storage.NewRustLanceDBClient(sock, nil)
|
||||
f := governance.NewForgetter()
|
||||
aa := routes.NewAdminAPI(rc, f, nil) // GraphStore 传 nil:extractTopEntityDegree 空安全
|
||||
|
||||
// ── 构造 4 条测试记忆(id 都以 mem_0 开头 → 字典序最小 → 必在首窗口)──
|
||||
vec := make([]float32, 1024)
|
||||
for i := range vec {
|
||||
vec[i] = 0.001
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
mk := func(id, category, content, freshness string, daysAgo int) models.MemoryRecord {
|
||||
lr := now.Add(-time.Duration(daysAgo) * 24 * time.Hour)
|
||||
return models.MemoryRecord{
|
||||
ID: id, AgentID: "hermes-a06", Namespace: "prot-test",
|
||||
Content: content, Category: category, Vector: vec,
|
||||
Tier: "normal", Importance: 0.5, QualityScore: 0.5,
|
||||
Version: 1, Freshness: freshness,
|
||||
Source: "wiki:/tmp/forget-guard-e2e.md",
|
||||
EmbeddingModel: "bge-m3", ScriptVersion: "forget-guard-e2e",
|
||||
CreatedAt: lr, UpdatedAt: lr, LastRecalledAt: lr,
|
||||
RecallVerifiedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// P1:规则3 freshness=verified 保护。content <30 字 + >20 天 ⇒ 本应走碎片快速道被删,
|
||||
// 但 freshness=verified ⇒ 守卫拦截 ⇒ 存活。
|
||||
p1 := mk("mem_0aaaa_p1", "distilled", "verified fact, kept", "verified", 30)
|
||||
// P2:规则1 category=decision 保护。
|
||||
p2 := mk("mem_0aaaa_p2", "decision", "short decision note", "fresh", 30)
|
||||
// P3:规则4 content 关键词「牧尘要求」保护。
|
||||
p3 := mk("mem_0aaaa_p3", "distilled", "牧尘要求:部署前必须先备份", "fresh", 30)
|
||||
// N1:不命中任何规则(distilled / fresh / 无关键词 / 低 V / 不在名单)⇒ 应被碎片快速道软删。
|
||||
n1 := mk("mem_0aaaa_n1", "distilled", "hi", "fresh", 45)
|
||||
|
||||
for _, m := range []models.MemoryRecord{p1, p2, p3, n1} {
|
||||
if err := rc.InsertMemory(m); err != nil {
|
||||
t.Fatalf("InsertMemory(%s) 失败: %v", m.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 跑真实判定路径:admin Forget 路由(= decay/auto_forget 决策 + 守卫 + SoftDelete)──
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/forget", nil)
|
||||
w := httptest.NewRecorder()
|
||||
aa.Forget(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("admin forget expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
t.Logf("admin forget 响应: %v", resp)
|
||||
|
||||
// ── 断言 ──
|
||||
// 读路径语义(rust lancedb_ops.rs):
|
||||
// scan_all → 仅 is_deleted=false(存活集,无 5000 上限)
|
||||
// scan_deleted → 仅 is_deleted=true(已删集,带 delete_reason)
|
||||
// 故 P1/P2/P3 应出现在 ScanAll 里;N1 应不在 ScanAll、而在 ScanDeleted 里。
|
||||
all, err := rc.ScanAll()
|
||||
if err != nil {
|
||||
t.Fatalf("ScanAll 失败: %v", err)
|
||||
}
|
||||
liveSet := map[string]bool{}
|
||||
for _, m := range all {
|
||||
liveSet[m.ID] = true // scan_all 只回 is_deleted=false
|
||||
}
|
||||
for _, id := range []string{p1.ID, p2.ID, p3.ID} {
|
||||
if !liveSet[id] {
|
||||
t.Errorf("❌ 受保护记忆 %s 应存活(ScanAll 命中),实际不在 —— 守卫失效", id)
|
||||
}
|
||||
}
|
||||
if liveSet[n1.ID] {
|
||||
t.Errorf("❌ 非保护衰减记忆 %s 应被软删(不在 ScanAll),实际仍存活 —— 回归异常", n1.ID)
|
||||
}
|
||||
|
||||
deleted, derr := rc.ScanDeleted()
|
||||
if derr != nil {
|
||||
t.Fatalf("ScanDeleted 失败: %v", derr)
|
||||
}
|
||||
deletedSet, n1Reason := map[string]bool{}, ""
|
||||
for _, m := range deleted {
|
||||
deletedSet[m.ID] = true
|
||||
if m.ID == n1.ID {
|
||||
n1Reason = m.DeleteReason
|
||||
}
|
||||
}
|
||||
if !deletedSet[n1.ID] {
|
||||
t.Errorf("❌ 非保护衰减记忆 %s 应出现在 ScanDeleted(is_deleted=true),实际不在", n1.ID)
|
||||
}
|
||||
if t.Failed() {
|
||||
return
|
||||
}
|
||||
// 受保护 3 条存活 + 非保护 1 条被删,打印结论
|
||||
t.Logf("✅ AC1 3 条命中保护规则(freshness=verified / category=decision / 关键词)全部存活于 ScanAll")
|
||||
t.Logf("✅ AC2 非保护衰减碎片 %s 照常软删(在 ScanDeleted)", n1.ID)
|
||||
// 取证:N1 的 delete_reason(auto_forget*/fragment 经 NormalizeDeleteReason → retention)
|
||||
t.Logf("取证: %s delete_reason=%q", n1.ID, n1Reason)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package routes
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
)
|
||||
@@ -32,17 +33,31 @@ func (ga *GapAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/gaps/{topic}/close
|
||||
// POST /api/v1/gaps/close/{topic}(现行前缀注册 /api/v1/gaps/close/;亦兼容 {topic} 通配注册)
|
||||
func (ga *GapAPI) Close(w http.ResponseWriter, r *http.Request) {
|
||||
topic := r.PathValue("topic")
|
||||
if topic == "" {
|
||||
// 现行注册是前缀路由(模式无 {topic} 通配 ⇒ PathValue 恒为 "",端点此前恒 400):
|
||||
// 回落剥前缀取 topic。若日后改注册为 /api/v1/gaps/{topic}/close,PathValue 直接命中。
|
||||
const prefix = "/api/v1/gaps/close/"
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
topic = r.URL.Path[len(prefix):]
|
||||
}
|
||||
}
|
||||
if topic == "" {
|
||||
respondError(w, 400, "topic required")
|
||||
return
|
||||
}
|
||||
|
||||
ga.Detector.Close(topic)
|
||||
selfoptimize.Dash.RecordGapClosed()
|
||||
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
|
||||
// t_e5997ca4:计数口径 = 「缺口个数」。+1 只发生在 GapDetector 的 open→closed 迁移点
|
||||
// (Close 内部);旧的无条件 selfoptimize.Dash.RecordGapClosed() 已删除 —— 那是同一
|
||||
// 缺口可被无限计数、ClosedGaps(9) 越过 TotalGaps(2) 的写侧根因 #1。
|
||||
switch ga.Detector.Close(topic) {
|
||||
case true:
|
||||
respond(w, 200, map[string]string{"status": "closed", "topic": topic})
|
||||
default:
|
||||
respond(w, 200, map[string]string{"status": "already_closed", "topic": topic})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/gaps/detect — 手动触发缺口检测(miss 计数)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 织忆 MemoryWeave — gap 关闭端点写侧回归(t_e5997ca4 卡 AC2)
|
||||
//
|
||||
// POST /close 打两回(httptest)⇒ gap 计数器只 +1;auto 等其余计数器不受影响。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
)
|
||||
|
||||
// makeGapTopic 3 次 miss 命中默认阈值 3 ⇒ 生成缺口(TotalGaps +1)。
|
||||
func makeGapTopic(t *testing.T, gd *selfoptimize.GapDetector, topic string) {
|
||||
t.Helper()
|
||||
var gap *selfoptimize.Gap
|
||||
for i := 0; i < 3; i++ {
|
||||
if gap = gd.RecordMiss(topic); gap != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if gap == nil {
|
||||
t.Fatalf("expected gap for %q after 3 misses", topic)
|
||||
}
|
||||
}
|
||||
|
||||
func postGapClose(t *testing.T, url string) (int, map[string]string) {
|
||||
t.Helper()
|
||||
resp, err := http.Post(url, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("POST %s: 非 JSON 响应 %q: %v", url, b, err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
// 卡 AC2:现行注册形态(前缀路由,topic 在 /api/v1/gaps/close/ 之后)打两回 ⇒ 只 +1。
|
||||
func TestGapAPI_CloseTwice_CounterOnlyOnce(t *testing.T) {
|
||||
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||
makeGapTopic(t, gd, "close-twice-topic")
|
||||
|
||||
baseClosed := selfoptimize.Dash.ClosedGapsCount()
|
||||
baseTotal := selfoptimize.Dash.TotalGapsCount()
|
||||
baseMetrics := selfoptimize.Dash.Metrics() // auto_* 快照(应不受影响)
|
||||
|
||||
api := NewGapAPI(gd)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/gaps/close/", api.Close) // 与 server.go:1205 现行注册同形
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
code1, body1 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
|
||||
if code1 != 200 || body1["status"] != "closed" {
|
||||
t.Fatalf("第 1 次 POST: got %d %v, want 200 closed", code1, body1)
|
||||
}
|
||||
code2, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/close/close-twice-topic")
|
||||
if code2 != 200 || body2["status"] != "already_closed" {
|
||||
t.Fatalf("第 2 次 POST: got %d %v, want 200 already_closed(不得再动计数)", code2, body2)
|
||||
}
|
||||
|
||||
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
|
||||
t.Fatalf("2× POST close 后 ClosedGaps 增量: got %d, want 1(改前为 +3 ⇒ 虚增)", got)
|
||||
}
|
||||
if got := selfoptimize.Dash.TotalGapsCount(); got != baseTotal {
|
||||
t.Errorf("TotalGaps 不应被 close 端点移动: got %d want %d", got, baseTotal)
|
||||
}
|
||||
|
||||
after := selfoptimize.Dash.Metrics()
|
||||
if after["gap_closure_rate"] > 1 {
|
||||
t.Errorf("gap_closure_rate = %f 越界", after["gap_closure_rate"])
|
||||
}
|
||||
// auto(冲突)计数器与 gap 关闭事件无关 ⇒ 快照不变(「auto 不适用」)
|
||||
for _, k := range []string{"auto_resolve_rate", "auto_resolve_samples", "auto_resolve_valid"} {
|
||||
if after[k] != baseMetrics[k] {
|
||||
t.Errorf("auto 计数器被 gap 关闭污染: %s got %f want %f", k, after[k], baseMetrics[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// {topic} 通配注册形态(若日后把 server.go 注册改成 /api/v1/gaps/{topic}/close):
|
||||
// PathValue 直接命中,计数语义不变(+1 只在首次)。
|
||||
func TestGapAPI_CloseWildcardRegistration(t *testing.T) {
|
||||
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||
makeGapTopic(t, gd, "wildcard-topic")
|
||||
|
||||
baseClosed := selfoptimize.Dash.ClosedGapsCount()
|
||||
|
||||
api := NewGapAPI(gd)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/gaps/{topic}/close", api.Close)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
code, body := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close")
|
||||
if code != 200 || body["status"] != "closed" {
|
||||
t.Fatalf("通配注册第 1 次 POST: got %d %v, want 200 closed", code, body)
|
||||
}
|
||||
if _, body2 := postGapClose(t, ts.URL+"/api/v1/gaps/wildcard-topic/close"); body2["status"] != "already_closed" {
|
||||
t.Errorf("通配注册第 2 次 POST: got %v, want already_closed", body2)
|
||||
}
|
||||
if got := selfoptimize.Dash.ClosedGapsCount() - baseClosed; got != 1 {
|
||||
t.Fatalf("通配注册 2× POST 后 ClosedGaps 增量: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 织忆 MemoryWeave — /api/v1/memory/write 显式直写 memories 表(主症A,t_fa6c9104,2026-10-16)
|
||||
//
|
||||
// 锁定行为:
|
||||
//
|
||||
// W1 写入落 memories 表:InsertMemory 被调用,id 前缀 mem_,tier=normal,quality=1.0,
|
||||
// 且向量非全 0(嵌入服务故障时不写零向量记录)
|
||||
// W2 category 缺省 distilled(recall Step 2.5 rawCategories 不过滤它)
|
||||
// W3 嵌入失败 ⇒ 502 且不写(零向量 = 主症A 病根,禁静默写)
|
||||
// W4 精确重复 ⇒ merged(不新增行)
|
||||
// W5 短内容 <8 字 ⇒ 400;GET ⇒ 405
|
||||
// W6 零回归:commit 路径仍只写 episodes、不写 memories(AC5)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// recLance 记录型 LanceDB mock:断言 InsertMemory/Update 的实际调用与载荷。
|
||||
// 复用 zz_commit_conflict_gate_test.go 的 mockGraph / newMockEmbedServer / newMockLLM / commitWith(同包)。
|
||||
type recLance struct {
|
||||
storage.LanceDB // 未实现的方法落到接口兜底(panic 即测试失败,不放任)
|
||||
|
||||
mu sync.Mutex
|
||||
seeds []models.MemoryRecord // 预置「库里已有」的记忆(Search 返回)
|
||||
inserted []models.MemoryRecord // 实际 InsertMemory 调用(W1/W3/W4 断言)
|
||||
updates []string // Update(table,id,...) 的 id 序列(merge 断言)
|
||||
failIns bool // 模拟 IPC 落库失败(500 断言)
|
||||
}
|
||||
|
||||
func (r *recLance) Search(table string, vector []float32, topK int, ns string) ([]models.MemoryRecord, error) {
|
||||
if table != "memories" {
|
||||
return nil, nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := make([]models.MemoryRecord, 0, len(r.seeds))
|
||||
out = append(out, r.seeds...)
|
||||
if topK > 0 && len(out) > topK {
|
||||
out = out[:topK]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *recLance) InsertMemory(m models.MemoryRecord) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.failIns {
|
||||
return errors.New("ipc down (simulated)")
|
||||
}
|
||||
r.inserted = append(r.inserted, m)
|
||||
r.seeds = append(r.seeds, m) // 后续 Search 能看见(近重复语义)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recLance) Update(table, id string, fields map[string]any) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.updates = append(r.updates, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *recLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return "ep_rec_" + strconv.Itoa(len(r.inserted)), nil
|
||||
}
|
||||
|
||||
func newRecAPI(t *testing.T, seeds ...models.MemoryRecord) (*API, *recLance) {
|
||||
t.Helper()
|
||||
emb := newMockEmbedServer()
|
||||
t.Cleanup(emb.Close)
|
||||
ldb := &recLance{seeds: seeds}
|
||||
return NewAPI(ldb, storage.NewEmbedder(emb.URL), nil, governance.NewConflictDetector(), mockGraph{}), ldb
|
||||
}
|
||||
|
||||
func writeMemRequest(t *testing.T, api *API, body map[string]any) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/api/v1/memory/write", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
api.WriteMemory(w, req)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
return w.Code, resp
|
||||
}
|
||||
|
||||
// W1+W2:写入落 memories 表,mem_ 前缀,category 缺省 distilled,向量非全 0
|
||||
func TestWriteMemory_InsertLandsInMemories(t *testing.T) {
|
||||
api, ldb := newRecAPI(t)
|
||||
code, resp := writeMemRequest(t, api, map[string]any{
|
||||
"content": "牧尘的工作所在省份是河南省",
|
||||
"agent_id": "hermes-a06",
|
||||
})
|
||||
if code != 201 {
|
||||
t.Fatalf("expected 201, got %d: %s", code, resp)
|
||||
}
|
||||
if resp["status"] != "ok" || resp["table"] != "memories" {
|
||||
t.Errorf("expected status=ok table=memories, got %v", resp)
|
||||
}
|
||||
if !strings.HasPrefix(resp["memory_id"].(string), "mem_") {
|
||||
t.Errorf("memory_id 须 mem_ 前缀(落 memories 表),got %v", resp["memory_id"])
|
||||
}
|
||||
if resp["category"] != "distilled" {
|
||||
t.Errorf("category 缺省须 distilled(Step 2.5 不过滤),got %v", resp["category"])
|
||||
}
|
||||
if len(ldb.inserted) != 1 {
|
||||
t.Fatalf("expected exactly 1 InsertMemory, got %d", len(ldb.inserted))
|
||||
}
|
||||
m := ldb.inserted[0]
|
||||
if m.Category != "distilled" || m.Tier != "normal" || m.QualityScore != 1.0 {
|
||||
t.Errorf("row 字段: category=%s tier=%s quality=%v(期望 distilled/normal/1.0)", m.Category, m.Tier, m.QualityScore)
|
||||
}
|
||||
if m.Namespace != "hermes-main" {
|
||||
t.Errorf("namespace 推导: got %s 期望 hermes-main(hermes-a06 ⇒ deriveNamespace)", m.Namespace)
|
||||
}
|
||||
if len(m.Vector) != 1024 {
|
||||
t.Fatalf("vector 维度须 1024,got %d", len(m.Vector))
|
||||
}
|
||||
// W1 核心判据:vector 非全 0
|
||||
nonzero := 0
|
||||
for _, f := range m.Vector {
|
||||
if f != 0 {
|
||||
nonzero++
|
||||
}
|
||||
}
|
||||
if nonzero == 0 {
|
||||
t.Errorf("向量全 0 —— 主症A 病根复现(禁写零向量记录)")
|
||||
}
|
||||
}
|
||||
|
||||
// W3:嵌入失败 ⇒ 502 且不写库(fail-close,不静默写零向量)
|
||||
func TestWriteMemory_EmbedFail_502_NoWrite(t *testing.T) {
|
||||
failEmb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "embed backend down", http.StatusInternalServerError)
|
||||
}))
|
||||
defer failEmb.Close()
|
||||
ldb := &recLance{}
|
||||
api := NewAPI(ldb, storage.NewEmbedder(failEmb.URL), nil, governance.NewConflictDetector(), mockGraph{})
|
||||
code, resp := writeMemRequest(t, api, map[string]any{"content": "牧尘的工作所在省份是河南省"})
|
||||
if code != 502 {
|
||||
t.Fatalf("embed 失败须 502,got %d: %v", code, resp)
|
||||
}
|
||||
if len(ldb.inserted) != 0 {
|
||||
t.Errorf("嵌入失败不得写库(零向量 = 主症A),inserted=%d", len(ldb.inserted))
|
||||
}
|
||||
}
|
||||
|
||||
// W4:精确重复 ⇒ merged,不新增行
|
||||
func TestWriteMemory_ExactDupMerges(t *testing.T) {
|
||||
seed := models.MemoryRecord{ID: "mem_seed", Content: "家用服务器地址是 192.168.123.11", Category: "distilled"}
|
||||
api, ldb := newRecAPI(t, seed)
|
||||
code, resp := writeMemRequest(t, api, map[string]any{"content": seed.Content})
|
||||
if code != 200 || resp["status"] != "merged" {
|
||||
t.Fatalf("expected 200 merged, got %d %v", code, resp)
|
||||
}
|
||||
if len(ldb.inserted) != 0 {
|
||||
t.Errorf("精确重复不得新增行,inserted=%d", len(ldb.inserted))
|
||||
}
|
||||
if len(ldb.updates) == 0 || ldb.updates[0] != "mem_seed" {
|
||||
t.Errorf("merge 须 Update 既有行 mem_seed,updates=%v", ldb.updates)
|
||||
}
|
||||
}
|
||||
|
||||
// W5:短内容 400;GET 405
|
||||
func TestWriteMemory_Validation(t *testing.T) {
|
||||
api, _ := newRecAPI(t)
|
||||
if c, _ := writeMemRequest(t, api, map[string]any{"content": "太短了"}); c != 400 {
|
||||
t.Errorf("短内容(<8字)须 400, got %d", c)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v1/memory/write", nil)
|
||||
w := httptest.NewRecorder()
|
||||
api.WriteMemory(w, req)
|
||||
if w.Code != 405 {
|
||||
t.Errorf("GET 须 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 落库失败(IPC down)⇒ 500 不吞错
|
||||
func TestWriteMemory_InsertFail_500(t *testing.T) {
|
||||
api, ldb := newRecAPI(t)
|
||||
ldb.failIns = true
|
||||
code, _ := writeMemRequest(t, api, map[string]any{"content": "测试落库失败场景内容足够长"})
|
||||
if code != 500 {
|
||||
t.Fatalf("InsertMemory 失败须 500(不吞错),got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// W6 零回归:commit 路径仍「只写 episodes、不写 memories」(等蒸馏)
|
||||
func TestCommit_StillEpisodesOnly_ZeroRegression(t *testing.T) {
|
||||
api, ldb := newRecAPI(t)
|
||||
llm := newMockLLM()
|
||||
defer llm.server.Close()
|
||||
oldRef := DistillEngineRef
|
||||
DistillEngineRef = distill.NewEngine(llm.server.URL, "mock-model", "")
|
||||
defer func() { DistillEngineRef = oldRef }()
|
||||
|
||||
// 复用 commitWith(同包 zz 测试):普通对话提交
|
||||
resp := commitWith(t, api, "今天天气不错,我们去楼下散步", false)
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("commit 基线须 status=ok,got %v", resp)
|
||||
}
|
||||
if resp["memory_id"] != "" {
|
||||
t.Errorf("commit 不在写入时创建 memory(AC5 零回归),got %v", resp["memory_id"])
|
||||
}
|
||||
if len(ldb.inserted) != 0 {
|
||||
t.Errorf("commit 不得直写 memories 表(零回归),inserted=%d", len(ldb.inserted))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package routes
|
||||
|
||||
// t_5ad3f843 AC8:handler 级门禁验证。
|
||||
// 门禁开 ⇒ meta-note 在**触碰 storage 之前**被 422 拒绝:
|
||||
// 零值 API{}(LanceDB 接口为 nil)足证——若门禁按设计在写路径前生效,
|
||||
// 422 先行返回、不会 nil 解引用 panic;若门禁失效走到 InsertEpisode ⇒ panic。
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/metanote"
|
||||
)
|
||||
|
||||
func TestCommitGate_MetaRejectedBeforeStorage(t *testing.T) {
|
||||
old := os.Getenv("ZHIYI_META_FILTER")
|
||||
os.Setenv("ZHIYI_META_FILTER", "1")
|
||||
defer os.Setenv("ZHIYI_META_FILTER", old)
|
||||
|
||||
a := &API{} // 零值:LanceDB 为 nil —— 422 先于写路径 ⇒ 不 panic
|
||||
body := `{"agent_id":"hermes-a06","namespace":"hermes-main","content":"实测 淇县 ANN 731→15(都进池50)","category":"system_fact"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/commit", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
a.Commit(w, req)
|
||||
if w.Code != 422 {
|
||||
t.Fatalf("commit(meta, 门禁开) 期望 422,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "meta-note") {
|
||||
t.Errorf("拒绝体应说明 meta-note 原因: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 门禁关 ⇒ 同一内容 ShouldSkip 必须 false(AC6 零行为变化)
|
||||
os.Setenv("ZHIYI_META_FILTER", "")
|
||||
if skip, _ := metanote.ShouldSkip("实测 淇县 ANN 731→15(都进池50)"); skip {
|
||||
t.Fatal("门禁关闭时 ShouldSkip 必须为 false(零行为变化)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMemoryGate_MetaRejectedBeforeStorage(t *testing.T) {
|
||||
old := os.Getenv("ZHIYI_META_FILTER")
|
||||
os.Setenv("ZHIYI_META_FILTER", "1")
|
||||
defer os.Setenv("ZHIYI_META_FILTER", old)
|
||||
|
||||
a := &API{}
|
||||
body := `{"agent_id":"hermes-a06","content":"织忆 golden 14/14 不退化,recall@5 = 0.4286"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/memory/write", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
a.WriteMemory(w, req)
|
||||
if w.Code != 422 {
|
||||
t.Fatalf("write_memory(meta, 门禁开) 期望 422,实际 %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// 织忆 MemoryWeave — L2 模式 API(t_d32e5f8a)
|
||||
//
|
||||
// 设计 §4.1 端点:
|
||||
// POST /api/v1/patterns/detect 手动触发模式检测(扫 L1 蒸馏记忆 → 工具组合频率 + 路径聚类
|
||||
// → 写 graph_nodes type='pattern')。短命单发:modernc sqlite
|
||||
// 开/关不占长连接、不碰 state.db(规避当年 daemon main_loop
|
||||
// 退役根因)—— 可挂 cron 单发。
|
||||
// GET /api/v1/patterns 列出检测到的模式(按 source_tasks 长度降序)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/skillgen"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// PatternAPI L2 模式端点 + L3 skill 草稿端点。
|
||||
type PatternAPI struct {
|
||||
Store *pattern.GraphPatternStore
|
||||
Provider *pattern.PatternProvider
|
||||
LDB func() storage.LanceDB // 扫 L1 记忆(distilled)
|
||||
Cfg pattern.Config
|
||||
|
||||
// L3 skill 草稿(t_fcadee04):草稿生成器 + skills 表存储 + 落盘根目录。
|
||||
// 由 server.go 装配;不装配时 Draft/Promote 返回 503/404(显式 API,不挂 cron)。
|
||||
SkillStore *skillgen.Store
|
||||
Generator skillgen.Generator
|
||||
AttachmentsDir string
|
||||
}
|
||||
|
||||
// NewPatternAPI 构造 L2 模式端点。
|
||||
func NewPatternAPI(store *pattern.GraphPatternStore, provider *pattern.PatternProvider, ldb func() storage.LanceDB) *PatternAPI {
|
||||
cfg := pattern.DefaultConfig()
|
||||
// cron 单发跑大批量时可放宽扫描上限
|
||||
if v := os.Getenv("ZHIYI_PATTERN_MAX_RECORDS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
cfg.MaxRecords = n
|
||||
}
|
||||
}
|
||||
return &PatternAPI{Store: store, Provider: provider, LDB: ldb, Cfg: cfg}
|
||||
}
|
||||
|
||||
// GET /api/v1/patterns — 列出所有检测到的模式(AC1:空库返回 count=0 不报错)。
|
||||
func (pa *PatternAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit := pa.Cfg.TopKPatternList
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
pats, _, err := pa.Store.ListPatterns(limit)
|
||||
if err != nil {
|
||||
respondError(w, 500, "list patterns: "+err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"patterns": pats,
|
||||
"count": len(pats),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/detect — 手动触发模式检测。
|
||||
// body 可选 {"limit": N, "namespace": "hermes-main"}。
|
||||
// 流程:LanceDB Scan(limit, ns) → pattern.DetectFromRecords → UpsertPatterns(graph_nodes)。
|
||||
// 空库/无命中 → created/updated=0,不报错(AC1)。
|
||||
func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Limit int `json:"limit"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req) // 空 body 也合法(用默认)
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = pa.Cfg.MaxRecords
|
||||
}
|
||||
ldb := pa.LDB()
|
||||
if ldb == nil {
|
||||
respondError(w, 503, "lancedb unavailable")
|
||||
return
|
||||
}
|
||||
records, err := ldb.Scan(req.Limit, req.Namespace)
|
||||
if err != nil {
|
||||
respondError(w, 500, "scan memories: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 只拿蒸馏产物(L1 蒸馏记忆)做检测——原始对话/episode 不是「已验证」的解决路径
|
||||
// (与召回管线 2.5 步同源判据:raw/episodes 类不参与模式沉淀)。
|
||||
distilled := make([]models.MemoryRecord, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if rec.Category == "distilled" || rec.Category == "" {
|
||||
distilled = append(distilled, rec)
|
||||
}
|
||||
}
|
||||
pats := pattern.DetectFromRecords(distilled, pa.Cfg)
|
||||
created, updated, uerr := pa.Store.UpsertPatterns(pats)
|
||||
if uerr != nil {
|
||||
respondError(w, 500, "upsert patterns: "+uerr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"scanned": len(records),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"detected": len(pats),
|
||||
"patterns": pats,
|
||||
})
|
||||
}
|
||||
|
||||
// ── L3 skill 草稿(t_fcadee04,设计 §4.2 SkillGenerator)────────────────────
|
||||
|
||||
// patternSubPath 解析 /api/v1/patterns/{id}/{action} 子路径。
|
||||
// 返回 (url-unescape 后的 id, action)。格式不符 → ("", action=="")。
|
||||
func patternSubPath(path string) (id, action string) {
|
||||
const prefix = "/api/v1/patterns/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix) // <id>/draft 或 <id>/promote
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "", ""
|
||||
}
|
||||
dec, err := url.PathUnescape(parts[0])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return dec, parts[1]
|
||||
}
|
||||
|
||||
// Sub 分发 /api/v1/patterns/{id}/draft 与 /api/v1/patterns/{id}/promote。
|
||||
// 装配在 server.go 的 /api/v1/patterns/ 前缀路由;List(/patterns) 与 Detect(/patterns/detect)
|
||||
// 因更长的精确路由优先,不受影响。
|
||||
func (pa *PatternAPI) Sub(w http.ResponseWriter, r *http.Request) {
|
||||
id, action := patternSubPath(r.URL.Path)
|
||||
if id == "" {
|
||||
respondError(w, 404, "pattern id required")
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "draft":
|
||||
pa.Draft(w, r, id)
|
||||
case "promote":
|
||||
pa.Promote(w, r, id)
|
||||
default:
|
||||
respondError(w, 404, "unknown action: "+action)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/{id}/draft — 生成 L3 skill 草稿(conf≥0.85 才生成)。
|
||||
// 流程:读 pattern → 校验 conf → DraftSkill(写 skills 表 draft + 落盘 attachments)。
|
||||
// 草稿绝不进 skills/ 技能库(红线)。
|
||||
func (pa *PatternAPI) Draft(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.SkillStore == nil || pa.Generator == nil {
|
||||
respondError(w, 503, "skillgen 未装配(SkillStore/Generator 为空)")
|
||||
return
|
||||
}
|
||||
pat, err := pa.Store.GetPattern(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "pattern not found: "+id)
|
||||
} else {
|
||||
respondError(w, 500, "get pattern: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
attRoot := pa.AttachmentsDir
|
||||
if attRoot == "" {
|
||||
attRoot = skillgen.DefaultAttachmentsRoot()
|
||||
}
|
||||
sk, path, derr := skillgen.DraftSkill(pa.SkillStore, pa.Generator, pat, attRoot)
|
||||
if derr != nil {
|
||||
respondError(w, 400, "draft skill: "+derr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"skill": sk,
|
||||
"file": path,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/{id}/promote — 人工确认后 draft→active。
|
||||
func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.SkillStore == nil {
|
||||
respondError(w, 503, "skillgen 未装配(SkillStore 为空)")
|
||||
return
|
||||
}
|
||||
if err := pa.SkillStore.Promote(id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "no skill draft for pattern: "+id)
|
||||
} else {
|
||||
respondError(w, 500, "promote skill: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
sk, gerr := pa.SkillStore.Get(id)
|
||||
if gerr != nil {
|
||||
respondError(w, 500, "read skill after promote: "+gerr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{"skill": sk})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 织忆 MemoryWeave — L3 skill 草稿 API 路由测试(t_fcadee04)
|
||||
//
|
||||
// 覆盖端点:POST /api/v1/patterns/{id}/draft + POST /api/v1/patterns/{id}/promote
|
||||
// 共享同一临时 graph.db(pattern 在 graph_nodes、skill 在 skills 表同库),零网络/零 CGO。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/skillgen"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// newSkillgenTestAPI 构造共享同一 temp graph.db 的 PatternAPI(含 L3 装配)。
|
||||
func newSkillgenTestAPI(t *testing.T) (*PatternAPI, string) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
graphPath := filepath.Join(dbDir, "graph.db")
|
||||
pstore := pattern.NewGraphPatternStore(graphPath)
|
||||
t.Cleanup(pstore.Close)
|
||||
sstore := skillgen.NewStore(graphPath)
|
||||
t.Cleanup(sstore.Close)
|
||||
attRoot := filepath.Join(t.TempDir(), "attachments")
|
||||
|
||||
api := NewPatternAPI(pstore, nil, func() storage.LanceDB { return nil })
|
||||
api.SkillStore = sstore
|
||||
api.Generator = &skillgen.FakeGenerator{Out: "## 适用场景\n重复处理"}
|
||||
api.AttachmentsDir = attRoot
|
||||
|
||||
// 种一个 conf=0.9 的 pattern 进 graph_nodes
|
||||
hi := pattern.Pattern{
|
||||
ID: "pat_tool_combo:curl+git+jq", Type: "tool_combo",
|
||||
Name: "tool_combo:curl+git+jq", Data: "curl+git+jq",
|
||||
Confidence: 0.9, Tools: []string{"curl", "git", "jq"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c"},
|
||||
}
|
||||
if _, _, err := pstore.UpsertPatterns([]pattern.Pattern{hi}); err != nil {
|
||||
t.Fatalf("seed pattern: %v", err)
|
||||
}
|
||||
return api, attRoot
|
||||
}
|
||||
|
||||
// TestSkillgen_DraftRoute —— AC1 API 层:draft 端点 → 200 + skills 表 1 行 draft + 附件文件。
|
||||
func TestSkillgen_DraftRoute(t *testing.T) {
|
||||
api, attRoot := newSkillgenTestAPI(t)
|
||||
id := "pat_tool_combo:curl+git+jq"
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil)
|
||||
api.Sub(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("draft 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// skills 表 1 行 draft
|
||||
if n, _ := api.SkillStore.Count("draft"); n != 1 {
|
||||
t.Fatalf("AC1 判据:draft 行数应 1,实际 %d", n)
|
||||
}
|
||||
sk, err := api.SkillStore.Get(id)
|
||||
if err != nil || sk.Status != "draft" {
|
||||
t.Fatalf("skill 应存在且 draft,实际 %v %+v", err, sk)
|
||||
}
|
||||
// 附件文件存在 + 含 [AUTO-DRAFT]
|
||||
path := filepath.Join(attRoot, skillgen.DraftSubDir("tool_combo:curl+git+jq"), "skill-draft.md")
|
||||
data, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
t.Fatalf("附件文件不存在 %s: %v", path, rerr)
|
||||
}
|
||||
if !strings.Contains(string(data), skillgen.AUTO_DRAFT_MARKER) {
|
||||
t.Fatalf("附件应含 [AUTO-DRAFT] 标记")
|
||||
}
|
||||
t.Logf("route draft 落盘: %s", path)
|
||||
}
|
||||
|
||||
// TestSkillgen_PromoteRoute —— AC3 API 层:promote 端点 → 200 + status active。
|
||||
func TestSkillgen_PromoteRoute(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
id := "pat_tool_combo:curl+git+jq"
|
||||
// 先 draft
|
||||
dr := httptest.NewRecorder()
|
||||
api.Sub(dr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil))
|
||||
if dr.Code != http.StatusOK {
|
||||
t.Fatalf("前置 draft 应 200,实际 %d", dr.Code)
|
||||
}
|
||||
// promote
|
||||
pr := httptest.NewRecorder()
|
||||
api.Sub(pr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/promote", nil))
|
||||
if pr.Code != http.StatusOK {
|
||||
t.Fatalf("promote 期望 200,实际 %d body=%s", pr.Code, pr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Skill struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"skill"`
|
||||
}
|
||||
if err := json.Unmarshal(pr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("promote 解析: %v", err)
|
||||
}
|
||||
if resp.Skill.Status != "active" {
|
||||
t.Fatalf("AC3 判据:promote 后 status 应 active,实际 %s", resp.Skill.Status)
|
||||
}
|
||||
// 库复核(AC3 判据:sqlite 查)
|
||||
if n, _ := api.SkillStore.Count("active"); n != 1 {
|
||||
t.Fatalf("AC3 判据:active 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_DraftRoute_NotFound —— 不存在的 pattern → 404。
|
||||
func TestSkillgen_DraftRoute_NotFound(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/nope/draft", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("不存在 pattern 的 draft 应 404,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_PromoteRoute_NoDraft —— 无草稿即 promote → 404。
|
||||
func TestSkillgen_PromoteRoute_NoDraft(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/pat_tool_combo:curl+git+jq/promote", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("无草稿 promote 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_SubPath —— 子路径解析单元(含 URL 编码 id)。
|
||||
func TestSkillgen_SubPath(t *testing.T) {
|
||||
id, action := patternSubPath("/api/v1/patterns/pat_tool_combo:curl+git+jq/promote")
|
||||
if id != "pat_tool_combo:curl+git+jq" || action != "promote" {
|
||||
t.Fatalf("解析失败 id=%q action=%q", id, action)
|
||||
}
|
||||
if _, a := patternSubPath("/api/v1/patterns"); a != "" {
|
||||
t.Fatalf("无子路径应 action 空")
|
||||
}
|
||||
if _, a := patternSubPath("/api/v1/patterns/detect"); a != "" {
|
||||
t.Fatalf("/patterns/detect 是精确路由,不应走 Sub")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// 织忆 — L2 模式 API 路由测试(t_d32e5f8a)
|
||||
// AC1: 空库 detect/list → 不报错(count=0)
|
||||
// AC2: 3 条同工具组合记忆 → detect 后 list 能查到 tool_combo pattern
|
||||
|
||||
// newPatternTestAPI 构造一个用临时 graph.db + 假 LanceDB 后端的 PatternAPI(无网络、无 CGO)。
|
||||
// 假后端只实现 PatternAPI 实际调用的 Scan(其余方法嵌 nil 接口,测试路径不会触达)。
|
||||
func newPatternTestAPI(t *testing.T, mems []models.MemoryRecord) *PatternAPI {
|
||||
t.Helper()
|
||||
store := pattern.NewGraphPatternStore(t.TempDir() + "/graph.db")
|
||||
t.Cleanup(store.Close)
|
||||
mlc := &patternScanLance{rows: mems}
|
||||
pemb := pattern.NewEmbedderProvider(&fakePatternEmbedder{})
|
||||
return NewPatternAPI(store, pattern.NewPatternProvider(store, pemb), func() storage.LanceDB { return mlc })
|
||||
}
|
||||
|
||||
// patternScanLance 覆写 Scan(PatternAPI.Detect 唯一用到的 LanceDB 方法);
|
||||
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
|
||||
type patternScanLance struct {
|
||||
storage.LanceDB
|
||||
rows []models.MemoryRecord
|
||||
}
|
||||
|
||||
func (f *patternScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
if limit > 0 && len(f.rows) > limit {
|
||||
return f.rows[:limit], nil
|
||||
}
|
||||
return f.rows, nil
|
||||
}
|
||||
|
||||
// fakePatternEmbedder 确定性假向量(pattern.Provider 匹配测试用;注入测试走 storage 侧不走这里)。
|
||||
type fakePatternEmbedder struct{}
|
||||
|
||||
func (fakePatternEmbedder) EncodeSingle(text string) ([]float32, error) {
|
||||
v := make([]float32, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
v[i] = 0.1
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// TestPatterns_ListEmpty —— AC1:空库 list → 200 + count=0(不报错)。
|
||||
func TestPatterns_ListEmpty(t *testing.T) {
|
||||
api := newPatternTestAPI(t, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.List(rec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("空库 list 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if int(resp["count"].(float64)) != 0 {
|
||||
t.Fatalf("空库 count 期望 0,实际 %v", resp["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatterns_DetectEmpty —— AC1:空库 detect → 200 + scanned=0/created=0(不报错)。
|
||||
func TestPatterns_DetectEmpty(t *testing.T) {
|
||||
api := newPatternTestAPI(t, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("空库 detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if int(resp["scanned"].(float64)) != 0 || int(resp["created"].(float64)) != 0 {
|
||||
t.Fatalf("空库 detect 期望 0/0,实际 %v/%v", resp["scanned"], resp["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatterns_DetectToolCombo —— AC2:3 条同工具组合蒸馏记忆 → detect 产出 1 个 tool_combo,
|
||||
// 且 list 能查到(sqlite graph_nodes 判据)。
|
||||
func TestPatterns_DetectToolCombo(t *testing.T) {
|
||||
mems := make([]models.MemoryRecord, 0, 3)
|
||||
for i, id := range []string{"pc_a", "pc_b", "pc_c"} {
|
||||
mems = append(mems, models.MemoryRecord{
|
||||
ID: id,
|
||||
Content: "用 jq git curl 处理第" + string(rune('一'+i)) + "项",
|
||||
Category: "distilled",
|
||||
})
|
||||
}
|
||||
api := newPatternTestAPI(t, mems)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var dresp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &dresp)
|
||||
if int(dresp["detected"].(float64)) < 1 {
|
||||
t.Fatalf("3 条同组合应检出 ≥1 个 pattern,实际 detected=%v", dresp["detected"])
|
||||
}
|
||||
// 用 list 再核一遍 sqlite 判据:确有 tool_combo 行
|
||||
lrec := httptest.NewRecorder()
|
||||
api.List(lrec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
|
||||
var lresp struct {
|
||||
Count int `json:"count"`
|
||||
Patterns []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
} `json:"patterns"`
|
||||
}
|
||||
if err := json.Unmarshal(lrec.Body.Bytes(), &lresp); err != nil {
|
||||
t.Fatalf("list 解析: %v (body=%s)", err, lrec.Body.String())
|
||||
}
|
||||
if lresp.Count < 1 {
|
||||
t.Fatalf("AC2 判据:detect 后 list count 应 ≥1,实际 %d (detected=%v)", lresp.Count, dresp["detected"])
|
||||
}
|
||||
var foundToolCombo bool
|
||||
for _, p := range lresp.Patterns {
|
||||
if p.Type == "tool_combo" {
|
||||
foundToolCombo = true
|
||||
}
|
||||
}
|
||||
if !foundToolCombo {
|
||||
t.Fatalf("AC2 判据:list 应含 tool_combo pattern,实际 %d 条均非 tool_combo", lresp.Count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// 织忆 MemoryWeave — P3 Peer Representation API(t_f8fc97e6,设计 §6.1)
|
||||
//
|
||||
// 端点:
|
||||
//
|
||||
// POST /api/v1/peer/{id}/refresh 手动刷新某实体的四层推理画像(显式触发)
|
||||
// GET /api/v1/peer/{id}/explain 解释为何这样理解该实体(可审计:画像 + 来源记忆)
|
||||
// POST /api/v1/recall/peer?peer_id=… 召回某 peer 的推理画像(env 门控 ZHIYI_PEER_RECALL)
|
||||
//
|
||||
// 设计约束(与 L2 pattern / L3 skillgen 同源):
|
||||
//
|
||||
// · refresh/explain 是显式 API,不挂 cron ⇒ 默认零行为变化。
|
||||
// · 召回注入走独立端点 + env 门控 ZHIYI_PEER_RECALL,默认 0 ⇒ 主召回管线逐条不变(AC2)。
|
||||
// · peers 表在 graph.db(短命 modernc),不碰 state.db。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/peer"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// PeerAPI P3 peer 表示端点。
|
||||
type PeerAPI struct {
|
||||
Store *peer.Store
|
||||
Reasoner peer.Reasoner
|
||||
LDB func() storage.LanceDB // 扫 L1 记忆(explicit 层来源)
|
||||
GraphStore governance.GraphStore // 图谱上下文(推理输入)
|
||||
}
|
||||
|
||||
// NewPeerAPI 构造。
|
||||
func NewPeerAPI(store *peer.Store, reasoner peer.Reasoner, ldb func() storage.LanceDB, g governance.GraphStore) *PeerAPI {
|
||||
return &PeerAPI{Store: store, Reasoner: reasoner, LDB: ldb, GraphStore: g}
|
||||
}
|
||||
|
||||
// peerSubPath 解析 /api/v1/peer/{id}/{action} 子路径。
|
||||
func peerSubPath(path string) (id, action string) {
|
||||
const prefix = "/api/v1/peer/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix) // <id>/refresh 或 <id>/explain
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "", ""
|
||||
}
|
||||
dec, err := url.PathUnescape(parts[0])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return dec, parts[1]
|
||||
}
|
||||
|
||||
// Sub 分发 /api/v1/peer/{id}/refresh 与 /api/v1/peer/{id}/explain。
|
||||
func (pa *PeerAPI) Sub(w http.ResponseWriter, r *http.Request) {
|
||||
id, action := peerSubPath(r.URL.Path)
|
||||
if id == "" {
|
||||
respondError(w, 404, "peer id required")
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "refresh":
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pa.Refresh(w, r, id)
|
||||
case "explain":
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pa.Explain(w, r, id)
|
||||
default:
|
||||
respondError(w, 404, "unknown action: "+action)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshAsync 异步刷新某实体的四层画像(推理不阻塞主流程,AC4)。
|
||||
// 返回一个 done channel,调用方可不等待(非阻塞);推理在后台 goroutine 完成。
|
||||
// 任何错误只记日志,不影响调用方。
|
||||
func (pa *PeerAPI) RefreshAsync(id, namespace string) chan error {
|
||||
done := make(chan error, 1)
|
||||
if pa == nil || pa.Store == nil || pa.Reasoner == nil {
|
||||
done <- fmt.Errorf("peer 未装配")
|
||||
return done
|
||||
}
|
||||
go func() {
|
||||
done <- pa.refresh(id, namespace, 2000)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// RefreshFromContent 提交新记忆后异步刷新内容里出现的已知实体(设计「新记忆进入 → 不阻塞主流程
|
||||
// → 更新该 peer 表示」)。匹配:content 含某 peer_id 子串即触发。全部 goroutine 内执行,立即返回。
|
||||
// 由 server.go 在 commit 处理器里以 ZHIYI_PEER_RECALL=1 门控调用;默认关 ⇒ 不触发。
|
||||
func (pa *PeerAPI) RefreshFromContent(content, namespace string) {
|
||||
if pa == nil || pa.Store == nil || content == "" {
|
||||
return
|
||||
}
|
||||
peers, err := pa.Store.List(200)
|
||||
if err != nil || len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.PeerID != "" && strings.Contains(content, p.PeerID) {
|
||||
go pa.refresh(p.PeerID, namespace, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh 实际刷新编排(同步执行体;由 RefreshAsync / RefreshFromContent 包 goroutine)。
|
||||
func (pa *PeerAPI) refresh(id, namespace string, limit int) error {
|
||||
explicit, _, scanErr := pa.collectExplicit(id, namespace, limit)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
graphCtx := pa.collectGraphContext(id)
|
||||
if _, err := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresh POST /api/v1/peer/{id}/refresh — 扫 L1 记忆 + 图谱上下文 → 四层推理 → 写 peers 表。
|
||||
// body 可选 {"namespace": "...", "limit": N}(namespace 空 = 全库)。
|
||||
func (pa *PeerAPI) Refresh(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.Store == nil || pa.Reasoner == nil {
|
||||
respondError(w, 503, "peer 未装配(Store/Reasoner 为空)")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 2000
|
||||
}
|
||||
// 1. 扫 L1 记忆(LanceDB),提取 explicit 层
|
||||
explicit, matched, scanErr := pa.collectExplicit(id, req.Namespace, req.Limit)
|
||||
if scanErr != nil {
|
||||
respondError(w, 500, "scan memories: "+scanErr.Error())
|
||||
return
|
||||
}
|
||||
// 2. 图谱上下文(实体 1 跳邻居名)
|
||||
graphCtx := pa.collectGraphContext(id)
|
||||
// 3. 四层推理 + 写库
|
||||
p, perr := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx)
|
||||
if perr != nil {
|
||||
respondError(w, 400, "build peer: "+perr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"peer": p,
|
||||
"matched": matched,
|
||||
"scanned": req.Limit,
|
||||
"graph_ctx": graphCtx,
|
||||
"has_reason": len(p.InferredTraits) > 0 || len(p.InferredGoals) > 0 || p.HypothesizedContext != "",
|
||||
})
|
||||
}
|
||||
|
||||
// Explain GET /api/v1/peer/{id}/explain — 可审计解释:画像 + 来源记忆。
|
||||
func (pa *PeerAPI) Explain(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.Store == nil {
|
||||
respondError(w, 503, "peer 未装配(Store 为空)")
|
||||
return
|
||||
}
|
||||
p, err := pa.Store.Get(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "peer not found: "+id+"(先 POST /peer/{id}/refresh 生成画像)")
|
||||
} else {
|
||||
respondError(w, 500, "get peer: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
// 来源记忆:扫 L1 里含该实体的记忆(仅读,不改库)
|
||||
src := []string{}
|
||||
if pa.LDB != nil {
|
||||
if ldb := pa.LDB(); ldb != nil {
|
||||
if recs, serr := ldb.Scan(2000, ""); serr == nil {
|
||||
for _, m := range recs {
|
||||
if strings.Contains(m.Content, id) {
|
||||
src = append(src, m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{"peer": p, "source_memories": src})
|
||||
}
|
||||
|
||||
// RecallPeer POST /api/v1/recall/peer?peer_id=… — 召回某 peer 的四层推理画像。
|
||||
// env 门控 ZHIYI_PEER_RECALL:默认 0 → 404(零变化);=1 → 返回画像 JSON(AC3 判据)。
|
||||
func (pa *PeerAPI) RecallPeer(w http.ResponseWriter, r *http.Request) {
|
||||
if !peerRecallEnabled() {
|
||||
respondError(w, 404, "peer recall 默认关(ZHIYI_PEER_RECALL 未设/≠1)")
|
||||
return
|
||||
}
|
||||
pid := r.URL.Query().Get("peer_id")
|
||||
if pid == "" {
|
||||
respondError(w, 400, "peer_id required")
|
||||
return
|
||||
}
|
||||
if pa.Store == nil {
|
||||
respondError(w, 503, "peer 未装配(Store 为空)")
|
||||
return
|
||||
}
|
||||
p, err := pa.Store.Get(pid)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "peer not found: "+pid)
|
||||
} else {
|
||||
respondError(w, 500, "get peer: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"peer_id": p.PeerID,
|
||||
"portrait": map[string]interface{}{
|
||||
"explicit": map[string]interface{}{"facts": p.Facts, "preferences": p.Preferences, "skills": p.Skills},
|
||||
"deductive": map[string]interface{}{"traits": p.InferredTraits, "goals": p.InferredGoals},
|
||||
"inductive": map[string]interface{}{"patterns": p.Patterns},
|
||||
"abductive": map[string]interface{}{"hypothesized_context": p.HypothesizedContext},
|
||||
},
|
||||
"updated_at": p.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// explicitResult explicit 层收集结果。
|
||||
type explicitResult struct {
|
||||
facts, prefs, skills []string
|
||||
}
|
||||
|
||||
// collectExplicit 扫 L1 记忆并提取 explicit 层。namespace 空 = 全库。
|
||||
func (pa *PeerAPI) collectExplicit(id, namespace string, limit int) (explicitResult, int, error) {
|
||||
var res explicitResult
|
||||
if pa.LDB == nil {
|
||||
return res, 0, nil
|
||||
}
|
||||
ldb := pa.LDB()
|
||||
if ldb == nil {
|
||||
return res, 0, nil
|
||||
}
|
||||
recs, err := ldb.Scan(limit, namespace)
|
||||
if err != nil {
|
||||
return res, 0, err
|
||||
}
|
||||
facts, prefs, skills, matched := peer.CollectFacts(recs, id)
|
||||
return explicitResult{facts: facts, prefs: prefs, skills: skills}, matched, nil
|
||||
}
|
||||
|
||||
// collectGraphContext 取实体图谱 1 跳邻居名作为推理上下文(读图,只读不改)。
|
||||
func (pa *PeerAPI) collectGraphContext(id string) []string {
|
||||
if pa.GraphStore == nil {
|
||||
return nil
|
||||
}
|
||||
paths, err := pa.GraphStore.Navigate(id, 1, "", nil)
|
||||
if err != nil || len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range paths {
|
||||
for _, k := range []string{"to", "target"} {
|
||||
if v, ok := p[k].(string); ok && v != "" && !seen[v] && v != id {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 10 {
|
||||
out = out[:10]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// peerRecallEnabled 每次调用读 env(默认关,零行为变化)。
|
||||
func peerRecallEnabled() bool {
|
||||
switch strings.ToLower(os.Getenv("ZHIYI_PEER_RECALL")) {
|
||||
case "1", "true", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRecallResults 供 storage.PeerRecallProvider 装配(server.go 在 ZHIYI_PEER_RECALL=1
|
||||
// 时注册)。query 命中某实体 → 返回该 peer 的四层画像(Category="peer")。
|
||||
// 命中判定:query 里含 peer_id 子串(大小写不敏感);取最多 topK 个。无命中 → 空。
|
||||
func (pa *PeerAPI) GetRecallResults(query, namespace string, topK int) []models.RecallResult {
|
||||
if pa == nil || pa.Store == nil || topK <= 0 {
|
||||
return nil
|
||||
}
|
||||
peers, err := pa.Store.List(200)
|
||||
if err != nil || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
var out []models.RecallResult
|
||||
for _, p := range peers {
|
||||
if p.PeerID != "" && q != "" && strings.Contains(q, strings.ToLower(p.PeerID)) {
|
||||
content := "peer 画像: " + p.PeerID +
|
||||
" | 事实: " + strings.Join(p.Facts, "; ") +
|
||||
" | 偏好: " + strings.Join(p.Preferences, "; ") +
|
||||
" | 技能: " + strings.Join(p.Skills, "; ") +
|
||||
" | 演绎特质: " + strings.Join(p.InferredTraits, "; ") +
|
||||
" | 演绎目标: " + strings.Join(p.InferredGoals, "; ") +
|
||||
" | 归纳模式: " + strings.Join(p.Patterns, "; ") +
|
||||
" | 最佳解释: " + p.HypothesizedContext
|
||||
out = append(out, models.RecallResult{
|
||||
ID: "peer:" + p.PeerID,
|
||||
Content: content,
|
||||
Category: "peer",
|
||||
Score: 1.0,
|
||||
Namespace: namespace,
|
||||
Source: "peer_representation",
|
||||
})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 织忆 MemoryWeave — P3 Peer Representation API 路由测试(t_f8fc97e6)
|
||||
//
|
||||
// 覆盖端点:POST /api/v1/peer/{id}/refresh + GET /api/v1/peer/{id}/explain
|
||||
// - POST /api/v1/recall/peer(env 门控)+ storage.PeerRecallProvider 注入。
|
||||
//
|
||||
// 共享同一临时 graph.db(peers 表),零网络 / CGO / 真模型。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/peer"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// peerScanLance 覆写 Scan(PeerAPI.Refresh/Explain 唯一用到的 LanceDB 方法);
|
||||
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
|
||||
type peerScanLance struct {
|
||||
storage.LanceDB
|
||||
rows []models.MemoryRecord
|
||||
}
|
||||
|
||||
func (f *peerScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
if limit > 0 && len(f.rows) > limit {
|
||||
return f.rows[:limit], nil
|
||||
}
|
||||
return f.rows, nil
|
||||
}
|
||||
|
||||
// newPeerTestAPI 构造共享同一 temp graph.db 的 PeerAPI。
|
||||
// GraphStore 传 nil(collectGraphContext 处理 nil ⇒ 无图上下文,不炸)。
|
||||
func newPeerTestAPI(t *testing.T, mems []models.MemoryRecord) *PeerAPI {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := peer.NewStore(filepath.Join(dir, "graph.db"))
|
||||
t.Cleanup(store.Close)
|
||||
mlc := &peerScanLance{rows: mems}
|
||||
return NewPeerAPI(store, &peer.FakeReasoner{}, func() storage.LanceDB { return mlc }, nil)
|
||||
}
|
||||
|
||||
// fiveMems 造 5 条关于「牧尘」的 L1 记忆(AC1 输入构造)。
|
||||
func fiveMems() []models.MemoryRecord {
|
||||
return []models.MemoryRecord{
|
||||
{Content: "牧尘选择 Docker 部署"},
|
||||
{Content: "牧尘偏好 SysV 风格"},
|
||||
{Content: "牧尘擅长 Python"},
|
||||
{Content: "牧尘厌恶 systemd"},
|
||||
{Content: "牧尘使用 jq 处理配置"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RefreshRoute —— AC1 API 层:5 条记忆 → refresh → peers 表 1 行四层非空。
|
||||
func TestPeer_RefreshRoute(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil) // 牧尘
|
||||
api.Sub(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Peer peer.Peer `json:"peer"`
|
||||
Matched int `json:"matched"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("refresh 解析: %v", err)
|
||||
}
|
||||
if resp.Matched != 5 {
|
||||
t.Errorf("AC1:matched=%d 期望 5", resp.Matched)
|
||||
}
|
||||
if resp.Peer.PeerID != "牧尘" {
|
||||
t.Errorf("peer_id=%q 期望 牧尘", resp.Peer.PeerID)
|
||||
}
|
||||
// 四层非空(AC1 判据)
|
||||
if len(resp.Peer.InferredTraits) == 0 {
|
||||
t.Error("inferred_traits 空")
|
||||
}
|
||||
if len(resp.Peer.InferredGoals) == 0 {
|
||||
t.Error("inferred_goals 空")
|
||||
}
|
||||
if len(resp.Peer.Patterns) == 0 {
|
||||
t.Error("patterns 空")
|
||||
}
|
||||
if resp.Peer.HypothesizedContext == "" {
|
||||
t.Error("hypothesized_context 空")
|
||||
}
|
||||
// peers 表行数(AC1:sqlite 查)
|
||||
if n, _ := api.Store.Count(); n != 1 {
|
||||
t.Errorf("AC1:peers 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_ExplainRoute —— GET explain → 200 + 画像 + 来源记忆。
|
||||
func TestPeer_ExplainRoute(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 先 refresh 种画像
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("前置 refresh 应 200,实际 %d", rec.Code)
|
||||
}
|
||||
// explain
|
||||
er := httptest.NewRecorder()
|
||||
api.Sub(er, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
|
||||
if er.Code != http.StatusOK {
|
||||
t.Fatalf("explain 期望 200,实际 %d body=%s", er.Code, er.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Peer peer.Peer `json:"peer"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
}
|
||||
if err := json.Unmarshal(er.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("explain 解析: %v", err)
|
||||
}
|
||||
if len(resp.SourceMemories) != 5 {
|
||||
t.Errorf("source_memories=%d 期望 5(可审计:画像有来源)", len(resp.SourceMemories))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_Explain_NotFound —— 未 refresh 直接 explain → 404。
|
||||
func TestPeer_Explain_NotFound(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("无画像 explain 应 404,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RecallPeer_DefaultOff —— AC3:ZHIYI_PEER_RECALL=0(默认)→ recall/peer 404 零变化。
|
||||
func TestPeer_RecallPeer_DefaultOff(t *testing.T) {
|
||||
t.Setenv("ZHIYI_PEER_RECALL", "0")
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("默认关 recall/peer 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RecallPeer_Enabled —— AC3:ZHIYI_PEER_RECALL=1 → 返回四层画像 JSON。
|
||||
func TestPeer_RecallPeer_Enabled(t *testing.T) {
|
||||
t.Setenv("ZHIYI_PEER_RECALL", "1")
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 先 refresh 种画像
|
||||
rrec := httptest.NewRecorder()
|
||||
api.Sub(rrec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
if rrec.Code != http.StatusOK {
|
||||
t.Fatalf("前置 refresh 应 200,实际 %d", rrec.Code)
|
||||
}
|
||||
// recall/peer
|
||||
rec := httptest.NewRecorder()
|
||||
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("recall/peer 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Portrait map[string]interface{} `json:"portrait"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("recall/peer 解析: %v", err)
|
||||
}
|
||||
// 四层各有内容(AC3 判据)
|
||||
for _, layer := range []string{"explicit", "deductive", "inductive", "abductive"} {
|
||||
v, ok := resp.Portrait[layer]
|
||||
if !ok {
|
||||
t.Errorf("portrait 缺 %s 层: %v", layer, resp.Portrait)
|
||||
continue
|
||||
}
|
||||
s, _ := json.Marshal(v)
|
||||
if string(s) == "{}" || strings.Contains(string(s), "[]\"") {
|
||||
t.Errorf("portrait.%s 层空: %s", layer, s)
|
||||
}
|
||||
}
|
||||
t.Logf("AC3 recall/peer 画像: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestPeer_GetRecallResults —— storage.PeerRecallProvider 注入:query 含实体 → 返回画像条目。
|
||||
func TestPeer_GetRecallResults(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 种画像
|
||||
api.Sub(httptest.NewRecorder(),
|
||||
httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
res := api.GetRecallResults("牧尘怎么部署", "", 1)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("GetRecallResults 应返回 1 条,实际 %d", len(res))
|
||||
}
|
||||
if res[0].Category != "peer" || res[0].ID != "peer:牧尘" {
|
||||
t.Errorf("返回条目 Category=%q ID=%q 期望 peer / peer:牧尘", res[0].Category, res[0].ID)
|
||||
}
|
||||
if !strings.Contains(res[0].Content, "演绎特质") || !strings.Contains(res[0].Content, "最佳解释") {
|
||||
t.Errorf("画像内容缺四层: %s", res[0].Content)
|
||||
}
|
||||
// 无命中 → 空
|
||||
if got := api.GetRecallResults("如何优化 Redis", "", 1); len(got) != 0 {
|
||||
t.Errorf("不相关 query 应空,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_SubPath —— 子路径解析单元。
|
||||
func TestPeer_SubPath(t *testing.T) {
|
||||
id, action := peerSubPath("/api/v1/peer/%E7%89%A7%E5%B0%98/refresh")
|
||||
if id != "牧尘" || action != "refresh" {
|
||||
t.Fatalf("解析失败 id=%q action=%q", id, action)
|
||||
}
|
||||
if _, a := peerSubPath("/api/v1/peer"); a != "" {
|
||||
t.Fatalf("无子路径应 action 空")
|
||||
}
|
||||
}
|
||||
|
||||
// blockingReasoner 会阻塞的推理器(测非阻塞:RefreshAsync 不等它完成)。
|
||||
type blockingReasoner struct{ release chan struct{} }
|
||||
|
||||
func (b *blockingReasoner) Reason(facts, prefs, skills, graphCtx []string) (*peer.ReasoningOutput, error) {
|
||||
<-b.release
|
||||
return &peer.ReasoningOutput{InferredTraits: []string{"T"}}, nil
|
||||
}
|
||||
|
||||
// TestPeer_RefreshAsync_NonBlocking —— AC4:推理不阻塞主流程。
|
||||
// RefreshAsync 立即返回(后台 goroutine 推理),即使推理器被阻塞也绝不等它。
|
||||
func TestPeer_RefreshAsync_NonBlocking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := peer.NewStore(filepath.Join(dir, "graph.db"))
|
||||
t.Cleanup(store.Close)
|
||||
br := &blockingReasoner{release: make(chan struct{})}
|
||||
mlc := &peerScanLance{rows: fiveMems()}
|
||||
api := NewPeerAPI(store, br, func() storage.LanceDB { return mlc }, nil)
|
||||
|
||||
done := api.RefreshAsync("牧尘", "")
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("RefreshAsync 应在推理器仍阻塞时立即返回(非阻塞),却等到了结果")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// 非阻塞 ✓:200ms 内未等推理完成
|
||||
}
|
||||
// 释放推理器,后台完成
|
||||
close(br.release)
|
||||
select {
|
||||
case <-done:
|
||||
// 后台完成
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("释放后后台推理未完成")
|
||||
}
|
||||
// 画像已落库
|
||||
if n, _ := store.Count(); n != 1 {
|
||||
t.Errorf("非阻塞刷新后 peers 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package routes
|
||||
|
||||
// 2026-10-26(P1-1):supersede / 版本字段必须真的出现在 /recall 响应里。
|
||||
// 用同包 fakeLanceDB 构造一条「已被取代 + 有效期已过 + 版本 2」的记录:
|
||||
// - 这是「写侧有、读侧看不见」的复发点(t_4c810e02「加列不接 IPC=静默 no-op」同款坑);
|
||||
// - 判据只看 JSON 响应键值,不看结构体注释。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
func TestRecall_SupersedeAndValidityFieldsExposed(t *testing.T) {
|
||||
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{
|
||||
ID: "mem_old_fact",
|
||||
Content: "织忆默认 stale 阈值是 720 小时(旧事实)",
|
||||
Category: "distilled",
|
||||
Version: 2,
|
||||
ValidFrom: "2026-10-26T10:00:00+08:00",
|
||||
ValidTo: "2026-10-26T11:00:00+08:00",
|
||||
SupersededBy: []string{"mem_new_fact"},
|
||||
},
|
||||
{ID: "mem_fresh", Content: "织忆默认 stale 阈值是 720 小时(现行事实)", Category: "distilled"},
|
||||
}}
|
||||
code, m, body := recallBody(t, db, `{"query":"stale 阈值多少小时","limit":5,"namespace":"hermes-main"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
results, _ := m["results"].([]interface{})
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("无结果,无法验证字段:%s", body)
|
||||
}
|
||||
var found map[string]interface{}
|
||||
for _, raw := range results {
|
||||
r, _ := raw.(map[string]interface{})
|
||||
if r["id"] == "mem_old_fact" {
|
||||
found = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("目标记录未被召回(fake DB 应原样返回),body=%s", body)
|
||||
}
|
||||
if found["superseded_by"] == nil {
|
||||
t.Fatalf("superseded_by 未暴露给读侧:%v", found)
|
||||
}
|
||||
if found["superseded"] != true {
|
||||
t.Fatalf("superseded 便捷布尔应为 true:%v", found["superseded"])
|
||||
}
|
||||
if found["generation"] != float64(2) {
|
||||
t.Fatalf("generation 应为 2:%v", found["generation"])
|
||||
}
|
||||
if found["valid_to"] != "2026-10-26T11:00:00+08:00" {
|
||||
t.Fatalf("valid_to 未直通:%v", found["valid_to"])
|
||||
}
|
||||
if found["channel"] == nil || found["channel"] == "" {
|
||||
t.Fatalf("channel 未暴露:%v", found)
|
||||
}
|
||||
if _, ok := m["trace_id"]; !ok {
|
||||
t.Fatalf("响应缺 trace_id:%s", body)
|
||||
}
|
||||
if m["status"] != "complete" {
|
||||
t.Fatalf("正常召回 status 应为 complete,实际 %v", m["status"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package routes
|
||||
|
||||
// 2026-10-26(P1-3):stale-return + 生命周期 fixture(10:00 生效 / 11:00 失效)。
|
||||
//
|
||||
// 评审缺口:「只有离线检索指标,没有 stale-return、没有生命周期 fixture」。
|
||||
// 本测试把三条读侧生命周期行为锁成机械判据:
|
||||
// ① bi-temporal 开启时,valid_to 已过的事实**不进 top-k**(但仍在库,non-lossy);
|
||||
// ② valid_to 在未来 / 为空 ⇒ 正常返回(同一 fixture 的对照,证明不是「整类被吞」);
|
||||
// ③ updated_at 超 720h ⇒ 响应 stale=true;新记录不输出该键。
|
||||
//
|
||||
// 时窗用真实语义:fixture 事实 10:00 生效、11:00 失效,查询时点取「当日 11:00 之后」。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// 时窗用**相对时间**:曾经写成 dayAt(10,0)/dayAt(11,0) 假设「跑测试时已是晚上 20:00」,
|
||||
// 凌晨 02:07 跑就是 flaky —— 11:00 还没到,「已失效」的 fixture 其实仍有效。
|
||||
// 生命周期判据必须与运行时刻无关:过期 = now-1h,有效期 = now+24h。
|
||||
func expiredPair() (from, to, future string) {
|
||||
now := time.Now()
|
||||
return now.Add(-2 * time.Hour).Format(time.RFC3339),
|
||||
now.Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
now.Add(24 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func idsOf(m map[string]interface{}) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if res, ok := m["results"].([]interface{}); ok {
|
||||
for _, raw := range res {
|
||||
if r, ok := raw.(map[string]interface{}); ok {
|
||||
if id, ok := r["id"].(string); ok {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestRecall_Lifecycle_ExpiredFactDroppedWhenBitemporal(t *testing.T) {
|
||||
from, to, future := expiredPair()
|
||||
|
||||
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_expired_window", Content: "生命周期 fixture:10:00 生效 11:00 失效的事实",
|
||||
Category: "distilled", ValidFrom: from, ValidTo: to},
|
||||
{ID: "mem_still_valid", Content: "生命周期 fixture:仍在有效期内的事实",
|
||||
Category: "distilled", ValidFrom: from, ValidTo: future},
|
||||
}}
|
||||
t.Setenv("ZHIYI_BITEMPORAL", "1")
|
||||
code, m, body := recallBody(t, db, `{"query":"生命周期 fixture 有效期","limit":5,"namespace":"hermes-main"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
ids := idsOf(m)
|
||||
if ids["mem_expired_window"] {
|
||||
t.Fatalf("11:00 已失效的事实不应进 top-k(bi-temporal 开);body=%s", body)
|
||||
}
|
||||
if !ids["mem_still_valid"] {
|
||||
t.Fatalf("有效期未过的事实必须正常返回(对照缺失 ⇒ 可能是整类被吞);body=%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecall_Lifecycle_ExpiredFactVisibleWhenBitemporalOff(t *testing.T) {
|
||||
// 开关关(默认)⇒ 行为零变化:过期事实照常返回,但 valid_to 字段可见(读侧能自己判)。
|
||||
from, to, _ := expiredPair()
|
||||
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_expired_visible", Content: "生命周期 fixture:过期但可见", Category: "distilled",
|
||||
ValidFrom: from, ValidTo: to},
|
||||
}}
|
||||
t.Setenv("ZHIYI_BITEMPORAL", "0")
|
||||
code, m, body := recallBody(t, db, `{"query":"过期但可见","limit":5,"namespace":"hermes-main"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
if !idsOf(m)["mem_expired_visible"] {
|
||||
t.Fatalf("bi-temporal 关时过期事实应照常返回(non-lossy 可见性);body=%s", body)
|
||||
}
|
||||
var found map[string]interface{}
|
||||
for _, raw := range m["results"].([]interface{}) {
|
||||
if r, ok := raw.(map[string]interface{}); ok && r["id"] == "mem_expired_visible" {
|
||||
found = r
|
||||
}
|
||||
}
|
||||
if found == nil || found["valid_to"] != to {
|
||||
t.Fatalf("valid_to 必须直通给读侧:%v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecall_StaleReturn_DefaultThreshold(t *testing.T) {
|
||||
old := time.Now().Add(-31 * 24 * time.Hour)
|
||||
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_old", Content: "陈年事实(31 天未更新)", Category: "distilled", UpdatedAt: old},
|
||||
{ID: "mem_new", Content: "新鲜事实(今天更新)", Category: "distilled", UpdatedAt: time.Now()},
|
||||
}}
|
||||
code, m, body := recallBody(t, db, `{"query":"事实 新鲜度","limit":5,"namespace":"hermes-main"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
got := map[string]map[string]interface{}{}
|
||||
for _, raw := range m["results"].([]interface{}) {
|
||||
if r, ok := raw.(map[string]interface{}); ok {
|
||||
got[r["id"].(string)] = r
|
||||
}
|
||||
}
|
||||
if got["mem_old"] == nil || got["mem_old"]["stale"] != true {
|
||||
t.Fatalf("31 天未更新的记忆应 stale=true:%v", got["mem_old"])
|
||||
}
|
||||
if _, ok := got["mem_new"]["stale"]; ok {
|
||||
t.Fatalf("新记忆不应输出 stale 键:%v", got["mem_new"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package routes
|
||||
|
||||
// t_3aa2a8c1:`/api/v1/recall` 响应暴露 `truncated`(Rust 侧单次重读行数上限截断标志,
|
||||
// 引擎已算、此前只打日志不上抛)。选定的省略语义 = **键缺失即未截断(omitempty)**。
|
||||
// 本测试经路由层机械判定:
|
||||
// ① 粗检索被截断(后端实现 SearchTruncationReporter 且上报 true)⇒ 响应含 "truncated":true
|
||||
// ② 未截断 / 纯 Go 后端(不实现 reporter)⇒ 键缺失(不出现 truncated:false 以外的值)
|
||||
// ③ 回归:既有字段 results/count/candidates_scanned/mode/cache_hit 行为不变
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// truncReportingLDB 复用同包 fakeLanceDB(recall_error_shape_test.go),
|
||||
// 额外实现可选扩展接口 SearchWithTruncationReporter(t_3aa2a8c1)。
|
||||
type truncReportingLDB struct {
|
||||
*fakeLanceDB
|
||||
reportTruncated bool
|
||||
}
|
||||
|
||||
func (f *truncReportingLDB) SearchWithTruncation(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, bool, error) {
|
||||
if f.searchErr != nil {
|
||||
return nil, false, f.searchErr
|
||||
}
|
||||
if len(f.searchResults) > topK {
|
||||
return f.searchResults[:topK], f.reportTruncated, nil
|
||||
}
|
||||
return f.searchResults, f.reportTruncated, nil
|
||||
}
|
||||
|
||||
func recallBody(t *testing.T, db storage.LanceDB, body string) (int, map[string]interface{}, string) {
|
||||
t.Helper()
|
||||
// 测试须可复跑(t_3aa2a8c1 实测踩坑):SearchCacheInstance 由 storage 包 init() 恒建
|
||||
// (L2 = 共享 Redis,TTL 60s)⇒ 上一次 run 的 recall 结果会进 L2,下一次 run 同 key
|
||||
// 命中缓存路径(Truncated 恒 false)⇒ 断言 flaky。逐测试摘除缓存(测后恢复),
|
||||
// 恒走未缓存路径。包内无 t.Parallel ⇒ 改全局安全。
|
||||
saved := storage.SearchCacheInstance
|
||||
storage.SearchCacheInstance = nil
|
||||
t.Cleanup(func() { storage.SearchCacheInstance = saved })
|
||||
|
||||
emb := storage.NewEmbedder("")
|
||||
rerank := storage.NewReranker("", emb)
|
||||
api := NewAPI(db, emb, rerank, nil, governance.NewInMemoryGraph())
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.Recall(w, req)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &m)
|
||||
return w.Code, m, w.Body.String()
|
||||
}
|
||||
|
||||
// 既有响应键回归基线(t_2d4e3b92 成功 schema + t_1c12e000 cache_hit + t_949840b1 candidates_scanned)
|
||||
var recallBaselineKeys = []string{"results", "count", "candidates_scanned", "mode", "cache_hit"}
|
||||
|
||||
func assertBaseline(t *testing.T, m map[string]interface{}, body string) {
|
||||
t.Helper()
|
||||
for _, k := range recallBaselineKeys {
|
||||
if _, ok := m[k]; !ok {
|
||||
t.Fatalf("既有字段 %q 缺失(回归破坏),body=%s", k, body)
|
||||
}
|
||||
}
|
||||
if res, ok := m["results"].([]interface{}); ok {
|
||||
if cnt, _ := m["count"].(float64); int(cnt) != len(res) {
|
||||
t.Fatalf("count=%v 与 len(results)=%d 不一致(回归破坏)", m["count"], len(res))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ① 粗检索被行数上限截断 ⇒ 响应暴露 truncated:true + 既有字段回归不变
|
||||
func TestRecall_TruncatedExposedWhenHitCap(t *testing.T) {
|
||||
db := &truncReportingLDB{
|
||||
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
|
||||
{ID: "mem_2", Content: "test memory two", Category: "distilled"},
|
||||
}},
|
||||
reportTruncated: true,
|
||||
}
|
||||
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
if v, ok := m["truncated"]; !ok || v != true {
|
||||
t.Fatalf("粗检索已截断,响应必须含 truncated:true;实际 key 存在=%v 值=%v body=%s", ok, m["truncated"], body)
|
||||
}
|
||||
assertBaseline(t, m, body)
|
||||
}
|
||||
|
||||
// ①b keyword 主路径(kwMeta 透传)同判据
|
||||
func TestRecall_Keyword_TruncatedExposed(t *testing.T) {
|
||||
db := &truncReportingLDB{
|
||||
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "keyword memory", Category: "distilled"},
|
||||
}},
|
||||
reportTruncated: true,
|
||||
}
|
||||
code, m, body := recallBody(t, db, `{"query":"keyword query","limit":5,"mode":"keyword","namespace":"shared"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
if v, ok := m["truncated"]; !ok || v != true {
|
||||
t.Fatalf("keyword 路径粗检索已截断,响应必须含 truncated:true;body=%s", body)
|
||||
}
|
||||
assertBaseline(t, m, body)
|
||||
}
|
||||
|
||||
// ② 未截断 ⇒ 键缺失(选定语义:省略=未截断),不得出现 truncated:false 以外的值
|
||||
func TestRecall_NotTruncated_KeyOmitted(t *testing.T) {
|
||||
db := &truncReportingLDB{
|
||||
fakeLanceDB: &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
|
||||
}},
|
||||
reportTruncated: false,
|
||||
}
|
||||
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
if v, ok := m["truncated"]; ok {
|
||||
t.Fatalf("未截断场景键应缺失(省略=未截断);实际 truncated=%v body=%s", v, body)
|
||||
}
|
||||
assertBaseline(t, m, body)
|
||||
}
|
||||
|
||||
// ②b 纯 Go 后端(不实现 SearchTruncationReporter)⇒ 键缺失,零值语义、行为不变
|
||||
func TestRecall_PureGoBackend_NoTruncatedKey(t *testing.T) {
|
||||
db := &fakeLanceDB{searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
|
||||
}}
|
||||
code, m, body := recallBody(t, db, `{"query":"test query","limit":5,"namespace":"shared"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", code, body)
|
||||
}
|
||||
if _, ok := m["truncated"]; ok {
|
||||
t.Fatalf("纯 Go 后端无行数上限,truncated 键必须缺失;body=%s", body)
|
||||
}
|
||||
assertBaseline(t, m, body)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package routes
|
||||
|
||||
// 织忆自检端点(2026-10-26,牧尘批「三档」)
|
||||
//
|
||||
// 背景:织忆相关的定时自检脚本有 ~20 个,取数方式三套并存——
|
||||
// ① 直连生产库 lancedb.connect("/var/lib/memoryweave")(绕过服务、schema 一改就静默腐烂)
|
||||
// ② IPC socket 自定义协议
|
||||
// ③ HTTP /api/v1/{stats,popwatch,metrics/self}
|
||||
// 三套契约对同一个库,schema 变更时分开损坏。典型事故:pattern 自检脚本写死
|
||||
// type='pattern',库里实际是 pattern_stat ⇒ 连续数月静默 0 输出。
|
||||
//
|
||||
// 本端点是**唯一取数契约**:把「读织忆内部状态」收敛成一份结构化 JSON。
|
||||
// 设计边界(不做什么):
|
||||
// - 不做判定/告警/投递 —— 阈值、渠道、评审留在外部 cron(观测者不能住在被观测者体内:
|
||||
// zhiyid 一挂自检就跟着哑,内外同时沉默是更坏的故障)。
|
||||
// - 不跑重活(eval 全量、备份、vacuum)—— 那些抢生产资源,留在 systemd-run 独立 scope。
|
||||
//
|
||||
// level 语义:
|
||||
// - cheap(默认,<50ms):只读内存态/计数,不扫全表。
|
||||
// - full(秒级):额外扫全表算碎片率/冗余率/近 7 天质量/写量分布(原 quality-watch 口径)。
|
||||
//
|
||||
// 兼容铁律:本端点返回的比率一律给「分子/分母/样本是否足够」,缺数据不返回 0
|
||||
// (0 会被下游读成"很好")。沿用 selfoptimize 的 metricNA(-1) 语义。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// SelfcheckSchemaVersion 变更返回结构时递增;消费方据此判兼容。
|
||||
const SelfcheckSchemaVersion = "1"
|
||||
|
||||
// 与 zhiyi-quality-watch.py 同口径(该脚本已改为读本端点,口径只此一处)
|
||||
const (
|
||||
selfcheckFragLen = 20 // <20 字符 = 碎片
|
||||
selfcheckDupMinSample = 5000 // 冗余率最小样本(活行),不足只报样本不判率
|
||||
selfcheckScanLimit = 300000
|
||||
)
|
||||
|
||||
type selfcheckQuality struct {
|
||||
TotalRows int `json:"total_rows"`
|
||||
LiveRows int `json:"live_rows"`
|
||||
ScannedRows int `json:"scanned_rows"`
|
||||
ScanTruncated bool `json:"scan_truncated"`
|
||||
Coverage float64 `json:"coverage"` // 扫描行 / store 真实总量;<1 表示结果不完整,禁止据此下结论
|
||||
FragLen int `json:"frag_len"`
|
||||
|
||||
FragRowsAll int `json:"frag_rows_all"`
|
||||
FragRateAll float64 `json:"frag_rate_all"`
|
||||
FragRowsLive int `json:"frag_rows_live"`
|
||||
FragRateLive float64 `json:"frag_rate_live"`
|
||||
|
||||
DupGroups int `json:"dup_groups"`
|
||||
DupExtraRows int `json:"dup_extra_rows"`
|
||||
DupRate float64 `json:"dup_rate"`
|
||||
DupSampleOK bool `json:"dup_sample_ok"`
|
||||
|
||||
NewRows7d int `json:"new_rows_7d"`
|
||||
NewFragRows7d int `json:"new_frag_rows_7d"`
|
||||
NewFragRate7d *float64 `json:"new_frag_rate_7d"` // nil = 近 7 天无新增(无分母)
|
||||
}
|
||||
|
||||
type selfcheckDayCount struct {
|
||||
Date string `json:"date"`
|
||||
Rows int `json:"rows"`
|
||||
Live int `json:"live"`
|
||||
}
|
||||
|
||||
type selfcheckWriteVolume struct {
|
||||
Days []selfcheckDayCount `json:"days"`
|
||||
Today int `json:"today"`
|
||||
Last7d int `json:"last_7d"`
|
||||
Median7d float64 `json:"median_7d"`
|
||||
P25_7d float64 `json:"p25_7d"`
|
||||
}
|
||||
|
||||
// Selfcheck GET /api/v1/selfcheck?level=cheap|full
|
||||
func (a *API) Selfcheck(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
if r.Method != http.MethodGet {
|
||||
respondError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
level := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("level")))
|
||||
if level == "" {
|
||||
level = "cheap"
|
||||
}
|
||||
if level != "cheap" && level != "full" {
|
||||
respondError(w, http.StatusBadRequest, "level must be cheap|full")
|
||||
return
|
||||
}
|
||||
|
||||
rep := map[string]interface{}{
|
||||
"schema_version": SelfcheckSchemaVersion,
|
||||
"checked_at": time.Now().Format(time.RFC3339),
|
||||
"level": level,
|
||||
}
|
||||
degraded := []string{}
|
||||
|
||||
// ── 存储总量 ───────────────────────────────
|
||||
if st, err := a.LanceDB.Stats(); err != nil {
|
||||
degraded = append(degraded, "store: "+err.Error())
|
||||
} else {
|
||||
total := numOf(st["total_memories"])
|
||||
tomb := numOf(st["tombstone_count"])
|
||||
live := total - tomb
|
||||
if live < 0 {
|
||||
live = 0
|
||||
}
|
||||
store := map[string]interface{}{
|
||||
"backend": st["backend"],
|
||||
"data_dir": st["data_dir"],
|
||||
"total_memories": total,
|
||||
"total_episodes": numOf(st["total_episodes"]),
|
||||
"tombstone_count": tomb,
|
||||
"live_memories": live,
|
||||
}
|
||||
if total > 0 {
|
||||
store["tombstone_rate"] = round4(tomb / total)
|
||||
} else {
|
||||
store["tombstone_rate"] = -1.0 // N/A:空库无分母
|
||||
}
|
||||
rep["store"] = store
|
||||
}
|
||||
|
||||
// ── 图谱 ───────────────────────────────────
|
||||
if a.GraphStore != nil {
|
||||
n, e, d := a.GraphStore.Stats()
|
||||
rep["graph"] = map[string]interface{}{
|
||||
"node_count": n,
|
||||
"edge_count": e,
|
||||
"density": round4(d),
|
||||
}
|
||||
} else {
|
||||
degraded = append(degraded, "graph: store not initialised")
|
||||
}
|
||||
|
||||
// ── pattern 召回的两侧健康(2026-10-26 新增)──────────
|
||||
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
|
||||
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默
|
||||
// (只能靠人翻 7 天日志才发现「实现了 ≠ 出过数」)。这里把「写入端有没有产数」
|
||||
// 变成一次 GET 就能读到的事实:rows=0 ⇒ 生产者坏了或从未接线,别去查读侧 SQL。
|
||||
rep["pattern"] = patternHealth(a)
|
||||
|
||||
// ── 面板指标(反馈/蒸馏/gap/cascade,含 _valid/_samples 语义)──
|
||||
rep["dashboard"] = selfoptimize.Dash.Metrics()
|
||||
|
||||
// ── 整合链路(内存态,<1ms)────────────────
|
||||
tracker := GetConsolTracker()
|
||||
consolStatus, consolFails, sinceSuccess, lastErr := tracker.HealthStatus()
|
||||
_, consolSkips, lastSkip, _ := tracker.Snapshot()
|
||||
rep["consolidate"] = map[string]interface{}{
|
||||
"status": consolStatus,
|
||||
"consec_failures": consolFails,
|
||||
"skipped": consolSkips,
|
||||
"last_skip": lastSkip,
|
||||
"last_success_age": sinceSuccess.String(),
|
||||
"last_error": lastErr,
|
||||
}
|
||||
|
||||
// ── 缓存 / FTS ─────────────────────────────
|
||||
cache := map[string]interface{}{"fts_index": storage.FtsIndexStats()}
|
||||
if storage.SearchCacheInstance != nil {
|
||||
cache["search_cache"] = storage.SearchCacheInstance.Stats()
|
||||
}
|
||||
rep["cache"] = cache
|
||||
|
||||
// ── 空壳拦截器(t_e11207f8 取证)───────────
|
||||
checks, blocks, loaded, rulesPath := distill.PayloadGuardStats()
|
||||
rep["payload_guard"] = map[string]interface{}{
|
||||
"checks": checks,
|
||||
"blocks": blocks,
|
||||
"loaded": loaded,
|
||||
"rules_path": rulesPath,
|
||||
}
|
||||
|
||||
// ── 全量质量段(level=full):读卫星快照,不在本进程扫全表 ────────
|
||||
// 设计:全表扫描是重活(4 万行 × 内容),且 daemon 还有 recall 要跑。
|
||||
// 取数由仓库内 tools/selfcheck_full.py 完成(列投影只读,1.7s / 250MB,独立进程),
|
||||
// 写 ~/.hermes/state/zhiyi-quality.json;本端点只读快照并标注新鲜度。
|
||||
// 这样"取数"只有一处实现(跟 schema 同仓),"判定/告警"仍在外部 cron。
|
||||
if level == "full" {
|
||||
snap, err := loadQualitySnapshot()
|
||||
if err != nil {
|
||||
degraded = append(degraded, "quality: "+err.Error())
|
||||
} else {
|
||||
rep["quality"] = snap.Quality
|
||||
rep["write_volume"] = snap.WriteVolume
|
||||
rep["quality_as_of"] = snap.GeneratedAt
|
||||
rep["quality_age_s"] = snap.AgeSeconds()
|
||||
rep["quality_stale"] = snap.AgeSeconds() > qualityStaleAfter
|
||||
}
|
||||
}
|
||||
|
||||
if len(degraded) > 0 {
|
||||
rep["degraded"] = degraded
|
||||
}
|
||||
rep["duration_ms"] = time.Since(start).Milliseconds()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(rep)
|
||||
}
|
||||
|
||||
// qualityStaleAfter:快照超过这个时长未刷新 ⇒ 标记 stale(消费方不得据此下结论)。
|
||||
const qualityStaleAfter = 6 * 3600
|
||||
|
||||
// qualitySnapshot 对应仓库内 tools/selfcheck_full.py 的产出。
|
||||
// 字段名与 python 侧严格一致(schema_version=1)。
|
||||
type qualitySnapshot struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Source struct {
|
||||
DB string `json:"db"`
|
||||
Table string `json:"table"`
|
||||
Mode string `json:"mode"`
|
||||
} `json:"source"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Quality json.RawMessage `json:"quality"`
|
||||
WriteVolume json.RawMessage `json:"write_volume"`
|
||||
fileModTime time.Time
|
||||
}
|
||||
|
||||
func (q *qualitySnapshot) AgeSeconds() int64 {
|
||||
if q.fileModTime.IsZero() {
|
||||
return -1
|
||||
}
|
||||
return int64(time.Since(q.fileModTime).Seconds())
|
||||
}
|
||||
|
||||
// QualitySnapshotPath 允许用 env 覆盖(测试/灰度)。
|
||||
func QualitySnapshotPath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("ZHIYI_QUALITY_SNAPSHOT")); p != "" {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".hermes", "state", "zhiyi-quality.json")
|
||||
}
|
||||
|
||||
func loadQualitySnapshot() (*qualitySnapshot, error) {
|
||||
p := QualitySnapshotPath()
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("快照不存在(先跑 tools/selfcheck_full.py):%w", err)
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var snap qualitySnapshot
|
||||
if err := json.Unmarshal(raw, &snap); err != nil {
|
||||
return nil, fmt.Errorf("快照解析失败: %w", err)
|
||||
}
|
||||
snap.fileModTime = fi.ModTime()
|
||||
return &snap, nil
|
||||
}
|
||||
|
||||
// numOf 把 stats 里可能是 float64/int/json.Number 的数值统一成 float64。
|
||||
func numOf(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func round4(f float64) float64 {
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return -1
|
||||
}
|
||||
return math.Round(f*10000) / 10000
|
||||
}
|
||||
|
||||
// patternHealth 一眼判定 pattern 召回是「有生产者」还是「恒 0」。
|
||||
//
|
||||
// 为什么需要它:2026-10-26 排查发现 pattern 召回 0 命中,真根因是写入端从未产数
|
||||
// (detect 端点无 cron、无调用方),但表面症状看起来像读侧 SQL bug —— 白查一轮方向。
|
||||
// 判据三类:
|
||||
//
|
||||
// producing 有行、有 id、有生产时间 → 读侧再 0 命中就该查 Match 阈值/properties;
|
||||
// zero_output 0 行 → 生产端问题,别去动读侧 SQL;
|
||||
// no_id 有行但 id 全空 → store.ListPatterns 会全 skip(存量冻结的典型形态)。
|
||||
func patternHealth(a *API) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"rows": -1, // -1 = 未知,别当 0 用
|
||||
"rows_with_id": -1,
|
||||
"last_produced_at": "",
|
||||
"verdict": "unknown",
|
||||
}
|
||||
if a == nil || a.GraphStore == nil {
|
||||
out["verdict"] = "no_graph_store"
|
||||
return out
|
||||
}
|
||||
rows, withID, last := a.GraphStore.CountNodesByType("pattern")
|
||||
out["rows"], out["rows_with_id"], out["last_produced_at"] = rows, withID, last
|
||||
switch {
|
||||
case rows == 0:
|
||||
out["verdict"] = "zero_output"
|
||||
case withID == 0:
|
||||
out["verdict"] = "no_id"
|
||||
case last == "":
|
||||
out["verdict"] = "unknown_age"
|
||||
default:
|
||||
out["verdict"] = "producing"
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 快照契约测试:/api/v1/selfcheck?level=full 依赖 tools/selfcheck_full.py 的产出格式。
|
||||
// 这里钉住 schema_version 与关键字段,防止 python 侧改字段名后 Go 侧静默返回空。
|
||||
func TestLoadQualitySnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "zhiyi-quality.json")
|
||||
body := `{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-10-26T13:20:00+0800",
|
||||
"source": {"db": "/var/lib/memoryweave", "table": "memories", "mode": "read_only_projection"},
|
||||
"duration_ms": 1680,
|
||||
"quality": {
|
||||
"frag_len": 20, "total_rows": 39957, "live_rows": 39836,
|
||||
"frag_rows_all": 313, "frag_rate_all": 0.0078,
|
||||
"frag_rows_live": 313, "frag_rate_live": 0.0079,
|
||||
"dup_groups": 34, "dup_extra_rows": 103, "dup_rate": 0.0026,
|
||||
"new_rows_7d": 1176, "new_frag_rows_7d": 35, "new_frag_rate_7d": 0.0298
|
||||
},
|
||||
"write_volume": {
|
||||
"days": [{"date": "2026-10-25", "rows": 1473, "live": 1471}],
|
||||
"today": 332, "today_date": "2026-10-26", "last_7d": 7706, "nonzero_days": 60
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", p)
|
||||
|
||||
snap, err := loadQualitySnapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("loadQualitySnapshot: %v", err)
|
||||
}
|
||||
if snap.SchemaVersion != 1 {
|
||||
t.Fatalf("schema_version=%d want 1", snap.SchemaVersion)
|
||||
}
|
||||
|
||||
var q struct {
|
||||
TotalRows int `json:"total_rows"`
|
||||
LiveRows int `json:"live_rows"`
|
||||
DupRate float64 `json:"dup_rate"`
|
||||
FragRowsAll int `json:"frag_rows_all"`
|
||||
}
|
||||
if err := json.Unmarshal(snap.Quality, &q); err != nil {
|
||||
t.Fatalf("quality 段解析失败: %v", err)
|
||||
}
|
||||
if q.TotalRows != 39957 || q.LiveRows != 39836 {
|
||||
t.Fatalf("quality 口径不符: %+v", q)
|
||||
}
|
||||
if q.FragRowsAll != 313 || q.DupRate != 0.0026 {
|
||||
t.Fatalf("quality 数值不符: %+v", q)
|
||||
}
|
||||
|
||||
var wv struct {
|
||||
Today int `json:"today"`
|
||||
Days []struct {
|
||||
Date string `json:"date"`
|
||||
Live int `json:"live"`
|
||||
} `json:"days"`
|
||||
}
|
||||
if err := json.Unmarshal(snap.WriteVolume, &wv); err != nil {
|
||||
t.Fatalf("write_volume 段解析失败: %v", err)
|
||||
}
|
||||
if wv.Today != 332 || len(wv.Days) != 1 || wv.Days[0].Live != 1471 {
|
||||
t.Fatalf("write_volume 不符: %+v", wv)
|
||||
}
|
||||
|
||||
// 新鲜度:刚写的文件 age 应 < 10s;人为改旧 → stale 判定为真
|
||||
if age := snap.AgeSeconds(); age < 0 || age > 10 {
|
||||
t.Fatalf("AgeSeconds=%d,本应接近 0", age)
|
||||
}
|
||||
old := time.Now().Add(-8 * time.Hour)
|
||||
if err := os.Chtimes(p, old, old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap2, err := loadQualitySnapshot()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap2.AgeSeconds() <= qualityStaleAfter {
|
||||
t.Fatalf("8h 前的快照应判 stale(>%d 秒),得到 %d", qualityStaleAfter, snap2.AgeSeconds())
|
||||
}
|
||||
}
|
||||
|
||||
// 快照缺失 ⇒ 必须报错(由 handler 转成 degraded),不能静默返回零值。
|
||||
func TestLoadQualitySnapshotMissing(t *testing.T) {
|
||||
t.Setenv("ZHIYI_QUALITY_SNAPSHOT", filepath.Join(t.TempDir(), "nope.json"))
|
||||
if _, err := loadQualitySnapshot(); err == nil {
|
||||
t.Fatal("缺失快照应返回 error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// 2026-10-13 (t_e9a4d18f P2) 常设答案路由(Hindsight mental models 直读/写入)。
|
||||
// 读取 = 一次 DB 读(question_id 主键),零检索零 LLM。写入由生成脚本
|
||||
// zhiyi-standing-answers.py 调用(复用本地 2B,免费)。
|
||||
|
||||
// standingStore 返回全局常设答案库(server.go 注入)。未初始化 → nil。
|
||||
func standingStore() *storage.StandingStore { return storage.StandingStoreInstance }
|
||||
|
||||
// HandleStandingAnswers GET/POST /api/v1/standing-answers
|
||||
// GET ?id=<question_id> → 直读一条;无 id → 列全部
|
||||
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
|
||||
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
s := standingStore()
|
||||
if s == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "standing answers store not initialized")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]interface{}{"answers": all, "count": len(all)})
|
||||
return
|
||||
}
|
||||
a, err := s.Get(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if a == nil {
|
||||
respondError(w, http.StatusNotFound, "standing answer not found: "+id)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, a)
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if in.QuestionID == "" || in.Answer == "" {
|
||||
respondError(w, http.StatusBadRequest, "question_id and answer required")
|
||||
return
|
||||
}
|
||||
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, a)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
+114
-44
@@ -1,117 +1,187 @@
|
||||
{
|
||||
"description": "织忆召回金标检查集(12条基线 + 2条目标知识)。统一判据口径(t_a70b07e1):取 recall 返回的前 N=5 条内容查关键词,ID 命中 = expected_ids 任一 ∈ 前 N=5 条;N=5 与 Go/Python 两量具(eval_test.go / golden-check.py)一致。判定:PASS=id且kw命中 / WARN=kw命中但ID漂移 / FAIL=其余 / ERROR=recall调用失败。单一真相源(t_949840b1):本文件是金标数据唯一副本;Python 量具 golden-check.py 直接读本文件(旧 ~/.hermes/scripts/tools/golden-set.json 副副本已删除),基线校准一次即两量具同步(--refresh 写回本文件)。⚠️ item4「牧尘喜欢什么样的工作方式」基线 2026-09-21 t_949840b1 校准为当前稳定值 mem_1789834428978989104(语料增长致旧基线 mem_1789837840173161705 掉出 top-5,前 N 内容仍切题)。校准后同批金标两量具分类差异=0(diff=1 的根因——两份副本 expected_ids 不同步——已消除)。",
|
||||
"description": "织忆召回金标检查集(单一真相源,t_949840b1)。统一判据口径 N=5:取 recall 返回前 N=5 条查关键词,ID 命中 = expected_ids 任一 ∈ 前 N=5 条。基线已刷新(golden-check.py --refresh,两量具同源)。",
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"expected_ids": ["mem_1789153726378940805"],
|
||||
"must_include_keywords": ["织忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1789153726378940805"
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"expected_ids": ["mem_1789823837902661249"],
|
||||
"must_include_keywords": ["图谱"],
|
||||
"expected_ids": [
|
||||
"mem_1791899478673190284"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"图谱"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1789823837902661249"
|
||||
"id_baseline": "mem_1791899478673190284",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"expected_ids": ["mem_1789915227303612056"],
|
||||
"must_include_keywords": ["MMR"],
|
||||
"expected_ids": [
|
||||
"mem_1791666890486125605"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"MMR"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1789915227303612056"
|
||||
"id_baseline": "mem_1791666890486125605",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"expected_ids": ["mem_1789667544872361813"],
|
||||
"must_include_keywords": ["牧尘"],
|
||||
"expected_ids": [
|
||||
"mem_1790448247125520424"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"牧尘"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1789667544872361813"
|
||||
"id_baseline": "mem_1790448247125520424",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"expected_ids": ["mem_1789834428978989104"],
|
||||
"must_include_keywords": ["牧尘"],
|
||||
"expected_ids": [
|
||||
"mem_1790448247125520424"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"牧尘"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1789834428978989104"
|
||||
"id_baseline": "mem_1790448247125520424",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"expected_ids": ["mem_1789190217889697697"],
|
||||
"must_include_keywords": ["记忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791747597032219499"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"记忆"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1789190217889697697"
|
||||
"id_baseline": "mem_1791747597032219499",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"expected_ids": ["mem_1789680870868739202"],
|
||||
"must_include_keywords": ["技术栈"],
|
||||
"expected_ids": [
|
||||
"mem_1790455676397796671"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"技术栈"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1789680870868739202"
|
||||
"id_baseline": "mem_1790455676397796671",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"expected_ids": ["mem_1789154058288011112"],
|
||||
"must_include_keywords": ["织忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791906354849281414"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1789154058288011112"
|
||||
"id_baseline": "mem_1791906354849281414",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"expected_ids": ["mem_1789153726378940805"],
|
||||
"must_include_keywords": ["织忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1789153726378940805"
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"expected_ids": ["mem_1787272787836687543"],
|
||||
"must_include_keywords": ["recall"],
|
||||
"expected_ids": [
|
||||
"mem_1791796178328736793"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"recall"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1787272787836687543"
|
||||
"id_baseline": "mem_1791796178328736793",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"expected_ids": ["mem_1789153726378940805"],
|
||||
"must_include_keywords": ["织忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1789153726378940805"
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"expected_ids": ["mem_1789153726378940805"],
|
||||
"must_include_keywords": ["织忆"],
|
||||
"expected_ids": [
|
||||
"mem_1791666188168772049"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1789153726378940805"
|
||||
"id_baseline": "mem_1791666188168772049",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"expected_ids": ["mem_1789229548476546193"],
|
||||
"must_include_keywords": ["8080"],
|
||||
"expected_ids": [
|
||||
"mem_1791423503359721152"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"8080"
|
||||
],
|
||||
"category": "target_knowledge",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1789229548476546193"
|
||||
"id_baseline": "mem_1791423503359721152",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"expected_ids": ["mem_1788977361686058470"],
|
||||
"must_include_keywords": ["session-to-zhiyi"],
|
||||
"expected_ids": [
|
||||
"mem_1791688470278719760"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"session-to-zhiyi"
|
||||
],
|
||||
"category": "target_knowledge",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1788977361686058470"
|
||||
"id_baseline": "mem_1791688470278719760",
|
||||
"namespace": "hermes-main"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// 织忆 MemoryWeave — routes 包测试隔离(两个护栏合并 · t_e5997ca4 + t_b7a8ed9c)
|
||||
//
|
||||
// ⚠️ 本文件是**两个 TestMain 的合并体**。两卡分支各自加了一个 TestMain:
|
||||
// · t_e5997ca4 → testmain_test.go(落盘点护栏)
|
||||
// · t_b7a8ed9c → scorecache_guard_test.go(打分缓存护栏)
|
||||
// 在各自分支上互不干扰,合并到 main 后同包两个 TestMain **编译冲突**
|
||||
// (multiple definitions of TestMain)⇒ 必须合进一个文件。
|
||||
// (此点由 t_b7a8ed9c 在 scorecache_guard_test.go 顶部预先标注;小怡合并时未读文件
|
||||
// 而漏看,由全套测试的 setup failed 拦下 —— 见 worklog 教训。)
|
||||
//
|
||||
// 护栏①:单元测试不得写到生产落盘点(t_e5997ca4)
|
||||
// ---------------------------------------------------------------
|
||||
// selfoptimize 包已有同款 TestMain 先例(曾实测:整套测试跑完把
|
||||
// /var/lib/memoryweave/dashboard.json 写成测试数字)。
|
||||
// gap close/repair 端点会触发 Dash.persist()(本地 JSON 兜底)⇒ 统一重定向到临时目录。
|
||||
//
|
||||
// 护栏②:默认关闭 distill 打分缓存(t_b7a8ed9c)
|
||||
// ---------------------------------------------------------------
|
||||
// 背景:distill 打分记忆化(0dc571d)+ Redis L2(36db9bb,键 zhiyi:distill-score:*,
|
||||
// TTL 168h)后,**本包 E2E 测试(zz_commit_conflict_gate_test.go)会读写生产 Redis**:
|
||||
// 第一次 run L2 miss → 真调 mock LLM 并把「测试内容哈希 → 结果」写进生产;
|
||||
// 7 天内后续 run L2 hit → LLM 被静默短路 → 数 LLM 调用次数的断言(base+1)必挂。
|
||||
// 实测 2026-10-17:TestCommitConflictGate_E2E 16.5s 固定 FAIL,
|
||||
// 「expected >= 1 LLM distill calls, got 0」。
|
||||
//
|
||||
// 治法(对齐项目门禁规矩③:加缓存必须给该包 TestMain 加关闭开关):
|
||||
// Setenv ZHIYI_SCORE_CACHE=0 —— distill.getScoreCache 的 Once 闭包**活读 env**
|
||||
// (包级变量在 init 时已定型,跨包设不了),本进程内缓存整体禁用:
|
||||
// 不读 L2(LLM 真被调用,计数断言有覆盖)、不写 L2(不再污染生产)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// 护栏①:落盘点重定向到临时目录(宁可测试直接失败,也不写生产落盘点)
|
||||
dir, err := os.MkdirTemp("", "routes-test-dash-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Setenv("ZHIYI_DASHBOARD_PATH", filepath.Join(dir, "dashboard.json"))
|
||||
|
||||
// 护栏②:关闭 distill 打分缓存(不读/不写生产 Redis L2)
|
||||
_ = os.Setenv("ZHIYI_SCORE_CACHE", "0")
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 织忆 MemoryWeave — 召回链路追踪端点(P0-1,2026-10-26)
|
||||
//
|
||||
// GET /api/v1/trace/<trace_id>
|
||||
//
|
||||
// 返回该次召回的:各阶段耗时(encode/coarse_search/fts_recall/bm25/rerank_fusion/mmr_select/
|
||||
// graph_expand/dedupe)、各通道贡献条数(channels)、每条结果的来源归因(items)、
|
||||
// status/partial_reason、scope、cache_hit/truncated。
|
||||
//
|
||||
// 数据来源:内存环形缓冲(最近 2000 条)+ JSONL 落盘(/var/lib/memoryweave/traces/),
|
||||
// 服务重启后仍可按 trace_id 回溯(落盘扫描)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// ── P0-3 召回硬超时:取消函数类型 + context.WithTimeout 包装 ──────────────
|
||||
// core.go Recall() 在 req.DeadlineMS>0 时需要带类型标记的取消函数,以便 defer 取消。
|
||||
// 2026-10-26 (t_e98cb1d6):此处补齐使 routes 包可编译(否则 routes 全包不可构建,
|
||||
// capRecallResults 的单测无法跑通)。这是缺失的声明,不是功能实现;P0-3 的 trace 完整
|
||||
// 接线(tr.Stage 埋点、tr 落盘)仍待 P0-3 本任务补齐。
|
||||
type ctxDeadlineFunc context.CancelFunc
|
||||
|
||||
func contextWithTimeout(ctx context.Context, timeout time.Duration) (context.Context, ctxDeadlineFunc) {
|
||||
c, cancel := context.WithTimeout(ctx, timeout)
|
||||
return c, ctxDeadlineFunc(cancel)
|
||||
}
|
||||
|
||||
// Trace 查询单条召回链路。路径形如 /api/v1/trace/tr_xxxx。
|
||||
func (a *API) Trace(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/api/v1/trace/"))
|
||||
if id == "" {
|
||||
respondErrorSchema(w, 400, "trace_id_required", "用法:GET /api/v1/trace/<trace_id>", 0)
|
||||
return
|
||||
}
|
||||
tr, ok := storage.GetTrace(id)
|
||||
if !ok {
|
||||
// 404 与「trace 过期/被环形缓冲覆盖」同义:调用方据此走「重新召回」而不是当成系统故障。
|
||||
respondErrorSchema(w, 404, "trace_not_found",
|
||||
"trace 不存在或已被环形缓冲覆盖(内存保留最近 2000 条,落盘按天分文件)", 0)
|
||||
return
|
||||
}
|
||||
respond(w, 200, tr)
|
||||
}
|
||||
@@ -2,11 +2,16 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// TuningConfig 可自动调优的参数
|
||||
@@ -21,6 +26,15 @@ type TuningConfig struct {
|
||||
|
||||
// 历史调参记录
|
||||
history []TuningEvent
|
||||
|
||||
// lastSkipped 上一次 AutoTune 里「因指标不可用而跳过」的规则与原因。
|
||||
// 让外部能区分「健康所以没调」与「数据不可用所以没调」——后者需要修数据源,不是好事。
|
||||
lastSkipped []string
|
||||
|
||||
// lastMoveAt 每个参数上一次被改动的时间 —— 用于冷却(治棘轮)。
|
||||
lastMoveAt map[string]time.Time
|
||||
// persistEnabled 仅全局 Tuner 为 true;测试里构造的实例为 false ⇒ 不写盘。
|
||||
persistEnabled bool
|
||||
}
|
||||
|
||||
type TuningEvent struct {
|
||||
@@ -32,11 +46,149 @@ type TuningEvent struct {
|
||||
}
|
||||
|
||||
var Tuner = &TuningConfig{
|
||||
Diversity: 0.5,
|
||||
// ⚠️ 初值必须等于**真实消费方的默认值**,否则接线当天就改变生产行为:
|
||||
// Diversity 对齐 storage.DefaultMMRDiversity() 的 0.3(core.go 原硬编码),原为 0.5。
|
||||
Diversity: 0.3,
|
||||
DecayRate: 0.015,
|
||||
GapThreshold: 3,
|
||||
DistillInterval: 5,
|
||||
ConsolidateAfter: 50,
|
||||
|
||||
lastMoveAt: map[string]time.Time{},
|
||||
persistEnabled: true,
|
||||
}
|
||||
|
||||
// tuneCooldown 同一参数的调参冷却时间。
|
||||
// 为什么需要:**AutoTune 不幂等** —— 同一判据每调一次就推一步(实测连跑两次:
|
||||
// decay_rate 0.015→0.018→0.0216、distill_interval 5→4→3),直到撞上限。
|
||||
// 闭环每天跑一次 ⇒ 几天内把参数顶到极值。冷却把「持续推进」限制成
|
||||
// 「同一指标须持续越界多个窗口,才多次调整」。
|
||||
const tuneCooldown = 6 * time.Hour
|
||||
|
||||
// minTuningSamples 调参判据的最小样本数。
|
||||
// 为什么需要:小样本的比率噪声极大 —— 实测现网 recall_usefulness_rate 只有 9 个样本,
|
||||
// 8 好 1 坏 = 0.89,再来 1 个好评就 > 0.9 ⇒ 触发「降低 diversity」直接改生产参数。
|
||||
// 少样本不是证据。
|
||||
const minTuningSamples = 30
|
||||
|
||||
// ratioFor 取一个「可用于调参」的比率指标,不可用则返回 ok=false。
|
||||
//
|
||||
// 约定(internal/selfoptimize/selfoptimize.go:248-322):
|
||||
//
|
||||
// <name>_rate —— 比率值;**分母退化时返回 -1**(metricNA),既不是 0 也不是 1
|
||||
// <name>_samples —— 该比率的分母样本数
|
||||
// <name>_valid —— 分母是否非退化
|
||||
//
|
||||
// ⚠️ AutoTune 原先只比较数值,无视这套约定 ⇒ `-1` 会让规则对「无数据」**恒真**:
|
||||
//
|
||||
// gap_closure_rate = -1 < 0.5 ⇒「降低 gap 阈值」每轮都触发(按垃圾指标改生产参数)
|
||||
// recall_usefulness_rate 样本 9 条 ⇒ 一次偶然就改 Diversity
|
||||
//
|
||||
// 本函数把「无数据 / 退化 / 样本不足」三类一律判为不可用 —— 宁可不动。
|
||||
// ⚠️ 命名不是统一的:比率键为 `<base>_rate`,而 *样本/有效* 键挂在 **base** 上
|
||||
// 而不是带 _rate 的那个串上 —— 实际键是 `recall_usefulness_samples`,
|
||||
// 不是 `recall_usefulness_rate_samples`(我第一版就按后者拼,护栏静默失效、
|
||||
// 参数照样被改;新加的 tuning_guard_test.go 当场逮住)。
|
||||
// 且 `avg_distill_loss` 这类**值键本身不带 _rate 后缀** ⇒ 统一用 TrimSuffix 推 base。
|
||||
func ratioFor(metrics map[string]float64, valueKey string) (float64, bool) {
|
||||
base := strings.TrimSuffix(valueKey, "_rate")
|
||||
v, ok := metrics[valueKey]
|
||||
if !ok || v < 0 {
|
||||
return 0, false // 缺失 或 -1 哨兵 = 无数据
|
||||
}
|
||||
if valid, ok := metrics[base+"_valid"]; ok && valid == 0 {
|
||||
return 0, false // 分母退化
|
||||
}
|
||||
if samples, ok := metrics[base+"_samples"]; ok && samples < minTuningSamples {
|
||||
return 0, false // 样本不足以支撑判断
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// canMove 判断某参数当前是否允许改动(冷却窗口内不许重复动 —— 治棘轮)。
|
||||
func (tc *TuningConfig) canMove(param string) bool {
|
||||
last, ok := tc.lastMoveAt[param]
|
||||
return !ok || time.Since(last) >= tuneCooldown
|
||||
}
|
||||
|
||||
func (tc *TuningConfig) markMove(param string) {
|
||||
if tc.lastMoveAt == nil {
|
||||
tc.lastMoveAt = map[string]time.Time{}
|
||||
}
|
||||
tc.lastMoveAt[param] = time.Now()
|
||||
}
|
||||
|
||||
// pushToConsumers 把当前参数推给**真实消费方**。
|
||||
// 🔴 这一步是「调参有没有用」的分水岭:2026-10-14 实测,此前 Tuner 的全部字段
|
||||
// 没有任何读者(全仓只有 3 处路由注册引用它)⇒ 调参改了数字、行为零变化。
|
||||
func (tc *TuningConfig) pushToConsumers() {
|
||||
storage.SetDefaultMMRDiversity(tc.Diversity)
|
||||
selfoptimize.SetGapThreshold(tc.GapThreshold)
|
||||
}
|
||||
|
||||
// tuningStatePath 持久化路径(可用 ZHIYI_TUNING_STATE 覆盖,便于测试/隔离)。
|
||||
func tuningStatePath() string {
|
||||
if p := os.Getenv("ZHIYI_TUNING_STATE"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/var/lib/memoryweave/tuning-state.json"
|
||||
}
|
||||
|
||||
type tuningState struct {
|
||||
Diversity float64 `json:"diversity"`
|
||||
GapThreshold int `json:"gap_threshold"`
|
||||
LastMoveAt map[string]string `json:"last_move_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// persist 落盘(失败只告警,不影响调参本身)。
|
||||
func (tc *TuningConfig) persist() {
|
||||
if !tc.persistEnabled {
|
||||
return
|
||||
}
|
||||
st := tuningState{Diversity: tc.Diversity, GapThreshold: tc.GapThreshold,
|
||||
LastMoveAt: map[string]string{}, UpdatedAt: time.Now().Format(time.RFC3339)}
|
||||
for k, v := range tc.lastMoveAt {
|
||||
st.LastMoveAt[k] = v.Format(time.RFC3339)
|
||||
}
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[tuning] 序列化失败: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(tuningStatePath(), data, 0o644); err != nil {
|
||||
log.Printf("[tuning] 落盘失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// load 启动时载入持久化参数(无文件则用默认值),并立即接线到消费方。
|
||||
// 没有这一步,重启即回默认 ⇒ 调参成果每次重启清零。
|
||||
func (tc *TuningConfig) load() {
|
||||
data, err := os.ReadFile(tuningStatePath())
|
||||
if err == nil {
|
||||
var st tuningState
|
||||
if json.Unmarshal(data, &st) == nil {
|
||||
if st.Diversity > 0 {
|
||||
tc.Diversity = st.Diversity
|
||||
}
|
||||
if st.GapThreshold > 0 {
|
||||
tc.GapThreshold = st.GapThreshold
|
||||
}
|
||||
for k, v := range st.LastMoveAt {
|
||||
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
||||
if tc.lastMoveAt == nil {
|
||||
tc.lastMoveAt = map[string]time.Time{}
|
||||
}
|
||||
tc.lastMoveAt[k] = t
|
||||
}
|
||||
}
|
||||
log.Printf("[tuning] 已载入持久化参数 diversity=%.3f gap_threshold=%d(%s)",
|
||||
tc.Diversity, tc.GapThreshold, st.UpdatedAt)
|
||||
}
|
||||
}
|
||||
tc.mu.Lock()
|
||||
tc.pushToConsumers()
|
||||
tc.mu.Unlock()
|
||||
}
|
||||
|
||||
// AutoTune 基于仪表盘指标自动调参
|
||||
@@ -45,61 +197,103 @@ func (tc *TuningConfig) AutoTune(metrics map[string]float64) []TuningEvent {
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
var events []TuningEvent
|
||||
skipped := make([]string, 0, 5)
|
||||
// 记一次「本规则为什么没跑」—— 让调用方能解释「为什么没调参」,
|
||||
// 而不是只回 no_changes_needed(后者无法区分「健康」与「数据不可用」)。
|
||||
note := func(rule, why string) {
|
||||
skipped = append(skipped, rule+": "+why)
|
||||
}
|
||||
|
||||
// 1. 召回命中率 < 0.5 → 提高 diversity(扩大搜索范围)
|
||||
if hitRate, ok := metrics["recall_hit_rate"]; ok && hitRate < 0.5 {
|
||||
// 1. 召回质量低 → 提高 diversity(扩大搜索范围)。
|
||||
// 2026-10-17:原信号 recall_hit_rate 已退役(恒=1、从不及 0.5 ⇒ 本安全阀长期死寂)。
|
||||
// 改用 recall_usefulness_rate(返回且被使用)作为召回质量的实时信号:< 0.5 ⇒ 召回太窄、扩大范围。
|
||||
hitRate, hitOK := ratioFor(metrics, "recall_usefulness_rate")
|
||||
if !hitOK {
|
||||
note("rule1_usefulness", "指标不可用(无数据/退化/样本<20)")
|
||||
}
|
||||
if hitOK && hitRate < 0.5 && !tc.canMove("diversity") {
|
||||
note("rule1_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if hitOK && hitRate < 0.5 && tc.canMove("diversity") {
|
||||
newDiversity := tc.Diversity + 0.1
|
||||
if newDiversity > 1.0 {
|
||||
newDiversity = 1.0
|
||||
}
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_hit_rate < 0.5"))
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate < 0.5"))
|
||||
tc.Diversity = newDiversity
|
||||
tc.markMove("diversity")
|
||||
}
|
||||
|
||||
// 2. 召回有用率 > 0.9 → 降低 diversity(更精准)
|
||||
if usefulRate, ok := metrics["recall_usefulness_rate"]; ok && usefulRate > 0.9 {
|
||||
usefulRate, usefulOK := ratioFor(metrics, "recall_usefulness_rate")
|
||||
if !usefulOK {
|
||||
note("rule2_usefulness", "指标不可用(无数据/退化/样本<30)")
|
||||
}
|
||||
if usefulOK && usefulRate > 0.9 && !tc.canMove("diversity") {
|
||||
note("rule2_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if usefulOK && usefulRate > 0.9 && tc.canMove("diversity") {
|
||||
newDiversity := tc.Diversity - 0.05
|
||||
if newDiversity < 0.1 {
|
||||
newDiversity = 0.1
|
||||
}
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate > 0.9"))
|
||||
tc.Diversity = newDiversity
|
||||
tc.markMove("diversity")
|
||||
}
|
||||
|
||||
// 3. 废弃率 > 5/day → 提高衰减率(加快清理)
|
||||
if deprecated, ok := metrics["deprecated_per_day"]; ok && deprecated > 5 {
|
||||
newDecay := tc.DecayRate * 1.2
|
||||
if newDecay > 0.05 {
|
||||
newDecay = 0.05
|
||||
}
|
||||
events = append(events, recordTuning(tc, "decay_rate", tc.DecayRate, newDecay, "deprecated_per_day > 5"))
|
||||
tc.DecayRate = newDecay
|
||||
}
|
||||
// 3.(2026-10-14 移除)废弃率 → 提高衰减率。
|
||||
// 移除理由:这个方向危险。governance.NewForgetter 上方注释记载:
|
||||
// 「2026-09-07 方案A: 0.015→0.03 曾致误删(36天库龄下 0-recall 长记忆全被清, 405字CNB经验被删)」。
|
||||
// 自动调参朝这个方向推 = 把那条事故路重新铺上;而收益只是「加快清理」。
|
||||
// 若要恢复,必须先有「误删可回滚 + 删除前抽样人工确认」的保护,不能只靠阈值。
|
||||
|
||||
// 4. 缺口闭环率 < 0.5 → 降低 gap 阈值
|
||||
if gapRate, ok := metrics["gap_closure_rate"]; ok && gapRate < 0.5 {
|
||||
gapRate, gapOK := ratioFor(metrics, "gap_closure_rate")
|
||||
if !gapOK {
|
||||
note("rule4_gap_threshold", "指标不可用(无数据/退化/样本<30)")
|
||||
}
|
||||
if gapOK && gapRate < 0.5 && !tc.canMove("gap_threshold") {
|
||||
note("rule4_gap_threshold", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if gapOK && gapRate < 0.5 && tc.canMove("gap_threshold") {
|
||||
newThreshold := tc.GapThreshold - 1
|
||||
if newThreshold < 2 {
|
||||
newThreshold = 2
|
||||
}
|
||||
events = append(events, recordTuningInt(tc, "gap_threshold", tc.GapThreshold, newThreshold, "gap_closure_rate < 0.5"))
|
||||
tc.GapThreshold = newThreshold
|
||||
tc.markMove("gap_threshold")
|
||||
}
|
||||
|
||||
// 5. 蒸馏损失 > 0.3 → 更频繁蒸馏
|
||||
if loss, ok := metrics["avg_distill_loss"]; ok && loss > 0.3 {
|
||||
newInterval := tc.DistillInterval - 1
|
||||
if newInterval < 1 {
|
||||
newInterval = 1
|
||||
}
|
||||
events = append(events, recordTuningInt(tc, "distill_interval", tc.DistillInterval, newInterval, "avg_distill_loss > 0.3"))
|
||||
tc.DistillInterval = newInterval
|
||||
}
|
||||
// 5.(2026-10-14 移除)蒸馏损失 → 缩短蒸馏间隔。
|
||||
// 移除理由:**没有正确的消费方**。distill/engine.go:265 的 batchTimeout(5min)是
|
||||
// 「队列冻结兜底 ticker」(2026-10-06 修的「生产者停止即永久冻结」安全网),
|
||||
// **不是蒸馏节奏**。调安全网的周期治不了蒸馏质量 —— 接到错的地方比不接更坏。
|
||||
// 若将来真有「蒸馏节奏」消费者,再恢复本规则并接对目标。
|
||||
|
||||
tc.history = append(tc.history, events...)
|
||||
tc.lastSkipped = skipped
|
||||
if len(events) > 0 {
|
||||
tc.pushToConsumers() // 改了就必须接线,否则等于没改
|
||||
tc.persist() // 不落盘则重启即丢
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// init 启动时载入持久化参数并接线(没有它:重启回默认 + 调参不生效)。
|
||||
func init() { Tuner.load() }
|
||||
|
||||
// LastSkipped 返回上一次调参里被跳过的规则及原因(只读快照)。
|
||||
func (tc *TuningConfig) LastSkipped() []string {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
out := make([]string, len(tc.lastSkipped))
|
||||
copy(out, tc.lastSkipped)
|
||||
return out
|
||||
}
|
||||
|
||||
func recordTuning(tc *TuningConfig, param string, oldVal, newVal float64, reason string) TuningEvent {
|
||||
return TuningEvent{
|
||||
Parameter: param,
|
||||
@@ -135,7 +329,8 @@ func (tc *TuningConfig) StatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"distill_interval": tc.DistillInterval,
|
||||
"consolidate_after": tc.ConsolidateAfter,
|
||||
},
|
||||
"history": tc.history,
|
||||
"history": tc.history,
|
||||
"last_skipped": tc.lastSkipped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,7 +340,11 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
events := tc.AutoTune(metrics)
|
||||
|
||||
if len(events) == 0 {
|
||||
respond(w, 200, map[string]string{"status": "no_changes_needed"})
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "no_changes_needed",
|
||||
// 区分「健康」与「数据不可用」:后者要修数据源,而不是当作一切正常。
|
||||
"skipped": tc.lastSkipped,
|
||||
})
|
||||
} else {
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "tuned",
|
||||
@@ -157,25 +356,34 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/v1/tuning/analytics — 运行因果分析
|
||||
func (tc *TuningConfig) AnalyticsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 基于指标间的关系给出建议
|
||||
m := selfoptimize.Dash.Metrics()
|
||||
|
||||
suggestions := []string{}
|
||||
if m["recall_hit_rate"] < 0.5 && m["avg_distill_loss"] > 0.3 {
|
||||
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
|
||||
}
|
||||
if m["deprecated_per_day"] > 10 {
|
||||
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
|
||||
}
|
||||
if m["gap_closure_rate"] < 0.3 {
|
||||
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
|
||||
}
|
||||
if m["auto_resolve_rate"] < 0.5 {
|
||||
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
|
||||
}
|
||||
|
||||
suggestions := AnalyticsSuggestions(m)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"metrics": m,
|
||||
"suggestions": suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
// AnalyticsSuggestions 依据指标间关系给出调优建议(纯函数,便于单测反例:N/A 不产生建议)。
|
||||
//
|
||||
// ⚠️ 每条建议的比率比较都必须先判 N/A(ratioFor 已封装:-1 哨兵 / valid=0 / 样本不足 ⇒ 不可用)。
|
||||
// 若直接 `m[key] < 阈值`,-1 会恒真 ⇒ 无数据也误报建议(本卡 t_c70f385c 事故面)。
|
||||
// deprecated_per_day 是计数(恒 ≥0,非比率),不走 ratioFor。
|
||||
func AnalyticsSuggestions(m map[string]float64) []string {
|
||||
suggestions := []string{}
|
||||
if hr, ok := ratioFor(m, "recall_usefulness_rate"); ok && hr < 0.5 {
|
||||
if dl, ok2 := ratioFor(m, "avg_distill_loss"); ok2 && dl > 0.3 {
|
||||
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
|
||||
}
|
||||
}
|
||||
if m["deprecated_per_day"] > 10 {
|
||||
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
|
||||
}
|
||||
if g, ok := ratioFor(m, "gap_closure_rate"); ok && g < 0.3 {
|
||||
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
|
||||
}
|
||||
if a, ok := ratioFor(m, "auto_resolve_rate"); ok && a < 0.5 {
|
||||
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// AnalyticsSuggestions N/A 哨兵护栏 —— 反例 + 正向对照。
|
||||
//
|
||||
// 本卡 t_c70f385c:AnalyticsHandler 的建议 builder 原为裸 `m[key] < 阈值`,
|
||||
// -1 哨兵(样本不足/越界被拦)会让 `-1 < 0.5` 恒真 ⇒ 无数据也误报建议。
|
||||
// 现统一走 ratioFor(已封装 N/A + valid=0 + 样本不足判定)。
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAnalyticsSuggestions_NA_NoFiring 反例:全 N/A 哨兵(-1)不产生任何建议(避免误报)。
|
||||
func TestAnalyticsSuggestions_NA_NoFiring(t *testing.T) {
|
||||
na := map[string]float64{
|
||||
"recall_usefulness_rate": -1, "recall_usefulness_samples": 0, "recall_usefulness_valid": 0,
|
||||
"avg_distill_loss": -1, "avg_distill_loss_samples": 0, "avg_distill_loss_valid": 0,
|
||||
"gap_closure_rate": -1, "gap_closure_samples": 0, "gap_closure_valid": 0,
|
||||
"auto_resolve_rate": -1, "auto_resolve_samples": 0, "auto_resolve_valid": 0,
|
||||
"deprecated_per_day": 0,
|
||||
}
|
||||
if s := AnalyticsSuggestions(na); len(s) != 0 {
|
||||
t.Fatalf("全 N/A 不应产生任何建议,实际 %d 条: %v", len(s), s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyticsSuggestions_ValidLow 正向对照:有效且低于阈值时产生建议(护栏没把功能焊死)。
|
||||
func TestAnalyticsSuggestions_ValidLow(t *testing.T) {
|
||||
m := map[string]float64{
|
||||
"recall_usefulness_rate": 0.2, "recall_usefulness_samples": 100, "recall_usefulness_valid": 1,
|
||||
"gap_closure_rate": 0.1, "gap_closure_samples": 100, "gap_closure_valid": 1,
|
||||
"auto_resolve_rate": 0.2, "auto_resolve_samples": 100, "auto_resolve_valid": 1,
|
||||
"avg_distill_loss": 0.1, "avg_distill_loss_samples": 100, "avg_distill_loss_valid": 1,
|
||||
"deprecated_per_day": 0,
|
||||
}
|
||||
s := AnalyticsSuggestions(m)
|
||||
// 至少触发「gap 闭环率过低」+「自动裁决率低」两条
|
||||
if len(s) < 2 {
|
||||
t.Fatalf("有效且低于阈值应产生 ≥2 条建议,实际 %d 条: %v", len(s), s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// TestAutoTuneGuard_SentinelAndSamples 双向验证护栏:
|
||||
// ① 无数据(-1 哨兵)不许改参数 —— 这是事故面:gap_closure_rate=-1 会让
|
||||
//
|
||||
// 「降低 gap 阈值」每轮恒真。
|
||||
//
|
||||
// ② 样本不足(<30)不许改参数 —— 少样本比率噪声极大。
|
||||
// ③ 数据健康时仍然要能调 —— 否则等于把功能焊死。
|
||||
func TestAutoTuneGuard_SentinelAndSamples(t *testing.T) {
|
||||
// ① 哨兵值:全 -1 ⇒ 一个事件都不许有
|
||||
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
na := map[string]float64{
|
||||
"gap_closure_rate": -1,
|
||||
"gap_closure_samples": 0,
|
||||
"gap_closure_valid": 0,
|
||||
"recall_usefulness_rate": -1,
|
||||
"avg_distill_loss": -1,
|
||||
"recall_usefulness_valid": 0,
|
||||
"avg_distill_loss_valid": 0,
|
||||
}
|
||||
if ev := tc.AutoTune(na); len(ev) != 0 {
|
||||
t.Fatalf("哨兵值(-1)不应触发任何调参,实际触发 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
if tc.GapThreshold != 3 || tc.Diversity != 0.5 {
|
||||
t.Fatalf("参数被改动:GapThreshold=%d Diversity=%v", tc.GapThreshold, tc.Diversity)
|
||||
}
|
||||
if len(tc.LastSkipped()) == 0 {
|
||||
t.Fatal("应记录跳过原因(否则无法区分『健康』与『数据不可用』)")
|
||||
}
|
||||
|
||||
// ② 样本不足:比率本身达标(>0.9)但只有 9 个样本 ⇒ 不许动
|
||||
tc2 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
low := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 9,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
if ev := tc2.AutoTune(low); len(ev) != 0 {
|
||||
t.Fatalf("样本 9<30 不应触发调参,实际 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
|
||||
// ③ 数据健康 ⇒ 必须能调(证明护栏没把功能焊死)
|
||||
tc3 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
good := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 100,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
ev := tc3.AutoTune(good)
|
||||
if len(ev) != 1 {
|
||||
t.Fatalf("健康数据应触发 1 条调参,实际 %d 条", len(ev))
|
||||
}
|
||||
if tc3.Diversity >= 0.5 {
|
||||
t.Fatalf("Diversity 应被降低,实际 %v", tc3.Diversity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoTuneCooldown 冷却:同一参数在冷却窗口内不许被重复推进(治棘轮)。
|
||||
// 事故面(实测):AutoTune 不幂等 —— 连跑两次 decay 0.015→0.018→0.0216。
|
||||
func TestAutoTuneCooldown(t *testing.T) {
|
||||
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
m := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 100,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
if ev := tc.AutoTune(m); len(ev) != 1 {
|
||||
t.Fatalf("第一次应调 1 条,实际 %d", len(ev))
|
||||
}
|
||||
first := tc.Diversity
|
||||
if ev := tc.AutoTune(m); len(ev) != 0 {
|
||||
t.Fatalf("冷却窗口内不应再调,实际 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
if tc.Diversity != first {
|
||||
t.Fatalf("冷却期内参数被改动:%v → %v(棘轮复发)", first, tc.Diversity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoTunePushesToConsumers 推送:改完参数必须真的到消费方手里,
|
||||
// 否则就是「调参改了没人读的数字」(2026-10-14 实测的原状)。
|
||||
func TestAutoTunePushesToConsumers(t *testing.T) {
|
||||
orig := storage.DefaultMMRDiversity()
|
||||
defer storage.SetDefaultMMRDiversity(orig)
|
||||
tc := &TuningConfig{Diversity: 0.5, GapThreshold: 3}
|
||||
m := map[string]float64{
|
||||
"gap_closure_rate": 0.1,
|
||||
"gap_closure_samples": 100,
|
||||
"gap_closure_valid": 1,
|
||||
}
|
||||
before := selfoptimize.GapThreshold()
|
||||
ev := tc.AutoTune(m)
|
||||
if len(ev) != 1 {
|
||||
t.Fatalf("应触发 1 条(gap 阈值),实际 %d", len(ev))
|
||||
}
|
||||
if tc.GapThreshold != before-1 {
|
||||
t.Fatalf("期望阈值 %d→%d,实际 %d", before, before-1, tc.GapThreshold)
|
||||
}
|
||||
if got := selfoptimize.GapThreshold(); got != tc.GapThreshold {
|
||||
t.Fatalf("没推到消费方:selfoptimize.GapThreshold()=%d,Tuner=%d", got, tc.GapThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuningPersistence 持久化:换实例后能从盘上读回(否则重启即回默认)。
|
||||
func TestTuningPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZHIYI_TUNING_STATE", dir+"/tuning-state.json")
|
||||
|
||||
a := &TuningConfig{Diversity: 0.42, GapThreshold: 4, persistEnabled: true, lastMoveAt: map[string]time.Time{}}
|
||||
a.persist()
|
||||
|
||||
b := &TuningConfig{Diversity: 0.3, GapThreshold: 3, lastMoveAt: map[string]time.Time{}}
|
||||
b.load()
|
||||
if b.Diversity != 0.42 || b.GapThreshold != 4 {
|
||||
t.Fatalf("未读回持久化参数:diversity=%v gap=%v", b.Diversity, b.GapThreshold)
|
||||
}
|
||||
}
|
||||
@@ -80,10 +80,14 @@ func (m *mockLance) IncrementUseful(id string) error { r
|
||||
func (m *mockLance) IncrementNotUseful(id string) error { return nil }
|
||||
|
||||
// GA-P0-1 (t_718d8707):拆分字段
|
||||
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
|
||||
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
|
||||
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
|
||||
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
|
||||
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
|
||||
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
|
||||
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockLance) IncrementDedupDownweight(id string) error { return nil }
|
||||
func (m *mockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
|
||||
func (m *mockLance) InsertMemory(rec models.MemoryRecord) error { return nil }
|
||||
@@ -116,6 +120,7 @@ func (mockGraph) ListNodesByType(nodeType string, namespace string) []map[string
|
||||
}
|
||||
func (mockGraph) ListNodes(namespace string) []map[string]interface{} { return nil }
|
||||
func (mockGraph) Stats() (int, int, float64) { return 0, 0, 0 }
|
||||
func (mockGraph) CountNodesByType(string) (int, int, string) { return 0, 0, "" }
|
||||
func (mockGraph) Prune(minWeight float64) {}
|
||||
func (mockGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
|
||||
return results
|
||||
|
||||
+303
-33
@@ -21,10 +21,14 @@ import (
|
||||
"github.com/xiaoxue/memoryweave/internal/api/routes"
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/memprotect"
|
||||
"github.com/xiaoxue/memoryweave/internal/metrics"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/peer"
|
||||
"github.com/xiaoxue/memoryweave/internal/redact"
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/skillgen"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
@@ -53,11 +57,28 @@ func NewServer() http.Handler {
|
||||
// 存储后端
|
||||
ldb := initStorageBackend(os.Getenv("STORAGE_BACKEND"), emb)
|
||||
|
||||
// 2026-10-13 (t_e9a4d18f P2) 常设答案库(独立 SQLite,Hindsight mental models)。
|
||||
// 只读写不主动注入 ⇒ 部署后行为零变化。注入由 zhiyi-standing-answers.py 生成后
|
||||
// 插件侧决定是否直读。路径可经 ZHIYI_STANDING_DB 覆盖。
|
||||
storage.InitStandingStore(os.Getenv("ZHIYI_STANDING_DB"))
|
||||
|
||||
// 召回元数据「累积延迟更新」缓冲(2026-09-12 优化B)
|
||||
// 读路径不再逐条写 LanceDB(MVCC 每写一行一个版本 → _versions 膨胀根因),
|
||||
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
|
||||
storage.InitRecallWriteBuffer(ldb)
|
||||
|
||||
// 2026-10-14 (t_2afae95e) FTS 词面通道:给 TF-IDF 索引挂全量数据源(ScanAll,
|
||||
// 只读 IPC lancedb_scan_all;不建 LanceDB tantivy 索引文件 ⇒ 零写 LanceDB)。
|
||||
// 索引本体在 storage 包内后台建(启动 3s 首建 + 6h 周期 + 写路径标脏)。
|
||||
// 门控 ZHIYI_RECALL_FTS 默认关 ⇒ 接线了也不改召回行为(AC4 零变化)。
|
||||
if rc, ok := ldb.(*storage.RustLanceDBClient); ok {
|
||||
storage.FtsProvider.FtsLdb = rc.ScanAll
|
||||
log.Printf("[fts] 词面通道数据源已挂(ScanAll);门控 ZHIYI_RECALL_FTS=%s(默认关)",
|
||||
os.Getenv("ZHIYI_RECALL_FTS"))
|
||||
} else {
|
||||
log.Printf("[fts] 后端非 RustLanceDBClient(%T)⇒ 词面通道数据源未挂(关态无影响)", ldb)
|
||||
}
|
||||
|
||||
// 软删除累积延迟更新缓冲(2026-09-12 优化C)
|
||||
// 软删除操作不再逐条写 LanceDB(每写一行一个版本 → _versions 膨胀),
|
||||
// 改为窗口内合并 + 单事务批量提交(每批 1 个版本)。
|
||||
@@ -72,7 +93,13 @@ func NewServer() http.Handler {
|
||||
// 冲突检测器(E4.1: Commit 时矛盾检测依赖此实例)
|
||||
// 2026-09-20:改用带持久化的构造 —— 此前 active 是纯内存 map,zhiyid 重启即丢
|
||||
// (实测 17:17 重启后 pending 冲突全部消失)。落盘失败不致命,降级为内存模式并记日志。
|
||||
conflictDetector, cdErr := governance.NewPersistentConflictDetector("/var/lib/memoryweave/conflicts.json")
|
||||
// 2026-10-18 (t_23b6a940):ZHIYI_CONFLICTS_DIR 覆盖落盘目录(副本/评估场景隔离
|
||||
// telemetry 写);未设 → 生产默认路径(行为不变)
|
||||
confDir := os.Getenv("ZHIYI_CONFLICTS_DIR")
|
||||
if confDir == "" {
|
||||
confDir = "/var/lib/memoryweave"
|
||||
}
|
||||
conflictDetector, cdErr := governance.NewPersistentConflictDetector(filepath.Join(confDir, "conflicts.json"), filepath.Join(confDir, "conflict-seen.json"))
|
||||
if cdErr != nil {
|
||||
log.Printf("[conflict] 持久化加载失败,降级为内存模式: %v", cdErr)
|
||||
}
|
||||
@@ -112,6 +139,17 @@ func NewServer() http.Handler {
|
||||
}
|
||||
}()
|
||||
|
||||
// t_cf680fad:启动即 drain durable pending-softdelete 队列。
|
||||
// 上一轮(尤其重启窗口 / 崩掉那轮)因 MVCC 写风暴耗尽而被入队的失败软删,
|
||||
// 此刻风暴已退、sidecar 刚就绪,重放必落盘 —— 这是「不修则每次重启窗口都留新残留」的根治。
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // 等 sidecar 完全就绪(比 metrics 的 1s 稍晚,避免抢首提交)
|
||||
if n := storage.PendingSoftDeleteCount(); n > 0 {
|
||||
log.Printf("[pending-softdelete] 启动发现 %d 条待重放,开始 drain", n)
|
||||
storage.DrainPendingSoftDeletes(ldb)
|
||||
}
|
||||
}()
|
||||
|
||||
// 图谱(带 Navigate 结果缓存:TTL 5min,容量 500,热点实体缓存命中加速)
|
||||
// 已在上面初始化
|
||||
graphUpdater := governance.NewAutoGraphUpdater(graphStore)
|
||||
@@ -147,6 +185,45 @@ func NewServer() http.Handler {
|
||||
consolPipe := routes.NewConsolidationPipeline(ldb, graphStore, conflictDetector)
|
||||
consolPipe.SetDataDir("/var/lib/memoryweave/lancedb", "/var/lib/memoryweave/graph.db")
|
||||
|
||||
// ─── t_d32e5f8a L2 模式检测(MemOS 血统)─────────────────────────
|
||||
// 复活 L1→L2 pattern 蒸馏(原 Python daemon main_loop 退役后停摆):
|
||||
// pattern 节点存 graph_nodes(type='pattern'),与旧 daemon 残留的 85 行
|
||||
// type='pattern_stat' 同表但不同类,不另开新表。
|
||||
// modernc sqlite 短命单发开/关(UpsertPatterns/ListPatterns 开一次用一次即关),
|
||||
// 不持长连接、不碰 state.db —— 规避当年退役根因。
|
||||
// 召回注入 env 门控 ZHIYI_PATTERN_RECALL,默认 0 ⇒ 不注册 Provider,行为零变化。
|
||||
graphPath := os.Getenv("GRAPH_PATH")
|
||||
if graphPath == "" {
|
||||
graphPath = "/var/lib/memoryweave/graph.db"
|
||||
}
|
||||
patternStore := pattern.NewGraphPatternStore(graphPath)
|
||||
patternProvider := pattern.NewPatternProvider(patternStore, pattern.NewEmbedderProvider(emb))
|
||||
patternAPI := routes.NewPatternAPI(patternStore, patternProvider, func() storage.LanceDB { return ldb })
|
||||
// L3 skill 草稿(t_fcadee04):装配生成器 + skills 表(graph.db 同库)+ 落盘根。
|
||||
// 显式 API(draft/promote)触发,不挂 cron、不改注入行为 ⇒ 默认零行为变化。
|
||||
patternAPI.SkillStore = skillgen.NewStore(graphPath)
|
||||
patternAPI.Generator = skillgen.NewLocal2BGenerator()
|
||||
patternAPI.AttachmentsDir = os.Getenv("ZHIYI_SKILL_ATTACHMENTS")
|
||||
if os.Getenv("ZHIYI_PATTERN_RECALL") == "1" {
|
||||
storage.PatternRecallProvider = patternProvider.Match
|
||||
log.Printf("[zhiyid] L2 模式召回注入已启用(ZHIYI_PATTERN_RECALL=1)")
|
||||
} else {
|
||||
log.Printf("[zhiyid] L2 模式召回注入默认关(ZHIYI_PATTERN_RECALL 未设/≠1 ⇒ 零行为变化)")
|
||||
}
|
||||
|
||||
// ─── t_f8fc97e6 P3 Peer Representation(Honcho 血统,设计 §5)────────────
|
||||
// 四层推理画像(explicit→deductive→inductive→abductive)存 graph.db 的 peers 表。
|
||||
// refresh/explain 是显式 API(不挂 cron);召回注入 env 门控 ZHIYI_PEER_RECALL 默认 0
|
||||
// ⇒ 主召回管线默认零行为变化(AC2)。推理引擎生产复用本地 2B(8081 免费腿)。
|
||||
peerStore := peer.NewStore(graphPath)
|
||||
peerAPI := routes.NewPeerAPI(peerStore, peer.NewLocal2BReasoner(), func() storage.LanceDB { return ldb }, graphStore)
|
||||
if os.Getenv("ZHIYI_PEER_RECALL") == "1" {
|
||||
storage.PeerRecallProvider = peerAPI.GetRecallResults
|
||||
log.Printf("[zhiyid] P3 Peer 推理画像召回注入已启用(ZHIYI_PEER_RECALL=1)")
|
||||
} else {
|
||||
log.Printf("[zhiyid] P3 Peer 推理画像召回注入默认关(ZHIYI_PEER_RECALL 未设/≠1 ⇒ 零行为变化)")
|
||||
}
|
||||
|
||||
// Obsidian
|
||||
obsidian := routes.NewObsidianSyncer("", ldb)
|
||||
|
||||
@@ -162,6 +239,14 @@ func NewServer() http.Handler {
|
||||
}
|
||||
distillEngine := distill.NewEngine(llmEndpoint, llmModel, llmAPIKey)
|
||||
routes.DistillEngineRef = distillEngine
|
||||
// t_e11207f8 治源①:加载共享「空壳正文」判据(zhiyi_payload_guard_rules.json,Python/Go 同源)。
|
||||
// 失败 ⇒ fail-open(distill 照跑、不拦空壳、不崩),/api/v1/payload-guard/stats 暴露 loaded=false。
|
||||
if ok, lerr := distill.LoadPayloadGuard(""); ok {
|
||||
_, _, _, rp := distill.PayloadGuardStats()
|
||||
log.Printf("[distill][payload-guard] 空壳判据已加载 rules=%s", rp)
|
||||
} else {
|
||||
log.Printf("[distill][payload-guard] ⚠️ 共享判据未加载(fail-open,不拦空壳): %v", lerr)
|
||||
}
|
||||
// 蒸馏完成 → 自动图谱更新 + 冲突检测 + 被动验证 + 回写内存
|
||||
distill.OnDistillComplete = func(input distill.DistillInput, result distill.DistillResult) {
|
||||
entityNames := make([]string, len(result.Entities))
|
||||
@@ -182,22 +267,47 @@ func NewServer() http.Handler {
|
||||
// 只要有任何一条真的 InsertMemory 成功 ⇒ 该 namespace 的 query 缓存作废
|
||||
//("先写再问" 场景:写完之后立刻可被之前问过但当时还没内容的答案命中)。
|
||||
factsLanded := false
|
||||
for _, fact := range result.Facts {
|
||||
// 2026-09-10 P0:直写路径同样脱敏(这里绕过 FilterQualityFacts,
|
||||
// 事故里泄漏进镜像的记忆正是从这条路径入库的)。
|
||||
// 2026-10-13 t_286d1310: 写库闸门同样感知准入分诊结论(lane)。
|
||||
// 此前这里独立跑 IsQualityFact 长度启发式——lane=keep 的决策类内容,
|
||||
// 其 <20 字碎片在 insert 前仍被第三次独立丢弃(t_2ae69bb2 只穿透到
|
||||
// fact 级,没穿透到写库级)⇒ DB 里 derived_from=<episode> 0 行。
|
||||
// 复用 engine.go 同一函数 FilterQualityFactsWithLane(判据单一入口,
|
||||
// 不复制):
|
||||
// LaneKeep → 只保留 纯疑问/状态汇报 两条硬否定,跳过全部长度类规则
|
||||
// LaneDrop / LaneObserve → 委托 FilterQualityFacts,与 IsQualityFact 逐字一致
|
||||
// ClassifyAdmission 是确定性分诊(同一输入永远同一 lane),此处重算即
|
||||
// engine.go:537 在蒸馏时算出的同一条 lane。
|
||||
lane, laneWhy := distill.ClassifyAdmission(input.Content)
|
||||
writeFacts := distill.FilterQualityFactsWithLane(result.Facts, lane)
|
||||
if before := len(result.Facts); before > 0 && len(writeFacts) != before {
|
||||
log.Printf("[distill][LANE] write-gate dropped %d/%d facts for %s (lane=%s, %s)",
|
||||
before-len(writeFacts), before, input.EpisodeID, lane, laneWhy)
|
||||
}
|
||||
for _, fact := range writeFacts {
|
||||
// 2026-09-10 P0:直写路径同样脱敏(FilterQualityFactsWithLane 已内部脱敏,
|
||||
// 这里再脱一次为幂等保险——防止任何中间层注入明文密钥。事故里泄漏进
|
||||
// 镜像的记忆正是从这条路径入库的)。
|
||||
fact = redact.RedactSecrets(fact)
|
||||
if !distill.IsQualityFact(fact) {
|
||||
continue // 碎片/噪声过滤
|
||||
// t_e11207f8 治源①:空壳拒写(单一判据 = 共享 zhiyi_payload_guard_rules.json,与 Python 同源)。
|
||||
// 蒸馏 LLM 偶发把「只有溯源指针/占位/纯日期、无知识载荷」的碎片当 fact(如 `证据:文件:…|日期:…`),
|
||||
// 长度/实体启发式(IsQualityFact)抓不住 ⇒ 落库前用 payload-guard 拦下(未加载时 fail-open)。
|
||||
if distill.ShellCheck(fact) {
|
||||
log.Printf("[distill][payload-guard] 拒写空壳蒸馏 fact episode=%s fact=%.40s", input.EpisodeID, fact)
|
||||
continue
|
||||
}
|
||||
// 查重: 同 namespace 已存在**语义等价**记忆 → 跳过
|
||||
// 必须用 cosine 近重复判据(≥0.98),不能只比字符串相等——
|
||||
// 否则 "牧尘在招商局工作" 与 "牧尘在招商局工作。"(多一个句号)
|
||||
// 会被判为两条不同事实双双入库,造成近重复堆积。
|
||||
var skip bool
|
||||
if qvec, eErr := emb.EncodeSingle(fact); eErr == nil {
|
||||
if sim, _ := ldb.Search("memories", qvec, 5, input.Namespace); len(sim) > 0 {
|
||||
for _, ex := range sim {
|
||||
if ex.Content == fact || cosineSimilarity(qvec, ex.Vector) >= 0.98 {
|
||||
var qvec []float32
|
||||
var sim []models.MemoryRecord
|
||||
if v, eErr := emb.EncodeSingle(fact); eErr == nil {
|
||||
qvec = v
|
||||
if found, _ := ldb.Search("memories", v, 5, input.Namespace); len(found) > 0 {
|
||||
sim = found
|
||||
for _, ex := range found {
|
||||
if ex.Content == fact || cosineSimilarity(v, ex.Vector) >= 0.98 {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
@@ -207,6 +317,38 @@ func NewServer() http.Handler {
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
// 2026-10-14 (t_3e98b65a):写时四操作判定(影子默认 ⇒ 零行为变化)。
|
||||
// 蒸馏产出的每条候选事实,对照 top-5 相似现有记忆判 ADD/UPDATE/NOOP/CONFLICT:
|
||||
// shadow:只记影子日志(本文件 write_ops.go),不真改库;apply:按判定执行。
|
||||
// 复用上面已算出的 qvec/sim(不重复编码),hash 去重兜底在 JudgeWriteOp 前。
|
||||
// t_a0831457 P0 解耦:门控**只决定标记动作**(supersede/conflict);基础
|
||||
// InsertMemory 是 pre-feature 基线行为,必须无条件执行(shadow/off 下
|
||||
// ApplyWriteOp 恒返回 shouldInsert=true ⇒ 下方不再 continue ⇒ 基础插入照跑)。
|
||||
// 唯一丢弃路径 = apply + NOOP(显式记 warning,含候选内容,禁静默)。
|
||||
if distill.WriteOpsEnabled() {
|
||||
if dupID, dup := distill.WriteOpHashDedup(fact, sim); dup {
|
||||
log.Printf("%s", distill.DedupNote(dupID, fact))
|
||||
continue
|
||||
}
|
||||
sims := distill.FromMemoryRecords(sim, qvec)
|
||||
verdict := distill.JudgeWriteOp(fact, sims.TopN(5), nil, nil)
|
||||
if verdict != nil {
|
||||
shouldInsert, err := distill.ApplyWriteOp(*verdict, &ldbWriteOpsStore{ldb: ldb}, memIDFor(fact, input))
|
||||
if err != nil {
|
||||
// apply 标记失败(MarkSuperseded/MarkConflict 落库失败):
|
||||
// 丢弃本条(不做半截标记),显式记 warning(禁静默失败)。
|
||||
log.Printf("[write-ops] apply 标记失败,本条事实丢弃: %v candidate=%.40s", err, fact)
|
||||
continue
|
||||
}
|
||||
if !shouldInsert {
|
||||
// 唯一丢弃路径:apply + NOOP(影子/off 不会到这里,见上)
|
||||
log.Printf("[write-ops] apply NOOP 丢弃事实(判定=NOOP,候选=%.40s)", fact)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// verdict==nil(off 未跑判定;理论死分支——off 不进 WriteOpsEnabled 块)
|
||||
// 或 shadow(标记零调用)⇒ 基础插入无条件执行。
|
||||
}
|
||||
mem := models.MemoryRecord{
|
||||
ID: fmt.Sprintf("mem_%d", time.Now().UnixNano()),
|
||||
AgentID: input.AgentID,
|
||||
@@ -219,6 +361,9 @@ func NewServer() http.Handler {
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
DerivedFrom: input.EpisodeID,
|
||||
// 2026-10-13 (t_e9a4d18f P1) 证据链:全量来源 episode ID(蒸馏直写当前单亲,
|
||||
// 单条证据;多 episode 聚合由 consolidate 路径补充)
|
||||
SourceEvents: []string{input.EpisodeID},
|
||||
Freshness: "fresh",
|
||||
Source: srcOrLLMDistill(input.Source), // 2026-09-26:有笔记出处→"wiki:<路径>";否则沿用 llm_distill(2026-09-07 方案C provenance)
|
||||
// 2026-10-08 (t_7e3577c5):血缘字段——写入方经 metadata 携带;缺省兜底
|
||||
@@ -228,6 +373,12 @@ func NewServer() http.Handler {
|
||||
// 2026-10-08 (Gap#2):信任等级透传到产物(nil 保持 NULL,不伪装 0.0)
|
||||
Confidence: input.Confidence,
|
||||
ConfidenceSource: input.ConfidenceSource,
|
||||
// 2026-10-14 (t_3ada332b):bi-temporal 有效期 + provenance。写时识别时间敏感事实
|
||||
// (含「这周/临时/截止/到 X 之前」等)自动填 valid_to(区间末端);来源归一 provenance
|
||||
// (user|inferred|import)。开关默认关 ZHIYI_BITEMPORAL=0 ⇒ 读路径行为零变化。
|
||||
ValidFrom: time.Now().Format(time.RFC3339),
|
||||
ValidTo: distill.DetectValidTo(fact, time.Now()),
|
||||
Provenance: distill.DetectProvenance(input.Source),
|
||||
}
|
||||
log.Printf("[srcprobe] 蒸馏落库 episode=%s input.Source=%q → mem.Source=%q", input.EpisodeID, input.Source, mem.Source)
|
||||
if err := ldb.InsertMemory(mem); err != nil {
|
||||
@@ -243,6 +394,9 @@ func NewServer() http.Handler {
|
||||
storage.SearchCacheInstance.Invalidate(input.Namespace)
|
||||
log.Printf("[cache] 蒸馏落库后失效 namespace=%s(%d 条 facts,episode=%s)",
|
||||
input.Namespace, len(result.Facts), input.EpisodeID)
|
||||
// 2026-10-14 (t_2afae95e):FTS 词面索引同步标脏(惰性重建,
|
||||
// 下次词面查询时后台刷;关态下只是置一个 bool,零成本)
|
||||
storage.FtsInvalidate()
|
||||
}
|
||||
// 冲突检测:加载已有记忆进行比较
|
||||
// 必须用**新内容的真实向量**做语义检索取近邻;用零向量在语义索引上
|
||||
@@ -314,6 +468,12 @@ func NewServer() http.Handler {
|
||||
|
||||
// 注入 V 值查询函数到存储层(召回排序融合)
|
||||
storage.VValueProvider = func(memoryID string) float64 {
|
||||
// 2026-10-13:ep_* 是蒸馏自证(恒 1.0),零区分度且淹没真实 mem_* 信号
|
||||
// (实测 ep_* 4304 个 vs mem_* 24 个)。召回目标全是 mem_*,ep_* 的 V 值
|
||||
// 不参与排序即可,无需删 Redis 数据(保留可回溯)。
|
||||
if strings.HasPrefix(memoryID, "ep_") {
|
||||
return 0
|
||||
}
|
||||
return vPropagator.GetMemVValue(memoryID)
|
||||
}
|
||||
// 在蒸馏完成回调中记录 V 值
|
||||
@@ -331,8 +491,8 @@ func NewServer() http.Handler {
|
||||
"distill", "success", "",
|
||||
)
|
||||
}
|
||||
// 更新仪表盘指标
|
||||
selfoptimize.Dash.RecordUseful()
|
||||
// 更新仪表盘指标:蒸馏成功次数(2026-10-14 t_c06ad4d0 改,勿再灌 UsefulCount)
|
||||
selfoptimize.Dash.RecordDistillSuccess()
|
||||
// 记录蒸馏质量损失(avg_distill_loss = 1 - Overall)
|
||||
// 仅记录真实蒸馏:Overall > 0 表示 LLM 成功返回,过滤 fallback (Overall=0)
|
||||
if result.Overall > 0 {
|
||||
@@ -360,6 +520,18 @@ func NewServer() http.Handler {
|
||||
|
||||
mux.HandleFunc("/health", routes.HandleHealth)
|
||||
mux.HandleFunc("/api/v1/health", routes.HandleHealth)
|
||||
// t_e11207f8 治源①:空壳拦截器「确实跑过」的取证端点。
|
||||
// checks=每个蒸馏 fact 判一次的心跳计数(>0 即拦截器活着);blocks=实际拒写空壳条数;
|
||||
// loaded=false 表示共享判据没加载上(fail-open,需排查 ZHIYI_PAYLOAD_GUARD_RULES 路径)。
|
||||
mux.HandleFunc("/api/v1/payload-guard/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
checks, blocks, loaded, rp := distill.PayloadGuardStats()
|
||||
respondJSON(w, 200, map[string]interface{}{
|
||||
"checks": checks,
|
||||
"blocks": blocks,
|
||||
"loaded": loaded,
|
||||
"rules_path": rp,
|
||||
})
|
||||
})
|
||||
// 图谱缓存统计 + 搜索缓存统计(t_1c12e000:并入 search_cache 段,
|
||||
// 暴露 hits/misses/invalidated/size/ttl,写频对命中率的影响可观测)
|
||||
mux.HandleFunc("/api/v1/cache/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -372,8 +544,32 @@ func NewServer() http.Handler {
|
||||
if storage.SearchCacheInstance != nil {
|
||||
resp["search_cache"] = storage.SearchCacheInstance.Stats()
|
||||
}
|
||||
// 2026-10-14 (t_2afae95e):FTS 词面索引状态(docs/tokens/built_at/dirty/门控)
|
||||
resp["fts_index"] = storage.FtsIndexStats()
|
||||
respondJSON(w, 200, resp)
|
||||
})
|
||||
// 2026-10-14 (t_2afae95e):FTS 手工刷索引(admin;异步重建不阻塞)
|
||||
mux.HandleFunc("/api/v1/fts/rebuild", func(w http.ResponseWriter, r *http.Request) {
|
||||
storage.FtsRebuildNow()
|
||||
respondJSON(w, 200, map[string]interface{}{"status": "rebuild_started"})
|
||||
})
|
||||
// 2026-10-14 (t_2afae95e):FTS 词面查询取证端点(AC1 判据命令):
|
||||
// 返回该 query 的词面 topN 命中(id + 分 + 内容摘录),不走路过 recall 缓存。
|
||||
mux.HandleFunc("/api/v1/fts/query", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
TopN int `json:"top_n"`
|
||||
CheckDocID string `json:"check_doc_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.TopN <= 0 {
|
||||
req.TopN = 10
|
||||
}
|
||||
respondJSON(w, 200, storage.FtsQuery(req.Query, req.TopN, req.CheckDocID))
|
||||
})
|
||||
// ─── Prometheus /metrics ─────────────────────
|
||||
mux.Handle("/metrics", metrics.Handler())
|
||||
|
||||
@@ -397,15 +593,37 @@ func NewServer() http.Handler {
|
||||
governance.PushCacheInvalidate(req.Namespace, "commit")
|
||||
governance.PushMemoryCommitted(req.AgentID, req.Namespace, "")
|
||||
}
|
||||
// t_f8fc97e6 P3 Peer:新记忆进入 → 异步刷新内容里出现的实体画像。
|
||||
// 默认关(ZHIYI_PEER_RECALL 未设/≠1)⇒ 本分支整体跳过,commit 路径零变化(AC4)。
|
||||
// 开启时在已有 goroutine 内再起子 goroutine,LLM 推理绝不阻塞 HTTP commit 返回。
|
||||
if os.Getenv("ZHIYI_PEER_RECALL") == "1" && peerAPI != nil {
|
||||
peerAPI.RefreshFromContent(req.Content, req.Namespace)
|
||||
}
|
||||
}()
|
||||
})
|
||||
mux.HandleFunc("/api/v1/recall", api.Recall)
|
||||
mux.HandleFunc("/api/v1/recall/debug", api.RecallDebug)
|
||||
// 2026-10-26 (P0-1 t_trace_p0):召回链路追踪回溯(trace_id 贯穿各阶段/各通道)
|
||||
mux.HandleFunc("/api/v1/trace/", api.Trace)
|
||||
// t_23b6a940 AC7 热门度看门狗快照(report-only,传感器端读它转既有告警通道)
|
||||
mux.HandleFunc("/api/v1/popwatch", api.PopWatch)
|
||||
// 2026-10-13 (t_e9a4d18f P2) 常设答案直读/写入
|
||||
mux.HandleFunc("/api/v1/standing-answers", routes.HandleStandingAnswers)
|
||||
// ⑤使用追踪(t_5aa4dcac):上报 + 手动触发。env 门控 ZHIYI_USAGE_TRACK,默认关。
|
||||
mux.HandleFunc("/api/v1/usage/report", api.UsageReport)
|
||||
mux.HandleFunc("/api/v1/usage/detect", api.UsageDetect)
|
||||
// GA-P0-7 (t_dd64345a):裸 /api/v1/feedback 已退役——其 handler 曾写旧字段 not_useful_count。
|
||||
// 反馈统一走 /api/v1/feedback/{useful,not-useful,deprecate,correct}(feedback.go,写拆分后新字段)。
|
||||
mux.HandleFunc("/api/v1/bootstrap", api.Bootstrap)
|
||||
mux.HandleFunc("/api/v1/stats", api.Stats)
|
||||
// 2026-10-26(牧尘批「三档」):自检统一取数端点。
|
||||
// 唯一契约 —— 自检脚本一律读这里,禁止再 lancedb.connect() 直连生产库。
|
||||
// ?level=cheap(默认,内存态计数)| full(+读 tools/selfcheck_full.py 的全量快照:碎片/冗余/近7天质量/写量)
|
||||
mux.HandleFunc("/api/v1/selfcheck", api.Selfcheck)
|
||||
mux.HandleFunc("/api/v1/batch-commit", api.BatchCommit)
|
||||
// 主症A(t_fa6c9104):显式写入直落 memories 表(嵌入 + 立刻可召回)。
|
||||
// 纯新增路由:commit/recall/蒸馏路径零改动;旧服务端 404 时插件自动回落 commit。
|
||||
mux.HandleFunc("/api/v1/memory/write", api.WriteMemory)
|
||||
// 自优化指标(Dashboard,含 avg_distill_loss)
|
||||
// total_episodes:Rust count_rows 真实值;旧 sidecar 回退 episodes_cached(进程内缓存,非总量)
|
||||
mux.HandleFunc("/api/v1/metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -972,27 +1190,12 @@ func NewServer() http.Handler {
|
||||
mux.HandleFunc("/api/v1/conflicts/resolve", conflictAPI.Resolve)
|
||||
|
||||
// 反馈 — G5: 修正记忆时填充 version_history
|
||||
// 2026-10-14 (t_c06ad4d0):删除冗余重复块。MarkUseful 已做 RecordFeedback(true)+
|
||||
// RecordDecision+QualityMonitor.Check+IncrementUseful+AppendHitEvent;原 handler 里
|
||||
// 这段二次读 r.Body 的 RecordUseful/RecordDecision/QualityMonitor 全部重复,且
|
||||
// r.Body 已被 MarkUseful 消费(io.ReadAll 恒空)⇒ 死代码 + 潜在二次计数污染。
|
||||
mux.HandleFunc("/api/v1/feedback/useful", func(w http.ResponseWriter, r *http.Request) {
|
||||
feedbackAPI.MarkUseful(w, r)
|
||||
// 质量监控:useful → 质量上升
|
||||
var req struct {
|
||||
MemoryID string `json:"memory_id"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, &req)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if req.MemoryID != "" {
|
||||
selfoptimize.Dash.RecordUseful()
|
||||
// V 值:useful = success
|
||||
selfoptimize.VProp.RecordDecision(
|
||||
"useful_"+req.MemoryID,
|
||||
[]string{req.MemoryID},
|
||||
"user_feedback", "success", "",
|
||||
)
|
||||
selfoptimize.QualityMonitor.Check(req.MemoryID, 0, 5) // 清除潜在告警
|
||||
}
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/v1/feedback/not-useful", func(w http.ResponseWriter, r *http.Request) {
|
||||
feedbackAPI.MarkNotUseful(w, r)
|
||||
@@ -1042,6 +1245,16 @@ func NewServer() http.Handler {
|
||||
// 缺口
|
||||
mux.HandleFunc("/api/v1/gaps", gapAPI.List)
|
||||
mux.HandleFunc("/api/v1/gaps/detect", gapAPI.Detect)
|
||||
// t_d32e5f8a L2 模式检测(MemOS 血统)
|
||||
mux.HandleFunc("/api/v1/patterns", patternAPI.List)
|
||||
mux.HandleFunc("/api/v1/patterns/detect", patternAPI.Detect)
|
||||
// t_fcadee04 L3 skill 草稿(/patterns/{id}/draft 与 /patterns/{id}/promote)。
|
||||
// 前缀路由;/patterns 与 /patterns/detect 因更长的精确路由优先,不受影响。
|
||||
mux.HandleFunc("/api/v1/patterns/", patternAPI.Sub)
|
||||
// t_f8fc97e6 P3 Peer Representation:显式 refresh/explain + 门控 recall。
|
||||
// 前缀路由 /api/v1/peer/;/api/v1/recall/peer 独立精确路由优先。
|
||||
mux.HandleFunc("/api/v1/recall/peer", peerAPI.RecallPeer)
|
||||
mux.HandleFunc("/api/v1/peer/", peerAPI.Sub)
|
||||
mux.HandleFunc("/api/v1/gaps/close/", func(w http.ResponseWriter, r *http.Request) {
|
||||
gapAPI.Close(w, r)
|
||||
topic := r.URL.Path[len("/api/v1/gaps/close/"):]
|
||||
@@ -1632,6 +1845,11 @@ func NewServer() http.Handler {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
// t_cf680fad:每个 tick 先 drain durable pending-softdelete 队列(风暴退去后的
|
||||
// 相对静默窗口重放,必落盘)。队列空 = 廉价 no-op,不打日志;仅在有待处理时出声。
|
||||
if n := storage.PendingSoftDeleteCount(); n > 0 {
|
||||
storage.DrainPendingSoftDeletes(ldb)
|
||||
}
|
||||
for _, t := range []struct{ id, action string }{
|
||||
{"t_distill", "distill"},
|
||||
{"t_merge", "merge"},
|
||||
@@ -1672,7 +1890,13 @@ func NewServer() http.Handler {
|
||||
memories, e := ldb.GetCandidatesForForgetting()
|
||||
decayScanned := 0
|
||||
decayForgotten := 0
|
||||
decayProtected := 0
|
||||
if e == nil {
|
||||
// 2026-10-16 (t_944344b5) 遗忘保护守卫:本 tick 加载一次保护状态
|
||||
// (Redis zhiyi:vprop_values + zhiyi:protected_ids),命中任一保护规则的
|
||||
// 候选永不自动软删(fragment 与 ShouldForget 两条道都拦截)。
|
||||
// Redis 宕机 → 降级为纯行内规则(category/freshness/关键词),不阻断衰减。
|
||||
protState := memprotect.LoadState()
|
||||
const maxDecayBatch = 2000
|
||||
if len(memories) > maxDecayBatch {
|
||||
sort.SliceStable(memories, func(a, b int) bool {
|
||||
@@ -1696,6 +1920,16 @@ func NewServer() http.Handler {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 🔒 2026-10-16 (t_944344b5) 遗忘保护守卫: 命中任一保护规则的候选
|
||||
// (category∈{decision,preference,pref} / V≥2.0 / freshness=verified /
|
||||
// protected_ids 名单 / distilled 且内容命中 10 个决策·偏好关键词) 永不自动软删。
|
||||
// 拦截 point 放在 fragment + ShouldForget 两条 SoftDelete 道之前。
|
||||
if isProt, protRule := memprotect.IsProtected(m, protState); isProt {
|
||||
gid, _ := m["id"].(string)
|
||||
decayProtected++
|
||||
log.Printf("[decay] protected skip id=%q rule=%s (遗忘保护守卫命中, 跳过自动遗忘)", gid, protRule)
|
||||
continue
|
||||
}
|
||||
// 解析 last_recalled_at
|
||||
lastAccessed := time.Now().Add(-30 * 24 * time.Hour) // 默认30天前
|
||||
if t, ok := m["last_recalled_at"].(string); ok && t != "" {
|
||||
@@ -1756,7 +1990,8 @@ func NewServer() http.Handler {
|
||||
}
|
||||
// AC-1 验收日志(P1 修复 type 断言后 last_recalled_at 可解析;batch<=2000)
|
||||
if decayScanned > 0 {
|
||||
log.Printf("[decay] scanned=%d forgotten=%d (batch<=2000, 最久未访问优先)", decayScanned, decayForgotten)
|
||||
// 2026-10-16 (t_944344b5):protected_skip = 被遗忘保护守卫拦截、未软删的候选数
|
||||
log.Printf("[decay] scanned=%d forgotten=%d protected_skip=%d (batch<=2000, 最久未访问优先)", decayScanned, decayForgotten, decayProtected)
|
||||
}
|
||||
case "gap_scan":
|
||||
// 检查已有缺口:过期 7 天的自动关闭
|
||||
@@ -1770,7 +2005,12 @@ func NewServer() http.Handler {
|
||||
for _, g := range gaps {
|
||||
if !g.Closed && routes.GapRepair != nil {
|
||||
if routes.GapRepair.AutoRepair(g) {
|
||||
selfoptimize.Dash.RecordGapClosed()
|
||||
// t_e5997ca4:AutoRepair 只做「修复」不关缺口。旧代码在此
|
||||
// 无条件 Dash.RecordGapClosed() 却不置 g.Closed ⇒ 同一缺口每轮
|
||||
// gap_scan 都再 +1(ClosedGaps 无限虚增,线上 9 vs 2 的写侧根因 #2)。
|
||||
// 现按 RepairHandler 语义:修复成功 = 关闭该缺口;+1 只发生在
|
||||
// Close 的 open→closed 迁移点(内部),下一轮 !g.Closed 即跳过。
|
||||
gapDetector.Close(g.Topic)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1997,3 +2237,33 @@ func confidenceSourceOf(c *float64) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ldbWriteOpsStore 适配 storage.LanceDB 到 distill.WriteOpsStore 接口。
|
||||
// 供写时四操作的 apply 模式标记 superseded/conflict(t_3e98b65a)。
|
||||
type ldbWriteOpsStore struct {
|
||||
ldb storage.LanceDB
|
||||
}
|
||||
|
||||
// MarkSuperseded 旧记忆标记「被 newID 取代」(写 superseded_by + is_deleted=true;
|
||||
// 历史经 supersedes/superseded_by 保留可追溯,不物理删)。
|
||||
func (s *ldbWriteOpsStore) MarkSuperseded(oldID, newID string) error {
|
||||
b, _ := json.Marshal([]string{newID})
|
||||
return s.ldb.Update("memories", oldID, map[string]any{
|
||||
"superseded_by": string(b),
|
||||
"is_deleted": true,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkConflict 记忆标记冲突(交人工;不删)。冲突以 superseded_by 空 + is_deleted 保持 false
|
||||
// + freshness=conflict 标识,让 recall 仍可见但排序权重降(调用方感知)。
|
||||
func (s *ldbWriteOpsStore) MarkConflict(id string) error {
|
||||
return s.ldb.Update("memories", id, map[string]any{
|
||||
"freshness": "conflict",
|
||||
})
|
||||
}
|
||||
|
||||
// memIDFor 为写时四操作 apply 预生成新记忆 ID(与下方 InsertMemory 同格式)。
|
||||
func memIDFor(fact string, input distill.DistillInput) string {
|
||||
_ = fact
|
||||
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// 织忆 MemoryWeave — 对抗审计插槽(②蒸馏环)
|
||||
//
|
||||
// 背景(2026-10-13,血统 HW_MyJarvis):织忆当前所有质量判断都是单模型自证
|
||||
// (本地 2B 自己打分自己提取),实测已抓到四起同类病:蒸馏评分恒 0.755、
|
||||
// ep_* 的 V 值恒 1.0(4304 条自证污染)、metrics 三完美值、avg_distill_loss
|
||||
// 无人复核。共同病根:一个模型自己说自己好,没有异源复核。
|
||||
//
|
||||
// 本插槽解法:蒸馏产出的事实,按抽样比例送【异源模型】(非本地 2B)做对抗审计,
|
||||
// 找矛盾 / 幻觉 / 无据。审计结论只写【影子日志】,不改任何丢弃/准入行为
|
||||
// (与 ZHIYI_SCORE_SHADOW 同一纪律:先观察,不真拦)。
|
||||
//
|
||||
// ── 门控(默认关 ⇒ 零行为变化,硬要求)──────────────────────────
|
||||
//
|
||||
// ZHIYI_ADVERSARIAL_AUDIT=1 开启(默认关)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_RATIO 抽样比例(默认 0.05 = 5%)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT 审计端点(默认 http://127.0.0.1:3000/v1/chat/completions,
|
||||
// NewAPI 免费通道,异源——非本地 2B 的 8081)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_MODEL 审计模型(默认 deepseek-v4-flash,免费腿)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_KEY 审计 key(默认回退 LLM_API_KEY)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_LOG 影子日志路径(默认 /var/lib/memoryweave/adversarial-audit.log)
|
||||
// ZHIYI_ADVERSARIAL_AUDIT_TIMEOUT 单次审计超时秒(默认 60)
|
||||
//
|
||||
// 关闭路径:unset ZHIYI_ADVERSARIAL_AUDIT(或置 0)→ 完全恢复,不产生任何调用、
|
||||
// 不写任何日志、不占任何资源。
|
||||
//
|
||||
// ── 设计约束 ────────────────────────────────────────────────
|
||||
// · 异源:审计模型走 NewAPI 免费通道(127.0.0.1:3000),默认 deepseek-v4-flash,
|
||||
// 与蒸馏主链的本地 2B(local/minicpm5-2b via 8081)不同源不同模型。
|
||||
// · 零新依赖:只用 stdlib net/http / encoding/json / os。
|
||||
// · 不碰 state.db:审计只写独立影子日志文件,不改存储、不新增常驻进程
|
||||
// (审计 goroutine 在 zhiyid 进程内,随调用起落,非守护)。
|
||||
// · 控并发:包级信号量 advAuditSem(默认 1),异步执行,绝不阻塞/打爆蒸馏主链。
|
||||
package distill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// advAuditSem 控制对抗审计并发(默认 1:串行,最保守,不抢本地 2B 也不突刺 NewAPI)。
|
||||
var advAuditSem = make(chan struct{}, 1)
|
||||
|
||||
// advAuditMu 保护影子日志文件的串行追加。
|
||||
var advAuditMu sync.Mutex
|
||||
|
||||
// envStr 读字符串环境变量,缺省/空则用 def。
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// adversarialAuditEnabled 是否开启对抗审计(运行时读 env,unset/0/其它 → 关)。
|
||||
func adversarialAuditEnabled() bool {
|
||||
v := os.Getenv("ZHIYI_ADVERSARIAL_AUDIT")
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
// MaybeAdversarialAudit 对抗审计入口(②环插槽)。影子模式:只写日志,不改行为。
|
||||
// 门控关 → 立即返回(零行为变化);开 → 按抽样比例决定是否异步审计。
|
||||
// 抽样为随机比例:命中才发一次异源审计;未命中/无事实/比例≤0 → 直接返回。
|
||||
func MaybeAdversarialAudit(input DistillInput, facts []string) {
|
||||
if !adversarialAuditEnabled() {
|
||||
return
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
return
|
||||
}
|
||||
ratio := envFloat("ZHIYI_ADVERSARIAL_AUDIT_RATIO", 0.05)
|
||||
if ratio <= 0 {
|
||||
return
|
||||
}
|
||||
if rand.Float64() >= ratio {
|
||||
return
|
||||
}
|
||||
// 异步执行 + 信号量控并发:取不到槽位则放弃本次(影子审计,不排队不阻塞)。
|
||||
select {
|
||||
case advAuditSem <- struct{}{}:
|
||||
default:
|
||||
log.Printf("[adv-audit] 并发槽已满,放弃本次抽样(episode=%s)", input.EpisodeID)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer func() { <-advAuditSem }()
|
||||
if err := auditOne(input, facts); err != nil {
|
||||
log.Printf("[adv-audit] 审计失败 episode=%s: %v", input.EpisodeID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// AuditVerdict 单条事实的对抗审计结论。
|
||||
type AuditVerdict struct {
|
||||
Fact string `json:"fact"`
|
||||
Verdict string `json:"verdict"` // supported | contradiction | hallucination | no_evidence
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// auditOne 执行一次对抗审计:送异源模型 → 解析结论 → 写影子日志。
|
||||
// 返回 error 供日志记录(审计失败绝不 panic,绝不触碰主链)。
|
||||
func auditOne(input DistillInput, facts []string) error {
|
||||
endpoint := envStr("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT",
|
||||
"http://127.0.0.1:3000/v1/chat/completions")
|
||||
model := envStr("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
|
||||
key := envStr("ZHIYI_ADVERSARIAL_AUDIT_KEY", "")
|
||||
if key == "" {
|
||||
key = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
timeout := time.Duration(envFloat("ZHIYI_ADVERSARIAL_AUDIT_TIMEOUT", 60)) * time.Second
|
||||
|
||||
raw, err := callAuditModel(endpoint, model, key, buildAuditPrompt(input.Content, facts), timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verdicts := parseAuditVerdicts(raw)
|
||||
flag := 0
|
||||
for _, v := range verdicts {
|
||||
if v.Verdict == "contradiction" || v.Verdict == "hallucination" || v.Verdict == "no_evidence" {
|
||||
flag++
|
||||
}
|
||||
}
|
||||
log.Printf("[adv-audit] episode=%s facts=%d verdicts=%d flagged=%d model=%s",
|
||||
input.EpisodeID, len(facts), len(verdicts), flag, model)
|
||||
for _, v := range verdicts {
|
||||
appendAuditLog(input, v)
|
||||
}
|
||||
if len(verdicts) == 0 {
|
||||
log.Printf("[adv-audit] 审计模型未返回可解析结论 episode=%s raw=%q", input.EpisodeID, truncate(raw, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAuditPrompt 构造对抗审计 prompt:给原文 + 提取的事实,要求逐条判定。
|
||||
// 输出格式要求严格 JSON 数组(异源模型普遍稳定支持)。
|
||||
func buildAuditPrompt(content string, facts []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("你是一个对抗性记忆审计员。下面是一段原始内容,以及系统从中\"蒸馏提取\"出的事实条目。\n")
|
||||
b.WriteString("你的任务:逐条判断每个事实是否被原文支持。\n\n")
|
||||
b.WriteString("原文:\n")
|
||||
b.WriteString(truncate(content, 1500))
|
||||
b.WriteString("\n\n提取的事实:\n")
|
||||
for i, f := range facts {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, f)
|
||||
}
|
||||
b.WriteString("\n对每条事实输出一个JSON对象:{\"fact\":\"<事实原文>\",\"verdict\":\"supported|contradiction|hallucination|no_evidence\",\"reason\":\"<一句话理由>\"}。\n")
|
||||
b.WriteString("判据:supported=原文明确支持;contradiction=与原文矛盾;hallucination=原文根本没提到(模型编造);no_evidence=原文不足以支撑该推断。\n")
|
||||
b.WriteString("只输出一个JSON数组,元素顺序与事实条目顺序一一对应,不要输出任何其他文字。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// callAuditModel 调异源审计模型,返回 message.content 原文。
|
||||
func callAuditModel(endpoint, model, key, prompt string, timeout time.Duration) (string, error) {
|
||||
body := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("audit endpoint HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("audit: no choices in response")
|
||||
}
|
||||
return out.Choices[0].Message.Content, nil
|
||||
}
|
||||
|
||||
// parseAuditVerdicts 解析审计模型返回的 JSON 数组。健壮剥离 markdown fence 与
|
||||
// 前后杂质(找第一个 [ 到匹配的 ]),解析失败返回空切片(不 panic)。
|
||||
func parseAuditVerdicts(raw string) []AuditVerdict {
|
||||
s := strings.TrimSpace(raw)
|
||||
// 剥 markdown code fence
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
// 找第一个 [ 和最后一个 ]
|
||||
si := strings.Index(s, "[")
|
||||
ei := strings.LastIndex(s, "]")
|
||||
if si < 0 || ei <= si {
|
||||
return nil
|
||||
}
|
||||
var verdicts []AuditVerdict
|
||||
if err := json.Unmarshal([]byte(s[si:ei+1]), &verdicts); err != nil {
|
||||
return nil
|
||||
}
|
||||
return verdicts
|
||||
}
|
||||
|
||||
// appendAuditLog 追加一条审计结论到影子日志(JSONL,串行追加)。
|
||||
// 路径运行时读 env;默认 /var/lib/memoryweave/adversarial-audit.log。
|
||||
func appendAuditLog(input DistillInput, v AuditVerdict) {
|
||||
path := envStr("ZHIYI_ADVERSARIAL_AUDIT_LOG", "/var/lib/memoryweave/adversarial-audit.log")
|
||||
line := map[string]interface{}{
|
||||
"ts": time.Now().Format(time.RFC3339),
|
||||
"episode": input.EpisodeID,
|
||||
"source": input.Source,
|
||||
"namespace": input.Namespace,
|
||||
"fact": v.Fact,
|
||||
"verdict": v.Verdict,
|
||||
"reason": v.Reason,
|
||||
}
|
||||
b, _ := json.Marshal(line)
|
||||
advAuditMu.Lock()
|
||||
defer advAuditMu.Unlock()
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("[adv-audit] 影子日志打开失败 %s: %v", path, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.Write(append(b, '\n'))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─── 测试策略 ───────────────────────────────────────────────
|
||||
// 对抗审计插槽是影子(只写日志不改行为),且 env 门控默认关。测试分四类:
|
||||
// ① AC1 门控默认关 → 零行为变化:开启校验函数不写日志、不发起调用。
|
||||
// ② AC5 关闭路径:unset env → 完全恢复。
|
||||
// ③ AC2 打开门控 → 审计日志有记录(用 httptest 假端点,确定性地验真实日志行)。
|
||||
// ④ 解析健壮性:模型返回带 markdown fence / 杂质时也能解析。
|
||||
//
|
||||
// 注意:审计异步(goroutine),测试里用同步版 auditOne 验真实日志写入。
|
||||
|
||||
// 返回一个假的审计 HTTP 端点,断言收到请求并返回固定 verdict 数组。
|
||||
func fakeAuditServer(t *testing.T, verdictJSON string) (*httptest.Server, *int) {
|
||||
t.Helper()
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 正确嵌套:把 verdict 数组作为 content 的字符串值序列化,避免换行/引号破坏外层 JSON
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{{"message": map[string]interface{}{"content": verdictJSON}}},
|
||||
})
|
||||
w.Write(out)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
|
||||
func TestAdvAuditGateOffNoSideEffect(t *testing.T) {
|
||||
// AC1/AC5:门控默认关(不设 env),开启函数必须零副作用。
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "")
|
||||
tmp := filepath.Join(t.TempDir(), "audit.log")
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_RATIO", "1.0") // 即便抽样=100%也必须不动作
|
||||
|
||||
in := DistillInput{EpisodeID: "ep_gate_off", Content: "牧尘决定把蒸馏模型换掉"}
|
||||
MaybeAdversarialAudit(in, []string{"一条事实"})
|
||||
|
||||
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
|
||||
t.Fatalf("门控关时不应创建影子日志文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvAuditGateOffWhenDisabledExplicit(t *testing.T) {
|
||||
// 显式置 0 → 关,零行为变化。
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "0")
|
||||
tmp := filepath.Join(t.TempDir(), "audit.log")
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
|
||||
|
||||
MaybeAdversarialAudit(DistillInput{EpisodeID: "ep0"}, []string{"f"})
|
||||
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
|
||||
t.Fatalf("ZHIYI_ADVERSARIAL_AUDIT=0 时应关,不应写日志: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvAuditGateOnWritesLog(t *testing.T) {
|
||||
// AC2:打开门控 + 同步 auditOne → 影子日志有真实记录(≥3 条 verdict)。
|
||||
verdicts := `[{"fact":"牧尘决定把蒸馏模型换掉","verdict":"supported","reason":"原文明确支持"},
|
||||
{"fact":"牧尘喜欢苹果","verdict":"hallucination","reason":"原文未提及"},
|
||||
{"fact":"牧尘讨厌苹果","verdict":"contradiction","reason":"与原文相反"}]`
|
||||
srv, _ := fakeAuditServer(t, verdicts)
|
||||
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "1")
|
||||
tmp := filepath.Join(t.TempDir(), "audit.log")
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT", srv.URL)
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
|
||||
|
||||
err := auditOne(DistillInput{EpisodeID: "ep1", Content: "牧尘决定把蒸馏模型换掉", Source: "wiki:test"}, []string{"牧尘决定把蒸馏模型换掉"})
|
||||
if err != nil {
|
||||
t.Fatalf("auditOne err: %v", err)
|
||||
}
|
||||
|
||||
b, rerr := os.ReadFile(tmp)
|
||||
if rerr != nil {
|
||||
t.Fatalf("读影子日志失败: %v", rerr)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
if len(lines) < 3 {
|
||||
t.Fatalf("影子日志应有 ≥3 条,实际 %d 条:\n%s", len(lines), string(b))
|
||||
}
|
||||
// 校验首行字段完整(episode / fact / verdict)
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &row); err != nil {
|
||||
t.Fatalf("日志首行非 JSON: %v", err)
|
||||
}
|
||||
if row["episode"] != "ep1" || row["fact"] != "牧尘决定把蒸馏模型换掉" || row["verdict"] != "supported" {
|
||||
t.Fatalf("日志行字段不完整: %v", row)
|
||||
}
|
||||
// 校验确有矛盾/幻觉判定
|
||||
if !strings.Contains(string(b), "contradiction") || !strings.Contains(string(b), "hallucination") {
|
||||
t.Fatalf("日志应含 contradiction 与 hallucination 判定:\n%s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvAuditParseMarkdownFence(t *testing.T) {
|
||||
// 解析健壮性:模型输出带 ```json fence + 前后杂质。
|
||||
raw := "好的,这是结果:\n```json\n[{\"fact\":\"A\",\"verdict\":\"supported\",\"reason\":\"r\"},{\"fact\":\"B\",\"verdict\":\"hallucination\",\"reason\":\"r2\"}]\n```"
|
||||
v := parseAuditVerdicts(raw)
|
||||
if len(v) != 2 {
|
||||
t.Fatalf("应解析出 2 条 verdict,实际 %d: %+v", len(v), v)
|
||||
}
|
||||
if v[0].Verdict != "supported" || v[1].Verdict != "hallucination" {
|
||||
t.Fatalf("verdict 解析错误: %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvAuditParseGarbage(t *testing.T) {
|
||||
// 完全无法解析 → 空切片,不 panic。
|
||||
if v := parseAuditVerdicts("抱歉,我无法完成该任务。"); v != nil && len(v) != 0 {
|
||||
t.Fatalf("垃圾输入应返回空,实际 %+v", v)
|
||||
}
|
||||
if v := parseAuditVerdicts(""); len(v) != 0 {
|
||||
t.Fatalf("空输入应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvAuditModelIsDifferentSource(t *testing.T) {
|
||||
// AC3:审计模型默认 = deepseek-v4-flash(NewAPI 免费腿),
|
||||
// 与蒸馏主链的本地 2B(local/minicpm5-2b)不同源不同模型。
|
||||
// 本测试校验默认端点/模型指向 NewAPI 而非本地 8081。
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT", "1")
|
||||
ep := envStr("ZHIYI_ADVERSARIAL_AUDIT_ENDPOINT", "http://127.0.0.1:3000/v1/chat/completions")
|
||||
md := envStr("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
|
||||
if strings.Contains(ep, "8081") {
|
||||
t.Fatalf("审计端点不得指向本地 2B(8081),实际 %s", ep)
|
||||
}
|
||||
if strings.Contains(md, "minicpm5") {
|
||||
t.Fatalf("审计模型不得为本地 2B,实际 %s", md)
|
||||
}
|
||||
t.Logf("默认审计端点=%s 模型=%s(异源:非本地 2B)", ep, md)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// 织忆 MemoryWeave — bi-temporal 有效期检测 + provenance(t_3ada332b,血统 Zep/Graphiti)
|
||||
//
|
||||
// 设计(方案C「长期正确性」):时间敏感事实用【有效区间】(valid_from/valid_to) 而非删除。
|
||||
// 双时间线:episode T'(CreatedAt,何时说的)+ 语义 T(valid_to 表达的失效终点)。
|
||||
// non-lossy:失效只标区间,历史永久保留(可追溯、当前状态干净)。
|
||||
// provenance:来源类型枚举 user|inferred|import —— 新语句不自动覆盖旧的(来源权威性)。
|
||||
//
|
||||
// ⚠️ 误报刹车(AC 止损线):时间敏感识别误报(把常驻事实标成临时的)>5% → 收紧识别规则
|
||||
// 只认【强时间词】+【带触发上下文的显式日期】,宽口径一律不进(宁漏不误伤)。
|
||||
// 返回空串 = 永久有效(NULL 语义,读路径不降权)。
|
||||
package distill
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 显式日期(绝对日期)触发词:只有紧邻这些上下文才解析,防把「生日是3月15日」这类常驻事实标成临时。
|
||||
var dateTriggerRe = regexp.MustCompile(
|
||||
`截止到|截止|截至|到|持续到|有效到|用到|在|于|(之前|前|为止|为止\b)`)
|
||||
|
||||
// dateTokenRe 匹配一个日期 token(含触发上下文内/前的日期片段)。
|
||||
// 支持:2006-01-02 / 2006/01/02 / 2006年01月02日 / 01月02日 / 1月2号 / 1月2日
|
||||
var dateTokenRe = regexp.MustCompile(
|
||||
`(?:(\d{4})[年/\-])?(\d{1,2})月(\d{1,2})[日号]|(\d{4})[-/](\d{1,2})[-/](\d{1,2})`)
|
||||
|
||||
// DetectValidTo 识别时间敏感事实的失效终点(RFC3339;空串 = 永久有效)。
|
||||
// now 为「本事实写入时刻」(episode T' 的审计时间),据此计算语义 T 的区间末端。
|
||||
func DetectValidTo(content string, now time.Time) string {
|
||||
// 0) 强时间词先于显式日期("截止到这周" 这类,「这周」优先给周末)
|
||||
// 只认【绝对强词】,避免「下」「这」单独出现的误判。
|
||||
if s := matchStrongTimeWord(content, now); s != "" {
|
||||
return s
|
||||
}
|
||||
|
||||
// 1) 带触发上下文的显式日期(要求有 截止/到/之前/为止 等前后缀,否则太宽进)
|
||||
if !dateTriggerRe.MatchString(content) {
|
||||
return ""
|
||||
}
|
||||
if d := parseExplicitDate(content, now); d != "" {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetectProvenance 从写入来源(source)字符串归一到 provenance 三枚举。
|
||||
// 映射口径:显式 user/口头/直接用户 段 → user;import/导入/wiki(笔记通道) → import;
|
||||
// agent/推断/inferred/llm/蒸馏 → inferred;其余兜底 → inferred。
|
||||
func DetectProvenance(source string) string {
|
||||
s := strings.ToLower(source)
|
||||
switch {
|
||||
case containsAny(s, []string{"user", "口头", "直接用户", "user_said", "直接说"}):
|
||||
return "user"
|
||||
case containsAny(s, []string{"import", "导入", "wiki:", "wiki/", "笔记通道", "从外部"}):
|
||||
return "import"
|
||||
case containsAny(s, []string{"agent", "inferred", "推断", "infer", "llm", "distill", "蒸馏", "autogen"}):
|
||||
return "inferred"
|
||||
default:
|
||||
return "inferred"
|
||||
}
|
||||
}
|
||||
|
||||
// matchStrongTimeWord 只认强时间词,命中即返回对应区间末端。
|
||||
func matchStrongTimeWord(content string, now time.Time) string {
|
||||
// 顺序:长词在前避免「这个月」被「这」提前命中(我们只精确匹配完整词)。
|
||||
type kw struct {
|
||||
word string
|
||||
end func(time.Time) time.Time
|
||||
}
|
||||
table := []kw{
|
||||
{"这个月", endOfThisMonth},
|
||||
{"本月", endOfThisMonth},
|
||||
{"下周", endOfNextWeek},
|
||||
{"这周", endOfThisWeek},
|
||||
{"本周", endOfThisWeek},
|
||||
{"今晚", endOfToday},
|
||||
{"今天", endOfToday},
|
||||
{"今日", endOfToday},
|
||||
{"明天", endOfTomorrow},
|
||||
{"临时", addDays7},
|
||||
{"暂定", addDays7},
|
||||
{"暂时", addDays7},
|
||||
}
|
||||
for _, k := range table {
|
||||
if strings.Contains(content, k.word) {
|
||||
return k.end(now).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func endOfToday(now time.Time) time.Time {
|
||||
return dayEnd(now)
|
||||
}
|
||||
func endOfTomorrow(now time.Time) time.Time {
|
||||
return dayEnd(now.AddDate(0, 0, 1))
|
||||
}
|
||||
func endOfThisWeek(now time.Time) time.Time {
|
||||
// 周日起 0,距下周日天数 = (7 - weekday) % 7(今天周日 → 0 → 今天)
|
||||
days := (7 - int(now.Weekday())) % 7
|
||||
return dayEnd(now.AddDate(0, 0, days))
|
||||
}
|
||||
func endOfNextWeek(now time.Time) time.Time {
|
||||
days := (7 - int(now.Weekday())) % 7
|
||||
return dayEnd(now.AddDate(0, 0, days+7))
|
||||
}
|
||||
func endOfThisMonth(now time.Time) time.Time {
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
return dayEnd(first.AddDate(0, 1, -1)) // 下月1号 -1天 = 月末
|
||||
}
|
||||
func addDays7(now time.Time) time.Time {
|
||||
return now.Add(7 * 24 * time.Hour)
|
||||
}
|
||||
func dayEnd(t time.Time) time.Time {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 23, 59, 59, 0, t.Location())
|
||||
}
|
||||
|
||||
// parseExplicitDate 在带触发词的上下文里找日期 token 并解析为「当日 23:59:59」。
|
||||
func parseExplicitDate(content string, now time.Time) string {
|
||||
loc := now.Location()
|
||||
for _, m := range dateTokenRe.FindAllStringSubmatch(content, -1) {
|
||||
var y, mo, d int
|
||||
switch {
|
||||
case m[2] != "" && m[3] != "":
|
||||
// 月日式:M月D日 / M月D号(m[1] 可空=今年)
|
||||
mo = atoiSafe(m[2])
|
||||
d = atoiSafe(m[3])
|
||||
y = now.Year()
|
||||
if m[1] != "" {
|
||||
y = atoiSafe(m[1])
|
||||
}
|
||||
case m[4] != "" && m[5] != "" && m[6] != "":
|
||||
// 完整式:YYYY-MM-DD / YYYY/MM/DD
|
||||
y = atoiSafe(m[4])
|
||||
mo = atoiSafe(m[5])
|
||||
d = atoiSafe(m[6])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if mo < 1 || mo > 12 || d < 1 || d > 31 {
|
||||
continue
|
||||
}
|
||||
return dayEnd(time.Date(y, time.Month(mo), d, 0, 0, 0, 0, loc)).Format(time.RFC3339)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// 织忆 MemoryWeave — bi-temporal 写时识别测试(t_3ada332b)
|
||||
//
|
||||
// 验证:
|
||||
// ① DetectValidTo:强时间词/带上下文显式日期 → 区间末端;常驻事实(无时间词/无触发上下文)→ ""(不误伤)
|
||||
// ② DetectProvenance:user/inferred/import 三来源归一
|
||||
package distill
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 固定审计时刻:2026-10-14(周三)UTC,便于确定性断言。
|
||||
var btNow = time.Date(2026, 10, 14, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestDetectValidTo_StrongTimeWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string // 期望 valid_to(RFC3339)
|
||||
desc string
|
||||
}{
|
||||
{"牧尘这周在 Berlin", "2026-10-18T23:59:59Z", "这周→本周日"},
|
||||
{"本周要发布版本", "2026-10-18T23:59:59Z", "本周→本周日"},
|
||||
{"下周去开会", "2026-10-25T23:59:59Z", "下周→下周日"},
|
||||
{"今天要交报告", "2026-10-14T23:59:59Z", "今天→当日末"},
|
||||
{"明天安排体检", "2026-10-15T23:59:59Z", "明天→次日末"},
|
||||
{"这个月完成迁移", "2026-10-31T23:59:59Z", "这个月→月末"},
|
||||
{"临时使用测试机", "", "临时→(非精确,仅断言非空,见下)"}, // 临时 = now+7d,逐条单测
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := DetectValidTo(c.in, btNow)
|
||||
if c.desc == "临时→(非精确,仅断言非空,见下)" {
|
||||
if got == "" {
|
||||
t.Fatalf("DetectValidTo(%q) 应非空(临时→+7天),实际空", c.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("✗ DetectValidTo(%q)=%q,want %q(%s)", c.in, got, c.want, c.desc)
|
||||
} else {
|
||||
t.Logf("✓ %s: %q → %s", c.desc, c.in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 临时/暂定 → now + 7 天(精确)。
|
||||
func TestDetectValidTo_Temporary(t *testing.T) {
|
||||
got := DetectValidTo("临时使用测试机", btNow)
|
||||
want := btNow.Add(7 * 24 * time.Hour).Format(time.RFC3339)
|
||||
if got != want {
|
||||
t.Fatalf("✗ 临时→now+7d:got %q want %q", got, want)
|
||||
}
|
||||
t.Logf("✓ 临时→now+7d: %s", got)
|
||||
}
|
||||
|
||||
// 常驻事实不得被误判成临时(AC 止损线:误报>5% → 收紧只认强词,宽口径不进)。
|
||||
func TestDetectValidTo_NoFalsePositive(t *testing.T) {
|
||||
permanent := []string{
|
||||
"牧尘住在北京", // 常驻地址
|
||||
"牧尘在招商局工作", // 常驻工作
|
||||
"牧尘生日是3月15日", // 显式日期但【无触发上下文】→ 不解析(防生日/纪念日被标临时)
|
||||
"系统使用 PostgreSQL", // 技术选型
|
||||
"我偏好简洁的回答", // 用户偏好
|
||||
"当前模型是 deepseek-v4", // 当前配置(无临时词)
|
||||
}
|
||||
for _, in := range permanent {
|
||||
if got := DetectValidTo(in, btNow); got != "" {
|
||||
t.Errorf("✗ 常驻事实被误判临时: %q → %s(应空=永久有效)", in, got)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ %d 条常驻事实均未被误判(误报止损线)", len(permanent))
|
||||
}
|
||||
|
||||
// 带触发上下文的显式日期才解析。
|
||||
func TestDetectValidTo_ExplicitDateWithTrigger(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"截止到2026-03-15", "2026-03-15T23:59:59Z"},
|
||||
{"合同截至2026年12月31日", "2026-12-31T23:59:59Z"},
|
||||
{"这个权限在3月20日前有效", "2026-03-20T23:59:59Z"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := DetectValidTo(c.in, btNow)
|
||||
if got != c.want {
|
||||
t.Errorf("✗ DetectValidTo(%q)=%q want %q", c.in, got, c.want)
|
||||
} else {
|
||||
t.Logf("✓ %q → %s", c.in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectProvenance(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"user", "user"}, {"user said", "user"}, {"牧尘口头", "user"},
|
||||
{"import", "import"}, {"wiki:xxx", "import"},
|
||||
{"agent", "inferred"}, {"agent inferred", "inferred"}, {"llm_distill", "inferred"},
|
||||
{"", "inferred"}, // 兜底 → inferred(保守,不误标 user/import)
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DetectProvenance(c.in); got != c.want {
|
||||
t.Errorf("✗ DetectProvenance(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// 三来源各覆盖至少一次
|
||||
if !strings.Contains("user inferred import", DetectProvenance("user")) ||
|
||||
!strings.Contains("user inferred import", DetectProvenance("import")) ||
|
||||
!strings.Contains("user inferred import", DetectProvenance("agent")) {
|
||||
t.Fatal("三来源枚举未全覆盖")
|
||||
}
|
||||
t.Logf("✓ DetectProvenance 三来源归一 user/inferred/import 正确")
|
||||
}
|
||||
@@ -96,6 +96,10 @@ type LLMResponse struct {
|
||||
}
|
||||
|
||||
// LLM 5维权重
|
||||
// 血统:借鉴 MemOS CREATE_EVAL_PROMPT(五维评估:IS 信息重要性 / SU 战略效用 /
|
||||
// PA 实际适用性 / VD 验证持久性 / RU 召回可用性)。出处登记:
|
||||
// ~/mc/小怡/01-织忆/01-01-定义规范/织忆-借鉴溯源-全清单.md §2.1。
|
||||
// 取值 0.20/0.20/0.15/0.25/0.20 系 MemOS 血统参数,勿随意改(改前先研究分布)。
|
||||
var Weights = FiveDScore{
|
||||
IS: 0.20,
|
||||
SU: 0.20,
|
||||
@@ -114,6 +118,9 @@ var Weights = FiveDScore{
|
||||
// 影子默认开启;设 ZHIYI_SCORE_SHADOW=0 可关(关掉 = 恢复按分数真丢)。
|
||||
|
||||
// echoScoreVectors 是历史 prompt 里出现过的示例评分向量(照抄即命中)。
|
||||
// 血统:这些数值来自旧 prompt 的「示例评分」(MemOS CREATE_EVAL_PROMPT 血统的
|
||||
// 五维评估,曾把示例写成 0.8/0.7/0.6/0.9/0.7 导致模型照抄)。出处登记:
|
||||
// ~/mc/小怡/01-织忆/01-01-定义规范/织忆-借鉴溯源-全清单.md §2.1(恒 0.755 之谜)。
|
||||
var echoScoreVectors = [][5]float64{
|
||||
{0.8, 0.7, 0.6, 0.9, 0.7}, // engine.go 旧 prompt 示例
|
||||
}
|
||||
@@ -604,7 +611,10 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
|
||||
// 规则(只保留纯疑问/状态汇报两条硬否定),避免「lane 说放行、fact 级
|
||||
// 却全丢」的静默吞噬;LaneDrop/LaneObserve 行为与原来逐字一致。
|
||||
if before := len(facts); before > 0 {
|
||||
facts = FilterQualityFactsWithLane(facts, lane)
|
||||
// t_ffcee190:env 门控 ZHIYI_SHORTFACT_KEEP_PASSTHROUGH(默认关)——对非规则卡
|
||||
// observe episode 的短事实走 LaneKeep 穿透,救真实对话自然短句误杀;规则卡/门控关
|
||||
// 时 effectiveFilterLane 原样返回 lane,行为与现状逐字一致。
|
||||
facts = FilterQualityFactsWithLane(facts, effectiveFilterLane(input.Content, lane))
|
||||
if dropped := before - len(facts); dropped > 0 {
|
||||
log.Printf("[distill] quality gate dropped %d/%d facts for %s (lane=%s)", dropped, before, input.EpisodeID, lane)
|
||||
}
|
||||
@@ -616,6 +626,11 @@ func (e *Engine) distillOne(input DistillInput) DistillResult {
|
||||
logAAAKIndex(index)
|
||||
}
|
||||
|
||||
// 2026-10-13 对抗审计插槽(②环,env 门控默认关):蒸馏产出的最终事实
|
||||
// 按抽样比例送【异源模型】做对抗审计,结论只写影子日志,不改丢弃/准入行为。
|
||||
// 门控关/未命中抽样/无事实 → 本调用立即返回,零行为变化。
|
||||
MaybeAdversarialAudit(input, facts)
|
||||
|
||||
return DistillResult{
|
||||
Facts: facts,
|
||||
Entities: entities,
|
||||
@@ -1193,7 +1208,7 @@ func (e *Engine) checkDailyLimit() {
|
||||
|
||||
// emitResult 发送蒸馏结果(回调)
|
||||
func (e *Engine) emitResult(input DistillInput, result DistillResult) {
|
||||
if len(result.Facts) == 0 {
|
||||
if len(result.Facts) == 0 && len(result.Entities) == 0 {
|
||||
return
|
||||
}
|
||||
// 由注册的回调处理
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── 补收口三(2026-10-13 t_62f35e62):facts==0 早退 ⇒ 实体静默丢失 ──
|
||||
// 此前 emitResult 在 `len(result.Facts)==0` 时整体 return,当 facts 全被质量
|
||||
// 闸门丢掉时,整个回调不触发 ⇒ 实体也不进图(图只剩噪声的真半因,实测定:
|
||||
// 领域内容 5 实体一个都没进图)。修复:把早退条件放宽为「facts 与 entities
|
||||
// 都空」才 return——facts 为空时回调内的 fact 落库循环自然空转、factsLanded
|
||||
// 保持 false,只有 §1 实体写图会执行(正是我们要的)。
|
||||
|
||||
// AC4:facts==0 但 entities>0 → 回调必须触发(实体仍进图)。
|
||||
func TestEmitResult_FactsZero_EntitiesStillLand(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var called bool
|
||||
var gotResult DistillResult
|
||||
prev := OnDistillComplete
|
||||
OnDistillComplete = func(input DistillInput, result DistillResult) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
called = true
|
||||
gotResult = result
|
||||
}
|
||||
defer func() { OnDistillComplete = prev }()
|
||||
|
||||
e := NewEngine("http://127.0.0.1:1/unused", "m", "k")
|
||||
// facts 全空(被质量闸门丢光)、entities 仍保留 2 个领域实体
|
||||
e.emitResult(DistillInput{EpisodeID: "ep_xx", Content: "领域内容"},
|
||||
DistillResult{
|
||||
Facts: []string{},
|
||||
Entities: []Entity{{Name: "鹤壁淇县生物制造产业专班", Type: "entity"}, {Name: "飞天农业", Type: "entity"}},
|
||||
})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !called {
|
||||
t.Fatalf("facts==0 且 entities>0:OnDistillComplete 应触发(实体才能进图),实际未触发")
|
||||
}
|
||||
if len(gotResult.Entities) != 2 {
|
||||
t.Errorf("回调收到的 Entities 应为 2,实际 %d", len(gotResult.Entities))
|
||||
}
|
||||
}
|
||||
|
||||
// AC4 对照:facts==0 且 entities==0 → 回调不触发(空结果不扰回调)。
|
||||
func TestEmitResult_FactsAndEntitiesZero_NoCallback(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
called := false
|
||||
prev := OnDistillComplete
|
||||
OnDistillComplete = func(DistillInput, DistillResult) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
called = true
|
||||
}
|
||||
defer func() { OnDistillComplete = prev }()
|
||||
|
||||
e := NewEngine("http://127.0.0.1:1/unused", "m", "k")
|
||||
e.emitResult(DistillInput{EpisodeID: "ep_yy"}, DistillResult{})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if called {
|
||||
t.Fatalf("facts==0 且 entities==0:回调不应触发,实际触发了")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// 织忆 MemoryWeave —— 写时四操作的 LLM 判定器(t_writeops_llm_judge,2026-10-25)
|
||||
//
|
||||
// 背景(离线回放实测,不是推测):
|
||||
// · 规则基 defaultJudge 用 ZHIYI_WRITE_SIM_UPDATE=0.70 判 UPDATE,影子日志 11 天 17179 条里
|
||||
// 14435 条(84%)被判「新事实应覆盖旧事实」;人工抽检 15 条低相似度样本,15/15 不是真覆盖
|
||||
// (「用户查看知乎链接」vs「用户查看微信文章」都判成覆盖)。
|
||||
// · 80 条分层回放(各相似度档 20 条,LLM 三分类):REPLACE 率 0.95+ 档 25%、0.90-0.95 档 35%、
|
||||
// 0.85-0.90 档 35%、0.70-0.85 档 20% —— 与相似度**不单调**,说明相似度只反映「话题相关」,
|
||||
// 不反映「是否同一属性的新旧值」。低档 LLM 同样会被话题带偏(0.70-0.85 档 REPLACE 精度约 25%)。
|
||||
//
|
||||
// 结论与实现:
|
||||
// · 只有 sim >= ZHIYI_WRITE_JUDGE_SIM(默认 0.95)才调 LLM 判三分类;低于该档直接 ADD(不调 LLM,
|
||||
// 省调用 + 该档不可信)。
|
||||
// · REPLACE→UPDATE / DUPLICATE→NOOP / KEEP→ADD;判不准与调用失败一律兜底 ADD(宁可不覆盖)。
|
||||
// · 默认 judge 模式仍是 rule(零行为变化);apply 模式强制要求 llm(见 write_ops.go 硬保护)。
|
||||
package distill
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const llmJudgeSystemPrompt = `你是记忆库的写入判定器。给你两条记忆:A=库里已有的旧事实,B=刚提取出的新事实。
|
||||
判断 B 与 A 的关系,只回一个词,不要解释:
|
||||
REPLACE = 同一主体的同一属性,B 是 A 的更新值/修正值(B 生效后 A 就过时了)
|
||||
DUPLICATE= B 与 A 语义等价,B 没有新增信息
|
||||
KEEP = B 补充/细化/扩展 A,或讲不同侧面/不同对象/不同阶段;两条都要保留
|
||||
判不准时回 KEEP。`
|
||||
|
||||
// llmJudgeSimThreshold 低于此相似度直接 ADD(实测该档 LLM 判定精度不足,且省调用)。
|
||||
const llmJudgeSimThresholdDefault = 0.95
|
||||
|
||||
// llmJudgeCall 可注入的 LLM 调用(单测替换,生产走 HTTP)。返回模型输出的原始文本。
|
||||
var llmJudgeCall = callLLMJudgeModel
|
||||
|
||||
// LLMJudgeMode 当前 judge 模式:rule(默认)| llm。
|
||||
func LLMJudgeMode() string {
|
||||
m := strings.ToLower(strings.TrimSpace(os.Getenv("ZHIYI_WRITE_JUDGE")))
|
||||
if m == "llm" {
|
||||
return "llm"
|
||||
}
|
||||
return "rule"
|
||||
}
|
||||
|
||||
// llmJudgeSimThreshold 判定相似度门槛(env 可调)。
|
||||
func llmJudgeSimThreshold() float64 {
|
||||
if v := strings.TrimSpace(os.Getenv("ZHIYI_WRITE_JUDGE_SIM")); v != "" {
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 && f <= 1 {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return llmJudgeSimThresholdDefault
|
||||
}
|
||||
|
||||
// LLMJudge 用 LLM 判「B 是否应取代 A」。
|
||||
// 返回的 WriteVerdict 与 defaultJudge 同构,调用方无需区分。
|
||||
func LLMJudge(candidate string, similar []SimilarMemory) WriteVerdict {
|
||||
v := WriteVerdict{
|
||||
Op: OpAdd,
|
||||
Candidate: candidate,
|
||||
Reason: "llm:无相似候选 → 新建",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if len(similar) == 0 {
|
||||
return v
|
||||
}
|
||||
top := similar[0] // 调用方已按相似度排序(TopN)
|
||||
v.Similarity = top.Similarity
|
||||
|
||||
// 归一化完全等价 → NOOP,不调 LLM
|
||||
if NormalizeContent(top.Content) == NormalizeContent(candidate) {
|
||||
v.Op = OpNoop
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "llm:归一化等价 → 丢弃"
|
||||
return v
|
||||
}
|
||||
|
||||
// 相似度不足 → ADD(实测低档 LLM 判定不可信,且省钱)
|
||||
if top.Similarity < llmJudgeSimThreshold() {
|
||||
v.Reason = fmt.Sprintf("llm:sim %.3f < %.2f → 不判,新建", top.Similarity, llmJudgeSimThreshold())
|
||||
return v
|
||||
}
|
||||
|
||||
raw, err := llmJudgeCall(candidate, top.Content)
|
||||
if err != nil {
|
||||
// 调用失败兜底 ADD:宁可不覆盖(漏覆盖可事后补,误覆盖会丢事实)
|
||||
v.Reason = "llm:调用失败兜底新建"
|
||||
log.Printf("[write-ops][llm-judge] 调用失败,兜底 ADD: %v candidate=%.40s", err, candidate)
|
||||
return v
|
||||
}
|
||||
verdict := parseLLMJudgeVerdict(raw)
|
||||
switch verdict {
|
||||
case "REPLACE":
|
||||
v.Op = OpUpdate
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "llm:同一属性更新 → 旧条标 superseded_by"
|
||||
case "DUPLICATE":
|
||||
v.Op = OpNoop
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "llm:语义等价 → 丢弃"
|
||||
default: // KEEP / 无法解析
|
||||
v.Reason = "llm:保留(补充/不同侧面)→ 新建"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// parseLLMJudgeVerdict 从模型输出里取三分类词;取不到返回 KEEP。
|
||||
func parseLLMJudgeVerdict(raw string) string {
|
||||
u := strings.ToUpper(raw)
|
||||
for _, k := range []string{"REPLACE", "DUPLICATE", "KEEP"} {
|
||||
if strings.Contains(u, k) {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return "KEEP"
|
||||
}
|
||||
|
||||
// callLLMJudgeModel 调判定模型,返回 message.content。配置项与对抗审计同构。
|
||||
func callLLMJudgeModel(candidate, existing string) (string, error) {
|
||||
endpoint := envStr("ZHIYI_WRITE_JUDGE_ENDPOINT", "http://127.0.0.1:3000/v1/chat/completions")
|
||||
model := envStr("ZHIYI_WRITE_JUDGE_MODEL", "agnes-2.5-flash")
|
||||
key := envStr("ZHIYI_WRITE_JUDGE_KEY", "")
|
||||
if key == "" {
|
||||
key = os.Getenv("LLM_API_KEY")
|
||||
}
|
||||
timeout := time.Duration(envFloat("ZHIYI_WRITE_JUDGE_TIMEOUT", 30)) * time.Second
|
||||
|
||||
body := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": llmJudgeSystemPrompt},
|
||||
{"role": "user", "content": fmt.Sprintf("A(旧): %s\n\nB(新): %s", existing, candidate)},
|
||||
},
|
||||
"temperature": 0,
|
||||
"max_tokens": 100,
|
||||
"reasoning_effort": "none", // 实测:不给 none 时 reasoning 会吃光 max_tokens,content 返空
|
||||
}
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest("POST", endpoint, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
client := &http.Client{Timeout: timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("judge HTTP %d: %.120s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var parsed struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return "", fmt.Errorf("judge 响应解析失败: %w", err)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return "", fmt.Errorf("judge 响应无 choices")
|
||||
}
|
||||
return parsed.Choices[0].Message.Content, nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withFakeLLMJudge 注入假 LLM 调用,返回调用次数计数器。
|
||||
func withFakeLLMJudge(t *testing.T, reply string, err error) *int32 {
|
||||
t.Helper()
|
||||
var calls int32
|
||||
orig := llmJudgeCall
|
||||
llmJudgeCall = func(candidate, existing string) (string, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return reply, err
|
||||
}
|
||||
t.Cleanup(func() { llmJudgeCall = orig })
|
||||
return &calls
|
||||
}
|
||||
|
||||
func similarTop(id, content string, sim float64) []SimilarMemory {
|
||||
return []SimilarMemory{{ID: id, Content: content, Similarity: sim}}
|
||||
}
|
||||
|
||||
// 低相似度档(实测 LLM 判定不可信)必须短路,不调 LLM。
|
||||
func TestLLMJudge_LowSimilaritySkipsLLM(t *testing.T) {
|
||||
calls := withFakeLLMJudge(t, "REPLACE", nil)
|
||||
v := LLMJudge("新事实", similarTop("mem_1", "旧事实", 0.80))
|
||||
if v.Op != OpAdd {
|
||||
t.Fatalf("sim 0.80 应判 ADD,得到 %s", v.Op)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 0 {
|
||||
t.Fatalf("sim 低于门槛时不应调用 LLM,实际调用 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMJudge_Replace(t *testing.T) {
|
||||
withFakeLLMJudge(t, "REPLACE", nil)
|
||||
v := LLMJudge("executions.db 台账 max_rowid 为 185769",
|
||||
similarTop("mem_x", "executions.db 台账 max_rowid 为 183371", 0.97))
|
||||
if v.Op != OpUpdate {
|
||||
t.Fatalf("REPLACE 应判 UPDATE,得到 %s", v.Op)
|
||||
}
|
||||
if v.TargetID != "mem_x" {
|
||||
t.Fatalf("UPDATE 必须带 TargetID,得到 %q", v.TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMJudge_Duplicate(t *testing.T) {
|
||||
withFakeLLMJudge(t, "DUPLICATE", nil)
|
||||
v := LLMJudge("该软件支持PDF与Word互转", similarTop("mem_y", "工具支持PDF与Word互转", 0.96))
|
||||
if v.Op != OpNoop {
|
||||
t.Fatalf("DUPLICATE 应判 NOOP,得到 %s", v.Op)
|
||||
}
|
||||
}
|
||||
|
||||
// KEEP=补充/不同侧面 → 必须新建,不能覆盖。
|
||||
func TestLLMJudge_Keep(t *testing.T) {
|
||||
withFakeLLMJudge(t, "KEEP", nil)
|
||||
v := LLMJudge("用户查看微信文章", similarTop("mem_z", "用户查看知乎链接", 0.97))
|
||||
if v.Op != OpAdd {
|
||||
t.Fatalf("KEEP 应判 ADD,得到 %s", v.Op)
|
||||
}
|
||||
if v.TargetID != "" {
|
||||
t.Fatalf("ADD 不应带 TargetID,得到 %q", v.TargetID)
|
||||
}
|
||||
}
|
||||
|
||||
// LLM 调用失败必须兜底 ADD(宁可不覆盖)。
|
||||
func TestLLMJudge_CallErrorFallsBackToAdd(t *testing.T) {
|
||||
withFakeLLMJudge(t, "", errors.New("503 Service Unavailable"))
|
||||
v := LLMJudge("新事实", similarTop("mem_1", "旧事实", 0.99))
|
||||
if v.Op != OpAdd {
|
||||
t.Fatalf("调用失败应兜底 ADD,得到 %s", v.Op)
|
||||
}
|
||||
}
|
||||
|
||||
// 归一化完全等价 → NOOP,且不调 LLM。
|
||||
func TestLLMJudge_NormalizedEqualSkipsLLM(t *testing.T) {
|
||||
calls := withFakeLLMJudge(t, "KEEP", nil)
|
||||
v := LLMJudge("用户喜欢喝咖啡。", similarTop("mem_1", "用户喜欢喝咖啡", 0.97))
|
||||
if v.Op != OpNoop {
|
||||
t.Fatalf("归一化等价应判 NOOP,得到 %s", v.Op)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 0 {
|
||||
t.Fatalf("归一化等价不应调用 LLM,实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLLMJudgeVerdict(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"REPLACE": "REPLACE",
|
||||
"duplicate": "DUPLICATE",
|
||||
"我认为应该 KEEP 两条": "KEEP",
|
||||
"": "KEEP",
|
||||
"无法判断": "KEEP",
|
||||
"REPLACE\n因为同一属性更新": "REPLACE",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseLLMJudgeVerdict(in); got != want {
|
||||
t.Errorf("parse(%q)=%q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply 模式必须强制 llm 判定;规则基判定精度实测≈0%,不许放行改库。
|
||||
func TestWriteOpsApply_RequiresLLMJudge(t *testing.T) {
|
||||
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "apply")
|
||||
|
||||
t.Setenv("ZHIYI_WRITE_JUDGE", "rule")
|
||||
if writeOpsApply() {
|
||||
t.Fatal("apply + rule 必须被拒绝(会误覆盖上万条),实际放行")
|
||||
}
|
||||
|
||||
t.Setenv("ZHIYI_WRITE_JUDGE", "llm")
|
||||
if !writeOpsApply() {
|
||||
t.Fatal("apply + llm 应放行")
|
||||
}
|
||||
|
||||
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "shadow")
|
||||
t.Setenv("ZHIYI_WRITE_JUDGE", "llm")
|
||||
if writeOpsApply() {
|
||||
t.Fatal("shadow 模式不应改库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMJudge_ModeDefaultIsRule(t *testing.T) {
|
||||
t.Setenv("ZHIYI_WRITE_JUDGE", "")
|
||||
if LLMJudgeMode() != "rule" {
|
||||
t.Fatalf("默认 judge 模式必须是 rule(零行为变化),得到 %s", LLMJudgeMode())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMJudgeLive 真实 LLM 端到端验证(默认跳过;ZHIYI_LIVE_JUDGE=1 才跑)。
|
||||
// 三条样本取自 2026-10-25 影子日志回放,人类已判定正确答案。
|
||||
func TestLLMJudgeLive(t *testing.T) {
|
||||
if os.Getenv("ZHIYI_LIVE_JUDGE") != "1" {
|
||||
t.Skip("live LLM 测试,设 ZHIYI_LIVE_JUDGE=1 才跑")
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cand string
|
||||
existing string
|
||||
want WriteOp
|
||||
}{
|
||||
{"真覆盖-同一指标新值", "executions.db台账的max_rowid为185769。", "executions.db台账的max_rowid为183371", OpUpdate},
|
||||
{"假覆盖-不同对象", "用户要求查看 https://mp.weixin.qq.com/s/4nmTHWoK 这篇文章", "用户: https://www.zhihu.com/pin/2075233714007757230", OpAdd},
|
||||
{"重复-同义改写", "该软件支持PDF与Word/Excel/图片互转功能。", "工具转换功能支持PDF与Word/Excel/图片互转", OpNoop},
|
||||
}
|
||||
for _, c := range cases {
|
||||
v := LLMJudge(c.cand, similarTop("mem_x", c.existing, 0.97))
|
||||
t.Logf("%-18s → op=%s reason=%s", c.name, v.Op, v.Reason)
|
||||
if v.Op != c.want {
|
||||
t.Errorf("%s: 期望 %s,实际 %s(%s)", c.name, c.want, v.Op, v.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@ import (
|
||||
// 缓存本身的正确性由 score_cache_test.go 直接测结构体(不依赖全局实例)。
|
||||
func TestMain(m *testing.M) {
|
||||
scoreCacheDisabled = true
|
||||
loadPayloadRulesForTests() // t_e11207f8 治源①:单测前确保 payload guard 规则已加载(共享 JSON 或内嵌兜底)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package distill
|
||||
|
||||
// 织忆 MemoryWeave — 蒸馏落库「空壳正文」拒写门禁(t_e11207f8 治源 ①)
|
||||
//
|
||||
// 背景:OnDistillComplete(api/server.go)把 LLM 蒸馏出的 fact 直接 InsertMemory 进
|
||||
// memories(category=distilled)。蒸馏 LLM 偶发把「只有溯源指针 / 占位 / 纯日期、无知识
|
||||
// 载荷」的碎片当 fact 输出(例:`证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23`),
|
||||
// 质量门槛 IsQualityFact 的长度/实体启发式抓不住它(>20 字)⇒ 库里持续长出新空壳。
|
||||
//
|
||||
// 单一真相源:判据的**规则值**读自共享 JSON(zhiyi_payload_guard_rules.json),与
|
||||
// Python 侧 ~/.hermes/scripts/lib/zhiyi_payload_guard.py **读同一份文件**(不在此手抄,
|
||||
// 防跨语言漂移)。改规则只改那一个 JSON。
|
||||
//
|
||||
// 铁律(与 Python 逐位一致,宁漏勿误删):
|
||||
// · 含任一句读标点(clause_punct)⇒ 绝不判空壳(保真优先)
|
||||
// · 仅 空串 / 占位变量 / 纯日期 / 溯源指针(来源/证据/文件/日期/source + 引用值) 判空壳
|
||||
//
|
||||
// 已知跨语言 nuance(文档,不改行为方向):refname 正则里的 \w,Go RE2 默认 ASCII、
|
||||
// Python 默认 unicode ⇒ CJK-only 文件名 Go 不判引用 ⇒ Go **多保留**(安全向,宁漏勿误删)。
|
||||
//
|
||||
// 无缓存、无新增 Redis 键(只用原子计数),故不触及项目现成件清单的缓存条目。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// defaultRulesPath — 与 Python 侧同一份共享规则文件(同机同用户 muc 可直读)。
|
||||
// env ZHIYI_PAYLOAD_GUARD_RULES 可覆盖(测试/部署用)。
|
||||
func defaultRulesPath() string {
|
||||
if p := os.Getenv("ZHIYI_PAYLOAD_GUARD_RULES"); p != "" {
|
||||
return p
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".hermes", "scripts", "lib", "zhiyi_payload_guard_rules.json")
|
||||
}
|
||||
return "/home/muc/.hermes/scripts/lib/zhiyi_payload_guard_rules.json"
|
||||
}
|
||||
|
||||
// payloadGuardRules 镜像共享 JSON(只取 Go 判据需要的字段;未知字段忽略)。
|
||||
type payloadGuardRules struct {
|
||||
ClausePunct string `json:"clause_punct"`
|
||||
Placeholder struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"placeholder"`
|
||||
BareDate struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"bare_date"`
|
||||
ProvLabel struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"prov_label"`
|
||||
Refname struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"refname"`
|
||||
RefValueSubstrings []string `json:"ref_value_substrings"`
|
||||
RefValueMaxLen int `json:"ref_value_max_len"`
|
||||
RefValueMaxCJK int `json:"ref_value_max_cjk"`
|
||||
CJKLo string `json:"cjk_lo"`
|
||||
CJKHi string `json:"cjk_hi"`
|
||||
}
|
||||
|
||||
type guard struct {
|
||||
rules payloadGuardRules
|
||||
placeH *regexp.Regexp
|
||||
bareD *regexp.Regexp
|
||||
provL *regexp.Regexp
|
||||
refname *regexp.Regexp
|
||||
loaded bool
|
||||
path string
|
||||
}
|
||||
|
||||
var (
|
||||
g *guard
|
||||
// shellChecks = 拦截器「确实跑过」的心跳计数(每个蒸馏 fact 判一次);
|
||||
// shellBlocks = 实际拦下的空壳条数。二者配对即证拦截器活着(0 blocks 但 checks>0 也合法)。
|
||||
shellChecks int64
|
||||
shellBlocks int64
|
||||
)
|
||||
|
||||
// LoadPayloadGuard 读共享规则 JSON 并编译正则。失败 ⇒ 未加载(fail-open:distill 照跑、
|
||||
// 不拦空壳、不崩 zhiyid),并把 loaded=false 记给 /api/v1/payload-guard/stats 暴露。
|
||||
func LoadPayloadGuard(explicitPath string) (loaded bool, err error) {
|
||||
p := explicitPath
|
||||
if p == "" {
|
||||
p = defaultRulesPath()
|
||||
}
|
||||
raw, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
g = &guard{}
|
||||
return false, rerr
|
||||
}
|
||||
var rc payloadGuardRules
|
||||
if jerr := json.Unmarshal(raw, &rc); jerr != nil {
|
||||
g = &guard{}
|
||||
return false, jerr
|
||||
}
|
||||
compile := func(pat string, ci bool) (*regexp.Regexp, error) {
|
||||
if ci {
|
||||
pat = "(?i)" + pat
|
||||
}
|
||||
return regexp.Compile(pat)
|
||||
}
|
||||
gh := &guard{rules: rc, path: p, loaded: true}
|
||||
if gh.placeH, err = compile(rc.Placeholder.Pattern, rc.Placeholder.CaseInsensitive); err != nil {
|
||||
g = &guard{}
|
||||
return false, err
|
||||
}
|
||||
if gh.bareD, err = compile(rc.BareDate.Pattern, rc.BareDate.CaseInsensitive); err != nil {
|
||||
g = &guard{}
|
||||
return false, err
|
||||
}
|
||||
if gh.provL, err = compile(rc.ProvLabel.Pattern, rc.ProvLabel.CaseInsensitive); err != nil {
|
||||
g = &guard{}
|
||||
return false, err
|
||||
}
|
||||
if gh.refname, err = compile(rc.Refname.Pattern, rc.Refname.CaseInsensitive); err != nil {
|
||||
g = &guard{}
|
||||
return false, err
|
||||
}
|
||||
if rc.RefValueMaxLen <= 0 {
|
||||
rc.RefValueMaxLen = 24
|
||||
}
|
||||
if rc.RefValueMaxCJK <= 0 {
|
||||
rc.RefValueMaxCJK = 12
|
||||
}
|
||||
gh.rules = rc
|
||||
g = gh
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PayloadGuardStats 供 /metrics 与验收:拦截器心跳(checks) / 拦截数(blocks) / 是否加载 / 规则路径。
|
||||
func PayloadGuardStats() (checks, blocks int64, loaded bool, path string) {
|
||||
if g == nil {
|
||||
return atomic.LoadInt64(&shellChecks), atomic.LoadInt64(&shellBlocks), false, ""
|
||||
}
|
||||
return atomic.LoadInt64(&shellChecks), atomic.LoadInt64(&shellBlocks), g.loaded, g.path
|
||||
}
|
||||
|
||||
// IncShellCheck / IncShellBlock 供写路径在判定前后调用(相对增量,避免并发写竞态)。
|
||||
func IncShellCheck() { atomic.AddInt64(&shellChecks, 1) }
|
||||
func IncShellBlock() { atomic.AddInt64(&shellBlocks, 1) }
|
||||
|
||||
// ShellCheck 治源入口(带心跳计数):
|
||||
// - 未加载 ⇒ fail-open(放行、checks 仍 +1,保证「拦截器跑过」可观测,不拦也不崩);
|
||||
// - 已加载 ⇒ 用共享判据判定,命中空壳则 blocks +1 并返回 true(写路径据此 continue 拒写)。
|
||||
func ShellCheck(content string) bool {
|
||||
IncShellCheck()
|
||||
if g == nil || !g.loaded {
|
||||
return false
|
||||
}
|
||||
if g.isEmptyShell(content) {
|
||||
IncShellBlock()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsEmptyShell 纯判据(无计数,供单测)。未加载时就地读一次默认路径兜底;失败则 fail-open。
|
||||
func IsEmptyShell(content string) bool {
|
||||
if g == nil {
|
||||
if _, err := LoadPayloadGuard(""); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return g.isEmptyShell(content)
|
||||
}
|
||||
|
||||
func (gr *guard) isEmptyShell(content string) bool {
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return true
|
||||
}
|
||||
// 含任一句读标点 ⇒ 有知识断言 ⇒ 绝不当空壳(保真优先,优先于漏删)
|
||||
for _, r := range []rune(gr.rules.ClausePunct) {
|
||||
if strings.ContainsRune(c, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if gr.placeH.MatchString(c) {
|
||||
return true
|
||||
}
|
||||
if gr.bareD.MatchString(c) {
|
||||
return true
|
||||
}
|
||||
if gr.provL.MatchString(c) && gr.isRefValue(c) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (gr *guard) isRefValue(c string) bool {
|
||||
s := strings.TrimSpace(c)
|
||||
for _, sub := range gr.rules.RefValueSubstrings {
|
||||
if strings.Contains(c, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if gr.bareD.MatchString(s) || gr.refname.MatchString(s) {
|
||||
return true
|
||||
}
|
||||
if utf8.RuneCountInString(s) <= gr.rules.RefValueMaxLen && gr.cjkCount(s) < gr.rules.RefValueMaxCJK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cjkCount 统计 [cjk_lo, cjk_hi] 区间的汉字数(与 Python re.findall(r"[一-鿿]") 等价)。
|
||||
func (gr *guard) cjkCount(s string) int {
|
||||
lo, hi := gr.rules.CJKLo, gr.rules.CJKHi
|
||||
if lo == "" || hi == "" {
|
||||
lo, hi = "一", "鿿" // U+4E00 / U+9FFF
|
||||
}
|
||||
loR, _ := utf8.DecodeRuneInString(lo)
|
||||
hiR, _ := utf8.DecodeRuneInString(hi)
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r >= loR && r <= hiR {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package distill
|
||||
|
||||
// t_e11207f8 治源 ① Go 单测:
|
||||
// ① 真样本(溯源指针)必判空壳;② 负控(真知识短句 / 含句读)必保留;③ 拦截计数。
|
||||
// 判据值优先读【生产共享 JSON】(~/.hermes/scripts/lib/zhiyi_payload_guard_rules.json),
|
||||
// 与 Python 同源;该文件缺失时用下方内嵌副本兜底(仅测试用,非运行时真相源)。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const embeddedRulesJSON = `{
|
||||
"clause_punct": ",。、;!?",
|
||||
"placeholder": {"pattern": "^[xX]=(?:true|false|\\\\d|[a-z_]{1,12})$", "case_insensitive": true},
|
||||
"bare_date": {"pattern": "^(?:日期[::]?)?\\\\d{4}[-/]\\\\d{1,2}[-/]\\\\d{1,2}\\\\.?[。.\\\\s]*$"},
|
||||
"prov_label": {"pattern": "^(?:来源|证据|文件|日期|source)[::]", "case_insensitive": true},
|
||||
"refname": {"pattern": "^[\\\\w.-]+\\\\.(?:md|py|sh|json|yaml|log|txt)$", "case_insensitive": true},
|
||||
"ref_value_substrings": ["|", "vault:", "/"],
|
||||
"ref_value_max_len": 24, "ref_value_max_cjk": 12,
|
||||
"cjk_lo": "一", "cjk_hi": "鿿"
|
||||
}`
|
||||
|
||||
func loadPayloadRulesForTests() {
|
||||
// 优先生产共享 JSON(与 Python 同源);缺失则落内嵌副本到临时目录再读(保证测试可独立跑)。
|
||||
p := os.Getenv("ZHIYI_PAYLOAD_GUARD_RULES")
|
||||
if p == "" {
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
p = filepath.Join(home, ".hermes", "scripts", "lib", "zhiyi_payload_guard_rules.json")
|
||||
}
|
||||
}
|
||||
if _, err := LoadPayloadGuard(p); err != nil {
|
||||
tmp, merr := os.MkdirTemp("", "zhiyi-payload-rules-")
|
||||
if merr != nil {
|
||||
return
|
||||
}
|
||||
rp := filepath.Join(tmp, "rules.json")
|
||||
if werr := os.WriteFile(rp, []byte(embeddedRulesJSON), 0o644); werr != nil {
|
||||
return
|
||||
}
|
||||
LoadPayloadGuard(rp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyShell_Positive_RealSample(t *testing.T) {
|
||||
cases := []string{
|
||||
"证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23", // 蒸馏真样本(QA/小怡复现的那条)
|
||||
"来源:vault:定义规范:106办公向优化方案-2026-09-18.md",
|
||||
"证据:文件:小怡/工作笔记/已办.md",
|
||||
"X=true",
|
||||
"",
|
||||
"2026-09-23",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if !IsEmptyShell(c) {
|
||||
t.Errorf("应判空壳却放行: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEmptyShell_Negative_KeepRealKnowledge(t *testing.T) {
|
||||
cases := []string{
|
||||
"牧尘在招商局工作", // 负控:超短真断言,绝不当空壳
|
||||
"活库直连写必须停服务", // 负控:真知识
|
||||
"来源:t_cbc41a01 待办 #6 核销结果。", // 含句读(。)⇒ 必保留
|
||||
"双向链接是 Obsidian 最核心的功能之一。", // 含句读
|
||||
"决定将newapi平台中的付费key改为免费", // 真知识
|
||||
"织忆端口7821", // 短但无句读、无溯源前缀、非占位 ⇒ 保守保留
|
||||
}
|
||||
for _, c := range cases {
|
||||
if IsEmptyShell(c) {
|
||||
t.Errorf("误判真知识为空壳(负控失败,违反宁漏勿误删): %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCheck_Counters(t *testing.T) {
|
||||
// ③ 拦截器计数:每个 fact 判一次(checks 增),命中空壳再 blocks 增。
|
||||
c0, b0, loaded, _ := PayloadGuardStats()
|
||||
if !loaded {
|
||||
t.Fatalf("payload guard 未加载,无法验证拦截器")
|
||||
}
|
||||
good := "用户决定升级系统"
|
||||
shell := "证据:文件:小怡/工作笔记/已办.md|日期:2026-09-23"
|
||||
if ShellCheck(good) {
|
||||
t.Errorf("真知识 fact 被误拦: %q", good)
|
||||
}
|
||||
if !ShellCheck(shell) {
|
||||
t.Errorf("空壳蒸馏 fact 未被拦: %q", shell)
|
||||
}
|
||||
c1, b1, _, _ := PayloadGuardStats()
|
||||
if c1-c0 != 2 {
|
||||
t.Errorf("checks 增量应为 2(good+shell 各判一次),实得 %d", c1-c0)
|
||||
}
|
||||
if b1-b0 != 1 {
|
||||
t.Errorf("blocks 增量应为 1(仅 shell 命中),实得 %d", b1-b0)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
package distill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -141,6 +142,36 @@ func FilterQualityFactsWithLane(facts []string, lane AdmissionLane) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── 短事实 keep 档穿透(kanban t_ffcee190)──────────────────────
|
||||
|
||||
// shortFactKeepPassthrough 短事实 keep 档穿透总开关(**默认关 = 现状**)。
|
||||
// 设环境变量 ZHIYI_SHORTFACT_KEEP_PASSTHROUGH=1 开启:对**非规则卡** episode 的
|
||||
// 短事实走 LaneKeep 穿透,救回真实对话自然短句被 <20/实体动词 启发式误杀的问题。
|
||||
// 规则卡(content 以 "[规则:" 前缀)维持现状 —— 规则本体在 skill 零损失,其
|
||||
// 碎片进库是设计内丢弃,不能放宽。默认关 ⇒ 生产行为零变化。
|
||||
var shortFactKeepPassthrough = os.Getenv("ZHIYI_SHORTFACT_KEEP_PASSTHROUGH") == "1"
|
||||
|
||||
// isRuleCardEpisode 结构化判别:规则卡 episode 的写入通道标识 = content 以 "[规则:" 前缀。
|
||||
// 这是写入侧(skill 规则卡采集管道)统一固化的标签,非内容关键词猜测 ——
|
||||
// 实测 LanceDB episodes 里规则卡 100% 以该前缀开头(8816 条规则卡 8816 条前缀命中,
|
||||
// 非前缀命中 6 条全部是真实对话里转述「规则卡」一词,可用 isRuleCard 歧义极小)。
|
||||
func isRuleCardEpisode(content string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(content), "[规则:")
|
||||
}
|
||||
|
||||
// effectiveFilterLane 计算 fact 级门禁应使用的 lane。
|
||||
// 语义(只对「不确定带 observe」做穿透,keep/drop 不动):
|
||||
// - env 门控开启 且 lane==LaneObserve 且 本条不是规则卡 → 返回 LaneKeep(走 keep 穿透,
|
||||
// 跳过长度类启发式,只保留 纯疑问/状态汇报 两条硬否定)。
|
||||
// - 其余(规则卡 / lane!=observe / 门控关)→ 原样返回 lane,行为与现状逐字一致。
|
||||
// 由此规则卡 episode 的 fact 门禁输出与现状恒等(见 TestFilterQualityFactsWithLane_...)。
|
||||
func effectiveFilterLane(content string, lane AdmissionLane) AdmissionLane {
|
||||
if shortFactKeepPassthrough && lane == LaneObserve && !isRuleCardEpisode(content) {
|
||||
return LaneKeep
|
||||
}
|
||||
return lane
|
||||
}
|
||||
|
||||
// ─── 纯疑问句判定 ──────────────────────────────────────────
|
||||
|
||||
var questionSuffixes = []string{"?", "?", "吗?", "呢?", "么?", "嘛?", "吗?", "呢?"}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// 织忆 MemoryWeave — 召回侧确定性判据(kanban t_2515307a)
|
||||
//
|
||||
// 为什么(判据优先级「确定性 > 模型」,见设计文档 §一·补):
|
||||
// 实测本地 2B 的 LLM 评分只有 12 种 dims 模板(前 4 种占 84%)、退化率 4%,
|
||||
// 无法用于「这条记忆能不能用」的精细排序。而 quality.go 的确定性规则
|
||||
// (isPureQuestion / isStatusReportLike / isSoulfulShortFact / hasEntity /
|
||||
// hasVerbPhrase)与 textfilter.IsSystemTemplate 只在**蒸馏写入侧**用,
|
||||
// 召回时没人用 —— 本文件把它们复用到召回侧,给候选做**纯规则过滤/降权**。
|
||||
//
|
||||
// 三条设计规则(§一·补):
|
||||
// 1. 凡能用确定性规则判的,不用模型 —— 本文件 0 LLM 调用。
|
||||
// 2. LLM 分数只做粗筛(2~3 档),不做精细排序 —— 精细判据走这里的确定性规则。
|
||||
// 3. 每个插槽可证伪:env 门控 ZHIYI_RULE_FIRST,默认关 ⇒ 零行为变化。
|
||||
//
|
||||
// 用法:调用方(routes 层,因 distill→storage 有 import 依赖,storage 不能
|
||||
// import distill)在召回结果集上逐条调 RecallRuleVerdict,按 verdict 过滤
|
||||
// (RecallDrop)或降权重排(RecallDemote)。纯函数,同输入恒同输出,可单测。
|
||||
package distill
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/textfilter"
|
||||
)
|
||||
|
||||
// hasUrlishEntity 判定内容是否含 URL / 路径 / 域名等**强实体**形态。
|
||||
// 为什么单独补:hasEntity(quality.go)只认「强实体词 + 词首大写 + 数字」,
|
||||
// 而 URL(github.com/iOfficeAI/AionUi)、路径(~/projects/…)、域名
|
||||
// (dart-lang)等中间无空格的实体形态命中不了它(2026-10-14 实测:8 条
|
||||
// 20-50 字的仓库 URL/源码路径事实被误判为「弱内容」降权)。URL/路径是
|
||||
// 确定性的强实体信号,命中即视为有实体 → 不应走弱内容降权。
|
||||
func hasUrlishEntity(s string) bool {
|
||||
lower := strings.ToLower(s)
|
||||
urlHints := []string{"http://", "https://", "www.", ".com", ".io", ".org",
|
||||
"github.com", "github", "gitlab", "gitea", ".git",
|
||||
"~/", "/home/", "/var/lib/", "/usr/", "/etc/", "C:\\", "://"}
|
||||
for _, h := range urlHints {
|
||||
if strings.Contains(lower, h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RecallVerdict 召回侧确定性判据结论。
|
||||
type RecallVerdict int
|
||||
|
||||
const (
|
||||
RecallKeep RecallVerdict = iota // 确定性可用 → 保留原序
|
||||
RecallDemote // 确定性弱内容 → 降权(排到后面,不删)
|
||||
RecallDrop // 确定性不可用 → 过滤(不进 top-N)
|
||||
)
|
||||
|
||||
// RecallRuleVerdict 对一条召回候选内容做确定性判据。
|
||||
//
|
||||
// 判什么 / 为什么可靠(全部复用写入侧已检验的规则,零 LLM 推理):
|
||||
//
|
||||
// 1. 系统样板(textfilter.IsSystemTemplate)→ Drop
|
||||
// 判什么:cron 投递 / 网关包裹 / 上下文摘要等系统元信息,不是用户记忆。
|
||||
// 为什么可靠:结构化头子串匹配("[IMPORTANT:"、"[HEARTBEAT]"、"[System note:"…),
|
||||
// 纯数据匹配,写入侧准入闸门(admission_lane.go)已用同一份判定。
|
||||
//
|
||||
// 2. 纯疑问句(isPureQuestion)→ Drop
|
||||
// 判什么:整句是疑问(问号结尾 / 疑问前缀+语气词),不是可用的陈述性记忆。
|
||||
// 为什么可靠:结尾问号+前缀集合匹配,写入侧质量门槛(quality.go)第一道硬否定。
|
||||
//
|
||||
// 3. 状态汇报型/进程噪声(isStatusReportLike)→ Drop
|
||||
// 判什么:健康检查/重启完成/桥接等无主句进程噪声,不是用户事实。
|
||||
// 为什么可靠:无主语+状态短语/完成标记匹配,写入侧质量门槛第二道硬否定。
|
||||
//
|
||||
// 4. 过短碎片(<20 字,非人侧结构化前缀,无实体且无动词)→ Drop
|
||||
// 判什么:无信息的碎片(无主体、无动作、无实体),不该进 top-N。
|
||||
// 为什么可靠:长度+实体+动词短语三重确定性判定,与 quality.go IsQualityFact
|
||||
// 的 <20 字拒绝规则同源;人侧结构化前缀(画像:/心迹:/偏好:…)豁免(isSoulfulShortFact)。
|
||||
//
|
||||
// 5. 弱内容(20-50 字,无实体且无动词)→ Demote
|
||||
// 判什么:有长度但无实体无动词,信息量弱的陈述,降权但不删除。
|
||||
// 为什么可靠:20-50 字需含实体或动词短语,quality.go 同一条长度门槛,降为降权。
|
||||
//
|
||||
// 其余 → Keep(保留原序,不做任何改动)。
|
||||
func RecallRuleVerdict(content string) (RecallVerdict, string) {
|
||||
s := strings.TrimSpace(content)
|
||||
if s == "" {
|
||||
return RecallDrop, "空内容"
|
||||
}
|
||||
// 1. 系统样板
|
||||
if textfilter.IsSystemTemplate(s) {
|
||||
return RecallDrop, "系统样板"
|
||||
}
|
||||
// 2. 纯疑问句
|
||||
if isPureQuestion(s) {
|
||||
return RecallDrop, "纯疑问句"
|
||||
}
|
||||
// 3. 状态汇报/进程噪声
|
||||
if isStatusReportLike(s) {
|
||||
return RecallDrop, "状态汇报/进程噪声"
|
||||
}
|
||||
// 4/5. 长度 + 实体/动词 门槛
|
||||
n := utf8.RuneCountInString(s)
|
||||
if n < 20 {
|
||||
// 人侧结构化短事实(画像:/心迹:/偏好:/习惯:/目标: 等)豁免 → 保留
|
||||
if isSoulfulShortFact(s) {
|
||||
return RecallKeep, "人侧结构化短事实"
|
||||
}
|
||||
if !(hasEntity(s) || hasVerbPhrase(s)) {
|
||||
return RecallDrop, "过短碎片(<20字,无实体无动词)"
|
||||
}
|
||||
return RecallKeep, "短但含实体/动词"
|
||||
}
|
||||
if n <= 50 {
|
||||
// URL/路径/域名 = 强实体信号,不判弱内容降权(2026-10-14 实测修正)
|
||||
if hasUrlishEntity(s) {
|
||||
return RecallKeep, "含URL/路径实体"
|
||||
}
|
||||
if !(hasEntity(s) || hasVerbPhrase(s)) {
|
||||
return RecallDemote, "弱内容(20-50字,无实体无动词)"
|
||||
}
|
||||
}
|
||||
return RecallKeep, "通过"
|
||||
}
|
||||
|
||||
// FilterRecallByRule 对一批召回结果按确定性判据过滤/降权。
|
||||
// 返回 (keep, demoted, dropped) 三组 + 每组的 reason 明细。
|
||||
// env 门控由调用方决定(本函数零副作用、零 LLM)。
|
||||
func FilterRecallByRule[T any](items []T, content func(T) string) (kept, demoted, dropped []T, reasons map[RecallVerdict][]string) {
|
||||
kept = make([]T, 0, len(items))
|
||||
demoted = make([]T, 0, len(items))
|
||||
dropped = make([]T, 0, len(items))
|
||||
reasons = map[RecallVerdict][]string{}
|
||||
for _, it := range items {
|
||||
v, why := RecallRuleVerdict(content(it))
|
||||
reasons[v] = append(reasons[v], why)
|
||||
switch v {
|
||||
case RecallKeep:
|
||||
kept = append(kept, it)
|
||||
case RecallDemote:
|
||||
demoted = append(demoted, it)
|
||||
case RecallDrop:
|
||||
dropped = append(dropped, it)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRecallRuleVerdict 锁定召回侧确定性判据各分支。
|
||||
// 每条用例给「判什么」与期望 verdict,验证纯确定性(同输入恒同输出)。
|
||||
func TestRecallRuleVerdict(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
want RecallVerdict
|
||||
}{
|
||||
// ── Drop:确定性不可用 ──────────────────────────
|
||||
{"空内容", "", RecallDrop},
|
||||
{"系统样板(IMPORTANT头)", "[IMPORTANT: You are running as a scheduled cron job... 请按时完成", RecallDrop},
|
||||
{"系统样板(System note头)", "[System note: 这是上下文摘要,供参考]", RecallDrop},
|
||||
{"纯疑问句(问号结尾)", "为什么系统总是重启?", RecallDrop},
|
||||
{"纯疑问句(疑问前缀+吗)", "要不要升级系统吗", RecallDrop},
|
||||
{"状态汇报(无主句)", "健康检查通过,所有进程运行中", RecallDrop},
|
||||
// 有主句的状态句不是进程噪声("服务器"=主体)→ 含实体 → Keep
|
||||
{"状态汇报(有主体=实体)", "服务器重启完成", RecallKeep},
|
||||
{"过短碎片(无实体无动词)", "这是一个东西", RecallDrop},
|
||||
|
||||
// ── Demote:弱内容降权(20-50 字,无实体、无动词、无数字、无大写词)──
|
||||
{"弱内容(20-50字无实体无动词)", "这是一个比较长的没有任何实际意义的填充文字用于演示降权", RecallDemote},
|
||||
// URL/路径 = 强实体,不降权(2026-10-14 实测修正)
|
||||
{"含URL(20-50字)", "该仓库的URL为https://github.com/iOfficeAI/AionUi", RecallKeep},
|
||||
{"含路径(20-50字)", "仓库的本地源码位于 ~/projects/ai-skills-research/", RecallKeep},
|
||||
|
||||
// ── Keep:确定性可用,保留原序 ──────────────────
|
||||
{"人侧结构化短事实", "偏好: 喜欢喝咖啡", RecallKeep},
|
||||
{"短但含实体", "使用 Hermes 处理", RecallKeep},
|
||||
{"长陈述含实体", "牧尘决定把织忆的召回判据改为规则优先,默认关闭,零行为变化", RecallKeep},
|
||||
{"长陈述含动词", "小唯负责维护全部的记忆系统与看板", RecallKeep},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, reason := RecallRuleVerdict(c.content)
|
||||
if got != c.want {
|
||||
t.Errorf("%s: RecallRuleVerdict(%q)=%v(%s) 期望 %v", c.name, c.content, got, reason, c.want)
|
||||
} else {
|
||||
t.Logf("%s: %v (%s)", c.name, got, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallRuleVerdict_Pure 断言纯函数无副作用:重复调用同输入恒同输出。
|
||||
func TestRecallRuleVerdict_Pure(t *testing.T) {
|
||||
inputs := []string{"为什么重启?", "健康检查通过", "牧尘定了召回规则", "偏好: 咖啡"}
|
||||
for _, s := range inputs {
|
||||
v1, _ := RecallRuleVerdict(s)
|
||||
v2, _ := RecallRuleVerdict(s)
|
||||
if v1 != v2 {
|
||||
t.Fatalf("非纯函数: %q → %v vs %v", s, v1, v2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterRecallByRule 验证批处理三路分组正确。
|
||||
func TestFilterRecallByRule(t *testing.T) {
|
||||
items := []struct{ id, content string }{
|
||||
{"a", "偏好: 咖啡"},
|
||||
{"b", "为什么系统重启?"},
|
||||
{"c", "健康检查通过,所有进程运行中"},
|
||||
{"d", "牧尘决定把召回判据改为规则优先,默认关闭,零行为变化"},
|
||||
{"e", "这是一个东西"},
|
||||
}
|
||||
kept, demoted, dropped, _ := FilterRecallByRule(items, func(x struct{ id, content string }) string { return x.content })
|
||||
if len(kept) != 2 { // a, d
|
||||
t.Fatalf("kept 期望 2 得 %d (%v)", len(kept), kept)
|
||||
}
|
||||
if len(demoted) != 0 {
|
||||
t.Fatalf("demoted 期望 0 得 %d", len(demoted))
|
||||
}
|
||||
if len(dropped) != 3 { // b, c, e
|
||||
t.Fatalf("dropped 期望 3 得 %d", len(dropped))
|
||||
}
|
||||
t.Logf("kept=%d demoted=%d dropped=%d ✓", len(kept), len(demoted), len(dropped))
|
||||
}
|
||||
|
||||
// TestFilterRecallByRule_CleanPassthrough 全为可用记忆时:零过滤、零降权、顺序原样。
|
||||
// 这就是 env=0(关闭)时召回结果逐位一致的保证——干净候选永不因规则被改动。
|
||||
func TestFilterRecallByRule_CleanPassthrough(t *testing.T) {
|
||||
items := []struct{ id, content string }{
|
||||
{"a", "牧尘决定把召回判据改为规则优先,默认关闭,零行为变化"},
|
||||
{"b", "偏好: 咖啡"},
|
||||
{"c", "小唯负责维护全部的记忆系统与看板"},
|
||||
}
|
||||
kept, demoted, dropped, _ := FilterRecallByRule(items, func(x struct{ id, content string }) string { return x.content })
|
||||
if len(kept) != 3 || len(demoted) != 0 || len(dropped) != 0 {
|
||||
t.Fatalf("干净集被改动: kept=%d demoted=%d dropped=%d(期望 3/0/0)", len(kept), len(demoted), len(dropped))
|
||||
}
|
||||
for i := range kept {
|
||||
if kept[i].id != items[i].id {
|
||||
t.Fatalf("干净集顺序被重排: 第%d位 %s 期望 %s", i, kept[i].id, items[i].id)
|
||||
}
|
||||
}
|
||||
t.Logf("干净集逐位一致 ✓ (keep=%d, 顺序未动)", len(kept))
|
||||
}
|
||||
@@ -82,7 +82,12 @@ var (
|
||||
|
||||
func getScoreCache() *scoreCache {
|
||||
scoreCacheOnce.Do(func() {
|
||||
if scoreCacheDisabled {
|
||||
// 活读 env(而非只认包级变量):包级变量在进程 init 时定型,跨包测试
|
||||
// (如 routes)在 TestMain 里 Setenv 时已太迟 —— 让 TestMain 能真正关掉
|
||||
// 缓存(测试进程确定性:数 LLM 调用次数的断言不被 L2 命中短路)。
|
||||
// 生产语义不变:env 在进程生命周期内稳定,启动时的 ZHIYI_SCORE_CACHE=0
|
||||
// 依然在此处被读到。
|
||||
if scoreCacheDisabled || os.Getenv("ZHIYI_SCORE_CACHE") == "0" {
|
||||
log.Printf("[scorecache] 已禁用(环境变量 ZHIYI_SCORE_CACHE=0 或测试环境)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 织忆 MemoryWeave — 短事实 keep 档穿透(kanban t_ffcee190)
|
||||
//
|
||||
// 背景(t_1c420e43 终审量化):observe 档真实对话的自然短事实被 <20字/无实体动词
|
||||
// 长度启发式误杀(真实对话子集误杀率 ~62%);规则卡碎片整批丢弃是设计内(规则本体
|
||||
// 在 skill 零损失)。本卡给非规则卡 observe episode 上移 keep 档穿透(env 门控默认关)。
|
||||
//
|
||||
// 本文件锁定穿透语义(确定性,内联 fixture,不依赖 DB):
|
||||
// - AC2:默认关(env unset)时 effectiveFilterLane 对任何输入原样返回 lane ⇒ 行为与现状逐字一致。
|
||||
// - 规则卡:env 开也不穿透(effectiveFilterLane 仍返回原 lane)⇒ 规则卡碎片零泄漏。
|
||||
// - 非规则卡 observe:env 开 → keep 穿透,短自然事实救回(除 纯疑问/状态汇报 两条硬否定)。
|
||||
// - 短事实救援:env 开,<20 字无实体自然陈述被救回(旧判据 IsQualityFact 拒绝)。
|
||||
// 大规模 A/B(≥10000 条真实 episode OLD vs NEW 三张表)见卡交付报告,不落仓库。
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
func mustEqual[T comparable](t *testing.T, name string, got, want T) {
|
||||
t.Helper()
|
||||
if got != want {
|
||||
t.Fatalf("%s: got %v want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func keptCount(facts []string) int {
|
||||
return len(facts)
|
||||
}
|
||||
|
||||
// TestEffectiveFilterLane_DefaultOffEquivalence AC2:默认关 = 现状。
|
||||
// env 未设(shortFactKeepPassthrough=false)时,对真实对话/规则卡/任何 lane 都返回原 lane。
|
||||
func TestEffectiveFilterLane_DefaultOffEquivalence(t *testing.T) {
|
||||
shortFactKeepPassthrough = false
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
lane AdmissionLane
|
||||
}{
|
||||
{"真实对话自然短句-observe", "用户NAS侧三件事全部完成", LaneObserve},
|
||||
{"真实对话短句-keep", "决定采用单一采集口径", LaneKeep},
|
||||
{"规则卡-observe", "[规则:software-development/lazy-senior-dev|硬性规则] 不要引入可避免的新依赖", LaneObserve},
|
||||
{"结构化牵挂-keep", "牵挂: 待处理: 织忆图谱201个旧实体仍叫旧名", LaneKeep},
|
||||
{"状态汇报-observe", "服务已重启完成", LaneObserve},
|
||||
}
|
||||
for _, c := range cases {
|
||||
eff := effectiveFilterLane(c.content, c.lane)
|
||||
if eff != c.lane {
|
||||
t.Errorf("默认关应原样返回 lane: %s lane=%s eff=%s", c.name, c.lane, eff)
|
||||
}
|
||||
}
|
||||
t.Logf("AC2 默认关等价:%d 例全部 effectiveFilterLane==lane", len(cases))
|
||||
}
|
||||
|
||||
// TestEffectiveFilterLane_RuleCardNoPassthrough 规则卡:env 开也不穿透。
|
||||
// 注意:规则卡正文若含 决定/不要/必须 等决策线索词会被 ClassifyAdmission 判 keep
|
||||
// (t_2ae69bb2 起已穿透,与 env 无关);此处用无决策词的规则卡(真 observe)隔离验证。
|
||||
func TestEffectiveFilterLane_RuleCardNoPassthrough(t *testing.T) {
|
||||
shortFactKeepPassthrough = true
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
// 规则卡 observe(正文无决策/身份线索词)→ env 开仍返回 observe(不穿透)
|
||||
ruleContent := "[规则:gongwen-wenxi|输出规范] 正文采用仿宋体风格,单句不超过30字"
|
||||
lane, _ := ClassifyAdmission(ruleContent)
|
||||
mustEqual(t, "规则卡 lane", lane, LaneObserve)
|
||||
eff := effectiveFilterLane(ruleContent, lane)
|
||||
mustEqual(t, "规则卡 env 开不穿透", eff, LaneObserve)
|
||||
|
||||
// 非规则卡 observe → env 开穿透到 keep
|
||||
lane2, _ := ClassifyAdmission("用户NAS侧三件事全部完成")
|
||||
mustEqual(t, "非规则卡 lane", lane2, LaneObserve)
|
||||
eff2 := effectiveFilterLane("用户NAS侧三件事全部完成", lane2)
|
||||
mustEqual(t, "非规则卡 env 开穿透", eff2, LaneKeep)
|
||||
}
|
||||
|
||||
// TestShortFactKeep_NonRuleRescued env 开:非规则卡 observe 的短自然事实被救回。
|
||||
func TestShortFactKeep_NonRuleRescued(t *testing.T) {
|
||||
shortFactKeepPassthrough = true
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
// t_1c420e43 实测误杀短句:≥8字(非 LaneDrop)、<20 字、无强实体/动词,旧 IsQualityFact 拒绝。
|
||||
miskilled := []string{
|
||||
"mc-sync 保持仅含 NAS 自身",
|
||||
"用户NAS侧三件事全部完成",
|
||||
"定位候选池三层叠压",
|
||||
"用户喜欢苹果手机",
|
||||
}
|
||||
for _, f := range miskilled {
|
||||
lane, _ := ClassifyAdmission(f)
|
||||
mustEqual(t, "误杀短句应落 observe(非 drop/keep)", lane, LaneObserve)
|
||||
// 对照锚:旧判据确实拒绝这些短句(证明本测试有区分度)
|
||||
if IsQualityFact(f) {
|
||||
t.Errorf("对照锚失效:IsQualityFact 放行了 <20 字短句 %q,本测试无区分度", f)
|
||||
continue
|
||||
}
|
||||
newF := FilterQualityFactsWithLane([]string{f}, effectiveFilterLane(f, lane))
|
||||
if len(newF) != 1 {
|
||||
t.Errorf("env 开应救回 %q(got %v)", f, newF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortFactKeep_QuestionStatusStillRejected 穿透下硬否定仍生效。
|
||||
func TestShortFactKeep_QuestionStatusStillRejected(t *testing.T) {
|
||||
shortFactKeepPassthrough = true
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
neg := []string{
|
||||
"这个闸门到底应该怎么定阈值才合理呢?", // 纯疑问
|
||||
"服务已重启完成,当前版本 v0.21.6。", // 状态汇报
|
||||
"健康检查完成",
|
||||
}
|
||||
for _, f := range neg {
|
||||
lane, _ := ClassifyAdmission(f)
|
||||
newF := FilterQualityFactsWithLane([]string{f}, effectiveFilterLane(f, lane))
|
||||
if len(newF) != 0 {
|
||||
t.Errorf("穿透下仍应拒绝 %q(got %v)", f, newF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortFactKeep_RuleCardFragmentStillDropped env 开:规则卡短碎片仍被丢(零泄漏)。
|
||||
// 用无决策词的规则卡(真 observe)验证:env 开下规则卡碎片仍被拒,同短非规则陈述被救回。
|
||||
func TestShortFactKeep_RuleCardFragmentStillDropped(t *testing.T) {
|
||||
shortFactKeepPassthrough = true
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
// 规则卡正文里的短规则碎片(无实体/动词)—— 规则本体在 skill,碎片不进库是设计内。
|
||||
ruleContent := "[规则:gongwen-wenxi|输出规范] 数据前置量化优先,结果在前措施在后"
|
||||
ruleFragment := "数据前置量化优先"
|
||||
lane, _ := ClassifyAdmission(ruleContent)
|
||||
mustEqual(t, "规则卡 lane", lane, LaneObserve)
|
||||
newF := FilterQualityFactsWithLane([]string{ruleFragment}, effectiveFilterLane(ruleContent, lane))
|
||||
if len(newF) != 0 {
|
||||
t.Errorf("规则卡短碎片不应泄漏进库(got %v)", newF)
|
||||
}
|
||||
|
||||
// 正对照:同样短的非规则自然陈述被救回(证明是规则卡身份在拦,不是长度)
|
||||
lane2, _ := ClassifyAdmission("用户喜欢苹果手机")
|
||||
newF2 := FilterQualityFactsWithLane([]string{"用户喜欢苹果手机"}, effectiveFilterLane("用户喜欢苹果手机", lane2))
|
||||
if len(newF2) != 1 {
|
||||
t.Errorf("非规则卡同短陈述应救回(got %v)", newF2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortFactKeep_DefaultOffDelegates AC2 补充:默认关时非规则卡 observe 行为=现状(短句仍被拒)。
|
||||
func TestShortFactKeep_DefaultOffDelegates(t *testing.T) {
|
||||
shortFactKeepPassthrough = false
|
||||
defer func() { shortFactKeepPassthrough = false }()
|
||||
|
||||
f := "用户NAS侧三件事全部完成"
|
||||
lane, _ := ClassifyAdmission(f)
|
||||
eff := effectiveFilterLane(f, lane)
|
||||
mustEqual(t, "默认关 eff=lane", eff, lane)
|
||||
got := FilterQualityFactsWithLane([]string{f}, eff)
|
||||
// 默认关 = 现状:observe 委托 FilterQualityFacts → <20 字无实体短句被拒
|
||||
if keptCount(got) != 0 {
|
||||
t.Errorf("默认关应保持现状(拒绝 %q),got %v", f, got)
|
||||
}
|
||||
t.Logf("AC2 默认关:非规则卡 observe 短句仍被拒(=现状)")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package distill
|
||||
|
||||
import "testing"
|
||||
|
||||
// t_286d1310: 写库闸门(server.go OnDistillComplete)此前独立跑 IsQualityFact
|
||||
// 长度启发式——lane=keep 的决策类内容,其 <20 字碎片在 insert 前被第三次独立
|
||||
// 丢弃(t_2ae69bb2 只穿透到 fact 级,没穿透到写库级),DB 里 derived_from=<episode>
|
||||
// 0 行。server.go 已改为复用 engine.go 同一函数 FilterQualityFactsWithLane,并在
|
||||
// 调用前用 ClassifyAdmission 重算 lane(确定性分诊,与 engine.go:537 同一 lane)。
|
||||
//
|
||||
// 本文件把 server.go 的写库判据【原样复现】成测试:ClassifyAdmission(input.Content)
|
||||
// → FilterQualityFactsWithLane(result.Facts, lane),证明决策碎片经写库判据后保留、
|
||||
// 非 keep lane 行为与旧判据逐字一致。
|
||||
//
|
||||
// 刻意不碰:admission_lane.go 判据、isSoulfulShortFact 白名单、IsQualityFact 阈值。
|
||||
// 与 quality_lane_test.go 的区别:那组直接把 lane 喂给 FilterQualityFactsWithLane;
|
||||
// 本组是「从 content 重算 lane 再过滤」的完整写库路径复现(同 server.go)。
|
||||
|
||||
// TestWriteGate_KeepDecisionFragmentsSurvive AC1:
|
||||
// LaneKeep 的 <20 字决定碎片经写库判据后保留(≥3 例,每例含对照锚证明旧判据确实全拒)。
|
||||
func TestWriteGate_KeepDecisionFragmentsSurvive(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
fragments []string
|
||||
}{
|
||||
{
|
||||
"决定录入格式规范(原文复现)",
|
||||
"【决定】决定录入格式规范:一条只写一个决定,路径与日期不混进正文,避免蒸馏碎片化后宽泛问法召回不到。",
|
||||
[]string{"路径与日期不混进正文", "一条只写一个决定"},
|
||||
},
|
||||
{
|
||||
"决策承诺(测试词表同款)",
|
||||
"牧尘决定以后都用分身C来处理采集类任务。",
|
||||
[]string{"采集任务交给分身C"},
|
||||
},
|
||||
{
|
||||
"自动化边界决定(同批原文复现)",
|
||||
"【决定】决定类记忆回流不挂 cron:由小怡在每次拍板后主动调用脚本,先跑顺一两周。",
|
||||
[]string{"回流不挂cron", "拍板后主动调用"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// server.go 写库判据的精确复现:先重算 lane
|
||||
lane, why := ClassifyAdmission(tc.content)
|
||||
if lane != LaneKeep {
|
||||
t.Fatalf("前提失败: ClassifyAdmission 未判 keep (lane=%s, %s), content=%.60s", lane, why, tc.content)
|
||||
}
|
||||
// 再走 FilterQualityFactsWithLane(server.go 现在用的同一函数)
|
||||
got := FilterQualityFactsWithLane(tc.fragments, lane)
|
||||
if len(got) != len(tc.fragments) {
|
||||
t.Fatalf("写库判据丢失决策碎片: got %d/%d %v", len(got), len(tc.fragments), got)
|
||||
}
|
||||
// 对照锚:旧写库判据(IsQualityFact)确实全拒这批 <20 字碎片 —— 证明本测试
|
||||
// 测的就是行为差(server.go 改前的 write-gate 会丢掉全部这些碎片)
|
||||
for _, f := range tc.fragments {
|
||||
if IsQualityFact(f) {
|
||||
t.Fatalf("对照锚失效: 旧判据 IsQualityFact 放行了 <20 字碎片 %q,本测试无区分度", f)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteGate_KeepStillRejectsNegatives AC1 边界:
|
||||
// LaneKeep 下两条硬否定(纯疑问 / 状态汇报型)仍被写库判据拒绝(任何长度)。
|
||||
func TestWriteGate_KeepStillRejectsNegatives(t *testing.T) {
|
||||
content := "牧尘决定以后都用分身C来处理采集类任务。"
|
||||
lane, why := ClassifyAdmission(content)
|
||||
if lane != LaneKeep {
|
||||
t.Fatalf("前提失败: lane=%s (%s)", lane, why)
|
||||
}
|
||||
negatives := []string{
|
||||
"为什么采集类任务要统一交给分身C来执行处理?", // 纯疑问
|
||||
"健康检查完成,所有服务运行正常,无异常", // 状态汇报
|
||||
}
|
||||
for _, f := range negatives {
|
||||
if got := FilterQualityFactsWithLane([]string{f}, lane); len(got) != 0 {
|
||||
t.Fatalf("LaneKeep 下写库判据不应放行 %q: %v", f, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteGate_NonKeepDelegates AC2:
|
||||
// LaneDrop / LaneObserve 的写库判据与旧判据 FilterQualityFacts 逐字一致(负例 ≥2 条)。
|
||||
func TestWriteGate_NonKeepDelegates(t *testing.T) {
|
||||
dropLane, _ := ClassifyAdmission("好的") // <8 字 → 确定性 drop
|
||||
if dropLane != LaneDrop {
|
||||
t.Fatalf("前提失败: 期望 drop, got %s", dropLane)
|
||||
}
|
||||
obsLane, _ := ClassifyAdmission("服务已重启完成,当前版本 v0.21.6。") // 不确定带 → observe
|
||||
if obsLane != LaneObserve {
|
||||
t.Fatalf("前提失败: 期望 observe, got %s", obsLane)
|
||||
}
|
||||
// 负例:两种 lane 下都应被旧判据拒绝的碎片/噪声(≥2 条,且与 quality_lane_test.go
|
||||
// 已有用例不重复,作为独立新增负例)
|
||||
in := []string{
|
||||
"重启系统", // <20 字无前缀碎片
|
||||
"为什么系统会重启", // 纯疑问(无问号但疑问前缀+短句)
|
||||
}
|
||||
for lane, name := range map[AdmissionLane]string{dropLane: "LaneDrop", obsLane: "LaneObserve"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
want := FilterQualityFacts(in)
|
||||
got := FilterQualityFactsWithLane(in, lane)
|
||||
if len(want) != len(got) {
|
||||
t.Fatalf("非 keep lane 行为漂移: got %d want %d (%v vs %v)", len(got), len(want), got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("非 keep lane 行为漂移: got[%d]=%q want=%q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("对照锚异常: 负例本应全拒, FilterQualityFacts 结果 = %v", want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// 织忆 MemoryWeave — 写时四操作 + 写前哈希去重(t_3e98b65a,血统 Mem0 + MemoryFabric-v2 §3.1 Layer3)
|
||||
//
|
||||
// 背景(2026-10-14):遗忘机制研究方案B「写时四操作」悬置至今。现状痛点:
|
||||
// · 无写前去重:731 条完全重复靠事后 /admin/dedup 清,新重复持续进(源头没堵)
|
||||
// · 写时矛盾只 warning 不自动处理 → 矛盾记忆堆积(Mem0 issue #4956 失败模式:
|
||||
// 「3个月前辞职」和「现在新工作」同时被检索 → 模型答错)
|
||||
//
|
||||
// 设计源:MemoryFabric-v2-自优化设计方案.md §3.1 Layer3(dedup_or_upgrade:
|
||||
// sim>0.92 → ignore/upgrade;contradictory → flag conflict)
|
||||
//
|
||||
// 本文件实现两个写侧门:
|
||||
// 1) 写前哈希去重:commit/写入前对【归一化】内容取 hash,完全重复 → 拒绝写入 + 记 log
|
||||
// 2) 写时四操作:蒸馏产出候选事实后,对照 top-N 相似现有记忆判定:
|
||||
// ADD 无语义等价 → 新建
|
||||
// UPDATE 互补/更新 → 并入现有,旧条标 superseded_by(历史保留可追溯,不删)
|
||||
// NOOP 无新增价值 → 丢弃(记 shadow log)
|
||||
// CONFLICT-MARK 矛盾 → 双方标记 conflict,交人工(DELETE 保守:永不自动删除)
|
||||
//
|
||||
// ── 门控(默认 shadow ⇒ 零行为变化,硬要求)──────────────────────────
|
||||
// 门控**只决定标记动作**(supersede/conflict),**不得拦基础 InsertMemory**
|
||||
// (基础插入是 pre-feature 基线行为,必须无条件执行;2026-10-15 t_a0831457 P0
|
||||
// 教训:旧实现让 shadow/off 恒不插入 ⇒ 10-14 起蒸馏事实全丢 2120 条)。
|
||||
// 三模式语义表:
|
||||
// · shadow(默认) ⇒ 插入新条 ✓ + 不做任何标记 ✓ + 记 shadow log ✓
|
||||
// (= 注释里"零行为变化"的**字面**含义:等价于特性不存在)
|
||||
// · apply ⇒ 插入新条 ✓ + 做标记 ✓(NOOP 丢弃;UPDATE 标 supersede;
|
||||
// CONFLICT-MARK 标 conflict)
|
||||
// · off ⇒ 插入新条 ✓ + 不跑判定 ✓(零开销)
|
||||
//
|
||||
// env(t_a0831457 AC10 改名,旧名兼容一档 + 弃用日志):
|
||||
// ZHIYI_WRITE_SEMANTIC_OPS=shadow(默认)/apply/off
|
||||
// 旧名 ZHIYI_WRITE_OPS 仍认(新名未设时兜底),日志提示弃用。
|
||||
//
|
||||
// 影子观察 48h → 出四操作判定分布 → 转 apply 走生产变更纪律
|
||||
// (停→备份→改→启→验,db 备份走门禁)。
|
||||
//
|
||||
// 设计约束:
|
||||
// · 零新依赖(只用 stdlib + internal/models)
|
||||
// · 判定函数可注入(JudgeFunc),单测用确定性假实现,不依赖真实 LLM
|
||||
// · 不碰 state.db:只写独立影子日志文件,不改存储常驻结构
|
||||
package distill
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// WriteOp 写时四操作
|
||||
type WriteOp string
|
||||
|
||||
const (
|
||||
OpAdd WriteOp = "ADD" // 无语义等价 → 新建
|
||||
OpUpdate WriteOp = "UPDATE" // 互补/更新 → 并入现有(supersede)
|
||||
OpNoop WriteOp = "NOOP" // 无新增价值 → 丢弃
|
||||
OpConflictMark WriteOp = "CONFLICT-MARK" // 矛盾 → 双方标记 conflict,交人工
|
||||
)
|
||||
|
||||
// WriteVerdict 单条候选事实的四操作判定结果
|
||||
type WriteVerdict struct {
|
||||
Op WriteOp `json:"op"`
|
||||
Reason string `json:"reason"`
|
||||
Candidate string `json:"candidate"`
|
||||
// TargetID 命中被取代/冲突的现有记忆 ID(ADD 为空)
|
||||
TargetID string `json:"target_id,omitempty"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// SimilarMemory 候选事实的相似现有记忆(供判定对照)
|
||||
type SimilarMemory struct {
|
||||
ID string
|
||||
Content string
|
||||
Similarity float64
|
||||
}
|
||||
|
||||
// cosineSim 计算两个向量的余弦相似度(局部实现,避免跨包依赖)。
|
||||
func cosineSim(a, b []float32) float64 {
|
||||
var dot, na, nb float64
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
na += float64(a[i]) * float64(a[i])
|
||||
nb += float64(b[i]) * float64(b[i])
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
|
||||
// SimilarMemoryList 相似记忆列表(含按相似度排序 + 取 top-N)
|
||||
type SimilarMemoryList []SimilarMemory
|
||||
|
||||
func (s SimilarMemoryList) Len() int { return len(s) }
|
||||
func (s SimilarMemoryList) Less(i, j int) bool { return s[i].Similarity > s[j].Similarity }
|
||||
func (s SimilarMemoryList) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||
|
||||
// TopN 返回相似度最高的前 n 条(稳定排序后截断)
|
||||
func (s SimilarMemoryList) TopN(n int) SimilarMemoryList {
|
||||
cp := make(SimilarMemoryList, len(s))
|
||||
copy(cp, s)
|
||||
sort.Sort(cp)
|
||||
if n > len(cp) {
|
||||
n = len(cp)
|
||||
}
|
||||
return cp[:n]
|
||||
}
|
||||
|
||||
// FromMemoryRecords 把召回结果转成 SimilarMemoryList(用余弦相似度,需要向量)
|
||||
func FromMemoryRecords(recs []models.MemoryRecord, queryVec []float32) SimilarMemoryList {
|
||||
out := make(SimilarMemoryList, 0, len(recs))
|
||||
for _, r := range recs {
|
||||
sim := 0.0
|
||||
if queryVec != nil && len(r.Vector) == len(queryVec) {
|
||||
sim = cosineSim(queryVec, r.Vector)
|
||||
}
|
||||
out = append(out, SimilarMemory{ID: r.ID, Content: r.Content, Similarity: sim})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JudgeFunc 判定函数签名。生产走 LLM(JudgementJudge);单测注入确定性假实现。
|
||||
type JudgeFunc func(candidate string, similar []SimilarMemory) WriteVerdict
|
||||
|
||||
// writeOpsMode 读取写时四操作模式(shadow 默认 / apply / off)。
|
||||
// t_a0831457 AC10:env 改名 ZHIYI_WRITE_OPS → ZHIYI_WRITE_SEMANTIC_OPS(明确只控
|
||||
// 语义增量操作,防基础写功能被误挂进此门控)。旧名保留兼容一档:新名未设时
|
||||
// 回退旧名,且首次读到旧名时打一条弃用日志(只打一次,不刷)。
|
||||
var writeOpsDeprecationOnce sync.Once
|
||||
|
||||
func writeOpsMode() string {
|
||||
switch v := os.Getenv("ZHIYI_WRITE_SEMANTIC_OPS"); v {
|
||||
case "apply", "off":
|
||||
return v
|
||||
case "":
|
||||
// 新名未设 → 旧名兜底(兼容一档)
|
||||
old := os.Getenv("ZHIYI_WRITE_OPS")
|
||||
if old != "" {
|
||||
writeOpsDeprecationOnce.Do(func() {
|
||||
log.Printf("[write-ops] DEPRECATED: env ZHIYI_WRITE_OPS 已弃用,请改用 ZHIYI_WRITE_SEMANTIC_OPS(当前值=%s,语义不变,下版本移除旧名)", old)
|
||||
})
|
||||
}
|
||||
switch old {
|
||||
case "apply", "off":
|
||||
return old
|
||||
default:
|
||||
return "shadow"
|
||||
}
|
||||
default:
|
||||
// 非 apply/off 的值(含拼写错误)→ 默认 shadow(安全默认,零行为变化)
|
||||
return "shadow"
|
||||
}
|
||||
}
|
||||
|
||||
// writeOpsEnabled 是否跑判定(off=完全不跑)
|
||||
func writeOpsEnabled() bool {
|
||||
return writeOpsMode() != "off"
|
||||
}
|
||||
|
||||
// WriteOpsEnabled 导出版:供 server.go 等外部包在写路径入口判断是否开启。
|
||||
func WriteOpsEnabled() bool {
|
||||
return writeOpsEnabled()
|
||||
}
|
||||
|
||||
// WriteOpsMode 导出版:返回当前模式(shadow/apply/off),供诊断/测试断言。
|
||||
func WriteOpsMode() string {
|
||||
return writeOpsMode()
|
||||
}
|
||||
|
||||
// writeOpsApply 是否真改库(apply 模式)。
|
||||
//
|
||||
// 2026-10-25 硬保护:apply **强制要求 LLM 判定**。理由(离线回放实测,非推测):
|
||||
// 规则基 defaultJudge 以 ZHIYI_WRITE_SIM_UPDATE=0.70 判 UPDATE,17179 条影子日志里
|
||||
// 84%(14435 条)被判「应覆盖旧事实」;人工抽检低相似度样本 15/15 不是真覆盖
|
||||
// (「用户查看知乎链接」vs「用户查看微信文章」都被判成覆盖)。放行 apply 会把上万条
|
||||
// 记忆错误标记 superseded_by —— 规模是 2026-09-07 误删事故(67 条)的 200 倍。
|
||||
// 因此未设 ZHIYI_WRITE_JUDGE=llm 时**降级为 shadow 并留痕**,不静默执行。
|
||||
func writeOpsApply() bool {
|
||||
if writeOpsMode() != "apply" {
|
||||
return false
|
||||
}
|
||||
if LLMJudgeMode() != "llm" {
|
||||
log.Printf("[write-ops] 拒绝 apply:ZHIYI_WRITE_JUDGE != llm(规则基判定精度实测≈0%%,会误覆盖上万条)→ 本次按 shadow 处理")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeOpsLogPath 影子日志路径(env 可覆盖)
|
||||
func writeOpsLogPath() string {
|
||||
if p := os.Getenv("ZHIYI_WRITE_OPS_LOG"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/var/lib/memoryweave/write-ops-shadow.log"
|
||||
}
|
||||
|
||||
var writeOpsLogMu sync.Mutex
|
||||
|
||||
// appendWriteOpsLog 追加一条判定记录到影子日志(JSONL)。
|
||||
// 幂等:写失败只 log 不 panic;日志文件不存在自动创建。
|
||||
func appendWriteOpsLog(v WriteVerdict) {
|
||||
line, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
log.Printf("[write-ops] shadow log marshal failed: %v", err)
|
||||
return
|
||||
}
|
||||
writeOpsLogMu.Lock()
|
||||
defer writeOpsLogMu.Unlock()
|
||||
f, err := os.OpenFile(writeOpsLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
log.Printf("[write-ops] shadow log open failed (%s): %v", writeOpsLogPath(), err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
log.Printf("[write-ops] shadow log write failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 归一化 + 哈希去重 ─────────────────────────────────────────
|
||||
|
||||
// NormalizeContent 归一化内容:去首尾空白、折叠连续空白、去句尾标点。
|
||||
// 用于哈希去重的「完全重复」判定 —— 语义相同但排版不同(多句号/多空格)判为重复。
|
||||
func NormalizeContent(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
// 折叠连续空白(空格/Tab/换行)为单个空格
|
||||
fields := strings.Fields(s)
|
||||
s = strings.Join(fields, " ")
|
||||
// 去句尾标点(。.!!??,,;;::)—— "工作。" 与 "工作" 视为同一事实
|
||||
s = strings.TrimRight(s, "。.!!??,,;;::")
|
||||
return s
|
||||
}
|
||||
|
||||
// ContentHash 对归一化内容取 MD5 前 12 字符(与 models.ContentMD5 同口径,精确保存去重)。
|
||||
func ContentHash(s string) string {
|
||||
sum := md5.Sum([]byte(NormalizeContent(s)))
|
||||
return hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
// WriteOpHashDedup 写前哈希去重:候选内容归一化取 hash,与存量记忆的
|
||||
// content_md5(或归一化后 hash)比对,完全重复 → 返回命中记忆(拒绝写入)。
|
||||
// 返回 (dupID, true) 表示重复;nil 表示无重复可写入。
|
||||
// existing 是候选内容编码后的相似召回(内存里做哈希比对,不额外编码)。
|
||||
func WriteOpHashDedup(candidate string, existing []models.MemoryRecord) (string, bool) {
|
||||
h := ContentHash(candidate)
|
||||
for _, m := range existing {
|
||||
if m.ContentMD5 != "" && m.ContentMD5 == h {
|
||||
return m.ID, true
|
||||
}
|
||||
// 存量可能没存 ContentMD5 → 归一化后直接比(兜底)
|
||||
if NormalizeContent(m.Content) == NormalizeContent(candidate) {
|
||||
return m.ID, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ─── 判定(规则基 + LLM 可选)────────────────────────────────────
|
||||
|
||||
// RuleThresholds 规则判定的阈值(MemoryFabric-v2 §3.1 Layer3 血统,env 可调)
|
||||
var (
|
||||
// ruleSimNoop:相似度 ≥ 此值且语义等价 → NOOP(无新增价值)
|
||||
ruleSimNoop = envFloat("ZHIYI_WRITE_SIM_NOOP", 0.95)
|
||||
// ruleSimUpdate:相似度在此区间且互补 → UPDATE(supersede)
|
||||
ruleSimUpdate = envFloat("ZHIYI_WRITE_SIM_UPDATE", 0.70)
|
||||
// ruleSimConflict:低于此值视为语义无关(不可能矛盾,走 ADD)
|
||||
ruleSimConflictFloor = envFloat("ZHIYI_WRITE_SIM_CONFLICT_FLOOR", 0.45)
|
||||
)
|
||||
|
||||
// ContradictionFunc 矛盾检测函数(可注入:默认用 ruleContradiction 启发式;
|
||||
// 生产可接 governance.IsContradiction / ConflictDetector.Scan)。
|
||||
type ContradictionFunc func(candidate string, similar SimilarMemory) bool
|
||||
|
||||
// ruleContradiction 规则基矛盾启发式:候选与相似记忆存在显式否定对立
|
||||
// (如 "在招商局" vs "不在招商局" / "离职" vs "入职" / true vs false)。
|
||||
// 保守:判不出明确对立 → 返回 false(宁可 UPDATE 也不误判 CONFLICT)。
|
||||
// 注意:只判「同一事实域的对立词」,不判无关事实 —— 无关性由调用方
|
||||
// 先按相似度 ≥ ruleSimConflictFloor 的门槛排除(不会拿无关记忆进来比)。
|
||||
func ruleContradiction(candidate string, s SimilarMemory) bool {
|
||||
negPairs := [][2]string{
|
||||
{"不在", "在"}, {"没", "有"}, {"离职", "入职"}, {"辞职", "就职"},
|
||||
{"false", "true"}, {"False", "True"}, {"FALSE", "TRUE"},
|
||||
{"否", "是"}, {"关", "开"}, {"停", "启"}, {"结束", "开始"},
|
||||
{"离开", "加入"}, {"卖出", "买入"}, {"减", "增"}, {"退出", "进入"},
|
||||
}
|
||||
for _, pair := range negPairs {
|
||||
// 候选含否定词、存量含肯定词(或反之)→ 对立
|
||||
if strings.Contains(candidate, pair[0]) && strings.Contains(s.Content, pair[1]) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(candidate, pair[1]) && strings.Contains(s.Content, pair[0]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultJudge 规则基判定(无 LLM 兜底,可测可跑)。比对 top-1 最相似记忆:
|
||||
// · 相似度 ≥ ruleSimNoop 且归一化等价 → NOOP
|
||||
// · 矛盾 → CONFLICT-MARK
|
||||
// · 相似度 ≥ ruleSimUpdate(互补/更新)→ UPDATE(supersede 最相似那条)
|
||||
// · 否则 → ADD
|
||||
func defaultJudge(candidate string, similar []SimilarMemory, contradict ContradictionFunc) WriteVerdict {
|
||||
v := WriteVerdict{
|
||||
Op: OpAdd,
|
||||
Candidate: candidate,
|
||||
Reason: "无语义等价 → 新建",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
if len(similar) == 0 {
|
||||
return v
|
||||
}
|
||||
top := similar[0] // 调用方已按相似度排序(TopN)
|
||||
v.Similarity = top.Similarity
|
||||
// 1) 完全重复(归一化等价)→ NOOP
|
||||
if NormalizeContent(top.Content) == NormalizeContent(candidate) {
|
||||
v.Op = OpNoop
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "语义等价/完全重复 → 无新增价值,丢弃"
|
||||
return v
|
||||
}
|
||||
// 2) 矛盾 → CONFLICT-MARK(共享锚 + 显式否定对立)
|
||||
if contradict != nil && contradict(candidate, top) && top.Similarity >= ruleSimConflictFloor {
|
||||
v.Op = OpConflictMark
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "与现有记忆矛盾 → 双方标记 conflict,交人工"
|
||||
return v
|
||||
}
|
||||
// 3) 高相似 + 互补 → UPDATE(supersede)
|
||||
if top.Similarity >= ruleSimUpdate {
|
||||
v.Op = OpUpdate
|
||||
v.TargetID = top.ID
|
||||
v.Reason = "互补/更新 → 并入现有,旧条标 superseded_by"
|
||||
return v
|
||||
}
|
||||
// 4) 相似度不足 → ADD
|
||||
return v
|
||||
}
|
||||
|
||||
// JudgeWriteOp 写时四操作判定入口。mode=shadow 只记日志不改库;
|
||||
// mode=apply 返回 verdict 供调用方 apply。
|
||||
// judge 为 nil 时用规则基 defaultJudge;contradict 为 nil 时用 ruleContradiction。
|
||||
// 返回 verdict;若完全关闭(off)返回 nil。
|
||||
func JudgeWriteOp(candidate string, similar []SimilarMemory, judge JudgeFunc, contradict ContradictionFunc) *WriteVerdict {
|
||||
if !writeOpsEnabled() {
|
||||
return nil
|
||||
}
|
||||
var v WriteVerdict
|
||||
if judge != nil {
|
||||
v = judge(candidate, similar)
|
||||
} else if writeOpsApply() || LLMJudgeMode() == "llm" {
|
||||
// 2026-10-25:LLM 三分类判定。规则基 defaultJudge 的 UPDATE 精度实测约 0%
|
||||
// (17179 条影子日志 84% 判 UPDATE,人工抽检 15/15 误判)⇒ apply 强制 llm;
|
||||
// shadow 下也可用 ZHIYI_WRITE_JUDGE=llm 先观察新判定质量。judge 显式注入时优先注入。
|
||||
v = LLMJudge(candidate, similar)
|
||||
} else {
|
||||
if contradict == nil {
|
||||
contradict = ruleContradiction
|
||||
}
|
||||
v = defaultJudge(candidate, similar, contradict)
|
||||
}
|
||||
if v.Timestamp == "" {
|
||||
v.Timestamp = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
// 影子日志:always 记(shadow 和 apply 都记,供 48h 分布统计)
|
||||
appendWriteOpsLog(v)
|
||||
if !writeOpsApply() {
|
||||
log.Printf("[write-ops][shadow] op=%s target=%s sim=%.3f reason=%s candidate=%.40s",
|
||||
v.Op, v.TargetID, v.Similarity, v.Reason, candidate)
|
||||
return &v
|
||||
}
|
||||
log.Printf("[write-ops][apply] op=%s target=%s sim=%.3f reason=%s candidate=%.40s",
|
||||
v.Op, v.TargetID, v.Similarity, v.Reason, candidate)
|
||||
return &v
|
||||
}
|
||||
|
||||
// WriteOpsStore 写操作要触碰的存储接口(apply 模式用;便于单测注入内存实现)。
|
||||
type WriteOpsStore interface {
|
||||
// MarkSuperseded 把旧记忆标记为「被 targetNewID 取代」(写 superseded_by + is_active=false)
|
||||
MarkSuperseded(oldID, targetNewID string) error
|
||||
// MarkConflict 把记忆标记为冲突(交人工;不删)
|
||||
MarkConflict(id string) error
|
||||
}
|
||||
|
||||
// ApplyWriteOp 应用四操作判定到存储。返回是否应插入新记忆 + 错误。
|
||||
//
|
||||
// t_a0831457 P0 解耦修复:门控**只决定标记动作**(supersede/conflict),
|
||||
// **不得拦基础 InsertMemory** —— 基础插入是 pre-feature 基线行为("特性不存在
|
||||
// 之前"插入总是发生),"零行为变化"的硬要求 ⇒ 默认态(shadow)必须等价于
|
||||
// 特性不存在。旧实现在 shadow/off 恒返回 shouldInsert=false,调用点据此
|
||||
// `continue` 跳过基础插入 ⇒ 2026-10-14 起每日蒸馏事实 2053/1094/1559 → 384 → 0
|
||||
// 全丢(2120 条判定被弃),属实现违背自身注释声明的 bug。
|
||||
//
|
||||
// 三模式语义表(与文件头部门控节一致):
|
||||
// · shadow(默认)⇒ 本函数对所有判定返回 shouldInsert=true:插入新条 ✓
|
||||
// + 不做任何标记(MarkSuperseded/MarkConflict 零调用)✓ + 判定已由
|
||||
// JudgeWriteOp 记入 shadow log ✓
|
||||
// · off ⇒ 不跑判定(JudgeWriteOp 返回 nil,调用点不进本函数);
|
||||
// 即使误调本函数也返回 true(基础插入照跑,零开销)
|
||||
// · apply ⇒ 按判定执行标记 + 插入:
|
||||
// · ADD → 插入新记忆(返回 true)
|
||||
// · UPDATE → 旧条标 superseded_by(历史保留)+ 插入新记忆(新条 active)
|
||||
// · NOOP → 不插入(丢弃,特性的预期去重行为)
|
||||
// · CONFLICT-MARK→ 旧条标 conflict + 新记忆供人工复核,仍插入(不静默丢)
|
||||
// 返回 shouldInsert:true = 调用方应继续 InsertMemory;false = 丢弃
|
||||
// (**只有 apply + NOOP / 标记失败** 才返回 false)。
|
||||
func ApplyWriteOp(v WriteVerdict, store WriteOpsStore, newID string) (shouldInsert bool, err error) {
|
||||
if !writeOpsApply() {
|
||||
// shadow/off:门控不参与插入决策、不做任何标记 —— 基础插入无条件执行。
|
||||
return true, nil
|
||||
}
|
||||
if v.Op == OpNoop {
|
||||
return false, nil
|
||||
}
|
||||
switch v.Op {
|
||||
case OpAdd:
|
||||
return true, nil
|
||||
case OpUpdate:
|
||||
if v.TargetID != "" && store != nil {
|
||||
if err := store.MarkSuperseded(v.TargetID, newID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
case OpConflictMark:
|
||||
// 旧条标 conflict + 新记忆也标 conflict(由调用方在 mem 上打标),仍插入供人工复核
|
||||
if v.TargetID != "" && store != nil {
|
||||
if err := store.MarkConflict(v.TargetID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// dedupNote 给 commit/distill 写路径用的统一去重提示(日志 + 响应可读)
|
||||
func dedupNote(dupID, candidate string) string {
|
||||
return fmt.Sprintf("write-ops dedup: 完全重复命中 %s,拒绝写入(candidate=%.40s)", dupID, candidate)
|
||||
}
|
||||
|
||||
// DedupNote 导出版(供 server.go 日志使用)。
|
||||
func DedupNote(dupID, candidate string) string {
|
||||
return dedupNote(dupID, candidate)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// 织忆 MemoryWeave — 实验门控不得劫持基础插入(t_a0831457 P0 解耦修复的回归测试)
|
||||
//
|
||||
// 背景:写时四操作门控默认 shadow,旧实现让 ApplyWriteOp 在 shadow/off 恒返回
|
||||
// shouldInsert=false ⇒ 调用点 server.go 的 `if !shouldInsert { continue }` 把基础
|
||||
// InsertMemory 整个跳过 ⇒ 2026-10-14 起每日蒸馏事实 2053/1094/1559 → 384 → 0,
|
||||
// 2120 条判定该落库的事实被丢(原始层 episodes 完整,可重蒸)。
|
||||
//
|
||||
// 修复语义(三模式表,与 write_ops.go 头部注释一致):
|
||||
// · shadow(默认)⇒ 插入新条 ✓ + 不做任何标记 ✓ + 记 shadow log ✓(= 特性不存在之前的基线)
|
||||
// · apply ⇒ 插入新条 ✓ + 做标记 ✓(NOOP 丢弃)
|
||||
// · off ⇒ 插入新条 ✓ + 不跑判定 ✓(零开销)
|
||||
//
|
||||
// ⚠️ 本文件只用旧代码就存在的符号(ApplyWriteOp/JudgeWriteOp/withMode/…),
|
||||
// 可直接在旧代码上运行且**必须失败**(先红);修复后全绿(后绿)。
|
||||
// 运行:go test ./internal/distill/ -run 'TestRegression' -v
|
||||
package distill
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 测试环境兜底:新 env 名(ZHIYI_WRITE_SEMANTIC_OPS,AC10 改名)置空 = 未设,
|
||||
// 避免测试机残留的新名干扰旧名/默认路径的断言。
|
||||
func clearSemanticOpsEnv(t *testing.T) {
|
||||
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "")
|
||||
}
|
||||
|
||||
// 四操作各造一个确定性 verdict(不依赖 LLM,与 write_ops_test.go 同型夹具)。
|
||||
func mkVerdicts() []WriteVerdict {
|
||||
return []WriteVerdict{
|
||||
{Op: OpAdd, Candidate: "一个独立新事实A", Similarity: 0.2, Reason: "无语义等价 → 新建"},
|
||||
{Op: OpUpdate, Candidate: "牧尘在招商局工作 负责算法部门", TargetID: "mem_1", Similarity: 0.92, Reason: "互补/更新"},
|
||||
{Op: OpNoop, Candidate: "牧尘在招商局工作", TargetID: "mem_1", Similarity: 0.99, Reason: "语义等价/完全重复"},
|
||||
{Op: OpConflictMark, Candidate: "牧尘不在招商局工作了", TargetID: "mem_1", Similarity: 0.85, Reason: "矛盾"},
|
||||
}
|
||||
}
|
||||
|
||||
// P0 主断言(AC2 + 增补):shadow 下任何判定都**不拦**基础插入,
|
||||
// 且 **MarkSuperseded / MarkConflict 零调用**(门控只决定标记,不决定插入)。
|
||||
func TestRegression_ShadowMode_InsertAlwaysProceeds(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("shadow")()
|
||||
defer withLogPath(t)()
|
||||
for _, v := range mkVerdicts() {
|
||||
t.Run(string(v.Op), func(t *testing.T) {
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(v, store, "mem_new")
|
||||
if err != nil {
|
||||
t.Fatalf("shadow %s 不应有错:err=%v", v.Op, err)
|
||||
}
|
||||
if !shouldInsert {
|
||||
t.Fatalf("P0:shadow %s 不得拦基础插入(旧实现此处恒 false ⇒ 蒸馏事实全丢):shouldInsert=%v", v.Op, shouldInsert)
|
||||
}
|
||||
// AC2 增补:shadow 下标记动作必须零调用
|
||||
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
|
||||
t.Fatalf("P0:shadow 不得做任何标记:superseded=%v conflicted=%v", store.superseded, store.conflicted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值安全断言(门控设计铁律 #3:默认态必须等价于「特性不存在」)。
|
||||
func TestRegression_DefaultMode_InsertAlwaysProceeds(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("")() // 空值模拟未设(writeOpsMode 回退默认 shadow)
|
||||
defer withLogPath(t)()
|
||||
v := JudgeWriteOp("默认态测试事实", []SimilarMemory{{ID: "mem_1", Content: "无关事实", Similarity: 0.2}}, nil, nil)
|
||||
if v == nil || v.Op != OpAdd {
|
||||
t.Fatalf("默认(shadow)态应跑判定且判 ADD,实际 %v", v)
|
||||
}
|
||||
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("P0:默认态不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
}
|
||||
|
||||
// off:不跑判定(JudgeWriteOp 恒 nil)⇒ 调用点跳过整个门控块,基础插入照常;
|
||||
// 且即使 off 态误调 ApplyWriteOp,也不得拦基础插入(零开销语义)。
|
||||
func TestRegression_OffMode_NoVerdict_InsertProceeds(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("off")()
|
||||
if v := JudgeWriteOp("任意事实", []SimilarMemory{{ID: "mem_1", Content: "无关", Similarity: 0.2}}, nil, nil); v != nil {
|
||||
t.Fatalf("off 不得跑判定(JudgeWriteOp 应返回 nil),实际 %+v", v)
|
||||
}
|
||||
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpNoop}, newFakeStore(), "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("P0:off 不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
}
|
||||
|
||||
// apply 语义保持(解耦修复不得顺手改掉 apply 的真行为):
|
||||
// NOOP 仍丢弃(这是特性的预期去重行为,不是基础功能)。
|
||||
func TestRegression_ApplyMode_NoopStillDrops(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("apply")()
|
||||
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpNoop, TargetID: "mem_1"}, newFakeStore(), "mem_new")
|
||||
if err != nil || shouldInsert {
|
||||
t.Fatalf("apply+NOOP 应丢弃:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
}
|
||||
|
||||
// apply + UPDATE:标记(supersede)+ 插入 —— 特性行为保持。
|
||||
func TestRegression_ApplyMode_UpdateMarksAndInserts(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("apply")()
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpUpdate, TargetID: "mem_1"}, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("apply+UPDATE 应插入:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
if store.superseded["mem_1"] != "mem_new" {
|
||||
t.Fatalf("apply+UPDATE 应标 superseded_by=mem_new:%v", store.superseded)
|
||||
}
|
||||
}
|
||||
|
||||
// apply + CONFLICT-MARK:标记(conflict)+ 插入 —— 特性行为保持。
|
||||
func TestRegression_ApplyMode_ConflictMarksAndInserts(t *testing.T) {
|
||||
clearSemanticOpsEnv(t)
|
||||
defer withMode("apply")()
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(WriteVerdict{Op: OpConflictMark, TargetID: "mem_1"}, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("apply+CONFLICT-MARK 应插入(供人工复核):shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
if !store.conflicted["mem_1"] {
|
||||
t.Fatalf("apply+CONFLICT-MARK 应标 conflict:%v", store.conflicted)
|
||||
}
|
||||
}
|
||||
|
||||
// AC10:env 改名 —— 新名 ZHIYI_WRITE_SEMANTIC_OPS 优先;旧名兼容一档;
|
||||
// 都未设 = shadow 默认(默认值不许漂移)。
|
||||
func TestRegression_EnvRename_CompatMatrix(t *testing.T) {
|
||||
// ① 新名生效(旧名相反值也不许干扰)
|
||||
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "apply")
|
||||
t.Setenv("ZHIYI_WRITE_OPS", "off")
|
||||
if WriteOpsMode() != "apply" {
|
||||
t.Fatalf("新名应优先:mode=%s", WriteOpsMode())
|
||||
}
|
||||
// ② 新名未设 → 旧名兜底(兼容一档)
|
||||
t.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", "")
|
||||
t.Setenv("ZHIYI_WRITE_OPS", "off")
|
||||
if WriteOpsMode() != "off" {
|
||||
t.Fatalf("旧名兼容失效:mode=%s", WriteOpsMode())
|
||||
}
|
||||
// ③ 双名都未设 → 默认 shadow
|
||||
t.Setenv("ZHIYI_WRITE_OPS", "")
|
||||
if WriteOpsMode() != "shadow" {
|
||||
t.Fatalf("默认值漂移(必须 shadow):mode=%s", WriteOpsMode())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// 织忆 MemoryWeave — 写时四操作 + 哈希去重 单测(t_3e98b65a)
|
||||
package distill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// ─── 测试辅助 ─────────────────────────────────────────────
|
||||
|
||||
// fakeStore 内存版 WriteOpsStore(记录被标记的操作,供 apply 断言)。
|
||||
type fakeStore struct {
|
||||
superseded map[string]string // oldID → newID
|
||||
conflicted map[string]bool
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{superseded: map[string]string{}, conflicted: map[string]bool{}}
|
||||
}
|
||||
func (f *fakeStore) MarkSuperseded(oldID, newID string) error {
|
||||
f.superseded[oldID] = newID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) MarkConflict(id string) error {
|
||||
f.conflicted[id] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func mkMem(id, content string) models.MemoryRecord {
|
||||
return models.MemoryRecord{ID: id, Content: content}
|
||||
}
|
||||
|
||||
// fixedJudge 返回固定 verdict 的判定器。
|
||||
// 2026-10-25:apply 模式改为强制 LLM 判定(规则基 UPDATE 精度实测≈0%),
|
||||
// 单测注入确定性实现,避免依赖网络/模型。
|
||||
func fixedJudge(op WriteOp) JudgeFunc {
|
||||
return func(c string, s []SimilarMemory) WriteVerdict {
|
||||
v := WriteVerdict{Op: op, Candidate: c}
|
||||
if len(s) > 0 {
|
||||
v.TargetID = s[0].ID
|
||||
v.Similarity = s[0].Similarity
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// withMode 临时设 ZHIYI_WRITE_OPS 模式,返回恢复函数。
|
||||
func withMode(mode string) func() {
|
||||
old := os.Getenv("ZHIYI_WRITE_SEMANTIC_OPS")
|
||||
oldJudge := os.Getenv("ZHIYI_WRITE_JUDGE")
|
||||
os.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", mode)
|
||||
if mode == "apply" {
|
||||
// 2026-10-25 硬保护:apply 强制 LLM 判定(规则基 UPDATE 精度实测≈0%)。
|
||||
// apply 用例均已注入 fixedJudge,因此设置 llm 不会真调网络。
|
||||
os.Setenv("ZHIYI_WRITE_JUDGE", "llm")
|
||||
} else {
|
||||
os.Setenv("ZHIYI_WRITE_JUDGE", "rule")
|
||||
}
|
||||
return func() {
|
||||
os.Setenv("ZHIYI_WRITE_SEMANTIC_OPS", old)
|
||||
os.Setenv("ZHIYI_WRITE_JUDGE", oldJudge)
|
||||
}
|
||||
}
|
||||
|
||||
// withLogPath 临时把影子日志指到临时文件,返回清理函数。
|
||||
func withLogPath(t *testing.T) func() {
|
||||
p := filepath.Join(t.TempDir(), "shadow.log")
|
||||
old := os.Getenv("ZHIYI_WRITE_OPS_LOG")
|
||||
os.Setenv("ZHIYI_WRITE_OPS_LOG", p)
|
||||
return func() { os.Setenv("ZHIYI_WRITE_OPS_LOG", old) }
|
||||
}
|
||||
|
||||
// ─── AC2:写前哈希去重(同内容连写 3 次 → 库内 1 条)────────────────
|
||||
|
||||
func TestWriteOpHashDedup_ExactDuplicate(t *testing.T) {
|
||||
candidate := "牧尘在招商局工作"
|
||||
existing := []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作。"}, // 多句号,归一化后等价
|
||||
{ID: "mem_2", Content: "牧尘在别的公司"},
|
||||
}
|
||||
dupID, dup := WriteOpHashDedup(candidate, existing)
|
||||
if !dup {
|
||||
t.Fatalf("期望判为重复,实际不重复(candidate=%q)", candidate)
|
||||
}
|
||||
if dupID != "mem_1" {
|
||||
t.Fatalf("期望命中 mem_1,实际 %s", dupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteOpHashDedup_NotDuplicate(t *testing.T) {
|
||||
candidate := "牧尘最近在学 Go"
|
||||
existing := []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作"},
|
||||
}
|
||||
if _, dup := WriteOpHashDedup(candidate, existing); dup {
|
||||
t.Fatalf("期望不判为重复,实际判重(candidate=%q)", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeContent_HashStable(t *testing.T) {
|
||||
if ContentHash("牧尘在招商局工作") != ContentHash(" 牧尘在招商局工作 ") {
|
||||
t.Fatalf("归一化哈希不稳定:首尾空白应被忽略")
|
||||
}
|
||||
}
|
||||
|
||||
// AC2 全链路:同内容连写 3 次 → 3 次都判同一重复 → 只该写 1 条
|
||||
func TestWriteOps_ThreeWritesOneRow(t *testing.T) {
|
||||
candidate := "X=true"
|
||||
// 第一次写入:库里没有 → 不重复
|
||||
if _, dup := WriteOpHashDedup(candidate, nil); dup {
|
||||
t.Fatalf("首次写入不应判重")
|
||||
}
|
||||
// 第二、三次:已有第一条 → 判重复(拒绝写入)
|
||||
existing := []models.MemoryRecord{mkMem("mem_1", candidate)}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, dup := WriteOpHashDedup(candidate, existing); !dup {
|
||||
t.Fatalf("第 %d 次重复写入未拦截", i+2)
|
||||
}
|
||||
}
|
||||
// 结论:库里始终只有 1 条(mem_1),新重复被源头堵住
|
||||
if len(existing) != 1 {
|
||||
t.Fatalf("库内应 1 条,实际 %d", len(existing))
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AC4:四操作各 1 个单测 ────────────────────────────────
|
||||
|
||||
// 操作1:ADD —— 无语义等价 → 新建
|
||||
func TestWriteOps_Add(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
candidate := "牧尘最近在学 Rust 异步"
|
||||
similar := []SimilarMemory{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.4},
|
||||
}
|
||||
v := JudgeWriteOp(candidate, similar, fixedJudge(OpAdd), nil)
|
||||
if v == nil {
|
||||
t.Fatalf("JudgeWriteOp 返回 nil")
|
||||
}
|
||||
if v.Op != OpAdd {
|
||||
t.Fatalf("期望 ADD,实际 %s(%s)", v.Op, v.Reason)
|
||||
}
|
||||
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("ADD 应插入:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 操作2:UPDATE —— 互补/更新 → 并入现有(supersede)
|
||||
func TestWriteOps_Update(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
candidate := "牧尘在招商局工作 负责算法部门"
|
||||
similar := []SimilarMemory{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.92},
|
||||
}
|
||||
v := JudgeWriteOp(candidate, similar, fixedJudge(OpUpdate), nil)
|
||||
if v == nil {
|
||||
t.Fatalf("nil verdict")
|
||||
}
|
||||
if v.Op != OpUpdate {
|
||||
t.Fatalf("期望 UPDATE,实际 %s(%s)", v.Op, v.Reason)
|
||||
}
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("UPDATE 应插入:%v %v", shouldInsert, err)
|
||||
}
|
||||
// 旧条被标记 superseded_by=mem_new
|
||||
if store.superseded["mem_1"] != "mem_new" {
|
||||
t.Fatalf("旧条未标记 superseded_by=mem_new,实际 %q", store.superseded["mem_1"])
|
||||
}
|
||||
}
|
||||
|
||||
// 操作3:NOOP —— 无新增价值。
|
||||
// t_a0831457 解耦修复后语义:apply 模式丢弃(特性预期去重行为);
|
||||
// shadow/默认模式**不再拦基础插入**(门控只决定标记,不决定插入)——
|
||||
// 旧断言「shadow+NOOP 不插入」正是 10-14 起蒸馏事实全丢的 bug 语义,已改写。
|
||||
func TestWriteOps_Noop(t *testing.T) {
|
||||
candidate := "牧尘在招商局工作"
|
||||
similar := []SimilarMemory{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.99}, // 语义等价
|
||||
}
|
||||
t.Run("apply+NOOP_丢弃", func(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
v := JudgeWriteOp(candidate, similar, fixedJudge(OpNoop), nil)
|
||||
if v == nil || v.Op != OpNoop {
|
||||
t.Fatalf("期望 NOOP,实际 %v", v)
|
||||
}
|
||||
shouldInsert, err := ApplyWriteOp(*v, newFakeStore(), "mem_new")
|
||||
if err != nil || shouldInsert {
|
||||
t.Fatalf("apply+NOOP 应丢弃:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
})
|
||||
t.Run("shadow+NOOP_基础插入照跑", func(t *testing.T) {
|
||||
defer withMode("shadow")()
|
||||
defer withLogPath(t)()
|
||||
v := JudgeWriteOp(candidate, similar, nil, nil)
|
||||
if v == nil || v.Op != OpNoop {
|
||||
t.Fatalf("期望 NOOP,实际 %v", v)
|
||||
}
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("P0:shadow+NOOP 不得拦基础插入:shouldInsert=%v err=%v", shouldInsert, err)
|
||||
}
|
||||
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
|
||||
t.Fatalf("shadow 不得做标记:%v %v", store.superseded, store.conflicted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 操作4:CONFLICT-MARK —— 矛盾 → 双方标记 conflict(不删),交人工
|
||||
func TestWriteOps_ConflictMark(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
candidate := "牧尘不在招商局工作了"
|
||||
similar := []SimilarMemory{
|
||||
{ID: "mem_1", Content: "牧尘在招商局工作", Similarity: 0.85},
|
||||
}
|
||||
v := JudgeWriteOp(candidate, similar, fixedJudge(OpConflictMark), nil)
|
||||
if v == nil {
|
||||
t.Fatalf("nil verdict")
|
||||
}
|
||||
if v.Op != OpConflictMark {
|
||||
t.Fatalf("期望 CONFLICT-MARK,实际 %s(%s)", v.Op, v.Reason)
|
||||
}
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(*v, store, "mem_new")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("CONFLICT 应插入(供人工复核):%v %v", shouldInsert, err)
|
||||
}
|
||||
// 旧条被标记 conflict
|
||||
if !store.conflicted["mem_1"] {
|
||||
t.Fatalf("旧条未标记 conflict")
|
||||
}
|
||||
}
|
||||
|
||||
// 矛盾判定:true/false 对立
|
||||
func TestRuleContradiction_TrueFalse(t *testing.T) {
|
||||
if !ruleContradiction("X=false", SimilarMemory{Content: "X=true"}) {
|
||||
t.Fatalf("false/true 应判矛盾")
|
||||
}
|
||||
if ruleContradiction("X=1", SimilarMemory{Content: "Y=2"}) {
|
||||
t.Fatalf("无关事实不应判矛盾")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AC1:写「X=true」后写「X=false」→ 旧条 conflict 标记 + 新条 active ──
|
||||
func TestWriteOps_WriteTrueThenFalse(t *testing.T) {
|
||||
defer withMode("apply")()
|
||||
|
||||
// 第一次:写 X=true(库里空 → ADD)
|
||||
first := JudgeWriteOp("X=true", nil, nil, nil)
|
||||
if first == nil || first.Op != OpAdd {
|
||||
t.Fatalf("首次 X=true 应 ADD,实际 %+v", first)
|
||||
}
|
||||
|
||||
// 第二次:写 X=false,库里已有 X=true → CONFLICT(矛盾)
|
||||
second := JudgeWriteOp("X=false", []SimilarMemory{
|
||||
{ID: "mem_1", Content: "X=true", Similarity: 0.9},
|
||||
}, fixedJudge(OpConflictMark), nil)
|
||||
if second == nil {
|
||||
t.Fatalf("nil")
|
||||
}
|
||||
if second.Op != OpConflictMark {
|
||||
t.Fatalf("X=false 与 X=true 矛盾,应 CONFLICT-MARK,实际 %s", second.Op)
|
||||
}
|
||||
store := newFakeStore()
|
||||
shouldInsert, err := ApplyWriteOp(*second, store, "mem_2")
|
||||
if err != nil || !shouldInsert {
|
||||
t.Fatalf("CONFLICT 应插入新条:%v %v", shouldInsert, err)
|
||||
}
|
||||
// 旧条 conflict 标记 + 新条 active(插入)
|
||||
if !store.conflicted["mem_1"] {
|
||||
t.Fatalf("旧条 X=true 未标 conflict")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AC5:影子模式行为零变化(判定不改库)──────────────────────
|
||||
// 影子模式:JudgeWriteOp 只记日志,不触碰 store;同 query 召回结果不受影响。
|
||||
func TestWriteOps_ShadowModeNoMutation(t *testing.T) {
|
||||
defer withMode("shadow")()
|
||||
defer withLogPath(t)()
|
||||
|
||||
store := newFakeStore()
|
||||
// 影子模式下连续判定 ADD/UPDATE/NOOP/CONFLICT
|
||||
ops := []struct {
|
||||
op WriteOp
|
||||
candidate string
|
||||
sim []SimilarMemory
|
||||
}{
|
||||
{OpAdd, "新事实A", []SimilarMemory{{"mem_1", "无关", 0.2}}},
|
||||
{OpUpdate, "牧尘在招商局负责算法", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.9}}},
|
||||
{OpNoop, "牧尘在招商局", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.99}}},
|
||||
{OpConflictMark, "牧尘不在招商局", []SimilarMemory{{"mem_1", "牧尘在招商局", 0.8}}},
|
||||
}
|
||||
for _, o := range ops {
|
||||
v := JudgeWriteOp(o.candidate, o.sim, nil, nil)
|
||||
if v == nil || v.Op != o.op {
|
||||
t.Fatalf("期望 %s,实际 %v", o.op, v)
|
||||
}
|
||||
// apply 到 store(验证影子模式语义下 store 不被改动)
|
||||
_, _ = ApplyWriteOp(*v, store, "mem_new")
|
||||
}
|
||||
// 影子模式:store 应保持零变化(superseded/conflict 都空)
|
||||
if len(store.superseded) != 0 || len(store.conflicted) != 0 {
|
||||
t.Fatalf("影子模式改了 store:superseded=%v conflicted=%v", store.superseded, store.conflicted)
|
||||
}
|
||||
}
|
||||
|
||||
// 影子日志确实被写入(供 AC3 分布统计)
|
||||
func TestWriteOps_ShadowLogWritten(t *testing.T) {
|
||||
defer withMode("shadow")()
|
||||
defer withLogPath(t)()
|
||||
|
||||
_ = JudgeWriteOp("测试事实", []SimilarMemory{{"mem_1", "无关", 0.1}}, nil, nil)
|
||||
content, err := os.ReadFile(os.Getenv("ZHIYI_WRITE_OPS_LOG"))
|
||||
if err != nil {
|
||||
t.Fatalf("读影子日志失败: %v", err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("影子日志为空,AC3 分布统计无源")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//go:build liveprobe
|
||||
|
||||
package distill
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAdvAuditLiveRealModel 直连 NewAPI deepseek-v4-flash 真模型,验证异源对抗审计
|
||||
// 能在真实输出上给出结论(含矛盾/幻觉判定)。影子:只写日志,不改行为。
|
||||
// go test ./internal/distill/ -tags liveprobe -run TestAdvAuditLiveRealModel -v
|
||||
func TestAdvAuditLiveRealModel(t *testing.T) {
|
||||
key := os.Getenv("ZHIYI_ADVERSARIAL_AUDIT_KEY")
|
||||
if key == "" {
|
||||
key = "***REMOVED***"
|
||||
}
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_KEY", key)
|
||||
tmp := t.TempDir() + "/audit.log"
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_LOG", tmp)
|
||||
t.Setenv("ZHIYI_ADVERSARIAL_AUDIT_MODEL", "deepseek-v4-flash")
|
||||
|
||||
in := DistillInput{
|
||||
EpisodeID: "ep_live_audit_001",
|
||||
Content: "[2026-10-13] 牧尘: 决定把蒸馏评分模型从本地2B换成更强的模型,因为评分区分度不够。",
|
||||
Source: "liveprobe",
|
||||
}
|
||||
facts := []string{
|
||||
"牧尘决定把蒸馏模型换成更强的模型",
|
||||
"牧尘每天吃三个苹果", // 故意编造的幻觉:原文未提及
|
||||
"牧尘换了蒸馏模型因为区分度不够",
|
||||
}
|
||||
if err := auditOne(in, facts); err != nil {
|
||||
t.Fatalf("live auditOne err: %v", err)
|
||||
}
|
||||
b, _ := os.ReadFile(tmp)
|
||||
t.Logf("\n══════ 真实影子日志 ══════\n%s", string(b))
|
||||
content := string(b)
|
||||
if content == "" {
|
||||
t.Fatal("影子日志为空,真模型未返回")
|
||||
}
|
||||
if !containsStr(content, "contradiction") && !containsStr(content, "hallucination") {
|
||||
t.Logf("⚠️ 本样例未触发矛盾/幻觉判定(模型全判 supported)—— 需另取样例")
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
return len(s) >= len(sub) && indexOf(s, sub) >= 0
|
||||
}
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -219,17 +219,31 @@ func (mh *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
metrics := mh.dashboard.Metrics()
|
||||
s := mh.stats()
|
||||
|
||||
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_rate Recall有用率\n")
|
||||
// ⚠️ 语义变更(2026-10-14):比率型指标在**分母退化**时返回 -1(无数据),
|
||||
// 不再返回 1。1 曾被读成「100% 好用」——那是失明,不是满分。
|
||||
// 配套 zhiyi_*_valid(1=可解读 / 0=值必为 -1,禁止当成绩用)与 zhiyi_*_samples。
|
||||
// 看板/告警若直接画 *_rate 曲线,请先用 *_valid 过滤,否则 -1 会拉低均值。
|
||||
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_rate Recall有用率;-1=无数据(无负样本通道)\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_rate gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_recall_usefulness_rate %f\n", metrics["recall_usefulness_rate"])
|
||||
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_valid 该率是否可解读;0 ⇒ 值为 -1\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_valid gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_recall_usefulness_valid %f\n", metrics["recall_usefulness_valid"])
|
||||
fmt.Fprintf(w, "# HELP zhiyi_recall_usefulness_samples 该率的分母样本数\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_recall_usefulness_samples gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_recall_usefulness_samples %f\n", metrics["recall_usefulness_samples"])
|
||||
|
||||
fmt.Fprintf(w, "# HELP zhiyi_recall_hit_rate Recall命中率\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_recall_hit_rate gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_recall_hit_rate %f\n", metrics["recall_hit_rate"])
|
||||
// 2026-10-17:zhiyi_recall_hit_rate 已退役(恒=1、无区分度),由 recall_usefulness_rate 承担「返回且被使用」语义。
|
||||
|
||||
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_rate 缺口闭环率\n")
|
||||
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_rate 缺口闭环率;-1=无数据\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_rate gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_gap_closure_rate %f\n", metrics["gap_closure_rate"])
|
||||
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_valid 该率是否可解读;0 ⇒ 值为 -1\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_valid gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_gap_closure_valid %f\n", metrics["gap_closure_valid"])
|
||||
fmt.Fprintf(w, "# HELP zhiyi_gap_closure_samples 该率的分母样本数\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_gap_closure_samples gauge\n")
|
||||
fmt.Fprintf(w, "zhiyi_gap_closure_samples %f\n", metrics["gap_closure_samples"])
|
||||
|
||||
fmt.Fprintf(w, "# HELP zhiyi_episodes_total 总episode数\n")
|
||||
fmt.Fprintf(w, "# TYPE zhiyi_episodes_total gauge\n")
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// 织忆 MemoryWeave — 实体名称拒写门禁(t_41f5ee7a 治源)
|
||||
//
|
||||
// 背景:在实体抽取写库前加拒绝规则,防止非法实体名(如过长、以数字开头、包含路径/后缀)
|
||||
// 进入图谱。单一真相源:判据的规则值读自共享 JSON
|
||||
// (~/.hermes/scripts/lib/graph_entity_guard.json),与 Python 侧
|
||||
// graph_entity_guard.py 读同一份文件;改规则只改那一个 JSON。
|
||||
//
|
||||
// 铁律(与 Python 逐位一致):
|
||||
// · 白名单内的名称直接放行(即使匹配判据)
|
||||
// · 否则匹配 j1/j2/j3 之一即判为垃圾(应被拒绝)
|
||||
//
|
||||
// 无缓存、无新增 Redis 键(只用原子计数),故不触及项目现成件清单的缓存条目。
|
||||
|
||||
package governance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// entityGuardG is the singleton guard instance.
|
||||
var entityGuardG *guard
|
||||
|
||||
// defaultRulesPath — 与 Python 侧同一份共享规则文件(同机同用户 muc 可直读)。
|
||||
// env GRAPH_ENTITY_GUARD_RULES 可覆盖(测试/部署用)。
|
||||
func defaultRulesPath() string {
|
||||
if p := os.Getenv("GRAPH_ENTITY_GUARD_RULES"); p != "" {
|
||||
return p
|
||||
}
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".hermes", "scripts", "lib", "graph_entity_guard.json")
|
||||
}
|
||||
return "/home/muc/.hermes/scripts/lib/graph_entity_guard.json"
|
||||
}
|
||||
|
||||
// entityGuardRules 镜像共享 JSON(只取 Go 判据需要的字段;未知字段忽略)。
|
||||
type entityGuardRules struct {
|
||||
Whitelist []string `json:"whitelist"`
|
||||
Judgments struct {
|
||||
J1LenGt14 struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"j1_len_gt_14"`
|
||||
J2StartsWithDigit struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"j2_startswith_digit"`
|
||||
J3PathOrExt struct {
|
||||
Pattern string `json:"pattern"`
|
||||
CaseInsensitive bool `json:"case_insensitive"`
|
||||
} `json:"j3_path_or_ext"`
|
||||
} `json:"judgments"`
|
||||
}
|
||||
|
||||
type guard struct {
|
||||
rules entityGuardRules
|
||||
j1Regex *regexp.Regexp
|
||||
j2Regex *regexp.Regexp
|
||||
j3Regex *regexp.Regexp
|
||||
loaded bool
|
||||
path string
|
||||
// 计数(可选,供监控)
|
||||
checks int64
|
||||
blocks int64
|
||||
}
|
||||
|
||||
|
||||
|
||||
// LoadEntityGuard 读取并编译规则。首次成功后设置单例。
|
||||
// 返回 (ok, error)。ok 表示是否成功加载。
|
||||
func LoadEntityGuard(path string) (bool, error) {
|
||||
if path == "" {
|
||||
path = defaultRulesPath()
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var rc entityGuardRules
|
||||
if err := json.Unmarshal(data, &rc); err != nil {
|
||||
return false, err
|
||||
}
|
||||
// 編譯判據正則
|
||||
j1Regex, err := regexp.Compile(rc.Judgments.J1LenGt14.Pattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rc.Judgments.J1LenGt14.CaseInsensitive {
|
||||
j1Regex = regexp.MustCompile("(?i)" + rc.Judgments.J1LenGt14.Pattern)
|
||||
}
|
||||
j2Regex, err := regexp.Compile(rc.Judgments.J2StartsWithDigit.Pattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rc.Judgments.J2StartsWithDigit.CaseInsensitive {
|
||||
j2Regex = regexp.MustCompile("(?i)" + rc.Judgments.J2StartsWithDigit.Pattern)
|
||||
}
|
||||
j3Regex, err := regexp.Compile(rc.Judgments.J3PathOrExt.Pattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if rc.Judgments.J3PathOrExt.CaseInsensitive {
|
||||
j3Regex = regexp.MustCompile("(?i)" + rc.Judgments.J3PathOrExt.Pattern)
|
||||
}
|
||||
g := &guard{
|
||||
rules: rc,
|
||||
j1Regex: j1Regex,
|
||||
j2Regex: j2Regex,
|
||||
j3Regex: j3Regex,
|
||||
path: path,
|
||||
loaded: true,
|
||||
}
|
||||
// assign to package-level singleton
|
||||
entityGuardG = g
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// EntityGuardStats 供 /metrics 与验收:检查数 / 拦截数 / 是否加载 / 规则路径。
|
||||
func EntityGuardStats() (checks, blocks int64, loaded bool, path string) {
|
||||
if entityGuardG == nil {
|
||||
return 0, 0, false, ""
|
||||
}
|
||||
return atomic.LoadInt64(&entityGuardG.checks), atomic.LoadInt64(&entityGuardG.blocks), entityGuardG.loaded, entityGuardG.path
|
||||
}
|
||||
|
||||
// IncEntityGuardCheck / IncEntityGuardBlock 供写路径在判定前后调用(相对增量,避免并发写竞态)。
|
||||
func IncEntityGuardCheck() {
|
||||
if entityGuardG == nil {
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&entityGuardG.checks, 1)
|
||||
}
|
||||
func IncEntityGuardBlock() {
|
||||
if entityGuardG == nil {
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&entityGuardG.blocks, 1)
|
||||
}
|
||||
|
||||
// IsJunkName 治源入口(帶心跳計數):
|
||||
// - 未加載 ⇒ fail-open(放行、checks 仍 +1,保證「拦截器跑過」可觀測,不拦也不崩);
|
||||
// - 已加載 ⇒ 用共享判據判定,命中垃圾則 blocks +1 並返回 true(寫路径據此 continue 拒寫)。
|
||||
func IsJunkName(name string) bool {
|
||||
IncEntityGuardCheck()
|
||||
if entityGuardG == nil || !entityGuardG.loaded {
|
||||
return false
|
||||
}
|
||||
if entityGuardG.isJunkName(name) {
|
||||
IncEntityGuardBlock()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsJunkNamePure 純判據(無計數,供單測)。未加載時就地讀一次默認路徑兜底;失敗則 fail-open。
|
||||
func IsJunkNamePure(name string) bool {
|
||||
if entityGuardG == nil {
|
||||
if _, err := LoadEntityGuard(""); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return entityGuardG.isJunkName(name)
|
||||
}
|
||||
|
||||
func (gr *guard) isJunkName(name string) bool {
|
||||
// 先修剪空白(與 SQL trim 一致)
|
||||
n := strings.TrimSpace(name)
|
||||
// 白名單直接放行
|
||||
for _, w := range gr.rules.Whitelist {
|
||||
if w == n {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 判定垃圾:匹配 j1/j2/j3 之一
|
||||
if gr.j1Regex.MatchString(n) {
|
||||
return true
|
||||
}
|
||||
if gr.j2Regex.MatchString(n) {
|
||||
return true
|
||||
}
|
||||
if gr.j3Regex.MatchString(n) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 织忆 MemoryWeave — 图谱实体质量闸(P0-4,2026-10-26)
|
||||
//
|
||||
// 背景(CS249r 评审实测):graph_expanded 抽样 15 条里 8 条是 ≤4 字垃圾实体
|
||||
// ("条""里存着""开发"),既污染上下文又抬延迟。根因是实体抽取「任意 2-8 字中文串都当实体」,
|
||||
// 且扩展前不检查节点在图里的度数 —— 孤边节点(度=1)被当成语义邻居注入。
|
||||
//
|
||||
// 两道闸(都在**导航前**执行,省算力):
|
||||
// ① isJunkEntity:长度 <2 rune / 纯虚词量词代词 / 常见填充短语 / 纯 ASCII 停用词 ⇒ 丢弃;
|
||||
// ② 度数闸:起点实体在图中的边数 <2 ⇒ 不导航、不注入(孤边不构成「关联」)。
|
||||
//
|
||||
// 观测:两道闸的拒绝计数写入 debug 日志(ZHIYI_GRAPH_EXPAND_DEBUG=1)与 recall trace
|
||||
// (graph_expand 阶段 note),保证「下次坏了不沉默」。
|
||||
package governance
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// junkEntitySet 高置信垃圾实体词表:中文虚词/代词/量词/时间副词/填充短语 + 英文停用词。
|
||||
// 原则:只收**几乎不可能**是真实实体名的词;宁可漏筛(由度数闸兜底),不可误杀专名。
|
||||
var junkEntitySet = map[string]struct{}{}
|
||||
|
||||
func init() {
|
||||
words := []string{
|
||||
// 虚词 / 助词 / 连词
|
||||
"的", "了", "着", "过", "地", "得", "所", "以", "于", "与", "及", "或", "而", "且", "则", "即",
|
||||
"因为", "所以", "但是", "而且", "然后", "如果", "虽然", "不过", "而是", "就是", "只是", "还是",
|
||||
"并且", "以及", "或者", "否则", "因此", "于是", "关于", "对于", "由于", "根据", "按照", "通过",
|
||||
// 代词 / 指示词
|
||||
"我", "你", "他", "她", "它", "我们", "你们", "他们", "她们", "它们", "自己", "别人", "大家",
|
||||
"这个", "那个", "这些", "那些", "这样", "那样", "这种", "那种", "这里", "那里", "里面", "上面",
|
||||
"下面", "前面", "后面", "其中", "其他", "其它", "什么", "怎么", "怎样", "为什么", "哪个", "哪些",
|
||||
// 数量 / 量词 / 程度
|
||||
"一个", "一条", "一次", "一些", "一种", "一件", "一点", "一下", "一直", "一样", "一起",
|
||||
"两个", "三个", "几个", "多个", "很多", "更多", "多条", "几条", "每条", "各种", "某种", "全部", "所有", "每个", "各种", "部分",
|
||||
"一条条", "一条里", "条", "个", "次", "些", "种", "只", "件", "篇", "位", "名", "层",
|
||||
"非常", "比较", "特别", "十分", "更加", "最为", "可能", "应该", "必须", "可以", "能够", "需要",
|
||||
// 常见谓词 / 填充动词短语(低信息量,几乎不可能是实体)
|
||||
"里存着", "存着", "里存在", "有着", "存在", "进行", "使用", "支持", "包括", "包含", "属于",
|
||||
"作为", "成为", "变成", "做到", "做好", "来看", "来说", "来讲", "看到", "看看", "发现",
|
||||
"出现", "提供", "实现", "完成", "开始", "结束", "继续", "返回", "调用", "处理", "执行",
|
||||
"开发", "编写", "添加", "修改", "删除", "更新", "检查", "确认", "验证", "测试", "部署",
|
||||
// 时间 / 方位
|
||||
"现在", "今天", "昨天", "明天", "之前", "之后", "以前", "以后", "当时", "当前", "目前",
|
||||
"最近", "刚才", "马上", "已经", "正在", "曾经", "从来", "后来", "最终", "最后", "首先",
|
||||
"时候", "时间", "期间", "上面", "下面", "左边", "右边", "中间", "附近", "里面", "外面",
|
||||
// 英文停用词(小写比较)
|
||||
"the", "and", "for", "are", "but", "not", "you", "all", "any", "can", "had", "her", "was",
|
||||
"one", "our", "out", "day", "get", "has", "him", "his", "how", "its", "new", "now", "old",
|
||||
"see", "two", "way", "who", "boy", "did", "she", "use", "her", "way", "may", "say",
|
||||
"this", "that", "with", "from", "have", "will", "your", "they", "than", "then", "them",
|
||||
"into", "more", "some", "such", "only", "over", "also", "just", "like", "make", "made",
|
||||
}
|
||||
for _, w := range words {
|
||||
junkEntitySet[w] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// IsJunkEntity 判定实体是否应被图谱扩展丢弃(P0-4 第①道闸)。
|
||||
func IsJunkEntity(e string) bool {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
return true
|
||||
}
|
||||
if utf8.RuneCountInString(e) < 2 {
|
||||
return true // 单字("条""里")不构成实体
|
||||
}
|
||||
// 归一化:去首尾标点/引号,压缩空白
|
||||
e = strings.Trim(e, " \t\r\n\"'`,。;:!?、()()【】[]{}<>《》—-·.,;:!?")
|
||||
if e == "" {
|
||||
return true
|
||||
}
|
||||
if utf8.RuneCountInString(e) < 2 {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(e)
|
||||
if _, bad := junkEntitySet[lower]; bad {
|
||||
return true
|
||||
}
|
||||
if _, bad := junkEntitySet[e]; bad {
|
||||
return true
|
||||
}
|
||||
// 全由虚词/量词字组成(如「里存着」之外的「的了着」「个条」组合)
|
||||
if allFunctionChars(e) {
|
||||
return true
|
||||
}
|
||||
// 纯标点/符号(无任何字母/数字/汉字)
|
||||
if !hasSemanticChar(e) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// functionChars 单字虚词/量词集合:整串都由这些字组成 ⇒ 无信息量。
|
||||
var functionChars = map[rune]struct{}{}
|
||||
|
||||
func init() {
|
||||
for _, r := range "的了着过地得所以于与及或而且则即是在有和与就都也还又很更最可会能要没对为从到把被让给吗呢吧啊呀哦嗯这那我你他她它们个条次些种只件篇位名层里上下中前后内外" {
|
||||
functionChars[r] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func allFunctionChars(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := functionChars[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasSemanticChar(s string) bool {
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 0x4E00 && r <= 0x9FFF: // 汉字
|
||||
return true
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
|
||||
return true
|
||||
case r >= '0' && r <= '9':
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterEntitiesForGraph 批量过滤垃圾实体,返回保留集与被拒数(观测用)。
|
||||
func FilterEntitiesForGraph(entities []string) ([]string, int) {
|
||||
if len(entities) == 0 {
|
||||
return entities, 0
|
||||
}
|
||||
out := make([]string, 0, len(entities))
|
||||
rejected := 0
|
||||
for _, e := range entities {
|
||||
if IsJunkEntity(e) {
|
||||
rejected++
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rejected
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package governance
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsJunkEntity(t *testing.T) {
|
||||
junk := []string{
|
||||
"条", "个", "里", "的", "了", "这个", "那些", "里存着", "开发", "进行", "the", "and",
|
||||
"一个", "多条", "存在", "什么", "为什么", "", " ", ",。", "的了着",
|
||||
}
|
||||
for _, e := range junk {
|
||||
if !IsJunkEntity(e) {
|
||||
t.Errorf("%q 应判为垃圾实体", e)
|
||||
}
|
||||
}
|
||||
keep := []string{
|
||||
"织忆", "MemoryWeave", "淇县", "生物制造", "卫都街道", "bge-m3", "KOCR", "llama-server-4b",
|
||||
"8081", "graph.db", "牧尘", "飞书",
|
||||
}
|
||||
for _, e := range keep {
|
||||
if IsJunkEntity(e) {
|
||||
t.Errorf("%q 是专名/标识符,不应被判为垃圾", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEntitiesForGraph(t *testing.T) {
|
||||
in := []string{"织忆", "条", "这个", "8081", "里存着"}
|
||||
out, rejected := FilterEntitiesForGraph(in)
|
||||
if rejected != 3 {
|
||||
t.Fatalf("应拒绝 3 个垃圾实体,实际 %d(输出 %v)", rejected, out)
|
||||
}
|
||||
if len(out) != 2 || out[0] != "织忆" || out[1] != "8081" {
|
||||
t.Fatalf("保留集错误:%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllFunctionChars(t *testing.T) {
|
||||
if !allFunctionChars("的了着") {
|
||||
t.Fatal("全虚词串应判 true")
|
||||
}
|
||||
if allFunctionChars("织忆") {
|
||||
t.Fatal("专名不应判 true")
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,11 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
|
||||
entity, distilled.EpisodeID)
|
||||
continue
|
||||
}
|
||||
cleaned := cleanEntityName(entity)
|
||||
if IsJunkName(cleaned) {
|
||||
log.Printf("[graph] skip-junk-entity=%q cleaned=%q", entity, cleaned)
|
||||
continue
|
||||
}
|
||||
validEnts = append(validEnts, entity)
|
||||
}
|
||||
|
||||
@@ -189,24 +194,13 @@ func (agu *AutoGraphUpdater) UpdateFromDistill(distilled *DistillInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. DERIVED_FROM 边:蒸馏产物 → 原始 episode
|
||||
if distilled.EpisodeID != "" {
|
||||
for _, entity := range validEnts {
|
||||
eid := entityID(entity)
|
||||
derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", eid, distilled.EpisodeID, time.Now().UnixNano())
|
||||
agu.graph.AddEdge(derivedEdgeID, eid, distilled.EpisodeID,
|
||||
"DERIVED_FROM", distilled.Namespace, 0.9)
|
||||
}
|
||||
for _, fact := range distilled.Facts {
|
||||
if redact.ContainsSecret(fact) {
|
||||
continue // 含凭证的事实不建节点(2026-09-10 P0)
|
||||
}
|
||||
factNodeID := "f_" + strings.ReplaceAll(entityID(fact), "n_", "")
|
||||
derivedEdgeID := fmt.Sprintf("df_%s_%s_%d", factNodeID, distilled.EpisodeID, time.Now().UnixNano())
|
||||
agu.graph.AddEdge(derivedEdgeID, factNodeID, distilled.EpisodeID,
|
||||
"DERIVED_FROM", distilled.Namespace, 0.9)
|
||||
}
|
||||
}
|
||||
// 4. DERIVED_FROM 边:已停写(2026-10-13 t_348cefc1 源头二收口)。
|
||||
// 血缘(L1→L0 episode)由 memories.derived_from 字段承载(models/memory.go:97,
|
||||
// lancedb_ipc.go:1145 有真实读取);图上的 DERIVED_FROM 边全库仅此处写、无人读
|
||||
// (全仓 go/rust/py/web/docs grep:读取点 0 处),且 AddEdge 会自动为缺失的
|
||||
// ep_/f_ 源/目标创建节点 → 每个蒸馏 episode 恒增 1+N 个噪声节点(N=facts 数)。
|
||||
// 此路径绕过全部判据(noiseEntity 只作用于 §1 实体,§4 的 fact 节点与 episode id
|
||||
// 目标节点从不经判据)。停写后血缘不丢,图节点不再膨胀。
|
||||
}
|
||||
|
||||
// RecordCoOccurrence record recall 后的共访关系
|
||||
@@ -298,7 +292,30 @@ func inSubAgentNamespace(ns string) bool {
|
||||
// - "证据:无证据" 自述模板
|
||||
// ⚠️ 不做「方案代号一刀切」——方案A/B 是领域决策常用概念(AC4 平台决策 14 保留),
|
||||
// 只拦「证据:无证据」这类纯自述模板 + AC 编号 + 工具动词 + 自ID 前缀。
|
||||
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*)$`)
|
||||
var noiseEntityRe = regexp.MustCompile(`(?i)^(ac\d+|ep_|f_|n_|mem_|e_|co_|cw_|df_|t_[0-9a-f]{6,}|.*(证据[::]无证据|ingest.?dry.?run|健康检查|部署流程|测试结果|代码修改|grep).*|.*(看板|kanban|任务).*(结案|发车|状态|归档|词汇|过程|卡|process).*)$`)
|
||||
|
||||
// kanbanCardIDRe 看板卡 ID 形态:t_<8 位 hex>(含 t_c6aebb8b / t_286d1310 等)。
|
||||
// 非锚定(可不限于整串),嵌在复合实体名里的卡 ID 也拦。看板卡 ID 是过程标识,
|
||||
// 绝不承载领域概念,无条件禁进图(与 ep_/f_ 等自ID 前缀同级)。
|
||||
var kanbanCardIDRe = regexp.MustCompile(`t_[0-9a-f]{6,}`)
|
||||
|
||||
// noiseStopwords 停用词表(2026-10-13 t_91af03e9):结构性泛词/探针残留,精确匹配整个实体名。
|
||||
// 设计原则——只收「短泛词」,长实体名(≥4 字)天然不会精确命中本表,故领域概念
|
||||
// (飞天农业/中维化纤/生物基材料/玉米淀粉深加工/Galway/织忆/鹤壁淇县生物制造产业专班…)
|
||||
// 一律不因本表被拦。精确匹配(整名相等)而非子串:任务/状态 等单字泛词嵌在「任务状态」「状态机」
|
||||
// 「任务已结案」里照常放行(组合形态仍由下方 noiseEntityRe 覆盖)。
|
||||
// 唯一例外:工具动词 虽 4 字,但纯结构性工具词、探针丙实测泄漏,显式入表(AC1 硬判据要求拦)。
|
||||
var noiseStopwords = map[string]struct{}{
|
||||
// 结构性泛词
|
||||
"任务": {}, "过程": {}, "状态": {}, "流程": {}, "词汇": {}, "工具": {}, "工具动词": {},
|
||||
"判据": {}, "结论": {}, "结果": {}, "方案": {}, "内容": {}, "信息": {}, "原因": {},
|
||||
"问题": {}, "步骤": {}, "阶段": {}, "记录": {}, "清单": {}, "备注": {}, "说明": {},
|
||||
// 探针/自指残留
|
||||
"甲": {}, "乙": {}, "丙": {}, "探针": {}, "probe": {},
|
||||
}
|
||||
|
||||
// noiseStopwordPrefixRe 探针前缀形态:^PROBE[-_]?.*(探针丙实测泄漏 PROBE-t62,lower 化后匹配)
|
||||
var noiseStopwordPrefixRe = regexp.MustCompile(`^probe[-_]?`)
|
||||
|
||||
// noiseEntity 实体名命中内容形态噪声 → 跳过写图。
|
||||
func noiseEntity(name string) bool {
|
||||
@@ -310,7 +327,18 @@ func noiseEntity(name string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// AC 编号 / 自述模板 / 工具动词
|
||||
// 看板卡 ID(t_<hex>):裸形或嵌在复合词里都拦(2026-10-13 t_348cefc1,探针乙 4/4 泄)
|
||||
if kanbanCardIDRe.MatchString(lower) {
|
||||
return true
|
||||
}
|
||||
// 停用词表:结构性泛词/探针残留,精确匹配整个实体名(先于现有正则)
|
||||
if _, ok := noiseStopwords[lower]; ok {
|
||||
return true
|
||||
}
|
||||
if noiseStopwordPrefixRe.MatchString(lower) {
|
||||
return true
|
||||
}
|
||||
// AC 编号 / 自述模板 / 工具动词 / 看板·任务流程词(组合形态,不单拦泛词)
|
||||
return noiseEntityRe.MatchString(lower)
|
||||
}
|
||||
|
||||
|
||||
@@ -155,10 +155,10 @@ func TestUpdateFromDistill_NoSelfLoop_WithDupEntities(t *testing.T) {
|
||||
|
||||
// 去重后唯一实体 = {Docker, Nginx, Tailscale},M=3
|
||||
// 两两循环每对建 2 边(语义 configures + CO_OCCURS)= 2*C(3,2) = 6
|
||||
// DERIVED_FROM = M = 3
|
||||
// (DERIVED_FROM 边已停写 t_348cefc1,不再 +M)
|
||||
const (
|
||||
expectCoEdges = 3 // C(3,2) —— 不丢正常边
|
||||
expectTotal = 9 // 2*C(3,2) + M
|
||||
expectTotal = 6 // 2*C(3,2)(无 DERIVED_FROM)
|
||||
)
|
||||
var selfLoops, coEdges, total int
|
||||
g.mu.RLock()
|
||||
@@ -417,3 +417,175 @@ func TestUpdateFromDistill_SubAgentSessionZeroNodes(t *testing.T) {
|
||||
t.Errorf("llm_distill 来源应 0 节点,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 源头二收口(2026-10-13 t_348cefc1):看板卡 ID + 流程词内容形态 ──
|
||||
// 探针乙实测:t_c6aebb8b / 任务 / 看板过程词汇 / 已结案 / 待发车 全泄漏建节点。
|
||||
// 看板卡 ID(t_<hex>)无条件禁;任务/看板流程词用组合形态拦(不单拦泛词,防误杀领域概念)。
|
||||
|
||||
// AC2:含看板卡 ID + 流程词的探针 → 图谱零新增(领域实体照常进图)。
|
||||
func TestNoiseEntity_KanbanCardIDAndProcessWordsNeverInGraph(t *testing.T) {
|
||||
g := NewInMemoryGraph()
|
||||
up := NewAutoGraphUpdater(g)
|
||||
// 复现探针乙:t_ 卡 ID + 任务/看板流程词;Source 用常规领域来源以证明是内容形态层拦截
|
||||
up.UpdateFromDistill(&DistillInput{
|
||||
EpisodeID: "ep_1791826303408181661",
|
||||
Content: "任务 t_c6aebb8b 与 t_286d1310 均已结案,任务 t_58d86c84 待发车,看板过程词汇整理",
|
||||
Facts: []string{"任务 t_c6aebb8b 与 t_286d1310 均已结案"},
|
||||
Entities: []string{"任务已结案", "任务待发车", "看板过程词汇", "t_c6aebb8b", "t_286d1310", "Docker", "牧尘"},
|
||||
Namespace: "ns_kanban_guard",
|
||||
Source: "wiki:vault-ingest", // 常规来源:专测内容形态穿透
|
||||
})
|
||||
nodes := g.ListNodes("ns_kanban_guard")
|
||||
names := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
names[n["name"].(string)] = true
|
||||
}
|
||||
// 看板卡 ID + 流程词一律不得进图
|
||||
for _, bad := range []string{"任务已结案", "任务待发车", "看板过程词汇", "t_c6aebb8b", "t_286d1310"} {
|
||||
if names[bad] {
|
||||
t.Errorf("内容形态噪声实体 %q 不应进图,实际出现: %v", bad, names)
|
||||
}
|
||||
}
|
||||
// 领域实体照常进图(AC3 假阳性护栏)
|
||||
if !names["Docker"] || !names["牧尘"] {
|
||||
t.Errorf("领域实体 Docker/牧尘 应照常进图,实际: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2 判据层:看板卡 ID 与流程词形态判定。
|
||||
func TestNoiseEntity_KanbanCardIDAndProcessWordDetected(t *testing.T) {
|
||||
// 必拦:看板卡 ID(裸/嵌复合词)+ 流程词组合形态
|
||||
for _, e := range []string{
|
||||
"t_c6aebb8b", "t_286d1310", "t_58d86c84", "t_08a9a0c1",
|
||||
"任务已结案", "任务待发车", "看板过程词汇", "看板卡整理", "kanban_process",
|
||||
"t_c6aebb8b已结案", "任务 t_286d1310 状态",
|
||||
} {
|
||||
if !noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 true(看板卡 ID/流程词)", e)
|
||||
}
|
||||
}
|
||||
// 不误杀:领域概念(尤其含 任务/看板/状态 的合法概念)照常放行
|
||||
for _, e := range []string{"Docker", "方案B", "织忆", "小怡", "看板", "系统状态", "状态机", "archive"} {
|
||||
if noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停写 DERIVED_FROM 后:常规来源蒸馏,facts 不再为图新增 f_/ep_ 目标节点。
|
||||
func TestUpdateFromDistill_NoDerivedFromEdges(t *testing.T) {
|
||||
g := NewInMemoryGraph()
|
||||
up := NewAutoGraphUpdater(g)
|
||||
before := len(g.ListNodes("ns_df"))
|
||||
beforeEdges := len(g.AllEdges("ns_df"))
|
||||
up.UpdateFromDistill(&DistillInput{
|
||||
EpisodeID: "ep_1791826302802227461",
|
||||
Content: "配置 Docker Nginx",
|
||||
Facts: []string{"Docker 容器已启动,配置了 Nginx 反向代理"},
|
||||
Entities: []string{"Docker", "Nginx"},
|
||||
Namespace: "ns_df",
|
||||
Source: "wiki:vault-ingest", // 常规来源:照写实体/关系边,但不建 DERIVED_FROM
|
||||
})
|
||||
nodes := g.ListNodes("ns_df")
|
||||
if len(nodes) != before+2 {
|
||||
t.Errorf("应新增 2 个实体节点(Docker/Nginx,无 f_/ep_ 派生节点),实际 +%d", len(nodes)-before)
|
||||
}
|
||||
edges := g.AllEdges("ns_df")
|
||||
for _, e := range edges {
|
||||
if e.Relation == "DERIVED_FROM" {
|
||||
t.Errorf("DERIVED_FROM 边应已停写,实际出现: %v", e)
|
||||
}
|
||||
}
|
||||
if len(edges) != beforeEdges+2 { // 2 实体 = 1 对 → 1 语义 + 1 CO_OCCURS
|
||||
t.Errorf("应有 2 条关系边(Docker-Nginx 语义+CO_OCCURS),实际 %d 条", len(edges)-beforeEdges)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 补收口三(2026-10-13 t_62f35e62):旧关键词单独立可拦 ──
|
||||
// 上一版把「健康检查/部署流程/测试结果/代码修改/grep」并进组合形态分支,
|
||||
// 必须再配一个流程词才拦,导致这些工具动词单独出现时回退(漏网建节点)。
|
||||
// 修复:拆成两个并列分支——旧关键词恢复「单独可拦」,泛词(看板/任务/kanban)仍只用组合形态。
|
||||
|
||||
// AC1:旧关键词恢复单独可拦(本轮回归修复判据)。
|
||||
func TestNoiseEntity_OldKeywordsSingleMatchRestored(t *testing.T) {
|
||||
for _, e := range []string{
|
||||
"健康检查", "部署流程", "测试结果", "代码修改", "grep",
|
||||
"健康检查与部署流程", "证据:无证据", "ingest dry run", "ingest-dry-run",
|
||||
} {
|
||||
if !noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 true(旧关键词应单独可拦,回归)", e)
|
||||
}
|
||||
}
|
||||
// 组合形态仍拦(不回归)
|
||||
for _, e := range []string{"任务已结案", "看板过程词汇", "kanban_process"} {
|
||||
if !noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 true(流程词组合形态)", e)
|
||||
}
|
||||
}
|
||||
// 不误杀:领域概念照常放行(AC3 假阳性护栏)
|
||||
for _, e := range []string{
|
||||
"鹤壁淇县生物制造产业专班", "飞天农业", "中维化纤", "织忆", "Galway",
|
||||
"Docker", "方案B", "小怡", "看板", "系统状态", "状态机", "archive",
|
||||
} {
|
||||
if noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 补收口三(2026-10-13 t_91af03e9):结构性泛词停用词表(精确匹配整名)──
|
||||
// 探针丙实测:任务/工具动词/判据/PROBE-t62 等结构性泛词单独成实体时仍漏进图。
|
||||
// 旧判据只拦「组合形态」+ 旧关键词,没给「停用词表」——本批补上:
|
||||
// 结构性泛词/探针残留 精确匹配整个实体名即拦(先于现有正则)。
|
||||
// 红线:领域概念(含 4 字以上实体名)不因本表被拦——本表只收短泛词,
|
||||
// 长实体名天然不会精确命中,且「任务状态/状态机/任务已结案」等复合形态照常放行。
|
||||
|
||||
// AC1:上列泛词必须全「拦」(逐词打印 拦/放,供终审核对)。
|
||||
func TestNoiseEntity_StopwordBlocked(t *testing.T) {
|
||||
// 上列泛词:结构性泛词 + 探针/自指残留(工具动词 显式入表,AC1 硬判据)
|
||||
block := []string{
|
||||
"任务", "过程", "状态", "流程", "词汇", "工具", "工具动词",
|
||||
"判据", "结论", "结果", "方案", "内容", "信息", "原因",
|
||||
"问题", "步骤", "阶段", "记录", "清单", "备注", "说明",
|
||||
"甲", "乙", "丙", "探针", "PROBE", "PROBE-t62",
|
||||
}
|
||||
fail := 0
|
||||
for _, e := range block {
|
||||
got := noiseEntity(e)
|
||||
mark := "放"
|
||||
if got {
|
||||
mark = "拦"
|
||||
} else {
|
||||
fail++
|
||||
}
|
||||
t.Logf("%-12s → %s", e, mark)
|
||||
if !got {
|
||||
t.Errorf("noiseEntity(%q) 应为 true(停用词表应拦)", e)
|
||||
}
|
||||
}
|
||||
if fail > 0 {
|
||||
t.Fatalf("停用词表 %d/%d 未拦", fail, len(block))
|
||||
}
|
||||
}
|
||||
|
||||
// AC2:不误杀——7 个红线领域词 + 复合形态照常放行(误杀数必须 = 0)。
|
||||
func TestNoiseEntity_StopwordNoFalseKill(t *testing.T) {
|
||||
keep := []string{
|
||||
// 红线 7 个领域词
|
||||
"鹤壁淇县生物制造产业专班", "飞天农业", "中维化纤", "玉米淀粉深加工",
|
||||
"生物基材料", "织忆", "Galway",
|
||||
// 复合形态(含单字泛词,非精确命中 → 放行;组合形态由 noiseEntityRe 拦)
|
||||
"信息收集", "状态机", "系统状态", "看板", "Docker", "方案B", "小怡", "archive",
|
||||
}
|
||||
kill := 0
|
||||
for _, e := range keep {
|
||||
if noiseEntity(e) {
|
||||
t.Errorf("noiseEntity(%q) 应为 false(领域概念误杀)", e)
|
||||
kill++
|
||||
}
|
||||
}
|
||||
if kill > 0 {
|
||||
t.Fatalf("误杀 %d 个领域概念", kill)
|
||||
}
|
||||
t.Logf("AC2 误杀数 = 0(红线 7 词 + %d 复合形态全放行)", len(keep)-7)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 织忆 MemoryWeave — 图谱扩展(ExpandFromResults)锁死 + 实测(t_818f77be)
|
||||
//
|
||||
// 背景:旧 InMemoryGraph/FileGraph 的 ExpandFromResults 拿 r.Category(类目名,如 "distilled")
|
||||
// 当实体名去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出。修正后导航起点 = 从 r.Content
|
||||
// 提实体(复用 extract*Entities + normalize*EntityID,与 ExpandWithSummary 同一套逻辑)。
|
||||
//
|
||||
// 本文件两类断言:
|
||||
// 1) 锁死(AC#4):构造小图 + 一条 recall ⇒ 结果必含 category=graph_expanded,且 Score=0.5 不变。
|
||||
// 2) 实测(AC#3):同批 10 条 recall ⇒ 补入率 > 0,给出具体数字。
|
||||
// 判读方式:改前树副本跑本测试 ⇒ 两条均得 0(复现结构零);改后树 ⇒ 均 >0。
|
||||
//
|
||||
// 说明:FileGraph.AddNode/AddEdge 存在潜伏死锁(save() 在写锁内又取 RLock ⇒ 非重入 RWMutex 自锁),
|
||||
// 故 FileGraph 用例改走「写 JSON 文件 + NewFileGraph 加载」,绕开该潜伏 bug(不在本卡修复范围)。
|
||||
package governance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// testNS 固定命名空间,用于证明「实体修对 + namespace 对上 ⇒ 补入 >0」,
|
||||
// 以及「namespace 对不上 ⇒ 二次阻塞补 0」(次级怀疑)。
|
||||
const testNS = "t-ns-expand"
|
||||
|
||||
// chainNodes 一条 4 节点链(中文实体,与生产 graph.db 节点命名一致:n_<名字>)。
|
||||
// 抽取器 extractPotentialEntitiesFromContent 对「2~8 字中文串」最可靠(英文带空格提不到),
|
||||
// 故实体一律用中文名;边端点是归一后形态(n_织忆 / n_图谱 / n_召回 / n_向量)。
|
||||
var (
|
||||
chainNodeIDs = []string{"n_织忆", "n_图谱", "n_召回", "n_向量"}
|
||||
chainNodeNames = map[string]string{"n_织忆": "织忆", "n_图谱": "图谱", "n_召回": "召回", "n_向量": "向量"}
|
||||
chainEdges = []struct{ id, src, dst string }{
|
||||
{"e-ab", "n_织忆", "n_图谱"},
|
||||
{"e-bc", "n_图谱", "n_召回"},
|
||||
{"e-cd", "n_召回", "n_向量"},
|
||||
}
|
||||
)
|
||||
|
||||
// addChainInMemory 给 InMemoryGraph 灌入链(AddNode/AddEdge 无死锁,可直接用)。
|
||||
func addChainInMemory(g *InMemoryGraph) {
|
||||
for _, id := range chainNodeIDs {
|
||||
_ = g.AddNode(id, chainNodeNames[id], "entity", testNS)
|
||||
}
|
||||
for _, e := range chainEdges {
|
||||
_ = g.AddEdge(e.id, e.src, e.dst, "LINKS_TO", testNS, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// buildFileGraphJSON 把链写成 JSON 文件,再 NewFileGraph 加载 —— 绕开 AddNode/AddEdge 潜伏死锁。
|
||||
func buildFileGraphJSON(t *testing.T) (*FileGraph, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "graph-expand-test.json")
|
||||
data := FileGraphData{Version: 2}
|
||||
for _, id := range chainNodeIDs {
|
||||
data.Nodes = append(data.Nodes, &FileGraphNode{
|
||||
ID: id, Name: chainNodeNames[id], Type: "entity",
|
||||
Namespace: testNS, PageRank: 0.15, EvidenceCount: 1,
|
||||
})
|
||||
}
|
||||
for _, e := range chainEdges {
|
||||
data.Edges = append(data.Edges, &FileGraphEdge{
|
||||
ID: e.id, Source: e.src, Target: e.dst,
|
||||
Relation: "LINKS_TO", Weight: 1.0, Namespace: testNS,
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(path, mustMarshal(data), 0644); err != nil {
|
||||
t.Fatalf("write filegraph json: %v", err)
|
||||
}
|
||||
fg, err := NewFileGraph(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileGraph: %v", err)
|
||||
}
|
||||
return fg, path
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// tenRecalls 构造 10 条 recall 结果:正文含中文实体「织忆 图谱 召回(±向量)」
|
||||
// (⇒ 归一 n_织忆/n_图谱/n_召回/n_向量,均在图内),类目名保持 "distilled"
|
||||
// (与生产实测一致,证明导航起点绝不能是类目名)。
|
||||
func tenRecalls() []models.RecallResult {
|
||||
contents := []string{
|
||||
"织忆 图谱 召回 的扩展机制",
|
||||
"图谱 与 召回 相关实体",
|
||||
"召回 通过 向量 关联 织忆",
|
||||
"织忆 图谱 召回 向量 的拓扑",
|
||||
"向量 在最末端;织忆 是起点",
|
||||
"recall 提及 织忆 图谱 召回",
|
||||
"链路 织忆 图谱 召回 向量",
|
||||
"织忆 节点 见 于 distilled 记录",
|
||||
"图谱 召回 织忆 共现",
|
||||
"最终 recall:织忆 图谱 召回 向量",
|
||||
}
|
||||
out := make([]models.RecallResult, 0, len(contents))
|
||||
for i, c := range contents {
|
||||
out = append(out, models.RecallResult{
|
||||
ID: "mem_expand_" + string(rune('a'+i)),
|
||||
Content: c,
|
||||
Category: "distilled",
|
||||
Score: 0.9,
|
||||
Namespace: testNS,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// countGraphExpanded 逐条跑 10 条 recall,返回每调用 graph_expanded 条数 + 总和,并校验
|
||||
// 每条补入结果 category=graph_expanded 且 Score=0.5(AC#5:不动 0.5 降权)。
|
||||
func countGraphExpanded(g interface {
|
||||
ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult
|
||||
}, t *testing.T, ns string) (perCall []int, total int) {
|
||||
t.Helper()
|
||||
recalls := tenRecalls()
|
||||
for i, r := range recalls {
|
||||
got := g.ExpandFromResults([]models.RecallResult{r}, ns, 1)
|
||||
for _, e := range got {
|
||||
if e.Category != "graph_expanded" {
|
||||
t.Fatalf("call %d: 补入结果 category=%q 期望 graph_expanded", i, e.Category)
|
||||
}
|
||||
if e.Score != 0.5 {
|
||||
t.Fatalf("call %d: 补入结果 score=%v 期望 0.5(AC#5 不改降权)", i, e.Score)
|
||||
}
|
||||
}
|
||||
perCall = append(perCall, len(got))
|
||||
total += len(got)
|
||||
}
|
||||
return perCall, total
|
||||
}
|
||||
|
||||
// ── 锁死(AC#4):InMemoryGraph ──────────────────────────────────
|
||||
// 改前树必 FAIL(起点=类目 ⇒ 0 补入);改后树 PASS。
|
||||
func TestInMemoryGraph_ExpandFromResults_Inserts(t *testing.T) {
|
||||
g := NewInMemoryGraph()
|
||||
addChainInMemory(g)
|
||||
|
||||
recalls := tenRecalls()
|
||||
got := g.ExpandFromResults(recalls, testNS, 1)
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("InMemoryGraph ExpandFromResults 补 0 条 — 导航起点仍非实体(结构性零产出未修)")
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Category != "graph_expanded" {
|
||||
t.Fatalf("category=%q 期望 graph_expanded", e.Category)
|
||||
}
|
||||
}
|
||||
t.Logf("InMemoryGraph: 一条 10-recall 批量补入 %d 条 graph_expanded", len(got))
|
||||
}
|
||||
|
||||
// ── 锁死(AC#4):FileGraph ─────────────────────────────────────
|
||||
func TestFileGraph_ExpandFromResults_Inserts(t *testing.T) {
|
||||
fg, _ := buildFileGraphJSON(t)
|
||||
|
||||
recalls := tenRecalls()
|
||||
got := fg.ExpandFromResults(recalls, testNS, 1)
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("FileGraph ExpandFromResults 补 0 条 — 导航起点仍非实体(结构性零产出未修)")
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Category != "graph_expanded" {
|
||||
t.Fatalf("category=%q 期望 graph_expanded", e.Category)
|
||||
}
|
||||
}
|
||||
t.Logf("FileGraph: 一条 10-recall 批量补入 %d 条 graph_expanded", len(got))
|
||||
}
|
||||
|
||||
// ── 次级怀疑(AC「namespace 不二次阻塞」)────────────────────────
|
||||
// 实体修对、但传错 namespace ⇒ 应补 0(证明 namespace 是硬约束,需对上才有产出)。
|
||||
func TestInMemoryGraph_ExpandFromResults_NamespaceBlock(t *testing.T) {
|
||||
g := NewInMemoryGraph()
|
||||
addChainInMemory(g)
|
||||
|
||||
got := g.ExpandFromResults(tenRecalls(), "no-such-ns", 1)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("namespace 对不上却补入 %d 条 — 预期 0(namespace 过滤应生效)", len(got))
|
||||
}
|
||||
t.Logf("namespace 不匹配 ⇒ 补 0(证明 namespace 为硬约束;对上即有产出)")
|
||||
}
|
||||
|
||||
// ── 实测(AC#3):同批 10 条 recall 补入率 ──────────────────────
|
||||
// 改前树:perCall 全 0 ⇒ total 0(复现结构零)。改后树:total>0,输出具体数字。
|
||||
func TestExpandFromResults_TenRecall_Empirical(t *testing.T) {
|
||||
gm := NewInMemoryGraph()
|
||||
addChainInMemory(gm)
|
||||
pIn, totalIn := countGraphExpanded(gm, t, testNS)
|
||||
t.Logf("InMemoryGraph 10-recall 补入: per-call=%v total=%d", pIn, totalIn)
|
||||
if totalIn == 0 {
|
||||
t.Fatalf("InMemoryGraph 改后仍补 0(10/10 全 0)— 结构性零产出未消除")
|
||||
}
|
||||
|
||||
fg, _ := buildFileGraphJSON(t)
|
||||
pF, totalF := countGraphExpanded(fg, t, testNS)
|
||||
t.Logf("FileGraph 10-recall 补入: per-call=%v total=%d", pF, totalF)
|
||||
if totalF == 0 {
|
||||
t.Fatalf("FileGraph 改后仍补 0(10/10 全 0)— 结构性零产出未消除")
|
||||
}
|
||||
}
|
||||
@@ -3,44 +3,87 @@ package governance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// graphExpandDebugEnabled 控制「图谱扩展」debug 日志(AC 可观测性,t_818f77be)。
|
||||
// 默认关;设 ZHIYI_GRAPH_EXPAND_DEBUG=1 打开。每次 recall 至多一条(低频),避免刷屏。
|
||||
func graphExpandDebugEnabled() bool {
|
||||
return os.Getenv("ZHIYI_GRAPH_EXPAND_DEBUG") == "1"
|
||||
}
|
||||
|
||||
// ExpandFromResults 从 recall 结果出发,双向 BFS 扩展图谱邻接节点
|
||||
// 实现 GraphStore 接口
|
||||
//
|
||||
// 导航起点 = 从 r.Content 提实体(复用 extractPotentialEntitiesFromContent + normalizeEntityID,
|
||||
// 与同文件 ExpandWithSummary 同一套逻辑)。旧实现拿 r.Category(类目名,如 "distilled")
|
||||
// 当实体去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出(t_818f77be 实测 10/10 全 distilled)。
|
||||
// 且 InMemoryGraph.Navigate 返回字段是 source/target(非 to/from),旧读法 p["to"]/p["from"]
|
||||
// 恒取到空 ⇒ 即便实体修对也补 0。两处一并修正。
|
||||
func (g *InMemoryGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
|
||||
var expanded []models.RecallResult
|
||||
seen := make(map[string]bool)
|
||||
seenEntities := make(map[string]bool)
|
||||
|
||||
// 收集已有结果 ID
|
||||
for _, r := range results {
|
||||
seen[r.ID] = true
|
||||
}
|
||||
|
||||
// 从每个结果出发扩展
|
||||
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
|
||||
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
|
||||
rejectedJunk := 0 // P0-4:被质量闸①丢弃的垃圾实体数
|
||||
rejectedLowDegree := 0 // P0-4:被质量闸②丢弃的低度数实体数
|
||||
// 从每个结果正文提实体出发扩展
|
||||
for _, r := range results {
|
||||
paths, err := g.Navigate(r.Category, maxHops, namespace, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths {
|
||||
to, _ := p["to"].(string)
|
||||
from, _ := p["from"].(string)
|
||||
entities := extractPotentialEntitiesFromContent(r.Content)
|
||||
// P0-4 第①道闸:垃圾实体不导航
|
||||
entities, rejected := FilterEntitiesForGraph(entities)
|
||||
rejectedJunk += rejected
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
}
|
||||
seenEntities[entity] = true
|
||||
startEntities++
|
||||
|
||||
for _, id := range []string{to, from} {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
expanded = append(expanded, models.RecallResult{
|
||||
ID: id,
|
||||
Category: "graph_expanded",
|
||||
Score: 0.5, // 图谱扩展降权
|
||||
})
|
||||
nodeID := normalizeEntityID(entity)
|
||||
paths, err := g.Navigate(nodeID, maxHops, namespace, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点,不构成语义关联(内存图无 degree 查询,用路径数代理)
|
||||
if len(paths) < 2 {
|
||||
rejectedLowDegree++
|
||||
continue
|
||||
}
|
||||
totalPathsHit += len(paths)
|
||||
for _, p := range paths {
|
||||
to, _ := p["target"].(string)
|
||||
from, _ := p["source"].(string)
|
||||
|
||||
for _, id := range []string{to, from} {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
expanded = append(expanded, models.RecallResult{
|
||||
ID: id,
|
||||
Category: "graph_expanded",
|
||||
Score: 0.5, // 图谱扩展降权
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
|
||||
if graphExpandDebugEnabled() {
|
||||
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
|
||||
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ package governance
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -457,32 +458,63 @@ func (fg *FileGraph) UpdateEdgeTrustScores() error { return
|
||||
func (fg *FileGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
|
||||
var expanded []models.RecallResult
|
||||
seen := make(map[string]bool)
|
||||
seenEntities := make(map[string]bool)
|
||||
|
||||
for _, r := range results {
|
||||
seen[r.ID] = true
|
||||
}
|
||||
|
||||
// 导航起点 = 从 r.Content 提实体(复用 extractFileGraphEntities + normalizeFileGraphEntityID,
|
||||
// 与同文件 ExpandWithSummary 同一套逻辑)。旧实现拿 r.Category(类目名,如 "distilled")
|
||||
// 当实体去 Navigate,永远匹配不到任何图节点 ⇒ 结构性零产出(t_818f77be)。
|
||||
startEntities := 0 // 可观测性:去重后实际导航的起点实体数 N
|
||||
totalPathsHit := 0 // 可观测性:命中路径总数 M(补前去重)
|
||||
rejectedJunk := 0 // P0-4:质量闸①拒绝数
|
||||
rejectedLowDegree := 0 // P0-4:质量闸②拒绝数
|
||||
for _, r := range results {
|
||||
paths, err := fg.Navigate(r.Category, maxHops, namespace, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths {
|
||||
target, _ := p["target"].(string)
|
||||
source, _ := p["source"].(string)
|
||||
entities := extractFileGraphEntities(r.Content)
|
||||
entities, rejected := FilterEntitiesForGraph(entities)
|
||||
rejectedJunk += rejected
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
}
|
||||
seenEntities[entity] = true
|
||||
startEntities++
|
||||
|
||||
for _, id := range []string{target, source} {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
expanded = append(expanded, models.RecallResult{
|
||||
ID: id,
|
||||
Category: "graph_expanded",
|
||||
Score: 0.5,
|
||||
})
|
||||
nodeID := normalizeFileGraphEntityID(entity)
|
||||
paths, err := fg.Navigate(nodeID, maxHops, namespace, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// P0-4 第②道闸:路径数 <2 ⇒ 孤边节点不注入
|
||||
if len(paths) < 2 {
|
||||
rejectedLowDegree++
|
||||
continue
|
||||
}
|
||||
totalPathsHit += len(paths)
|
||||
for _, p := range paths {
|
||||
target, _ := p["target"].(string)
|
||||
source, _ := p["source"].(string)
|
||||
|
||||
for _, id := range []string{target, source} {
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
expanded = append(expanded, models.RecallResult{
|
||||
ID: id,
|
||||
Category: "graph_expanded",
|
||||
Score: 0.5,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 可观测性:默认关(ZHIYI_GRAPH_EXPAND_DEBUG=1 打开),保证「下次坏了不沉默」。
|
||||
if graphExpandDebugEnabled() {
|
||||
log.Printf("[graph-expand] ns=%s maxHops=%d 起点实体 %d 个 / 命中 %d 条 / 补入 %d 条 / 质量闸拒 垃圾 %d 低度 %d",
|
||||
namespace, maxHops, startEntities, totalPathsHit, len(expanded), rejectedJunk, rejectedLowDegree)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package governance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -295,6 +296,22 @@ func (g *InMemoryGraph) AllEdges(namespace string) []GraphEdge {
|
||||
return out
|
||||
}
|
||||
|
||||
// CountNodesByType 内存实现:无时间列,lastUpdated 恒为空(调用方按 unknown_age 处理)。
|
||||
func (g *InMemoryGraph) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
for _, n := range g.nodes {
|
||||
if n.Type != typ {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if strings.TrimSpace(n.ID) != "" {
|
||||
withID++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Stats 返回图谱统计
|
||||
func (g *InMemoryGraph) Stats() (nodeCount, edgeCount int, density float64) {
|
||||
g.mu.RLock()
|
||||
|
||||
@@ -16,6 +16,7 @@ import "C"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -216,11 +217,33 @@ func (gs *SQLiteGraphStore) AddEdge(id, source, target, relation, namespace stri
|
||||
return execSQL(gs.db, sql)
|
||||
}
|
||||
|
||||
func (gs *SQLiteGraphStore) isQuarantinedNode(nodeID string) (bool, error) {
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT namespace, pagerank FROM graph_nodes WHERE id='%s'", escape(nodeID))
|
||||
rows := queryRows(gs.db, sql)
|
||||
if len(rows) == 0 {
|
||||
// node not found; treat as not quarantined to avoid breaking existing flows
|
||||
return false, nil
|
||||
}
|
||||
row := rows[0]
|
||||
namespace, _ := row["namespace"].(string)
|
||||
pagerankVal, _ := row["pagerank"].(float64)
|
||||
return namespace == "quarantine" && pagerankVal == 0, nil
|
||||
}
|
||||
|
||||
func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace string, relFilter []string) ([]map[string]interface{}, error) {
|
||||
// 单源 BFS:从 entity 展开到邻居,不找路径
|
||||
// E1.4: relFilter 白名单过滤关系类型
|
||||
gs.mu.RLock()
|
||||
defer gs.mu.RUnlock()
|
||||
if quarantined, err := gs.isQuarantinedNode(entity); err != nil {
|
||||
// If we cannot determine, treat as not quarantined to avoid breaking existing flows.
|
||||
if err != nil {
|
||||
// Continue; assume not quarantined.
|
||||
} else if quarantined {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
nsClause := buildNamespaceClause(namespace)
|
||||
relClause := buildRelationFilterClause(relFilter)
|
||||
@@ -233,7 +256,7 @@ func (gs *SQLiteGraphStore) Navigate(entity string, maxHops int, namespace strin
|
||||
var next []string
|
||||
for _, node := range queue {
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s",
|
||||
"SELECT e.id, e.target, e.relation, e.weight, n.name FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
|
||||
escape(node), nsClause, relClause)
|
||||
edges := queryRows(gs.db, sql)
|
||||
for _, edge := range edges {
|
||||
@@ -269,6 +292,20 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
|
||||
|
||||
gs.mu.RLock()
|
||||
defer gs.mu.RUnlock()
|
||||
if quarantinedSrc, err := gs.isQuarantinedNode(source); err != nil {
|
||||
if err != nil {
|
||||
// Continue; assume not quarantined.
|
||||
} else if quarantinedSrc {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
}
|
||||
if quarantinedTgt, err := gs.isQuarantinedNode(target); err != nil {
|
||||
if err != nil {
|
||||
// Continue; assume not quarantined.
|
||||
} else if quarantinedTgt {
|
||||
return []map[string]interface{}{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
nsClause := buildNamespaceClause(namespace)
|
||||
|
||||
@@ -310,7 +347,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
|
||||
var next []string
|
||||
for _, node := range fwdQ {
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e WHERE e.source = '%s' AND %s",
|
||||
"SELECT e.id, e.target, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.target = n.id WHERE e.source = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
|
||||
escape(node), nsClause)
|
||||
edges := queryRows(gs.db, sql)
|
||||
for _, edge := range edges {
|
||||
@@ -340,7 +377,7 @@ func (gs *SQLiteGraphStore) NavigateBiDir(source, target string, maxHops int, na
|
||||
for _, node := range bwdQ {
|
||||
// 反向要找所有指向 node 的边(target = node)
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e WHERE e.target = '%s' AND %s",
|
||||
"SELECT e.id, e.source, e.relation, e.weight FROM graph_edges e JOIN graph_nodes n ON e.source = n.id WHERE e.target = '%s' AND %s AND n.namespace != 'quarantine' AND n.pagerank > 0",
|
||||
escape(node), nsClause)
|
||||
edges := queryRows(gs.db, sql)
|
||||
for _, edge := range edges {
|
||||
@@ -548,6 +585,32 @@ func (gs *SQLiteGraphStore) Stats() (nodeCount, edgeCount int, density float64)
|
||||
return
|
||||
}
|
||||
|
||||
// CountNodesByType 返回某类节点的总量、id 非空量、最近一次更新时间(2026-10-26)。
|
||||
//
|
||||
// 存在的理由:selfcheck 需要暴露「pattern 召回有没有生产者」这一可观测事实。
|
||||
// 事故背景:`POST /api/v1/patterns/detect` 是短命单发端点,全库 0 cron、0 调用方,
|
||||
// 于是 graph_nodes(type='pattern') 恒 0 行、recall 恒 0 命中,而日志层面完全沉默。
|
||||
// 零产出必须能被机器读到,不能靠人翻 7 天日志才发现。
|
||||
func (gs *SQLiteGraphStore) CountNodesByType(typ string) (total, withID int, lastUpdated string) {
|
||||
gs.mu.RLock()
|
||||
defer gs.mu.RUnlock()
|
||||
|
||||
t := escape(typ)
|
||||
total = queryInt(gs.db, fmt.Sprintf("SELECT COUNT(*) FROM graph_nodes WHERE type='%s'", t))
|
||||
withID = queryInt(gs.db, fmt.Sprintf(
|
||||
"SELECT COUNT(*) FROM graph_nodes WHERE type='%s' AND id IS NOT NULL AND TRIM(id)<>''", t))
|
||||
rows := queryRows(gs.db, fmt.Sprintf(
|
||||
"SELECT MAX(last_updated_at) AS m FROM graph_nodes WHERE type='%s'", t))
|
||||
if len(rows) > 0 && rows[0]["m"] != nil {
|
||||
if v, ok := rows[0]["m"].(string); ok {
|
||||
lastUpdated = v
|
||||
} else {
|
||||
lastUpdated = fmt.Sprintf("%v", rows[0]["m"])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (gs *SQLiteGraphStore) Prune(minWeight float64) {
|
||||
gs.mu.Lock()
|
||||
defer gs.mu.Unlock()
|
||||
@@ -755,6 +818,8 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
|
||||
|
||||
seen := make(map[string]bool) // 追踪已访问的记忆 ID
|
||||
seenEntities := make(map[string]bool) // 追踪已访问的实体(用于去重扩展结果)
|
||||
rejectedJunk := 0
|
||||
rejectedLowDegree := 0
|
||||
for _, r := range results {
|
||||
if seen[r.ID] {
|
||||
continue
|
||||
@@ -762,6 +827,9 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
|
||||
seen[r.ID] = true
|
||||
|
||||
entities := extractPotentialEntities(r.Content)
|
||||
// P0-4 第①道闸:垃圾实体(虚词/量词/代词/填充短语)不导航
|
||||
entities, rejected := FilterEntitiesForGraph(entities)
|
||||
rejectedJunk += rejected
|
||||
for _, entity := range entities {
|
||||
if seenEntities[entity] {
|
||||
continue
|
||||
@@ -769,18 +837,25 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
|
||||
seenEntities[entity] = true
|
||||
|
||||
// SQLite 图谱节点 ID 格式: n_{entity_name},需 normalizeEntityID 转换
|
||||
// Navigate 的 from/to 都是**节点 ID**(n_织忆),不能用裸名 entity 比对,
|
||||
// 否则恒不相等 ⇒ 路径全被 continue ⇒ 结构性 0 条附加(t_be4d070a 诊断)。
|
||||
nodeID := normalizeEntityID(entity)
|
||||
// P0-4 第②道闸:度数 <2 的孤边节点不构成语义关联 ⇒ 不导航、不注入。
|
||||
if gs.EdgeDegree(nodeID) < 2 {
|
||||
rejectedLowDegree++
|
||||
continue
|
||||
}
|
||||
paths, _ := gs.Navigate(nodeID, maxHops, namespace, nil)
|
||||
for _, p := range paths {
|
||||
// Navigate 返回字段: from, to, relation, weight, hop
|
||||
// Navigate 返回字段: from, to, relation, weight, hop(均为节点 ID)
|
||||
var neighbor, rel string
|
||||
from, _ := p["from"].(string)
|
||||
to, _ := p["to"].(string)
|
||||
relVal, _ := p["relation"].(string)
|
||||
if from == entity && to != "" {
|
||||
if from == nodeID && to != "" {
|
||||
neighbor = to
|
||||
rel = relVal
|
||||
} else if to == entity && from != "" {
|
||||
} else if to == nodeID && from != "" {
|
||||
neighbor = from
|
||||
rel = "↩ " + relVal
|
||||
}
|
||||
@@ -792,15 +867,62 @@ func (gs *SQLiteGraphStore) ExpandFromResults(results []models.RecallResult, nam
|
||||
}
|
||||
seenEntities[neighbor] = true
|
||||
expanded = append(expanded, models.RecallResult{
|
||||
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, neighbor),
|
||||
// ID 用邻居节点 ID(n_<实体> 或 n_mem_<记忆id>),非空才不破坏 recall.go 的
|
||||
// ID 去重(Step 5 按 r.ID==e.ID 判已存在);若邻居是记忆节点,ID 即真实记忆指针。
|
||||
ID: neighbor,
|
||||
Category: "graph_expanded",
|
||||
Content: fmt.Sprintf("[graph] %s --[%s]--> %s", entity, rel, displayNodeName(neighbor)),
|
||||
Score: r.Score * 0.5,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// P0-4 可观测性:两道闸的拒绝量必须能读出来(默认关,debug 开才打印,避免刷屏)。
|
||||
if graphExpandDebugEnabled() && (rejectedJunk > 0 || rejectedLowDegree > 0) {
|
||||
log.Printf("[graph-expand] ns=%s 质量闸:垃圾实体拒 %d / 低度数拒 %d / 补入 %d",
|
||||
namespace, rejectedJunk, rejectedLowDegree, len(expanded)-len(results))
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
// EdgeDegree 返回节点在图中的边数(入度+出度,P0-4 度数闸用)。
|
||||
// 与 GetEntityDegree 的区别:后者返回 evidence_count 之和(证据强度),
|
||||
// 本函数才是字面意义的「度数」—— 孤边节点(度=1)不足以构成语义关联。
|
||||
func (gs *SQLiteGraphStore) EdgeDegree(nodeID string) int {
|
||||
if nodeID == "" {
|
||||
return 0
|
||||
}
|
||||
gs.mu.RLock()
|
||||
defer gs.mu.RUnlock()
|
||||
rows := queryRows(gs.db, fmt.Sprintf(
|
||||
"SELECT COUNT(*) as c FROM graph_edges WHERE source = '%s' OR target = '%s'",
|
||||
escape(nodeID), escape(nodeID)))
|
||||
if len(rows) == 0 {
|
||||
return 0
|
||||
}
|
||||
switch v := rows[0]["c"].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// displayNodeName 把节点 ID 转成可读显示名(去 n_ / n_mem_ / mem_ 前缀)。
|
||||
// 图节点 ID 形态:实体 n_织忆 / 概念 n_mem_<记忆id>(name 列才是 mem_<id>)。
|
||||
// 仅用于格式化 Content 的可读展示,不做任何记忆正文编造。
|
||||
func displayNodeName(nodeID string) string {
|
||||
for _, p := range []string{"n_mem_", "n_", "mem_", "ep_"} {
|
||||
if strings.HasPrefix(nodeID, p) {
|
||||
return strings.TrimPrefix(nodeID, p)
|
||||
}
|
||||
}
|
||||
return nodeID
|
||||
}
|
||||
|
||||
// ExpandWithSummary BFS 扩展 + 生成汇总语句 — E1 图谱导航增强
|
||||
// 从 recall 结果提取实体,进行多跳扩展,返回扩展关系列表和一句话汇总
|
||||
func (gs *SQLiteGraphStore) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user