Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4aa664c0cd | ||
|
|
ae766a88bb | ||
|
|
8f38c2de0c | ||
|
|
abcb4ddef9 | ||
|
|
1957228d9e | ||
|
|
35e3ca9e42 | ||
|
|
37890e3095 | ||
|
|
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)。
|
||||
+13
@@ -32,3 +32,16 @@ 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
|
||||
|
||||
@@ -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,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,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,36 @@ 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"])
|
||||
_ = cp.ldb.SoftDelete(idJ, fmt.Sprintf("merged_into_%s", idI))
|
||||
merged++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,12 +259,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 +334,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 +444,8 @@ func extractEntities(text string) []string {
|
||||
}
|
||||
|
||||
func minInt2(a, b int) int {
|
||||
if a < b { return a }
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
|
||||
+798
-122
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
+410
-43
@@ -3,27 +3,172 @@ package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"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"
|
||||
if p := os.Getenv("ZHIYI_GOLDEN_SET"); p != "" {
|
||||
var err error
|
||||
data, err = os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if path != "" {
|
||||
usedSource = "path:" + path
|
||||
d, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// 相对路径在部署 CWD 下读不到 ⇒ 内嵌兜底(AC5 根因)
|
||||
data = embeddedGoldenSetJSON
|
||||
usedSource = "embedded"
|
||||
} else {
|
||||
data = d
|
||||
}
|
||||
} 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 +185,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 +223,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 +240,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 +288,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 +311,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 +356,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 +377,176 @@ 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
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body")
|
||||
return
|
||||
}
|
||||
ns := req.Namespace
|
||||
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 +576,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 +597,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 +629,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 +651,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 +666,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 +690,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 +701,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 {
|
||||
@@ -40,7 +40,14 @@ func (fa *FeedbackAPI) MarkUseful(w http.ResponseWriter, r *http.Request) {
|
||||
[]string{req.MemoryID},
|
||||
"user_feedback", "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 +85,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 +125,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,211 @@
|
||||
// 织忆 MemoryWeave — L2 模式 API(t_d32e5f8a)
|
||||
//
|
||||
// 设计 §4.1 端点:
|
||||
// POST /api/v1/patterns/detect 手动触发模式检测(扫 L1 蒸馏记忆 → 工具组合频率 + 路径聚类
|
||||
// → 写 graph_nodes type='pattern')。短命单发:modernc sqlite
|
||||
// 开/关不占长连接、不碰 state.db(规避当年 daemon main_loop
|
||||
// 退役根因)—— 可挂 cron 单发。
|
||||
// GET /api/v1/patterns 列出检测到的模式(按 source_tasks 长度降序)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/skillgen"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// PatternAPI L2 模式端点 + L3 skill 草稿端点。
|
||||
type PatternAPI struct {
|
||||
Store *pattern.GraphPatternStore
|
||||
Provider *pattern.PatternProvider
|
||||
LDB func() storage.LanceDB // 扫 L1 记忆(distilled)
|
||||
Cfg pattern.Config
|
||||
|
||||
// L3 skill 草稿(t_fcadee04):草稿生成器 + skills 表存储 + 落盘根目录。
|
||||
// 由 server.go 装配;不装配时 Draft/Promote 返回 503/404(显式 API,不挂 cron)。
|
||||
SkillStore *skillgen.Store
|
||||
Generator skillgen.Generator
|
||||
AttachmentsDir string
|
||||
}
|
||||
|
||||
// NewPatternAPI 构造 L2 模式端点。
|
||||
func NewPatternAPI(store *pattern.GraphPatternStore, provider *pattern.PatternProvider, ldb func() storage.LanceDB) *PatternAPI {
|
||||
cfg := pattern.DefaultConfig()
|
||||
// cron 单发跑大批量时可放宽扫描上限
|
||||
if v := os.Getenv("ZHIYI_PATTERN_MAX_RECORDS"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
cfg.MaxRecords = n
|
||||
}
|
||||
}
|
||||
return &PatternAPI{Store: store, Provider: provider, LDB: ldb, Cfg: cfg}
|
||||
}
|
||||
|
||||
// GET /api/v1/patterns — 列出所有检测到的模式(AC1:空库返回 count=0 不报错)。
|
||||
func (pa *PatternAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
limit := pa.Cfg.TopKPatternList
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
pats, err := pa.Store.ListPatterns(limit)
|
||||
if err != nil {
|
||||
respondError(w, 500, "list patterns: "+err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"patterns": pats,
|
||||
"count": len(pats),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/detect — 手动触发模式检测。
|
||||
// body 可选 {"limit": N, "namespace": "hermes-main"}。
|
||||
// 流程:LanceDB Scan(limit, ns) → pattern.DetectFromRecords → UpsertPatterns(graph_nodes)。
|
||||
// 空库/无命中 → created/updated=0,不报错(AC1)。
|
||||
func (pa *PatternAPI) Detect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Limit int `json:"limit"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req) // 空 body 也合法(用默认)
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = pa.Cfg.MaxRecords
|
||||
}
|
||||
ldb := pa.LDB()
|
||||
if ldb == nil {
|
||||
respondError(w, 503, "lancedb unavailable")
|
||||
return
|
||||
}
|
||||
records, err := ldb.Scan(req.Limit, req.Namespace)
|
||||
if err != nil {
|
||||
respondError(w, 500, "scan memories: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 只拿蒸馏产物(L1 蒸馏记忆)做检测——原始对话/episode 不是「已验证」的解决路径
|
||||
// (与召回管线 2.5 步同源判据:raw/episodes 类不参与模式沉淀)。
|
||||
distilled := make([]models.MemoryRecord, 0, len(records))
|
||||
for _, rec := range records {
|
||||
if rec.Category == "distilled" || rec.Category == "" {
|
||||
distilled = append(distilled, rec)
|
||||
}
|
||||
}
|
||||
pats := pattern.DetectFromRecords(distilled, pa.Cfg)
|
||||
created, updated, uerr := pa.Store.UpsertPatterns(pats)
|
||||
if uerr != nil {
|
||||
respondError(w, 500, "upsert patterns: "+uerr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"scanned": len(records),
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"detected": len(pats),
|
||||
"patterns": pats,
|
||||
})
|
||||
}
|
||||
|
||||
// ── L3 skill 草稿(t_fcadee04,设计 §4.2 SkillGenerator)────────────────────
|
||||
|
||||
// patternSubPath 解析 /api/v1/patterns/{id}/{action} 子路径。
|
||||
// 返回 (url-unescape 后的 id, action)。格式不符 → ("", action=="")。
|
||||
func patternSubPath(path string) (id, action string) {
|
||||
const prefix = "/api/v1/patterns/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix) // <id>/draft 或 <id>/promote
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "", ""
|
||||
}
|
||||
dec, err := url.PathUnescape(parts[0])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return dec, parts[1]
|
||||
}
|
||||
|
||||
// Sub 分发 /api/v1/patterns/{id}/draft 与 /api/v1/patterns/{id}/promote。
|
||||
// 装配在 server.go 的 /api/v1/patterns/ 前缀路由;List(/patterns) 与 Detect(/patterns/detect)
|
||||
// 因更长的精确路由优先,不受影响。
|
||||
func (pa *PatternAPI) Sub(w http.ResponseWriter, r *http.Request) {
|
||||
id, action := patternSubPath(r.URL.Path)
|
||||
if id == "" {
|
||||
respondError(w, 404, "pattern id required")
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "draft":
|
||||
pa.Draft(w, r, id)
|
||||
case "promote":
|
||||
pa.Promote(w, r, id)
|
||||
default:
|
||||
respondError(w, 404, "unknown action: "+action)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/{id}/draft — 生成 L3 skill 草稿(conf≥0.85 才生成)。
|
||||
// 流程:读 pattern → 校验 conf → DraftSkill(写 skills 表 draft + 落盘 attachments)。
|
||||
// 草稿绝不进 skills/ 技能库(红线)。
|
||||
func (pa *PatternAPI) Draft(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.SkillStore == nil || pa.Generator == nil {
|
||||
respondError(w, 503, "skillgen 未装配(SkillStore/Generator 为空)")
|
||||
return
|
||||
}
|
||||
pat, err := pa.Store.GetPattern(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "pattern not found: "+id)
|
||||
} else {
|
||||
respondError(w, 500, "get pattern: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
attRoot := pa.AttachmentsDir
|
||||
if attRoot == "" {
|
||||
attRoot = skillgen.DefaultAttachmentsRoot()
|
||||
}
|
||||
sk, path, derr := skillgen.DraftSkill(pa.SkillStore, pa.Generator, pat, attRoot)
|
||||
if derr != nil {
|
||||
respondError(w, 400, "draft skill: "+derr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"skill": sk,
|
||||
"file": path,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/patterns/{id}/promote — 人工确认后 draft→active。
|
||||
func (pa *PatternAPI) Promote(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.SkillStore == nil {
|
||||
respondError(w, 503, "skillgen 未装配(SkillStore 为空)")
|
||||
return
|
||||
}
|
||||
if err := pa.SkillStore.Promote(id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "no skill draft for pattern: "+id)
|
||||
} else {
|
||||
respondError(w, 500, "promote skill: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
sk, gerr := pa.SkillStore.Get(id)
|
||||
if gerr != nil {
|
||||
respondError(w, 500, "read skill after promote: "+gerr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{"skill": sk})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 织忆 MemoryWeave — L3 skill 草稿 API 路由测试(t_fcadee04)
|
||||
//
|
||||
// 覆盖端点:POST /api/v1/patterns/{id}/draft + POST /api/v1/patterns/{id}/promote
|
||||
// 共享同一临时 graph.db(pattern 在 graph_nodes、skill 在 skills 表同库),零网络/零 CGO。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/skillgen"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// newSkillgenTestAPI 构造共享同一 temp graph.db 的 PatternAPI(含 L3 装配)。
|
||||
func newSkillgenTestAPI(t *testing.T) (*PatternAPI, string) {
|
||||
t.Helper()
|
||||
dbDir := t.TempDir()
|
||||
graphPath := filepath.Join(dbDir, "graph.db")
|
||||
pstore := pattern.NewGraphPatternStore(graphPath)
|
||||
t.Cleanup(pstore.Close)
|
||||
sstore := skillgen.NewStore(graphPath)
|
||||
t.Cleanup(sstore.Close)
|
||||
attRoot := filepath.Join(t.TempDir(), "attachments")
|
||||
|
||||
api := NewPatternAPI(pstore, nil, func() storage.LanceDB { return nil })
|
||||
api.SkillStore = sstore
|
||||
api.Generator = &skillgen.FakeGenerator{Out: "## 适用场景\n重复处理"}
|
||||
api.AttachmentsDir = attRoot
|
||||
|
||||
// 种一个 conf=0.9 的 pattern 进 graph_nodes
|
||||
hi := pattern.Pattern{
|
||||
ID: "pat_tool_combo:curl+git+jq", Type: "tool_combo",
|
||||
Name: "tool_combo:curl+git+jq", Data: "curl+git+jq",
|
||||
Confidence: 0.9, Tools: []string{"curl", "git", "jq"},
|
||||
SourceTasks: []string{"m_a", "m_b", "m_c"},
|
||||
}
|
||||
if _, _, err := pstore.UpsertPatterns([]pattern.Pattern{hi}); err != nil {
|
||||
t.Fatalf("seed pattern: %v", err)
|
||||
}
|
||||
return api, attRoot
|
||||
}
|
||||
|
||||
// TestSkillgen_DraftRoute —— AC1 API 层:draft 端点 → 200 + skills 表 1 行 draft + 附件文件。
|
||||
func TestSkillgen_DraftRoute(t *testing.T) {
|
||||
api, attRoot := newSkillgenTestAPI(t)
|
||||
id := "pat_tool_combo:curl+git+jq"
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil)
|
||||
api.Sub(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("draft 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// skills 表 1 行 draft
|
||||
if n, _ := api.SkillStore.Count("draft"); n != 1 {
|
||||
t.Fatalf("AC1 判据:draft 行数应 1,实际 %d", n)
|
||||
}
|
||||
sk, err := api.SkillStore.Get(id)
|
||||
if err != nil || sk.Status != "draft" {
|
||||
t.Fatalf("skill 应存在且 draft,实际 %v %+v", err, sk)
|
||||
}
|
||||
// 附件文件存在 + 含 [AUTO-DRAFT]
|
||||
path := filepath.Join(attRoot, skillgen.DraftSubDir("tool_combo:curl+git+jq"), "skill-draft.md")
|
||||
data, rerr := os.ReadFile(path)
|
||||
if rerr != nil {
|
||||
t.Fatalf("附件文件不存在 %s: %v", path, rerr)
|
||||
}
|
||||
if !strings.Contains(string(data), skillgen.AUTO_DRAFT_MARKER) {
|
||||
t.Fatalf("附件应含 [AUTO-DRAFT] 标记")
|
||||
}
|
||||
t.Logf("route draft 落盘: %s", path)
|
||||
}
|
||||
|
||||
// TestSkillgen_PromoteRoute —— AC3 API 层:promote 端点 → 200 + status active。
|
||||
func TestSkillgen_PromoteRoute(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
id := "pat_tool_combo:curl+git+jq"
|
||||
// 先 draft
|
||||
dr := httptest.NewRecorder()
|
||||
api.Sub(dr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/draft", nil))
|
||||
if dr.Code != http.StatusOK {
|
||||
t.Fatalf("前置 draft 应 200,实际 %d", dr.Code)
|
||||
}
|
||||
// promote
|
||||
pr := httptest.NewRecorder()
|
||||
api.Sub(pr, httptest.NewRequest("POST", "/api/v1/patterns/"+id+"/promote", nil))
|
||||
if pr.Code != http.StatusOK {
|
||||
t.Fatalf("promote 期望 200,实际 %d body=%s", pr.Code, pr.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Skill struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"skill"`
|
||||
}
|
||||
if err := json.Unmarshal(pr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("promote 解析: %v", err)
|
||||
}
|
||||
if resp.Skill.Status != "active" {
|
||||
t.Fatalf("AC3 判据:promote 后 status 应 active,实际 %s", resp.Skill.Status)
|
||||
}
|
||||
// 库复核(AC3 判据:sqlite 查)
|
||||
if n, _ := api.SkillStore.Count("active"); n != 1 {
|
||||
t.Fatalf("AC3 判据:active 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_DraftRoute_NotFound —— 不存在的 pattern → 404。
|
||||
func TestSkillgen_DraftRoute_NotFound(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/nope/draft", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("不存在 pattern 的 draft 应 404,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_PromoteRoute_NoDraft —— 无草稿即 promote → 404。
|
||||
func TestSkillgen_PromoteRoute_NoDraft(t *testing.T) {
|
||||
api, _ := newSkillgenTestAPI(t)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/patterns/pat_tool_combo:curl+git+jq/promote", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("无草稿 promote 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillgen_SubPath —— 子路径解析单元(含 URL 编码 id)。
|
||||
func TestSkillgen_SubPath(t *testing.T) {
|
||||
id, action := patternSubPath("/api/v1/patterns/pat_tool_combo:curl+git+jq/promote")
|
||||
if id != "pat_tool_combo:curl+git+jq" || action != "promote" {
|
||||
t.Fatalf("解析失败 id=%q action=%q", id, action)
|
||||
}
|
||||
if _, a := patternSubPath("/api/v1/patterns"); a != "" {
|
||||
t.Fatalf("无子路径应 action 空")
|
||||
}
|
||||
if _, a := patternSubPath("/api/v1/patterns/detect"); a != "" {
|
||||
t.Fatalf("/patterns/detect 是精确路由,不应走 Sub")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/pattern"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// 织忆 — L2 模式 API 路由测试(t_d32e5f8a)
|
||||
// AC1: 空库 detect/list → 不报错(count=0)
|
||||
// AC2: 3 条同工具组合记忆 → detect 后 list 能查到 tool_combo pattern
|
||||
|
||||
// newPatternTestAPI 构造一个用临时 graph.db + 假 LanceDB 后端的 PatternAPI(无网络、无 CGO)。
|
||||
// 假后端只实现 PatternAPI 实际调用的 Scan(其余方法嵌 nil 接口,测试路径不会触达)。
|
||||
func newPatternTestAPI(t *testing.T, mems []models.MemoryRecord) *PatternAPI {
|
||||
t.Helper()
|
||||
store := pattern.NewGraphPatternStore(t.TempDir() + "/graph.db")
|
||||
t.Cleanup(store.Close)
|
||||
mlc := &patternScanLance{rows: mems}
|
||||
pemb := pattern.NewEmbedderProvider(&fakePatternEmbedder{})
|
||||
return NewPatternAPI(store, pattern.NewPatternProvider(store, pemb), func() storage.LanceDB { return mlc })
|
||||
}
|
||||
|
||||
// patternScanLance 覆写 Scan(PatternAPI.Detect 唯一用到的 LanceDB 方法);
|
||||
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
|
||||
type patternScanLance struct {
|
||||
storage.LanceDB
|
||||
rows []models.MemoryRecord
|
||||
}
|
||||
|
||||
func (f *patternScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
if limit > 0 && len(f.rows) > limit {
|
||||
return f.rows[:limit], nil
|
||||
}
|
||||
return f.rows, nil
|
||||
}
|
||||
|
||||
// fakePatternEmbedder 确定性假向量(pattern.Provider 匹配测试用;注入测试走 storage 侧不走这里)。
|
||||
type fakePatternEmbedder struct{}
|
||||
|
||||
func (fakePatternEmbedder) EncodeSingle(text string) ([]float32, error) {
|
||||
v := make([]float32, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
v[i] = 0.1
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// TestPatterns_ListEmpty —— AC1:空库 list → 200 + count=0(不报错)。
|
||||
func TestPatterns_ListEmpty(t *testing.T) {
|
||||
api := newPatternTestAPI(t, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.List(rec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("空库 list 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if int(resp["count"].(float64)) != 0 {
|
||||
t.Fatalf("空库 count 期望 0,实际 %v", resp["count"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatterns_DetectEmpty —— AC1:空库 detect → 200 + scanned=0/created=0(不报错)。
|
||||
func TestPatterns_DetectEmpty(t *testing.T) {
|
||||
api := newPatternTestAPI(t, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("空库 detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if int(resp["scanned"].(float64)) != 0 || int(resp["created"].(float64)) != 0 {
|
||||
t.Fatalf("空库 detect 期望 0/0,实际 %v/%v", resp["scanned"], resp["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPatterns_DetectToolCombo —— AC2:3 条同工具组合蒸馏记忆 → detect 产出 1 个 tool_combo,
|
||||
// 且 list 能查到(sqlite graph_nodes 判据)。
|
||||
func TestPatterns_DetectToolCombo(t *testing.T) {
|
||||
mems := make([]models.MemoryRecord, 0, 3)
|
||||
for i, id := range []string{"pc_a", "pc_b", "pc_c"} {
|
||||
mems = append(mems, models.MemoryRecord{
|
||||
ID: id,
|
||||
Content: "用 jq git curl 处理第" + string(rune('一'+i)) + "项",
|
||||
Category: "distilled",
|
||||
})
|
||||
}
|
||||
api := newPatternTestAPI(t, mems)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.Detect(rec, httptest.NewRequest("POST", "/api/v1/patterns/detect", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("detect 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var dresp map[string]interface{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &dresp)
|
||||
if int(dresp["detected"].(float64)) < 1 {
|
||||
t.Fatalf("3 条同组合应检出 ≥1 个 pattern,实际 detected=%v", dresp["detected"])
|
||||
}
|
||||
// 用 list 再核一遍 sqlite 判据:确有 tool_combo 行
|
||||
lrec := httptest.NewRecorder()
|
||||
api.List(lrec, httptest.NewRequest("GET", "/api/v1/patterns", nil))
|
||||
var lresp struct {
|
||||
Count int `json:"count"`
|
||||
Patterns []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
} `json:"patterns"`
|
||||
}
|
||||
if err := json.Unmarshal(lrec.Body.Bytes(), &lresp); err != nil {
|
||||
t.Fatalf("list 解析: %v (body=%s)", err, lrec.Body.String())
|
||||
}
|
||||
if lresp.Count < 1 {
|
||||
t.Fatalf("AC2 判据:detect 后 list count 应 ≥1,实际 %d (detected=%v)", lresp.Count, dresp["detected"])
|
||||
}
|
||||
var foundToolCombo bool
|
||||
for _, p := range lresp.Patterns {
|
||||
if p.Type == "tool_combo" {
|
||||
foundToolCombo = true
|
||||
}
|
||||
}
|
||||
if !foundToolCombo {
|
||||
t.Fatalf("AC2 判据:list 应含 tool_combo pattern,实际 %d 条均非 tool_combo", lresp.Count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// 织忆 MemoryWeave — P3 Peer Representation API(t_f8fc97e6,设计 §6.1)
|
||||
//
|
||||
// 端点:
|
||||
//
|
||||
// POST /api/v1/peer/{id}/refresh 手动刷新某实体的四层推理画像(显式触发)
|
||||
// GET /api/v1/peer/{id}/explain 解释为何这样理解该实体(可审计:画像 + 来源记忆)
|
||||
// POST /api/v1/recall/peer?peer_id=… 召回某 peer 的推理画像(env 门控 ZHIYI_PEER_RECALL)
|
||||
//
|
||||
// 设计约束(与 L2 pattern / L3 skillgen 同源):
|
||||
//
|
||||
// · refresh/explain 是显式 API,不挂 cron ⇒ 默认零行为变化。
|
||||
// · 召回注入走独立端点 + env 门控 ZHIYI_PEER_RECALL,默认 0 ⇒ 主召回管线逐条不变(AC2)。
|
||||
// · peers 表在 graph.db(短命 modernc),不碰 state.db。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/peer"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// PeerAPI P3 peer 表示端点。
|
||||
type PeerAPI struct {
|
||||
Store *peer.Store
|
||||
Reasoner peer.Reasoner
|
||||
LDB func() storage.LanceDB // 扫 L1 记忆(explicit 层来源)
|
||||
GraphStore governance.GraphStore // 图谱上下文(推理输入)
|
||||
}
|
||||
|
||||
// NewPeerAPI 构造。
|
||||
func NewPeerAPI(store *peer.Store, reasoner peer.Reasoner, ldb func() storage.LanceDB, g governance.GraphStore) *PeerAPI {
|
||||
return &PeerAPI{Store: store, Reasoner: reasoner, LDB: ldb, GraphStore: g}
|
||||
}
|
||||
|
||||
// peerSubPath 解析 /api/v1/peer/{id}/{action} 子路径。
|
||||
func peerSubPath(path string) (id, action string) {
|
||||
const prefix = "/api/v1/peer/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", ""
|
||||
}
|
||||
rest := strings.TrimPrefix(path, prefix) // <id>/refresh 或 <id>/explain
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "", ""
|
||||
}
|
||||
dec, err := url.PathUnescape(parts[0])
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
return dec, parts[1]
|
||||
}
|
||||
|
||||
// Sub 分发 /api/v1/peer/{id}/refresh 与 /api/v1/peer/{id}/explain。
|
||||
func (pa *PeerAPI) Sub(w http.ResponseWriter, r *http.Request) {
|
||||
id, action := peerSubPath(r.URL.Path)
|
||||
if id == "" {
|
||||
respondError(w, 404, "peer id required")
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "refresh":
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pa.Refresh(w, r, id)
|
||||
case "explain":
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
pa.Explain(w, r, id)
|
||||
default:
|
||||
respondError(w, 404, "unknown action: "+action)
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshAsync 异步刷新某实体的四层画像(推理不阻塞主流程,AC4)。
|
||||
// 返回一个 done channel,调用方可不等待(非阻塞);推理在后台 goroutine 完成。
|
||||
// 任何错误只记日志,不影响调用方。
|
||||
func (pa *PeerAPI) RefreshAsync(id, namespace string) chan error {
|
||||
done := make(chan error, 1)
|
||||
if pa == nil || pa.Store == nil || pa.Reasoner == nil {
|
||||
done <- fmt.Errorf("peer 未装配")
|
||||
return done
|
||||
}
|
||||
go func() {
|
||||
done <- pa.refresh(id, namespace, 2000)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// RefreshFromContent 提交新记忆后异步刷新内容里出现的已知实体(设计「新记忆进入 → 不阻塞主流程
|
||||
// → 更新该 peer 表示」)。匹配:content 含某 peer_id 子串即触发。全部 goroutine 内执行,立即返回。
|
||||
// 由 server.go 在 commit 处理器里以 ZHIYI_PEER_RECALL=1 门控调用;默认关 ⇒ 不触发。
|
||||
func (pa *PeerAPI) RefreshFromContent(content, namespace string) {
|
||||
if pa == nil || pa.Store == nil || content == "" {
|
||||
return
|
||||
}
|
||||
peers, err := pa.Store.List(200)
|
||||
if err != nil || len(peers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.PeerID != "" && strings.Contains(content, p.PeerID) {
|
||||
go pa.refresh(p.PeerID, namespace, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refresh 实际刷新编排(同步执行体;由 RefreshAsync / RefreshFromContent 包 goroutine)。
|
||||
func (pa *PeerAPI) refresh(id, namespace string, limit int) error {
|
||||
explicit, _, scanErr := pa.collectExplicit(id, namespace, limit)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
graphCtx := pa.collectGraphContext(id)
|
||||
if _, err := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refresh POST /api/v1/peer/{id}/refresh — 扫 L1 记忆 + 图谱上下文 → 四层推理 → 写 peers 表。
|
||||
// body 可选 {"namespace": "...", "limit": N}(namespace 空 = 全库)。
|
||||
func (pa *PeerAPI) Refresh(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.Store == nil || pa.Reasoner == nil {
|
||||
respondError(w, 503, "peer 未装配(Store/Reasoner 为空)")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Namespace string `json:"namespace"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 2000
|
||||
}
|
||||
// 1. 扫 L1 记忆(LanceDB),提取 explicit 层
|
||||
explicit, matched, scanErr := pa.collectExplicit(id, req.Namespace, req.Limit)
|
||||
if scanErr != nil {
|
||||
respondError(w, 500, "scan memories: "+scanErr.Error())
|
||||
return
|
||||
}
|
||||
// 2. 图谱上下文(实体 1 跳邻居名)
|
||||
graphCtx := pa.collectGraphContext(id)
|
||||
// 3. 四层推理 + 写库
|
||||
p, perr := peer.BuildPeer(pa.Store, pa.Reasoner, id, explicit.facts, explicit.prefs, explicit.skills, graphCtx)
|
||||
if perr != nil {
|
||||
respondError(w, 400, "build peer: "+perr.Error())
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"peer": p,
|
||||
"matched": matched,
|
||||
"scanned": req.Limit,
|
||||
"graph_ctx": graphCtx,
|
||||
"has_reason": len(p.InferredTraits) > 0 || len(p.InferredGoals) > 0 || p.HypothesizedContext != "",
|
||||
})
|
||||
}
|
||||
|
||||
// Explain GET /api/v1/peer/{id}/explain — 可审计解释:画像 + 来源记忆。
|
||||
func (pa *PeerAPI) Explain(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if pa.Store == nil {
|
||||
respondError(w, 503, "peer 未装配(Store 为空)")
|
||||
return
|
||||
}
|
||||
p, err := pa.Store.Get(id)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "peer not found: "+id+"(先 POST /peer/{id}/refresh 生成画像)")
|
||||
} else {
|
||||
respondError(w, 500, "get peer: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
// 来源记忆:扫 L1 里含该实体的记忆(仅读,不改库)
|
||||
src := []string{}
|
||||
if pa.LDB != nil {
|
||||
if ldb := pa.LDB(); ldb != nil {
|
||||
if recs, serr := ldb.Scan(2000, ""); serr == nil {
|
||||
for _, m := range recs {
|
||||
if strings.Contains(m.Content, id) {
|
||||
src = append(src, m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{"peer": p, "source_memories": src})
|
||||
}
|
||||
|
||||
// RecallPeer POST /api/v1/recall/peer?peer_id=… — 召回某 peer 的四层推理画像。
|
||||
// env 门控 ZHIYI_PEER_RECALL:默认 0 → 404(零变化);=1 → 返回画像 JSON(AC3 判据)。
|
||||
func (pa *PeerAPI) RecallPeer(w http.ResponseWriter, r *http.Request) {
|
||||
if !peerRecallEnabled() {
|
||||
respondError(w, 404, "peer recall 默认关(ZHIYI_PEER_RECALL 未设/≠1)")
|
||||
return
|
||||
}
|
||||
pid := r.URL.Query().Get("peer_id")
|
||||
if pid == "" {
|
||||
respondError(w, 400, "peer_id required")
|
||||
return
|
||||
}
|
||||
if pa.Store == nil {
|
||||
respondError(w, 503, "peer 未装配(Store 为空)")
|
||||
return
|
||||
}
|
||||
p, err := pa.Store.Get(pid)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
respondError(w, 404, "peer not found: "+pid)
|
||||
} else {
|
||||
respondError(w, 500, "get peer: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"peer_id": p.PeerID,
|
||||
"portrait": map[string]interface{}{
|
||||
"explicit": map[string]interface{}{"facts": p.Facts, "preferences": p.Preferences, "skills": p.Skills},
|
||||
"deductive": map[string]interface{}{"traits": p.InferredTraits, "goals": p.InferredGoals},
|
||||
"inductive": map[string]interface{}{"patterns": p.Patterns},
|
||||
"abductive": map[string]interface{}{"hypothesized_context": p.HypothesizedContext},
|
||||
},
|
||||
"updated_at": p.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// explicitResult explicit 层收集结果。
|
||||
type explicitResult struct {
|
||||
facts, prefs, skills []string
|
||||
}
|
||||
|
||||
// collectExplicit 扫 L1 记忆并提取 explicit 层。namespace 空 = 全库。
|
||||
func (pa *PeerAPI) collectExplicit(id, namespace string, limit int) (explicitResult, int, error) {
|
||||
var res explicitResult
|
||||
if pa.LDB == nil {
|
||||
return res, 0, nil
|
||||
}
|
||||
ldb := pa.LDB()
|
||||
if ldb == nil {
|
||||
return res, 0, nil
|
||||
}
|
||||
recs, err := ldb.Scan(limit, namespace)
|
||||
if err != nil {
|
||||
return res, 0, err
|
||||
}
|
||||
facts, prefs, skills, matched := peer.CollectFacts(recs, id)
|
||||
return explicitResult{facts: facts, prefs: prefs, skills: skills}, matched, nil
|
||||
}
|
||||
|
||||
// collectGraphContext 取实体图谱 1 跳邻居名作为推理上下文(读图,只读不改)。
|
||||
func (pa *PeerAPI) collectGraphContext(id string) []string {
|
||||
if pa.GraphStore == nil {
|
||||
return nil
|
||||
}
|
||||
paths, err := pa.GraphStore.Navigate(id, 1, "", nil)
|
||||
if err != nil || len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, p := range paths {
|
||||
for _, k := range []string{"to", "target"} {
|
||||
if v, ok := p[k].(string); ok && v != "" && !seen[v] && v != id {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) > 10 {
|
||||
out = out[:10]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// peerRecallEnabled 每次调用读 env(默认关,零行为变化)。
|
||||
func peerRecallEnabled() bool {
|
||||
switch strings.ToLower(os.Getenv("ZHIYI_PEER_RECALL")) {
|
||||
case "1", "true", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRecallResults 供 storage.PeerRecallProvider 装配(server.go 在 ZHIYI_PEER_RECALL=1
|
||||
// 时注册)。query 命中某实体 → 返回该 peer 的四层画像(Category="peer")。
|
||||
// 命中判定:query 里含 peer_id 子串(大小写不敏感);取最多 topK 个。无命中 → 空。
|
||||
func (pa *PeerAPI) GetRecallResults(query, namespace string, topK int) []models.RecallResult {
|
||||
if pa == nil || pa.Store == nil || topK <= 0 {
|
||||
return nil
|
||||
}
|
||||
peers, err := pa.Store.List(200)
|
||||
if err != nil || len(peers) == 0 {
|
||||
return nil
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
var out []models.RecallResult
|
||||
for _, p := range peers {
|
||||
if p.PeerID != "" && q != "" && strings.Contains(q, strings.ToLower(p.PeerID)) {
|
||||
content := "peer 画像: " + p.PeerID +
|
||||
" | 事实: " + strings.Join(p.Facts, "; ") +
|
||||
" | 偏好: " + strings.Join(p.Preferences, "; ") +
|
||||
" | 技能: " + strings.Join(p.Skills, "; ") +
|
||||
" | 演绎特质: " + strings.Join(p.InferredTraits, "; ") +
|
||||
" | 演绎目标: " + strings.Join(p.InferredGoals, "; ") +
|
||||
" | 归纳模式: " + strings.Join(p.Patterns, "; ") +
|
||||
" | 最佳解释: " + p.HypothesizedContext
|
||||
out = append(out, models.RecallResult{
|
||||
ID: "peer:" + p.PeerID,
|
||||
Content: content,
|
||||
Category: "peer",
|
||||
Score: 1.0,
|
||||
Namespace: namespace,
|
||||
Source: "peer_representation",
|
||||
})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 织忆 MemoryWeave — P3 Peer Representation API 路由测试(t_f8fc97e6)
|
||||
//
|
||||
// 覆盖端点:POST /api/v1/peer/{id}/refresh + GET /api/v1/peer/{id}/explain
|
||||
// - POST /api/v1/recall/peer(env 门控)+ storage.PeerRecallProvider 注入。
|
||||
//
|
||||
// 共享同一临时 graph.db(peers 表),零网络 / CGO / 真模型。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/peer"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// peerScanLance 覆写 Scan(PeerAPI.Refresh/Explain 唯一用到的 LanceDB 方法);
|
||||
// 其余方法嵌 nil 接口 —— 本测试路径不会调用,触达即 panic(暴露误用)。
|
||||
type peerScanLance struct {
|
||||
storage.LanceDB
|
||||
rows []models.MemoryRecord
|
||||
}
|
||||
|
||||
func (f *peerScanLance) Scan(limit int, namespaceFilter string) ([]models.MemoryRecord, error) {
|
||||
if limit > 0 && len(f.rows) > limit {
|
||||
return f.rows[:limit], nil
|
||||
}
|
||||
return f.rows, nil
|
||||
}
|
||||
|
||||
// newPeerTestAPI 构造共享同一 temp graph.db 的 PeerAPI。
|
||||
// GraphStore 传 nil(collectGraphContext 处理 nil ⇒ 无图上下文,不炸)。
|
||||
func newPeerTestAPI(t *testing.T, mems []models.MemoryRecord) *PeerAPI {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
store := peer.NewStore(filepath.Join(dir, "graph.db"))
|
||||
t.Cleanup(store.Close)
|
||||
mlc := &peerScanLance{rows: mems}
|
||||
return NewPeerAPI(store, &peer.FakeReasoner{}, func() storage.LanceDB { return mlc }, nil)
|
||||
}
|
||||
|
||||
// fiveMems 造 5 条关于「牧尘」的 L1 记忆(AC1 输入构造)。
|
||||
func fiveMems() []models.MemoryRecord {
|
||||
return []models.MemoryRecord{
|
||||
{Content: "牧尘选择 Docker 部署"},
|
||||
{Content: "牧尘偏好 SysV 风格"},
|
||||
{Content: "牧尘擅长 Python"},
|
||||
{Content: "牧尘厌恶 systemd"},
|
||||
{Content: "牧尘使用 jq 处理配置"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RefreshRoute —— AC1 API 层:5 条记忆 → refresh → peers 表 1 行四层非空。
|
||||
func TestPeer_RefreshRoute(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil) // 牧尘
|
||||
api.Sub(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Peer peer.Peer `json:"peer"`
|
||||
Matched int `json:"matched"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("refresh 解析: %v", err)
|
||||
}
|
||||
if resp.Matched != 5 {
|
||||
t.Errorf("AC1:matched=%d 期望 5", resp.Matched)
|
||||
}
|
||||
if resp.Peer.PeerID != "牧尘" {
|
||||
t.Errorf("peer_id=%q 期望 牧尘", resp.Peer.PeerID)
|
||||
}
|
||||
// 四层非空(AC1 判据)
|
||||
if len(resp.Peer.InferredTraits) == 0 {
|
||||
t.Error("inferred_traits 空")
|
||||
}
|
||||
if len(resp.Peer.InferredGoals) == 0 {
|
||||
t.Error("inferred_goals 空")
|
||||
}
|
||||
if len(resp.Peer.Patterns) == 0 {
|
||||
t.Error("patterns 空")
|
||||
}
|
||||
if resp.Peer.HypothesizedContext == "" {
|
||||
t.Error("hypothesized_context 空")
|
||||
}
|
||||
// peers 表行数(AC1:sqlite 查)
|
||||
if n, _ := api.Store.Count(); n != 1 {
|
||||
t.Errorf("AC1:peers 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_ExplainRoute —— GET explain → 200 + 画像 + 来源记忆。
|
||||
func TestPeer_ExplainRoute(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 先 refresh 种画像
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("前置 refresh 应 200,实际 %d", rec.Code)
|
||||
}
|
||||
// explain
|
||||
er := httptest.NewRecorder()
|
||||
api.Sub(er, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
|
||||
if er.Code != http.StatusOK {
|
||||
t.Fatalf("explain 期望 200,实际 %d body=%s", er.Code, er.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Peer peer.Peer `json:"peer"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
}
|
||||
if err := json.Unmarshal(er.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("explain 解析: %v", err)
|
||||
}
|
||||
if len(resp.SourceMemories) != 5 {
|
||||
t.Errorf("source_memories=%d 期望 5(可审计:画像有来源)", len(resp.SourceMemories))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_Explain_NotFound —— 未 refresh 直接 explain → 404。
|
||||
func TestPeer_Explain_NotFound(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
api.Sub(rec, httptest.NewRequest("GET", "/api/v1/peer/%E7%89%A7%E5%B0%98/explain", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("无画像 explain 应 404,实际 %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RecallPeer_DefaultOff —— AC3:ZHIYI_PEER_RECALL=0(默认)→ recall/peer 404 零变化。
|
||||
func TestPeer_RecallPeer_DefaultOff(t *testing.T) {
|
||||
t.Setenv("ZHIYI_PEER_RECALL", "0")
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
rec := httptest.NewRecorder()
|
||||
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("默认关 recall/peer 应 404,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_RecallPeer_Enabled —— AC3:ZHIYI_PEER_RECALL=1 → 返回四层画像 JSON。
|
||||
func TestPeer_RecallPeer_Enabled(t *testing.T) {
|
||||
t.Setenv("ZHIYI_PEER_RECALL", "1")
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 先 refresh 种画像
|
||||
rrec := httptest.NewRecorder()
|
||||
api.Sub(rrec, httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
if rrec.Code != http.StatusOK {
|
||||
t.Fatalf("前置 refresh 应 200,实际 %d", rrec.Code)
|
||||
}
|
||||
// recall/peer
|
||||
rec := httptest.NewRecorder()
|
||||
api.RecallPeer(rec, httptest.NewRequest("POST", "/api/v1/recall/peer?peer_id=牧尘", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("recall/peer 期望 200,实际 %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Portrait map[string]interface{} `json:"portrait"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("recall/peer 解析: %v", err)
|
||||
}
|
||||
// 四层各有内容(AC3 判据)
|
||||
for _, layer := range []string{"explicit", "deductive", "inductive", "abductive"} {
|
||||
v, ok := resp.Portrait[layer]
|
||||
if !ok {
|
||||
t.Errorf("portrait 缺 %s 层: %v", layer, resp.Portrait)
|
||||
continue
|
||||
}
|
||||
s, _ := json.Marshal(v)
|
||||
if string(s) == "{}" || strings.Contains(string(s), "[]\"") {
|
||||
t.Errorf("portrait.%s 层空: %s", layer, s)
|
||||
}
|
||||
}
|
||||
t.Logf("AC3 recall/peer 画像: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// TestPeer_GetRecallResults —— storage.PeerRecallProvider 注入:query 含实体 → 返回画像条目。
|
||||
func TestPeer_GetRecallResults(t *testing.T) {
|
||||
api := newPeerTestAPI(t, fiveMems())
|
||||
// 种画像
|
||||
api.Sub(httptest.NewRecorder(),
|
||||
httptest.NewRequest("POST", "/api/v1/peer/%E7%89%A7%E5%B0%98/refresh", nil))
|
||||
res := api.GetRecallResults("牧尘怎么部署", "", 1)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("GetRecallResults 应返回 1 条,实际 %d", len(res))
|
||||
}
|
||||
if res[0].Category != "peer" || res[0].ID != "peer:牧尘" {
|
||||
t.Errorf("返回条目 Category=%q ID=%q 期望 peer / peer:牧尘", res[0].Category, res[0].ID)
|
||||
}
|
||||
if !strings.Contains(res[0].Content, "演绎特质") || !strings.Contains(res[0].Content, "最佳解释") {
|
||||
t.Errorf("画像内容缺四层: %s", res[0].Content)
|
||||
}
|
||||
// 无命中 → 空
|
||||
if got := api.GetRecallResults("如何优化 Redis", "", 1); len(got) != 0 {
|
||||
t.Errorf("不相关 query 应空,实际 %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeer_SubPath —— 子路径解析单元。
|
||||
func TestPeer_SubPath(t *testing.T) {
|
||||
id, action := peerSubPath("/api/v1/peer/%E7%89%A7%E5%B0%98/refresh")
|
||||
if id != "牧尘" || action != "refresh" {
|
||||
t.Fatalf("解析失败 id=%q action=%q", id, action)
|
||||
}
|
||||
if _, a := peerSubPath("/api/v1/peer"); a != "" {
|
||||
t.Fatalf("无子路径应 action 空")
|
||||
}
|
||||
}
|
||||
|
||||
// blockingReasoner 会阻塞的推理器(测非阻塞:RefreshAsync 不等它完成)。
|
||||
type blockingReasoner struct{ release chan struct{} }
|
||||
|
||||
func (b *blockingReasoner) Reason(facts, prefs, skills, graphCtx []string) (*peer.ReasoningOutput, error) {
|
||||
<-b.release
|
||||
return &peer.ReasoningOutput{InferredTraits: []string{"T"}}, nil
|
||||
}
|
||||
|
||||
// TestPeer_RefreshAsync_NonBlocking —— AC4:推理不阻塞主流程。
|
||||
// RefreshAsync 立即返回(后台 goroutine 推理),即使推理器被阻塞也绝不等它。
|
||||
func TestPeer_RefreshAsync_NonBlocking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := peer.NewStore(filepath.Join(dir, "graph.db"))
|
||||
t.Cleanup(store.Close)
|
||||
br := &blockingReasoner{release: make(chan struct{})}
|
||||
mlc := &peerScanLance{rows: fiveMems()}
|
||||
api := NewPeerAPI(store, br, func() storage.LanceDB { return mlc }, nil)
|
||||
|
||||
done := api.RefreshAsync("牧尘", "")
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("RefreshAsync 应在推理器仍阻塞时立即返回(非阻塞),却等到了结果")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// 非阻塞 ✓:200ms 内未等推理完成
|
||||
}
|
||||
// 释放推理器,后台完成
|
||||
close(br.release)
|
||||
select {
|
||||
case <-done:
|
||||
// 后台完成
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("释放后后台推理未完成")
|
||||
}
|
||||
// 画像已落库
|
||||
if n, _ := store.Count(); n != 1 {
|
||||
t.Errorf("非阻塞刷新后 peers 行数应 1,实际 %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,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,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)
|
||||
}
|
||||
|
||||
@@ -17,18 +17,18 @@ const skillRedisKey = "zhiyi:skills"
|
||||
|
||||
// BetaSkill 贝叶斯 Skill 评分(G7 扩展版)
|
||||
type BetaSkill struct {
|
||||
Name string `json:"name"`
|
||||
Alpha float64 `json:"alpha"` // α = successes + 1
|
||||
Beta float64 `json:"beta"` // β = failures + 1
|
||||
Trials int `json:"trials"`
|
||||
Successes int `json:"successes"`
|
||||
ETA float64 `json:"eta"` // α/(α+β) 贝叶斯均值
|
||||
Status string `json:"status"` // active / probation / retired
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Name string `json:"name"`
|
||||
Alpha float64 `json:"alpha"` // α = successes + 1
|
||||
Beta float64 `json:"beta"` // β = failures + 1
|
||||
Trials int `json:"trials"`
|
||||
Successes int `json:"successes"`
|
||||
ETA float64 `json:"eta"` // α/(α+β) 贝叶斯均值
|
||||
Status string `json:"status"` // active / probation / retired
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
// G7 扩展字段
|
||||
PromptTemplate string `json:"prompt_template,omitempty"` // 可执行 prompt(含 {param} 占位符)
|
||||
LinkedMemoryIDs []string `json:"linked_memory_ids,omitempty"` // 关联的记忆 ID
|
||||
LinkedEntities []string `json:"linked_entities,omitempty"` // 关联的图谱实体
|
||||
PromptTemplate string `json:"prompt_template,omitempty"` // 可执行 prompt(含 {param} 占位符)
|
||||
LinkedMemoryIDs []string `json:"linked_memory_ids,omitempty"` // 关联的记忆 ID
|
||||
LinkedEntities []string `json:"linked_entities,omitempty"` // 关联的图谱实体
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -146,12 +146,12 @@ func (bsm *BayesianSkillManager) Register(name, promptTemplate string, linkedEnt
|
||||
skill, exists := bsm.skills[name]
|
||||
if !exists {
|
||||
skill = &BetaSkill{
|
||||
Name: name,
|
||||
Alpha: 1.0,
|
||||
Beta: 1.0,
|
||||
CreatedAt: time.Now(),
|
||||
PromptTemplate: promptTemplate,
|
||||
LinkedEntities: linkedEntities,
|
||||
Name: name,
|
||||
Alpha: 1.0,
|
||||
Beta: 1.0,
|
||||
CreatedAt: time.Now(),
|
||||
PromptTemplate: promptTemplate,
|
||||
LinkedEntities: linkedEntities,
|
||||
}
|
||||
bsm.skills[name] = skill
|
||||
} else {
|
||||
@@ -306,11 +306,11 @@ func (bsm *BayesianSkillManager) Stats() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"total": len(bsm.skills),
|
||||
"active": active,
|
||||
"probation": probation,
|
||||
"retired": retired,
|
||||
"persisted": bsm.persisted,
|
||||
"total": len(bsm.skills),
|
||||
"active": active,
|
||||
"probation": probation,
|
||||
"retired": retired,
|
||||
"persisted": bsm.persisted,
|
||||
"redis_connected": bsm.redisClient != nil,
|
||||
}
|
||||
}
|
||||
@@ -371,10 +371,10 @@ func (sm *SkillManager) Trial(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /api/v1/skills — 注册新 skill(G7 新增)
|
||||
func (sm *SkillManager) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
LinkedEntities []string `json:"linked_entities"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
LinkedEntities []string `json:"linked_entities"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, 400, "invalid body: "+err.Error())
|
||||
@@ -427,4 +427,4 @@ func (sm *SkillManager) GetSkillETA(name string) float64 {
|
||||
return 0.5
|
||||
}
|
||||
return skill.ETA
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,8 +153,8 @@ func crystallizeFromRecord(mem *models.MemoryRecord) (*BetaSkill, error) {
|
||||
if err == nil && llmResult != "" {
|
||||
var parsed struct {
|
||||
PromptTemplate string `json:"prompt_template"`
|
||||
Entities []string `json:"entities"`
|
||||
SkillName string `json:"skill_name"`
|
||||
Entities []string `json:"entities"`
|
||||
SkillName string `json:"skill_name"`
|
||||
}
|
||||
if err2 := json.Unmarshal([]byte(llmResult), &parsed); err2 == nil {
|
||||
if parsed.PromptTemplate != "" {
|
||||
@@ -318,4 +318,4 @@ func getLDB() storage.LanceDB {
|
||||
return ldbGetter()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,4 +240,3 @@ func ExecuteSkill(w http.ResponseWriter, r *http.Request) {
|
||||
"eta": skill.ETA,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// 2026-10-13 (t_e9a4d18f P2) 常设答案路由(Hindsight mental models 直读/写入)。
|
||||
// 读取 = 一次 DB 读(question_id 主键),零检索零 LLM。写入由生成脚本
|
||||
// zhiyi-standing-answers.py 调用(复用本地 2B,免费)。
|
||||
|
||||
// standingStore 返回全局常设答案库(server.go 注入)。未初始化 → nil。
|
||||
func standingStore() *storage.StandingStore { return storage.StandingStoreInstance }
|
||||
|
||||
// HandleStandingAnswers GET/POST /api/v1/standing-answers
|
||||
// GET ?id=<question_id> → 直读一条;无 id → 列全部
|
||||
// POST {"question_id","question","answer","source_memories":[]} → 写入/改写
|
||||
func HandleStandingAnswers(w http.ResponseWriter, r *http.Request) {
|
||||
s := standingStore()
|
||||
if s == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "standing answers store not initialized")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, map[string]interface{}{"answers": all, "count": len(all)})
|
||||
return
|
||||
}
|
||||
a, err := s.Get(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if a == nil {
|
||||
respondError(w, http.StatusNotFound, "standing answer not found: "+id)
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, a)
|
||||
case http.MethodPost:
|
||||
var in struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SourceMemories []string `json:"source_memories"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if in.QuestionID == "" || in.Answer == "" {
|
||||
respondError(w, http.StatusBadRequest, "question_id and answer required")
|
||||
return
|
||||
}
|
||||
a, err := s.Upsert(in.QuestionID, in.Question, in.Answer, in.SourceMemories)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond(w, http.StatusOK, a)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 织忆 MemoryWeave — 系统通知/提示样板文本判定(被动注入入口噪音闸门)
|
||||
//
|
||||
// 背景(t_689b6d38,2026-10-11 实测):织忆被动注入入口不区分「真实用户消息」与
|
||||
// 「系统样板文本」。近 7 天 [graph/notes] 通道 806 次注入里 543 次(67%)的入参是
|
||||
// 系统通知/提示样板(形如 "[IMPORTANT: You are running as a scheduled cron job...",
|
||||
// "[IMPORTANT: Background process ... completed", "[System note: ..."),它们照常走
|
||||
// 完整实体拆解并返回 3 篇笔记注入上下文 —— 纯浪费 + 稀释有效注入信噪比。
|
||||
//
|
||||
// 判定优先用【结构化信号】(消息来源标记头,如 "[IMPORTANT:"、"[HEARTBEAT]"、
|
||||
// "[System note:"、"Cronjob Response:"、role/来源字段),而不是纯文本长度启发式。
|
||||
// 命中 ⇒ 调用方应跳过语义召回与笔记返回。白名单:真实用户提问(含飞书/QQ/CLI 入站
|
||||
// 消息)不命中任何前缀/子串 ⇒ 照常召回,不误杀。
|
||||
package routes
|
||||
|
||||
import "github.com/xiaoxue/memoryweave/internal/textfilter"
|
||||
|
||||
// IsSystemTemplateInput 判定一段文本是否系统通知/提示样板(非真实用户提问)。
|
||||
// 命中 ⇒ 调用方应跳过语义召回与笔记返回(t_689b6d38 噪音闸门)。
|
||||
//
|
||||
// 2026-10-12:判定清单已抽到叶子包 internal/textfilter(蒸馏侧准入闸门也要用,
|
||||
// 而 distill 不能反向 import routes)。本函数保留为薄委托,调用点零改动。
|
||||
// 清单只维护 textfilter 一处。
|
||||
func IsSystemTemplateInput(s string) bool {
|
||||
return textfilter.IsSystemTemplate(s)
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"description": "织忆召回金标检查集(单一真相源,t_949840b1)。统一判据口径 N=5:取 recall 返回前 N=5 条查关键词,ID 命中 = expected_ids 任一 ∈ 前 N=5 条。基线已刷新(golden-check.py --refresh,两量具同源)。",
|
||||
"items": [
|
||||
{
|
||||
"query": "织忆是什么",
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆的图谱扩展机制是什么",
|
||||
"expected_ids": [
|
||||
"mem_1791899478673190284"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"图谱"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1791899478673190284",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆如何通过 E1 提取和 MMR 实现多样去重",
|
||||
"expected_ids": [
|
||||
"mem_1791666890486125605"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"MMR"
|
||||
],
|
||||
"category": "system_fact",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1791666890486125605",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "牧尘的偏好是什么",
|
||||
"expected_ids": [
|
||||
"mem_1790448247125520424"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"牧尘"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1790448247125520424",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "牧尘喜欢什么样的工作方式",
|
||||
"expected_ids": [
|
||||
"mem_1790448247125520424"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"牧尘"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1790448247125520424",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何根据牧尘的偏好调整记忆检索策略",
|
||||
"expected_ids": [
|
||||
"mem_1791747597032219499"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"记忆"
|
||||
],
|
||||
"category": "user_pref",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1791747597032219499",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "当前项目的技术栈是什么",
|
||||
"expected_ids": [
|
||||
"mem_1790455676397796671"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"技术栈"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1790455676397796671",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆项目有哪些核心组件",
|
||||
"expected_ids": [
|
||||
"mem_1791906354849281414"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1791906354849281414",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆和其他记忆系统相比有什么架构优势",
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "proj_context",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何使用 recall 接口",
|
||||
"expected_ids": [
|
||||
"mem_1791796178328736793"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"recall"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1791796178328736793",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "织忆有哪些管理工具",
|
||||
"expected_ids": [
|
||||
"mem_1791666702950691634"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "medium",
|
||||
"id_baseline": "mem_1791666702950691634",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "如何通过 API 扩展织忆功能",
|
||||
"expected_ids": [
|
||||
"mem_1791666188168772049"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"织忆"
|
||||
],
|
||||
"category": "tool_usage",
|
||||
"difficulty": "hard",
|
||||
"id_baseline": "mem_1791666188168772049",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "本地 llama 服务 8080 端口",
|
||||
"expected_ids": [
|
||||
"mem_1791423503359721152"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"8080"
|
||||
],
|
||||
"category": "target_knowledge",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1791423503359721152",
|
||||
"namespace": "hermes-main"
|
||||
},
|
||||
{
|
||||
"query": "session-to-zhiyi.py 脚本名",
|
||||
"expected_ids": [
|
||||
"mem_1791688470278719760"
|
||||
],
|
||||
"must_include_keywords": [
|
||||
"session-to-zhiyi"
|
||||
],
|
||||
"category": "target_knowledge",
|
||||
"difficulty": "easy",
|
||||
"id_baseline": "mem_1791688470278719760",
|
||||
"namespace": "hermes-main"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// 织忆 MemoryWeave — routes 包测试隔离(两个护栏合并 · t_e5997ca4 + t_b7a8ed9c)
|
||||
//
|
||||
// ⚠️ 本文件是**两个 TestMain 的合并体**。两卡分支各自加了一个 TestMain:
|
||||
// · t_e5997ca4 → testmain_test.go(落盘点护栏)
|
||||
// · t_b7a8ed9c → scorecache_guard_test.go(打分缓存护栏)
|
||||
// 在各自分支上互不干扰,合并到 main 后同包两个 TestMain **编译冲突**
|
||||
// (multiple definitions of TestMain)⇒ 必须合进一个文件。
|
||||
// (此点由 t_b7a8ed9c 在 scorecache_guard_test.go 顶部预先标注;小怡合并时未读文件
|
||||
// 而漏看,由全套测试的 setup failed 拦下 —— 见 worklog 教训。)
|
||||
//
|
||||
// 护栏①:单元测试不得写到生产落盘点(t_e5997ca4)
|
||||
// ---------------------------------------------------------------
|
||||
// selfoptimize 包已有同款 TestMain 先例(曾实测:整套测试跑完把
|
||||
// /var/lib/memoryweave/dashboard.json 写成测试数字)。
|
||||
// gap close/repair 端点会触发 Dash.persist()(本地 JSON 兜底)⇒ 统一重定向到临时目录。
|
||||
//
|
||||
// 护栏②:默认关闭 distill 打分缓存(t_b7a8ed9c)
|
||||
// ---------------------------------------------------------------
|
||||
// 背景:distill 打分记忆化(0dc571d)+ Redis L2(36db9bb,键 zhiyi:distill-score:*,
|
||||
// TTL 168h)后,**本包 E2E 测试(zz_commit_conflict_gate_test.go)会读写生产 Redis**:
|
||||
// 第一次 run L2 miss → 真调 mock LLM 并把「测试内容哈希 → 结果」写进生产;
|
||||
// 7 天内后续 run L2 hit → LLM 被静默短路 → 数 LLM 调用次数的断言(base+1)必挂。
|
||||
// 实测 2026-10-17:TestCommitConflictGate_E2E 16.5s 固定 FAIL,
|
||||
// 「expected >= 1 LLM distill calls, got 0」。
|
||||
//
|
||||
// 治法(对齐项目门禁规矩③:加缓存必须给该包 TestMain 加关闭开关):
|
||||
// Setenv ZHIYI_SCORE_CACHE=0 —— distill.getScoreCache 的 Once 闭包**活读 env**
|
||||
// (包级变量在 init 时已定型,跨包设不了),本进程内缓存整体禁用:
|
||||
// 不读 L2(LLM 真被调用,计数断言有覆盖)、不写 L2(不再污染生产)。
|
||||
package routes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// 护栏①:落盘点重定向到临时目录(宁可测试直接失败,也不写生产落盘点)
|
||||
dir, err := os.MkdirTemp("", "routes-test-dash-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Setenv("ZHIYI_DASHBOARD_PATH", filepath.Join(dir, "dashboard.json"))
|
||||
|
||||
// 护栏②:关闭 distill 打分缓存(不读/不写生产 Redis L2)
|
||||
_ = os.Setenv("ZHIYI_SCORE_CACHE", "0")
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -42,8 +42,8 @@ type Trigger struct {
|
||||
Condition string `json:"condition"`
|
||||
Urgency float64 `json:"urgency"` // 0-1,越大越紧急
|
||||
Active bool `json:"active"`
|
||||
Paused bool `json:"paused"` // kill-switch
|
||||
Cooldown string `json:"cooldown"` // 冷却时间
|
||||
Paused bool `json:"paused"` // kill-switch
|
||||
Cooldown string `json:"cooldown"` // 冷却时间
|
||||
FailCount int `json:"fail_count"` // 连续失败计数
|
||||
LastFiredAt time.Time `json:"last_fired_at,omitempty"`
|
||||
Description string `json:"description"`
|
||||
@@ -189,5 +189,3 @@ func (tm *TriggerManager) RecordFail(id string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// TuningConfig 可自动调优的参数
|
||||
@@ -20,7 +25,16 @@ type TuningConfig struct {
|
||||
ConsolidateAfter int `json:"consolidate_after"` // N 条蒸馏后触发整合
|
||||
|
||||
// 历史调参记录
|
||||
history []TuningEvent
|
||||
history []TuningEvent
|
||||
|
||||
// lastSkipped 上一次 AutoTune 里「因指标不可用而跳过」的规则与原因。
|
||||
// 让外部能区分「健康所以没调」与「数据不可用所以没调」——后者需要修数据源,不是好事。
|
||||
lastSkipped []string
|
||||
|
||||
// lastMoveAt 每个参数上一次被改动的时间 —— 用于冷却(治棘轮)。
|
||||
lastMoveAt map[string]time.Time
|
||||
// persistEnabled 仅全局 Tuner 为 true;测试里构造的实例为 false ⇒ 不写盘。
|
||||
persistEnabled bool
|
||||
}
|
||||
|
||||
type TuningEvent struct {
|
||||
@@ -32,11 +46,149 @@ type TuningEvent struct {
|
||||
}
|
||||
|
||||
var Tuner = &TuningConfig{
|
||||
Diversity: 0.5,
|
||||
// ⚠️ 初值必须等于**真实消费方的默认值**,否则接线当天就改变生产行为:
|
||||
// Diversity 对齐 storage.DefaultMMRDiversity() 的 0.3(core.go 原硬编码),原为 0.5。
|
||||
Diversity: 0.3,
|
||||
DecayRate: 0.015,
|
||||
GapThreshold: 3,
|
||||
DistillInterval: 5,
|
||||
ConsolidateAfter: 50,
|
||||
|
||||
lastMoveAt: map[string]time.Time{},
|
||||
persistEnabled: true,
|
||||
}
|
||||
|
||||
// tuneCooldown 同一参数的调参冷却时间。
|
||||
// 为什么需要:**AutoTune 不幂等** —— 同一判据每调一次就推一步(实测连跑两次:
|
||||
// decay_rate 0.015→0.018→0.0216、distill_interval 5→4→3),直到撞上限。
|
||||
// 闭环每天跑一次 ⇒ 几天内把参数顶到极值。冷却把「持续推进」限制成
|
||||
// 「同一指标须持续越界多个窗口,才多次调整」。
|
||||
const tuneCooldown = 6 * time.Hour
|
||||
|
||||
// minTuningSamples 调参判据的最小样本数。
|
||||
// 为什么需要:小样本的比率噪声极大 —— 实测现网 recall_usefulness_rate 只有 9 个样本,
|
||||
// 8 好 1 坏 = 0.89,再来 1 个好评就 > 0.9 ⇒ 触发「降低 diversity」直接改生产参数。
|
||||
// 少样本不是证据。
|
||||
const minTuningSamples = 30
|
||||
|
||||
// ratioFor 取一个「可用于调参」的比率指标,不可用则返回 ok=false。
|
||||
//
|
||||
// 约定(internal/selfoptimize/selfoptimize.go:248-322):
|
||||
//
|
||||
// <name>_rate —— 比率值;**分母退化时返回 -1**(metricNA),既不是 0 也不是 1
|
||||
// <name>_samples —— 该比率的分母样本数
|
||||
// <name>_valid —— 分母是否非退化
|
||||
//
|
||||
// ⚠️ AutoTune 原先只比较数值,无视这套约定 ⇒ `-1` 会让规则对「无数据」**恒真**:
|
||||
//
|
||||
// gap_closure_rate = -1 < 0.5 ⇒「降低 gap 阈值」每轮都触发(按垃圾指标改生产参数)
|
||||
// recall_usefulness_rate 样本 9 条 ⇒ 一次偶然就改 Diversity
|
||||
//
|
||||
// 本函数把「无数据 / 退化 / 样本不足」三类一律判为不可用 —— 宁可不动。
|
||||
// ⚠️ 命名不是统一的:比率键为 `<base>_rate`,而 *样本/有效* 键挂在 **base** 上
|
||||
// 而不是带 _rate 的那个串上 —— 实际键是 `recall_usefulness_samples`,
|
||||
// 不是 `recall_usefulness_rate_samples`(我第一版就按后者拼,护栏静默失效、
|
||||
// 参数照样被改;新加的 tuning_guard_test.go 当场逮住)。
|
||||
// 且 `avg_distill_loss` 这类**值键本身不带 _rate 后缀** ⇒ 统一用 TrimSuffix 推 base。
|
||||
func ratioFor(metrics map[string]float64, valueKey string) (float64, bool) {
|
||||
base := strings.TrimSuffix(valueKey, "_rate")
|
||||
v, ok := metrics[valueKey]
|
||||
if !ok || v < 0 {
|
||||
return 0, false // 缺失 或 -1 哨兵 = 无数据
|
||||
}
|
||||
if valid, ok := metrics[base+"_valid"]; ok && valid == 0 {
|
||||
return 0, false // 分母退化
|
||||
}
|
||||
if samples, ok := metrics[base+"_samples"]; ok && samples < minTuningSamples {
|
||||
return 0, false // 样本不足以支撑判断
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// canMove 判断某参数当前是否允许改动(冷却窗口内不许重复动 —— 治棘轮)。
|
||||
func (tc *TuningConfig) canMove(param string) bool {
|
||||
last, ok := tc.lastMoveAt[param]
|
||||
return !ok || time.Since(last) >= tuneCooldown
|
||||
}
|
||||
|
||||
func (tc *TuningConfig) markMove(param string) {
|
||||
if tc.lastMoveAt == nil {
|
||||
tc.lastMoveAt = map[string]time.Time{}
|
||||
}
|
||||
tc.lastMoveAt[param] = time.Now()
|
||||
}
|
||||
|
||||
// pushToConsumers 把当前参数推给**真实消费方**。
|
||||
// 🔴 这一步是「调参有没有用」的分水岭:2026-10-14 实测,此前 Tuner 的全部字段
|
||||
// 没有任何读者(全仓只有 3 处路由注册引用它)⇒ 调参改了数字、行为零变化。
|
||||
func (tc *TuningConfig) pushToConsumers() {
|
||||
storage.SetDefaultMMRDiversity(tc.Diversity)
|
||||
selfoptimize.SetGapThreshold(tc.GapThreshold)
|
||||
}
|
||||
|
||||
// tuningStatePath 持久化路径(可用 ZHIYI_TUNING_STATE 覆盖,便于测试/隔离)。
|
||||
func tuningStatePath() string {
|
||||
if p := os.Getenv("ZHIYI_TUNING_STATE"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "/var/lib/memoryweave/tuning-state.json"
|
||||
}
|
||||
|
||||
type tuningState struct {
|
||||
Diversity float64 `json:"diversity"`
|
||||
GapThreshold int `json:"gap_threshold"`
|
||||
LastMoveAt map[string]string `json:"last_move_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// persist 落盘(失败只告警,不影响调参本身)。
|
||||
func (tc *TuningConfig) persist() {
|
||||
if !tc.persistEnabled {
|
||||
return
|
||||
}
|
||||
st := tuningState{Diversity: tc.Diversity, GapThreshold: tc.GapThreshold,
|
||||
LastMoveAt: map[string]string{}, UpdatedAt: time.Now().Format(time.RFC3339)}
|
||||
for k, v := range tc.lastMoveAt {
|
||||
st.LastMoveAt[k] = v.Format(time.RFC3339)
|
||||
}
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("[tuning] 序列化失败: %v", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(tuningStatePath(), data, 0o644); err != nil {
|
||||
log.Printf("[tuning] 落盘失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// load 启动时载入持久化参数(无文件则用默认值),并立即接线到消费方。
|
||||
// 没有这一步,重启即回默认 ⇒ 调参成果每次重启清零。
|
||||
func (tc *TuningConfig) load() {
|
||||
data, err := os.ReadFile(tuningStatePath())
|
||||
if err == nil {
|
||||
var st tuningState
|
||||
if json.Unmarshal(data, &st) == nil {
|
||||
if st.Diversity > 0 {
|
||||
tc.Diversity = st.Diversity
|
||||
}
|
||||
if st.GapThreshold > 0 {
|
||||
tc.GapThreshold = st.GapThreshold
|
||||
}
|
||||
for k, v := range st.LastMoveAt {
|
||||
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
||||
if tc.lastMoveAt == nil {
|
||||
tc.lastMoveAt = map[string]time.Time{}
|
||||
}
|
||||
tc.lastMoveAt[k] = t
|
||||
}
|
||||
}
|
||||
log.Printf("[tuning] 已载入持久化参数 diversity=%.3f gap_threshold=%d(%s)",
|
||||
tc.Diversity, tc.GapThreshold, st.UpdatedAt)
|
||||
}
|
||||
}
|
||||
tc.mu.Lock()
|
||||
tc.pushToConsumers()
|
||||
tc.mu.Unlock()
|
||||
}
|
||||
|
||||
// AutoTune 基于仪表盘指标自动调参
|
||||
@@ -45,61 +197,103 @@ func (tc *TuningConfig) AutoTune(metrics map[string]float64) []TuningEvent {
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
var events []TuningEvent
|
||||
skipped := make([]string, 0, 5)
|
||||
// 记一次「本规则为什么没跑」—— 让调用方能解释「为什么没调参」,
|
||||
// 而不是只回 no_changes_needed(后者无法区分「健康」与「数据不可用」)。
|
||||
note := func(rule, why string) {
|
||||
skipped = append(skipped, rule+": "+why)
|
||||
}
|
||||
|
||||
// 1. 召回命中率 < 0.5 → 提高 diversity(扩大搜索范围)
|
||||
if hitRate, ok := metrics["recall_hit_rate"]; ok && hitRate < 0.5 {
|
||||
// 1. 召回质量低 → 提高 diversity(扩大搜索范围)。
|
||||
// 2026-10-17:原信号 recall_hit_rate 已退役(恒=1、从不及 0.5 ⇒ 本安全阀长期死寂)。
|
||||
// 改用 recall_usefulness_rate(返回且被使用)作为召回质量的实时信号:< 0.5 ⇒ 召回太窄、扩大范围。
|
||||
hitRate, hitOK := ratioFor(metrics, "recall_usefulness_rate")
|
||||
if !hitOK {
|
||||
note("rule1_usefulness", "指标不可用(无数据/退化/样本<20)")
|
||||
}
|
||||
if hitOK && hitRate < 0.5 && !tc.canMove("diversity") {
|
||||
note("rule1_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if hitOK && hitRate < 0.5 && tc.canMove("diversity") {
|
||||
newDiversity := tc.Diversity + 0.1
|
||||
if newDiversity > 1.0 {
|
||||
newDiversity = 1.0
|
||||
}
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_hit_rate < 0.5"))
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate < 0.5"))
|
||||
tc.Diversity = newDiversity
|
||||
tc.markMove("diversity")
|
||||
}
|
||||
|
||||
// 2. 召回有用率 > 0.9 → 降低 diversity(更精准)
|
||||
if usefulRate, ok := metrics["recall_usefulness_rate"]; ok && usefulRate > 0.9 {
|
||||
usefulRate, usefulOK := ratioFor(metrics, "recall_usefulness_rate")
|
||||
if !usefulOK {
|
||||
note("rule2_usefulness", "指标不可用(无数据/退化/样本<30)")
|
||||
}
|
||||
if usefulOK && usefulRate > 0.9 && !tc.canMove("diversity") {
|
||||
note("rule2_usefulness", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if usefulOK && usefulRate > 0.9 && tc.canMove("diversity") {
|
||||
newDiversity := tc.Diversity - 0.05
|
||||
if newDiversity < 0.1 {
|
||||
newDiversity = 0.1
|
||||
}
|
||||
events = append(events, recordTuning(tc, "diversity", tc.Diversity, newDiversity, "recall_usefulness_rate > 0.9"))
|
||||
tc.Diversity = newDiversity
|
||||
tc.markMove("diversity")
|
||||
}
|
||||
|
||||
// 3. 废弃率 > 5/day → 提高衰减率(加快清理)
|
||||
if deprecated, ok := metrics["deprecated_per_day"]; ok && deprecated > 5 {
|
||||
newDecay := tc.DecayRate * 1.2
|
||||
if newDecay > 0.05 {
|
||||
newDecay = 0.05
|
||||
}
|
||||
events = append(events, recordTuning(tc, "decay_rate", tc.DecayRate, newDecay, "deprecated_per_day > 5"))
|
||||
tc.DecayRate = newDecay
|
||||
}
|
||||
// 3.(2026-10-14 移除)废弃率 → 提高衰减率。
|
||||
// 移除理由:这个方向危险。governance.NewForgetter 上方注释记载:
|
||||
// 「2026-09-07 方案A: 0.015→0.03 曾致误删(36天库龄下 0-recall 长记忆全被清, 405字CNB经验被删)」。
|
||||
// 自动调参朝这个方向推 = 把那条事故路重新铺上;而收益只是「加快清理」。
|
||||
// 若要恢复,必须先有「误删可回滚 + 删除前抽样人工确认」的保护,不能只靠阈值。
|
||||
|
||||
// 4. 缺口闭环率 < 0.5 → 降低 gap 阈值
|
||||
if gapRate, ok := metrics["gap_closure_rate"]; ok && gapRate < 0.5 {
|
||||
gapRate, gapOK := ratioFor(metrics, "gap_closure_rate")
|
||||
if !gapOK {
|
||||
note("rule4_gap_threshold", "指标不可用(无数据/退化/样本<30)")
|
||||
}
|
||||
if gapOK && gapRate < 0.5 && !tc.canMove("gap_threshold") {
|
||||
note("rule4_gap_threshold", "冷却中(距上次调整 <"+tuneCooldown.String()+")")
|
||||
}
|
||||
if gapOK && gapRate < 0.5 && tc.canMove("gap_threshold") {
|
||||
newThreshold := tc.GapThreshold - 1
|
||||
if newThreshold < 2 {
|
||||
newThreshold = 2
|
||||
}
|
||||
events = append(events, recordTuningInt(tc, "gap_threshold", tc.GapThreshold, newThreshold, "gap_closure_rate < 0.5"))
|
||||
tc.GapThreshold = newThreshold
|
||||
tc.markMove("gap_threshold")
|
||||
}
|
||||
|
||||
// 5. 蒸馏损失 > 0.3 → 更频繁蒸馏
|
||||
if loss, ok := metrics["avg_distill_loss"]; ok && loss > 0.3 {
|
||||
newInterval := tc.DistillInterval - 1
|
||||
if newInterval < 1 {
|
||||
newInterval = 1
|
||||
}
|
||||
events = append(events, recordTuningInt(tc, "distill_interval", tc.DistillInterval, newInterval, "avg_distill_loss > 0.3"))
|
||||
tc.DistillInterval = newInterval
|
||||
}
|
||||
// 5.(2026-10-14 移除)蒸馏损失 → 缩短蒸馏间隔。
|
||||
// 移除理由:**没有正确的消费方**。distill/engine.go:265 的 batchTimeout(5min)是
|
||||
// 「队列冻结兜底 ticker」(2026-10-06 修的「生产者停止即永久冻结」安全网),
|
||||
// **不是蒸馏节奏**。调安全网的周期治不了蒸馏质量 —— 接到错的地方比不接更坏。
|
||||
// 若将来真有「蒸馏节奏」消费者,再恢复本规则并接对目标。
|
||||
|
||||
tc.history = append(tc.history, events...)
|
||||
tc.lastSkipped = skipped
|
||||
if len(events) > 0 {
|
||||
tc.pushToConsumers() // 改了就必须接线,否则等于没改
|
||||
tc.persist() // 不落盘则重启即丢
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// init 启动时载入持久化参数并接线(没有它:重启回默认 + 调参不生效)。
|
||||
func init() { Tuner.load() }
|
||||
|
||||
// LastSkipped 返回上一次调参里被跳过的规则及原因(只读快照)。
|
||||
func (tc *TuningConfig) LastSkipped() []string {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
out := make([]string, len(tc.lastSkipped))
|
||||
copy(out, tc.lastSkipped)
|
||||
return out
|
||||
}
|
||||
|
||||
func recordTuning(tc *TuningConfig, param string, oldVal, newVal float64, reason string) TuningEvent {
|
||||
return TuningEvent{
|
||||
Parameter: param,
|
||||
@@ -135,7 +329,8 @@ func (tc *TuningConfig) StatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"distill_interval": tc.DistillInterval,
|
||||
"consolidate_after": tc.ConsolidateAfter,
|
||||
},
|
||||
"history": tc.history,
|
||||
"history": tc.history,
|
||||
"last_skipped": tc.lastSkipped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,7 +340,11 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
events := tc.AutoTune(metrics)
|
||||
|
||||
if len(events) == 0 {
|
||||
respond(w, 200, map[string]string{"status": "no_changes_needed"})
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "no_changes_needed",
|
||||
// 区分「健康」与「数据不可用」:后者要修数据源,而不是当作一切正常。
|
||||
"skipped": tc.lastSkipped,
|
||||
})
|
||||
} else {
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"status": "tuned",
|
||||
@@ -157,25 +356,34 @@ func (tc *TuningConfig) RunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// POST /api/v1/tuning/analytics — 运行因果分析
|
||||
func (tc *TuningConfig) AnalyticsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 基于指标间的关系给出建议
|
||||
m := selfoptimize.Dash.Metrics()
|
||||
|
||||
suggestions := []string{}
|
||||
if m["recall_hit_rate"] < 0.5 && m["avg_distill_loss"] > 0.3 {
|
||||
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
|
||||
}
|
||||
if m["deprecated_per_day"] > 10 {
|
||||
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
|
||||
}
|
||||
if m["gap_closure_rate"] < 0.3 {
|
||||
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
|
||||
}
|
||||
if m["auto_resolve_rate"] < 0.5 {
|
||||
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
|
||||
}
|
||||
|
||||
suggestions := AnalyticsSuggestions(m)
|
||||
respond(w, 200, map[string]interface{}{
|
||||
"metrics": m,
|
||||
"suggestions": suggestions,
|
||||
})
|
||||
}
|
||||
|
||||
// AnalyticsSuggestions 依据指标间关系给出调优建议(纯函数,便于单测反例:N/A 不产生建议)。
|
||||
//
|
||||
// ⚠️ 每条建议的比率比较都必须先判 N/A(ratioFor 已封装:-1 哨兵 / valid=0 / 样本不足 ⇒ 不可用)。
|
||||
// 若直接 `m[key] < 阈值`,-1 会恒真 ⇒ 无数据也误报建议(本卡 t_c70f385c 事故面)。
|
||||
// deprecated_per_day 是计数(恒 ≥0,非比率),不走 ratioFor。
|
||||
func AnalyticsSuggestions(m map[string]float64) []string {
|
||||
suggestions := []string{}
|
||||
if hr, ok := ratioFor(m, "recall_usefulness_rate"); ok && hr < 0.5 {
|
||||
if dl, ok2 := ratioFor(m, "avg_distill_loss"); ok2 && dl > 0.3 {
|
||||
suggestions = append(suggestions, "蒸馏质量低导致召回率低 — 建议检查 Embedding 模型")
|
||||
}
|
||||
}
|
||||
if m["deprecated_per_day"] > 10 {
|
||||
suggestions = append(suggestions, "废弃率过高 — 正在流失大量记忆,检查冲突检测阈值")
|
||||
}
|
||||
if g, ok := ratioFor(m, "gap_closure_rate"); ok && g < 0.3 {
|
||||
suggestions = append(suggestions, "缺口闭环率过低 — Agent 未回应缺口学习请求")
|
||||
}
|
||||
if a, ok := ratioFor(m, "auto_resolve_rate"); ok && a < 0.5 {
|
||||
suggestions = append(suggestions, "自动裁决率低 — 规则覆盖不全,考虑扩展策略")
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// AnalyticsSuggestions N/A 哨兵护栏 —— 反例 + 正向对照。
|
||||
//
|
||||
// 本卡 t_c70f385c:AnalyticsHandler 的建议 builder 原为裸 `m[key] < 阈值`,
|
||||
// -1 哨兵(样本不足/越界被拦)会让 `-1 < 0.5` 恒真 ⇒ 无数据也误报建议。
|
||||
// 现统一走 ratioFor(已封装 N/A + valid=0 + 样本不足判定)。
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAnalyticsSuggestions_NA_NoFiring 反例:全 N/A 哨兵(-1)不产生任何建议(避免误报)。
|
||||
func TestAnalyticsSuggestions_NA_NoFiring(t *testing.T) {
|
||||
na := map[string]float64{
|
||||
"recall_usefulness_rate": -1, "recall_usefulness_samples": 0, "recall_usefulness_valid": 0,
|
||||
"avg_distill_loss": -1, "avg_distill_loss_samples": 0, "avg_distill_loss_valid": 0,
|
||||
"gap_closure_rate": -1, "gap_closure_samples": 0, "gap_closure_valid": 0,
|
||||
"auto_resolve_rate": -1, "auto_resolve_samples": 0, "auto_resolve_valid": 0,
|
||||
"deprecated_per_day": 0,
|
||||
}
|
||||
if s := AnalyticsSuggestions(na); len(s) != 0 {
|
||||
t.Fatalf("全 N/A 不应产生任何建议,实际 %d 条: %v", len(s), s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyticsSuggestions_ValidLow 正向对照:有效且低于阈值时产生建议(护栏没把功能焊死)。
|
||||
func TestAnalyticsSuggestions_ValidLow(t *testing.T) {
|
||||
m := map[string]float64{
|
||||
"recall_usefulness_rate": 0.2, "recall_usefulness_samples": 100, "recall_usefulness_valid": 1,
|
||||
"gap_closure_rate": 0.1, "gap_closure_samples": 100, "gap_closure_valid": 1,
|
||||
"auto_resolve_rate": 0.2, "auto_resolve_samples": 100, "auto_resolve_valid": 1,
|
||||
"avg_distill_loss": 0.1, "avg_distill_loss_samples": 100, "avg_distill_loss_valid": 1,
|
||||
"deprecated_per_day": 0,
|
||||
}
|
||||
s := AnalyticsSuggestions(m)
|
||||
// 至少触发「gap 闭环率过低」+「自动裁决率低」两条
|
||||
if len(s) < 2 {
|
||||
t.Fatalf("有效且低于阈值应产生 ≥2 条建议,实际 %d 条: %v", len(s), s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/selfoptimize"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// TestAutoTuneGuard_SentinelAndSamples 双向验证护栏:
|
||||
// ① 无数据(-1 哨兵)不许改参数 —— 这是事故面:gap_closure_rate=-1 会让
|
||||
//
|
||||
// 「降低 gap 阈值」每轮恒真。
|
||||
//
|
||||
// ② 样本不足(<30)不许改参数 —— 少样本比率噪声极大。
|
||||
// ③ 数据健康时仍然要能调 —— 否则等于把功能焊死。
|
||||
func TestAutoTuneGuard_SentinelAndSamples(t *testing.T) {
|
||||
// ① 哨兵值:全 -1 ⇒ 一个事件都不许有
|
||||
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
na := map[string]float64{
|
||||
"gap_closure_rate": -1,
|
||||
"gap_closure_samples": 0,
|
||||
"gap_closure_valid": 0,
|
||||
"recall_usefulness_rate": -1,
|
||||
"avg_distill_loss": -1,
|
||||
"recall_usefulness_valid": 0,
|
||||
"avg_distill_loss_valid": 0,
|
||||
}
|
||||
if ev := tc.AutoTune(na); len(ev) != 0 {
|
||||
t.Fatalf("哨兵值(-1)不应触发任何调参,实际触发 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
if tc.GapThreshold != 3 || tc.Diversity != 0.5 {
|
||||
t.Fatalf("参数被改动:GapThreshold=%d Diversity=%v", tc.GapThreshold, tc.Diversity)
|
||||
}
|
||||
if len(tc.LastSkipped()) == 0 {
|
||||
t.Fatal("应记录跳过原因(否则无法区分『健康』与『数据不可用』)")
|
||||
}
|
||||
|
||||
// ② 样本不足:比率本身达标(>0.9)但只有 9 个样本 ⇒ 不许动
|
||||
tc2 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
low := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 9,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
if ev := tc2.AutoTune(low); len(ev) != 0 {
|
||||
t.Fatalf("样本 9<30 不应触发调参,实际 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
|
||||
// ③ 数据健康 ⇒ 必须能调(证明护栏没把功能焊死)
|
||||
tc3 := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
good := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 100,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
ev := tc3.AutoTune(good)
|
||||
if len(ev) != 1 {
|
||||
t.Fatalf("健康数据应触发 1 条调参,实际 %d 条", len(ev))
|
||||
}
|
||||
if tc3.Diversity >= 0.5 {
|
||||
t.Fatalf("Diversity 应被降低,实际 %v", tc3.Diversity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoTuneCooldown 冷却:同一参数在冷却窗口内不许被重复推进(治棘轮)。
|
||||
// 事故面(实测):AutoTune 不幂等 —— 连跑两次 decay 0.015→0.018→0.0216。
|
||||
func TestAutoTuneCooldown(t *testing.T) {
|
||||
tc := &TuningConfig{Diversity: 0.5, DecayRate: 0.015, GapThreshold: 3, DistillInterval: 5, ConsolidateAfter: 50}
|
||||
m := map[string]float64{
|
||||
"recall_usefulness_rate": 0.95,
|
||||
"recall_usefulness_samples": 100,
|
||||
"recall_usefulness_valid": 1,
|
||||
}
|
||||
if ev := tc.AutoTune(m); len(ev) != 1 {
|
||||
t.Fatalf("第一次应调 1 条,实际 %d", len(ev))
|
||||
}
|
||||
first := tc.Diversity
|
||||
if ev := tc.AutoTune(m); len(ev) != 0 {
|
||||
t.Fatalf("冷却窗口内不应再调,实际 %d 条: %+v", len(ev), ev)
|
||||
}
|
||||
if tc.Diversity != first {
|
||||
t.Fatalf("冷却期内参数被改动:%v → %v(棘轮复发)", first, tc.Diversity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoTunePushesToConsumers 推送:改完参数必须真的到消费方手里,
|
||||
// 否则就是「调参改了没人读的数字」(2026-10-14 实测的原状)。
|
||||
func TestAutoTunePushesToConsumers(t *testing.T) {
|
||||
orig := storage.DefaultMMRDiversity()
|
||||
defer storage.SetDefaultMMRDiversity(orig)
|
||||
tc := &TuningConfig{Diversity: 0.5, GapThreshold: 3}
|
||||
m := map[string]float64{
|
||||
"gap_closure_rate": 0.1,
|
||||
"gap_closure_samples": 100,
|
||||
"gap_closure_valid": 1,
|
||||
}
|
||||
before := selfoptimize.GapThreshold()
|
||||
ev := tc.AutoTune(m)
|
||||
if len(ev) != 1 {
|
||||
t.Fatalf("应触发 1 条(gap 阈值),实际 %d", len(ev))
|
||||
}
|
||||
if tc.GapThreshold != before-1 {
|
||||
t.Fatalf("期望阈值 %d→%d,实际 %d", before, before-1, tc.GapThreshold)
|
||||
}
|
||||
if got := selfoptimize.GapThreshold(); got != tc.GapThreshold {
|
||||
t.Fatalf("没推到消费方:selfoptimize.GapThreshold()=%d,Tuner=%d", got, tc.GapThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTuningPersistence 持久化:换实例后能从盘上读回(否则重启即回默认)。
|
||||
func TestTuningPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("ZHIYI_TUNING_STATE", dir+"/tuning-state.json")
|
||||
|
||||
a := &TuningConfig{Diversity: 0.42, GapThreshold: 4, persistEnabled: true, lastMoveAt: map[string]time.Time{}}
|
||||
a.persist()
|
||||
|
||||
b := &TuningConfig{Diversity: 0.3, GapThreshold: 3, lastMoveAt: map[string]time.Time{}}
|
||||
b.load()
|
||||
if b.Diversity != 0.42 || b.GapThreshold != 4 {
|
||||
t.Fatalf("未读回持久化参数:diversity=%v gap=%v", b.Diversity, b.GapThreshold)
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@ var upgrader = websocket.Upgrader{
|
||||
|
||||
// WSManager 管理所有 WebSocket 连接,支持按 AgentID 定向推送
|
||||
type WSManager struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]map[*websocket.Conn]bool // agentID → 连接集合
|
||||
connMu sync.Map // per-conn mutex: *websocket.Conn → *sync.Mutex
|
||||
mu sync.RWMutex
|
||||
clients map[string]map[*websocket.Conn]bool // agentID → 连接集合
|
||||
connMu sync.Map // per-conn mutex: *websocket.Conn → *sync.Mutex
|
||||
}
|
||||
|
||||
// 全局 WebSocket 管理器
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
// 织忆 MemoryWeave — Commit 冲突门控端到端回归 (2026-09-18 t_a54aae79 #14)
|
||||
//
|
||||
// 锁定 commit → 冲突检测 → AutoDistillTrigger 门控行为:
|
||||
//
|
||||
// A. 真矛盾(否定差异+高重叠) 且无 resolve_conflict → 仍跳过蒸馏(保护不松)
|
||||
// B. 真矛盾 + resolve_conflict=true → 触发蒸馏(覆盖开关真存在)
|
||||
// C. 单字"别"误报句对(旧逻辑 0.42>0.3 误杀) → 新逻辑放行, 触发蒸馏
|
||||
// D. 无矛盾 → 触发蒸馏(基线)
|
||||
package routes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xiaoxue/memoryweave/internal/distill"
|
||||
"github.com/xiaoxue/memoryweave/internal/governance"
|
||||
"github.com/xiaoxue/memoryweave/internal/models"
|
||||
"github.com/xiaoxue/memoryweave/internal/storage"
|
||||
)
|
||||
|
||||
// ─── mock: storage.LanceDB ───────────────────────────────────
|
||||
|
||||
type mockLance struct {
|
||||
memories []models.MemoryRecord
|
||||
}
|
||||
|
||||
// Scan mock:返回按 namespace 过滤后的记忆(测试列表/导出路径用)。
|
||||
func (m *mockLance) Scan(limit int, ns string) ([]models.MemoryRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
var out []models.MemoryRecord
|
||||
for _, r := range m.memories {
|
||||
if ns != "" && r.Namespace != ns {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mockLance) Search(table string, vector []float32, topK int, ns string) ([]models.MemoryRecord, error) {
|
||||
if table != "memories" {
|
||||
return nil, nil
|
||||
}
|
||||
if topK < len(m.memories) {
|
||||
return m.memories[:topK], nil
|
||||
}
|
||||
return m.memories, nil
|
||||
}
|
||||
func (m *mockLance) InsertEpisode(agentID, namespace, content, category string) (string, error) {
|
||||
return fmt.Sprintf("ep_test_%d", time.Now().UnixNano()), nil
|
||||
}
|
||||
func (m *mockLance) GetTopByQuality(agentID string, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockLance) Stats() (map[string]interface{}, error) { return map[string]interface{}{}, nil }
|
||||
func (m *mockLance) SoftDelete(id, reason string) error { return nil }
|
||||
func (m *mockLance) PurgeInvalid() (int64, error) { return 0, nil }
|
||||
func (m *mockLance) SoftDeleteBatch(table string, ids []string, updatedAt string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (m *mockLance) GetVersionHistory(id string) ([]map[string]interface{}, error) { return nil, nil }
|
||||
func (m *mockLance) GetCandidatesForForgetting() ([]map[string]interface{}, error) { return nil, nil }
|
||||
func (m *mockLance) GetSkillCandidates(minRecalls, limit int) ([]models.MemoryRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockLance) Backup(path string) error { return nil }
|
||||
func (m *mockLance) GetAuditLog(limit int) ([]map[string]interface{}, error) { return nil, nil }
|
||||
func (m *mockLance) IncrementUseful(id string) error { return nil }
|
||||
func (m *mockLance) IncrementNotUseful(id string) error { return nil }
|
||||
|
||||
// GA-P0-1 (t_718d8707):拆分字段
|
||||
func (m *mockLance) IncrementHumanNotUseful(id string) error { return nil }
|
||||
func (m *mockLance) AppendHitEvent(memoryID, outcome, reason string) error { return nil }
|
||||
func (m *mockLance) CountHitEvents(memoryID, outcome string, since time.Time) (int, error) { return 0, nil }
|
||||
func (m *mockLance) GetHitEvents(memoryID string, limit int) ([]models.HitEvent, error) { return nil, nil }
|
||||
func (m *mockLance) IncrementDedupDownweight(id string) error { return nil }
|
||||
func (m *mockLance) UpdateMemoryContent(id, newContent, source string) error { return nil }
|
||||
func (m *mockLance) InsertMemory(rec models.MemoryRecord) error { return nil }
|
||||
func (m *mockLance) Insert(table string, record any) error { return nil }
|
||||
func (m *mockLance) Update(table, id string, fields map[string]any) error { return nil }
|
||||
|
||||
// ─── mock: governance.GraphStore (全 no-op) ──────────────────
|
||||
|
||||
type mockGraph struct{}
|
||||
|
||||
func (mockGraph) AddNode(id, name, nodeType, namespace string) error { return nil }
|
||||
func (mockGraph) AddEdge(id, source, target, relation, namespace string, weight float64) error {
|
||||
return nil
|
||||
}
|
||||
func (mockGraph) Navigate(entity string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (mockGraph) NavigateBiDir(source, target string, maxHops int, namespace string, relationFilter []string) ([]map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (mockGraph) Query(entity, relation, namespace string) []map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
func (mockGraph) AllEdges(namespace string) []governance.GraphEdge { return nil }
|
||||
func (mockGraph) SearchNodes(label string, namespace string) []map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
func (mockGraph) ListNodesByType(nodeType string, namespace string) []map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
func (mockGraph) ListNodes(namespace string) []map[string]interface{} { return nil }
|
||||
func (mockGraph) Stats() (int, int, float64) { return 0, 0, 0 }
|
||||
func (mockGraph) Prune(minWeight float64) {}
|
||||
func (mockGraph) ExpandFromResults(results []models.RecallResult, namespace string, maxHops int) []models.RecallResult {
|
||||
return results
|
||||
}
|
||||
func (mockGraph) ExpandWithSummary(results []models.RecallResult, namespace string, maxHops int) models.GraphBFSResult {
|
||||
return models.GraphBFSResult{}
|
||||
}
|
||||
func (mockGraph) PageRank(damping float64, iterations int) map[string]float64 { return nil }
|
||||
func (mockGraph) EvidenceCount(entity string) int { return 0 }
|
||||
func (mockGraph) GetEntityDegree(entity string) int { return 0 }
|
||||
func (mockGraph) GetGraph(namespace string, limit int) ([]map[string]interface{}, []map[string]interface{}) {
|
||||
return nil, nil
|
||||
}
|
||||
func (mockGraph) CleanupNoiseNodes(dryRun bool) (int, []string, error) { return 0, nil, nil }
|
||||
func (mockGraph) FallbackTextSearch(query, namespace string, limit int) []map[string]interface{} {
|
||||
return nil
|
||||
}
|
||||
func (mockGraph) AddEdgeFeedback(edgeID string, helpful bool) error { return nil }
|
||||
func (mockGraph) IncrementEdgeRetrieval(edgeID string) error { return nil }
|
||||
func (mockGraph) UpdateEdgeTrustScores() error { return nil }
|
||||
|
||||
// ─── mock: embedding 服务 (OpenAI 兼容, 每文本一个确定性独热向量) ────
|
||||
|
||||
func newMockEmbedServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var reqBody struct {
|
||||
Input []string `json:"input"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&reqBody)
|
||||
data := make([]map[string]any, 0, len(reqBody.Input))
|
||||
for _, text := range reqBody.Input {
|
||||
v := make([]float64, 1024)
|
||||
// 确定性散列: 每个 rune 决定一个维度
|
||||
sum := 0
|
||||
for i, r := range text {
|
||||
dim := (int(r)*31 + i*7) % 1024
|
||||
v[dim] += 1
|
||||
sum += int(r)
|
||||
}
|
||||
// 保证不同文本向量不同 (至少一个维度不重合的概率极高; 追加 sum 维度兜底)
|
||||
v[sum%1024] += 2
|
||||
data = append(data, map[string]any{"embedding": v})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": data})
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── mock: LLM 服务 (记录调用次数, 返回合法 5D JSON) ──────────────
|
||||
|
||||
type mockLLM struct {
|
||||
server *httptest.Server
|
||||
calls int32
|
||||
}
|
||||
|
||||
func newMockLLM() *mockLLM {
|
||||
m := &mockLLM{}
|
||||
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// 2026-09-22 修:槽位探测(probeLLMSlots,GET /props)不是"蒸馏 LLM 调用",
|
||||
// 不能计入 calls —— 否则 A/B 批次断言(期望 base+1)会被 flush 前后的
|
||||
// 两次探测撑成 base+3。根因:5e2cc63 引入并发槽位探测后,本 mock 未按 path 区分。
|
||||
if r.URL.Path == "/props" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"total_slots": 1})
|
||||
return
|
||||
}
|
||||
atomic.AddInt32(&m.calls, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{{
|
||||
"message": map[string]any{
|
||||
"content": `{"facts":["测试事实:mock LLM 已收到蒸馏请求并返回一条合法事实。"],"entities":["测试"],"is":0.9,"su":0.8,"pa":0.8,"vd":0.8,"ru":0.8}`,
|
||||
},
|
||||
}},
|
||||
})
|
||||
}))
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockLLM) count() int { return int(atomic.LoadInt32(&m.calls)) }
|
||||
|
||||
// ─── 测试主体 ─────────────────────────────────────────────────
|
||||
|
||||
func commitWith(t *testing.T, api *API, content string, resolve bool) map[string]interface{} {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"agent_id": "test-agent",
|
||||
"namespace": "shared",
|
||||
"category": "general",
|
||||
"content": content,
|
||||
"resolve_conflict": resolve,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/commit", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
api.Commit(w, req)
|
||||
if w.Code != 201 {
|
||||
t.Fatalf("commit expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
func waitLLMCalls(t *testing.T, m *mockLLM, min int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if m.count() >= min {
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("expected >= %d LLM distill calls, got %d (AutoDistillTrigger 未触发?)", min, m.count())
|
||||
}
|
||||
|
||||
func TestCommitConflictGate_E2E(t *testing.T) {
|
||||
emb := newMockEmbedServer()
|
||||
defer emb.Close()
|
||||
llm := newMockLLM()
|
||||
defer llm.server.Close()
|
||||
|
||||
// 已有记忆 E: "用户昨天买了苹果"
|
||||
ldb := &mockLance{memories: []models.MemoryRecord{
|
||||
{ID: "mem_e", Content: "用户昨天买了苹果", Category: "distilled"},
|
||||
}}
|
||||
api := NewAPI(ldb, storage.NewEmbedder(emb.URL), nil, governance.NewConflictDetector(), mockGraph{})
|
||||
|
||||
oldRef := DistillEngineRef
|
||||
DistillEngineRef = distill.NewEngine(llm.server.URL, "mock-model", "")
|
||||
defer func() { DistillEngineRef = oldRef }()
|
||||
|
||||
// A. 真矛盾 (没买 vs 买了, 重叠 0.875) 无 flag → 跳过蒸馏
|
||||
base := llm.count()
|
||||
resp := commitWith(t, api, "用户昨天没买苹果", false)
|
||||
if resp["distill_skipped"] != "conflict" {
|
||||
t.Errorf("A: expected distill_skipped=conflict, got %v (resp=%s)", resp["distill_skipped"], resp)
|
||||
}
|
||||
time.Sleep(1500 * time.Millisecond) // 给(误)触发的蒸馏留机会, 应没调用
|
||||
if llm.count() != base {
|
||||
t.Errorf("A: contradiction without flag should NOT trigger distill, LLM calls %d→%d", base, llm.count())
|
||||
}
|
||||
|
||||
// B. 同句 + resolve_conflict=true → 触发蒸馏
|
||||
// (内容需 ≥10 rune 才过蒸馏硬规则 HardRulesPass; 用 10 字版, 与 E 重叠 8/10=0.8)
|
||||
resp = commitWith(t, api, "用户昨天没有购买苹果", true)
|
||||
if _, skipped := resp["distill_skipped"]; skipped {
|
||||
t.Errorf("B: resolve_conflict=true must not skip, resp=%v", resp)
|
||||
}
|
||||
waitLLMCalls(t, llm, base+1)
|
||||
time.Sleep(2 * time.Second) // 同一 flush 批次内多 item 会连续调用 LLM; 等批次排空
|
||||
if got := llm.count(); got != base+1 {
|
||||
t.Errorf("A/B: flush 批次应恰含 B 一条(A 被矛盾门拦下), 实际 LLM 调用 %d 次", got)
|
||||
}
|
||||
|
||||
// C. 单字"别"误报句对 (旧逻辑: negdiff(别)+0.42>0.3 → 误杀; 新逻辑: 放行)
|
||||
// 分支断言: 不 skip 即走了 Enqueue 路径 (可达 LLM 由 B 证明, 同一 Enqueue→flush 通道)
|
||||
resp = commitWith(t, api, "用户昨天买了苹果和梨,别的事情先放一放", false)
|
||||
if _, skipped := resp["distill_skipped"]; skipped {
|
||||
t.Errorf("C: single-char 别 false positive must NOT skip, resp=%v", resp)
|
||||
}
|
||||
|
||||
// D. 无矛盾基线 → 不 skip
|
||||
resp = commitWith(t, api, "nginx 反向代理端口是 80", false)
|
||||
if _, skipped := resp["distill_skipped"]; skipped {
|
||||
t.Errorf("D: no-conflict baseline must NOT skip, resp=%v", resp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package routes
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClassifyNamespaceForMuchen(t *testing.T) {
|
||||
for _, c := range []string{"牧尘在招商局工作", "牧尘不在招商局工作", "画像规则: 牧尘在招商局工作"} {
|
||||
got := classifyNamespace("profiles", "a06", c)
|
||||
t.Logf("content=%q → namespace=%q", c, got)
|
||||
}
|
||||
}
|
||||
+930
-178
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user