Files
CyberGril/scripts/verify_hel876_voice_seam.py
T
施工员2号andmultica-agent 23415fbcd0 HEL-876: voice-base read-only verification + R5/R6 readiness materials - docs/voice-r5-readiness-HEL876.md records the 2026-10-26 bounded read-only audit of 192.168.200.12 with timestamps: LiveKit/FunASR/8090 alive with health evidence, 8081 corrected as the voice-agent livekit worker (not a legacy lip-sync piece), CosyVoice TTS 9880 gracefully stopped since 2026-10-24 22:17 and NOT running, MuseTalk 8010 unit gone, ComfyUI 8188 holding 26.6GB of the 32GB GPU leaving 3.9GB free < ~5.6GB TTS requirement (the only hard resource gap, coordination not unilateral action), voice catalog (11 voices, rain selected) with whitelisted config field names only and no credentials read or printed, .11 gateway model-list probe and open-meteo outbound both 200; the R5 seam is fixed at src/cybergril/api/app.py:488 - the worker must reuse the same chat chain via a livekit LLM adapter speaking the identical RealChatAdapter protocol (epoch sync/serialized turns/barge-in cancel/idempotent replay/receipt round-trip), proven offline with zero model calls by scripts/verify_hel876_voice_seam.py (six checks all pass on 3.11 venv + requirements.lock installed from the local uv offline cache, ruff clean, explicitly NOT claimed as end-to-end voice verification); R6 seams located read-only: city placeholder ManagePanel.tsx:182 with no backend, no weather client (outbound now proven), demo-only material import :178/:183, full-backup boundary = companion.db + vec_public + mem0_public_history.db + embed ledger where copying the single db file is not a complete backup and Persona.fingerprint is the same-her check; three-column honest conclusions (verified / unverified / actually-missing) plus six zero-cost work items ready for dispatch; no product code touched (src/ web/ unchanged), no new product PR, candidate branch not merged, dual-target requirement checks 4/4 pass with handoff-HEL-876.json
Co-authored-by: multica-agent <github@multica.ai>
2026-10-26 21:42:41 +08:00

122 lines
5.6 KiB
Python

"""HEL-876 offline protocol check for the R5 voice-worker seam (zero model / zero embed / zero TTS / zero ASR).
Proves, against the unified-candidate API with a fake LLM provider, that a voice
worker reusing POST /api/chat gets the exact contract the text adapter already
uses: epoch resync (409), serialized turns, mid-stream cancel (client disconnect
-> row marked cancelled, next turn clean), idempotent replay of a completed turn
(no second model call), and the receive-confirmation receipt round trip.
Run: PYTHONPATH=src .venv311/bin/python scripts/verify_hel876_voice_seam.py
"""
from __future__ import annotations
import json
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
from fastapi.testclient import TestClient # noqa: E402
from test_r2 import R2LLM # noqa: E402
from cybergril.api.app import create_app # noqa: E402
from cybergril.life.runtime import LifeConfig # noqa: E402
from cybergril.think.router import build_router # noqa: E402
from cybergril.think.scenes import Tier # noqa: E402
CREATE = {"name": "协议检查", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
T1, T2, T3, T4 = (str(uuid.uuid4()) for _ in range(4))
def sse_frames(response) -> list[dict]:
frames = []
for line in response.iter_lines():
if line.startswith("data: "):
frames.append(json.loads(line[6:]))
return frames
def main() -> int:
provider = R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
tmp = Path(tempfile.mkdtemp(prefix="hel876-seam-"))
app = create_app(db_path=tmp / "seam.db", router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="stub")
ok = []
with TestClient(app) as client:
# 1. epoch discovery (worker startup: GET /api/health)
health = client.get("/api/health").json()
epoch = health["epoch"]
assert health["capabilities"]["voice"] is False, "voice must stay honestly false"
ok.append(f"health epoch={epoch}, voice=false")
# 2. companion exists (worker does NOT create a second her)
client.post("/api/companion", json=CREATE)
assert client.get("/api/companion").status_code == 200
# 3. full turn: deltas then final with receipt
r = client.post("/api/chat", json={"text": "你好呀", "turn_id": T1, "epoch": epoch})
frames = sse_frames(r)
deltas = [f["delta"] for f in frames if f["t"] == "chat.delta"]
final = [f for f in frames if f["t"] == "chat.final"]
assert deltas and "".join(deltas) == final[0]["utterance"]["text"]
assert final[0]["receipt"], "final must carry the receive-confirmation receipt"
receipt = final[0]["receipt"]
ok.append(f"turn v-1 streamed {len(deltas)} deltas + final(receipt keys={sorted(receipt)})")
calls_after_turn = len(provider.calls)
# 4. replay of the completed turn: no second model call
r2 = client.post("/api/chat", json={"text": "你好呀", "turn_id": T1, "epoch": epoch})
frames2 = sse_frames(r2)
assert [f["t"] for f in frames2] == ["chat.final"], frames2
assert len(provider.calls) == calls_after_turn, "replay must not call the model"
ok.append("replay of v-1 returns saved final only, model calls unchanged")
# 5. receive confirmation (worker acks the receipt after TTS playback)
ack = client.post("/api/chat/received", json={k: receipt[k] for k in
("message_id", "turn_id", "epoch", "token", "text_hash")})
assert ack.status_code == 200 and ack.json() == {"accepted": True}
ok.append("receipt acknowledged via /api/chat/received")
# 6. mid-stream interruption: a new turn (barge-in) cancels the active one
provider.mode = "slow" # reply hangs after first delta
def slow_turn() -> None:
with client.stream("POST", "/api/chat",
json={"text": "说一半我打断你", "turn_id": T2, "epoch": epoch}) as s:
for _ in s.iter_lines():
pass
slow = threading.Thread(target=slow_turn, daemon=True)
slow.start()
time.sleep(1.0) # first delta arrives, turn v-2 is active and hanging
r3 = client.post("/api/chat", json={"text": "还在吗", "turn_id": T3, "epoch": epoch})
assert [f["t"] for f in sse_frames(r3)][-1] == "chat.final"
slow.join(timeout=10)
provider.mode = ""
messages = client.get("/api/messages").json()["messages"]
v2 = [m for m in messages if m["turn_id"] == T2]
v3 = [m for m in messages if m["turn_id"] == T3]
assert v2 and all(m["status"] == "cancelled" for m in v2), v2
assert not [m for m in v2 if m["role"] == "companion"], "no phantom companion row for aborted turn"
assert any(m["status"] == "sent" and m["role"] == "companion" for m in v3)
ok.append("barge-in new turn cancels active v-2 (row=cancelled, no companion row); v-3 completes")
# 8. epoch mismatch -> 409 with fresh epoch (worker resyncs like real.ts)
stale = client.post("/api/chat", json={"text": "旧纪元", "turn_id": T4, "epoch": epoch + 999})
assert stale.status_code == 409 and "epoch" in stale.json()
ok.append("stale epoch rejected with 409 + current epoch for resync")
print("HEL-876 voice-seam offline protocol check: ALL PASS")
for line in ok:
print(" ✓", line)
return 0
if __name__ == "__main__":
raise SystemExit(main())