From 4a38106eef21c43ba24ab7d07992b1bfe539137d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=BD=E5=B7=A5=E5=91=982=E5=8F=B7?= Date: Thu, 29 Oct 2026 21:25:25 +0800 Subject: [PATCH 1/5] HEL-911: fix AgentHandoff/shutdown seams, attribute & fix the 3.66s pre-generation wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - worker _on_item: filter conversation_item_added by the real 1.8.8 item discriminator (ChatMessage | AgentHandoff | _TypeDiscriminator); only assistant str-part body feeds the echo filter (HEL-910 AttributeError) - bind_interrupt_channel unload: async (JobContext shutdown callbacks are awaited), idempotent, disconnect-safe; interrupt/receipt/budget paths untouched (HEL-910 TypeError await None) - TURN_HANDLING: explicit turn_detection=None — 1.8.8 silently installs an English EOU TurnDetector whose unlikely verdicts on Chinese finals push endpointing to max_delay=6.0 (measured 3.667/3.654s HEL-910 call2; offline A/B on the real SDK: baseline 3.704s vs fixed 0.004s). Endpointing 0.8/6.0 kept verbatim; no VAD/buffer_turn/semantic change - scripts/gen_hel911_wait_repro.py: offline reproduction harness (real AgentSession + real silero VAD + HEL-910 wav and measured timings) - tests: +3 real-SDK checks (11/11 on .12 agent venv; prior 8 regressions kept, unload call sites await the new protocol) - hel910_call1.mjs reliability: segmented probe/recorder dumps to disk, timing-A trigger requires the server-committed voice turn row (not a we-sent-it proxy); node --check only, no real run - window-record.md §9: appended factual corrections (companion 9 not 18, turn_start +6.099 not +4.10, assistant_final is the playout/receipt gate, post-window ListRooms token failure is not empty-room evidence); original record untouched - docs/rework/hel911/summary.md + handoff-HEL-911.json; requirement checks pass vs origin/main and 407fc81 Co-authored-by: multica-agent --- docs/requirements/handoff-HEL-911.json | 46 +++ docs/rework/hel910/tools/hel910_call1.mjs | 55 ++- docs/rework/hel910/window-record.md | 11 + docs/rework/hel911/summary.md | 133 +++++++ scripts/gen_hel911_wait_repro.py | 432 ++++++++++++++++++++++ src/cybergril/voice/worker.py | 57 ++- tests/test_sdk_livekit_hel908.py | 158 +++++++- 7 files changed, 868 insertions(+), 24 deletions(-) create mode 100644 docs/requirements/handoff-HEL-911.json create mode 100644 docs/rework/hel911/summary.md create mode 100644 scripts/gen_hel911_wait_repro.py diff --git a/docs/requirements/handoff-HEL-911.json b/docs/requirements/handoff-HEL-911.json new file mode 100644 index 0000000..de11fb0 --- /dev/null +++ b/docs/requirements/handoff-HEL-911.json @@ -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 离线纠错往返是否可再压:识别链参数,本卡不动语义,与真实复测同窗评估", + "真实用户麦克风打断时延/可闻旧声回放打断正面样本/生成中文字取消正面样本:两轮窗口均未取得,需按新门控重试", + "真实端点带 形状分离(离线证据 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,不冒充" + ] +} diff --git a/docs/rework/hel910/tools/hel910_call1.mjs b/docs/rework/hel910/tools/hel910_call1.mjs index b7f2ba2..3fc8f01 100644 --- a/docs/rework/hel910/tools/hel910_call1.mjs +++ b/docs/rework/hel910/tools/hel910_call1.mjs @@ -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 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-.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); diff --git a/docs/rework/hel910/window-record.md b/docs/rework/hel910/window-record.md index 783fd46..99c0c05 100644 --- a/docs/rework/hel910/window-record.md +++ b/docs/rework/hel910/window-record.md @@ -83,3 +83,14 @@ - **真实端点带 `` 形状**:未出现,真实分离未复现(离线证据在 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**。思考隔离结论(零 `` 泄漏)不变,基数以 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),**首声总时延下降需真实窗口复测确认,本文不宣称**。 diff --git a/docs/rework/hel911/summary.md b/docs/rework/hel911/summary.md new file mode 100644 index 0000000..e6712cf --- /dev/null +++ b/docs/rework/hel911/summary.md @@ -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 离线纠错往返是否可再压:属识别链参数,本卡不动 + 语义,留待与真实复测同窗评估。 +- 真实端点带 `` 形状、真机/手机麦克风、Safari、完整 R1–R7:继续主卡承接。 diff --git a/scripts/gen_hel911_wait_repro.py b/scripts/gen_hel911_wait_repro.py new file mode 100644 index 0000000..47d1fc8 --- /dev/null +++ b/scripts/gen_hel911_wait_repro.py @@ -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()) diff --git a/src/cybergril/voice/worker.py b/src/cybergril/voice/worker.py index b7e740d..77d96be 100644 --- a/src/cybergril/voice/worker.py +++ b/src/cybergril/voice/worker.py @@ -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) diff --git a/tests/test_sdk_livekit_hel908.py b/tests/test_sdk_livekit_hel908.py index d0049d8..2c4bc55 100644 --- a/tests/test_sdk_livekit_hel908.py +++ b/tests/test_sdk_livekit_hel908.py @@ -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: -- 2.54.0 From 0146e370b1f41cd8b6b616516bc6b72b3a434530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Thu, 29 Oct 2026 22:04:52 +0800 Subject: [PATCH 2/5] =?UTF-8?q?HEL-913:=20unified-experience=20run=20packa?= =?UTF-8?q?ge=20=E2=80=94=20fixed-version=20manifest,=20sanitized=20templa?= =?UTF-8?q?tes,=20offline=20zero-real-call=20verification=20(34/34),=20mem?= =?UTF-8?q?0=20cp311=20gap=20verified,=20capability=20list=20+=20one-shot?= =?UTF-8?q?=20enablement=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- deploy/hel913-unified/README.md | 166 ++++++++++++ deploy/hel913-unified/api_ctl.sh | 63 +++++ deploy/hel913-unified/dist-manifest.json | 10 + deploy/hel913-unified/manifest.json | 50 ++++ deploy/hel913-unified/mem0-cp311-wheels.txt | 45 ++++ deploy/hel913-unified/models.template.yaml | 89 +++++++ deploy/hel913-unified/verify_offline.py | 273 ++++++++++++++++++++ deploy/hel913-unified/voice.template.yaml | 19 ++ deploy/hel913-unified/worker.env.template | 12 + deploy/hel913-unified/worker_ctl.sh | 48 ++++ docs/hel913-capability-and-enablement.md | 76 ++++++ docs/requirements/handoff-HEL-913.json | 54 ++++ docs/rework/hel913/verify-offline-run.txt | 36 +++ 13 files changed, 941 insertions(+) create mode 100644 deploy/hel913-unified/README.md create mode 100644 deploy/hel913-unified/api_ctl.sh create mode 100644 deploy/hel913-unified/dist-manifest.json create mode 100644 deploy/hel913-unified/manifest.json create mode 100644 deploy/hel913-unified/mem0-cp311-wheels.txt create mode 100644 deploy/hel913-unified/models.template.yaml create mode 100644 deploy/hel913-unified/verify_offline.py create mode 100644 deploy/hel913-unified/voice.template.yaml create mode 100644 deploy/hel913-unified/worker.env.template create mode 100644 deploy/hel913-unified/worker_ctl.sh create mode 100644 docs/hel913-capability-and-enablement.md create mode 100644 docs/requirements/handoff-HEL-913.json create mode 100644 docs/rework/hel913/verify-offline-run.txt diff --git a/deploy/hel913-unified/README.md b/deploy/hel913-unified/README.md new file mode 100644 index 0000000..b67b5f2 --- /dev/null +++ b/deploy/hel913-unified/README.md @@ -0,0 +1,166 @@ +# HEL-913 · 统一体验运行包(固定版本,未部署、cap=0) + +> 本目录是把已通过 HEL-912 审核的统一候选装成的**可交接运行包**:精确源码版本、 +> 产物与哈希、脱敏配置模板、唯一启动方式、独立空资料空间、停止/备份/保留资料 +> 回退说明,以及一次零真实调用的运行准备验证记录。**本卡未部署任何现网服务、 +> 未监听 836x/8082/9880/8090、未改任何现有实例;实际启用与新用量由总管统一 +> 向白栖知说明后再执行。** + +## 0. 精确版本与授权来源 + +- **候选分支** `agent/2/8e99cc2cc592`,head **`4a38106eef21c43ba24ab7d07992b1bfe539137d`** + (HEL-911:AgentHandoff/shutdown 接缝修复 + 3.66s endpointing 等待归因修复)。 + 父线:`407fc81`(HEL-910 窗口记录)← `958c696`(HEL-908 R1/R2 返工)。 +- 审核:HEL-912 结论「通过,无必须修复项」。主卡 HEL-797;本卡 HEL-913。 +- 需求基准 v1.12(`docs/requirements/`),需求检查见 handoff-HEL-913.json。 + +## 1. 交付物清单(本目录) + +| 文件 | 作用 | +| --- | --- | +| `README.md` | 本运行说明(唯一照抄入口) | +| `manifest.json` | 机器可读清单:head、锁文件、dist 哈希、环境引用 | +| `dist-manifest.json` | web dist 8 文件 SHA256(复用 HEL-910 实测构建) | +| `models.template.yaml` | 脱敏模型/预算配置(**一切预算=0**;占位符标注待批写入位置) | +| `voice.template.yaml` | 脱敏语音配置(LiveKit/TTS/面板指向占位,真实值服务器本地) | +| `worker.env.template` | 语音 worker 环境模板(agent venv 运行) | +| `api_ctl.sh` | 统一实例 API 启停(单进程 HTTP+HTTPS 双入口) | +| `worker_ctl.sh` | 语音 worker 启停(只按 PID 句柄停止) | +| `verify_offline.py` | 零真实调用运行准备验证(34 项检查;TestClient 进程内,不监听端口) | +| `mem0-cp311-wheels.txt` | 真实 Mem0 记忆栈 cp311 缺项轮清单(40 项,待批一次性下载) | + +运行记录:`docs/rework/hel913/verify-offline-run.txt`(.12 产品 3.11 venv 实跑 34/34)。 + +## 2. 版本固定与产物复用 + +- **源码**:head `4a38106`(含 `requirements.lock` 19 项固定版本,Python 3.11 实测)。 +- **web dist 复用**:`git diff 958c696 4a38106 -- web/` 为空(网页源码零变动), + 直接复用 HEL-910 已实测部署的 dist(.12 `/opt/cybergril-hel910/current/web/dist`, + REVISION 标记 `958c6962…`),只读拷贝,不安装 node、不重建。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)。 + - 语音 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;语音页 secure context 依赖 HTTPS 入口)。 + - 本机 LiveKit `ws://127.0.0.1:7880`(常驻);TTS 9880 / 面板 8090 / 语音桥 + 按获批窗口另行启动,未获批保持停机。 + +## 3. 目标实例布局(唯一布局;用户实例 data 必须为空) + +``` +/opt/cybergril-hel913/ # 实例根(名字可换;以下结构固定) + current/ # git clone 后 checkout 4a38106(或 git archive 解包) + web/dist/ # 按 dist-manifest.json 核哈希后放入(只读复用件) + etc/ + models.yaml # 由 models.template.yaml 复制,填入获批网关与预算 + voice.yaml # 由 voice.template.yaml 复制,填入服务器本地值 + data/ # 独立空资料空间:新库从零初始化 + worker/.env # 由 worker.env.template 复制 + logs/ +``` + +**不打包进她的正式档案**:任何历史测试库(8360 团队库、HEL-907/910 窗口库)、 +聊天记录、LiveKit/TTS 凭据、自签证书私钥、agent venv 内实物。用户实例在用户 +亲自创建前 `companion` 表必须为空(离线验证第 3 项复现该守门)。 + +## 4. 启动与停止(唯一方式) + +```bash +# 4.1 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 且未配网关) + +# 4.2 语音 worker(语音窗口获批后;TTS 9880 先起、面板 8090 可选) +HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh start +# worker 经 PYTHONPATH=current/src 用 agent venv 运行,注册到本机 LiveKit 7880, +# 健康口 8082( HEL913_WORKER_HEALTH 可改);只按 logs/worker.pid 停止。 + +# 4.3 停止顺序:先 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 +``` + +同一数据目录**绝不允许**两个写入实例并存(双入口由 `deploy/voice/serve_dual_8360_8361.py` +在同一进程内保证;另起独立 uvicorn 会触发 lifespan 重入 fail-closed)。 + +## 5. 备份与保留资料回退 + +- **发布/阶段归档**(本包口径,沿用 HEL-896 收口工具,一致性快照): + ```bash + # 停服口径(推荐;目录内 SQLite 一并 checkpoint+逐表核对) + systemctl stop <实例> # 或 api_ctl.sh stop + .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/ 原样保留,systemd 单元 + disable;重新启用 = 同一 data 目录再 start(epoch 递增、聊天记忆保留,离线 + 验证第 11 组复现)。 +- **CG-009 每日自动备份/用户手动完整导出(R6)尚未实现**,本节只是发布归档, + 不得冒充 R6 完成。 + +## 6. 零真实调用运行准备验证(已执行,34/34) + +在 .12 产品 3.11 venv 对候选 head 树实跑 `verify_offline.py`(进程内 TestClient, +零监听、零出站——网关地址重写为 127.0.0.1:1 + provider.stream 出站哨兵双保险): + +- 包完整性:Python 3.11.16、候选树 import、锁定依赖版本、记忆栈四项缺置如实。 +- 入口/创建守门: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` 才是诚实状态。 + +## 7. 真实记忆(Mem0)运行缺项 —— 已核实,未安装 + +- **服务器 .12 现状(2026-10-29 只读实测)**:产品 `.venv311`(3.11.16)内 + `mem0 / qdrant_client / fastembed / onnxruntime` 四项均未安装;服务器全盘无 + mem0 安装、无固定 tarball、无已建轮、无 fastembed 缓存、uv 缓存仅 28K。 + **没有另一个已准备好的 3.11 兼容环境**;旧 3.10 `hel857-venv`(3.10.12, + 工作站本地)不能混入 3.11 进程。 +- **已有实物(工作站本地,未上服务器)**:固定源 `mem0-2c81af3.tar.gz` + (sha256 `9dcef930…`,与 `deploy/mem0-stack.lock` 一致,本卡复验);固定源 + 离线构建轮 `mem0ai-2.2.1-py3-none-any.whl`(sha256 `698284b6…`,本卡复验); + fastembed 模型缓存 92MB(Qdrant/bge-small-zh-v1.5 + bm25);本地 uv 缓存可 + 复用 3 轮(greenlet/pillow/truststore cp311)。 +- **最少安装动作(待一次性批准;本卡零安装零下载)**,唯一入口 + `deploy/hel895-release-candidate.md` §4: + 1. tarball 哈希核对(或直接复用已建轮并核对 `698284b6…`); + 2. 获批窗口内 `pip download --only-binary :all: --implementation cp + --python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 + -r deploy/hel913-unified/mem0-cp311-wheels.txt -d wheels/`(40 项, + 逐项 sha256 留证;其中 3 项可从工作站缓存离线复用); + 3. 传输轮目录 + fastembed 缓存(92MB)到服务器; + 4. `uv pip install --python .venv311/bin/python --no-index --find-links wheels/ + mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 <40项>`; + 5. co-import 冒烟 + fastembed cache_dir 指向实例数据目录内缓存拷贝。 + **空间/影响**:.venv311 增约 300–400MB(3.10 同栈实测 366MB 参考)+ 轮暂存 + 约 150MB + 模型缓存 92MB;首次本地嵌入零下载(缓存就位)。激活语义层仍需 + B 部分嵌入/提取预算(见 docs/hel913-capability-and-enablement.md §2)。 + 在此之前 `CYBERGRIL_SEMANTIC=stub`,长期检索走词面 FTS,不以 stub 冒充 + 真实 Mem0。 + +## 8. 本包不做的事 + +不部署、不监听现网端口、不恢复 9880/8090/836x/8082、不触 4080、不下载任何 +依赖/模型/浏览器、不改模型/认证/运行时绑定、不合并 PR、不替换正式界面、 +8300/8310/8320/8350 与既有资料不动、8340/ComfyUI 保持停机。旧三次测试窗口 +余额不结转;一切提额是待批准建议。 diff --git a/deploy/hel913-unified/api_ctl.sh b/deploy/hel913-unified/api_ctl.sh new file mode 100644 index 0000000..eea78ef --- /dev/null +++ b/deploy/hel913-unified/api_ctl.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# HEL-913 统一体验运行包 · 统一实例 API 启停(单进程 HTTP+HTTPS 双入口) +# +# 与 HEL-910 的 api_ctl.sh 同构,去掉一次性路径硬编码:实例根、端口、证书、 +# 解释器全部可用环境变量覆盖。启动器 deploy/voice/serve_dual_8360_8361.py +# 保证 HTTP 与 HTTPS 共用同一 API 进程/同一份状态(lifespan 只进一次)。 +# +# 用法: [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 +PAT="cybergril.*serve_dual" + +stop() { + if [ -f "$PIDF" ]; then + P=$(cat "$PIDF") + if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill "$P"; sleep 3; fi + if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill -9 "$P"; fi + rm -f "$PIDF" + fi + echo "api stopped" +} + +case "${1:-}" in + stop) stop; exit 0 ;; + status) + [ -f "$PIDF" ] && { P=$(cat "$PIDF"); kill -0 "$P" 2>/dev/null && echo "running pid $P" || echo "pid $P not alive"; } + curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" && echo " <- health" || echo "health not answering" + exit 0 ;; +esac + +stop +cd "$CODE" +set -a +export CYBERGRIL_DB=$BASE/data/companion.db +export CYBERGRIL_SEMANTIC=${HEL913_SEMANTIC:-stub} +export CYBERGRIL_R2_ENABLED=${HEL913_R2:-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 & +echo $! > "$PIDF" +echo "api starting (pid $(cat "$PIDF")) http=$HTTP https=$HTTPS" +sleep 6 +curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" >/dev/null && echo "OK: $HTTP healthy" || { echo "FAIL:"; tail -20 "$LOG"; exit 1; } +curl -sk --max-time 3 "https://127.0.0.1:$HTTPS/api/health" >/dev/null && echo "OK: $HTTPS healthy" || { echo "FAIL $HTTPS:"; tail -20 "$LOG"; exit 1; } diff --git a/deploy/hel913-unified/dist-manifest.json b/deploy/hel913-unified/dist-manifest.json new file mode 100644 index 0000000..91c842c --- /dev/null +++ b/deploy/hel913-unified/dist-manifest.json @@ -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" +} \ No newline at end of file diff --git a/deploy/hel913-unified/manifest.json b/deploy/hel913-unified/manifest.json new file mode 100644 index 0000000..3cc82f7 --- /dev/null +++ b/deploy/hel913-unified/manifest.json @@ -0,0 +1,50 @@ +{ + "task": "HEL-913", + "head": "4a38106eef21c43ba24ab7d07992b1bfe539137d", + "branch": "agent/2/8e99cc2cc592", + "review": "HEL-912 01a187e8-1058-7a2d-84e7-bd1896f212cb passed, no must-fix items", + "requirements_lock": "requirements.lock (19 pinned, Python 3.11.16 verified)", + "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": "/opt/cybergril-hel879/current/.venv311 (Python 3.11.16)", + "worker_python": "/opt/voice-agent/agent/venv (livekit-agents 1.8.8)", + "certs": "/opt/cybergril-voice-certs/cybergril-voice.{pem,key}", + "livekit": "ws://127.0.0.1:7880 (standing)", + "tts_panel": "9880/8090 stopped until approved window" + }, + "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/api_ctl.sh", + "deploy/hel913-unified/worker_ctl.sh", + "deploy/hel913-unified/verify_offline.py", + "deploy/hel913-unified/mem0-cp311-wheels.txt", + "docs/rework/hel913/verify-offline-run.txt", + "docs/hel913-capability-and-enablement.md" + ], + "offline_verification": { + "where": ".12 product .venv311, candidate tree at /tmp/hel913-verify (cleaned after run)", + "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" + }, + "memory_gap": { + "server_venv311": "mem0/qdrant_client/fastembed/onnxruntime all absent (verified 2026-10-29)", + "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" + }, + "minimal_install": "deploy/hel913-unified/mem0-cp311-wheels.txt (40 wheels, one approved window) + wheel transfer + --no-index install + co-import; entry remains deploy/hel895-release-candidate.md §4" + }, + "boundaries": "not deployed; nothing listening; caps all 0; no PR; existing instances and data untouched; no downloads this card" +} diff --git a/deploy/hel913-unified/mem0-cp311-wheels.txt b/deploy/hel913-unified/mem0-cp311-wheels.txt new file mode 100644 index 0000000..85a4be4 --- /dev/null +++ b/deploy/hel913-unified/mem0-cp311-wheels.txt @@ -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 diff --git a/deploy/hel913-unified/models.template.yaml b/deploy/hel913-unified/models.template.yaml new file mode 100644 index 0000000..e4af16a --- /dev/null +++ b/deploy/hel913-unified/models.template.yaml @@ -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: + api_key_env: null + timeout: 60.0 + temperature: 0.85 + reasoning: buffer_turn + standard: + provider: openai_compat + model: <获批模型名> + base_url: + api_key_env: null + timeout: 40.0 + temperature: 0.7 + reasoning: buffer_turn + low: + provider: openai_compat + model: <获批模型名> + base_url: + 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 diff --git a/deploy/hel913-unified/verify_offline.py b/deploy/hel913-unified/verify_offline.py new file mode 100644 index 0000000..795d62e --- /dev/null +++ b/deploy/hel913-unified/verify_offline.py @@ -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 /python deploy/hel913-unified/verify_offline.py \ + [--web ] [--keep] +退出码 0 = 全部通过;任一 FAIL 即非 0。临时库写在 /.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"
/etc/voice.yaml。真实值属于服务器本地(不入仓库): +# livekit 指向 .12 本机 LiveKit(内部明文 7880;浏览器侧若走独立 TLS 桥, +# 由部署时的 wss 桥地址替换 livekit_url,桥属可选件);TTS/面板地址在 +# 语音窗口获批启动后填本机 9880/8090。registry/worker_state 落在实例数据 +# 目录内,与产品库同空间,随实例备份。 +livekit_url: +livekit_internal: http://127.0.0.1:7880 +api_key: +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: <实例数据目录>/voices.json +worker_state_file: <实例数据目录>/voice_state_cybergril.json +token_ttl_seconds: 300 +session_ttl_seconds: 900 diff --git a/deploy/hel913-unified/worker.env.template b/deploy/hel913-unified/worker.env.template new file mode 100644 index 0000000..32f5dda --- /dev/null +++ b/deploy/hel913-unified/worker.env.template @@ -0,0 +1,12 @@ +# HEL-913 统一体验运行包 · 语音 worker 环境模板(脱敏) +# 用法:复制到 <实例>/worker/.env。真实 LiveKit 凭据属于服务器本地。 +# 运行解释器是 agent venv(livekit-agents 1.8.8),不是产品 .venv311。 +CYBERGRIL_API_BASE=http://127.0.0.1: +CYBERGRIL_AGENT_NAME=cybergril-voice-agent +LIVEKIT_URL=ws://127.0.0.1:7880 +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= +CYBERGRIL_WORKER_HEALTH_PORT= +CYBERGRIL_VOICE_STATE=<实例数据目录>/voice_state_cybergril.json +# ASR 用量账本独立于产品库,落在实例数据目录: +CYBERGRIL_ASR_USAGE_LEDGER=<实例数据目录>/asr-usage.jsonl diff --git a/deploy/hel913-unified/worker_ctl.sh b/deploy/hel913-unified/worker_ctl.sh new file mode 100644 index 0000000..f4ed432 --- /dev/null +++ b/deploy/hel913-unified/worker_ctl.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# HEL-913 统一体验运行包 · 专属语音 worker 启停(只按 PID 句柄停止) +# +# 运行解释器是 agent venv(livekit-agents 1.8.8),产品代码经 PYTHONPATH 注入。 +# worker 只连接本实例 API 与本机 LiveKit;停止只杀自己写的 PID,不匹配进程名。 +# +# 用法: [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} +LOG=$BASE/logs/worker.log +PIDF=$BASE/logs/worker.pid + +stop() { + if [ -f "$PIDF" ]; then + P=$(cat "$PIDF") + if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill "$P"; sleep 3; fi + if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill -9 "$P"; fi + rm -f "$PIDF" + echo "worker stopped (pid $P)" + else + echo "no pid file; nothing stopped" + fi +} + +case "${1:-}" in + stop) stop; exit 0 ;; + status) + if [ -f "$PIDF" ]; then P=$(cat "$PIDF"); kill -0 "$P" 2>/dev/null && echo "running pid $P" || echo "pid $P not alive"; fi + curl -s --max-time 3 "http://127.0.0.1:$HEALTH/" >/dev/null && echo "health ok" || echo "health port not answering" + exit 0 ;; +esac + +cd "$BASE/worker" +set -a +source "$BASE/worker/.env" +set +a +PYTHONPATH="$BASE/current/src" nohup "$VENV" -m cybergril.voice.worker start > "$LOG" 2>&1 & +echo $! > "$PIDF" +echo "worker starting (pid $(cat "$PIDF"))" +sleep 18 +grep -m1 "registered worker" "$LOG" >/dev/null && echo "OK: worker registered" \ + || { echo "FAIL:"; tail -8 "$LOG"; exit 1; } diff --git a/docs/hel913-capability-and-enablement.md b/docs/hel913-capability-and-enablement.md new file mode 100644 index 0000000..c365614 --- /dev/null +++ b/docs/hel913-capability-and-enablement.md @@ -0,0 +1,76 @@ +# HEL-913 · 一页能力清单与一次真实启用建议 + +依据:候选 head `4a38106`(HEL-912 审核通过)、HEL-907/910 真实窗口记录、 +HEL-913 离线验证 34/34(`docs/rework/hel913/verify-offline-run.txt`)。分级只分 +三种:**✅接好且已验证**(真实窗口或真实服务验证过)/ **🟡有实现、缺真实验证** +(代码与离线/替身验证在,真实链路未跑或未批预算)/ **❌尚未接入**。不照抄 +旧表:语音已接(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 实测),安装路径见运行包 §7;激活还需 B 部分嵌入/提取预算 | +| 6 | 关掉网页再回来 | ✅ | 同一位她保留:聊天/关系/八字/记忆持久;epoch 递增同步、分页缺口不跳留言、未读标记(HEL-895/896 真实服务浏览器 9 项检查;HEL-913 离线重开验证) | +| 7 | 她主动留言 | 🟡 | 时机判定+网页内留言卡+回来即看的前端链路已接并经真实服务浏览器验证(种子数据);**真实 LLM 主动措辞生成未发生**(proactive 预算 0);不接手机推送(CG-011 首版范围) | +| 8 | 语音通话 | ✅(工程验证) | 真实网页开通话、连续对话、字幕同步、挂断删房;文字打断在播旧声实测停(≤~1.4s);首声原实测 9.53s,HEL-911 移除 3.66s 等待后**未实测复测**(离线推算 ~5.9s);真人麦克风打断/生成中文字取消正面样本未取得(承接) | +| 9 | 自定义声音 | ✅(工程验证) | 网页上传参考音频→登记→真 TTS 试听→应用→下一通生效;通话中改选不换声(HEL-907 全链 13/14 步实测,call1/2 以 cg- 音色真实合成);用户本人尚未亲自用过 | +| 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,不继承旧窗口余额) + +**原则**:团队检查、用户首体验、长期后台三本账分开(独立实例/独立库, +HEL-896 §6 分账语义),任何一本用完不自动转到另一本;所有硬闸发送前拒绝、 +碰顶即停、封账不解除;提额永远是新批准。费用均为内网自持服务 +(LLM 网关 .11:8731、TTS/ASR/LiveKit 在 .12),**无新增现金支出**, +消耗的是授权额度与本机资源;用户不需要理解 token/EOU,只批下面三件事: +开多久、要不要记忆、语音几通。 + +### A · 团队验收检查(总管权限内新批,不用问用户) +- 沿用 `docs/budget-package-hel895.md` §6:独立团队实例 55 次/500,000 tokens/ + 0 嵌入,体验链 1–7 走完即 `seal_budget_hel879.py` 封账归档。用途:发布前 + 把创建→诠释→对话→生活→情绪关系→主动联系→重开同步整体走一遍(本清单 + 🟡 项的团队级补证)。 + +### B · 用户首体验(建议一次批准;最小够用) +- **时长**:建议 3 个自然日窗口(如周五晚–周日晚),期内任意使用,期满 + 配置归 0、数据全保留(她不重置)。 +- **文字与生活**:对话 60 轮/日、生活 8 次/日、判定/成长/摘要 40 次/日、 + 日 token 池 2,000,000(C 表值);创建+诠释+习惯 4 次一次性。 +- **记忆**:如批准,先执行运行包 §7 的 Mem0 安装(40 轮一次性下载安装, + 约 500–600MB 磁盘),再按 B 算式:提取 LLM 累计 60 次、嵌入新增 180 条 + (embed_cap=680、冻结 2 条不动)、语义类 40 次/日。**不批安装则记忆停在 + 词面检索**,其余体验不受影响(stub 已验证可全链跑通)。 +- **语音**:3 通/窗口、累计 ≤2 小时(与 v1.12 授权同形,新批);需用户当场 + 在浏览器允许麦克风(HTTPS+自签证书「高级→继续」);ASR ≤1800 秒/窗口、 + TTS ≤60 段/窗口、建房即用即删。自定义声音当场可用(上传→试听→应用)。 +- **停**:任一类别到顶当次诚实拒绝、当日该类停;窗口到期统一归零封账; + 团队账耗尽不影响本账,反之亦然。 +- **断开与恢复**:关网页/断开后她的档案、聊天、关系、记忆原样保留(重开 + 验证过);语音所需的共享 TTS 9880/面板 8090 只在批准窗口内启动、窗口后 + 停回(`deploy/voice/restart_cybergril.sh.example` 流程参考),LiveKit 7880 + 常驻无需动。 +- **如实告知用户的已知边界**:首声延迟修复后未实测(原 9.5s、离线推算 + ~5.9s);真人打断正面样本未取得;日子/主动留言的真实生成首次发生在这 + 个窗口里,质量如不足属正常迭代范围。 + +### C · 长期后台 +- 暂不批。首体验结束后凭实际用量与质量报告(`llm_ops`/`embed_ops`/日账 + 导出)再定常驻节奏;在此之前 `CYBERGRIL_R2_ENABLED` 保持按窗口启停。 + +## 3. 不变边界 + +本建议不改变任何需求与验收状态:8300/8310/8320/8350 与资料不动、正式界面 +不替换、PR 不合并、4080 不触、旧三窗口余额不结转;阶段体验不等于完整产品 +完成,R5–R7 未完项继续按主卡承接。 diff --git a/docs/requirements/handoff-HEL-913.json b/docs/requirements/handoff-HEL-913.json new file mode 100644 index 0000000..e6ae824 --- /dev/null +++ b/docs/requirements/handoff-HEL-913.json @@ -0,0 +1,54 @@ +{ + "baseline_version": "1.12", + "read_commit": "4a38106eef21c43ba24ab7d07992b1bfe539137d", + "task": "HEL-913:把通过 HEL-912 审核的统一候选(agent/2/8e99cc2cc592 @ 4a38106)装成可交接的统一体验运行包:固定版本清单与哈希、脱敏配置模板、单进程 HTTP/HTTPS 唯一启动方式、worker 接线、独立空资料空间与停止/备份/保留资料回退说明;一次零真实调用的运行准备离线验证(.12 产品 3.11 venv,TestClient 34/34,零监听零出站);核实真实记忆运行缺项(.12 venv311 四项未装、服务器无任何已备 3.11 环境或离线包;工作站持有固定 tarball/已建轮/92MB 模型缓存,40 轮待批一次性下载安装);按用户使用顺序的一页能力清单;集中一次真实启用建议(A 团队检查 / B 用户首体验 / C 长期后台三本账,全部待批,当前配置仍 0)。本卡不部署、不监听 836x/8082/9880/8090、不触 4080、不下载、不合 PR、不动现有实例与资料。", + "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 运行说明、manifest.json、dist-manifest.json 8 文件 SHA256、models/voice/worker.env 三份脱敏模板全 0、api_ctl.sh/worker_ctl.sh 启停、verify_offline.py、mem0-cp311-wheels.txt 40 项缺轮清单);docs/rework/hel913/verify-offline-run.txt(34/34 实跑记录);docs/hel913-capability-and-enablement.md(一页能力清单 14 项三态分级 + 一次启用建议)。web dist 复用依据:git diff 958c696 4a38106 -- web/ 为空,HEL-910 实测构建件哈希核对后只读复用,不装 node 不重建。", + "acceptance": [ + "运行包完整性:精确 head/锁文件/环境引用(venv311=3.11.16、agent venv=livekit-agents 1.8.8、证书路径、LiveKit 7880 常驻)齐备;唯一启动方式=deploy/voice/serve_dual_8360_8361.py 单进程双入口;停止顺序 worker→api;备份=release_snapshot_hel896 一致性快照;回退=快照+git 切回旧 head;撤销留资料=data 目录原样保留", + "零真实调用验证(.12 venv311 实跑 34/34):包完整(候选树 import+锁定依赖)、入口健康诚实(llm.ok=false)、空库 404 无伪造档案、创建守门 409、八字落库与预览一致、persona 在 cap=0 诚实 failed、SSE 对话预算 0 下诚实 chat.error 且 provider.stream 出站哨兵 0 次、失败轮如实落库、同轮重试幂等、关系可调名字保留、未分享生活服务端裁剪、未知回执/详情 404、语音 TTS 不可达开房 503 不建房、未知会话 DELETE 404、重开 epoch=2 资料全保留、GET / 出 index.html、dist 8 文件哈希全符;worker 在 agent venv import 通过(未启动注册)", + "记忆缺项核实:.12 venv311 无 mem0/qdrant_client/fastembed/onnxruntime(实测);服务器全盘无固定 tarball/已建轮/fastembed 缓存,uv 缓存 28K,无另一 3.11 环境;工作站实物 tarball sha256 9dcef930… 与 lock 一致、固定源轮 698284b6… 一致、fastembed 缓存 92MB、hel857-venv 为 3.10 不可混;最少安装=运行包 README §7(40 轮一次批准窗口 + --no-index 安装 + co-import),空间约 500–600MB,本卡零安装零下载", + "能力清单 14 项按用户顺序三态分级(语音已接并以 HEL-907/910 实测纠正旧表;天气/素材/备份/摄像头/真实口型如实标 ❌ 并追到当前文件与承接);启用建议分 A/B/C 三本账、含时长/上限/费用(内网自持零现金)/何时停/开麦/断开保留/共享服务恢复/记忆前置,全部待批、默认 0、旧三窗口余额不结转", + "边界保持:未部署未监听、8300/8310/8320/8350 与资料未动、8340/ComfyUI 停机、.12 临时树 /tmp/hel913-verify 与本机 tar 均已清理、需求与用户原话零改动" + ], + "deferred": [ + "实际部署与启用:待总管向白栖知说明获批后按 deploy/hel913-unified/README.md 执行(含端口选择、团队/用户实例分账配置)", + "Mem0 cp311 安装(40 轮下载+安装+co-import+缓存落位):待一次性批准,唯一入口 deploy/hel895-release-candidate.md §4", + "persona 自主诠释、生活事件、主动留言的真实生成:从未在真实窗口发生(预算 0),A 团队检查与 B 用户首体验窗口补证", + "语音遗留:修复后首声实测(原 9.53s、离线推算 ~5.9s)、真人麦克风打断、生成中文字取消正面样本、真机/手机/Safari(承接 HEL-910/911 deferred)", + "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、候选树、零监听零出站:网关地址重写 127.0.0.1:1 + stream 出站哨兵);agent venv worker import ok", + "dist 复用:git diff 958c696 4a38106 -- web/ 为空;8 文件 sha256 于 .12 实测与 dist-manifest.json 一致", + "记忆缺项:.12 实测 import 四项失败;服务器 find 无 mem0 轮/缓存;工作站 tarball/wheel sha256 本卡复验一致", + "未验证(如实):运行包尚未在任何现网端口实际起服(cap=0 未部署);修复后语音首声、真机、Safari——见 deferred,不冒充" + ] +} diff --git a/docs/rework/hel913/verify-offline-run.txt b/docs/rework/hel913/verify-offline-run.txt new file mode 100644 index 0000000..9f35c38 --- /dev/null +++ b/docs/rework/hel913/verify-offline-run.txt @@ -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 -- 2.54.0 From 8ea5fdc4c9d40800643b38919ef7050a8f0f06f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=80=BB=E5=B7=A5?= Date: Thu, 29 Oct 2026 22:30:56 +0800 Subject: [PATCH 3/5] HEL-913 close-out: executable package version/dir entry, consistent pending-approval enablement, complete auditable mem0 install (isolated venv), identity-checked ctl scripts + voice-window wiring (42/42 offline stub checks), fact-table accuracy fixes Co-authored-by: multica-agent --- deploy/hel913-unified/README.md | 332 +++++++++++++----- deploy/hel913-unified/api_ctl.sh | 145 ++++++-- deploy/hel913-unified/bridge_ctl.template.sh | 77 ++++ deploy/hel913-unified/force_end.template.sh | 73 ++++ deploy/hel913-unified/manifest.json | 43 ++- deploy/hel913-unified/test_ctl_scripts.sh | 149 ++++++++ deploy/hel913-unified/tts_ctl.template.sh | 79 +++++ .../voice-window-arm.template.env | 12 + deploy/hel913-unified/voice.template.yaml | 20 +- deploy/hel913-unified/worker.env.template | 26 +- deploy/hel913-unified/worker_ctl.sh | 138 ++++++-- docs/hel913-capability-and-enablement.md | 108 +++--- docs/requirements/handoff-HEL-913.json | 29 +- docs/rework/hel913/ctl-checks-run.txt | 49 +++ 14 files changed, 1058 insertions(+), 222 deletions(-) create mode 100644 deploy/hel913-unified/bridge_ctl.template.sh create mode 100644 deploy/hel913-unified/force_end.template.sh create mode 100644 deploy/hel913-unified/test_ctl_scripts.sh create mode 100644 deploy/hel913-unified/tts_ctl.template.sh create mode 100644 deploy/hel913-unified/voice-window-arm.template.env create mode 100644 docs/rework/hel913/ctl-checks-run.txt diff --git a/deploy/hel913-unified/README.md b/deploy/hel913-unified/README.md index b67b5f2..e361a96 100644 --- a/deploy/hel913-unified/README.md +++ b/deploy/hel913-unified/README.md @@ -1,63 +1,86 @@ # HEL-913 · 统一体验运行包(固定版本,未部署、cap=0) -> 本目录是把已通过 HEL-912 审核的统一候选装成的**可交接运行包**:精确源码版本、 -> 产物与哈希、脱敏配置模板、唯一启动方式、独立空资料空间、停止/备份/保留资料 -> 回退说明,以及一次零真实调用的运行准备验证记录。**本卡未部署任何现网服务、 -> 未监听 836x/8082/9880/8090、未改任何现有实例;实际启用与新用量由总管统一 -> 向白栖知说明后再执行。** +> 本目录是把已通过 HEL-912 审核的统一候选装成的**可交接运行包**:精确源码 +> 版本、产物与哈希、脱敏配置模板、唯一启动方式、独立空资料空间、停止/ +> 备份/保留资料回退说明、一次零真实调用的运行准备验证记录、真实记忆的 +> 完整可审安装清单,以及语音窗口的唯一布防接线。**本卡未部署任何现网 +> 服务、未监听 836x/8082/9880/8090、未改任何现有实例;实际启用与新用量 +> 由总管统一向白栖知说明并获批准后再执行。** -## 0. 精确版本与授权来源 +## 0. 精确版本:产品基线 vs 运行包提交(不要混同) -- **候选分支** `agent/2/8e99cc2cc592`,head **`4a38106eef21c43ba24ab7d07992b1bfe539137d`** - (HEL-911:AgentHandoff/shutdown 接缝修复 + 3.66s endpointing 等待归因修复)。 - 父线:`407fc81`(HEL-910 窗口记录)← `958c696`(HEL-908 R1/R2 返工)。 -- 审核:HEL-912 结论「通过,无必须修复项」。主卡 HEL-797;本卡 HEL-913。 -- 需求基准 v1.12(`docs/requirements/`),需求检查见 handoff-HEL-913.json。 +- **已审产品基线**:`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` | 本运行说明(唯一照抄入口) | -| `manifest.json` | 机器可读清单:head、锁文件、dist 哈希、环境引用 | +| `README.md` | 本运行说明(唯一照抄入口;不跳旧 HEL-895 通用步骤) | +| `manifest.json` | 机器可读清单:产品基线、dist 哈希、环境引用、边界 | | `dist-manifest.json` | web dist 8 文件 SHA256(复用 HEL-910 实测构建) | | `models.template.yaml` | 脱敏模型/预算配置(**一切预算=0**;占位符标注待批写入位置) | -| `voice.template.yaml` | 脱敏语音配置(LiveKit/TTS/面板指向占位,真实值服务器本地) | -| `worker.env.template` | 语音 worker 环境模板(agent venv 运行) | -| `api_ctl.sh` | 统一实例 API 启停(单进程 HTTP+HTTPS 双入口) | -| `worker_ctl.sh` | 语音 worker 启停(只按 PID 句柄停止) | -| `verify_offline.py` | 零真实调用运行准备验证(34 项检查;TestClient 进程内,不监听端口) | +| `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 ` 到点;封窗停服) | +| `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/verify-offline-run.txt`(.12 产品 3.11 venv +实跑 34/34)、`docs/rework/hel913/ctl-checks-run.txt`(收口轮启停检查 42/42)。 ## 2. 版本固定与产物复用 -- **源码**:head `4a38106`(含 `requirements.lock` 19 项固定版本,Python 3.11 实测)。 +- **源码**:产品基线 `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、不重建。8 文件哈希见 - `dist-manifest.json`(2026-10-29 于 .12 逐文件实测),部署时核对一致即可。 -- **解释器(既有环境引用,不新建)**: + 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)。 - - 语音 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;语音页 secure context 依赖 HTTPS 入口)。 - - 本机 LiveKit `ws://127.0.0.1:7880`(常驻);TTS 9880 / 面板 8090 / 语音桥 - 按获批窗口另行启动,未获批保持停机。 + 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 clone 后 checkout 4a38106(或 git archive 解包) + 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 复制 + worker/.env # 由 worker.env.template 复制并填好(占位全清) logs/ ``` @@ -65,59 +88,123 @@ 聊天记录、LiveKit/TTS 凭据、自签证书私钥、agent venv 内实物。用户实例在用户 亲自创建前 `companion` 表必须为空(离线验证第 3 项复现该守门)。 -## 4. 启动与停止(唯一方式) +## 4. 首次准备与启停(唯一照抄入口) + +### 4.1 首次准备(获批部署窗口内执行一次;不真启动服务) ```bash -# 4.1 API:单进程双入口(HTTP 8360 + HTTPS 8361 共用同一 app 状态;lifespan 只进一次) +# 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 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 且未配网关) -# 4.2 语音 worker(语音窗口获批后;TTS 9880 先起、面板 8090 可选) +# 语音 worker(语音窗口获批后;接线与前置见 §5) HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/worker_ctl.sh start -# worker 经 PYTHONPATH=current/src 用 agent venv 运行,注册到本机 LiveKit 7880, -# 健康口 8082( HEL913_WORKER_HEALTH 可改);只按 logs/worker.pid 停止。 -# 4.3 停止顺序:先 worker 后 API(语音会话先释放,再关统一进程) +# 停止顺序:先 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. 备份与保留资料回退 +## 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/_MAX_AUDIO_SECONDS/_MAX_CONNECT_RETRIES_PER_CALL`(空/0=拒绝一切 ASR;布防值见 `voice-window-arm.template.env`) | 程序硬闸,发送前拒绝(`src/cybergril/voice/asr_usage.py`,逐通落账) | +| 每通墙钟/房间寿命 | `voice.yaml` `session_ttl_seconds: 900` + token TTL 300;挂断即删房(失败留账重试) | 程序硬闸 + 到期失效 | +| TTS 段数 | `tts_ctl.template.sh` start 时记录 tts.log 起始字节,`grep -c "first chunk"` 对照窗口段数上限 | 监控口径(到顶即人工 force_end;程序不硬闸) | +| 窗口总时限 | `force_end.template.sh watch ` 到点强制收尾(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+逐表核对) - systemctl stop <实例> # 或 api_ctl.sh stop + bash deploy/hel913-unified/api_ctl.sh stop .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/ 原样保留,systemd 单元 - disable;重新启用 = 同一 data 目录再 start(epoch 递增、聊天记忆保留,离线 - 验证第 11 组复现)。 + `current` 切回上一运行包提交(`git checkout <旧head>`)→ 起服务 → `verify` 通过。 +- **整实例撤销但保留资料**:`api_ctl.sh stop`,data/ 原样保留,重新启用 = + 同一 data 目录再 start(epoch 递增、聊天记忆保留,离线验证第 11 组复现)。 - **CG-009 每日自动备份/用户手动完整导出(R6)尚未实现**,本节只是发布归档, 不得冒充 R6 完成。 -## 6. 零真实调用运行准备验证(已执行,34/34) +## 7. 零真实调用运行准备验证(已执行,34/34;口径如实) -在 .12 产品 3.11 venv 对候选 head 树实跑 `verify_offline.py`(进程内 TestClient, -零监听、零出站——网关地址重写为 127.0.0.1:1 + provider.stream 出站哨兵双保险): +在 .12 产品 3.11 venv 对产品基线树实跑 `verify_offline.py`(进程内 +TestClient,零监听、零出站——网关地址重写为 127.0.0.1:1 + provider.stream +出站哨兵双保险): -- 包完整性:Python 3.11.16、候选树 import、锁定依赖版本、记忆栈四项缺置如实。 -- 入口/创建守门:health 诚实(llm.ok=false)、空库 404 无伪造档案、预览本地 - 计算、创建 201、二次创建 409、底色落库与预览一致、persona 在 cap=0 下诚实 - failed(无编造诠释)。 +- 包完整性: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。 +- 日子与回忆:**空库上的**未分享生活服务端裁剪(told=1;未做正反种子对照, + 不夸成实质权限全覆盖)、分页形状、未知回执/详情 404。 - 语音:status 诚实 voice=false;TTS 不可达时开房 503 拒绝(绝不建房); 未知会话 DELETE 404。 - 重开:epoch=2、身份/关系/聊天保留、无伪造回复。 @@ -125,42 +212,121 @@ HEL913_BASE=/opt/cybergril-hel913 bash deploy/hel913-unified/api_ctl.sh stop - worker 接线:agent venv(livekit-agents 1.8.8)下 `import cybergril.voice.worker` 通过(只读 import,未启动注册)。 -**预算 0 下只能诚实拒绝的**:一切真实创建诠释/习惯、对话生成、生活事件生成、 -判定/成长/gist、主动措辞、嵌入、语音开房。`/api/health` 的 200 不代表模型已 -运行——`llm.ok=false` 才是诚实状态。 +**预算 0 下只能诚实拒绝的**:一切真实创建诠释/习惯、对话生成、生活事件 +生成、判定/成长/gist、主动措辞、嵌入、语音开房。`/api/health` 的 200 不代表 +模型已运行——`llm.ok=false` 才是诚实状态。 -## 7. 真实记忆(Mem0)运行缺项 —— 已核实,未安装 +## 8. 真实记忆(Mem0)最少安装与接线 —— 完整可审清单(零安装零下载,待批) -- **服务器 .12 现状(2026-10-29 只读实测)**:产品 `.venv311`(3.11.16)内 - `mem0 / qdrant_client / fastembed / onnxruntime` 四项均未安装;服务器全盘无 - mem0 安装、无固定 tarball、无已建轮、无 fastembed 缓存、uv 缓存仅 28K。 - **没有另一个已准备好的 3.11 兼容环境**;旧 3.10 `hel857-venv`(3.10.12, - 工作站本地)不能混入 3.11 进程。 -- **已有实物(工作站本地,未上服务器)**:固定源 `mem0-2c81af3.tar.gz` - (sha256 `9dcef930…`,与 `deploy/mem0-stack.lock` 一致,本卡复验);固定源 - 离线构建轮 `mem0ai-2.2.1-py3-none-any.whl`(sha256 `698284b6…`,本卡复验); - fastembed 模型缓存 92MB(Qdrant/bge-small-zh-v1.5 + bm25);本地 uv 缓存可 +### 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)。 -- **最少安装动作(待一次性批准;本卡零安装零下载)**,唯一入口 - `deploy/hel895-release-candidate.md` §4: - 1. tarball 哈希核对(或直接复用已建轮并核对 `698284b6…`); - 2. 获批窗口内 `pip download --only-binary :all: --implementation cp - --python-version 3.11 --platform manylinux2014_x86_64 --abi cp311 - -r deploy/hel913-unified/mem0-cp311-wheels.txt -d wheels/`(40 项, - 逐项 sha256 留证;其中 3 项可从工作站缓存离线复用); - 3. 传输轮目录 + fastembed 缓存(92MB)到服务器; - 4. `uv pip install --python .venv311/bin/python --no-index --find-links wheels/ - mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 <40项>`; - 5. co-import 冒烟 + fastembed cache_dir 指向实例数据目录内缓存拷贝。 - **空间/影响**:.venv311 增约 300–400MB(3.10 同栈实测 366MB 参考)+ 轮暂存 - 约 150MB + 模型缓存 92MB;首次本地嵌入零下载(缓存就位)。激活语义层仍需 - B 部分嵌入/提取预算(见 docs/hel913-capability-and-enablement.md §2)。 - 在此之前 `CYBERGRIL_SEMANTIC=stub`,长期检索走词面 FTS,不以 stub 冒充 - 真实 Mem0。 -## 8. 本包不做的事 +### 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 /mem0-2c81af3.tar.gz # 必须等于 9dcef930…2dda23 +sha256sum /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 \ + -r deploy/hel913-unified/mem0-cp311-wheels.txt -d wheels-mem0/ # 记忆栈 40 项 +# 其中 greenlet/pillow/truststore 3 项可从工作站 uv 缓存离线复用 +# (release-artifacts.json 记录),命中即从 wheels-mem0/ 删除同版本再放缓存件 +cp /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 /fastembed-cache root@192.168.200.12:/opt/cybergril-hel913/ +ssh root@192.168.200.12 'sha256sum -c /opt/cybergril-hel913/wheels-product.sha256 && \ + sha256sum -c /opt/cybergril-hel913/wheels-mem0.sha256' # 传输后复核 + +# W3 · 隔离 venv 创建 + 仅本地轮安装(--no-index 全程,失败即停) +ssh root@192.168.200.12 +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 \ + mem0ai==2.2.1 qdrant-client==1.20.0 fastembed==0.9.1 +# mem0ai 的安装来源必须是 W0 的固定源码轮(同版本号不同来源不互认) + +# W4 · co-import 冒烟(同进程;失败即停,不降级) +cd /opt/cybergril-hel913/current +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 +# semantic.yaml 的 fastembed cache_dir 指向上面的目录(见 8.4) + +# 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`, + 填 `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 保持停机。旧三次测试窗口 -余额不结转;一切提额是待批准建议。 +8300/8310/8320/8350 与既有资料不动、8340/ComfyUI 保持停机、不向 +`/opt/cybergril-hel879/current/.venv311` 增改依赖。旧三次测试窗口余额不 +结转;一切提额、安装、部署都是待批准建议。 diff --git a/deploy/hel913-unified/api_ctl.sh b/deploy/hel913-unified/api_ctl.sh index eea78ef..830cefd 100644 --- a/deploy/hel913-unified/api_ctl.sh +++ b/deploy/hel913-unified/api_ctl.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash # HEL-913 统一体验运行包 · 统一实例 API 启停(单进程 HTTP+HTTPS 双入口) # -# 与 HEL-910 的 api_ctl.sh 同构,去掉一次性路径硬编码:实例根、端口、证书、 -# 解释器全部可用环境变量覆盖。启动器 deploy/voice/serve_dual_8360_8361.py -# 保证 HTTP 与 HTTPS 共用同一 API 进程/同一份状态(lifespan 只进一次)。 +# 启动器 deploy/voice/serve_dual_8360_8361.py 保证 HTTP 与 HTTPS 共用同一 +# API 进程/同一份状态(lifespan 只进一次)。本脚本只做本实例常规启停安全: +# - 子命令只认 start|stop|status,其他一律拒绝退出 2(不会静默当 start); +# - start 前核对 pid 句柄:本实例已在跑 → 拒绝重复 start(不覆盖句柄); +# 句柄 pid 活着但不属于本实例(PID 复用)→ 拒绝动手,提示人工处理; +# - stop/start 失败收尾只杀经身份核验的进程(/proc/ 的 cmdline 含 +# 本启动器且 cwd 是本实例代码目录),身份不符一律不杀; +# - start 健康检查不过 → 清掉自己刚起的进程、保留日志、退出 1。 # # 用法: [ENV=..] bash deploy/hel913-unified/api_ctl.sh start|stop|status # 环境变量(默认值按部署说明的实例布局): @@ -22,42 +27,114 @@ 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 -PAT="cybergril.*serve_dual" +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() { - if [ -f "$PIDF" ]; then - P=$(cat "$PIDF") - if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill "$P"; sleep 3; fi - if [ "$P" != "$$" ] && kill -0 "$P" 2>/dev/null; then kill -9 "$P"; fi - rm -f "$PIDF" + 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 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 - stop) stop; exit 0 ;; - status) - [ -f "$PIDF" ] && { P=$(cat "$PIDF"); kill -0 "$P" 2>/dev/null && echo "running pid $P" || echo "pid $P not alive"; } - curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" && echo " <- health" || echo "health not answering" - exit 0 ;; + start) start ;; + stop) stop ;; + status) status ;; + *) usage ;; esac - -stop -cd "$CODE" -set -a -export CYBERGRIL_DB=$BASE/data/companion.db -export CYBERGRIL_SEMANTIC=${HEL913_SEMANTIC:-stub} -export CYBERGRIL_R2_ENABLED=${HEL913_R2:-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 & -echo $! > "$PIDF" -echo "api starting (pid $(cat "$PIDF")) http=$HTTP https=$HTTPS" -sleep 6 -curl -s --max-time 3 "http://127.0.0.1:$HTTP/api/health" >/dev/null && echo "OK: $HTTP healthy" || { echo "FAIL:"; tail -20 "$LOG"; exit 1; } -curl -sk --max-time 3 "https://127.0.0.1:$HTTPS/api/health" >/dev/null && echo "OK: $HTTPS healthy" || { echo "FAIL $HTTPS:"; tail -20 "$LOG"; exit 1; } diff --git a/deploy/hel913-unified/bridge_ctl.template.sh b/deploy/hel913-unified/bridge_ctl.template.sh new file mode 100644 index 0000000..b5143eb --- /dev/null +++ b/deploy/hel913-unified/bridge_ctl.template.sh @@ -0,0 +1,77 @@ +#!/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 +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 \ + | grep -q "TLSv1.3" && echo "OK: bridge TLS up" \ + || { echo "WARN: TLS probe inconclusive — check $LOG"; tail -5 "$LOG"; } diff --git a/deploy/hel913-unified/force_end.template.sh b/deploy/hel913-unified/force_end.template.sh new file mode 100644 index 0000000..b623431 --- /dev/null +++ b/deploy/hel913-unified/force_end.template.sh @@ -0,0 +1,73 @@ +#!/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 # 到点自动收尾(受监督后台任务; +# # 更可靠的等价物是获批后装 +# # systemd timer 调本脚本 now) +# 环境变量: HEL913_BASE / HEL913_HTTP(8360) +set -u +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 " >&2; exit 2; } + +force_end() { + 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 +state = d.get("state", d) +def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k in ("sessions", "active") and isinstance(v, dict): + for sid, sess in v.items(): + print(sid) + else: + walk(v) + elif isinstance(o, list): + for i in o: + walk(i) +walk(state) +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} + 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 diff --git a/deploy/hel913-unified/manifest.json b/deploy/hel913-unified/manifest.json index 3cc82f7..03f8ec7 100644 --- a/deploy/hel913-unified/manifest.json +++ b/deploy/hel913-unified/manifest.json @@ -1,20 +1,23 @@ { "task": "HEL-913", - "head": "4a38106eef21c43ba24ab7d07992b1bfe539137d", + "product_baseline_head": "4a38106eef21c43ba24ab7d07992b1bfe539137d", + "package_head": "运行包提交 = 本文件所在分支 tip(交付评论给出精确 hash;本文件无法写死自身提交)。部署照抄入口是运行包提交,只 checkout 4a38106 会没有 deploy/hel913-unified/", + "package_only_change_check": "git diff --stat 4a38106 -- 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", - "requirements_lock": "requirements.lock (19 pinned, Python 3.11.16 verified)", + "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": "/opt/cybergril-hel879/current/.venv311 (Python 3.11.16)", + "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}", - "livekit": "ws://127.0.0.1:7880 (standing)", - "tts_panel": "9880/8090 stopped until approved window" + "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", @@ -23,20 +26,34 @@ "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)", + "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" + "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 (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)", + "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", @@ -44,7 +61,9 @@ "fastembed_cache": "92MB (Qdrant/bge-small-zh-v1.5 + bm25)", "hel857_venv": "Python 3.10.12 — must not mix into 3.11 process" }, - "minimal_install": "deploy/hel913-unified/mem0-cp311-wheels.txt (40 wheels, one approved window) + wheel transfer + --no-index install + co-import; entry remains deploy/hel895-release-candidate.md §4" + "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" }, - "boundaries": "not deployed; nothing listening; caps all 0; no PR; existing instances and data untouched; no downloads this card" + "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" } diff --git a/deploy/hel913-unified/test_ctl_scripts.sh b/deploy/hel913-unified/test_ctl_scripts.sh new file mode 100644 index 0000000..b7445ec --- /dev/null +++ b/deploy/hel913-unified/test_ctl_scripts.sh @@ -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