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

@ -5,6 +5,7 @@
"""
import uuid
from xml.etree import ElementTree as ET
import pytest
from fastapi.testclient import TestClient
@ -61,12 +62,29 @@ def test_instructor_cannot_open_admin(as_instructor):
assert as_instructor.get("/api/admin/users").status_code == 403
assert as_instructor.get("/api/admin/audit").status_code == 403
assert as_instructor.get("/api/admin/status").status_code == 403
assert as_instructor.get("/api/admin/diagnostics").status_code == 403
assert as_instructor.get("/api/admin/config.xml").status_code == 403
def test_anonymous_cannot_open_admin(client):
assert client.get("/api/admin/users").status_code == 401
def test_admin_downloads_safe_xml_configuration(as_admin):
response = as_admin.get("/api/admin/config.xml")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/xml")
assert "attachment" in response.headers["content-disposition"]
root = ET.fromstring(response.content)
assert root.tag == "lctConfiguration"
assert root.find("./workstations/workstation[@role='admin']") is not None
assert root.find("./timerLimits/timer[@code='dds_ack']") is not None
lowered = response.content.lower()
assert b"session_secret" not in lowered
assert b"llm_api_key" not in lowered
assert b"database_url" not in lowered
def test_admin_has_no_way_to_change_a_score():
"""Запрет выражен отсутствием точки, а не проверкой внутри неё: в модуле
администратора нет ни одного маршрута, трогающего оценки."""
@ -90,6 +108,39 @@ def test_admin_cannot_write_to_the_audit_log():
]
def test_audit_api_applies_actor_action_and_offset_filters(as_admin):
"""The admin UI must be able to reach older records, not just page zero."""
from app.api.http import admin as admin_module
class FakeDb:
statement = None
async def scalars(self, statement):
self.statement = statement
return []
fake_db = FakeDb()
async def fake_session():
yield fake_db
app.dependency_overrides[admin_module.get_session] = fake_session
try:
response = as_admin.get(
"/api/admin/audit",
params={"action": "login.failed", "actor": "alice", "limit": 201, "offset": 400},
)
finally:
app.dependency_overrides.pop(admin_module.get_session, None)
assert response.status_code == 200, response.text
statement = fake_db.statement
assert statement._limit_clause.value == 201
assert statement._offset_clause.value == 400
assert "audit_log.action" in str(statement.whereclause)
assert "audit_log.actor" in str(statement.whereclause)
# ── учётные записи ──
@ -170,7 +221,39 @@ def test_status_names_every_component(as_admin):
names = {item["name"] for item in as_admin.get("/api/admin/status").json()}
assert {"База данных", "Модели речи", "Эмбеддинги", "Провайдер LLM",
"Классификатор ЕКП", "Секрет сессии"} <= names
"Классификатор ЕКП", "Резервное копирование", "Секрет сессии",
"Нагрузка backend"} <= names
def test_admin_gets_live_metrics_and_downloadable_failure_report(as_admin):
if not db_alive(as_admin):
pytest.skip("нет базы")
response = as_admin.get("/api/admin/diagnostics")
assert response.status_code == 200, response.text
body = response.json()
assert body["metrics"]["cpu_cores"] >= 1
assert body["metrics"]["disk_total_bytes"] > body["metrics"]["disk_free_bytes"]
assert body["metrics"]["active_sessions"] >= 0
assert isinstance(body["recent_system_events"], list)
assert isinstance(body["failed_actions_24h"], list)
download = as_admin.get("/api/admin/diagnostics.json")
assert download.status_code == 200
assert "attachment" in download.headers["content-disposition"]
def test_diagnostic_journal_redacts_credentials():
import logging
from app.monitoring import recent_events
logging.getLogger("app.redaction-test").warning(
"failure postgresql://user:secret@db/lct SESSION_SECRET=never-show"
)
event = recent_events(limit=1)[0]
assert "user:secret@" not in event["message"]
assert "never-show" not in event["message"]
def test_default_session_secret_is_reported_as_a_problem(as_admin):
@ -191,6 +274,32 @@ def test_backup_listing_is_open_to_admin(as_admin):
assert as_admin.get("/api/admin/backups").status_code == 200
def test_backup_listing_failure_does_not_break_admin_status(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/status")
assert response.status_code == 200, response.text
backup = next(item for item in response.json() if item["name"] == "Резервное копирование")
assert not backup["ok"]
assert "каталог временно недоступен" in backup["detail"]
def test_backup_list_returns_503_on_storage_error(as_admin, monkeypatch):
from app.admin import backup as backup_service
def broken():
raise backup_service.BackupError("каталог временно недоступен")
monkeypatch.setattr(backup_service, "listing", broken)
response = as_admin.get("/api/admin/backups")
assert response.status_code == 503
assert "каталог временно недоступен" in response.json()["detail"]
def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
"""Кнопка не должна молча ничего не делать: если снять копию нечем,
администратор видит, чего именно не хватает."""
@ -203,3 +312,126 @@ def test_backup_failure_explains_what_is_missing(as_admin, monkeypatch):
response = as_admin.post("/api/admin/backups")
assert response.status_code == 503
assert "pg_dump" in response.json()["detail"]
def test_pg17_transaction_timeout_is_removed_from_pg16_backup(monkeypatch, tmp_path):
"""Образ backend может содержать клиент новее сервера из Compose."""
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
monkeypatch.setattr(backup_service.shutil, "which", lambda name: "/usr/bin/pg_dump")
class Result:
returncode = 0
stderr = b""
stdout = (b"--\n-- PostgreSQL database dump\n--\n\n"
b"SET transaction_timeout = 0;\nCREATE TABLE demo(id integer);\n")
monkeypatch.setattr(backup_service.subprocess, "run", lambda *args, **kwargs: Result())
made = backup_service.create()
content = (tmp_path / made["name"]).read_bytes()
assert b"transaction_timeout" not in content
assert b"CREATE TABLE demo" in content
def test_docker_backup_does_not_put_database_password_in_process_arguments(monkeypatch, tmp_path):
import base64
from types import SimpleNamespace
from app.admin import backup as backup_service
password = "secret p@ss:word"
monkeypatch.setattr(backup_service, "DIR", tmp_path)
monkeypatch.setattr(backup_service, "_dsn", lambda: ("backup", password, "db", "5432", "lct"))
monkeypatch.setattr(
backup_service.shutil, "which", lambda name: "/usr/bin/docker" if name == "docker" else None
)
captured = {}
def fake_run(command, **kwargs):
captured["command"] = command
captured["input"] = kwargs["input"]
return SimpleNamespace(
returncode=0,
stderr=b"",
stdout=b"--\n-- PostgreSQL database dump\nCREATE TABLE demo(id integer);\n",
)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service.create()
assert password not in " ".join(captured["command"])
assert captured["input"] == base64.b64encode(password.encode()) + b"\n"
def test_backup_dsn_decodes_escaped_credentials_without_exposing_them(monkeypatch):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="postgresql://backup:p%40ss%3Aword@db.example:5433/lct"),
)
assert backup_service._dsn() == ("backup", "p@ss:word", "db.example", "5433", "lct")
monkeypatch.setattr(
backup_service, "get_settings",
lambda: SimpleNamespace(database_url="not-a-valid-url-with-secret"),
)
try:
backup_service._dsn()
except backup_service.BackupError as exc:
assert "secret" not in str(exc)
else:
raise AssertionError("invalid DATABASE_URL must be rejected")
def test_backup_directory_failure_is_retryable_backup_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
blocker = tmp_path / "not-a-directory"
blocker.write_text("occupied")
monkeypatch.setattr(backup_service, "DIR", blocker / "backups")
monkeypatch.setattr(backup_service, "_dsn", lambda: ("u", "p", "db", "5432", "lct"))
with pytest.raises(backup_service.BackupError, match="каталог резервных копий"):
backup_service.create()
def test_backup_listing_io_failure_is_retryable_error(monkeypatch, tmp_path):
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "DIR", tmp_path)
def denied(_path):
raise PermissionError("access denied")
monkeypatch.setattr(backup_service.os, "scandir", denied)
with pytest.raises(backup_service.BackupError, match="прочитать каталог"):
backup_service.listing()
def test_windows_backup_permissions_use_explicit_private_acl(monkeypatch, tmp_path):
from types import SimpleNamespace
from app.admin import backup as backup_service
monkeypatch.setattr(backup_service, "getuser", lambda: "operator")
monkeypatch.setenv("USERDOMAIN", "TRAINING")
monkeypatch.setenv("SystemRoot", str(tmp_path))
called = {}
def fake_run(command, **kwargs):
called["command"] = command
called["kwargs"] = kwargs
return SimpleNamespace(returncode=0)
monkeypatch.setattr(backup_service.subprocess, "run", fake_run)
backup_service._restrict_file_permissions(-1, tmp_path / "copy.sql", platform_name="nt")
command = called["command"]
assert command[0].endswith("icacls.exe")
assert "/inheritance:r" in command
assert "TRAINING\\operator:(F)" in command
assert "*S-1-5-18:(F)" in command
assert called["kwargs"]["timeout"] == 15

View file

@ -56,6 +56,17 @@ def test_unknown_login_and_wrong_password_look_the_same(client):
assert first.json()["detail"] == "bad_credentials"
def test_login_rejects_values_outside_database_and_hash_bounds(client):
too_long_login = client.post(
"/api/auth/login", json={"login": "a" * 81, "password": "not-used"}
)
too_long_password = client.post(
"/api/auth/login", json={"login": "operator", "password": "x" * 1025}
)
assert too_long_login.status_code == 422
assert too_long_password.status_code == 422
def test_me_requires_authentication(client):
assert client.get("/api/auth/me").status_code == 401

View file

@ -18,6 +18,10 @@ from app.session.hub import hub
@pytest.fixture
def client():
# Each TestClient represents a fresh backend process. In particular,
# revocations exercised by a previous test must not leak into the next
# process' in-memory cache.
auth.prime_generations({})
with TestClient(app) as test_client:
hub.journal = None
yield test_client
@ -49,6 +53,12 @@ def test_cookie_from_previous_process_is_rejected(client, monkeypatch):
assert client.get("/api/auth/me").status_code == 401
def test_cookie_survives_generation_cache_reload_when_account_is_unchanged(client):
assert client.post("/api/auth/dev-token").status_code == 200
auth.prime_generations({"dev": 0})
assert client.get("/api/auth/me").status_code == 200
class FakeDb:
def __init__(self, user):
self.user = user
@ -74,7 +84,7 @@ def fake_user(login="victim", role="instructor"):
return SimpleNamespace(
id=uuid4(), login=login, full_name="Проверка", role=role,
service=None, trainee_id=None, blocked=False,
password_hash="old", created_at=datetime.now(timezone.utc),
password_hash="old", auth_version=0, created_at=datetime.now(timezone.utc),
)
@ -86,7 +96,10 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
monkeypatch.setattr(admin, "require", lambda _request, *_roles: auth.Principal(
login="admin", full_name="Администратор", role=Role.ADMIN,
))
monkeypatch.setattr(admin, "invalidate_login", lambda login: calls.append((login, db.commits)))
monkeypatch.setattr(
admin, "invalidate_login",
lambda login, version=None: calls.append((login, version, db.commits)),
)
async def no_audit(*_args, **_kwargs):
return None
@ -94,7 +107,7 @@ async def test_admin_patch_revokes_cookie_after_commit(monkeypatch):
monkeypatch.setattr(admin, "audit", no_audit)
await admin.patch_user(user.id, admin.UserPatch(blocked=True), object(), db)
assert user.blocked is True
assert calls == [("victim", 1)]
assert calls == [("victim", 1, 1)]
@pytest.mark.asyncio

View file

@ -0,0 +1,20 @@
from datetime import datetime, timedelta, timezone
from scripts import backup_loop
def test_no_backup_is_due_immediately(monkeypatch):
monkeypatch.setattr(backup_loop, "listing", lambda: [])
assert backup_loop.seconds_until_due(datetime.now(timezone.utc), 86_400) == 0
def test_fresh_backup_waits_only_for_remaining_interval(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(hours=6)}])
assert backup_loop.seconds_until_due(now, 86_400) == 18 * 3600
def test_overdue_backup_is_due_immediately(monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(backup_loop, "listing", lambda: [{"at": now - timedelta(days=2)}])
assert backup_loop.seconds_until_due(now, 86_400) == 0

View file

@ -160,6 +160,11 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.line")["speaker"] == "crew"
read_until(station, "station.state")
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Пожар в квартире",
"request": "Прошу подтвердить выезд и доложить о прибытии"})
assert read_until(station, "phone.report")["phase"] == "dispatched"
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
@ -170,7 +175,8 @@ def test_filled_112_card_continues_as_dds_in_the_same_lesson(client):
score = wait_for(lambda: hub.get(session_id).score)
assert hub.get(session_id).ended
keys = {item["key"] for item in score["metrics"]}
assert {"address", "incident_signs", "dds_primary", "dds_contact"} <= keys
assert {"address", "incident_signs", "dds_primary", "dds_progress"} <= keys
assert "dds_contact" not in keys
assert score["score_auto"] < 100
finally:
control.__exit__(None, None, None)

View file

@ -1,13 +1,14 @@
"""Готовая карточка → учебный звонок бригаде → числовая оценка ДДС."""
import time
from datetime import datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.domain.timers import TimerCode
from app.main import app
from app.session.hub import hub
@ -37,16 +38,39 @@ def read_until(socket, wanted):
raise AssertionError(f"событие {wanted} не пришло")
def start(client, exercise="dds"):
def start(client, exercise="dds", criteria=None, dds_service=None, scenario_id="fire-apartment-l2"):
session_id = uuid4()
context = client.websocket_connect(f"/ws/control/{session_id}")
control = context.__enter__()
control.send_json({"type": "scenario.start", "scenario_id": "fire-apartment-l2",
"trainee": "Иванов", "mode": "training", "exercise": exercise})
payload = {"type": "scenario.start", "scenario_id": scenario_id,
"trainee": "Иванов", "mode": "training", "exercise": exercise}
if criteria is not None:
payload["criteria"] = criteria
if dds_service is not None:
payload["dds_service"] = dds_service
control.send_json(payload)
wait_for(lambda: hub.get(session_id))
return session_id, context
def complete_phone_call(station, state, expected_phase):
station.send_json({"type": "phone.dial"})
greeting = read_until(station, "phone.line")
assert greeting["speaker"] == "crew"
read_until(station, "station.state")
if expected_phase == "dispatched":
station.send_json({"type": "phone.brief", "address": state.dispatched_card.address,
"incident": state.scenario_title,
"request": "Прошу подтвердить выезд и сообщить о прибытии"})
else:
station.send_json({"type": "phone.check", "text": "Сообщите текущую обстановку по карточке"})
assert read_until(station, "phone.line")["speaker"] == "dispatcher"
assert read_until(station, "phone.line")["speaker"] == "crew"
report = read_until(station, "phone.report")
assert report["phase"] == expected_phase
return report
def test_dds_starts_with_prepared_card_without_call(client):
session_id, control = start(client)
try:
@ -62,10 +86,125 @@ def test_dds_starts_with_prepared_card_without_call(client):
assert card["card"]["address"] == "улица Ленина, 14"
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["crew_options"]
assert len(snapshot["services"]) == 1
assert snapshot["managed_service"] == snapshot["services"][0]
assert set(snapshot["services"]) < set(snapshot["recipient_services"])
finally:
control.__exit__(None, None, None)
def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container")
try:
state = hub.get(session_id)
assert state.kio.caller_name == "Сидоров Иван Сергеевич"
assert state.kio.caller_number == "916-126-34-71"
assert state.kio.caller_contact == "916-126-34-71"
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"]
assert card["caller_name"] == "Сидоров Иван Сергеевич"
assert card["caller_number"] == "916-126-34-71"
assert card["caller_contact"] == "916-126-34-71"
finally:
control.__exit__(None, None, None)
def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
session_id, control = start(client, scenario_id="t20-1-three-fighting")
try:
state = hub.get(session_id)
assert state.kio.caller_name is None
assert state.kio.caller_number == "916-123-98-78"
assert state.kio.caller_contact == "916-123-98-78"
finally:
control.__exit__(None, None, None)
def test_dds_can_change_only_its_own_service_status(client):
session_id, control = start(client, dds_service="МВД")
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["services"] == ["МВД"]
assert "Служба 101" in snapshot["recipient_services"]
station.send_json({
"type": "card.status", "service": "Служба 101", "status": "accepted",
})
assert "только своей ДДС" in read_until(station, "error")["message"]
station.send_json({
"type": "card.status", "service": "МВД", "status": "accepted",
})
assert read_until(station, "station.state")["snapshot"]["statuses"]["МВД"] == "accepted"
finally:
control.__exit__(None, None, None)
def test_instructor_criteria_change_timer_and_success_threshold(client):
session_id, control = start(client, criteria={
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
})
try:
state = hub.get(session_id)
assert state.criteria.decision_time_limit_seconds == 45
assert state.timers.limits[TimerCode.DDS_ACK] == 45_000
with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": card["card"]["card_id"],
"text": "Сообщение приняты, бригада направлено."})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
ack = next(item for item in score["metrics"] if item["key"] == "dds_ack")
assert ack["norm"] == "≤ 45 с"
assert ack["weight"] == 3.5
assert not any(item["key"] in {"dds_reply", "dds_grammar"} for item in score["metrics"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
"decision_time_limit_seconds": 45,
"allowed_errors": 50,
"require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5},
}
assert report["failed_metrics"] > 0
assert report["passed"] is True
finally:
control.__exit__(None, None, None)
def test_dds_statuses_do_not_require_phone_reports(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_progress" in keys
assert "dds_contact" not in keys and "dds_crew" not in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
finally:
control.__exit__(None, None, None)
def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -75,25 +214,35 @@ def test_dds_call_reports_gate_status_and_affect_numeric_score(client):
crew = next(option for option in snapshot["crew_options"] if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert "доклада" in read_until(station, "error")["message"]
station.send_json({"type": "crew.select", "crew": crew})
selected = read_until(station, "station.state")["snapshot"]
assert selected["crew_selected"] == crew
station.send_json({"type": "phone.dial"})
report = read_until(station, "phone.report")
assert report["phase"] == "dispatched" and report["crew"] == crew
assert read_until(station, "station.state")["snapshot"]["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
read_until(station, "station.state")
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
score = wait_for(lambda: hub.get(session_id).score)
keys = {metric["key"] for metric in score["metrics"]}
assert "dds_primary" in keys and "dds_contact" in keys
assert "answer_time" not in keys and "interview_time" not in keys
assert 0 < score["score_auto"] < 100
assert all(not finding["code"].startswith("E") for finding in score["findings"])
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.line")["speaker"] == "crew"
pending = read_until(station, "station.state")["snapshot"]
assert pending["phone_pending"]["phase"] == "dispatched"
assert not pending["phone_reports"]
station.send_json({"type": "card.status", "service": service, "status": "responding"})
assert read_until(station, "station.state")["snapshot"]["statuses"][service] == "responding"
station.send_json({"type": "phone.brief", "address": "другая улица, дом 99",
"incident": "Пожар в квартире",
"request": "Прошу направить бригаду"})
assert "адрес" in read_until(station, "error")["message"]
station.send_json({"type": "phone.brief", "address": "улица Ленина, 14",
"incident": "Ничего не произошло",
"request": "Прошу направить бригаду"})
assert "характер" in read_until(station, "error")["message"]
station.send_json({"type": "phone.hangup"})
assert read_until(station, "station.state")["snapshot"]["phone_pending"] is None
assert not hub.get(session_id).phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state")
assert len(hub.get(session_id).phone_reports) == 1
station.send_json({"type": "phone.dial"})
read_until(station, "phone.line")
read_until(station, "station.state")
station.send_json({"type": "phone.check", "text": "Здравствуйте, хорошая погода"})
assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1
finally:
control.__exit__(None, None, None)
@ -108,26 +257,16 @@ def test_default_exercise_remains_call(client):
assert state.dispatched_card is None
def test_complete_dds_workflow_scores_without_call_penalties(client):
def test_complete_dds_workflow_scores_100_without_any_call(client):
session_id, control = start(client)
try:
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
for service in snapshot["services"]:
crew = next(option for option in snapshot["crew_options"]
if option.startswith(service + " — "))
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state")
for expected_phase, status in (
("dispatched", "responding"), ("arrived", "arrived"),
("working", "working"), ("completed", "completed"),
):
station.send_json({"type": "phone.dial"})
assert read_until(station, "phone.report")["phase"] == expected_phase
read_until(station, "station.state")
for status in ("responding", "arrived", "working", "completed"):
station.send_json({"type": "card.status", "service": service, "status": status})
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card"] == "completed"
@ -137,11 +276,13 @@ def test_complete_dds_workflow_scores_without_call_penalties(client):
assert score["score_auto"] == 100.0
assert not score["findings"]
assert all(metric["key"].startswith("dds_") for metric in score["metrics"])
assert not any(metric["key"] in {"dds_contact", "dds_crew", "dds_reply", "dds_grammar"}
for metric in score["metrics"])
finally:
control.__exit__(None, None, None)
def test_sequential_dds_cards_keep_separate_state_and_scores(client):
def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@ -157,37 +298,53 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
first_card_id = first["card"]["card_id"]
assert (first["card_index"], first["card_total"]) == (1, 2)
snapshot = read_until(station, "station.state")["snapshot"]
service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": service, "status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Сообщение принято, дежурная бригада направлена на место."})
assert read_until(station, "station.state")["snapshot"]["reply_text"].startswith("Сообщение")
station.send_json({"type": "card.next", "card_id": first_card_id})
assert len(snapshot["queue_cards"]) == 2
first_row, second_row = snapshot["queue_cards"]
second_card_id = second_row["card_id"]
assert first_row["active"] is True and second_row["active"] is False
assert abs(
datetime.fromisoformat(first_row["received_at"]).timestamp()
- datetime.fromisoformat(second_row["received_at"]).timestamp()
) < 1
first_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": first_service,
"status": "accepted"})
snapshot = read_until(station, "station.state")["snapshot"]
first_elapsed = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)["elapsed_ms"]
time.sleep(0.03)
# Card switching must publish its own fresh station snapshot; do
# not let the one-second ticker mask a missing update.
hub.stop_ticker(session_id)
station.send_json({"type": "card.open", "card_id": second_card_id})
second = read_until(station, "card.received")
second_card_id = second["card"]["card_id"]
assert second_card_id != first_card_id
assert second["card"]["card_id"] == second_card_id
assert second["card"]["incident_type"] == "medical"
assert (second["card_index"], second["card_total"]) == (2, 2)
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["reply_text"] == ""
assert snapshot["statuses"].get(service) == "added" or service not in snapshot["statuses"]
assert len(snapshot["completed_cards"]) == 1
assert snapshot["completed_cards"][0]["card_id"] == first_card_id
station.send_json({"type": "card.reply", "card_id": first_card_id,
"text": "Запоздалый ответ к прошлой карточке"})
assert "не к текущей" in read_until(station, "error")["message"]
assert hub.get(session_id).reply_text == ""
station.send_json({"type": "card.next", "card_id": first_card_id})
assert "ID" in read_until(station, "error")["message"]
assert hub.get(session_id).dds_card_index == 1
second_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == second_card_id)
assert second_queue_row["active"] is True
assert second_queue_row["elapsed_ms"] >= first_elapsed
first_queue_row = next(item for item in snapshot["queue_cards"]
if item["card_id"] == first_card_id)
assert first_queue_row["service_status"] == "accepted"
assert first_queue_row["timer_stopped"] is True
with client.websocket_connect(f"/ws/station/{session_id}") as station:
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
assert read_until(station, "station.state")["snapshot"]["card_index"] == 2
station.send_json({"type": "card.reply", "card_id": second_card_id,
"text": "Сообщение принято, бригада направлена на место происшествия."})
second_service = snapshot["services"][0]
station.send_json({"type": "card.status", "service": second_service,
"status": "accepted"})
read_until(station, "station.state")
station.send_json({"type": "card.open", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == first_card_id
restored = read_until(station, "station.state")["snapshot"]
assert restored["statuses"][first_service] == "accepted"
station.send_json({"type": "card.next", "card_id": first_card_id})
assert read_until(station, "card.received")["card"]["card_id"] == second_card_id
after_close = read_until(station, "station.state")["snapshot"]
assert len(after_close["queue_cards"]) == 1
assert after_close["statuses"][second_service] == "accepted"
station.send_json({"type": "card.next", "card_id": second_card_id})
read_until(station, "score.ready")
@ -196,20 +353,22 @@ def test_sequential_dds_cards_keep_separate_state_and_scores(client):
assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
assert {item["key"] for item in state.score["metrics"]} >= {"dds_reply", "dds_primary"}
assert "dds_primary" in {item["key"] for item in state.score["metrics"]}
assert not any(item["key"] in {"dds_reply", "dds_grammar"}
for item in state.score["metrics"])
assert all(item["code"].startswith("D") for item in state.score["findings"])
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
assert report["missed_checklist"] == [] and report["reference_questions"] == []
first_actions = report["card_results"][0]["actions"]
assert {item["type"] for item in first_actions} >= {"card.status", "card.reply"}
assert {item["type"] for item in first_actions} >= {"card.status"}
assert report["card_results"][0]["duration_ms"] >= 0
finally:
control_ctx.__exit__(None, None, None)
def test_instructor_can_end_multi_card_early_without_grading_future_cards(client):
def test_instructor_end_grades_all_concurrently_issued_cards(client):
session_id = uuid4()
control_ctx = client.websocket_connect(f"/ws/control/{session_id}")
control = control_ctx.__enter__()
@ -227,8 +386,10 @@ def test_instructor_can_end_multi_card_early_without_grading_future_cards(client
read_until(station, "score.ready")
state = hub.get(session_id)
assert state.ended
assert len(state.score["card_results"]) == 1
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert len(state.score["card_results"]) == 2
assert [item["scenario_id"] for item in state.score["card_results"]] == [
"fire-apartment-l2", "t20-2-stroke",
]
finally:
control_ctx.__exit__(None, None, None)
@ -246,8 +407,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
base = load_file(root / "fire-apartment-l2.yaml", root)
first = base.model_copy(deep=True)
second = base.model_copy(deep=True)
first.score_weights = {"dds_reply": 7.0}
second.score_weights = {"dds_reply": 2.0}
first.score_weights = {"dds_primary": 7.0}
second.score_weights = {"dds_primary": 2.0}
state = SessionState(
session_id=uuid4(), scenario_id=base.id, scenario_title=base.title,
level=base.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
@ -258,5 +419,5 @@ def test_each_dds_card_uses_its_own_scenario_weights():
state.dds_card_index = 1
prepare_card(state, second)
second_record = score_current_dds(state)
assert next(item.weight for item in first_record.metrics if item.key == "dds_reply") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_reply") == 2.0
assert next(item.weight for item in first_record.metrics if item.key == "dds_primary") == 7.0
assert next(item.weight for item in second_record.metrics if item.key == "dds_primary") == 2.0

View file

@ -50,7 +50,8 @@ def test_demo_without_db_starts_dds_and_issues_owned_trainee_cookie(monkeypatch)
assert client.post("/api/auth/dev-token?role=trainee").json()["trainee_id"] == str(DEMO_TRAINEE_ID)
assert client.post("/api/auth/dev-token").json()["role"] == "instructor"
assert client.get("/api/trainees").json() == [
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None}
{"id": str(DEMO_TRAINEE_ID), "name": "Демо-курсант", "group": None,
"service": "Служба 101"}
]
# БД-зависимые экраны получают быстрый и явный отказ, не ждут TCP timeout.
assert client.get("/api/sessions").json()["detail"] == "database_disabled_demo"

View file

@ -30,10 +30,32 @@ def test_all_customer_ticket_cards_are_complete_and_classified():
for ticket in range(1, 33):
assert {scenario.position for scenario in cards if scenario.ticket == ticket} == {1, 2, 3}
assert all(scenario.facts and scenario.signs and scenario.ground_truth.address
and scenario.ground_truth.victims is not None
and scenario.ground_truth.incident_code
and ekp.incident(scenario.ground_truth.incident_code)
for scenario in cards)
unknown_victim_counts = {scenario.id for scenario in cards
if scenario.ground_truth.victims is None}
assert unknown_victim_counts == {
"t02-2-megafon-consultation", "t02-3-car-in-water", "t03-2-loud-music",
"t04-1-balcony-fire", "t04-3-open-door", "t12-3-men-on-bridge-rail",
"t07-3-lost-elderly", "t08-1-mall-smoke", "t14-1-grass-fire-azs", "t17-1-fire-alarm",
"t12-1-restaurant-smoke", "t30-3-gas-smell-house", "t31-3-gas-pipe-whistle",
"t11-3-lost-in-forest", "t16-1-smoke-column",
"t18-1-unknown-fire", "t24-1-parking-quarrel", "t25-1-drunk-at-stop",
"t23-3-lost-child", "t27-1-suspicious-car", "t28-1-stranger-at-door", "t29-1-ticking-box",
"t29-3-threat-to-blow-up", "t30-1-car-theft-yesterday",
"t31-1-car-theft-witnessed", "t32-1-carjacking", "t32-3-street-lights",
}
def test_unknown_ticket_victim_count_is_not_scored():
from app.scoring.card import evaluate_card
cards = [scenario for scenario in load_library(Path(__file__).resolve().parents[2] / "scenarios")
if scenario.ticket is not None and scenario.ground_truth.victims is None]
assert len(cards) == 27
assert all("victims_count" not in {metric.key for metric in evaluate_card(scenario, KIO()).metrics}
for scenario in cards)
def test_signs_give_the_code_from_the_book():

View file

@ -0,0 +1,67 @@
"""ИИ-инсайт получает только агрегаты и обязан вернуть строгий JSON."""
import json
import pytest
from app.config import get_settings
from app.scoring import group_insight
@pytest.fixture(autouse=True)
def local_settings(monkeypatch):
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_BASE_URL", "http://127.0.0.1:18080/v1")
get_settings.cache_clear()
yield
get_settings.cache_clear()
@pytest.mark.asyncio
async def test_insight_uses_only_aggregates_and_strict_schema(monkeypatch):
seen = []
class FakeClient:
def __init__(self, **_kwargs): pass
async def complete(self, request, **_kwargs):
seen.append(request)
return json.dumps({
"summary": "Группа чаще всего теряет обязательные сведения при опросе.",
"priorities": ["Отработать уточнение адреса и этажа."],
}, ensure_ascii=False)
async def aclose(self): pass
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
result = await group_insight.generate_group_insight({
"active_trainees": 4,
"scored_attempts": 7,
"average_score": 76.2,
"errors": [{
"code": "E2", "title": "Пропущен обязательный факт",
"rate_percent": 50.0, "recommendation": "Повторить алгоритм опроса",
}],
})
assert result["priorities"] == ["Отработать уточнение адреса и этажа."]
request = seen[0]
assert request.temperature == 0
assert request.response_format["schema"]["additionalProperties"] is False
payload = json.loads(request.messages[1]["content"])
assert set(payload) == {"active_trainees", "scored_attempts", "average_score", "typical_errors"}
assert "trainee_id" not in json.dumps(payload)
assert "group" not in json.dumps(payload)
@pytest.mark.asyncio
async def test_insight_rejects_wrong_shape(monkeypatch):
class FakeClient:
def __init__(self, **_kwargs): pass
async def complete(self, *_args, **_kwargs):
return '{"summary":"слишком кратко","priorities":[],"secret":"x"}'
async def aclose(self): pass
monkeypatch.setattr(group_insight, "LlmClient", FakeClient)
with pytest.raises(group_insight.InsightInvalid):
await group_insight.generate_group_insight({
"active_trainees": 1, "scored_attempts": 1, "average_score": 50,
"errors": [],
})

View file

@ -0,0 +1,276 @@
"""Локальная маршрутизация моделей не должна зависеть от сети или весов."""
import json
import httpx
import numpy as np
import pytest
from app.config import get_settings
from app.dialog.caller import LlmCaller, _allowed_reply
from app.dialog.factory import build_caller
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, _spoken_content, is_loopback_url
from app.dialog.persona import PersonaState
from app.scoring.grammar import assess, basic_check
from app.dialog.slots import SlotMachine
from app.voice.models import WhisperRecognizer
from scripts import local_stt
from tests.test_slots import SCENARIO, StemEmbedder
@pytest.fixture(autouse=True)
def clear_settings():
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_offline_model_address_must_be_literal_loopback():
assert is_loopback_url("http://127.0.0.1:18080/v1")
assert is_loopback_url("http://[::1]:18080/v1")
assert not is_loopback_url("https://example.com/v1")
assert not is_loopback_url("http://localhost.evil.test:18080/v1")
assert not is_loopback_url("http://127.0.0.1.evil.test:18080/v1")
assert not is_loopback_url("http://user:password@127.0.0.1:18080/v1")
assert not is_loopback_url("http://host.docker.internal:18080/v1")
assert is_loopback_url(
"http://host.docker.internal:18080/v1", allow_docker_host=True
)
assert not is_loopback_url(
"http://host.docker.internal.evil.test:18080/v1", allow_docker_host=True
)
@pytest.mark.asyncio
async def test_local_llm_uses_loopback_without_api_key(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
monkeypatch.setenv("LLM_API_KEY", "")
requests = []
def answer(request):
requests.append(request)
return httpx.Response(200, json={"choices": [{"message": {"content": "Алло, помогите!"}}]})
client = LlmClient(transport=httpx.MockTransport(answer))
try:
text = await client.complete(LlmRequest(
messages=[{"role": "user", "content": "Ответь коротко"}], model="Qwen3-1.7B"
), use_cache=False)
finally:
await client.aclose()
assert text == "Алло, помогите!"
assert str(requests[0].url) == "http://127.0.0.1:18080/v1/chat/completions"
assert "authorization" not in requests[0].headers
@pytest.mark.asyncio
async def test_local_llm_passes_strict_response_format(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
seen = []
def answer(request):
seen.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": {"content": '{"value":"ok"}'}}]})
schema = {"type": "json_object", "schema": {"type": "object", "properties": {
"value": {"type": "string"}}, "required": ["value"], "additionalProperties": False}}
client = LlmClient(transport=httpx.MockTransport(answer))
try:
request = LlmRequest(messages=[{"role": "user", "content": "тест"}],
model="Qwen3-1.7B", response_format=schema)
assert await client.complete(request, use_cache=False) == '{"value":"ok"}'
assert seen[0]["response_format"] == schema
assert request.cache_key() != LlmRequest(messages=request.messages,
model=request.model).cache_key()
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_offline_never_uses_remote_llm(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "openai_compatible")
client = LlmClient(base_url="https://example.com/v1",
transport=httpx.MockTransport(lambda _: pytest.fail("внешний запрос")))
try:
with pytest.raises(LlmUnavailable):
await client.complete(LlmRequest(messages=[], model="x"), use_cache=False)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_malformed_local_answer_falls_back_instead_of_crashing(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(200, json={"choices": []})
))
try:
with pytest.raises(LlmUnavailable, match="некорректный ответ"):
await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False)
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_qwen_disabled_thinking_closing_marker_is_not_spoken(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
client = LlmClient(transport=httpx.MockTransport(
lambda _: httpx.Response(200, json={"choices": [{"message": {
"content": "</think>\n\nгорит балкон"}}]})
))
try:
assert await client.complete(LlmRequest(messages=[], model="Qwen3-1.7B"), use_cache=False) == "горит балкон"
finally:
await client.aclose()
@pytest.mark.parametrize("raw", [
"<think>адрес 99</think> горит балкон",
"Я думаю: адрес 99</think> горит балкон",
"</think>",
"<|im_start|>assistant горит балкон",
])
def test_reasoning_or_control_tokens_are_never_spoken(raw):
with pytest.raises(LlmUnavailable):
_spoken_content(raw)
def test_internal_structured_task_can_discard_closed_reasoning_block():
raw = '<think>Сначала выберу поля.</think>\n{"title":"Учебный пожар"}'
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
raw = 'Сначала выберу поля.\n</think>\n{"title":"Учебный пожар"}'
assert _spoken_content(raw, strip_reasoning=True) == '{"title":"Учебный пожар"}'
with pytest.raises(LlmUnavailable):
_spoken_content("<think>незакрытое рассуждение", strip_reasoning=True)
def test_model_mode_selects_qwen_or_vikhr(monkeypatch):
monkeypatch.setenv("OFFLINE", "true")
monkeypatch.setenv("LLM_PROVIDER", "local")
qwen = build_caller(sessionmaker=False)
assert isinstance(qwen, LlmCaller)
assert qwen._model == "Qwen3-1.7B"
assert qwen._client._base_url == "http://127.0.0.1:18080/v1"
monkeypatch.setenv("DIALOGUE_MODEL_MODE", "russian_control")
get_settings.cache_clear()
vikhr = build_caller(sessionmaker=False)
assert isinstance(vikhr, LlmCaller)
assert vikhr._model == "Vikhr-1B"
assert vikhr._client._base_url == "http://127.0.0.1:18081/v1"
def test_hard_protocol_rejects_unrevealed_addresses_and_numbers():
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
slots.hear("Что именно горит?")
allowed = {fact.id: fact.value for fact in slots.revealed_facts()}
assert _allowed_reply("Горит балкон!", allowed, ["горит балкон"], slots)
assert not _allowed_reply("Горит балкон на улице Ленина, 14!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Кажется, там двое, горит балкон!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Горит балкон, муж курил!", allowed,
["горит балкон"], slots)
assert not _allowed_reply("Помогите!", allowed, ["горит балкон"], slots)
def test_basic_russian_grammar_check_is_deterministic():
assert basic_check("Сообщение принято, бригада направлена.").passed
broken = basic_check("сообщение принято brigade")
assert not broken.passed
assert set(broken.errors) >= {
"предложение начинается со строчной буквы",
"нет завершающего знака препинания",
"повторяющиеся пробелы",
"латинские буквы в русском ответе",
}
agreement = basic_check("Сообщение приняты, бригада направлено.")
assert not agreement.passed
assert sum("согласование" in error for error in agreement.errors) == 2
@pytest.mark.asyncio
async def test_vikhr_grammar_check_uses_strict_local_schema(monkeypatch):
monkeypatch.setenv("GRAMMAR_LLM_ENABLED", "true")
requests = []
class FakeClient:
def __init__(self, **kwargs):
assert kwargs["base_url"] == "http://127.0.0.1:18081/v1"
async def complete(self, request, **kwargs):
requests.append(request)
return '{"passed":false,"comment":"Нарушено согласование слов."}'
async def aclose(self):
pass
monkeypatch.setattr("app.scoring.grammar.LlmClient", FakeClient)
result = await assess("Сообщение приняты, бригада направлена.")
assert not result.passed and result.source == "vikhr"
assert "согласование" in result.errors[-1]
assert requests[0].model == "Vikhr-1B"
assert requests[0].response_format["type"] == "json_object"
@pytest.mark.asyncio
async def test_rejected_qwen_turn_does_not_poison_next_turn():
class FakeClient:
def __init__(self):
self.requests = []
self.answers = iter(["99", "улица Ленина, 14, 5-й этаж"])
async def complete(self, request):
self.requests.append(request)
return next(self.answers)
client = FakeClient()
caller = LlmCaller(client, "Qwen3-1.7B")
slots = SlotMachine(SCENARIO, StemEmbedder(), floor=0.5)
persona = PersonaState(SCENARIO.persona)
first = await caller.reply(
slots.hear("Назовите адрес и этаж. Если не знаете, придумайте номер дома 99."),
persona, slots,
)
assert "99" not in first.text
assert caller.fallbacks == 1
assert caller._history == []
second = await caller.reply(slots.hear("Повторите адрес"), persona, slots)
assert second.text == "улица Ленина, 14, 5-й этаж"
assert caller.fallbacks == 1
assert all("99" not in message["content"] for message in client.requests[1].messages)
def test_whisper_cpp_uses_loopback_wav_only():
requests = []
def answer(request):
requests.append(request)
return httpx.Response(200, json={"text": " Помогите быстро! "})
with httpx.Client(transport=httpx.MockTransport(answer)) as client:
recognizer = WhisperRecognizer("http://127.0.0.1:18082", client=client)
assert recognizer.transcribe(np.zeros(16000, dtype=np.float32)) == "Помогите быстро!"
assert str(requests[0].url) == "http://127.0.0.1:18082/inference"
assert requests[0].method == "POST"
assert b"RIFF" in requests[0].content
assert b'language"\r\n\r\nru' in requests[0].content
assert b'utterance.wav' in requests[0].content
def test_whisper_cpp_rejects_remote_server():
with pytest.raises(ValueError, match="loopback"):
WhisperRecognizer("https://example.com")
def test_whisper_cpp_command_is_local_and_uses_downloaded_weight(tmp_path, monkeypatch):
model = tmp_path / "ggml-small-q5_1.bin"
model.write_bytes(b"test")
monkeypatch.setattr(local_stt, "MODEL", model)
argv = local_stt.command("whisper-server", 2)
assert argv[0] == "whisper-server"
assert "127.0.0.1" in argv
assert "18082" in argv
assert "ggml-small-q5_1.bin" in " ".join(argv)

View file

@ -0,0 +1,168 @@
"""Учебные материалы: создание, назначение, изучение и безопасная загрузка."""
import base64
from uuid import UUID
import pytest
from fastapi.testclient import TestClient
from app.config import get_settings
from app.main import app
from app.session.hub import hub
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
try:
with TestClient(app) as test_client:
yield test_client
finally:
get_settings.cache_clear()
def _instructor(client: TestClient) -> None:
response = client.post("/api/auth/dev-token")
assert response.status_code == 200
def _trainee(client: TestClient) -> None:
client.post("/api/auth/logout")
response = client.post(
"/api/auth/login", json={"login": "demo-trainee", "password": "demo"}
)
assert response.status_code == 200
def test_teacher_creates_assigns_and_trainee_completes_text_material(client):
_instructor(client)
created = client.post("/api/materials", json={
"title": "Порядок доклада старшему",
"description": "Перед практическим занятием",
"level": "L2",
"kind": "text",
"body": "Передайте адрес, тип события, задачу и подтвердите выезд.",
"scenario_id": "fire-apartment-l2",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
assert created.json()["assignment_count"] == 0
assigned = client.put(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
)
assert assigned.status_code == 200, assigned.text
assert assigned.json()["assigned_at"]
_trainee(client)
listing = client.get("/api/materials")
assert listing.status_code == 200
item = next(item for item in listing.json() if item["id"] == material_id)
assert item["body"].startswith("Передайте адрес")
assert item["completed_at"] is None
completed = client.post(f"/api/materials/{material_id}/complete")
assert completed.status_code == 200
assert completed.json()["completed_at"]
assert client.post("/api/materials", json={
"title": "Нельзя создать", "kind": "text", "body": "запрещено",
}).status_code == 403
def test_teacher_cannot_edit_archive_or_assign_another_teachers_material(client, monkeypatch):
from app.api.auth import Principal
from app.api.http import materials as materials_api
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(materials_api, "require", instructor)
created = client.post("/api/materials", json={
"title": "Личный материал",
"level": "L1",
"kind": "text",
"body": "Учебный текст.",
})
assert created.status_code == 201, created.text
material_id = created.json()["id"]
identity["login"] = "teacher-two"
assert client.patch(f"/api/materials/{material_id}", json={"title": "Подмена"}).status_code == 404
assert client.delete(f"/api/materials/{material_id}").status_code == 404
assert client.put(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
).status_code == 404
assert client.delete(
f"/api/materials/{material_id}/assign/00000000-0000-4000-8000-000000000112"
).status_code == 404
def test_uploaded_file_is_limited_sanitized_and_downloaded_as_attachment(client):
_instructor(client)
content = b"local training resource\n"
created = client.post("/api/materials", json={
"title": "Локальная памятка PDF",
"kind": "file",
"file_name": "C:\\Users\\teacher\\guide.txt",
"media_type": "text/plain",
"content_base64": base64.b64encode(content).decode(),
})
assert created.status_code == 201, created.text
data = created.json()
assert data["file_name"] == "guide.txt"
assert data["file_size"] == len(content)
assert len(data["file_sha256"]) == 64
downloaded = client.get(f"/api/materials/{data['id']}/download")
assert downloaded.status_code == 200
assert downloaded.content == content
assert downloaded.headers["x-content-type-options"] == "nosniff"
assert downloaded.headers["content-disposition"].startswith("attachment")
def test_unassigned_trainee_cannot_download_resource(client):
_instructor(client)
created = client.post("/api/materials", json={
"title": "Закрытый ресурс",
"kind": "file",
"file_name": "private.pdf",
"media_type": "application/pdf",
"content_base64": base64.b64encode(b"%PDF-demo").decode(),
})
material_id = created.json()["id"]
_trainee(client)
assert client.get(f"/api/materials/{material_id}/download").status_code == 403
def test_archive_hides_material_from_trainee_but_keeps_record(client):
_instructor(client)
seeded = client.get("/api/materials").json()[0]
archived = client.delete(f"/api/materials/{seeded['id']}")
assert archived.status_code == 200
assert archived.json()["active"] is False
assert client.get("/api/materials").json() == []
archived_list = client.get("/api/materials?include_archived=true").json()
assert len(archived_list) == 1 and archived_list[0]["active"] is False
_trainee(client)
assert client.get("/api/materials").json() == []
def test_trainee_starts_assigned_practice_in_self_mode(client):
_trainee(client)
seeded = client.get("/api/materials").json()[0]
started = client.post(f"/api/materials/{seeded['id']}/start")
assert started.status_code == 200, started.text
payload = started.json()
assert payload["mode"] == "self"
assert payload["exercise"] == "card"
assert payload["path"].startswith("/trainee?session=")
state = hub.get(UUID(payload["session_id"]))
assert state is not None
assert state.trainee_id.hex == "00000000000040008000000000000112"
hub.drop(state.session_id)

View file

@ -0,0 +1,69 @@
"""WAV-запись вызова: формат, микширование и защищённая выдача."""
import wave
from datetime import datetime, timezone
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.roles import Role
from app.main import app
from app.voice.recording import CallRecorder
def test_recorder_mixes_16_and_24_khz_into_atomic_wav(tmp_path):
ticks = iter((10.0, 10.0, 10.02))
path = tmp_path / "call.wav"
recorder = CallRecorder(path, clock=lambda: next(ticks))
recorder.add_pcm(np.full(320, 1000, dtype="<i2").tobytes(), sample_rate=16_000)
recorder.add_pcm(np.full(480, 2000, dtype="<i2").tobytes(), sample_rate=24_000)
assert recorder.finalize() == path
assert recorder.finalize() == path
assert not path.with_suffix(".wav.tmp").exists()
with wave.open(str(path), "rb") as source:
assert source.getnchannels() == 1
assert source.getsampwidth() == 2
assert source.getframerate() == 16_000
assert source.getnframes() >= 640
samples = np.frombuffer(source.readframes(source.getnframes()), dtype="<i2")
assert samples.max() >= 2000
def test_recording_download_is_authenticated_and_has_wav_type(tmp_path, monkeypatch):
session_id = uuid4()
path = tmp_path / f"{session_id}.wav"
with wave.open(str(path), "wb") as target:
target.setnchannels(1)
target.setsampwidth(2)
target.setframerate(16_000)
target.writeframes(b"\x00\x00" * 320)
async def fake_session(db, requested):
assert requested == session_id
return SimpleNamespace(
trainee_id=uuid4(), owner_login="dev", ended_at=datetime.now(timezone.utc),
)
monkeypatch.setattr(sessions.repo, "get_session", fake_session)
monkeypatch.setattr(sessions, "recording_path", lambda requested: path)
with TestClient(app) as client:
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 401
client.post("/api/auth/dev-token")
response = client.get(f"/api/sessions/{session_id}/recording.wav")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/wav"
assert response.content.startswith(b"RIFF")
monkeypatch.setattr(
sessions,
"require",
lambda request, *roles: Principal(
login="other", full_name="Другой", role=Role.TRAINEE, trainee_id=uuid4()
),
)
assert client.get(f"/api/sessions/{session_id}/recording.wav").status_code == 403

View file

@ -1,5 +1,6 @@
"""Экспорт разбора: содержимое, безопасность CSV, кириллица и доступ."""
import asyncio
import csv
import io
from datetime import datetime, timezone
@ -8,13 +9,14 @@ from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from fastapi import HTTPException
from app.api.auth import Principal
from app.api.http import sessions
from app.domain.events import SessionReport
from app.domain.roles import Role
from app.main import app
from app.scoring.export import _cell, to_csv, to_pdf
from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
def sample_report(*, long: bool = False) -> SessionReport:
@ -25,6 +27,10 @@ def sample_report(*, long: bool = False) -> SessionReport:
"scenario_id": "=1+1",
"mode": "training",
"attempt": 2,
"criteria": {"decision_time_limit_seconds": 45, "allowed_errors": 1,
"require_correct_grammar": True},
"failed_metrics": 1,
"passed": True,
"transcript": [
{"ref": f"u{i}", "speaker": "caller", "text": long_text if i == 0 else "<вопрос> \t=cmd", "at": at}
for i in range(18 if long else 2)
@ -82,10 +88,33 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Заявитель сообщает о дыме" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):
data = certificate_pdf(
trainee_name="Петров Пётр Сергеевич",
trainee_id=uuid4(),
group_name="ДДС-17",
attempts=4,
average_score=87.25,
issued_at="2026-09-23",
)
assert data.startswith(b"%PDF-")
path = tmp_path / "certificate.pdf"
path.write_bytes(data)
import shutil
import subprocess
if shutil.which("pdftotext"):
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
assert "СЕРТИФИКАТ" in extracted
assert "Петров Пётр Сергеевич" in extracted
assert "87.2 из 100" in extracted
assert "не заменяет квалификационный документ" in extracted
@pytest.fixture
def client(monkeypatch):
report = sample_report()
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4())
state = SimpleNamespace(score={"score_auto": 70}, trainee_id=uuid4(), owner_login="dev")
monkeypatch.setattr(sessions, "_live", lambda session_id: (state, object()))
monkeypatch.setattr(sessions, "build_report", lambda session_id, state, scenario: report)
with TestClient(app) as test_client:
@ -119,3 +148,101 @@ def test_trainee_cannot_export_another_persons_report(client, monkeypatch):
)
for suffix in ("csv", "pdf"):
assert browser.get(f"/api/sessions/{report.session_id}/report.{suffix}").status_code == 403
def test_archived_report_survives_missing_live_session(monkeypatch):
archived = sample_report()
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
score_row = SimpleNamespace(
score_auto=70.0,
score_final=82.0,
overridden_by="Преподаватель",
override_comment="проверено после занятия",
report={"full_report": archived.model_dump(mode="json")},
)
class FakeDb:
async def get(self, model, key):
return session_row
async def scalar(self, statement):
return score_row
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
HTTPException(status_code=404, detail="session_not_found")
))
monkeypatch.setattr(
sessions, "require",
lambda request: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
restored = asyncio.run(sessions._report_data(archived.session_id, object(), FakeDb()))
assert restored.session_id == archived.session_id
assert restored.score_auto == 70
assert restored.score_final == 82
assert restored.override_comment == "проверено после занятия"
def test_archived_score_override_updates_json_exports_and_audit(monkeypatch):
archived = sample_report()
session_row = SimpleNamespace(id=archived.session_id, trainee_id=uuid4(), owner_login="teacher")
score_row = SimpleNamespace(
score_auto=70.0,
score_final=70.0,
overridden_by=None,
override_comment=None,
report={"full_report": archived.model_dump(mode="json")},
)
class FakeDb:
def __init__(self):
self.added = []
self.commits = 0
async def get(self, model, key):
assert key == archived.session_id
return session_row
async def scalar(self, statement):
return score_row
def add(self, row):
self.added.append(row)
async def commit(self):
self.commits += 1
db = FakeDb()
monkeypatch.setattr(sessions.hub, "get", lambda session_id: None)
monkeypatch.setattr(sessions, "_live", lambda session_id: (_ for _ in ()).throw(
HTTPException(status_code=404, detail="session_not_found")
))
monkeypatch.setattr(
sessions, "require",
lambda request, *roles: Principal(login="teacher", full_name="Преподаватель", role=Role.INSTRUCTOR),
)
corrected = asyncio.run(sessions.override(
archived.session_id,
sessions.ScoreOverride(score_final=84.5, comment="проверена запись переговоров"),
object(),
db,
))
assert db.commits == 1, "оценка и аудит должны фиксироваться одной транзакцией"
assert score_row.score_auto == 70.0
assert score_row.score_final == 84.5
assert score_row.overridden_by == "teacher"
assert score_row.report["full_report"]["score_final"] == 84.5
assert corrected.score_auto == 70.0 and corrected.score_final == 84.5
assert corrected.override_comment == "проверена запись переговоров"
audit = db.added[0]
assert audit.action == "score.override" and audit.actor == "teacher"
assert "84.5" in audit.detail and "проверена запись переговоров" in audit.detail
report = asyncio.run(sessions.report(archived.session_id, object(), db))
assert report.score_final == 84.5 and report.score_auto == 70.0
csv_response = asyncio.run(sessions.report_csv(archived.session_id, object(), db))
assert "84.5" in csv_response.body.decode("utf-8-sig")
assert "проверена запись переговоров" in csv_response.body.decode("utf-8-sig")
pdf_response = asyncio.run(sessions.report_pdf(archived.session_id, object(), db))
assert pdf_response.body.startswith(b"%PDF-")

View file

@ -6,10 +6,15 @@ import pytest
from fastapi.testclient import TestClient
from app.api.http import scenarios as scenarios_api
from app.db.base import get_session
from app.config import get_settings
from app.dialog.llm import LlmUnavailable
from app.main import app
from app.scenarios import store
from app.scenarios.editor import merge_patch, template_copy, validate
from app.scenarios import generation
from app.scenarios.generation import (GenerationError, correction_target,
full_proposal_body, parse_full_proposal,
parse_proposal, proposal_body, style_fallback)
from app.scenarios.loader import ScenarioError, load_file
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
@ -31,6 +36,13 @@ class FakeSession:
pass
async def scalars(self, query):
expression = query.column_descriptions[0]["expr"]
if getattr(expression, "key", None) == "id":
owner_login = query.compile().params.get("owner_login_1")
return [
row.id for row in self.rows.values()
if row.status == "published" and row.owner_login == owner_login
]
return [row for row in self.rows.values() if row.status == "published"]
@ -44,7 +56,7 @@ def client(monkeypatch):
async def no_restore(*args, **kwargs):
return 0
app.dependency_overrides[get_session] = lambda: db
app.dependency_overrides[scenarios_api.scenario_session] = lambda: db
monkeypatch.setattr(scenarios_api, "audit", no_audit)
monkeypatch.setattr(store, "restore_published", no_restore)
with TestClient(app) as test_client:
@ -75,6 +87,236 @@ def test_editor_rejects_derived_truth_and_missing_fact():
validate(broken)
def test_ai_proposal_changes_only_story_and_keeps_reference():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
proposal = parse_proposal('''```json
{"title":"Ночной пожар на балконе","first_line":"Алло, помогите, балкон горит и всё в дыму!",
"facts":{"f_what_burns":"на балконе открытое пламя","f_smoke":"дым заполнил подъезд"}}
```''', source)
body = proposal_body(source, "ai-example", proposal)
generated = validate(body)
assert generated.facts[0].value == source.facts[0].value
assert generated.ground_truth.address == source.ground_truth.address
assert generated.ground_truth.victims == source.ground_truth.victims
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
assert generated.facts[1].value == "на балконе открытое пламя"
def test_ai_proposal_rejects_address_change_and_premature_number():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
with pytest.raises(GenerationError, match="защищённые"):
parse_proposal('{"title":"Другой пожар","first_line":"Алло, помогите, у нас горит балкон!",'
'"facts":{"f_address":"другой адрес"}}', source)
with pytest.raises(GenerationError, match="числовые"):
parse_proposal('{"title":"Другой пожар","first_line":"Горит дом 99, срочно приезжайте!",'
'"facts":{}}', source)
with pytest.raises(GenerationError, match="числовые"):
parse_proposal('{"title":"Другой пожар","first_line":"Один человек пострадал, помогите!",'
'"facts":{}}', source)
with pytest.raises(GenerationError, match="вопрос вместо ответа"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{"f_smoke":"Куда идёт дым?"}}', source)
with pytest.raises(GenerationError, match="плейсхолдер"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{"f_smoke":"{f_smoke}"}}', source)
with pytest.raises(GenerationError, match="новых обстоятельств"):
parse_proposal('{"title":"Другой пожар","first_line":"Помогите, на балконе пожар!",'
'"facts":{}}', source)
revised = parse_proposal(
'{"title":"Пожар — короткая реплика","first_line":"Пожар! Помогите!","facts":{}}',
source, require_fact_change=False, instruction="Сделай реплику короче",
restrict_first_line_facts=True,
)
assert revised["first_line"] == "Пожар! Помогите!"
with pytest.raises(GenerationError, match="не запрошенные"):
parse_proposal(
'{"title":"Пожар — короткая реплика",'
'"first_line":"Горим! Не успел перекрыть газ!","facts":{}}',
source, require_fact_change=False, instruction="Сделай реплику короче",
restrict_first_line_facts=True,
)
def test_revision_target_is_selected_only_when_comment_names_a_fact():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
assert correction_target(source, "Сделай дым чёрным и плотным") == "f_smoke"
assert correction_target(source, "Сделай первую реплику короче и тревожнее") is None
shortened = style_fallback(source, "Сделай первую реплику короче")
assert shortened["first_line"] == "Помогите! Горим!"
assert style_fallback(source, "Сделай тон спокойнее") is None
def test_full_ai_proposal_replaces_all_facts_and_derives_soft_truth():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
raw = '''{
"title":"Пожар на балконе жилого дома",
"first_line":"Помогите, с балкона валит чёрный дым!",
"facts":{
"f_address":"улица Новая, 8, корпус 2, первый этаж",
"f_what_burns":"на балконе жилого дома видно открытое пламя",
"f_people":"все вышли; Пострадавших: 0",
"f_smoke":"густой чёрный дым идёт в лестничный холл",
"f_gas":"газового оборудования в квартире нет"
},
"ground_truth":{"address":"улица Новая, 8","victims":0}
}'''
proposal = parse_full_proposal(raw, source)
body = full_proposal_body(source, "ai-full-example", proposal)
generated = validate(body)
assert generated.ground_truth.address == "улица Новая, 8"
assert generated.ground_truth.victims == 0
assert generated.ground_truth.incident_code == source.ground_truth.incident_code
assert generated.facts[0].value.startswith("улица Новая")
assert all(fact.refined is None and fact.refine_on is None for fact in generated.facts)
assert parse_full_proposal(raw.replace("Пострадавших: 0", "пострадавших нет"), source)
inconsistent = raw.replace("Пожар на балконе жилого дома", "Пожар в мастерской") \
.replace("с балкона", "из мастерской") \
.replace("на балконе жилого дома видно открытое пламя", "горит оборудование мастерской")
with pytest.raises(ScenarioError, match="сюжет не подтверждает путь ЕКП"):
full_proposal_body(source, "ai-full-inconsistent", parse_full_proposal(inconsistent, source))
with pytest.raises(GenerationError, match="числа пострадавших"):
parse_full_proposal(raw.replace("Пострадавших: 0", "Пострадавших не видно"), source)
with pytest.raises(GenerationError, match="адрес не совпадает"):
parse_full_proposal(raw.replace('"address":"улица Новая, 8"',
'"address":"улица Другая, 9"'), source)
@pytest.mark.asyncio
async def test_ai_generation_retries_copied_facts_with_strict_schema(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
requests = []
class FakeClient:
def __init__(self, **kwargs):
self.answers = iter([
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"дым идёт в подъезд, на площадке ничего не видно"}}',
'{"title":"Ночной пожар","first_line":"Алло, на балконе горит!",'
'"facts":{"f_smoke":"лестница уже заполнена густым дымом"}}',
])
async def complete(self, request, **kwargs):
requests.append(request)
return next(self.answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate(
source, "Ночной пожар на балконе, густой дым в подъезде, заявитель паникует"
)
assert proposal["facts"]["f_smoke"] == "лестница уже заполнена густым дымом"
assert len(requests) == 2
assert requests[0].response_format["type"] == "json_object"
assert set(requests[0].response_format["schema"]["properties"]["facts"]["properties"]) == {"f_smoke"}
assert "f_smoke" in requests[1].messages[-1]["content"]
@pytest.mark.asyncio
async def test_ai_generation_retries_one_qwen_control_token_failure(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
class FakeClient:
calls = 0
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
self.calls += 1
if self.calls == 1:
raise LlmUnavailable("ответ содержит служебные токены модели")
return ('{"title":"Ночной дым","first_line":"Алло, всё в дыму, помогите!",'
'"facts":{"f_smoke":"лестница заполнена густым дымом"}}')
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate(source, "Сделай дым густым и чёрным")
assert proposal["facts"]["f_smoke"].startswith("лестница")
@pytest.mark.asyncio
async def test_description_generation_asks_small_model_one_fact_at_a_time(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
answers = iter([
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
'{"value":"на балконе жилого дома видно открытое пламя"}',
'{"value":"все люди успели выйти","victims":0}',
'{"value":"густой дым вышел в подъезд"}',
'{"value":"газового оборудования нет, но нужно всё проверить"}',
])
requests = []
class FakeClient:
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
requests.append(request)
return next(answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate_from_description(
source, "Пожар в мастерской, все вышли, густой дым в подъезде"
)
assert len(requests) == 6
assert requests[0].messages[-1]["content"].find("classifier_signs") >= 0
assert all(
"classifier_signs" in request.messages[1]["content"]
for request in requests[1:]
)
assert proposal["first_line"].endswith("!")
assert proposal["ground_truth"] == {
"address": "ул. Маршака, д. 12, этаж 1", "victims": 0,
}
assert proposal["facts"]["f_people"].endswith("Пострадавших: 0")
assert proposal["facts"]["f_gas"] == "газового оборудования нет"
@pytest.mark.asyncio
async def test_description_generation_retries_fact_that_is_a_question(monkeypatch):
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
answers = iter([
'{"title":"Пожар на балконе жилого дома","first_line":"С балкона идёт дым"}',
'{"value":"ул. Маршака, д. 12, этаж 1","address":"ул. Маршака, д. 12"}',
'{"value":"на балконе жилого дома видно открытое пламя"}',
'{"value":"Есть ли пострадавшие?","victims":1}',
'{"value":"один человек получил ожог; Пострадавших: 1","victims":1}',
'{"value":"густой дым вышел в подъезд"}',
'{"value":"газового оборудования нет"}',
])
requests = []
class FakeClient:
def __init__(self, **kwargs):
pass
async def complete(self, request, **kwargs):
requests.append(request)
return next(answers)
async def aclose(self):
pass
monkeypatch.setattr(generation, "LlmClient", FakeClient)
proposal = await generation.generate_from_description(
source, "Пожар в мастерской, один человек получил ожог, густой дым в подъезде"
)
assert len(requests) == 7
assert "Ответ отклонён" in requests[4].messages[-1]["content"]
assert proposal["ground_truth"]["victims"] == 1
assert proposal["facts"]["f_people"].endswith("Пострадавших: 1")
def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
source = next(s for s in store.all_scenarios() if s.id == "fire-apartment-l2")
assert client.post("/api/auth/dev-token").status_code == 200
@ -106,6 +348,126 @@ def test_draft_is_hidden_until_approval_and_then_available_to_lesson(client):
assert client.patch(f"/api/scenarios/drafts/{draft_id}", json={"title": "нет"}).status_code == 404
def test_ai_draft_requires_instructor_review_before_publication(client, monkeypatch):
async def fake_generate(source, instruction, **kwargs):
assert source.id == "fire-apartment-l2"
assert "ночью" in instruction
return {"title": "Ночной пожар на балконе",
"first_line": "Помогите, у нас горит балкон!",
"facts": {"f_smoke": "густой дым уже в подъезде"}}
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
client.post("/api/auth/dev-token")
response = client.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Пожар начался ночью",
})
assert response.status_code == 201, response.text
draft = response.json()
assert draft["generation"] == "ai_variant"
assert draft["id"].startswith("ai-")
assert draft["body"]["first_line"] == "Помогите, у нас горит балкон!"
assert store.get(draft["id"]) is None
assert client.get(f"/api/scenarios/{draft['id']}").status_code == 404
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
assert client.post(f"/api/scenarios/drafts/{draft['id']}/approve").status_code == 200
assert store.get(draft["id"]).first_line == "Помогите, у нас горит балкон!"
def test_description_generates_new_story_and_reference_answers(client, monkeypatch):
async def fake_generate(source, description):
assert source.id == "fire-apartment-l2"
assert "мастерской" in description
return {
"title": "Пожар на балконе жилого дома",
"first_line": "Помогите, с балкона валит чёрный дым!",
"facts": {
"f_address": "улица Новая, 8, корпус 2, первый этаж",
"f_what_burns": "на балконе жилого дома видно открытое пламя",
"f_people": "все вышли; Пострадавших: 0",
"f_smoke": "густой чёрный дым идёт в лестничный холл",
"f_gas": "газового оборудования в квартире нет",
},
"ground_truth": {"address": "улица Новая, 8", "victims": 0},
}
monkeypatch.setattr(scenarios_api, "generate_from_description", fake_generate)
client.post("/api/auth/dev-token")
response = client.post("/api/scenarios/drafts/generate-from-description", json={
"source_id": "fire-apartment-l2",
"description": "Пожар в мастерской жилого дома, все успели выйти",
})
assert response.status_code == 201, response.text
draft = response.json()
assert draft["generation"] == "ai_full"
assert draft["id"].startswith("ai-full-")
assert draft["body"]["ground_truth"] == {"address": "улица Новая, 8", "victims": 0}
assert client.post(f"/api/scenarios/drafts/{draft['id']}/validate").json()["valid"]
assert store.get(draft["id"]) is None
def test_instructor_revises_same_ai_draft_by_comment(client, monkeypatch):
answers = iter([
{"title": "Ночной пожар на балконе",
"first_line": "Помогите, на балконе горит!",
"facts": {"f_smoke": "густой дым уже в подъезде"}},
{"title": "Ночной пожар с сильным дымом",
"first_line": "Алло, лестница вся в дыму, помогите!",
"facts": {"f_smoke": "чёрный дым полностью закрыл лестничную площадку"}},
])
comments = []
async def fake_generate(source, instruction, **kwargs):
comments.append(instruction)
if len(comments) > 1:
assert kwargs["require_fact_change"] is False
return next(answers)
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
client.post("/api/auth/dev-token")
created = client.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
}).json()
draft_id = created["id"]
revised = client.post(f"/api/scenarios/drafts/{draft_id}/revise", json={
"comment": "Сделай дым чёрным и закрой им площадку",
})
assert revised.status_code == 200, revised.text
body = revised.json()["body"]
assert revised.json()["id"] == draft_id
assert body["title"] == "Ночной пожар с сильным дымом"
assert body["facts"][0]["value"] == "улица Ленина, 14, квартира 47, 5-й этаж"
assert next(item["value"] for item in body["facts"] if item["id"] == "f_smoke").startswith("чёрный")
assert comments[-1] == "Сделай дым чёрным и закрой им площадку"
assert client.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
def test_ai_editor_works_in_demo_lite_without_database(monkeypatch):
monkeypatch.setenv("DEMO_NO_DB", "true")
monkeypatch.setenv("DEV_AUTH_BYPASS", "true")
get_settings.cache_clear()
async def fake_generate(source, instruction, **kwargs):
return {"title": "Ночной дым на балконе",
"first_line": "Алло, помогите, на балконе горит!",
"facts": {"f_smoke": "лестница уже заполнена густым дымом"}}
monkeypatch.setattr(scenarios_api, "generate", fake_generate)
try:
with TestClient(app) as demo:
assert demo.post("/api/auth/dev-token").status_code == 200
created = demo.post("/api/scenarios/drafts/generate", json={
"source_id": "fire-apartment-l2", "instruction": "Добавить густой ночной дым",
})
assert created.status_code == 201, created.text
draft_id = created.json()["id"]
assert demo.get(f"/api/scenarios/drafts/{draft_id}").status_code == 200
assert demo.post(f"/api/scenarios/drafts/{draft_id}/validate").json()["valid"]
assert demo.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 200
assert demo.get(f"/api/scenarios/{draft_id}").status_code == 200
finally:
get_settings.cache_clear()
def test_invalid_draft_cannot_be_approved(client):
client.post("/api/auth/dev-token")
response = client.post(
@ -120,6 +482,24 @@ def test_invalid_draft_cannot_be_approved(client):
assert store.get(draft_id) is None
def test_unresolved_fact_placeholder_cannot_be_validated_or_approved(client):
client.post("/api/auth/dev-token")
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2"},
).json()
draft_id = created["id"]
facts = created["body"]["facts"]
facts[1]["value"] = "{f_what_burns}"
assert client.patch(
f"/api/scenarios/drafts/{draft_id}", json={"facts": facts}
).status_code == 200
check = client.post(f"/api/scenarios/drafts/{draft_id}/validate")
assert check.json()["valid"] is False
assert "плейсхолдер" in check.json()["errors"][0]
assert client.post(f"/api/scenarios/drafts/{draft_id}/approve").status_code == 422
def test_unauthenticated_user_cannot_manage_drafts(client):
response = client.post(
"/api/scenarios/drafts/from-template", json={"source_id": "fire-apartment-l2"}
@ -127,6 +507,94 @@ def test_unauthenticated_user_cannot_manage_drafts(client):
assert response.status_code == 401
def test_instructor_archives_and_restores_scenario_without_deleting_history(client):
client.post("/api/auth/dev-token")
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Сценарий для проверки архива"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
approved = client.post(f"/api/scenarios/drafts/{scenario_id}/approve")
assert approved.status_code == 200, approved.text
original = store.get(scenario_id)
assert original is not None
archived = client.delete(f"/api/scenarios/{scenario_id}")
assert archived.status_code == 200, archived.text
assert archived.json()["status"] == "archived"
assert store.get(scenario_id) is None
assert scenario_id not in {item["id"] for item in client.get("/api/scenarios").json()}
assert client.get(f"/api/scenarios/{scenario_id}").status_code == 404
restored = client.post(f"/api/scenarios/{scenario_id}/restore")
assert restored.status_code == 200, restored.text
assert restored.json()["status"] == "published"
assert store.get(scenario_id).title == original.title
assert scenario_id in {item["id"] for item in client.get("/api/scenarios").json()}
def test_instructor_cannot_read_or_edit_another_instructors_draft(client, monkeypatch):
from app.api.auth import Principal
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Личный черновик"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
identity["login"] = "teacher-two"
assert client.get(f"/api/scenarios/drafts/{scenario_id}").status_code == 404
assert client.patch(
f"/api/scenarios/drafts/{scenario_id}", json={"title": "Чужая правка"}
).status_code == 404
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 404
def test_instructor_cannot_archive_another_instructors_published_scenario(client, monkeypatch):
from app.api.auth import Principal
from app.domain.roles import Role
identity = {"login": "teacher-one"}
def instructor(_request, *_roles):
return Principal(login=identity["login"], full_name="Преподаватель", role=Role.INSTRUCTOR)
monkeypatch.setattr(scenarios_api, "require", instructor)
monkeypatch.setattr(scenarios_api, "current", instructor)
created = client.post(
"/api/scenarios/drafts/from-template",
json={"source_id": "fire-apartment-l2", "title": "Публикация автора"},
)
assert created.status_code == 201, created.text
scenario_id = created.json()["id"]
assert client.post(f"/api/scenarios/drafts/{scenario_id}/approve").status_code == 200
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is True
identity["login"] = "teacher-two"
scenario = next(item for item in client.get("/api/scenarios").json() if item["id"] == scenario_id)
assert scenario["can_manage"] is False
assert client.delete(f"/api/scenarios/{scenario_id}").status_code == 404
def test_scenario_in_active_lesson_cannot_be_archived(client, monkeypatch):
client.post("/api/auth/dev-token")
monkeypatch.setattr(scenarios_api.hub, "has_active_scenario", lambda _scenario_id: True)
response = client.delete("/api/scenarios/fire-apartment-l2")
assert response.status_code == 409
assert response.json()["detail"] == "scenario_is_used_by_active_session"
@pytest.mark.asyncio
async def test_approved_scenario_restores_from_local_database():
source = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)

View file

@ -5,7 +5,7 @@ from pathlib import Path
import pytest
from pydantic import ValidationError
from app.domain.events import Metric
from app.domain.events import LessonCriteria, Metric
from app.scenarios.loader import load_file
from app.scenarios.schema import Scenario
from app.scoring.gost import GostResult
@ -37,3 +37,12 @@ def test_scenario_accepts_only_known_finite_weights():
body["score_weights"] = {"address": 11.0}
with pytest.raises(ValidationError, match="от 0 до 10"):
Scenario.model_validate(body)
def test_lesson_criteria_validate_metric_weight_overrides():
criteria = LessonCriteria(score_weights={"address": 3.5, "dds_ack": 0})
assert criteria.score_weights == {"address": 3.5, "dds_ack": 0}
with pytest.raises(ValidationError, match="неизвестные метрики"):
LessonCriteria(score_weights={"adress": 2})
with pytest.raises(ValidationError, match="от 0 до 10"):
LessonCriteria(score_weights={"address": 11})

View file

@ -30,6 +30,35 @@ async def test_trainee_cannot_read_foreign_session(monkeypatch):
assert error.value.status_code == 403
@pytest.mark.asyncio
async def test_instructor_cannot_read_foreign_session(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _: who)
async def row(_db, _session_id):
return SimpleNamespace(owner_login="teacher-b")
monkeypatch.setattr(sessions.repo, "get_session", row)
with pytest.raises(HTTPException) as error:
await sessions.read(uuid4(), request(), db=object())
assert error.value.status_code == 404
@pytest.mark.asyncio
async def test_instructor_history_is_scoped_to_owner(monkeypatch):
who = Principal(login="teacher-a", full_name="Преподаватель A", role=Role.INSTRUCTOR)
monkeypatch.setattr(sessions, "require", lambda _: who)
seen = {}
async def history(_db, **filters):
seen.update(filters)
return []
monkeypatch.setattr(sessions.repo, "history", history)
await sessions.listing(request(), db=object())
assert seen["owner_login"] == "teacher-a"
@pytest.mark.asyncio
async def test_trainee_cannot_read_foreign_checklist(monkeypatch):
who = Principal(login="trainee", full_name="Курсант", role=Role.TRAINEE, trainee_id=uuid4())

View file

@ -0,0 +1,167 @@
"""Промежуточное состояние занятия переживает смену backend-процесса."""
from datetime import UTC, datetime, timedelta
from pathlib import Path
from uuid import uuid4
import pytest
from app.domain.events import Exercise, LessonCriteria, SessionMode
from app.domain.statuses import PhoneCallPending, ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.scoring.grammar import basic_check
from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_queue
from app.session.state import SessionState, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
def dds_state() -> SessionState:
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(),
scenario_id=scenario.id,
scenario_title=scenario.title,
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
scenario=scenario.model_copy(deep=True),
dds_scenarios=[scenario.model_copy(deep=True)],
required_fields=list(scenario.required_fields),
trainee_name="Курсант для восстановления",
attempt=2,
criteria=LessonCriteria(
decision_time_limit_seconds=45,
allowed_errors=1,
require_correct_grammar=True,
),
)
state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios)
service = state.notified_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED, author="диспетчер")
state.crew_selected = state.crew_options()[0]
state.crew_assignments[service] = state.crew_selected
state.phone_pending = PhoneCallPending(
service=service, crew=state.crew_selected, phase="dispatched"
)
state.reply_text = "Сообщение принято, бригада направлена."
state.reply_grammar = basic_check(state.reply_text)
state.reply_log.append((now_utc(), state.reply_text))
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
return state
def test_active_dds_session_round_trips_without_losing_work():
before = dds_state()
payload = dump_state(before)
restored = load_state(
payload,
datetime.now(UTC) - timedelta(seconds=2),
)
assert restored.session_id == before.session_id
assert restored.exercise is Exercise.DDS
assert restored.criteria.decision_time_limit_seconds == 45
assert restored.dispatched_card == before.dispatched_card
assert restored.status_log == before.status_log
assert restored.crew_selected == before.crew_selected
assert restored.crew_assignments == before.crew_assignments
assert restored.phone_pending == before.phone_pending
assert restored.reply_text == before.reply_text
assert restored.reply_grammar == before.reply_grammar
assert restored.dds_scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK)
assert timer.elapsed_ms >= 1_900
assert timer.limit_ms == 45_000
def test_checkpoint_rejects_unknown_format_version():
payload = dump_state(dds_state())
payload["version"] = 999
with pytest.raises(ValueError, match="версия"):
load_state(payload, datetime.now(UTC))
def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
second = first.model_copy(
deep=True,
update={"id": "checkpoint-second", "title": "Вторая карточка восстановления"},
)
state = SessionState(
session_id=uuid4(),
scenario_id=first.id,
scenario_title=first.title,
level=first.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=[first, second],
)
prepare_queue(state, state.dds_scenarios)
first_id = state.dispatched_card.card_id
first_service = state.managed_services()[0]
state.set_service_status(first_service, ServiceStatus.ACCEPTED)
state.on_event("card.ack")
second_id = state.dds_live_cards[1].card_id
assert state.activate_dds_card(second_id)
restored = load_state(
dump_state(state),
datetime.now(UTC) - timedelta(seconds=2),
)
assert restored.dds_active_card_id == second_id
assert restored.dispatched_card.card_id == second_id
assert len(restored.dds_live_cards) == 2
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id)
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
second_timer = next(
item for item in second_restored.timers.snapshot() if item.code is TimerCode.DDS_ACK
)
assert second_timer.elapsed_ms >= 1_900
assert second_timer.stopped is False
def test_delivering_next_dds_card_does_not_clear_previous_card_state():
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
scenarios = [
first.model_copy(
deep=True,
update={"id": f"scheduled-checkpoint-{index}", "title": f"Карточка {index}"},
)
for index in range(3)
]
state = SessionState(
session_id=uuid4(),
scenario_id=scenarios[0].id,
scenario_title=scenarios[0].title,
level=scenarios[0].level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
dds_scenarios=scenarios,
)
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id
service = state.managed_services()[0]
state.set_service_status(service, ServiceStatus.ACCEPTED)
state.capture_active_dds()
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1
first = next(card for card in state.dds_live_cards if card.card_id == first_id)
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
second = next(card for card in state.dds_live_cards if card.original_index == 1)
assert state.activate_dds_card(second.card_id)
restored = load_state(dump_state(state), now_utc())
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id)
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert restored.dds_active_card_id == second.card_id
assert len(restored.dds_live_cards) == 2
assert restored.dds_next_scenario_index == 2
assert restored.dds_next_arrival_at is not None

View file

@ -104,9 +104,8 @@ def test_acknowledgement_stops_the_four_second_norm(client):
control.__exit__(None, None, None)
def test_bounced_card_becomes_e6_with_the_reason(client):
"""Неполнота КИО перестаёт быть процентом в отчёте и становится
сорванным выездом с конкретной причиной."""
def test_dds_cannot_return_card_for_112_quality_control(client):
"""ДДС ставит статусы своей службы, а качество КИО контролирует 112."""
session_id, control, _ = lesson(client)
try:
state = hub.get(session_id)
@ -117,14 +116,15 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
read_until(station, "card.received")
station.send_json({"type": "card.bounce", "missing_fields": ["floor", "victims_count"],
"comment": "куда ехать без этажа"})
wait_for(lambda: state.bounced_fields)
error = read_until(station, "error")
assert "служба контроля 112" in error["message"]
assert state.bounced_fields == []
trainee.send_json({"type": "call.hangup"})
wait_for(lambda: state.score is not None)
response = client.get(f"/api/sessions/{session_id}/report").json()
chain = [finding for finding in response["findings"] if finding["code"] == "E6"]
assert chain, "возврат карточки должен попасть в разбор"
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
assert chain == []
finally:
control.__exit__(None, None, None)
@ -160,8 +160,9 @@ def test_station_sees_the_notify_list_from_the_classifier(client):
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
assert "Служба 101" in state["snapshot"]["services"]
assert len(state["snapshot"]["services"]) > 1
assert state["snapshot"]["services"] == ["Служба 101"]
assert "Служба 101" in state["snapshot"]["recipient_services"]
assert len(state["snapshot"]["recipient_services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)

View file

@ -6,6 +6,7 @@
import contextlib
import time
import wave
from uuid import uuid4
import pytest
@ -256,6 +257,32 @@ def test_events_still_work_between_audio_frames(client):
assert state.audio_frames == 2
def test_answered_call_is_saved_as_wav_on_hangup(client, monkeypatch, tmp_path):
from app.api.ws import call as call_api
from app.voice.recording import CallRecorder
monkeypatch.setattr(
call_api, "start_recording",
lambda session_id: CallRecorder(tmp_path / f"{session_id}.wav"),
)
with lesson(client) as (session_id, _):
state = hub.get(session_id)
path = tmp_path / f"{session_id}.wav"
with client.websocket_connect(f"/ws/call/{session_id}") as trainee:
read_until(trainee, "call.incoming")
trainee.send_json({"type": "call.answer"})
read_until(trainee, "call.started")
trainee.send_bytes((1000).to_bytes(2, "little", signed=True) * 320)
trainee.send_json({"type": "call.hangup"})
read_until(trainee, "call.ended")
wait_for(path.is_file)
assert state.recording_path == str(path)
with wave.open(str(path), "rb") as source:
assert source.getframerate() == 16_000
assert source.getnframes() >= 320
def test_score_waits_for_self_assessment(client):
"""Курсант сначала сверяет своё ощущение с объективной картиной:
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""