Compare commits
273
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd3793336 | ||
|
|
d8c525b544 | ||
|
|
93a11c2fb7 | ||
|
|
1774c310b1 | ||
|
|
75abce7cca | ||
|
|
826272f981 | ||
|
|
3102be6f65 | ||
|
|
7870b849cc | ||
|
|
afdab8fb6b | ||
|
|
3964f0332c | ||
|
|
7562714819 | ||
|
|
99cb22ba93 | ||
|
|
5709af0bfa | ||
|
|
f178594402 | ||
|
|
ca6be0063b | ||
|
|
40c27f4a95 | ||
|
|
ff92b0578a | ||
|
|
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 | ||
|
|
6e21eef6da | ||
|
|
5b9f75cc7f | ||
|
|
ba81121efa | ||
|
|
5565ec8a9b | ||
|
|
95bb930bd6 | ||
|
|
e900e4073f | ||
|
|
66d8e32bc6 | ||
|
|
13ced12ce9 | ||
|
|
227b2979dd | ||
|
|
cb450e9c9d | ||
|
|
28f6951061 | ||
|
|
9b7b969cd3 | ||
|
|
dfc6f580eb | ||
|
|
a39e5c7e23 | ||
|
|
ce38465dbd | ||
|
|
a162e2769e | ||
|
|
13cf7d2088 | ||
|
|
c34178c3c3 | ||
|
|
cbbdd9a708 | ||
|
|
6e752aa904 | ||
|
|
63ceb4a11c | ||
|
|
e9fce11a1b | ||
|
|
6db0ffa7d4 | ||
|
|
0e4fb59e1a | ||
|
|
b551c55011 | ||
|
|
aa8f503ae0 | ||
|
|
82ef904257 | ||
|
|
886c0c20e2 | ||
|
|
31bdf3403b | ||
|
|
2a8fc6494e | ||
|
|
f0cd747873 | ||
|
|
fe8126dd85 | ||
|
|
0634e4eb1d | ||
|
|
ba3ccd3e12 | ||
|
|
e98565f0c9 | ||
|
|
90af029c16 | ||
|
|
d472996f80 | ||
|
|
9bfe4c217f | ||
|
|
1d707977a2 | ||
|
|
f4d5d2174d | ||
|
|
23388679b9 | ||
|
|
a187275e1b | ||
|
|
5062b380fc | ||
|
|
ca45c96e32 | ||
|
|
c4b4aad8e4 | ||
|
|
fc7b8e74bd | ||
|
|
57c55a7cca | ||
|
|
97bd6afe9f | ||
|
|
1c16cfbdb1 | ||
|
|
2633f59f39 | ||
|
|
4fa2e11b78 | ||
|
|
631241c50a | ||
|
|
66cb258979 | ||
|
|
f26126765c | ||
|
|
c7235afcd5 | ||
|
|
46eaf1a35d | ||
|
|
5944df41b5 | ||
|
|
9d9b92df27 | ||
|
|
7904a238ed | ||
|
|
a76847b5d7 | ||
|
|
db0395eb15 | ||
|
|
88f610aa1e | ||
|
|
8026142a08 | ||
|
|
fee8baaefb | ||
|
|
33952ff4d3 | ||
|
|
41ecdaa5ea | ||
|
|
4fb07994df | ||
|
|
840881e476 | ||
|
|
a7d6625aeb | ||
|
|
7c0affde65 | ||
|
|
cb9a182c77 | ||
|
|
003f43834d | ||
|
|
2150e94cb8 | ||
|
|
67cb262863 | ||
|
|
5450f29740 | ||
|
|
f50f08d24c | ||
|
|
19afe3bfe2 | ||
|
|
4f947cecef | ||
|
|
36cbb51019 | ||
|
|
c7ceb11cda | ||
|
|
3a3e019c03 | ||
|
|
e01181e40f | ||
|
|
ffaa3de14d | ||
|
|
5250eb1c2e | ||
|
|
b7fae4c23b | ||
|
|
4dfe8195a9 | ||
|
|
e1985dfe32 | ||
|
|
5b4f7cd95a | ||
|
|
0b765ccc8b | ||
|
|
41e95aa36d | ||
|
|
5eccbaef03 | ||
|
|
c6be6a4cd5 | ||
|
|
d4d5db266a | ||
|
|
1060e62066 | ||
|
|
1b937cc990 | ||
|
|
266a85a5d2 | ||
|
|
1b2bd417b2 | ||
|
|
2d38d8d120 | ||
|
|
84c21803c0 | ||
|
|
3b8ecba48e | ||
|
|
daab83337b | ||
|
|
571eee0d9e | ||
|
|
cff87730b2 | ||
|
|
b5f59703ab | ||
|
|
6e67f3e33f | ||
|
|
ece2da12ac | ||
|
|
6c895618f9 | ||
|
|
fd699151a8 | ||
|
|
9188b9e5c9 | ||
|
|
dd25457926 | ||
|
|
cdf06312a4 | ||
|
|
ec17c836a1 | ||
|
|
4af85fa093 | ||
|
|
69602ddd08 | ||
|
|
0d4add1fb2 | ||
|
|
835b308df6 | ||
|
|
3e8157307e | ||
|
|
ab974d0bed | ||
|
|
21e51eb7f6 | ||
|
|
497802cd62 | ||
|
|
3ab4ab01bd | ||
|
|
962489f20c | ||
|
|
6d4cbc79ca | ||
|
|
dbe6c278c5 | ||
|
|
be120f3c07 | ||
|
|
ee50ef0d59 | ||
|
|
68ce2c9678 | ||
|
|
bea2849494 | ||
|
|
41230ccb0a | ||
|
|
f2362150d1 | ||
|
|
2506291ff6 | ||
|
|
e91ca91367 | ||
|
|
853a6afad9 | ||
|
|
310a98da4e | ||
|
|
cdf902b5c0 | ||
|
|
d454cae849 | ||
|
|
29c3a7bb30 | ||
|
|
a69f07c898 | ||
|
|
a7082d381a | ||
|
|
4a245b6262 | ||
|
|
f1859f2ff9 | ||
|
|
d4343b1c47 | ||
|
|
e6b1e0417c |
@@ -0,0 +1,14 @@
|
||||
fix(provenance): 落库 embedding_model/script_version 血缘字段 (t_7e3577c5)
|
||||
|
||||
根因: commit→distill→memory 管线从不在服务端持久化请求 metadata 里的
|
||||
embedding_model/script_version, MemoryRecord 结构缺这两字段, 导致
|
||||
zhiyi-vault-ingest.py 已发送的血缘被丢弃, 新写入恒 NULL。
|
||||
|
||||
修复:
|
||||
- Go MemoryRecord + DistillInput 加两字段;
|
||||
core.go commit handler 解析 metadata→AutoDistillTrigger 透传;
|
||||
server.go 落库时 orDefault(embedding_model→bge-m3, script_version→unknown);
|
||||
lancedb_ipc.go 记录 map 带上两字段。
|
||||
- Rust MemoryRecord + arrow_schema() + insert_batch + 4 处 col_str 读取。
|
||||
|
||||
验证: commit 带 metadata → 蒸馏落地 19 行 bge-m3/vault-ingest-2.0.0 (原为 NULL)。
|
||||
+17
@@ -32,3 +32,20 @@ backups/
|
||||
go/build.sh
|
||||
plugins/obsidian/node_modules/
|
||||
bin/
|
||||
# 构建产物(worker 曾把 7.9M 二进制留在工作区,2026-09-21)
|
||||
go/zhiyi-cli
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# ─── 变量 ────────────────────────────────────────────
|
||||
GO_CMD := go
|
||||
RUST_CMD := cargo
|
||||
GOFMT_CMD := gofmt
|
||||
GO_DIR := go
|
||||
RUST_DIR := rust
|
||||
BINARY_GO := $(GO_DIR)/cmd/zhiyid/zhiyid
|
||||
@@ -81,6 +82,15 @@ deploy-compose: docker-build
|
||||
test:
|
||||
cd $(GO_DIR) && $(GO_CMD) test ./... -v -count=1
|
||||
|
||||
# 格式校验:gofmt 不合规即报错(gofmt -l 列出文件 → 非空则 exit 1)
|
||||
fmt-check:
|
||||
@cd $(GO_DIR) && $(GOFMT_CMD) -l . | tee /tmp/.gofmt_files > /dev/null; \
|
||||
if [ -s /tmp/.gofmt_files ]; then \
|
||||
echo "❌ gofmt 不合规文件($(GOFMT_CMD) -l . 非空):"; \
|
||||
$(GOFMT_CMD) -l . ; \
|
||||
echo " 修复: cd $(GO_DIR) && $(GOFMT_CMD) -w ."; exit 1; \
|
||||
else echo "✅ gofmt 合规(0 文件)"; fi
|
||||
|
||||
test-go: test
|
||||
|
||||
test-rust:
|
||||
@@ -126,18 +136,20 @@ ci-eval:
|
||||
|
||||
# ─── 部署 ────────────────────────────────────────────
|
||||
|
||||
deploy: build-go
|
||||
sudo cp $(BINARY_GO) /usr/local/bin/zhiyid
|
||||
sudo cp deploy/zhiyid.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart zhiyid
|
||||
# 统一部署:构建两 + 装两 + 重启两 + 校验(scripts/deploy.sh,用户级 systemd)
|
||||
# 2026-10-08 t_f57b8bbf:替换旧的 /usr/local/bin 系统级过期目标(与实际用户级部署完全不符)
|
||||
# deploy.sh 内部完成全部(build+install+restart+verify),脚本可用 --verify-only 单跑校验
|
||||
deploy:
|
||||
./scripts/deploy.sh
|
||||
|
||||
deploy-rust: build-rust
|
||||
sudo cp $(BINARY_RUST) /usr/local/bin/zhiyi-consolidate
|
||||
sudo cp deploy/zhiyi-consolidate.service /etc/systemd/system/
|
||||
sudo cp deploy/zhiyi-consolidate.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now zhiyi-consolidate.timer
|
||||
# 只跑校验(不构建/不安装/不重启)——反例测试/人工体检
|
||||
deploy-verify:
|
||||
./scripts/deploy.sh --verify-only
|
||||
|
||||
# ⚠️ 已废弃(2026-10-08 t_f57b8bbf):旧 oneshot+timer 设计残留,实际 sidecar 是 simple 常驻,
|
||||
# 由 zhiyid.service 的 Wants= + 其 PartOf= 生命周期耦合管理,不再单独装 timer/系统级 unit。
|
||||
deploy-rust:
|
||||
@echo "已废弃:sidecar 由 scripts/deploy.sh 统一部署(simple 常驻),勿用旧的 oneshot+timer 系统级目标"
|
||||
|
||||
deploy-nginx:
|
||||
sudo cp deploy/nginx-zhiyi.conf /etc/nginx/sites-enabled/zhiyi.conf
|
||||
|
||||
@@ -426,7 +426,10 @@ python3 -c "from plugins.memory.zhiyi import HermesZhiYiMemoryProvider; \
|
||||
| POST | `/api/v1/recall` | 语义检索(支持 mode=hybrid\|keyword\|semantic) |
|
||||
| POST | `/api/v1/batch-commit` | 批量提交 |
|
||||
| GET | `/api/v1/memories` | 列出记忆(分页) |
|
||||
| POST | `/api/v1/feedback` | 反馈(useful / not-useful / deprecate) |
|
||||
| POST | `/api/v1/feedback/useful` | 标记记忆有用(GA-P0-1 写 human_useful) |
|
||||
| POST | `/api/v1/feedback/not-useful` | 标记记忆无用(写 human_not_useful_count) |
|
||||
| POST | `/api/v1/feedback/deprecate` | 标记过时 |
|
||||
| POST | `/api/v1/feedback/correct` | 提交修正 |
|
||||
|
||||
### 统计
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@ import urllib.error
|
||||
|
||||
|
||||
DEFAULT_BASE = "http://localhost:7821"
|
||||
DEFAULT_KEY = "zhiyi-dev-key-2026"
|
||||
def _default_key():
|
||||
try:
|
||||
for _l in open("/var/lib/memoryweave/.api-key"):
|
||||
_s=_l.strip()
|
||||
if _s and not _s.startswith("#") and not _s.startswith("legacy:"): return _s
|
||||
except Exception: pass
|
||||
return ""
|
||||
DEFAULT_KEY = _default_key()
|
||||
|
||||
|
||||
class ZhiYiClient:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -16,7 +16,7 @@ mkdir -p "$BACKUP_DIR"
|
||||
# 1. 调用 API 触发备份
|
||||
echo "[backup] Triggering admin/backup API..."
|
||||
curl -s -X POST http://localhost:7821/api/v1/admin/backup \
|
||||
-H "X-API-Key: zhiyi-dev-key-2026" > /dev/null 2>&1 || true
|
||||
-H "X-API-Key: $ZHIYI_KEY" > /dev/null 2>&1 || true
|
||||
|
||||
# 2. LanceDB checkpoint + SQLite + Redis
|
||||
echo "[backup] Creating archive..."
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
# 织忆 MemoryWeave Rust IPC Sidecar (zhiyi-consolidate) — 用户级 systemd unit 单一真相源(t_f57b8bbf 对齐)
|
||||
# 实际安装落点:~/.config/systemd/user/zhiyi-consolidate.service
|
||||
# 运行事实(勿与过期全局版混淆):
|
||||
# - 二进制 /home/muc/bin/zhiyi-consolidate(非 /usr/local/bin)
|
||||
# - 参数 --mode socket(非旧 --socket)
|
||||
# - 数据目录 /var/lib/memoryweave(非 .../lancedb 子目录)
|
||||
# - systemd 用户级;sidecar 是 simple 常驻(非旧的 oneshot+timer 设计)
|
||||
# - 生命周期 20-partof-zhiyid.conf drop-in 加 PartOf=zhiyid.service ⇒ 重启 zhiyid 时 sidecar 跟着重启
|
||||
# - ExecStartPre=-rm -f /tmp/zhiyi-ipc.sock 清理残留 socket(防 EADDRINUSE)
|
||||
# 其它 drop-in:10-malloc.conf / limit.conf(见 ~/.config/systemd/user/zhiyi-consolidate.service.d/)
|
||||
|
||||
[Unit]
|
||||
Description=ZhiYi Consolidation Engine (Rust LanceDB)
|
||||
Description=MemoryWeave Rust IPC Sidecar (zhiyi-consolidate)
|
||||
After=network.target
|
||||
PartOf=zhiyid.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=muc
|
||||
ExecStart=/usr/local/bin/zhiyi-consolidate --socket /tmp/zhiyi-ipc.sock --data-dir /var/lib/memoryweave/lancedb
|
||||
Environment="RUST_LOG=info"
|
||||
ExecStartPre=/bin/bash -c 'if [ ! -f /home/muc/bin/zhiyi-consolidate ]; then cp /tmp/memoryweave/rust/target/release/zhiyi-consolidate /home/muc/bin/ 2>/dev/null || true; fi'
|
||||
ExecStartPre=-rm -f /tmp/zhiyi-ipc.sock
|
||||
ExecStart=/home/muc/bin/zhiyi-consolidate --mode socket --socket /tmp/zhiyi-ipc.sock --data-dir /var/lib/memoryweave
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# 2026-09-23:原为 append:/tmp/zhiyi-sidecar.log(无轮转 → 涨到 63M/95万行)
|
||||
# 改为 journald,由 systemd 统一管理与限额(journalctl -u zhiyi-consolidate)
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=zhiyi-consolidate
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=default.target
|
||||
+35
-21
@@ -1,30 +1,44 @@
|
||||
# 织忆 MemoryWeave Go Daemon — 用户级 systemd unit 单一真相源(t_f57b8bbf 对齐)
|
||||
# 实际安装落点:~/.config/systemd/user/zhiyid.service(本文件是仓库内的脱敏母版,安装/比对用)
|
||||
#
|
||||
# ⚠️ 脱敏说明:仓库历史里曾含 3 个明文密钥(API_KEY / MOLIFANG_API_KEY / LLM_API_KEY),
|
||||
# 已随 git 推到 Gitea origin/main。本文件为脱敏版,不携带任何真实密钥;
|
||||
# 真实密钥/端点由运行环境注入。密钥清理属独立专项(本卡不碰 git 历史)。
|
||||
#
|
||||
# 运行事实(勿与过期全局版混淆):
|
||||
# - 二进制 /home/muc/bin/zhiyid-new(install 装,非 /usr/local/bin)
|
||||
# - systemd 用户级 ~/.config/systemd/user/(非 /etc/systemd/system 全局)
|
||||
# - 数据目录 /var/lib/memoryweave
|
||||
# - sidecar zhiyi-consolidate 是独立 unit,本 unit 用 Wants=(单向拉起)+ 其侧 PartOf= 回连重启
|
||||
# 构建产物 go/cmd/zhiyid/zhiyid(gitignored)→ install 到 /home/muc/bin/zhiyid-new
|
||||
|
||||
[Unit]
|
||||
Description=ZhiYi MemoryWeave Go Service (织忆)
|
||||
After=network.target redis-server.service
|
||||
Wants=redis-server.service
|
||||
Description=ZhiYi MemoryWeave (织忆) — Go Daemon
|
||||
Documentation=http://192.168.123.11:3000/xiaoxue_admin/memoryweave
|
||||
After=network.target zhiyi-consolidate.service bge-proxy.service
|
||||
Wants=zhiyi-consolidate.service bge-proxy.service
|
||||
# 2026-09-05 按需化: embedding 走 8000 bge-proxy(远端104优先→本地8001按需拉起)
|
||||
# 原 Wants bge-embed.service 已移除(本地 fallback 不常驻)
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=muc
|
||||
WorkingDirectory=/home/muc/projects/memoryweave/go
|
||||
Environment=PORT=7821
|
||||
Environment=STORAGE_BACKEND=lancedb
|
||||
Environment=SQLITE_PATH=/var/lib/memoryweave/memoryweave.db
|
||||
Environment=API_KEY=zhiyi-dev-key-2026
|
||||
Environment=VLLM_ENDPOINT=https://ai.gitee.com/v1/embeddings
|
||||
Environment=MOLIFANG_API_KEY=3TSVVXRFFECE4TISXHGE1VXDAXBIPAP6O1VPJK18
|
||||
Environment=LLM_ENDPOINT=http://127.0.0.1:3000/v1/chat/completions
|
||||
Environment=LLM_MODEL=qwen/qwen3.5-122b-a10b
|
||||
Environment=LLM_API_KEY=***REMOVED***
|
||||
Environment=RERANK_ENDPOINT=https://ai.gitee.com/v1
|
||||
Environment=GRAPH_PATH=/var/lib/memoryweave/graph.db
|
||||
ExecStartPre=/bin/mkdir -p /var/lib/memoryweave
|
||||
ExecStart=/usr/local/bin/zhiyid
|
||||
ExecStartPre=/bin/mkdir -p /var/lib/memoryweave /home/muc/.logs
|
||||
ExecStart=/home/muc/bin/zhiyid-new
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
MemoryMax=512M
|
||||
MemoryMax=2G
|
||||
CPUQuota=200%
|
||||
Environment=STORAGE_BACKEND=lancedb
|
||||
# 2026-09-14 distill 改走队列代理 8081(fail fast,避免打爆单槽)
|
||||
Environment=LLM_ENDPOINT=http://127.0.0.1:8081/v1/chat/completions
|
||||
Environment=LLM_MODEL=local/minicpm5-2b
|
||||
Environment=LLM_API_KEY=local-key
|
||||
# 2026-09-22 restore rerank: use local cosine reranker via bge-embed service on port 8001
|
||||
Environment=RERANK_ENDPOINT=http://127.0.0.1:8001
|
||||
# 以下按需 drop-in(~/.config/systemd/user/zhiyid.service.d/):
|
||||
# 20-obsidian-vault.conf → OBSIDIAN_VAULT=/home/muc/mc
|
||||
# bind-lan-vpn.conf → ZHIYI_BIND=0.0.0.0(ufw 收窄 LAN/VPN 两网段)
|
||||
# provenance-observe.conf → ZHIYI_COMMIT_PROVENANCE_OBSERVE=1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=default.target
|
||||
@@ -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,89 @@
|
||||
# hits+/hits− outcome 信号定义(GA-P0-4 · t_bb10e8bc)
|
||||
|
||||
> 一句话:hits+/hits− 是**独立、语义单一**的「真实 outcome」信号,供 P2 自适应阈值做
|
||||
> 有意义的拟合。它**不是** useful_count / not_useful_count 的别名 —— 那两个列混入了
|
||||
> 去重副作用与「只是被看到」的假信号(见下文「为什么不能直接用旧列」)。
|
||||
|
||||
## 1. 它是什么
|
||||
|
||||
`hit_events` 是 **append-only 事件表**(sqlite 表 / LanceDB 表 / 内存切片三种后端同步),
|
||||
每次**真实 outcome** 追加一行,带 `created_at` 时间戳,**不覆盖式累加**。字段:
|
||||
|
||||
| 字段 | 取值 | 含义 |
|
||||
|------|------|------|
|
||||
| `outcome` | `pos` / `neg` | hits+ / hits− |
|
||||
| `reason` | `human_feedback` / `contradiction` / `replacement` / `downstream_use` | 这条 outcome 由什么产生 |
|
||||
| `memory_id` | 记忆 ID | 这条 outcome 落在哪条记忆上 |
|
||||
| `created_at` | RFC3339(UTC) | 事件发生时刻 —— **按周窗口聚合的依据** |
|
||||
|
||||
P2 自适应阈值按周窗口聚合:`WHERE created_at >= 本周起点`,得到该记忆/该标签近一周的
|
||||
hits+ / hits− 数,再拟合阈值。因为事件带时间戳且 append-only,周窗口天然可算(老窗口
|
||||
自然滑出,不污染当前拟合)。
|
||||
|
||||
## 2. y 判据:什么算 hits+ / 什么算 hits− / 什么不算
|
||||
|
||||
铁律一句话:**只有内容被「消费」或「否定」,才产生 outcome;被搬运、被看到、被降权都不算。**
|
||||
|
||||
### hits+(`outcome="pos"`)—— 被真实使用/认可
|
||||
1. **human_feedback**:用户对召回结果点「有用」(`/feedback/useful`)、或「修正」(`/feedback/correct`)
|
||||
2. **downstream_use**:下游流程**实际引用**了这条记忆的内容(写代码/写文档/决策时真正用上)
|
||||
3. **contradiction(正面)**:后续对话中它被反复印证、未被推翻
|
||||
|
||||
### hits−(`outcome="neg"`)—— 被真实否定/取代
|
||||
1. **human_feedback**:用户对召回结果点「没用」(`/feedback/not-useful`)
|
||||
2. **contradiction**:后续对话中它与更可信的信息**冲突并被淘汰**(`delete_reason=contradiction`)
|
||||
3. **replacement**:后续对话中它被**更新的信息取代**(更新的内容登场,旧的作废)
|
||||
|
||||
### ✗ 明确**不**算(排除项 —— 这些是 P0-1/P0-2 拆出来的假信号)
|
||||
| 信号 | 为什么不算 | 落在哪 |
|
||||
|------|-----------|--------|
|
||||
| dedup 自动降权 | 只是去重副作用,不代表内容质量差 | `dedup_downweight_count`(GA-P0-1) |
|
||||
| 单纯出现在召回结果里 | 「被看到」≠「被用上」,不代表有用 | `recall_seen_count`(GA-P0-2) |
|
||||
| 只是被召回、无任何后续 | 没有消费、没有否定 → 无 outcome | 不产生事件 |
|
||||
|
||||
## 3. 为什么不能直接用旧列(等同不成立的证据)
|
||||
|
||||
方案文档(`已办.md:16623`)曾写 `hits+ = useful_count`、`hits− = not_useful_count`、
|
||||
`V = hits+ + hits−`。该等同**不成立**:
|
||||
|
||||
- `not_useful_count` 混入 **dedup 自动降权** —— 一次 dedup 合并把内容质量**尚可**的记忆
|
||||
降权,却被当成了「这条记忆内容差」的负反馈 → P2 拿它当负标签会系统性误判「合并整理」
|
||||
为「内容质量差」。
|
||||
- `recall_count` 记的是「出现在结果里」而非「被验证使用」—— 一次召回命中就 ++,
|
||||
被召回 500+ 次的记忆永不遗忘(虚高保命),却从未被真正用上 → 拿它当正向信号,
|
||||
阈值会拟合在「被看到」上而不是「被用上」上。
|
||||
|
||||
所以必须新建**只由真实 outcome 写入**的独立信号。本表就是它。
|
||||
|
||||
## 4. 写入点(只由真实 outcome 触发)
|
||||
|
||||
当前已接线的真实 outcome 写入点(`go/internal/api/routes/feedback.go`):
|
||||
|
||||
| 端点 | outcome | reason | 说明 |
|
||||
|------|---------|--------|------|
|
||||
| `/feedback/useful` | pos | human_feedback | 用户点「有用」 |
|
||||
| `/feedback/not-useful` | neg | human_feedback | 用户点「没用」 |
|
||||
|
||||
矛盾/取代/下游引用三类由 P2 阶段接入(本卡只定义语义 + 提供 API,不部署、不强行接
|
||||
对话处理器)。
|
||||
|
||||
## 5. 写入约束(代码强制,防污染)
|
||||
|
||||
- `AppendHitEvent(memoryID, outcome, reason)` 的 `outcome` 必须落在 `pos|neg`,
|
||||
**否则报错不写库**(`hit_event.go:NormalizeHitOutcome`)—— 非法/混合语义值在源头被拦。
|
||||
- `reason` 经 `NormalizeHitReason` 归一到固定枚举(`human_feedback|contradiction|
|
||||
replacement|downstream_use`)。
|
||||
- dedup 降权 / 单纯召回出现**禁止**调用本方法(它们在 P0-1/P0-2 已各有专属字段)。
|
||||
|
||||
## 6. 实现位置
|
||||
|
||||
| 层 | 文件 |
|
||||
|----|------|
|
||||
| Go 语义定义(枚举+归一化,单一真源) | `go/internal/storage/hit_event.go` |
|
||||
| Go 模型 | `go/internal/models/memory.go`(`HitEvent`) |
|
||||
| sqlite 后端 | `go/internal/storage/sqlite.go`(`hit_events` 表 + 3 方法) |
|
||||
| 内存后端 | `go/internal/storage/memvector.go`(`hitEvents` 切片 + 3 方法) |
|
||||
| Rust-LanceDB 后端 | `rust/src/lancedb_ops.rs`(`hit_events` 表 + 3 方法)· `rust/src/main.rs`(IPC) |
|
||||
| 真实 outcome 接线 | `go/internal/api/routes/feedback.go` |
|
||||
|
||||
**禁部署**:本卡只落代码 + 测试 + 文档,不更新运行中的二进制、不碰 live 库。
|
||||
@@ -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,276 @@
|
||||
# 织忆蒸馏质量链路修复方案
|
||||
|
||||
> **面向实现者**:每任务 2~5 分钟粒度,含精确路径、行号、完整代码、验证命令与期望输出。
|
||||
> **日期**:2026-10-06 | **状态**:待拍板 | **拟定**:小怡
|
||||
|
||||
---
|
||||
|
||||
## 一、目标
|
||||
|
||||
切断这条因果链(本次排查发现的真实病灶):
|
||||
|
||||
```
|
||||
评分闸门失效(prompt 示例被模型原样 echo)
|
||||
↓
|
||||
只剩「文本长度」一道闸门在扛
|
||||
↓
|
||||
提取器为过闸门 → 给短规则追加固定话术(enrich)
|
||||
↓
|
||||
伪造内容进入长期记忆库(已污染 6 条,含跨技能串味)
|
||||
```
|
||||
|
||||
并顺带修掉 2 个次生 bug。
|
||||
|
||||
**注意**:上一轮我曾建议「蒸馏入口拒收规则块」—— **该建议作废**。证据 E4 表明「规则→episode→蒸馏」是**有意设计且工作正常**的流水线,拒收会砸掉整条技能规则入库链路。
|
||||
|
||||
## 二、架构
|
||||
|
||||
三处独立改动,互不阻塞:
|
||||
|
||||
| 层 | 文件 | 改什么 |
|
||||
|---|---|---|
|
||||
| 蒸馏(Go) | `go/internal/distill/engine.go` | 去 echo 源 + 加 echo 检测 + 影子模式 |
|
||||
| 提取(Python) | `~/.hermes/scripts/skill_rule_extractor_v2.py` | 停用伪造式扩写 |
|
||||
| 遗忘(Go) | `go/internal/api/routes/admin.go` · `go/internal/storage/lancedb_ipc.go` · `go/internal/api/server.go` | 空 id 防御 + 计数修正 |
|
||||
|
||||
## 三、证据链(为什么这么改)
|
||||
|
||||
| # | 事实 | 证据(可复现) |
|
||||
|---|---|---|
|
||||
| E1 | 评分是 prompt 示例复读 | `engine.go:534` 写死示例 `"is":0.8,"su":0.7,"pa":0.6,"vd":0.9,"ru":0.7`。实测:换内容分数不变;把示例数字改成 0.1~0.5,输出跟着变成 0.3~0.7 |
|
||||
| E2 | 评分门槛永不触发 | `engine.go:417` 判据 `overall<0.7 && score.VD<0.8`;echo 值按 `engine.go:89` 权重算得 `overall=0.755`,恒 ≥0.7 |
|
||||
| E3 | 扩写话术已入记忆库 | `memories` 表命中 **6 条**,含 3 条纯话术本身、1 条跨技能串味(`hermes-soul-maintenance|修改纪律:…rclone lsf`) |
|
||||
| E4 | 流水线本身是好的 | 台账按规则指纹去重:**入库 1539 / 被丢 0 / 在途 60 = 100% 收敛**(`~/.hermes/skill_rules_ingest_ledger.jsonl`) |
|
||||
| E5 | 空 id 根因在数据层 | `memories` 表有 **2 行** id/content/source 全空 ⇒ 命中「<30 字碎片快速道」⇒ `SoftDelete("")` ⇒ Rust `rust/src/main.rs:331` 拒绝 |
|
||||
|
||||
## 四、施工铁律
|
||||
|
||||
1. **不许 python 直连写 LanceDB**:zhiyid 运行期直连写 = 双写源(与 state.db 同类事故)。**所有删除走 HTTP API**。
|
||||
2. **P0-1/P0-2 必须影子模式先行**:修好 echo 会**激活一个此前从不触发的闸门**,可能造成批量丢弃。先 log-only 观察,再决定是否真拦。
|
||||
3. **改前备份、改后复验**:Go 改动过 `go vet` + 全量测试;Python 改动跑断言。
|
||||
|
||||
---
|
||||
|
||||
## 五、任务清单
|
||||
|
||||
### P0-3 停用「伪造式扩写」〔建议先做,止血〕
|
||||
|
||||
**文件**:`/home/muc/.hermes/scripts/skill_rule_extractor_v2.py:407-409`
|
||||
|
||||
**Step 1** 删掉追加固定话术的分支
|
||||
|
||||
旧:
|
||||
```python
|
||||
if len(r["rule"]) < 45:
|
||||
body += (" |执行时须落到具体命令与路径(如 hermes kanban list、pgrep -f、"
|
||||
"~/.hermes/scripts/、rclone lsf),并在汇报里贴出原始输出片段作为证据。")
|
||||
```
|
||||
|
||||
新:
|
||||
```python
|
||||
# 🔴 禁止追加与规则无关的固定话术(2026-10-06):那会伪造出技能从未声明过的"事实",
|
||||
# 已污染 memories 6 条(含跨技能串味)。短规则宁可被丢,也不许编。
|
||||
# 真实扩写只允许用规则自带字段(why/anti/scope,已在上方拼入)。
|
||||
```
|
||||
|
||||
**Step 2** 验证(必须证明它不再产出那句话)
|
||||
```bash
|
||||
python3 -c "
|
||||
import importlib.util,pathlib
|
||||
spec=importlib.util.spec_from_file_location('e', pathlib.Path.home()/'.hermes/scripts/skill_rule_extractor_v2.py')
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
out=m.enrich({'skill':'x','section':'y','rule':'短规则','why':'因为所以','anti':'','scope':''})
|
||||
print(out)
|
||||
assert '执行时须落到具体命令与路径' not in out, '❌ 伪造话术仍在'
|
||||
print('✅ 已停用伪造扩写')
|
||||
"
|
||||
```
|
||||
期望:打印 `✅ 已停用伪造扩写`。
|
||||
|
||||
**Step 3** 记录代价:改完重跑一轮 `skill_rules_sync.py`,把台账 `landed` 变化作为**代价数据**上报(短规则入库率会下降,这是可接受的真话)。
|
||||
|
||||
---
|
||||
|
||||
### P0-4 清理已污染的 6 条记忆〔建议先做,止血〕
|
||||
|
||||
**🔴 走 HTTP API,不许直连写库。**
|
||||
|
||||
**Step 1** 取 id(已取得,见下)→ 先 dry-run 看影响面
|
||||
**Step 2** 逐个软删,`reason` 用 `polluted_expansion_20261006`
|
||||
|
||||
```
|
||||
mem_1791262758250672496 收到牧尘任务…|依据…|适用…|执行时须落到具体命令与路径(…)
|
||||
mem_1791261603417879613 适用:cron 连续失败诊断 |执行时须落到…
|
||||
mem_1791267692697346820 hermes-soul-maintenance|修改纪律:执行时须落到… ← 跨技能串味
|
||||
mem_1791215971324172233 执行时须落到具体命令与路径(…) ← 纯话术
|
||||
mem_1791215338803223776 执行时须落到具体命令与路径(…) ← 纯话术
|
||||
mem_1791270196923533388 使用 --move-slide 时 |执行时须落到… ← 无关规则被串味
|
||||
```
|
||||
|
||||
**Step 3** 复验:同脚本再查一次,`执行时须落到具体命令与路径` 命中 **0**。
|
||||
|
||||
---
|
||||
|
||||
### P0-1 去掉评分 prompt 的 echo 源
|
||||
|
||||
**文件**:`go/internal/distill/engine.go:533-536`
|
||||
|
||||
**Step 1** 把「填好的示例」换成「类型骨架 + 显式禁令」
|
||||
|
||||
旧:
|
||||
```
|
||||
输出格式(严格JSON):
|
||||
{"facts": ["独立事实1", "独立事实2"], "decisions": ["决定1"], "conclusions": ["结论1"], "actions_taken": ["行动1"], "open_questions": ["问题1"], "entities": ["entity1", "entity2"], "is": 0.8, "su": 0.7, "pa": 0.6, "vd": 0.9, "ru": 0.7}
|
||||
|
||||
评分说明:is/su/pa/vd/ru 是 0.0 到 1.0 之间的浮点数(越高越好),不要用 0-10 整数。
|
||||
```
|
||||
|
||||
新:
|
||||
```
|
||||
输出格式(严格JSON):
|
||||
{"facts": ["..."], "decisions": ["..."], "conclusions": ["..."], "actions_taken": ["..."], "open_questions": ["..."], "entities": ["..."], "is": <小数>, "su": <小数>, "pa": <小数>, "vd": <小数>, "ru": <小数>}
|
||||
|
||||
评分说明:is/su/pa/vd/ru 必须是 0.0~1.0 之间的浮点数(越高越好),不得用 0-10 整数。
|
||||
🔴 上例中的 <小数> 是类型占位符,不是数值。必须依据**本次内容**独立打分;沿用任何示例数值都算错误答案。
|
||||
```
|
||||
|
||||
**Step 2** 验证
|
||||
```bash
|
||||
cd /home/muc/src/memoryweave/go && go vet ./internal/distill/ && go test ./internal/distill/ -count=1
|
||||
```
|
||||
期望:全 PASS。
|
||||
|
||||
**Step 3** liveprobe 实证(沿用 `quality_liveprobe_test.go` 的手法)
|
||||
```bash
|
||||
go test ./internal/distill/ -tags liveprobe -run TestLiveScoreNotEcho -v -count=1
|
||||
```
|
||||
期望:6 条不同内容 → **分数不再全部相同**(≥3 种不同向量)。
|
||||
|
||||
---
|
||||
|
||||
### P0-2 加「echo 检测 + 影子模式」
|
||||
|
||||
**Step 1** 新增检测(`engine.go`,用现成评分结构,见 `engine.go:65-80`)
|
||||
|
||||
```go
|
||||
// echoScoreVectors 是 prompt 历史版本里出现过的示例评分向量。
|
||||
// 模型输出与任一向量完全相同 ⇒ 判定为「照抄示例」,评分不可信。
|
||||
var echoScoreVectors = [][5]float64{
|
||||
{0.8, 0.7, 0.6, 0.9, 0.7},
|
||||
}
|
||||
|
||||
// IsEchoScore 判断评分是否在照抄 prompt 示例。
|
||||
func IsEchoScore(s Score5D) bool {
|
||||
for _, v := range echoScoreVectors {
|
||||
if s.IS == v[0] && s.SU == v[1] && s.PA == v[2] && s.VD == v[3] && s.RU == v[4] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2** 影子开关(默认开=只记录不拦)
|
||||
|
||||
```go
|
||||
// 默认影子模式:只记录「本该被丢」的样本,不真的丢。
|
||||
// 修好 echo 会激活一个此前从不触发的闸门,必须先观察再开拦。
|
||||
var scoreShadowMode = os.Getenv("ZHIYI_SCORE_SHADOW") != "0"
|
||||
```
|
||||
|
||||
在 `engine.go:417` 判据处:
|
||||
```go
|
||||
if overall < 0.7 && score.VD < 0.8 {
|
||||
if scoreShadowMode {
|
||||
log.Printf("[distill][SHADOW] would-drop episode=%s overall=%.3f vd=%.2f echo=%v",
|
||||
input.EpisodeID, overall, score.VD, IsEchoScore(score))
|
||||
} else {
|
||||
// …原丢弃逻辑保持不变…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3** 验证(**阴性对照是硬要求**)
|
||||
```bash
|
||||
go test ./internal/distill/ -run TestIsEchoScore -v -count=1
|
||||
```
|
||||
必须含:正例 `{0.8,0.7,0.6,0.9,0.7}` → `true`;负例 `{0.8,0.7,0.6,0.9,0.8}` → `false`。
|
||||
> 量具铁律:新量具先用正例证明它会报阳性,再用负例证明它不滥报。
|
||||
|
||||
**Step 4** 观察期:影子模式跑 ≥3 天,统计 `[SHADOW] would-drop` 条数占总量比,再决定是否 `ZHIYI_SCORE_SHADOW=0` 开真拦。
|
||||
|
||||
---
|
||||
|
||||
### P1-1 修遗忘路径的空 id 崩溃 + 计数 bug
|
||||
|
||||
**Step 1** `go/internal/storage/lancedb_ipc.go:584` 入口挡空 id
|
||||
```go
|
||||
func (rc *RustLanceDBClient) SoftDelete(id, reason string) error {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return fmt.Errorf("SoftDelete: empty id (reason=%s)", reason)
|
||||
}
|
||||
…
|
||||
```
|
||||
|
||||
**Step 2** 两个调用点加守卫
|
||||
- `go/internal/api/routes/admin.go:86-92`:加 `if strVal(mem["id"]) == "" { skippedNoID++; continue }`
|
||||
- `go/internal/api/server.go:1641`:`if id, ok := m["id"].(string); ok && id != "" {`
|
||||
> ⚠️ 漏洞点:**空串能通过 `ok` 断言**,原写法拦不住。
|
||||
|
||||
**Step 3** 修计数 bug(`admin.go:90`)—— 现在 `SoftDelete` 失败**照样** `forgotten++`
|
||||
```go
|
||||
if err := aa.LanceDB.SoftDelete(id, "auto_forget_fragment"); err != nil {
|
||||
log.Printf("[softdelete] failed id=%q: %v", id, err)
|
||||
continue // 失败不计数
|
||||
}
|
||||
forgotten++
|
||||
```
|
||||
|
||||
**Step 4** 那 2 行垃圾记录(id/content/source 全空):**无法用 id 软删**(id 为空)⇒ 需要 zhiyid 侧一次性维护(加 `/api/v1/admin/purge-invalid` 端点,或 Rust 侧按 `id == ""` 条件清理)。**本项单独拍板,不许手工直连删。**
|
||||
|
||||
**Step 5** 验证
|
||||
```bash
|
||||
journalctl --user -u zhiyid --since "10 min ago" | grep -c 'id required'
|
||||
```
|
||||
期望:触发一次 forget 后仍为 **0**。
|
||||
|
||||
---
|
||||
|
||||
### P2 根治方向〔观察后再定,本次不实施〕
|
||||
|
||||
**问题**:`IsQualityFact` 用「≥20 字 + 含实体/动词」判「像不像事实」,但技能规则天然是短句 ⇒ 规则块只能靠"拉长"过关。**根子是闸门与内容类型不匹配。**
|
||||
|
||||
**方向**:给规则块开专用通道 —— 蒸馏入口识别 `^\[规则:` 前缀,**整条规则作为一条 fact**(不交给 2B 剁碎)。收益:规则不再需要扩写作弊、碎片不再产生、评分闸门可以专心管真对话。
|
||||
**风险**:改动 `distillOne` 的 fact 组装,影响面大 ⇒ 先用 P0 的影子数据说话再定。
|
||||
|
||||
---
|
||||
|
||||
## 六、验收总表
|
||||
|
||||
| 任务 | 判据 | 复验方式 |
|
||||
|---|---|---|
|
||||
| P0-3 | 不再产生伪造话术 | `enrich()` 输出断言不含该句 |
|
||||
| P0-4 | 污染清零 | 同脚本复查命中 0 |
|
||||
| P0-1 | 分数不再恒定 | liveprobe:6 条不同内容 → ≥3 种分数向量 |
|
||||
| P0-2 | echo 可检出 | 单测正例 true / 负例 false |
|
||||
| P1-1 | 日志无 `id required`;计数与实际一致 | `journalctl \| grep -c` = 0;`forgotten` == 真删数 |
|
||||
|
||||
## 七、顺序与依赖
|
||||
|
||||
```
|
||||
P0-3(停伪造)──┐
|
||||
P0-4(清污染)──┴─→ 止血完成
|
||||
↓
|
||||
P0-1(去 echo)→ P0-2(影子观察 ≥3 天)→ 决定是否开真拦 → P2 评估
|
||||
|
||||
P1-1(防御+计数)独立,随时可做
|
||||
```
|
||||
|
||||
**建议**:先做 **P0-3 + P0-4**(停止新增污染 + 清掉已有污染,改动小、可逆),再做 **P0-1/P0-2**(治本,但需观察期),**P1-1** 穿插做。
|
||||
|
||||
## 八、明确不做的事
|
||||
|
||||
- ❌ 不在蒸馏入口拒收规则块(会砸掉技能规则入库链路,见 E4)
|
||||
- ❌ 不直接改 LanceDB(双写源风险)
|
||||
- ❌ 不在影子数据出来前打开真拦(可能批量丢弃)
|
||||
- ❌ 不动 `graph_auto.go` 等无关文件(只改本方案列出的项)
|
||||
+9
-9
@@ -71,7 +71,7 @@ type RegisterResponse struct {
|
||||
|
||||
// WSEvent WebSocket 推送事件
|
||||
type WSEvent struct {
|
||||
Type string `json:"type"` // prefetch.push / gap.detected / memory.updated / conflict.detected / ...
|
||||
Type string `json:"type"` // prefetch.push / gap.detected / memory.updated / conflict.detected / ...
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
@@ -88,14 +88,14 @@ type StatsResponse struct {
|
||||
|
||||
// ZhiYiClient 织忆 Go SDK(Hermes/OpenClaw/Cron Jobs 共用)
|
||||
type ZhiYiClient struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
agentID string
|
||||
http *http.Client
|
||||
wsConn *websocket.Conn
|
||||
wsMu sync.Mutex
|
||||
eventCh chan WSEvent
|
||||
done chan struct{}
|
||||
baseURL string
|
||||
apiKey string
|
||||
agentID string
|
||||
http *http.Client
|
||||
wsConn *websocket.Conn
|
||||
wsMu sync.Mutex
|
||||
eventCh chan WSEvent
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewZhiYiClient 创建织忆客户端
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
// 织忆 MemoryWeave — is_deleted 删除原因回填工具(GA-P0-3 t_f785d7f2)
|
||||
//
|
||||
// 背景:is_deleted=true 实测 11228 条,但删除原因不可分辨——「坏记忆被删」(contradiction/
|
||||
// manual/retention) 和「合并/整理被删」(merge_dup) 混在同一个布尔里。P2 要拿「被删」
|
||||
// 当负标签,必须先把原因分列。
|
||||
//
|
||||
// 本工具:
|
||||
// 1. 读 LanceDB 全部 is_deleted=true 行(走 Rust IPC lancedb_scan_deleted)。
|
||||
// 2. 以 tombstones 表为据尽力判定删除原因(sqlite tombstones.reason 列)。
|
||||
// tombstones 判不出的写 unknown。
|
||||
// 3. 对每行写 delete_reason(跳过往已回填的、行上 delete_reason 非空的行)。
|
||||
// 4. 输出 delete_reason 直方图。
|
||||
//
|
||||
// 用法(**dry-run 默认,apply 需显式 --apply,且须经小怡终审后才允许跑**):
|
||||
//
|
||||
// go run ./cmd/backfill_deletereason --socket /tmp/zhiyi-ipc.sock --tombstones <sqlite.db>
|
||||
// go run ./cmd/backfill_deletereason --socket /tmp/zhiyi-ipc.sock --tombstones <sqlite.db> --apply
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ipcReq struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Fields string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type ipcResp struct {
|
||||
Status string `json:"status"`
|
||||
ReportJSON string `json:"report_json"`
|
||||
ErrorDetail string `json:"error_detail"`
|
||||
}
|
||||
|
||||
func rpc(sock string, req ipcReq) (*ipcResp, error) {
|
||||
conn, err := net.DialTimeout("unix", sock, 5e9)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc dial %s: %w", sock, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
body, _ := json.Marshal(req)
|
||||
lenBuf := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBuf, uint32(len(body)))
|
||||
conn.Write(lenBuf)
|
||||
conn.Write(body)
|
||||
io.ReadFull(conn, lenBuf)
|
||||
msgLen := binary.BigEndian.Uint32(lenBuf)
|
||||
respBuf := make([]byte, msgLen)
|
||||
io.ReadFull(conn, respBuf)
|
||||
var resp ipcResp
|
||||
if err := json.Unmarshal(respBuf, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
return nil, fmt.Errorf("ipc: %s", resp.ErrorDetail)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// 枚举(与 internal/storage/delete_reason.go 保持一致)
|
||||
const (
|
||||
DRMergeDup = "merge_dup"
|
||||
DRContradiction = "contradiction"
|
||||
DRManual = "manual"
|
||||
DRRetention = "retention"
|
||||
DRUnknown = "unknown"
|
||||
)
|
||||
|
||||
// Normalize 把 free-form reason 归一到枚举(与 storage.NormalizeDeleteReason 同规则)。
|
||||
func Normalize(reason string) string {
|
||||
r := strings.ToLower(strings.TrimSpace(reason))
|
||||
if r == "" {
|
||||
return DRUnknown
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(r, "batch:"):
|
||||
return DRUnknown
|
||||
case strings.Contains(r, "manual"), strings.Contains(r, "用户"),
|
||||
strings.Contains(r, "user"), strings.Contains(r, "feedback"),
|
||||
strings.Contains(r, "human"), strings.Contains(r, "人工"):
|
||||
return DRManual
|
||||
case strings.Contains(r, "dedup"), strings.Contains(r, "wording"),
|
||||
strings.Contains(r, "duplicate"), strings.Contains(r, "merged"),
|
||||
strings.Contains(r, "merge"):
|
||||
return DRMergeDup
|
||||
case strings.Contains(r, "conflict"), strings.Contains(r, "contradict"),
|
||||
strings.Contains(r, "consolidate"):
|
||||
return DRContradiction
|
||||
case strings.Contains(r, "auto_forget"), strings.Contains(r, "ephemeral"),
|
||||
strings.Contains(r, "fragment"), strings.Contains(r, "expire"),
|
||||
strings.Contains(r, "retention"), strings.Contains(r, "过期"),
|
||||
strings.Contains(r, "自动"):
|
||||
return DRRetention
|
||||
default:
|
||||
return DRUnknown
|
||||
}
|
||||
}
|
||||
|
||||
type deletedRow struct {
|
||||
ID string `json:"id"`
|
||||
DeleteReason string `json:"delete_reason"`
|
||||
}
|
||||
|
||||
// loadTombstoneReasons 从 sqlite tombstones 表读取 original_id → reason 映射。
|
||||
// 文件不存在/表不存在 ⇒ 空映射(全部落 unknown)。
|
||||
func loadTombstoneReasons(path string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if path == "" {
|
||||
return out, nil
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return out, nil // 无 tombstone 库 ⇒ 尽力判定为 0,全 unknown
|
||||
}
|
||||
// 复用系统 sqlite3 CLI 最简读法(避免 CGO 依赖);库通常只在 SQLite 后端写入。
|
||||
// 生产 LanceDB 后端 tombstones 表是空(软删直接写 memories.is_deleted),
|
||||
// 故此处判不出是常态 —— 符合「判不出的写 unknown」。
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
sock := flag.String("socket", "/tmp/zhiyi-ipc.sock", "rust sidecar unix socket")
|
||||
tombstones := flag.String("tombstones", "", "可选:sqlite tombstones 库路径(尽力判定用)")
|
||||
apply := flag.Bool("apply", false, "真正写库(默认 false = dry-run;未经终审禁止 true)")
|
||||
flag.Parse()
|
||||
|
||||
resp, err := rpc(*sock, ipcReq{Type: "lancedb_scan_deleted"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "scan_deleted 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var rows []deletedRow
|
||||
if err := json.Unmarshal([]byte(resp.ReportJSON), &rows); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unmarshal 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
tombReasons, _ := loadTombstoneReasons(*tombstones)
|
||||
N := len(rows)
|
||||
|
||||
// 分类:存量已回填(非空 reason)跳过;未回填的按 tombstone 尽力判定,判不出 unknown。
|
||||
hist := map[string]int{}
|
||||
skipFilled := 0
|
||||
toWrite := map[string]string{} // id -> reason
|
||||
for _, r := range rows {
|
||||
if r.DeleteReason != "" {
|
||||
skipFilled++ // 已回填,跳过
|
||||
continue
|
||||
}
|
||||
reason := DRUnknown
|
||||
if tr, ok := tombReasons[r.ID]; ok && tr != "" {
|
||||
reason = Normalize(tr)
|
||||
}
|
||||
toWrite[r.ID] = reason
|
||||
hist[reason]++
|
||||
}
|
||||
|
||||
fmt.Printf("已删除行总数 N = %d\n", N)
|
||||
fmt.Printf(" 已有 delete_reason(跳过) = %d\n", skipFilled)
|
||||
fmt.Printf(" 待回填 = %d\n", len(toWrite))
|
||||
fmt.Println("── delete_reason 直方图(value→行数,仅待回填)──")
|
||||
order := []string{DRMergeDup, DRContradiction, DRManual, DRRetention, DRUnknown}
|
||||
keys := make([]string, 0, len(hist))
|
||||
for k := range hist {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range order {
|
||||
fmt.Printf("[%s] %d\n", k, hist[k])
|
||||
}
|
||||
|
||||
if *apply {
|
||||
if len(toWrite) == 0 {
|
||||
fmt.Println("apply: 无可回填行(全部已回填或已删除行为空)。")
|
||||
} else {
|
||||
fmt.Printf("apply: 将逐条 Update %d 行 delete_reason(id IN ... 分批)。—— 未经终审禁止。\n", len(toWrite))
|
||||
// 实际写入走 lancedb_update(每 id 一条,带 delete_reason + is_deleted 保持 true)。
|
||||
// 实现:对每 id 发 lancedb_update fields=[{column:delete_reason,value:'<reason>'}]
|
||||
// 注意:**只写 delete_reason,不碰其它字段**(禁改已删除记录其它字段)。
|
||||
var wrote int64
|
||||
for id, reason := range toWrite {
|
||||
fields := fmt.Sprintf(`[{"column":"delete_reason","value":"'%s'"}]`, reason)
|
||||
r, err := rpc(*sock, ipcReq{Type: "lancedb_update", Table: "memories", ID: id, Fields: fields})
|
||||
_ = r
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update %s 失败: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
wrote++
|
||||
}
|
||||
fmt.Printf("apply: 已写 %d 行 delete_reason。\n", wrote)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("(dry-run) 未写库。输出须先经小怡终审,批准后才可 --apply。")
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func main() {
|
||||
if err != nil || t.Before(epochThreshold) || t.Year() < 2024 {
|
||||
// 用确定性派生时间(避免所有epoch-0都用同一时间)
|
||||
// 基于 ID 哈希分配 2024-2026 之间的不同日期
|
||||
offsetDays := int(len(m.ID)%(365*2)) // 0-729天
|
||||
offsetDays := int(len(m.ID) % (365 * 2)) // 0-729天
|
||||
newTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, offsetDays)
|
||||
ts := newTime.Format(time.RFC3339)
|
||||
if err := ipcUpdate(m.ID, "created_at", ts); err != nil {
|
||||
@@ -107,4 +107,4 @@ func main() {
|
||||
}
|
||||
|
||||
log.Printf("\n完成: 修复 %d/%d 条记忆时间戳\n", fixed, len(memories))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// 织忆 MemoryWeave — not_useful_count 拆分迁移工具(GA-P0-1 t_718d8707)
|
||||
//
|
||||
// 背景:not_useful_count 被两种语义混写——用户人工点「没用」(feedback.go
|
||||
// MarkNotUseful)与 dedup/合并流程自动降权(core.go Dedup demote)都写同一个字段。
|
||||
// 本工具把存量 not_useful_count 按来源拆分到 human_not_useful_count /
|
||||
// dedup_downweight_count。
|
||||
//
|
||||
// 分类依据(可判 M 条):本库**无任何 per-row 来源 provenance**——feedback 与
|
||||
// dedup 都不在行上留下可辨识标记(source 只是「哪条管道进来」,非「人点还是 dedup」)。
|
||||
// 故对存量行无法按来源判定:M=0,全部归 unknown(K=N)。apply 在 M=0 时是 no-op,
|
||||
// 不会改动任何存量数据(保守安全)。
|
||||
//
|
||||
// 用法(**dry-run 默认,apply 需显式 --apply,且须经小怡终审后才允许跑**):
|
||||
//
|
||||
// go run ./cmd/split_notuseful --socket /tmp/zhiyi-ipc.sock # dry-run
|
||||
// go run ./cmd/split_notuseful --socket /tmp/zhiyi-ipc.sock --apply # 真写(禁未审批)
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type ipcReq struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type ipcResp struct {
|
||||
Status string `json:"status"`
|
||||
ReportJSON string `json:"report_json"`
|
||||
ErrorDetail string `json:"error_detail"`
|
||||
}
|
||||
|
||||
func rpc(sock string, req ipcReq) (*ipcResp, error) {
|
||||
conn, err := net.DialTimeout("unix", sock, 5e9)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc dial %s: %w", sock, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
body, _ := json.Marshal(req)
|
||||
lenBuf := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBuf, uint32(len(body)))
|
||||
conn.Write(lenBuf)
|
||||
conn.Write(body)
|
||||
io.ReadFull(conn, lenBuf)
|
||||
msgLen := binary.BigEndian.Uint32(lenBuf)
|
||||
respBuf := make([]byte, msgLen)
|
||||
io.ReadFull(conn, respBuf)
|
||||
var resp ipcResp
|
||||
if err := json.Unmarshal(respBuf, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Status != "ok" {
|
||||
return nil, fmt.Errorf("ipc: %s", resp.ErrorDetail)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Category string `json:"category"`
|
||||
UsefulCount int `json:"useful_count"`
|
||||
NotUsefulCount int `json:"not_useful_count"`
|
||||
HumanNotUsefulCount int `json:"human_not_useful_count"`
|
||||
DedupDownweightCount int `json:"dedup_downweight_count"`
|
||||
}
|
||||
|
||||
// classifyBySource 按来源判定归属。返回 "human" / "dedup" / "unknown"。
|
||||
// 当前 schema 无 per-row 来源 provenance ⇒ 恒 unknown(M=0)。
|
||||
// 将来若某来源可明确标识「人工反馈」或「dedup 降权」,在此追加映射即可。
|
||||
func classifyBySource(_ row) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func main() {
|
||||
sock := flag.String("socket", "/tmp/zhiyi-ipc.sock", "rust sidecar unix socket")
|
||||
apply := flag.Bool("apply", false, "真正写库(默认 false = dry-run;未经终审禁止 true)")
|
||||
flag.Parse()
|
||||
|
||||
resp, err := rpc(*sock, ipcReq{Type: "lancedb_scan_all"})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "scan_all 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var rows []row
|
||||
if err := json.Unmarshal([]byte(resp.ReportJSON), &rows); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unmarshal 失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var nu []row
|
||||
for _, r := range rows {
|
||||
if r.NotUsefulCount > 0 {
|
||||
nu = append(nu, r)
|
||||
}
|
||||
}
|
||||
N := len(nu)
|
||||
|
||||
// 分类
|
||||
humanIDs := map[string]int{} // id -> count
|
||||
dedupIDs := map[string]int{} // id -> count
|
||||
unknownIDs := map[string]int{}
|
||||
for _, r := range nu {
|
||||
switch classifyBySource(r) {
|
||||
case "human":
|
||||
humanIDs[r.ID] = r.NotUsefulCount
|
||||
case "dedup":
|
||||
dedupIDs[r.ID] = r.NotUsefulCount
|
||||
default:
|
||||
unknownIDs[r.ID] = r.NotUsefulCount
|
||||
}
|
||||
}
|
||||
M := len(humanIDs) + len(dedupIDs)
|
||||
K := len(unknownIDs)
|
||||
|
||||
// 3 字段直方图(当前存量值分布)
|
||||
hist := map[string]map[int]int{}
|
||||
hist["not_useful_count"] = map[int]int{}
|
||||
hist["human_not_useful_count"] = map[int]int{}
|
||||
hist["dedup_downweight_count"] = map[int]int{}
|
||||
for _, r := range rows {
|
||||
hist["not_useful_count"][r.NotUsefulCount]++
|
||||
hist["human_not_useful_count"][r.HumanNotUsefulCount]++
|
||||
hist["dedup_downweight_count"][r.DedupDownweightCount]++
|
||||
}
|
||||
|
||||
fmt.Printf("总行数(存活): %d\n", len(rows))
|
||||
fmt.Printf("not_useful_count>0 待改行数 N = %d\n", N)
|
||||
fmt.Printf(" 其中可按来源判定 M = %d (human=%d dedup=%d)\n", M, len(humanIDs), len(dedupIDs))
|
||||
fmt.Printf(" 无法判定归 unknown K = %d\n", K)
|
||||
fmt.Println("── 3 字段直方图(value→行数,仅列当前非零分布)──")
|
||||
order := []string{"not_useful_count", "human_not_useful_count", "dedup_downweight_count"}
|
||||
for _, f := range order {
|
||||
m := hist[f]
|
||||
keys := make([]int, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
fmt.Printf("[%s] ", f)
|
||||
if len(keys) == 1 && keys[0] == 0 {
|
||||
fmt.Println("全 0(0 行非零)")
|
||||
continue
|
||||
}
|
||||
parts := []string{}
|
||||
for _, k := range keys {
|
||||
if k == 0 {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d×%d", k, m[k]))
|
||||
}
|
||||
fmt.Println(parts)
|
||||
}
|
||||
|
||||
if *apply {
|
||||
if M == 0 {
|
||||
fmt.Println("apply: 无可判定行(M=0),无需迁移,存量 not_useful_count 保持原样。")
|
||||
} else {
|
||||
fmt.Printf("apply: 将拆分 %d 行(human=%d dedup=%d)—— 需逐条 Update。\n", M, len(humanIDs), len(dedupIDs))
|
||||
}
|
||||
} else {
|
||||
fmt.Println("(dry-run) 未写库。输出须先经小怡终审,批准后才可 --apply。")
|
||||
}
|
||||
}
|
||||
+179
-53
@@ -10,22 +10,53 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// t_4fab32c1 AC2: 删除硬编码 "zhiyi-dev-key-2026" 默认值。
|
||||
// 密钥来源优先级:ZHIYI_API_KEY env → /var/lib/memoryweave/.api-key → 报错退出。
|
||||
const (
|
||||
apiKeyDefault = "zhiyi-dev-key-2026"
|
||||
apiURLDefault = "http://localhost:7821"
|
||||
apiKeyDefault = "" // 不再提供硬编码默认 key
|
||||
apiURLDefault = "http://localhost:7821"
|
||||
namespaceDefault = "hermes-main"
|
||||
)
|
||||
|
||||
// resolveAPIKey 解析 API Key:
|
||||
// 1. -key flag 显式传入(非空)
|
||||
// 2. ZHIYI_API_KEY 环境变量
|
||||
// 3. /var/lib/memoryweave/.api-key 文件(**多行格式**:注释与 `legacy:` 行跳过,
|
||||
// 取第 1 个非注释非 legacy 行 = 主密钥)
|
||||
// 4. 均无 → 返回 error,调用方退出
|
||||
//
|
||||
// 🔴 2026-09-21 修:原实现 `TrimSpace(整份文件)` 在多行 key 文件下会把
|
||||
// 「注释头 + 主密钥 + legacy 行」整块当 key 发给服务端 → 必然 401。
|
||||
func resolveAPIKey(flagKey string) (string, error) {
|
||||
if flagKey != "" {
|
||||
return flagKey, nil
|
||||
}
|
||||
if v := os.Getenv("ZHIYI_API_KEY"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
if data, err := os.ReadFile("/var/lib/memoryweave/.api-key"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "legacy:") {
|
||||
continue
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("API Key 未找到:请设 ZHIYI_API_KEY 环境变量,或将 key 写入 /var/lib/memoryweave/.api-key")
|
||||
}
|
||||
|
||||
// ─── 全局参数 ──────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
apiURL = flag.String("url", getEnv("ZHIYI_API_URL", apiURLDefault), "织忆 API 地址")
|
||||
apiKey = flag.String("key", getEnv("ZHIYI_API_KEY", apiKeyDefault), "API Key")
|
||||
ns = flag.String("n", getEnv("ZHIYI_NAMESPACE", namespaceDefault), "命名空间")
|
||||
apiURL = flag.String("url", getEnv("ZHIYI_API_URL", apiURLDefault), "织忆 API 地址")
|
||||
apiKey = flag.String("key", getEnv("ZHIYI_API_KEY", apiKeyDefault), "API Key")
|
||||
ns = flag.String("n", getEnv("ZHIYI_NAMESPACE", namespaceDefault), "命名空间")
|
||||
)
|
||||
|
||||
// ─── 入口 ─────────────────────────────────────────────────
|
||||
@@ -39,16 +70,31 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// t_4fab32c1 AC2: 启动时解析 API Key,缺失则报错退出
|
||||
// 旧版使用硬编码 "zhiyi-dev-key-2026" 作为默认值
|
||||
apiKeyVal, err := resolveAPIKey(*apiKey)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := flag.Arg(0)
|
||||
args := flag.Args()[1:]
|
||||
|
||||
var err error
|
||||
var runErr error
|
||||
switch cmd {
|
||||
case "tree": err = runTree(args)
|
||||
case "graph": err = runGraph(args)
|
||||
case "recall": err = runRecall(args)
|
||||
case "stats": err = runStats()
|
||||
case "entity": err = runEntity(args)
|
||||
case "tree":
|
||||
runErr = runTree(args, apiKeyVal)
|
||||
case "graph":
|
||||
runErr = runGraph(args, apiKeyVal)
|
||||
case "recall":
|
||||
runErr = runRecall(args, apiKeyVal)
|
||||
case "stats":
|
||||
runErr = runStats(apiKeyVal)
|
||||
case "entity":
|
||||
runErr = runEntity(args, apiKeyVal)
|
||||
case "pattern":
|
||||
runErr = runPattern(args, apiKeyVal)
|
||||
case "help", "--help", "-h":
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
@@ -57,8 +103,8 @@ func main() {
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
if runErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", runErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +133,7 @@ func usage() {
|
||||
recall <query> 语义搜索,返回 top-10 结果
|
||||
stats 显示系统统计(记忆数、蒸馏状态等)
|
||||
entity <name> 查询实体详情(出现次数、关联记忆)
|
||||
pattern <detect|list> L2 模式检测/列出(detect=手动触发检测,list=列出已检测)
|
||||
|
||||
示例:
|
||||
zhiyi tree
|
||||
@@ -99,12 +146,12 @@ func usage() {
|
||||
|
||||
// ─── API 调用 ──────────────────────────────────────────────
|
||||
|
||||
func apiGet(path string, v interface{}) error {
|
||||
return getJSON(*apiURL+path, *apiKey, v)
|
||||
func apiGet(path string, v interface{}, apiKey string) error {
|
||||
return getJSON(*apiURL+path, apiKey, v)
|
||||
}
|
||||
|
||||
func apiPost(path string, body, v interface{}) error {
|
||||
return postJSON(*apiURL+path, *apiKey, body, v)
|
||||
func apiPost(path string, body, v interface{}, apiKey string) error {
|
||||
return postJSON(*apiURL+path, apiKey, body, v)
|
||||
}
|
||||
|
||||
func getJSON(url, apiKey string, v interface{}) error {
|
||||
@@ -147,15 +194,15 @@ func postJSON(url, apiKey string, body interface{}, v interface{}) error {
|
||||
|
||||
// ─── 命令实现 ──────────────────────────────────────────────
|
||||
|
||||
func runTree(args []string) error {
|
||||
func runTree(args []string, apiKey string) error {
|
||||
// 从图谱 export 获取节点(带 category),按 category 分组
|
||||
var r struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Weight float64 `json:"weight,omitempty"`
|
||||
} `json:"nodes"`
|
||||
Edges []struct {
|
||||
Source string `json:"source"`
|
||||
@@ -167,7 +214,7 @@ func runTree(args []string) error {
|
||||
Edges int `json:"edges"`
|
||||
} `json:"count"`
|
||||
}
|
||||
if err := apiGet(fmt.Sprintf("/api/v1/graph/export?namespace=%s&limit=500", *ns), &r); err != nil {
|
||||
if err := apiGet(fmt.Sprintf("/api/v1/graph/export?namespace=%s&limit=500", *ns), &r, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -198,12 +245,12 @@ func runTree(args []string) error {
|
||||
fmt.Println(strings.Repeat("─", 50))
|
||||
|
||||
catIcons := map[string]string{
|
||||
"人物": "👤",
|
||||
"项目": "📦",
|
||||
"事件": "📅",
|
||||
"概念": "💡",
|
||||
"位置": "📍",
|
||||
"组织": "🏢",
|
||||
"人物": "👤",
|
||||
"项目": "📦",
|
||||
"事件": "📅",
|
||||
"概念": "💡",
|
||||
"位置": "📍",
|
||||
"组织": "🏢",
|
||||
"distilled": "🔄",
|
||||
}
|
||||
for _, cat := range cats {
|
||||
@@ -224,7 +271,7 @@ func runTree(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runGraph(args []string) error {
|
||||
func runGraph(args []string, apiKey string) error {
|
||||
var entity string
|
||||
if len(args) == 0 {
|
||||
// 从 pagerank map 取 score 最高的非 episode 实体
|
||||
@@ -232,7 +279,7 @@ func runGraph(args []string) error {
|
||||
Pagerank map[string]float64 `json:"pagerank"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := apiGet("/api/v1/graph/pagerank", &pr); err != nil {
|
||||
if err := apiGet("/api/v1/graph/pagerank", &pr, apiKey); err != nil {
|
||||
return fmt.Errorf("获取 Top 实体失败: %w", err)
|
||||
}
|
||||
var topEntity string
|
||||
@@ -252,20 +299,20 @@ func runGraph(args []string) error {
|
||||
}
|
||||
|
||||
type navigateResp struct {
|
||||
Entity string `json:"entity"`
|
||||
Count int `json:"count"`
|
||||
Entity string `json:"entity"`
|
||||
Count int `json:"count"`
|
||||
Paths []struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Relation string `json:"relation"`
|
||||
Hop int `json:"hop"`
|
||||
Weight float64 `json:"weight"`
|
||||
Hop int `json:"hop"`
|
||||
Weight float64 `json:"weight"`
|
||||
} `json:"paths"`
|
||||
}
|
||||
var resp navigateResp
|
||||
if err := apiPost("/api/v1/graph/navigate", map[string]interface{}{
|
||||
"entity": entity, "max_hops": 1, "namespace": *ns,
|
||||
}, &resp); err != nil {
|
||||
}, &resp, apiKey); err != nil {
|
||||
return fmt.Errorf("获取邻居失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -336,7 +383,7 @@ func printASCIIGraph(center string, neighbors []string, rels map[string]string)
|
||||
fmt.Printf(" └%s (%d 个邻居)\n", strings.Repeat("─", 20), len(neighbors))
|
||||
}
|
||||
|
||||
func runRecall(args []string) error {
|
||||
func runRecall(args []string, apiKey string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("用法: zhiyi recall <query>")
|
||||
}
|
||||
@@ -353,7 +400,7 @@ func runRecall(args []string) error {
|
||||
}
|
||||
if err := apiPost("/api/v1/recall", map[string]interface{}{
|
||||
"query": query, "namespace": *ns, "top_k": 10,
|
||||
}, &resp); err != nil {
|
||||
}, &resp, apiKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -372,7 +419,7 @@ func runRecall(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runStats() error {
|
||||
func runStats(apiKey string) error {
|
||||
var stats struct {
|
||||
TotalMemories int `json:"total_memories"`
|
||||
TotalEpisodes int `json:"total_episodes"`
|
||||
@@ -386,15 +433,15 @@ func runStats() error {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
var distStatus struct {
|
||||
QueueLen int `json:"queue_len"`
|
||||
DailyUsed int `json:"daily_used"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
QueueLen int `json:"queue_len"`
|
||||
DailyUsed int `json:"daily_used"`
|
||||
DailyLimit int `json:"daily_limit"`
|
||||
BatchSize int `json:"batch_size"`
|
||||
}
|
||||
|
||||
apiGet("/api/v1/stats", &stats)
|
||||
apiGet("/api/v1/distill/quota", "a)
|
||||
apiGet("/api/v1/distill/status", &distStatus)
|
||||
apiGet("/api/v1/stats", &stats, apiKey)
|
||||
apiGet("/api/v1/distill/quota", "a, apiKey)
|
||||
apiGet("/api/v1/distill/status", &distStatus, apiKey)
|
||||
|
||||
barLen := 40
|
||||
filled := 0
|
||||
@@ -418,7 +465,86 @@ func runStats() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runEntity(args []string) error {
|
||||
// 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>")
|
||||
}
|
||||
@@ -455,7 +581,7 @@ func runEntity(args []string) error {
|
||||
MaxHops int `json:"max_hops"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if err := postJSON(*apiURL+"/api/v1/graph/navigate", *apiKey, navPostReq{entity, 1, *ns}, &nav); err == nil {
|
||||
if err := postJSON(*apiURL+"/api/v1/graph/navigate", apiKey, navPostReq{entity, 1, *ns}, &nav); err == nil {
|
||||
evCh <- nav
|
||||
} else {
|
||||
evCh <- navResp{}
|
||||
@@ -470,7 +596,7 @@ func runEntity(args []string) error {
|
||||
}
|
||||
if err := apiPost("/api/v1/recall", map[string]interface{}{
|
||||
"query": entity, "namespace": *ns, "top_k": 10,
|
||||
}, &recallResp); err == nil {
|
||||
}, &recallResp, apiKey); err == nil {
|
||||
mrCh <- recallResp
|
||||
} else {
|
||||
mrCh <- struct {
|
||||
@@ -554,4 +680,4 @@ func box(style, s string) string {
|
||||
return "◉ " + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/api"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,11 +20,18 @@ func main() {
|
||||
port = "7821"
|
||||
}
|
||||
|
||||
// t_4fab32c1 AC1: 监听地址可配,默认 127.0.0.1(仅本机)
|
||||
// 旧代码写死 0.0.0.0,局域网内任何机器均可访问记忆库 API。
|
||||
bind := os.Getenv("ZHIYI_BIND")
|
||||
if bind == "" {
|
||||
bind = "127.0.0.1"
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Addr: bind + ":" + port,
|
||||
Handler: api.NewServer(),
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second, // full consolidation takes ~15s, need headroom
|
||||
WriteTimeout: 60 * time.Second, // full consolidation takes ~15s, need headroom
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
@@ -34,6 +42,8 @@ func main() {
|
||||
<-sigCh
|
||||
|
||||
log.Println("[zhiyid] 收到关闭信号,正在退出...")
|
||||
// 累积延迟更新:把最后一个窗口的召回元数据落盘(防止统计增量丢失)
|
||||
storage.StopRecallWriteBuffer()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
@@ -41,7 +51,8 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("[zhiyid] 织忆 MemoryWeave 启动 — 端口 %s", port)
|
||||
// t_4fab32c1 AC1: 日志输出实际绑定的地址
|
||||
log.Printf("[zhiyid] 织忆 MemoryWeave 启动 — 绑定 %s:%s", bind, port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("[zhiyid] 启动失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
module github.com/xiaoxue/memoryweave
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.23.5
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,10 +2,37 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
@@ -14,10 +41,56 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
+20
-10
@@ -19,7 +19,16 @@ import (
|
||||
|
||||
var testServer http.Handler
|
||||
|
||||
// testAPIKey 测试专用密钥。
|
||||
//
|
||||
// 2026-09-22:P0 安全(d026739)改为「密钥强制 + 去硬编码回落」后,集成测试原用的
|
||||
// 旧值 zhiyi-dev-key-2026 既非 env 也非 keyfile primary ⇒ 7 处请求全 401(4 个测试红)。
|
||||
// 测试自备 env key:auth 的 loadKeys() 里 env(API_KEY) 优先于 keyfile,
|
||||
// 因此既不依赖、也不触碰生产 keyfile(apiKey != "" 时不会走生成分支)。
|
||||
const testAPIKey = "test-key-integration-20260922"
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Setenv("API_KEY", testAPIKey)
|
||||
testServer = api.NewServer()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -35,7 +44,7 @@ func TestIntegration_FullPipeline(t *testing.T) {
|
||||
}
|
||||
header := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "zhiyi-dev-key-2026",
|
||||
"X-API-Key": testAPIKey,
|
||||
}
|
||||
|
||||
// Step 1: 提交记忆
|
||||
@@ -128,7 +137,7 @@ func TestIntegration_GraphFlow(t *testing.T) {
|
||||
h := testServer
|
||||
header := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "zhiyi-dev-key-2026",
|
||||
"X-API-Key": testAPIKey,
|
||||
}
|
||||
|
||||
// Graph stats
|
||||
@@ -159,7 +168,7 @@ func TestIntegration_AgentRegisterFlow(t *testing.T) {
|
||||
h := testServer
|
||||
header := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "zhiyi-dev-key-2026",
|
||||
"X-API-Key": testAPIKey,
|
||||
}
|
||||
|
||||
// Register
|
||||
@@ -201,7 +210,7 @@ func TestEdge_EmptyCommit(t *testing.T) {
|
||||
body := map[string]interface{}{"agent_id": "", "content": ""}
|
||||
req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
req.Header.Set("X-API-Key", testAPIKey)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
@@ -247,7 +256,7 @@ func TestEdge_LargePayload(t *testing.T) {
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
req.Header.Set("X-API-Key", testAPIKey)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
|
||||
@@ -275,7 +284,7 @@ func TestEdge_ConcurrentCommits(t *testing.T) {
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/v1/commit", bytesBody(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", "zhiyi-dev-key-2026")
|
||||
req.Header.Set("X-API-Key", testAPIKey)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Code != 201 {
|
||||
@@ -293,7 +302,7 @@ func TestEdge_ConcurrentCommits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEdge_GapDetectionThreshold(t *testing.T) {
|
||||
gd := selfoptimize.NewGapDetector()
|
||||
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||
|
||||
// 2 misses — no gap
|
||||
if gap := gd.RecordMiss("test"); gap != nil {
|
||||
@@ -320,7 +329,7 @@ func TestEdge_GraphEmpty(t *testing.T) {
|
||||
t.Error("empty graph should return all zeros")
|
||||
}
|
||||
|
||||
paths, err := g.Navigate("nonexistent", 2, "shared")
|
||||
paths, err := g.Navigate("nonexistent", 2, "shared", nil)
|
||||
if err != nil {
|
||||
t.Errorf("navigate on empty graph should not error: %v", err)
|
||||
}
|
||||
@@ -333,10 +342,11 @@ func TestEdge_TriggersFired(t *testing.T) {
|
||||
h := testServer
|
||||
header := map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": "zhiyi-dev-key-2026",
|
||||
"X-API-Key": testAPIKey,
|
||||
}
|
||||
|
||||
body := map[string]string{"trigger_id": "t1"}
|
||||
// 触发器 ID 与 routes.Triggers 一致(t1..t8 是 executor 的内部编号,不是 API ID)
|
||||
body := map[string]string{"trigger_id": "t_decay"}
|
||||
req := httptest.NewRequest("POST", "/api/v1/triggers/fire", bytesBody(body))
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import "math"
|
||||
|
||||
// cosineSimilarity 计算两个向量的余弦相似度。
|
||||
// 放在 api 包根目录,让 server(蒸馏写入路径)和 routes(commit 路径)
|
||||
// 共享同一套近重复判据(>=0.98),避免两套逻辑各自漂移。
|
||||
func cosineSimilarity(a, b []float32) float64 {
|
||||
var dot, normA, normB float64
|
||||
n := len(a)
|
||||
if len(b) < n {
|
||||
n = len(b)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
dot += float64(a[i]) * float64(b[i])
|
||||
normA += float64(a[i]) * float64(a[i])
|
||||
normB += float64(b[i]) * float64(b[i])
|
||||
}
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / math.Sqrt(normA*normB)
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -14,19 +16,19 @@ import (
|
||||
// ─── Per-Agent 令牌桶 ────────────────────────────────────────
|
||||
|
||||
type tokenBucket struct {
|
||||
tokens float64
|
||||
tokens float64
|
||||
lastRefill time.Time
|
||||
rate float64 // tokens/sec
|
||||
burst float64
|
||||
mu sync.Mutex
|
||||
rate float64 // tokens/sec
|
||||
burst float64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newTokenBucket(rate, burst float64) *tokenBucket {
|
||||
return &tokenBucket{
|
||||
tokens: burst,
|
||||
tokens: burst,
|
||||
lastRefill: time.Now(),
|
||||
rate: rate,
|
||||
burst: burst,
|
||||
rate: rate,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +58,10 @@ type AgentRateConfig struct {
|
||||
}
|
||||
|
||||
var agentRates = map[string]AgentRateConfig{
|
||||
"hermes": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"hermes-a06":{RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"openclaw": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"cron-job": {RecallQPS: 5, CommitQPS: 1, Burst: 10},
|
||||
"hermes": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"hermes-a06": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"openclaw": {RecallQPS: 10, CommitQPS: 2, Burst: 20},
|
||||
"cron-job": {RecallQPS: 5, CommitQPS: 1, Burst: 10},
|
||||
}
|
||||
|
||||
var defaultRate = AgentRateConfig{RecallQPS: 5, CommitQPS: 1, Burst: 10}
|
||||
@@ -67,14 +69,14 @@ var defaultRate = AgentRateConfig{RecallQPS: 5, CommitQPS: 1, Burst: 10}
|
||||
// ─── RateLimiter ─────────────────────────────────────────────
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*tokenBucket // agentID → bucket
|
||||
maxPerMinute int // 全局兜底
|
||||
mu sync.Mutex
|
||||
buckets map[string]*tokenBucket // agentID → bucket
|
||||
maxPerMinute int // 全局兜底
|
||||
}
|
||||
|
||||
func NewRateLimiter(maxPerMinute int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
buckets: make(map[string]*tokenBucket),
|
||||
maxPerMinute: maxPerMinute,
|
||||
}
|
||||
}
|
||||
@@ -121,29 +123,124 @@ func init() {
|
||||
|
||||
// ─── HTTP 中间件 ─────────────────────────────────────────────
|
||||
|
||||
// Auth 认证中间件 (X-API-Key)
|
||||
// t_4fab32c1 AC2: 密钥强制(无硬编码回落)。
|
||||
// 🔑 2026-09-21 过渡期补丁(牧尘当晚批准上线):密钥文件为**多行**格式 ——
|
||||
//
|
||||
// 第 1 个非注释行 = 主密钥;`legacy:` 前缀行 = 过渡期兼容密钥(放行但打 DEPRECATED 日志)。
|
||||
//
|
||||
// 为什么需要 legacy:hermes 记忆插件在 **import 时**读 `ZHIYI_API_KEY`,
|
||||
//
|
||||
// 正在运行的网关不重启不会重读;另有 8 个本地脚本持旧密钥。
|
||||
// 若直接轮换值 → 瞬间打断记忆读写(recall/commit 全 401)。
|
||||
//
|
||||
// 收口条件:客户端迁移完(网关重启后)删除 key 文件里的 legacy 行。
|
||||
const keyFile = "/var/lib/memoryweave/.api-key"
|
||||
|
||||
// parseKeys 纯解析:data=key 文件内容,envKey=环境变量值。抽出来是为了可被单测直接覆盖。
|
||||
func parseKeys(data, envKey string) (primary string, legacy []string) {
|
||||
primary = strings.TrimSpace(envKey)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "legacy:") {
|
||||
if k := strings.TrimSpace(strings.TrimPrefix(line, "legacy:")); k != "" {
|
||||
legacy = append(legacy, k)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if primary == "" {
|
||||
primary = line
|
||||
}
|
||||
}
|
||||
return primary, legacy
|
||||
}
|
||||
|
||||
// loadKeys 返回 (主密钥, 过渡期兼容密钥列表)
|
||||
func loadKeys() (primary string, legacy []string) {
|
||||
data := ""
|
||||
if b, err := os.ReadFile(keyFile); err == nil {
|
||||
data = string(b)
|
||||
}
|
||||
return parseKeys(data, os.Getenv("API_KEY"))
|
||||
}
|
||||
|
||||
var (
|
||||
legacyUseMu sync.Mutex
|
||||
legacyLast = map[string]time.Time{}
|
||||
)
|
||||
|
||||
// noteLegacyUse 对 legacy 密钥放行做限频告警(每密钥 10 分钟一次)
|
||||
func noteLegacyUse(key string) {
|
||||
legacyUseMu.Lock()
|
||||
last := legacyLast[key]
|
||||
legacyLast[key] = time.Now()
|
||||
legacyUseMu.Unlock()
|
||||
if time.Since(last) > 10*time.Minute {
|
||||
shown := key
|
||||
if len(shown) > 6 {
|
||||
shown = shown[:6] + "…"
|
||||
}
|
||||
log.Printf("[auth] DEPRECATED: 放行了过渡期 legacy 密钥(%s)。请迁移调用方到主密钥,并删除 %s 的 legacy 行收口。", shown, keyFile)
|
||||
}
|
||||
}
|
||||
|
||||
func isLegacy(list []string, key string) bool {
|
||||
for _, k := range list {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Auth(next http.Handler) http.Handler {
|
||||
apiKey := os.Getenv("API_KEY")
|
||||
apiKey, legacyKeys := loadKeys()
|
||||
if apiKey == "" {
|
||||
apiKey = "zhiyi-dev-key-2026"
|
||||
// 生成随机密钥并持久化(无任何可用密钥时拒绝启动或落盘随机密钥)
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
log.Fatalf("[auth] API_KEY 未设且无法生成随机密钥: %v — 拒绝启动", err)
|
||||
}
|
||||
apiKey = hex.EncodeToString(buf)
|
||||
if err := os.MkdirAll("/var/lib/memoryweave", 0700); err != nil {
|
||||
log.Printf("[auth] WARNING: 已生成随机密钥但无法创建目录: %v — 密钥仅存于内存,重启后失效", err)
|
||||
} else if err := os.WriteFile(keyFile, []byte(apiKey+"\n"), 0600); err != nil {
|
||||
log.Printf("[auth] WARNING: 已生成随机密钥但写入 %s 失败: %v — 密钥仅存于内存,重启后失效", keyFile, err)
|
||||
} else {
|
||||
log.Printf("[auth] API_KEY 未设 — 已生成随机密钥并写入 %s (0600)。请同步给调用方(zhiyi-cli / hermes 等)。", keyFile)
|
||||
}
|
||||
}
|
||||
if len(legacyKeys) > 0 {
|
||||
log.Printf("[auth] 已加载 %d 个过渡期 legacy 密钥(放行+告警);收口:迁移调用方后删除 %s 的 legacy 行", len(legacyKeys), keyFile)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// /health, /metrics, /static/*, / 不需要认证(/ 供 Web UI 使用)
|
||||
if r.URL.Path == "/health" || r.URL.Path == "/metrics" ||
|
||||
// t_4fab32c1 AC3: /metrics 纳入鉴权(旧版在免鉴权白名单内,无 key 即可
|
||||
// 读取 episode 总数/进程 RSS/队列深度等运维指标,暴露系统内部状态)。
|
||||
// /health 保留免鉴权(供 systemd / 监控探针使用);
|
||||
// /static/* 和 / 保留免鉴权(Web UI 静态资源)。
|
||||
if r.URL.Path == "/health" ||
|
||||
strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
key := r.Header.Get("X-API-Key")
|
||||
if key != apiKey {
|
||||
if key != apiKey && !isLegacy(legacyKeys, key) {
|
||||
// t_2d4e3b92: 统一错误体 schema — 非 2xx + {"error":{"code","message","retry_after_s"}}
|
||||
// 此前为扁平 {"error":"..."},调用方难以与成功体区分(曾导致把 401/429 误读为「召回 0 条」)。
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(401)
|
||||
w.Write([]byte(`{"error":"unauthorized: invalid or missing X-API-Key"}`))
|
||||
w.Write([]byte(`{"error":{"code":"unauthorized","message":"invalid or missing X-API-Key","retry_after_s":0}}`))
|
||||
return
|
||||
}
|
||||
|
||||
if key != apiKey {
|
||||
noteLegacyUse(key) // 过渡期:legacy 密钥放行但留审计痕迹
|
||||
}
|
||||
|
||||
// 提取 agent ID(从 X-Agent-ID 头或 URL 路径)
|
||||
agentID := r.Header.Get("X-Agent-ID")
|
||||
if agentID == "" {
|
||||
@@ -157,13 +254,14 @@ func Auth(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
// Per-agent 令牌桶检查
|
||||
// t_2d4e3b92: 统一错误体 schema — 非 2xx + {"error":{"code","message","retry_after_s"}}
|
||||
if !GlobalLimiter.Allow(agentID, endpointType) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Unix()+1))
|
||||
w.WriteHeader(429)
|
||||
w.Write([]byte(fmt.Sprintf(
|
||||
`{"error":"rate_limit_exceeded","agent":"%s","retry_after":1}`, agentID,
|
||||
`{"error":{"code":"rate_limit_exceeded","message":"rate limit exceeded for agent %s","retry_after_s":1}}`, agentID,
|
||||
)))
|
||||
log.Printf("[ratelimit] %s exceeded for agent %s", endpointType, agentID)
|
||||
return
|
||||
@@ -200,7 +298,8 @@ func RateLimit(maxPerMinute int) func(http.Handler) http.Handler {
|
||||
w.Header().Set("X-RateLimit-Reset", fmt.Sprintf("%d", resetTime))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(429)
|
||||
w.Write([]byte(`{"error":"rate_limit_exceeded","retry_after":1}`))
|
||||
// t_2d4e3b92: 统一错误体 schema
|
||||
w.Write([]byte(`{"error":{"code":"rate_limit_exceeded","message":"global rate limit exceeded","retry_after_s":60}}`))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t_2d4e3b92: 中间件失败路径必须「非 2xx + 统一错误体 schema」。
|
||||
//
|
||||
// 起因:401/429 曾返回扁平 {"error":"..."} 或 {"error":..,"agent":..,"retry_after":..},
|
||||
// 与成功体形状不同却被调用方当成「空结果」处理 —— 实测把小怡的召回质量复算一度误导成
|
||||
// 「召回 0 条 = 真实回退」。本测试锁死形状契约。
|
||||
|
||||
// validateUnifiedErrorBody 纯函数:校验错误体形状,返回问题列表(空 = 通过)。
|
||||
// 抽成纯函数是为了能做**反证**(喂扁平 body 必须报出问题),否则判据可能恒真。
|
||||
func validateUnifiedErrorBody(status int, body []byte, wantStatus int, wantCode string) []string {
|
||||
var problems []string
|
||||
|
||||
if status == 200 {
|
||||
problems = append(problems, "失败路径不得返回 200")
|
||||
}
|
||||
if wantStatus != 0 && status != wantStatus {
|
||||
problems = append(problems, fmt.Sprintf("HTTP 状态码期望 %d,实际 %d", wantStatus, status))
|
||||
}
|
||||
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &top); err != nil {
|
||||
return append(problems, "错误体不是合法 JSON: "+err.Error())
|
||||
}
|
||||
|
||||
raw, ok := top["error"]
|
||||
if !ok {
|
||||
return append(problems, "错误体缺少 error 键")
|
||||
}
|
||||
|
||||
// 核心判别点:error 必须是**对象**,不是字符串 —— 这正是「统一 schema」的判据
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return append(problems, "error 必须是对象,实际是标量/数组: "+string(raw))
|
||||
}
|
||||
for _, k := range []string{"code", "message", "retry_after_s"} {
|
||||
if _, ok := obj[k]; !ok {
|
||||
problems = append(problems, fmt.Sprintf("error 缺少字段 %q", k))
|
||||
}
|
||||
}
|
||||
if wantCode != "" {
|
||||
if got, _ := obj["code"].(string); got != wantCode {
|
||||
problems = append(problems, fmt.Sprintf("error.code 期望 %q,实际 %q", wantCode, got))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func assertUnified(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantCode string) {
|
||||
t.Helper()
|
||||
if probs := validateUnifiedErrorBody(rec.Code, rec.Body.Bytes(), wantStatus, wantCode); len(probs) > 0 {
|
||||
t.Fatalf("错误体形状不合契约: %v | body=%s", probs, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 401:缺 key
|
||||
func TestAuthMiddleware_401_UnifiedErrorBody(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assertUnified(t, rec, 401, "unauthorized")
|
||||
}
|
||||
|
||||
// 401:key 错误
|
||||
func TestAuthMiddleware_401_WrongKey_UnifiedErrorBody(t *testing.T) {
|
||||
handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", nil)
|
||||
req.Header.Set("X-API-Key", "definitely-wrong-key")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
assertUnified(t, rec, 401, "unauthorized")
|
||||
}
|
||||
|
||||
// 反证:喂历史扁平 body → 校验器**必须**报出问题(证明判据非恒真)
|
||||
func TestValidateUnifiedErrorBody_Negative(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
body string
|
||||
}{
|
||||
{"扁平 error 字符串(修前 401 形状)", 401, `{"error":"unauthorized: invalid or missing X-API-Key"}`},
|
||||
{"旧限流体(修前 429 形状)", 429, `{"error":"rate_limit_exceeded","agent":"a1","retry_after":1}`},
|
||||
{"成功体混入(无 error 键)", 200, `{"results":[],"count":0}`},
|
||||
{"error 对象缺字段", 502, `{"error":{"code":"x"}}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
probs := validateUnifiedErrorBody(c.code, []byte(c.body), 0, "")
|
||||
if len(probs) == 0 {
|
||||
t.Fatalf("反证失败:%s 未被判错 → 判据恒真", c.body)
|
||||
}
|
||||
t.Logf("已判错(符合预期): %v", probs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 正例:统一 schema 必须通过(防止校验器过严,把正确形状也判错)
|
||||
func TestValidateUnifiedErrorBody_Positive(t *testing.T) {
|
||||
ok := `{"error":{"code":"unauthorized","message":"bad key","retry_after_s":0}}`
|
||||
if probs := validateUnifiedErrorBody(401, []byte(ok), 401, "unauthorized"); len(probs) > 0 {
|
||||
t.Fatalf("统一 schema 被误判: %v", probs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 行为契约(2026-09-21 过渡期密钥补丁):
|
||||
// key 文件多行格式 —— 注释行忽略;第 1 个非注释非 legacy 行 = 主密钥;`legacy:` 行 = 过渡期兼容。
|
||||
// 若退化会出现两种生产事故之一:①主密钥读成整份文件(所有客户端 401,记忆读写全断);
|
||||
// ②legacy 行被当成主密钥(等于没轮换)。
|
||||
// 直接覆盖生产的 parseKeys(不是复刻实现)。
|
||||
func TestParseKeysMultiLineFormat(t *testing.T) {
|
||||
content := "# 注释行\nPRIMARYKEY123\nlegacy:oldkey456\n\n# 再来一行注释\n"
|
||||
|
||||
primary, legacy := parseKeys(content, "")
|
||||
if primary != "PRIMARYKEY123" {
|
||||
t.Fatalf("主密钥应为 PRIMARYKEY123,实际 %q", primary)
|
||||
}
|
||||
if len(legacy) != 1 || legacy[0] != "oldkey456" {
|
||||
t.Fatalf("legacy 应为 [oldkey456],实际 %v", legacy)
|
||||
}
|
||||
|
||||
if p2, _ := parseKeys(content, "ENVKEY"); p2 != "ENVKEY" {
|
||||
t.Fatalf("env 变量应优先于文件,实际 %q", p2)
|
||||
}
|
||||
|
||||
if !isLegacy(legacy, "oldkey456") {
|
||||
t.Fatal("isLegacy 应识别过渡密钥")
|
||||
}
|
||||
if isLegacy(legacy, "PRIMARYKEY123") {
|
||||
t.Fatal("主密钥不应被判为 legacy")
|
||||
}
|
||||
if isLegacy(legacy, "") {
|
||||
t.Fatal("空 key 绝不可被判为 legacy(否则等于无鉴权)")
|
||||
}
|
||||
|
||||
// 只有注释的损坏文件 → 无主密钥(上层走"生成随机密钥"分支,而不是放行空 key)
|
||||
if p3, l3 := parseKeys("# only comments\n", ""); p3 != "" || len(l3) != 0 {
|
||||
t.Fatalf("纯注释文件应解析为空,实际 primary=%q legacy=%v", p3, l3)
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,4 @@ func CORS() func(http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/memprotect"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
@@ -66,22 +68,87 @@ 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"])
|
||||
recallCnt := intVal(mem["recall_count"])
|
||||
// GA-P0-2 (t_c6d41ac6):衰减/遗忘只认 recall_verified_count(正反馈才是 V 值)。
|
||||
// recall_count 已冻结(历史虚高保命来源),seen 只用于观测,不参与衰减。
|
||||
recallCnt := intVal(mem["recall_verified_count"])
|
||||
tier := strVal(mem["tier"])
|
||||
content := strVal(mem["content"])
|
||||
|
||||
// 🔒 2026-09-07 修复(误删事故): episodes(原始对话)不参与遗忘
|
||||
if cat := strVal(mem["category"]); cat == "episodes" {
|
||||
continue
|
||||
}
|
||||
// 🔒 长内容保护: >200 字记忆不参与 auto 遗忘(有实质信息)
|
||||
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 {
|
||||
// 🔴 2026-10-06:空 id 直接跳过(DB 里有 id/content 全空的垃圾行会命中这条快速道);
|
||||
// 且 SoftDelete 失败不再计数 —— 原实现失败照样 forgotten++ ⇒ 遗忘计数虚高。
|
||||
id := strVal(mem["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if err := aa.LanceDB.SoftDelete(id, "auto_forget_fragment"); err != nil {
|
||||
log.Printf("[softdelete] failed id=%q: %v", id, err)
|
||||
continue
|
||||
}
|
||||
forgotten++
|
||||
continue
|
||||
}
|
||||
|
||||
// E4.3 修复: 从 content 提取实体(而非读 namespace),取图谱最大度
|
||||
degree := extractTopEntityDegree(content, aa.GraphStore)
|
||||
|
||||
if aa.Forgetter.ShouldForget(lastAccess, recallCnt, tier, degree) {
|
||||
aa.LanceDB.SoftDelete(strVal(mem["id"]), "auto_forget")
|
||||
id := strVal(mem["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if err := aa.LanceDB.SoftDelete(id, "auto_forget"); err != nil {
|
||||
log.Printf("[softdelete] failed id=%q: %v", id, err)
|
||||
continue
|
||||
}
|
||||
forgotten++
|
||||
}
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "ok", "scanned": len(results), "forgotten": forgotten,
|
||||
// 2026-10-16 (t_944344b5):protected = 被遗忘保护守卫拦截、未软删的候选数
|
||||
"protected": protected,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/purge-invalid
|
||||
// P1-1 2026-10-08: 一次性清理 id 为空的垃圾行(DB 存在 id/content/source 全空的 2 行,
|
||||
// 命中 <30 字碎片快速道 → SoftDelete("") 被 Rust 拒绝 → 每轮刷错误日志 + 遗忘计数虚高)。
|
||||
// 这些行无 id 无法按 id 软删,只能按 id=” 条件一次性软删(is_deleted=true)。
|
||||
// ⚠️ 由验收/决策者按需调用(本实现不自动触发),本端点只提供能力 + 返回受影响行数。
|
||||
func (aa *AdminAPI) PurgeInvalid(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := aa.LanceDB.PurgeInvalid()
|
||||
if err != nil {
|
||||
respondError(w, 500, "purge-invalid failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "ok", "purged": n,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,9 +308,9 @@ func (aa *AdminAPI) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
aa.startServices()
|
||||
|
||||
respond(w, 200, map[string]string{
|
||||
"status": "ok",
|
||||
"restored": req.Timestamp,
|
||||
"data_dir": dataDir,
|
||||
"status": "ok",
|
||||
"restored": req.Timestamp,
|
||||
"data_dir": dataDir,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -291,10 +358,14 @@ func parseTimeStr(s interface{}) time.Time {
|
||||
|
||||
func intVal(v interface{}) int {
|
||||
switch n := v.(type) {
|
||||
case int: return n
|
||||
case int32: return int(n)
|
||||
case int64: return int(n)
|
||||
case float64: return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int32:
|
||||
return int(n)
|
||||
case int64:
|
||||
return int(n)
|
||||
case float64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
@@ -307,8 +378,10 @@ func strVal(v interface{}) string {
|
||||
return ""
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case string: return s
|
||||
case json.Number: return s.String()
|
||||
case string:
|
||||
return s
|
||||
case json.Number:
|
||||
return s.String()
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
@@ -422,7 +495,9 @@ func isAllDigits(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GET /api/v1/memories — 批量列出记忆(按 importance 排序,不含向量)
|
||||
// GET /api/v1/memories — 批量列出记忆(按时间倒序,不含向量)
|
||||
// 2026-09-21:改用全表扫描(aa.LanceDB.Scan)替代「零向量 ANN 冒充列表」。
|
||||
// 修前零向量无方向 ⇒ 只拿到存储序前 N 行再按 ns 过滤 ⇒ 列表/计数失真。
|
||||
func (aa *AdminAPI) ListMemories(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
ns := r.URL.Query().Get("namespace")
|
||||
@@ -434,20 +509,19 @@ func (aa *AdminAPI) ListMemories(w http.ResponseWriter, r *http.Request) {
|
||||
limit = 1000
|
||||
}
|
||||
}
|
||||
zeroVec := make([]float32, 1024)
|
||||
results, err := aa.LanceDB.Search("memories", zeroVec, limit, ns)
|
||||
results, err := aa.LanceDB.Scan(limit, ns)
|
||||
if err != nil {
|
||||
respondError(w, 500, "list memories: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 去掉向量字段,减少响应体积
|
||||
type memoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Importance float64 `json:"importance"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
items := make([]memoryItem, 0, len(results))
|
||||
for _, m := range results {
|
||||
@@ -476,8 +550,8 @@ func (aa *AdminAPI) ExportMemoriesMD(w http.ResponseWriter, r *http.Request) {
|
||||
limit = 5000
|
||||
}
|
||||
}
|
||||
zeroVec := make([]float32, 1024)
|
||||
results, err := aa.LanceDB.Search("memories", zeroVec, limit, ns)
|
||||
// 2026-09-21:改用全表扫描(aa.LanceDB.Scan)替代「零向量 ANN 冒充列表」。
|
||||
results, err := aa.LanceDB.Scan(limit, ns)
|
||||
if err != nil {
|
||||
respondError(w, 500, "export memories: "+err.Error())
|
||||
return
|
||||
@@ -512,4 +586,4 @@ func orDefault(s, def string) string {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// 织忆 MemoryWeave — P1-1 遗忘路径计数修复单测(t_37ac8a45, 2026-10-08)
|
||||
//
|
||||
// 目标(AC3):SoftDelete 失败 → forgotten 不得增加(原实现失败照样 forgotten++ ⇒ 遗忘计数虚高)。
|
||||
// 量具铁律:正例(成功 → forgotten+1)与负例(失败 → forgotten 不 +1)都要有。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
)
|
||||
|
||||
// forgetMockLance 为 Forget 计数测试提供的 LanceDB 桩:
|
||||
// GetCandidatesForForgetting 可配置候选列表;SoftDelete 可配置失败/成功。
|
||||
type forgetMockLance struct {
|
||||
candidates []map[string]interface{}
|
||||
softDeleteErr error
|
||||
deletedIDs []string
|
||||
}
|
||||
|
||||
func (m *forgetMockLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
|
||||
return "ep_test", nil
|
||||
}
|
||||
func (m *forgetMockLance) GetTopByQuality(agentID string, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *forgetMockLance) Stats() (map[string]interface{}, error) {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
func (m *forgetMockLance) SoftDelete(id, reason string) error {
|
||||
if m.softDeleteErr != nil {
|
||||
return m.softDeleteErr
|
||||
}
|
||||
m.deletedIDs = append(m.deletedIDs, id)
|
||||
return nil
|
||||
}
|
||||
func (m *forgetMockLance) PurgeInvalid() (int64, error) { return 0, nil }
|
||||
func (m *forgetMockLance) SoftDeleteBatch(table string, ids []string, updatedAt string) (int64, error) {
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
func (m *forgetMockLance) GetVersionHistory(id string) ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *forgetMockLance) GetCandidatesForForgetting() ([]map[string]interface{}, error) {
|
||||
return m.candidates, nil
|
||||
}
|
||||
func (m *forgetMockLance) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *forgetMockLance) Backup(path string) error { return nil }
|
||||
func (m *forgetMockLance) GetAuditLog(limit int) ([]map[string]interface{}, error) { return nil, nil }
|
||||
func (m *forgetMockLance) IncrementUseful(id string) error { return nil }
|
||||
func (m *forgetMockLance) IncrementNotUseful(id string) error { return nil }
|
||||
|
||||
// GA-P0-1 (t_718d8707):拆分字段
|
||||
func (m *forgetMockLance) IncrementHumanNotUseful(id string) error { return nil }
|
||||
func (m *forgetMockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
|
||||
func (m *forgetMockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
|
||||
func (m *forgetMockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
|
||||
func (m *forgetMockLance) IncrementDedupDownweight(id string) error { return nil }
|
||||
func (m *forgetMockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
|
||||
func (m *forgetMockLance) InsertMemory(mem models.MemoryRecord) error { return nil }
|
||||
func (m *forgetMockLance) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *forgetMockLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *forgetMockLance) Insert(table string, record any) error { return nil }
|
||||
func (m *forgetMockLance) Update(table, id string, fields map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fragment 构造一个命中「<30 字碎片快速道」的候选(content<30 字 + 20 天未访问)。
|
||||
func fragmentCandidate(id string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"content": "短碎片", // <30 字
|
||||
"category": "distilled",
|
||||
"last_recalled_at": time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339),
|
||||
"recall_count": 0,
|
||||
"tier": "normal",
|
||||
}
|
||||
}
|
||||
|
||||
// forgetResp 解析 Forget 响应体。
|
||||
type forgetResp struct {
|
||||
Status string `json:"status"`
|
||||
Scanned int `json:"scanned"`
|
||||
Forgotten int `json:"forgotten"`
|
||||
}
|
||||
|
||||
// callForget 构造 AdminAPI 并调用 Forget 端点,返回响应体。
|
||||
func callForget(t *testing.T, db *forgetMockLance) forgetResp {
|
||||
t.Helper()
|
||||
aa := NewAdminAPI(db, governance.NewForgetter(), governance.NewInMemoryGraph())
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/forget", strings.NewReader(""))
|
||||
w := httptest.NewRecorder()
|
||||
aa.Forget(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("Forget 期望 200,实得 %d:%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp forgetResp
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// TestForget_SoftDeleteFailure_DoesNotIncrement 负例(AC3 核心):
|
||||
// 有效 id 的候选命中碎片快速道,但 SoftDelete 失败 → forgotten 不得增加。
|
||||
// 修前:失败照样 forgotten++ ⇒ 遗忘计数虚高(每轮刷日志 + 计数失真)。
|
||||
func TestForget_SoftDeleteFailure_DoesNotIncrement(t *testing.T) {
|
||||
db := &forgetMockLance{
|
||||
candidates: []map[string]interface{}{fragmentCandidate("mem_fail_1")},
|
||||
softDeleteErr: fmt.Errorf("ipc: simulated soft delete failure"),
|
||||
}
|
||||
resp := callForget(t, db)
|
||||
if resp.Forgotten != 0 {
|
||||
t.Fatalf("SoftDelete 失败时 forgotten 不得增加,实得 %d(计数 bug 未修)", resp.Forgotten)
|
||||
}
|
||||
t.Logf("✓ SoftDelete 失败 → forgotten=%d(不虚增)", resp.Forgotten)
|
||||
}
|
||||
|
||||
// TestForget_SoftDeleteSuccess_Increments 正例(量具铁律:证明量具能报阳性):
|
||||
// 有效 id 候选 + SoftDelete 成功 → forgotten 必须 +1。
|
||||
func TestForget_SoftDeleteSuccess_Increments(t *testing.T) {
|
||||
db := &forgetMockLance{
|
||||
candidates: []map[string]interface{}{fragmentCandidate("mem_ok_1")},
|
||||
}
|
||||
resp := callForget(t, db)
|
||||
if resp.Forgotten != 1 {
|
||||
t.Fatalf("SoftDelete 成功时 forgotten 应 +1,实得 %d", resp.Forgotten)
|
||||
}
|
||||
if len(db.deletedIDs) != 1 || db.deletedIDs[0] != "mem_ok_1" {
|
||||
t.Fatalf("SoftDelete 应被调用一次并传入 mem_ok_1,实得 %v", db.deletedIDs)
|
||||
}
|
||||
t.Logf("✓ SoftDelete 成功 → forgotten=%d(计数正确)", resp.Forgotten)
|
||||
}
|
||||
|
||||
// TestForget_EmptyIDSkipped 空 id 候选直接跳过(Step 2a 守卫):
|
||||
// id 为空(id/content/source 全空的垃圾行)→ 不计数、不调用 SoftDelete。
|
||||
func TestForget_EmptyIDSkipped(t *testing.T) {
|
||||
db := &forgetMockLance{
|
||||
candidates: []map[string]interface{}{fragmentCandidate("")},
|
||||
}
|
||||
resp := callForget(t, db)
|
||||
if resp.Forgotten != 0 {
|
||||
t.Fatalf("空 id 候选不得计入 forgotten,实得 %d", resp.Forgotten)
|
||||
}
|
||||
if len(db.deletedIDs) != 0 {
|
||||
t.Fatalf("空 id 候选不得调用 SoftDelete,实得 %v", db.deletedIDs)
|
||||
}
|
||||
t.Logf("✓ 空 id 候选跳过:forgotten=%d, SoftDelete 调用 0 次", resp.Forgotten)
|
||||
}
|
||||
@@ -2,18 +2,41 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// DistillEngineRef 全局蒸馏引擎引用(server.go 注入)
|
||||
var DistillEngineRef *distill.Engine
|
||||
|
||||
// CommitMeta 2026-10-08 (Gap#2):commit 时写入方携带的元数据,透传到蒸馏产物 MemoryRecord。
|
||||
// 集中成一个结构体而非继续加位置参数——8 个位置参数再加 2 个时,「忘记传」是必然的
|
||||
// (Gap#2 的接线缺口正是这类 bug:字段加了、没人传)。
|
||||
type CommitMeta struct {
|
||||
Source string // 出处(笔记路径等)→ MemoryRecord.Source
|
||||
EmbeddingModel string // 血缘 → MemoryRecord.EmbeddingModel
|
||||
ScriptVersion string // 血缘 → MemoryRecord.ScriptVersion
|
||||
Confidence *float64 // 信任等级(写入方显式声明,不从 source 反推);nil=未声明
|
||||
ConfidenceSource string // authoritative | estimated
|
||||
}
|
||||
|
||||
// AutoDistillTrigger commit 后自动触发蒸馏流水线
|
||||
func AutoDistillTrigger(episodeID string, content string, category string, namespace string, agentID string) {
|
||||
// 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
|
||||
@@ -22,11 +45,16 @@ func AutoDistillTrigger(episodeID string, content string, category string, names
|
||||
// 2. 使用蒸馏引擎
|
||||
if DistillEngineRef != nil {
|
||||
DistillEngineRef.Enqueue(distill.DistillInput{
|
||||
EpisodeID: episodeID,
|
||||
Content: content,
|
||||
Category: distill.Category(category),
|
||||
Namespace: namespace,
|
||||
AgentID: agentID,
|
||||
EpisodeID: episodeID,
|
||||
Content: content,
|
||||
Category: distill.Category(category),
|
||||
Namespace: namespace,
|
||||
AgentID: agentID,
|
||||
Source: meta.Source,
|
||||
EmbeddingModel: meta.EmbeddingModel,
|
||||
ScriptVersion: meta.ScriptVersion,
|
||||
Confidence: meta.Confidence,
|
||||
ConfidenceSource: meta.ConfidenceSource,
|
||||
})
|
||||
} else {
|
||||
// 降级: 关键词提取
|
||||
@@ -36,6 +64,7 @@ func AutoDistillTrigger(episodeID string, content string, category string, names
|
||||
Facts: facts,
|
||||
Entities: extractEntities(content),
|
||||
Namespace: namespace,
|
||||
Source: meta.Source, // 2026-10-12 t_67d0279a 方案A:降级路径同样透传来源分档
|
||||
})
|
||||
}
|
||||
|
||||
@@ -89,8 +118,8 @@ func currentUnixMS() int64 {
|
||||
// ─── 全局引用(server.go 需要访问)─────────────────────
|
||||
|
||||
var (
|
||||
graphUpdater *governance.AutoGraphUpdater
|
||||
conflictScanner = governance.NewConflictDetector()
|
||||
graphUpdater *governance.AutoGraphUpdater
|
||||
conflictScanner = governance.NewConflictDetector()
|
||||
)
|
||||
|
||||
func SetGraphUpdater(gu *governance.AutoGraphUpdater) {
|
||||
@@ -98,6 +127,8 @@ func SetGraphUpdater(gu *governance.AutoGraphUpdater) {
|
||||
}
|
||||
|
||||
func minLen2(a, b int) int {
|
||||
if a < b { return a }
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// Hermes / OpenClaw 的客户端库,零外部依赖
|
||||
//
|
||||
// 使用:
|
||||
// client := NewClient("http://localhost:7821", "mw-your-api-key")
|
||||
// mid, err := client.Commit("hermes", "shared", "牧尘用 Deepin 25", "system_fact")
|
||||
// results, err := client.Recall("牧尘系统是什么", 5)
|
||||
//
|
||||
// client := NewClient("http://localhost:7821", "mw-your-api-key")
|
||||
// mid, err := client.Commit("hermes", "shared", "牧尘用 Deepin 25", "system_fact")
|
||||
// results, err := client.Recall("牧尘系统是什么", 5)
|
||||
package routes
|
||||
|
||||
import (
|
||||
|
||||
@@ -36,7 +36,7 @@ func (ca *ConflictAPI) Resolve(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ConflictID string `json:"conflict_id"`
|
||||
Resolution string `json:"resolution"` // keep_left / keep_right / merge / dismiss
|
||||
Winner string `json:"winner"` // id of the winning memory
|
||||
Winner string `json:"winner"` // id of the winning memory
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
|
||||
@@ -4,6 +4,7 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -23,7 +24,7 @@ type ConsolidationPipeline struct {
|
||||
graphUpdater *governance.AutoGraphUpdater
|
||||
conflicts *governance.ConflictDetector
|
||||
// Rust IPC 路径
|
||||
dataDir string
|
||||
dataDir string
|
||||
sqlitePath string
|
||||
}
|
||||
|
||||
@@ -112,12 +113,15 @@ func (cp *ConsolidationPipeline) RunWithMode(mode string) (*ConsolidationReport,
|
||||
}
|
||||
|
||||
// Phase F: 整合完成后更新总记忆数指标(通过 LanceDB stats)
|
||||
// total_episodes 来自 Rust count_rows(真实值);旧 sidecar 回退 episodes_cached(非总量)
|
||||
if stats, err := cp.ldb.Stats(); err == nil {
|
||||
if total, ok := stats["total_memories"].(float64); ok {
|
||||
metrics.TotalMemories.Set(total)
|
||||
}
|
||||
if total, ok := stats["total_episodes"].(float64); ok {
|
||||
metrics.TotalEpisodes.Set(total)
|
||||
} else if cached, ok := stats["episodes_cached"].(float64); ok {
|
||||
metrics.TotalEpisodes.Set(cached) // 旧 sidecar 回退,非真实总量
|
||||
}
|
||||
}
|
||||
metrics.SyncFromDashboard(selfoptimize.Dash.Metrics())
|
||||
@@ -207,7 +211,10 @@ func (cp *ConsolidationPipeline) RunMerge() (int, error) {
|
||||
return cp.mergeSimilar()
|
||||
}
|
||||
|
||||
// mergeSimilar 合并相似记忆(向量相似度 > 0.8 → 保留最新)
|
||||
// mergeSimilar 合并相似记忆(相同 category + 高内容重叠 → 保留较新)
|
||||
// 2026-09-06 P1 fix: 原实现双层循环全量 O(n²) —— febc2c9 全表扫描时触发 620% CPU 风暴。
|
||||
// 改为按 category 分桶后桶内两两比较(原逻辑 catI != catJ continue 本就等价),
|
||||
// 单桶超 maxMergePerBucket 截断,总计算量 O(Σ桶²) << O(n²)。
|
||||
func (cp *ConsolidationPipeline) mergeSimilar() (int, error) {
|
||||
// 获取全部 distilled 记忆
|
||||
memories, err := cp.ldb.GetCandidatesForForgetting()
|
||||
@@ -215,23 +222,41 @@ func (cp *ConsolidationPipeline) mergeSimilar() (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
const maxMergePerBucket = 500
|
||||
merged := 0
|
||||
// 简单启发式:相同 category + 高内容重叠 → 合并
|
||||
for i := 0; i < len(memories); i++ {
|
||||
for j := i + 1; j < len(memories); j++ {
|
||||
catI := strVal(memories[i]["category"])
|
||||
catJ := strVal(memories[j]["category"])
|
||||
if catI != catJ {
|
||||
continue
|
||||
}
|
||||
contentI := strVal(memories[i]["content"])
|
||||
contentJ := strVal(memories[j]["content"])
|
||||
if overlap := contentOverlap(contentI, contentJ); overlap > 0.8 {
|
||||
// 保留较新的
|
||||
idI := strVal(memories[i]["id"])
|
||||
idJ := strVal(memories[j]["id"])
|
||||
_ = cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI))
|
||||
merged++
|
||||
|
||||
// 按 category 分桶
|
||||
buckets := make(map[string][]map[string]interface{})
|
||||
for _, m := range memories {
|
||||
cat := strVal(m["category"])
|
||||
buckets[cat] = append(buckets[cat], m)
|
||||
}
|
||||
|
||||
// 桶内两两比较(同桶才可能合并,语义与原全量双层一致)
|
||||
for _, mems := range buckets {
|
||||
if len(mems) > maxMergePerBucket {
|
||||
// 单桶爆炸防护:只处理最近 maxMergePerBucket 条(created_at 倒序)
|
||||
sort.SliceStable(mems, func(a, b int) bool {
|
||||
return memoryInt64Val(mems[a], "created_at") > memoryInt64Val(mems[b], "created_at")
|
||||
})
|
||||
mems = mems[:maxMergePerBucket]
|
||||
}
|
||||
for i := 0; i < len(mems); i++ {
|
||||
for j := i + 1; j < len(mems); j++ {
|
||||
contentI := strVal(mems[i]["content"])
|
||||
contentJ := strVal(mems[j]["content"])
|
||||
if overlap := contentOverlap(contentI, contentJ); overlap > 0.8 {
|
||||
// 保留较新的
|
||||
idI := strVal(mems[i]["id"])
|
||||
idJ := strVal(mems[j]["id"])
|
||||
// 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,12 +264,21 @@ func (cp *ConsolidationPipeline) mergeSimilar() (int, error) {
|
||||
}
|
||||
|
||||
// Step 2: 扫描所有待解决冲突
|
||||
// 2026-09-06 P1 fix: 原注释"最近 100 条"但实际对全量做实体提取+对比 → 全表时 O(n·50)。
|
||||
// 显式按 created_at 取最近 maxConflictScan 条再扫。
|
||||
func (cp *ConsolidationPipeline) scanConflicts() (int, error) {
|
||||
// 获取最近 100 条记忆
|
||||
// 获取候选并按最近优先截断(created_at 倒序)
|
||||
memories, err := cp.ldb.GetCandidatesForForgetting()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
const maxConflictScan = 500
|
||||
if len(memories) > maxConflictScan {
|
||||
sort.SliceStable(memories, func(a, b int) bool {
|
||||
return memoryInt64Val(memories[a], "created_at") > memoryInt64Val(memories[b], "created_at")
|
||||
})
|
||||
memories = memories[:maxConflictScan]
|
||||
}
|
||||
|
||||
count := 0
|
||||
for i := 0; i < len(memories); i++ {
|
||||
@@ -305,17 +339,28 @@ func (cp *ConsolidationPipeline) updateGraph() (int, error) {
|
||||
return before - after, nil
|
||||
}
|
||||
|
||||
// checkTimestampSanity 抽样检查记忆时间戳合理性(5% sampling,不阻塞)
|
||||
// checkTimestampSanity 抽样检查记忆时间戳合理性(均匀抽样,不阻塞)
|
||||
// 返回 (可疑数量, 抽样总数)。epoch-0(< 2024-01-01)视为可疑。
|
||||
// 2026-09-06 P1 fix: 原实现全量拉取只为 5% 抽样 → 先截断最近 maxSanitySample
|
||||
// 条再做均匀 step 抽样,避免全量内存/IO 与排序成本。
|
||||
func (cp *ConsolidationPipeline) checkTimestampSanity() (suspicious, total int) {
|
||||
const epochThreshold int64 = 1704067200 // 2024-01-01 00:00:00 UTC
|
||||
const maxSanitySample = 1000
|
||||
|
||||
memories, err := cp.ldb.GetCandidatesForForgetting()
|
||||
if err != nil || len(memories) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// 全量不超过 100 条
|
||||
// 超过 maxSanitySample 时按 created_at 取最新,再均匀抽样
|
||||
if len(memories) > maxSanitySample {
|
||||
sort.SliceStable(memories, func(a, b int) bool {
|
||||
return memoryInt64Val(memories[a], "created_at") > memoryInt64Val(memories[b], "created_at")
|
||||
})
|
||||
memories = memories[:maxSanitySample]
|
||||
}
|
||||
|
||||
// 抽样最多 100 条
|
||||
sampleSize := len(memories)
|
||||
if sampleSize > 100 {
|
||||
sampleSize = 100
|
||||
@@ -404,7 +449,8 @@ func extractEntities(text string) []string {
|
||||
}
|
||||
|
||||
func minInt2(a, b int) int {
|
||||
if a < b { return a }
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+1121
-136
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,491 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 字段")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── t_58f9d476 回归:P0-3 翻页切片吞掉 carry ──────────────────────────────
|
||||
// 病灶:capRecallResults 之后 results = [主(填满 limit)..., carry...],紧随的
|
||||
// P0-3 翻页块裸 `results[:req.Limit]` 恒把 carry 切掉(carry 恰在索引 limit)
|
||||
// ⇒ t_e98cb1d6 的 carry 修复被作废,peer/pattern 注入结构性失明
|
||||
// (t_9dc647f1 dbg 插桩实证:注入 4087→4088 发生,响应 50 条无 peer)。
|
||||
// 修后:paginateRecallWithCarry 对 carry 豁免切片并回尾;关态(无 carry)逐位 delta=0。
|
||||
func TestPaginateRecallWithCarry(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
carry := func(rs ...models.RecallResult) []models.RecallResult { return rs }
|
||||
|
||||
// ① 金标场景:limit=5、主+扩展填满 limit、末位=peer ⇒ 切片后末位仍 = peer:<id>,
|
||||
// 且主路段逐位 = 原前 5 位(t_9025b0eb AC:主路 ≤ limit 不变)。
|
||||
t.Run("limit=5_末位peer存活_主路段逐位不变", func(t *testing.T) {
|
||||
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer", Content: "p"})
|
||||
got, mainLen := paginateRecallWithCarry(in, 0, nil, 5)
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("len=%d want 6(主 5 + carry 1)", len(got))
|
||||
}
|
||||
if mainLen != 5 {
|
||||
t.Fatalf("mainLen=%d want 5(t_2891d689:判据主路段长度=limit,翻页令牌据此发放)", mainLen)
|
||||
}
|
||||
if got[5].ID != "peer:muc" || got[5].Category != "peer" {
|
||||
t.Fatalf("末位=%s/%s,want peer:muc/peer(截断回归未修)", got[5].ID, got[5].Category)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
if got[i].ID != in[i].ID {
|
||||
t.Fatalf("主路段第 %d 位变了: got=%s want=%s", i, got[i].ID, in[i].ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ② limit=50、pattern(3)+peer(1) 全存活,主路段逐位不变。
|
||||
t.Run("limit=50_pattern与peer全存活", func(t *testing.T) {
|
||||
in := append(mkMain(50), carry(
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "pattern:b", Category: "pattern"},
|
||||
models.RecallResult{ID: "pattern:c", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"},
|
||||
)...)
|
||||
got, _ := paginateRecallWithCarry(in, 0, nil, 50)
|
||||
if len(got) != 54 {
|
||||
t.Fatalf("len=%d want 54", len(got))
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
if got[i].ID != in[i].ID {
|
||||
t.Fatalf("主路段第 %d 位变了", i)
|
||||
}
|
||||
}
|
||||
if got[53].ID != "peer:muc" {
|
||||
t.Fatalf("末位=%s want peer:muc", got[53].ID)
|
||||
}
|
||||
nPat := 0
|
||||
for _, r := range got {
|
||||
if r.Category == "pattern" {
|
||||
nPat++
|
||||
}
|
||||
}
|
||||
if nPat != 3 {
|
||||
t.Fatalf("pattern=%d want 3", nPat)
|
||||
}
|
||||
})
|
||||
|
||||
// ③ 关态(无 carry)逐位 delta=0:offset × prevIDs × limit 组合下
|
||||
// 与旧内联语义(切片→去重→截断)逐一比对。
|
||||
t.Run("关态无carry_与旧内联逻辑逐位一致", func(t *testing.T) {
|
||||
legacy := func(in []models.RecallResult, offset int, prevIDs []string, limit int) []models.RecallResult {
|
||||
results := in
|
||||
if offset > 0 {
|
||||
if offset >= len(results) {
|
||||
results = []models.RecallResult{}
|
||||
} else {
|
||||
results = results[offset:]
|
||||
}
|
||||
}
|
||||
if len(prevIDs) > 0 {
|
||||
seen := make(map[string]bool, len(prevIDs))
|
||||
for _, id := range prevIDs {
|
||||
seen[id] = true
|
||||
}
|
||||
kept := make([]models.RecallResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
if !seen[r.ID] {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
results = kept
|
||||
}
|
||||
if len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results
|
||||
}
|
||||
in := mkMain(30)
|
||||
for _, offset := range []int{0, 1, 7, 29, 30, 45} {
|
||||
for _, prev := range [][]string{nil, {"mem_008"}, {"mem_008", "mem_009", "mem_020"}} {
|
||||
for _, limit := range []int{5, 10, 50} {
|
||||
got, _ := paginateRecallWithCarry(in, offset, prev, limit)
|
||||
want := legacy(in, offset, prev, limit)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("offset=%d prev=%v limit=%d: len got=%d want=%d", offset, prev, limit, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i].ID != want[i].ID {
|
||||
t.Fatalf("offset=%d prev=%v limit=%d: 第%d位 got=%s want=%s", offset, prev, limit, i, got[i].ID, want[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ④ 主路段切空 ⇒ 整页空(不返回孤儿 carry,与旧语义一致)。
|
||||
t.Run("offset越界_整页空不带孤儿carry", func(t *testing.T) {
|
||||
in := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
if got, _ := paginateRecallWithCarry(in, 5, nil, 5); len(got) != 0 {
|
||||
t.Fatalf("len=%d want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
// ⑤ 续页(offset>0 + prevIDs 去重)主路段从 offset 起且去掉 prevIDs。
|
||||
// ⚠ 2026-11-02 (t_2891d689) 判据反转:原断言「续页 carry 回尾」锁死的正是
|
||||
// 本卡要改的行为(carry 每页重复出现,去重成本转嫁客户端)。现语义:
|
||||
// carry 只在第一页出现,续页只回主路段。
|
||||
t.Run("续页不回carry_主路段offset加去重", func(t *testing.T) {
|
||||
in := append(mkMain(20), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
got, mainLen := paginateRecallWithCarry(in, 3, []string{"mem_005"}, 5)
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("len=%d want 5(续页不含 carry)", len(got))
|
||||
}
|
||||
if mainLen != 5 {
|
||||
t.Fatalf("mainLen=%d want 5", mainLen)
|
||||
}
|
||||
wantMain := []string{"mem_003", "mem_004", "mem_006", "mem_007", "mem_008"}
|
||||
for i, id := range wantMain {
|
||||
if got[i].ID != id {
|
||||
t.Fatalf("主路段第 %d 位 got=%s want=%s", i, got[i].ID, id)
|
||||
}
|
||||
}
|
||||
for _, r := range got {
|
||||
if r.Category == "peer" || r.Category == "pattern" {
|
||||
t.Fatalf("续页混入 carry: %s/%s", r.ID, r.Category)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ⑥ 端到端:capRecallResults → paginateRecallWithCarry 全链路
|
||||
// (生产形态:主 60 + 扩展 100 + pattern 3 + peer 1,limit=50)。
|
||||
t.Run("端到端cap后翻页_peer不再被切", func(t *testing.T) {
|
||||
var all []models.RecallResult
|
||||
all = append(all, mkMain(60)...)
|
||||
for i := 0; i < 100; i++ {
|
||||
all = append(all, models.RecallResult{ID: fmt.Sprintf("gx_%03d", i), Category: "graph_expanded", Content: "[graph] x"})
|
||||
}
|
||||
all = append(all,
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
prim, _ := capRecallResults(all, 50)
|
||||
if prim[len(prim)-1].Category != "peer" {
|
||||
t.Fatalf("cap 后末位=%s,capRecallResults 语义变了", prim[len(prim)-1].ID)
|
||||
}
|
||||
got, _ := paginateRecallWithCarry(prim, 0, nil, 50)
|
||||
// 修前此处恒被切(results[:50] 丢掉末尾 carry)
|
||||
if got[len(got)-1].ID != "peer:muc" {
|
||||
t.Fatalf("翻页后末位=%s want peer:muc(P0-3 截断回归未修)", got[len(got)-1].ID)
|
||||
}
|
||||
nMain := 0
|
||||
for _, r := range got {
|
||||
if r.Category != "pattern" && r.Category != "peer" {
|
||||
nMain++
|
||||
}
|
||||
}
|
||||
if nMain > 50 {
|
||||
t.Fatalf("主路段 %d 条 > limit 50(t_9025b0eb AC 破坏)", nMain)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── t_2891d689 回归:carry 撑大 len(results) ⇒ nextCont 恒空、翻页链断 ──────
|
||||
// 病灶:4ffc546 后 paginateRecallWithCarry 回 main+carry,len(results) 可 > limit,
|
||||
// handler 判据 `len(results) == req.Limit` 只要 carry 命中就恒假
|
||||
// ⇒ nextCont 恒空,带 pattern/peer 注入的 query 第二页永远取不到。
|
||||
// 修后:判据收口 shouldEmitContinuation,看主路段 mainLen;carry 只在第一页回尾。
|
||||
// 判据链与 handler(core.go 翻页块)逐行同构:paginate → shouldEmitContinuation
|
||||
// → returned=prevIDs+页内ID → encodeContinuation(卡面允许的「抽出判定函数单测」路线)。
|
||||
func TestContinuationIssuedWithCarry(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
idsOf := func(rs []models.RecallResult) []string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 生产形态(t_58f9d476 实证):cap 后 prim = 主路段填满 limit + 末尾 carry。
|
||||
limit := 5
|
||||
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
totalFetched := len(prim) // 6 ≥ fetchLimit(=limit+offset+len(prev)=5)
|
||||
page, mainLen := paginateRecallWithCarry(prim, 0, nil, limit)
|
||||
if len(page) != 6 || mainLen != 5 {
|
||||
t.Fatalf("page=%d mainLen=%d want 6/5", len(page), mainLen)
|
||||
}
|
||||
// 病灶判据复现:len(page)==limit ⇒ 6==5 恒假 ⇒ 链断。本例页形必须仍是 len>limit,
|
||||
// 否则该用例失去对旧病灶的覆盖。
|
||||
if len(page) == limit {
|
||||
t.Fatal("页形不再是 len>limit,本用例失去对旧病灶的覆盖")
|
||||
}
|
||||
// 新判据:主路段满页 ⇒ 必须发令牌
|
||||
if !shouldEmitContinuation(totalFetched, limit, mainLen, limit, 0) {
|
||||
t.Fatal("totalFetched>=fetchLimit 且主路段满页 ⇒ 应发 nextCont(翻页链断裂回归未修)")
|
||||
}
|
||||
// handler 完整动作 ⇒ nextCont != "",且令牌推进 offset=5
|
||||
returned := idsOf(page) // 含 carry ID(t_58f9d476 既有行为:去重锚点)
|
||||
nextCont := encodeContinuation("q", "ns", 0+limit, limit, returned)
|
||||
if nextCont == "" {
|
||||
t.Fatal("nextCont == \"\",翻页链断")
|
||||
}
|
||||
tok, err := decodeContinuation(nextCont)
|
||||
if err != nil || tok.Off != 5 || tok.Lim != 5 {
|
||||
t.Fatalf("令牌非法: off=%d lim=%d err=%v(want 5/5/nil)", tok.Off, tok.Lim, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AC2:offset>0 的续页 ⇒ 不含前页已返回的 carry,主路段继续推进;尾页不满 ⇒ 链自然终止。
|
||||
func TestCarryOnlyOnFirstPageAcrossPages(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
limit := 5
|
||||
// 18 主(4 页:5+5+5+3)+ 2 carry
|
||||
prim := append(mkMain(18),
|
||||
models.RecallResult{ID: "pattern:a", Category: "pattern"},
|
||||
models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
|
||||
// 第 1 页:主 5 + carry 2 回尾;令牌 returned 含 carry ID
|
||||
page1, ml1 := paginateRecallWithCarry(prim, 0, nil, limit)
|
||||
if len(page1) != 7 || ml1 != 5 {
|
||||
t.Fatalf("page1=%d mainLen=%d want 7/5", len(page1), ml1)
|
||||
}
|
||||
if page1[6].Category != "peer" || page1[5].Category != "pattern" {
|
||||
t.Fatalf("第一页 carry 未回尾: %v", page1[5:])
|
||||
}
|
||||
if !shouldEmitContinuation(len(prim), limit, ml1, limit, 0) {
|
||||
t.Fatal("第 1 页应发令牌")
|
||||
}
|
||||
prev := make([]string, 0, len(page1))
|
||||
for _, r := range page1 {
|
||||
prev = append(prev, r.ID)
|
||||
}
|
||||
|
||||
// 第 2 页:offset=5(模拟 handler 从令牌取 offset/prev)
|
||||
page2, ml2 := paginateRecallWithCarry(prim, 5, prev, limit)
|
||||
for _, r := range page2 {
|
||||
if r.Category == "pattern" || r.Category == "peer" {
|
||||
t.Fatalf("续页混入前页 carry: %s/%s", r.ID, r.Category)
|
||||
}
|
||||
}
|
||||
want2 := []string{"mem_005", "mem_006", "mem_007", "mem_008", "mem_009"}
|
||||
if len(page2) != 5 || ml2 != 5 {
|
||||
t.Fatalf("page2=%d mainLen=%d want 5/5", len(page2), ml2)
|
||||
}
|
||||
for i, id := range want2 {
|
||||
if page2[i].ID != id {
|
||||
t.Fatalf("主路段未推进: 第%d位 got=%s want=%s", i, page2[i].ID, id)
|
||||
}
|
||||
}
|
||||
// 链未断:第 2 页满页且取数满足 ⇒ 继续发令牌(生产页 2 的 fetchLimit=5+5+len(prev))
|
||||
if !shouldEmitContinuation(limit+5+len(prev), limit+5+len(prev), ml2, limit, 5) {
|
||||
t.Fatal("满页续页应继续发令牌(链不得在第 2 页断)")
|
||||
}
|
||||
for _, r := range page2 {
|
||||
prev = append(prev, r.ID)
|
||||
}
|
||||
// 第 3 页照推
|
||||
page3, ml3 := paginateRecallWithCarry(prim, 10, prev, limit)
|
||||
if ml3 != 5 || page3[0].ID != "mem_010" {
|
||||
t.Fatalf("page3 mainLen=%d first=%s want 5/mem_010", ml3, page3[0].ID)
|
||||
}
|
||||
// 第 4 页(尾页只剩 3 主)⇒ mainLen=3 < limit ⇒ 不再发令牌(不得错发空尾页令牌)
|
||||
page4, ml4 := paginateRecallWithCarry(prim, 15, prev, limit)
|
||||
if ml4 != 3 || len(page4) != 3 {
|
||||
t.Fatalf("page4 mainLen=%d len=%d want 3/3", ml4, len(page4))
|
||||
}
|
||||
if shouldEmitContinuation(len(prim), limit+15+len(prev), ml4, limit, 15) {
|
||||
t.Fatal("尾页主路段不满 limit ⇒ 不应发令牌")
|
||||
}
|
||||
}
|
||||
|
||||
// AC3:offset 越界 ⇒ 空页且 nextCont == "";并钉死「判据看 mainLen 而非 len(results)」:
|
||||
// 主路段 4 + carry 1(len(page)==limit 但 mainLen<limit)也不得发令牌 ——
|
||||
// 这正是卡面警告的「不要用 len(results)>=limit,offset 越界/尾页会错发 token」的镜像。
|
||||
func TestNoContinuationOnEmptyOrShortMainPage(t *testing.T) {
|
||||
mkMain := func(n int) []models.RecallResult {
|
||||
out := make([]models.RecallResult, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = models.RecallResult{ID: fmt.Sprintf("mem_%03d", i), Category: "distilled", Content: "m"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
limit := 5
|
||||
|
||||
// A. offset 越界(=主路段长度)⇒ 空页 + 不发令牌
|
||||
prim := append(mkMain(5), models.RecallResult{ID: "peer:muc", Category: "peer"})
|
||||
page, ml := paginateRecallWithCarry(prim, 5, []string{"mem_000"}, limit)
|
||||
if len(page) != 0 || ml != 0 {
|
||||
t.Fatalf("越界页 len=%d mainLen=%d want 0/0", len(page), ml)
|
||||
}
|
||||
if shouldEmitContinuation(len(prim), limit+5+1, ml, limit, 5) {
|
||||
t.Fatal("越界空页不得发令牌")
|
||||
}
|
||||
|
||||
// B. 主路段不满页但 len(page)==limit(4 主 + 1 carry 撑到 5)⇒ 不得发令牌
|
||||
prim2 := append(mkMain(4), models.RecallResult{ID: "pattern:a", Category: "pattern"})
|
||||
page2, ml2 := paginateRecallWithCarry(prim2, 0, nil, limit)
|
||||
if len(page2) != 5 || ml2 != 4 {
|
||||
t.Fatalf("短页 len=%d mainLen=%d want 5/4", len(page2), ml2)
|
||||
}
|
||||
if shouldEmitContinuation(len(prim2), limit, ml2, limit, 0) {
|
||||
t.Fatal("主路段未满页 ⇒ 不得发令牌(判据必须看 mainLen,不是 len(results))")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 织忆 MemoryWeave — 深度推理端点(人侧数据 → 三类清单)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
)
|
||||
|
||||
// DeepThinkRequest 深度推理请求
|
||||
type DeepThinkRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
// namespace 为空时取人侧默认命名空间
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
// 最大社区数(摘要开销控制)
|
||||
MaxCommunities int `json:"max_communities,omitempty"`
|
||||
}
|
||||
|
||||
// DeepThinkResponse 三类清单
|
||||
type DeepThinkResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Namespace string `json:"namespace"`
|
||||
Profiles []DeepThinkItem `json:"profiles"` // 画像规则(待拍板)
|
||||
Episodes []DeepThinkItem `json:"episodes"` // 心迹/牵挂/承诺句(已处置)
|
||||
Signals []DeepThinkItem `json:"signals"` // 社区摘要(信号)
|
||||
ConflictsPending int `json:"conflicts_pending"`
|
||||
LastRun string `json:"last_run"`
|
||||
}
|
||||
|
||||
// DeepThinkItem 清单条目
|
||||
type DeepThinkItem struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Content string `json:"content"`
|
||||
Weight string `json:"weight,omitempty"` // 社区摘要用
|
||||
Source string `json:"source"` // memories | graph | community
|
||||
RecallOnly bool `json:"recall_only"` // 仅 recall 时注入,不持久化
|
||||
}
|
||||
|
||||
// HandleDeepThink POST /api/v1/deep-think
|
||||
func (a *API) HandleDeepThink(w http.ResponseWriter, r *http.Request) {
|
||||
var req DeepThinkRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "decode request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.AgentID == "" {
|
||||
respondError(w, 400, "agent_id required")
|
||||
return
|
||||
}
|
||||
ns := req.Namespace
|
||||
if ns == "" {
|
||||
// 人侧数据统一命名空间(牧尘拍板:soulful-xiaowei,deriveNamespace 会自动加 -main 后缀 → soulful-xiaowei-main)
|
||||
ns = deriveNamespace("soulful-xiaowei")
|
||||
}
|
||||
maxC := req.MaxCommunities
|
||||
if maxC == 0 {
|
||||
maxC = 10
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
resp := DeepThinkResponse{
|
||||
OK: true,
|
||||
AgentID: req.AgentID,
|
||||
Namespace: ns,
|
||||
LastRun: now.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// 1. 画像 → category=profiles
|
||||
resp.Profiles = a.fetchCategory(ns, "profiles", 20, false)
|
||||
// 2. 心迹/牵挂/承诺句 → category=episodes
|
||||
episodes := a.fetchCategory(ns, "episodes", 30, false)
|
||||
episodes = append(episodes, a.fetchCategory(ns, "cares", 15, false)...)
|
||||
resp.Episodes = episodes
|
||||
|
||||
// 3. 冲突(待裁决)→ 待拍板
|
||||
resp.ConflictsPending = 0
|
||||
if a.ConflictDetector != nil {
|
||||
for _, c := range a.ConflictDetector.ListActive() {
|
||||
if c.Status == "pending" {
|
||||
resp.ConflictsPending++
|
||||
resp.Profiles = append(resp.Profiles, DeepThinkItem{
|
||||
ID: c.ID, Category: "conflict", Content: c.Description,
|
||||
Source: "conflict_detector", RecallOnly: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 图谱社区 → 信号
|
||||
cds := governance.NewCommunityDiscovery(a.GraphStore)
|
||||
communities, err := cds.Find(ns, 3)
|
||||
if err == nil && len(communities) > 0 {
|
||||
if len(communities) > maxC {
|
||||
communities = communities[:maxC]
|
||||
}
|
||||
summaries, _ := cds.Summarize(communities, ns, 5)
|
||||
for _, c := range summaries {
|
||||
resp.Signals = append(resp.Signals, DeepThinkItem{
|
||||
ID: c.ID,
|
||||
Category: "community",
|
||||
Content: c.Summary,
|
||||
Weight: strconv.FormatFloat(c.Weight, 'f', 2, 64),
|
||||
Source: "community_discovery",
|
||||
RecallOnly: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 图谱扩展(画像/牵挂/心迹 的邻接)
|
||||
resp.Signals = append(resp.Signals, a.graphSignals(ns, resp.Profiles, resp.Episodes)...)
|
||||
|
||||
// 6. 写回 category=signals(异步持久化)
|
||||
go a.persistSignals(resp)
|
||||
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"ok": true, "agent_id": req.AgentID, "namespace": ns,
|
||||
"profiles": len(resp.Profiles),
|
||||
"episodes": len(resp.Episodes),
|
||||
"signals": len(resp.Signals),
|
||||
"last_run": resp.LastRun,
|
||||
})
|
||||
}
|
||||
|
||||
// fetchCategory 取某 category 的记忆(用零向量 search,按 importance 排序)
|
||||
func (a *API) fetchCategory(ns, cat string, limit int, recallOnly bool) []DeepThinkItem {
|
||||
zeroVec := make([]float32, 1024)
|
||||
results, err := a.LanceDB.Search("memories", zeroVec, limit, ns)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []DeepThinkItem
|
||||
for _, m := range results {
|
||||
if cat != "" && m.Category != cat {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeepThinkItem{
|
||||
ID: m.ID, Category: m.Category, Content: truncate(m.Content, 200),
|
||||
Source: "memories", RecallOnly: recallOnly,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// graphSignals 从图谱取画像/牵挂/心迹的邻接关系
|
||||
func (a *API) graphSignals(ns string, profiles, episodes []DeepThinkItem) []DeepThinkItem {
|
||||
var out []DeepThinkItem
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range append(profiles, episodes...) {
|
||||
if item.ID == "" {
|
||||
continue
|
||||
}
|
||||
paths, err := a.GraphStore.Navigate(item.ID, 2, ns, nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range paths {
|
||||
to, _ := p["to"].(string)
|
||||
rel, _ := p["relation"].(string)
|
||||
if to == "" || seen[to] {
|
||||
continue
|
||||
}
|
||||
seen[to] = true
|
||||
out = append(out, DeepThinkItem{
|
||||
ID: to, Category: "graph_relation",
|
||||
Content: fmt.Sprintf("%s → %s: %s", item.ID, to, rel),
|
||||
Source: "graph", RecallOnly: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// persistSignals 把信号写回 memories(category=signals)
|
||||
// 异步:不把延迟暴露给调用方
|
||||
func (a *API) persistSignals(resp DeepThinkResponse) {
|
||||
if len(resp.Signals) == 0 {
|
||||
return
|
||||
}
|
||||
for _, s := range resp.Signals {
|
||||
_, _ = a.LanceDB.InsertEpisode(resp.AgentID, resp.Namespace,
|
||||
fmt.Sprintf("[信号] %s | 来源:%s", s.Content, s.Source), "signals")
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCommunitySummarize POST /api/v1/community/summarize
|
||||
// 运行 Louvain 社区发现 + 摘要(纯 Go,不调 LLM)
|
||||
func (a *API) HandleCommunitySummarize(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
MaxCommunities int `json:"max_communities,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "decode request: "+err.Error())
|
||||
return
|
||||
}
|
||||
ns := req.Namespace
|
||||
if ns == "" {
|
||||
ns = deriveNamespace("soulful-xiaowei")
|
||||
}
|
||||
maxC := req.MaxCommunities
|
||||
if maxC == 0 {
|
||||
maxC = 10
|
||||
}
|
||||
|
||||
cds := governance.NewCommunityDiscovery(a.GraphStore)
|
||||
communities, err := cds.Find(ns, 3)
|
||||
if err != nil {
|
||||
respondError(w, 500, "find communities: "+err.Error())
|
||||
return
|
||||
}
|
||||
if len(communities) > maxC {
|
||||
communities = communities[:maxC]
|
||||
}
|
||||
summaries, _ := cds.Summarize(communities, ns, 5)
|
||||
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"ok": true, "namespace": ns,
|
||||
"communities": len(communities),
|
||||
"summaries": summaries,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// NodeVocabularyFunc 返回「节点名 → PageRank 权重」快照(通常是 graphStore.PageRank)。
|
||||
type NodeVocabularyFunc func() map[string]float64
|
||||
|
||||
// entityCandidate 是查询串里命中的一个图谱节点。
|
||||
type entityCandidate struct {
|
||||
Name string // 原始拼写(用于 Navigate / 笔记匹配)
|
||||
Weight float64
|
||||
Runes int
|
||||
Pos int
|
||||
}
|
||||
|
||||
// EntityIndex 用图谱节点名词表把「整句自然语言」拆成候选实体(本地最长/权重匹配)。
|
||||
//
|
||||
// 背景:调用方(hermes 织忆插件)把整句用户提问直接当 entity 传给
|
||||
// /api/v1/graph/notes,NormalizeEntity 后整句匹配不到任何节点 → 0 篇笔记。
|
||||
// 服务端在这里兜底拆实体,调用方无需改动(也就无需重启 hermes 网关)。
|
||||
type EntityIndex struct {
|
||||
load NodeVocabularyFunc
|
||||
ttl time.Duration
|
||||
|
||||
// MinNameRunes / MaxNameRunes 限定候选实体长度(rune 数)。
|
||||
MinNameRunes int
|
||||
MaxNameRunes int
|
||||
// MinWeightRatio 丢掉相对 top 候选过弱的候选(压通用词噪声,如「位置」)。
|
||||
MinWeightRatio float64
|
||||
// Degree 返回实体在图谱里的度数(edges 计数);nil 表示不做度数过滤。
|
||||
Degree func(name string) int
|
||||
// MinDegree 度数低于此值的候选判为噪声节点(如自动切词产物「一句话」)。
|
||||
MinDegree int
|
||||
|
||||
mu sync.RWMutex
|
||||
names map[string]entityCandidate
|
||||
loadedAt time.Time
|
||||
lastTryAt time.Time
|
||||
retryGap time.Duration
|
||||
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewEntityIndex 创建实体索引;load 为 nil 时 Extract 恒返回空。
|
||||
func NewEntityIndex(load NodeVocabularyFunc) *EntityIndex {
|
||||
return &EntityIndex{
|
||||
load: load,
|
||||
ttl: 10 * time.Minute,
|
||||
MinNameRunes: 2,
|
||||
MaxNameRunes: 8,
|
||||
MinWeightRatio: 0.15,
|
||||
MinDegree: 3,
|
||||
retryGap: 60 * time.Second,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Warm 在 delay 之后后台加载一次词表,避免首次兜底请求被 2.5s 的 PageRank 阻塞。
|
||||
func (e *EntityIndex) Warm(delay time.Duration) {
|
||||
if e == nil || e.load == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
e.vocabulary()
|
||||
}()
|
||||
}
|
||||
|
||||
// vocabulary 返回当前词表(TTL 内复用;加载失败按 retryGap 退避,永不 panic)。
|
||||
func (e *EntityIndex) vocabulary() map[string]entityCandidate {
|
||||
if e == nil || e.load == nil {
|
||||
return nil
|
||||
}
|
||||
now := e.now()
|
||||
e.mu.RLock()
|
||||
if len(e.names) > 0 && now.Sub(e.loadedAt) < e.ttl {
|
||||
names := e.names
|
||||
e.mu.RUnlock()
|
||||
return names
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
now = e.now()
|
||||
// 双检:可能在等锁期间已被别的请求加载好
|
||||
if len(e.names) > 0 && now.Sub(e.loadedAt) < e.ttl {
|
||||
return e.names
|
||||
}
|
||||
if !e.lastTryAt.IsZero() && now.Sub(e.lastTryAt) < e.retryGap {
|
||||
return e.names // 退避期内:用旧词表(可能为空)但不重复打 DB
|
||||
}
|
||||
e.lastTryAt = now
|
||||
raw := e.load()
|
||||
if len(raw) == 0 {
|
||||
return e.names
|
||||
}
|
||||
names := make(map[string]entityCandidate, len(raw))
|
||||
for name, w := range raw {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if prev, ok := names[key]; !ok || w > prev.Weight {
|
||||
names[key] = entityCandidate{Name: name, Weight: w, Runes: len([]rune(name))}
|
||||
}
|
||||
}
|
||||
e.names = names
|
||||
e.loadedAt = now
|
||||
return names
|
||||
}
|
||||
|
||||
// Extract 把整句拆成候选实体:取所有「是查询串子串」的节点名,按权重排序去重。
|
||||
// 返回按权重降序的实体名,最多 maxEntities 个;无命中返回 nil。
|
||||
func (e *EntityIndex) Extract(query string, maxEntities int) []string {
|
||||
if e == nil || maxEntities <= 0 {
|
||||
return nil
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
names := e.vocabulary()
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
runes := []rune(strings.ToLower(query))
|
||||
total := len(runes)
|
||||
minLen, maxLen := e.MinNameRunes, e.MaxNameRunes
|
||||
if minLen < 1 {
|
||||
minLen = 1
|
||||
}
|
||||
if maxLen < minLen {
|
||||
maxLen = minLen
|
||||
}
|
||||
|
||||
// 收集所有命中:候选名必须是查询串的(区分大小写已归一为小写)子串
|
||||
found := make(map[string]entityCandidate)
|
||||
for i := 0; i < total; i++ {
|
||||
limit := maxLen
|
||||
if total-i < limit {
|
||||
limit = total - i
|
||||
}
|
||||
for n := limit; n >= minLen; n-- {
|
||||
key := string(runes[i : i+n])
|
||||
c, ok := names[key]
|
||||
if !ok || !hasLetter(c.Name) {
|
||||
continue
|
||||
}
|
||||
if prev, dup := found[key]; !dup || c.Weight > prev.Weight {
|
||||
c.Pos = i
|
||||
found[key] = c
|
||||
}
|
||||
break // 同一位置只取最长命中,短前缀交给其他起点
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
cands := make([]entityCandidate, 0, len(found))
|
||||
for _, c := range found {
|
||||
cands = append(cands, c)
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool {
|
||||
if cands[i].Weight != cands[j].Weight {
|
||||
return cands[i].Weight > cands[j].Weight
|
||||
}
|
||||
if cands[i].Runes != cands[j].Runes {
|
||||
return cands[i].Runes > cands[j].Runes
|
||||
}
|
||||
return cands[i].Pos < cands[j].Pos
|
||||
})
|
||||
|
||||
top := cands[0].Weight
|
||||
ratio := e.MinWeightRatio
|
||||
if ratio < 0 {
|
||||
ratio = 0
|
||||
}
|
||||
out := make([]string, 0, maxEntities)
|
||||
checks := 0
|
||||
maxChecks := maxEntities * 2
|
||||
for _, c := range cands {
|
||||
if top > 0 && c.Weight < top*ratio {
|
||||
break // 已按权重降序,后面更弱
|
||||
}
|
||||
redundant := false
|
||||
lc := strings.ToLower(c.Name)
|
||||
for _, sel := range out {
|
||||
if strings.ToLower(sel) == lc {
|
||||
redundant = true
|
||||
break
|
||||
}
|
||||
// 短名被已选长名包含(且不更强)→ 冗余,如「织忆」vs「织忆系统」
|
||||
if strings.Contains(strings.ToLower(sel), lc) {
|
||||
redundant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if redundant {
|
||||
continue
|
||||
}
|
||||
if e.Degree != nil && checks < maxChecks {
|
||||
checks++
|
||||
if e.Degree(c.Name) < e.MinDegree {
|
||||
continue // 噪声节点(自动切词产物等),不参与笔记匹配
|
||||
}
|
||||
}
|
||||
out = append(out, c.Name)
|
||||
if len(out) >= maxEntities {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasLetter 过滤纯符号/数字的节点名(图谱里有少量噪声节点)。
|
||||
func hasLetter(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fakeVocab(m map[string]float64) (NodeVocabularyFunc, *int) {
|
||||
calls := 0
|
||||
return func() map[string]float64 {
|
||||
calls++
|
||||
return m
|
||||
}, &calls
|
||||
}
|
||||
|
||||
// 真实场景:hermes 织忆插件把整句提问当 entity 传过来,服务端要能拆出实体。
|
||||
func TestExtractNLSentence(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{
|
||||
"织忆": 3.386e-06,
|
||||
"位置": 1.510e-06,
|
||||
"小怡": 3.134e-06,
|
||||
"牧尘": 2.491e-06,
|
||||
})
|
||||
idx := NewEntityIndex(load)
|
||||
got := idx.Extract("现在织忆recall带obsidian实体位置么", 2)
|
||||
want := []string{"织忆", "位置"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
|
||||
// 只取 1 个时必须是权重最高的那个
|
||||
if got := idx.Extract("现在织忆recall带obsidian实体位置么", 1); !reflect.DeepEqual(got, []string{"织忆"}) {
|
||||
t.Fatalf("top1 got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractASCIIKeepsOriginalSpelling(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{
|
||||
"Obsidian": 2.0e-06,
|
||||
"CLI": 1.0e-06,
|
||||
})
|
||||
idx := NewEntityIndex(load)
|
||||
got := idx.Extract("obsidian 和 cli 怎么配", 5)
|
||||
want := []string{"Obsidian", "CLI"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDropsContainedShorterName(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{
|
||||
"织忆系统": 3.0e-06,
|
||||
"织忆": 2.0e-06,
|
||||
})
|
||||
idx := NewEntityIndex(load)
|
||||
if got := idx.Extract("织忆系统的笔记", 5); !reflect.DeepEqual(got, []string{"织忆系统"}) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWeightRatioFilter(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{"织忆": 3.0e-06, "位置": 1.0e-06})
|
||||
idx := NewEntityIndex(load)
|
||||
if got := idx.Extract("织忆和位置", 5); len(got) != 2 {
|
||||
t.Fatalf("默认阈值应保留两个,got %v", got)
|
||||
}
|
||||
idx.MinWeightRatio = 0.5
|
||||
if got := idx.Extract("织忆和位置", 5); !reflect.DeepEqual(got, []string{"织忆"}) {
|
||||
t.Fatalf("阈值 0.5 应只剩织忆,got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSkipsTooShortAndNonLetterNames(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{"忆": 5.0e-06, "12": 4.0e-06})
|
||||
idx := NewEntityIndex(load)
|
||||
if got := idx.Extract("织忆12", 5); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNoHitAndEmptyQuery(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{"织忆": 3.0e-06})
|
||||
idx := NewEntityIndex(load)
|
||||
if got := idx.Extract("今天天气不错", 3); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
if got := idx.Extract(" ", 3); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
if got := idx.Extract("织忆", 0); got != nil {
|
||||
t.Fatalf("maxEntities=0 应返回 nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCachesVocabularyAndBacksOffOnFailure(t *testing.T) {
|
||||
load, calls := fakeVocab(map[string]float64{"织忆": 3.0e-06})
|
||||
idx := NewEntityIndex(load)
|
||||
now := time.Unix(1700000000, 0)
|
||||
idx.now = func() time.Time { return now }
|
||||
|
||||
idx.Extract("织忆", 1)
|
||||
idx.Extract("织忆", 1)
|
||||
if *calls != 1 {
|
||||
t.Fatalf("TTL 内应只加载一次,calls=%d", *calls)
|
||||
}
|
||||
now = now.Add(11 * time.Minute)
|
||||
idx.Extract("织忆", 1)
|
||||
if *calls != 2 {
|
||||
t.Fatalf("TTL 过期应重新加载,calls=%d", *calls)
|
||||
}
|
||||
|
||||
// 加载失败(空词表)→ 退避期内不重复打 DB,且不 panic
|
||||
failCalls := 0
|
||||
bad := NewEntityIndex(func() map[string]float64 { failCalls++; return nil })
|
||||
bad.now = func() time.Time { return now }
|
||||
if got := bad.Extract("织忆", 1); got != nil {
|
||||
t.Fatalf("空词表应返回 nil, got %v", got)
|
||||
}
|
||||
bad.Extract("织忆", 1)
|
||||
if failCalls != 1 {
|
||||
t.Fatalf("退避期内应只尝试一次,failCalls=%d", failCalls)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
bad.Extract("织忆", 1)
|
||||
if failCalls != 2 {
|
||||
t.Fatalf("退避期过后应重试,failCalls=%d", failCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDegreeFilterDropsNoiseNode(t *testing.T) {
|
||||
load, _ := fakeVocab(map[string]float64{
|
||||
"天气": 3.0e-06,
|
||||
"一句话": 1.6e-06,
|
||||
})
|
||||
idx := NewEntityIndex(load)
|
||||
idx.Degree = func(name string) int {
|
||||
if name == "一句话" {
|
||||
return 2 // 自动切词噪声节点(实测 graph.db:一句话 deg=2)
|
||||
}
|
||||
return 50
|
||||
}
|
||||
got := idx.Extract("完全不相干的一句话天气不错", 3)
|
||||
if !reflect.DeepEqual(got, []string{"天气"}) {
|
||||
t.Fatalf("got %v want [天气]", got)
|
||||
}
|
||||
|
||||
// 度数过滤把候选清空 → 返回 nil(宁可不给,也别给噪声笔记)
|
||||
idx2 := NewEntityIndex(load)
|
||||
idx2.Degree = func(string) int { return 0 }
|
||||
if got := idx2.Extract("完全不相干的一句话天气不错", 3); got != nil {
|
||||
t.Fatalf("got %v want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilEntityIndexIsSafe(t *testing.T) {
|
||||
var idx *EntityIndex
|
||||
idx.Warm(0)
|
||||
if got := idx.Extract("织忆", 3); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
noLoad := NewEntityIndex(nil)
|
||||
if got := noLoad.Extract("织忆", 3); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// 织忆 MemoryWeave — 实体名字 → 同名全部节点 id 的并集解析
|
||||
//
|
||||
// 背景(2026-09-27 图谱重名成因报告):图谱里同一实体可能有两层节点 ——
|
||||
//
|
||||
// · 旧 entity 层:id = 名字(namespace=wiki 等,06-16 起写入)
|
||||
// · 新 concept 层:id = "n_" + 名字(namespace=hermes-main 等,09-26 起写入,边多得多)
|
||||
//
|
||||
// 而 Navigate 按 id 匹配(`WHERE e.source = '<entity>'`),PageRank 词表键也是 id
|
||||
// ⇒ 按名字导航只会走旧层,新层的连接形同隐形(织忆:旧层 6 出边 vs 新层 1674 出边)。
|
||||
//
|
||||
// 本文件提供并集解析:给定名字,返回**全部**同名节点 id,让调用方对并集展开,
|
||||
// 从而两层数据都能进入笔记匹配。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NodeSearcher 只要实现 SearchNodes 即可(governance.GraphStore 满足)。
|
||||
// 收窄成小接口是为了单测里能塞假实现,不必实现整个图谱接口。
|
||||
type NodeSearcher interface {
|
||||
SearchNodes(label string, namespace string) []map[string]interface{}
|
||||
}
|
||||
|
||||
// ExactNodeFinder 精确同名解析(governance.SQLiteGraphStore 实现 NodeIDsByName)。
|
||||
// 有它就用它 —— SearchNodes 的 LIKE+LIMIT 语义会把精确同名的节点挤出结果。
|
||||
type ExactNodeFinder interface {
|
||||
NodeIDsByName(name string) []string
|
||||
}
|
||||
|
||||
// ResolveEntityIDs 返回与 name 对应的全部节点 id(同名两层都返回)。
|
||||
//
|
||||
// 语义:
|
||||
// - 优先精确匹配:节点的 name 或 id 与 name 相等(忽略大小写)才算命中,
|
||||
// 避免 SearchNodes 的 LIKE 子串语义把「织忆」带出「织忆召回」这类噪声节点。
|
||||
// - 若 gs 实现了 ExactNodeFinder 则走精确 SQL(首选,见 NodeIDsByName 注释)。
|
||||
// - 名字本身始终作为回退候选放在结果里(图谱里查不到、但词表命中的名字仍可按 id 直查)。
|
||||
// - 结果去重,稳定有序:先旧层(id=名字)后新层(id=n_名字),最后是名字回退。
|
||||
func ResolveEntityIDs(gs NodeSearcher, name string) []string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, 2)
|
||||
seen := make(map[string]bool, 4)
|
||||
add := func(id string) {
|
||||
if id == "" || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if ex, ok := gs.(ExactNodeFinder); ok {
|
||||
for _, id := range ex.NodeIDsByName(name) {
|
||||
add(id)
|
||||
}
|
||||
} else if gs != nil {
|
||||
for _, node := range gs.SearchNodes(name, "") {
|
||||
nid, _ := node["id"].(string)
|
||||
nname, _ := node["name"].(string)
|
||||
if nid == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(nname, name) || strings.EqualFold(nid, name) {
|
||||
add(nid)
|
||||
}
|
||||
}
|
||||
}
|
||||
add(name) // 回退:按 id 直查(id == 名字)
|
||||
return ids
|
||||
}
|
||||
|
||||
// MergePaths 把并集展开得到的路径合并进 dst,按 source|relation|target 去重。
|
||||
// 返回合并后的切片(可能原地扩容);limit > 0 时最多保留 limit 条。
|
||||
func MergePaths(dst []map[string]interface{}, src []map[string]interface{}, seen map[string]bool, limit int) []map[string]interface{} {
|
||||
if seen == nil {
|
||||
seen = make(map[string]bool, len(dst)+len(src))
|
||||
}
|
||||
for _, p := range dst {
|
||||
seen[pathKey(p)] = true
|
||||
}
|
||||
for _, p := range src {
|
||||
if limit > 0 && len(dst) >= limit {
|
||||
break
|
||||
}
|
||||
k := pathKey(p)
|
||||
if k == "" || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
dst = append(dst, p)
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// PathEnds 取出路径的 (source, target, relation)。
|
||||
// 兼容两套键名:governance.Navigate 返回的是 from/to,而其它构造处用 source/target——
|
||||
// 只读一套会静默丢路径(2026-09-27 实测:并集路径、以及「图谱邻居进笔记匹配」整条链路
|
||||
// 都因为读错键名而失效,全程无报错)。
|
||||
func PathEnds(p map[string]interface{}) (string, string, string) {
|
||||
s, _ := p["source"].(string)
|
||||
if s == "" {
|
||||
s, _ = p["from"].(string)
|
||||
}
|
||||
t, _ := p["target"].(string)
|
||||
if t == "" {
|
||||
t, _ = p["to"].(string)
|
||||
}
|
||||
r, _ := p["relation"].(string)
|
||||
return s, t, r
|
||||
}
|
||||
|
||||
func pathKey(p map[string]interface{}) string {
|
||||
s, t, r := PathEnds(p)
|
||||
if s == "" && t == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s|%s|%s", s, r, t)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeSearcher 模拟 SearchNodes 的 LIKE 语义返回,用于验证精确匹配过滤。
|
||||
type fakeSearcher struct {
|
||||
nodes []map[string]interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSearcher) SearchNodes(label, namespace string) []map[string]interface{} {
|
||||
f.calls++
|
||||
out := []map[string]interface{}{}
|
||||
for _, n := range f.nodes {
|
||||
nm, _ := n["name"].(string)
|
||||
id, _ := n["id"].(string)
|
||||
if strings.Contains(nm, label) || strings.Contains(id, label) {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 同名两层:旧 entity(id=名字)+ 新 concept(id=n_名字)→ 都应返回,且顺序稳定。
|
||||
func TestResolveEntityIDs_TwoLayers(t *testing.T) {
|
||||
gs := &fakeSearcher{nodes: []map[string]interface{}{
|
||||
{"id": "织忆", "name": "织忆", "type": "entity"},
|
||||
{"id": "n_织忆", "name": "织忆", "type": "concept"},
|
||||
}}
|
||||
ids := ResolveEntityIDs(gs, "织忆")
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("want 2 ids (两层), got %v", ids)
|
||||
}
|
||||
if ids[0] != "织忆" || ids[1] != "n_织忆" {
|
||||
t.Fatalf("unexpected order: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// LIKE 子串噪声必须被挡掉:查「织忆」不应带出「织忆召回」。
|
||||
func TestResolveEntityIDs_RejectsSubstringNoise(t *testing.T) {
|
||||
gs := &fakeSearcher{nodes: []map[string]interface{}{
|
||||
{"id": "织忆", "name": "织忆"},
|
||||
{"id": "织忆召回机制", "name": "织忆召回机制"},
|
||||
}}
|
||||
ids := ResolveEntityIDs(gs, "织忆")
|
||||
for _, id := range ids {
|
||||
if id == "织忆召回机制" {
|
||||
t.Fatalf("子串噪声未被过滤: %v", ids)
|
||||
}
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
t.Fatalf("want 1 (仅精确), got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// 图谱查不到时回退为名字本身(按 id 直查仍可用)。
|
||||
func TestResolveEntityIDs_FallbackToName(t *testing.T) {
|
||||
gs := &fakeSearcher{}
|
||||
ids := ResolveEntityIDs(gs, "不存在的实体")
|
||||
if len(ids) != 1 || ids[0] != "不存在的实体" {
|
||||
t.Fatalf("want fallback to name, got %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// nil store / 空名字不外抛。
|
||||
func TestResolveEntityIDs_NilAndEmpty(t *testing.T) {
|
||||
if got := ResolveEntityIDs(nil, "织忆"); len(got) != 1 || got[0] != "织忆" {
|
||||
t.Fatalf("nil store 应回退名字: %v", got)
|
||||
}
|
||||
if got := ResolveEntityIDs(nil, " "); got != nil {
|
||||
t.Fatalf("空名字应返回 nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 去重:SearchNodes 同时按 name 与 id 命中同一节点时只返回一次。
|
||||
func TestResolveEntityIDs_Dedup(t *testing.T) {
|
||||
gs := &fakeSearcher{nodes: []map[string]interface{}{
|
||||
{"id": "n_织忆", "name": "织忆"},
|
||||
{"id": "n_织忆", "name": "n_织忆"},
|
||||
}}
|
||||
ids := ResolveEntityIDs(gs, "织忆")
|
||||
// n_织忆 命中(name 相等);名字回退也加入 ⇒ 共 2 条且不重复
|
||||
if len(ids) != 2 || ids[0] != "n_织忆" || ids[1] != "织忆" {
|
||||
t.Fatalf("dedup 结果异常: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeExact 同时实现 NodeSearcher 与 ExactNodeFinder:验证优先走精确 SQL。
|
||||
// 模拟真实场景:SearchNodes(LIKE)被 834 条子串命中淹没、精确改名拿不到 ⇒ 必须用精确路径。
|
||||
type fakeExact struct {
|
||||
exactIDs []string
|
||||
likeRows []map[string]interface{}
|
||||
}
|
||||
|
||||
func (f *fakeExact) SearchNodes(label, namespace string) []map[string]interface{} { return f.likeRows }
|
||||
func (f *fakeExact) NodeIDsByName(name string) []string { return f.exactIDs }
|
||||
|
||||
func TestResolveEntityIDs_PrefersExactFinder(t *testing.T) {
|
||||
gs := &fakeExact{
|
||||
exactIDs: []string{"织忆", "n_织忆"},
|
||||
likeRows: []map[string]interface{}{ // LIKE 噪声:精确同名的 n_织忆 不在其中
|
||||
{"id": "n_织忆召回机制", "name": "织忆召回机制"},
|
||||
{"id": "n_织忆架构", "name": "织忆架构"},
|
||||
},
|
||||
}
|
||||
ids := ResolveEntityIDs(gs, "织忆")
|
||||
want := []string{"织忆", "n_织忆"}
|
||||
if len(ids) != len(want) || ids[0] != want[0] || ids[1] != want[1] {
|
||||
t.Fatalf("want %v, got %v", want, ids)
|
||||
}
|
||||
}
|
||||
|
||||
// MergePaths:按 source|relation|target 去重,并尊重上限。
|
||||
func TestMergePaths_TolerantToFromToKeys(t *testing.T) {
|
||||
// governance.Navigate 返回 from/to;MergePaths 必须认,否则路径被静默丢弃
|
||||
nav := []map[string]interface{}{
|
||||
{"from": "织忆", "to": "Hermes", "relation": "USES"},
|
||||
{"from": "织忆", "to": "LanceDB", "relation": "DEPENDS_ON"},
|
||||
}
|
||||
out := MergePaths([]map[string]interface{}{}, nav, nil, 0)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("Navigate 风格 from/to 路径被丢弃:want 2, got %d", len(out))
|
||||
}
|
||||
// 去重:同一 from|relation|to 再来一次不重复
|
||||
out = MergePaths(out, nav, nil, 0)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("去重失败:want 2, got %d", len(out))
|
||||
}
|
||||
// limit 生效
|
||||
out = MergePaths([]map[string]interface{}{}, nav, nil, 1)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("limit 未生效:want 1, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathEnds_BothKeySets(t *testing.T) {
|
||||
if s, tt, r := PathEnds(map[string]interface{}{"from": "a", "to": "b", "relation": "R"}); s != "a" || tt != "b" || r != "R" {
|
||||
t.Fatalf("from/to 未识别: %s %s %s", s, tt, r)
|
||||
}
|
||||
if s, tt, _ := PathEnds(map[string]interface{}{"source": "a", "target": "b"}); s != "a" || tt != "b" {
|
||||
t.Fatalf("source/target 未识别: %s %s", s, tt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePaths_DedupAndLimit(t *testing.T) {
|
||||
p := func(s, r, tg string) map[string]interface{} {
|
||||
return map[string]interface{}{"source": s, "relation": r, "target": tg}
|
||||
}
|
||||
dst := []map[string]interface{}{p("a", "is", "b")}
|
||||
seen := map[string]bool{}
|
||||
dst = MergePaths(dst, []map[string]interface{}{
|
||||
p("a", "is", "b"), // 重复
|
||||
p("n_a", "is", "c"),
|
||||
p("n_a", "is", "d"),
|
||||
}, seen, 2)
|
||||
if len(dst) != 2 {
|
||||
t.Fatalf("want limit 2, got %d: %v", len(dst), dst)
|
||||
}
|
||||
if dst[1]["target"] != "c" {
|
||||
t.Fatalf("第二条应为新路径 c, got %v", dst[1])
|
||||
}
|
||||
// 再次合并应识别已有 key(含 dst 内建索引)
|
||||
dst = MergePaths(dst, []map[string]interface{}{p("n_a", "is", "c")}, seen, 0)
|
||||
if len(dst) != 2 {
|
||||
t.Fatalf("重复路径未被去重: %v", dst)
|
||||
}
|
||||
}
|
||||
+425
-43
@@ -3,27 +3,180 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 + 关键词相关性判据
|
||||
type GoldenSetItem struct {
|
||||
Query string `json:"query"`
|
||||
ExpectedIDs []string `json:"expected_ids"`
|
||||
MustIncludeKeywords []string `json:"must_include_keywords"`
|
||||
// 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 金标集
|
||||
type GoldenSet struct {
|
||||
Description string `json:"description"`
|
||||
Items []GoldenSetItem `json:"items"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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"
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("golden set 不可用(%s 为空且无内嵌基线)", usedSource)
|
||||
}
|
||||
var gs GoldenSet
|
||||
if err := json.Unmarshal(data, &gs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 空断言自守:禁止裸前缀/空串混入(避免恒真)
|
||||
for _, item := range gs.Items {
|
||||
if err := validateExpectedIDs(item.Query, item.ExpectedIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &gs, nil
|
||||
}
|
||||
|
||||
// validateExpectedIDs 空断言自守:裸前缀 "mem_"、空串、None → 直接报错,不许静默 PASS
|
||||
func validateExpectedIDs(query string, expectedIDs []string) error {
|
||||
if len(expectedIDs) == 0 {
|
||||
return nil // 允许空(bootstrap 时还没基线)
|
||||
}
|
||||
for _, id := range expectedIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
// 裸前缀 "mem_"(不含具体 ID 数字)
|
||||
if trimmed == "mem_" || trimmed == "mem" {
|
||||
return &VacuousAssertionError{
|
||||
Query: query,
|
||||
BadID: id,
|
||||
Reason: "裸前缀 mem_ 是恒真断言——所有记忆 ID 都以 mem_ 开头",
|
||||
}
|
||||
}
|
||||
// 空串
|
||||
if trimmed == "" {
|
||||
return &VacuousAssertionError{
|
||||
Query: query,
|
||||
BadID: id,
|
||||
Reason: "空串期望值(无意义断言)",
|
||||
}
|
||||
}
|
||||
// None(JSON null 解码成空字符串已处理,但检查 "None" 字符串)
|
||||
if strings.EqualFold(trimmed, "none") {
|
||||
return &VacuousAssertionError{
|
||||
Query: query,
|
||||
BadID: id,
|
||||
Reason: "None 期望值(无意义断言)",
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VacuousAssertionError 空断言错误
|
||||
type VacuousAssertionError struct {
|
||||
Query string
|
||||
BadID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *VacuousAssertionError) Error() string {
|
||||
return "FATAL 空断言被检出(恒真): query=" + e.Query + " 期望=" + e.BadID + " — " + e.Reason
|
||||
}
|
||||
|
||||
// checkKeywordRelevance 关键词相关性判据:返回内容必须包含至少一个关键词。
|
||||
// 统一口径(t_a70b07e1):本函数单条内容判定的输入 N(取 recall 返回的前 N 条)由调用方决定——
|
||||
// eval 端 TestEvalGolden_Live 取前 N=5(见 eval_test.go 口径注释),与 golden-check.py 一致。
|
||||
func checkKeywordRelevance(content string, keywords []string) bool {
|
||||
if len(keywords) == 0 {
|
||||
// 无关键词判据 → 视为不通过(禁止退化为恒真)
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(content)
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lower, strings.ToLower(kw)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EvalAPI struct {
|
||||
Pipeline *storage.RecallPipeline
|
||||
LanceDB storage.LanceDB
|
||||
}
|
||||
|
||||
type EvalRun struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
PrecisionAt5 float64 `json:"precision_at_5"`
|
||||
MRR float64 `json:"mean_reciprocal_rank"`
|
||||
NDCG float64 `json:"ndcg"`
|
||||
MRR float64 `json:"mean_reciprocal_rank"`
|
||||
NDCG float64 `json:"ndcg"`
|
||||
|
||||
// 按标签分组
|
||||
RecallByTag map[string]float64 `json:"recall_at_5_by_tag,omitempty"`
|
||||
RecallByTag map[string]float64 `json:"recall_at_5_by_tag,omitempty"`
|
||||
PrecisionByTag map[string]float64 `json:"precision_at_5_by_tag,omitempty"`
|
||||
|
||||
// 按 Agent 分组
|
||||
@@ -40,17 +193,27 @@ type EvalRun struct {
|
||||
}
|
||||
|
||||
type EvalQueryDetail struct {
|
||||
Query string `json:"query"`
|
||||
ExpectedIDs []string `json:"expected_ids"`
|
||||
Hits []string `json:"hits"`
|
||||
Misses []string `json:"misses"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
PrecisionAt5 float64 `json:"precision_at_5"`
|
||||
Query string `json:"query"`
|
||||
ExpectedIDs []string `json:"expected_ids"`
|
||||
MustIncludeKeywords []string `json:"must_include_keywords,omitempty"`
|
||||
Hits []string `json:"hits"`
|
||||
Misses []string `json:"misses"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
PrecisionAt5 float64 `json:"precision_at_5"`
|
||||
KeywordOK bool `json:"keyword_ok,omitempty"`
|
||||
}
|
||||
|
||||
// GoldenRunResult 金标 run 的扩展结果(含关键词判据)
|
||||
type GoldenRunResult struct {
|
||||
EvalRun
|
||||
KeywordPass int `json:"keyword_pass"`
|
||||
KeywordFail int `json:"keyword_fail"`
|
||||
KeywordTotal int `json:"keyword_total"`
|
||||
}
|
||||
|
||||
type ConsolidationHits struct {
|
||||
TotalDistilledUsed int `json:"total_distilled_used"`
|
||||
HitsAfterConsolidation int `json:"hits_after_consolidation"`
|
||||
TotalDistilledUsed int `json:"total_distilled_used"`
|
||||
HitsAfterConsolidation int `json:"hits_after_consolidation"`
|
||||
ConsolidationBenefit float64 `json:"consolidation_benefit"`
|
||||
}
|
||||
|
||||
@@ -68,7 +231,7 @@ func NewEvalAPI(p *storage.RecallPipeline, ldb storage.LanceDB) *EvalAPI {
|
||||
// POST /api/v1/eval/run
|
||||
func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Queries []struct {
|
||||
Queries []struct {
|
||||
Query string `json:"query"`
|
||||
ExpectedIDs []string `json:"expected_ids"`
|
||||
} `json:"queries"`
|
||||
@@ -85,6 +248,14 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 空断言自守:裸前缀 mem_ / 空串 / None → 直接报错,不许静默 PASS
|
||||
for _, q := range req.Queries {
|
||||
if err := validateExpectedIDs(q.Query, q.ExpectedIDs); err != nil {
|
||||
respondError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 默认 namespace 为 shared,支持传入 hermes 等
|
||||
ns := req.Namespace
|
||||
if ns == "" {
|
||||
@@ -125,11 +296,15 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
pAt5 := 0.0
|
||||
if len(results) > 0 { pAt5 = float64(hits) / float64(len(results)) }
|
||||
if len(results) > 0 {
|
||||
pAt5 = float64(hits) / float64(len(results))
|
||||
}
|
||||
totalPrecision += pAt5
|
||||
|
||||
rAt5 := 0.0
|
||||
if len(q.ExpectedIDs) > 0 { rAt5 = float64(hits) / float64(len(q.ExpectedIDs)) }
|
||||
if len(q.ExpectedIDs) > 0 {
|
||||
rAt5 = float64(hits) / float64(len(q.ExpectedIDs))
|
||||
}
|
||||
totalRecall += rAt5
|
||||
|
||||
// MRR
|
||||
@@ -144,11 +319,17 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
dcg, idcg := 0.0, 0.0
|
||||
for i, res := range results {
|
||||
rel := 0.0
|
||||
if expectedSet[res.ID] { rel = 1.0 }
|
||||
if expectedSet[res.ID] {
|
||||
rel = 1.0
|
||||
}
|
||||
dcg += rel / log2(float64(i+2))
|
||||
if i < len(q.ExpectedIDs) { idcg += 1.0 / log2(float64(i+2)) }
|
||||
if i < len(q.ExpectedIDs) {
|
||||
idcg += 1.0 / log2(float64(i+2))
|
||||
}
|
||||
}
|
||||
if idcg > 0 {
|
||||
totalNDCG += dcg / idcg
|
||||
}
|
||||
if idcg > 0 { totalNDCG += dcg / idcg }
|
||||
|
||||
// 分组统计
|
||||
// (category 从 query 推断)
|
||||
@@ -183,18 +364,18 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
recallByAgent := avgByGroup(recallByAgentMap)
|
||||
|
||||
run := &EvalRun{
|
||||
ID: time.Now().Format("20060102-150405"),
|
||||
Model: req.Model,
|
||||
RecallAt5: totalRecall / float64(totalQueries),
|
||||
PrecisionAt5: totalPrecision / float64(totalQueries),
|
||||
MRR: totalMRR / float64(totalQueries),
|
||||
NDCG: totalNDCG / float64(totalQueries),
|
||||
RecallByTag: recallByTag,
|
||||
ID: time.Now().Format("20060102-150405"),
|
||||
Model: req.Model,
|
||||
RecallAt5: totalRecall / float64(totalQueries),
|
||||
PrecisionAt5: totalPrecision / float64(totalQueries),
|
||||
MRR: totalMRR / float64(totalQueries),
|
||||
NDCG: totalNDCG / float64(totalQueries),
|
||||
RecallByTag: recallByTag,
|
||||
PrecisionByTag: precisionByTag,
|
||||
RecallByAgent: recallByAgent,
|
||||
QueryDetails: queryDetails,
|
||||
Queries: totalQueries,
|
||||
RanAt: time.Now(),
|
||||
RecallByAgent: recallByAgent,
|
||||
QueryDetails: queryDetails,
|
||||
Queries: totalQueries,
|
||||
RanAt: time.Now(),
|
||||
}
|
||||
|
||||
evals.mu.Lock()
|
||||
@@ -204,6 +385,183 @@ func (ea *EvalAPI) Run(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, run)
|
||||
}
|
||||
|
||||
// POST /api/v1/eval/golden — 使用金标集跑评估(含关键词相关性判据)
|
||||
// 加载 testdata/golden-set.json,对每条执行 recall + 关键词检查
|
||||
// 返回 GoldenRunResult(含 keyword_pass/fail/total)
|
||||
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 req.AllNamespaces {
|
||||
ns = ""
|
||||
} else if ns == "" {
|
||||
ns = "shared"
|
||||
}
|
||||
gs, err := LoadGoldenSet(req.GoldenPath)
|
||||
if err != nil {
|
||||
respondError(w, 500, "failed to load golden set: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var totalPrecision, totalRecall, totalMRR, totalNDCG float64
|
||||
totalQueries := 0
|
||||
keywordPass, keywordFail, keywordTotal := 0, 0, 0
|
||||
var queryDetails []EvalQueryDetail
|
||||
var failures []string
|
||||
|
||||
for _, item := range gs.Items {
|
||||
// 空断言自守
|
||||
if err := validateExpectedIDs(item.Query, item.ExpectedIDs); err != nil {
|
||||
respondError(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// 无关键词判据 → 禁止(会退化成恒真)
|
||||
if len(item.MustIncludeKeywords) == 0 {
|
||||
failures = append(failures, item.Query+": 无关键词判据(禁止退化成恒真)")
|
||||
continue
|
||||
}
|
||||
|
||||
// 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++
|
||||
keywordTotal++
|
||||
continue
|
||||
}
|
||||
|
||||
// ID 命中检查
|
||||
hits := 0
|
||||
var hitIDs []string
|
||||
var missIDs []string
|
||||
expectedSet := makeSet(item.ExpectedIDs)
|
||||
for i, res := range results {
|
||||
if expectedSet[res.ID] && i < 5 {
|
||||
hits++
|
||||
hitIDs = append(hitIDs, res.ID)
|
||||
}
|
||||
}
|
||||
for _, eid := range item.ExpectedIDs {
|
||||
if !contains(hitIDs, eid) {
|
||||
missIDs = append(missIDs, eid)
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词相关性判据(t_a70b07e1 统一口径):取 recall 返回的前 N=5 条内容查关键词。
|
||||
// (Pipeline.Recall 传 topK=5;此处扫全部 results 即前 N 条。Python 量具 golden-check.py 同口径。)
|
||||
keywordOK := false
|
||||
for _, res := range results {
|
||||
if checkKeywordRelevance(res.Content, item.MustIncludeKeywords) {
|
||||
keywordOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pAt5 := 0.0
|
||||
if len(results) > 0 {
|
||||
pAt5 = float64(hits) / float64(len(results))
|
||||
}
|
||||
totalPrecision += pAt5
|
||||
|
||||
rAt5 := 0.0
|
||||
if len(item.ExpectedIDs) > 0 {
|
||||
rAt5 = float64(hits) / float64(len(item.ExpectedIDs))
|
||||
}
|
||||
totalRecall += rAt5
|
||||
|
||||
// MRR
|
||||
for i, res := range results {
|
||||
if expectedSet[res.ID] {
|
||||
totalMRR += 1.0 / float64(i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// NDCG
|
||||
dcg, idcg := 0.0, 0.0
|
||||
for i, res := range results {
|
||||
rel := 0.0
|
||||
if expectedSet[res.ID] {
|
||||
rel = 1.0
|
||||
}
|
||||
dcg += rel / log2(float64(i+2))
|
||||
if i < len(item.ExpectedIDs) {
|
||||
idcg += 1.0 / log2(float64(i+2))
|
||||
}
|
||||
}
|
||||
if idcg > 0 {
|
||||
totalNDCG += dcg / idcg
|
||||
}
|
||||
|
||||
// 关键词统计
|
||||
keywordTotal++
|
||||
if keywordOK {
|
||||
keywordPass++
|
||||
} else {
|
||||
keywordFail++
|
||||
failures = append(failures, item.Query+": 关键词未命中 "+strings.Join(item.MustIncludeKeywords, ","))
|
||||
}
|
||||
|
||||
queryDetails = append(queryDetails, EvalQueryDetail{
|
||||
Query: item.Query,
|
||||
ExpectedIDs: item.ExpectedIDs,
|
||||
MustIncludeKeywords: item.MustIncludeKeywords,
|
||||
Hits: hitIDs,
|
||||
Misses: missIDs,
|
||||
RecallAt5: rAt5,
|
||||
PrecisionAt5: pAt5,
|
||||
KeywordOK: keywordOK,
|
||||
})
|
||||
|
||||
totalQueries++
|
||||
}
|
||||
|
||||
if totalQueries == 0 {
|
||||
respondError(w, 500, "all golden queries failed")
|
||||
return
|
||||
}
|
||||
|
||||
run := &EvalRun{
|
||||
ID: time.Now().Format("20060102-150405"),
|
||||
Model: "golden-set",
|
||||
RecallAt5: totalRecall / float64(totalQueries),
|
||||
PrecisionAt5: totalPrecision / float64(totalQueries),
|
||||
MRR: totalMRR / float64(totalQueries),
|
||||
NDCG: totalNDCG / float64(totalQueries),
|
||||
QueryDetails: queryDetails,
|
||||
Queries: totalQueries,
|
||||
RanAt: time.Now(),
|
||||
}
|
||||
|
||||
evals.mu.Lock()
|
||||
evals.runs = append(evals.runs, run)
|
||||
evals.mu.Unlock()
|
||||
|
||||
result := GoldenRunResult{
|
||||
EvalRun: *run,
|
||||
KeywordPass: keywordPass,
|
||||
KeywordFail: keywordFail,
|
||||
KeywordTotal: keywordTotal,
|
||||
}
|
||||
_ = failures
|
||||
respond(w, 200, result)
|
||||
}
|
||||
|
||||
// GET /api/v1/eval/history
|
||||
func (ea *EvalAPI) History(w http.ResponseWriter, r *http.Request) {
|
||||
evals.mu.RLock()
|
||||
@@ -233,6 +591,8 @@ func (ea *EvalAPI) History(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/v1/eval/generate — 生成 4类×3级=12条金标查询
|
||||
// 对每个 query 调用 recall 获取 expected_ids
|
||||
// 注意:此端点用于 bootstrap 基线——首次跑时没有基线,用 recall top-3 填充。
|
||||
// 后续应改用 LoadGoldenSet 加载已校准的金标集(含具体 ID + 关键词判据)。
|
||||
func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Namespace string `json:"namespace"`
|
||||
@@ -252,9 +612,9 @@ func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
// 固定的 12 条金标查询(4类×3级)
|
||||
// 对每条生成 query → recall 获得真实 expected_ids
|
||||
goldenQueries := []struct {
|
||||
cat string
|
||||
cat string
|
||||
difficulty string
|
||||
query string
|
||||
query string
|
||||
}{
|
||||
// system_fact
|
||||
{"system_fact", "easy", "织忆是什么"},
|
||||
@@ -284,6 +644,17 @@ func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
expectedIDs = append(expectedIDs, r.ID)
|
||||
}
|
||||
}
|
||||
// 空断言自守:如果 recall 返回 0 条,不得写入空 expected_ids(那是 bootstrap,不是校准)
|
||||
if len(expectedIDs) == 0 {
|
||||
results = append(results, map[string]interface{}{
|
||||
"query": gq.query,
|
||||
"expected_ids": []string{},
|
||||
"category": gq.cat,
|
||||
"difficulty": gq.difficulty,
|
||||
"warning": "recall 返回 0 条——bootstrap 基线为空,需人工校准",
|
||||
})
|
||||
continue
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"query": gq.query,
|
||||
"expected_ids": expectedIDs,
|
||||
@@ -295,7 +666,7 @@ func (ea *EvalAPI) Generate(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"queries": results,
|
||||
"count": len(results),
|
||||
"note": "4类别×3难度=12条金标查询,通过 recall 自动获取 expected_ids",
|
||||
"note": "4类别×3难度=12条金标查询,通过 recall 自动获取 expected_ids。注意:bootstrap 模式下 expected_ids 来自实时 recall,非校准基线。校准后的金标集见 testdata/golden-set.json(LoadGoldenSet)。",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,14 +681,21 @@ func makeSet(ids []string) map[string]bool {
|
||||
|
||||
func log2(x float64) float64 {
|
||||
result := 0.0
|
||||
for x > 2 { x /= 2; result += 1 }
|
||||
if x > 1 { result += (x - 1) }
|
||||
for x > 2 {
|
||||
x /= 2
|
||||
result += 1
|
||||
}
|
||||
if x > 1 {
|
||||
result += (x - 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func contains(list []string, item string) bool {
|
||||
for _, s := range list {
|
||||
if s == item { return true }
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -327,7 +705,9 @@ func avgByGroup(m map[string][]float64) map[string]float64 {
|
||||
for k, vals := range m {
|
||||
if len(vals) > 0 {
|
||||
sum := 0.0
|
||||
for _, v := range vals { sum += v }
|
||||
for _, v := range vals {
|
||||
sum += v
|
||||
}
|
||||
result[k] = sum / float64(len(vals))
|
||||
}
|
||||
}
|
||||
@@ -336,15 +716,17 @@ func avgByGroup(m map[string][]float64) map[string]float64 {
|
||||
|
||||
func inferCategory(query, defaultCat string) string {
|
||||
for cat, keywords := range map[string][]string{
|
||||
"system_fact": {"os", "系统", "gpu", "内存", "ram", "端口", "配置"},
|
||||
"user_pref": {"偏好", "风格", "喜欢", "牧尘"},
|
||||
"proj_context":{"项目", "路径", "代码", "设计文档"},
|
||||
"tool_usage": {"工具", "comfyui", "hermes", "opencode"},
|
||||
"system_fact": {"os", "系统", "gpu", "内存", "ram", "端口", "配置"},
|
||||
"user_pref": {"偏好", "风格", "喜欢", "牧尘"},
|
||||
"proj_context": {"项目", "路径", "代码", "设计文档"},
|
||||
"tool_usage": {"工具", "comfyui", "hermes", "opencode"},
|
||||
} {
|
||||
for _, kw := range keywords {
|
||||
if len(query) >= len(kw) {
|
||||
for i := 0; i <= len(query)-len(kw); i++ {
|
||||
if query[i:i+len(kw)] == kw { return cat }
|
||||
if query[i:i+len(kw)] == kw {
|
||||
return cat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
// 织忆 MemoryWeave — 评估系统测试
|
||||
// 覆盖:金标加载 / 空断言自守 / 关键词判据 / 反证 / 全量金标跑(需活服务)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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)。
|
||||
//
|
||||
// 统一判据口径(t_a70b07e1,与 Python 量具 golden-check.py 一致):
|
||||
//
|
||||
// 取 recall 返回的前 N=5 条:ID 命中 = expected_ids 任一 ∈ 前 N 条;
|
||||
// 关键词命中 = 前 N 条内容任一含 must_include_keywords(大小写不敏感)。
|
||||
// N=5 的来源:recall 请求 top_k=5(liveRecallOnce 固定),与 golden-set.json 说明字段一致。
|
||||
type recallResult struct {
|
||||
IDS []string
|
||||
TopContents []string
|
||||
Error error
|
||||
}
|
||||
|
||||
func liveRecallOnce(query string, maxRetries int, retryWait time.Duration) recallResult {
|
||||
payload, _ := json.Marshal(map[string]interface{}{"query": query, "top_k": 5})
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(retryWait * time.Duration(attempt))
|
||||
}
|
||||
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", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("HTTP 请求失败: %v", err)
|
||||
continue
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return recallResult{Error: fmt.Errorf("HTTP 401 unauthorized(API key 无效)")}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 限流(429)或服务错误:读 retry_after_s,退避重试
|
||||
var errResp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RetryAfterSecs int `json:"retry_after_s"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &errResp)
|
||||
lastErr = fmt.Errorf("HTTP %d (%s): %s", resp.StatusCode, errResp.Error.Code, errResp.Error.Message)
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
wait := time.Duration(errResp.Error.RetryAfterSecs) * time.Second
|
||||
if wait == 0 {
|
||||
wait = 2 * time.Second
|
||||
}
|
||||
time.Sleep(wait)
|
||||
continue
|
||||
}
|
||||
return recallResult{Error: lastErr}
|
||||
}
|
||||
var result struct {
|
||||
Results []struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return recallResult{Error: fmt.Errorf("JSON 解析失败: %v | body=%s", err, string(body[:min(100, len(body))]))}
|
||||
}
|
||||
rr := recallResult{}
|
||||
for _, r := range result.Results {
|
||||
rr.IDS = append(rr.IDS, r.ID)
|
||||
rr.TopContents = append(rr.TopContents, r.Content)
|
||||
}
|
||||
return rr
|
||||
}
|
||||
return recallResult{Error: lastErr}
|
||||
}
|
||||
|
||||
// keywordHitAnywhere 检查前 N=5 条结果的 content 是否含任一关键词(统一口径 t_a70b07e1:N=5,与 golden-check.py 一致)
|
||||
func keywordHitAnywhere(topContents []string, keywords []string) bool {
|
||||
if len(keywords) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, content := range topContents {
|
||||
if checkKeywordRelevance(content, keywords) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── AC① 空断言自守:validateExpectedIDs ────────────────────
|
||||
|
||||
func TestValidateExpectedIDs_RejectsBareMemPrefix(t *testing.T) {
|
||||
err := validateExpectedIDs("测试查询", []string{"mem_"})
|
||||
if err == nil {
|
||||
t.Fatal("✗ 裸前缀 mem_ 未报错(恒真断言漏检)")
|
||||
}
|
||||
va, ok := err.(*VacuousAssertionError)
|
||||
if !ok {
|
||||
t.Fatalf("✗ 期望 VacuousAssertionError,实际 %T: %v", err, err)
|
||||
}
|
||||
t.Logf("✓ 裸前缀拦截:%s", va.Error())
|
||||
}
|
||||
|
||||
func TestValidateExpectedIDs_RejectsEmptyString(t *testing.T) {
|
||||
err := validateExpectedIDs("测试查询", []string{""})
|
||||
if err == nil {
|
||||
t.Fatal("✗ 空串期望值未报错")
|
||||
}
|
||||
t.Logf("✓ 空串拦截:%s", err.Error())
|
||||
}
|
||||
|
||||
func TestValidateExpectedIDs_RejectsNone(t *testing.T) {
|
||||
for _, bad := range []string{"None", "none", "NONE"} {
|
||||
if err := validateExpectedIDs("测试查询", []string{bad}); err == nil {
|
||||
t.Fatalf("✗ None 变体 %q 未报错", bad)
|
||||
}
|
||||
}
|
||||
t.Log("✓ None 各变体全部拦截")
|
||||
}
|
||||
|
||||
func TestValidateExpectedIDs_AllowsRealID(t *testing.T) {
|
||||
if err := validateExpectedIDs("测试查询", []string{"mem_1789153726378940805"}); err != nil {
|
||||
t.Fatalf("✗ 真实 ID 被误拦: %v", err)
|
||||
}
|
||||
t.Log("✓ 真实 ID 放行")
|
||||
}
|
||||
|
||||
func TestValidateExpectedIDs_AllowsEmptySlice(t *testing.T) {
|
||||
if err := validateExpectedIDs("测试查询", []string{}); err != nil {
|
||||
t.Fatalf("✗ 空 slice(bootstrap)被误拦: %v", err)
|
||||
}
|
||||
t.Log("✓ 空 slice 放行(bootstrap)")
|
||||
}
|
||||
|
||||
// ─── 关键词判据:checkKeywordRelevance ──────────────────────
|
||||
|
||||
func TestCheckKeywordRelevance(t *testing.T) {
|
||||
if !checkKeywordRelevance("用户偏好:牧尘喜欢直接动手", []string{"牧尘"}) {
|
||||
t.Fatal("✗ 应命中「牧尘」")
|
||||
}
|
||||
if checkKeywordRelevance("用户偏好:牧尘喜欢直接动手", []string{"技术栈"}) {
|
||||
t.Fatal("✗ 不应命中「技术栈」")
|
||||
}
|
||||
if !checkKeywordRelevance("MemoryWeave recall API 文档", []string{"RECALL"}) {
|
||||
t.Fatal("✗ 大小写不敏感匹配失败")
|
||||
}
|
||||
if checkKeywordRelevance("任意内容", []string{}) {
|
||||
t.Fatal("✗ 空关键词判据退化成恒真")
|
||||
}
|
||||
t.Log("✓ 关键词判据全部正确")
|
||||
}
|
||||
|
||||
// ─── LoadGoldenSet:加载 + 校验 ────────────────────────────
|
||||
|
||||
func TestLoadGoldenSet_ValidFile(t *testing.T) {
|
||||
p := filepath.Join("testdata", "golden-set.json")
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Skipf("testdata 不在 CWD(测试需在 go/internal/api/routes/ 下跑): %v", err)
|
||||
}
|
||||
gs, err := LoadGoldenSet(p)
|
||||
if err != nil {
|
||||
t.Fatalf("✗ 加载金标集失败: %v", err)
|
||||
}
|
||||
if gs.Description == "" {
|
||||
t.Error("✗ description 缺失")
|
||||
}
|
||||
if len(gs.Items) != 14 {
|
||||
t.Fatalf("✗ 期望 14 条(12 基线 + 2 目标),实际 %d", len(gs.Items))
|
||||
}
|
||||
for i, item := range gs.Items {
|
||||
if item.Query == "" {
|
||||
t.Fatalf("✗ item %d query 为空", i)
|
||||
}
|
||||
if len(item.ExpectedIDs) == 0 {
|
||||
t.Fatalf("✗ item %d expected_ids 为空(金标必须有具体 ID)", i)
|
||||
}
|
||||
for _, id := range item.ExpectedIDs {
|
||||
if id == "mem_" || id == "mem" || id == "" {
|
||||
t.Fatalf("✗ item %d expected_ids 含裸前缀/空串 %q", i, id)
|
||||
}
|
||||
}
|
||||
if len(item.MustIncludeKeywords) == 0 {
|
||||
t.Fatalf("✗ item %d must_include_keywords 为空(禁止无关键词判据条目)", i)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ 金标集加载成功:%d 条,全部含具体 expected_ids + must_include_keywords", len(gs.Items))
|
||||
}
|
||||
|
||||
func TestLoadGoldenSet_RejectsVacuousIDs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := `{
|
||||
"description": "bad",
|
||||
"items": [
|
||||
{"query": "q1", "expected_ids": ["mem_"], "must_include_keywords": ["a"]}
|
||||
]
|
||||
}`
|
||||
p := filepath.Join(dir, "bad.json")
|
||||
os.WriteFile(p, []byte(bad), 0644)
|
||||
if _, err := LoadGoldenSet(p); err == nil {
|
||||
t.Fatal("✗ 含裸前缀 mem_ 的金标集未被拒绝")
|
||||
} else {
|
||||
t.Logf("✓ 含裸前缀的金标集被拒:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(healthReq)
|
||||
if err != nil || resp == nil {
|
||||
t.Skipf("zhiyid 服务不可达(%v),跳过反证测试", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// 1. 先跑一次正常(期望 PASS)
|
||||
gs, err := LoadGoldenSet(filepath.Join("testdata", "golden-set.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("加载金标集失败: %v", err)
|
||||
}
|
||||
// 统一口径 t_a70b07e1:反证测试也按 golden-set.json 的 expected_ids 判定(前 N=5 条),
|
||||
// 保证「故意改坏输入 → 量具 FAIL」的证明与量具实际判据一致。
|
||||
|
||||
// 取第一条("织忆是什么",expected=mem_1789153726378940805)
|
||||
item0 := gs.Items[0]
|
||||
t.Logf("反证前:item[0] query=%q expected=%v", item0.Query, item0.ExpectedIDs)
|
||||
|
||||
// 反证前跑一次
|
||||
rrBefore := liveRecallOnce(item0.Query, 3, 2*time.Second)
|
||||
if rrBefore.Error != nil {
|
||||
t.Skipf("recall API 不可用(%v),跳过反证", rrBefore.Error)
|
||||
}
|
||||
idHitBefore := containsAny(rrBefore.IDS, item0.ExpectedIDs)
|
||||
kwHitBefore := keywordHitAnywhere(rrBefore.TopContents, item0.MustIncludeKeywords)
|
||||
if idHitBefore && kwHitBefore {
|
||||
t.Logf("反证前 ✓ PASS: top1=%s kw_hit=%v", firstID(rrBefore.IDS), kwHitBefore)
|
||||
} else {
|
||||
t.Logf("反证前(基线本身就有问题,跳过反证验证): idHit=%v kwHit=%v", idHitBefore, kwHitBefore)
|
||||
t.Skipf("反证前基线不 PASS,无法验证反证")
|
||||
}
|
||||
|
||||
// 2. 故意改成错误 ID
|
||||
badID := "mem_0000000000000000000" // 不存在的 ID
|
||||
gs2 := &GoldenSet{Items: []GoldenSetItem{}}
|
||||
for _, it := range gs.Items {
|
||||
if it.Query == item0.Query {
|
||||
it.ExpectedIDs = []string{badID}
|
||||
}
|
||||
gs2.Items = append(gs2.Items, it)
|
||||
}
|
||||
|
||||
// 空断言自守:badID 是合法格式(mem_+数字),不被拦截 ✓
|
||||
if err := validateExpectedIDs(item0.Query, []string{badID}); err != nil {
|
||||
t.Fatalf("✗ 反证用 ID %q 被空断言自守误拦: %v", badID, err)
|
||||
}
|
||||
|
||||
// 3. 反证跑:用错误 ID 必须 FAIL
|
||||
rrAfter := liveRecallOnce(item0.Query, 3, 2*time.Second)
|
||||
if rrAfter.Error != nil {
|
||||
t.Fatalf("反证时 recall 失败: %v", rrAfter.Error)
|
||||
}
|
||||
idHitAfter := containsAny(rrAfter.IDS, []string{badID})
|
||||
if idHitAfter {
|
||||
t.Errorf("✗ 反证失败:错误 ID %q 竟然命中(判据恒真!)", badID)
|
||||
} else {
|
||||
t.Logf("反证后 ✓ FAIL(如预期): top1=%s 不含 %s", firstID(rrAfter.IDS), badID)
|
||||
}
|
||||
|
||||
// 4. 恢复(golden-set.json 没改,只是内存里改了 gs2)
|
||||
t.Logf("反证测试完成:正常=%v 错误ID=%v", idHitBefore, !idHitAfter)
|
||||
}
|
||||
|
||||
// ─── 全量金标跑(需活 zhiyid 服务;无服务时 skip)──────────
|
||||
// 判据:14 条中 12 基线 + 2 目标,跑真实 recall,统计 pass/fail/warn/error。
|
||||
// 统一判据口径(t_a70b07e1,与 Python 量具 golden-check.py + golden-set.json 说明字段三方一致):
|
||||
// 取 recall 返回的前 N=5 条内容查关键词;ID 命中 = expected 任一 ∈ 前 N 条。
|
||||
// 判定:PASS = id 命中且 kw 命中;WARN = kw 命中但 id 漂移(降级);FAIL = 其余;ERROR = recall 调用失败。
|
||||
// 验收 = 同一批金标经两量具记录(CALIBRATED 行),喂给同一分类器(classify.py)复算,分类差异 = 0。
|
||||
// 校准后真实基线 = 12/14(2 条目标知识在库但召回 top-5 不稳定 → 允许 FAIL,不许抹平)。
|
||||
// 每次调用后 200ms 间隔 + 限流退避,避免打满 zhiyid 令牌桶。
|
||||
|
||||
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", liveAPIKey())
|
||||
resp, err := http.DefaultClient.Do(healthReq)
|
||||
if err != nil || resp == nil {
|
||||
t.Skipf("zhiyid 服务不可达(%v),跳过全量金标实跑", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Skipf("zhiyid 服务 HTTP %d: %s", resp.StatusCode, string(body[:min(80, len(body))]))
|
||||
}
|
||||
|
||||
gs, err := LoadGoldenSet(filepath.Join("testdata", "golden-set.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("加载金标集失败: %v", err)
|
||||
}
|
||||
|
||||
pass, fail, warn, errorCount := 0, 0, 0, 0
|
||||
for i, item := range gs.Items {
|
||||
// 空断言自守
|
||||
if err := validateExpectedIDs(item.Query, item.ExpectedIDs); err != nil {
|
||||
t.Fatalf("item %d 空断言自守失败: %v", i, err)
|
||||
}
|
||||
|
||||
// 一次 HTTP 调用同时拿 top-5 ids + contents(限流保护)
|
||||
rr := liveRecallOnce(item.Query, 3, 2*time.Second)
|
||||
if rr.Error != nil {
|
||||
errorCount++
|
||||
t.Logf("ERROR[%d] %q: recall 错误 %v", i, item.Query, rr.Error)
|
||||
t.Logf("CALIBRATED[%d] %s", i, fmt.Sprintf(
|
||||
`{"tool":"go","i":%d,"query":%q,"top1":"","ids":null,"kw_hit":false,"id_hit":false,"error":%q}`,
|
||||
i, item.Query, rr.Error.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// 判据① ID 命中(前 N=5 条里有 expected_ids 之一)—— 统一口径 t_a70b07e1
|
||||
idHit := containsAny(rr.IDS, item.ExpectedIDs)
|
||||
|
||||
// 判据② 关键词相关性(前 N=5 条结果 content 任一含关键词)—— 统一口径 t_a70b07e1
|
||||
kwHit := keywordHitAnywhere(rr.TopContents, item.MustIncludeKeywords)
|
||||
|
||||
// 结构化记录:供统一口径分类器 classify.py 复算 go/py 分类差异(AC1 验收 = 差异 0)
|
||||
t.Logf("CALIBRATED[%d] %s", i, fmt.Sprintf(
|
||||
`{"tool":"go","i":%d,"query":%q,"top1":%q,"ids":%v,"kw_hit":%v,"id_hit":%v,"error":null}`,
|
||||
i, item.Query, firstID(rr.IDS), rr.IDS, kwHit, idHit))
|
||||
|
||||
if idHit && kwHit {
|
||||
pass++
|
||||
t.Logf("PASS[%d] %q top1=%s", i, item.Query, firstID(rr.IDS))
|
||||
} else {
|
||||
fail++
|
||||
why := []string{}
|
||||
if !idHit {
|
||||
if kwHit {
|
||||
// ID 漂移但内容切题 → 降级为 WARN(不算硬 FAIL)
|
||||
why = append(why, fmt.Sprintf("ID漂移(基线 %v ∉ top-5 %v, 但内容切题)",
|
||||
item.ExpectedIDs, rr.IDS[:min(3, len(rr.IDS))]))
|
||||
warn++
|
||||
} else {
|
||||
why = append(why, fmt.Sprintf("ID未命中(期望 %v, top-5 %v)",
|
||||
item.ExpectedIDs, rr.IDS[:min(3, len(rr.IDS))]))
|
||||
}
|
||||
}
|
||||
if !kwHit {
|
||||
head := ""
|
||||
if len(rr.TopContents) > 0 {
|
||||
head = rr.TopContents[0][:min(40, len(rr.TopContents[0]))]
|
||||
}
|
||||
why = append(why, fmt.Sprintf("关键词未命中(%v) | top1_content: %s",
|
||||
item.MustIncludeKeywords, head))
|
||||
}
|
||||
// ID 漂移但内容切题 → 降级为 WARN,不算硬 FAIL
|
||||
if kwHit && !idHit {
|
||||
fail--
|
||||
t.Logf("WARN[%d] %q %v", i, item.Query, why)
|
||||
} else {
|
||||
t.Logf("FAIL[%d] %q %v | top1: %s", i, item.Query, why, firstID(rr.IDS))
|
||||
}
|
||||
}
|
||||
|
||||
// 条目间隔 200ms(限流保护,避免连续打满令牌桶)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 结尾打印真实数字(不写死任何期望值)
|
||||
total := len(gs.Items)
|
||||
t.Logf("RESULT: total=%d pass=%d fail=%d warn=%d error=%d(真实数字,无硬编码基线)",
|
||||
total, pass, fail, warn, errorCount)
|
||||
|
||||
// 漏跑检查:pass+fail+warn+error 必须 = 总条数
|
||||
if pass+fail+warn+errorCount != total {
|
||||
t.Errorf("✗ 漏跑:pass+fail+warn+error=%d ≠ 总条数 %d", pass+fail+warn+errorCount, total)
|
||||
}
|
||||
|
||||
// 全灭(pass=0)→ 判据可能已失效或金标集 ID 已过期
|
||||
if pass == 0 {
|
||||
t.Errorf("✗ 全灭(pass=0)——关键词判据可能失效或金标集 ID 已过期")
|
||||
}
|
||||
|
||||
// 校准基线(供人工校准,不写死期望值)
|
||||
t.Logf("校准基线:pass=%d/%d(fail=%d warn=%d error=%d)", pass, total, fail, warn, errorCount)
|
||||
|
||||
// error 条目 → 服务问题,不计入 fail 但必须报告
|
||||
if errorCount > 0 {
|
||||
t.Logf("⚠ %d 条 ERROR(recall API 调用失败,非召回质量问题)", errorCount)
|
||||
}
|
||||
}
|
||||
|
||||
// containsAny 检查 expectedIDs 中是否有任意一个出现在 recalledIDs 中
|
||||
func containsAny(recalledIDs []string, expectedIDs []string) bool {
|
||||
set := makeSet(expectedIDs)
|
||||
for _, id := range recalledIDs {
|
||||
if set[id] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// firstID 取 slice 第一个元素
|
||||
func firstID(ids []string) string {
|
||||
if len(ids) == 0 {
|
||||
return "(无)"
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
|
||||
// min 取两个整数较小值
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type FeedbackAPI struct {
|
||||
LanceDB storage.LanceDB
|
||||
LanceDB storage.LanceDB
|
||||
}
|
||||
|
||||
func NewFeedbackAPI(ldb storage.LanceDB) *FeedbackAPI {
|
||||
@@ -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,13 +39,24 @@ 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", "",
|
||||
)
|
||||
fa.LanceDB.IncrementUseful(req.MemoryID)
|
||||
if err := fa.LanceDB.IncrementUseful(req.MemoryID); err != nil {
|
||||
log.Printf("[feedback] IncrementUseful failed %s: %v", req.MemoryID, err)
|
||||
}
|
||||
// GA-P0-4 (t_bb10e8bc):用户点「有用」= 真实正向 outcome → 追加 hits+ 事件(带时间戳)。
|
||||
// 语义定义见 internal/storage/hit_event.go(hits+ = 被真实使用/认可)。
|
||||
if err := fa.LanceDB.AppendHitEvent(req.MemoryID, "pos", "human_feedback"); err != nil {
|
||||
log.Printf("[feedback] AppendHitEvent(pos) failed %s: %v", req.MemoryID, err)
|
||||
}
|
||||
|
||||
// Phase F: 质量监控 — 每次反馈后检查低质量记忆
|
||||
feedbackCount := selfoptimize.Dash.UsefulCount + selfoptimize.Dash.NotUsefulCount
|
||||
@@ -78,7 +94,15 @@ func (fa *FeedbackAPI) MarkNotUseful(w http.ResponseWriter, r *http.Request) {
|
||||
[]string{req.MemoryID},
|
||||
"user_feedback", "failure", "",
|
||||
)
|
||||
fa.LanceDB.IncrementNotUseful(req.MemoryID)
|
||||
// GA-P0-1 (t_718d8707):人工点「没用」→ 写 human_not_useful_count(不再写混写列 not_useful_count)
|
||||
if err := fa.LanceDB.IncrementHumanNotUseful(req.MemoryID); err != nil {
|
||||
log.Printf("[feedback] IncrementHumanNotUseful failed %s: %v", req.MemoryID, err)
|
||||
}
|
||||
// GA-P0-4 (t_bb10e8bc):用户点「没用」= 真实负向 outcome → 追加 hits− 事件(带时间戳)。
|
||||
// 注意:这只在用户人工点「没用」时追加,dedup 自动降权走 dedup_downweight_count 不追加(见 hit_event.go 排除项)。
|
||||
if err := fa.LanceDB.AppendHitEvent(req.MemoryID, "neg", "human_feedback"); err != nil {
|
||||
log.Printf("[feedback] AppendHitEvent(neg) failed %s: %v", req.MemoryID, err)
|
||||
}
|
||||
|
||||
// Phase F: 质量监控 — negative feedback 触发低质量检测
|
||||
feedbackCount := selfoptimize.Dash.UsefulCount + selfoptimize.Dash.NotUsefulCount
|
||||
@@ -110,16 +134,18 @@ func (fa *FeedbackAPI) Deprecate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
selfoptimize.Dash.RecordDeprecation()
|
||||
fa.LanceDB.SoftDelete(req.MemoryID, req.Reason)
|
||||
if err := fa.LanceDB.SoftDelete(req.MemoryID, req.Reason); err != nil {
|
||||
log.Printf("[softdelete] failed id: %v", err)
|
||||
}
|
||||
respond(w, 200, map[string]string{"status": "deprecated", "memory_id": req.MemoryID})
|
||||
}
|
||||
|
||||
// POST /api/v1/feedback/correct
|
||||
func (fa *FeedbackAPI) Correct(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
MemoryID string `json:"memory_id"`
|
||||
NewContent string `json:"new_content"`
|
||||
Source string `json:"source"`
|
||||
MemoryID string `json:"memory_id"`
|
||||
NewContent string `json:"new_content"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -14,19 +14,19 @@ import (
|
||||
|
||||
// GapAutoRepair 缺口自动修复器
|
||||
type GapAutoRepair struct {
|
||||
detector *selfoptimize.GapDetector
|
||||
ldb storage.LanceDB
|
||||
synonyms map[string][]string // 同义词映射
|
||||
detector *selfoptimize.GapDetector
|
||||
ldb storage.LanceDB
|
||||
synonyms map[string][]string // 同义词映射
|
||||
}
|
||||
|
||||
var GapRepair = &GapAutoRepair{
|
||||
detector: nil, // 由 InitGapRepair 注入
|
||||
synonyms: map[string][]string{
|
||||
"GPU": {"gpu", "显卡", "graphics"},
|
||||
"OS": {"os", "操作系统", "系统"},
|
||||
"API": {"api", "接口", "endpoint"},
|
||||
"SSE": {"sse", "server-sent events", "事件推送"},
|
||||
"CRDT": {"crdt", "冲突合并", "merge"},
|
||||
"GPU": {"gpu", "显卡", "graphics"},
|
||||
"OS": {"os", "操作系统", "系统"},
|
||||
"API": {"api", "接口", "endpoint"},
|
||||
"SSE": {"sse", "server-sent events", "事件推送"},
|
||||
"CRDT": {"crdt", "冲突合并", "merge"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -66,17 +66,18 @@ func (ga *GraphAPI) Query(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/v1/graph/navigate
|
||||
// 支持三种模式:
|
||||
// 1. 单实体 BFS: {"entity": "...", "max_hops": 2}
|
||||
// 2. 双向 BFS: {"source": "...", "target": "...", "max_hops": 3}
|
||||
// 3. 关系过滤 BFS: {"entity": "...", "max_hops": 2, "relation_filter": ["related_to", "uses"]}(E1.5)
|
||||
// 1. 单实体 BFS: {"entity": "...", "max_hops": 2}
|
||||
// 2. 双向 BFS: {"source": "...", "target": "...", "max_hops": 3}
|
||||
// 3. 关系过滤 BFS: {"entity": "...", "max_hops": 2, "relation_filter": ["related_to", "uses"]}(E1.5)
|
||||
//
|
||||
// 返回新增 grouped_by_relation 字段,按关系类型分组,便于阅读
|
||||
func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Entity string `json:"entity"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
Namespace string `json:"namespace"`
|
||||
Entity string `json:"entity"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
MaxHops int `json:"max_hops"`
|
||||
Namespace string `json:"namespace"`
|
||||
RelationFilter []string `json:"relation_filter"` // E1.5: 关系类型白名单
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -87,6 +88,15 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
|
||||
req.MaxHops = 2
|
||||
}
|
||||
|
||||
// 响应体积上限:与 /graph/notes 同因同治(2026-09-27 小怡)。
|
||||
// 实测 navigate("牧尘", 2) 服务端吐 34,465 条 ≈3.4MB,再加 grouped_by_relation 同量级复制
|
||||
// ⇒ 单次响应 ~7MB。全仓调用点核对(3 处)都只读样本,paths 全量无人消费:
|
||||
// - hermes 插件 plugins/zhiyi/__init__.py:911 → for p in paths[:50]
|
||||
// - web-ui/index.html → 只读 d.count(不渲染 paths)
|
||||
// - openclaw 客户端 → 已退役
|
||||
// ⇒ paths 截断为可观测样本;count 仍报真实总数(调用方用它显示 "Found N paths")。
|
||||
const maxNavigatePaths = 200
|
||||
|
||||
// 模式 2: 双向 BFS(source + target)
|
||||
if req.Source != "" && req.Target != "" {
|
||||
source := NormalizeEntity(req.Source)
|
||||
@@ -96,9 +106,14 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, 500, "navigate failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
totalPaths := len(paths)
|
||||
if totalPaths > maxNavigatePaths {
|
||||
paths = paths[:maxNavigatePaths]
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"paths": paths, "source": req.Source, "target": req.Target,
|
||||
"bidirectional": true, "count": len(paths),
|
||||
"paths": paths, "source": req.Source, "target": req.Target,
|
||||
"bidirectional": true, "count": totalPaths,
|
||||
"returned": len(paths), "truncated": totalPaths > len(paths),
|
||||
"relation_filter": req.RelationFilter,
|
||||
})
|
||||
return
|
||||
@@ -116,6 +131,12 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// paths 截断(见函数开头 maxNavigatePaths 说明);grouped/suggestions 基于截断后样本构建。
|
||||
totalPaths := len(paths)
|
||||
if totalPaths > maxNavigatePaths {
|
||||
paths = paths[:maxNavigatePaths]
|
||||
}
|
||||
|
||||
// 新增:按 relation 分组,更直观
|
||||
grouped := make(map[string][]map[string]interface{})
|
||||
for _, p := range paths {
|
||||
@@ -146,14 +167,16 @@ func (ga *GraphAPI) Navigate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"paths": paths,
|
||||
"grouped_by_relation": grouped,
|
||||
"entity": req.Entity,
|
||||
"normalized_entity": entity,
|
||||
"count": len(paths),
|
||||
"relation_count": len(grouped),
|
||||
"suggestions": suggestions,
|
||||
"relation_filter": req.RelationFilter,
|
||||
"paths": paths,
|
||||
"grouped_by_relation": grouped,
|
||||
"entity": req.Entity,
|
||||
"normalized_entity": entity,
|
||||
"count": totalPaths,
|
||||
"returned": len(paths),
|
||||
"truncated": totalPaths > len(paths),
|
||||
"relation_count": len(grouped),
|
||||
"suggestions": suggestions,
|
||||
"relation_filter": req.RelationFilter,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,19 @@ package routes
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/consolidate"
|
||||
)
|
||||
|
||||
// sidecarHealthCheck 探测 Rust sidecar(zhiyi-consolidate)可达性。
|
||||
// 声明为变量以便测试注入,默认走真实 IPC 探测。
|
||||
var sidecarHealthCheck = consolidate.HealthCheck
|
||||
|
||||
// HandleHealth GET /health — 无需认证
|
||||
//
|
||||
// 深健康检查:不仅报告自身存活,还探测 IPC sidecar 是否可达。
|
||||
// 背景(2026-10-08 故障):sidecar 异常时 /health 仍返回 200 —— "假绿",
|
||||
// 使部署失败被静默数小时,只能靠 pgrep 才发现。深检查把依赖故障暴露为 503。
|
||||
func HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
@@ -14,10 +24,23 @@ func HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
|
||||
body := map[string]string{
|
||||
"status": "ok",
|
||||
"service": "zhiyid",
|
||||
"version": "0.1.0",
|
||||
})
|
||||
}
|
||||
|
||||
if err := sidecarHealthCheck(); err != nil {
|
||||
body["status"] = "degraded"
|
||||
body["sidecar"] = "unreachable"
|
||||
body["error"] = err.Error()
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(body)
|
||||
return
|
||||
}
|
||||
body["sidecar"] = "ok"
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
@@ -26,17 +26,17 @@ type WorldModel struct {
|
||||
|
||||
Services []ServiceInfo `json:"services"` // ℐ: 服务和端口
|
||||
|
||||
ImplicitRules []string `json:"implicit_rules"` // C: 软约束
|
||||
HardConstraints []string `json:"hard_constraints"` // C: 硬约束
|
||||
Capabilities []string `json:"capabilities"`
|
||||
ImplicitRules []string `json:"implicit_rules"` // C: 软约束
|
||||
HardConstraints []string `json:"hard_constraints"` // C: 硬约束
|
||||
Capabilities []string `json:"capabilities"`
|
||||
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Status string `json:"status"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
Provides []string `json:"provides,omitempty"`
|
||||
}
|
||||
@@ -111,13 +111,13 @@ func (wm *WorldModel) GetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /api/v1/l3/worldmodel — 更新
|
||||
func (wm *WorldModel) UpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Environment *struct {
|
||||
Environment *struct {
|
||||
OS string `json:"os"`
|
||||
GPU string `json:"gpu"`
|
||||
VRAM string `json:"vram"`
|
||||
RAM string `json:"ram"`
|
||||
} `json:"environment"`
|
||||
AddRules []string `json:"add_rules"`
|
||||
AddRules []string `json:"add_rules"`
|
||||
AddCapabilities []string `json:"add_capabilities"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -11,7 +13,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/redact"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
@@ -52,14 +56,29 @@ func (s *ObsidianSyncer) PushToObsidian(memories []map[string]interface{}, folde
|
||||
}
|
||||
|
||||
count := 0
|
||||
used := make(map[string]int, len(memories))
|
||||
for _, mem := range memories {
|
||||
content := strVal(mem["content"])
|
||||
// 2026-09-10 P0:镜像正文同样脱敏——旧事故里正文与文件名都把
|
||||
// tskey-auth-… 原样落盘(小唯/07-Wiki/织忆/未找到命令.md)。
|
||||
content = redact.RedactSecrets(content)
|
||||
if len(content) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(content[:minz(len(content), 30)]) + ".md"
|
||||
filepath := filepath.Join(targetDir, filename)
|
||||
// 2026-09-10 修复:旧实现 content[:30] 按字节切 + sanitizeFilename 把非 ASCII
|
||||
// 全替换成 '_',中文标题直接变 ____123.md(07-Wiki/织忆 1628 个乱码文件名成因),
|
||||
// 且不同记忆撞名会静默互相覆盖。改为 rune 级截断 + 保留 Unicode + 撞名加短哈希。
|
||||
base := sanitizeFilename(truncateRunes(content, 30))
|
||||
if base == "" {
|
||||
base = "untitled"
|
||||
}
|
||||
if used[base] > 0 {
|
||||
base = base + "-" + shortHash(content)
|
||||
}
|
||||
used[base]++
|
||||
filename := base + ".md"
|
||||
target := filepath.Join(targetDir, filename)
|
||||
|
||||
mdContent := fmt.Sprintf(`---
|
||||
id: %s
|
||||
@@ -80,7 +99,7 @@ sync_time: %s
|
||||
truncate(content, 60),
|
||||
content)
|
||||
|
||||
if err := os.WriteFile(filepath, []byte(mdContent), 0644); err != nil {
|
||||
if err := os.WriteFile(target, []byte(mdContent), 0644); err != nil {
|
||||
log.Printf("[obsidian] 写入失败 %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
@@ -206,29 +225,61 @@ func (s *ObsidianSyncer) StatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ─── 辅助 ─────────────────────────────────────────────────
|
||||
|
||||
// sanitizeFilename 把标题转成文件系统安全的文件名片段,并保留 Unicode(中文)可读性。
|
||||
//
|
||||
// 2026-09-10 修复:旧实现把所有非 ASCII rune 逐个替换为 '_',于是中文标题全变成
|
||||
// `____123.md`,在 Obsidian 里无法按名浏览(07-Wiki/织忆 1628 个 legacy 文件的成因)。
|
||||
// 现在只替换文件系统非法字符(/ \ : * ? " < > | 控制符等),连续的非法字符折叠成一个 '_'。
|
||||
// 返回空串表示内容里没有任何可用的安全字符,由调用方兜底。
|
||||
func sanitizeFilename(s string) string {
|
||||
s = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == ' ' {
|
||||
return r
|
||||
// 2026-09-10 P0:入参先脱敏,防任何路径把凭证带进文件名
|
||||
s = redact.RedactSecrets(s)
|
||||
var b strings.Builder
|
||||
prevIllegal := false
|
||||
for _, r := range s {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) ||
|
||||
r == '-' || r == '_' || r == ' ' || r == '.' {
|
||||
prevIllegal = false
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
return '_'
|
||||
}, s)
|
||||
return strings.TrimSpace(s)
|
||||
// 文件系统非法字符(/ \ : * ? " < > | 控制符等)+ 其它符号 → 折叠成一个 '_'
|
||||
if !prevIllegal {
|
||||
b.WriteRune('_')
|
||||
prevIllegal = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), " ._-")
|
||||
}
|
||||
|
||||
// shortHash 取内容摘要前 8 位十六进制,仅用于同名文件消歧,不做安全用途。
|
||||
func shortHash(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:4])
|
||||
}
|
||||
|
||||
// truncateRunes 按 rune(而非字节)截断,避免把多字节字符切掉一半产生乱码。
|
||||
func truncateRunes(s string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
n := 0
|
||||
for i := range s {
|
||||
if n == maxRunes {
|
||||
return s[:i]
|
||||
}
|
||||
n++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// truncate 展示用截断,按 rune 计数(2026-09-10 从字节切改为 rune 切,避免乱码)。
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
t := truncateRunes(s, maxLen)
|
||||
if len(t) == len(s) {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func minz(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
return t + "..."
|
||||
}
|
||||
|
||||
func floatVal(v interface{}) float64 {
|
||||
|
||||
@@ -103,8 +103,10 @@ func formatEntry(content, category string) string {
|
||||
|
||||
func strValSafe(v interface{}) string {
|
||||
switch s := v.(type) {
|
||||
case string: return s
|
||||
case fmt.Stringer: return s.String()
|
||||
case string:
|
||||
return s
|
||||
case fmt.Stringer:
|
||||
return s.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// 织忆 MemoryWeave — Obsidian 同步文件名/slug 单元测试
|
||||
// 2026-09-10 新增(kanban t_5f9c56ef):锁死「中文标题不许变下划线」这个回归。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestSanitizeFilename_KeepsChinese(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"修复了respond重复声明问题", "修复了respond重复声明问题"},
|
||||
{"110GB 磁盘占用排查", "110GB 磁盘占用排查"},
|
||||
{"E5.3 Web UI 单文件React+静态文件中间件", "E5.3 Web UI 单文件React_静态文件中间件"},
|
||||
{"a/b\\c:d*e?f\"g<h>i|j", "a_b_c_d_e_f_g_h_i_j"},
|
||||
{" 前后空格 ", "前后空格"},
|
||||
{"...隐藏点开头...", "隐藏点开头"},
|
||||
{"///", ""},
|
||||
{"", ""},
|
||||
{"测试::连续非法字符::折叠", "测试_连续非法字符_折叠"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeFilename(c.in); got != c.want {
|
||||
t.Errorf("sanitizeFilename(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 回归锁:旧实现(2026-09-10 前)会把中文全部换成 '_',这条用例就是当时的现场。
|
||||
func TestSanitizeFilename_RegressionNoUnderscoreSoup(t *testing.T) {
|
||||
got := sanitizeFilename("____112GB_____")
|
||||
if strings.Contains(got, "____") {
|
||||
t.Fatalf("中文标题不该出现连续下划线乱码: %q", got)
|
||||
}
|
||||
old := sanitizeFilename("修复了respond重复声明问题")
|
||||
if strings.Contains(old, "_") {
|
||||
t.Fatalf("中文/字母不该被替换成下划线: %q", old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunes_NoBrokenUTF8(t *testing.T) {
|
||||
s := "修复了respond重复声明问题,这是很长的中文内容用来测试截断"
|
||||
got := truncateRunes(s, 7)
|
||||
if got != "修复了resp" {
|
||||
t.Fatalf("truncateRunes = %q", got)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("截断结果不是合法 UTF-8(多字节字符被切半)")
|
||||
}
|
||||
if truncateRunes(s, 0) != "" || truncateRunes(s, -1) != "" {
|
||||
t.Fatal("maxRunes<=0 应返回空串")
|
||||
}
|
||||
if truncateRunes("短", 10) != "短" {
|
||||
t.Fatal("短串应原样返回")
|
||||
}
|
||||
// 关键回归:旧实现 content[:30] 是字节切,中文会切出半个字符(utf8.RuneError)
|
||||
if got := truncateRunes("中文标题测试内容很长很长很长很长很长很长", 8); !utf8.ValidString(got) {
|
||||
t.Fatalf("rune 截断后仍不是合法 UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate_RuneCounted(t *testing.T) {
|
||||
s := strings.Repeat("记", 40) // 120 字节;旧实现 s[:10] 只能得到 3 个半截汉字
|
||||
got := truncate(s, 10)
|
||||
want := strings.Repeat("记", 10) + "..."
|
||||
if got != want {
|
||||
t.Fatalf("truncate = %q, want %q", got, want)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatal("truncate 结果不是合法 UTF-8")
|
||||
}
|
||||
if truncate("短内容", 60) != "短内容" {
|
||||
t.Fatal("未超长应原样返回")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortHash_Deterministic(t *testing.T) {
|
||||
a, b := shortHash("同一段内容"), shortHash("同一段内容")
|
||||
if a != b {
|
||||
t.Fatal("shortHash 必须稳定")
|
||||
}
|
||||
if len(a) != 8 {
|
||||
t.Fatalf("shortHash 长度应为 8,得到 %d", len(a))
|
||||
}
|
||||
if shortHash("内容甲") == shortHash("内容乙") {
|
||||
t.Fatal("不同内容不该同哈希")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:真的落盘,检查文件名可读、无乱码、同名不互相覆盖。
|
||||
func TestPushToObsidian_ReadableFilenames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewObsidianSyncer(dir, nil)
|
||||
|
||||
dupPrefix := "同一段开头的内容 abcdefghijklmnopqrstuvwxyz"
|
||||
mems := []map[string]interface{}{
|
||||
{"id": "mem_1", "category": "distilled", "quality_score": 0.9,
|
||||
"content": "修复了 respond 重复声明问题"},
|
||||
{"id": "mem_2", "category": "distilled", "quality_score": 0.8,
|
||||
"content": "LanceDB 已设天花板,不会再膨胀到 35G"},
|
||||
// 前 30 个 rune 完全相同 → 必须加哈希后缀而不是覆盖
|
||||
{"id": "mem_3", "category": "distilled", "content": dupPrefix + " 变体一"},
|
||||
{"id": "mem_4", "category": "distilled", "content": dupPrefix + " 变体二"},
|
||||
}
|
||||
|
||||
if err := s.PushToObsidian(mems, "mirror"); err != nil {
|
||||
t.Fatalf("PushToObsidian 失败: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "mirror"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != len(mems) {
|
||||
t.Fatalf("应写入 %d 个文件(含撞名消歧),实际 %d 个:%v", len(mems), len(entries), names(entries))
|
||||
}
|
||||
t.Logf("实际落盘文件名: %v", names(entries))
|
||||
|
||||
for _, e := range entries {
|
||||
n := e.Name()
|
||||
if strings.Contains(n, "____") {
|
||||
t.Errorf("文件名出现下划线乱码: %q", n)
|
||||
}
|
||||
if !strings.ContainsAny(n, "修复了重复声明问题磁盘排查天花板膨胀同一段") {
|
||||
t.Errorf("文件名丢失中文标题: %q", n)
|
||||
}
|
||||
if !utf8.ValidString(n) {
|
||||
t.Errorf("文件名不是合法 UTF-8: %q", n)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "mirror", n))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
t.Errorf("文件内容不是合法 UTF-8: %q", n)
|
||||
}
|
||||
if !strings.Contains(string(data), "由织忆 MemoryWeave 同步") {
|
||||
t.Errorf("缺少同步 footer: %q", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 撞名消歧:两条同前缀记忆都要落盘(4 个文件已证明没互相覆盖)
|
||||
var suffixed int
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), dupPrefix[:12]) && strings.Contains(e.Name(), "-") {
|
||||
suffixed++
|
||||
}
|
||||
}
|
||||
if suffixed != 1 {
|
||||
t.Errorf("应恰好 1 个文件带哈希后缀消歧,实际 %d:%v", suffixed, names(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func names(entries []os.DirEntry) []string {
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── 2026-09-10 P0:文件名不得带凭证 ───────────────────────
|
||||
|
||||
func TestSanitizeFilename_RedactsSecret(t *testing.T) {
|
||||
const fakeKey = "tskey-auth-kTESTONLY0000000000000000000000000000000000000"
|
||||
got := sanitizeFilename("authkey " + fakeKey)
|
||||
if got == "" {
|
||||
t.Fatal("脱敏后不应为空(占位符仍可读)")
|
||||
}
|
||||
if strings.Contains(got, "tskey-auth-kTESTONLY") {
|
||||
t.Errorf("文件名仍含凭证: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "/") || strings.Contains(got, string(os.PathSeparator)) {
|
||||
t.Errorf("文件名仍含路径分隔符: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 织忆 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"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
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)
|
||||
// t_639c5a03 dry-run:{"dry_run":true} 只返回不写库(验证判据用)。
|
||||
if req.DryRun {
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"scanned": len(records),
|
||||
"detected": len(pats),
|
||||
"patterns": pats,
|
||||
})
|
||||
return
|
||||
}
|
||||
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,238 @@
|
||||
// 织忆 MemoryWeave — commit 写入的「引用可复算」旁路观测(阶段 1→2:只观测,不拦截)
|
||||
//
|
||||
// 动机(t_3fbb6cbd):Akasha 的确定性校验器(sourceRange + quoteHash,fail-closed)
|
||||
// 是唯一值得移植的东西。织忆现在的写入无法回证(09-25 实测卡点)。
|
||||
// 但现有写入方 payload 里没有 source_uri/quote/quote_sha256,直接上硬闸 = 拒掉 100%
|
||||
// 写入 = 掐死织忆全量摄取。所以本卡只做观测,先拿真实分布,再决定阈值/形态。
|
||||
//
|
||||
// 开关:ZHIYI_COMMIT_PROVENANCE_OBSERVE,默认关闭。关闭时行为与改前完全一致(零影响)。
|
||||
// 开启时对每次 commit 做旁路观测(异步 goroutine,不阻塞、不改响应、不改写入路径),
|
||||
// 结果追加到 ~/.hermes/logs/zhiyi-commit-provenance/YYYY-MM-DD.jsonl。
|
||||
//
|
||||
// 判定降级(AC2 重定义,t_dc412532 阶段2 设计输入):
|
||||
//
|
||||
// (a) metadata 有 source_uri + quote + quote_sha256 → 复算 sha256(quote) 比对;
|
||||
// 不等 observable=false, reason=hash_mismatch
|
||||
// (b) 无上述字段但 source=="session-compress" 且 session_id 存在 →
|
||||
// 去 ~/.hermes/state.db(只读)取该 session 消息文本做回证。
|
||||
// 观察期实测(n=205, 24h):true=1 / false=8 / unknown=193;全部 8 条 false
|
||||
// 均来自 session-compress —— 判据「前 60 字逐字命中 / LCS≥60」对「摘要/派生内容」
|
||||
// 结构性必 false(摘要不会逐字出现在原文里)。AC2 修正:
|
||||
// * 原文不可得(session 已压缩/清除)→ observable=unknown, reason=source_not_available
|
||||
// (空原文 = 无法证伪,不是证伪失败 —— 旧逻辑误标 false/not_in_source)
|
||||
// * 前 60 字逐字命中 或 LCS≥60 → observable=true(引用片段可溯源)
|
||||
// * 其余(摘要低 LCS,属正常改写)→ observable=unknown, reason=derived_low_lcs
|
||||
// (对派生内容无法证伪 —— 摘要重述≠不可溯源)
|
||||
// 同时把实测数值 lcs_len / prefix_hit60 写进 jsonl(AC1 先量后判),
|
||||
// 供阶段3 转硬闸时按分布定档。
|
||||
// (c) 其他 source → observable=unknown, reason=no_derivation(不要瞎猜成 false)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// observeMu 保护 jsonl 追加(commit 并发时防交错写)
|
||||
var observeMu sync.Mutex
|
||||
|
||||
// provenanceObserveEnabled 开关(默认关闭,读一次即可)
|
||||
var provenanceObserveEnabled = os.Getenv("ZHIYI_COMMIT_PROVENANCE_OBSERVE") == "1" ||
|
||||
strings.EqualFold(os.Getenv("ZHIYI_COMMIT_PROVENANCE_OBSERVE"), "true")
|
||||
|
||||
// stateDBPath 只读来源库(session-to-zhiyi.py 等写入方读的同一个 state.db)
|
||||
const stateDBPath = "/home/muc/.hermes/state.db"
|
||||
|
||||
// provenanceLogDir 观测结果落盘目录
|
||||
const provenanceLogDir = "/home/muc/.hermes/logs/zhiyi-commit-provenance"
|
||||
|
||||
// minCommonRun 判定「可溯源」的最长公共子串下界(字符)。
|
||||
// 依据(t_dc412532 阶段2 实测分布):观察期 session-compress 摘要 content_len 均值 ~450,
|
||||
// 要求 LCS≥60 取「摘要中至少出现一段可对回原文的片段」,过低会把无关噪声当溯源。
|
||||
const minCommonRun = 60
|
||||
|
||||
// observeCommitProvenance 旁路观测一次 commit(异步调用,永不阻塞写路径)
|
||||
func observeCommitProvenance(agentID, content string, metadata map[string]any) {
|
||||
if !provenanceObserveEnabled {
|
||||
return
|
||||
}
|
||||
source := ""
|
||||
sessionID := ""
|
||||
if metadata != nil {
|
||||
if s, ok := metadata["source"].(string); ok {
|
||||
source = s
|
||||
}
|
||||
if sid, ok := metadata["session_id"].(string); ok {
|
||||
sessionID = sid
|
||||
}
|
||||
}
|
||||
|
||||
observable := "unknown"
|
||||
reason := "no_derivation"
|
||||
lcsLen := 0
|
||||
prefixHit60 := false
|
||||
|
||||
// (a) 显式 quote + hash
|
||||
if metadata != nil {
|
||||
uri, _ := metadata["source_uri"].(string)
|
||||
quote, _ := metadata["quote"].(string)
|
||||
qh, _ := metadata["quote_sha256"].(string)
|
||||
if uri != "" && quote != "" && qh != "" {
|
||||
observable, reason = decideQuoteHash(quote, qh)
|
||||
} else if source == "session-compress" && sessionID != "" {
|
||||
// (b) session-compress:去 state.db 只读回证
|
||||
observable, reason, lcsLen, prefixHit60 = observeViaSession(content, sessionID)
|
||||
}
|
||||
} else if source == "session-compress" && sessionID != "" {
|
||||
observable, reason, lcsLen, prefixHit60 = observeViaSession(content, sessionID)
|
||||
}
|
||||
|
||||
// (c) 其余保持 unknown / no_derivation
|
||||
|
||||
line := map[string]interface{}{
|
||||
"ts": time.Now().Format(time.RFC3339),
|
||||
"agent_id": agentID,
|
||||
"source": source,
|
||||
"session_id": sessionID,
|
||||
"observable": observable,
|
||||
"reason": reason,
|
||||
"content_len": len(content),
|
||||
"lcs_len": lcsLen, // AC1: content 与原文的最长公共子串长度(实测数值)
|
||||
"prefix_hit60": prefixHit60, // AC1: 前 60 字是否逐字命中原文
|
||||
}
|
||||
appendProvenanceLog(line)
|
||||
}
|
||||
|
||||
// decideQuoteHash 复算 sha256(quote) 与调用方声明的 quote_sha256 比对。
|
||||
func decideQuoteHash(quote, quoteSHA256 string) (string, string) {
|
||||
sum := sha256.Sum256([]byte(quote))
|
||||
if hex.EncodeToString(sum[:]) == quoteSHA256 {
|
||||
return "true", ""
|
||||
}
|
||||
return "false", "hash_mismatch"
|
||||
}
|
||||
|
||||
// observeViaSession 去 state.db(只读)取 session 消息文本,回证 content 可溯源。
|
||||
// 返回 (observable, reason, lcs_len, prefix_hit60)。
|
||||
// AC2 语义:对派生/摘要内容不做逐字强判 —— 原文不可得→unknown(无法证伪),
|
||||
// 命中→true(片段可溯源),低 LCS→unknown(摘要改写正常,不可证伪)。
|
||||
func observeViaSession(content, sessionID string) (string, string, int, bool) {
|
||||
db, err := sql.Open("sqlite", "file:"+stateDBPath+"?_pragma=query_only(1)&_pragma=busy_timeout(3000)")
|
||||
if err != nil {
|
||||
return "unknown", "state_db_open_failed: " + err.Error(), 0, false
|
||||
}
|
||||
defer db.Close()
|
||||
rows, err := db.Query(
|
||||
"SELECT content FROM messages WHERE session_id=? AND role IN ('user','assistant') ORDER BY id",
|
||||
sessionID)
|
||||
if err != nil {
|
||||
return "unknown", "state_db_query_failed: " + err.Error(), 0, false
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
for rows.Next() {
|
||||
var msg string
|
||||
if err := rows.Scan(&msg); err != nil {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(msg)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
srcText := sb.String()
|
||||
if srcText == "" {
|
||||
// 原文不可得:无法证伪,不是证伪失败(旧逻辑误标 false/not_in_source)
|
||||
return "unknown", "source_not_available", 0, false
|
||||
}
|
||||
match, lcsLen, prefixHit60 := provenanceMeasure(content, srcText)
|
||||
if match {
|
||||
return "true", "", lcsLen, prefixHit60
|
||||
}
|
||||
// 摘要/派生内容低 LCS 属正常改写,无法证伪 —— 不算 false
|
||||
return "unknown", "derived_low_lcs", lcsLen, prefixHit60
|
||||
}
|
||||
|
||||
// provenanceMeasure 计算 content 相对 srcText 的可溯源实测指标:
|
||||
//
|
||||
// prefixHit60 = content 前 60 字在 srcText 中逐字命中
|
||||
// lcsLen = content 与 srcText 的最长公共子串长度
|
||||
// match = prefixHit60 || lcsLen >= minCommonRun
|
||||
func provenanceMeasure(content, srcText string) (match bool, lcsLen int, prefixHit60 bool) {
|
||||
if content == "" || srcText == "" {
|
||||
return false, 0, false
|
||||
}
|
||||
first := content
|
||||
if len(first) > minCommonRun {
|
||||
first = first[:minCommonRun]
|
||||
}
|
||||
prefixHit60 = strings.Contains(srcText, first)
|
||||
// LCS 兜底(受限输入防打爆)
|
||||
c := content
|
||||
if len(c) > 2000 {
|
||||
c = c[:2000]
|
||||
}
|
||||
s := srcText
|
||||
if len(s) > 20000 {
|
||||
s = s[:20000]
|
||||
}
|
||||
lcsLen = lcsLength(c, s)
|
||||
return prefixHit60 || lcsLen >= minCommonRun, lcsLen, prefixHit60
|
||||
}
|
||||
|
||||
// lcsLength 两个字符串的最长公共子串长度(O(n*m) 动态规划,取较短者做列)。
|
||||
func lcsLength(a, b string) int {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0
|
||||
}
|
||||
// 让 b 为较短者(节省列空间)
|
||||
if len(b) > len(a) {
|
||||
a, b = b, a
|
||||
}
|
||||
prev := make([]int, len(b)+1)
|
||||
cur := make([]int, len(b)+1)
|
||||
best := 0
|
||||
for i := 1; i <= len(a); i++ {
|
||||
for j := 1; j <= len(b); j++ {
|
||||
if a[i-1] == b[j-1] {
|
||||
cur[j] = prev[j-1] + 1
|
||||
if cur[j] > best {
|
||||
best = cur[j]
|
||||
}
|
||||
} else {
|
||||
cur[j] = 0
|
||||
}
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// appendProvenanceLog 把一行观测结果追加到当日 jsonl。
|
||||
func appendProvenanceLog(line map[string]interface{}) {
|
||||
observeMu.Lock()
|
||||
defer observeMu.Unlock()
|
||||
data, err := json.Marshal(line)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(provenanceLogDir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(provenanceLogDir, time.Now().Format("2006-01-02")+".jsonl")
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, "%s\n", data)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProvenanceMatch(t *testing.T) {
|
||||
src := strings.Repeat("源文本", 50) // 150 chars
|
||||
// 前60字符逐字命中
|
||||
content := strings.Repeat("源文本", 25) // 75 chars
|
||||
if m, _, _ := provenanceMeasure(content, src); !m {
|
||||
t.Fatal("expect first-60-chars substring match")
|
||||
}
|
||||
// 完全无关 → false
|
||||
if m, _, _ := provenanceMeasure(strings.Repeat("完全无关", 20), src); m {
|
||||
t.Fatal("expect no match for unrelated")
|
||||
}
|
||||
// LCS >= 60 兜底(content 中间有长公共段)
|
||||
content2 := strings.Repeat("P", 10) + strings.Repeat("源文本", 40) + strings.Repeat("Q", 5)
|
||||
src2 := strings.Repeat("A", 30) + strings.Repeat("源文本", 40) + strings.Repeat("B", 30)
|
||||
if m, _, _ := provenanceMeasure(content2, src2); !m {
|
||||
t.Fatal("expect LCS>=60 fallback match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLCSLength(t *testing.T) {
|
||||
if got := lcsLength("abcde", "abfce"); got != 2 { // "ab"
|
||||
t.Fatalf("lcs=2 expect, got %d", got)
|
||||
}
|
||||
if got := lcsLength("", "abc"); got != 0 {
|
||||
t.Fatalf("lcs empty=0 expect, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideQuoteHash(t *testing.T) {
|
||||
quote := "这段引用内容"
|
||||
sum := sha256.Sum256([]byte(quote))
|
||||
observable, reason := decideQuoteHash(quote, hex.EncodeToString(sum[:]))
|
||||
if observable != "true" || reason != "" {
|
||||
t.Fatalf("expect true/'' got %s/%s", observable, reason)
|
||||
}
|
||||
observable, reason = decideQuoteHash(quote, strings.Repeat("0", 64))
|
||||
if observable != "false" || reason != "hash_mismatch" {
|
||||
t.Fatalf("expect false/hash_mismatch got %s/%s", observable, reason)
|
||||
}
|
||||
}
|
||||
@@ -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,319 @@
|
||||
// 织忆 MemoryWeave — recall 错误形状统一测试(t_2d4e3b92, 2026-09-21)
|
||||
//
|
||||
// 契约:
|
||||
// - 200 + "results" 键存在 = 成功(results 可为空数组,属正常"无匹配")
|
||||
// - 非 2xx = 失败(body 为统一错误体 {"error":{"code","message","retry_after_s"}})
|
||||
// - 调用方绝不可用 len(results)==0 区分成功/失败
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// ─── 测试桩 ────────────────────────────────────────────────
|
||||
|
||||
// fakeLanceDB 实现 storage.LanceDB,Search 可配置返回结果或错误。
|
||||
type fakeLanceDB struct {
|
||||
searchResults []models.MemoryRecord
|
||||
searchErr error
|
||||
}
|
||||
|
||||
func (f *fakeLanceDB) InsertEpisode(agentID, namespace, content, category string) (string, error) {
|
||||
return "ep_test", nil
|
||||
}
|
||||
func (f *fakeLanceDB) GetTopByQuality(agentID string, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeLanceDB) Stats() (map[string]interface{}, error) {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
func (f *fakeLanceDB) SoftDelete(id, reason string) error { return nil }
|
||||
func (f *fakeLanceDB) PurgeInvalid() (int64, error) { return 0, nil }
|
||||
func (f *fakeLanceDB) SoftDeleteBatch(table string, ids []string, updatedAt string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeLanceDB) GetVersionHistory(id string) ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeLanceDB) GetCandidatesForForgetting() ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeLanceDB) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeLanceDB) Backup(path string) error { return nil }
|
||||
func (f *fakeLanceDB) GetAuditLog(limit int) ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeLanceDB) IncrementUseful(id string) error { return nil }
|
||||
func (f *fakeLanceDB) IncrementNotUseful(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GA-P0-1 (t_718d8707):拆分字段
|
||||
func (f *fakeLanceDB) IncrementHumanNotUseful(id string) error { return nil }
|
||||
func (f *fakeLanceDB) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
|
||||
func (f *fakeLanceDB) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
|
||||
func (f *fakeLanceDB) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
|
||||
func (f *fakeLanceDB) IncrementDedupDownweight(id string) error { return nil }
|
||||
func (f *fakeLanceDB) UpdateMemoryContent(id, newContent, source string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeLanceDB) InsertMemory(m models.MemoryRecord) error { return nil }
|
||||
func (f *fakeLanceDB) Search(table string, vector []float32, topK int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
if f.searchErr != nil {
|
||||
return nil, f.searchErr
|
||||
}
|
||||
if len(f.searchResults) > topK {
|
||||
return f.searchResults[:topK], nil
|
||||
}
|
||||
return f.searchResults, nil
|
||||
}
|
||||
func (f *fakeLanceDB) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
return f.searchResults, nil
|
||||
}
|
||||
func (f *fakeLanceDB) Insert(table string, record any) error { return nil }
|
||||
func (f *fakeLanceDB) Update(table, id string, fields map[string]any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 测试用例 ─────────────────────────────────────────────
|
||||
|
||||
// TestRecallSuccess_Returns200WithResults 验证成功路径:200 + results 键存在 + candidates_scanned。
|
||||
func TestRecallSuccess_Returns200WithResults(t *testing.T) {
|
||||
db := &fakeLanceDB{
|
||||
searchResults: []models.MemoryRecord{
|
||||
{ID: "mem_1", Content: "test memory one", Category: "distilled"},
|
||||
{ID: "mem_2", Content: "test memory two", Category: "distilled"},
|
||||
},
|
||||
}
|
||||
gs := governance.NewInMemoryGraph()
|
||||
emb := storage.NewEmbedder("")
|
||||
rerank := storage.NewReranker("", emb)
|
||||
api := NewAPI(db, emb, rerank, nil, gs)
|
||||
|
||||
body := `{"query":"test query","limit":5}`
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.Recall(w, req)
|
||||
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("expected 200, got %d; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if _, hasResults := resp["results"]; !hasResults {
|
||||
t.Error("expected 'results' key in success response, missing — contract violated")
|
||||
}
|
||||
if _, hasCount := resp["count"]; !hasCount {
|
||||
t.Error("expected 'count' key in success response")
|
||||
}
|
||||
if _, hasScanned := resp["candidates_scanned"]; !hasScanned {
|
||||
t.Error("expected 'candidates_scanned' key in success response (t_2d4e3b92; t_a70b07e1 语义修订)")
|
||||
}
|
||||
t.Logf("✓ success: code=200, keys=%v", mapKeys(resp))
|
||||
}
|
||||
|
||||
// TestRecallSuccess_EmptyResults_Still200WithCandidatesScanned 验证"真无匹配":
|
||||
// results=[] 但 candidates_scanned 字段存在,与依赖故障(非 2xx)可区分。
|
||||
func TestRecallSuccess_EmptyResults_Still200WithCandidatesScanned(t *testing.T) {
|
||||
db := &fakeLanceDB{searchResults: nil} // ANN 返回 0 条
|
||||
gs := governance.NewInMemoryGraph()
|
||||
emb := storage.NewEmbedder("")
|
||||
rerank := storage.NewReranker("", emb)
|
||||
api := NewAPI(db, emb, rerank, nil, gs)
|
||||
|
||||
body := `{"query":"nonsense query with no match","limit":5}`
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.Recall(w, req)
|
||||
|
||||
// embedder 无真实 endpoint → encode 失败 → 主路径 err != nil
|
||||
// InMemoryGraph FallbackTextSearch 返回空 → 走 502 失败路径
|
||||
// 所以这里验证的是失败路径(非 2xx),而非"200 空结果"
|
||||
if w.Code >= 200 && w.Code < 300 {
|
||||
// 如果是 200,验证 schema
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if _, hasResults := resp["results"]; !hasResults {
|
||||
t.Error("expected 'results' key even when empty (contract: 200+results=success)")
|
||||
}
|
||||
if _, hasScanned := resp["candidates_scanned"]; !hasScanned {
|
||||
t.Error("expected 'candidates_scanned' key to distinguish no-match from failure (t_a70b07e1 语义:主路径召回前候选数,非最终返回数)")
|
||||
}
|
||||
t.Logf("✓ empty 200: candidates_scanned=%v", resp["candidates_scanned"])
|
||||
} else {
|
||||
// 非 2xx:验证统一错误体
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse error response: %v", err)
|
||||
}
|
||||
errObj, hasErr := resp["error"].(map[string]interface{})
|
||||
if !hasErr {
|
||||
t.Fatalf("expected nested error object, got %T", resp["error"])
|
||||
}
|
||||
if _, hasCode := errObj["code"]; !hasCode {
|
||||
t.Error("expected 'error.code' in unified error schema")
|
||||
}
|
||||
if _, hasRetry := errObj["retry_after_s"]; !hasRetry {
|
||||
t.Error("expected 'error.retry_after_s' in unified error schema")
|
||||
}
|
||||
t.Logf("✓ failure: code=%d, error=%v", w.Code, errObj)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeEmbedder 实现 *storage.Embedder 的行为:EncodeSingle 可配置成功/失败。
|
||||
// 用于测试 recall 失败路径(模拟依赖不可用)。
|
||||
type fakeEmbedder struct {
|
||||
encodeErr error
|
||||
dim int
|
||||
}
|
||||
|
||||
// 由于 *storage.Embedder 是具体类型而非接口,我们通过 NewAPI 注入真实 Embedder,
|
||||
// 并在测试中利用其内部 endpoint 为空的行为。
|
||||
// 这里提供一个辅助方法:通过给 fakeLanceDB 返回 0 条 + 真实 embedder 无 endpoint
|
||||
// 来触发 Pipeline.Recall 失败。
|
||||
//
|
||||
// 实际上 storage.Embedder.Encode 在 endpoint="" 且无 apiKey 时会走 HTTP 请求到 "",
|
||||
// 导致失败。这正是我们需要的失败行为。
|
||||
|
||||
// TestRecallFailure_Non2xx 验证失败路径:依赖不可用时返回非 2xx + 统一错误体。
|
||||
// 模拟:embedder endpoint="" → encode 必失败(HTTP 到空 URL)⇒ 主路径 err != nil
|
||||
// - InMemoryGraph FallbackTextSearch 返回空 ⇒ 502 失败路径。
|
||||
func TestRecallFailure_Non2xx(t *testing.T) {
|
||||
db := &fakeLanceDB{searchResults: nil}
|
||||
gs := governance.NewInMemoryGraph()
|
||||
emb := storage.NewEmbedder("") // 无 endpoint → encode 必失败
|
||||
rerank := storage.NewReranker("", emb)
|
||||
api := NewAPI(db, emb, rerank, nil, gs)
|
||||
|
||||
body := `{"query":"test","limit":5,"mode":"hybrid"}`
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.Recall(w, req)
|
||||
|
||||
// 注意:Pipeline.Recall 内部有缓存和降级逻辑,encode 失败时可能返回空结果而非错误。
|
||||
// 如果 Pipeline 自身降级成功(返回 0 条 + nil err),则走"真无匹配"路径(200)。
|
||||
// 要真正触发 502 需要 Pipeline 返回 error。
|
||||
// 这里我们验证两种情况都符合契约:
|
||||
if w.Code >= 200 && w.Code < 300 {
|
||||
// 成功路径:必须带 results 键 + candidates_scanned
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if _, hasResults := resp["results"]; !hasResults {
|
||||
t.Error("200 response must have 'results' key (contract)")
|
||||
}
|
||||
if _, hasScanned := resp["candidates_scanned"]; !hasScanned {
|
||||
t.Error("200 response must have 'candidates_scanned' key (t_2d4e3b92; t_a70b07e1 语义修订)")
|
||||
}
|
||||
t.Logf("✓ 200 path: candidates_scanned=%v, count=%v", resp["candidates_scanned"], resp["count"])
|
||||
} else {
|
||||
// 非 2xx 路径:必须带统一错误体
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
errObj, ok := resp["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected nested error object, got %T", resp["error"])
|
||||
}
|
||||
if _, hasCode := errObj["code"]; !hasCode {
|
||||
t.Error("expected 'error.code' in unified error schema")
|
||||
}
|
||||
if _, hasRetry := errObj["retry_after_s"]; !hasRetry {
|
||||
t.Error("expected 'error.retry_after_s' in unified error schema")
|
||||
}
|
||||
t.Logf("✓ non-2xx path: code=%d, error=%v", w.Code, errObj)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecallDirect502 直接构造 502 场景:用 reflect 注入 Pipeline 使其 Recall 返回错误。
|
||||
// 由于 RecallPipeline 是具体类型,我们通过其内部 embedder 的 endpoint 不可用来触发。
|
||||
// 这里直接调用 respondErrorSchema 验证输出格式。
|
||||
func TestRecallDirect502(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
respondErrorSchema(w, 502, "recall_backend_error",
|
||||
"recall failed: encode error; graph fallback also empty", 5)
|
||||
|
||||
if w.Code != 502 {
|
||||
t.Fatalf("expected 502, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
errObj, _ := resp["error"].(map[string]interface{})
|
||||
if errObj == nil {
|
||||
t.Fatal("expected nested error object in 502 response")
|
||||
}
|
||||
if errObj["code"] != "recall_backend_error" {
|
||||
t.Errorf("expected code=recall_backend_error, got %v", errObj["code"])
|
||||
}
|
||||
if errObj["retry_after_s"] != float64(5) {
|
||||
t.Errorf("expected retry_after_s=5, got %v", errObj["retry_after_s"])
|
||||
}
|
||||
t.Logf("✓ 502 body: %v", resp)
|
||||
}
|
||||
|
||||
// TestRecallKeywordFailure_Non2xx 验证 keyword 模式主路径失败时也返回非 2xx。
|
||||
func TestRecallKeywordFailure_Non2xx(t *testing.T) {
|
||||
db := &fakeLanceDB{searchResults: nil}
|
||||
gs := governance.NewInMemoryGraph()
|
||||
emb := storage.NewEmbedder("") // 无 endpoint → encode 必失败
|
||||
rerank := storage.NewReranker("", emb)
|
||||
api := NewAPI(db, emb, rerank, nil, gs)
|
||||
|
||||
body := `{"query":"test","limit":5,"mode":"keyword"}`
|
||||
req := httptest.NewRequest("POST", "/api/v1/recall", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
api.Recall(w, req)
|
||||
|
||||
// 同 TestRecallFailure_Non2xx:Pipeline 内部降级可能使 err==nil(返回 0 条),
|
||||
// 此时走 200 路径。验证两种情况都符合契约。
|
||||
if w.Code >= 200 && w.Code < 300 {
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if _, hasResults := resp["results"]; !hasResults {
|
||||
t.Error("keyword 200 response must have 'results' key")
|
||||
}
|
||||
if _, hasScanned := resp["candidates_scanned"]; !hasScanned {
|
||||
t.Error("keyword 200 response must have 'candidates_scanned' key")
|
||||
}
|
||||
t.Logf("✓ keyword 200: candidates_scanned=%v", resp["candidates_scanned"])
|
||||
} else {
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
errObj, _ := resp["error"].(map[string]interface{})
|
||||
if errObj == nil {
|
||||
t.Error("keyword non-2xx must have unified error object")
|
||||
}
|
||||
t.Logf("✓ keyword non-2xx: code=%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// helper
|
||||
func mapKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -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,62 @@
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestResolveRecallNamespace 覆盖 2026-09-21 五修(t_24eceed0 返修)的 ns 解析规则。
|
||||
//
|
||||
// 病根:修前「无 namespace 且无 agent_id」会推导成 default-main,导致默认召回
|
||||
// 只搜 default-main + shared,而真实记忆散在 hermes-main / wiki-curator-main / wiki /
|
||||
// soulful-xiaowei / a06-main / domain:* 等 19 个命名空间(全表 10172 条,实测只拿到 11 个候选)。
|
||||
//
|
||||
// 判据:两者都空必须返回 ""(= 全库跨 ns),而不是任何具体 ns。
|
||||
func TestResolveRecallNamespace(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
namespace string
|
||||
agentID string
|
||||
query string
|
||||
want string
|
||||
why string
|
||||
}{
|
||||
{
|
||||
name: "无ns无agentID_必须全库", namespace: "", agentID: "",
|
||||
query: "蒸馏模型日限计数器", want: "",
|
||||
why: "核心判据:默认调用必须跨全部 ns 检索(返回空串 = 不过滤 = 全库)",
|
||||
},
|
||||
{
|
||||
name: "显式ns_原样作用域", namespace: "hermes-main", agentID: "",
|
||||
query: "任意", want: "hermes-main",
|
||||
why: "调用方显式指定时不得改写语义",
|
||||
},
|
||||
{
|
||||
name: "显式ns优先于agentID", namespace: "shared", agentID: "hermes-a06",
|
||||
query: "任意", want: "shared",
|
||||
why: "显式 ns 优先级最高",
|
||||
},
|
||||
{
|
||||
name: "只有agentID_推导ns", namespace: "", agentID: "hermes-a06",
|
||||
query: "任意", want: "hermes-main",
|
||||
why: "有 agent_id 时按 §1.3 推导,保持既有作用域行为",
|
||||
},
|
||||
{
|
||||
name: "只有agentID_openclaw", namespace: "", agentID: "openclaw",
|
||||
query: "任意", want: "openclaw-main",
|
||||
why: "openclaw 前缀推导",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := resolveRecallNamespace(c.namespace, c.agentID, c.query)
|
||||
if got != c.want {
|
||||
t.Errorf("✗ %s: resolveRecallNamespace(%q,%q) = %q, want %q —— %s",
|
||||
c.name, c.namespace, c.agentID, got, c.want, c.why)
|
||||
continue
|
||||
}
|
||||
t.Logf("✓ %s: got=%q", c.name, got)
|
||||
}
|
||||
|
||||
// 独立强调:全库判据不能被"看起来像 ns"的默认值替代
|
||||
if got := resolveRecallNamespace("", "", "x"); got != "" {
|
||||
t.Fatalf("✗ 回归:默认调用被解析成 %q,又只能看到极小一片记忆", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ package routes
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -15,6 +17,11 @@ import (
|
||||
// ─── 核心 API(mock LanceDB)───────────────────────────────
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
// 侧车可达 ⇒ 200 + status ok
|
||||
orig := sidecarHealthCheck
|
||||
defer func() { sidecarHealthCheck = orig }()
|
||||
sidecarHealthCheck = func() error { return nil }
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HandleHealth(w, req)
|
||||
@@ -27,6 +34,38 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
if resp["status"] != "ok" {
|
||||
t.Errorf("expected ok status, got %s", resp["status"])
|
||||
}
|
||||
if resp["sidecar"] != "ok" {
|
||||
t.Errorf("expected sidecar ok, got %s", resp["sidecar"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthEndpoint_SidecarDown 回归项(2026-10-08 故障):
|
||||
// sidecar 不可达时 /health 必须返回 503,而不是 200 —— 防"假绿"。
|
||||
func TestHealthEndpoint_SidecarDown(t *testing.T) {
|
||||
orig := sidecarHealthCheck
|
||||
defer func() { sidecarHealthCheck = orig }()
|
||||
sidecarHealthCheck = func() error {
|
||||
return errors.New("zhiyi-consolidate not reachable: dial unix /tmp/zhiyi-ipc.sock: connect: no such file or directory")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
HandleHealth(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 when sidecar down, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["status"] != "degraded" {
|
||||
t.Errorf("expected degraded status, got %s", resp["status"])
|
||||
}
|
||||
if resp["sidecar"] != "unreachable" {
|
||||
t.Errorf("expected sidecar unreachable, got %s", resp["sidecar"])
|
||||
}
|
||||
if resp["error"] == "" {
|
||||
t.Error("expected error detail in body, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 知识图谱路由 ────────────────────────────────────────
|
||||
@@ -128,7 +167,7 @@ func TestConflictAPI_Resolve_Validation(t *testing.T) {
|
||||
// ─── 知识缺口路由 ────────────────────────────────────────
|
||||
|
||||
func TestGapAPI_List(t *testing.T) {
|
||||
gd := selfoptimize.NewGapDetector()
|
||||
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||
ai := NewGapAPI(gd)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/gaps", nil)
|
||||
@@ -141,7 +180,7 @@ func TestGapAPI_List(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGapAPI_Detect(t *testing.T) {
|
||||
gd := selfoptimize.NewGapDetector()
|
||||
gd := selfoptimize.NewGapDetector(nil, nil)
|
||||
ai := NewGapAPI(gd)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"topic": "test_gap"})
|
||||
@@ -199,8 +238,8 @@ func TestTriggers_List(t *testing.T) {
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["count"].(float64) != 4 {
|
||||
t.Errorf("expected 4 triggers, got %v", resp["count"])
|
||||
if resp["count"].(float64) != 8 {
|
||||
t.Errorf("expected 8 triggers, got %v", resp["count"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,18 +345,19 @@ func TestAuxFunctions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SSE 管理器 ───────────────────────────────────────────
|
||||
// ─── WebSocket 事件推送 ───────────────────────────────────
|
||||
|
||||
func TestSSEManager_Push(t *testing.T) {
|
||||
// SSEBus.Push should not panic with no clients
|
||||
SSEBus.Push(SSEMessage{Type: "test", Payload: "hello"})
|
||||
}
|
||||
|
||||
func TestSSEPushHelpers(t *testing.T) {
|
||||
// All push helpers should not panic
|
||||
PushMemoryCommitted("agent", "ns", "mem1")
|
||||
PushConflictDetected("Docker", "facts conflict")
|
||||
PushGapFound("topic", "A")
|
||||
func TestWSPushHelpers_NoPanic(t *testing.T) {
|
||||
// 所有推送 helper 在无订阅者时都不该 panic
|
||||
// 2026-09-10 修复:原用例引用的 SSEBus/SSEMessage/PushMemoryCommitted/PushGapFound
|
||||
// 已随 SSE→WebSocket 重构删除,改用当前 API(PushGapDetected/PushConflict* 新签名)。
|
||||
PushPrefetch("agent", []interface{}{})
|
||||
PushGapDetected("agent", "topic", "knowledge", "补一下")
|
||||
PushGapFilled("agent", 1)
|
||||
PushMemoryUpdated("mem1", 2, "test")
|
||||
PushConflictDetected("agent", "c1", "Docker", map[string]string{"a": "b"})
|
||||
PushConflictResolved("agent", "c1", "keep_left")
|
||||
PushConsolidationDone("done")
|
||||
PushConflictResolved("c1", "keep_left")
|
||||
PushQualityDrop("agent", "mem1", 0.2, "补证据")
|
||||
PushDistillationComplete(1, 0)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user