Complete DDS training workflow and delivery package

This commit is contained in:
andreysk0304 2026-09-24 01:10:49 +03:00
commit 4c4b91064f
229 changed files with 11969 additions and 1024 deletions

View file

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""Живой smoke: активная карточка ДДС переживает restart backend.
Скрипт сам запускает два последовательных uvicorn-процесса на одном порту,
использует один локальный PostgreSQL и проверяет карточку, статус службы,
выбранную бригаду, письменный ответ и продолжающийся таймер.
"""
import argparse
import asyncio
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import websockets
ROOT = Path(__file__).resolve().parents[1]
BACKEND = ROOT / "backend"
def request_json(url: str, *, method: str = "GET", cookie: str = "") -> tuple[dict, str]:
request = urllib.request.Request(url, method=method)
if cookie:
request.add_header("Cookie", cookie)
with urllib.request.urlopen(request, timeout=5) as response:
value = json.loads(response.read())
issued = response.headers.get("set-cookie", "").split(";", 1)[0]
return value, issued
def wait_ready(base: str, timeout: float = 20) -> dict:
deadline = time.monotonic() + timeout
last: Exception | None = None
while time.monotonic() < deadline:
try:
health, _ = request_json(f"{base}/api/health")
return health
except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc:
last = exc
time.sleep(0.1)
raise RuntimeError(f"backend не стал готов: {last}")
@asynccontextmanager
async def backend_process(port: int, database_url: str, *, hard_stop: bool = False):
env = {
**os.environ,
"DATABASE_URL": database_url,
"OFFLINE": "true",
"VOICE_ENABLED": "false",
"DEV_AUTH_BYPASS": "true",
"DEMO_NO_DB": "false",
"SESSION_SECRET": "recovery-smoke-secret-that-is-not-production",
"LLM_API_KEY": "",
"LLM_PROVIDER": "disabled",
}
process = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1",
"--port", str(port), "--workers", "1"],
cwd=BACKEND,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
try:
wait_ready(f"http://127.0.0.1:{port}")
yield process
finally:
if process.poll() is None:
process.kill() if hard_stop else process.terminate()
try:
process.wait(timeout=8)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
if not hard_stop and process.returncode not in (0, -15):
error = process.stderr.read()[-4000:] if process.stderr else ""
raise RuntimeError(f"backend завершился с кодом {process.returncode}: {error}")
async def receive_type(socket, wanted: str) -> dict:
for _ in range(30):
value = json.loads(await asyncio.wait_for(socket.recv(), timeout=5))
if value.get("type") == wanted:
return value
raise AssertionError(f"событие {wanted} не получено")
async def start_and_edit(ws_base: str, cookie: str, session_id: str) -> dict:
options = {"additional_headers": {"Cookie": cookie}}
async with websockets.connect(f"{ws_base}/ws/control/{session_id}", **options) as control:
await control.send(json.dumps({
"type": "scenario.start",
"scenario_id": "fire-apartment-l2",
"trainee": "Recovery smoke",
"mode": "training",
"exercise": "dds",
"criteria": {
"decision_time_limit_seconds": 45,
"allowed_errors": 0,
"require_correct_grammar": True,
},
}))
await asyncio.sleep(0.4)
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
card = await receive_type(station, "card.received")
snapshot = (await receive_type(station, "station.state"))["snapshot"]
service = snapshot["services"][0]
crew = snapshot["crew_options"][0]
await station.send(json.dumps({
"type": "card.status", "service": service, "status": "accepted",
}))
await receive_type(station, "station.state")
await station.send(json.dumps({"type": "crew.select", "crew": crew}))
await receive_type(station, "station.state")
await station.send(json.dumps({
"type": "card.reply",
"card_id": card["card"]["card_id"],
"text": "Сообщение принято, бригада направлена.",
}))
latest = (await receive_type(station, "station.state"))["snapshot"]
return {"service": service, "crew": crew, "card_id": card["card"]["card_id"],
"before": latest}
async def verify_and_finish(ws_base: str, cookie: str, session_id: str, expected: dict) -> dict:
options = {"additional_headers": {"Cookie": cookie}}
async with websockets.connect(f"{ws_base}/ws/station/{session_id}", **options) as station:
card = await receive_type(station, "card.received")
snapshot = (await receive_type(station, "station.state"))["snapshot"]
assert card["card"]["card_id"] == expected["card_id"]
assert snapshot["statuses"][expected["service"]] == "accepted"
assert snapshot["crew_selected"] == expected["crew"]
assert snapshot["reply_text"] == "Сообщение принято, бригада направлена."
await station.send(json.dumps({"type": "station.finish"}))
await receive_type(station, "score.ready")
return snapshot
async def run(args) -> dict:
http_base = f"http://127.0.0.1:{args.port}"
ws_base = f"ws://127.0.0.1:{args.port}"
session_id = str(uuid4())
async with backend_process(args.port, args.database_url, hard_stop=True):
_, cookie = request_json(f"{http_base}/api/auth/dev-token", method="POST")
expected = await start_and_edit(ws_base, cookie, session_id)
# Небольшая пауза делает проверяемым продолжение активного таймера.
await asyncio.sleep(1.2)
async with backend_process(args.port, args.database_url):
health = wait_ready(http_base)
assert health["sessions_restored"] >= 1
# Та же подписанная cookie должна пережить restart. При смене роли,
# пароля или блокировке её отзовёт сохранённый auth_version.
after = await verify_and_finish(ws_base, cookie, session_id, expected)
return {
"ok": True,
"session_id": session_id,
"sessions_restored": health["sessions_restored"],
"card_id": after["card_id"],
"status": after["statuses"][expected["service"]],
"crew": after["crew_selected"],
"reply_text": after["reply_text"],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", type=int, default=18116)
parser.add_argument(
"--database-url",
default=os.environ.get(
"DATABASE_URL", "postgresql+asyncpg://lct:lct@127.0.0.1:5434/lct"
),
)
args = parser.parse_args()
try:
print(json.dumps(asyncio.run(run(args)), ensure_ascii=False, indent=2))
except Exception as exc: # noqa: BLE001
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())