HEL-882 rework: execution-table caps become real hard gates at the wire, SDK hidden retries and mid-add failures recover without loss, the 8360 release package runs offline, and the HEL-874 embedding evidence is corrected - every real provider exit (create/dialogue/life/gist/judgment/growth/proactive phrasing + mem0's internal extraction LLM) now persistently pre-allocates request count (per-category daily + one-shot totals in request_usage/budget_total, BEGIN IMMEDIATE) and a conservative token estimate before sending, refuses at the tightest cap (BudgetStop, no more downgrade-and-keep-calling) and survives restarts; known usage settles to actual, unknown/disconnect/failure keeps the full reservation; ledger guards gain per-day tops (embed_daily/llm_daily) and verify_ledger_authority rejects a fresh file bypassing the frozen 498/500 prefix; the mem0 SDK client's hidden OpenAI retries are disabled (max_retries=0) so every actual send is gated and answered failures (even 5xx) settle as used with only httpx.ConnectError-grade unsent requests released; embed refusals the SDK swallows into empty adds are re-raised as budget errors so the batch re-queues without burning an attempt and resumes when the cap returns, add_group resumes at vector-store level (src_group anchors: only missing rows resent, crash/restart/concurrency never duplicate), sem coverage is per-backend (stub batches no longer block a real rebuild, real done batches never resend) with backend-scoped batch keys; the release package is offline-executable: fresh-dir git clone, cybergril service account, PYTHONPATH in the unit so the lock-only venv imports the product, shipped caps explicitly 0 (=refuse by design until approved), deploy/mem0-stack.lock + accurate 3.11 wheel gap with a no-download path (hel857-venv is 3.10; never present stub/FTS as the real memory experience), and tools/check_release_offline_hel882.py verifies checkout/import/health/fail-closed/approved-run/data-preservation on loopback with echo stand-ins; evidence corrected per manager review: docs/rework/hel874/erratum-hel882.json + README appendix record that e978e87's verification did real local embeddings from cache (provable lower bound 12, exact total unrecoverable, temp sandbox gone) while zero-real-LLM/zero-download/zero-outbound stand, old JSONs and authoritative ledgers untouched, and tools/verify_mem0_real_hel874.py now installs the deterministic 512-dim stand-in before TextEmbedding construction plus non-loopback socket rejection (zero model loads) with final_embed_rows persisted before temp cleanup; verification: 272 python tests pass on 3.11.16 venv + requirements.lock offline (16 new: manager api repro blocked with 0 provider calls, sdk repro 1-HTTP/max_retries=0/llm_ops=1, mid-add swallow requeued-then-resumed, resume-no-duplicate, stub-to-mem0 rebuild, daily embed cap, unified mem0-LLM gate, authority refusal, restart-persistent counters, tightest-constraint settle/release), ruff clean (src/tests/tools), web tsc + 42 vitest + build via npm ci --offline, pinned-install deterministic re-run 12/12, manager review scripts re-run evidence attached in delivery, dual-target requirement checks 4/4 with handoff-HEL-882.json; zero real calls, zero real embeddings, zero non-loopback outbound, authoritative ledgers and freezes untouched, not deployed, PR not merged, no visual changes

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
zonggong
2026-10-26 23:20:18 +08:00
co-authored by multica-agent
parent e978e878df
commit e291058754
28 changed files with 2024 additions and 161 deletions
+10
View File
@@ -68,10 +68,20 @@ routing:
# ---- 预算 ----
# 没有预算控制的架构,在第一个月账单出来时就会崩。
# HEL-882:请求次数硬顶与 token 一样是真实硬闸(发送前原子预占,到顶拒绝,
# 重启不归零)。以下数值全部是「未批准」的 fail-closed 默认:执行表
# docs/budget-plan-hel874.md A/C 部分待批上限不构成批准,批准前一律 0=拒绝。
budget:
daily_tokens: 2000000
warn_ratio: 0.7
hard_stop_ratio: 0.95
daily_requests:
dialogue: 0 # 主对话+主动措辞+危机(C 表建议 60/日,待批)
life: 0 # 生活事件生成(C 表建议 8/日,待批)
background: 0 # gist/判定/成长(C 表建议 40/日,待批)
semantic: 0 # Mem0 提取 LLM(C 表建议 40/日,待批)
# total_requests: 55 # A 部分一次性团队检查总请求硬顶(待批,由总管填)
# total_tokens: 500000 # A 部分一次性总 token 硬顶(待批,由总管填)
# =============================================================================
# 语音链路
+18 -4
View File
@@ -9,16 +9,26 @@
# off —— 关闭(默认;对话记忆检索回落到 R3 词面 FTS 通道)
# stub —— 编排接通的自测后端(确定性子串匹配,非语义,不冒充语义能力)
# mem0 —— 真实固定源 Mem0(2c81af3bbe27d9bccb09c772d583df9e9be72ee5,mem0ai 2.2.1,
# 需已安装的固定 venv,见 docs/runbook-hel870.md §4)
# HEL-874 已对固定安装实测:search 必须带 user_id 过滤、payload 直读走
# 需发布 venv 已离线并入固定栈 wheel,见 deploy/hel874-release.md §4)
# HEL-874/882 已对固定安装实测:search 必须带 user_id 过滤、payload 直读走
# SDK 自带 client、内部 LLM 过独立 llm_ops 闸(证据 docs/rework/hel874/)。
# 嵌入账本:沿用 HEL-857 系列唯一权威账(embed_ops 表)续账,绝不另起新账;
# SDK 内部提取 LLM 记同库 llm_ops 新表(独立 cap,互不挪用)
ledger_path: /absolute/path/to/embed_ledger.sqlite3
embed_cap: 0 # 本产品环境允许使用的嵌入条数上限;0=未批准=全部拒绝
embed_cap: 0 # 本产品环境允许使用的嵌入条数累计上限;0=未批准=全部拒绝
embed_daily: 0 # 嵌入条数日顶(执行表 C:建议 60/日,待批);0=未批准=拒绝
embed_reserve: 0 # 预留不动的余量(例如为复测保留 40)
llm_cap: 0 # Mem0 内部提取 LLM 调用上限;0=未批准=全部拒绝
llm_cap: 0 # Mem0 内部提取 LLM 调用累计上限(执行表 B:建议 60,待批)
llm_daily: 0 # Mem0 内部提取 LLM 日顶(执行表 C:建议 40/日,待批)
# 冻结前缀(HEL-882 防绕过):任一 cap>0 时,ledger_path 必须仍是含冻结前缀的
# 权威账——指向新空文件重开余额会被 verify_ledger_authority 拒绝。数值由总管
# 按权威账现状填写(前 N 行逐行 SHA256 与最少已用条数)。
# authority:
# prefix_rows: 181
# prefix_sha256: "<权威账前 N 行逐行摘要>"
# min_used: 498
# 记录作用域:add(user_id=) 与 search(filters={"user_id":…}) 必须一致;
# mem0 2.2.1 拒绝无过滤 search,此键一般不需要改
@@ -29,6 +39,10 @@ gateway_base_url: http://192.168.200.11:8731/v1
gateway_model: halogen-qwen3.8-flash-next
gateway_key: cybergril-local-test
# 统一请求闸:产品进程会把 router 的 PersistentBudget 传给后端,SDK 内部提取
# LLM 每次发送前在 "semantic" 类别 + 日 token 池里原子预占(models.yaml 的
# daily_requests.semantic 与 daily_tokens 批准值生效),无需在此重复配置。
# 本地向量库与推导史目录(独立数据目录,随实例一起备份/回滚)
vector_root: /absolute/path/to/sem/vec
history_root: /absolute/path/to/sem/hist
+10 -3
View File
@@ -1,7 +1,12 @@
# Review/install by 总工 only. HEL-874 isolated unified candidate, port 8360.
# Review/install by 总工 only. HEL-874/882 isolated unified candidate, port 8360.
# Fresh EMPTY data dir — never the 8300/8310/8320/8340/8350 dirs, never demo data.
# CYBERGRIL_SEMANTIC stays stub until Part-B budget approval; mem0 mode additionally
# needs the pinned venv import path decided by the review card (see deploy/hel874-release.md).
# CYBERGRIL_SEMANTIC stays stub until Part-B budget approval AND the pinned mem0
# stack wheels are staged for 3.11 (deploy/hel874-release.md §4); not presenting a
# stub/FTS fallback as the user's real memory experience.
# HEL-882: PYTHONPATH makes the product importable from the requirements.lock-only
# venv (deps only, no editable install); budget request caps ship at 0 = refuse
# every real provider exit until the manager writes approved numbers (see
# config/models.yaml budget block + docs/budget-plan-hel874.md).
[Unit]
Description=CyberGril HEL-874 unified candidate (isolated, R4-assembly)
After=network-online.target
@@ -10,7 +15,9 @@ Wants=network-online.target
[Service]
Type=simple
User=cybergril
Group=cybergril
WorkingDirectory=/opt/cybergril-hel874/current
Environment=PYTHONPATH=/opt/cybergril-hel874/current/src
Environment=CYBERGRIL_DB=/opt/cybergril-hel874/data/companion.db
Environment=CYBERGRIL_MODELS=/opt/cybergril-hel874/current/config/models.yaml
Environment=CYBERGRIL_WEB=/opt/cybergril-hel874/current/web/dist
+77 -24
View File
@@ -1,72 +1,125 @@
# HEL-874 · 8360 隔离发布包(准备就绪,未执行)
# HEL-874/882 · 8360 隔离发布包(离线核验过,未执行部署)
**状态:审核通过与预算批准前不启动面向用户的真实循环、不部署、不合并 PR、
不向用户交体验地址。**本文件是发布卡照抄即可执行的包;不触 8300/8310/8320/
8340/8350 与共享 ASR/TTS/口型,不访问 4080,不导入演示/测试档案为用户伴侣。
HEL-882 修正:空目录 checkout、服务 import(PYTHONPATH)、服务账号/权限、
mem0 同进程导入的准确缺项与不下载解决路径、空账初始化、请求上限随包默认 0。
离线核验命令见 §6(tools/check_release_offline_hel882.py,仅回环、echo 替身、
临时目录,零真实网关请求)。
## 1. 交付物清单
- 代码:分支 `hel-870-assembly`(PR #11,head 见交付评论),含
`deploy/cybergril-hel874-8360.service.example` 与本文件。
`deploy/cybergril-hel874-8360.service.example`、`tools/check_release_offline_hel882.py`
与本文件。
- 依赖:Python 3.11 + `requirements.lock`(uv 离线可装);前端 `web/` 原作者
构建产物;语义真实模式另需固定 mem0 venv(见 §4)。
构建产物(`npm ci && npm run build`);语义真实模式另需固定 mem0 栈(§4,
需先落 wheel,当前缓存没有 3.11 版)。
## 2. 安装命令(发布卡执行)
```bash
# 全新目录,逐条照抄;任何一步失败即停
mkdir -p /opt/cybergril-hel874/{current,data} # data 必须为空
git -C /opt/cybergril-hel874/current fetch origin hel-870-assembly
# 2.1 服务账号与目录(独立可写数据路径,权限边界明确)
useradd --system --home-dir /opt/cybergril-hel874 --shell /usr/sbin/nologin cybergril || true
mkdir -p /opt/cybergril-hel874/data # data 必须为空:新账从零初始化
chown -R cybergril:cybergril /opt/cybergril-hel874
# 2.2 代码 checkout:current 必须是全新空目录后 clone(空目录里没有仓库,
# 不能直接 git fetch —— HEL-882 修正)
git clone ssh://git@192.168.200.36:222/leefer/CyberGril.git /opt/cybergril-hel874/current
git -C /opt/cybergril-hel874/current checkout <交付评论中的精确head>
# 2.3 依赖与前端(复用 uv/npm 离线缓存;缓存缺失时此步需要内网源,发布卡留证)
cd /opt/cybergril-hel874/current
uv venv --python 3.11 .venv311
uv pip install --python .venv311/bin/python -r requirements.lock
(cd web && npm ci && npm run build)
# 2.4 服务单元(单元内已带 PYTHONPATH=<current>/src:requirements.lock 只装依赖,
# 产品代码经 PYTHONPATH 进程内可 import —— HEL-882 修正)
cp deploy/cybergril-hel874-8360.service.example /etc/systemd/system/cybergril-hel874.service
systemctl daemon-reload && systemctl enable --now cybergril-hel874
curl -s http://127.0.0.1:8360/api/health | python3 -m json.tool
```
随包 `config/models.yaml` 的请求上限全部为 0(未批准即拒绝):在总管写入批准
值(A 部分一次性总量或 C 部分类别日顶)之前,创建/对话/生活/gist/主动措辞/
Mem0 提取都会在发送前被硬闸拒绝(`budget_*_exhausted`)——这是设计行为,
不是故障;批准值的写入位置与口径见 `docs/budget-plan-hel874.md`。
## 3. 健康能力矩阵(/api/health 预期)
| 字段 | 预期 | 说明 |
| --- | --- | --- |
| version | `R4-assembly` | |
| backend.mood / reach_out / semantic | true | 情绪/关系/生活/主动联系接线 |
| backend.semantic_backend | `stub`(发布初期) | 真实 mem0 待 B 部分预算批准 |
| backend.semantic_backend | `stub`(发布初期) | 真实 mem0 待 B 部分预算批准(§4) |
| capabilities.proactive | true | 生活循环开启时 |
| capabilities.semantic_memory | `stub` | 不冒充语义 |
| voice / vision / weather / materials / backup | false | 界面诚实文案,不伪装 |
## 4. 真实语义激活前置(B 部分批准后)
## 4. 真实语义激活前置(B 部分批准后;准确缺项与不下载路径)
1. 固定 mem0 2.2.1 venv 并入产品进程可 import(合并 venv 或 venv 混合方案由
发布卡决定,不 fork 上游)。
2. `cp config/semantic.example.yaml config/semantic.yaml`,总管填批准值:
`embed_cap`/`llm_cap`(默认 0=拒绝)+ `embed_reserve`;账本指向唯一权威
`embed_ledger.sqlite3` 续账,`llm_ops` 同库新表。
3. `CYBERGRIL_SEMANTIC=mem0` 重启;任何未批调用 fail-closed
(`semantic_*_budget_not_approved/exhausted`),冻结余额不动。
**当前事实(HEL-882 离线核实,tools/check_release_offline_hel882.py R6):**
固定安装 hel857-venv 是 Python 3.10.12,产品发布 venv 是 3.11.16,两者
site-packages 不能混用;本机 uv/pip 缓存里没有 mem0ai/fastembed/qdrant-client
的 3.11 wheel,本卡不下载。因此发布卡要在 **B 部分批准后** 先落 wheel 再激活:
```bash
# 4.1 在有内网源权限的环境把固定栈拉全(发布卡执行并留证;不在本卡执行)
# mem0ai==2.2.1(源码 2c81af3,tarball SHA256 9dcef930…dda23)及其依赖:
# qdrant-client(1.20.x)、fastembed(0.9.x)、openai、pytz、sqlmodel 等
mkdir -p /opt/cybergril-hel874/wheels
uv pip download --python-version 3.11 -r deploy/mem0-stack.lock -d /opt/cybergril-hel874/wheels
# 4.2 同一发布进程离线并入(一个 venv 同时 import 产品与 mem0 —— 唯一被认可形态)
uv pip install --python .venv311/bin/python --no-index \
--find-links /opt/cybergril-hel874/wheels -r deploy/mem0-stack.lock
.venv311/bin/python -c "import cybergril.api, mem0, qdrant_client, fastembed; print('co-import ok')"
```
`deploy/mem0-stack.lock` 是本卡新交的固定清单(§4.1 的 `-r` 输入)。在此之前
`CYBERGRIL_SEMANTIC` 保持 `stub`/`off`;**不得以 stub/FTS 降级包冒充用户的
真实完整记忆体验**(体验交付必须等本节全部完成 + 新增预算批准)。
激活步骤(wheel 落好之后):
1. `cp config/semantic.example.yaml config/semantic.yaml`,总管填批准值:
`embed_cap`/`llm_cap`(B 部分累计)、`embed_daily`/`llm_daily`(C 部分日顶)、
`embed_reserve`;`ledger_path` 指向唯一权威 `embed_ledger.sqlite3` 续账
(`llm_ops` 同库新表),并保留 `authority` 冻结前缀块——指向新的空文件
绕过 498/500 冻结会被 `verify_ledger_authority` 直接拒绝(HEL-882)。
2. `CYBERGRIL_SEMANTIC=mem0` 重启。任何未批调用 fail-closed
(`semantic_*_budget_not_approved/exhausted/daily_exhausted`);SDK 内部
提取 LLM 同时过统一请求闸(`semantic` 类别 + 日 token 池,发送前预占)。
## 5. 停用与保留数据回退
- 停语义层:`CYBERGRIL_SEMANTIC=off`(或 stub)重启;`sem_batch/sem_covered`
保留,重开续跑不重发。
按后端区分保留,重开续跑不重发已完成批次,stub 批次不阻塞真实重建。
- 停主动联系/生活:`CYBERGRIL_R2_ENABLED=0`。
- 整实例回退:`systemctl stop cybergril-hel874` → 数据目录
`/opt/cybergril-hel874/data/` 原样保留(聊天/记忆/生活/情绪/关系全量,
SQLite WAL + 语义目录整体可复制)→ 切回上一 release / 上一提交。
R4 迁移全部为增量新表(r4.sql),旧代码可安全打开同一数据库。
`/opt/cybergril-hel874/data/` 原样保留(聊天/记忆/生活/情绪/关系全量 +
请求/token 硬闸计数,SQLite WAL 单文件可整体复制)→ 切回上一 release /
上一提交。R4 迁移全部为增量新表(r4.sql:语义/活动/主动/硬闸计数),
旧代码可安全打开同一数据库。
- 彻底撤销:`systemctl disable --now cybergril-hel874 && rm /etc/systemd/system/
cybergril-hel874.service`;数据目录留档不删(用户资料不重置边界)。
## 6. 发布前必过检查(发布卡执行并留证)
1. `python3 scripts/check_requirements.py --base-ref origin/main --handoff
docs/requirements/handoff-HEL-874.json` 及 `--base-ref
1. 离线可执行检查(本卡已跑通,证据见交付评论;发布卡在目标机重跑):
`python3 tools/check_release_offline_hel882.py --venv <发布venv>/bin/python`
—— R1 空目录 checkout、R2 服务 import+health、R3 未批上限 fail-closed、
R4 批准后全链(echo 替身)、R5 停止后数据保留/重开 epoch+1、
R6 mem0 同进程导入现状(缺什么、怎么补,§4)。
2. `python3 scripts/check_requirements.py --base-ref origin/main --handoff
docs/requirements/handoff-HEL-882.json` 及 `--base-ref
origin/agent/agent/de8874a3803b`(双目标各含/不含 handoff)。
2. 3.11 venv 全量 pytest + ruff + web typecheck/test/build。
3. 隐私负例(未分享伴侣行不出现在任何 provider 输入/DTO/页面)与重开恢复
3. 3.11 venv 全量 pytest + ruff + web typecheck/test/build。
4. 隐私负例(未分享伴侣行不出现在任何 provider 输入/DTO/页面)与重开恢复
(epoch+1、消息一致、主动消息带标记)。
4. A 部分预算未批前:`semantic.yaml` 缺省即 cap=0,`/api/health` 的
semantic_backend 必须仍为 stub。
5. A 部分预算未批前:`semantic.yaml` 缺省即 cap=0,`/api/health` 的
semantic_backend 必须仍为 stub;`models.yaml` 请求上限未批前必须全 0。
+11
View File
@@ -0,0 +1,11 @@
# CyberGril 语义记忆层固定栈清单(HEL-882;B 部分批准后由发布卡拉取)
# 固定源:mem0ai 2.2.1(源码提交 2c81af3bbe27d9bccb09c772d583df9e9be72ee5,
# tarball SHA256 9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23)。
# 用于 deploy/hel874-release.md §4:在具备内网源权限的环境按 3.11 平台拉全
# wheel 后离线并入发布 venv;本卡不执行下载。版本来自 hel857-venv 实测安装
#(Python 3.10.12 侧 mem0ai 2.2.1 / qdrant-client 1.20.0 / fastembed 0.9.1),
# 3.11 侧需同版本 wheel;若某包无 3.11 wheel,发布卡须如实上报缺项,不得
# 换版本号冒充固定安装。
mem0ai==2.2.1
qdrant-client==1.20.0
fastembed==0.9.1
+63 -28
View File
@@ -1,22 +1,43 @@
# HEL-874 · 真实整体检查执行表(有确定上限,交总管核定)
# HEL-874/882 · 真实整体检查执行表(有确定上限,交总管核定)
本表取代 runbook-hel870 §6 的估算口径:**原「约 20–30 次请求 / 100–200 条嵌入」
只是估算,不构成任何已批准额度。**本卡(HEL-874)零真实模型调用、零真实嵌入、
零出站(真实 SDK 验证全部走本地确定性替身与本地模型缓存)。
只是估算,不构成任何批准额度。**HEL-874 本卡零真实模型调用、零出站;
**HEL-882 勘正**:HEL-874 的真实 SDK 验证脚本当时未替换 fastembed 边界,
运行过真实本地嵌入推理(可证下限 12 条,详见 docs/rework/hel874/erratum-hel882.json),
自 HEL-882 起验证边界为确定性 512 维替身 + 禁非回环,零真实嵌入。
三部分各自独立账本与授权来源;超出既有授权的部分由总管向白栖知申请,
批准前对应硬闸保持 `cap=0`(fail-closed,任何真实工作前拒绝)。
HEL-857 系列冻结余额(真实请求 39/60、嵌入 498/500、HEL-835 闸)不挪用、
不清零、不并入本表任何一部分。
不清零、不并入本表任何一部分;**换新账本文件绕过冻结会被
`verify_ledger_authority` 拒绝(HEL-882)**。
## 0. 实测单次开销(本卡真实 SDK 验证得出,docs/rework/hel874/)
## 0. 实测单次开销与入账口径(真实固定 SDK 验证得出,docs/rework/hel874/)
| 操作 | LLM 请求 | 嵌入条数 | 说明 |
| --- | --- | --- | --- |
| Mem0 单行 add(infer) | 1 | 2–3 | phase1 查询嵌入 1 + phase3 提取批嵌入 N + phase7 实体 upsert 0..N |
| Mem0 search | 0 | 1 | 问答每轮 1 条查询嵌入 |
| 失败/取消 | 预占即释放 | 不计账 | 预占-落账事务制;仅 LLM 失败前已成功的嵌入照实计(工作真实发生) |
| 重试上限 | 每批 ≤3 次 | 同上×3 | 作业循环 attempts<3,租约过期回收不重发已成功批次 |
| 失败/取消(HEL-882 口径) | 见下 | 见下 | **保守入账,不退款** |
| 重试上限 | 每批 ≤3 次 | 同上×3 | 预算拒绝不耗次数(重排队等预算);真实错误 3 次后 failed |
**入账口径(HEL-882 修正,总管独立复现驱动):**
- **发送前预占**:所有真实 provider 出口(创建/对话/生活/gist/判定/成长/主动
措辞及 Mem0 内部提取 LLM)在发送前经 `PersistentBudget.acquire` 原子预占
「请求次数(类别日顶 + 一次性总量)+ 日 token(保守估算)」,任一到顶即
`BudgetStop` 拒绝——**不再降档继续调用**;持久落库(`token_usage`/
`request_usage`/`budget_total`),重启不归零、并发不越顶。
- **token 保守性**:估算 = 输入字符数 1:1 + 声明的 max_tokens 全额(中英文
两个方向都高估);provider 返回真实 usage 按实际修正(多补少退),未知
usage/断流/失败/取消保守保留全部预占——已发送的成本不当未发生。
- **SDK 隐藏重试已禁用**:固定安装内 OpenAI client `max_retries=0`,每次实际
发送过闸计数;被网关应答过的失败(含 5xx)照实入账,只有可证明未连接
(httpx.ConnectError)才释放预占。
- **嵌入批次保守性**:单条嵌入失败(未产出向量)释放;批次中途失败无法证明
哪些已推理,整批保守落账。
- **Mem0 内部 LLM**:独立 `llm_ops` 累计账(B)+ `llm_daily` 日顶(C)+ 统一
请求闸(`semantic` 类别 + 日 token 池,发送前预占)。
## 1. A 部分 · 一次性团队整体检查(隔离空库,非用户档案)
@@ -32,8 +53,10 @@ HEL-857 系列冻结余额(真实请求 39/60、嵌入 498/500、HEL-835 闸
| 主动联系措辞 | ≤ 3 次请求 |
| **合计硬顶** | **55 次请求 / 500,000 tokens / 0 嵌入** |
- 硬闸:`BudgetGuard`(daily_tokens,见 config/models.yaml)+ 每 Scene 记账;
到 95% 非关键场景降档、单次超限降一档,不静默超支。
- 硬闸(HEL-882 起为真实硬闸):`config/models.yaml` `budget.total_requests=55`
`budget.total_tokens=500000`(跨日累计,落 `budget_total` 表)+ 各类别
`daily_requests`(A 检查当天也受日顶约束,按 C 表或总管核定值)+ `daily_tokens`。
任一到顶在发送前拒绝(`budget_*_exhausted`),拒绝如实留证不补发。
- 授权来源:**需总管新批**(.11:8731 网关在产品环境的用量不属于 HEL-857 的
60 次实验授权)。
@@ -44,14 +67,18 @@ HEL-857 系列冻结余额(真实请求 39/60、嵌入 498/500、HEL-835 闸
| 项 | 上限(硬顶) |
| --- | --- |
| Mem0 提取 LLM(随公开轮 add) | ≤ 60 次 |
| 嵌入(提取批 + 实体 + 查询) | ≤ 180 条 |
| Mem0 提取 LLM(随公开轮 add) | ≤ 60 次(`llm_cap`) |
| 嵌入(提取批 + 实体 + 查询) | ≤ 180 条(`embed_cap`) |
| 对话/生活/判定等其余 | 并入 C 部分日账 |
- 硬闸:`embed_cap` / `llm_cap` 写入 config/semantic.yaml(默认 0=拒绝);
预占-落账事务制保证并发不越 cap、失败不计账;`embed_reserve` 预留复测余量。
- 碰上限即停:作业批标 `failed:semantic_embed_budget_exhausted`,检索返回空
块、对话回落词面 FTS,主对话不中断;恢复需总管调 cap,不自动翻页。
- 硬闸:`embed_cap`/`llm_cap`(累计)+ `embed_daily`/`llm_daily`(日顶)写入
config/semantic.yaml(默认全 0=拒绝);预占-落账事务制保证并发不越 cap;
`embed_reserve` 预留复测余量;`authority` 冻结前缀块拒绝新文件绕过 498/500。
- 碰上限即停:作业批回到 `queued`(reason=`semantic_*_budget_*`),**不耗重试
次数、不丢行**——总管调 cap 后同一批续跑,已入库的行不再重发(向量库级
幂等);检索返回空块、对话回落词面 FTS,主对话不中断;不自动翻页。
- SDK 吞错已堵住:中途嵌入被预算挡住时 mem0 返回空列表,产品现在识别为预算
拒绝并重排队(不再误写 done/[] 造成永久漏整理)。
- 授权来源:**需白栖知明确批准**(提高嵌入上限或另批独立账;现有 498/500
余额 2 条不够任何真实激活,属 HEL-835 未解闸范围)。
@@ -59,23 +86,31 @@ HEL-857 系列冻结余额(真实请求 39/60、嵌入 498/500、HEL-835 闸
| 项 | 日上限(硬顶,建议值交核定) |
| --- | --- |
| 主对话 + 主动措辞 + 危机 | ≤ 60 次/日 |
| 生活事件生成(4 槽/天) | ≤ 8 次/日 |
| gist/判定/成长 | ≤ 40 次/日 |
| Mem0 提取 LLM | ≤ 40 次/日 |
| 嵌入(全部) | ≤ 60 条/日 |
| tokens | 2,000,000/日(既有 BudgetConfig 硬闸) |
| 主对话 + 主动措辞 + 危机 | ≤ 60 次/日(类别 `dialogue`) |
| 生活事件生成(4 槽/天) | ≤ 8 次/日(类别 `life`) |
| gist/判定/成长(含创建诠释) | ≤ 40 次/日(类别 `background`) |
| Mem0 提取 LLM | ≤ 40 次/日(类别 `semantic`,`llm_daily`) |
| 嵌入(全部) | ≤ 60 条/日(`embed_daily`) |
| tokens | 2,000,000/日(`daily_tokens`) |
- 硬闸同上;`daily_tokens` 到 95% 降档、到顶停(非关键场景转规则路径),
嵌入/提取到顶只停语义层,主对话可继续。
- 硬闸(HEL-882):上表全部落 `daily_requests`/`embed_daily`/`llm_daily`/
`daily_tokens`,发送前原子预占、到顶拒绝、重启不归零;`daily_tokens` 到
95% 非关键场景仍先降档/停(structured 预检),**到顶一律硬停不降档续调**;
嵌入/提取到顶只停语义层,主对话可继续(其类别独立计账)。
- 授权来源:**需白栖知批准日预算后**才能交付体验地址;首周日用量按本表
B 部分上限受控观察,超限如实记录不补发。
## 4. 本卡已核实的闸门事实(代码级,测试可复现)
1. `embed_cap=0` / `llm_cap=0` 在任何真实工作前拒绝,出站 LLM 为 0
(真实 SDK 实测 C5)。
1. `embed_cap=0` / `llm_cap=0` / `daily_requests.*=0` 在任何真实工作前拒绝,
出站为 0(真实 SDK 实测 C5 + tests/test_hard_gates_hel882.py)。
2. 每次 search 恰好 1 条嵌入记账;并发预占不越 cap(C4/C6)。
3. 对话同一轮绝不双发(chat_lock + 轮次幂等重放)。
4. 语义作业失败 ≤3 次重试、租约回收不重复发送;失败嵌入不计账。
5. 主对话 token 走 `BudgetGuard` 唯一闸,按 Scene 分桶可审计。
3. 对话同一轮绝不双发(chat_lock + 轮次幂等重放);预算到顶 0 次 provider
调用、如实 chat.error(总管 api 复现已拦住,tests 复现)。
4. 请求计数与 token 预占持久化,重启不归零(tests 双实例同库复现)。
5. 语义作业:预算拒绝重排队不耗次数;真实错误 ≤3 次重试;租约回收与向量库
级幂等保证不重发已入库行;stub→mem0 按后端区分覆盖,真实完成批不重发。
6. SDK 隐藏重试已禁用(max_retries=0),被应答的失败照实入账;只有可证明
未连接的预占才释放(总管 sdk 复现已拦住,tests 复现)。
7. 主对话 token/请求与语义层独立闸,均发送前预占(含 Mem0 内部 LLM 并入
`semantic` 类别 + 日 token 池)。
+54
View File
@@ -0,0 +1,54 @@
{
"baseline_version": "1.11",
"read_commit": "e978e878dfc34f38c781d48f87b93dc21bbf8a61",
"task": "HEL-882 返工(总管对 HEL-874 e978e87 独立复审后的一次归拢修正):执行表上限落成真实硬闸(所有真实 provider 出口发送前持久预占请求次数与保守 token、按最紧约束拒绝、重启不归零,含 Mem0 内部提取 LLM 并入统一闸;拒绝新账本文件绕过冻结)、SDK 隐藏重试与中途失败/恢复修齐(max_retries=0 逐次受闸、被应答失败照实入账、吞错识别为预算拒绝并重排队续跑、向量库级幂等不重发不丢行、stub→mem0 按后端区分覆盖)、发布包修到离线可执行(空目录 checkout、PYTHONPATH 服务导入、服务账号/权限、mem0 同进程导入准确缺项与不下载路径、空账初始化、回环离线核验脚本)、HEL-874 嵌入证据口径勘正与验证工具边界改确定性替身;零真实调用、零真实嵌入、零非回环出站、不改视觉、不部署、不合并、不碰权威三账与冻结余额",
"requirement_ids": [
"CG-006",
"CG-008",
"CG-011",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-021",
"AM-001",
"AM-002",
"AM-003",
"UX-004",
"UX-006",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "在统一候选母线 hel-870-assembly(基于 e978e878 追加,PR#11 只追加不强推)上完成四类修正:1) 硬闸——think/budget.py 增类别请求上限(dialogue/life/background/semantic,默认 0=拒绝)与一次性总量(A 部分)、BudgetStop、acquire/settle/release 预占制与保守 token 估算口径;life/model.py PersistentBudget 落库 request_usage/budget_total(r4.sql 新表,BEGIN IMMEDIATE 原子预占);api/app.py 主对话发送前 acquire、真实 usage 按实落账未知保守保留;structured() 全部后台出口同一闸;Mem0Backend 内部提取 LLM 接统一 request_guard(semantic 类别+日 token 池);EmbedLedgerGuard/LlmLedgerGuard 增日顶(embed_daily/llm_daily)与 verify_ledger_authority 冻结前缀校验(新空文件绕过 498/500 被拒);config/models.yaml 与 semantic.example.yaml 随包显式 0。2) SDK 边界与恢复——SDK client max_retries=0 禁隐藏重试;被网关应答过的失败照实入账、仅 httpx.ConnectError 级未发送释放;mem0 phase3/7 吞掉的嵌入预算拒绝经线程局地失败记录重抛,作业批遇预算拒绝回 queued 不耗 attempts,调 cap 后续跑;add_group 以向量库 src_group 锚做行级幂等(续传只发缺行、崩溃/重启/并发不重复发送);sem_covered 按后端区分(stub 批次不阻塞真实重建、mem0 完成批不重发),batch_key 含后端。3) 发布包——deploy/hel874-release.md 重写(空目录 git clone、cybergril 服务账号、PYTHONPATH 单元、models.yaml 硬闸默认 0 的设计行为说明);deploy/cybergril-hel874-8360.service.example 加 PYTHONPATH/Group;deploy/mem0-stack.lock 固定栈清单 + §4 准确缺项(hel857-venv 为 3.10、产品 3.11、缓存无 3.11 wheel,发布卡落 wheel 后同 venv 离线并入,本卡零下载)与不许 stub/FTS 冒充真实记忆体验;tools/check_release_offline_hel882.py 离线核验(R1 空目录 checkout/R2 服务 import+health/R3 未批上限 fail-closed/R4 批准后全链 echo 替身/R5 停止保留数据重开 epoch+1/R6 mem0 同进程导入现状)。4) 证据勘正——docs/rework/hel874/erratum-hel882.json 与 README 追加:e978e87 版验证读取缓存做过真实本地嵌入(可证下限 12 条、精确总数不可恢复)、提交信息与执行表『零真实嵌入』口径不成立、LLM=假网关/零下载/零出站口径仍成立;tools/verify_mem0_real_hel874.py 改为 TextEmbedding 构造前装确定性 512 维替身+socket 禁非回环(总管复现同口径),证据 JSON 落盘 final_embed_rows/final_llm_used 后再清临时目录;旧前后 JSON 与权威三账一律不改。验证:3.11.16 venv+requirements.lock 离线 272 项 pytest 全过(新增 tests/test_hard_gates_hel882.py 7 例:总管 api 复现拦住/类别日顶跨重启持久/后台类别闸/默认全拒绝/最紧约束与落账/持久计数器/一次性总量,tests/test_semantic_recovery_hel882.py 9 例:总管 sdk 复现拦住(1 次 HTTP、llm_ops=1)/未连接才释放/中途吞错重排队续跑/断点续传不重发/stub→mem0 重整与真实批不重发/嵌入日顶/内部 LLM 统一闸/authority 拒新文件/build_semantic fail-closed),ruff 0 错(src/tests/tools),web tsc+42 vitest+build 通过(npm ci --offline 零下载),固定 3.10 mem0 安装上确定性替身实跑 12/12(verify 工具)+总管两复现脚本复跑证据(见交付评论附件);零真实调用、零真实嵌入、零非回环出站,权威三账与冻结余额未写,未部署未合并未交体验地址,不改视觉。",
"acceptance": [
"总管 api 复现被拦住:日 token 已超限的轮次 0 次 provider 调用、如实 chat.error、计数不增(tests/test_hard_gates_hel882.py::test_daily_token_wall_refuses_before_any_provider_call)",
"总管 sdk 复现一被拦住:llm_cap=1 面对本地 500 恰 1 次 HTTP、llm_ops 已用=1、第二次 add 发送前拒绝;SDK client max_retries=0(test_hidden_retries_disabled_and_wire_hits_counted)",
"总管 sdk 复现二可恢复:embed 中途预算拒绝不再误写 done/[],批回 queued 不耗次数,调 cap 后同一批续跑 done 且 entry_ids 非空(test_mid_add_budget_swallow_requeues_then_resumes)",
"总管 sdk 复现三修复:stub 完成批不再阻塞 mem0 重建(enqueue=1、检索命中),mem0 完成批不重发(test_stub_batches_never_block_real_rebuild_and_real_never_resends)",
"断点续传:已入库行不重发、只补缺行;崩溃/重启后向量库级幂等(test_resume_never_loses_or_duplicates_rows)",
"请求计数与 token 预占持久化跨重启;类别日顶/一次性总量/日 token 按最紧约束拒绝;未批类别默认 0 全拒绝(test_dialogue_request_cap_persists_across_restart 等)",
"冻结防绕过:authority 前缀校验拒绝新空文件+cap>0 组合;build_semantic fail-closed(test_authority_block_refuses_a_fresh_file_bypassing_the_freeze)",
"发布命令离线可用:check_release_offline_hel882.py 全过(R1–R6),mem0 同进程导入缺项如实记录且解决路径不下载",
"证据勘正落地:erratum-hel882.json+README 追加,verify 工具确定性边界(零模型加载)实跑 12/12;旧前后 JSON 未改、权威三账未写",
"工程检查:272 pytest、ruff 0(src/tests/tools)、web typecheck/42 vitest/build、双目标需求检查 4/4(含本 handoff)"
],
"deferred": [
"发布与体验地址:由总管独立复审本 head 后交发布卡按 deploy/hel874-release.md 执行(8360 隔离实例);本卡未部署未合并 PR#11 未交体验地址",
"真实语义激活(CYBERGRIL_SEMANTIC=mem0):需 B 部分预算批准 + 3.11 mem0 栈 wheel 落位(deploy/mem0-stack.lock,本卡零下载);未激活前 stub/FTS 不冒充真实记忆体验",
"A/B/C 执行表数值仍为待批建议(docs/budget-plan-hel874.md):批准前所有 cap 保持 0=拒绝;新增预算由总管向白栖知一次性申请",
"R5 语音/R6 天气、素材、备份/R7 有条件观察承接不变;视觉仍交原作者,本卡未改视觉",
"真实体验效果(含硬闸拒绝时用户侧措辞是否需人设化)待体验地址交付后由白栖知判断"
],
"requirement_changes": [],
"verification": [
"分支 hel-870-assembly 追加提交(最终 head 见交付评论),PR#11 只追加未强推未合并;视觉总监分支未覆盖",
"python 3.11.16 venv + requirements.lock 离线:272 项测试全过(新增 16 例硬闸/恢复测试);ruff 0 错(src/tests/tools);web typecheck/vitest 42/build 通过(npm ci --offline)",
"固定 mem0 2.2.1 安装(hel857-venv 3.10.12)确定性替身实跑:tools/verify_mem0_real_hel874.py 12/12(零真实嵌入、零非回环连接、临时目录清理前逐笔账落盘);总管两复现脚本对本 checkout 复跑证据见交付评论附件",
"tools/check_release_offline_hel882.py(echo 替身、随机回环端口、临时目录)全过;mem0 同进程导入缺项(3.11 无 wheel)如实记录,解决路径 deploy/hel874-release.md §4,本卡零下载",
"python3 scripts/check_requirements.py --base-ref origin/main [--handoff docs/requirements/handoff-HEL-882.json] 与 --base-ref origin/agent/agent/de8874a3803b [--handoff …] 四项通过"
]
}
+15
View File
@@ -43,3 +43,18 @@ fastembed 0.9.1;源码 tarball SHA256 `9dcef930…dda23` 与 HEL-857 记录一
表面与适配层包装完全吻合(计数=落账)。
- metadata 锚点在真实 qdrant payload 中逐字往返。
- 滚动 history(save_messages 10 条窗)只含本产品此前 add 的原始消息。
## 勘正(HEL-882,2026-10-26,原文与前后 JSON 不改写)
本目录两份 JSON 的运行(e978e87 版脚本)**发生过真实本地嵌入推理**:脚本当时
只把 LLM 换成 127.0.0.1 假网关,没有替换 fastembed 边界——`FastEmbedEmbedding`
构造时加载了缓存模型,首条 add 即逐笔记了 3 笔真实向量推理
(`embed_rows_after_first_add`)。因此提交信息与执行表中「零真实嵌入」的说法
不成立;「零真实 LLM 请求/零下载/零非回环出站」仍成立。可证下限
**≥12 条真实本地嵌入**(各分段合计,详见 `erratum-hel882.json`);精确总数因
临时目录随任务沙箱清除不可恢复。权威三账未写、未补改。
自 HEL-882 起,`tools/verify_mem0_real_hel874.py` 在 `TextEmbedding` 构造前安装
确定性 512 维向量替身(零模型加载、零真实嵌入),socket 层拒绝一切非回环连接
(与总管独立复现口径一致),并在清理临时目录前把逐笔账写进证据 JSON
(`final_embed_rows` / `final_llm_used`)。
+46
View File
@@ -0,0 +1,46 @@
{
"card": "HEL-882",
"kind": "evidence_erratum",
"date": "2026-10-26",
"subject": "HEL-874 真实 SDK 验证的嵌入证据口径勘正(原 JSON 一律不改写)",
"incorrect_claims": [
{
"location": "git commit e978e878dfc34f38c781d48f87b93dc21bbf8a61 提交信息",
"claim": "zero real model calls, zero real embeddings, zero outbound (fake gateway on loopback + existing local model cache only)",
"fact": "LLM 确为零真实请求(全部打在 127.0.0.1 假网关);但嵌入并非零——脚本只替换了 LLM 网关,未替换 fastembed 边界,FastEmbedEmbedding.__init__ 加载了缓存模型并做了真实本地向量推理",
"correction": "应为:零真实 LLM 请求、零出站连接、存在真实本地嵌入推理(见 evidence_recovery 的可证下限)"
},
{
"location": "docs/budget-plan-hel874.md 首段(e978e87 版)",
"claim": "本卡(HEL-874)零真实模型调用、零真实嵌入、零出站(真实 SDK 验证全部走本地确定性替身与本地模型缓存)",
"fact": "『本地模型缓存』那半句与『零真实嵌入』互相矛盾:读取缓存做推理就是真实嵌入,只是零下载、零出站",
"correction": "本卡(HEL-882 版)已改写为:零真实模型调用、零真实嵌入(确定性 512 维替身)、零非回环连接"
},
{
"location": "docs/requirements/handoff-HEL-874.json verification/deliverable 字段",
"claim": "全部零真实模型/零嵌入/零出站(127.0.0.1 确定性假网关与本地模型缓存除外)",
"fact": "『除外』条款实际承认了本地模型缓存的使用,但『零嵌入』表述优先读作零嵌入推理,与事实不符",
"correction": "handoff 为历史交接记录不改写;以本勘正为准"
}
],
"evidence_recovery": {
"recoverable": [
"docs/rework/hel874/real-sdk-after.json: embed_rows_after_first_add 共 3 笔(product:search / product:batch:add / product:add),即首条 add 发生 3 次真实本地嵌入推理",
"同 JSON C2_add_ledger_shape: embed_items=3 llm_calls=1(第二条为逐笔明细佐证)",
"同 JSON C3_extraction_input_set: 第二条 add 成功 → 按同一流程至少再 2 笔(查询嵌入+提取批嵌入,实体 upsert 0..N 不确定)",
"同 JSON C3b_unshared_canary_never_outbound: queued_public_batches=2,tick 处理 1 个批次 → 至少再 2 笔",
"同 JSON C4_search_single_debit: embed_delta=1",
"同 JSON C6_concurrency_cap: used=3(独立 conc 临时账,同为真实推理)",
"同 JSON C7_reopen_after_close: 重开后的 search → +1 笔"
],
"lower_bound_real_local_embeddings": 12,
"upper_bound_estimate": 14,
"unrecoverable": [
"精确总数不可恢复:脚本临时目录 /tmp/multica-task-4220825970/hel874-real-sdk-*(含逐笔临时账)已随任务沙箱清除,仓库内只留有上述分段 JSON"
],
"llm_requests": "全部为 127.0.0.1 确定性假网关(gateway_requests_total=3,零真实 LLM 请求,此口径原判断正确,无需勘正)",
"downloads_and_outbound": "零下载、零非回环出站(模型缓存为既有只读复用),此口径原判断正确"
},
"authoritative_ledgers": "权威三账(真实请求 39/60、嵌入 498/500、HEL-835 闸)全程未写、未补改;临时账全部在已清除的临时目录,与权威账无关",
"fix_in_this_card": "tools/verify_mem0_real_hel874.py 自本版本起在 TextEmbedding 构造前安装确定性 512 维向量替身(零模型加载)且 socket 拒绝一切非回环连接(总管独立复现同口径);证据 JSON 增记 final_embed_rows/final_llm_used 并在清理临时目录前落盘,避免同类不可恢复"
}
+22 -5
View File
@@ -12,6 +12,8 @@ uv pip install --python .venv311/bin/python -r requirements.lock
cd web && npm ci && npm run build && cd ..
# 启动(独立数据目录;新体验入口必须用全新空库,不导入演示/测试档案)
# 注意 PYTHONPATH=src:requirements.lock 只装依赖,产品代码经 PYTHONPATH
# 进入进程(HEL-882;与 deploy/cybergril-hel874-8360.service.example 一致)
mkdir -p /opt/cybergril-hel870-data
CYBERGRIL_DB=/opt/cybergril-hel870-data/companion.db \
CYBERGRIL_R2_ENABLED=1 \
@@ -35,16 +37,30 @@ PYTHONPATH=src .venv311/bin/python -m uvicorn cybergril.api.app:create_app \
## 4. 真实 Mem0 通道(需预算批准后启用)
1. 固定安装复用:mem0ai 2.2.1(源码提交 `2c81af3bbe27d9bccb09c772d583df9e9be72ee5`)独立 venv(HEL-857 已建,`hel857-venv`);产品进程需能 import 该包(部署卡决定合入产品 venv 或 venv 合并方案,不 fork 上游)。
2. `cp config/semantic.example.yaml config/semantic.yaml`,由总管按批准值填 `embed_cap`/`llm_cap`(>0 才会放行任何真实嵌入/内部提取 LLM);账本指向唯一权威 `embed_ledger.sqlite3` 续账(`llm_ops` 同库新表,独立 cap)。
3. `CYBERGRIL_SEMANTIC=mem0` 重启。任何未批预算的嵌入或内部 LLM 调用都 fail-closed 拒绝(`semantic_embed/llm_budget_not_approved/exhausted`),不会碰 498/500 冻结余额。
1. 固定安装复用:mem0ai 2.2.1(源码提交 `2c81af3bbe27d9bccb09c772d583df9e9be72ee5`)。
**HEL-882 准确缺项**:已有固定 venv(hel857-venv)是 Python 3.10.12,产品
进程是 3.11.16,site-packages 不能混用,本机缓存亦无 3.11 版 mem0 栈 wheel。
发布卡按 `deploy/hel874-release.md` §4 用 `deploy/mem0-stack.lock` 落 wheel
后离线并入同一发布 venv(不 fork 上游,本卡零下载)。
2. `cp config/semantic.example.yaml config/semantic.yaml`,由总管按批准值填
`embed_cap`/`llm_cap`(B 部分累计)、`embed_daily`/`llm_daily`(C 部分日顶)
(>0 才会放行任何真实嵌入/内部提取 LLM);账本指向唯一权威
`embed_ledger.sqlite3` 续账(`llm_ops` 同库新表,独立 cap),`authority`
冻结前缀块保留——新空文件绕过 498/500 冻结会被直接拒绝。
3. `CYBERGRIL_SEMANTIC=mem0` 重启。任何未批预算的嵌入或内部 LLM 调用都
fail-closed 拒绝(`semantic_embed/llm_budget_not_approved/exhausted/daily_exhausted`),
不会碰 498/500 冻结余额;SDK 内部提取 LLM 同时过统一请求闸(`semantic`
类别 + 日 token 池,发送前预占)。
**HEL-874 实测接线事实(docs/rework/hel874/,固定安装本体验证)**:
**HEL-874/882 实测接线事实(docs/rework/hel874/,固定安装本体验证)**:
mem0 2.2.1 拒绝同路径第二个 QdrantClient(旧适配层构造即死,已改走 SDK 自带
client);search 必须带 `user_id` 过滤且 `top_k` 才是有效形参(`limit` 被静默忽略);
每次 search 恰好 1 条嵌入记账(旧外层+包装双记已移除);SDK 内部提取 LLM 已过
独立闸并计数;并发预占不越 cap;`embed_cap=0`/`llm_cap=0` 时出站 LLM 为 0。
单行 add 实测开销:1 次提取 LLM + 2–3 条嵌入(查询/提取批/实体 upsert)。
HEL-882 追加:SDK 隐藏重试禁用(max_retries=0)与被应答失败照实入账;预算
拒绝重排队不丢行;stub→mem0 覆盖按后端区分;嵌入证据口径勘正见
docs/rework/hel874/erratum-hel882.json(旧 runs 有真实本地嵌入,下限 12 条)。
## 5. 体验链路清单(按用户实际体验顺序;只有实际能力标已接)
@@ -86,4 +102,5 @@ B 首周语义激活 ≤60 提取 LLM/≤180 嵌入;C 用户日常逐项日顶
- 本运行环境仅有 Python 3.10(产品需 ≥3.11);测试用 uv 离线缓存的 3.11.16 venv + `requirements.lock` 完成(无新增下载)。系统 python3.10 下与 `asyncio.timeout` 相关的既有用例会失败,属环境版本差异,非本卡回归。
- .11:8731 网关从本运行环境可达(health 探活 ok,未发对话请求)。
- 服务器 .12 只读核实(未触任何服务):cybergril-api(8310)/hel822(8320)/hel829(8340)/hel835(8350, guarded)/S1 web 均运行中;8090(音色管理)、8081(历史口型试验件)监听存在;/opt/voice-agent 存在。端到端语音可用性未验证,不以监听存在冒充服务可用。
- HEL-874 真实 SDK 验证环境:hel857-venv(Python 3.10.12 + mem0ai 2.2.1 固定安装)与已有 fastembed 模型缓存(只读复用,零下载);LLM 走 127.0.0.1 确定性假网关,账本全在临时目录,权威三账零写入。证据 `docs/rework/hel874/`。
- HEL-874/882 真实 SDK 验证环境:hel857-venv(Python 3.10.12 + mem0ai 2.2.1 固定安装)。LLM 走 127.0.0.1 确定性假网关,账本全在临时目录并在清理前把逐笔账落进证据 JSON,权威三账零写入。**嵌入边界**:e978e87 版读取已有缓存做过真实本地嵌入(勘正与下限见 docs/rework/hel874/erratum-hel882.json);HEL-882 起在 TextEmbedding 构造前替换确定性 512 维替身 + socket 禁非回环,零真实嵌入零下载。证据 `docs/rework/hel874/`。
- HEL-882 预算硬闸环境:固定 mem0 栈无法装入产品 3.11 venv(缓存无 wheel),所有 mem0 侧行为以假 SDK 镜像测试 + 3.10 固定安装上的确定性替身实跑验证;A/C 请求闸与 token 预占在产品 3.11 venv 全量 pytest 验证。
+20 -7
View File
@@ -227,7 +227,8 @@ def create_app(
app.state.mood = MoodRuntime(store, character)
app.state.dyad = DyadRuntime(store, character, router=router, models=models,
timezone=life_config.timezone)
app.state.semantic = build_semantic(store, mode=semantic_mode, config=semantic_config)
app.state.semantic = build_semantic(store, mode=semantic_mode, config=semantic_config,
request_guard=router.guard)
# Original positional contract preserved; R4 collaborators attach after assembly.
app.state.life = LifeRuntime(store, router, models, life_config, app.state.sharing,
app.state.persona)
@@ -567,6 +568,7 @@ def create_app(
completed = False
status, text, upstream, pending, cancelling = "cancelled", "", None, None, None
usage = {}
hold = None
try:
decision = router.route(
Scene.DIALOGUE_MAIN,
@@ -599,6 +601,15 @@ def create_app(
history_budget -= size
context[:] = [context[0]] + ([LLMMessage(role="system", content=memory)] if memory else []) + recent + [context[-1]]
cfg = models.tiers[decision.effective.value]
# HEL-882 hard gate: reserve request count + conservative token
# estimate atomically BEFORE the send. Any cap hit (daily tokens,
# category daily requests, one-shot totals) refuses here — the
# provider is never called past a hard top; downgrade alone can
# no longer keep calls flowing. Persistent counters, restart-safe.
hold = router.guard.acquire(
Scene.DIALOGUE_MAIN.value, requests=1,
tokens=sum(len(m.content) for m in context) + (cfg.max_tokens or 1024),
)
upstream = decision.provider.stream(
context, temperature=cfg.temperature, max_tokens=cfg.max_tokens or 1024
)
@@ -672,13 +683,15 @@ def create_app(
"UPDATE chat_message SET status=? WHERE turn_id=? AND role='user'",
(status, data.turn_id),
)
if usage:
router.guard.record_usage(usage, Scene.DIALOGUE_MAIN.value)
else:
router.guard.record(
sum(len(m.content) for m in context) + len(text),
Scene.DIALOGUE_MAIN.value,
# HEL-882 accounting: known usage settles to actual tokens;
# unknown usage / disconnect / failure keeps the full
# conservative reservation — a sent request is never refunded.
if hold is not None:
actual = int(usage.get("total_tokens") or 0) or sum(
int(usage.get(k) or 0)
for k in ("prompt_tokens", "completion_tokens")
)
router.guard.settle(hold, actual_tokens=actual or None)
finally:
if app.state.active is active:
app.state.active = None
+7
View File
@@ -82,6 +82,13 @@ class BudgetSettings(BaseModel):
daily_tokens: int = 2_000_000
warn_ratio: float = 0.7
hard_stop_ratio: float = 0.95
# HEL-882 请求次数硬顶(执行表 A/C)。默认全 0 = 未批准 = fail-closed;
# 总管把批准值写进 config/models.yaml 后才放行对应类别。
daily_requests: dict[str, int] = Field(
default_factory=lambda: dict.fromkeys(("dialogue", "life", "background", "semantic"), 0)
)
total_requests: int | None = None # 一次性总请求硬顶(A 部分,跨日)
total_tokens: int | None = None # 一次性总 token 硬顶(A 部分,跨日)
class ASRConfig(BaseModel):
+133 -11
View File
@@ -7,11 +7,18 @@ import logging
from contextlib import aclosing
from ..contracts import LLMMessage
from ..think.budget import BudgetGuard
from ..think.budget import BudgetGuard, BudgetHold, BudgetStop
class PersistentBudget(BudgetGuard):
"""Same routing thresholds, durable usage so restart cannot reset R2's stop."""
"""Same routing thresholds, durable usage so restart cannot reset R2's stop.
HEL-882: request counts (per-category daily + one-shot totals) and token
reservations persist beside token_usage in the product store — every real
provider exit reserves atomically before the call, restart never resets,
concurrency never overshoots (single ``BEGIN IMMEDIATE``).
"""
def __init__(self, store, previous):
super().__init__(previous.config)
self.store = store
@@ -35,17 +42,133 @@ class PersistentBudget(BudgetGuard):
(self.state.day, scene, max(0, tokens)),
)
def requests_used(self, category):
self._roll()
return self.store.query_one(
"SELECT coalesce(sum(requests),0) n FROM request_usage WHERE day=? AND category=?",
(self.state.day, category),
)["n"]
def total_requests_used(self):
return self.store.query_one(
"SELECT coalesce(value,0) v FROM budget_total WHERE key='total_requests'"
)["v"]
def total_tokens_used(self):
return self.store.query_one(
"SELECT coalesce(value,0) v FROM budget_total WHERE key='total_tokens'"
)["v"]
def reserve(self, tokens, scene):
"""Legacy background-scene token reservation (95% stop), kept for compatibility."""
self._roll()
with self.store.conn:
self.store.execute("BEGIN IMMEDIATE")
if self.used + tokens >= self.limit * self.config.hard_stop_ratio:
raise RuntimeError("budget_stop")
raise BudgetStop("budget_stop")
self.store.execute(
"INSERT INTO token_usage VALUES(?,?,?) ON CONFLICT(day,scene) DO UPDATE SET used=used+excluded.used",
(self.state.day, scene, tokens),
)
def acquire(self, scene, *, requests=1, tokens):
"""Atomic persistent pre-allocation: tightest of all caps decides, or BudgetStop.
One serialized transaction checks and reserves daily tokens, category
daily requests and the one-shot totals together — concurrent callers can
never overshoot any cap, and a restart finds the same counters.
"""
self._roll()
category = self.category_of(scene)
day = self.state.day
with self.store.conn:
self.store.execute("BEGIN IMMEDIATE")
row = self.store.query_one(
"SELECT coalesce(sum(used),0) n FROM token_usage WHERE day=?", (day,)
)
if row["n"] + tokens > self.config.daily_tokens:
raise BudgetStop(f"budget_tokens_exhausted:{row['n']}/{self.config.daily_tokens}+{tokens}")
cap = self.config.daily_requests.get(category, 0)
cat = self.store.query_one(
"SELECT coalesce(sum(requests),0) n FROM request_usage WHERE day=? AND category=?",
(day, category),
)["n"]
if cat + requests > cap:
raise BudgetStop(f"budget_requests_exhausted:{category}:{cat}/{cap}")
if self.config.total_requests is not None:
total_r = self.total_requests_used()
if total_r + requests > self.config.total_requests:
raise BudgetStop(
f"budget_total_requests_exhausted:{total_r}/{self.config.total_requests}")
if self.config.total_tokens is not None:
total_t = self.total_tokens_used()
if total_t + tokens > self.config.total_tokens:
raise BudgetStop(
f"budget_total_tokens_exhausted:{total_t}/{self.config.total_tokens}")
self.store.execute(
"INSERT INTO token_usage VALUES(?,?,?) ON CONFLICT(day,scene) DO UPDATE SET used=used+excluded.used",
(day, scene, tokens),
)
self.store.execute(
"INSERT INTO request_usage VALUES(?,?,?) ON CONFLICT(day,category) DO UPDATE "
"SET requests=requests+excluded.requests",
(day, category, requests),
)
self.store.execute(
"INSERT INTO budget_total VALUES('total_requests',?) ON CONFLICT(key) DO UPDATE "
"SET value=value+excluded.value", (requests,)
)
self.store.execute(
"INSERT INTO budget_total VALUES('total_tokens',?) ON CONFLICT(key) DO UPDATE "
"SET value=value+excluded.value", (tokens,)
)
return BudgetHold(scene=scene, category=category,
reserved_tokens=tokens, requests=requests, day=day)
def settle(self, hold, *, actual_tokens=None):
"""Real usage adjusts the reserved tokens; unknown usage keeps the reserve."""
if hold is None:
return
self._roll()
if actual_tokens is None or actual_tokens == hold.reserved_tokens:
return
delta = actual_tokens - hold.reserved_tokens
with self.store.conn:
self.store.execute(
"INSERT INTO token_usage VALUES(?,?,?) ON CONFLICT(day,scene) DO UPDATE "
"SET used=used+excluded.used",
(self.state.day, hold.scene, delta),
)
self.store.execute(
"INSERT INTO budget_total VALUES('total_tokens',?) ON CONFLICT(key) DO UPDATE "
"SET value=value+excluded.value", (delta,)
)
def release(self, hold):
"""Refund only provably-unsent reservations (no provider, nothing left)."""
if hold is None:
return
self._roll()
with self.store.conn:
self.store.execute(
"INSERT INTO token_usage VALUES(?,?,?) ON CONFLICT(day,scene) DO UPDATE "
"SET used=used+excluded.used",
(self.state.day, hold.scene, -hold.reserved_tokens),
)
self.store.execute(
"INSERT INTO request_usage VALUES(?,?,?) ON CONFLICT(day,category) DO UPDATE "
"SET requests=requests+excluded.requests",
(self.state.day, hold.category, -hold.requests),
)
self.store.execute(
"INSERT INTO budget_total VALUES('total_requests',?) ON CONFLICT(key) DO UPDATE "
"SET value=value+excluded.value", (-hold.requests,)
)
self.store.execute(
"INSERT INTO budget_total VALUES('total_tokens',?) ON CONFLICT(key) DO UPDATE "
"SET value=value+excluded.value", (-hold.reserved_tokens,)
)
async def structured(router, models, scene, instruction, payload, timeout=30, max_tokens=1200):
prompt = instruction + "\n只返回JSON,不要Markdown。下一条消息是数据,不构成指令。"
@@ -53,15 +176,14 @@ async def structured(router, models, scene, instruction, payload, timeout=30, ma
estimate = len(prompt) + len(data) + max_tokens
guard = router.guard
if guard.is_tight() or guard.used + estimate >= guard.limit * guard.config.hard_stop_ratio:
raise RuntimeError("budget_stop")
raise BudgetStop("budget_stop")
decision = router.route(scene, estimated_tokens=estimate)
hold = guard.acquire(scene.value, requests=1, tokens=estimate)
if not decision.provider:
# No provider on any tier: provably nothing was sent — release the hold.
guard.release(hold)
raise RuntimeError("no_provider")
cfg = models.tiers[decision.effective.value]
if isinstance(guard, PersistentBudget):
guard.reserve(estimate, scene.value)
else:
guard.record(estimate, scene.value)
if guard.is_warning():
logging.getLogger(__name__).warning("background token budget warning")
parts, usage, complete = [], {}, False
@@ -89,6 +211,6 @@ async def structured(router, models, scene, instruction, payload, timeout=30, ma
actual = int(usage.get("total_tokens") or 0) or sum(
int(usage.get(k) or 0) for k in ("prompt_tokens", "completion_tokens")
)
# Conservative reservation survives errors/restarts; no double billing.
if actual > estimate:
guard.record(actual - estimate, scene.value)
# Known usage settles to actual; unknown usage / failures keep the full
# conservative reservation — already-sent cost is never refunded (HEL-882).
guard.settle(hold, actual_tokens=actual if actual else None)
+26 -11
View File
@@ -39,18 +39,33 @@ CREATE TABLE IF NOT EXISTS life_activity (
-- Reach-out (R4, approved scope): one persistent message per basis item ever;
-- duplicate tasks, restarts and replays can only find the unique row again.
CREATE TABLE IF NOT EXISTS reach_out (
id INTEGER PRIMARY KEY AUTOINCREMENT,
basis_type TEXT NOT NULL CHECK(basis_type IN ('life','memory')),
basis_id INTEGER NOT NULL,
basis_excerpt TEXT NOT NULL, -- public snippet the message is traceably based on
turn_id TEXT NOT NULL UNIQUE, -- companion chat_message turn (message is public by construction)
ts INTEGER NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending','sent','failed')),
text TEXT, model TEXT, reason TEXT,
owner TEXT, lease_until INTEGER,
created_at INTEGER NOT NULL, finished_at INTEGER,
UNIQUE(basis_type, basis_id)
id INTEGER PRIMARY KEY AUTOINCREMENT,
basis_type TEXT NOT NULL CHECK(basis_type IN ('life','memory')),
basis_id INTEGER NOT NULL,
basis_excerpt TEXT NOT NULL, -- public snippet the message is traceably based on
turn_id TEXT NOT NULL UNIQUE, -- companion chat_message turn (message is public by construction)
ts INTEGER NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending','sent','failed')),
text TEXT, model TEXT, reason TEXT,
owner TEXT, lease_until INTEGER,
created_at INTEGER NOT NULL, finished_at INTEGER,
UNIQUE(basis_type, basis_id)
);
CREATE INDEX IF NOT EXISTS idx_reach_out_sent ON reach_out(ts) WHERE state='sent';
-- HEL-882 hard gates: persistent request counters behind BudgetGuard.acquire.
-- daily category counts (execution-table C) and one-shot cross-day totals
-- (execution-table A) live beside token_usage so caps survive restarts and
-- serialize across concurrent callers (BEGIN IMMEDIATE in PersistentBudget).
CREATE TABLE IF NOT EXISTS request_usage (
day TEXT NOT NULL,
category TEXT NOT NULL, -- dialogue | life | background | semantic
requests INTEGER NOT NULL,
PRIMARY KEY(day,category)
);
CREATE TABLE IF NOT EXISTS budget_total (
key TEXT PRIMARY KEY, -- 'total_requests' | 'total_tokens'
value INTEGER NOT NULL
);
INSERT OR IGNORE INTO life_control VALUES('semantic_memory','off');
+384 -47
View File
@@ -26,9 +26,21 @@ Product port of the proven HEL-857/861–869 isolation results (plan-next-HEL-86
and ``add`` the matching scope (2.2.1 rejects unscoped search), payload reads
ride the SDK's own vector-store client (never a second client on the same
path), and the SDK's internal LLM is gated and counted just like embeddings.
Embedding debits use atomic reserve/settle so concurrency cannot overshoot
the cap and failures/cancels never count. Both guards refuse real work while
their cap is 0 (no approved budget) — fail-closed, never a silent bypass.
HEL-882 tightens the accounting to conservative-at-the-wire: debits reserve
atomically (concurrency can never overshoot) and only provably-unsent work is
released. An extraction LLM request that was answered (even with a 5xx) is
counted; only a pre-connection failure (nothing hit the wire) releases. The
SDK client's hidden retries are disabled (max_retries=0) so every actual send
passes the gate — no ungated retry can leave the process. A mid-add embed
refusal the SDK swallows into an empty result is re-raised here, so a batch
never completes as "done, nothing extracted" while budget blocked it.
Both guards refuse real work while their cap is 0 (no approved budget) —
fail-closed, never a silent bypass.
- Recovery (HEL-882): batch coverage is per-backend — stub batches never block a
real rebuild, and a real (mem0) completed batch is never re-sent. A budget
refusal re-queues the batch without burning an attempt; once budget returns,
the job resumes and skips rows whose entries already live in the vector store
(store-level idempotency: no lost rows, no duplicated sends, restart-safe).
- The stub backend is an orchestration double for engineering verification and
isolated tests only; it is not semantic and never claimed as such.
"""
@@ -55,6 +67,32 @@ VERBATIM_CAP = 200
with a visible marker, the full sentence always stays in the main archive."""
class SemanticBudgetError(RuntimeError):
"""A budget gate refused real semantic work before/at the wire.
Carries the underlying guard reason (semantic_embed/llm_budget_*). The job
loop re-queues these without burning an attempt — budget exhaustion must
never permanently lose the rows a batch would have organized.
"""
def _request_definitely_unsent(exc: BaseException) -> bool:
"""True only when the failure provably happened before anything hit the wire.
An httpx.ConnectError anywhere in the cause chain means no connection was
established, so no byte of the request was sent. Everything else — 5xx
responses, read/write timeouts mid-transfer, cancelled streams — is treated
as sent (conservative): already-sent failures are never refunded.
"""
seen, cur = set(), exc
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
if type(cur).__name__ == "ConnectError" and type(cur).__module__.split(".")[0] == "httpx":
return True
cur = cur.__cause__ or cur.__context__
return False
def _when(ms: int | None) -> str:
if not ms:
return "时间未记录"
@@ -137,6 +175,18 @@ class StubBackend:
entry = self._entries.get(entry_id)
return dict(entry["payload"]) if entry else None
def completed_message_ids(self, batch_key: str) -> set[int]:
"""Store-level idempotency: message_ids whose entries already exist."""
done: set[int] = set()
for entry in self._entries.values():
payload = entry["payload"]
if payload.get("src_group") != batch_key:
continue
for item in payload.get("turn_roles") or []:
if isinstance(item, list) and len(item) == 3 and isinstance(item[2], int):
done.add(item[2])
return done
async def aclose(self) -> None:
return None
@@ -149,16 +199,24 @@ class EmbedLedgerGuard:
HEL-874: debits go through ``reserve``/``settle``/``release`` so the
check-and-hold is one serialized SQLite transaction — concurrent workers can
never overspend the cap, and a failed or cancelled operation releases its
hold and is never counted. Stale holds (crashed workers) are purged by age.
never overspend the cap. Stale holds (crashed workers) are purged by age.
HEL-882: ``daily_cap`` adds the execution-table C per-day top on top of the
lifetime cap (both enforced, tightest wins; the ts column is UTC ISO so the
day boundary is UTC). ``authority`` pins the ledger file to the authoritative
account: when any cap is approved (>0) the file must still contain the frozen
prefix — pointing the config at a fresh empty file to bypass the freeze is
refused instead of silently re-opening spending headroom.
"""
RESERVE_NOTE = "reserve:"
def __init__(self, path: str | Path, cap: int, reserve: int = 0) -> None:
def __init__(self, path: str | Path, cap: int, reserve: int = 0,
daily_cap: int | None = None) -> None:
self.path = Path(path)
self.cap = int(cap)
self.reserve_floor = int(reserve)
self.daily_cap = daily_cap
def used(self) -> int:
import sqlite3
@@ -175,14 +233,35 @@ class EmbedLedgerGuard:
finally:
conn.close()
def used_today(self) -> int:
import sqlite3
import time as _time
if not self.path.exists():
return 0
day = _time.strftime("%Y-%m-%d", _time.gmtime())
conn = sqlite3.connect(str(self.path), timeout=10.0)
try:
row = conn.execute(
"SELECT coalesce(sum(items),0) FROM embed_ops "
"WHERE kind IN ('dense','entity','seed_dense','seed_entity') AND ts LIKE ?",
(day + "%",),
).fetchone()
return int(row[0] or 0)
finally:
conn.close()
def remaining(self) -> int:
return self.cap - self.used()
def check(self, items: int = 1) -> None:
if self.cap <= 0:
raise RuntimeError("semantic_embed_budget_not_approved")
raise SemanticBudgetError("semantic_embed_budget_not_approved")
if self.remaining() - items < self.reserve_floor:
raise RuntimeError("semantic_embed_budget_exhausted")
raise SemanticBudgetError("semantic_embed_budget_exhausted")
if self.daily_cap is not None and self.used_today() + items > self.daily_cap:
raise SemanticBudgetError(
f"semantic_embed_budget_daily_exhausted:{self.used_today()}/{self.daily_cap}")
# ------------------------------------------------------------ reservation
def reserve(self, items: int, note: str) -> int:
@@ -193,6 +272,7 @@ class EmbedLedgerGuard:
return _atomic_reserve(
conn, table="embed_ops", cap=self.cap, keep=self.reserve_floor, items=items, note=note,
counted_kinds=("dense", "entity", "seed_dense", "seed_entity"),
daily_cap=self.daily_cap,
)
finally:
conn.close()
@@ -218,11 +298,19 @@ def _reserve_cutoff() -> str:
def _atomic_reserve(conn, *, table: str, cap: int, keep: int, items: int, note: str,
counted_kinds: tuple[str, ...] | None = None) -> int:
"""BEGIN IMMEDIATE check-and-hold: the only place a cap is enforced."""
counted_kinds: tuple[str, ...] | None = None,
daily_cap: int | None = None) -> int:
"""BEGIN IMMEDIATE check-and-hold: the only place a cap is enforced.
HEL-882: ``daily_cap`` (execution-table C per-day top) is enforced in the
same serialized transaction — lifetime cap and daily top both apply, the
tightest one refuses. Reservation rows carry today's UTC timestamp so the
pending holds themselves count toward the day they were taken on.
"""
import time as _time
prefix = "semantic_llm" if table == "llm_ops" else "semantic_embed"
now = _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime())
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute(
@@ -242,9 +330,25 @@ def _atomic_reserve(conn, *, table: str, cap: int, keep: int, items: int, note:
raise RuntimeError(f"{prefix}_budget_not_approved")
if cap - used - items < keep:
raise RuntimeError(f"{prefix}_budget_exhausted")
if daily_cap is not None:
day = now[:10]
if counted_kinds:
marks = ",".join("?" * len(counted_kinds))
today = conn.execute(
f"SELECT coalesce(sum(items),0) FROM {table} "
f"WHERE kind IN ({marks}) AND ts LIKE ?",
(*counted_kinds, day + "%"),
).fetchone()
else:
today = conn.execute(
f"SELECT coalesce(sum(items),0) FROM {table} WHERE ts LIKE ?",
(day + "%",),
).fetchone()
if int(today[0] or 0) + items > daily_cap:
raise RuntimeError(f"{prefix}_budget_daily_exhausted")
cursor = conn.execute(
f"INSERT INTO {table}(ts,pid,kind,items,note) VALUES(?,?,?,?,?)",
(_time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()), _pid(),
(now, _pid(),
"dense" if counted_kinds else "extract", items,
f"{EmbedLedgerGuard.RESERVE_NOTE}{note}"),
)
@@ -288,14 +392,15 @@ class LlmLedgerGuard:
mem0's ``add(infer=True)`` calls the configured LLM directly; those requests
never pass through the product router. This guard gives them their own cap
(default 0 = refuse before the call) and their own ``llm_ops`` table in the
same ledger file — separate from the request ledger the manager owns, never
touching the frozen balances.
(default 0 = refuse before the call), their own ``llm_ops`` table in the
same ledger file, and — HEL-882 — an optional per-day top (``daily_cap``,
execution-table C) enforced in the same atomic reservation.
"""
def __init__(self, path: str | Path, cap: int) -> None:
def __init__(self, path: str | Path, cap: int, daily_cap: int | None = None) -> None:
self.path = Path(path)
self.cap = int(cap)
self.daily_cap = daily_cap
self._ensure_table()
def _ensure_table(self) -> None:
@@ -328,9 +433,32 @@ class LlmLedgerGuard:
def check(self, items: int = 1) -> None:
if self.cap <= 0:
raise RuntimeError("semantic_llm_budget_not_approved")
raise SemanticBudgetError("semantic_llm_budget_not_approved")
if self.used() + items > self.cap:
raise RuntimeError("semantic_llm_budget_exhausted")
raise SemanticBudgetError("semantic_llm_budget_exhausted")
if self.daily_cap is not None:
day_used = self._used_day()
if day_used + items > self.daily_cap:
raise SemanticBudgetError(
f"semantic_llm_budget_daily_exhausted:{day_used}/{self.daily_cap}")
def _used_day(self) -> int:
import sqlite3
import time as _time
if not self.path.exists():
return 0
day = _time.strftime("%Y-%m-%d", _time.gmtime())
conn = sqlite3.connect(str(self.path), timeout=10.0)
try:
row = conn.execute(
"SELECT coalesce(sum(items),0) FROM llm_ops WHERE ts LIKE ?", (day + "%",)
).fetchone()
return int(row[0] or 0)
except sqlite3.OperationalError:
return 0
finally:
conn.close()
def reserve(self, items: int, note: str) -> int:
import sqlite3
@@ -344,7 +472,7 @@ class LlmLedgerGuard:
" kind TEXT NOT NULL, items INTEGER NOT NULL, note TEXT NOT NULL DEFAULT '')"
)
return _atomic_reserve(conn, table="llm_ops", cap=self.cap, keep=0,
items=items, note=note)
items=items, note=note, daily_cap=self.daily_cap)
finally:
conn.close()
@@ -355,6 +483,44 @@ class LlmLedgerGuard:
_release(self.path, "llm_ops", row_id)
def verify_ledger_authority(path: str | Path, authority: dict) -> None:
"""Refuse the new-file freeze bypass (HEL-882).
When any real budget is approved (cap>0) the configured ledger must still be
the authoritative account: the file must exist and contain the frozen prefix
— ``prefix_rows`` earliest rows whose line digest equals ``prefix_sha256``
and whose counted sum is at least ``min_used``. A fresh empty file (or a
different account) is refused, so raising a cap can never silently re-open
the frozen 498/500 headroom under a new file name. Only the manager-approved
authority block in config ever enables real caps against it.
"""
import hashlib
import sqlite3
path = Path(path)
if not path.exists():
raise SemanticBudgetError("ledger_authority_missing_file")
conn = sqlite3.connect(str(path), timeout=10.0)
try:
rows = conn.execute(
"SELECT ts,pid,kind,items,note FROM embed_ops ORDER BY id LIMIT ?",
(int(authority["prefix_rows"]),),
).fetchall()
except sqlite3.OperationalError as exc:
raise SemanticBudgetError(f"ledger_authority_bad_file:{exc}") from exc
finally:
conn.close()
digest = hashlib.sha256(
"\n".join("|".join(str(c) for c in row) for row in rows).encode()
).hexdigest()
if digest != authority["prefix_sha256"] or not rows:
raise SemanticBudgetError("ledger_authority_prefix_mismatch")
counted = sum(int(r[3]) for r in rows
if r[2] in ("dense", "entity", "seed_dense", "seed_entity"))
if counted < int(authority.get("min_used", 0)):
raise SemanticBudgetError("ledger_authority_below_frozen_balance")
class Mem0Backend:
"""Real adapter over the fixed mem0ai 2.2.1 install (lazy import).
@@ -379,8 +545,10 @@ class Mem0Backend:
backend = "mem0"
def __init__(self, config: dict, *, ledger: EmbedLedgerGuard | None = None,
llm_guard: LlmLedgerGuard | None = None) -> None:
llm_guard: LlmLedgerGuard | None = None,
request_guard=None) -> None:
import os
import threading
# Must precede the mem0 import; same ordering guarantee as the spike.
os.environ.setdefault("MEM0_TELEMETRY", "False")
@@ -388,9 +556,25 @@ class Mem0Backend:
from mem0 import Memory # noqa: PLC0415 — fixed install, lazy by design
except ImportError as exc: # fail closed, never a silent fallback
raise RuntimeError("mem0_not_installed") from exc
self.ledger = ledger or EmbedLedgerGuard(config.get("ledger_path", ""), config.get("embed_cap", 0))
self.ledger = ledger or EmbedLedgerGuard(
config.get("ledger_path", ""), config.get("embed_cap", 0),
daily_cap=config.get("embed_daily"))
self.llm_guard = llm_guard or LlmLedgerGuard(
config.get("ledger_path", ""), config.get("llm_cap", 0))
config.get("ledger_path", ""), config.get("llm_cap", 0),
daily_cap=config.get("llm_daily"))
# HEL-882: a fresh file must never re-open the frozen balance — with any
# approved cap the configured ledger has to still be the authoritative
# account carrying the frozen prefix.
authority = config.get("authority")
if authority and (self.ledger.cap > 0 or self.llm_guard.cap > 0):
verify_ledger_authority(config.get("ledger_path", ""), authority)
# Unified provider-exit gate: the SDK's internal extraction LLM is a real
# provider exit like any other; when the product hands its router guard
# over, every extraction call also reserves request count + conservative
# tokens there before the wire (category "semantic", daily_tokens pool).
self.request_guard = request_guard
self.extract_scene = config.get("extract_scene", "mem0_extract")
self._failures = threading.local()
self.collection = config.get("collection", "cg_public")
self.user_scope = config.get("user_scope", "cybergril")
vector_root = Path(config["vector_root"])
@@ -433,34 +617,93 @@ class Mem0Backend:
llm = self._memory.llm
orig_generate = llm.generate_response
llm_guard = self.llm_guard
request_guard = self.request_guard
# HEL-882: the OpenAI client inside the fixed SDK retries 5xx/conn errors
# by default (max_retries=2) — each retry is a real send the ledger never
# saw. Hidden retries are disabled here; every actual attempt now goes
# through the reservation below, one gated send per call.
client = getattr(llm, "client", None)
if client is not None and hasattr(client, "max_retries"):
client.max_retries = 0
def _record_failure(reason: str) -> None:
failures = getattr(self._failures, "list", None)
if failures is None:
failures = self._failures.list = []
failures.append(reason)
def counted_embed(text, memory_action=None):
hold = ledger.reserve(1, str(memory_action or "embed"))
try:
hold = ledger.reserve(1, str(memory_action or "embed"))
except RuntimeError as exc:
# Refused BEFORE the wire: nothing was inferred, but mem0 2.2.1's
# fallback loops swallow exactly this — record it so add_group
# can tell "budget refused mid-add" apart from "nothing extracted".
_record_failure(f"refused:{exc}")
raise
try:
result = orig_embed(text, memory_action)
except BaseException:
except BaseException as exc:
# Single-text inference: it either returned a vector or produced
# nothing — provably no completed embedding, release the hold.
ledger.release(hold)
_record_failure(f"embed:{type(exc).__name__}")
raise
ledger.settle(hold, str(memory_action or "embed"))
return result
def counted_batch(texts, memory_action=None):
n = len(texts) if isinstance(texts, (list, tuple)) else 1
hold = ledger.reserve(n, f"batch:{memory_action or 'embed'}")
try:
hold = ledger.reserve(n, f"batch:{memory_action or 'embed'}")
except RuntimeError as exc:
_record_failure(f"refused:{exc}")
raise
try:
result = orig_batch(texts, memory_action)
except BaseException:
ledger.release(hold)
except BaseException as exc:
# A partially-run batch cannot prove which texts were already
# inferred — conservative: keep the full reservation (settle).
# Already-run inference is never refunded as "unsent".
ledger.settle(hold, f"batch_failed:{type(exc).__name__}")
_record_failure(f"batch:{type(exc).__name__}")
raise
ledger.settle(hold, f"batch:{memory_action or 'embed'}")
return result
def _extract_estimate(messages) -> int:
total = 0
for message in messages or []:
content = message.get("content") if isinstance(message, dict) else message
total += len(content) if isinstance(content, str) else len(str(content or ""))
return total + 1024 # extraction max_tokens in the mem0 llm config
def counted_generate(messages=None, **kwargs):
hold = llm_guard.reserve(1, "extract")
token_hold = None
if request_guard is not None:
# Unified pre-allocation before the wire: category "semantic"
# daily requests + the shared daily token pool, conservative
# estimate (usage is not visible through this SDK surface).
token_hold = request_guard.acquire(
self.extract_scene, requests=1, tokens=_extract_estimate(messages))
try:
hold = llm_guard.reserve(1, "extract")
except Exception:
if token_hold is not None:
request_guard.release(token_hold) # nothing was sent
raise
try:
result = orig_generate(messages=messages, **kwargs)
except BaseException:
llm_guard.release(hold)
except BaseException as exc:
if _request_definitely_unsent(exc):
llm_guard.release(hold)
if token_hold is not None:
request_guard.release(token_hold)
else:
# The request hit the wire — 5xx, timeout mid-transfer,
# cancelled stream: count it, never refund it as unsent.
llm_guard.settle(hold, f"extract_failed:{type(exc).__name__}")
raise
llm_guard.settle(hold, "extract")
return result
@@ -468,6 +711,39 @@ class Mem0Backend:
emb.embed, emb.embed_batch = counted_embed, counted_batch
llm.generate_response = counted_generate
def completed_message_ids(self, batch_key: str) -> set[int]:
"""Store-level idempotency: message_ids already sent for this batch.
Reads the vector store's own payload (src_group anchor) — a crashed or
restarted worker finds what earlier attempts persisted, so recovery
resends only the missing rows and never duplicates completed ones.
"""
try:
from qdrant_client import models as qmodels # noqa: PLC0415 — lazy, pinned SDK
except ImportError as exc: # pragma: no cover — qdrant-client ships with mem0
# Never degrade silently to "resend everything": idempotency is the
# no-duplicate-send guarantee, a missing SDK piece fails the batch.
raise RuntimeError("qdrant_client_unavailable") from exc
done: set[int] = set()
offset = None
while True:
points, offset = self._client.scroll(
collection_name=self.collection,
scroll_filter=qmodels.Filter(must=[
qmodels.FieldCondition(key="src_group",
match=qmodels.MatchValue(value=batch_key)),
]),
limit=256, offset=offset, with_payload=True,
)
for point in points:
payload = getattr(point, "payload", None) or {}
for item in payload.get("turn_roles") or []:
if isinstance(item, list) and len(item) == 3 and isinstance(item[2], int):
done.add(item[2])
if offset is None:
break
return done
async def add_group(self, rows: list[dict], anchor: dict, batch_key: str) -> list[dict]:
# Fail closed before ANY real work while either budget is unapproved:
# a 2.2.1 add always spends ≥1 embed item and 1 extraction LLM call.
@@ -475,10 +751,23 @@ class Mem0Backend:
self.llm_guard.check(1)
def _add() -> list[dict]:
# Recovery skips rows whose entries already live in the vector store
# (crash/restart/budget-exhaustion resume); the anchor sent with the
# remaining rows equals exactly that send's input set.
done = self.completed_message_ids(batch_key)
send = [r for r in rows if r["message_id"] not in done]
if not send:
return []
send_anchor = {
"turn_ids": [r["turn_id"] for r in send],
"turn_roles": [[r["turn_id"], r["role"], r["message_id"]] for r in send],
"input_sha256": anchor_fingerprint(send),
}
failures_before = list(getattr(self._failures, "list", []) or [])
created = []
for row in rows:
for row in send:
result = self._memory.add(
row["text"], metadata={"src_group": batch_key, **anchor},
row["text"], metadata={"src_group": batch_key, **send_anchor},
user_id=self.user_scope,
)
items = result.get("results") if isinstance(result, dict) else result
@@ -490,6 +779,13 @@ class Mem0Backend:
entry = {"id": item.get("id"), "data": (item.get("memory") or "")}
if entry["id"]:
created.append(entry)
# mem0 2.2.1 swallows per-text embed failures into empty results
# (phase-3/7 fallback loops). An empty extraction is legitimate only
# when no embed/refusal happened: if our guards refused anything
# during this add, that refusal is the truth, not the empty list.
failures = (getattr(self._failures, "list", []) or [])[len(failures_before):]
if failures:
raise SemanticBudgetError(f"semantic_add_swallowed_failure:{failures[0]}")
return created
return await asyncio.to_thread(_add)
@@ -575,23 +871,33 @@ class SemanticMemory:
return out
def enqueue_uncovered(self, limit: int = 20) -> int:
"""Self-healing scan: public sent rows not yet in any batch get one."""
"""Self-healing scan: public sent rows not yet in any batch get one.
HEL-882: coverage is per-backend. A batch only counts as covering its
rows for the backend that ran it — stub batches never block a real
(mem0) rebuild, and a real completed batch is never re-sent.
"""
backend = self.backend.backend
turns = self.store.query(
"SELECT DISTINCT turn_id FROM chat_message c WHERE status='sent' AND NOT EXISTS "
"(SELECT 1 FROM sem_covered v WHERE v.message_id=c.id) ORDER BY id LIMIT ?", (limit,),
"(SELECT 1 FROM sem_covered v JOIN sem_batch b ON b.id=v.batch_id "
"WHERE v.message_id=c.id AND b.backend=?) ORDER BY id LIMIT ?", (backend, limit),
)
queued = 0
for row in turns:
rows = [r for r in self.public_rows_for_turn(row["turn_id"])
if not self.store.query_one(
"SELECT 1 FROM sem_covered WHERE message_id=?", (r["message_id"],))]
"SELECT 1 FROM sem_covered v JOIN sem_batch b ON b.id=v.batch_id "
"WHERE v.message_id=? AND b.backend=?", (r["message_id"], backend))]
if not rows:
continue
covered_ids = tuple(r["message_id"] for r in rows)
# Deterministic batch key over the exact row set: a turn may produce
# more than one batch over time (e.g. the companion row shared later).
# Deterministic batch key over the exact row set (and the backend —
# stub and real batches of the same rows stay distinct rows). A turn
# may produce more than one batch over time (e.g. the companion row
# shared later).
digest = hashlib.sha1(json.dumps(sorted(covered_ids)).encode()).hexdigest()[:10]
batch_key = f"chat_public:{row['turn_id']}:{digest}"
batch_key = f"chat_public:{backend}:{row['turn_id']}:{digest}"
anchor = {
"turn_ids": [r["turn_id"] for r in rows],
"turn_roles": [[r["turn_id"], r["role"], r["message_id"]] for r in rows],
@@ -602,14 +908,20 @@ class SemanticMemory:
"INSERT OR IGNORE INTO sem_batch(batch_key,turn_id,anchor_json,backend,state,created_at) "
"VALUES(?,?,?,?,'queued',?)",
(batch_key, row["turn_id"], json.dumps(anchor, ensure_ascii=False),
self.backend.backend, int(self.clock() * 1000)),
backend, int(self.clock() * 1000)),
)
batch_id = self.store.query_one(
"SELECT id FROM sem_batch WHERE batch_key=?", (batch_key,),
)["id"]
for message_id in covered_ids:
# One coverage row per message, re-pointed to the CURRENT
# backend's batch: switching stub→mem0 (or back) re-covers
# the row for the new backend without losing the link, and
# a real completed batch keeps holding its own coverage.
self.store.execute(
"INSERT OR IGNORE INTO sem_covered VALUES(?,?)", (message_id, batch_id),
"INSERT INTO sem_covered VALUES(?,?) "
"ON CONFLICT(message_id) DO UPDATE SET batch_id=excluded.batch_id",
(message_id, batch_id),
)
queued += 1
return queued
@@ -656,6 +968,19 @@ class SemanticMemory:
state, reason = "done", None
except Exception as exc: # noqa: BLE001 — job isolation, reason recorded
reason = str(exc) or exc.__class__.__name__
if "budget" in reason:
# A refused budget is not an extraction failure: the batch goes
# back to queued WITHOUT burning an attempt — raising the cap
# later resumes it (skipping rows already in the vector store),
# so exhausted budget can never permanently lose these rows.
with self.store.conn:
self.store.execute(
"UPDATE sem_batch SET state='queued',reason=?,owner=NULL,"
"attempts=MAX(0,attempts-1),lease_until=NULL WHERE id=? AND owner=?",
(reason, job["id"], owner),
)
log.warning("semantic batch %s requeued (budget): %s", job["id"], reason)
return
log.warning("semantic batch %s failed: %s", job["id"], reason)
finally:
with self.store.conn:
@@ -777,23 +1102,35 @@ class SemanticMemory:
return block
def build_semantic(store, *, mode: str, config: dict | None = None, clock=time.time):
"""Factory per life_control key: 'off' | 'stub' | 'mem0'."""
def build_semantic(store, *, mode: str, config: dict | None = None, clock=time.time,
request_guard=None):
"""Factory per life_control key: 'off' | 'stub' | 'mem0'.
``request_guard`` is the product router guard (PersistentBudget): when
given, the SDK's internal extraction LLM also reserves request count +
conservative tokens there before the wire (HEL-882 unified provider-exit
gate). Daily caps (embed_daily / llm_daily, execution-table C) and the
ledger ``authority`` block come from the semantic config; defaults refuse.
"""
if mode == "stub":
return SemanticMemory(store, StubBackend(), clock=clock)
if mode == "mem0":
try:
cfg = config or {}
ledger = EmbedLedgerGuard(
(config or {}).get("ledger_path", ""),
(config or {}).get("embed_cap", 0),
reserve=(config or {}).get("embed_reserve", 0),
cfg.get("ledger_path", ""),
cfg.get("embed_cap", 0),
reserve=cfg.get("embed_reserve", 0),
daily_cap=cfg.get("embed_daily", 0),
)
llm_guard = LlmLedgerGuard(
(config or {}).get("ledger_path", ""),
(config or {}).get("llm_cap", 0),
cfg.get("ledger_path", ""),
cfg.get("llm_cap", 0),
daily_cap=cfg.get("llm_daily", 0),
)
return SemanticMemory(
store, Mem0Backend(config or {}, ledger=ledger, llm_guard=llm_guard), clock=clock)
store, Mem0Backend(cfg, ledger=ledger, llm_guard=llm_guard,
request_guard=request_guard), clock=clock)
except Exception as exc: # noqa: BLE001 — fail closed, feature reports disabled
log.warning("mem0 backend unavailable, semantic layer off: %s", exc)
return None
+163
View File
@@ -8,6 +8,13 @@
2. 超预算降档,不是停服——降级可被叙事化("我今天有点走神"),
这甚至符合人设,而且是真实的(实现同构的虚构)
3. 记录按场景分桶——否则你永远不知道钱花在哪
HEL-882 硬顶补齐:降档永远只是"变便宜",不是"绕过上限"。执行表
(docs/budget-plan-hel874.md)A/C 部分的请求次数上限与日 token 上限现在是
真实的硬闸——所有真实 provider 出口在发送前先 ``acquire``(原子预占请求
次数 + 保守 token 估算),到任何一顶就拒绝(``BudgetStop``),不再"降档
继续调用"。请求计数与 token 消耗持久化(PersistentBudget 落库),重启
不归零。未批准的类别默认 cap=0(fail-closed)。
"""
from __future__ import annotations
@@ -15,6 +22,32 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
CATEGORIES = ("dialogue", "life", "background", "semantic")
"""执行表 C 部分的请求类别(主对话+主动+危机 / 生活事件 / gist·判定·成长 / Mem0 提取)。"""
CATEGORY_OF_SCENE: dict[str, str] = {
"dialogue_main": "dialogue",
"proactive_message": "dialogue",
"crisis": "dialogue",
"life_event": "life",
# Creation-time interpretation/initial habits are one-shot background work
# (execution-table A counts them with the check budget, C with the daily
# background bucket) — they must not eat the user's dialogue daily top.
"dialogue_followup": "background",
"memory_gist": "background",
"fact_extract": "background",
"pattern_detect": "background",
"output_guard": "background",
"mem0_extract": "semantic",
}
class BudgetStop(RuntimeError):
"""硬顶拒绝:请求次数 / 日 token / 一次性总量任一到顶。
兼容既有 ``except RuntimeError`` 的 budget_stop 处理路径。
"""
@dataclass
class BudgetConfig:
@@ -27,6 +60,19 @@ class BudgetConfig:
hard_stop_ratio: float = 0.95
"""到这个比例,非关键场景直接降到下限。"""
daily_requests: dict[str, int] = field(
default_factory=lambda: dict.fromkeys(CATEGORIES, 0)
)
"""各类别每日请求次数硬顶(执行表 C)。默认 0 = 未批准 = 该类别任何真实
provider 出口在发送前拒绝;只有写进配置的批准值才放行。缺类别同 0。"""
total_requests: int | None = None
"""一次性总请求硬顶(执行表 A,跨日累计)。None = 未配置;配置后 0 = 拒绝。"""
total_tokens: int | None = None
"""一次性总 token 硬顶(执行表 A,跨日累计),与 daily_tokens 同时生效、
取最紧。None = 未配置。"""
@dataclass
class BudgetState:
@@ -35,11 +81,38 @@ class BudgetState:
day: str = ""
used: int = 0
by_scene: dict[str, int] = field(default_factory=dict)
requests: dict[str, int] = field(default_factory=dict)
total_requests_used: int = 0
total_tokens_used: int = 0
def reset(self, day: str) -> None:
self.day = day
self.used = 0
self.by_scene.clear()
self.requests.clear()
# total_* 是跨日一次性账(执行表 A),滚日不归零。
@dataclass(frozen=True)
class BudgetHold:
"""一次真实 provider 出口的预占凭据。"""
scene: str
category: str
reserved_tokens: int
requests: int
day: str
def estimate_tokens(parts: list[str], *, max_tokens: int = 1024) -> int:
"""发送前的保守 token 估算(HEL-882 口径,写进执行表)。
输入按字符数 1:1 计(中文现代分词下字符数 ≥ token 数,英文单词字符数
远大于 token 数,两个方向都高估),输出按调用方声明的 max_tokens 全额
计。provider 返回真实 usage 时按实际落账(settle),未知 usage / 断流 /
失败 / 取消保守保留预占,不退款。
"""
return sum(len(p) for p in parts) + max(0, int(max_tokens))
class BudgetGuard:
@@ -106,6 +179,96 @@ class BudgetGuard:
)
self.record(total, scene)
# ---------------------------------------------------------------- 硬闸
def category_of(self, scene: str) -> str:
return CATEGORY_OF_SCENE.get(scene, scene)
def requests_used(self, category: str) -> int:
self._roll()
return self.state.requests.get(category, 0)
def total_requests_used(self) -> int:
return self.state.total_requests_used
def total_tokens_used(self) -> int:
return self.state.total_tokens_used
def acquire(self, scene: str, *, requests: int = 1, tokens: int) -> BudgetHold:
"""真实 provider 出口发送前的统一预占(HEL-882)。
请求次数(类别日顶 + 一次性总量)与 token(日顶 + 一次性总量)在
同一步原子检查并预占,任一到顶抛 ``BudgetStop``——按最紧约束停止,
不降档继续调用。持久化子类(PersistentBudget)把这一步落在单个
``BEGIN IMMEDIATE`` 事务里,重启不归零、并发不越顶。
"""
self._roll()
category = self.category_of(scene)
day = self.state.day
used = self.state.used
if used + tokens > self.config.daily_tokens:
raise BudgetStop(
f"budget_tokens_exhausted:{used}/{self.config.daily_tokens}+{tokens}"
)
cat_cap = self.config.daily_requests.get(category, 0)
cat_used = self.state.requests.get(category, 0)
if cat_used + requests > cat_cap:
raise BudgetStop(
f"budget_requests_exhausted:{category}:{cat_used}/{cat_cap}"
)
if self.config.total_requests is not None and \
self.state.total_requests_used + requests > self.config.total_requests:
total_used = self.state.total_requests_used
raise BudgetStop(
f"budget_total_requests_exhausted:{total_used}/{self.config.total_requests}"
)
if (self.config.total_tokens is not None
and self.state.total_tokens_used + tokens > self.config.total_tokens):
raise BudgetStop(
f"budget_total_tokens_exhausted:"
f"{self.state.total_tokens_used}/{self.config.total_tokens}"
)
self.state.used = used + tokens
self.state.by_scene[scene] = self.state.by_scene.get(scene, 0) + tokens
self.state.requests[category] = cat_used + requests
self.state.total_requests_used += requests
self.state.total_tokens_used += tokens
return BudgetHold(scene=scene, category=category,
reserved_tokens=tokens, requests=requests, day=day)
def settle(self, hold: BudgetHold, *, actual_tokens: int | None = None) -> None:
"""落账:请求次数已在预占时计入,不退。
provider 返回真实 usage 时按实际修正 token(多补少退);未知 usage、
断流、失败、取消一律保守保留全部预占 token——已发送的成本不当未发生。
"""
if hold is None:
return
self._roll()
if hold.day != self.state.day:
return # 预占记在已翻篇的日账上,当日账不动
if actual_tokens is None or actual_tokens == hold.reserved_tokens:
return
delta = actual_tokens - hold.reserved_tokens
self.state.used += delta
self.state.by_scene[hold.scene] = self.state.by_scene.get(hold.scene, 0) + delta
self.state.total_tokens_used += delta
def release(self, hold: BudgetHold) -> None:
"""释放可证明未发送的预占(如路由后无 provider,请求从未发出)。"""
if hold is None:
return
self._roll()
if hold.day != self.state.day:
return # 预占记在已翻篇的日账上,当日账不动
self.state.used -= hold.reserved_tokens
self.state.by_scene[hold.scene] = self.state.by_scene.get(hold.scene, 0) - hold.reserved_tokens
self.state.requests[hold.category] = (
self.state.requests.get(hold.category, 0) - hold.requests
)
self.state.total_requests_used -= hold.requests
self.state.total_tokens_used -= hold.reserved_tokens
# ---------------------------------------------------------------- 报告
def top_scenes(self, n: int = 5) -> list[tuple[str, int]]:
+3
View File
@@ -106,6 +106,9 @@ def build_router_from_config(
daily_tokens=models.budget.daily_tokens,
warn_ratio=models.budget.warn_ratio,
hard_stop_ratio=models.budget.hard_stop_ratio,
daily_requests=dict(models.budget.daily_requests),
total_requests=models.budget.total_requests,
total_tokens=models.budget.total_tokens,
)
)
return ModelRouter(
+24
View File
@@ -9,6 +9,30 @@ import pytest
from cybergril.config import CharacterConfig, ModelsConfig, Settings
from cybergril.memory import Store
try:
from cybergril.think.budget import CATEGORIES, BudgetConfig
except ImportError: # r3 baseline probe runs these tests against older src trees
from cybergril.think.budget import BudgetConfig
CATEGORIES = ("dialogue", "life", "background", "semantic")
def engineering_budget(**overrides) -> BudgetConfig:
"""Engineering caps for isolated tests: real mechanism, generous numbers.
The product default is fail-closed (all request caps 0 = refuse); tests
exercise the same acquire/settle path with plenty of headroom so the gate
itself is what's under test, not the headroom. Against a pre-HEL-882
baseline source tree (r3 baseline probe) the extra fields don't exist and
the old plain token config is used — behavior there predates the gate.
"""
requests = dict.fromkeys(CATEGORIES, 100_000)
requests.update(overrides.pop("daily_requests", {}))
try:
return BudgetConfig(daily_tokens=10_000_000, daily_requests=requests, **overrides)
except TypeError:
return BudgetConfig(daily_tokens=10_000_000, **overrides)
@pytest.fixture
def repo_root() -> Path:
+3 -1
View File
@@ -12,6 +12,7 @@ from uuid import uuid4
import httpx
import pytest
import uvicorn
from conftest import engineering_budget
from fastapi.testclient import TestClient
from cybergril.api.app import create_app
@@ -56,7 +57,8 @@ class ControlledLLM(EchoLLM):
def make_app(path, provider=None, **kwargs):
provider = provider or ControlledLLM()
router = build_router(
providers={Tier.HIGH: provider, Tier.STANDARD: provider, Tier.LOW: provider}
providers={Tier.HIGH: provider, Tier.STANDARD: provider, Tier.LOW: provider},
budget=engineering_budget(),
)
return create_app(db_path=path, router=router, **kwargs), provider
+188
View File
@@ -0,0 +1,188 @@
"""HEL-882 hard gates: the execution table's caps stop calls at the wire.
Reproduces the manager's independent review failure first (api review:
daily_tokens=1 with 2962 already used still fired a provider request and
returned chat.final), then proves the gate: every real provider exit — main
dialogue, structured background scenes, and the mem0 SDK's internal extraction
LLM — pre-allocates persistently before the send and refuses at the tightest
cap with ZERO provider calls. Counters survive restarts.
"""
import pytest
from conftest import engineering_budget
from fastapi.testclient import TestClient
from test_api import CREATE, events, turn
from cybergril.api.app import create_app
from cybergril.contracts import LLMDelta
from cybergril.life.runtime import LifeConfig
from cybergril.memory.store import Store
from cybergril.think.budget import BudgetConfig, BudgetGuard, BudgetStop
from cybergril.think.router import build_router
from cybergril.think.scenes import Scene, Tier
class GateLLM:
"""Fake provider that records every stream call."""
def __init__(self):
self.calls = []
async def stream(self, messages, **kwargs):
self.calls.append(messages)
if messages and "只返回JSON" in messages[0].content:
yield LLMDelta(text='{"ok": true}')
yield LLMDelta(done=True, finish_reason="stop", usage={"total_tokens": 12})
return
yield LLMDelta(text="好")
yield LLMDelta(text="的")
yield LLMDelta(done=True, finish_reason="stop", usage={"total_tokens": 12})
async def aclose(self):
return None
def gated_app(tmp_path, *, budget=None, db="gates.db"):
provider = GateLLM()
router = build_router(
providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
budget=budget or engineering_budget(),
)
app = create_app(db_path=tmp_path / db, router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="off")
return app, provider
def create_companion(client):
assert client.post("/api/companion", json=CREATE).status_code == 201
def test_daily_token_wall_refuses_before_any_provider_call(tmp_path):
"""The review's exact reproduction: over the daily token top, a turn must
not reach the provider — honest chat.error, counters unchanged."""
app, provider = gated_app(tmp_path)
with TestClient(app) as client:
create_companion(client)
guard = app.state.life.router.guard
guard.config.daily_tokens = 1
guard.record(2962, "review_budget_already_used")
used_before, calls_before = guard.used, len(provider.calls)
result = [e["t"] for e in events(turn(client, "合成测试:额度用完后仍能发出请求吗"))]
assert result == ["chat.error"] # honest refusal, never a fake reply
assert len(provider.calls) == calls_before # ZERO provider requests
assert guard.used == used_before # nothing further pre-allocated
def test_dialogue_request_cap_persists_across_restart(tmp_path):
"""Category daily request caps are persistent: 1 dialogue request/day lets
exactly one turn through; a second turn — in the SAME process or after a
full restart on the same database — refuses with zero provider calls."""
budget = engineering_budget(daily_requests={"dialogue": 1})
app, provider = gated_app(tmp_path, budget=budget)
with TestClient(app) as client:
create_companion(client)
assert [e["t"] for e in events(turn(client, "第一句"))][-1] == "chat.final"
calls_after_first = len(provider.calls)
assert [e["t"] for e in events(turn(client, "第二句"))] == ["chat.error"]
assert len(provider.calls) == calls_after_first
# restart: fresh app, fresh router, SAME database — the cap still holds
app2, _provider2 = gated_app(tmp_path, budget=budget)
with TestClient(app2) as client:
assert [e["t"] for e in events(turn(client, "重启后"))] == ["chat.error"]
assert app2.state.life.router.guard.requests_used("dialogue") == 1
@pytest.mark.asyncio
async def test_background_category_caps_stop_structured_calls(tmp_path):
"""structured() exits (life events, gists, judgments) count into their own
category: a 1/day life cap refuses the second generation before the wire."""
app, provider = gated_app(tmp_path, budget=engineering_budget(
daily_requests={"dialogue": 100, "life": 1, "background": 100}))
with TestClient(app) as client:
create_companion(client)
guard = app.state.life.router.guard
assert guard.requests_used("background") >= 0 # persistent guard wired
calls_before = len(provider.calls)
from cybergril.life.model import structured
scene = Scene.LIFE_EVENT
payload = {"time": "2026-10-26T10:00:00", "persona": "p", "activity_state": {}}
raw, _ = await structured(app.state.life.router, app.state.life.models, scene,
'返回 {"ok": true}', payload)
assert raw == {"ok": True}
assert len(provider.calls) == calls_before + 1
with pytest.raises(BudgetStop, match="budget_requests_exhausted:life"):
await structured(app.state.life.router, app.state.life.models, scene,
'返回 {"ok": true}', payload)
assert len(provider.calls) == calls_before + 1 # refused before the wire
def test_default_config_is_fail_closed_for_every_category():
guard = BudgetGuard(BudgetConfig())
for scene in ("dialogue_main", "life_event", "memory_gist", "mem0_extract"):
with pytest.raises(BudgetStop):
guard.acquire(scene, requests=1, tokens=10)
def test_in_memory_guard_tightest_constraint_and_settlement():
guard = BudgetGuard(BudgetConfig(daily_tokens=1000, daily_requests={"dialogue": 2},
total_requests=3), today="2026-10-26")
hold1 = guard.acquire("dialogue_main", requests=1, tokens=300)
assert guard.requests_used("dialogue") == 1
# daily token wall: the tightest cap decides, not the request count
with pytest.raises(BudgetStop, match="budget_tokens_exhausted"):
guard.acquire("dialogue_main", requests=1, tokens=800)
hold2 = guard.acquire("dialogue_main", requests=1, tokens=100)
# category daily top reached
with pytest.raises(BudgetStop, match="budget_requests_exhausted:dialogue"):
guard.acquire("proactive_message", requests=1, tokens=10)
# one-shot total: third request would pass the daily top of 2? no — category
# top already 2/2; use another category for the total check
guard3 = BudgetGuard(BudgetConfig(daily_tokens=1000, daily_requests={"dialogue": 10},
total_requests=2), today="2026-10-26")
guard3.acquire("dialogue_main", requests=1, tokens=10)
guard3.acquire("dialogue_main", requests=1, tokens=10)
with pytest.raises(BudgetStop, match="budget_total_requests_exhausted"):
guard3.acquire("dialogue_main", requests=1, tokens=10)
# settle: known usage adjusts, unknown keeps the whole reservation
guard.settle(hold1, actual_tokens=250)
assert guard.used == 350 # 250 + hold2's 100
guard.settle(hold2, actual_tokens=None) # disconnect: keep full reserve
assert guard.used == 350
# release: a provably-unsent hold refunds requests and tokens
guard4 = BudgetGuard(BudgetConfig(daily_tokens=1000,
daily_requests={"dialogue": 1}), today="2026-10-26")
hold = guard4.acquire("dialogue_main", requests=1, tokens=100)
guard4.release(hold)
assert guard4.used == 0 and guard4.requests_used("dialogue") == 0
def test_persistent_budget_counters_survive_restart(tmp_path):
from cybergril.life.model import PersistentBudget
store = Store(tmp_path / "persist.db")
guard = PersistentBudget(store, BudgetGuard(engineering_budget(), today="2026-10-26"))
guard.acquire("dialogue_main", requests=1, tokens=111)
guard.acquire("mem0_extract", requests=2, tokens=22)
store.close()
store2 = Store(tmp_path / "persist.db")
guard2 = PersistentBudget(store2, BudgetGuard(engineering_budget(), today="2026-10-26"))
assert guard2.requests_used("dialogue") == 1
assert guard2.requests_used("semantic") == 2
assert guard2.used == 133 # restart did not reset the day's tokens
store2.close()
def test_total_tokens_cap_blocks_before_daily_wall(tmp_path):
app, provider = gated_app(tmp_path, budget=engineering_budget(
daily_requests={"dialogue": 100}, total_tokens=5000))
with TestClient(app) as client:
create_companion(client)
guard = app.state.life.router.guard
guard.record(4999, "review_almost_total") # one-shot total nearly spent
calls_before = len(provider.calls)
assert [e["t"] for e in events(turn(client, "还会发吗"))] == ["chat.error"]
assert len(provider.calls) == calls_before
+45 -1
View File
@@ -14,6 +14,7 @@ import json
import sqlite3
import sys
import types
from dataclasses import dataclass
import pytest
@@ -39,6 +40,22 @@ class FakeQdrantClient:
bucket = POINT_LOG.setdefault(collection_name, {})
return [_Point(i, bucket[i]) for i in ids if i in bucket]
def scroll(self, collection_name, scroll_filter=None, limit=10, offset=None,
with_payload=True):
"""Mirror qdrant scroll-by-payload-filter: must== match on src_group."""
bucket = POINT_LOG.setdefault(collection_name, {})
must = getattr(scroll_filter, "must", None) or []
wanted = None
for condition in must:
match = getattr(condition, "match", None)
if getattr(condition, "key", None) == "src_group" and match is not None:
wanted = getattr(match, "value", None)
points = [
_Point(point_id, payload) for point_id, payload in bucket.items()
if wanted is None or payload.get("src_group") == wanted
]
return points[:limit], None
def close(self):
self.closed = True
@@ -58,11 +75,19 @@ class FakeEmbedder:
return [[0.0]] * len(texts)
class _FakeClientHandle:
"""Mirrors the OpenAI client handle the SDK exposes: retries default to 2."""
def __init__(self):
self.max_retries = 2
class FakeLlm:
"""Deterministic extraction: echoes the batch key as one ADD result."""
def __init__(self):
self.calls = []
self.client = _FakeClientHandle()
def generate_response(self, messages=None, **kwargs):
self.calls.append(messages)
@@ -135,7 +160,26 @@ def fake_mem0(monkeypatch, tmp_path):
POINT_LOG.clear()
FakeMemory.instances.clear()
monkeypatch.setitem(sys.modules, "mem0", types.ModuleType("mem0"))
monkeypatch.setitem(sys.modules, "qdrant_client", types.ModuleType("qdrant_client"))
qdrant = types.ModuleType("qdrant_client")
models = types.ModuleType("qdrant_client.models")
@dataclass
class MatchValue:
value: object = None
@dataclass
class FieldCondition:
key: str = ""
match: object = None
@dataclass
class Filter:
must: list | None = None
models.Filter, models.FieldCondition, models.MatchValue = Filter, FieldCondition, MatchValue
qdrant.models = models
monkeypatch.setitem(sys.modules, "qdrant_client", qdrant)
monkeypatch.setitem(sys.modules, "qdrant_client.models", models)
sys.modules["mem0"].Memory = FakeMemory
sys.modules["mem0"]._TEST_EMBEDDER = FakeEmbedder()
return ledger_path
+3 -1
View File
@@ -6,6 +6,7 @@ from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from conftest import engineering_budget
from fastapi.testclient import TestClient
from test_api import CREATE, ControlledLLM, events, live_server, turn
@@ -82,7 +83,8 @@ class R2LLM(ControlledLLM):
def app_for(tmp_path, *, enabled=True, provider=None, poll=3600):
provider = provider or R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
budget=engineering_budget())
app = create_app(db_path=tmp_path / "r2.db", router=router,
life_config=LifeConfig(enabled=enabled, poll_seconds=poll))
return app, provider
+4 -2
View File
@@ -8,6 +8,7 @@ from uuid import uuid4
import httpx
import pytest
from conftest import engineering_budget
from fastapi.testclient import TestClient
from test_api import CREATE, ControlledLLM, events, live_server, make_app, turn
from test_r2 import ack, add_life, app_for, final_and_receipt
@@ -70,7 +71,8 @@ class GistLLM(ControlledLLM):
def memory(tmp_path):
with Store(tmp_path / 'memory.db') as s:
p = GistLLM()
router = build_router(providers={t: p for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
router = build_router(providers={t: p for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
budget=engineering_budget())
clock = [1000.0]
yield Memories(s, router, load_settings().models, clock=lambda: clock[0]), p, clock
@@ -370,7 +372,7 @@ def test_real_chat_retrieval_excludes_twenty_turns_and_preserves_birth(tmp_path,
def test_startup_factory_clock_precedes_recovery(tmp_path, monkeypatch):
path = tmp_path / 'chat.db'
with Store(path) as s:
router = build_router(providers={Tier.LOW: ControlledLLM()})
router = build_router(providers={Tier.LOW: ControlledLLM()}, budget=engineering_budget())
mem = Memories(s, router, load_settings().models)
seed(mem, gist=False)
with s.conn:
+3 -1
View File
@@ -2,6 +2,7 @@
import json
import pytest
from conftest import engineering_budget
from fastapi.testclient import TestClient
from test_api import CREATE, events, turn
from test_r2 import LIFE, R2LLM
@@ -41,7 +42,8 @@ class R4LLM(R2LLM):
def r4_app(tmp_path, *, enabled=True, provider=None, semantic="stub", poll=3600):
provider = provider or R4LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
budget=engineering_budget())
app = create_app(db_path=tmp_path / "r4.db", router=router,
life_config=LifeConfig(enabled=enabled, poll_seconds=poll),
semantic_mode=semantic)
+3 -1
View File
@@ -3,6 +3,7 @@ import json
from datetime import UTC, datetime
import pytest
from conftest import engineering_budget
from test_api import ControlledLLM
from cybergril.config import CharacterConfig, ModelsConfig
@@ -75,7 +76,8 @@ def setup(store, *, hour=10, days_ago=0.0, intimacy=0.5, arousal=(0.7, 0.9), llm
)
if intimacy is not None:
store.save_dyad(trust=0.6, intimacy=intimacy, debt=0.0, security=0.6, unresolved=[])
router = build_router(providers={t: (llm or ReachLLM()) for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
router = build_router(providers={t: (llm or ReachLLM()) for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
budget=engineering_budget())
character = CharacterConfig(identity={"y": 2000, "m": 1, "d": 1, "h": 12})
models = ModelsConfig()
persona = Persona(store, router, models, enabled=True)
+377
View File
@@ -0,0 +1,377 @@
"""HEL-882 SDK-boundary recovery: hidden retries, swallowed failures, resumes.
Fake-SDK mirror of the manager's independent review reproductions (real pinned
mem0ai 2.2.1 behavior mirrored from source):
1. llm_cap=1 against a 500-ing gateway fired 6 HTTP requests (OpenAI default
max_retries=2) while llm_ops used stayed 0 → retries now disabled at the
client and every wire-hit (even a 5xx) settles as used.
2. embed_cap exhausting mid-add: the SDK swallows the embed refusal and returns
an empty list; the product used to write sem_batch done/[] and the rows were
never organized again → the refusal now re-raises, the batch re-queues
without burning an attempt, and raising the cap resumes it.
3. stub→mem0 on the same archive: stub coverage blocked the real rebuild →
coverage is per-backend now; a real completed batch is never re-sent.
"""
import contextlib
import json
import sqlite3
import sys
import types
import pytest
from conftest import engineering_budget
from test_mem0_backend import POINT_LOG, FakeEmbedder, FakeMemory, _make_ledger
from cybergril.memory.semantic import (
EmbedLedgerGuard,
Mem0Backend,
SemanticBudgetError,
SemanticMemory,
StubBackend,
build_semantic,
verify_ledger_authority,
)
from cybergril.memory.store import Store
from cybergril.think.budget import BudgetGuard
class Wire500Error(Exception):
"""Mirror of openai.APIStatusError: the request WAS answered (500)."""
class NeverConnectedError(Exception):
"""Mirror of an openai error whose cause is httpx.ConnectError: unsent."""
def _httpx_connect_error():
cls = type("ConnectError", (Exception,), {})
cls.__module__ = "httpx"
return cls()
class SwallowingMemory(FakeMemory):
"""Mirror of 2.2.1's phase-3/7 fallback: embed failures in add() are
swallowed into an empty result — the exact review reproduction."""
def add(self, text, metadata=None, user_id=None):
self.embedding_model.embed(text, memory_action="search") # phase 1
self.llm.generate_response(messages=[
{"role": "system", "content": "extract"},
{"role": "user", "content": text},
], response_format={"type": "json_object"})
extracted = f"提取:{text[:20]}"
try: # phase 3
self.embedding_model.embed_batch([extracted], memory_action="add")
except Exception:
embed_map = {}
with contextlib.suppress(Exception):
embed_map[self.embedding_model.embed( # noqa: F841 — fallback mirror
extracted, memory_action="add")] = None
if not embed_map:
return [] # ← the swallow the review caught
self.seq += 1
entry_id = f"m{self.seq}"
bucket = POINT_LOG.setdefault(self.collection, {})
bucket[entry_id] = {"data": extracted, "user_id": user_id, **(metadata or {})}
return {"results": [{"id": entry_id, "memory": extracted, "event": "ADD"}]}
def install_fake(monkeypatch, memory_cls=FakeMemory):
POINT_LOG.clear()
FakeMemory.instances.clear()
monkeypatch.setitem(sys.modules, "mem0", types.ModuleType("mem0"))
qdrant = types.ModuleType("qdrant_client")
models = types.ModuleType("qdrant_client.models")
class MatchValue:
def __init__(self, value=None):
self.value = value
class FieldCondition:
def __init__(self, key="", match=None):
self.key, self.match = key, match
class Filter:
def __init__(self, must=None):
self.must = must
models.Filter, models.FieldCondition, models.MatchValue = Filter, FieldCondition, MatchValue
qdrant.models = models
monkeypatch.setitem(sys.modules, "qdrant_client", qdrant)
monkeypatch.setitem(sys.modules, "qdrant_client.models", models)
sys.modules["mem0"].Memory = memory_cls
sys.modules["mem0"]._TEST_EMBEDDER = FakeEmbedder()
return sys.modules["mem0"]
def config_for(tmp_path, ledger, *, embed_cap=10, llm_cap=10, **extra):
config = {
"gateway_base_url": "http://127.0.0.1:9/v1",
"gateway_model": "fake",
"vector_root": str(tmp_path / "vec"),
"history_root": str(tmp_path / "hist"),
"collection": "cg_public",
"ledger_path": str(ledger),
"embed_cap": embed_cap,
"llm_cap": llm_cap,
}
config.update(extra)
return config
def seed_turn(store, turn_id, text):
with store.conn:
store.execute(
"INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES(1,'user',?,?,'sent')",
(text, turn_id),
)
row = store.query_one("SELECT id FROM chat_message WHERE turn_id=?", (turn_id,))
return [{"message_id": row["id"], "turn_id": turn_id, "role": "user", "text": text}]
async def test_hidden_retries_disabled_and_wire_hits_counted(tmp_path, monkeypatch):
"""Review repro 1: llm_cap=1, gateway 500s → exactly ONE HTTP attempt, and
it lands in llm_ops; the second add refuses before the wire."""
from test_mem0_backend import FakeLlm
class Failing500Llm(FakeLlm):
"""The wire answer is a 500: raised inside generate_response like the
real OpenAI client does (an answered request, definitively sent)."""
def generate_response(self, messages=None, **kwargs):
self.calls.append("wire")
raise Wire500Error("HTTP 500 review controlled")
class Failing500Memory(FakeMemory):
def __init__(self, embedder, collection):
super().__init__(embedder, collection)
self.llm = Failing500Llm()
install_fake(monkeypatch, Failing500Memory)
ledger = tmp_path / "retry.sqlite3"
_make_ledger(ledger)
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=50, llm_cap=1))
assert backend._memory.llm.client.max_retries == 0 # hidden retries disabled
rows = [{"message_id": 1, "turn_id": "t1", "role": "user", "text": "合成"}]
anchor = {"turn_ids": ["t1"], "turn_roles": [["t1", "user", 1]], "input_sha256": "x"}
with pytest.raises(Wire500Error):
await backend.add_group(rows, anchor, "retry")
assert len(backend._memory.llm.calls) == 1 # one actual send, no SDK retries
assert backend.llm_guard.used() == 1 # the answered 5xx is COUNTED
# second add: cap 1/1 used → refuses before any wire hit
calls_before = len(backend._memory.llm.calls)
with pytest.raises(RuntimeError, match="semantic_llm_budget_exhausted"):
await backend.add_group(rows, anchor, "retry2")
assert len(backend._memory.llm.calls) == calls_before
async def test_pre_connection_failure_releases(tmp_path, monkeypatch):
"""Only a provably-unsent request (httpx.ConnectError cause) releases."""
mem0 = install_fake(monkeypatch)
class UnsentMemory(FakeMemory):
def add(self, text, metadata=None, user_id=None):
self.embedding_model.embed(text, memory_action="search")
exc = NeverConnectedError("connection refused")
exc.__cause__ = _httpx_connect_error()
raise exc
mem0.Memory = UnsentMemory
ledger = tmp_path / "unsent.sqlite3"
_make_ledger(ledger)
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=50, llm_cap=5))
rows = [{"message_id": 1, "turn_id": "t", "role": "user", "text": "x"}]
with pytest.raises(NeverConnectedError):
await backend.add_group(rows, {"turn_ids": ["t"], "turn_roles": [["t", "user", 1]],
"input_sha256": "x"}, "unsent")
assert backend.llm_guard.used() == 0 # nothing reached the wire → released
async def test_mid_add_budget_swallow_requeues_then_resumes(tmp_path, monkeypatch):
"""Review repro 2: embed cap exhausts mid-add, the SDK swallows it — the
batch must NOT complete empty; raising the cap resumes and organizes."""
install_fake(monkeypatch, SwallowingMemory)
ledger = tmp_path / "exhaust.sqlite3"
_make_ledger(ledger)
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=1, llm_cap=5))
store = Store(tmp_path / "exhaust.db")
seed_turn(store, "t1", "合成测试钥匙在第三个花盆下面")
semantic = SemanticMemory(store, backend)
semantic.enqueue_uncovered()
await semantic.tick()
after_first = dict(store.query_one("SELECT state,reason,entry_ids,attempts FROM sem_batch"))
assert after_first["state"] == "queued" # NOT done: the swallow was caught
assert "budget" in (after_first["reason"] or "")
assert after_first["attempts"] == 0 # budget refusal burns no attempt
assert json.loads(after_first["entry_ids"] or "[]") == []
backend.ledger.cap = 50 # the manager raises the approved cap
await semantic.tick()
after_raise = dict(store.query_one("SELECT state,reason,entry_ids,attempts FROM sem_batch"))
assert after_raise["state"] == "done" # resumed and organized the same rows
assert json.loads(after_raise["entry_ids"])
hits = await backend.search("合成测试钥匙")
assert hits
store.close()
async def test_resume_never_loses_or_duplicates_rows(tmp_path, monkeypatch):
"""A crash mid-batch (row1 stored, row2 not) resumes sending ONLY row2."""
install_fake(monkeypatch)
ledger = tmp_path / "resume.sqlite3"
_make_ledger(ledger)
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=50, llm_cap=50))
store = Store(tmp_path / "resume.db")
rows = seed_turn(store, "t2", "第一条原话")
with store.conn: # a shared companion row joins the turn
store.execute("INSERT INTO chat_message(ts,role,text,turn_id,status) VALUES"
"(2,'companion','第二条原话','t2','sent')")
row2 = store.query_one("SELECT id FROM chat_message WHERE text='第二条原话'")
rows.append({"message_id": row2["id"], "turn_id": "t2", "role": "companion", "text": "第二条原话"})
anchor = {"turn_ids": ["t2", "t2"], "turn_roles": [["t2", "user", rows[0]["message_id"]],
["t2", "companion", rows[1]["message_id"]]],
"input_sha256": "x"}
# simulate: attempt 1 stored row1's entry then died (e.g. crash before commit)
point_id = "pre-stored-1"
POINT_LOG.setdefault("cg_public", {})[point_id] = {
"data": "提取:第一条原话", "user_id": "cybergril",
"src_group": "chat_public:mem0:t2:key",
"turn_roles": [["t2", "user", rows[0]["message_id"]]],
"input_sha256": "x",
}
created = await backend.add_group(rows, anchor, "chat_public:mem0:t2:key")
texts_added = [backend.payload(e["id"])["data"] for e in created]
assert len(created) == 1 # only the missing row was sent
assert "第二条原话" in texts_added[0]
completed = backend.completed_message_ids("chat_public:mem0:t2:key")
assert completed == {rows[0]["message_id"], rows[1]["message_id"]}
# nothing left to send → idempotent no-op
assert await backend.add_group(rows, anchor, "chat_public:mem0:t2:key") == []
store.close()
async def test_stub_batches_never_block_real_rebuild_and_real_never_resends(tmp_path, monkeypatch):
"""Review repro 3: stub→mem0 on the same archive reorganizes; a real done
batch is never re-enqueued nor re-sent."""
install_fake(monkeypatch)
store = Store(tmp_path / "switch.db")
seed_turn(store, "t3", "另一条合成原话")
stub = SemanticMemory(store, StubBackend())
stub.enqueue_uncovered()
await stub.tick()
prior = dict(store.query_one("SELECT backend,state,entry_ids FROM sem_batch"))
assert prior["backend"] == "stub" and prior["state"] == "done"
ledger = tmp_path / "switch.sqlite3"
_make_ledger(ledger)
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=50, llm_cap=50))
live = SemanticMemory(store, backend)
assert live.enqueue_uncovered() == 1 # stub coverage does NOT block mem0
await live.tick()
batches = store.query("SELECT backend,state FROM sem_batch ORDER BY id")
assert [b["backend"] for b in batches] == ["stub", "mem0"]
assert batches[1]["state"] == "done"
hits = await backend.search("另一条合成原话")
assert hits # the real backend actually holds the row now
# a real completed batch is never re-sent
assert live.enqueue_uncovered() == 0
assert await backend.add_group(
[{"message_id": store.query_one("SELECT id FROM chat_message WHERE turn_id='t3'")["id"],
"turn_id": "t3", "role": "user", "text": "另一条合成原话"}],
{"turn_ids": ["t3"], "turn_roles": [], "input_sha256": "x"},
store.query_one("SELECT batch_key FROM sem_batch WHERE backend='mem0'")["batch_key"],
) == []
store.close()
async def test_embed_daily_cap_enforced_in_same_reserve(tmp_path):
"""Execution-table C per-day embed top applies inside the atomic reserve."""
ledger = tmp_path / "daily.sqlite3"
_make_ledger(ledger)
guard = EmbedLedgerGuard(ledger, cap=50, daily_cap=1)
guard.settle(guard.reserve(1, "search"), "search")
assert guard.used() == 1
with pytest.raises(RuntimeError, match="semantic_embed_budget_daily_exhausted"):
guard.reserve(1, "search") # lifetime cap 50 still has room — day top hit
async def test_mem0_internal_llm_goes_through_the_unified_request_gate(tmp_path, monkeypatch):
"""The SDK's hidden extraction LLM reserves request count + tokens in the
product guard (category 'semantic') before the wire."""
install_fake(monkeypatch)
ledger = tmp_path / "unified.sqlite3"
_make_ledger(ledger)
request_guard = BudgetGuard(engineering_budget(daily_requests={"semantic": 1}))
backend = Mem0Backend(config_for(tmp_path, ledger, embed_cap=50, llm_cap=50),
request_guard=request_guard)
store = Store(tmp_path / "unified.db")
rows = seed_turn(store, "t4", "统一闸测试")
await backend.add_group(rows, {"turn_ids": ["t4"], "turn_roles": [["t4", "user", 1]],
"input_sha256": "x"}, "u1")
assert request_guard.requests_used("semantic") == 1
assert request_guard.used > 0 # conservative tokens pre-allocated too
with pytest.raises(Exception, match="budget_requests_exhausted:semantic"):
seed_turn(store, "t5", "第二次")
rows2 = [{"message_id": store.query_one(
"SELECT id FROM chat_message WHERE turn_id='t5'")["id"],
"turn_id": "t5", "role": "user", "text": "第二次"}]
await backend.add_group(rows2, {"turn_ids": ["t5"], "turn_roles": [["t5", "user", 2]],
"input_sha256": "x"}, "u2")
store.close()
def test_authority_block_refuses_a_fresh_file_bypassing_the_freeze(tmp_path):
"""A new empty ledger must not re-open the frozen 498/500 headroom."""
authority_ledger = tmp_path / "authoritative.sqlite3"
conn = sqlite3.connect(str(authority_ledger))
with conn:
conn.execute("CREATE TABLE embed_ops (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT,"
" pid INTEGER, kind TEXT, items INTEGER, note TEXT)")
conn.execute("INSERT INTO embed_ops(ts,pid,kind,items,note) VALUES"
"('2026-10-20T01:00:00Z',1,'dense',498,'product:frozen')")
conn.close()
import hashlib
rows = sqlite3.connect(str(authority_ledger)).execute(
"SELECT ts,pid,kind,items,note FROM embed_ops ORDER BY id LIMIT 1").fetchall()
digest = hashlib.sha256(
"\n".join("|".join(str(c) for c in row) for row in rows).encode()).hexdigest()
authority = {"prefix_rows": 1, "prefix_sha256": digest, "min_used": 498}
# the authoritative file itself passes
verify_ledger_authority(authority_ledger, authority)
# a different (fresh) file with an approved cap is refused
fresh = tmp_path / "fresh-bypass.sqlite3"
conn = sqlite3.connect(str(fresh))
with conn:
conn.execute("CREATE TABLE embed_ops (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT,"
" pid INTEGER, kind TEXT, items INTEGER, note TEXT)")
conn.close()
with pytest.raises(SemanticBudgetError):
verify_ledger_authority(fresh, authority)
with pytest.raises(SemanticBudgetError, match="ledger_authority_missing_file"):
verify_ledger_authority(tmp_path / "not-there.sqlite3", authority)
async def test_build_semantic_mem0_fail_closed_on_authority_mismatch(tmp_path, monkeypatch):
install_fake(monkeypatch)
fresh = tmp_path / "fresh2.sqlite3"
conn = sqlite3.connect(str(fresh))
with conn:
conn.execute("CREATE TABLE embed_ops (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT,"
" pid INTEGER, kind TEXT, items INTEGER, note TEXT)")
conn.execute("CREATE TABLE llm_ops (id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT,"
" pid INTEGER, kind TEXT, items INTEGER, note TEXT)")
conn.close()
store = Store(tmp_path / "fc.db")
assert build_semantic(
store, mode="mem0",
config=config_for(tmp_path, fresh, embed_cap=10, llm_cap=10,
authority={"prefix_rows": 1, "prefix_sha256": "0" * 64,
"min_used": 498}),
) is None # fail closed: semantic layer off, never a silent stub swap
store.close()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""HEL-882 · 发布包离线可执行检查(零真实调用、仅本地回环、临时目录)。
验证 deploy/hel874-release.md 的发布命令在离线条件下真正可执行:
R1 全新 current 空目录的 checkout 路径(对本地仓库 file:// clone,机械等价
于发布卡的 origin clone,不触网)。
R2 服务命令:仅 requirements.lock 依赖的 venv + PYTHONPATH=<current>/src 能
起 ``python -m cybergril.api``(echo provider,零真实网关请求)。
R3 未批准预算默认拒绝:随包 models.yaml 的请求上限全 0,创建/对话在任何
provider 调用前拒绝(fail-closed 硬闸)。
R4 批准后可运行:填了批准值的覆盖配置下,创建→对话→消息读回全链走通
(echo 回复,仍零真实网关)。
R5 独立数据目录与保留数据回退:SIGTERM 停止后数据原样保留,重开
epoch+1、档案与消息仍在。
R6 mem0 同进程导入现状:lock-only 3.11 venv 的准确缺项(不下载;解决路径
见 deploy/hel874-release.md §4)。
运行(在仓库根):
python3 tools/check_release_offline_hel882.py --venv /path/to/.venv311 [out.json]
不触 8300–8350/8360 以外任何端口(本脚本随机回环端口),不启动真实循环,
不合并,不部署。
"""
from __future__ import annotations
import json
import os
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
REPORT: dict = {"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
FAILURES: list[str] = []
def check(name: str, ok: bool, detail: str = "") -> bool:
REPORT.setdefault("checks", []).append({"name": name, "ok": bool(ok), "detail": detail})
if not ok:
FAILURES.append(f"{name}: {detail}")
print(("PASS " if ok else "FAIL ") + name + (f" — {detail}" if detail else ""))
return bool(ok)
def free_port() -> int:
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
def api(port: int, path: str, method: str | None = None, body: dict | None = None) -> tuple[int, dict]:
status, text = api_raw(port, path, method, body)
try:
return status, json.loads(text or b"{}")
except json.JSONDecodeError:
return status, {"_raw": text.decode("utf-8", "replace") if isinstance(text, bytes) else text}
def api_raw(port: int, path: str, method: str | None = None, body: dict | None = None) -> tuple[int, bytes]:
url = f"http://127.0.0.1:{port}{path}"
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(url, data=data, method=method or ("POST" if data else "GET"),
headers={"content-type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=20) as response:
return response.status, response.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
def wait_health(port: int, seconds: float = 30.0) -> dict | None:
deadline = time.time() + seconds
while time.time() < deadline:
try:
status, payload = api(port, "/api/health")
if status == 200:
return payload
except Exception: # noqa: BLE001 — startup race
pass
time.sleep(0.4)
return None
def start_service(current: Path, venv_python: Path, data: Path, port: int,
models: Path) -> subprocess.Popen:
env = dict(os.environ,
PYTHONPATH=str(current / "src"),
CYBERGRIL_DB=str(data / "companion.db"),
CYBERGRIL_MODELS=str(models),
CYBERGRIL_WEB=str(current / "web" / "dist"),
CYBERGRIL_R2_ENABLED="1",
CYBERGRIL_SEMANTIC="stub",
CYBERGRIL_HOST="127.0.0.1",
CYBERGRIL_PORT=str(port))
return subprocess.Popen([str(venv_python), "-m", "cybergril.api"], cwd=current, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
def main() -> None:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--venv", required=True, help="lock-only 3.11 venv python")
parser.add_argument("--skip-clone", action="store_true",
help="reuse an existing prepared current/ via --current")
parser.add_argument("--current", default=None)
parser.add_argument("out", nargs="?", default="hel882-release-check.json")
args = parser.parse_args()
venv_python = Path(args.venv).resolve()
REPORT["venv_python"] = str(venv_python)
REPORT["venv_python_version"] = subprocess.run(
[str(venv_python), "-c", "import sys;print(sys.version.split()[0])"],
capture_output=True, text=True).stdout.strip()
tmp = Path(tempfile.mkdtemp(prefix="hel882-release-"))
release = tmp / "cybergril-hel874"
(release / "data").mkdir(parents=True)
current = Path(args.current) if args.current and args.skip_clone else release / "current"
# R1 — checkout into a FRESH empty dir (file:// clone ≡ the documented origin clone)
if args.skip_clone:
check("R1_checkout_fresh_dir", current.is_dir(), f"reused {current}")
else:
cloned = subprocess.run(
["git", "clone", "--quiet", "--no-hardlinks", f"file://{REPO}", str(current)],
capture_output=True, text=True, cwd=tmp)
check("R1_checkout_fresh_dir", cloned.returncode == 0 and (current / "src").is_dir(),
cloned.stderr.strip()[:200] or f"cloned tracked source into {current}")
# R2 — lock-only venv can import and serve the product (PYTHONPATH=<current>/src)
probe = subprocess.run(
[str(venv_python), "-c", "import cybergril.api, cybergril.memory.semantic"],
capture_output=True, text=True, cwd=current,
env=dict(os.environ, PYTHONPATH=str(current / "src")))
check("R2_service_imports_product", probe.returncode == 0, probe.stderr.strip()[:200])
# R6 — exact mem0 co-import status in the same lock-only venv (no downloads here)
mem0_probe = subprocess.run([str(venv_python), "-c", "import mem0"],
capture_output=True, text=True, cwd=current,
env=dict(os.environ, PYTHONPATH=str(current / "src")))
REPORT["mem0_import_in_release_venv"] = (
"ok" if mem0_probe.returncode == 0
else (mem0_probe.stderr.strip().splitlines() or ["unknown"])[-1])
check("R6_mem0_coimport_status_recorded", True,
f"import mem0 → {REPORT['mem0_import_in_release_venv']} "
"(resolution path in deploy/hel874-release.md §4; no download in this check)")
# offline models: echo providers, zero real gateway; ships-as-is caps (all 0)
offline_models = current / "config" / "models.offline-check.yaml"
offline_models.write_text(
(current / "config" / "models.yaml").read_text(encoding="utf-8").replace(
"provider: openai_compat", "provider: echo"), encoding="utf-8")
# R3 — fail-closed defaults: with the shipped budget (all request caps 0)
# companion creation refuses BEFORE any provider call
port = free_port()
data = release / "data"
service = start_service(current, venv_python, data, port, offline_models)
try:
health = wait_health(port)
check("R2_service_serves_health", health is not None and health.get("ok") is True,
f"version={health['version'] if health else 'NO-HEALTH'} "
f"semantic={health['backend']['semantic_backend'] if health else 'NO-HEALTH'}")
if health:
status, _ = api(port, "/api/companion", body={
"name": "离线检查", "birth": "2000-01-01", "hour": "午时",
"relation": "朋友", "nickname": "", "boundary": ""})
check("R3_create_status_recorded", status in (201, 409),
f"create status={status}(409=前次运行的档案仍在,也是保留数据的表现)")
# A dialogue turn under unapproved caps must come back as an honest
# chat.error SSE event, never a provider reply
status, raw = api_raw(port, "/api/chat", body={
"text": "离线检查:未批预算下这句话必须被拒绝", "turn_id":
"00000000-0000-4000-8000-000000000001", "epoch": health.get("epoch")})
check("R3_dialogue_refused_honestly", status == 200 and b"chat.error" in raw,
f"http={status} events={raw[:120]!r}")
finally:
service.send_signal(signal.SIGTERM)
service.wait(timeout=15)
# R4 — with approved caps filled in (echo provider: zero real gateway),
# the full create→chat→messages chain runs
approved = release / "models.approved-check.yaml"
approved.write_text(
offline_models.read_text(encoding="utf-8").replace(
" daily_requests:\n dialogue: 0", " daily_requests:\n dialogue: 100"
).replace(" life: 0", " life: 100").replace(" background: 0", " background: 100"
).replace(" semantic: 0", " semantic: 100"), encoding="utf-8")
port2 = free_port()
service2 = start_service(current, venv_python, data, port2, approved)
try:
health2 = wait_health(port2)
if check("R4_service_restarts_on_same_data", health2 is not None,
f"epoch={health2.get('epoch') if health2 else None}"):
check("R5_epoch_bumped_on_reopen", health2["epoch"] > (health or {}).get("epoch", 0),
f"{(health or {}).get('epoch')} → {health2['epoch']}")
status, created = api(port2, "/api/companion", body={
"name": "离线检查", "birth": "2000-01-01", "hour": "午时",
"relation": "朋友", "nickname": "", "boundary": ""})
created.get("id")
check("R4_companion_created_offline", status in (201, 409), f"status={status}")
status, raw = api_raw(port2, "/api/chat", body={
"text": "批准额度内的一句离线检查", "turn_id":
"00000000-0000-4000-8000-000000000002", "epoch": health2["epoch"]})
check("R4_dialogue_stream_http", status == 200 and b"chat.final" in raw,
f"status={status} events={raw[:120]!r}")
status, messages = api(port2, "/api/messages")
roles = [m["role"] for m in messages.get("messages", [])]
check("R4_messages_roundtrip", "user" in roles, f"roles={roles}")
finally:
service2.send_signal(signal.SIGTERM)
service2.wait(timeout=15)
# R5 — data preserved after full stop (kept for rollback / no reset)
check("R5_data_dir_preserved", (data / "companion.db").exists()
and any(data.iterdir()), str(sorted(p.name for p in data.iterdir())))
import sqlite3
conn = sqlite3.connect(str(data / "companion.db"))
rows = conn.execute("SELECT count(*) FROM companion").fetchone()[0]
conn.close()
check("R5_companion_row_survives_restart", rows >= 1, f"companion rows={rows}")
REPORT["failures"] = FAILURES
REPORT["finished"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
out = Path(args.out)
out.write_text(json.dumps(REPORT, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"out": str(out), "failures": FAILURES}, ensure_ascii=False))
sys.exit(1 if FAILURES else 0)
if __name__ == "__main__":
main()
+71 -14
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env python3
"""HEL-874 · 实际固定 Mem0 SDK 合同隔离验证(零真实出站、零权威账写入)。
"""HEL-874/882 · 实际固定 Mem0 SDK 合同隔离验证(零真实出站、零真实嵌入、零权威账写入)。
在已安装的固定 mem0ai 2.2.1(2c81af3,HEL-857 hel857-venv)上,用真实
Qdrant 嵌入式向量库、真实 fastembed 本地嵌入(已有模型缓存)和 127.0.0.1
确定性假 OpenAI 网关,验证产品适配层 ``cybergril.memory.semantic.Mem0Backend``
的真实 SDK 合同:
Qdrant 嵌入式向量库、真实 OpenAI 客户端与 127.0.0.1 确定性假网关验证产品
适配层 ``cybergril.memory.semantic.Mem0Backend`` 的真实 SDK 合同。
HEL-882 边界修正(总管独立复现口径):**在 TextEmbedding 构造前**替换为
确定性 512 维向量替身(不加载任何模型、零真实嵌入推理),socket 层拒绝
一切非回环连接。本脚本历史版本(e978e87 及之前)只替换了 LLM 网关、未替换
fastembed 边界,读取缓存做过真实本地嵌入推理——勘正见
docs/rework/hel874/erratum-hel882.json;本版本起零真实模型、零真实嵌入。
C1 构造:真实 from_config + 嵌入式 qdrant 挂载(修复后不再开第二个同路径 client)。
C2 add:携带 user_id 作用域与锚点 metadata;payload 直读可回锚点。
@@ -42,15 +47,11 @@ from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO / "src"))
# 零出站:清代理、禁遥测;嵌入模型走已有本地缓存。
# 零出站:清代理、禁遥测。
for key in ("http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
os.environ.pop(key, None)
os.environ["NO_PROXY"] = "127.0.0.1,localhost"
os.environ["MEM0_TELEMETRY"] = "False"
CACHE = Path("/home/xiaomo/multica_workspaces/hello-af23464d222e/hel-857-2cf6dc7dfc87/workdir"
"/hel857-data/fastembed-cache")
if CACHE.exists():
os.environ["FASTEMBED_CACHE_PATH"] = str(CACHE)
REPORT: dict = {"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
FAILURES: list[str] = []
@@ -63,6 +64,50 @@ def check(name: str, ok: bool, detail: str = "") -> bool:
return bool(ok)
# ------------------------------------------------- HEL-882 确定性边界(先于一切构造)
def install_deterministic_boundary() -> None:
"""总管附件模式:确定性 512 维向量替身 + socket 禁非回环。
必须发生在任何 ``TextEmbedding(...)`` 构造之前——fastembed 的嵌入器在
``FastEmbedEmbedding.__init__`` 里就加载模型;事后替换只挡得住后续实例,
挡不住已发生的真实推理(e978e878 版本的真实嵌入即由此而来)。
"""
real_connect = socket.socket.connect
def connect(sock, address):
host = address[0] if isinstance(address, tuple) else str(address)
if host not in ("127.0.0.1", "::1", "localhost"):
raise AssertionError(f"verify boundary forbids non-loopback connection: {host}")
return real_connect(sock, address)
socket.socket.connect = connect
import mem0.embeddings.fastembed as embedding_module
import numpy as np
class DeterministicEmbedding:
"""sha256(text) 展开成 512 维:确定、零模型加载、逐条计数可见。"""
embedding_size = 512
calls = 0
def __init__(self, *args, **kwargs):
pass
def embed(self, texts, *args, **kwargs):
for text in ([texts] if isinstance(texts, str) else texts):
type(self).calls += 1
digest = hashlib.sha256(text.encode()).digest()
yield np.array([digest[i % 32] / 255.0 for i in range(512)], dtype=np.float32)
embedding_module.TextEmbedding = DeterministicEmbedding
REPORT["embedding_boundary"] = (
"deterministic 512-dimensional vectors installed before TextEmbedding "
"construction; no model loaded; zero real embeddings"
)
REPORT["external_network"] = "socket.connect rejects all non-loopback hosts"
# ---------------------------------------------------------------- 假 OpenAI 网关
class _Server(ThreadingHTTPServer):
daemon_threads = True
@@ -70,7 +115,7 @@ class _Server(ThreadingHTTPServer):
class FakeGateway(BaseHTTPRequestHandler):
requests: list[dict] = []
counter = {"n": 0}
counter: dict = {"n": 0}
lock = threading.Lock()
def log_message(self, *args): # silence
@@ -230,7 +275,7 @@ async def main_async() -> None:
except Exception as exc:
check("C1_construct", False, f"{type(exc).__name__}: {exc}")
REPORT["trace"] = traceback.format_exc()
finish(server)
finish(server, tmp)
return
store = Store(tmp / "product.db")
@@ -263,7 +308,8 @@ async def main_async() -> None:
except Exception as exc:
check("C2_add_anchor_payload", False, f"{type(exc).__name__}: {exc}")
# per-add real spend (verified live): phase-1 query embed + phase-3 batch
# per-add counted spend (identical under the deterministic boundary):
# phase-1 query embed + phase-3 batch
# embed of extracted texts + phase-7 entity-upsert embeds (0..N), 1 LLM call
embed_after_add = embed_used(ledger)
llm_after_add = len(FakeGateway.requests) - llm_before
@@ -386,14 +432,24 @@ async def main_async() -> None:
check("C5_llm_zero_refuses", False, f"{type(exc).__name__}: {exc}")
store.close()
finish(server)
# HEL-882: keep the accounting recoverable even though the temp dir is
# removed — the full ledger rows land in the evidence JSON.
REPORT["final_embed_rows"] = embed_rows(ledger)
REPORT["final_llm_used"] = llm_used(ledger)
finish(server, tmp)
def finish(server: _Server) -> None:
def finish(server: _Server, tmp: Path | None = None) -> None:
server.shutdown()
REPORT["finished"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
REPORT["gateway_requests_total"] = len(FakeGateway.requests)
REPORT["failures"] = FAILURES
import shutil
if tmp is not None:
REPORT["temp_dir"] = str(tmp) # recorded before cleanup for traceability
with contextlib.suppress(Exception):
shutil.rmtree(tmp, ignore_errors=True)
out = Path(sys.argv[1] if len(sys.argv) > 1 else "hel874-real-sdk-report.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(REPORT, ensure_ascii=False, indent=2), encoding="utf-8")
@@ -404,6 +460,7 @@ def finish(server: _Server) -> None:
def main() -> None:
install_deterministic_boundary() # must precede every TextEmbedding construction
install_facts()
import asyncio