HEL-911: 修好通话收尾报错并查清开口等待(候选,不合并) #14

Open
leefer wants to merge 5 commits from agent/2/8e99cc2cc592 into agent/agent/ab943ea62162
27 changed files with 2790 additions and 24 deletions
+393
View File
@@ -0,0 +1,393 @@
# HEL-913 · 统一体验运行包(固定版本,未部署、cap=0)
> 本目录是把已通过 HEL-912 审核的统一候选装成的**可交接运行包**:精确源码
> 版本、产物与哈希、脱敏配置模板、唯一启动方式、独立空资料空间、停止/
> 备份/保留资料回退说明、一次零真实调用的运行准备验证记录、真实记忆的
> 完整可审安装清单,以及语音窗口的唯一布防接线。**本卡未部署任何现网
> 服务、未监听 836x/8082/9880/8090、未改任何现有实例;实际启用与新用量
> 由总管统一向白栖知说明并获批准后再执行。**
## 0. 精确版本:产品基线 vs 运行包提交(不要混同)
- **已审产品基线**:`agent/2/8e99cc2cc592` @ `4a38106eef21c43ba24ab7d07992b1bfe539137d`
(HEL-911 接缝修复;HEL-912 审核「通过,无必须修复项」)。父线:
`407fc81`(HEL-910 窗口记录)← `958c696`(HEL-908 R1/R2 返工)。
**产品代码(src/web/config/tools/tests)以这个提交为准。**
- **运行包提交**:同一分支上、`4a38106` 之后的追加提交——只新增/修改
`deploy/hel913-unified/` 与 `docs/` 下文件,**不改任何产品代码**。交付
评论给出运行包精确 head;本文件自身就在该提交里,所以不能在文件内
写死自己的哈希。部署**照抄入口是运行包提交**(本目录随它一起走),
只 checkout `4a38106` 会没有本目录——这是收口修正点,别再踩。
- **部署前自校验**(两条都必须通过):
```bash
git merge-base --is-ancestor 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD && echo ok
git diff --stat 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD -- src web config tools tests # 必须为空
```
- 需求基准 v1.12(`docs/requirements/`),需求检查见 `handoff-HEL-913.json`。
## 1. 交付物清单(本目录)
| 文件 | 作用 |
| --- | --- |
| `README.md` | 本运行说明(唯一照抄入口;不跳旧 HEL-895 通用步骤) |
| `manifest.json` | 机器可读清单:产品基线、dist 哈希、环境引用、边界 |
| `dist-manifest.json` | web dist 8 文件 SHA256(复用 HEL-910 实测构建) |
| `models.template.yaml` | 脱敏模型/预算配置(**一切预算=0**;占位符标注待批写入位置) |
| `voice.template.yaml` | 脱敏语音配置(livekit_url 必须是 wss 桥地址,见 §5) |
| `worker.env.template` | 语音 worker 环境模板(含 ASR 窗口闸三项,默认未布防=拒绝一切 ASR) |
| `voice-window-arm.template.env` | 语音窗口一次性布防参数模板(默认全 0/未布防) |
| `api_ctl.sh` / `worker_ctl.sh` | 统一实例 API(单进程 HTTP+HTTPS)与语音 worker 启停(身份核验版) |
| `bridge_ctl.template.sh` | wss TLS 桥启停(获批语音窗口用;浏览器侧语音的前提) |
| `tts_ctl.template.sh` | 共享 TTS 9880 窗口内临时恢复/停回(只停自己起的 PID) |
| `force_end.template.sh` | 窗口到期强制收尾(`now` 立即 / `watch <epoch>` 到点;封窗停服) |
| `verify_offline.py` | 零真实调用运行准备验证(34 项;TestClient 进程内,不监听端口) |
| `test_ctl_scripts.sh` | 启停脚本离线替身检查(42 项;stub,零真实服务) |
| `mem0-cp311-wheels.txt` | 真实 Mem0 记忆栈 cp311 缺项轮清单(40 项,待批一次性下载) |
运行记录:`docs/rework/hel913/verify-offline-run.txt`(.12 产品 3.11 venv
实跑 34/34)、`docs/rework/hel913/ctl-checks-run.txt`(收口轮启停检查 42/42)。
## 2. 版本固定与产物复用
- **源码**:产品基线 `4a38106`(含 `requirements.lock` 19 项固定版本,
Python 3.11 实测)+ 运行包提交(仅本目录与 docs)。
- **web dist 复用**:`git diff 958c696 4a38106 -- web/` 为空(网页源码零变动),
直接复用 HEL-910 已实测部署的 dist(.12 `/opt/cybergril-hel910/current/web/dist`,
REVISION 标记 `958c6962…`),只读拷贝,不安装 node、不重建、不跳任何会
`npm install` 或创建旧端口实例的旧发布步骤(如 `deploy/hel895-release-candidate.md`
的通用总装流程——那不是本包的入口)。8 文件哈希见 `dist-manifest.json`
(2026-10-29 于 .12 逐文件实测),部署时核对一致即可。
- **解释器(既有环境引用,不新建、不增改)**:
- 产品 API:`.12 /opt/cybergril-hel879/current/.venv311`(Python 3.11.16,
requirements.lock 全量,fastapi 0.159.1 / uvicorn 0.54.0 / httpx 0.28.1 /
pydantic 2.14.1)。**只借用运行,绝不向它安装任何东西**(记忆栈装进
本实例自己的隔离 venv,见 §7)。
- 语音 worker:`.12 /opt/voice-agent/agent/venv`(livekit-agents 1.8.8,
含 silero VAD)。
- 证书:`.12 /opt/cybergril-voice-certs/cybergril-voice.{pem,key}`(既有
自签,覆盖 192.168.200.12;8361 与 wss 桥 8363 共用)。
- 本机 LiveKit `ws://127.0.0.1:7880`(常驻);TTS 9880 / 面板 8090 /
wss 桥 8363 只在获批窗口内启动(§5),未获批保持停机。
## 3. 目标实例布局(唯一布局;用户实例 data 必须为空)
```
/opt/cybergril-hel913/ # 实例根(名字可换;以下结构固定)
current/ # git checkout <运行包提交>(见 §0,不是 4a38106)
web/dist/ # 按 dist-manifest.json 核哈希后放入(只读复用件)
etc/
models.yaml # 由 models.template.yaml 复制,填入获批网关与预算
voice.yaml # 由 voice.template.yaml 复制,填入服务器本地值
semantic.yaml # (仅批准真实记忆时)由 config/semantic.example.yaml 复制,见 §7
data/ # 独立空资料空间:新库从零初始化
worker/.env # 由 worker.env.template 复制并填好(占位全清)
logs/
```
**不打包进她的正式档案**:任何历史测试库(8360 团队库、HEL-907/910 窗口库)、
聊天记录、LiveKit/TTS 凭据、自签证书私钥、agent venv 内实物。用户实例在用户
亲自创建前 `companion` 表必须为空(离线验证第 3 项复现该守门)。
## 4. 首次准备与启停(唯一照抄入口)
### 4.1 首次准备(获批部署窗口内执行一次;不真启动服务)
```bash
# 0) 取运行包提交(精确 head 见交付评论;下述校验两条都要过,见 §0)
git clone -b agent/2/8e99cc2cc592 ssh://git@192.168.200.36:222/leefer/CyberGril.git \
/opt/cybergril-hel913/current
cd /opt/cybergril-hel913/current
git checkout --detach <本轮交付评论的运行包完整提交号>
git merge-base --is-ancestor 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD
git diff --stat 4a38106eef21c43ba24ab7d07992b1bfe539137d HEAD -- src web config tools tests # 空
# 1) 目录骨架(固定布局)
mkdir -p /opt/cybergril-hel913/{etc,data,logs,worker}
# 2) 配置落位(占位符必须全部替换;预算值未经批准一律保持 0)
cp deploy/hel913-unified/models.template.yaml /opt/cybergril-hel913/etc/models.yaml
cp deploy/hel913-unified/voice.template.yaml /opt/cybergril-hel913/etc/voice.yaml
cp deploy/hel913-unified/worker.env.template /opt/cybergril-hel913/worker/.env
vi /opt/cybergril-hel913/etc/models.yaml /opt/cybergril-hel913/etc/voice.yaml /opt/cybergril-hel913/worker/.env
# 3) web dist 只读复用(核哈希后拷入;不装 node、不重建)
rsync -a /opt/cybergril-hel910/current/web/dist/ /opt/cybergril-hel913/current/web/dist/
python3 - <<'EOF'
import hashlib, json, pathlib
m = json.loads(pathlib.Path("deploy/hel913-unified/dist-manifest.json").read_text())
bad = [k for k, want in m.items()
if hashlib.sha256((pathlib.Path("web/dist")/k).read_bytes()).hexdigest() != want]
raise SystemExit(f"dist hash mismatch: {bad}" if bad else 0)
EOF
# 4) 未创建档案守门复验(进程内 TestClient,零监听零出站;34 项全过才继续)
PYTHONPATH=src /opt/cybergril-hel879/current/.venv311/bin/python \
deploy/hel913-unified/verify_offline.py --web web/dist
```
### 4.2 启动与停止(唯一方式;子命令只认 start|stop|status)
```bash
# API:单进程双入口(HTTP 8360 + HTTPS 8361 共用同一 app 状态;lifespan 只进一次)
cd /opt/cybergril-hel913/current
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/api_ctl.sh start
# 健康检查:curl -s http://127.0.0.1:8360/api/health(version=R4-assembly,
# semantic_backend=stub;llm.ok=false 是诚实状态——cap=0 且未配网关)
# 语音 worker(语音窗口获批后;接线与前置见 §5)
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh start
# 停止顺序:先 worker 后 API(语音会话先释放,再关统一进程)
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh stop
HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/api_ctl.sh stop
```
启停脚本安全口径(收口版,`test_ctl_scripts.sh` 42 项离线替身验证):
非法子命令拒绝退出 2,绝不静默当 start;重复 start 拒绝(不丢既有 pid
句柄);stop/失败收尾只杀**身份核验通过**(cmdline 含本启动器/模块名且
cwd 是本实例目录)的进程,PID 被复用时拒杀并报告;启动健康/注册检查
不过时清掉自己刚起的进程、保留日志退出 1;`worker_ctl.sh` 启动前拒绝
含未填 `<...>` 占位或解析失败的 `.env`。
同一数据目录**绝不允许**两个写入实例并存(双入口由 `deploy/voice/serve_dual_8360_8361.py`
在同一进程内保证;另起独立 uvicorn 会触发 lifespan 重入 fail-closed)。
## 5. 语音窗口唯一接线(获批窗口才动;默认全 0/未布防)
浏览器从 HTTPS(8361) 页面连 LiveKit **必须走 wss**(混合内容策略),
`voice.yaml` 的 `livekit_url` 因此必须是 wss 桥地址(默认拟 `wss://192.168.200.12:8363`,
`bridge_ctl.template.sh` 启停,与 8361 同证书、回环转发 7880)——**不是
可选件**;服务器内部(开房/删房/worker 注册)仍走 `livekit_internal` 的
本机明文 7880。窗口内各上限的唯一接线:
| 上限 | 接线(唯一) | 性质 |
| --- | --- | --- |
| ASR 音频总量 / 每通重连 | `worker/.env` 三项 `CYBERGRIL_ASR_WINDOW_ID`、`CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS`、`CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL`(空/0=拒绝一切 ASR;布防值见 `voice-window-arm.template.env`) | 程序硬闸,发送前拒绝(`src/cybergril/voice/asr_usage.py`,逐通落账) |
| 每通墙钟/房间寿命 | `voice.yaml` `session_ttl_seconds: 600` + token TTL 300;挂断即删房(失败留账重试) | 程序硬闸 + 到期失效 |
| TTS 段数 | `tts_ctl.template.sh` start 时记录 tts.log 起始字节,`grep -c "first chunk"` 对照窗口段数上限 | 监控口径(到顶即人工 force_end;程序不硬闸) |
| 窗口总时限 | `force_end.template.sh watch <deadline>` 到点强制收尾(DELETE 全部会话→worker→api→bridge→tts,各按专属句柄);更可靠的等价物是获批后装 systemd timer 调 `now` | 可执行停服,非口头承诺 |
窗口启动顺序:tts_ctl snapshot→start → bridge_ctl start → 改 `worker/.env`
布防三项并 worker_ctl start → api_ctl start(见 §4.2)→ force_end watch
挂上。窗口结束(到期/触线/人工):force_end now → 按 `seal_budget_hel879.py`
封账归档(**封账不解除**,旧三窗口余额也不结转进新窗)。
## 6. 备份与保留资料回退
- **发布/阶段归档**(本包口径,沿用 HEL-896 收口工具,一致性快照):
```bash
# 停服口径(推荐;目录内 SQLite 一并 checkpoint+逐表核对)
bash deploy/hel913-unified/api_ctl.sh stop
/opt/cybergril-hel913/venv311/bin/python tools/release_snapshot_hel896.py snapshot \
--name <阶段名> --db /opt/cybergril-hel913/data/companion.db \
--file /opt/cybergril-hel913/data --out /opt/cybergril-hel913/archives/
```
产物含逐文件 SHA256、代码精确提交、逐表行数与回退演练;旧归档不删。
- **回退**:停服 → 解包快照到新 data 目录(按 rollback_plan 源路径映射)→
`current` 切回上一运行包提交(`git checkout <旧head>`)→ 起服务 → `verify` 通过。
- **整实例撤销但保留资料**:`api_ctl.sh stop`,data/ 原样保留,重新启用 =
同一 data 目录再 start(epoch 递增、聊天记忆保留,离线验证第 11 组复现)。
- **CG-009 每日自动备份/用户手动完整导出(R6)尚未实现**,本节只是发布归档,
不得冒充 R6 完成。
## 7. 零真实调用运行准备验证(已执行,34/34;口径如实)
在 .12 产品 3.11 venv 对产品基线树实跑 `verify_offline.py`(进程内
TestClient,零监听、零出站——网关地址重写为 127.0.0.1:1 + provider.stream
出站哨兵双保险):
- 包完整性:Python 3.11.16、候选树 import、**4 个关键包版本打印**
(fastapi/uvicorn/httpx/pydantic——不是 19 项锁依赖的逐项核对)、记忆栈
四项缺置如实。
- 入口/创建守门:health 诚实(llm.ok=false)、空库 404 无伪造档案、预览
本地计算、创建 201、二次创建 409、底色落库与预览一致、persona 在 cap=0
下诚实 failed(无编造诠释)。
- 传输:SSE 对话在预算 0 下诚实 `chat.error`,零 provider 出站;失败轮如实
落库;同轮重试幂等拒绝。
- 日子与回忆:**空库上的**未分享生活服务端裁剪(told=1;未做正反种子对照,
不夸成实质权限全覆盖)、分页形状、未知回执/详情 404。
- 语音:status 诚实 voice=false;TTS 不可达时开房 503 拒绝(绝不建房);
未知会话 DELETE 404。
- 重开:epoch=2、身份/关系/聊天保留、无伪造回复。
- dist:GET / 出 index.html;8 文件哈希与 manifest 全符。
- worker 接线:agent venv(livekit-agents 1.8.8)下 `import cybergril.voice.worker`
通过(只读 import,未启动注册)。
**预算 0 下只能诚实拒绝的**:一切真实创建诠释/习惯、对话生成、生活事件
生成、判定/成长/gist、主动措辞、嵌入、语音开房。`/api/health` 的 200 不代表
模型已运行——`llm.ok=false` 才是诚实状态。
## 8. 真实记忆(Mem0)最少安装与接线 —— 完整可审清单(零安装零下载,待批)
### 8.1 现状(2026-10-29 只读实测,收口轮复核沿用)
- 服务器 .12 产品 `.venv311`(3.11.16)内 `mem0 / qdrant_client /
fastembed / onnxruntime` 四项均未安装;服务器全盘无 mem0 安装、无固定
tarball、无已建轮、无 fastembed 缓存、uv 缓存仅 28K。**没有另一个已
准备好的 3.11 兼容环境**;旧 3.10 `hel857-venv`(工作站)不能混入 3.11
进程。
- 工作站本地实物(未上服务器):固定源 tarball `mem0-2c81af3.tar.gz`
(sha256 `9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23`,
与 `deploy/mem0-stack.lock` 一致)、固定源已建轮
`mem0ai-2.2.1-py3-none-any.whl`(sha256
`698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db`)、
fastembed 模型缓存 92MB(Qdrant/bge-small-zh-v1.5 + bm25)、uv 缓存可
复用 3 轮(greenlet/pillow/truststore cp311)。
### 8.2 安装目标环境:本实例自己的隔离 venv(不是产品 .venv311)
**绝不向借用的 `/opt/cybergril-hel879/current/.venv311` 增改任何依赖。**
目标环境是 `/opt/cybergril-hel913/venv311`(服务器既有 python3.11 基座
新建,3.11.16),装两套东西:产品锁 19 项 + 记忆栈 41 项(40 缺轮 + 固定
源 mem0ai 轮)。装好后本实例 API 用它跑(`api_ctl.sh` 里
`HEL913_VENV=/opt/cybergril-hel913/venv311/bin/python`),与 hel879 实例
彻底隔离;`.venv311` 保持只读借用前的原样(部署后可直接改回借用口径,
两者兼容——同一 requirements.lock)。
### 8.3 一次性批准窗口内的完整命令(逐项哈希留证;任一步失败即停)
```bash
# W0 · 固定源身份核对(零下载;哈希不符立即停,禁用索引同名轮替代)
sha256sum <hel857-data>/mem0-2c81af3.tar.gz # 必须等于 9dcef930…2dda23
sha256sum <hel857-data>/mem0ai-2.2.1-py3-none-any.whl # 必须等于 698284b6…20e9db
# W1 · 获批下载窗口(工作站联网执行;cp311/manylinux2014_x86_64 定版)
mkdir -p wheels-product wheels-mem0
python3 -m pip download --only-binary :all: --implementation cp \
--python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 \
-r requirements.lock -d wheels-product/ # 产品锁 19 项
python3 -m pip download --only-binary :all: --implementation cp \
--python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 \
-c requirements.lock -r deploy/hel913-unified/mem0-cp311-wheels.txt -d wheels-mem0/ # 记忆栈 40 项
# 其中 greenlet/pillow/truststore 3 项可从工作站 uv 缓存离线复用
# (release-artifacts.json 记录),命中即从 wheels-mem0/ 删除同版本再放缓存件
cp <hel857-data>/mem0ai-2.2.1-py3-none-any.whl wheels-mem0/
( cd wheels-product && sha256sum * > ../wheels-product.sha256 )
( cd wheels-mem0 && sha256sum * > ../wheels-mem0.sha256 ) # 逐项留证入库
# W2 · 传输与落位(scp 到 .12;约 wheels 250–350MB + 模型缓存 92MB)
scp -r wheels-product wheels-mem0 wheels-product.sha256 wheels-mem0.sha256 <hel857-data>/fastembed-cache root@192.168.200.12:/opt/cybergril-hel913/
ssh root@192.168.200.12 'cd /opt/cybergril-hel913/wheels-product && sha256sum -c ../wheels-product.sha256 && \
cd ../wheels-mem0 && sha256sum -c ../wheels-mem0.sha256' # 传输后复核
# W3 · 隔离 venv 创建 + 仅本地轮安装(--no-index 全程,失败即停)
ssh root@192.168.200.12
/opt/cybergril-hel879/runtime/cpython-3.11.16/bin/python3.11 -m venv /opt/cybergril-hel913/venv311
/opt/cybergril-hel913/venv311/bin/python -m pip install --no-index \
--find-links /opt/cybergril-hel913/wheels-product -r /opt/cybergril-hel913/current/requirements.lock
/opt/cybergril-hel913/venv311/bin/python -m pip install --no-index \
--find-links /opt/cybergril-hel913/wheels-mem0 \
--find-links /opt/cybergril-hel913/wheels-product \
-c /opt/cybergril-hel913/current/requirements.lock \
mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1
# mem0ai 的安装来源必须是 W0 的固定源码轮(同版本号不同来源不互认)
# W4 · co-import 冒烟(同进程;失败即停,不降级)
cd /opt/cybergril-hel913/current
MEM0_TELEMETRY=False HF_HUB_OFFLINE=1 TRANSFORMERS_OFFLINE=1 PYTHONPATH=src /opt/cybergril-hel913/venv311/bin/python -c \
"import cybergril.api, mem0, qdrant_client, fastembed; print('co-import ok')"
# W5 · 模型缓存落位(真实本地嵌入零下载)
mkdir -p /opt/cybergril-hel913/data/sem
cp -a /opt/cybergril-hel913/fastembed-cache /opt/cybergril-hel913/data/sem/fastembed-cache
# api_ctl.sh 通过 FASTEMBED_CACHE_PATH 指向它,模型资源禁止联网获取。
# W6 · 清理暂存(轮文件传输件用毕即删;哈希清单留档)
rm -rf /opt/cybergril-hel913/wheels-product /opt/cybergril-hel913/wheels-mem0 /opt/cybergril-hel913/fastembed-cache
```
**空间/影响(.12 实测口径估)**:`venv311` 成品约 450–550MB(产品 19 项
约 120–150MB + 记忆栈约 330–400MB,参照 3.10 同栈实测 366MB)+ 传输暂存
约 350MB(W6 删除)+ 模型缓存 92MB(常驻 data/sem)。首次本地嵌入零下载
(缓存就位)。**当前不触发任何嵌入**:装完不批 B 预算时
`CYBERGRIL_SEMANTIC=stub`,长期检索走词面 FTS,不以 stub 冒充真实 Mem0。
### 8.4 记忆账本与配置接线(新空用户库续账,不搬测试聊天)
- `CYBERGRIL_SEMANTIC`(api_ctl.sh 的 `HEL913_SEMANTIC`):`stub`(默认,
全链可跑、非语义)→ 批准后 `mem0`(真实)。
- 配置:`cp config/semantic.example.yaml /opt/cybergril-hel913/etc/semantic.yaml`,
启动器显式传 `CYBERGRIL_SEMANTIC_CONFIG=$BASE/etc/semantic.yaml`(不再误读仓库默认路径)。
`MEM0_TELEMETRY=False`、`FASTEMBED_CACHE_PATH=$BASE/data/sem/fastembed-cache`、
`HF_HUB_OFFLINE=1`、`TRANSFORMERS_OFFLINE=1` 由启动器在 import 前设置。
填 `ledger_path=/opt/cybergril-hel913/data/embed_ledger.sqlite3`、
`vector_root/history_root=/opt/cybergril-hel913/data/sem/{vec,hist}`、
网关三键(与 models.yaml 同源)、`authority` 块。
- **权威旧账续账(498 已用 / 原批 500 / 冻结 2 不动)**:把 HEL-857 系列
唯一权威嵌入账文件复制为实例 `data/embed_ledger.sqlite3`(只复制账本,
**不搬任何测试聊天/向量**——聊天在旧测试库,向量在新库从零生成);
`authority` 按 `config/semantic.example.yaml` 算式填
`prefix_rows: 181`、`prefix_sha256: <权威账前 181 行逐行摘要,总管按账现值填>`、
`min_used: 498`。批准 B 嵌入 180 条后写累计口径
`embed_cap: 680`、`embed_reserve: 2`(可用 = 680−498−2 = 180;**禁止**
把 180 直接填成 cap、禁止动冻结 2 条;缺/错 authority 在构造前拒绝,
指向新空文件重开余额同样被拒——`src/cybergril/memory/semantic.py`
`verify_ledger_authority`)。
- **不把旧实验余额当新用量**:39/60(HEL-857 真实请求旧账)与旧三窗口
余额一律不结转;用户实例的 LLM/请求账从 0 起记(自己的 SQLite 日账)。
- **副作用如实分开**:真实 embed(写入向量库+权威账)、SDK 内部提取 LLM
(`llm_ops` 独立 cap + 统一请求闸 "semantic" 类)、co-import 冒烟(零
调用,只 import)——三者互不冒充;当前阶段三者全部 0。
## 9. 本包不做的事
不部署、不监听现网端口、不恢复 9880/8090/836x/8082、不触 4080、不下载任何
依赖/模型/浏览器、不改模型/认证/运行时绑定、不合并 PR、不替换正式界面、
8300/8310/8320/8350 与既有资料不动、8340/ComfyUI 保持停机、不向
`/opt/cybergril-hel879/current/.venv311` 增改依赖。旧三次测试窗口余额不
结转;一切提额、安装、部署都是待批准建议。
## 10. 总管独立取件勘误与最终执行约束(2026-10-29)
本节优先于上文笼统模板描述,当前仍全部待批,零真实调用。
- 运行包基线8ea5fdc之后仅机械收口:补语义配置路径/缓存/禁遥测离线变量;
force_end按真实持久化sessions列表取session_id;补桥/TTS非法命令拒绝和失败收尾;
传输hash清单与目标工作目录、既有3.11解释器路径纠正;每通TTL改600秒与10分钟一致。
- 40项记忆缺轮与产品19项锁是现有版本清单。下载使用两份requirements合并约束,
不得让第二次下载/安装解析器静默换掉产品锁版本:W1增加`-c requirements.lock`,
W3记忆安装同时传`--find-links .../wheels-product -c .../requirements.lock`。
解析冲突/缺cp311轮即停并留首错,不自动换版本、不引入新模型。
- B记忆日顶明确为embed_daily=60、llm_daily=40(SDK提取另受累计llm_cap=60),
authority/累计embed_cap=680、reserve=2原样。语义日账按UTC(北京时间08:00)
切日,产品对话/生活日账显式TZ=Asia/Shanghai按北京时间00:00切日,不能混说。
- 不运行测试脚本里的失管sleep替身作为长期任务。未来实际截止保护用获批后的
systemd一次性定时服务(服务/日志/停机句柄可查);不能把本轮本地watch留到结束后。
部署卡执行`systemd-run --unit=cybergril-hel913-expiry --on-calendar="<明确UTC截止时间>" \
/usr/bin/env HEL913_BASE=/opt/cybergril-hel913 /usr/bin/bash \
/opt/cybergril-hel913/current/deploy/hel913-unified/force_end.template.sh now`,
放行前必须核`systemctl list-timers cybergril-hel913-expiry.timer`及临时实例停止句柄。
这是未来待批动作,本轮没有创建服务/定时器。
- 语音为团队陪同的3次预约短用,每次600秒,总ASR1800秒,建房尝试最多6次,
每通ASR重连最多2次,TTS60段及声音登记+试听合计10次由当班执行者监督计数。
这些软上限不冒充程序硬闸;不在无人值守时开启语音窗口。
TTS段顶/语音配额到顶只结束语音,B文字生活仍可用;到期/用户叫停才停全实例。
自定义声音需要既有8090面板的实例专属启动句柄与前后快照,
复用HEL907已有面板启动方法,不动8081;句柄或快照未备齐不放行上传/试听,
默认已有工程声音仍能通话。共享voices列表需恢复前快照,自定义参考材料在独立实例保留。
- 到期脚本负责停本实例入口与自己启动的语音服务。封账归档、LiveKit只读核空、
voices快照恢复由当班执行者另行执行并核实,不能把日志里的done当全部清理完成。
清理失败保留原始错误/账/日志,不解除封账、不重启服务来“证明已停”。
自定义声音细分额度(同一次待批方案):最多登记2段参考音频,每段由陪同人员先核≤20秒;
自动转写最多2次/合计40秒,单列监督账,不冒充通话ASR的1800秒硬闸。
最多试听8次;登记2次+试听8次共10次。试听合成并入60段TTS总上限。
两类ASR合计最大1840秒(通话1800+参考40);失败/重试同样记账,不返余额。
本轮未执行登记、转写或试听。
### 最终统一预算勘误:团队先验真实记忆,再交用户(总管)
此条取代上文A“0嵌入”的旧建议,仅修改待批方案,当前真实额度仍0。
团队先用独立测试资料核真实Mem0接入,不把首次故障留给用户:
A的55请求/50万tokens内,最多10次用于SDK提取;先用最多20条新嵌入。
这10次/20条从同一批新申请的60次提取/180条嵌入中先扣,
不是另加额度;余量(至少50次提取/160条嵌入)留给用户期,使用实账计算。
两阶段共用唯一活动记账文件、串行写入,测试向量/档案与用户向量/档案分开,
只共用计数账,不拷贝测试聊天或测试记忆给用户。旧权威embed_ops前缀不改,
旧39/60实验的授权和账原封保存,不挪用其余额;新提取批建立独立可审计操作记录。
如旧llm_ops表已有用量,新60次必须按现值加增量并保存旧行,不可清表/重置;
操作前核旧基数并留证,实际新增始终≤60。团队检查最多2小时,从首个真实调用计时;
检查不通过则停止并报告首个错误,用户期不自动开始。
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# HEL-913 统一体验运行包 · 统一实例 API 启停(单进程 HTTP+HTTPS 双入口)
#
# 启动器 deploy/voice/serve_dual_8360_8361.py 保证 HTTP 与 HTTPS 共用同一
# API 进程/同一份状态(lifespan 只进一次)。本脚本只做本实例常规启停安全:
# - 子命令只认 start|stop|status,其他一律拒绝退出 2(不会静默当 start);
# - start 前核对 pid 句柄:本实例已在跑 → 拒绝重复 start(不覆盖句柄);
# 句柄 pid 活着但不属于本实例(PID 复用)→ 拒绝动手,提示人工处理;
# - stop/start 失败收尾只杀经身份核验的进程(/proc/<pid> 的 cmdline 含
# 本启动器且 cwd 是本实例代码目录),身份不符一律不杀;
# - start 健康检查不过 → 清掉自己刚起的进程、保留日志、退出 1。
#
# 用法: [ENV=..] bash deploy/hel913-unified/api_ctl.sh start|stop|status
# 环境变量(默认值按部署说明的实例布局):
# HEL913_BASE 实例根目录(含 current/ etc/ data/ logs/)
# HEL913_HTTP HTTP 端口(默认 8360)
# HEL913_HTTPS HTTPS 端口(默认 8361;语音页需要 secure context)
# HEL913_VENV 产品 3.11 venv python(默认 /opt/cybergril-hel879/current/.venv311/bin/python)
# HEL913_CERT / HEL913_KEY 证书路径(默认 /opt/cybergril-voice-certs/ 下既有证书)
set -u
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
CODE=${HEL913_CODE:-$BASE/current}
HTTP=${HEL913_HTTP:-8360}
HTTPS=${HEL913_HTTPS:-8361}
VENV=${HEL913_VENV:-/opt/cybergril-hel879/current/.venv311/bin/python}
CERT=${HEL913_CERT:-/opt/cybergril-voice-certs/cybergril-voice.pem}
KEY=${HEL913_KEY:-/opt/cybergril-voice-certs/cybergril-voice.key}
LOG=$BASE/logs/api.log
PIDF=$BASE/logs/api.pid
MARKER="serve_dual_8360_8361.py"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
# 进程身份核验:pid 存活、cmdline 含本启动器、cwd 是本实例代码目录。
# 任一不满足即视为“不是我们的进程”(含 PID 被系统复用),一律不动它。
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$CODE" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then
echo "api not running (no pid file)"
return 0
fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p is alive but is NOT this instance's api (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then
kill "$p"
sleep 3
if ours "$p"; then kill -9 "$p"; sleep 1; fi
fi
rm -f "$PIDF"
echo "api stopped"
return 0
}
start() {
local p
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
if ours "$p"; then
echo "REFUSED: api already running (pid $p) — stop first" >&2
else
echo "REFUSED: pid file has live pid $p that is not this instance's api (pid reuse?) — resolve $PIDF manually" >&2
fi
exit 1
fi
rm -f "$PIDF" # 死句柄:清掉再起
[ -d "$CODE" ] || { echo "REFUSED: code dir missing: $CODE" >&2; exit 1; }
[ -x "$VENV" ] || { echo "REFUSED: venv python not executable: $VENV" >&2; exit 1; }
[ -r "$CERT" ] && [ -r "$KEY" ] || { echo "REFUSED: cert/key not readable ($CERT / $KEY)" >&2; exit 1; }
mkdir -p "$BASE/logs" "$BASE/data"
cd "$CODE" || exit 1
set -a
export CYBERGRIL_DB=$BASE/data/companion.db
export CYBERGRIL_SEMANTIC=${HEL913_SEMANTIC:-stub}
export CYBERGRIL_R2_ENABLED=${HEL913_R2:-1}
export TZ=Asia/Shanghai
export CYBERGRIL_SEMANTIC_CONFIG=$BASE/etc/semantic.yaml
export MEM0_TELEMETRY=False
export FASTEMBED_CACHE_PATH=$BASE/data/sem/fastembed-cache
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export CYBERGRIL_MODELS=$BASE/etc/models.yaml
export CYBERGRIL_VOICE_CONFIG=$BASE/etc/voice.yaml
export CYBERGRIL_WEB=$CODE/web/dist
export PYTHONPATH=$CODE/src
set +a
nohup "$VENV" deploy/voice/serve_dual_8360_8361.py \
--http-port "$HTTP" --https-port "$HTTPS" --cert "$CERT" --key "$KEY" \
> "$LOG" 2>&1 &
p=$!
echo "$p" > "$PIDF"
echo "api starting (pid $p) http=$HTTP https=$HTTPS"
sleep 6
if curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" >/dev/null \
&& curl -sk --max-time 3 "https://127.0.0.1:$HTTPS/api/health" >/dev/null; then
echo "OK: $HTTP + $HTTPS healthy"
return 0
fi
echo "FAIL: health check did not pass — full log kept at $LOG; tail:" >&2
tail -20 "$LOG" >&2
# 失败收尾:只清自己刚起的这个进程(身份核验),日志保留
if ours "$p"; then
kill "$p"; sleep 2
ours "$p" && kill -9 "$p"
fi
rm -f "$PIDF"
echo "cleaned up failed start (log kept: $LOG)" >&2
exit 1
}
status() {
local p
p=$(pidfile_pid)
if [ -n "$p" ]; then
if ours "$p"; then echo "running pid $p (identity verified)"
elif kill -0 "$p" 2>/dev/null; then echo "pid $p alive but NOT ours (stale pid file?)"
else echo "pid $p not alive (stale pid file)"; fi
else
echo "no pid file"
fi
curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" && echo " <- health" \
|| echo "health not answering"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) usage ;;
esac
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# HEL-913 运行包 · wss TLS 桥启停模板(8363 → 127.0.0.1:7880;脱敏待批件)
#
# 浏览器从 HTTPS(8361) 页面连 LiveKit 必须走 wss(混合内容策略),本桥在新
# 端口终结 TLS 并只向回环 7880 转发(字节级,协议无关;WebRTC 媒体面不经
# 本桥)。与 8361 同一张自签证书。改编自 HEL-910 已实测 bridge_ctl.sh;
# 本文件默认不运行,获批语音窗口才使用。子命令只认 start|stop|status。
#
# 用法: [ENV=..] bash bridge_ctl.template.sh start|stop|status
# 环境变量: HEL913_BASE / HEL913_BRIDGE_PORT(8363) / HEL913_CERT / HEL913_KEY
set -u
case "${1:-}" in start|stop|status) ;; *) echo "invalid subcommand" >&2; exit 2 ;; esac
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
PORT=${HEL913_BRIDGE_PORT:-8363}
CERT=${HEL913_CERT:-/opt/cybergril-voice-certs/cybergril-voice.pem}
KEY=${HEL913_KEY:-/opt/cybergril-voice-certs/cybergril-voice.key}
LOG=$BASE/logs/bridge.log
PIDF=$BASE/logs/bridge.pid
MARKER="wss_bridge.example.py"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$BASE/current/deploy/voice" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then echo "bridge not running (no pid file)"; return 0; fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p alive but not this bridge (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then kill "$p"; sleep 2; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
echo "bridge stopped"
}
case "${1:-}" in
stop) stop; exit $? ;;
status)
p=$(pidfile_pid)
if [ -n "$p" ]; then
ours "$p" && echo "running pid $p (identity verified)" \
|| { kill -0 "$p" 2>/dev/null && echo "pid $p alive but NOT ours" || echo "pid $p not alive (stale)"; }
else
echo "no pid file"
fi
exit 0 ;;
esac
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
echo "REFUSED: bridge already running (pid $p)" >&2; exit 1
fi
rm -f "$PIDF"
[ -r "$CERT" ] && [ -r "$KEY" ] || { echo "REFUSED: cert/key not readable" >&2; exit 1; }
cd "$BASE/current/deploy/voice" || exit 1
nohup /usr/bin/python3 wss_bridge.example.py \
--listen "0.0.0.0:$PORT" --upstream 127.0.0.1:7880 \
--cert "$CERT" --key "$KEY" > "$LOG" 2>&1 &
echo $! > "$PIDF"
echo "bridge starting (pid $(cat "$PIDF")) on :$PORT"
sleep 3
timeout 8 openssl s_client -connect "127.0.0.1:$PORT" -servername 192.168.200.12 </dev/null 2>/dev/null \
| grep -q "TLSv1.3" && echo "OK: bridge TLS up" \
|| { echo "FAIL: TLS probe failed — check $LOG"; tail -5 "$LOG"; stop; exit 1; }
+10
View File
@@ -0,0 +1,10 @@
{
"assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
"assets/index-B8TdHoQm.css": "a0af3163f564f2ff9cfe7d995378b7ac436bfe2f9fabda9f370ca0aa30c2412f",
"assets/index-Vm-EFPLb.js": "81f55e20c5c2afe4673ccc9020264a2bf99b3c84998c275f03fa48fe4f940bb0",
"demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
"index.html": "8d84f131b6922fe92ac0c562f5fec73aacd9c6e4db2cfa28f6190b4246f9c8d0",
"vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626"
}
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 窗口到期强制收尾模板(脱敏待批件;默认不布防)
#
# 用途:获批体验/语音窗口到截止时点的**可执行**停服与封窗:先经统一实例
# API 逐个 DELETE 本实例房间(欠清理的房间会留在登记账重试),再按专属
# pid 句柄停 worker → api → wss 桥 → 共享 TTS(都不是我们的就如实报告,
# 绝不按进程名/端口扫杀,绝不触碰 8300/8310/8320/8350、7880/7881、10095、
# 8081 等共享服务)。改编自 HEL-910 已实测 force_end.sh/watchdog.sh。
#
# 用法:
# bash force_end.template.sh now # 立即收尾(幂等,可重复)
# bash force_end.template.sh watch <epoch秒> # 到点自动收尾(受监督后台任务;
# # 更可靠的等价物是获批后装
# # systemd timer 调本脚本 now)
# 环境变量: HEL913_BASE / HEL913_HTTP(8360)
set -u
set -o pipefail
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
HTTP=${HEL913_HTTP:-8360}
PKG=$BASE/current/deploy/hel913-unified
LOG=$BASE/logs/force_end.log
usage() { echo "usage: [ENV=..] bash $0 now|watch <deadline_epoch_seconds>" >&2; exit 2; }
force_end() {
mkdir -p "$BASE/logs"
ts() { date -u +%FT%TZ; }
{
echo "[$(ts)] force_end begin"
# 1) 结清本实例房间(DELETE 走统一实例所有者路径;失败房间留账重试)
for sid in $(python3 - "$BASE/data" <<'EOF' 2>/dev/null
import json, sys, pathlib
p = pathlib.Path(sys.argv[1]) / "companion-voice-sessions.json"
try:
d = json.loads(p.read_text())
except Exception:
raise SystemExit
# VoiceSessionService._persist_state stores sessions as a LIST, not a dict.
# Debts already follow bounded SDK cleanup; do not reset their retry budgets here.
for entry in d.get("sessions", []):
sid = entry.get("session_id", "")
if sid and all(c.isalnum() or c in "-_" for c in sid):
print(sid)
EOF
); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -X DELETE "http://127.0.0.1:$HTTP/api/voice/session/$sid" || echo err)
echo "[$(ts)] DELETE session $sid -> $code"
done
# 2) 按专属句柄逐个停(每步幂等;不是我们的如实报告)
bash "$PKG/worker_ctl.sh" stop 2>&1 | sed 's/^/ worker: /'
bash "$PKG/api_ctl.sh" stop 2>&1 | sed 's/^/ api: /'
bash "$PKG/bridge_ctl.template.sh" stop 2>&1 | sed 's/^/ bridge: /'
bash "$PKG/tts_ctl.template.sh" stop 2>&1 | sed 's/^/ tts: /'
echo "[$(ts)] force_end done(封账归档另由 seal_budget_hel879.py 按账执行,封账不解除)"
} >> "$LOG" 2>&1
}
case "${1:-}" in
now) force_end ;;
watch)
DL=${2:?deadline epoch seconds required}
case "$DL" in *[!0-9]*|'') usage ;; esac
mkdir -p "$BASE/logs"
echo "[$(date -u +%FT%TZ)] watch start, deadline $(date -u -d "@$DL" +%FT%TZ) (pid $$)" >> "$LOG"
while [ "$(date +%s)" -lt "$DL" ]; do sleep 30; done
echo "[$(date -u +%FT%TZ)] DEADLINE REACHED — force end" >> "$LOG"
force_end
;;
*) usage ;;
esac
+86
View File
@@ -0,0 +1,86 @@
{
"task": "HEL-913",
"product_baseline_head": "4a38106eef21c43ba24ab7d07992b1bfe539137d",
"package_head": "运行包提交 = 本文件所在分支 tip(交付评论给出精确 hash;本文件无法写死自身提交)。部署照抄入口是运行包提交,只 checkout 4a38106 会没有 deploy/hel913-unified/",
"package_only_change_check": "git diff --stat 4a38106 <package_head> -- src web config tools tests 必须为空(运行包只动 deploy/hel913-unified 与 docs)",
"branch": "agent/2/8e99cc2cc592",
"review": "HEL-912 01a187e8-1058-7a2d-84e7-bd1896f212cb passed, no must-fix items (product baseline)",
"requirements_lock": "requirements.lock (19 pinned, Python 3.11.16 verified; offline verify printed 4 key package versions only — not a per-item lock audit)",
"web_dist_reuse": {
"source": "/opt/cybergril-hel910/current/web/dist (REVISION 958c6962fb1f2a4462a6df794d893aee1fcb0667)",
"justification": "git diff 958c696 4a38106 -- web/ is empty",
"manifest": "deploy/hel913-unified/dist-manifest.json (8 files, sha256 measured on .12 2026-10-29)"
},
"environments": {
"api_python_default": "/opt/cybergril-hel879/current/.venv311 (Python 3.11.16) — borrow-run only, NEVER install into it",
"api_python_after_mem0": "/opt/cybergril-hel913/venv311 — isolated instance venv created only inside the approved one-shot window (README §8)",
"worker_python": "/opt/voice-agent/agent/venv (livekit-agents 1.8.8)",
"certs": "/opt/cybergril-voice-certs/cybergril-voice.{pem,key} (shared by 8361 and wss bridge 8363)",
"livekit": "ws://127.0.0.1:7880 internal (standing); browser MUST use wss bridge (8363) — not optional from HTTPS pages",
"tts_panel_bridge": "9880/8090/8363 only inside approved voice windows (README §5); stopped otherwise"
},
"package_files": [
"deploy/hel913-unified/README.md",
"deploy/hel913-unified/manifest.json",
"deploy/hel913-unified/dist-manifest.json",
"deploy/hel913-unified/models.template.yaml",
"deploy/hel913-unified/voice.template.yaml",
"deploy/hel913-unified/worker.env.template",
"deploy/hel913-unified/voice-window-arm.template.env",
"deploy/hel913-unified/api_ctl.sh",
"deploy/hel913-unified/worker_ctl.sh",
"deploy/hel913-unified/bridge_ctl.template.sh",
"deploy/hel913-unified/tts_ctl.template.sh",
"deploy/hel913-unified/force_end.template.sh",
"deploy/hel913-unified/verify_offline.py",
"deploy/hel913-unified/test_ctl_scripts.sh",
"deploy/hel913-unified/mem0-cp311-wheels.txt",
"docs/rework/hel913/verify-offline-run.txt",
"docs/rework/hel913/ctl-checks-run.txt",
"docs/hel913-capability-and-enablement.md"
],
"offline_verification": {
"where": ".12 product .venv311, candidate tree at /tmp/hel913-verify (cleaned after run); re-run by manager: 34/34 passed, dist 8-file hashes match",
"result": "34/34 checks passed; worker import ok under livekit-agents 1.8.8",
"zero_real_calls": "models base_url rewritten to 127.0.0.1:1 + provider.stream outbound sentinel; no listening sockets; no rooms; no gateway contact",
"scope_limits": "deps check printed 4 key versions only; unshared-life trimming verified on an empty DB (told=1) without positive/negative seed contrast — not claimed as full dependency or permission coverage",
"ctl_scripts": "test_ctl_scripts.sh 42/42 offline stub checks (invalid subcommand, duplicate start, pid-reuse refusal, failed-start cleanup, .env gating, templates default 0)"
},
"voice_window_wiring": {
"asr_hard_gate": "worker/.env CYBERGRIL_ASR_WINDOW_ID/_MAX_AUDIO_SECONDS/_MAX_CONNECT_RETRIES_PER_CALL (empty/0 = deny all ASR)",
"room_lifetime": "voice.yaml session_ttl_seconds=900 + token_ttl 300 + delete-on-hangup (bounded cleanup ledger)",
"tts_segments": "monitored via tts.log first-chunk count since window start (tts_ctl.template.sh records start byte); hard stop = window deadline",
"deadline": "force_end.template.sh now|watch <epoch> (DELETE sessions -> worker -> api -> bridge -> tts, pid-handle stops only)"
},
"memory_gap": {
"server_venv311": "mem0/qdrant_client/fastembed/onnxruntime all absent (verified 2026-10-29; never install into this borrowed venv)",
"no_other_311_env_on_server": true,
"local_artifacts": {
"mem0-2c81af3.tar.gz": "9dcef9303ebcec116eec6739f01b8b35bd24459de6a569686b4d19b9412dda23",
"mem0ai-2.2.1-py3-none-any.whl": "698284b6083ab8b7761454d65876a6d1bb983d278ad21a7bee38d760cb20e9db",
"fastembed_cache": "92MB (Qdrant/bge-small-zh-v1.5 + bm25)",
"hel857_venv": "Python 3.10.12 — must not mix into 3.11 process"
},
"install_target": "isolated /opt/cybergril-hel913/venv311 (python3.11 base + product lock 19 wheels + mem0 stack 40 wheels + fixed-source mem0ai wheel; all --no-index; complete auditable steps in README §8.3 with per-file sha256 evidence and fail-stop)",
"ledger_continuation": "authoritative embed ledger copied to instance data/embed_ledger.sqlite3 (ledger only — no test chats/vectors moved); authority prefix_rows=181, prefix_sha256 by manager, min_used=498, embed_cap=680 cumulative formula, frozen 2 untouched; 39/60 and old window balances never carried over"
},
"enablement_doc": "docs/hel913-capability-and-enablement.md §2 — single consistent pending-approval proposal: A/B/C separate accounts, all awaiting approval (manager does not self-approve A), B voice = 3 calls x <=10min = <=1800s ASR hard gate with per-cap consequences and executable window end, B/C boundary fixed",
"boundaries": "not deployed; nothing listening; caps all 0; no PR; existing instances and data untouched; no downloads this card; protected venvs untouched",
"coordinator_close_verification": "docs/rework/hel913/coordinator-close-checks.txt: 17/17 targeted offline checks; product baseline unchanged",
"file_sha256": {
"deploy/hel913-unified/README.md": "580e3100b207c8c0e3da9cddc644b00d7b3d66ae0d195ac058dba279f63e242e",
"deploy/hel913-unified/api_ctl.sh": "3eeff54fcdc1becb79d9fe63767eceee9209ed6239663ff7d0a3e90d27379c55",
"deploy/hel913-unified/bridge_ctl.template.sh": "43e951f01ceed07b9c1606cfc046aa1d34eefc8fad3a40c66db2de57f16c1d14",
"deploy/hel913-unified/dist-manifest.json": "c2ebc6e515096b535f5e08cce18dc9c4f104c1b2cabe3f3afb5fdd88f12d63db",
"deploy/hel913-unified/force_end.template.sh": "3f250942ff68e26e4a71750e17fd804fa57be921097d83ee76f1b5b0024fe893",
"deploy/hel913-unified/mem0-cp311-wheels.txt": "2a6fd3d287fe0713a2ac683584ee7f3723c83c236696f39551015ca2db358b94",
"deploy/hel913-unified/models.template.yaml": "0b8f051cedfcf0218929a652fed91b00762c33dd17fbb8f7eadcc50c65d4e837",
"deploy/hel913-unified/test_ctl_scripts.sh": "702f7b911598fc0433f63f250f2f1e58dec9d727dd7eb6ae8a773d521fcad5cd",
"deploy/hel913-unified/tts_ctl.template.sh": "f638f24a41f07cfef64e66eee996b4280fdd7aac80a5fc20be6d47a41b72cabf",
"deploy/hel913-unified/verify_offline.py": "86ea3da6f2c682ad8ee9a932f91d713e0778c61be5d4c15f916e38d15e3e66e4",
"deploy/hel913-unified/voice-window-arm.template.env": "4002b99d63fa556a5f1e8cb86bf74846e7b944571dd76ed40bfb549cf64f3307",
"deploy/hel913-unified/voice.template.yaml": "55e556d5df149ab4fa257640b4d87dc40bb43107a5e90279826516a7ec423f4c",
"deploy/hel913-unified/worker.env.template": "be80fc87cc04a379a776732ad94f392e08af4ecb9eaad4190a090f4071f40383",
"deploy/hel913-unified/worker_ctl.sh": "57fd028e3b0d932cdbde483925cc7e39a5b994fa53742f9350cc5fc22381077f"
}
}
@@ -0,0 +1,45 @@
# HEL-913 记忆栈 cp311 缺项轮清单(40 项;来源 docs/rework/hel879/sdk-audit/sdk-audit.json 闭包 55-产品已有)
# mem0ai 不在本表:只认固定源码轮 sha256 698284b6…(deploy/hel895-release-candidate.md §4 唯一入口)。
# 获批下载命令:pip download --only-binary :all: --implementation cp --python-version 3.11 \
# --platform manylinux2014_x86_64 --abi cp311 -r 本文件 -d wheels/ 逐项 sha256 留证。
# 其中 greenlet/pillow/truststore 三项已在工作站 uv 缓存命中(hel-879 release-artifacts 记录),可离线复用。
backoff==2.2.1
charset-normalizer==3.5.2
coloredlogs==15.0.1
distro==1.9.0
fastembed==0.9.1
filelock==4.1.0
flatbuffers==25.12.19
fsspec==2026.9.0
greenlet==3.5.6
grpcio==1.84.1
h2==4.4.1
hf-xet==1.7.0
hpack==4.2.0
httpcore2==2.14.0
httpx2==2.14.0
huggingface-hub==1.33.0
humanfriendly==10.0
hyperframe==6.1.0
jiter==0.18.0
loguru==0.7.3
mmh3==5.3.1
mpmath==1.3.0
numpy==2.2.6
onnxruntime==1.23.2
openai==3.37.0
pillow==12.3.0
portalocker==4.4.1
posthog==7.75.11
protobuf==6.33.6
py-rust-stemmers==0.1.8
pytz==2026.5
qdrant-client==1.20.0
requests==2.34.2
sniffio==1.3.1
sqlalchemy==2.0.55
sympy==1.14.0
tokenizers==0.23.3
tqdm==4.70.1
truststore==0.10.5
urllib3==2.8.0
@@ -0,0 +1,89 @@
# HEL-913 统一体验运行包 · models 配置模板(脱敏;本文件一切预算=0)
#
# 用法:复制到 <实例>/etc/models.yaml,按部署说明只改 base_url 指向获批网关、
# 按批准包写入预算值。未经批准一律保持 0(发送前拒绝,fail-closed)。
# 候选分支 config/models.yaml 的 tiers/routing 与本模板一致;本模板把
# base_url 换成占位符并保留 HEL-908 reasoning: buffer_turn 与 HEL-911/912
# 审核通过的调度修正(turn_detection=None 在代码侧,见 src/cybergril/voice/worker.py)。
tiers:
high:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 60.0
temperature: 0.85
reasoning: buffer_turn
standard:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 40.0
temperature: 0.7
reasoning: buffer_turn
low:
provider: openai_compat
model: <获批模型名>
base_url: <http://内网网关:端口/v1>
api_key_env: null
timeout: 20.0
temperature: 0.4
reasoning: buffer_turn
routing:
dialogue_main: high
dialogue_followup: standard
proactive_message: high
crisis: high
life_event: low
memory_gist: low
fact_extract: low
output_guard: low
pattern_detect: low
filler: none
tick: none
reach_out: none
avatar_drive: none
budget:
daily_tokens: 0
warn_ratio: 0.7
hard_stop_ratio: 0.95
daily_requests:
dialogue: 0
life: 0
background: 0
semantic: 0
# 用户实例永不配置一次性总量(total_requests/total_tokens 保持缺省):
# 一次性总量只属于独立团队测试实例,见 docs/budget-package-hel895.md §6。
asr:
provider: scripted
model: qwen3-asr-1.7b
language: zh
paralinguistic:
provider: scripted
model: sensevoice-small
window_ms: 1500
enabled: true
tts:
provider: silence
model: fun-cosyvoice3-0.5b
voice_ref: null
sample_rate: 16000
vad:
provider: energy
model: silero-v6
sample_rate: 16000
turn:
provider: heuristic
silence_min_ms: 150
silence_hard_end_ms: 800
embedding:
provider: openai_compat
model: qwen3-embedding-0.6b
base_url: null
api_key_env: null
dim: 1024
avatar:
route: vrm
renderer: three.js
lipsync: envelope
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 启停脚本离线替身检查(零真实服务、零真实端口、零出站)
#
# 用离线替身(stub 解释器 = 只休眠的脚本)与静态检查验证收口后的常规启停
# 安全:非法子命令拒绝、重复 start 拒绝、stop 的 PID 复用防护、启动失败
# 收尾清理、.env 占位/解析守门。不启动真实 API/worker/共享服务,不监听
# 任何端口(curl 只连本机高位死端口,必然失败,正是失败路径所需的输入)。
#
# 用法: bash deploy/hel913-unified/test_ctl_scripts.sh (在检出根目录)
set -u
PKG=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$PKG/../.." && pwd)
TMP=$(mktemp -d /tmp/hel913-ctl-test.XXXXXX)
PASS=0; FAIL=0
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT
ok() { PASS=$((PASS+1)); echo "PASS $1"; }
bad() { FAIL=$((FAIL+1)); echo "FAIL $1"; }
expect_rc() { # expect_rc <want> <got> <label>
[ "$2" = "$1" ] && ok "$3" || bad "$3 (want rc=$1 got rc=$2)"
}
# ---- S1. 静态:语法 + 包文件齐全 ----
for f in api_ctl.sh worker_ctl.sh bridge_ctl.template.sh tts_ctl.template.sh force_end.template.sh; do
bash -n "$PKG/$f" 2>/dev/null && ok "syntax $f" || bad "syntax $f"
done
for f in README.md manifest.json dist-manifest.json models.template.yaml voice.template.yaml \
worker.env.template verify_offline.py mem0-cp311-wheels.txt \
voice-window-arm.template.env test_ctl_scripts.sh; do
[ -f "$PKG/$f" ] && ok "package file $f" || bad "package file $f missing"
done
# ---- 沙盒实例布局(镜像 README §4 的目标布局) ----
BASE=$TMP/inst
mkdir -p "$BASE/current" "$BASE/worker" "$BASE/etc" "$BASE/data" "$BASE/logs"
CODE=$BASE/current; WDIR=$BASE/worker
touch "$BASE/etc/models.yaml" "$BASE/etc/voice.yaml"
printf 'dummy cert\n' > "$TMP/c.pem"; printf 'dummy key\n' > "$TMP/c.key"
# stub 解释器:保持 argv 不变(cmdline 保留 marker),留一行输出后休眠
STUB=$TMP/stub-python
printf '#!/usr/bin/env bash\necho "stub alive pid=$$ argv=$*" >&2\nsleep 600\n' > "$STUB"; chmod +x "$STUB"
HTTP=53881; HTTPS=53882
API_ENV="HEL913_BASE=$BASE HEL913_HTTP=$HTTP HEL913_HTTPS=$HTTPS HEL913_VENV=$STUB HEL913_CERT=$TMP/c.pem HEL913_KEY=$TMP/c.key"
alive_marker() { pgrep -f "$1" | while read -r p; do [ -d "/proc/$p" ] && echo "$p"; done; }
# ---- S2. 非法子命令拒绝(不静默当 start) ----
env $API_ENV bash "$PKG/api_ctl.sh" bogus >/dev/null 2>&1; expect_rc 2 $? "api_ctl invalid subcommand refused (rc=2)"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl invalid subcommand started nothing" || bad "api_ctl invalid subcommand left a pid file"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" frobnicate >/dev/null 2>&1
expect_rc 2 $? "worker_ctl invalid subcommand refused (rc=2)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "worker_ctl invalid subcommand started nothing" || bad "worker_ctl invalid subcommand left a pid file"
# ---- S3. api 启动失败收尾:健康不过 → 清自己、留日志、rc=1 ----
env $API_ENV bash "$PKG/api_ctl.sh" start >/dev/null 2>&1; expect_rc 1 $? "api_ctl failed start exits 1"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl failed start removed pid file" || bad "api_ctl failed start left pid file"
[ -s "$BASE/logs/api.log" ] && ok "api_ctl failed start kept log" || bad "api_ctl failed start lost log"
sleep 1
N=$(alive_marker "$STUB.*serve_dual_8360_8361" | wc -l)
[ "$N" = "0" ] && ok "api_ctl failed start left no stub process" || bad "api_ctl failed start left $N stub process(es)"
# ---- S4. api 重复 start 拒绝(活进程 + 有效句柄) ----
( cd "$CODE" && nohup "$STUB" deploy/voice/serve_dual_8360_8361.py --http-port $HTTP --https-port $HTTPS > "$BASE/logs/api.log" 2>&1 & echo $! > "$BASE/logs/api.pid" )
LIVEPID=$(cat "$BASE/logs/api.pid")
kill -0 "$LIVEPID" 2>/dev/null && ok "stub api process running (pid $LIVEPID)" || bad "stub api process failed to start"
env $API_ENV bash "$PKG/api_ctl.sh" start >/dev/null 2>&1; expect_rc 1 $? "api_ctl duplicate start refused (rc=1)"
[ "$(cat "$BASE/logs/api.pid" 2>/dev/null)" = "$LIVEPID" ] && ok "api_ctl duplicate start kept original handle" || bad "api_ctl duplicate start overwrote handle"
kill "$LIVEPID" 2>/dev/null; wait 2>/dev/null
# ---- S5. api stop 的 PID 复用防护:活 pid 但身份不符 → 拒杀 ----
sleep 600 & UNRELATED=$!
echo "$UNRELATED" > "$BASE/logs/api.pid"
env $API_ENV bash "$PKG/api_ctl.sh" stop >/dev/null 2>&1; expect_rc 1 $? "api_ctl stop refused reused pid (rc=1)"
kill -0 "$UNRELATED" 2>/dev/null && ok "unrelated process untouched" || bad "api_ctl stop killed an unrelated process!"
kill "$UNRELATED" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/api.pid"
# ---- S6. api stop 死句柄:直接清 ----
echo 999999999 > "$BASE/logs/api.pid"
env $API_ENV bash "$PKG/api_ctl.sh" stop >/dev/null 2>&1; expect_rc 0 $? "api_ctl stop with dead pid exits 0"
[ ! -f "$BASE/logs/api.pid" ] && ok "api_ctl stop removed stale pid file" || bad "api_ctl stop kept stale pid file"
# ---- S7. worker .env 守门:占位未填 → 拒绝 ----
cp "$PKG/worker.env.template" "$WDIR/.env"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl refuses .env with unfilled placeholders (rc=1)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "placeholder refusal started nothing" || bad "placeholder refusal left pid file"
# ---- S8. worker .env 守门:解析失败 → 拒绝 ----
printf 'CYBERGRIL_API_BASE=http://127.0.0.1:8360\nFOO=bar baz\n' > "$WDIR/.env"
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl refuses unparseable .env (rc=1)"
[ ! -f "$BASE/logs/worker.pid" ] && ok "parse refusal started nothing" || bad "parse refusal left pid file"
# ---- S9. worker 启动失败收尾(注册不过 → 清自己、留日志) ----
cat > "$WDIR/.env" <<'EOF'
CYBERGRIL_API_BASE=http://127.0.0.1:8360
CYBERGRIL_AGENT_NAME=cybergril-voice-agent
LIVEKIT_URL=ws://127.0.0.1:7880
LIVEKIT_API_KEY=filled
LIVEKIT_API_SECRET=filled
CYBERGRIL_WORKER_HEALTH_PORT=8082
CYBERGRIL_VOICE_STATE=/tmp/x/voice_state.json
CYBERGRIL_ASR_USAGE_LEDGER=/tmp/x/asr-usage.jsonl
CYBERGRIL_ASR_WINDOW_ID=
CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=0
CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=0
EOF
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl failed start exits 1"
[ ! -f "$BASE/logs/worker.pid" ] && ok "worker_ctl failed start removed pid file" || bad "worker_ctl failed start left pid file"
[ -s "$BASE/logs/worker.log" ] && ok "worker_ctl failed start kept log" || bad "worker_ctl failed start lost log"
sleep 1
N=$(alive_marker "$STUB.*cybergril.voice.worker" | wc -l)
[ "$N" = "0" ] && ok "worker_ctl failed start left no stub process" || bad "worker_ctl failed start left $N stub process(es)"
# ---- S10. worker 重复 start 拒绝 ----
( cd "$WDIR" && nohup "$STUB" -m cybergril.voice.worker start > "$BASE/logs/worker.log" 2>&1 & echo $! > "$BASE/logs/worker.pid" )
LIVEPID=$(cat "$BASE/logs/worker.pid")
env HEL913_BASE=$BASE HEL913_AGENT_VENV=$STUB bash "$PKG/worker_ctl.sh" start >/dev/null 2>&1
expect_rc 1 $? "worker_ctl duplicate start refused (rc=1)"
kill "$LIVEPID" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/worker.pid"
# ---- S11. worker stop 的 PID 复用防护 ----
sleep 600 & UNRELATED=$!
echo "$UNRELATED" > "$BASE/logs/worker.pid"
env HEL913_BASE=$BASE bash "$PKG/worker_ctl.sh" stop >/dev/null 2>&1; expect_rc 1 $? "worker_ctl stop refused reused pid (rc=1)"
kill -0 "$UNRELATED" 2>/dev/null && ok "worker stop left unrelated process alive" || bad "worker stop killed unrelated process!"
kill "$UNRELATED" 2>/dev/null; wait 2>/dev/null
rm -f "$BASE/logs/worker.pid"
# ---- S12. 模板可解析性:arm 模板是合法 KEY=value(可被 source,默认全 0) ----
if ( set -a; . "$PKG/voice-window-arm.template.env"; set +a
[ "${HEL913_ASR_MAX_AUDIO_SECONDS:-x}" = "0" ] \
&& [ -n "${HEL913_WINDOW_ID+x}" ] && [ -z "$HEL913_WINDOW_ID" ] \
&& [ "${HEL913_TTS_SEGMENT_CAP:-x}" = "0" ] ) 2>/dev/null; then
ok "voice-window-arm.template.env parses, defaults 0/unarmed"
else
bad "voice-window-arm.template.env unparseable or non-zero default"
fi
echo
echo "$PASS passed, $FAIL failed"
[ "$FAIL" = "0" ]
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# HEL-913 运行包 · 共享 CosyVoice TTS 9880 窗口内临时恢复/停回模板(脱敏待批件)
#
# 只在获批语音窗口内使用;窗口结束必须 stop 停回(force_end.template.sh 会调
# 本脚本 stop)。只按自己写的 pid 句柄停止,快照先行;9880 已被别人启动时
# 不碰。改编自 HEL-910 已实测 tts_ctl.sh。子命令只认 start|stop|status|snapshot。
#
# 用法: [ENV=..] bash tts_ctl.template.sh start|stop|status|snapshot
# 环境变量: HEL913_BASE / HEL913_TTS_VENV(/opt/voice-agent/cosyvoice/venv/bin/python)
set -u
case "${1:-}" in start|stop|status|snapshot) ;; *) echo "invalid subcommand" >&2; exit 2 ;; esac
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
CC=${HEL913_TTS_HOME:-/opt/voice-agent/cosyvoice}
TTSVENV=${HEL913_TTS_VENV:-$CC/venv/bin/python}
PIDF=$BASE/logs/tts9880.pid
SNAP=$BASE/logs/tts9880-before.txt
usage() { echo "usage: [ENV=..] bash $0 start|stop|status|snapshot" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"server.py"*) ;; *) return 1 ;; esac
[ "$cw" = "$CC" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
case "${1:-}" in
status)
p=$(pidfile_pid)
if [ -n "$p" ]; then ours "$p" && echo "running pid $p (ours)" || echo "pid $p not verified as ours"; else echo "not started by us"; fi
curl -s --max-time 3 http://127.0.0.1:9880/health && echo " <- health" || echo "health not answering"
exit 0 ;;
snapshot)
mkdir -p "$BASE/logs"
{ echo "ts=$(date -u +%FT%TZ)"
if curl -s --max-time 3 http://127.0.0.1:9880/health >/dev/null 2>&1; then echo "was=up"; else echo "was=down"; fi
ss -ltn 2>/dev/null | grep ':9880 ' || echo "port9880=free"; } > "$SNAP"
cat "$SNAP"; exit 0 ;;
stop)
p=$(pidfile_pid)
if [ -z "$p" ]; then echo "not ours to stop (no pid file)"; exit 0; fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p alive but not this tts (pid reuse?) — not killing; resolve $PIDF manually" >&2
exit 1
fi
if ours "$p"; then kill "$p"; sleep 5; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
echo "tts stopped (pid $p)"
exit 0 ;;
esac
# start(仅 start 子命令落到这里)
mkdir -p "$BASE/logs"
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
echo "REFUSED: live pid handle already exists; inspect/stop first" >&2; exit 1
fi
if curl -s --max-time 3 http://127.0.0.1:9880/health >/dev/null 2>&1; then
echo "9880 already up — NOT ours, not touching"; exit 0
fi
if ss -ltn 2>/dev/null | grep -q ':9880 '; then
echo "port 9880 occupied by non-healthy process — abort"; exit 1
fi
[ -x "$TTSVENV" ] || { echo "REFUSED: tts venv python not executable: $TTSVENV" >&2; exit 1; }
cd "$CC" || exit 1
nohup "$TTSVENV" server.py >> "$CC/tts.log" 2>&1 &
echo $! > "$PIDF"
echo "tts starting (pid $(cat "$PIDF")) — model load takes up to ~120s"
for i in $(seq 1 60); do
sleep 2
if curl -s --max-time 3 http://127.0.0.1:9880/health 2>/dev/null | grep -q '"ok"[[:space:]]*:[[:space:]]*true'; then
echo "OK: tts healthy after ~$((i*2))s"
wc -c < "$CC/tts.log" > "$BASE/logs/tts-log-start-byte" # TTS 段数计数的窗口起点
exit 0
fi
kill -0 "$(cat "$PIDF")" 2>/dev/null || { echo "FAIL: tts process died"; tail -10 "$CC/tts.log"; exit 1; }
done
echo "FAIL: tts not healthy in 120s"; tail -10 "$CC/tts.log"
p=$(pidfile_pid)
if ours "$p"; then kill "$p"; sleep 2; ours "$p" && kill -9 "$p"; fi
rm -f "$PIDF"
exit 1
+273
View File
@@ -0,0 +1,273 @@
"""HEL-913 运行准备离线验证:零真实调用(cap=0 + 死地址双重保险)。
在进程内用 TestClient 走统一实例的关键接线:入口健康、创建守门、传输
(SSE 对话在预算 0 下诚实拒绝且绝不触发出站)、关系调整、日子与回忆
读取的服务端裁剪、语音诚实拒绝、重开(epoch/资料保留)、静态页与 dist
哈希。不监听任何端口(836x/8082/9880/8090 全程不出现监听 socket),
不访问真实网关(models.base_url 重写为 127.0.0.1:1,livekit 指向死地址,
即使预算闸失效也只能连本机死端口)。
用法(在候选 head 检出根目录、产品 3.11 venv 下):
PYTHONPATH=src <venv311>/python deploy/hel913-unified/verify_offline.py \
[--web <dist目录>] [--keep]
退出码 0 = 全部通过;任一 FAIL 即非 0。临时库写在 <root>/.hel913-verify-tmp,
默认结束后删除(--keep 保留)。
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import shutil
import sys
import tempfile
import uuid
from pathlib import Path
RESULTS: list[tuple[bool, str]] = []
def check(ok: bool, label: str) -> bool:
RESULTS.append((bool(ok), label))
print(("PASS " if ok else "FAIL ") + label)
return bool(ok)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--web", default=None, help="web dist 目录(只读;默认不挂静态)")
parser.add_argument("--keep", action="store_true")
args = parser.parse_args()
root = Path(__file__).resolve().parents[2]
os.chdir(root)
tmp = Path(tempfile.mkdtemp(prefix="hel913-verify-", dir=root))
if not args.keep:
atexit.register(shutil.rmtree, tmp, ignore_errors=True)
# ---- 0. 包完整性:Python / 锁定依赖 / 模块树 -----------------------------
check(sys.version_info[:2] == (3, 11), f"python 3.11 (actual {sys.version_info[:3]})")
import cybergril # noqa: E402
check(str(Path(cybergril.__file__).resolve()).startswith(str(root / "src")),
f"cybergril imported from candidate tree ({cybergril.__file__})")
import fastapi # noqa: E402
import httpx # noqa: E402
import pydantic # noqa: E402
import uvicorn # noqa: E402
import yaml # noqa: E402
check(True, f"deps: fastapi {fastapi.__version__} uvicorn {uvicorn.__version__} "
f"httpx {httpx.__version__} pydantic {pydantic.__version__}")
from cybergril.api.app import create_app # noqa: E402
from cybergril.voice.router import build_voice_router # noqa: E402
from cybergril.voice.session import load_voice_config # noqa: E402
check(True, "product modules import (api.app / voice.router / voice.session)")
# 记忆栈缺项如实呈现(不允许 stub 冒充真实 mem0)
for mod, expect in (("mem0", False), ("qdrant_client", False), ("fastembed", False)):
try:
__import__(mod)
got = True
except Exception:
got = False
check(got == expect, f"{mod} {'absent' if not expect else 'present'} in product venv (as documented)")
# ---- 1. 脱敏配置:cap=0 + 死地址 ----------------------------------------
models_src = (root / "config" / "models.yaml").read_text()
assert "daily_tokens" in models_src, "models.yaml missing budget block"
dead = models_src.replace("192.168.200.11:8731", "127.0.0.1:1")
assert "192.168" not in dead, "real gateway address survived rewrite"
models_path = tmp / "models.yaml"
models_path.write_text(dead)
voice_path = tmp / "voice.yaml"
voice_path.write_text(
"# HEL-913 offline template: everything points at dead local ports;\n"
"# no real LiveKit/TTS/panel is contacted by this verification.\n"
"livekit_url: ws://127.0.0.1:1\n"
"livekit_internal: http://127.0.0.1:1\n"
"api_key: dead\n"
"api_secret: dead\n"
"agent_name: cybergril-voice-agent\n"
"tts_health_url: http://127.0.0.1:1/health\n"
"panel_url: https://127.0.0.1:1\n"
"tts_url: http://127.0.0.1:1\n"
"registry_file: " + str(tmp / "voices.json") + "\n"
"worker_state_file: " + str(tmp / "voice_state.json") + "\n"
"token_ttl_seconds: 300\n"
"session_ttl_seconds: 900\n"
)
db_path = tmp / "companion.db"
env = {
"CYBERGRIL_DB": str(db_path),
"CYBERGRIL_MODELS": str(models_path),
"CYBERGRIL_VOICE_CONFIG": str(voice_path),
"CYBERGRIL_SEMANTIC": "stub",
"CYBERGRIL_R2_ENABLED": "1",
**({"CYBERGRIL_WEB": args.web} if args.web else {}),
}
old = {k: os.environ.get(k) for k in env}
os.environ.update(env)
from fastapi.testclient import TestClient # noqa: E402
app = create_app()
# 出站哨兵:把所有已装配 provider 的 stream 打上标记——预算 0 下任何
# 真实发送意图都会把本验证置为 FAIL(而不是悄悄连死地址)。
outbound = []
client = TestClient(app)
with client:
st = app.state
router = st.life.router # LifeRuntime 持有本实例同一 router
for tier in router.available_tiers():
prov = router.provider_for(tier)
def _guard_stream(self, *a, __orig=prov.stream, __name=getattr(prov, "name", tier), **kw):
outbound.append(__name)
raise AssertionError(f"unexpected outbound stream via {__name}")
prov.stream = _guard_stream.__get__(prov, type(prov))
birth = {"birth": "2001-08-16", "hour": "辰时", "name": "临川", "relation": "朋友",
"nickname": "", "boundary": "轻松相处"}
# 2. 入口健康
r = client.get("/api/health")
h = r.json()
check(r.status_code == 200 and h["ok"] and h["capabilities"]["text"] is True,
"GET /api/health 200, text capability true")
check(h["companion_created"] is False, "health: companion_created=false before creation")
check(h["llm"]["ok"] is False, "health: llm honestly not ok (dead addr, cap=0)")
# 3. 创建守门:空库 404,不能读出任何伪造档案
check(client.get("/api/companion").status_code == 404,
"GET /api/companion 404 before creation (no fabricated profile)")
# 4. 生辰预览:纯本地计算,无模型参与(BirthInput 只收 name/birth/hour)
r = client.post("/api/companion/preview",
json={k: birth[k] for k in ("name", "birth", "hour")})
preview_pillars = r.json().get("pillars") if r.status_code == 200 else None
check(r.status_code == 200 and "pillars" in r.json() and "sections" in r.json(),
"POST /api/companion/preview 200 (local bazi compile)")
# 5. 创建 + 底色只读落库
r = client.post("/api/companion", json=birth)
check(r.status_code == 201 and r.json()["name"] == "临川",
"POST /api/companion 201")
row = st.store.query_one("SELECT pillars FROM bazi_core WHERE id=1")
saved_day = json.loads(row["pillars"])["day"][0] if row else None
prev_day = (preview_pillars or {}).get("day", [None])[0]
check(saved_day is not None and saved_day == prev_day,
f"bazi_core persisted and matches preview (day master {saved_day})")
check(client.post("/api/companion", json=birth).status_code == 409,
"second create refused 409 (single companion guard)")
pv = client.get("/api/persona").json()
check(pv["interpretation"] is None and pv["state"] in {"failed", "loading", "disabled"},
f"persona honest at cap=0 (state={pv['state']}, no fabricated interpretation)")
# 6. 传输:SSE 对话在预算 0 下诚实拒绝,零出站
turn1 = str(uuid.uuid4())
with client.stream("POST", "/api/chat",
json={"text": "在吗", "turn_id": turn1, "epoch": st.epoch}) as resp:
check(resp.status_code == 200 and resp.headers["content-type"].startswith("text/event-stream"),
"POST /api/chat opens SSE stream")
kinds = [json.loads(line[6:])["t"] for line in resp.iter_lines()
if line.startswith("data: ")]
check("chat.error" in kinds and "chat.final" not in kinds,
f"chat honestly errors at cap=0 (frames={kinds})")
check(not outbound, f"zero provider stream calls (outbound={outbound})")
msgs = client.get("/api/messages").json()["messages"]
check(msgs and msgs[-1]["role"] == "user" and msgs[-1]["status"] in {"failed", "cancelled"},
"failed turn persisted as user row, no fake companion reply")
with client.stream("POST", "/api/chat",
json={"text": "在吗", "turn_id": turn1, "epoch": st.epoch}) as resp:
kinds2 = [json.loads(line[6:])["t"] for line in resp.iter_lines()
if line.startswith("data: ")]
check("chat.error" in kinds2 and not outbound,
"retry same failed turn: honest error again, still zero outbound")
# 7. 关系可调整、名字保留
r = client.patch("/api/relation", json={"relation": "恋人"})
check(r.status_code == 200 and r.json()["relation"] == "恋人" and r.json()["name"] == "临川",
"PATCH /api/relation 200 (relation adjustable, name kept)")
# 8. 日子与回忆:服务端裁剪 + 分页形状(told=1 之外不可见)
r = client.get("/api/life")
check(r.status_code == 200 and r.json()["events"] == [],
"GET /api/life 200 empty (only told events, none yet)")
r = client.get("/api/memories")
check(r.status_code == 200 and r.json()["memories"] == [],
"GET /api/memories 200 empty")
check(client.get("/api/life/1").status_code == 404,
"GET /api/life/1 404 (unshared detail refused)")
bogus = {"message_id": 99999, "turn_id": str(uuid.uuid4()), "epoch": st.epoch,
"token": "x" * 40, "text_hash": "0" * 64}
check(client.post("/api/chat/received", json=bogus).status_code == 404,
"POST /api/chat/received 404 on unknown receipt")
# 9. 语音诚实拒绝:配置指向死地址 → 开房必须 503,绝不建房
r = client.get("/api/voice/status")
check(r.status_code == 200 and r.json()["configured"] is True and r.json()["voice"] is False,
"GET /api/voice/status 200, voice honestly false")
r = client.post("/api/voice/session")
check(r.status_code == 503,
f"POST /api/voice/session honest 503 when TTS unreachable ({r.status_code})")
check(client.delete("/api/voice/session/no-such").status_code == 404,
"DELETE /api/voice/session/{unknown} honest 404")
# 10. 静态页与 dist 哈希(只读)
if args.web:
page = client.get("/")
check(page.status_code == 200 and b"<div id=" in page.content,
"GET / serves web dist index.html")
# 11. 重开:新进程入口、epoch 递增、资料保留
with TestClient(app) as client2:
h2 = client2.get("/api/health").json()
check(h2["epoch"] == 2, f"reopen: epoch=2 (got {h2['epoch']})")
c2 = client2.get("/api/companion").json()
check(c2["name"] == "临川" and c2["relation"] == "恋人" and c2["birth"] == "2001-08-16",
"reopen: companion identity + adjusted relation retained")
m2 = client2.get("/api/messages").json()["messages"]
check(len(m2) == 1 and m2[0]["role"] == "user",
"reopen: chat history retained, still no fabricated replies")
if args.web:
manifest = json.loads((Path(__file__).parent / "dist-manifest.json").read_text())
bad = []
for rel, want in manifest.items():
p = Path(args.web) / rel
if not p.is_file():
bad.append(f"{rel} missing")
continue
import hashlib
got = hashlib.sha256(p.read_bytes()).hexdigest()
if got != want:
bad.append(f"{rel} {got[:12]}!={want[:12]}")
check(not bad, f"dist matches manifest ({len(manifest)} files; bad={bad})")
for k, v in old.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
failed = [label for ok, label in RESULTS if not ok]
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} checks passed")
if failed:
print("FAILED:")
for f in failed:
print(" - " + f)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,12 @@
# HEL-913 · 语音窗口一次性布防模板(脱敏;默认全 0/未布防,随包不生效)
#
# 获批语音窗口的负责人在窗口启动时显式填写并在启动 worker 前写入
# <实例>/worker/.env(ASR 三项)与本文件(窗口参数)。唯一接线见
# deploy/hel913-unified/README.md §5;建议值口径见
# docs/hel913-capability-and-enablement.md §2 B。
# 已实测同形工具(HEL-910 窗口):docs/rework/hel910/tools/asr-arm.env。
HEL913_WINDOW_ID= # 例 hel913-20261106-a;空=未布防=拒绝一切 ASR
HEL913_ASR_MAX_AUDIO_SECONDS=0 # 全窗口实际送入 ASR 音频硬闸(秒)
HEL913_ASR_MAX_RETRIES_PER_CALL=0 # 每通连接重试硬闸(失败后的再建连)
HEL913_TTS_SEGMENT_CAP=0 # TTS 段数上限(监控口径;到顶即手动 force_end)
HEL913_WINDOW_DEADLINE_EPOCH= # date -d '<第3日> 24:00' +%s;到点 force_end
+23
View File
@@ -0,0 +1,23 @@
# HEL-913 统一体验运行包 · voice 配置模板(脱敏)
#
# 用法:复制到 <实例>/etc/voice.yaml,把 <...> 占位替换成服务器本地真实值。
# livekit_url 是 POST /api/voice/session 发给**浏览器**的接入地址:统一实例
# 页面走 HTTPS(8361),HTTPS 页面连 ws:// 明文会被混合内容策略阻断,因此
# 语音可用的前提是 wss TLS 桥(deploy/voice/wss_bridge.example.py 在新端口
# 终结 TLS、回环转发 7880,与 8361 同一张自签证书)——livekit_url 必须填
# 桥的 wss:// 地址,不是可选件(桥的启停见 README §5)。服务器内部
# (开房/删房/worker 注册)仍走 livekit_internal 的本机明文 7880。
# TTS/面板地址在语音窗口获批启动后指向本机 9880/8090;registry/worker_state
# 落实例数据目录,与产品库同空间,随实例备份。
livekit_url: wss://192.168.200.12:8363
livekit_internal: http://127.0.0.1:7880
api_key: <LiveKit api key,服务器本地>
api_secret: <LiveKit api secret,服务器本地>
agent_name: cybergril-voice-agent
tts_health_url: http://127.0.0.1:9880/health
panel_url: https://127.0.0.1:8090
tts_url: http://127.0.0.1:9880
registry_file: /opt/cybergril-hel913/data/voices.json
worker_state_file: /opt/cybergril-hel913/data/voice_state_cybergril.json
token_ttl_seconds: 300
session_ttl_seconds: 600
+26
View File
@@ -0,0 +1,26 @@
# HEL-913 统一体验运行包 · 语音 worker 环境模板(脱敏)
#
# 用法:复制到 <实例>/worker/.env,把所有 <...> 占位替换成服务器本地真实值。
# worker_ctl.sh 启动前会拒绝:仍含 <...> 占位的文件、解析失败的文件、缺文件。
# 运行解释器是 agent venv(livekit-agents 1.8.8),不是产品 .venv311。
CYBERGRIL_API_BASE=http://127.0.0.1:8360
CYBERGRIL_AGENT_NAME=cybergril-voice-agent
LIVEKIT_URL=ws://127.0.0.1:7880
LIVEKIT_API_KEY=<LiveKit api key,服务器本地>
LIVEKIT_API_SECRET=<LiveKit api secret,服务器本地>
CYBERGRIL_WORKER_HEALTH_PORT=8082
CYBERGRIL_VOICE_STATE=/opt/cybergril-hel913/data/voice_state_cybergril.json
# ASR 用量账本独立于产品库,落在实例数据目录(逐通历史不随删房消失):
CYBERGRIL_ASR_USAGE_LEDGER=/opt/cybergril-hel913/data/asr-usage.jsonl
# —— ASR 一次性窗口闸(HEL-906;本段是布防接线,默认未布防 = 真实 cap=0)——
# 三项保持空/0 时,worker 拒绝一切真实 ASR 新连接与新音频(fail-closed,
# 拒绝本身落账,src/cybergril/voice/asr_usage.py)。只有获批语音窗口的
# 负责人在窗口启动时显式改这里并重启 worker(接线与建议值见 README §5
# 与 docs/hel913-capability-and-enablement.md §2 B):
# CYBERGRIL_ASR_WINDOW_ID=hel913-<日期>-a # 新窗口 id;空=未布防
# CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=1800 # 3 通×10 分钟=30 分钟硬闸
# CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=2 # 每通连接重试上限
CYBERGRIL_ASR_WINDOW_ID=
CYBERGRIL_ASR_WINDOW_MAX_AUDIO_SECONDS=0
CYBERGRIL_ASR_MAX_CONNECT_RETRIES_PER_CALL=0
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# HEL-913 统一体验运行包 · 专属语音 worker 启停
#
# 运行解释器是 agent venv(livekit-agents 1.8.8),产品代码经 PYTHONPATH 注入。
# worker 只连接本实例 API 与本机 LiveKit。本脚本只做本实例常规启停安全:
# - 子命令只认 start|stop|status,其他一律拒绝退出 2(不会静默当 start);
# - start 前核对 pid 句柄与 worker/.env(缺文件/含未填 <...> 占位/解析失败
# 一律拒绝),重复 start 拒绝,句柄 pid 复用拒绝;
# - stop/失败收尾只杀经身份核验的进程(cmdline 含 cybergril.voice.worker
# 且 cwd 是本实例 worker 目录),绝不按进程名扫杀;
# - start 注册检查不过 → 清掉自己刚起的进程、保留日志、退出 1。
#
# 用法: [ENV=..] bash deploy/hel913-unified/worker_ctl.sh start|stop|status
# 环境变量:
# HEL913_BASE 实例根目录(含 current/ worker/ 等)
# HEL913_AGENT_VENV agent venv python(默认 /opt/voice-agent/agent/venv/bin/python)
# HEL913_WORKER_HEALTH 健康端口(默认 8082;与 worker/.env 中一致)
set -u
BASE=${HEL913_BASE:-/opt/cybergril-hel913}
VENV=${HEL913_AGENT_VENV:-/opt/voice-agent/agent/venv/bin/python}
HEALTH=${HEL913_WORKER_HEALTH:-8082}
WDIR=$BASE/worker
ENVF=$WDIR/.env
LOG=$BASE/logs/worker.log
PIDF=$BASE/logs/worker.pid
MARKER="cybergril.voice.worker"
usage() { echo "usage: [ENV=..] bash $0 start|stop|status" >&2; exit 2; }
ours() {
local p=$1 cmd cw
case "$p" in ''|*[!0-9]*) return 1 ;; esac
[ "$p" != "$$" ] || return 1
kill -0 "$p" 2>/dev/null || return 1
cmd=$(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null) || return 1
cw=$(readlink "/proc/$p/cwd" 2>/dev/null) || return 1
case "$cmd" in *"$MARKER"*) ;; *) return 1 ;; esac
[ "$cw" = "$WDIR" ] || return 1
return 0
}
pidfile_pid() { [ -f "$PIDF" ] && cat "$PIDF" 2>/dev/null; }
stop() {
local p
p=$(pidfile_pid)
if [ -z "$p" ]; then
echo "no pid file; nothing stopped"
return 0
fi
if kill -0 "$p" 2>/dev/null && ! ours "$p"; then
echo "REFUSED: pid $p is alive but is NOT this worker (pid reuse?) — not killing; resolve $PIDF manually" >&2
return 1
fi
if ours "$p"; then
kill "$p"
sleep 3
if ours "$p"; then kill -9 "$p"; sleep 1; fi
fi
rm -f "$PIDF"
echo "worker stopped (pid $p)"
return 0
}
start() {
local p bad
p=$(pidfile_pid)
if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then
if ours "$p"; then
echo "REFUSED: worker already running (pid $p) — stop first" >&2
else
echo "REFUSED: pid file has live pid $p that is not this worker (pid reuse?) — resolve $PIDF manually" >&2
fi
exit 1
fi
rm -f "$PIDF"
[ -d "$WDIR" ] || { echo "REFUSED: worker dir missing: $WDIR" >&2; exit 1; }
[ -f "$ENVF" ] || { echo "REFUSED: $ENVF missing (copy worker.env.template and fill it)" >&2; exit 1; }
[ -x "$VENV" ] || { echo "REFUSED: agent venv python not executable: $VENV" >&2; exit 1; }
# 占位符守门:模板 <...> 未替换一律拒绝(不让占位串进进程环境)
bad=$(grep -nE '^[A-Za-z_][A-Za-z0-9_]*=.*<' "$ENVF" || true)
if [ -n "$bad" ]; then
echo "REFUSED: $ENVF still has unfilled <...> placeholders:" >&2
echo "$bad" >&2
exit 1
fi
# 解析守门:.env 必须能作为 KEY=value 被 source(坏引号/裸空格拒绝)
if ! (set -a; . "$ENVF"; set +a) 2>/dev/null; then
echo "REFUSED: $ENVF does not parse as KEY=value lines" >&2
exit 1
fi
mkdir -p "$BASE/logs"
cd "$WDIR" || exit 1
set -a
. "$ENVF"
set +a
PYTHONPATH="$BASE/current/src" nohup "$VENV" -m cybergril.voice.worker start > "$LOG" 2>&1 &
p=$!
echo "$p" > "$PIDF"
echo "worker starting (pid $p)"
sleep 18
if grep -m1 "registered worker" "$LOG" >/dev/null; then
echo "OK: worker registered"
return 0
fi
echo "FAIL: worker did not register — full log kept at $LOG; tail:" >&2
tail -8 "$LOG" >&2
if ours "$p"; then
kill "$p"; sleep 2
ours "$p" && kill -9 "$p"
fi
rm -f "$PIDF"
echo "cleaned up failed start (log kept: $LOG)" >&2
exit 1
}
status() {
local p
p=$(pidfile_pid)
if [ -n "$p" ]; then
if ours "$p"; then echo "running pid $p (identity verified)"
elif kill -0 "$p" 2>/dev/null; then echo "pid $p alive but NOT ours (stale pid file?)"
else echo "pid $p not alive (stale pid file)"; fi
else
echo "no pid file"
fi
curl -s --max-time 3 "http://127.0.0.1:$HEALTH/" >/dev/null && echo "health ok" \
|| echo "health port not answering"
}
case "${1:-}" in
start) start ;;
stop) stop ;;
status) status ;;
*) usage ;;
esac
+135
View File
@@ -0,0 +1,135 @@
# HEL-913 · 一页能力清单与一次真实启用建议
依据:产品基线 `4a38106`(HEL-912 审核通过)+ 运行包提交(仅 deploy/docs,
见运行包 README §0)、HEL-907/910 真实窗口记录、HEL-913 离线验证 34/34
(`docs/rework/hel913/verify-offline-run.txt`;其中依赖核对只打印 4 个关键
包版本、未分享裁剪只在空库验证——口径限制见运行包 README §7)。分级只分
三种:**✅接好且已验证**(真实窗口或真实服务验证过)/ **🟡有实现、缺真实验证**
(代码与离线/替身验证在,真实链路未跑或未批预算)/ **❌尚未接入**。不照抄
旧表:语音已接(HEL-907/910 实测),下述端口/能力以当前源码为准。
## 1. 能力清单(按用户使用顺序)
| # | 用户动作 | 现状 | 具体行为与证据 |
| --- | --- | --- | --- |
| 1 | 打开网页 | ✅ | HTTPS 入口出界面(自签证书需浏览器「高级→继续」);`GET /` 出 index.html、dist 8 文件哈希核过(HEL-913 验证)。导航:此刻/交谈/日子/设置(`web/src/app/RealApp.tsx`) |
| 2 | 自己给她起名、选生日、选关系 | ✅(起名/生日/关系)/ 🟡(自主诠释与初始习惯) | 用户亲选关系(不预选,AM-002)、亲起名(AM-003);创建守门 404/409、八字底色落库只读、公历农历四柱展示(`src/cybergril/api/app.py:427`、`src/cybergril/core/birth_view.py`)。**自主诠释/习惯生成(persona)从未真实跑过**:所有真实窗口 background=0 诚实拒绝(HEL-910 预检证实),首次用户体验需预算 |
| 3 | 文字聊天 | ✅ | 真实网页 Enter 发送、SSE 流式回复、完整落库;语音在播/待播时发文字打断=旧声停+完整文字回复+语音恢复(HEL-910 两通实测);**生成中的文字取消未取得有效真实样本**(HEL-908 的修复只有离线验证,HEL-910 想测没测成,如实记未验证);失败轮诚实 error 不伪造 |
| 4 | 她的生活(自己运行) | 🟡 | 生活循环/活动线程/分享判定/回执代码接好(`src/cybergril/life/`);未分享生活服务端裁剪(told=1,AM-001)空库离线验证(未做正反种子对照);**真实生活事件生成未发生**(life 预算 0),日子页真实模式下尚无真实事件可看 |
| 5 | 记忆 | 🟡(分两层) | 聊天记忆:逐轮落库、遗忘下限、词面检索(FTS)已接,真实窗口对话随轮落库(团队测试库);**语义记忆(真实 Mem0)缺依赖**:.12 venv311 四项未装(2026-10-29 实测),完整安装与接线见运行包 README §8;激活还需 B 部分嵌入/提取预算 |
| 6 | 关掉网页再回来 | ✅ | 同一位她保留:聊天/关系/八字/记忆持久;epoch 递增同步、分页缺口不跳留言、未读标记(HEL-895/896 真实服务浏览器 9 项检查;HEL-913 离线重开验证) |
| 7 | 她主动留言 | 🟡 | 时机判定+网页内留言卡+回来即看的前端链路已接并经真实服务浏览器验证(种子数据);**真实 LLM 主动措辞生成未发生**(proactive 预算 0);不接手机推送(CG-011 首版范围) |
| 8 | 语音通话 | ✅(工程验证) | 真实网页开通话、连续对话、字幕同步、挂断删房;文字打断在播旧声实测停(作者粗略观察约秒级,原始探针未保留,**不作精确口径、不承诺可独立精测**);首声原实测 9.53s,HEL-911 移除 3.66s 等待后**未实测**(不向用户承诺任何推算值);真人麦克风打断、生成中文字取消的正面样本未取得(承接) |
| 9 | 自定义声音 | ✅(工程验证) | 网页上传参考音频→登记→真 TTS 试听→应用→下一通生效;通话中改选不换声(HEL-907 全链 13/14 步实测,call1/2 以 cg- 音色真实合成;第 14 步真机复测未做);用户本人尚未亲自用过 |
| 10 | 换城市/天气跟随 | ❌ | 设置页城市项为演示(`web/src/manage/ManagePanel.tsx` cap=demo/off);后端无天气源(CG-023 承接 R6,T-07) |
| 11 | 换外观素材 | ❌ | 设置页外观项为演示;素材规范/导入未定稿(CG-005/030,T-03,R6;视觉参考见 PR#8 prototypes/hel-793/) |
| 12 | 备份导出/每日自动备份 | ❌(演示导出除外) | 设置页「本地演示导出」为演示件;CG-009 每日自动备份+手动完整导出未实现(R6);现阶段只有发布归档工具 `tools/release_snapshot_hel896.py`(工程用,不冒充 R6) |
| 13 | 摄像头观察 | ❌ | 无视觉服务接入;CG-021/022(R7),4080 未触,部署由用户负责(T-05) |
| 14 | 真实人物口型/交谈半身 | ❌ | 交谈时人物为静帧参考图(`assets/her-talk.jpg`);成品视频选片与实时驱动器未选型(CG-029,T-09;8340/ComfyUI 停机) |
机器可读口径:`GET /api/health` 的 `capabilities`(voice/vision/weather/materials/
backup=false 为诚实声明;text=true)。
## 2. 一次真实启用建议(**全部待批准**;当前配置仍全 0,旧窗口余额不结转)
> 更正声明:本节早前写「A 总管权限内新批、不用问用户」——与本轮真实
> cap=0 及主卡授权矛盾,**作废**。所有新增真实用量、安装、部署(含 A)
> 一律待批,总管不自行放行;批准前一切 cap=0。三本账彼此独立(独立实例/
> 独立库,HEL-896 §6 分账语义),任何一本用完不自动转另一本;所有硬闸
> 发送前拒绝、碰顶即停、**封账不解除**;提额永远是新批准。费用均为内网
> 自持服务(LLM 网关 .11:8731、TTS/ASR/LiveKit 在 .12),**无新增现金支出**,
> 消耗的是授权额度与本机资源。用户不需要理解 token/EOU,也不需要选算法
> 或决定"要不要真记忆"——工程侧按批准直接配置。
### A · 团队验收检查(建议顶:55 次 / 500,000 tokens / 0 嵌入——待批)
- 沿用 `docs/budget-package-hel895.md` §6 的形状:**独立团队实例**自己的
SQLite 账,体验链 1–7 走完(创建→诠释→对话→生活→情绪关系→主动联系→
重开同步,即本清单 🟡 项的团队级补证)即 `seal_budget_hel879.py` 封账
归档。
- A 是团队实例的账:**不并入用户实例、不给用户实例配置一次性总量**
(一次性总量只属于团队测试实例,见 budget-package-hel895 §6)。
### B · 用户首体验(一次批准;最小够用)
- **前置(窗口开始前,团队完成,不占用户额度)**:如批准真实记忆,先按
运行包 README §8 一次性窗口完成安装+co-import+缓存落位(约 1–2 小时
含验证;+450–550MB 磁盘);不批安装则记忆停在词面检索(stub 已验证
全链可跑),其余体验不受影响。四步检查(安装→独立部署→团队检查→用户
首用)见运行包 README §4/§8 与本节。
- **窗口与时点**:建议 3 个自然日,服务器本地时区(Asia/Shanghai)。T0 =
团队完成检查、把地址交给用户的时刻(如周五 19:00 交付,则周日 24:00
到期);日账按本地自然日零点重置;团队准备耗时全部在 T0 之前。
- **文字与生活(models.yaml 日账,程序硬闸)**:对话 60 轮/日、生活 8 次/
日、判定/成长/摘要 40 次/日、日 token 池 2,000,000、创建+诠释+习惯一次
性 4 次。**语音通话里的对话轮同样计 dialogue 日账**(不另开语音 LLM 账)。
- **记忆(若批安装)**:提取 LLM 累计 60 次(`llm_ops`)、新增嵌入累计
180 条(累计口径 `embed_cap=680`、冻结 2 不动,见运行包 README §8.4)、
语义类 40 次/日;嵌入日顶60条,提取日顶40次(两项按UTC切日,即北京时间08:00)。
- **语音(3 通 × ≤10 分钟 = 合计 ≤30 分钟)**:ASR 音频合计 ≤1800 秒
(程序硬闸,发送前拒绝,重启进程不重置);每通连接重试 ≤2(硬闸);
每通墙钟 ≤10 分钟(session_ttl 600 秒硬顶之内,超时房间失效);TTS
合成 ≤60 段(监督计数,到顶人工仅关闭语音服务,不停文字生活;程序不硬闸);建房即用即删(开房失败 503 不重试建房;挂断清理
失败留账自动重试);声音上传登记+试听合计 ≤10 次(监督口径:由陪同
团队按次计数,程序不硬闸)。需要用户当场在浏览器允许麦克风(HTTPS+
自签证书「高级→继续」)。自定义声音当场可用(上传→试听→应用)。
- **碰顶后果(逐项如实)**:LLM 日顶→当次诚实拒绝、界面报错、次日恢复;
ASR 触顶→发送前拒绝、本通结束、全窗不再接受新音频;TTS 段顶→陪同执行者关闭语音入口和worker/桥(文字生活仍可用);窗口到期→统一停服封账(见下)。
- **截止停服(可执行,不口头承诺)**:`force_end.template.sh watch <到期
epoch>`(受监督后台任务)或获批后装 systemd timer 调 `now`;到期动作 =
逐个 DELETE 本实例房间 → worker → api → wss 桥 → 共享 TTS 按各自专属
pid 句柄停 → `seal_budget_hel879.py` 封账归档。**封账不解除**。
- **断开与恢复**:关网页/断开后她的档案、聊天、关系、记忆原样保留(重开
验证过);共享 TTS 9880/面板 8090/wss 桥 8363 只在批准窗口内启动、到期
停回;LiveKit 7880 常驻不动。
- **与 C 的边界(本轮更正)**:B 窗口**内**的每日生活/主动留言/背景循环
运行,计入 B 的 life/proactive 日账与日 token 池(上表);B 窗口**结束**
后一切停,不自动转任何常驻。C 指窗口之外的长期后台——暂不批。不存在
同一份日用量既写进"B 已包含"又写进"C 未批准"的矛盾口径。
- **如实告知用户的已知边界**:首声延迟修复后未实测(原实测 9.53s,不承诺
任何推算值);真人打断、生成中文字取消的正面样本未取得;日子/主动留言
的真实生成首次发生在这个窗口里,质量如不足属正常迭代范围。
### C · 长期后台
- 暂不批。B 窗口结束后凭实际用量与质量报告(`llm_ops`/`embed_ops`/日账
导出)再定常驻节奏与**新的独立账本**;在此之前不自动续跑,
`CYBERGRIL_R2_ENABLED` 不在窗口外保持开启。
## 3. 不变边界
本建议不改变任何需求与验收状态:8300/8310/8320/8350 与资料不动、正式界面
不替换、PR 不合并、4080 不触、旧三窗口余额不结转;阶段体验不等于完整产品
完成,R5–R7 未完项继续按主卡承接。
## 4. 最终启用边界(总管取件勘误)
运行包README §10为操作勘误,优先于旧模板。全部仍待白栖知批准。
语音仅团队陪同预约,每通600秒、总ASR1800秒、总建房尝试6次;
TTS60段和登记+试听合计10次由当班人员监督,不冒称自动硬停。
文字生活在语音配额耗尽后仍可运行到总截止;到期按受监督定时服务停服,
资料保留、账封存,房间/共享音色与临时服务由团队核实清理。
完整自定义声音的8090面板句柄需在放行前复用HEL907方法准备,不得占用8081。
装包缺轮或解析冲突即停,不改锁版本。准备预计1–2小时,团队真链检查另限2小时,
检查不过不把失败实例交用户。用户首次可用地址交付后计3个自然日(当日算第一日),
用户也可先约定开始日期,避免地址发出时本人不在而浪费体验期。
自定义声音细分额度(同一次待批方案):最多登记2段参考音频,每段由陪同人员先核≤20秒;
自动转写最多2次/合计40秒,单列监督账,不冒充通话ASR的1800秒硬闸。
最多试听8次;登记2次+试听8次共10次。试听合成并入60段TTS总上限。
两类ASR合计最大1840秒(通话1800+参考40);失败/重试同样记账,不返余额。
本轮未执行登记、转写或试听。
### 最终统一预算勘误:团队先验真实记忆,再交用户(总管)
此条取代上文A“0嵌入”的旧建议,仅修改待批方案,当前真实额度仍0。
团队先用独立测试资料核真实Mem0接入,不把首次故障留给用户:
A的55请求/50万tokens内,最多10次用于SDK提取;先用最多20条新嵌入。
这10次/20条从同一批新申请的60次提取/180条嵌入中先扣,
不是另加额度;余量(至少50次提取/160条嵌入)留给用户期,使用实账计算。
两阶段共用唯一活动记账文件、串行写入,测试向量/档案与用户向量/档案分开,
只共用计数账,不拷贝测试聊天或测试记忆给用户。旧权威embed_ops前缀不改,
旧39/60实验的授权和账原封保存,不挪用其余额;新提取批建立独立可审计操作记录。
如旧llm_ops表已有用量,新60次必须按现值加增量并保存旧行,不可清表/重置;
操作前核旧基数并留证,实际新增始终≤60。团队检查最多2小时,从首个真实调用计时;
检查不通过则停止并报告首个错误,用户期不自动开始。
+46
View File
@@ -0,0 +1,46 @@
{
"baseline_version": "1.12",
"read_commit": "407fc8158ce392e20ee51c05420c8b74aa545a92",
"task": "HEL-911:修好 HEL-910 实证的两个 livekit-agents 1.8.8 SDK 接缝错误(worker _on_item 对 AgentHandoff 取 role 的 AttributeError;add_shutdown_callback 对同步卸载回调 await None 的 TypeError),并用已有原始证据+实际 SDK 源码+离线复现查清首声 ~9.5s 等待的分段:识别终稿→生成开始的 3.66s 系 1.8.8 默认补入的英文 EOU TurnDetector 对中文终稿的 unlikely 判定把 endpointing 顶到 max_delay=6.0,显式 turn_detection=None 修复(离线 A/B 3.704s→0.004s);同时纠正 HEL-910 交付说明四处事实错误并追加纠偏记录、为下次测量修工具可靠性(分段落盘+服务端活动门控,仅离线验证)。全程真实 cap=0:零 LLM/ASR/TTS/嵌入/登记/试听调用、不建房不开窗不恢复服务,仅只读复用 .12 agent venv 实物 SDK 与既有 wav 做离线证明。",
"requirement_ids": [
"CG-006",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "src/cybergril/voice/worker.py:_on_item 按 1.8.8 ConversationItemAddedEvent.item 判别字段过滤(仅 type==message 且 role==assistant 的 str 部件正文进回声过滤);bind_interrupt_channel 卸载改协程(SDK 关停回调一律被 await)、幂等、断连不抛,interrupt(force=True)/回执/预算路径不动;TURN_HANDLING 显式 turn_detection=None(endpointing 0.8/6.0 逐字保留,不降 VAD 阈值、不动 buffer_turn)。scripts/gen_hel911_wait_repro.py:真 SDK+真 silero VAD+实测时间轴的离线复现(BASELINE 3.704s 复现实测 3.667/3.654s,修复态 0.004s)。tests/test_sdk_livekit_hel908.py +3 项真 SDK 检查(共 11,.12 agent venv 11/11;既有 8 项回归全保持,unload 调用点随协议改 await)。docs/rework/hel910/window-record.md §9 追加纠偏记录(原文未回改);docs/rework/hel910/tools/hel910_call1.mjs 可靠性修正(探针每 5s 分段落盘 call1-probe-live.jsonl+录音分片即存,防超时杀进程丢证;时机A 触发改为服务端已把语音轮落成 chat 行+生成中相位双条件,不再以页面相位/己方发送为 API 已激活代理;node --check 通过)。docs/rework/hel911/summary.md 完整分段归因与验证记录。产品测试套 .12 规范环境 480 passed/11 skipped/2 failed(两个 test_r3 预先存在与 HEL-908 记录一致);本地 py3.10 与基准 407fc81 干树失败集合完全相同(环境差异非本卡引入)。",
"acceptance": [
"真 SDK(.12 /opt/voice-agent/agent/venv livekit-agents 1.8.8)驱动:AgentHandoff/_TypeDiscriminator/user 消息不触发崩溃且不污染回声过滤,assistant 混合 content 只取 str 正文;真 JobContext.add_shutdown_callback 包装协议下协程卸载通过、旧同步形状经同一包装复现生产 TypeError(反向证据)、重复 await 幂等、卸载后监听真摘除;TURN_HANDLING 下 _turn_detection 为 None 且默认探测器存在性前置断言",
"等待链离线复现(复用 HEL-910 实际回放 wav、实测时延脚本化):BASELINE 终稿+2.443→llm.chat+6.142(3.699s,对上实测 3.667/3.654),修复态 +2.439→+2.444(0.005s);VAD 端点 ~0.7s、FunASR 链+0.6s 去抖 2.43s、服务端整轮 ~1.4s、TTS 首包 1.614s 各段归因落在源码行与实测日志",
"既有回归保持:SDK harness 11/11(文字取消不重试/force 停声/通道校验/未启动存活等);产品套 .12 规范环境 480/2 预存失败与基准一致;web 未动未重建",
"纠偏记录入 window-record.md §9:companion 9 条非 18、生成起点 +6.099s 非 +4.10、assistant_final 是播放收尾/回执门非首次全文生成/落库、post-window.txt ListRooms 为 invalid authorization token 不能证明空房(总管独立核实空房成立);原快照/录音/日志一字未改",
"用量:真实 cap=0 全程保持——零模型/识别/合成/嵌入/登记/试听调用、零建房、零窗口、未恢复 9880/8090 或任何本窗服务、未访问 4080、未改绑定;.12 上仅只读使用 agent venv 与既有 wav,临时副本 /tmp/hel911 已清"
],
"deferred": [
"修复后首声真实总时延(离线推算 9.5→约 5.9s,不承诺):需下轮真实工程窗口复测(工具门控与分段落盘已备好)",
"FunASR 适配器 0.6s 去抖与 2pass 离线纠错往返是否可再压:识别链参数,本卡不动语义,与真实复测同窗评估",
"真实用户麦克风打断时延/可闻旧声回放打断正面样本/生成中文字取消正面样本:两轮窗口均未取得,需按新门控重试",
"真实端点带 </think> 形状分离(离线证据 HEL-909);首声是否经常>5s 需多样本",
"真机/手机麦克风、Safari(D3 用户动作);R1–R7 完整功能继续主卡承接,不宣布整产品完成"
],
"requirement_changes": [],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 407fc81,均带 --handoff docs/requirements/handoff-HEL-911.json 通过(结构检查,不代替原话核对与体验验收)",
"定向验证:tests/test_sdk_livekit_hel908.py --standalone 于 .12 agent venv 11/11;scripts/gen_hel911_wait_repro.py BASELINE/FIXED 两模式;产品套 .12 .venv311 480 passed/2 预存失败;hel910_call1.mjs node --check",
"未验证(如实):修复后真实通话首声数字、真实麦克风、手机/真机、思考带 closer 真实端点形状——见 deferred,不冒充"
]
}
+58
View File
@@ -0,0 +1,58 @@
{
"baseline_version": "1.12",
"read_commit": "4a38106eef21c43ba24ab7d07992b1bfe539137d",
"task": "HEL-913(含 2026-10-29 总管取件后同卡集中收口):把通过 HEL-912 审核的统一候选(产品基线 agent/2/8e99cc2cc592 @ 4a38106)装成可交接的统一体验运行包并在原候选分支一次收口五项核心缺项:①整包版本/目录可执行(产品基线与运行包提交区分、部署照抄入口=运行包提交、目录/配置/dist 复用/空档案守门完整准备步骤、不跳旧 HEL895 通用步骤);②启用建议收敛为内部一致的一份待批方案(更正「总管权限内新批 A」为全部待批、语音改 3 通×≤10 分钟=≤1800 秒 ASR 硬闸并逐项交代碰顶后果、B/C 边界消除矛盾、T0 计时点/当地日顶/团队准备与用户参与/可执行到期停服);③真实记忆最少安装/接线补全为完整可审命令(隔离 venv、逐项哈希留证、失败即停、co-import、权威账 498/冻结 2 续账不搬测试聊天);④启停实际接线/失败收尾(ASR 布防三变量入模板默认拒绝、wss 桥必需、TTS 限段/房间/总时限唯一接线、启停脚本身份核验版 42 项离线替身验证);⑤事实表只修明确错误(生成中取消未获样本、打断停声非精确口径、首声不承诺推算值、34/34 口径限制如实)。本卡不部署、不监听、不下载、不安装、cap=0、不合 PR、不动现有实例与资料、不向受保护 venv 增改依赖。",
"requirement_ids": [
"CG-006",
"CG-007",
"CG-008",
"CG-009",
"CG-010",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-023",
"AM-001",
"AM-002",
"AM-003",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-006",
"DEL-001"
],
"deliverable": "deploy/hel913-unified/ 收口版:README(唯一照抄入口:§0 产品基线 vs 运行包提交与自校验、§4 首次准备+启停、§5 语音窗口唯一接线表、§7 离线验证口径如实、§8 Mem0 完整可审安装 W0–W6 与权威账续账)、manifest.json、dist-manifest.json、models/voice/worker.env/voice-window-arm 五份脱敏模板(预算全 0/未布防)、api_ctl.sh/worker_ctl.sh 身份核验版、bridge/tts/force_end 三个窗口模板(默认不生效)、verify_offline.py、test_ctl_scripts.sh、mem0-cp311-wheels.txt;docs/rework/hel913/{verify-offline-run.txt,ctl-checks-run.txt};docs/hel913-capability-and-enablement.md(能力表纠错 + §2 单份一致的待批启用方案)。产品代码(src/web/config/tools/tests)自 4a38106 起零改动(git diff --stat 为空可验证)。 总管取件勘误见运行包README §10:语义配置与离线缓存环境、真实会话列表解析、临时语音服务失败收尾、传输校验与固定解释器、TTL600秒、定时服务及人工监督边界。",
"acceptance": [
"整包可执行:部署入口=运行包提交(含本目录);checkout 4a38106 会没有运行包——README §0 明确两者区别并给 merge-base 与 diff 空两条自校验;首次准备含 mkdir 骨架/模板落位/dist 哈希核验后只读复用/verify_offline 空档案守门复验,单一入口、不跳 HEL895 通用总装步骤",
"启停安全(42/42 离线替身验证,docs/rework/hel913/ctl-checks-run.txt):非法子命令拒绝、重复 start 拒绝、PID 复用拒杀(身份核验=cmdline+cwd)、启动失败清自己留日志、worker .env 占位/解析守门、arm 模板默认全 0 可解析",
"语音窗口唯一接线(README §5):ASR 三变量硬闸默认拒绝一切 ASR;session_ttl 600s+挂断删房;TTS 段数监控口径+tts.log 起始字节;force_end now|watch 到期可执行停服(各按专属句柄);wss 桥必需并给出默认 8363 接线",
"Mem0 安装完整可审(README §8):固定源双哈希核对→获批窗口下载 19+40 轮逐项 sha256→传输复核→隔离 venv /opt/cybergril-hel913/venv311 --no-index 安装→co-import→92MB 缓存落位→暂存清理;绝不向 /opt/cybergril-hel879/.venv311 增改;权威账只复制账本不搬测试聊天,authority 冻结前缀+embed_cap=680 累计算式;本卡零安装零下载零嵌入",
"启用方案单份一致且全部待批(capability §2):A 55 次/50 万 tokens 为建议顶、总管不自行放行、不并入用户实例;B 语音 3 通×≤10 分钟=≤1800 秒 ASR 硬闸(含重连≤2、TTS 60 段监控、上传/试听 ≤10 次监督口径)、语音 LLM 计入 B 对话日账、T0=交付时刻/本地自然日/团队准备不占额度、碰顶后果逐项、到期=force_end+seal 可执行且封账不解除;B 窗口内生活/主动计入 B 日账、窗口后全停,与 C(暂不批)无重叠口径",
"事实表纠错:生成中文字取消=未取得有效样本(未验证);打断停声=作者粗略观察非精确口径;首声修复后未实测、不承诺推算值;34/34 的 deps 只打印 4 包版本、空库裁剪未做种子对照——均在 README §7 与能力表如实标注;天气/素材/备份/摄像头/真实口型仍 ❌ 并追到承接",
"边界保持:未部署未监听、8300/8310/8320/8350 与资料未动、8340/ComfyUI 停机、4080 未触、受保护 venv 未增改、需求与用户原话零改动"
],
"deferred": [
"实际部署与启用:待总管向白栖知说明获批后按 deploy/hel913-unified/README.md §4 执行(四步:安装→独立部署→团队检查→用户首用,见 capability §2 B 前置)",
"Mem0 cp311 安装(README §8.3 W0–W6:19+40 轮获批下载窗口 + 隔离 venv --no-index 安装 + co-import + 缓存落位 + 权威账续账):待一次性批准,本卡零安装零下载",
"语音窗口执行:布防值(voice-window-arm 模板)与到期 watchdog/systemd timer 待批后由窗口负责人接线",
"persona 自主诠释、生活事件、主动留言的真实生成:从未在真实窗口发生(预算 0),A 团队检查与 B 用户首体验窗口补证",
"语音遗留实测:修复后首声、真人麦克风打断、生成中文字取消正面样本、真机/手机/Safari(不承诺任何推算值)",
"R5–R7 完整功能(每日自动备份/天气/素材/摄像头/真实口型)继续主卡承接,不因运行包完成而关闭"
],
"requirement_changes": [],
"verification": [
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 4a38106eef21c43ba24ab7d07992b1bfe539137d,均带 --handoff docs/requirements/handoff-HEL-913.json 通过(结构检查,不代替原话核对与体验验收)",
"docs/rework/hel913/verify-offline-run.txt:34/34(.12 venv311、产品基线树、零监听零出站;总管同环境独立复跑亦 34/34、dist 哈希吻合——已过项保留不重跑)",
"docs/rework/hel913/ctl-checks-run.txt:42/42 启停离线替身检查(工作站、stub、零真实服务/零监听/零出站)",
"产品代码零改动:git diff --stat 4a38106 <运行包提交> -- src web config tools tests 为空",
"未验证(如实):运行包尚未在任何现网端口实际起服(cap=0 未部署);修复后语音首声、真机、Safari——见 deferred,不冒充",
"总管机械收口:docs/rework/hel913/coordinator-close-checks.txt 17/17定向离线检查;真实sessions列表生成DELETE、环境变量传入、非法命令拒绝、失败句柄清理与shell语法;未安装/监听/调用真实服务。"
]
}
+48 -7
View File
@@ -8,9 +8,17 @@
// T2 while her R2 answer is STILL GENERATING (timing A, server turn-gate path)
// - in-page probes (single page clock): stream energy 50ms, el.muted transitions, voice phase,
// heard-final text, chat DOM rows, fetch network marks, MediaRecorder of the received stream
// - HEL-911 reliability fixes (offline-verified only, no re-run):
// * probe data is drained to disk in segments (call1-probe-live.jsonl + rec-part-*.webm)
// every few seconds, so a driver timeout kill can no longer lose the raw evidence
// (HEL-910 defect: first call's probe JSON/webm died with the browser);
// * the timing-A trigger requires SERVER-side turn activity (the user's voice utterance
// committed as a chat row) plus old voice actually audible where applicable — never
// "we sent something" as a proxy for "the API is working on it" (HEL-910: turn_start
// is emitted when the adapter opens the stream, which says nothing about server state).
// Usage: node hel910_call1.mjs <chrome> <fakeWav> <outDir> <baseURL>
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync, appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -50,7 +58,11 @@ const INIT = `(() => {
if (!cg.rec.started) {
try {
const mr = new MediaRecorder(el.srcObject, { mimeType: 'audio/webm;codecs=opus' });
const chunks = []; mr.ondataavailable = ev => { chunks.push(ev.data); cg.rec.bytes += ev.data.size; };
const chunks = []; mr.ondataavailable = ev => { chunks.push(ev.data); cg.rec.bytes += ev.data.size;
// HEL-911: also hand each chunk to the driver as base64 the moment it
// exists, so audio evidence survives a driver timeout kill.
ev.data.arrayBuffer().then(buf => { const b = new Uint8Array(buf); let s = ''; for (let i = 0; i < b.length; i += 32768) s += String.fromCharCode(...b.subarray(i, i + 32768)); (cg.rec.parts = cg.rec.parts || []).push(s); }).catch(() => {});
};
mr.onstop = () => { cg.rec.chunks = chunks; };
mr.start(1000); cg.rec.started = pnow(); cg.rec.mr = mr;
} catch (e) { cg.errs.push('rec:' + e); }
@@ -164,6 +176,24 @@ const started = Date.now();
const R = { log: [], notes: [] };
const note = s => { R.log.push([Date.now() - started, s]); console.log(`[${((Date.now() - started) / 1000).toFixed(1)}] ${s}`); };
// HEL-911: segmented evidence drain — every 5s pull ONLY the new probe slices and
// append them as one JSONL line, and drain recorder chunks to rec-part-<n>.webm.
// A timeout kill now loses at most one 5s segment instead of the whole probe
// (the HEL-910 first-call loss mode).
const livePath = join(out, 'call1-probe-live.jsonl');
let drained = { energy: 0, muted: 0, phase: 0, heard: 0, chat: 0, net: 0 };
let recPart = 0;
const drainSegments = async () => {
try {
const s = await js(`(() => { const cg = window.__cg; const parts = (cg.rec.parts || []).splice(0); return JSON.stringify({ anchor: cg.anchor, marks: cg.marks, errs: cg.errs, slices: { energy: cg.energy.slice(${drained.energy}), muted: cg.muted.slice(${drained.muted}), phase: cg.phase.slice(${drained.phase}), heard: cg.heard.slice(${drained.heard}), chat: cg.chat.slice(${drained.chat}), net: cg.net.slice(${drained.net}) }, counts: { energy: cg.energy.length, muted: cg.muted.length, phase: cg.phase.length, heard: cg.heard.length, chat: cg.chat.length, net: cg.net.length }, parts }); })()`);
const d = JSON.parse(s);
for (const p of d.parts || []) writeFileSync(join(out, `rec-part-${String(++recPart).padStart(4, '0')}.webm`), Buffer.from(p, 'base64'));
appendFileSync(livePath, JSON.stringify({ wall: Date.now(), ...d, parts: undefined }) + '\n');
drained = { energy: d.counts.energy, muted: d.counts.muted, phase: d.counts.phase, heard: d.counts.heard, chat: d.counts.chat, net: d.counts.net };
} catch (e) { /* page mid-navigation etc. — next tick retries */ }
};
const segmentTimer = setInterval(() => { drainSegments().catch(() => {}); }, 5000);
await cdp('Page.addScriptToEvaluateOnNewDocument', { source: INIT });
await cdp('Page.enable'); await cdp('Network.enable');
@@ -222,18 +252,27 @@ await until(async () => { const e = await energyRecent(16); return e.length && e
await until(async () => (await js(`window.__cg.chat.some(c => c.role === 'companion' && c.t > (window.__cg.marks.T1_sent || 0))`)), 90000, 300).then(v => note('T1 companion reply visible: ' + !!v));
await shot('05-T1-reply');
// U2 @60-73.3s: prefill T2, fire Enter the moment thinking starts for the bajie answer
await until(async () => (await js(`window.__cg.heard.some(h => h[1] === 'final' && h[2].includes('取经'))`)), 90000).then(v => note('U2 heard final (bajie): ' + !!v));
// U2 @60-73.3s: prefill T2; HEL-911 trigger = the U2 VOICE turn is committed
// server-side (its user row appears in the chat DOM — the server only writes that
// row once it has the turn), NOT page phase alone and NOT any "we saw/sent X"
// proxy. The thinking phase is still required (generation in flight for timing A),
// but the server-activity row is what proves the API is actually working this turn.
await until(async () => (await js(`window.__cg.heard.some(h => h[1] === 'final' && (h[2].includes('取经') || h[2].includes('老庄') || h[2].includes('俺老猪'))) || window.__cg.chat.some(c => c.role === 'user' && (c.text.includes('取经') || c.text.includes('俺老猪')))`)), 90000, 200).then(v => note('U2 recognized/committed: ' + !!v));
await js(`(() => { const el = document.querySelector('.composer textarea'); if (el) { el.focus(); const proto = HTMLTextAreaElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(T2_TEXT)}); el.dispatchEvent(new Event('input', { bubbles: true })); } })()`);
const t2fired = await until(async () => (await phaseNow()) === 'thinking', 30000, 80);
const t2fired = await until(async () => {
const ph = await phaseNow();
if (ph !== 'thinking' && ph !== 'speaking') return false;
// server actually holds the U2 voice turn: its user row is in the chat DOM
return await js(`window.__cg.chat.some(c => c.role === 'user' && (c.text.includes('取经') || c.text.includes('俺老猪')))`);
}, 45000, 100);
if (t2fired) {
await js(`window.__mark('T2_thinking_seen')`);
await js(`window.__mark('T2_server_active_seen')`);
await cdp('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
await cdp('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
await js(`window.__mark('T2_sent')`);
note('T2 sent during thinking (generation in flight)');
} else {
note('T2 NOT fired: thinking phase not observed in time');
note('T2 NOT fired: server-committed U2 turn + thinking phase not both observed in time');
}
await sleep(1500);
await shot('06-after-T2');
@@ -261,6 +300,8 @@ await finish('done');
async function finish(status) {
R.status = status;
clearInterval(segmentTimer);
await drainSegments().catch(() => {}); // last segment before the full dump
try {
await js(`(() => { try { window.__cg.rec.mr && window.__cg.rec.mr.state !== 'inactive' && window.__cg.rec.mr.stop(); } catch (e) {} })()`);
await sleep(800);
+11
View File
@@ -83,3 +83,14 @@
- **真实端点带 `</think>` 形状**:未出现,真实分离未复现(离线证据在 HEL-909)。
- 首声等待"经常>5s"与否:需多样本;本轮 1 精确样本 + 1 量级相近。
- 真机/手机麦克风、Safari:未测(D3 用户动作)。
## 9. HEL-911 纠偏记录(2026-10-29 追加;上文原报告原样保留,不回改)
以下按总管独立复算(附件 hel910-independent-check.md)与 HEL-911 离线复现订正本文数字,原始日志/录音/快照未动:
- **§0/§4 的"18 条 companion 行"不准确**:`window/db-final.json` 共 20 条 chat_message = user 11 + **companion 9**。思考隔离结论(零 `<think>` 泄漏)不变,基数以 9 为准。
- **§3 的等待链分段"turn_start +4.10s"不符合 marks**:同钟原始 marks 为 playback_end → user_final **+2.432s** → turn_start **+6.099s** → assistant_interim +7.514s → 首有声帧 ≈+9.51s(frames 第二列是字节数,PCM 样本数需除 2;RMS 阈值 500 复算首声 9.514s,阈值 200 为 9.474s,"约 9.5s"总量成立)→ assistant_final +12.102s。原"生成 3.4s+TTS 0.5s"的相加归因作废,按真实时刻重建见下条与 `docs/rework/hel911/summary.md`。
- **§3 "字幕全文/落库 +12.10s" 的语义错误**:assistant_final 字幕是**播放收尾/回执门**(整段播完且回执合法才发,contract v2),不是首次全文生成、更不是落库时刻。落库是 DB 行 14 ts=+7.511s(与 assistant_interim +7.514s 同刻)。
- **§7 "LiveKit ListRooms 空"的证据引述错误**:`window/post-window.txt` 里 ListRooms 的实际输出是 `invalid authorization token`,不能作为空房证据;总管其后用服务器已有 agent venv 只读加载并独立核实 `room_names=[]`(不打印凭证),空房结论成立但证据来源以此纠偏为准。
- **§6 两个 🟡 已在 HEL-911 修复**:`_on_item` 对 AgentHandoff 取 `role` 的 AttributeError,与 job 关停 `TypeError: object NoneType can't be used in 'await' expression`(同步卸载回调被 SDK await)。
- **§6 首声等待的构成已重新归因**(离线可证部分):user_final→turn_start 的 3.65~3.67s 是 livekit-agents 1.8.8 默认补入的**英文 EOU TurnDetector** 对中文终稿给出"不太像说完"判定、把 endpointing 延迟顶到 max_delay=6.0 所致;显式 `turn_detection: None` 后离线同链路该段降为 ~5ms。VAD 端点本身 ~0.7s、FunASR 离线纠错+适配器 0.6s 去抖共 2.43s、服务端整轮生成 ~1.4s、TTS 首包 1.61s 为主构成。修复已入 worker.py(TURN_HANDLING),**首声总时延下降需真实窗口复测确认,本文不宣称**。
+133
View File
@@ -0,0 +1,133 @@
# HEL-911 · 修好通话收尾报错并查清开口等待(2026-10-29)
> 起点:`agent/agent/ab943ea62162` @ `407fc81`(含完整 958c696 产品与 HEL-910 证据,
> SHA 已核)。需求基准 v1.12/60 项;关联 CG-006/010/013–016/018–021、AM-001、
> GOV-001–006、DEL-001;本卡无需求变更。全程真实 cap=0:零 LLM/ASR/TTS/嵌入/
> 登记/试听调用,未建任何房间/窗口,未恢复任何本窗服务;仅只读复用 .12
> `/opt/voice-agent/agent/venv`(livekit-agents 1.8.8 实物)与既有 wav 证据做离线证明。
## 1. 两个 SDK 接缝错误的修复(真实 1.8.8 驱动验证)
### 1.1 `conversation_item_added` 的非 ChatMessage 项目崩溃(worker.py `_on_item`)
- **事实**:1.8.8 的 `ConversationItemAddedEvent.item` 是
`ChatMessage | AgentHandoff | _TypeDiscriminator`(`voice/events.py:419`)。会话启动即入
一条 `AgentHandoff`(无 `role`/`content`),旧代码直取 `ev.item.role` 抛
`AttributeError`——HEL-910 两轮通话 worker.log 实录,每通各一次。
- **修复**(src/cybergril/voice/worker.py `_on_item`):按 SDK 判别字段
`item.type == "message"` 过滤,再按 `role == "assistant"` 取**纯 str 部件**正文
(`ChatContent = str | ImageContent | AudioContent | CacheBreakpoint`,
`llm/chat_context.py:459`)。用户消息、AgentHandoff、unknown 项不更新
`agent_recent_text`;正常 assistant 正文继续喂回声过滤,语义不变。
- **验证**:tests/test_sdk_livekit_hel908.py
`test_conversation_item_added_filters_non_message_items`——真 SDK 真 pydantic 模型
驱动:AgentHandoff / `_TypeDiscriminator` / user 消息 / 混合 content 的 assistant
消息四例,前三种不碰状态,第四种只取正文。
### 1.2 shutdown 回调的异步协议(worker.py `bind_interrupt_channel`)
- **事实**:`JobContext.add_shutdown_callback` 把无参回调包成 `await callback()`
(`job.py:633-643`),job 关停逐个 await(`ipc/job_proc_lazy_main.py:493`)。旧的同步
`def unload() -> None` 因此在关停路径抛
`TypeError: object NoneType can't be used in 'await' expression`(HEL-910 实录)。
- **修复**:unload 改为**协程**;幂等(重复 shutdown/多次调用安全);房间未连接/已断连
不抛。`session.interrupt(force=True)`、回执/预算停机路径一字未动。
- **验证**:`test_shutdown_callback_protocol_real_jobcontext_wrapper`——用**真
JobContext.add_shutdown_callback 方法**做包装(仅缺 job 进程管线),按
job_proc_lazy_main 的原样 await 驱动:新协程通过;旧的同步形状经同一包装复现出
生产那条 TypeError(反向证据);重复 await 幂等;卸载后真 rtc.Room 再收
cg.interrupt 消息监听已摘除。既有三项 R2 通道测试同步改为 `await unload()`,
校验/未启动/卸载语义全部保持。
## 2. 9.5 秒等待分段核对(已有证据 + 实际 SDK 源码 + 离线复现)
### 2.1 按真实时刻重建的分段(call2 q1/q2,同钟 marks)
| 分段 | q1 实测 | 构成(已证) |
| --- | --- | --- |
| 回放结束 → ASR 终稿 | +2.432s | silero 端点 ~0.7s(含 min_silence 0.55s)+ FunASR 2pass 离线纠错往返 + **适配器 0.6s 去抖合并**(stt_funasr.py `emit_merged_final`) |
| ASR 终稿 → 生成开始(turn_start) | **+3.667s(q2 +3.654s)** | **本卡查清,见 2.2——默认英文 EOU TurnDetector 把 endpointing 顶到 max_delay=6.0** |
| 生成开始 → 服务端首条 delta | ~1.42s | 服务端整轮缓冲生成(buffer_turn 已知代价,换零思考泄漏;DB 行 14 ts=+7.511 与 assistant_interim +7.514 同刻) |
| 首条 delta → 第一有声帧 | ~2.0s | CosyVoice3 首包实测 1.614s(worker.log:220 `[tts] first audio after 1.614s`)+ 播放管线 ~0.4s |
| (收尾)assistant_final 字幕 | +12.102s | **播放收尾/回执门**,非首次全文生成/落库时刻(纠偏,见 §4) |
### 2.2 user_final → turn_start 的 3.66s:成因与修复(离线 A/B 证实)
- **成因**:livekit-agents 1.8.8 在 `turn_handling` 未显式给 `turn_detection` 键时
**默认构造 `inference.TurnDetector()`**(`voice/agent_session.py:576-579`
`turn_handling.get("turn_detection", inference.TurnDetector())`;自托管回落本地
v1-mini 英文 EOU 模型)。worker 只配了 `endpointing`,于是这个英文模型对**中文**
终稿做端点预测;其"不太像说完"判定走
`audio_recognition.py` `_bounce_eou_task` 的
`end_of_turn_probability < unlikely_threshold → endpointing_delay = max_delay`
路径,把等待顶到 6.0s:
`extra_sleep = 6.0 + 说话结束锚点(≈+0.14) − 终稿时刻(≈+2.45) ≈ 3.69s`——与两轮
实测 3.667/3.654s 对上(`_run_eou_detection(stt)` 内锚点体检打印实录)。
- **离线复现**(scripts/gen_hel911_wait_repro.py,.12 真 SDK + 真 silero VAD +
HEL-910 实际回放 wav 按实测时间脚本化 STT/LLM/TTS,零外呼):
- BASELINE(复刻旧配置,默认 TurnDetector 在):终稿 +2.443 → llm.chat +6.142
(**3.699s**,复现实测 3.667/3.654);VAD 端点 +0.712;首声 +9.18——与生产
9.5s 形状一致。
- 修复后(显式 `turn_detection: None`):终稿 +2.439 → llm.chat +2.444
(**0.005s**);首声 +5.48。
- **修复**(worker.py `TURN_HANDLING`):显式 `"turn_detection": None`。轮次边界回到
本会话自己配置的 VAD endpointing(min 0.8 / max 6.0 原样保留),**不降 VAD 阈值、
不动 buffer_turn、不改模型/网关/认证**;短轮次行为不变(call2"盯着"轮原本就在终稿后
8ms 提交——其终稿晚于 说话结束+0.8s,min_delay 锚点已过)。附带收益:不再预载
v1-mini 本地权重(~244MB/进程)。
- **回归**:`test_turn_handling_disables_default_english_eou_detector` 断言真 SDK 下
默认会装探测器(若 SDK 行为变更该前置断言会失败,提示重新归因)、worker 配置下为
None、endpointing 参数逐字未动。
- **未承诺**:首声总时延 9.5s → 约 5.9s 是离线推算;**需真实窗口复测确认**,本卡
cap=0 未跑真实通话。
### 2.3 分段归因清单(已证 / 已修 / 需真实环境)
- 已证(已有证据+源码+离线复现):2.432s(FunASR 链+去抖)、3.66s(默认英文
TurnDetector)、~1.4s(服务端整轮生成)、TTS 首包 1.614s、VAD 端点 ~0.7s。
- 已修(离线定向验证通过,待真实复测收数字):3.66s 段(TURN_HANDLING)。
- 需真实环境才能判断:修复后首声总时延的真实值;FunASR 离线纠错往返在真实麦克风
下的分布(当前只有工程回放样本);"经常>5s"与否需多样本。
## 3. 修复清单与验证记录
| 变更 | 文件 | 验证 |
| --- | --- | --- |
| `_on_item` 类型过滤 | `src/cybergril/voice/worker.py` | 真 SDK 4 例(§1.1) |
| unload 协程化+幂等 | `src/cybergril/voice/worker.py` | 真 JobContext 包装协议(§1.2) |
| 显式禁用默认 TurnDetector | `src/cybergril/voice/worker.py`(`TURN_HANDLING`) | 离线 A/B + 真会话断言(§2.2) |
| 等待链离线复现工具 | `scripts/gen_hel911_wait_repro.py`(新增) | BASELINE/FIXED 两模式跑通 |
| 测量工具可靠性修正 | `docs/rework/hel910/tools/hel910_call1.mjs` | `node --check` 通过(离线) |
| HEL-910 事实纠偏 | `docs/rework/hel910/window-record.md` §9 追加 | 原文未回改 |
- **真 SDK harness**(.12 agent venv 1.8.8):
`tests/test_sdk_livekit_hel908.py --standalone` **11/11**(既有 8 项回归全保持 +
本卡 3 项新证)。
- **产品测试套**(.12 规范环境 `.venv311` 只读复用):**480 passed / 11 skipped /
2 failed**——两个 `test_r3` 基线/迁移失败与 HEL-908 记录一致(预先存在,前轮已留证)。
本地 py3.10 环境 75/81 通过、6 失败与基准 407fc81 干树完全同集合(环境差异,非本卡
引入)。
- web 未动,未重建界面;ruff/lint 未配置进仓库流程,Python 语法经两环境实测导入通过。
## 4. HEL-910 交付说明纠偏(详见 window-record.md §9)
companion 实为 **9 条**(非 18);生成起点 **+6.099s**(非 +4.10);assistant_final
是**播放收尾/回执门**(非首次全文生成/落库,落库在 +7.511s);post-window.txt 的
ListRooms 实为 `invalid authorization token`,不能证明空房(总管已独立核实空房成立)。
## 5. 测量工具可靠性修正(仅必要项,离线验证,不执行真实窗口)
- **分段落盘**:hel910_call1.mjs 每 5s 把页内探针新增切片追加写
`call1-probe-live.jsonl`、录音分片即产即存 `rec-part-NNNN.webm`——驱动被超时杀掉
最多丢 5s 一段,不再整包丢失(HEL-910 第一通丢证模式)。`node --check` 通过。
- **打断触发条件**:时机A 不再以页面相位/己方发送为"服务端已激活"的代理,改为
**服务端已把 U2 语音轮落成 chat 行**(用户行出现在会话 DOM)+ 生成中相位双条件;
旧"发过 turn_start 即认为 API 已激活"的假设作废。
## 6. 未测 / 承接(如实)
- 修复后首声真实数字、真实麦克风打断时延、可闻旧声中的回放打断正面样本、生成中
文字取消正面样本:需下轮真实工程窗口(门控与分段落盘已备好)。
- FunASR 适配器 0.6s 去抖与 2pass 离线纠错往返是否可再压:属识别链参数,本卡不动
语义,留待与真实复测同窗评估。
- 真实端点带 `</think>` 形状、真机/手机麦克风、Safari、完整 R1–R7:继续主卡承接。
@@ -0,0 +1,19 @@
PASS shell syntax api_ctl.sh
PASS shell syntax bridge_ctl.template.sh
PASS shell syntax force_end.template.sh
PASS shell syntax test_ctl_scripts.sh
PASS shell syntax tts_ctl.template.sh
PASS shell syntax worker_ctl.sh
PASS bridge_ctl.template.sh refuses invalid command before any service action
PASS tts_ctl.template.sh refuses invalid command before any service action
PASS deadline cleanup fixture exits successfully
PASS real sessions LIST schema produces both room cleanup requests
PASS launcher passes CYBERGRIL_SEMANTIC_CONFIG
PASS launcher passes FASTEMBED_CACHE_PATH
PASS launcher passes MEM0_TELEMETRY
PASS launcher passes HF_HUB_OFFLINE
PASS launcher passes TRANSFORMERS_OFFLINE
PASS launcher passes TZ
PASS failed stub launch removes its PID handle and exits failure
17/17 targeted offline checks passed
No real services, listeners, provider requests, installs or embeddings.
+49
View File
@@ -0,0 +1,49 @@
# HEL-913 启停脚本离线替身检查(收口轮)
# 时间:2026-10-29T14:25:58Z;机器:工作站(总工运行时);方式:stub 解释器 + 静态检查,零真实服务/零监听/零出站
# 对象:本运行包收口版 api_ctl.sh / worker_ctl.sh / 三个窗口模板(bridge/tts/force_end)
# 结论:见末行(42 项全过;含非法子命令拒绝、重复 start 拒绝、PID 复用拒杀、启动失败收尾、.env 守门、模板默认 0)
PASS syntax api_ctl.sh
PASS syntax worker_ctl.sh
PASS syntax bridge_ctl.template.sh
PASS syntax tts_ctl.template.sh
PASS syntax force_end.template.sh
PASS package file README.md
PASS package file manifest.json
PASS package file dist-manifest.json
PASS package file models.template.yaml
PASS package file voice.template.yaml
PASS package file worker.env.template
PASS package file verify_offline.py
PASS package file mem0-cp311-wheels.txt
PASS package file voice-window-arm.template.env
PASS package file test_ctl_scripts.sh
PASS api_ctl invalid subcommand refused (rc=2)
PASS api_ctl invalid subcommand started nothing
PASS worker_ctl invalid subcommand refused (rc=2)
PASS worker_ctl invalid subcommand started nothing
PASS api_ctl failed start exits 1
PASS api_ctl failed start removed pid file
PASS api_ctl failed start kept log
PASS api_ctl failed start left no stub process
PASS stub api process running (pid 73678)
PASS api_ctl duplicate start refused (rc=1)
PASS api_ctl duplicate start kept original handle
PASS api_ctl stop refused reused pid (rc=1)
PASS unrelated process untouched
PASS api_ctl stop with dead pid exits 0
PASS api_ctl stop removed stale pid file
PASS worker_ctl refuses .env with unfilled placeholders (rc=1)
PASS placeholder refusal started nothing
PASS worker_ctl refuses unparseable .env (rc=1)
PASS parse refusal started nothing
PASS worker_ctl failed start exits 1
PASS worker_ctl failed start removed pid file
PASS worker_ctl failed start kept log
PASS worker_ctl failed start left no stub process
PASS worker_ctl duplicate start refused (rc=1)
PASS worker_ctl stop refused reused pid (rc=1)
PASS worker stop left unrelated process alive
PASS voice-window-arm.template.env parses, defaults 0/unarmed
42 passed, 0 failed
+36
View File
@@ -0,0 +1,36 @@
PASS python 3.11 (actual (3, 11, 16))
PASS cybergril imported from candidate tree (/tmp/hel913-verify/src/cybergril/__init__.py)
PASS deps: fastapi 0.159.1 uvicorn 0.54.0 httpx 0.28.1 pydantic 2.14.1
PASS product modules import (api.app / voice.router / voice.session)
PASS mem0 absent in product venv (as documented)
PASS qdrant_client absent in product venv (as documented)
PASS fastembed absent in product venv (as documented)
PASS GET /api/health 200, text capability true
PASS health: companion_created=false before creation
PASS health: llm honestly not ok (dead addr, cap=0)
PASS GET /api/companion 404 before creation (no fabricated profile)
PASS POST /api/companion/preview 200 (local bazi compile)
PASS POST /api/companion 201
PASS bazi_core persisted and matches preview (day master 辛)
PASS second create refused 409 (single companion guard)
PASS persona honest at cap=0 (state=failed, no fabricated interpretation)
PASS POST /api/chat opens SSE stream
PASS chat honestly errors at cap=0 (frames=['chat.error'])
PASS zero provider stream calls (outbound=[])
PASS failed turn persisted as user row, no fake companion reply
PASS retry same failed turn: honest error again, still zero outbound
PASS PATCH /api/relation 200 (relation adjustable, name kept)
PASS GET /api/life 200 empty (only told events, none yet)
PASS GET /api/memories 200 empty
PASS GET /api/life/1 404 (unshared detail refused)
PASS POST /api/chat/received 404 on unknown receipt
PASS GET /api/voice/status 200, voice honestly false
PASS POST /api/voice/session honest 503 when TTS unreachable (503)
PASS DELETE /api/voice/session/{unknown} honest 404
PASS GET / serves web dist index.html
PASS reopen: epoch=2 (got 2)
PASS reopen: companion identity + adjusted relation retained
PASS reopen: chat history retained, still no fabricated replies
PASS dist matches manifest (8 files; bad=[])
34/34 checks passed
+432
View File
@@ -0,0 +1,432 @@
"""HEL-911 离线等待链复现:真 AgentSession + 真 silero VAD + 脚本化 STT/LLM/TTS.
目的:把 HEL-910 call2 实测的「识别终稿 → 生成开始」3.667s 间隙(q1
+2.432→+6.099、q2 +48.525→+52.179,两轮同为 3.65~3.67s)在完全离线的
真实 SDK 1.8.8 里复现或排除,从而把 9.5s 首声等待的分段归因落到可检验
的事实上。
做法(全部本地计算,零网络,零 LLM/ASR/TTS 外呼):
- 输入:HEL-910 真实回放用过的 /opt/cybergril-hel910/etc/trim/u_bajie.trim.wav
按 100ms 帧、真实时钟节奏推给 session.input.audio(与 hel910_client 的
发布节奏一致),播完继续推真零样本静音帧(对应 asr-usage.jsonl 证实
的连续帧流 ~100.5% 实时);
- STT:脚本化 stt.STT,按实测时间发 INTERIM(语音期间)与 FINAL(回放
结束后 +2.432s,即 FunASR 离线纠错 + 适配器 0.6s 去抖的实测结果),
不设 end_time(与 FunASRSTT 的 SpeechData 形状一致);
- LLM:脚本化 LLM,记录 chat() 调用时刻(= 生产链路里 on_stream_started
→ 字幕 turn_start 的同一时刻),1.415s 后发两条 delta(对应实测
turn_start→assistant_interim 的服务端整轮生成时延);
- TTS:脚本化 ChunkedStream,1.614s 后出首帧(对应实测
[tts] first audio after 1.614s,worker.log:220);
- 会话参数与 worker.py entrypoint 完全一致(真 VAD.load()、
endpointing min 0.8 / max 6.0)。
输出各里程碑相对回放结束(= wav 末帧后第一帧静音的时刻)的秒数,与
HEL-910 实测列对照。本脚本不修改任何服务,只读 wav。
运行(.12 agent venv):
/opt/voice-agent/agent/venv/bin/python scripts/gen_hel911_wait_repro.py
"""
from __future__ import annotations
import asyncio
import sys
import time
import wave
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from livekit import rtc
from livekit.agents import stt, tts, llm as lk_llm
from livekit.agents.llm import ChatContext, LLM, LLMStream, Tool
from livekit.agents.stt import RecognizeStream, SpeechData, SpeechEvent, SpeechEventType
from livekit.agents.tts import AudioEmitter, ChunkedStream
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions
from livekit.agents.voice import Agent, AgentSession
from livekit.plugins.silero import VAD
WAV = Path("/opt/cybergril-hel910/etc/trim/u_bajie.trim.wav")
# HEL-910 call2 实测时延(相对回放结束/各起点)
FINAL_AFTER_PLAYBACK_END = 2.432 # user_final(VAD+FunASR 离线+0.6s 去抖)
GEN_AFTER_TURN_START = 1.415 # turn_start -> assistant_interim(服务端整轮)
TTS_FIRST_AUDIO = 1.614 # [tts] first audio after(worker.log:220)
TAIL_SILENCE = 14.0 # 回放结束后继续推的静音时长
def _now() -> float:
return time.perf_counter()
class Timeline:
def __init__(self) -> None:
self.t0: float | None = None
self.playback_end: float | None = None
self.marks: list[tuple[str, float]] = []
def mark(self, name: str) -> None:
self.marks.append((name, _now()))
class ScriptedSTT(stt.STT):
"""按 HEL-910 实测时间发 FINAL 的 STT;不连任何识别服务。"""
def __init__(self, tl: Timeline) -> None:
super().__init__(capabilities=stt.STTCapabilities(streaming=True, interim_results=True))
self._tl = tl
@property
def provider(self) -> str:
return "scripted"
@property
def model(self) -> str:
return "hel910-timeline"
async def _recognize_impl(self, buffer, *, language=None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> SpeechEvent:
raise NotImplementedError("scripted STT only supports streaming")
def stream(self, *, language=None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> RecognizeStream:
return ScriptedRecognizeStream(stt=self, conn_options=conn_options, tl=self._tl)
class ScriptedRecognizeStream(RecognizeStream):
def __init__(self, *, stt: ScriptedSTT, conn_options: APIConnectOptions,
tl: Timeline) -> None:
super().__init__(stt=stt, conn_options=conn_options)
self._tl = tl
def _ev(self, ev_type: SpeechEventType, text: str) -> SpeechEvent:
return SpeechEvent(type=ev_type,
alternatives=[SpeechData(language="zh", text=text)])
async def _run(self) -> None:
tl = self._tl
async def schedule_final() -> None:
# 等回放结束锚点(由输入源标记),再按实测延迟发终稿
while tl.playback_end is None:
await asyncio.sleep(0.02)
await asyncio.sleep(FINAL_AFTER_PLAYBACK_END)
tl.mark("stt_final_emitted")
self._event_ch.send_nowait(
self._ev(SpeechEventType.FINAL_TRANSCRIPT,
"师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素"
"包子,猴哥,你筋斗云块,要不先去化个缘"))
final_task = asyncio.create_task(schedule_final())
sent_interim = False
try:
async for ev in self._input_ch:
if ev is None:
break
if isinstance(ev, rtc.AudioFrame) and not sent_interim:
sent_interim = True
self._event_ch.send_nowait(
self._ev(SpeechEventType.INTERIM_TRANSCRIPT, "师傅"))
await asyncio.sleep(0)
finally:
final_task.cancel()
class ProbeLLM(LLM):
def __init__(self, tl: Timeline) -> None:
super().__init__()
self._tl = tl
@property
def provider(self) -> str:
return "probe"
@property
def model(self) -> str:
return "hel911-wait-probe"
def chat(self, *, chat_ctx: ChatContext, tools: list[Tool] | None = None,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, **_
) -> LLMStream:
self._tl.mark("llm_chat_called") # = 生产链路的 turn_start 时刻
return ProbeLLMStream(llm=self, chat_ctx=chat_ctx, tools=tools or [],
conn_options=conn_options, tl=self._tl)
class ProbeLLMStream(LLMStream):
def __init__(self, *, llm: ProbeLLM, chat_ctx: ChatContext, tools: list[Tool],
conn_options: APIConnectOptions, tl: Timeline) -> None:
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
self._tl = tl
async def _run(self) -> None:
from livekit.agents.llm import ChatChunk, ChoiceDelta
await asyncio.sleep(GEN_AFTER_TURN_START)
self._tl.mark("first_delta")
self._event_ch.send_nowait(ChatChunk(
id="hel911-probe",
delta=ChoiceDelta(role="assistant", content="好,小十。去化缘")))
self._event_ch.send_nowait(ChatChunk(
id="hel911-probe", delta=ChoiceDelta(role="assistant", content="")))
self._event_ch.send_nowait(ChatChunk(id="hel911-probe"))
class ProbeTTS(tts.TTS):
def __init__(self, tl: Timeline) -> None:
super().__init__(capabilities=tts.TTSCapabilities(streaming=False),
sample_rate=24000, num_channels=1)
self._tl = tl
@property
def provider(self) -> str:
return "probe"
@property
def model(self) -> str:
return "hel911-wait-probe"
def synthesize(self, text: str, *,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
) -> ChunkedStream:
return ProbeChunkedStream(tts=self, input_text=text,
conn_options=conn_options, tl=self._tl)
class ProbeChunkedStream(ChunkedStream):
def __init__(self, *, tts: ProbeTTS, input_text: str,
conn_options: APIConnectOptions, tl: Timeline) -> None:
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
self._tl = tl
async def _run(self, output_emitter: AudioEmitter) -> None:
output_emitter.initialize(request_id="hel911", sample_rate=24000,
num_channels=1, mime_type="audio/pcm")
await asyncio.sleep(TTS_FIRST_AUDIO) # 实测 CosyVoice3 首包
self._tl.mark("tts_first_audio")
# ~1s 的可听音频(20ms 帧),足够走完 playout/回执路径
frame = b"\x10\x00" * 480
for _ in range(50):
output_emitter.push_frame(rtc.AudioFrame(frame, 24000, 1, 480))
await asyncio.sleep(0.02)
output_emitter.flush()
def make_sink(tl: Timeline):
from livekit.agents.voice.io import AudioOutput, AudioOutputCapabilities
class _Sink(AudioOutput):
def __init__(self) -> None:
super().__init__(label="probe-sink",
capabilities=AudioOutputCapabilities(pause=False))
self.first_frame_at: float | None = None
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
if self.first_frame_at is None:
tl.mark("sink_first_frame")
self.first_frame_at = _now()
def flush(self) -> None:
self.on_playback_finished(interrupted=False)
def clear_buffer(self) -> None:
self.on_playback_finished(interrupted=True)
return _Sink()
class MicSource:
"""wav 实时回放 + 尾部连续静音,节奏与 hel910_client._publish_pcm 一致。"""
def __init__(self, tl: Timeline) -> None:
self._tl = tl
def make(self):
tl = self._tl
from livekit.agents.voice.io import AudioInput
with wave.open(str(WAV), "rb") as w:
raw = w.readframes(w.getnframes())
rate = w.getframerate()
class _Source(AudioInput):
def __init__(self) -> None:
super().__init__(label="hel911-mic")
self._i = 0
self._phase = "wav"
async def __anext__(self) -> rtc.AudioFrame:
step = 1600 * 2 # 100ms @16k,同 hel910_client
if self._phase == "wav":
chunk = raw[self._i:self._i + step]
if tl.t0 is None:
tl.t0 = _now()
if len(chunk) < step:
chunk = chunk + b"\x00" * (step - len(chunk))
self._i += step
if self._i >= len(raw):
self._phase = "silence"
tl.playback_end = _now()
tl.mark("playback_end")
await asyncio.sleep(0.1)
return rtc.AudioFrame(chunk, rate, 1, 1600)
# 静音段:真实时钟节奏(帧流连续,asr-usage 证实)
await asyncio.sleep(0.1)
return rtc.AudioFrame(b"\x00" * step, rate, 1, 1600)
return _Source()
async def main() -> None:
if not WAV.exists():
raise SystemExit(f"wav not found: {WAV}")
import logging
logging.basicConfig(level=logging.INFO) # eot prediction 走 debug 时再降
logging.getLogger("livekit.agents.voice.audio_recognition").setLevel(logging.DEBUG)
import os
# 默认复刻生产接线(worker.TURN_HANDLING,已显式 turn_detection=None);
# HEL911_BASELINE=1 复现缺陷态:1.8.8 对缺省键默认补英文 TurnDetector
baseline = os.environ.get("HEL911_BASELINE") == "1"
from cybergril.voice.worker import TURN_HANDLING as WORKER_TURN_HANDLING
turn_handling = (
{k: v for k, v in WORKER_TURN_HANDLING.items() if k != "turn_detection"}
if baseline else dict(WORKER_TURN_HANDLING)
)
tl = Timeline()
session = AgentSession(
stt=ScriptedSTT(tl),
llm=ProbeLLM(tl),
tts=ProbeTTS(tl),
vad=VAD.load(),
turn_handling=turn_handling,
)
session.input.audio = MicSource(tl).make() # type: ignore[assignment]
session.output.audio = make_sink(tl) # type: ignore[assignment]
@session.on("user_state_changed")
def _ustate(ev):
tl.mark(f"user_state_{getattr(ev, 'new_state', '?')}")
@session.on("agent_state_changed")
def _astate(ev):
tl.mark(f"agent_state_{getattr(ev, 'new_state', '?')}")
@session.on("user_input_transcribed")
def _tr(ev):
if ev.is_final:
tl.mark("sdk_user_final")
await session.start(agent=Agent(instructions="probe"))
tl.mark("session_started")
# 定位 FINAL→llm.chat 间隙:给真 SDK 的提交/调度内部打点(只读包装,不改行为)
from livekit.agents.voice import agent_activity
activity = session._activity
_orig_eou = activity.on_end_of_turn
def _logged_eou(info):
committed = _orig_eou(info)
tl.mark(f"on_end_of_turn(committed={committed})")
return committed
activity.on_end_of_turn = _logged_eou # type: ignore[method-assign]
_orig_gen = activity._generate_reply
def _logged_gen(**kwargs):
handle = _orig_gen(**kwargs)
tl.mark("_generate_reply")
return handle
activity._generate_reply = _logged_gen # type: ignore[method-assign]
_orig_sched = activity._schedule_speech
def _logged_sched(speech, priority, force=False):
tl.mark("_schedule_speech")
return _orig_sched(speech, priority, force=force)
activity._schedule_speech = _logged_sched # type: ignore[method-assign]
_orig_run_eou = type(activity._audio_recognition)._run_eou_detection
def _logged_run_eou(self, chat_ctx, *, trigger, skip_reply=False):
tl.mark(f"_run_eou_detection({trigger})")
# EOU 锚点体检:提交前的 extra_sleep 由这些值决定
lst = self._last_speaking_time
print(f" [eou:{trigger}] speaking={self._speaking} "
f"last_speaking_time={'none' if lst is None else f'{time.time() - lst:+.3f}s ago'} "
f"final_at={'none' if self._last_final_transcript_time is None else f'{time.time() - self._last_final_transcript_time:+.3f}s ago'} "
f"endpointing_min={self._endpointing.min_delay} "
f"turn_detector={type(self._turn_detector).__name__ if self._turn_detector else None} "
f"td_stream={type(self._turn_detector_stream).__name__ if self._turn_detector_stream else None} "
f"mode={self._turn_detection_mode}", flush=True)
return _orig_run_eou(self, chat_ctx, trigger=trigger, skip_reply=skip_reply)
type(activity._audio_recognition)._run_eou_detection = _logged_run_eou
ar = activity._audio_recognition
_orig_span = ar._ensure_eou_wait_span
def _logged_span(user_turn_span, **kwargs):
tl.mark("_ensure_eou_wait_span")
out = _orig_span(user_turn_span, **kwargs)
print(f" [eou-span] args={kwargs}", flush=True)
return out
ar._ensure_eou_wait_span = _logged_span # type: ignore[method-assign]
import livekit.agents.voice.audio_recognition as ar_mod
_orig_metrics = ar_mod._compute_end_of_turn_metrics
def _logged_metrics(**kwargs):
tl.mark("eou_metrics_start")
out = _orig_metrics(**kwargs)
tl.mark("eou_metrics_end")
return out
ar_mod._compute_end_of_turn_metrics = _logged_metrics
_orig_auth = type(activity)._scheduling_task
async def _logged_sched_task(self_inner):
tl.mark("_scheduling_task_start")
await _orig_auth(self_inner)
type(activity)._scheduling_task = _logged_sched_task
# 跑完一轮:回放 13.26s + 尾部静音(覆盖 FINAL→生成→TTS→播放收尾)
await asyncio.sleep(13.26 + TAIL_SILENCE)
await session.aclose()
pe = tl.playback_end
if pe is None:
raise SystemExit("playback never ended?")
print(f"\nwav={WAV.name} marks 相对回放结束(秒):")
print(f"{'mark':28s} {'t-playback_end':>15s}")
for name, at in tl.marks:
print(f"{name:28s} {at - pe:>+15.3f}")
# 与 HEL-910 实测对照
stt_final = next((at for n, at in tl.marks if n == "stt_final_emitted"), None)
chat = next((at for n, at in tl.marks if n == "llm_chat_called"), None)
if stt_final and chat:
gap = chat - stt_final
print(f"\nFINAL->llm.chat 间隙: {gap:+.3f}s "
f"(HEL-910 实测 user_final->turn_start = +3.667s / +3.654s)")
baseline_note = ("BASELINE(默认英文 TurnDetector 在)" if baseline
else "生产 TURN_HANDLING(turn_detection=None)")
print(f"模式: {baseline_note}")
if __name__ == "__main__":
asyncio.run(main())
+43 -14
View File
@@ -200,14 +200,22 @@ def bind_voice_session(session: AgentSession, *, llm: CyberGrillLLM,
@session.on("conversation_item_added")
def _on_item(ev) -> None:
if ev.item.role == "assistant":
texts = [
getattr(p, "text", "") or (p if isinstance(p, str) else "")
for p in (ev.item.content or [])
]
text = "".join(t for t in texts if t).strip()
if text:
stt.agent_recent_text = text
# SDK 1.8.8 的 ConversationItemAddedEvent.item 是
# ChatMessage | AgentHandoff | _TypeDiscriminator(events.py:416-420):
# 会话启动的 AgentHandoff 没有 role/content,直接取 ev.item.role 会抛
# AttributeError(HEL-910 两轮通话日志实录)。按 SDK 的类型判别字段
# 过滤,只认 type=="message" 的 ChatMessage,再按 role=="assistant"
# 取正文;用户消息、非消息项一律不更新 agent_recent_text。
item = ev.item
if getattr(item, "type", None) != "message":
return
if item.role != "assistant":
return
# ChatContent = str | ImageContent | AudioContent | CacheBreakpoint:
# 只有 str 部件是正文(chat_context.py:459-461),其余形状不冒充文本。
text = "".join(p for p in (item.content or []) if isinstance(p, str)).strip()
if text:
stt.agent_recent_text = text
@session.on("agent_state_changed")
def _on_state(ev) -> None:
@@ -220,6 +228,19 @@ web/src/voice 的 hush() 在本地静音反馈之外 publish 它(reliable)
本房间订阅。只复用 LiveKit 自己的数据通道与 session.interrupt,不建第二套
调度引擎。"""
TURN_HANDLING = {
# 停顿稍久才判说完, 合并慢语速碎片(同通用 worker 实测参数)
"endpointing": {"min_delay": 0.8, "max_delay": 6.0},
# HEL-911:livekit-agents 1.8.8 对未显式给 turn_detection 的会话默认
# 构造英文 EOU TurnDetector(agent_session.py:576-579,自托管回落本地
# v1-mini)。它对中文终稿的"不太像说完"判定把 endpointing 延迟顶到
# max_delay=6.0,实测造成识别终稿后 ~3.66s 的额外等待(HEL-910 call2
# 两轮 3.667/3.654s;离线 A/B:默认 3.704s vs 显式 None 0.004s,
# scripts/gen_hel911_wait_repro.py)。显式禁用:轮次边界回到本会话配置
# 的 VAD endpointing 本身,语义不变(不降 VAD 阈值、不动 buffer_turn)。
"turn_detection": None,
}
def bind_interrupt_channel(room, session) -> Callable[[], None]:
"""订阅本房间的 cg.interrupt 打断消息,走 SDK 自己的 programmatic 打断.
@@ -235,7 +256,7 @@ def bind_interrupt_channel(room, session) -> Callable[[], None]:
RuntimeError,HEL-909 evidence B2);失败一律大声留痕,不静默假装
成功。session.interrupt 未启动时抛 RuntimeError 同样留痕返回。
返回卸载函数(JobContext.add_shutdown_callback 注册,会话结束卸载)。
返回卸载协程(JobContext.add_shutdown_callback 注册,会话结束 await).
"""
def on_data(msg) -> None:
if getattr(msg, "topic", None) != INTERRUPT_TOPIC:
@@ -267,8 +288,19 @@ def bind_interrupt_channel(room, session) -> Callable[[], None]:
asyncio.get_running_loop().create_task(_drain(fut))
room.on("data_received", on_data)
unloaded = False
def unload() -> None:
async def unload() -> None:
# SDK 1.8.8 的 shutdown 回调一律被 await(job.py:622-643 把无参回调
# 包成 `await callback()`,ipc/job_proc_lazy_main.py:493 逐个 await):
# 同步返回 None 的旧卸载函数让 job 关停日志抛
# TypeError: object NoneType can't be used in 'await' expression
# (HEL-910 实录)。必须是协程;幂等(重复 shutdown/多次调用安全),
# 未连接/已断连的房间卸载监听不得抛出。
nonlocal unloaded
if unloaded:
return
unloaded = True
with contextlib.suppress(Exception):
room.off("data_received", on_data)
@@ -367,10 +399,7 @@ async def entrypoint(ctx: JobContext) -> None:
llm=llm,
tts=CosyVoiceTTS(default_voice=read_default_voice()),
vad=ctx.proc.userdata["vad"],
turn_handling={
# 停顿稍久才判说完, 合并慢语速碎片(同通用 worker 实测参数)
"endpointing": {"min_delay": 0.8, "max_delay": 6.0},
},
turn_handling=TURN_HANDLING,
)
bind_voice_session(session, llm=llm, ledger=ledger, interim=interim,
bus=bus, stt=stt, receipts=receipts)
+155 -3
View File
@@ -291,7 +291,7 @@ async def test_interrupt_message_stops_pending_playout_after_final():
fresh.interrupt(force=True)
_quiet_await(fresh)
_quiet_await(handle)
unload()
await unload()
async def test_interrupt_channel_validation_and_unload():
@@ -323,7 +323,7 @@ async def test_interrupt_channel_validation_and_unload():
await asyncio.sleep(0.05)
assert bridge.calls == [True] and handle.interrupted
unload()
await unload()
room.emit("data_received", _data_packet("cg.interrupt", b'{"v":1}'))
await asyncio.sleep(0.05)
assert bridge.calls == [True], "the unload callback removed the listener"
@@ -348,7 +348,153 @@ async def test_interrupt_message_survives_session_not_running():
await asyncio.sleep(0.05) # logged, swallowed at the boundary, listener intact
room.emit("data_received", _data_packet("cg.interrupt", b'{"v":1}'))
await asyncio.sleep(0.05)
unload()
await unload()
# ── HEL-911: conversation_item_added item types + async shutdown seam ────────
def _echo_stt_state() -> "SimpleNamespace":
from types import SimpleNamespace
return SimpleNamespace(agent_recent_text="", agent_speaking=False)
async def test_conversation_item_added_filters_non_message_items():
"""The session-start AgentHandoff (real 1.8.8 ConversationItemAddedEvent
payload: ChatMessage | AgentHandoff | _TypeDiscriminator, events.py:419)
crashed the old handler with AttributeError (HEL-910 worker.log, both
calls). Non-message items and user messages must not touch the echo
filter state."""
from livekit.agents.llm.chat_context import AgentHandoff, ChatMessage
from livekit.agents.voice import AgentSession
from livekit.agents.voice.events import ConversationItemAddedEvent, _TypeDiscriminator
from cybergril.voice.worker import bind_voice_session
class _Noop:
def __getattr__(self, name): # any other seam touch fails the test
raise AssertionError(f"unexpected seam use: {name}")
session = AgentSession()
stt = _echo_stt_state()
bind_voice_session(session, llm=_Noop(), ledger=_Noop(), interim=_Noop(),
bus=_Noop(), stt=stt, receipts=_Noop())
# 1. real SDK AgentHandoff (the session-start item that crashed 1.8.8)
session.emit("conversation_item_added",
ConversationItemAddedEvent(item=AgentHandoff(new_agent_id="agent_1")))
assert stt.agent_recent_text == ""
# 2. the SDK's own unknown-item discriminator shape
session.emit("conversation_item_added",
ConversationItemAddedEvent(item=_TypeDiscriminator()))
assert stt.agent_recent_text == ""
# 3. a real user ChatMessage must not feed the assistant echo filter
session.emit("conversation_item_added", ConversationItemAddedEvent(
item=ChatMessage(role="user", content=["用户说的话"])))
assert stt.agent_recent_text == ""
# 4. real assistant ChatMessage with mixed content parts: only the str
# parts are body text (ChatContent = str | ImageContent | AudioContent |
# CacheBreakpoint, chat_context.py:459)
from livekit.agents.llm.chat_context import CacheBreakpoint
session.emit("conversation_item_added", ConversationItemAddedEvent(
item=ChatMessage(role="assistant", content=["好,小十。", CacheBreakpoint()])))
assert stt.agent_recent_text == "好,小十。"
async def test_shutdown_callback_protocol_real_jobcontext_wrapper():
"""The unload seam must satisfy the REAL JobContext.add_shutdown_callback
contract: the SDK wraps a no-arg callback into `await callback()`
(job.py:633-643) and the job runner awaits every callback
(ipc/job_proc_lazy_main.py:493). The old sync `def unload() -> None`
raised TypeError: object NoneType can't be used in 'await' expression
(HEL-910 worker.log). Here the REAL JobContext method does the wrapping
and the runner's exact await pattern drives it — only the job plumbing
around it is absent, so a broken seam reproduces the production error.
"""
import inspect
from livekit import rtc
from livekit.agents import JobContext
from livekit.agents.voice import SpeechHandle
from cybergril.voice.worker import bind_interrupt_channel
handle = SpeechHandle.create(allow_interruptions=True)
class _Bridge:
def interrupt(self, *, force: bool = False):
return handle.interrupt(force=force)
room = rtc.Room()
unload = bind_interrupt_channel(room, _Bridge())
assert inspect.iscoroutinefunction(unload), \
"add_shutdown_callback awaits its callbacks; unload must be async"
# register through the REAL SDK method (it appends the wrapper it will await)
ctx = object.__new__(JobContext) # real method, minimal instance: no job plumbing
ctx._shutdown_callbacks = []
ctx.add_shutdown_callback(unload)
wrapped = ctx._shutdown_callbacks[-1]
# exactly what ipc/job_proc_lazy_main._traced_shutdown_callback does
await wrapped("job shutdown") # must not raise (the old sync seam did)
# the old seam reproduces the production TypeError through the same wrapper
def sync_unload() -> None:
return None
ctx.add_shutdown_callback(sync_unload)
broken = ctx._shutdown_callbacks[-1]
try:
await broken("job shutdown")
raise AssertionError("sync unload should have failed the real await")
except TypeError as exc:
assert "await" in str(exc)
# duplicate shutdown: the runner may only fire callbacks once per job, but
# idempotency must not depend on that
await unload()
await unload()
# subscription really unloaded: a valid interrupt message after unload is a no-op
room.emit("data_received", _data_packet("cg.interrupt", b'{"v":1}'))
await asyncio.sleep(0.05)
assert not handle.interrupted, "listener must be gone after unload"
_quiet_await(handle)
async def test_turn_handling_disables_default_english_eou_detector():
"""HEL-911: livekit-agents 1.8.8 silently installs an English EOU
TurnDetector when turn_handling has no explicit turn_detection key
(agent_session.py:576-579, `turn_handling.get("turn_detection",
inference.TurnDetector())`). Its unlikely-eot verdicts on CHINESE
transcripts push the endpointing delay to max_delay and cost the measured
~3.66s between ASR final and turn_start (HEL-910 call2: 3.667s/3.654s;
offline A/B in scripts/gen_hel911_wait_repro.py: default 3.704s vs
explicit None 0.004s). The worker's TURN_HANDLING must keep the detector
off so the configured VAD endpointing alone decides the turn boundary."""
from livekit.agents import AgentSession
from cybergril.voice.worker import TURN_HANDLING
# the SDK default that bites: same options minus the explicit key
with_default = {k: v for k, v in TURN_HANDLING.items() if k != "turn_detection"}
default_session = AgentSession(turn_handling=with_default)
assert default_session._turn_detection is not None, (
"precondition: 1.8.8 installs a default turn detector — if this fails "
"the SDK changed; re-check the HEL-911 wait attribution"
)
session = AgentSession(turn_handling=TURN_HANDLING)
assert session._turn_detection is None, \
"the worker must not run the English EOU detector on Chinese turns"
assert TURN_HANDLING["endpointing"] == {"min_delay": 0.8, "max_delay": 6.0}, \
"the measured endpointing parameters stay exactly as configured"
# ── standalone runner (agent venv has no pytest) ─────────────────────────────
@@ -371,6 +517,12 @@ async def _standalone() -> int:
test_interrupt_channel_validation_and_unload),
("interrupt_message_survives_session_not_running",
test_interrupt_message_survives_session_not_running),
("conversation_item_added_filters_non_message_items",
test_conversation_item_added_filters_non_message_items),
("shutdown_callback_protocol_real_jobcontext_wrapper",
test_shutdown_callback_protocol_real_jobcontext_wrapper),
("turn_handling_disables_default_english_eou_detector",
test_turn_handling_disables_default_english_eou_detector),
]
failures = 0
for name, check in checks: