Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
53
.env.example
53
.env.example
|
|
@ -1,15 +1,56 @@
|
|||
# Скопировать в .env. Файл в .gitignore: ключи в репозиторий не едут.
|
||||
BIND_HOST=127.0.0.1
|
||||
POSTGRES_PORT=5432
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5173
|
||||
TLS_PORT=5443
|
||||
TLS_CERT_DIR=./.local/tls
|
||||
SIP_PORT=5060
|
||||
SIPS_PORT=5061
|
||||
SIP_WS_PORT=8088
|
||||
RTP_PORT_START=10000
|
||||
RTP_PORT_END=10099
|
||||
SIP_TLS_DIR=./.local/sip-tls
|
||||
# Если пусто, Asterisk создаст стойкие случайные пароли в volume sipdata.
|
||||
SIP_6001_PASSWORD=
|
||||
SIP_6002_PASSWORD=
|
||||
SIP_6003_PASSWORD=
|
||||
SIP_6101_PASSWORD=
|
||||
SIP_6102_PASSWORD=
|
||||
# IP/DNS сервера, который WebRTC-клиенты видят в локальной сети. При запуске
|
||||
# только на той же машине оставьте 127.0.0.1; RTP-порты должны отображаться 1:1.
|
||||
SIP_EXTERNAL_MEDIA_ADDRESS=127.0.0.1
|
||||
DATABASE_URL=postgresql+asyncpg://lct:lct@localhost:5432/lct
|
||||
|
||||
# LLM: провайдер меняется значением, не кодом (docs/arch/STACK.md)
|
||||
LLM_PROVIDER=gigachat
|
||||
# Два локальных OpenAI-совместимых сервера (например, llama-server).
|
||||
# Адреса должны быть loopback: OFFLINE=true не допускает внешний API.
|
||||
LLM_PROVIDER=local
|
||||
LLM_BASE_URL=http://127.0.0.1:18080/v1
|
||||
# Адреса тех же процессов из backend-контейнера Docker Desktop (macOS/Windows).
|
||||
DOCKER_LLM_BASE_URL=http://host.docker.internal:18080/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL_CALLER=
|
||||
LLM_MODEL_JUDGE=
|
||||
LLM_MODEL_CALLER=Qwen3-1.7B
|
||||
LLM_CONTROL_BASE_URL=http://127.0.0.1:18081/v1
|
||||
DOCKER_LLM_CONTROL_BASE_URL=http://host.docker.internal:18081/v1
|
||||
LLM_MODEL_CONTROL=Vikhr-1B
|
||||
GRAMMAR_LLM_ENABLED=true
|
||||
DIALOGUE_MODEL_MODE=dialogue
|
||||
JUDGE_TEMPERATURE=0
|
||||
|
||||
# Голосовой контур
|
||||
MODELS_DIR=models
|
||||
STT_MODEL=gigaam-v3-rnnt
|
||||
STT_MODEL=whisper-small
|
||||
WHISPER_MODEL_PATH=models/whisper-small
|
||||
WHISPER_SERVER_URL=http://127.0.0.1:18082
|
||||
VOICE_ENABLED=false
|
||||
RECORD_CALLS=true
|
||||
RECORDINGS_DIR=recordings
|
||||
BACKUP_INTERVAL_SECONDS=86400
|
||||
BACKUP_RETRY_SECONDS=300
|
||||
BACKUP_KEEP=14
|
||||
ENDPOINTING_MS=600
|
||||
OFFLINE=false
|
||||
OFFLINE=true
|
||||
SECURE_COOKIES=false
|
||||
# Необязательно для Compose: если пусто, стойкий случайный ключ создаётся в
|
||||
# volume securitydata. На управляемом стенде можно задать свой 48+ байтный ключ.
|
||||
SESSION_SECRET=
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -7,6 +7,8 @@ backend/models/
|
|||
|
||||
# Резервные копии базы (make backup)
|
||||
backend/backups/
|
||||
backend/recordings/
|
||||
recordings/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
|
@ -22,9 +24,14 @@ frontend/dist/
|
|||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
.local/
|
||||
|
||||
# Python
|
||||
.pytest_cache/
|
||||
|
||||
# Temporary SQLite files created by local in-memory demo/test runs
|
||||
:memory:.ses
|
||||
backend/:memory:.ses
|
||||
|
||||
# Артефакты Windows рядом со скачанными файлами
|
||||
*:Zone.Identifier
|
||||
|
|
|
|||
43
Makefile
43
Makefile
|
|
@ -21,10 +21,34 @@ dev: ## Поднять стенд: postgres + backend --reload + frontend
|
|||
offline: ## Поднять карточки/ДДС из заранее собранных локальных образов, без скачивания
|
||||
$(COMPOSE) up --pull never --no-build
|
||||
|
||||
tls: ## Поднять полный стенд по HTTPS/WSS с локальным сертификатом
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --build
|
||||
|
||||
offline-tls: ## Поднять HTTPS/WSS из уже собранных образов без сети
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.tls.yml up --pull never --no-build
|
||||
|
||||
sip: ## Собрать и поднять локальный Asterisk SIP/VoIP
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --build -d sip
|
||||
|
||||
offline-sip: ## Поднять SIP из уже собранного образа без сети
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml up --pull never --no-build -d sip
|
||||
|
||||
sip-credentials: ## Показать локальные пароли софтфонов 6001–6003 и WebRTC 6101–6102
|
||||
$(COMPOSE) -f docker-compose.yml -f docker-compose.sip.yml exec -T sip cat /var/lib/lct-sip/credentials.env
|
||||
|
||||
sip-test: ## Проверить регистрацию, звонок, RTP и задержку (args="--rtp-host-offset 1000")
|
||||
python3 scripts/smoke_sip.py --port $(or $(SIP_PORT),5060) $(args)
|
||||
|
||||
webrtc-test: ## Проверить два браузерных SIP-клиента, аудио и запись Asterisk
|
||||
python3 scripts/smoke_webrtc_browser.py \
|
||||
--frontend-url https://127.0.0.1:$(or $(TLS_PORT),5443) \
|
||||
--backend-url http://127.0.0.1:$(or $(BACKEND_PORT),8000) \
|
||||
--database-url postgresql+asyncpg://lct:lct@127.0.0.1:$(or $(POSTGRES_PORT),5432)/lct $(args)
|
||||
|
||||
down: ## Погасить стенд
|
||||
$(COMPOSE) down
|
||||
|
||||
back: ## Бэкенд нативно, с голосовым контуром (порт 8000: сначала docker compose stop backend)
|
||||
back: ## Бэкенд нативно, без голоса по умолчанию (порт 8000: сначала docker compose stop backend)
|
||||
cd backend && $(UV) run --extra voice uvicorn app.main:app --reload --port 8000 --workers 1
|
||||
|
||||
front: ## Только фронтенд, локально
|
||||
|
|
@ -42,9 +66,18 @@ test-voice: ## Тест голосового контура на настоящ
|
|||
typecheck: ## Проверить фронтенд компилятором
|
||||
npm --prefix frontend run typecheck
|
||||
|
||||
models: ## Скачать модели в backend/models/: эмбеддинги, GigaAM, Silero VAD; Silero TTS — вручную
|
||||
models: ## Скачать эмбеддинги и Silero VAD; Silero TTS — вручную
|
||||
cd backend && $(UV) run python scripts/models.py
|
||||
|
||||
local-models: ## Скачать и проверить Qwen3 1.7B, Vikhr 1B, Whisper small (нужна сеть один раз)
|
||||
cd backend && $(UV) run --offline --no-sync python scripts/download_local_models.py
|
||||
|
||||
local-llm: ## Запустить Qwen3 на localhost:18080 (mode=both для Qwen3 и Vikhr)
|
||||
cd backend && python3 scripts/local_llms.py --mode $(or $(mode),dialogue)
|
||||
|
||||
local-stt: ## Запустить локальный whisper.cpp server на localhost:18082
|
||||
cd backend && python3 scripts/local_stt.py
|
||||
|
||||
backup: ## Резервная копия базы в backend/backups/ (для cron: раз в сутки)
|
||||
cd backend && $(UV) run python scripts/backup.py
|
||||
|
||||
|
|
@ -63,7 +96,7 @@ lesson: ## Запустить занятие и напечатать ссылк
|
|||
test-llm: ## Живые проверки LLM по backend/.env.test (медленно: рассуждающая модель)
|
||||
cd backend && $(UV) run --extra dev pytest tests/test_llm.py -m llm -q -s
|
||||
|
||||
llm-check: ## Один запрос к LLM: проверить ключ и адрес из backend/.env
|
||||
llm-check: ## Один запрос к активной локальной модели из backend/.env
|
||||
cd backend && $(UV) run python scripts/llm_check.py
|
||||
|
||||
latency: ## Замер задержки голосового контура по этапам — запускать на демо-машине
|
||||
|
|
@ -81,10 +114,10 @@ repl: ## Текстовый диалог со звонящим без голос
|
|||
pregen: ## Офлайн-таблицы реплик: make pregen [s=<сценарий>] [t=<билет>] [force=1]
|
||||
cd backend && s="$(s)" t="$(t)" force="$(force)" $(UV) run python scripts/pregenerate.py
|
||||
|
||||
demo: ## Поднять стенд для занятия: база, сценарии, бэкенд с голосом, фронт
|
||||
demo: ## Поднять основной стенд ДДС: база, готовые карточки, бэкенд и фронт
|
||||
./scripts/demo.sh
|
||||
|
||||
demo-lite: ## Локальная демонстрация карточки/ДДС без Docker, БД, голоса и внешней сети
|
||||
cd backend && UV_CACHE_DIR=/tmp/lct-uv-demo-cache OFFLINE=true VOICE_ENABLED=false DEV_AUTH_BYPASS=true DEMO_NO_DB=true $(UV) run --offline --no-sync uvicorn app.main:app --host 127.0.0.1 --port $(DEMO_PORT) --workers 1
|
||||
|
||||
.PHONY: help dev offline down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models seed repl pregen demo demo-lite
|
||||
.PHONY: help dev offline tls offline-tls sip offline-sip sip-credentials sip-test webrtc-test down back front types users users-docker backup test test-voice typecheck test-llm lesson llm-check latency migrate revision models local-models local-llm local-stt seed repl pregen demo demo-lite
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ ENV PYTHONUNBUFFERED=1
|
|||
|
||||
# Экспорт PDF требует локальный TTF с кириллицей; базовый slim-образ его не
|
||||
# гарантирует. Шрифт запекается в образ при подготовке офлайн-комплекта.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Зависимости — из pyproject.toml, а не отдельным списком: ручной список разошёлся
|
||||
|
|
|
|||
|
|
@ -7,13 +7,16 @@
|
|||
честно говорим, чего не хватает.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from getpass import getuser
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
|
@ -23,51 +26,122 @@ DIR = Path(__file__).resolve().parents[2] / "backups"
|
|||
#: Контейнер базы в docker compose проекта.
|
||||
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
|
||||
TIMEOUT_S = 120
|
||||
# pg_dump 17 добавляет эту настройку в plain SQL, а целевая PostgreSQL 16 из
|
||||
# Compose её ещё не знает. Остальной plain dump обратно совместим; удаляем
|
||||
# только служебный SET, не данные и не DDL.
|
||||
INCOMPATIBLE_SETTINGS = (b"SET transaction_timeout = 0;\n",)
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
|
||||
|
||||
|
||||
def _restrict_file_permissions(fd: int, path: Path, platform_name: str | None = None) -> None:
|
||||
"""Закрыть резервную копию от остальных локальных пользователей.
|
||||
|
||||
chmod(0o600) не задаёт ACL на Windows, поэтому там icacls удаляет
|
||||
наследуемые разрешения и оставляет полный доступ владельцу процесса и
|
||||
системной учётной записи. При ошибке копию не публикуем.
|
||||
"""
|
||||
platform_name = platform_name or os.name
|
||||
if platform_name == "nt":
|
||||
username = getuser()
|
||||
domain = os.environ.get("USERDOMAIN")
|
||||
if domain and "\\" not in username:
|
||||
username = f"{domain}\\{username}"
|
||||
system_root = Path(os.environ.get("SystemRoot", "C:\\Windows"))
|
||||
icacls = system_root / "System32" / "icacls.exe"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(icacls), str(path), "/inheritance:r", "/grant:r",
|
||||
f"{username}:(F)", "*S-1-5-18:(F)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise BackupError("не удалось задать ACL резервной копии через icacls") from exc
|
||||
if result.returncode != 0:
|
||||
raise BackupError("Windows не применила закрытый ACL к резервной копии")
|
||||
return
|
||||
if hasattr(os, "fchmod"):
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
except OSError as exc:
|
||||
raise BackupError("не удалось ограничить права резервной копии") from exc
|
||||
else:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError as exc:
|
||||
raise BackupError("не удалось ограничить права резервной копии") from exc
|
||||
|
||||
|
||||
def _dsn() -> tuple[str, str, str, str, str]:
|
||||
"""Разобрать DATABASE_URL на части для pg_dump."""
|
||||
url = get_settings().database_url
|
||||
match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
|
||||
if not match:
|
||||
raise BackupError(f"не разобрать DATABASE_URL: {url}")
|
||||
return match.groups() # user, password, host, port, database
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
user = unquote(parsed.username or "")
|
||||
password = unquote(parsed.password or "")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port
|
||||
database = unquote(parsed.path.lstrip("/"))
|
||||
except ValueError as exc:
|
||||
# Не включать исходный URL в диагностику: он содержит пароль.
|
||||
raise BackupError("не удалось разобрать DATABASE_URL") from exc
|
||||
if not all((user, password, host, port, database)) or "/" in database:
|
||||
raise BackupError("не удалось разобрать DATABASE_URL")
|
||||
return user, password, host, str(port), database
|
||||
|
||||
|
||||
def listing() -> list[dict]:
|
||||
"""Готовые копии, новые первыми."""
|
||||
if not DIR.exists():
|
||||
items = []
|
||||
try:
|
||||
with os.scandir(DIR) as entries:
|
||||
for entry in entries:
|
||||
if not entry.name.endswith(".sql") or not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
stat = entry.stat(follow_symlinks=False)
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
})
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
items = [
|
||||
{
|
||||
"name": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
|
||||
}
|
||||
for path in DIR.glob("*.sql")
|
||||
]
|
||||
except OSError as exc:
|
||||
raise BackupError(f"не удалось прочитать каталог резервных копий: {exc}") from exc
|
||||
return sorted(items, key=lambda item: item["at"], reverse=True)
|
||||
|
||||
|
||||
def create() -> dict:
|
||||
"""Снять копию. Возвращает описание файла, как в `listing()`."""
|
||||
user, password, host, port, database = _dsn()
|
||||
DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
try:
|
||||
DIR.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise BackupError(f"недоступен каталог резервных копий: {exc}") from exc
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||
target = DIR / f"lct-{stamp}.sql"
|
||||
|
||||
if shutil.which("pg_dump"):
|
||||
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
|
||||
env = {**os.environ, "PGPASSWORD": password}
|
||||
stdin_data = None
|
||||
elif shutil.which("docker"):
|
||||
# В контейнере база доступна как localhost, а не по хосту из DSN.
|
||||
command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
|
||||
"pg_dump", "-U", user, "-d", database]
|
||||
# Пароль передаём через stdin, а не аргументом `docker exec -e`,
|
||||
# поскольку аргументы процесса могут быть видны другим пользователям.
|
||||
script = (
|
||||
"IFS= read -r encoded_password || exit 1; "
|
||||
'PGPASSWORD="$(printf %s "$encoded_password" | base64 -d)" || exit 1; '
|
||||
'export PGPASSWORD; exec pg_dump -U "$1" -d "$2"'
|
||||
)
|
||||
command = ["docker", "exec", "-i", CONTAINER, "sh", "-c", script,
|
||||
"lct-pg-dump", user, database]
|
||||
env = dict(os.environ)
|
||||
stdin_data = base64.b64encode(password.encode("utf-8")) + b"\n"
|
||||
else:
|
||||
raise BackupError(
|
||||
"нечем снять копию: нет ни pg_dump, ни docker. "
|
||||
|
|
@ -75,7 +149,9 @@ def create() -> dict:
|
|||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, timeout=TIMEOUT_S, env=env, input=stdin_data
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
|
||||
except OSError as exc:
|
||||
|
|
@ -84,10 +160,38 @@ def create() -> dict:
|
|||
if result.returncode != 0:
|
||||
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
|
||||
|
||||
target.write_bytes(result.stdout)
|
||||
log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
|
||||
dump = result.stdout
|
||||
for setting in INCOMPATIBLE_SETTINGS:
|
||||
dump = dump.replace(setting, b"")
|
||||
if not dump.startswith(b"--\n-- PostgreSQL database dump"):
|
||||
raise BackupError("pg_dump вернул неожиданный или пустой формат")
|
||||
# Публикуем только полностью записанный dump. При аварийном завершении
|
||||
# процесса список копий не должен показывать обрезанный файл как готовый.
|
||||
try:
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=DIR)
|
||||
except OSError as exc:
|
||||
raise BackupError(f"не удалось создать временный файл копии: {exc}") from exc
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
_restrict_file_permissions(stream.fileno(), temporary)
|
||||
stream.write(dump)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(target)
|
||||
except Exception as exc:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
log.exception("не удалось удалить временную копию %s", temporary.name)
|
||||
if isinstance(exc, OSError):
|
||||
raise BackupError(f"не удалось записать резервную копию: {exc}") from exc
|
||||
raise
|
||||
size_bytes = len(dump)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
log.info("резервная копия: %s, %d КБ", target.name, size_bytes // 1024)
|
||||
return {
|
||||
"name": target.name,
|
||||
"size_bytes": target.stat().st_size,
|
||||
"at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
|
||||
"size_bytes": size_bytes,
|
||||
"at": created_at,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import weakref
|
||||
|
|
@ -24,7 +25,7 @@ from uuid import UUID
|
|||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import get_settings
|
||||
|
|
@ -37,10 +38,12 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|||
DEMO_TRAINEE_ID = UUID("00000000-0000-4000-8000-000000000112")
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Cookie is signed, but carries a role snapshot. A changed account must not
|
||||
# keep its old privileges for the full 12-hour cookie lifetime. Compose runs
|
||||
# one worker; the process marker also invalidates all cookies after restart.
|
||||
_INSTANCE = secrets.token_urlsafe(32)
|
||||
# Cookie is signed and may safely survive a backend restart. Marker changes
|
||||
# only together with SESSION_SECRET; account edits are revoked independently
|
||||
# through the persistent auth_version loaded below.
|
||||
_INSTANCE = hashlib.sha256(
|
||||
f"lct-auth-instance:{get_settings().session_secret}".encode()
|
||||
).hexdigest()
|
||||
_generations: dict[str, int] = {}
|
||||
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
|
||||
|
||||
|
|
@ -53,9 +56,21 @@ async def _close_revoked(ws: WebSocket) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def invalidate_login(login: str) -> None:
|
||||
def prime_generations(values: dict[str, int]) -> None:
|
||||
"""Загрузить версии полномочий из БД при старте нового процесса."""
|
||||
_generations.clear()
|
||||
_generations.update(values)
|
||||
|
||||
|
||||
async def load_generations() -> None:
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||
prime_generations({login: version for login, version in rows})
|
||||
|
||||
|
||||
def invalidate_login(login: str, version: int | None = None) -> None:
|
||||
"""Revoke previously issued cookies after account/role/password changes."""
|
||||
_generations[login] = _generations.get(login, 0) + 1
|
||||
_generations[login] = version if version is not None else _generations.get(login, 0) + 1
|
||||
for ws, loop in list(_active_sockets.get(login, {}).items()):
|
||||
try:
|
||||
if not loop.is_closed():
|
||||
|
|
@ -117,8 +132,8 @@ class Principal(BaseModel):
|
|||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
login: str
|
||||
password: str
|
||||
login: str = Field(min_length=3, max_length=80)
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
def _demo_local(request: Request) -> None:
|
||||
|
|
@ -199,10 +214,17 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
# Одинаковый ответ на неизвестный логин и неверный пароль: иначе форма
|
||||
# входа превращается в список действующих учётных записей.
|
||||
if user is None or not verify_password(user.password_hash, payload.password):
|
||||
# Не записываем пароль, IP либо факт существования учётной записи.
|
||||
# Логин нужен администратору для расследования перебора; ограничиваем
|
||||
# длину до размера поля AuditLog.actor.
|
||||
await audit(payload.login[:80], "unknown", "login.failed")
|
||||
raise HTTPException(status_code=401, detail="bad_credentials")
|
||||
if user.blocked:
|
||||
await audit(user.login, user.role, "login.blocked")
|
||||
raise HTTPException(status_code=403, detail="blocked")
|
||||
|
||||
_generations[user.login] = user.auth_version
|
||||
|
||||
who = Principal(
|
||||
login=user.login,
|
||||
full_name=user.full_name,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@
|
|||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -27,13 +30,68 @@ from app.config import get_settings
|
|||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Session as SessionRow, Trainee, User
|
||||
from app.domain import ekp
|
||||
from app.domain.roles import ROLE_LABELS, Role
|
||||
from app.domain.roles import ROLE_LABELS, SCREENS, Role
|
||||
from app.domain.timers import NORMATIVES
|
||||
from app.dialog.llm import is_loopback_url
|
||||
from app.monitoring import recent_events, sample_metrics
|
||||
from app.session.hub import hub
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _configuration_xml() -> bytes:
|
||||
"""Безопасный переносимый снимок конфигурации без паролей и ключей."""
|
||||
settings = get_settings()
|
||||
root = ET.Element("lctConfiguration", {"version": "1"})
|
||||
ET.SubElement(root, "platform", {
|
||||
"offline": str(settings.offline).lower(),
|
||||
"voiceEnabled": str(settings.voice_enabled).lower(),
|
||||
"secureCookies": str(settings.secure_cookies).lower(),
|
||||
})
|
||||
models = ET.SubElement(root, "localModels")
|
||||
ET.SubElement(models, "dialogue", {"name": settings.llm_model_caller})
|
||||
ET.SubElement(models, "russianControl", {
|
||||
"name": settings.llm_model_control,
|
||||
"grammarEnabled": str(settings.grammar_llm_enabled).lower(),
|
||||
})
|
||||
ET.SubElement(models, "speechToText", {
|
||||
"name": settings.stt_model,
|
||||
"enabled": str(settings.voice_enabled).lower(),
|
||||
})
|
||||
workstations = ET.SubElement(root, "workstations")
|
||||
for role, screens in SCREENS.items():
|
||||
workstation = ET.SubElement(workstations, "workstation", {
|
||||
"role": role.value, "label": ROLE_LABELS[role],
|
||||
})
|
||||
for path in screens:
|
||||
ET.SubElement(workstation, "screen", {"path": path})
|
||||
timers = ET.SubElement(root, "timerLimits")
|
||||
for code, normative in NORMATIVES.items():
|
||||
ET.SubElement(timers, "timer", {
|
||||
"code": code.value,
|
||||
"milliseconds": str(settings.limit_ms(code)),
|
||||
"defaultMilliseconds": str(normative.limit_ms),
|
||||
})
|
||||
reference = ekp.reference()
|
||||
ET.SubElement(root, "ekp", {
|
||||
"version": reference.version,
|
||||
"incidents": str(len(reference.incidents)),
|
||||
})
|
||||
ET.indent(root, space=" ")
|
||||
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
|
||||
@router.get("/config.xml")
|
||||
async def configuration_xml(request: Request) -> Response:
|
||||
require(request, Role.ADMIN)
|
||||
return Response(
|
||||
content=_configuration_xml(),
|
||||
media_type="application/xml",
|
||||
headers={"Content-Disposition": 'attachment; filename="lct-workstations.xml"'},
|
||||
)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: UUID
|
||||
login: str
|
||||
|
|
@ -47,7 +105,7 @@ class UserOut(BaseModel):
|
|||
class UserCreate(BaseModel):
|
||||
login: str = Field(min_length=3, max_length=80)
|
||||
full_name: str = Field(min_length=1, max_length=120)
|
||||
password: str = Field(min_length=8, description="Короткий пароль не заводится")
|
||||
password: str = Field(min_length=8, max_length=1024, description="Пароль должен быть от 8 до 1024 символов")
|
||||
role: Role
|
||||
service: str | None = None
|
||||
|
||||
|
|
@ -59,7 +117,7 @@ class UserPatch(BaseModel):
|
|||
role: Role | None = None
|
||||
service: str | None = None
|
||||
blocked: bool | None = None
|
||||
password: str | None = Field(default=None, min_length=8)
|
||||
password: str | None = Field(default=None, min_length=8, max_length=1024)
|
||||
|
||||
|
||||
def _out(user: User) -> UserOut:
|
||||
|
|
@ -148,8 +206,9 @@ async def patch_user(
|
|||
|
||||
if not changed:
|
||||
return _out(user)
|
||||
user.auth_version += 1
|
||||
await db.commit()
|
||||
invalidate_login(user.login)
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
await audit(who.login, who.role.value, "user.update", user.login, ", ".join(changed))
|
||||
return _out(user)
|
||||
|
||||
|
|
@ -169,12 +228,18 @@ async def audit_log(
|
|||
action: str | None = None,
|
||||
actor: str | None = None,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> list[AuditOut]:
|
||||
"""Журнал действий. Администратор его читает, но не правит: точки удаления
|
||||
или изменения записи здесь нет — ТЗ требует хранения, а не управления."""
|
||||
require(request, Role.ADMIN)
|
||||
query = select(AuditLog).order_by(AuditLog.at.desc()).limit(max(1, min(limit, 1000)))
|
||||
query = (
|
||||
select(AuditLog)
|
||||
.order_by(AuditLog.at.desc(), AuditLog.id.desc())
|
||||
.limit(max(1, min(limit, 1001)))
|
||||
.offset(max(0, min(offset, 10_000_000)))
|
||||
)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
if actor:
|
||||
|
|
@ -195,6 +260,104 @@ class ServiceState(BaseModel):
|
|||
detail: str
|
||||
|
||||
|
||||
class RuntimeMetrics(BaseModel):
|
||||
at: datetime
|
||||
uptime_seconds: float
|
||||
cpu_percent: float
|
||||
load_1m_percent: float | None
|
||||
cpu_cores: int
|
||||
rss_bytes: int | None
|
||||
memory_total_bytes: int | None
|
||||
memory_available_bytes: int | None
|
||||
disk_total_bytes: int
|
||||
disk_free_bytes: int
|
||||
threads: int
|
||||
active_sessions: int
|
||||
restored_sessions: int
|
||||
completed_sessions_24h: int
|
||||
|
||||
|
||||
class DiagnosticEvent(BaseModel):
|
||||
at: datetime
|
||||
level: str
|
||||
source: str
|
||||
message: str
|
||||
|
||||
|
||||
class FailureEvent(BaseModel):
|
||||
at: datetime
|
||||
actor: str
|
||||
action: str
|
||||
object_id: str | None
|
||||
detail: str
|
||||
|
||||
|
||||
class DiagnosticReport(BaseModel):
|
||||
generated_at: datetime
|
||||
metrics: RuntimeMetrics
|
||||
recent_system_events: list[DiagnosticEvent]
|
||||
failed_actions_24h: list[FailureEvent]
|
||||
|
||||
|
||||
async def _runtime_metrics(db: AsyncSession) -> RuntimeMetrics:
|
||||
from app.main import app
|
||||
|
||||
raw = sample_metrics()
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
completed = await db.scalar(
|
||||
select(func.count()).select_from(SessionRow).where(SessionRow.ended_at >= since)
|
||||
)
|
||||
return RuntimeMetrics(
|
||||
**raw,
|
||||
active_sessions=sum(not item.ended for item in hub._sessions.values()), # noqa: SLF001
|
||||
restored_sessions=getattr(app.state, "sessions_restored", 0),
|
||||
completed_sessions_24h=completed or 0,
|
||||
)
|
||||
|
||||
|
||||
async def _diagnostic_report(db: AsyncSession) -> DiagnosticReport:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
rows = await db.scalars(
|
||||
select(AuditLog)
|
||||
.where(AuditLog.at >= since, AuditLog.action.like("%.failed"))
|
||||
.order_by(AuditLog.at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
return DiagnosticReport(
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
metrics=await _runtime_metrics(db),
|
||||
recent_system_events=[DiagnosticEvent(**item) for item in recent_events(limit=100)],
|
||||
failed_actions_24h=[
|
||||
FailureEvent(
|
||||
at=row.at, actor=row.actor, action=row.action,
|
||||
object_id=row.object_id, detail=row.detail,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/diagnostics", response_model=DiagnosticReport)
|
||||
async def diagnostics(
|
||||
request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> DiagnosticReport:
|
||||
"""Live load plus a bounded, redacted incident report for the admin."""
|
||||
require(request, Role.ADMIN)
|
||||
return await _diagnostic_report(db)
|
||||
|
||||
|
||||
@router.get("/diagnostics.json")
|
||||
async def download_diagnostics(
|
||||
request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> JSONResponse:
|
||||
require(request, Role.ADMIN)
|
||||
report = await _diagnostic_report(db)
|
||||
return JSONResponse(
|
||||
jsonable_encoder(report),
|
||||
headers={"Content-Disposition": 'attachment; filename="lct-diagnostics.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=list[ServiceState])
|
||||
async def status(request: Request, db: AsyncSession = Depends(get_session)) -> list[ServiceState]:
|
||||
"""Состояние компонентов стенда — то, что администратор смотрит до занятия,
|
||||
|
|
@ -220,6 +383,21 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
|
|||
detail="распознавание и синтез готовы" if models_ready else "не загружены: занятие пойдёт без голоса",
|
||||
)
|
||||
)
|
||||
metrics = sample_metrics()
|
||||
disk_free = metrics["disk_free_bytes"]
|
||||
disk_total = max(metrics["disk_total_bytes"], 1)
|
||||
disk_ok = disk_free >= 1024 ** 3 and disk_free / disk_total >= 0.05
|
||||
load = metrics["load_1m_percent"]
|
||||
states.append(ServiceState(
|
||||
name="Нагрузка backend",
|
||||
ok=disk_ok and (load is None or load < 100),
|
||||
detail=(
|
||||
f"CPU процесса {metrics['cpu_percent']:.1f}%; "
|
||||
+ (f"нагрузка хоста {load:.1f}%; " if load is not None else "")
|
||||
+ f"RAM процесса {(metrics['rss_bytes'] or 0) / 1024 ** 2:.0f} МБ; "
|
||||
+ f"свободно на диске {disk_free / 1024 ** 3:.1f} ГБ"
|
||||
),
|
||||
))
|
||||
states.append(
|
||||
ServiceState(
|
||||
name="Эмбеддинги",
|
||||
|
|
@ -228,11 +406,20 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
|
|||
else "нет модели: подсказки идут по порядку чек-листа",
|
||||
)
|
||||
)
|
||||
llm_configured = (
|
||||
is_loopback_url(
|
||||
settings.llm_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
)
|
||||
if settings.offline or settings.llm_provider == "local"
|
||||
else bool(settings.llm_api_key and settings.llm_base_url)
|
||||
)
|
||||
states.append(
|
||||
ServiceState(
|
||||
name="Провайдер LLM",
|
||||
ok=bool(settings.llm_api_key and settings.llm_base_url),
|
||||
detail=settings.llm_base_url or "не настроен: звонящий читает офлайн-таблицу",
|
||||
ok=llm_configured,
|
||||
detail=(f"локальный адрес разрешён: {settings.llm_base_url}"
|
||||
if llm_configured else "не настроен: звонящий читает офлайн-таблицу"),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -248,9 +435,39 @@ async def status(request: Request, db: AsyncSession = Depends(get_session)) -> l
|
|||
ServiceState(
|
||||
name="Живых занятий",
|
||||
ok=True,
|
||||
detail=str(len(hub._sessions)), # noqa: SLF001 — реестр в памяти процесса
|
||||
detail=(
|
||||
f"активно {sum(not item.ended for item in hub._sessions.values())}; "
|
||||
f"восстановлено после запуска {getattr(app.state, 'sessions_restored', 0)}"
|
||||
), # noqa: SLF001 — реестр в памяти процесса
|
||||
)
|
||||
)
|
||||
try:
|
||||
copies = backup_service.listing()
|
||||
except backup_service.BackupError as exc:
|
||||
states.append(ServiceState(
|
||||
name="Резервное копирование",
|
||||
ok=False,
|
||||
detail=f"ошибка чтения копий: {exc}",
|
||||
))
|
||||
copies = []
|
||||
if copies:
|
||||
latest = copies[0]
|
||||
age_seconds = max(0.0, (datetime.now(timezone.utc) - latest["at"]).total_seconds())
|
||||
allowed_age = max(60, settings.backup_interval_seconds) + max(
|
||||
60, settings.backup_retry_seconds
|
||||
)
|
||||
states.append(ServiceState(
|
||||
name="Резервное копирование",
|
||||
ok=age_seconds <= allowed_age,
|
||||
detail=(f"последняя копия {latest['name']}, "
|
||||
f"{age_seconds / 3600:.1f} ч назад; хранится {len(copies)}"),
|
||||
))
|
||||
else:
|
||||
states.append(ServiceState(
|
||||
name="Резервное копирование",
|
||||
ok=False,
|
||||
detail="успешных копий ещё нет",
|
||||
))
|
||||
# Секрет сессии по умолчанию — не ошибка запуска, но на стенде это дыра,
|
||||
# и увидеть её должен администратор, а не проверяющий.
|
||||
default_secret = settings.session_secret.startswith("dev-secret")
|
||||
|
|
@ -273,7 +490,12 @@ class BackupOut(BaseModel):
|
|||
@router.get("/backups", response_model=list[BackupOut])
|
||||
async def backups(request: Request) -> list[BackupOut]:
|
||||
require(request, Role.ADMIN)
|
||||
return [BackupOut(**item) for item in await run_in_threadpool(backup_service.listing)]
|
||||
try:
|
||||
copies = await run_in_threadpool(backup_service.listing)
|
||||
except backup_service.BackupError as exc:
|
||||
detail = _safe_backup_error(exc)
|
||||
raise HTTPException(status_code=503, detail=detail) from exc
|
||||
return [BackupOut(**item) for item in copies]
|
||||
|
||||
|
||||
def _safe_backup_error(exc: backup_service.BackupError) -> str:
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.api.auth import audit, require
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.domain.roles import Role
|
||||
from app.scoring.group import ScoredAttempt, summarize
|
||||
from app.scoring.group_insight import InsightInvalid, generate_group_insight
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["groups"])
|
||||
|
||||
|
|
@ -26,6 +28,10 @@ class GroupCreate(BaseModel):
|
|||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class GroupOwnerPatch(BaseModel):
|
||||
owner_login: str | None
|
||||
|
||||
|
||||
class GroupErrorOut(BaseModel):
|
||||
code: str
|
||||
title: str
|
||||
|
|
@ -44,62 +50,23 @@ class GroupAnalyticsOut(BaseModel):
|
|||
errors: list[GroupErrorOut]
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
groups = await db.scalars(select(Group).order_by(Group.name))
|
||||
return [GroupOut(id=group.id, name=group.name) for group in groups]
|
||||
class GroupInsightOut(BaseModel):
|
||||
summary: str
|
||||
priorities: list[str]
|
||||
source: str = "local_qwen"
|
||||
personal_data_sent: bool = False
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=201)
|
||||
async def create(
|
||||
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="group_name_required")
|
||||
group = Group(name=name)
|
||||
db.add(group)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="group_exists") from exc
|
||||
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
|
||||
async def assign_trainee(
|
||||
group_id: UUID, trainee_id: UUID, request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
trainee.group_id = group_id
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
|
||||
async def analytics(
|
||||
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
async def _analytics(
|
||||
group_id: UUID, db: AsyncSession, *, owner_login: str | None = None
|
||||
) -> GroupAnalyticsOut:
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
if group is None or (owner_login is not None and group.owner_login != owner_login):
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
enrolled = await db.scalar(
|
||||
select(func.count()).select_from(Trainee).where(Trainee.group_id == group_id)
|
||||
)
|
||||
rows = await db.execute(
|
||||
attempts_query = (
|
||||
select(Session.trainee_id, Score.score_final, Score.report)
|
||||
.join(Score, Score.session_id == Session.id)
|
||||
.join(Trainee, Trainee.id == Session.trainee_id, isouter=True)
|
||||
|
|
@ -111,6 +78,9 @@ async def analytics(
|
|||
Session.ended_at.is_not(None),
|
||||
)
|
||||
)
|
||||
if owner_login is not None:
|
||||
attempts_query = attempts_query.where(Session.owner_login == owner_login)
|
||||
rows = await db.execute(attempts_query)
|
||||
attempts = [
|
||||
ScoredAttempt(
|
||||
trainee_id=trainee_id,
|
||||
|
|
@ -123,3 +93,116 @@ async def analytics(
|
|||
group=GroupOut(id=group.id, name=group.name),
|
||||
**summarize(attempts, int(enrolled or 0)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
async def listing(request: Request, db: AsyncSession = Depends(get_session)) -> list[GroupOut]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
query = select(Group).order_by(Group.name)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
query = query.where(Group.owner_login == who.login)
|
||||
groups = await db.scalars(query)
|
||||
return [GroupOut(id=group.id, name=group.name) for group in groups]
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=201)
|
||||
async def create(
|
||||
body: GroupCreate, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="group_name_required")
|
||||
group = Group(name=name, owner_login=who.login if who.role is Role.INSTRUCTOR else None)
|
||||
db.add(group)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="group_exists") from exc
|
||||
await audit(who.login, who.role.value, "group.create", str(group.id), group.name)
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.patch("/{group_id}/owner", response_model=GroupOut)
|
||||
async def transfer_ownership(
|
||||
group_id: UUID,
|
||||
body: GroupOwnerPatch,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> GroupOut:
|
||||
"""Администратор безопасно закрепляет legacy-группу за преподавателем."""
|
||||
who = require(request, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
if body.owner_login is not None:
|
||||
user = await db.scalar(select(User).where(User.login == body.owner_login))
|
||||
if user is None or user.role != Role.INSTRUCTOR.value or user.blocked:
|
||||
raise HTTPException(status_code=422, detail="active_instructor_required")
|
||||
previous_owner = group.owner_login
|
||||
group.owner_login = body.owner_login
|
||||
await db.commit()
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"group.transfer",
|
||||
str(group.id),
|
||||
f"{previous_owner or 'admin'} -> {body.owner_login or 'admin'}",
|
||||
)
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.put("/{group_id}/trainees/{trainee_id}", response_model=GroupOut)
|
||||
async def assign_trainee(
|
||||
group_id: UUID, trainee_id: UUID, request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> GroupOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None or (who.role is Role.INSTRUCTOR and group.owner_login != who.login):
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
if (
|
||||
who.role is Role.INSTRUCTOR
|
||||
and trainee.group_id is not None
|
||||
and trainee.group_id != group_id
|
||||
):
|
||||
current_group = await db.get(Group, trainee.group_id)
|
||||
if current_group is None or current_group.owner_login != who.login:
|
||||
raise HTTPException(status_code=409, detail="trainee_in_other_instructor_group")
|
||||
trainee.group_id = group_id
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "group.assign", str(group.id), str(trainee_id))
|
||||
return GroupOut(id=group.id, name=group.name)
|
||||
|
||||
|
||||
@router.get("/{group_id}/analytics", response_model=GroupAnalyticsOut)
|
||||
async def analytics(
|
||||
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupAnalyticsOut:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
return await _analytics(
|
||||
group_id, db, owner_login=who.login if who.role is Role.INSTRUCTOR else None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{group_id}/analytics/insight", response_model=GroupInsightOut)
|
||||
async def ai_insight(
|
||||
group_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> GroupInsightOut:
|
||||
"""Сформировать по запросу преподавателя локальный обезличенный инсайт."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
data = await _analytics(group_id, db, owner_login=who.login)
|
||||
if data.scored_attempts == 0:
|
||||
raise HTTPException(status_code=409, detail="no_scored_attempts")
|
||||
try:
|
||||
insight = await generate_group_insight(data.model_dump(mode="json"))
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except InsightInvalid as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "group.ai_insight", str(group_id), "aggregated_only")
|
||||
return GroupInsightOut(**insight)
|
||||
|
|
|
|||
549
backend/app/api/http/materials.py
Normal file
549
backend/app/api/http/materials.py
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
"""Учебные материалы, локальные вложения и назначения курсантам.
|
||||
|
||||
Файлы хранятся в PostgreSQL и отдаются только как attachment: учебный контур
|
||||
не зависит от внешнего файлового сервиса и не исполняет загруженный HTML.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, audit, require
|
||||
from app.config import get_settings
|
||||
from app.db.base import get_session
|
||||
from app.db.models import Group, LearningMaterial, MaterialAssignment, Trainee
|
||||
from app.domain.events import Exercise, ScenarioStart, SessionMode
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/materials", tags=["materials"])
|
||||
MAX_FILE_BYTES = 5 * 1024 * 1024
|
||||
LEVELS = {"L1", "L2", "L3"}
|
||||
|
||||
_DEMO_GUIDE_ID = UUID("00000000-0000-4000-8000-000000000901")
|
||||
_demo_materials: dict[UUID, LearningMaterial] = {}
|
||||
_demo_assignments: dict[tuple[UUID, UUID], dict] = {}
|
||||
|
||||
|
||||
def reset_demo_materials() -> None:
|
||||
"""Демонстрационный справочник воспроизводим после каждого старта."""
|
||||
_demo_materials.clear()
|
||||
_demo_assignments.clear()
|
||||
now = datetime.now(timezone.utc)
|
||||
_demo_materials[_DEMO_GUIDE_ID] = LearningMaterial(
|
||||
id=_DEMO_GUIDE_ID,
|
||||
title="Памятка диспетчера ДДС",
|
||||
description="Короткий алгоритм работы с готовой карточкой происшествия.",
|
||||
level="L1",
|
||||
kind="text",
|
||||
body=(
|
||||
"1. Подтвердите получение карточки.\n"
|
||||
"2. Проверьте зону ответственности и список оповещения.\n"
|
||||
"3. Назначьте бригаду и передайте адрес, событие и задачу.\n"
|
||||
"4. Фиксируйте выезд, прибытие, локализацию и завершение работ."
|
||||
),
|
||||
scenario_id="t01-1-fire-container",
|
||||
file_name=None,
|
||||
media_type=None,
|
||||
file_data=None,
|
||||
file_sha256=None,
|
||||
active=True,
|
||||
created_by="system",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
_demo_assignments[(_DEMO_GUIDE_ID, DEMO_TRAINEE_ID)] = {
|
||||
"assigned_by": "system", "assigned_at": now, "completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def material_session() -> AsyncIterator[AsyncSession | None]:
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class MaterialCreate(BaseModel):
|
||||
title: str = Field(min_length=3, max_length=200)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
level: Literal["L1", "L2", "L3"] = "L1"
|
||||
kind: Literal["text", "file"] = "text"
|
||||
body: str = Field(default="", max_length=30_000)
|
||||
scenario_id: str | None = Field(default=None, max_length=80)
|
||||
file_name: str | None = Field(default=None, max_length=240)
|
||||
media_type: str | None = Field(default=None, max_length=120)
|
||||
content_base64: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_content(self):
|
||||
if self.kind == "text" and not self.body.strip():
|
||||
raise ValueError("текст материала пуст")
|
||||
if self.kind == "file" and (not self.file_name or not self.content_base64):
|
||||
raise ValueError("для файла нужны имя и содержимое")
|
||||
return self
|
||||
|
||||
|
||||
class MaterialPatch(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=3, max_length=200)
|
||||
description: str | None = Field(default=None, max_length=2000)
|
||||
level: Literal["L1", "L2", "L3"] | None = None
|
||||
body: str | None = Field(default=None, max_length=30_000)
|
||||
scenario_id: str | None = Field(default=None, max_length=80)
|
||||
active: bool | None = None
|
||||
|
||||
|
||||
class MaterialOut(BaseModel):
|
||||
id: UUID
|
||||
title: str
|
||||
description: str
|
||||
level: str
|
||||
kind: str
|
||||
body: str
|
||||
scenario_id: str | None
|
||||
file_name: str | None
|
||||
media_type: str | None
|
||||
file_size: int
|
||||
file_sha256: str | None
|
||||
active: bool
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
assigned_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
assignment_count: int = 0
|
||||
|
||||
|
||||
def _out(
|
||||
row: LearningMaterial, *, assignment: MaterialAssignment | dict | None = None,
|
||||
assignment_count: int = 0,
|
||||
) -> MaterialOut:
|
||||
if isinstance(assignment, dict):
|
||||
assigned_at = assignment.get("assigned_at")
|
||||
completed_at = assignment.get("completed_at")
|
||||
else:
|
||||
assigned_at = assignment.assigned_at if assignment else None
|
||||
completed_at = assignment.completed_at if assignment else None
|
||||
return MaterialOut(
|
||||
id=row.id,
|
||||
title=row.title,
|
||||
description=row.description,
|
||||
level=row.level,
|
||||
kind=row.kind,
|
||||
body=row.body,
|
||||
scenario_id=row.scenario_id,
|
||||
file_name=row.file_name,
|
||||
media_type=row.media_type,
|
||||
file_size=len(row.file_data or b""),
|
||||
file_sha256=row.file_sha256,
|
||||
active=row.active,
|
||||
created_by=row.created_by,
|
||||
created_at=row.created_at,
|
||||
assigned_at=assigned_at,
|
||||
completed_at=completed_at,
|
||||
assignment_count=assignment_count,
|
||||
)
|
||||
|
||||
|
||||
def _decode_file(payload: MaterialCreate) -> bytes | None:
|
||||
if payload.kind != "file":
|
||||
return None
|
||||
try:
|
||||
data = base64.b64decode(payload.content_base64 or "", validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail="invalid_file_base64") from exc
|
||||
if not data:
|
||||
raise HTTPException(status_code=422, detail="empty_file")
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise HTTPException(status_code=413, detail="file_too_large_5mb")
|
||||
return data
|
||||
|
||||
|
||||
def _validate_scenario(scenario_id: str | None) -> None:
|
||||
if scenario_id and store.get(scenario_id) is None:
|
||||
raise HTTPException(status_code=422, detail="scenario_not_found")
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
"""Убрать и POSIX-, и Windows-путь; в БД остаётся только имя файла."""
|
||||
return value.replace("\\", "/").rsplit("/", 1)[-1] or "resource.bin"
|
||||
|
||||
|
||||
@router.get("", response_model=list[MaterialOut])
|
||||
async def listing(
|
||||
request: Request,
|
||||
include_archived: bool = False,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> list[MaterialOut]:
|
||||
who = require(request)
|
||||
if db is None:
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
result = []
|
||||
for material_id, row in _demo_materials.items():
|
||||
assignment = _demo_assignments.get((material_id, who.trainee_id))
|
||||
if assignment and row.active:
|
||||
result.append(_out(row, assignment=assignment))
|
||||
return result
|
||||
return [
|
||||
_out(row, assignment_count=sum(mid == row.id for mid, _ in _demo_assignments))
|
||||
for row in _demo_materials.values()
|
||||
if (include_archived or row.active)
|
||||
and (who.role is not Role.INSTRUCTOR or row.created_by in {who.login, "system"})
|
||||
]
|
||||
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
rows = await db.execute(
|
||||
select(LearningMaterial, MaterialAssignment)
|
||||
.join(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
|
||||
.where(
|
||||
MaterialAssignment.trainee_id == who.trainee_id,
|
||||
LearningMaterial.active.is_(True),
|
||||
)
|
||||
.order_by(MaterialAssignment.assigned_at.desc())
|
||||
)
|
||||
return [_out(row, assignment=assignment) for row, assignment in rows]
|
||||
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
statement = (
|
||||
select(LearningMaterial, func.count(MaterialAssignment.id))
|
||||
.outerjoin(MaterialAssignment, MaterialAssignment.material_id == LearningMaterial.id)
|
||||
.group_by(LearningMaterial.id)
|
||||
.order_by(LearningMaterial.active.desc(), LearningMaterial.updated_at.desc())
|
||||
)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
statement = statement.where(LearningMaterial.created_by == who.login)
|
||||
if not include_archived:
|
||||
statement = statement.where(LearningMaterial.active.is_(True))
|
||||
rows = await db.execute(statement)
|
||||
return [_out(row, assignment_count=count) for row, count in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=MaterialOut, status_code=201)
|
||||
async def create(
|
||||
payload: MaterialCreate,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> MaterialOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
_validate_scenario(payload.scenario_id)
|
||||
data = _decode_file(payload)
|
||||
now = datetime.now(timezone.utc)
|
||||
row = LearningMaterial(
|
||||
id=uuid4(),
|
||||
title=payload.title.strip(),
|
||||
description=payload.description.strip(),
|
||||
level=payload.level,
|
||||
kind=payload.kind,
|
||||
body=payload.body.strip(),
|
||||
scenario_id=payload.scenario_id,
|
||||
file_name=_safe_filename(payload.file_name) if payload.file_name else None,
|
||||
media_type=(payload.media_type or "application/octet-stream") if data else None,
|
||||
file_data=data,
|
||||
file_sha256=hashlib.sha256(data).hexdigest() if data else None,
|
||||
active=True,
|
||||
created_by=who.login,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
if db is None:
|
||||
_demo_materials[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.create", str(row.id), row.title)
|
||||
return _out(row)
|
||||
|
||||
|
||||
async def _material(db: AsyncSession | None, material_id: UUID) -> LearningMaterial | None:
|
||||
return _demo_materials.get(material_id) if db is None else await db.get(LearningMaterial, material_id)
|
||||
|
||||
|
||||
def _require_owner(row: LearningMaterial, login: str) -> None:
|
||||
"""Only the instructor who authored a resource may manage it."""
|
||||
if row.created_by != login:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
|
||||
|
||||
@router.patch("/{material_id}", response_model=MaterialOut)
|
||||
async def update(
|
||||
material_id: UUID,
|
||||
payload: MaterialPatch,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> MaterialOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await _material(db, material_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
_require_owner(row, who.login)
|
||||
patch = payload.model_dump(exclude_unset=True)
|
||||
if "scenario_id" in patch:
|
||||
patch["scenario_id"] = patch["scenario_id"] or None
|
||||
_validate_scenario(patch["scenario_id"])
|
||||
if row.kind == "text" and "body" in patch and not (patch["body"] or "").strip():
|
||||
raise HTTPException(status_code=422, detail="empty_material_body")
|
||||
for key, value in patch.items():
|
||||
setattr(row, key, value.strip() if isinstance(value, str) else value)
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.update", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
@router.delete("/{material_id}", response_model=MaterialOut)
|
||||
async def archive(
|
||||
material_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> MaterialOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await _material(db, material_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
_require_owner(row, who.login)
|
||||
row.active = False
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.archive", str(row.id))
|
||||
return _out(row)
|
||||
|
||||
|
||||
@router.put("/{material_id}/assign/{trainee_id}", response_model=MaterialOut)
|
||||
async def assign(
|
||||
material_id: UUID,
|
||||
trainee_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> MaterialOut:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await _material(db, material_id)
|
||||
if row is None or not row.active:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
_require_owner(row, who.login)
|
||||
if db is None:
|
||||
if trainee_id != DEMO_TRAINEE_ID:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
assignment = _demo_assignments.setdefault(
|
||||
(material_id, trainee_id),
|
||||
{"assigned_by": who.login, "assigned_at": datetime.now(timezone.utc), "completed_at": None},
|
||||
)
|
||||
else:
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
if trainee.group_id is not None:
|
||||
group = await db.get(Group, trainee.group_id)
|
||||
if group is None or group.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
assignment = await db.scalar(select(MaterialAssignment).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id == trainee_id,
|
||||
))
|
||||
if assignment is None:
|
||||
assignment = MaterialAssignment(
|
||||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
)
|
||||
db.add(assignment)
|
||||
await db.commit()
|
||||
await db.refresh(assignment)
|
||||
await audit(who.login, who.role.value, "material.assign", str(row.id), str(trainee_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
@router.put("/{material_id}/assign-group/{group_id}")
|
||||
async def assign_group(
|
||||
material_id: UUID,
|
||||
group_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await _material(db, material_id)
|
||||
if row is None or not row.active:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
_require_owner(row, who.login)
|
||||
if db is None:
|
||||
raise HTTPException(status_code=409, detail="groups_unavailable_in_demo")
|
||||
group = await db.get(Group, group_id)
|
||||
if group is None or group.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="group_not_found")
|
||||
trainee_ids = list(await db.scalars(select(Trainee.id).where(Trainee.group_id == group_id)))
|
||||
existing = set(await db.scalars(select(MaterialAssignment.trainee_id).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id.in_(trainee_ids),
|
||||
))) if trainee_ids else set()
|
||||
for trainee_id in trainee_ids:
|
||||
if trainee_id not in existing:
|
||||
db.add(MaterialAssignment(
|
||||
material_id=material_id, trainee_id=trainee_id, assigned_by=who.login
|
||||
))
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.assign_group", str(row.id), str(group_id))
|
||||
return {"material_id": str(row.id), "assigned": len(trainee_ids)}
|
||||
|
||||
|
||||
@router.delete("/{material_id}/assign/{trainee_id}")
|
||||
async def unassign(
|
||||
material_id: UUID,
|
||||
trainee_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await _material(db, material_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
_require_owner(row, who.login)
|
||||
if db is None:
|
||||
removed = _demo_assignments.pop((material_id, trainee_id), None) is not None
|
||||
else:
|
||||
assignment = await db.scalar(select(MaterialAssignment).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id == trainee_id,
|
||||
))
|
||||
if assignment is not None and assignment.assigned_by != who.login:
|
||||
raise HTTPException(status_code=404, detail="assignment_not_found")
|
||||
if assignment is not None:
|
||||
await db.delete(assignment)
|
||||
await db.commit()
|
||||
removed = assignment is not None
|
||||
await audit(who.login, who.role.value, "material.unassign", str(material_id), str(trainee_id))
|
||||
return {"removed": removed}
|
||||
|
||||
|
||||
@router.post("/{material_id}/complete", response_model=MaterialOut)
|
||||
async def complete(
|
||||
material_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> MaterialOut:
|
||||
who = require(request, Role.TRAINEE)
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
row = await _material(db, material_id)
|
||||
if row is None or not row.active:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
if db is None:
|
||||
assignment = _demo_assignments.get((material_id, who.trainee_id))
|
||||
else:
|
||||
assignment = await db.scalar(select(MaterialAssignment).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id == who.trainee_id,
|
||||
))
|
||||
if assignment is None:
|
||||
raise HTTPException(status_code=403, detail="material_not_assigned")
|
||||
completed_at = datetime.now(timezone.utc)
|
||||
if isinstance(assignment, dict):
|
||||
assignment["completed_at"] = completed_at
|
||||
else:
|
||||
assignment.completed_at = completed_at
|
||||
await db.commit()
|
||||
await audit(who.login, who.role.value, "material.complete", str(material_id))
|
||||
return _out(row, assignment=assignment)
|
||||
|
||||
|
||||
@router.post("/{material_id}/start")
|
||||
async def start_assigned_practice(
|
||||
material_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> dict:
|
||||
"""Курсант сам выбирает назначенный модуль и запускает текстовую практику."""
|
||||
who = require(request, Role.TRAINEE)
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
row = await _material(db, material_id)
|
||||
if row is None or not row.active:
|
||||
raise HTTPException(status_code=404, detail="material_not_found")
|
||||
if db is None:
|
||||
assignment = _demo_assignments.get((material_id, who.trainee_id))
|
||||
else:
|
||||
assignment = await db.scalar(select(MaterialAssignment).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id == who.trainee_id,
|
||||
))
|
||||
if assignment is None:
|
||||
raise HTTPException(status_code=403, detail="material_not_assigned")
|
||||
if not row.scenario_id:
|
||||
raise HTTPException(status_code=409, detail="material_has_no_practice")
|
||||
scenario = store.get(row.scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||
# Явное назначение преподавателя — и есть разрешение на самостоятельный
|
||||
# модуль. Оно не открывает курсанту остальные сценарии библиотеки.
|
||||
|
||||
from app.api.ws.control import _start
|
||||
|
||||
session_id = uuid4()
|
||||
await _start(session_id, ScenarioStart(
|
||||
scenario_id=scenario.id,
|
||||
trainee=who.full_name,
|
||||
trainee_id=who.trainee_id,
|
||||
mode=SessionMode.SELF,
|
||||
exercise=Exercise.CARD,
|
||||
), who)
|
||||
if hub.get(session_id) is None:
|
||||
raise HTTPException(status_code=409, detail="practice_start_failed")
|
||||
return {
|
||||
"session_id": str(session_id),
|
||||
"scenario_id": scenario.id,
|
||||
"mode": SessionMode.SELF.value,
|
||||
"exercise": Exercise.CARD.value,
|
||||
"path": f"/trainee?session={session_id}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{material_id}/download")
|
||||
async def download(
|
||||
material_id: UUID,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(material_session),
|
||||
) -> Response:
|
||||
who = require(request)
|
||||
row = await _material(db, material_id)
|
||||
if row is None or not row.active or row.kind != "file" or row.file_data is None:
|
||||
raise HTTPException(status_code=404, detail="file_not_found")
|
||||
if who.role is Role.TRAINEE:
|
||||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
if db is None:
|
||||
allowed = (material_id, who.trainee_id) in _demo_assignments
|
||||
else:
|
||||
allowed = await db.scalar(select(MaterialAssignment.id).where(
|
||||
MaterialAssignment.material_id == material_id,
|
||||
MaterialAssignment.trainee_id == who.trainee_id,
|
||||
)) is not None
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=403, detail="material_not_assigned")
|
||||
elif who.role is Role.INSTRUCTOR:
|
||||
_require_owner(row, who.login)
|
||||
elif who.role not in {Role.INSTRUCTOR, Role.ADMIN}:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
filename = _safe_filename(row.file_name or "resource.bin")
|
||||
disposition = f"attachment; filename=resource; filename*=UTF-8''{quote(filename)}"
|
||||
return Response(
|
||||
content=row.file_data,
|
||||
media_type=row.media_type or "application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": disposition,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'",
|
||||
},
|
||||
)
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.api.auth import audit, require
|
||||
from app.domain import ekp
|
||||
from app.db.base import get_session
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.generation import GenerationError, generate, generate_from_description
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
|
||||
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
|
||||
"""Только редактор в demo-lite использует временное хранилище без БД."""
|
||||
if get_settings().demo_no_db:
|
||||
yield None
|
||||
else:
|
||||
async for db in get_session():
|
||||
yield db
|
||||
|
||||
|
||||
class TemplateDraftIn(BaseModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class GenerateDraftIn(BaseModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
instruction: str = Field(min_length=10, max_length=1000)
|
||||
|
||||
|
||||
class GenerateFullDraftIn(BaseModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
description: str = Field(min_length=20, max_length=1500)
|
||||
|
||||
|
||||
class ReviseDraftIn(BaseModel):
|
||||
comment: str = Field(min_length=10, max_length=1000)
|
||||
|
||||
|
||||
def _draft_out(row) -> dict:
|
||||
if row.id.startswith("ai-full-"):
|
||||
generation = "ai_full"
|
||||
elif row.id.startswith("ai-"):
|
||||
generation = "ai_variant"
|
||||
else:
|
||||
generation = "template_copy"
|
||||
return {
|
||||
"id": row.id,
|
||||
"status": row.status,
|
||||
"generation": "template_copy",
|
||||
"generation": generation,
|
||||
"body": row.body,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
row = await store.create_draft(db, source=source, title=body.title)
|
||||
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/generate", status_code=201)
|
||||
async def create_ai_draft(
|
||||
body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
try:
|
||||
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
|
||||
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/generate-from-description", status_code=201)
|
||||
async def create_full_ai_draft(
|
||||
body: GenerateFullDraftIn, request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
"""Новый сюжет и мягкий эталон внутри выбранного класса ЕКП."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
source = store.get(body.source_id)
|
||||
if source is None:
|
||||
raise HTTPException(status_code=404, detail="published_source_not_found")
|
||||
try:
|
||||
proposal = await generate_from_description(source, body.description.strip())
|
||||
row = await store.create_draft(
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except GenerationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.get("/drafts/{scenario_id}")
|
||||
async def read_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
return _draft_out(row)
|
||||
|
|
@ -72,10 +150,10 @@ async def patch_draft(
|
|||
scenario_id: str,
|
||||
body: dict[str, Any],
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
|
|
@ -86,12 +164,36 @@ async def patch_draft(
|
|||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/revise")
|
||||
async def revise_ai_draft(
|
||||
scenario_id: str,
|
||||
body: ReviseDraftIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
source = validate(row.body)
|
||||
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
|
||||
row = await store.revise_draft(db, row, proposal)
|
||||
except LlmUnavailable as exc:
|
||||
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
||||
except (GenerationError, ScenarioError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
body.comment.strip()[:500])
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/validate")
|
||||
async def validate_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
|
|
@ -107,10 +209,10 @@ async def validate_draft(
|
|||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
row = await store.draft(db, scenario_id, owner_login=who.login)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft_not_found")
|
||||
try:
|
||||
|
|
@ -122,7 +224,15 @@ async def approve_draft(
|
|||
|
||||
|
||||
@router.get("")
|
||||
async def listing() -> list[dict]:
|
||||
async def listing(
|
||||
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> list[dict]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
owned_ids = (
|
||||
await store.owned_scenario_ids(db, who.login)
|
||||
if who is not None and who.role is Role.INSTRUCTOR
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": scenario.id,
|
||||
|
|
@ -131,19 +241,57 @@ async def listing() -> list[dict]:
|
|||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
"modes": scenario.modes,
|
||||
# Преподаватель должен видеть не только название карточки, но и
|
||||
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
|
||||
# этого пути, а не незаметно подменяет код происшествия.
|
||||
"signs": scenario.signs,
|
||||
"incident_code": scenario.ground_truth.incident_code,
|
||||
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
|
||||
"ticket": scenario.ticket,
|
||||
"position": scenario.position,
|
||||
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
|
||||
if scenario.ground_truth.incident_code
|
||||
and ekp.incident(scenario.ground_truth.incident_code) else None),
|
||||
"can_manage": scenario.id in owned_ids,
|
||||
}
|
||||
for scenario in store.all_scenarios()
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/{scenario_id}")
|
||||
async def archive_scenario(
|
||||
scenario_id: str, request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
"""Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
if hub.has_active_scenario(scenario_id):
|
||||
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
|
||||
scenario = await store.archive(db, scenario_id, owner_login=who.login)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
return {"id": scenario_id, "status": "archived", "title": scenario.title}
|
||||
|
||||
|
||||
@router.post("/{scenario_id}/restore")
|
||||
async def restore_scenario(
|
||||
scenario_id: str, request: Request,
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
return {"id": scenario_id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
@router.get("/{scenario_id}")
|
||||
async def read(scenario_id: str) -> dict:
|
||||
async def read(scenario_id: str, request: Request) -> dict:
|
||||
# Training content is local but not public: anonymous clients must not be
|
||||
# able to enumerate cards or inspect even the trainee-safe scenario body.
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
|
|
|
|||
|
|
@ -8,18 +8,23 @@ from datetime import datetime
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import audit, require
|
||||
from app.config import get_settings
|
||||
from app.db import repo
|
||||
from app.db.base import get_session
|
||||
from app.db.models import AuditLog, Score
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.scenarios import store
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
||||
|
|
@ -59,8 +64,11 @@ def _out(session) -> SessionOut:
|
|||
|
||||
@router.post("", response_model=SessionOut, status_code=201)
|
||||
async def create(body: SessionCreate, request: Request, db: AsyncSession = Depends(get_session)) -> SessionOut:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
group = await repo.ensure_group(db, body.group) if body.group else None
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
try:
|
||||
group = await repo.ensure_group(db, body.group, owner_login=who.login) if body.group else None
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=404, detail="group_not_found") from exc
|
||||
trainee = await repo.ensure_trainee(db, body.trainee, group) if body.trainee else None
|
||||
session = await repo.create_session(
|
||||
db,
|
||||
|
|
@ -68,6 +76,14 @@ async def create(body: SessionCreate, request: Request, db: AsyncSession = Depen
|
|||
mode=body.mode.value,
|
||||
trainee_id=trainee.id if trainee else None,
|
||||
group_id=group.id if group else None,
|
||||
owner_login=who.login,
|
||||
)
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"session.create",
|
||||
str(session.id),
|
||||
f"scenario={session.scenario_id}; mode={session.mode}; attempt={session.attempt}",
|
||||
)
|
||||
return _out(session)
|
||||
|
||||
|
|
@ -80,6 +96,8 @@ async def read(session_id: UUID, request: Request, db: AsyncSession = Depends(ge
|
|||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
return _out(session)
|
||||
|
||||
|
||||
|
|
@ -102,6 +120,8 @@ async def checklist(session_id: UUID, request: Request) -> list[ChecklistItemOut
|
|||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if not state.ended:
|
||||
raise HTTPException(status_code=409, detail="call_not_ended")
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
|
|
@ -118,7 +138,15 @@ class ScoreOverride(BaseModel):
|
|||
"""Коррекция оценки преподавателем. Автооценка сохраняется рядом."""
|
||||
|
||||
score_final: float = Field(ge=0, le=100)
|
||||
comment: str = ""
|
||||
comment: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@field_validator("comment")
|
||||
@classmethod
|
||||
def comment_must_not_be_blank(cls, comment: str) -> str:
|
||||
cleaned = comment.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("обоснование корректировки обязательно")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _live(session_id: UUID):
|
||||
|
|
@ -131,8 +159,9 @@ def _live(session_id: UUID):
|
|||
return state, scenario
|
||||
|
||||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(session_id: UUID, request: Request) -> SessionReport:
|
||||
async def _report_data(
|
||||
session_id: UUID, request: Request, db: AsyncSession,
|
||||
) -> SessionReport:
|
||||
"""Разбор сессии: метрики, отметки, эталонные вопросы, самооценка, пометки.
|
||||
|
||||
Обучающийся открывает только свой разбор: ТЗ запрещает доступ к чужим
|
||||
|
|
@ -140,20 +169,62 @@ async def report(session_id: UUID, request: Request) -> SessionReport:
|
|||
занятия, а не по номеру в ссылке.
|
||||
"""
|
||||
who = require(request)
|
||||
state, scenario = _live(session_id)
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
try:
|
||||
state, scenario = _live(session_id)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
state = None
|
||||
scenario = None
|
||||
if state is not None and scenario is not None:
|
||||
if who.role is Role.INSTRUCTOR and state.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and state.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
|
||||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if who.role is Role.TRAINEE and session.trainee_id != who.trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_session")
|
||||
if who.role is Role.TRAINEE and state.exercise is Exercise.CALL and not state.self_assessed:
|
||||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
if state.score is None:
|
||||
if who.role is Role.INSTRUCTOR and session.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
if score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
return build_report(session_id, state, scenario)
|
||||
archived = (score.report or {}).get("full_report")
|
||||
if archived is None:
|
||||
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||
data = SessionReport.model_validate(archived)
|
||||
if (who.role is Role.TRAINEE and data.reference_questions
|
||||
and data.self_assessment is None):
|
||||
raise HTTPException(status_code=409, detail="self_assessment_required")
|
||||
return data.model_copy(update={
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": score.score_final,
|
||||
"overridden_by": score.overridden_by,
|
||||
"override_comment": score.override_comment,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{session_id}/report", response_model=SessionReport)
|
||||
async def report(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
) -> SessionReport:
|
||||
return await _report_data(session_id, request, db)
|
||||
|
||||
|
||||
@router.get("/{session_id}/report.csv")
|
||||
async def report_csv(session_id: UUID, request: Request) -> Response:
|
||||
async def report_csv(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Те же права и готовность оценки, что у JSON-разбора."""
|
||||
data = await report(session_id, request)
|
||||
data = await _report_data(session_id, request, db)
|
||||
return Response(
|
||||
content=to_csv(data), media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-{session_id}-report.csv"'},
|
||||
|
|
@ -161,9 +232,11 @@ async def report_csv(session_id: UUID, request: Request) -> Response:
|
|||
|
||||
|
||||
@router.get("/{session_id}/report.pdf")
|
||||
async def report_pdf(session_id: UUID, request: Request) -> Response:
|
||||
async def report_pdf(
|
||||
session_id: UUID, request: Request, db: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""Печатный разбор; генерация полностью локальна."""
|
||||
data = await report(session_id, request)
|
||||
data = await _report_data(session_id, request, db)
|
||||
try:
|
||||
content = to_pdf(data)
|
||||
except RuntimeError as exc:
|
||||
|
|
@ -174,31 +247,120 @@ async def report_pdf(session_id: UUID, request: Request) -> Response:
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/recording.wav")
|
||||
async def recording(session_id: UUID, request: Request, db: AsyncSession = Depends(get_session)):
|
||||
"""Запись учебного звонка: преподавателю либо владельцу занятия."""
|
||||
who = require(request, Role.INSTRUCTOR, Role.TRAINEE)
|
||||
if get_settings().demo_no_db:
|
||||
state = hub.get(session_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
owner_id = state.trainee_id
|
||||
owner_login = state.owner_login
|
||||
ended = state.ended
|
||||
else:
|
||||
row = await repo.get_session(db, session_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
owner_id = row.trainee_id
|
||||
owner_login = row.owner_login
|
||||
ended = row.ended_at is not None
|
||||
if who.role is Role.TRAINEE and (owner_id is None or owner_id != who.trainee_id):
|
||||
raise HTTPException(status_code=403, detail="not_your_recording")
|
||||
if who.role is Role.INSTRUCTOR and owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="recording_not_found")
|
||||
if not ended:
|
||||
raise HTTPException(status_code=409, detail="recording_not_ready")
|
||||
path = recording_path(session_id)
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail="recording_not_found")
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="audio/wav",
|
||||
filename=f"session-{session_id}-recording.wav",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{session_id}/report", response_model=SessionReport)
|
||||
async def override(session_id: UUID, body: ScoreOverride, request: Request) -> SessionReport:
|
||||
async def override(
|
||||
session_id: UUID,
|
||||
body: ScoreOverride,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> SessionReport:
|
||||
"""Тренажёр готовит материал, преподаватель имеет последнее слово.
|
||||
|
||||
Администратору сюда нельзя: ТЗ запрещает ему вмешиваться в оценки прямо.
|
||||
"""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
state, scenario = _live(session_id)
|
||||
if state.score is None:
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
if session.owner_login != who.login:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
state = hub.get(session_id)
|
||||
live_ready = state is not None and state.score is not None
|
||||
scenario = (state.scenario or store.get(state.scenario_id)) if live_ready else None
|
||||
if live_ready and scenario is None:
|
||||
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||
score = await db.scalar(select(Score).where(Score.session_id == session_id))
|
||||
if score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
if hub.journal:
|
||||
await hub.journal.score_override(
|
||||
session_id, body.score_final, who.login, body.comment
|
||||
)
|
||||
await audit(
|
||||
who.login, who.role.value, "score.override", str(session_id),
|
||||
f"{state.score.get('score_auto')} → {body.score_final}: {body.comment}",
|
||||
)
|
||||
return build_report(session_id, state, scenario)
|
||||
archived = (score.report or {}).get("full_report")
|
||||
if not live_ready and archived is None:
|
||||
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||
|
||||
# Persist score and audit entry in the same request transaction: the API
|
||||
# must not report success if either durable record failed to commit.
|
||||
score.score_final = body.score_final
|
||||
score.overridden_by = who.login
|
||||
score.override_comment = body.comment
|
||||
report_payload = dict(score.report or {})
|
||||
if archived is not None:
|
||||
archived_payload = dict(archived)
|
||||
archived_payload.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
})
|
||||
report_payload["full_report"] = archived_payload
|
||||
score.report = report_payload
|
||||
db.add(AuditLog(
|
||||
actor=who.login,
|
||||
role=who.role.value,
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=f"{score.score_auto} → {body.score_final}: {body.comment}"[:2000],
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
if live_ready:
|
||||
assert state is not None and state.score is not None and scenario is not None
|
||||
state.score = {
|
||||
**state.score,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
state.score["full_report"] = {
|
||||
**state.score.get("full_report", {}),
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
}
|
||||
result = build_report(session_id, state, scenario)
|
||||
else:
|
||||
# Historical/archived sessions no longer have a live object in the hub.
|
||||
# Keep score correction available from the instructor's report history.
|
||||
result = SessionReport.model_validate(archived).model_copy(update={
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": body.score_final,
|
||||
"overridden_by": who.login,
|
||||
"override_comment": body.comment,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
|
|
@ -217,11 +379,13 @@ async def listing(
|
|||
if who.trainee_id is None:
|
||||
raise HTTPException(status_code=403, detail="trainee_profile_required")
|
||||
trainee = who.trainee_id
|
||||
owner_login = who.login if who.role is Role.INSTRUCTOR else None
|
||||
rows = await repo.history(
|
||||
db,
|
||||
trainee_id=trainee,
|
||||
group_id=group,
|
||||
mode=mode.value if mode else None,
|
||||
owner_login=owner_login,
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,24 +8,77 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.auth import DEMO_TRAINEE_ID, require
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.db.base import get_session, get_sessionmaker
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.db.models import Group, Score, Session, Trainee, User
|
||||
from app.scoring.export import certificate_pdf
|
||||
from app.voice.recording import recording_path
|
||||
|
||||
router = APIRouter(prefix="/api/trainees", tags=["trainees"])
|
||||
|
||||
|
||||
@router.get("/{trainee_id}/certificate.pdf")
|
||||
async def certificate(
|
||||
trainee_id: UUID, request: Request, db: AsyncSession = Depends(get_session)
|
||||
) -> Response:
|
||||
who = require(request)
|
||||
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_certificate")
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
owns_trainee = await db.scalar(
|
||||
select(Session.id)
|
||||
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
|
||||
.limit(1)
|
||||
)
|
||||
if owns_trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
|
||||
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
|
||||
group = None
|
||||
scores_query = (
|
||||
select(func.count(Score.id), func.avg(Score.score_final), func.max(Score.created_at))
|
||||
.join(Session, Session.id == Score.session_id)
|
||||
.where(Session.trainee_id == trainee_id)
|
||||
)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
scores_query = scores_query.where(Session.owner_login == who.login)
|
||||
result = await db.execute(scores_query)
|
||||
attempts, average_score, completed_at = result.one()
|
||||
if not attempts:
|
||||
raise HTTPException(status_code=409, detail="no_scored_attempts")
|
||||
try:
|
||||
content = certificate_pdf(
|
||||
trainee_name=trainee.name,
|
||||
trainee_id=trainee.id,
|
||||
group_name=group.name if group else None,
|
||||
attempts=int(attempts),
|
||||
average_score=float(average_score),
|
||||
issued_at=completed_at.date().isoformat(),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="trainee-{trainee_id}-certificate.pdf"'},
|
||||
)
|
||||
|
||||
|
||||
class TraineeOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
group: str | None = None
|
||||
service: str | None = None
|
||||
|
||||
|
||||
class AttemptOut(BaseModel):
|
||||
|
|
@ -40,6 +93,7 @@ class AttemptOut(BaseModel):
|
|||
facts_required: int | None = None
|
||||
hints: int | None = None
|
||||
codes: dict[str, int] = {}
|
||||
recording_available: bool = False
|
||||
|
||||
|
||||
class DeltaOut(BaseModel):
|
||||
|
|
@ -65,14 +119,39 @@ class ProfileOut(BaseModel):
|
|||
async def listing(request: Request) -> list[TraineeOut]:
|
||||
"""Список курсантов — преподавателю и администратору: обучающемуся он
|
||||
не нужен, а чужие фамилии из него видны."""
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN)
|
||||
if get_settings().demo_no_db:
|
||||
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант")]
|
||||
return [TraineeOut(id=DEMO_TRAINEE_ID, name="Демо-курсант", service="Служба 101")]
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = await db.execute(
|
||||
select(Trainee, Group.name).join(Group, Group.id == Trainee.group_id, isouter=True)
|
||||
query = (
|
||||
select(Trainee, Group.name, Group.owner_login, User.service)
|
||||
.join(Group, Group.id == Trainee.group_id, isouter=True)
|
||||
.join(User, User.trainee_id == Trainee.id, isouter=True)
|
||||
)
|
||||
return [TraineeOut(id=trainee.id, name=trainee.name, group=group) for trainee, group in rows]
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
has_owned_attempt = exists(
|
||||
select(Session.id).where(
|
||||
Session.trainee_id == Trainee.id,
|
||||
Session.owner_login == who.login,
|
||||
)
|
||||
)
|
||||
query = query.where(
|
||||
or_(
|
||||
Trainee.group_id.is_(None),
|
||||
Group.owner_login == who.login,
|
||||
has_owned_attempt,
|
||||
)
|
||||
)
|
||||
rows = await db.execute(query)
|
||||
return [
|
||||
TraineeOut(
|
||||
id=trainee.id,
|
||||
name=trainee.name,
|
||||
group=(group if who.role is not Role.INSTRUCTOR or group_owner == who.login else None),
|
||||
service=service,
|
||||
)
|
||||
for trainee, group, group_owner, service in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{trainee_id}/profile", response_model=ProfileOut)
|
||||
|
|
@ -82,17 +161,30 @@ async def profile(
|
|||
who = require(request)
|
||||
if who.role is Role.TRAINEE and who.trainee_id != trainee_id:
|
||||
raise HTTPException(status_code=403, detail="not_your_profile")
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
owns_trainee = await db.scalar(
|
||||
select(Session.id)
|
||||
.where(Session.trainee_id == trainee_id, Session.owner_login == who.login)
|
||||
.limit(1)
|
||||
)
|
||||
if owns_trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
trainee = await db.get(Trainee, trainee_id)
|
||||
if trainee is None:
|
||||
raise HTTPException(status_code=404, detail="trainee_not_found")
|
||||
group = await db.get(Group, trainee.group_id) if trainee.group_id else None
|
||||
if who.role is Role.INSTRUCTOR and group is not None and group.owner_login != who.login:
|
||||
group = None
|
||||
|
||||
rows = await db.execute(
|
||||
attempts_query = (
|
||||
select(Session, Score)
|
||||
.join(Score, Score.session_id == Session.id, isouter=True)
|
||||
.where(Session.trainee_id == trainee_id)
|
||||
.order_by(Session.created_at)
|
||||
)
|
||||
if who.role is Role.INSTRUCTOR:
|
||||
attempts_query = attempts_query.where(Session.owner_login == who.login)
|
||||
rows = await db.execute(attempts_query)
|
||||
attempts: list[AttemptOut] = []
|
||||
competency_sums: dict[str, list[float]] = {}
|
||||
for session, score in rows:
|
||||
|
|
@ -110,6 +202,7 @@ async def profile(
|
|||
facts_required=summary.get("facts_required"),
|
||||
hints=summary.get("hints"),
|
||||
codes=summary.get("codes", {}),
|
||||
recording_available=recording_path(session.id).is_file(),
|
||||
)
|
||||
)
|
||||
for item in (score.report or {}).get("competencies", []) if score else []:
|
||||
|
|
|
|||
|
|
@ -33,14 +33,15 @@ from app.domain.events import (
|
|||
)
|
||||
from app.domain.events import BgStart
|
||||
from app.scenarios import store
|
||||
from app.session.finish import finish, release_score
|
||||
from app.session.finish import finish, refresh_archived_report, release_score
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.roles import Role
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.domain.kio import ResponseStatus
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.models import TTS_RATE, get_voice_models
|
||||
from app.voice.pipeline import VoiceSession
|
||||
from app.voice.recording import start_recording
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -62,6 +63,8 @@ def _on_audio(session_id: UUID, state, frame: bytes) -> None:
|
|||
session_id, len(frame), FRAME_BYTES)
|
||||
return
|
||||
state.audio_frames += 1
|
||||
if state.recorder is not None:
|
||||
state.recorder.add_pcm(frame, sample_rate=16_000)
|
||||
if state.voice is not None:
|
||||
state.voice.feed(frame)
|
||||
if state.audio_frames % FRAMES_PER_LOG == 0:
|
||||
|
|
@ -138,6 +141,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
hub.to_observers(session_id, state.snapshot())
|
||||
if hub.journal:
|
||||
await hub.journal.session_started(session_id, state.started_at)
|
||||
if state.recorder is None:
|
||||
state.recorder = start_recording(session_id)
|
||||
_start_voice(session_id, state)
|
||||
|
||||
case "kio.patch":
|
||||
|
|
@ -220,6 +225,7 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
await hub.journal.self_assessment(
|
||||
session_id, event.missed, event.comment, now_utc()
|
||||
)
|
||||
await refresh_archived_report(session_id, state)
|
||||
# Оценка могла быть готова раньше самооценки — теперь её можно отдать.
|
||||
await release_score(session_id, state)
|
||||
|
||||
|
|
@ -237,6 +243,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
)
|
||||
await finish(session_id, state)
|
||||
|
||||
await hub.checkpoint(session_id)
|
||||
|
||||
|
||||
def _start_voice(session_id: UUID, state) -> None:
|
||||
"""Голос включается, когда курсант снял трубку: звонящий сразу кричит первую реплику."""
|
||||
|
|
@ -244,13 +252,18 @@ def _start_voice(session_id: UUID, state) -> None:
|
|||
scenario = store.get(state.scenario_id)
|
||||
if models is None or scenario is None or state.voice is not None:
|
||||
return
|
||||
def send_audio(pcm: bytes) -> None:
|
||||
if state.recorder is not None:
|
||||
state.recorder.add_pcm(pcm, sample_rate=TTS_RATE)
|
||||
hub.to_trainee(session_id, pcm)
|
||||
|
||||
state.voice = VoiceSession(
|
||||
session_id=session_id,
|
||||
state=state,
|
||||
models=models,
|
||||
send_event=lambda event: hub.to_trainee(session_id, event),
|
||||
send_observer=lambda event: hub.to_observers(session_id, event),
|
||||
send_audio=lambda pcm: hub.to_trainee(session_id, pcm),
|
||||
send_audio=send_audio,
|
||||
journal=hub.journal,
|
||||
)
|
||||
if scenario.background:
|
||||
|
|
|
|||
|
|
@ -8,43 +8,43 @@
|
|||
преподавателю, уходит на его же сокет `observe`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
CallEnded,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
ScoreReady,
|
||||
CallIncoming,
|
||||
CardBriefing,
|
||||
Exercise,
|
||||
StationState,
|
||||
InstructorToServer,
|
||||
InstructorNoteShown,
|
||||
ModeSet,
|
||||
ReferenceStarted,
|
||||
SessionEnded,
|
||||
)
|
||||
import asyncio
|
||||
|
||||
from app.dialog.factory import build_caller
|
||||
from app.api.auth import audit, principal_of
|
||||
from app.dialog.director import apply as apply_directive
|
||||
from app.dialog.director import mood_of
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.scenarios import store
|
||||
from app.api.auth import audit, principal_of
|
||||
from app.domain.events import (
|
||||
CallEnded,
|
||||
CallEndReason,
|
||||
CallIncoming,
|
||||
CardBriefing,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
InstructorNoteShown,
|
||||
InstructorToServer,
|
||||
ModeSet,
|
||||
ReferenceStarted,
|
||||
ScoreReady,
|
||||
SessionEnded,
|
||||
StationState,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.session.dds import prepare_queue
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.dds import prepare_card
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -93,10 +93,10 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
|
||||
scenario_ids = event.scenario_ids or [event.scenario_id]
|
||||
if event.exercise is Exercise.DDS:
|
||||
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 10:
|
||||
if not scenario_ids or scenario_ids[0] != event.scenario_id or len(scenario_ids) > 96:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.SCENARIO_INVALID,
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 10 карточек",
|
||||
message="Очередь ДДС должна начинаться с scenario_id и содержать не более 96 карточек",
|
||||
))
|
||||
return
|
||||
scenarios = [store.get(scenario_id) for scenario_id in scenario_ids]
|
||||
|
|
@ -110,10 +110,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
|
||||
attempt = 1
|
||||
recorded_trainee_id = event.trainee_id
|
||||
recorded_service = None
|
||||
if hub.journal:
|
||||
attempt, recorded_trainee_id = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id
|
||||
)
|
||||
try:
|
||||
attempt, recorded_trainee_id, recorded_service = await hub.journal.start_lesson(
|
||||
session_id, scenario.id, event.mode.value, event.trainee, event.trainee_id,
|
||||
owner_login=who.login if who is not None else None,
|
||||
)
|
||||
except PermissionError:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Занятие создано другим преподавателем",
|
||||
))
|
||||
return
|
||||
|
||||
# Занятие собирается целиком и только потом регистрируется: иначе
|
||||
# наблюдатель, подключившийся в эту щель, увидит полусобранное состояние
|
||||
|
|
@ -124,23 +133,35 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
scenario_title=scenario.title,
|
||||
level=scenario.level.value,
|
||||
mode=event.mode,
|
||||
owner_login=who.login if who is not None else None,
|
||||
exercise=event.exercise,
|
||||
handoff_to_dds=event.handoff_to_dds,
|
||||
scenario=scenario.model_copy(deep=True),
|
||||
required_fields=scenario.required_fields,
|
||||
trainee_name=event.trainee,
|
||||
trainee_id=recorded_trainee_id,
|
||||
dds_service=recorded_service or event.dds_service,
|
||||
attempt=attempt,
|
||||
criteria=event.criteria,
|
||||
)
|
||||
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
|
||||
if event.exercise is Exercise.CALL:
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(scenario.id)
|
||||
state.caller = build_caller(
|
||||
scenario.id,
|
||||
use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
elif event.exercise is Exercise.DDS:
|
||||
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
|
||||
prepare_card(state, state.dds_scenarios[0])
|
||||
prepare_queue(
|
||||
state,
|
||||
state.dds_scenarios,
|
||||
arrival_interval_seconds=event.dds_arrival_interval_seconds,
|
||||
max_waiting=event.dds_max_waiting,
|
||||
)
|
||||
state.started_at = state.dispatched_at
|
||||
else:
|
||||
state.started_at = now_utc()
|
||||
|
|
@ -155,13 +176,19 @@ async def _start(session_id: UUID, event, who=None) -> None:
|
|||
if models is not None:
|
||||
asyncio.create_task(prefetch(models, [scenario.first_line, *FILLERS.values()]))
|
||||
state.on_event("call.incoming")
|
||||
await hub.checkpoint(session_id)
|
||||
hub.start_ticker(session_id)
|
||||
if who is not None:
|
||||
# Запуск занятия меняет чужой результат — значит попадает в журнал
|
||||
# аудита (ТЗ, хранение не менее шести месяцев).
|
||||
asyncio.create_task(
|
||||
audit(who.login, who.role.value, "lesson.start", str(session_id),
|
||||
f"{scenario.id}, режим {event.mode.value}, курсант {event.trainee}")
|
||||
# Сохраняем до продолжения сценария, чтобы завершение процесса не
|
||||
# потеряло событие. ФИО курсанта в долгоживущий журнал не дублируем.
|
||||
await audit(
|
||||
who.login,
|
||||
who.role.value,
|
||||
"lesson.start",
|
||||
str(session_id),
|
||||
f"{scenario.id}, режим {event.mode.value}",
|
||||
)
|
||||
|
||||
if event.exercise is Exercise.CALL:
|
||||
|
|
@ -191,18 +218,22 @@ async def _stop(session_id: UUID) -> None:
|
|||
return
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.INSTRUCTOR
|
||||
if state.voice is not None:
|
||||
await state.voice.close()
|
||||
hub.stop_ticker(session_id)
|
||||
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
if state.exercise in {Exercise.DDS, Exercise.CARD}:
|
||||
from app.session.finish import finish
|
||||
from app.session.finish import finish
|
||||
|
||||
if state.exercise in {Exercise.DDS, Exercise.CARD}:
|
||||
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
|
||||
hub.to_station(session_id, SessionEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
await finish(session_id, state)
|
||||
if state.exercise is Exercise.DDS or state.handoff_to_dds and state.dispatched_card is not None:
|
||||
hub.to_station(session_id, ScoreReady(session_id=session_id))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=CallEndReason.INSTRUCTOR))
|
||||
await finish(session_id, state)
|
||||
if hub.journal:
|
||||
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.INSTRUCTOR.value)
|
||||
|
||||
|
|
@ -236,6 +267,21 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
)
|
||||
continue
|
||||
|
||||
existing_state = hub.get(session_id)
|
||||
if event.type == "scenario.start":
|
||||
if existing_state is not None:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Для нового занятия создайте новый номер сессии",
|
||||
))
|
||||
continue
|
||||
elif existing_state is None or existing_state.owner_login != who.login:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.FORBIDDEN,
|
||||
message="Это занятие принадлежит другому преподавателю",
|
||||
))
|
||||
continue
|
||||
|
||||
match event.type:
|
||||
case "scenario.start":
|
||||
await _start(session_id, event, who)
|
||||
|
|
@ -315,7 +361,7 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
if result.needs_network:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.DIRECTIVE_NEEDS_NETWORK,
|
||||
message="Свободный текст требует LLM: офлайн доступны только кнопки",
|
||||
message="Свободный текст требует локально запущенной модели; без неё доступны кнопки",
|
||||
))
|
||||
continue
|
||||
state.directives.append(event.directive)
|
||||
|
|
@ -338,5 +384,6 @@ async def control(ws: WebSocket, session_id: UUID) -> None:
|
|||
message=f"{event.type} ещё не реализовано",
|
||||
),
|
||||
)
|
||||
await hub.checkpoint(session_id)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -10,23 +10,41 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from app.domain.events import (
|
||||
CallEndReason, ErrorEvent, ErrorKind, Exercise, PhoneReport, ScoreReady,
|
||||
SessionEnded, StationState, StationToServer,
|
||||
)
|
||||
from app.domain.statuses import PRIMARY, PhoneReportRecord, ServiceStatus, StatusError, current
|
||||
from app.api.auth import principal_of
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
PhoneLine,
|
||||
PhoneReport,
|
||||
ScoreReady,
|
||||
SessionEnded,
|
||||
StationState,
|
||||
StationToServer,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import (
|
||||
PRIMARY,
|
||||
SERVICE_STATUS_LABELS,
|
||||
PhoneCallPending,
|
||||
PhoneLineRecord,
|
||||
PhoneReportRecord,
|
||||
ServiceStatus,
|
||||
StatusError,
|
||||
current,
|
||||
)
|
||||
from app.scoring.grammar import assess
|
||||
from app.session.dds import deliver_due_cards
|
||||
from app.session.finish import finish, score_current_dds
|
||||
from app.session.hub import hub
|
||||
from app.session.state import now_utc
|
||||
from app.session.finish import finish
|
||||
from app.session.finish import score_current_dds
|
||||
from app.session.dds import prepare_card
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
@ -34,24 +52,85 @@ router = APIRouter()
|
|||
_adapter = TypeAdapter(StationToServer)
|
||||
|
||||
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
|
||||
REPORT_TEXT = {
|
||||
"dispatched": "Бригада выехала к месту происшествия.",
|
||||
"arrived": "Бригада прибыла на место происшествия.",
|
||||
"working": "Бригада приступила к проведению работ.",
|
||||
"completed": "Работы завершены, бригада освобождена.",
|
||||
REQUIRED_STATUS = {
|
||||
"dispatched": ServiceStatus.ACCEPTED,
|
||||
"arrived": ServiceStatus.RESPONDING,
|
||||
"working": ServiceStatus.ARRIVED,
|
||||
"completed": ServiceStatus.WORKING,
|
||||
}
|
||||
REPORT_FOR_STATUS = {
|
||||
ServiceStatus.RESPONDING: "dispatched",
|
||||
ServiceStatus.ARRIVED: "arrived",
|
||||
ServiceStatus.WORKING: "working",
|
||||
ServiceStatus.COMPLETED: "completed",
|
||||
STATUS_AT_OR_AFTER = {
|
||||
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
|
||||
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
|
||||
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
|
||||
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
|
||||
}
|
||||
|
||||
|
||||
def _error(session_id: UUID, message: str) -> None:
|
||||
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
|
||||
|
||||
|
||||
def _line(session_id: UUID, state, speaker: str, text: str) -> None:
|
||||
call = state.phone_pending
|
||||
if call is None:
|
||||
return
|
||||
line = PhoneLineRecord(service=call.service, crew=call.crew,
|
||||
speaker=speaker, text=text, at=now_utc())
|
||||
state.phone_lines.append(line)
|
||||
state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
|
||||
hub.to_station(session_id, PhoneLine(**line.model_dump()))
|
||||
|
||||
|
||||
def _address_matches(expected: str | None, supplied: str) -> bool:
|
||||
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
|
||||
if not expected:
|
||||
return bool(supplied.strip())
|
||||
numbers = re.findall(r"\d+", expected)
|
||||
spoken_numbers = re.findall(r"\d+", supplied)
|
||||
words = re.findall(r"[а-яё]{4,}", expected.casefold())
|
||||
spoken_words = re.findall(r"[а-яё]{4,}", supplied.casefold())
|
||||
return (all(number in spoken_numbers for number in numbers)
|
||||
and any(word[:4] == spoken[:4] for word in words for spoken in spoken_words))
|
||||
|
||||
|
||||
def _incident_matches(state, supplied: str) -> bool:
|
||||
text = supplied.casefold()
|
||||
# Описание часто начинается с адреса: его нельзя считать совпадением
|
||||
# характера происшествия. Берём только название сценария и признаки ЕКП.
|
||||
source = " ".join((state.scenario_title, " ".join(state.kio.signs)))
|
||||
anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())}
|
||||
return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors)
|
||||
|
||||
|
||||
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
|
||||
normalized = text.casefold()
|
||||
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
|
||||
|
||||
|
||||
def _report_text(phase: str, crew: str, address: str) -> str:
|
||||
match phase:
|
||||
case "dispatched":
|
||||
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
|
||||
case "arrived":
|
||||
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
|
||||
case "working":
|
||||
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
|
||||
case _:
|
||||
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
|
||||
|
||||
|
||||
def _finish_phone_call(session_id: UUID, state) -> None:
|
||||
call = state.phone_pending
|
||||
assert call is not None
|
||||
text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки")
|
||||
_line(session_id, state, "crew", text)
|
||||
report = PhoneReportRecord(service=call.service, crew=call.crew,
|
||||
phase=call.phase, text=text, at=now_utc())
|
||||
state.phone_reports.append(report)
|
||||
state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
|
||||
state.phone_pending = None
|
||||
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||
|
||||
|
||||
async def _finish_dds(session_id: UUID, state) -> None:
|
||||
state.ended_at = now_utc()
|
||||
state.end_reason = CallEndReason.COMPLETE
|
||||
|
|
@ -75,22 +154,15 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
# диспетчер один и выбирать службу не из чего.
|
||||
state.on_event("card.ack")
|
||||
state.dds_log.append(("card.ack", now_utc(), None))
|
||||
services = state.notified_services()
|
||||
services = state.managed_services()
|
||||
if services:
|
||||
try:
|
||||
state.set_service_status(services[0], ServiceStatus.ACCEPTED)
|
||||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
if event.service not in state.notified_services():
|
||||
_error(session_id, "Служба отсутствует в списке оповещения карточки")
|
||||
return
|
||||
phase = REPORT_FOR_STATUS.get(event.status)
|
||||
if (state.exercise is Exercise.DDS or state.handoff_to_dds) and phase is not None and not any(
|
||||
report.service == event.service and report.phase == phase
|
||||
for report in state.phone_reports
|
||||
):
|
||||
_error(session_id, f"Статус «{event.status.value}» требует доклада бригады")
|
||||
if event.service not in state.managed_services():
|
||||
_error(session_id, "Можно менять статусы только своей ДДС")
|
||||
return
|
||||
try:
|
||||
state.set_service_status(
|
||||
|
|
@ -116,33 +188,73 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
):
|
||||
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
|
||||
return
|
||||
if state.phone_pending is not None:
|
||||
_error(session_id, "Завершите текущий разговор перед сменой бригады")
|
||||
return
|
||||
state.crew_selected = event.crew
|
||||
state.crew_assignments[service] = event.crew
|
||||
state.dds_log.append(("crew.select", now_utc(), event.crew))
|
||||
case "phone.dial":
|
||||
if state.phone_pending is not None:
|
||||
_error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
|
||||
return
|
||||
crew = state.crew_selected
|
||||
service = state.crew_service(crew) if crew else None
|
||||
if service is None:
|
||||
_error(session_id, "Сначала выберите бригаду")
|
||||
return
|
||||
if current(state.status_log, service) not in {
|
||||
ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
|
||||
ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}:
|
||||
_error(session_id, "Сначала примите карточку этой службы")
|
||||
return
|
||||
previous = [report for report in state.phone_reports if report.service == service]
|
||||
if len(previous) >= len(REPORT_PHASES):
|
||||
_error(session_id, "Все доклады этой бригады уже получены")
|
||||
return
|
||||
phase = REPORT_PHASES[len(previous)]
|
||||
report = PhoneReportRecord(
|
||||
service=service, crew=crew, phase=phase,
|
||||
text=REPORT_TEXT[phase], at=now_utc(),
|
||||
)
|
||||
state.phone_reports.append(report)
|
||||
required = REQUIRED_STATUS[phase]
|
||||
if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
|
||||
_error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
|
||||
return
|
||||
state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
|
||||
state.dds_log.append(("phone.dial", now_utc(), crew))
|
||||
hub.to_station(session_id, PhoneReport(**report.model_dump()))
|
||||
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
|
||||
"и что требуется от бригады."
|
||||
if phase == "dispatched" else
|
||||
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
|
||||
_line(session_id, state, "crew", greeting)
|
||||
case "phone.brief":
|
||||
call = state.phone_pending
|
||||
if call is None or call.phase != "dispatched":
|
||||
_error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
|
||||
return
|
||||
if not _address_matches(state.dispatched_card.address, event.address):
|
||||
_error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
|
||||
return
|
||||
if not _incident_matches(state, event.incident):
|
||||
_error(session_id, "Уточните характер происшествия по данным карточки")
|
||||
return
|
||||
if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
|
||||
"реагир", "помощ", "подтверд", "долож")):
|
||||
_error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
|
||||
return
|
||||
_line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. "
|
||||
f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
|
||||
_finish_phone_call(session_id, state)
|
||||
case "phone.check":
|
||||
call = state.phone_pending
|
||||
if call is None or call.phase == "dispatched":
|
||||
_error(session_id, "Сначала передайте вызов, затем запросите обстановку")
|
||||
return
|
||||
if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех",
|
||||
"выех", "работ", "заверш", "мест",
|
||||
"ход", "сообщ", "долож", "уточн")):
|
||||
_error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
|
||||
return
|
||||
_line(session_id, state, "dispatcher", event.text.strip())
|
||||
_finish_phone_call(session_id, state)
|
||||
case "phone.hangup":
|
||||
if state.phone_pending is None:
|
||||
_error(session_id, "Нет активного разговора")
|
||||
return
|
||||
state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew))
|
||||
state.phone_pending = None
|
||||
case "card.reply":
|
||||
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
|
|
@ -150,7 +262,18 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
_error(session_id, "Ответ относится не к текущей карточке")
|
||||
return
|
||||
state.reply_text = event.text
|
||||
state.reply_grammar = await assess(event.text)
|
||||
state.reply_log.append((now_utc(), event.text))
|
||||
case "card.open":
|
||||
if state.exercise is not Exercise.DDS or not state.activate_dds_card(event.card_id):
|
||||
_error(session_id, "Карточка отсутствует в текущей очереди")
|
||||
return
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
# CardReceived carries the contents, while StationState carries
|
||||
# the status journal and current queue. Send both on every switch
|
||||
# so the newly opened card cannot briefly inherit the previous
|
||||
# card's status snapshot until the next periodic tick.
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
case "card.next":
|
||||
if state.exercise is not Exercise.DDS or not state.dispatched_card or (
|
||||
event.card_id != state.dispatched_card.card_id
|
||||
|
|
@ -161,11 +284,32 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
_error(session_id, "Эта карточка уже завершена")
|
||||
return
|
||||
state.dds_completed.append(score_current_dds(state))
|
||||
if state.dds_card_index + 1 < len(state.dds_scenarios):
|
||||
state.dds_card_index += 1
|
||||
prepare_card(state, state.dds_scenarios[state.dds_card_index])
|
||||
completed_index = state.dds_card_index
|
||||
state.dds_live_cards = [
|
||||
item for item in state.dds_live_cards if item.card_id != event.card_id
|
||||
]
|
||||
remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index)
|
||||
if remaining:
|
||||
following = next(
|
||||
(item for item in remaining if item.original_index > completed_index),
|
||||
remaining[0],
|
||||
)
|
||||
state.activate_dds_card(following.card_id, capture=False)
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
else:
|
||||
# Keep the lesson alive if selected cards have not arrived yet.
|
||||
# The next delivery may become the active card immediately or
|
||||
# after its configured interval; no completed card is reused.
|
||||
state.dds_active_card_id = None
|
||||
state.dispatched_card = None
|
||||
state.dispatched_at = None
|
||||
state.dds_card_index = completed_index
|
||||
active_before_delivery = state.dds_active_card_id
|
||||
deliver_due_cards(state)
|
||||
if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery:
|
||||
hub.to_station(session_id, state.card_received_event())
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios):
|
||||
await _finish_dds(session_id, state)
|
||||
case "station.finish":
|
||||
if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
|
||||
|
|
@ -173,9 +317,11 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
return
|
||||
await _finish_dds(session_id, state)
|
||||
case "card.bounce":
|
||||
# Карточка вернулась: в разборе это E6 с конкретной причиной.
|
||||
state.bounced_fields = list(event.missing_fields)
|
||||
state.dds_log.append(("card.bounce", now_utc(), event.comment))
|
||||
_error(
|
||||
session_id,
|
||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112",
|
||||
)
|
||||
return
|
||||
case "zone.decision":
|
||||
state.on_event("zone.decision")
|
||||
state.dds_log.append(("zone.decision", now_utc(), "в зоне" if event.in_zone else "не в зоне"))
|
||||
|
|
@ -186,6 +332,8 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
state.on_event("crew.arrived")
|
||||
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
|
||||
state.dds_log.append(("crew.arrived", now_utc(), None))
|
||||
state.capture_active_dds()
|
||||
await hub.checkpoint(session_id)
|
||||
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
hub.to_observers(session_id, state.snapshot())
|
||||
|
||||
|
|
|
|||
|
|
@ -19,36 +19,63 @@ class Settings(BaseSettings):
|
|||
|
||||
# Голосовой контур
|
||||
models_dir: str = "models"
|
||||
stt_model: str = "gigaam-v3-rnnt"
|
||||
stt_model: str = "whisper-small"
|
||||
# Локальный файл GGML Whisper small для whisper.cpp. Имя модели из сети
|
||||
# не принимается: учебный контур не скачивает веса при запуске.
|
||||
whisper_model_path: str = "models/whisper-small"
|
||||
whisper_server_url: str = "http://127.0.0.1:18082"
|
||||
endpointing_ms: int = 600
|
||||
mic_sample_rate: int = 16_000
|
||||
tts_sample_rate: int = 24_000
|
||||
offline: bool = False
|
||||
# Голосовой контур: грузит ~5 с моделей при старте. В тестах выключен.
|
||||
voice_enabled: bool = True
|
||||
# STT/голос не входят в MVP ДДС; включаются лишь для отдельного эксперимента.
|
||||
voice_enabled: bool = False
|
||||
# Запись хранится локально и отдаётся только через авторизованный API.
|
||||
record_calls: bool = True
|
||||
recordings_dir: str = "recordings"
|
||||
# Автокопирование полного стенда. Интервал по умолчанию ровно сутки;
|
||||
# небольшой grace нужен только для отображения просрочки в мониторинге.
|
||||
backup_interval_seconds: int = 86_400
|
||||
backup_retry_seconds: int = 300
|
||||
backup_keep: int = 14
|
||||
|
||||
# LLM. Провайдер меняется значением, не кодом (docs/arch/STACK.md).
|
||||
# Имена COMPAT_MODEL_* принимаются тоже — так их выставляет командный сниппет.
|
||||
llm_provider: str = "openai_compatible"
|
||||
llm_provider: str = "local"
|
||||
llm_base_url: str = Field(
|
||||
default="", validation_alias=AliasChoices("llm_base_url", "compat_model_url")
|
||||
default="http://127.0.0.1:18080/v1",
|
||||
validation_alias=AliasChoices("llm_base_url", "compat_model_url"),
|
||||
)
|
||||
llm_api_key: str = Field(
|
||||
default="", validation_alias=AliasChoices("llm_api_key", "compat_model_api_key"),
|
||||
repr=False,
|
||||
)
|
||||
llm_model_caller: str = Field(
|
||||
default="", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
|
||||
default="Qwen3-1.7B", validation_alias=AliasChoices("llm_model_caller", "compat_model_name")
|
||||
)
|
||||
llm_model_judge: str = Field(
|
||||
default="", validation_alias=AliasChoices("llm_model_judge", "compat_model_name")
|
||||
)
|
||||
judge_temperature: float = 0.0
|
||||
# Vikhr — отдельный русскоязычный контрольный профиль диалога. Никакая
|
||||
# модель не решает, какие факты раскрыть, как маршрутизировать карточку или
|
||||
# какой балл поставить: это делает протокол и оценка в коде.
|
||||
llm_control_base_url: str = "http://127.0.0.1:18081/v1"
|
||||
llm_model_control: str = "Vikhr-1B"
|
||||
grammar_llm_enabled: bool = False
|
||||
dialogue_model_mode: str = "dialogue" # dialogue | russian_control
|
||||
# Docker Desktop даёт контейнеру специальное имя хоста. Оно разрешается
|
||||
# только явным флагом: обычный OFFLINE по-прежнему принимает лишь literal
|
||||
# loopback и не может незаметно уйти во внешний API.
|
||||
allow_docker_host_models: bool = False
|
||||
|
||||
# Вход и роли (lct-23). `session_secret` обязателен на рабочем стенде:
|
||||
# значение по умолчанию годится только для разработки, и администратор
|
||||
# видит в состоянии сервисов, что оно не заменено.
|
||||
session_secret: str = "dev-secret-поменять-на-стенде"
|
||||
# В TLS-профиле cookie уходит только по HTTPS/WSS. Для нативной разработки
|
||||
# на loopback флаг выключен, иначе браузер не сможет войти по HTTP.
|
||||
secure_cookies: bool = False
|
||||
#: Вход без пароля для `make lesson` и тестов. На стенде выключен.
|
||||
dev_auth_bypass: bool = False
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
"""учебные материалы и назначения
|
||||
|
||||
Revision ID: a4c9d1e2f3b4
|
||||
Revises: e6e2ab171dd7
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a4c9d1e2f3b4"
|
||||
down_revision = "e6e2ab171dd7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"learning_materials",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("title", sa.String(length=200), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("level", sa.String(length=4), nullable=False),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("scenario_id", sa.String(length=80), nullable=True),
|
||||
sa.Column("file_name", sa.String(length=240), nullable=True),
|
||||
sa.Column("media_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("file_data", sa.LargeBinary(), nullable=True),
|
||||
sa.Column("file_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=80), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["scenario_id"], ["scenarios.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
"material_assignments",
|
||||
sa.Column("id", sa.Uuid(), nullable=False),
|
||||
sa.Column("material_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("trainee_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("assigned_by", sa.String(length=80), nullable=False),
|
||||
sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["material_id"], ["learning_materials.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["trainee_id"], ["trainees.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_material_assignments_trainee",
|
||||
"material_assignments",
|
||||
["trainee_id", "assigned_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_material_assignments_trainee", table_name="material_assignments")
|
||||
op.drop_table("material_assignments")
|
||||
op.drop_table("learning_materials")
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""снимки активных занятий для восстановления
|
||||
|
||||
Revision ID: b5e0f2a3c4d5
|
||||
Revises: a4c9d1e2f3b4
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "b5e0f2a3c4d5"
|
||||
down_revision = "a4c9d1e2f3b4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sessions", sa.Column("live_state", postgresql.JSONB(), nullable=True))
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("checkpoint_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_sessions_active_checkpoint",
|
||||
"sessions",
|
||||
["checkpoint_at"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("ended_at IS NULL AND live_state IS NOT NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sessions_active_checkpoint", table_name="sessions")
|
||||
op.drop_column("sessions", "checkpoint_at")
|
||||
op.drop_column("sessions", "live_state")
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""сохраняемая версия полномочий пользователя
|
||||
|
||||
Revision ID: c6f1a3b4d5e6
|
||||
Revises: b5e0f2a3c4d5
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c6f1a3b4d5e6"
|
||||
down_revision = "b5e0f2a3c4d5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("auth_version", sa.Integer(), server_default="0", nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "auth_version")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""ограничение авторского контента и занятий владельцем-преподавателем
|
||||
|
||||
Revision ID: d7e2f4a5b6c7
|
||||
Revises: c6f1a3b4d5e6
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d7e2f4a5b6c7"
|
||||
down_revision = "c6f1a3b4d5e6"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scenarios",
|
||||
sa.Column("owner_login", sa.String(length=80), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"sessions",
|
||||
sa.Column("owner_login", sa.String(length=80), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sessions", "owner_login")
|
||||
op.drop_column("scenarios", "owner_login")
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""закрепить учебные группы за преподавателем
|
||||
|
||||
Revision ID: f7a3c9d1e2b4
|
||||
Revises: d7e2f4a5b6c7
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f7a3c9d1e2b4"
|
||||
down_revision = "d7e2f4a5b6c7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Existing groups are not auto-assigned: historical membership/analytics may
|
||||
# span instructors, so they remain admin-managed until explicitly migrated.
|
||||
op.add_column("groups", sa.Column("owner_login", sa.String(length=80), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("groups", "owner_login")
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
|
|
@ -26,6 +26,8 @@ class Group(Base):
|
|||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
# NULL — legacy/admin-managed group; instructor-created groups are private.
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
|
||||
|
|
@ -43,6 +45,52 @@ class Trainee(Base):
|
|||
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
|
||||
|
||||
|
||||
class LearningMaterial(Base):
|
||||
"""Локальный учебный материал или загруженный преподавателем ресурс."""
|
||||
|
||||
__tablename__ = "learning_materials"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
level: Mapped[str] = mapped_column(String(4), default="L1")
|
||||
kind: Mapped[str] = mapped_column(String(16), default="text")
|
||||
body: Mapped[str] = mapped_column(Text, default="")
|
||||
scenario_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
file_name: Mapped[str | None] = mapped_column(String(240))
|
||||
media_type: Mapped[str | None] = mapped_column(String(120))
|
||||
file_data: Mapped[bytes | None] = mapped_column(LargeBinary)
|
||||
file_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
active: Mapped[bool] = mapped_column(default=True)
|
||||
created_by: Mapped[str] = mapped_column(String(80))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class MaterialAssignment(Base):
|
||||
"""Назначение материала курсанту и факт изучения."""
|
||||
|
||||
__tablename__ = "material_assignments"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
material_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("learning_materials.id", ondelete="CASCADE")
|
||||
)
|
||||
trainee_id: Mapped[UUID] = mapped_column(ForeignKey("trainees.id", ondelete="CASCADE"))
|
||||
assigned_by: Mapped[str] = mapped_column(String(80))
|
||||
assigned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("material_id", "trainee_id", name="uq_material_assignment"),
|
||||
Index("ix_material_assignments_trainee", "trainee_id", "assigned_at"),
|
||||
)
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
|
||||
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
|
||||
|
|
@ -56,6 +104,9 @@ class Scenario(Base):
|
|||
topics: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
modes: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
# У черновика и утверждённого авторского сценария ровно один владелец;
|
||||
# NULL означает базовую/унаследованную системную библиотеку.
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
body: Mapped[dict] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
|
@ -74,6 +125,7 @@ class Session(Base):
|
|||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
|
||||
owner_login: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
|
|
@ -82,6 +134,10 @@ class Session(Base):
|
|||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_reason: Mapped[str | None] = mapped_column(String(16))
|
||||
kio: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
# Последний подтверждённый снимок незавершённого занятия. Он очищается при
|
||||
# завершении и позволяет новому backend-процессу продолжить карточку/ДДС.
|
||||
live_state: Mapped[dict | None] = mapped_column(JSONB)
|
||||
checkpoint_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
|
||||
|
|
@ -214,6 +270,9 @@ class User(Base):
|
|||
service: Mapped[str | None] = mapped_column(String(120))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
blocked: Mapped[bool] = mapped_column(default=False)
|
||||
# Версия полномочий попадает в подписанную cookie. Смена роли, пароля или
|
||||
# блокировки увеличивает её и отзывает старые cookie даже после restart.
|
||||
auth_version: Mapped[int] = mapped_column(default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ async def create_session(
|
|||
trainee_id: UUID | None = None,
|
||||
group_id: UUID | None = None,
|
||||
session_id: UUID | None = None,
|
||||
owner_login: str | None = None,
|
||||
) -> Session:
|
||||
session = Session(
|
||||
scenario_id=scenario_id,
|
||||
mode=mode,
|
||||
owner_login=owner_login,
|
||||
trainee_id=trainee_id,
|
||||
group_id=group_id,
|
||||
attempt=await next_attempt(db, trainee_id, scenario_id),
|
||||
|
|
@ -54,6 +56,7 @@ async def ensure_session(
|
|||
trainee_name: str | None = None,
|
||||
trainee_id: UUID | None = None,
|
||||
group_name: str | None = None,
|
||||
owner_login: str | None = None,
|
||||
) -> Session:
|
||||
"""Занятие, запущенное с пульта, должно иметь строку в журнале.
|
||||
|
||||
|
|
@ -62,9 +65,11 @@ async def ensure_session(
|
|||
"""
|
||||
existing = await db.get(Session, session_id)
|
||||
if existing is not None:
|
||||
if existing.owner_login != owner_login:
|
||||
raise PermissionError("занятие принадлежит другому преподавателю")
|
||||
return existing
|
||||
|
||||
group = await ensure_group(db, group_name) if group_name else None
|
||||
group = await ensure_group(db, group_name, owner_login=owner_login) if group_name else None
|
||||
trainee = await db.get(Trainee, trainee_id) if trainee_id else None
|
||||
if trainee_id and trainee is None:
|
||||
raise ValueError(f"курсант {trainee_id} не найден")
|
||||
|
|
@ -77,6 +82,7 @@ async def ensure_session(
|
|||
trainee_id=trainee.id if trainee else None,
|
||||
group_id=group.id if group else trainee.group_id if trainee else None,
|
||||
session_id=session_id,
|
||||
owner_login=owner_login,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -137,6 +143,7 @@ async def history(
|
|||
trainee_id: UUID | None = None,
|
||||
group_id: UUID | None = None,
|
||||
mode: str | None = None,
|
||||
owner_login: str | None = None,
|
||||
since: datetime | None = None,
|
||||
limit: int = 100,
|
||||
) -> list[Session]:
|
||||
|
|
@ -149,17 +156,23 @@ async def history(
|
|||
query = query.where(Session.group_id == group_id)
|
||||
if mode is not None:
|
||||
query = query.where(Session.mode == mode)
|
||||
if owner_login is not None:
|
||||
query = query.where(Session.owner_login == owner_login)
|
||||
if since is not None:
|
||||
query = query.where(Session.created_at >= since)
|
||||
return list(await db.scalars(query))
|
||||
|
||||
|
||||
async def ensure_group(db: AsyncSession, name: str) -> Group:
|
||||
async def ensure_group(
|
||||
db: AsyncSession, name: str, *, owner_login: str | None = None
|
||||
) -> Group:
|
||||
group = await db.scalar(select(Group).where(Group.name == name))
|
||||
if group is None:
|
||||
group = Group(name=name)
|
||||
group = Group(name=name, owner_login=owner_login)
|
||||
db.add(group)
|
||||
await db.commit()
|
||||
elif group.owner_login != owner_login:
|
||||
raise PermissionError("группа принадлежит другому преподавателю или администратору")
|
||||
return group
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
|
@ -20,6 +21,11 @@ from app.domain.events import Mood
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NUMBER_WORDS = {
|
||||
"один", "одна", "одно", "двое", "два", "две", "трое", "три", "четверо", "четыре",
|
||||
"пять", "шесть", "семь", "восемь", "девять", "десять",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallerLine:
|
||||
|
|
@ -152,6 +158,11 @@ class LlmCaller:
|
|||
persona.on_repeat()
|
||||
mood = persona.remember()
|
||||
|
||||
# Непонятная реплика не передаёт модели право импровизировать фактами.
|
||||
# Отбор фактов и вопросная карта — только слот-автомат, не LLM.
|
||||
if not (turn.revealed or turn.refined or turn.repeated):
|
||||
return await self._fallback.reply(turn, persona, slots)
|
||||
|
||||
facts = {fact.id: fact.value for fact in slots.revealed_facts()}
|
||||
say_now = [
|
||||
facts[fact_id]
|
||||
|
|
@ -161,7 +172,7 @@ class LlmCaller:
|
|||
repeated = [facts[fact_id] for fact_id in turn.repeated if fact_id in facts]
|
||||
|
||||
system = _prompt("caller.md").format(
|
||||
scenario=slots.scenario.title,
|
||||
scenario="учебное происшествие",
|
||||
mood=MOOD_WORDS.get(mood, mood.value),
|
||||
directive=_directive_line(persona),
|
||||
revealed="\n".join(f"- {value}" for value in facts.values()) or "- пока ничего",
|
||||
|
|
@ -173,13 +184,15 @@ class LlmCaller:
|
|||
repeated="; ".join(repeated), repeats=persona.repeats
|
||||
)
|
||||
|
||||
self._history.append({"role": "user", "content": turn.text})
|
||||
current_message = {"role": "user", "content": turn.text}
|
||||
try:
|
||||
text = await self._client.complete(
|
||||
LlmRequest(
|
||||
messages=[{"role": "system", "content": system}, *self._history[-6:]],
|
||||
messages=[{"role": "system", "content": system},
|
||||
*self._history[-6:], current_message],
|
||||
model=self._model,
|
||||
temperature=self._temperature,
|
||||
max_tokens=160,
|
||||
)
|
||||
)
|
||||
except LlmUnavailable as exc:
|
||||
|
|
@ -187,7 +200,13 @@ class LlmCaller:
|
|||
log.warning("звонящий на заготовках: %s", exc)
|
||||
return await self._fallback.reply(turn, persona, slots)
|
||||
|
||||
self._history.append({"role": "assistant", "content": text})
|
||||
if not _allowed_reply(text, facts, say_now + repeated, slots):
|
||||
self.fallbacks += 1
|
||||
log.warning("ответ модели нарушил протокол раскрытия фактов — использована заготовка")
|
||||
return await self._fallback.reply(turn, persona, slots)
|
||||
# Отклонённый ответ и провокационный вопрос не должны загрязнять
|
||||
# последующий контекст. Запоминаем только проверенную пару ходов.
|
||||
self._history.extend((current_message, {"role": "assistant", "content": text}))
|
||||
return CallerLine(text=text, mood=mood)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
|
@ -196,6 +215,48 @@ class LlmCaller:
|
|||
await self._client.aclose()
|
||||
|
||||
|
||||
def _allowed_reply(text: str, allowed: dict[str, str], required_now: list[str], slots: SlotMachine) -> bool:
|
||||
"""Консервативная граница для текста модели; протокол 112 всё равно в коде.
|
||||
|
||||
Невозможно доказать истинность произвольной русской фразы регулярками,
|
||||
поэтому сомнительный ответ заменяется детерминированной репликой.
|
||||
"""
|
||||
if not text or len(text) > 300 or "\n" in text or "<think>" in text.lower():
|
||||
return False
|
||||
normalized = text.casefold().replace("ё", "е")
|
||||
# Модель не вправе назвать числовой адрес или телефон, которого нет в
|
||||
# раскрытых фактах, даже если оператор предположил его в своей реплике.
|
||||
allowed_digits = set(re.findall(r"\d+", " ".join(allowed.values())))
|
||||
if any(number not in allowed_digits for number in re.findall(r"\d+", normalized)):
|
||||
return False
|
||||
allowed_words = set(re.findall(r"[а-яё]+", " ".join(allowed.values()).casefold().replace("ё", "е")))
|
||||
spoken_words = set(re.findall(r"[а-яё]+", normalized))
|
||||
if (spoken_words & NUMBER_WORDS) - allowed_words:
|
||||
return False
|
||||
allowed_stems = {word[:4] for word in allowed_words if len(word) >= 4}
|
||||
spoken_stems = {word[:4] for word in spoken_words if len(word) >= 4}
|
||||
for fact in slots.scenario.facts:
|
||||
if fact.id in allowed:
|
||||
continue
|
||||
for value in (fact.value, fact.refined):
|
||||
if not value:
|
||||
continue
|
||||
if len(value) >= 7 and value.casefold().replace("ё", "е") in normalized:
|
||||
return False
|
||||
# Полная фраза — не единственный способ слить скрытый факт:
|
||||
# «муж курил» раскрывает причину, даже без «на балконе».
|
||||
hidden_stems = {word[:4] for word in re.findall(
|
||||
r"[а-яё]{4,}", value.casefold().replace("ё", "е")
|
||||
)} - allowed_stems
|
||||
if hidden_stems & spoken_stems:
|
||||
return False
|
||||
# Новый обязательный факт нельзя опустить ради красивой реплики.
|
||||
for value in required_now:
|
||||
if value.casefold().replace("ё", "е") not in normalized:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
MOOD_WORDS = {
|
||||
Mood.PANIC: "паника, ты кричишь",
|
||||
Mood.AGGRESSIVE: "злость, ты срываешься на оператора",
|
||||
|
|
@ -210,6 +271,8 @@ def _directive_line(persona: PersonaState) -> str:
|
|||
|
||||
if persona.directive in SOFT:
|
||||
return f"ПРЕПОДАВАТЕЛЬ ВЕДЁТ СИТУАЦИЮ: {SOFT[persona.directive].lower()}."
|
||||
if persona.directive:
|
||||
return f"ПРЕПОДАВАТЕЛЬ ПРОСИТ ИЗМЕНИТЬ ПОДАЧУ: {persona.directive}. Факты не меняй."
|
||||
return ""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ class DirectiveResult:
|
|||
say: str | None = None
|
||||
#: Оборвать связь: звук на полуслове, дальше обратный дозвон.
|
||||
drop_line: bool = False
|
||||
#: Директива требует сети (свободный текст без LLM).
|
||||
#: Директива требует локально работающую модель (без неё — только кнопки).
|
||||
needs_network: bool = False
|
||||
|
||||
|
||||
|
|
@ -72,14 +72,15 @@ def apply(state, directive: str) -> DirectiveResult:
|
|||
state.slots.invalidate(fact.id)
|
||||
return DirectiveResult(applied=True, say=HARD_LINES[directive])
|
||||
|
||||
# Свободный текст уходит в контекст персоны — но подставить его в реплику
|
||||
# может только LLM. Офлайн-дерево предгенерировано, произвольную фразу
|
||||
# взять неоткуда (docs/arch/CONTRACT.md).
|
||||
# Свободный текст разрешён лишь в режиме локальной модели. Он управляет
|
||||
# интонацией, но не раскрывает факты и не меняет правила 112.
|
||||
from app.dialog.caller import LlmCaller
|
||||
|
||||
if not isinstance(state.caller, LlmCaller) or len(directive) > 500:
|
||||
return DirectiveResult(applied=False, needs_network=True)
|
||||
if state.persona is not None:
|
||||
state.persona.directive = None
|
||||
if state.directives is not None:
|
||||
state.directives.append(directive)
|
||||
return DirectiveResult(applied=False, needs_network=True)
|
||||
state.persona.directive = directive
|
||||
return DirectiveResult(applied=True)
|
||||
|
||||
|
||||
def mood_of(state) -> Mood:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Кто играет звонящего: LLM, если есть ключ, иначе заготовки.
|
||||
"""Кто играет звонящего: локальная LLM, таблица или заготовки.
|
||||
|
||||
Провайдер и модель меняются значением в конфиге, а не кодом. Заготовки —
|
||||
не запасной костыль, а рабочий режим: занятие идёт и без сети.
|
||||
|
|
@ -9,30 +9,45 @@ import logging
|
|||
from app.config import get_settings
|
||||
from app.dialog.caller import Caller, LlmCaller, TemplateCaller
|
||||
from app.dialog.llm import LlmClient
|
||||
from app.dialog.llm import is_loopback_url
|
||||
from app.dialog.tree import TreeCaller, has_table
|
||||
from app.db.base import get_sessionmaker
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_caller(scenario_id: str | None = None, sessionmaker=None) -> Caller:
|
||||
def build_caller(
|
||||
scenario_id: str | None = None,
|
||||
sessionmaker=None,
|
||||
*,
|
||||
use_pregenerated: bool = False,
|
||||
) -> Caller:
|
||||
settings = get_settings()
|
||||
|
||||
# Офлайн и «нет ключа» — это один и тот же путь: предгенерированная таблица,
|
||||
# а не локальная модель. Формулировки в ней от облачной модели, а задержка
|
||||
# нулевая (docs/arch/STACK.md).
|
||||
if (settings.offline or not settings.llm_api_key) and scenario_id and has_table(scenario_id):
|
||||
local = settings.llm_provider == "local"
|
||||
mode = settings.dialogue_model_mode
|
||||
if mode not in {"dialogue", "russian_control"}:
|
||||
raise ValueError(f"неизвестный DIALOGUE_MODEL_MODE: {mode}")
|
||||
model = settings.llm_model_control if mode == "russian_control" else settings.llm_model_caller
|
||||
base_url = settings.llm_control_base_url if mode == "russian_control" else settings.llm_base_url
|
||||
model_allowed = settings.llm_provider != "disabled" and bool(model and base_url and (
|
||||
is_loopback_url(base_url, allow_docker_host=settings.allow_docker_host_models)
|
||||
if settings.offline or local else settings.llm_api_key
|
||||
))
|
||||
|
||||
# Локальный loopback не считается внешней сетью. Если сервер модели не
|
||||
# отвечает, LlmCaller откатится на проверенные заготовки этой же реплики.
|
||||
if model_allowed:
|
||||
client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker(), base_url=base_url)
|
||||
log.info("звонящий: режим %s, модель %s, адрес %s", mode, model, base_url)
|
||||
return LlmCaller(client, model=model)
|
||||
|
||||
if use_pregenerated and scenario_id and has_table(scenario_id):
|
||||
log.info("звонящий по предгенерированной таблице сценария %s", scenario_id)
|
||||
return TreeCaller(scenario_id)
|
||||
|
||||
if not settings.llm_api_key or settings.offline:
|
||||
reason = "офлайн-режим" if settings.offline else "нет ключа LLM"
|
||||
log.info("звонящий отвечает заготовками: %s (таблицы нет — make pregen)", reason)
|
||||
return TemplateCaller()
|
||||
|
||||
client = LlmClient(sessionmaker=sessionmaker or _safe_sessionmaker())
|
||||
log.info("звонящий на модели %s", settings.llm_model_caller)
|
||||
return LlmCaller(client, model=settings.llm_model_caller)
|
||||
log.info("звонящий отвечает фактами сценария: модель отключена или предгенерация не разрешена")
|
||||
return TemplateCaller()
|
||||
|
||||
|
||||
def _safe_sessionmaker():
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Клиент облачной LLM за интерфейсом: провайдер меняется значением в конфиге.
|
||||
"""Клиент совместимого API для локальных или внешних моделей.
|
||||
|
||||
Кэш ответов по хешу контекста лежит в Postgres, а не в Redis: база уже поднята,
|
||||
лишняя движущаяся часть на стенде не нужна (docs/arch/STACK.md). Кэш работает
|
||||
|
|
@ -9,7 +9,10 @@
|
|||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
|
|
@ -26,6 +29,48 @@ class LlmUnavailable(RuntimeError):
|
|||
занятие продолжается — молчащий звонящий хуже шаблонной фразы."""
|
||||
|
||||
|
||||
def is_loopback_url(value: str, *, allow_docker_host: bool = False) -> bool:
|
||||
"""В офлайн-режиме модели разрешены лишь на той же машине.
|
||||
|
||||
Не доверяем доменам или hosts-записям: они могут указывать наружу.
|
||||
"""
|
||||
try:
|
||||
url = urlsplit(value)
|
||||
host = url.hostname or ""
|
||||
try:
|
||||
local_host = ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
local_host = allow_docker_host and host == "host.docker.internal"
|
||||
return (url.scheme == "http" and local_host and url.port is not None
|
||||
and not url.username and not url.password)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _spoken_content(raw: str, *, strip_reasoning: bool = False) -> str:
|
||||
"""Убрать только пустой служебный хвост Qwen3, не рассуждения модели.
|
||||
|
||||
llama.cpp с выключенным thinking иногда возвращает в `content` один
|
||||
закрывающий `</think>` перед самой репликой. Внутренний текст размышлений
|
||||
мы намеренно не вырезаем: если он есть, ответ небезопасен и идёт fallback.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if strip_reasoning:
|
||||
closing = list(re.finditer(r"</think>\s*", text, re.IGNORECASE))
|
||||
if closing:
|
||||
text = text[closing[-1].end():].strip()
|
||||
elif text.startswith("<|"):
|
||||
start = text.find("{")
|
||||
if start >= 0:
|
||||
text = text[start:].strip()
|
||||
text = re.sub(r"^(?:</think>\s*)+", "", text, flags=re.IGNORECASE).strip()
|
||||
if re.search(r"</?think\b", text, re.IGNORECASE) or "<|" in text:
|
||||
raise LlmUnavailable("ответ содержит служебные токены модели")
|
||||
if not text:
|
||||
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
|
||||
return text
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmRequest:
|
||||
messages: list[dict]
|
||||
|
|
@ -34,10 +79,15 @@ class LlmRequest:
|
|||
# С запасом на рассуждающие модели: Qwen3 тратит на размышление сотни токенов
|
||||
# и при малом бюджете возвращает пустой ответ с finish_reason="length".
|
||||
max_tokens: int = 400
|
||||
response_format: dict | None = None
|
||||
# Только для внутренних структурированных задач. В репликах звонящего
|
||||
# рассуждение всегда отвергается, чтобы оно не попало в эфир.
|
||||
strip_reasoning: bool = False
|
||||
|
||||
def cache_key(self) -> str:
|
||||
payload = json.dumps(
|
||||
{"m": self.model, "t": self.temperature, "msgs": self.messages},
|
||||
{"m": self.model, "t": self.temperature, "msgs": self.messages,
|
||||
"format": self.response_format, "strip_reasoning": self.strip_reasoning},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
)
|
||||
|
|
@ -50,36 +100,43 @@ class LlmClient:
|
|||
*,
|
||||
sessionmaker: async_sessionmaker | None = None,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
base_url: str | None = None,
|
||||
# Ответ дольше этого бессмысленен: бюджет хода — 1.5 с, а звонящий
|
||||
# с заготовками ответит сразу.
|
||||
timeout: float = 8.0,
|
||||
) -> None:
|
||||
settings = get_settings()
|
||||
self._base_url = settings.llm_base_url.rstrip("/")
|
||||
self._base_url = (base_url or settings.llm_base_url).rstrip("/")
|
||||
self._key = settings.llm_api_key
|
||||
self._local_only = settings.offline or settings.llm_provider == "local"
|
||||
self._allow_docker_host = settings.allow_docker_host_models
|
||||
self._sessionmaker = sessionmaker
|
||||
self._client = httpx.AsyncClient(timeout=timeout, transport=transport)
|
||||
self._client = httpx.AsyncClient(timeout=timeout, transport=transport, trust_env=False)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
if self._local_only:
|
||||
return is_loopback_url(
|
||||
self._base_url, allow_docker_host=self._allow_docker_host
|
||||
)
|
||||
return bool(self._key and self._base_url)
|
||||
|
||||
async def complete(self, request: LlmRequest, *, use_cache: bool = True) -> str:
|
||||
"""Ответ модели. Кэш по хешу контекста: та же реплика на том же месте
|
||||
занятия звучит одинаково у каждой группы."""
|
||||
if not self.configured:
|
||||
raise LlmUnavailable("не задан ключ или адрес провайдера")
|
||||
raise LlmUnavailable("локальный адрес модели недопустим или провайдер не настроен")
|
||||
|
||||
key = request.cache_key()
|
||||
if use_cache:
|
||||
cached = await self._from_cache(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return _spoken_content(cached, strip_reasoning=request.strip_reasoning)
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self._base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self._key}"},
|
||||
headers={"Authorization": f"Bearer {self._key}"} if self._key else {},
|
||||
json={
|
||||
"model": request.model,
|
||||
"messages": request.messages,
|
||||
|
|
@ -88,6 +145,8 @@ class LlmClient:
|
|||
# Рассуждение в ответе не нужно: оно только раздувает трафик.
|
||||
# Провайдеры, которые про это поле не знают, его игнорируют.
|
||||
"reasoning": {"exclude": True},
|
||||
**({"response_format": request.response_format}
|
||||
if request.response_format is not None else {}),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
|
|
@ -97,13 +156,14 @@ class LlmClient:
|
|||
# Тело ошибки в лог, ключ в заголовке — не логируется.
|
||||
raise LlmUnavailable(f"HTTP {response.status_code}: {response.text[:200]}")
|
||||
|
||||
message = response.json()["choices"][0]["message"]
|
||||
text = (message.get("content") or "").strip()
|
||||
if not text:
|
||||
# У рассуждающих моделей при нехватке бюджета весь ответ уходит
|
||||
# в размышление, а content приходит пустым. Для занятия это отказ:
|
||||
# звонящий откатится на заготовку, а не промолчит.
|
||||
raise LlmUnavailable("пустой ответ модели: весь бюджет токенов ушёл в рассуждение")
|
||||
try:
|
||||
message = response.json()["choices"][0]["message"]
|
||||
content = message.get("content")
|
||||
if content is not None and not isinstance(content, str):
|
||||
raise TypeError("content не строка")
|
||||
text = _spoken_content(content or "", strip_reasoning=request.strip_reasoning)
|
||||
except (ValueError, KeyError, IndexError, TypeError, AttributeError) as exc:
|
||||
raise LlmUnavailable("некорректный ответ локальной модели") from exc
|
||||
if use_cache and text:
|
||||
await self._to_cache(key, request, text)
|
||||
return text
|
||||
|
|
|
|||
|
|
@ -19,3 +19,5 @@
|
|||
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
|
||||
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
|
||||
6. Отвечай только репликой, без пояснений и без кавычек.
|
||||
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
|
||||
полностью и дословно. Одного «да», «нет» или намёка недостаточно.
|
||||
|
|
|
|||
|
|
@ -15,14 +15,15 @@ from enum import StrEnum
|
|||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
|
||||
from app.domain.classifiers import DDSCode, Level, Outcome
|
||||
from app.domain.kio import KIO, Coords
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import ServiceStatus, StationSnapshot
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerSnapshot
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
|
||||
|
||||
class SessionMode(StrEnum):
|
||||
|
|
@ -40,6 +41,30 @@ class Exercise(StrEnum):
|
|||
CARD = "card"
|
||||
|
||||
|
||||
class LessonCriteria(BaseModel):
|
||||
"""Настраиваемые преподавателем условия именно этого занятия.
|
||||
|
||||
Нормативы ГОСТ для приёма вызова сюда не входят. Для занятия меняются
|
||||
учебный лимит решения, порог успешности и веса метрик; веса сценария
|
||||
остаются базовыми, а настройки занятия могут их переопределить.
|
||||
"""
|
||||
|
||||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("score_weights")
|
||||
@classmethod
|
||||
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
|
||||
unknown = weights.keys() - METRIC_MAP.keys()
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
|
||||
if any(not 0 <= weight <= 10 for weight in weights.values()):
|
||||
raise ValueError("каждый вес метрики должен быть от 0 до 10")
|
||||
return weights
|
||||
|
||||
|
||||
class Speaker(StrEnum):
|
||||
CALLER = "caller"
|
||||
OPERATOR = "operator"
|
||||
|
|
@ -346,6 +371,7 @@ class SessionSnapshot(BaseModel):
|
|||
level: Level
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
|
||||
trainee_name: str | None = None
|
||||
started_at: datetime | None = None
|
||||
kio: KIO
|
||||
|
|
@ -420,12 +446,20 @@ class ScenarioStart(BaseModel):
|
|||
type: Literal["scenario.start"] = "scenario.start"
|
||||
scenario_id: str
|
||||
scenario_ids: list[str] | None = None
|
||||
# Pace defaults to simultaneous for older clients; the instructor UI
|
||||
# explicitly sends its slower training default.
|
||||
dds_arrival_interval_seconds: int = Field(default=0, ge=0, le=300)
|
||||
dds_max_waiting: int = Field(default=3, ge=1, le=10)
|
||||
trainee: str
|
||||
trainee_id: UUID | None = None
|
||||
# Служба обучающегося определяет, чьи статусы он ведёт. В рабочем режиме
|
||||
# сервер заменяет это значение данными учётной записи; поле нужно demo без БД.
|
||||
dds_service: str | None = None
|
||||
group_id: str | None = None
|
||||
mode: SessionMode
|
||||
exercise: Exercise = Exercise.CALL
|
||||
handoff_to_dds: bool = False
|
||||
criteria: LessonCriteria = Field(default_factory=LessonCriteria)
|
||||
|
||||
|
||||
class DirectorInject(BaseModel):
|
||||
|
|
@ -527,6 +561,35 @@ class PhoneDial(BaseModel):
|
|||
type: Literal["phone.dial"] = "phone.dial"
|
||||
|
||||
|
||||
class PhoneBrief(BaseModel):
|
||||
"""Что диспетчер передал старшему группы при первом исходящем звонке."""
|
||||
|
||||
type: Literal["phone.brief"] = "phone.brief"
|
||||
address: str = Field(min_length=3, max_length=300)
|
||||
incident: str = Field(min_length=3, max_length=500)
|
||||
request: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class PhoneCheck(BaseModel):
|
||||
"""Запрос диспетчера об обстановке после предыдущего доклада."""
|
||||
|
||||
type: Literal["phone.check"] = "phone.check"
|
||||
text: str = Field(min_length=8, max_length=500)
|
||||
|
||||
|
||||
class PhoneHangup(BaseModel):
|
||||
type: Literal["phone.hangup"] = "phone.hangup"
|
||||
|
||||
|
||||
class PhoneLine(BaseModel):
|
||||
type: Literal["phone.line"] = "phone.line"
|
||||
service: str
|
||||
crew: str
|
||||
speaker: Literal["dispatcher", "crew"]
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class PhoneReport(BaseModel):
|
||||
type: Literal["phone.report"] = "phone.report"
|
||||
service: str
|
||||
|
|
@ -551,6 +614,13 @@ class CardNext(BaseModel):
|
|||
card_id: UUID
|
||||
|
||||
|
||||
class CardOpen(BaseModel):
|
||||
"""Открыть одну из уже выданных карточек, не меняя её таймер."""
|
||||
|
||||
type: Literal["card.open"] = "card.open"
|
||||
card_id: UUID
|
||||
|
||||
|
||||
class StationState(BaseModel):
|
||||
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
|
||||
|
||||
|
|
@ -574,13 +644,14 @@ class CrewArrived(BaseModel):
|
|||
|
||||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
StationToServer = Annotated[
|
||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | StationFinish
|
||||
| CardReply | CardNext
|
||||
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
|
||||
| PhoneHangup | StationFinish
|
||||
| CardReply | CardNext | CardOpen
|
||||
| ZoneDecision | CrewDispatched | CrewArrived,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -651,6 +722,9 @@ class SessionReport(BaseModel):
|
|||
scenario_id: str
|
||||
mode: SessionMode
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria
|
||||
failed_metrics: int
|
||||
passed: bool
|
||||
transcript: list[TranscriptEntry]
|
||||
findings: list[Finding]
|
||||
metrics: list[Metric]
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ class KIO(BaseModel):
|
|||
caller_number: str | None = None
|
||||
caller_name: str | None = None
|
||||
caller_contact: str | None = None
|
||||
phone_on_scene: str | None = None
|
||||
language: str = "ru"
|
||||
|
||||
# Место
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ ROLE_LABELS: dict[Role, str] = {
|
|||
#: настройки, администратор не вмешивается в оценки, обучающийся не видит
|
||||
#: чужих результатов.
|
||||
SCREENS: dict[Role, tuple[str, ...]] = {
|
||||
Role.ADMIN: ("/admin", "/profile"),
|
||||
Role.INSTRUCTOR: ("/instructor", "/wall", "/profile", "/dds"),
|
||||
Role.TRAINEE: ("/trainee", "/profile"),
|
||||
Role.ADMIN: ("/admin", "/profile", "/groups", "/materials"),
|
||||
Role.INSTRUCTOR: (
|
||||
"/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials",
|
||||
),
|
||||
Role.TRAINEE: ("/trainee", "/dds", "/phone", "/profile", "/materials"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,12 +135,46 @@ class PhoneReportRecord(BaseModel):
|
|||
at: datetime
|
||||
|
||||
|
||||
class PhoneLineRecord(BaseModel):
|
||||
service: str
|
||||
crew: str
|
||||
speaker: str # dispatcher | crew
|
||||
text: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class PhoneCallPending(BaseModel):
|
||||
service: str
|
||||
crew: str
|
||||
phase: str
|
||||
|
||||
|
||||
class DdsCardSummary(BaseModel):
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
|
||||
|
||||
class DdsQueueCard(BaseModel):
|
||||
"""Строка общей очереди ДДС; таймер каждой карточки идёт независимо."""
|
||||
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
title: str
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime
|
||||
managed_service: str | None = None
|
||||
service_status: ServiceStatus
|
||||
card_status: CardStatus
|
||||
elapsed_ms: int
|
||||
limit_ms: int
|
||||
timer_stopped: bool
|
||||
active: bool
|
||||
|
||||
|
||||
class StatusError(ValueError):
|
||||
"""Переход запрещён автоматом или нет обязательного комментария."""
|
||||
|
||||
|
|
@ -208,6 +242,8 @@ class StationSnapshot(BaseModel):
|
|||
"""
|
||||
|
||||
services: list[str]
|
||||
recipient_services: list[str] = []
|
||||
managed_service: str | None = None
|
||||
statuses: dict[str, ServiceStatus]
|
||||
available: dict[str, list[ServiceStatus]]
|
||||
card: CardStatus
|
||||
|
|
@ -215,8 +251,14 @@ class StationSnapshot(BaseModel):
|
|||
crew_options: list[str] = []
|
||||
crew_selected: str | None = None
|
||||
phone_reports: list[PhoneReportRecord] = []
|
||||
phone_lines: list[PhoneLineRecord] = []
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
card_id: UUID | None = None
|
||||
card_index: int = 1
|
||||
card_total: int = 1
|
||||
reply_text: str = ""
|
||||
completed_cards: list[DdsCardSummary] = []
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
pending_cards_count: int = 0
|
||||
next_arrival_in_seconds: int | None = None
|
||||
max_waiting_cards: int = 3
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from app.api import auth
|
|||
from app.api.http import admin as admin_api
|
||||
from app.api.http import ekp as ekp_api
|
||||
from app.api.http import groups as groups_api
|
||||
from app.api.http import materials as materials_api
|
||||
from app.api.http import scenarios as scenarios_api
|
||||
from app.api.http import sessions
|
||||
from app.api.http import trainees
|
||||
|
|
@ -29,6 +30,7 @@ from app.scenarios import store
|
|||
from app.session.hub import hub
|
||||
from app.session.journal import DbJournal
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.monitoring import install_diagnostics
|
||||
|
||||
|
||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
||||
|
|
@ -38,6 +40,7 @@ LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
|||
# а до лога доходят одни предупреждения. Чужие библиотеки — от WARNING.
|
||||
logging.basicConfig(level=logging.WARNING, format="%(levelname)-8s %(name)s: %(message)s")
|
||||
logging.getLogger("app").setLevel(logging.INFO)
|
||||
install_diagnostics()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -52,6 +55,9 @@ async def lifespan(app: FastAPI):
|
|||
except ScenarioError as exc:
|
||||
raise RuntimeError(f"библиотека сценариев не прошла проверку: {exc}") from exc
|
||||
app.state.scenarios_loaded = len(loaded)
|
||||
if settings.demo_no_db:
|
||||
store.reset_demo_drafts()
|
||||
materials_api.reset_demo_materials()
|
||||
|
||||
# Утверждённые преподавателем сценарии хранятся в БД и должны переживать
|
||||
# перезапуск процесса. При недоступной БД остаётся базовая YAML-библиотека.
|
||||
|
|
@ -71,8 +77,34 @@ async def lifespan(app: FastAPI):
|
|||
"DEMO_NO_DB: занятия и оценки живут только до перезапуска; журнал БД выключен"
|
||||
)
|
||||
|
||||
if not settings.demo_no_db:
|
||||
try:
|
||||
async with asyncio.timeout(2):
|
||||
await auth.load_generations()
|
||||
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"не удалось загрузить версии полномочий: %s", exc
|
||||
)
|
||||
|
||||
# Журнал: всё, что не записано, для оценки не существует.
|
||||
hub.journal = None if settings.demo_no_db else DbJournal(get_sessionmaker())
|
||||
app.state.sessions_restored = 0
|
||||
if hub.journal is not None:
|
||||
try:
|
||||
async with asyncio.timeout(3):
|
||||
restored = await hub.journal.restore_active()
|
||||
for state in restored:
|
||||
hub.register(state)
|
||||
hub.start_ticker(state.session_id)
|
||||
app.state.sessions_restored = len(restored)
|
||||
if restored:
|
||||
logging.getLogger(__name__).info(
|
||||
"восстановлено активных занятий: %d", len(restored)
|
||||
)
|
||||
except (SQLAlchemyError, OSError, TimeoutError) as exc:
|
||||
logging.getLogger(__name__).warning(
|
||||
"не удалось восстановить активные занятия: %s", exc
|
||||
)
|
||||
|
||||
# Эмбеддинги для слот-автомата — грузятся один раз, до первого занятия.
|
||||
app.state.embeddings_ready = not settings.demo_no_db and get_embedder() is not None
|
||||
|
|
@ -81,6 +113,10 @@ async def lifespan(app: FastAPI):
|
|||
app.state.models_ready = get_voice_models() is not None
|
||||
yield
|
||||
|
||||
if hub.journal is not None:
|
||||
for state in list(hub._sessions.values()):
|
||||
if not state.ended:
|
||||
await hub.journal.checkpoint(state)
|
||||
await hub.shutdown()
|
||||
for state in list(hub._sessions.values()):
|
||||
if state.voice is not None:
|
||||
|
|
@ -94,7 +130,7 @@ app.add_middleware(
|
|||
SessionMiddleware,
|
||||
secret_key=get_settings().session_secret,
|
||||
session_cookie="lct_session",
|
||||
https_only=False, # учебный класс живёт по http во внутренней сети
|
||||
https_only=get_settings().secure_cookies,
|
||||
max_age=12 * 60 * 60, # смена занятий, не месяц
|
||||
)
|
||||
app.include_router(auth.router)
|
||||
|
|
@ -102,6 +138,7 @@ app.include_router(sessions.router)
|
|||
app.include_router(scenarios_api.router)
|
||||
app.include_router(ekp_api.router)
|
||||
app.include_router(groups_api.router)
|
||||
app.include_router(materials_api.router)
|
||||
app.include_router(admin_api.router)
|
||||
app.include_router(trainees.router)
|
||||
app.include_router(call_ws.router)
|
||||
|
|
@ -119,6 +156,7 @@ async def health() -> dict:
|
|||
"models_ready": getattr(app.state, "models_ready", False),
|
||||
"scenarios_loaded": getattr(app.state, "scenarios_loaded", 0),
|
||||
"embeddings_ready": getattr(app.state, "embeddings_ready", False),
|
||||
"sessions_restored": getattr(app.state, "sessions_restored", 0),
|
||||
"offline": settings.offline,
|
||||
"demo_no_db": settings.demo_no_db,
|
||||
}
|
||||
|
|
|
|||
167
backend/app/monitoring.py
Normal file
167
backend/app/monitoring.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Small, dependency-free runtime diagnostics for the local stand.
|
||||
|
||||
The product has to run on Windows as well as Linux. Keep the collector in
|
||||
the standard library: unavailable host counters are returned as ``None``
|
||||
instead of making the administrator screen fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
STARTED_MONOTONIC = time.monotonic()
|
||||
_events: deque[dict] = deque(maxlen=500)
|
||||
_installed = False
|
||||
_sample_lock = threading.Lock()
|
||||
_last_wall = time.monotonic()
|
||||
_last_cpu = time.process_time()
|
||||
|
||||
_CREDENTIALS = re.compile(r"(://[^:/\s]+:)([^@/\s]+)(@)")
|
||||
_TOKENS = re.compile(
|
||||
r"(?i)\b(session_secret|llm_api_key|authorization|password)\b\s*[:=]\s*([^\s,;]+)"
|
||||
)
|
||||
|
||||
|
||||
def _safe(message: str) -> str:
|
||||
message = _CREDENTIALS.sub(r"\1[скрыто]\3", message)
|
||||
message = _TOKENS.sub(r"\1=[скрыто]", message)
|
||||
return message.replace("\x00", "")[:2000]
|
||||
|
||||
|
||||
class DiagnosticHandler(logging.Handler):
|
||||
"""Bounded warning/error journal safe enough to expose to an admin."""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
_events.append({
|
||||
"at": datetime.fromtimestamp(record.created, timezone.utc),
|
||||
"level": record.levelname,
|
||||
"source": record.name[:120],
|
||||
"message": _safe(record.getMessage()),
|
||||
})
|
||||
except Exception: # logging must never take the service down
|
||||
pass
|
||||
|
||||
|
||||
def install_diagnostics() -> None:
|
||||
global _installed
|
||||
if _installed:
|
||||
return
|
||||
handler = DiagnosticHandler(level=logging.WARNING)
|
||||
logging.getLogger().addHandler(handler)
|
||||
_installed = True
|
||||
|
||||
|
||||
def recent_events(*, limit: int = 100) -> list[dict]:
|
||||
return list(_events)[-max(1, min(limit, 500)):][::-1]
|
||||
|
||||
|
||||
def _rss_bytes() -> int | None:
|
||||
try:
|
||||
if sys.platform.startswith("linux"):
|
||||
resident_pages = int(Path("/proc/self/statm").read_text().split()[1])
|
||||
return resident_pages * int(os.sysconf("SC_PAGE_SIZE"))
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class Counters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD),
|
||||
("PeakWorkingSetSize", ctypes.c_size_t),
|
||||
("WorkingSetSize", ctypes.c_size_t),
|
||||
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("PagefileUsage", ctypes.c_size_t),
|
||||
("PeakPagefileUsage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = Counters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
ctypes.windll.psapi.GetProcessMemoryInfo( # type: ignore[attr-defined]
|
||||
ctypes.windll.kernel32.GetCurrentProcess(), ctypes.byref(counters), counters.cb
|
||||
)
|
||||
return int(counters.WorkingSetSize)
|
||||
import resource
|
||||
|
||||
value = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
return int(value if sys.platform == "darwin" else value * 1024)
|
||||
except (OSError, ValueError, AttributeError, ImportError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _memory() -> tuple[int | None, int | None]:
|
||||
try:
|
||||
if sys.platform.startswith("linux"):
|
||||
fields: dict[str, int] = {}
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
name, value = line.split(":", 1)
|
||||
fields[name] = int(value.strip().split()[0]) * 1024
|
||||
return fields.get("MemTotal"), fields.get("MemAvailable")
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
|
||||
class MemoryStatus(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("length", ctypes.c_ulong), ("memory_load", ctypes.c_ulong),
|
||||
("total_physical", ctypes.c_ulonglong),
|
||||
("available_physical", ctypes.c_ulonglong),
|
||||
("total_page_file", ctypes.c_ulonglong),
|
||||
("available_page_file", ctypes.c_ulonglong),
|
||||
("total_virtual", ctypes.c_ulonglong),
|
||||
("available_virtual", ctypes.c_ulonglong),
|
||||
("available_extended_virtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
status = MemoryStatus()
|
||||
status.length = ctypes.sizeof(status)
|
||||
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) # type: ignore[attr-defined]
|
||||
return int(status.total_physical), int(status.available_physical)
|
||||
page = int(os.sysconf("SC_PAGE_SIZE"))
|
||||
return int(os.sysconf("SC_PHYS_PAGES")) * page, None
|
||||
except (OSError, ValueError, AttributeError, ImportError):
|
||||
return None, None
|
||||
|
||||
|
||||
def sample_metrics() -> dict:
|
||||
"""Return a cheap point-in-time sample; the first CPU sample is zero."""
|
||||
global _last_wall, _last_cpu
|
||||
now = time.monotonic()
|
||||
cpu_now = time.process_time()
|
||||
with _sample_lock:
|
||||
wall_delta = max(now - _last_wall, 0.001)
|
||||
cpu_percent = max(0.0, (cpu_now - _last_cpu) / wall_delta * 100.0)
|
||||
_last_wall, _last_cpu = now, cpu_now
|
||||
|
||||
cores = os.cpu_count() or 1
|
||||
try:
|
||||
load_1m_percent = os.getloadavg()[0] / cores * 100.0
|
||||
except (AttributeError, OSError):
|
||||
load_1m_percent = None
|
||||
memory_total, memory_available = _memory()
|
||||
disk = shutil.disk_usage(Path.cwd())
|
||||
return {
|
||||
"at": datetime.now(timezone.utc),
|
||||
"uptime_seconds": max(0.0, now - STARTED_MONOTONIC),
|
||||
"cpu_percent": round(cpu_percent, 2),
|
||||
"load_1m_percent": round(load_1m_percent, 2) if load_1m_percent is not None else None,
|
||||
"cpu_cores": cores,
|
||||
"rss_bytes": _rss_bytes(),
|
||||
"memory_total_bytes": memory_total,
|
||||
"memory_available_bytes": memory_available,
|
||||
"disk_total_bytes": disk.total,
|
||||
"disk_free_bytes": disk.free,
|
||||
"threads": threading.active_count(),
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
import re
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
|
@ -12,6 +13,31 @@ from app.scenarios.loader import ScenarioError, _derive_ground_truth
|
|||
from app.scenarios.schema import Scenario
|
||||
|
||||
DERIVED_TRUTH = {"incident_type", "dds", "incident_code", "notify", "required_facts"}
|
||||
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
|
||||
|
||||
|
||||
def classifier_sign_gaps(signs: list[str], narrative: list[str]) -> list[str]:
|
||||
"""Найти признаки ЕКП, которые не подтверждены текстом сценария.
|
||||
|
||||
Сравниваются трёхбуквенные основы, поэтому русские окончания
|
||||
(``жилой дом`` / ``жилого дома``) не создают ложного расхождения. Это
|
||||
намеренно строгая, детерминированная граница: модель не может заявить
|
||||
один объект, а в карточку незаметно положить код другого.
|
||||
"""
|
||||
def stems(value: str) -> set[str]:
|
||||
return {
|
||||
word[:3]
|
||||
for word in re.findall(r"[0-9a-zа-яё]+", value.casefold())
|
||||
if len(word) >= 3
|
||||
}
|
||||
|
||||
present = stems(" ".join(narrative))
|
||||
gaps: list[str] = []
|
||||
for sign in signs:
|
||||
required = stems(sign)
|
||||
if required and not required <= present:
|
||||
gaps.append(sign)
|
||||
return gaps
|
||||
|
||||
|
||||
def template_copy(source: Scenario, draft_id: str, title: str | None = None) -> dict:
|
||||
|
|
@ -92,9 +118,26 @@ def validate(body: dict) -> Scenario:
|
|||
if item.fact and item.fact not in known:
|
||||
raise ScenarioError(f"пункт {item.id} ссылается на факт {item.fact}, которого нет")
|
||||
for fact in scenario.facts:
|
||||
if PLACEHOLDER.search(fact.value) or (fact.refined and PLACEHOLDER.search(fact.refined)):
|
||||
raise ScenarioError(
|
||||
f"факт {fact.id}: остался плейсхолдер вместо конкретного ответа заявителя"
|
||||
)
|
||||
question_id = fact.reveal_on.question if fact.reveal_on else None
|
||||
if question_id and question_id not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} раскрывается несуществующим вопросом {question_id}")
|
||||
if fact.refine_on and fact.refine_on not in question_ids:
|
||||
raise ScenarioError(f"факт {fact.id} уточняется несуществующим вопросом {fact.refine_on}")
|
||||
# Для старых карточек и безопасных вариаций классификатор уже утверждён
|
||||
# заказчиком. Новый свободный сюжет обязан доказать свой путь ЕКП текстом.
|
||||
if scenario.id.startswith("ai-full-"):
|
||||
gaps = classifier_sign_gaps(
|
||||
scenario.signs,
|
||||
[scenario.title, scenario.first_line]
|
||||
+ [fact.value for fact in scenario.facts]
|
||||
+ [fact.refined for fact in scenario.facts if fact.refined],
|
||||
)
|
||||
if gaps:
|
||||
raise ScenarioError(
|
||||
"signs: сюжет не подтверждает путь ЕКП: " + ", ".join(gaps)
|
||||
)
|
||||
return _derive_ground_truth(scenario)
|
||||
|
|
|
|||
634
backend/app/scenarios/generation.py
Normal file
634
backend/app/scenarios/generation.py
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
"""Локальная ИИ-вариация утверждённого сценария для проверки преподавателем.
|
||||
|
||||
Модель пишет только сюжетные поля. Адрес, число пострадавших, классификацию,
|
||||
список оповещения и протокол оценки она менять не может. Публикация — отдельное
|
||||
действие преподавателя после просмотра черновика и эталона.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
||||
from app.scenarios.editor import classifier_sign_gaps, template_copy, validate
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.scenarios.schema import Scenario
|
||||
|
||||
|
||||
class GenerationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
PROTECTED_IDS = ("address", "people", "victim", "floor", "location", "region")
|
||||
PLACEHOLDER = re.compile(r"\{\{?\s*[a-zA-Z_][\w.-]*\s*\}?\}")
|
||||
NUMBER_WORD = re.compile(
|
||||
r"\b(?:ноль|один|одна|одно|одного|одну|два|две|двое|двух|три|трое|тр[её]х|"
|
||||
r"четыре|четверо|четыр[её]х|пять|пятеро|пяти|шесть|шестеро|шести|семь|"
|
||||
r"семеро|семи|восемь|восьмеро|восьми|девять|девятеро|девяти|десять|"
|
||||
r"десятеро|десяти)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def reveals_number(value: str) -> bool:
|
||||
return bool(re.search(r"\d", value) or NUMBER_WORD.search(value))
|
||||
|
||||
|
||||
def editable_fact_ids(source: Scenario) -> list[str]:
|
||||
return [fact.id for fact in source.facts
|
||||
if not fact.hidden and not fact.refined and not fact.refine_on
|
||||
and not any(part in fact.id.casefold() for part in PROTECTED_IDS)]
|
||||
|
||||
|
||||
def correction_target(source: Scenario, instruction: str) -> str | None:
|
||||
"""Указать маленькой модели конкретный факт для правки при повторе."""
|
||||
eligible = [fact for fact in source.facts if fact.id in editable_fact_ids(source)]
|
||||
if not eligible:
|
||||
return None
|
||||
def stems(text: str) -> set[str]:
|
||||
return {word[:4] for word in re.findall(r"[а-яё]{3,}", text.casefold())}
|
||||
|
||||
words = stems(instruction)
|
||||
questions = {item.fact: item.question or "" for item in source.checklist if item.fact}
|
||||
|
||||
def relevance(fact) -> int:
|
||||
haystack = stems(f"{fact.value} {questions.get(fact.id, '')}")
|
||||
return len(words & haystack)
|
||||
|
||||
_, best = max(enumerate(eligible), key=lambda pair: (relevance(pair[1]), pair[0]))
|
||||
return best.id if relevance(best) > 0 else None
|
||||
|
||||
|
||||
def style_fallback(source: Scenario, instruction: str) -> dict | None:
|
||||
"""Безопасно сократить реплику, если 1.7B дважды вернула копию."""
|
||||
if not re.search(r"\b(?:короч|кратк)", instruction.casefold()):
|
||||
return None
|
||||
chunks = re.findall(r"[^.!?…]+[.!?…]+|[^.!?…]+$", source.first_line)
|
||||
useful: list[str] = []
|
||||
for chunk in chunks:
|
||||
value = chunk.strip()
|
||||
letters = re.sub(r"[^а-яё]", "", value.casefold())
|
||||
if not letters or letters in {"алло", "ааа", "аа", "а"}:
|
||||
continue
|
||||
useful.append(value)
|
||||
if not useful:
|
||||
return None
|
||||
shortened = " ".join(useful[:2])[:300].strip()
|
||||
if shortened == source.first_line.strip():
|
||||
return None
|
||||
return {"title": source.title, "first_line": shortened, "facts": {}}
|
||||
|
||||
|
||||
def parse_proposal(
|
||||
raw: str, source: Scenario, *, require_fact_change: bool = True,
|
||||
instruction: str = "", restrict_first_line_facts: bool = False,
|
||||
) -> dict:
|
||||
"""Никакой произвольный JSON модели не попадает в схему сценария."""
|
||||
text = raw.strip()
|
||||
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
||||
if fenced:
|
||||
text = fenced.group(1)
|
||||
try:
|
||||
proposal = json.loads(text)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise GenerationError("модель не вернула корректный JSON") from exc
|
||||
if not isinstance(proposal, dict) or set(proposal) != {"title", "first_line", "facts"}:
|
||||
raise GenerationError("модель вернула поля вне разрешённого формата")
|
||||
title, first_line, facts = (proposal[key] for key in ("title", "first_line", "facts"))
|
||||
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
|
||||
raise GenerationError("название варианта пустое или слишком длинное")
|
||||
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
|
||||
raise GenerationError("первая реплика пустая или слишком длинная")
|
||||
if reveals_number(first_line):
|
||||
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
|
||||
if instruction and restrict_first_line_facts:
|
||||
stop = {"есть", "если", "меня", "очень", "этот", "только", "сейчас", "помогите"}
|
||||
|
||||
def stems(value: str) -> set[str]:
|
||||
return {word[:3] for word in re.findall(r"[а-яё]{3,}", value.casefold())
|
||||
if word not in stop}
|
||||
|
||||
fact_stems = set().union(*(stems(fact.value) for fact in source.facts))
|
||||
allowed_stems = stems(f"{source.first_line} {instruction}")
|
||||
leaked = (stems(first_line) & fact_stems) - allowed_stems
|
||||
if leaked:
|
||||
raise GenerationError(
|
||||
"первая реплика добавляет не запрошенные обстоятельства карточки"
|
||||
)
|
||||
if not isinstance(facts, dict):
|
||||
raise GenerationError("факты должны быть объектом")
|
||||
allowed = set(editable_fact_ids(source))
|
||||
if set(facts) - allowed:
|
||||
raise GenerationError("модель попыталась изменить защищённые или неизвестные факты")
|
||||
for fact_id, value in facts.items():
|
||||
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
|
||||
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
|
||||
if PLACEHOLDER.search(value):
|
||||
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
|
||||
if "?" in value:
|
||||
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
|
||||
source_values = {fact.id: fact.value for fact in source.facts}
|
||||
fact_changed = any(value.strip() != source_values[fact_id] for fact_id, value in facts.items())
|
||||
story_changed = title.strip() != source.title or first_line.strip() != source.first_line
|
||||
if require_fact_change and not fact_changed:
|
||||
raise GenerationError("модель не предложила новых обстоятельств происшествия")
|
||||
if not fact_changed and not story_changed:
|
||||
raise GenerationError("модель не изменила черновик по комментарию")
|
||||
return {"title": title.strip(), "first_line": first_line.strip(), "facts": facts}
|
||||
|
||||
|
||||
def proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
|
||||
body = template_copy(source, draft_id, proposal["title"])
|
||||
by_id = proposal["facts"]
|
||||
for fact in body["facts"]:
|
||||
if fact["id"] in by_id:
|
||||
fact["value"] = by_id[fact["id"]].strip()
|
||||
body["first_line"] = proposal["first_line"]
|
||||
# Схема, связи фактов и эталон проверяются до сохранения черновика.
|
||||
validate(body)
|
||||
return body
|
||||
|
||||
|
||||
def parse_full_proposal(raw: str, source: Scenario) -> dict:
|
||||
"""Проверить новый сюжет, где модель заполняет все ответы и мягкий эталон.
|
||||
|
||||
Тип, признаки ЕКП, маршрутизация и чек-лист остаются структурным каркасом,
|
||||
но сами обстоятельства, адрес и число пострадавших создаются заново.
|
||||
"""
|
||||
text = raw.strip()
|
||||
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
|
||||
if fenced:
|
||||
text = fenced.group(1)
|
||||
try:
|
||||
proposal = json.loads(text)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise GenerationError("модель не вернула корректный JSON нового сюжета") from exc
|
||||
if not isinstance(proposal, dict) or set(proposal) != {
|
||||
"title", "first_line", "facts", "ground_truth"
|
||||
}:
|
||||
raise GenerationError("новый сюжет содержит поля вне разрешённого формата")
|
||||
title = proposal["title"]
|
||||
first_line = proposal["first_line"]
|
||||
facts = proposal["facts"]
|
||||
truth = proposal["ground_truth"]
|
||||
if not isinstance(title, str) or not 5 <= len(title.strip()) <= 200:
|
||||
raise GenerationError("название нового сюжета пустое или слишком длинное")
|
||||
if not isinstance(first_line, str) or not 8 <= len(first_line.strip()) <= 300:
|
||||
raise GenerationError("первая реплика нового сюжета пустая или слишком длинная")
|
||||
if reveals_number(first_line):
|
||||
raise GenerationError("первая реплика раскрывает числовые детали до опроса")
|
||||
expected = {fact.id for fact in source.facts}
|
||||
if not isinstance(facts, dict) or set(facts) != expected:
|
||||
raise GenerationError("новый сюжет должен заполнить каждый факт каркаса ровно один раз")
|
||||
for fact_id, value in facts.items():
|
||||
if not isinstance(value, str) or not 3 <= len(value.strip()) <= 300:
|
||||
raise GenerationError(f"факт {fact_id}: значение пустое или слишком длинное")
|
||||
if PLACEHOLDER.search(value):
|
||||
raise GenerationError(f"факт {fact_id}: модель вернула плейсхолдер вместо ответа")
|
||||
if "?" in value:
|
||||
raise GenerationError(f"факт {fact_id}: модель вернула вопрос вместо ответа")
|
||||
if not isinstance(truth, dict) or set(truth) != {"address", "victims"}:
|
||||
raise GenerationError("ground_truth нового сюжета должен содержать address и victims")
|
||||
address, victims = truth["address"], truth["victims"]
|
||||
if not isinstance(address, str) or not 5 <= len(address.strip()) <= 200:
|
||||
raise GenerationError("эталонный адрес пустой или слишком длинный")
|
||||
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
|
||||
raise GenerationError("число пострадавших должно быть целым от 0 до 20")
|
||||
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
|
||||
if address_id is None:
|
||||
raise GenerationError("в выбранном каркасе нет адресного факта")
|
||||
|
||||
def normalized(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value.casefold().replace("ё", "е")).strip(" .,")
|
||||
|
||||
if not normalized(facts[address_id]).startswith(normalized(address)):
|
||||
raise GenerationError("эталонный адрес не совпадает с началом адресного факта")
|
||||
evidence = " ".join(facts.values()).casefold().replace("ё", "е")
|
||||
count_words = {
|
||||
0: ("ноль",), 1: ("один", "одна", "одного"),
|
||||
2: ("два", "двое", "двух"), 3: ("три", "трое", "трех"),
|
||||
4: ("четыре", "четверо", "четырех"), 5: ("пять", "пятеро", "пяти"),
|
||||
6: ("шесть", "шестеро", "шести"), 7: ("семь", "семеро", "семи"),
|
||||
8: ("восемь", "восьмеро", "восьми"), 9: ("девять", "девятеро", "девяти"),
|
||||
10: ("десять", "десятеро", "десяти"),
|
||||
}
|
||||
numeric = re.search(rf"пострадавш\w*\s*[:—-]?\s*{victims}(?:\D|$)", evidence)
|
||||
worded = any(re.search(
|
||||
rf"(?:пострадавш\w*.{{0,25}}\b{word}\b|\b{word}\b.{{0,25}}пострада\w*)", evidence
|
||||
) for word in count_words.get(victims, ()))
|
||||
none_hurt = victims == 0 and re.search(
|
||||
r"пострадавш(?:их|ие|их лиц)\s+нет|никто\s+не\s+пострадал|"
|
||||
r"все\s+(?:люди\s+)?(?:успели\s+)?(?:выйти|эвакуироваться)", evidence
|
||||
)
|
||||
if not (numeric or worded or none_hurt):
|
||||
raise GenerationError("в фактах нет явного подтверждения числа пострадавших")
|
||||
old_values = {fact.id: fact.value.strip() for fact in source.facts}
|
||||
changed = sum(value.strip() != old_values[fact_id] for fact_id, value in facts.items())
|
||||
if changed < max(1, (len(facts) + 1) // 2):
|
||||
raise GenerationError("новый сюжет слишком близко копирует исходный каркас")
|
||||
if normalized(address) in normalized(first_line):
|
||||
raise GenerationError("первая реплика раскрывает адрес до опроса")
|
||||
return {
|
||||
"title": title.strip(), "first_line": first_line.strip(),
|
||||
"facts": {key: value.strip() for key, value in facts.items()},
|
||||
"ground_truth": {"address": address.strip(), "victims": victims},
|
||||
}
|
||||
|
||||
|
||||
def full_proposal_body(source: Scenario, draft_id: str, proposal: dict) -> dict:
|
||||
"""Собрать самостоятельный сценарий из проверенного нового сюжета."""
|
||||
body = template_copy(source, draft_id, proposal["title"])
|
||||
for fact in body["facts"]:
|
||||
fact["value"] = proposal["facts"][fact["id"]]
|
||||
# Старое уточнение относится к старому сюжету и не переносится.
|
||||
fact["refined"] = None
|
||||
fact["refine_on"] = None
|
||||
body["first_line"] = proposal["first_line"]
|
||||
body["ground_truth"] = dict(proposal["ground_truth"])
|
||||
validate(body)
|
||||
return body
|
||||
|
||||
|
||||
async def generate_from_description(source: Scenario, description: str) -> dict:
|
||||
"""Создать все обстоятельства и мягкий эталон внутри выбранной категории ЕКП.
|
||||
|
||||
Qwen 1.7B заметно надёжнее отвечает на один вопрос за раз. Поэтому вместо
|
||||
одного длинного JSON, где малая модель копировала вопросы и старую карточку,
|
||||
используются короткие строгие запросы. Итог всё равно проходит единую
|
||||
сквозную проверку непротиворечивости.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if (not is_loopback_url(
|
||||
settings.llm_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
) or not settings.llm_model_caller):
|
||||
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
|
||||
questions = {item.fact: item.question for item in source.checklist if item.fact}
|
||||
address_id = next((fact.id for fact in source.facts if "address" in fact.id.casefold()), None)
|
||||
if address_id is None:
|
||||
raise GenerationError("в выбранном каркасе нет адресного факта")
|
||||
|
||||
def victim_relevance(fact_id: str) -> int:
|
||||
text = f"{fact_id} {questions.get(fact_id, '')}".casefold()
|
||||
return sum(word in text for word in (
|
||||
"people", "victim", "injur", "who", "люд", "пострад", "кто", "кому"
|
||||
))
|
||||
|
||||
victim_id = max((fact.id for fact in source.facts), key=victim_relevance)
|
||||
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
|
||||
|
||||
def clean_answer(value: object) -> str:
|
||||
"""Нормализовать пробелы и ограничить ответ без порчи `ул.`/`д.`."""
|
||||
text = re.sub(r"\s+", " ", str(value)).strip()
|
||||
for marker in (", но", ". В данном", ". Однако", ". Необходимо",
|
||||
". Рекомендуется", ". Следует"):
|
||||
text = text.split(marker, 1)[0]
|
||||
return text[:300].strip()
|
||||
|
||||
def validate_value(data: dict, *, extra_key: str | None = None) -> None:
|
||||
value = clean_answer(data.get("value", ""))
|
||||
if not 3 <= len(value) <= 300:
|
||||
raise GenerationError("значение пустое или слишком длинное")
|
||||
if PLACEHOLDER.search(value):
|
||||
raise GenerationError("модель вернула плейсхолдер вместо ответа")
|
||||
if "?" in value:
|
||||
raise GenerationError("модель вернула вопрос вместо ответа")
|
||||
if extra_key is not None and extra_key not in data:
|
||||
raise GenerationError(f"нет обязательного поля {extra_key}")
|
||||
|
||||
def validate_headline(data: dict) -> None:
|
||||
title = clean_answer(data.get("title", ""))
|
||||
first_line = clean_answer(data.get("first_line", ""))
|
||||
if not 5 <= len(title) <= 200:
|
||||
raise GenerationError("название пустое или слишком длинное")
|
||||
if not 8 <= len(first_line) <= 300:
|
||||
raise GenerationError("первая реплика пустая или слишком длинная")
|
||||
if reveals_number(first_line):
|
||||
raise GenerationError("первая реплика раскрывает числовые детали")
|
||||
|
||||
def validate_victims(data: dict) -> None:
|
||||
validate_value(data, extra_key="victims")
|
||||
victims = data["victims"]
|
||||
if not isinstance(victims, int) or isinstance(victims, bool) or not 0 <= victims <= 20:
|
||||
raise GenerationError("victims должен быть целым от 0 до 20")
|
||||
|
||||
def victim_answer(count: int) -> str:
|
||||
"""Число модели сохраняем, формулировку делаем воспроизводимой и грамотной."""
|
||||
if count == 0:
|
||||
return "Никто не пострадал. Пострадавших: 0"
|
||||
if count == 1:
|
||||
return "Пострадал один человек. Пострадавших: 1"
|
||||
noun = "человека" if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14) else "человек"
|
||||
return f"Пострадали {count} {noun}. Пострадавших: {count}"
|
||||
|
||||
async def ask(
|
||||
system: str, payload: dict, schema: dict, *, max_tokens: int = 180,
|
||||
validator=None,
|
||||
) -> dict:
|
||||
last_error: Exception | None = None
|
||||
raw = ""
|
||||
for attempt in range(3):
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
|
||||
]
|
||||
if attempt:
|
||||
messages += [
|
||||
{"role": "assistant", "content": raw},
|
||||
{"role": "user", "content": (
|
||||
f"Ответ отклонён: {last_error}. Верни исправленный JSON. "
|
||||
"Нужен конкретный ответ, не повторяй вопрос."
|
||||
)},
|
||||
]
|
||||
try:
|
||||
raw = await client.complete(LlmRequest(
|
||||
messages=messages, model=settings.llm_model_caller,
|
||||
temperature=0.25, max_tokens=max_tokens,
|
||||
response_format={"type": "json_object", "schema": schema},
|
||||
strip_reasoning=True,
|
||||
), use_cache=False)
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict) or set(data) != set(schema["required"]):
|
||||
raise GenerationError("неверный набор полей")
|
||||
if validator is not None:
|
||||
validator(data)
|
||||
return data
|
||||
except (ValueError, TypeError, GenerationError, LlmUnavailable) as exc:
|
||||
last_error = exc
|
||||
if isinstance(exc, LlmUnavailable) and not any(marker in str(exc) for marker in (
|
||||
"служебные токены", "пустой ответ модели"
|
||||
)):
|
||||
raise
|
||||
raise GenerationError(f"локальная модель не заполнила часть сценария: {last_error}")
|
||||
|
||||
value_schema = {
|
||||
"type": "object", "properties": {"value": {"type": "string"}},
|
||||
"required": ["value"], "additionalProperties": False,
|
||||
}
|
||||
try:
|
||||
# Заголовочный запрос не получает числовые детали даже словами: малая
|
||||
# модель склонна повторять их в первой реплике, раскрывая ответ до
|
||||
# обязательного вопроса оператора. Полное описание остаётся у запросов
|
||||
# фактов ниже, поэтому эталон числа пострадавших не теряется.
|
||||
headline_description = re.sub(r"\d+", "", description)
|
||||
headline_description = re.sub(r"\s+", " ", NUMBER_WORD.sub("", headline_description)).strip()
|
||||
headline = await ask(
|
||||
"Создай название и первую эмоциональную реплику заявителя для учебного вызова 112. "
|
||||
"Верни JSON title и first_line. first_line без цифр, адреса и служебных пояснений. "
|
||||
"classifier_signs — обязательный путь классификатора ЕКП: сюжет не должен ему "
|
||||
"противоречить. Если свободное description конфликтует с classifier_signs, "
|
||||
"приоритет всегда у classifier_signs.",
|
||||
{"incident_type": source.type.value, "classifier_signs": source.signs,
|
||||
"level": source.level.value, "description": headline_description},
|
||||
{"type": "object", "properties": {
|
||||
"title": {"type": "string"}, "first_line": {"type": "string"},
|
||||
}, "required": ["title", "first_line"], "additionalProperties": False},
|
||||
max_tokens=220,
|
||||
validator=validate_headline,
|
||||
)
|
||||
facts: dict[str, str] = {}
|
||||
address = ""
|
||||
victims: int | None = None
|
||||
for fact in source.facts:
|
||||
question = questions.get(fact.id, fact.id)
|
||||
base_payload = {
|
||||
"description": description, "incident_type": source.type.value,
|
||||
"classifier_signs": source.signs,
|
||||
"question": question, "already_known_answers": facts,
|
||||
}
|
||||
if fact.id == address_id:
|
||||
data = await ask(
|
||||
"Придумай конкретный вымышленный московский адрес для учебного сценария. "
|
||||
"Верни JSON value и address. value должно начинаться дословно с address, "
|
||||
"после него можно добавить квартиру, этаж или ориентир. Это ответ, не вопрос. "
|
||||
"Адрес и тип объекта не должны противоречить classifier_signs.",
|
||||
base_payload,
|
||||
{"type": "object", "properties": {
|
||||
"value": {"type": "string"}, "address": {"type": "string"},
|
||||
}, "required": ["value", "address"], "additionalProperties": False},
|
||||
validator=lambda data: validate_value(data, extra_key="address"),
|
||||
)
|
||||
facts[fact.id] = clean_answer(data["value"])
|
||||
# Эталон выводится из факта, а не из второго пересказа модели:
|
||||
# малая Qwen иногда меняет пунктуацию или номер между полями.
|
||||
address = facts[fact.id][:200].rstrip(" ,.;")
|
||||
elif fact.id == victim_id:
|
||||
data = await ask(
|
||||
"Ответь на вопрос заявителя конкретным фактом по описанию. Верни JSON value "
|
||||
"и victims. victims — число пострадавших от 0 до 20. value обязательно "
|
||||
"заканчивается точной фразой `Пострадавших: N`, где N равно victims. "
|
||||
"Не пиши вопрос и не считай всех присутствующих пострадавшими. "
|
||||
"Ответ не должен противоречить classifier_signs.",
|
||||
base_payload,
|
||||
{"type": "object", "properties": {
|
||||
"value": {"type": "string"},
|
||||
"victims": {"type": "integer", "minimum": 0, "maximum": 20},
|
||||
}, "required": ["value", "victims"], "additionalProperties": False},
|
||||
validator=validate_victims,
|
||||
)
|
||||
victims = data["victims"] if isinstance(data["victims"], int) else None
|
||||
if victims is not None:
|
||||
facts[fact.id] = victim_answer(victims)
|
||||
else:
|
||||
data = await ask(
|
||||
"Ответь по-русски конкретным обстоятельством заявителя на question, используя "
|
||||
"description, classifier_signs и already_known_answers. classifier_signs — "
|
||||
"обязательный путь ЕКП и важнее конфликтующих деталей description. Верни JSON "
|
||||
"только с ключом value. value — ответ, не вопрос; не используй знак вопроса "
|
||||
"и плейсхолдеры.",
|
||||
base_payload, value_schema, validator=validate_value,
|
||||
)
|
||||
facts[fact.id] = clean_answer(data["value"])
|
||||
if victims is None:
|
||||
raise GenerationError("модель не указала число пострадавших")
|
||||
gaps = classifier_sign_gaps(
|
||||
source.signs,
|
||||
[str(headline["title"]), str(headline["first_line"]), *facts.values()],
|
||||
)
|
||||
if gaps:
|
||||
target = next(
|
||||
fact for fact in source.facts
|
||||
if fact.id not in {address_id, victim_id}
|
||||
)
|
||||
|
||||
def validate_alignment(data: dict) -> None:
|
||||
validate_value(data)
|
||||
candidate = dict(facts)
|
||||
candidate[target.id] = clean_answer(data["value"])
|
||||
missing = classifier_sign_gaps(
|
||||
source.signs,
|
||||
[str(headline["title"]), str(headline["first_line"]), *candidate.values()],
|
||||
)
|
||||
if missing:
|
||||
raise GenerationError(
|
||||
"ответ не подтверждает признаки ЕКП: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
aligned = await ask(
|
||||
"Перепиши один ответ заявителя так, чтобы он оставался конкретным ответом на "
|
||||
"question и дословно, осмысленно подтверждал все missing_classifier_signs. "
|
||||
"Это обязательные признаки ЕКП, их нельзя заменять другим объектом или событием. "
|
||||
"Верни JSON только с ключом value, без вопроса и служебных пояснений.",
|
||||
{
|
||||
"description": description,
|
||||
"question": questions.get(target.id, target.id),
|
||||
"current_answer": facts[target.id],
|
||||
"missing_classifier_signs": gaps,
|
||||
"all_classifier_signs": source.signs,
|
||||
"already_known_answers": facts,
|
||||
},
|
||||
value_schema,
|
||||
max_tokens=220,
|
||||
validator=validate_alignment,
|
||||
)
|
||||
facts[target.id] = clean_answer(aligned["value"])
|
||||
first_line = str(headline["first_line"]).strip()
|
||||
if first_line and first_line[-1] not in ".!?…":
|
||||
first_line += "!"
|
||||
proposal = {
|
||||
"title": headline["title"], "first_line": first_line,
|
||||
"facts": facts, "ground_truth": {"address": address, "victims": victims},
|
||||
}
|
||||
proposal = parse_full_proposal(json.dumps(proposal, ensure_ascii=False), source)
|
||||
full_proposal_body(source, "ai-full-validation", proposal)
|
||||
return proposal
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def generate(
|
||||
source: Scenario, instruction: str, *, require_fact_change: bool = True
|
||||
) -> dict:
|
||||
settings = get_settings()
|
||||
if (not is_loopback_url(
|
||||
settings.llm_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
) or not settings.llm_model_caller):
|
||||
raise LlmUnavailable("для генерации нужна разрешённая локальная модель")
|
||||
all_editable = editable_fact_ids(source)
|
||||
target = correction_target(source, instruction)
|
||||
style_only = target is None and not require_fact_change
|
||||
# Для 1.7B один комментарий = один изменяемый факт. Это не даёт модели
|
||||
# «заодно» испортить газ, пока преподаватель просит сделать плотнее дым.
|
||||
editable = [] if style_only else ([target] if target else all_editable)
|
||||
fact_questions = {item.fact: item.question for item in source.checklist
|
||||
if item.fact and item.question}
|
||||
context = {
|
||||
"incident_type": source.type.value,
|
||||
"source_title": source.title,
|
||||
"editable_facts": {
|
||||
fact.id: {"question": fact_questions.get(fact.id, fact.id), "old_answer": fact.value}
|
||||
for fact in source.facts if fact.id in editable
|
||||
},
|
||||
}
|
||||
if not require_fact_change:
|
||||
context["current_first_line"] = source.first_line
|
||||
change_rule = (
|
||||
"Измени обстоятельства хотя бы одного факта: дословная копия недопустима. "
|
||||
if require_fact_change else
|
||||
"Измени черновик по комментарию: допустимо менять только заголовок или first_line; "
|
||||
"полностью прежняя версия недопустима. "
|
||||
)
|
||||
if style_only:
|
||||
change_rule += (
|
||||
"Это стилистическая правка: не добавляй в first_line новые обстоятельства, "
|
||||
"которых нет в current_first_line или комментарии. facts оставь пустым. "
|
||||
)
|
||||
system = (
|
||||
"Сочини НОВУЮ вариацию учебного вызова 112 на русском языке. "
|
||||
"Верни ровно JSON с тремя ключами: title, first_line, facts. "
|
||||
"facts содержит только id из editable_facts. " + change_rule +
|
||||
"Значения facts — НОВЫЕ ответы "
|
||||
"заявителя на question, не повторение вопроса. Не добавляй ключи. "
|
||||
"first_line — прямая речь заявителя без цифр и адреса. "
|
||||
"Тип происшествия, адрес и люди не меняются; их не включай в facts. "
|
||||
"Не придумывай службы, коды и регламенты."
|
||||
)
|
||||
request = LlmRequest(
|
||||
messages=[{"role": "system", "content": system},
|
||||
{"role": "user", "content": json.dumps(
|
||||
{"source": context, "request": instruction}, ensure_ascii=False)}],
|
||||
model=settings.llm_model_caller, temperature=0.7, max_tokens=500,
|
||||
response_format={"type": "json_object", "schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"first_line": {"type": "string"},
|
||||
"facts": {"type": "object", "properties": {
|
||||
fact_id: {"type": "string"} for fact_id in editable
|
||||
}, "additionalProperties": False},
|
||||
},
|
||||
"required": ["title", "first_line", "facts"],
|
||||
"additionalProperties": False,
|
||||
}},
|
||||
strip_reasoning=True,
|
||||
)
|
||||
client = LlmClient(base_url=settings.llm_base_url, timeout=45)
|
||||
|
||||
async def complete_safely(candidate: LlmRequest) -> str:
|
||||
last_error: LlmUnavailable | None = None
|
||||
for _ in range(2):
|
||||
try:
|
||||
return await client.complete(candidate, use_cache=False)
|
||||
except LlmUnavailable as exc:
|
||||
last_error = exc
|
||||
if not any(marker in str(exc) for marker in (
|
||||
"служебные токены", "пустой ответ модели"
|
||||
)):
|
||||
raise
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
try:
|
||||
raw = await complete_safely(request)
|
||||
try:
|
||||
proposal = parse_proposal(
|
||||
raw, source, require_fact_change=require_fact_change, instruction=instruction,
|
||||
restrict_first_line_facts=style_only,
|
||||
)
|
||||
except GenerationError as first_error:
|
||||
feedback = (
|
||||
f"Предыдущий ответ отклонён: {first_error}. "
|
||||
"Верни исправленный JSON той же схемы. "
|
||||
"Значения facts должны быть ответами заявителя, не вопросами. "
|
||||
"Запрещены плейсхолдеры вида {fact_id}: пиши конкретный русский текст. "
|
||||
)
|
||||
if target:
|
||||
old = next(fact.value for fact in source.facts if fact.id == target)
|
||||
feedback += (
|
||||
f"Обязательно замени факт {target}. Старый ответ: {old}. "
|
||||
f"Новый ответ должен учитывать запрос: {instruction}. "
|
||||
"Не повторяй старый ответ дословно."
|
||||
)
|
||||
retry = LlmRequest(
|
||||
messages=[*request.messages, {"role": "assistant", "content": raw},
|
||||
{"role": "user", "content": feedback}],
|
||||
model=request.model, temperature=0.8, max_tokens=request.max_tokens,
|
||||
response_format=request.response_format,
|
||||
strip_reasoning=True,
|
||||
)
|
||||
raw = await complete_safely(retry)
|
||||
try:
|
||||
proposal = parse_proposal(
|
||||
raw, source, require_fact_change=require_fact_change, instruction=instruction,
|
||||
restrict_first_line_facts=style_only,
|
||||
)
|
||||
except GenerationError:
|
||||
fallback = style_fallback(source, instruction) if not require_fact_change else None
|
||||
if fallback is None:
|
||||
raise
|
||||
# Даже детерминированный fallback проходит те же запреты утечек.
|
||||
proposal = parse_proposal(
|
||||
json.dumps(fallback, ensure_ascii=False), source,
|
||||
require_fact_change=False, instruction=instruction,
|
||||
restrict_first_line_facts=style_only,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
# Проверка на фиксированном ID гарантирует, что генерация не выдаст
|
||||
# черновик, который потом невозможно утвердить из-за сломанной схемы.
|
||||
try:
|
||||
proposal_body(source, "ai-validation", proposal)
|
||||
except ScenarioError as exc:
|
||||
raise GenerationError(f"сгенерированный сценарий не прошёл проверку: {exc}") from exc
|
||||
return proposal
|
||||
|
|
@ -16,6 +16,18 @@ from app.scenarios.schema import Scenario
|
|||
from app.scenarios import editor
|
||||
|
||||
_library: dict[str, Scenario] = {}
|
||||
_demo_drafts: dict[str, ScenarioRow] = {}
|
||||
_demo_archived: dict[str, Scenario] = {}
|
||||
_demo_scenario_owners: dict[str, str] = {}
|
||||
_demo_archived_owners: dict[str, str] = {}
|
||||
|
||||
|
||||
def reset_demo_drafts() -> None:
|
||||
"""Черновики и архив demo-lite живут только в текущем процессе."""
|
||||
_demo_drafts.clear()
|
||||
_demo_archived.clear()
|
||||
_demo_scenario_owners.clear()
|
||||
_demo_archived_owners.clear()
|
||||
|
||||
|
||||
def set_library(scenarios: list[Scenario]) -> None:
|
||||
|
|
@ -56,6 +68,7 @@ async def seed(db: AsyncSession, scenarios: list[Scenario]) -> int:
|
|||
row.topics = scenario.topics
|
||||
row.modes = scenario.modes
|
||||
row.body = payload
|
||||
row.owner_login = None
|
||||
await db.commit()
|
||||
return len(scenarios)
|
||||
|
||||
|
|
@ -66,21 +79,128 @@ async def restore_published(db: AsyncSession) -> int:
|
|||
Вызвать после ``load_from_disk``. Базовую библиотеку не перезаписываем:
|
||||
её источником остаются проверенные YAML из репозитория.
|
||||
"""
|
||||
rows = await db.scalars(select(ScenarioRow).where(ScenarioRow.status == "published"))
|
||||
restored = 0
|
||||
rows = await db.scalars(select(ScenarioRow).where(
|
||||
ScenarioRow.status.in_(["published", "archived"])
|
||||
))
|
||||
delta = 0
|
||||
for row in rows:
|
||||
if row.status == "archived":
|
||||
if row.owner_login:
|
||||
_demo_scenario_owners.pop(row.id, None)
|
||||
_demo_archived_owners[row.id] = row.owner_login
|
||||
if _library.pop(row.id, None) is not None:
|
||||
delta -= 1
|
||||
continue
|
||||
if row.owner_login:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
if row.id in _library:
|
||||
continue
|
||||
_library[row.id] = Scenario.model_validate(row.body)
|
||||
restored += 1
|
||||
return restored
|
||||
delta += 1
|
||||
return delta
|
||||
|
||||
|
||||
async def owned_scenario_ids(db: AsyncSession | None, owner_login: str) -> set[str]:
|
||||
"""IDs the current instructor may edit/archive; base and legacy rows are read-only."""
|
||||
if db is None:
|
||||
return {
|
||||
scenario_id for scenario_id, owner in _demo_scenario_owners.items()
|
||||
if owner == owner_login and scenario_id in _library
|
||||
}
|
||||
rows = await db.scalars(select(ScenarioRow.id).where(
|
||||
ScenarioRow.owner_login == owner_login,
|
||||
ScenarioRow.status == "published",
|
||||
))
|
||||
values = set(rows)
|
||||
# Test doubles and alternate SQLAlchemy result wrappers may return rows
|
||||
# for a scalar selection; normalize them without leaking owner data.
|
||||
return {value if isinstance(value, str) else value.id for value in values}
|
||||
|
||||
|
||||
async def archive(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
) -> Scenario | None:
|
||||
"""Скрыть опубликованный сценарий без удаления истории и внешних ключей."""
|
||||
scenario = _library.get(scenario_id)
|
||||
if scenario is None:
|
||||
return None
|
||||
if db is None:
|
||||
owner = _demo_scenario_owners.get(scenario_id)
|
||||
if owner_login is not None and owner != owner_login:
|
||||
return None
|
||||
_demo_archived[scenario_id] = scenario
|
||||
if owner is not None:
|
||||
_demo_archived_owners[scenario_id] = owner
|
||||
_demo_scenario_owners.pop(scenario_id, None)
|
||||
else:
|
||||
row = await db.get(ScenarioRow, scenario_id)
|
||||
if owner_login is not None and (row is None or row.owner_login != owner_login):
|
||||
return None
|
||||
if row is None:
|
||||
row = ScenarioRow(
|
||||
id=scenario.id,
|
||||
title=scenario.title,
|
||||
incident_type=scenario.type.value,
|
||||
level=scenario.level.value,
|
||||
topics=scenario.topics,
|
||||
modes=scenario.modes,
|
||||
body=scenario.model_dump(mode="json"),
|
||||
)
|
||||
db.add(row)
|
||||
row.status = "archived"
|
||||
await db.commit()
|
||||
_library.pop(scenario_id, None)
|
||||
return scenario
|
||||
|
||||
|
||||
async def restore_archived(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
) -> Scenario | None:
|
||||
"""Вернуть мягко удалённый сценарий в библиотеку назначения."""
|
||||
if db is None:
|
||||
owner = _demo_archived_owners.get(scenario_id)
|
||||
if owner_login is not None and owner != owner_login:
|
||||
return None
|
||||
scenario = _demo_archived.pop(scenario_id, None)
|
||||
if scenario is not None and owner is not None:
|
||||
_demo_scenario_owners[scenario_id] = owner
|
||||
_demo_archived_owners.pop(scenario_id, None)
|
||||
else:
|
||||
row = await db.get(ScenarioRow, scenario_id)
|
||||
if (row is None or row.status != "archived"
|
||||
or owner_login is not None and row.owner_login != owner_login):
|
||||
return None
|
||||
# В опубликованном body уже лежат вычисленные поля ground_truth (ЕКП,
|
||||
# ДДС, список оповещения). Редакторская validate предназначена для
|
||||
# черновика до вычисления и закономерно запрещает такие поля.
|
||||
scenario = Scenario.model_validate(row.body)
|
||||
row.status = "published"
|
||||
await db.commit()
|
||||
if scenario is None:
|
||||
return None
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
||||
|
||||
async def create_draft(
|
||||
db: AsyncSession, *, source: Scenario, title: str | None = None
|
||||
db: AsyncSession | None, *, source: Scenario, title: str | None = None,
|
||||
proposal: dict | None = None, full_proposal: dict | None = None,
|
||||
owner_login: str | None = None,
|
||||
) -> ScenarioRow:
|
||||
draft_id = f"draft-{uuid4().hex}"
|
||||
body = editor.template_copy(source, draft_id, title)
|
||||
if proposal is not None and full_proposal is not None:
|
||||
raise ValueError("нельзя одновременно передать вариацию и полный сюжет")
|
||||
prefix = "ai-full" if full_proposal is not None else "ai" if proposal is not None else "draft"
|
||||
draft_id = f"{prefix}-{uuid4().hex}"
|
||||
if full_proposal is not None:
|
||||
from app.scenarios.generation import full_proposal_body
|
||||
|
||||
body = full_proposal_body(source, draft_id, full_proposal)
|
||||
elif proposal is None:
|
||||
body = editor.template_copy(source, draft_id, title)
|
||||
else:
|
||||
from app.scenarios.generation import proposal_body
|
||||
|
||||
body = proposal_body(source, draft_id, proposal)
|
||||
row = ScenarioRow(
|
||||
id=draft_id,
|
||||
title=body["title"],
|
||||
|
|
@ -89,26 +209,53 @@ async def create_draft(
|
|||
topics=body["topics"],
|
||||
modes=body["modes"],
|
||||
status="draft",
|
||||
owner_login=owner_login,
|
||||
body=body,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
if db is None:
|
||||
_demo_drafts[row.id] = row
|
||||
else:
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def draft(db: AsyncSession, scenario_id: str) -> ScenarioRow | None:
|
||||
row = await db.get(ScenarioRow, scenario_id)
|
||||
return row if row is not None and row.status == "draft" else None
|
||||
async def draft(
|
||||
db: AsyncSession | None, scenario_id: str, *, owner_login: str | None = None
|
||||
) -> ScenarioRow | None:
|
||||
row = _demo_drafts.get(scenario_id) if db is None else await db.get(ScenarioRow, scenario_id)
|
||||
if row is None or row.status != "draft":
|
||||
return None
|
||||
if owner_login is not None and row.owner_login != owner_login:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
async def update_draft(db: AsyncSession, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
async def update_draft(db: AsyncSession | None, row: ScenarioRow, patch: dict) -> ScenarioRow:
|
||||
row.body = editor.merge_patch(row.body, patch)
|
||||
row.title = str(row.body.get("title") or "")[:200]
|
||||
await db.commit()
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
|
||||
async def revise_draft(db: AsyncSession | None, row: ScenarioRow, proposal: dict) -> ScenarioRow:
|
||||
"""Заменить сюжетную версию того же черновика после комментария преподавателя."""
|
||||
from app.scenarios.generation import proposal_body
|
||||
|
||||
source = editor.validate(row.body)
|
||||
row.body = proposal_body(source, row.id, proposal)
|
||||
row.title = row.body["title"]
|
||||
row.incident_type = row.body["type"]
|
||||
row.level = row.body["level"]
|
||||
row.topics = row.body["topics"]
|
||||
row.modes = row.body["modes"]
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
return row
|
||||
|
||||
|
||||
async def approve_draft(db: AsyncSession | None, row: ScenarioRow) -> Scenario:
|
||||
scenario = editor.validate(row.body)
|
||||
row.title = scenario.title
|
||||
row.incident_type = scenario.type.value
|
||||
|
|
@ -117,6 +264,11 @@ async def approve_draft(db: AsyncSession, row: ScenarioRow) -> Scenario:
|
|||
row.modes = scenario.modes
|
||||
row.body = scenario.model_dump(mode="json")
|
||||
row.status = "published"
|
||||
await db.commit()
|
||||
if db is None:
|
||||
_demo_drafts.pop(row.id, None)
|
||||
if row.owner_login is not None:
|
||||
_demo_scenario_owners[row.id] = row.owner_login
|
||||
else:
|
||||
await db.commit()
|
||||
publish(scenario)
|
||||
return scenario
|
||||
|
|
|
|||
|
|
@ -130,16 +130,13 @@ def evaluate_dispatcher(
|
|||
def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
Веса предварительные — до утверждения методистом. Неподтверждённые
|
||||
телефонные действия нельзя засчитывать по одной кнопке статуса.
|
||||
Веса предварительные — до утверждения методистом. Телефон — возможный
|
||||
источник информации, но не обязательный шлюз статуса.
|
||||
"""
|
||||
metrics: list[Metric] = []
|
||||
for service in state.notified_services():
|
||||
for service in state.managed_services():
|
||||
marks = [mark for mark in state.status_log if mark.service == service]
|
||||
statuses = {mark.status for mark in marks}
|
||||
reports = {report.phase for report in state.phone_reports if report.service == service}
|
||||
selected_crew = state.crew_assignments.get(service)
|
||||
selected = selected_crew is not None
|
||||
|
||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||
metrics.append(Metric(
|
||||
|
|
@ -153,7 +150,7 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|||
if primary and state.dispatched_at else None)
|
||||
add("dds_primary", "решение по карточке", primary is not None,
|
||||
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||
add("dds_ack", "первичная отметка за 30 с",
|
||||
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
|
||||
elapsed is not None and elapsed <= deadline_ms,
|
||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
||||
f"≤ {deadline_ms // 1000} с")
|
||||
|
|
@ -164,22 +161,14 @@ def dispatcher_metrics(state, deadline_ms: int) -> list[Metric]:
|
|||
primary.status.value, "профильную заявку принять", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED:
|
||||
continue
|
||||
add("dds_crew", "ручной выбор бригады", selected,
|
||||
selected_crew or "бригада не выбрана", "выбрать конкретную бригаду", 1.5)
|
||||
add("dds_contact", "связь с бригадой", bool(reports),
|
||||
", ".join(sorted(reports)) or "докладов нет", "получить доклад о выезде", 1.5)
|
||||
expected = {
|
||||
"dispatched": ServiceStatus.RESPONDING,
|
||||
"arrived": ServiceStatus.ARRIVED,
|
||||
"working": ServiceStatus.WORKING,
|
||||
ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING,
|
||||
}
|
||||
aligned = all(status in statuses for phase, status in expected.items() if phase in reports)
|
||||
all_progress = all(phase in reports and status in statuses for phase, status in expected.items())
|
||||
add("dds_progress", "статусы по докладам", aligned and all_progress,
|
||||
add("dds_progress", "ведение хода реагирования", expected <= statuses,
|
||||
", ".join(status.value for status in statuses) or "статусов нет",
|
||||
"выезд, прибытие и работы отмечены после докладов", 2.0)
|
||||
add("dds_completion", "закрытие работ по докладу",
|
||||
"completed" in reports and ServiceStatus.COMPLETED in statuses,
|
||||
"начало реагирования, прибытие и работы отмечены по полученной информации", 2.0)
|
||||
add("dds_completion", "закрытие работ",
|
||||
ServiceStatus.COMPLETED in statuses,
|
||||
"завершено" if ServiceStatus.COMPLETED in statuses else "не завершено",
|
||||
"доклад о завершении и статус «Работы завершены»")
|
||||
"по факту поставить статус «Работы завершены»")
|
||||
return metrics
|
||||
|
|
|
|||
|
|
@ -37,13 +37,19 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||
row("Критерии", "", "Нарушенных метрик", report.failed_metrics)
|
||||
row("Критерии", "", "Зачёт", "да" if report.passed else "нет")
|
||||
row("Оценка", "", "Автоматическая", report.score_auto)
|
||||
row("Оценка", "", "Итоговая", report.score_final)
|
||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
||||
for number, item in enumerate(report.metrics, 1):
|
||||
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; источник: {item.ref or ''}")
|
||||
row("Метрики", number, item.title, item.fact, f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; вес: {item.weight:g}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.findings, 1):
|
||||
row("Ошибки", number, item.code.value, item.summary, f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}")
|
||||
for number, item in enumerate(report.competencies, 1):
|
||||
|
|
@ -138,6 +144,12 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
("Сценарий", report.scenario_id),
|
||||
("Режим", report.mode.value),
|
||||
("Попытка", report.attempt),
|
||||
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||
("Нарушенных метрик", report.failed_metrics),
|
||||
("Результат", "зачёт" if report.passed else "не зачтено"),
|
||||
("Автоматическая оценка", report.score_auto),
|
||||
("Итоговая оценка", report.score_final),
|
||||
):
|
||||
|
|
@ -155,7 +167,7 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
if not report.metrics:
|
||||
story.append(p("Нет данных", muted))
|
||||
for item in report.metrics:
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'}"))
|
||||
story.append(p(f"{item.title} - {'выполнено' if item.passed else 'нарушено'} (вес {item.weight:g})"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm}. {item.ref or ''}", muted))
|
||||
|
||||
section("Выявленные ошибки")
|
||||
|
|
@ -226,3 +238,74 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
|
||||
document.build(story, onFirstPage=footer, onLaterPages=footer)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def certificate_pdf(
|
||||
*, trainee_name: str, trainee_id: object, group_name: str | None,
|
||||
attempts: int, average_score: float, issued_at: object,
|
||||
) -> bytes:
|
||||
"""Одностраничный сертификат по фактически сохранённым результатам."""
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
regular_path, bold_path = _font_paths()
|
||||
regular_name = "LCTCertificateRegular"
|
||||
bold_name = "LCTCertificateBold"
|
||||
if regular_name not in pdfmetrics.getRegisteredFontNames():
|
||||
regular = TTFont(regular_name, str(regular_path))
|
||||
if not all(ord(char) in regular.face.charToGlyph for char in "Сертификат Ёж"):
|
||||
raise RuntimeError(f"Шрифт не поддерживает кириллицу: {regular_path}")
|
||||
pdfmetrics.registerFont(regular)
|
||||
pdfmetrics.registerFont(TTFont(bold_name, str(bold_path or regular_path)))
|
||||
|
||||
title = ParagraphStyle(
|
||||
"certificate-title", fontName=bold_name, fontSize=28, leading=34,
|
||||
alignment=TA_CENTER, textColor=colors.HexColor("#193b56"), spaceAfter=20,
|
||||
)
|
||||
body = ParagraphStyle(
|
||||
"certificate-body", fontName=regular_name, fontSize=14, leading=21,
|
||||
alignment=TA_CENTER, textColor=colors.HexColor("#18202a"), spaceAfter=10,
|
||||
)
|
||||
name_style = ParagraphStyle(
|
||||
"certificate-name", parent=body, fontName=bold_name, fontSize=22, leading=28,
|
||||
textColor=colors.HexColor("#a24816"), spaceAfter=18,
|
||||
)
|
||||
small = ParagraphStyle(
|
||||
"certificate-small", parent=body, fontSize=9, leading=13,
|
||||
textColor=colors.HexColor("#5d6873"),
|
||||
)
|
||||
safe_name = escape(trainee_name)
|
||||
safe_group = escape(group_name or "без группы")
|
||||
safe_date = escape(str(issued_at))
|
||||
story = [
|
||||
Spacer(1, 34),
|
||||
Paragraph("СЕРТИФИКАТ", title),
|
||||
Paragraph("о прохождении учебных занятий в локальном тренажёре системы-112 и ДДС", body),
|
||||
Spacer(1, 12),
|
||||
Paragraph(safe_name, name_style),
|
||||
Paragraph(
|
||||
f"Выполнено занятий: <b>{attempts}</b> · средняя итоговая оценка: "
|
||||
f"<b>{average_score:.1f} из 100</b>", body,
|
||||
),
|
||||
Paragraph(f"Учебная группа: {safe_group}", body),
|
||||
Spacer(1, 28),
|
||||
Paragraph(f"Дата формирования: {safe_date}", small),
|
||||
Paragraph(f"Идентификатор курсанта: {escape(str(trainee_id))}", small),
|
||||
Paragraph(
|
||||
"Документ сформирован локально по сохранённым результатам и не заменяет "
|
||||
"квалификационный документ установленного образца.", small,
|
||||
),
|
||||
]
|
||||
output = io.BytesIO()
|
||||
document = SimpleDocTemplate(
|
||||
output, pagesize=landscape(A4), leftMargin=60, rightMargin=60,
|
||||
topMargin=45, bottomMargin=45, title="Сертификат о прохождении обучения",
|
||||
author="Учебный симулятор 112",
|
||||
)
|
||||
document.build(story)
|
||||
return output.getvalue()
|
||||
|
|
|
|||
102
backend/app/scoring/grammar.py
Normal file
102
backend/app/scoring/grammar.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Проверка письменного ответа ДДС: локальные правила и необязательный Vikhr."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GrammarAssessment:
|
||||
passed: bool
|
||||
errors: tuple[str, ...]
|
||||
source: str
|
||||
|
||||
|
||||
def basic_check(text: str) -> GrammarAssessment:
|
||||
"""Воспроизводимый минимум работает даже без модели."""
|
||||
value = text.strip()
|
||||
errors: list[str] = []
|
||||
if not value:
|
||||
errors.append("ответ отсутствует")
|
||||
else:
|
||||
first_letter = next((char for char in value if char.isalpha()), "")
|
||||
if first_letter and first_letter != first_letter.upper():
|
||||
errors.append("предложение начинается со строчной буквы")
|
||||
if value[-1:] not in ".!?…":
|
||||
errors.append("нет завершающего знака препинания")
|
||||
if re.search(r"\s{2,}", value):
|
||||
errors.append("повторяющиеся пробелы")
|
||||
if re.search(r"[A-Za-z]", value):
|
||||
errors.append("латинские буквы в русском ответе")
|
||||
if re.search(r"([!?.,])\1{2,}", value):
|
||||
errors.append("избыточно повторяющиеся знаки препинания")
|
||||
agreement_errors = (
|
||||
(r"\bсообщение\s+принят(?:ы|а)\b", "нарушено согласование: «сообщение принято»"),
|
||||
(r"\bбригада\s+направлен(?:о|ы)\b", "нарушено согласование: «бригада направлена»"),
|
||||
(r"\bинформация\s+передан(?:о|ы)\b", "нарушено согласование: «информация передана»"),
|
||||
(r"\bработы\s+завершен(?:а|о)\b", "нарушено согласование: «работы завершены»"),
|
||||
)
|
||||
for pattern, message in agreement_errors:
|
||||
if re.search(pattern, value, re.IGNORECASE):
|
||||
errors.append(message)
|
||||
return GrammarAssessment(not errors, tuple(errors), "rules")
|
||||
|
||||
|
||||
async def assess(text: str) -> GrammarAssessment:
|
||||
"""Vikhr проверяет мягкую грамматику, правила остаются страховочной сеткой."""
|
||||
baseline = basic_check(text)
|
||||
settings = get_settings()
|
||||
if (not settings.grammar_llm_enabled or not text.strip()
|
||||
or not is_loopback_url(
|
||||
settings.llm_control_base_url,
|
||||
allow_docker_host=settings.allow_docker_host_models,
|
||||
)
|
||||
or not settings.llm_model_control):
|
||||
return baseline
|
||||
|
||||
schema = {"type": "json_object", "schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"passed": {"type": "boolean"},
|
||||
"comment": {"type": "string"},
|
||||
},
|
||||
"required": ["passed", "comment"],
|
||||
"additionalProperties": False,
|
||||
}}
|
||||
request = LlmRequest(
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Ты строгий корректор русского языка. Проверяй только грамматику, "
|
||||
"орфографию и пунктуацию короткого служебного сообщения; не оценивай "
|
||||
"факты и действия. Пример ошибки: «Сообщение приняты» => passed=false, "
|
||||
"comment=«Нарушено согласование: сообщение принято». Пример нормы: "
|
||||
"«Сообщение принято, бригада направлена.» => passed=true, comment=«». "
|
||||
"Найди даже одну языковую ошибку и верни JSON по заданной схеме."
|
||||
),
|
||||
}, {"role": "user", "content": text}],
|
||||
model=settings.llm_model_control,
|
||||
temperature=0.0,
|
||||
max_tokens=160,
|
||||
response_format=schema,
|
||||
)
|
||||
client = LlmClient(base_url=settings.llm_control_base_url, timeout=5)
|
||||
try:
|
||||
raw = await client.complete(request, use_cache=True)
|
||||
payload = json.loads(raw)
|
||||
if set(payload) != {"passed", "comment"} or not isinstance(payload["passed"], bool):
|
||||
raise ValueError("неверная схема")
|
||||
comment = payload["comment"]
|
||||
if not isinstance(comment, str) or len(comment) > 500:
|
||||
raise ValueError("неверный комментарий")
|
||||
errors = list(baseline.errors)
|
||||
if not payload["passed"]:
|
||||
errors.append(comment.strip() or "локальная модель обнаружила языковые ошибки")
|
||||
return GrammarAssessment(not errors, tuple(dict.fromkeys(errors)), "vikhr")
|
||||
except (LlmUnavailable, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
||||
return baseline
|
||||
finally:
|
||||
await client.aclose()
|
||||
77
backend/app/scoring/group_insight.py
Normal file
77
backend/app/scoring/group_insight.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Локальный Qwen-инсайт по обезличенной агрегированной аналитике группы."""
|
||||
|
||||
import json
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable, is_loopback_url
|
||||
|
||||
|
||||
class InsightInvalid(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
async def generate_group_insight(data: dict) -> dict:
|
||||
settings = get_settings()
|
||||
if (not settings.llm_model_caller or not is_loopback_url(
|
||||
settings.llm_base_url, allow_docker_host=settings.allow_docker_host_models
|
||||
)):
|
||||
raise LlmUnavailable("локальная модель аналитики не настроена")
|
||||
schema = {"type": "json_object", "schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "minLength": 20, "maxLength": 700},
|
||||
"priorities": {
|
||||
"type": "array", "minItems": 1, "maxItems": 3,
|
||||
"items": {"type": "string", "minLength": 8, "maxLength": 240},
|
||||
},
|
||||
},
|
||||
"required": ["summary", "priorities"],
|
||||
"additionalProperties": False,
|
||||
}}
|
||||
prompt = {
|
||||
"active_trainees": data["active_trainees"],
|
||||
"scored_attempts": data["scored_attempts"],
|
||||
"average_score": data["average_score"],
|
||||
"typical_errors": [{
|
||||
"code": item["code"],
|
||||
"title": item["title"],
|
||||
"rate_percent": item["rate_percent"],
|
||||
"rule_recommendation": item["recommendation"],
|
||||
} for item in data["errors"][:8]],
|
||||
}
|
||||
request = LlmRequest(
|
||||
model=settings.llm_model_caller,
|
||||
messages=[{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Ты методист учебного центра системы-112. По обезличенной агрегированной "
|
||||
"статистике сформулируй краткий русский вывод и от одной до трёх конкретных "
|
||||
"тем следующего занятия. Не придумывай персональные данные, новые числа, "
|
||||
"диагнозы или нормативы. Верни только JSON по заданной схеме. /no_think"
|
||||
),
|
||||
}, {"role": "user", "content": json.dumps(prompt, ensure_ascii=False)}],
|
||||
temperature=0.0,
|
||||
max_tokens=420,
|
||||
response_format=schema,
|
||||
strip_reasoning=True,
|
||||
)
|
||||
client = LlmClient(base_url=settings.llm_base_url, timeout=25)
|
||||
try:
|
||||
raw = await client.complete(request, use_cache=True)
|
||||
finally:
|
||||
await client.aclose()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise InsightInvalid("модель вернула не JSON") from exc
|
||||
if set(payload) != {"summary", "priorities"}:
|
||||
raise InsightInvalid("неверный набор полей инсайта")
|
||||
summary = payload["summary"]
|
||||
priorities = payload["priorities"]
|
||||
if not isinstance(summary, str) or not 20 <= len(summary.strip()) <= 700:
|
||||
raise InsightInvalid("неверная длина вывода")
|
||||
if (not isinstance(priorities, list) or not 1 <= len(priorities) <= 3
|
||||
or any(not isinstance(item, str) or not 8 <= len(item.strip()) <= 240
|
||||
for item in priorities)):
|
||||
raise InsightInvalid("неверные приоритеты")
|
||||
return {"summary": summary.strip(), "priorities": [item.strip() for item in priorities]}
|
||||
|
|
@ -46,6 +46,8 @@ def assess_difference(
|
|||
|
||||
def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
||||
score = state.score or {}
|
||||
metrics = [Metric.model_validate(item) for item in score.get("metrics", [])]
|
||||
failed_metrics = sum(not item.passed and item.weight > 0 for item in metrics)
|
||||
# Без модели эмбеддингов распознать *какие* факты прозвучали нельзя.
|
||||
# Но если оператор не произнёс ни слова, все обязательные вопросы точно
|
||||
# пропущены — это доказуемый частный случай и он нужен офлайн-разбору.
|
||||
|
|
@ -78,9 +80,12 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
else scenario.id),
|
||||
mode=state.mode,
|
||||
attempt=state.attempt,
|
||||
criteria=state.criteria,
|
||||
failed_metrics=failed_metrics,
|
||||
passed=failed_metrics <= state.criteria.allowed_errors,
|
||||
transcript=list(state.transcript),
|
||||
findings=[Finding.model_validate(item) for item in score.get("findings", [])],
|
||||
metrics=[Metric.model_validate(item) for item in score.get("metrics", [])],
|
||||
metrics=metrics,
|
||||
card_results=[DdsCardReport.model_validate(item)
|
||||
for item in score.get("card_results", [])],
|
||||
competencies=[CompetencyScore.model_validate(item) for item in score.get("competencies", [])],
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
|
|||
"dds_progress": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_completion": (ErrorCode.D6, Competency.CARD),
|
||||
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
|
|
|
|||
358
backend/app/session/checkpoint.py
Normal file
358
backend/app/session/checkpoint.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Переносимый JSON-снимок незавершённого занятия.
|
||||
|
||||
Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не
|
||||
содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса
|
||||
они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
LessonCriteria,
|
||||
Metric,
|
||||
SessionMode,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.domain.kio import KIO
|
||||
from app.domain.statuses import (
|
||||
PhoneCallPending,
|
||||
PhoneLineRecord,
|
||||
PhoneReportRecord,
|
||||
StatusEntry,
|
||||
)
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
|
||||
from app.session.timers import SessionTimers, Timer
|
||||
|
||||
CHECKPOINT_VERSION = 1
|
||||
|
||||
|
||||
def _grammar(value: GrammarAssessment | None) -> dict | None:
|
||||
if value is None:
|
||||
return None
|
||||
return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
|
||||
|
||||
|
||||
def _dump_timers(timers: SessionTimers, now: float) -> dict:
|
||||
return {
|
||||
"limits": {code.value: limit for code, limit in timers.limits.items()},
|
||||
"items": {
|
||||
code.value: {
|
||||
"elapsed_ms": timer.current_ms(now),
|
||||
"attempt": timer.attempt,
|
||||
"stopped": timer.stopped,
|
||||
}
|
||||
for code, timer in timers.timers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
|
||||
return {
|
||||
"original_index": item.original_index,
|
||||
"scenario": item.scenario.model_dump(mode="json"),
|
||||
"kio": item.kio.model_dump(mode="json"),
|
||||
"dispatched_card": item.dispatched_card.model_dump(mode="json"),
|
||||
"dispatched_at": item.dispatched_at.isoformat(),
|
||||
"timers": _dump_timers(item.timers, now),
|
||||
"bounced_fields": item.bounced_fields,
|
||||
"dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
|
||||
"status_log": [entry.model_dump(mode="json") for entry in item.status_log],
|
||||
"crew_selected": item.crew_selected,
|
||||
"crew_assignments": item.crew_assignments,
|
||||
"phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
|
||||
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
|
||||
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
|
||||
"reply_text": item.reply_text,
|
||||
"reply_grammar": _grammar(item.reply_grammar),
|
||||
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
|
||||
}
|
||||
|
||||
|
||||
def dump_state(state: SessionState) -> dict:
|
||||
"""Сериализовать только данные, необходимые для точного продолжения."""
|
||||
state.capture_active_dds()
|
||||
now = time.monotonic()
|
||||
payload = {
|
||||
"version": CHECKPOINT_VERSION,
|
||||
"session_id": str(state.session_id),
|
||||
"scenario_id": state.scenario_id,
|
||||
"scenario_title": state.scenario_title,
|
||||
"level": state.level,
|
||||
"mode": state.mode.value,
|
||||
"owner_login": state.owner_login,
|
||||
"exercise": state.exercise.value,
|
||||
"handoff_to_dds": state.handoff_to_dds,
|
||||
"required_fields": state.required_fields,
|
||||
"trainee_name": state.trainee_name,
|
||||
"trainee_id": str(state.trainee_id) if state.trainee_id else None,
|
||||
"dds_service": state.dds_service,
|
||||
"attempt": state.attempt,
|
||||
"criteria": state.criteria.model_dump(mode="json"),
|
||||
"kio": state.kio.model_dump(mode="json"),
|
||||
"transcript": [entry.model_dump(mode="json") for entry in state.transcript],
|
||||
"timers": _dump_timers(state.timers, now),
|
||||
"hints_shown": state.hints_shown,
|
||||
"hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
|
||||
"notes": state.notes,
|
||||
"directives": state.directives,
|
||||
"scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
|
||||
"audio_frames": state.audio_frames,
|
||||
"bad_frames": state.bad_frames,
|
||||
"self_assessed": state.self_assessed,
|
||||
"self_assessment": state.self_assessment,
|
||||
"score": state.score,
|
||||
"started_at": state.started_at.isoformat() if state.started_at else None,
|
||||
"ended_at": state.ended_at.isoformat() if state.ended_at else None,
|
||||
"end_reason": state.end_reason.value if state.end_reason else None,
|
||||
"dispatched_card": (
|
||||
state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
|
||||
),
|
||||
"dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
|
||||
"bounced_fields": state.bounced_fields,
|
||||
"dds_log": [
|
||||
[action, at.isoformat(), detail] for action, at, detail in state.dds_log
|
||||
],
|
||||
"status_log": [item.model_dump(mode="json") for item in state.status_log],
|
||||
"crew_selected": state.crew_selected,
|
||||
"crew_assignments": state.crew_assignments,
|
||||
"phone_reports": [item.model_dump(mode="json") for item in state.phone_reports],
|
||||
"phone_lines": [item.model_dump(mode="json") for item in state.phone_lines],
|
||||
"phone_pending": (
|
||||
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
|
||||
),
|
||||
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
|
||||
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
|
||||
"dds_active_card_id": (
|
||||
str(state.dds_active_card_id) if state.dds_active_card_id else None
|
||||
),
|
||||
"dds_card_index": state.dds_card_index,
|
||||
"dds_arrival_interval_seconds": state.dds_arrival_interval_seconds,
|
||||
"dds_max_waiting": state.dds_max_waiting,
|
||||
"dds_next_scenario_index": state.dds_next_scenario_index,
|
||||
"dds_next_arrival_at": (
|
||||
state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None
|
||||
),
|
||||
"dds_completed": [
|
||||
{
|
||||
"card_id": str(item.card_id),
|
||||
"scenario_id": item.scenario_id,
|
||||
"reply_text": item.reply_text,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in item.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in item.findings],
|
||||
"actions": item.actions,
|
||||
"duration_ms": item.duration_ms,
|
||||
}
|
||||
for item in state.dds_completed
|
||||
],
|
||||
"reply_text": state.reply_text,
|
||||
"reply_grammar": _grammar(state.reply_grammar),
|
||||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
}
|
||||
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
|
||||
# получать только стандартные JSON-типы.
|
||||
return jsonable_encoder(payload)
|
||||
|
||||
|
||||
def _dt(value: str | None) -> datetime | None:
|
||||
return datetime.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
|
||||
raw = payload.get("timers") or {}
|
||||
limits = {
|
||||
TimerCode(code): int(limit)
|
||||
for code, limit in (raw.get("limits") or {}).items()
|
||||
}
|
||||
restored = SessionTimers(limits=limits or SessionTimers().limits)
|
||||
now_mono = time.monotonic()
|
||||
now_wall = datetime.now(UTC)
|
||||
if saved_at.tzinfo is None:
|
||||
saved_at = saved_at.replace(tzinfo=UTC)
|
||||
downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
|
||||
for raw_code, item in (raw.get("items") or {}).items():
|
||||
code = TimerCode(raw_code)
|
||||
stopped = bool(item.get("stopped"))
|
||||
elapsed = max(0, int(item.get("elapsed_ms", 0)))
|
||||
total = elapsed if stopped else elapsed + downtime_ms
|
||||
restored.timers[code] = Timer(
|
||||
code=code,
|
||||
started_at=None if stopped else now_mono - total / 1000,
|
||||
elapsed_ms=elapsed if stopped else 0,
|
||||
attempt=max(1, int(item.get("attempt", 1))),
|
||||
stopped=stopped,
|
||||
)
|
||||
return restored
|
||||
|
||||
|
||||
def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
|
||||
if not value:
|
||||
return None
|
||||
return GrammarAssessment(
|
||||
passed=bool(value["passed"]),
|
||||
errors=tuple(value.get("errors") or []),
|
||||
source=value["source"],
|
||||
)
|
||||
|
||||
|
||||
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
|
||||
return DdsLiveCard(
|
||||
original_index=int(item["original_index"]),
|
||||
scenario=Scenario.model_validate(item["scenario"]),
|
||||
kio=KIO.model_validate(item["kio"]),
|
||||
dispatched_card=KIO.model_validate(item["dispatched_card"]),
|
||||
dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
|
||||
timers=_restore_timers({"timers": item["timers"]}, saved_at),
|
||||
bounced_fields=list(item.get("bounced_fields") or []),
|
||||
dds_log=[(action, datetime.fromisoformat(at), detail)
|
||||
for action, at, detail in item.get("dds_log", [])],
|
||||
status_log=[StatusEntry.model_validate(entry)
|
||||
for entry in item.get("status_log", [])],
|
||||
crew_selected=item.get("crew_selected"),
|
||||
crew_assignments=dict(item.get("crew_assignments") or {}),
|
||||
phone_reports=[PhoneReportRecord.model_validate(entry)
|
||||
for entry in item.get("phone_reports", [])],
|
||||
phone_lines=[PhoneLineRecord.model_validate(entry)
|
||||
for entry in item.get("phone_lines", [])],
|
||||
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
|
||||
if item.get("phone_pending") else None),
|
||||
reply_text=item.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(item.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
for at, text in item.get("reply_log", [])],
|
||||
)
|
||||
|
||||
|
||||
def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
||||
"""Восстановить состояние; неизвестная версия отклоняется явно."""
|
||||
if payload.get("version") != CHECKPOINT_VERSION:
|
||||
raise ValueError("неподдерживаемая версия снимка занятия")
|
||||
scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None
|
||||
state = SessionState(
|
||||
session_id=UUID(payload["session_id"]),
|
||||
scenario_id=payload["scenario_id"],
|
||||
scenario_title=payload["scenario_title"],
|
||||
level=payload["level"],
|
||||
mode=SessionMode(payload["mode"]),
|
||||
owner_login=payload.get("owner_login"),
|
||||
exercise=Exercise(payload["exercise"]),
|
||||
handoff_to_dds=bool(payload.get("handoff_to_dds")),
|
||||
required_fields=list(payload.get("required_fields") or []),
|
||||
trainee_name=payload.get("trainee_name"),
|
||||
trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
|
||||
dds_service=payload.get("dds_service"),
|
||||
attempt=int(payload.get("attempt", 1)),
|
||||
criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
|
||||
kio=KIO.model_validate(payload.get("kio") or {}),
|
||||
transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
|
||||
timers=_restore_timers(payload, saved_at),
|
||||
hints_shown=list(payload.get("hints_shown") or []),
|
||||
hints_log=[(item, datetime.fromisoformat(at))
|
||||
for item, at in payload.get("hints_log", [])],
|
||||
notes=list(payload.get("notes") or []),
|
||||
directives=list(payload.get("directives") or []),
|
||||
scenario=scenario,
|
||||
audio_frames=int(payload.get("audio_frames", 0)),
|
||||
bad_frames=int(payload.get("bad_frames", 0)),
|
||||
self_assessed=bool(payload.get("self_assessed")),
|
||||
self_assessment=payload.get("self_assessment"),
|
||||
score=payload.get("score"),
|
||||
started_at=_dt(payload.get("started_at")),
|
||||
ended_at=_dt(payload.get("ended_at")),
|
||||
end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
|
||||
dispatched_card=(
|
||||
KIO.model_validate(payload["dispatched_card"])
|
||||
if payload.get("dispatched_card") else None
|
||||
),
|
||||
dispatched_at=_dt(payload.get("dispatched_at")),
|
||||
bounced_fields=list(payload.get("bounced_fields") or []),
|
||||
dds_log=[(action, datetime.fromisoformat(at), detail)
|
||||
for action, at, detail in payload.get("dds_log", [])],
|
||||
status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
|
||||
crew_selected=payload.get("crew_selected"),
|
||||
crew_assignments=dict(payload.get("crew_assignments") or {}),
|
||||
phone_reports=[PhoneReportRecord.model_validate(item)
|
||||
for item in payload.get("phone_reports", [])],
|
||||
phone_lines=[PhoneLineRecord.model_validate(item)
|
||||
for item in payload.get("phone_lines", [])],
|
||||
phone_pending=(
|
||||
PhoneCallPending.model_validate(payload["phone_pending"])
|
||||
if payload.get("phone_pending") else None
|
||||
),
|
||||
dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("dds_scenarios", [])],
|
||||
dds_live_cards=[_restore_live_card(item, saved_at)
|
||||
for item in payload.get("dds_live_cards", [])],
|
||||
dds_active_card_id=(
|
||||
UUID(payload["dds_active_card_id"])
|
||||
if payload.get("dds_active_card_id") else None
|
||||
),
|
||||
dds_card_index=int(payload.get("dds_card_index", 0)),
|
||||
dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
|
||||
dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
|
||||
dds_next_scenario_index=int(payload.get(
|
||||
"dds_next_scenario_index",
|
||||
max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
|
||||
)),
|
||||
dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
|
||||
dds_completed=[
|
||||
DdsCardRecord(
|
||||
card_id=UUID(item["card_id"]),
|
||||
scenario_id=item["scenario_id"],
|
||||
reply_text=item.get("reply_text", ""),
|
||||
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
|
||||
findings=[Finding.model_validate(finding)
|
||||
for finding in item.get("findings", [])],
|
||||
actions=list(item.get("actions") or []),
|
||||
duration_ms=int(item.get("duration_ms", 0)),
|
||||
)
|
||||
for item in payload.get("dds_completed", [])
|
||||
],
|
||||
reply_text=payload.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
resolve_comment=payload.get("resolve_comment", ""),
|
||||
)
|
||||
if state.dds_live_cards:
|
||||
# Legacy snapshots had no explicit active ID; newer snapshots may
|
||||
# intentionally be between cards while waiting for the next arrival.
|
||||
active_id = state.dds_active_card_id
|
||||
if active_id is None and "dds_active_card_id" not in payload:
|
||||
active_id = state.dds_live_cards[0].card_id
|
||||
if active_id is not None:
|
||||
state.activate_dds_card(active_id, capture=False)
|
||||
if (state.dds_scenarios
|
||||
and state.dds_next_scenario_index < len(state.dds_scenarios)
|
||||
and state.dds_next_arrival_at is None):
|
||||
# Old checkpoints had no delivery schedule; resume any remaining
|
||||
# selected scenarios immediately rather than strand the session.
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
|
||||
# карточка и оценка при этом остаются прежними.
|
||||
if state.exercise is Exercise.CALL and state.scenario is not None:
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
state.scenario.id,
|
||||
use_pregenerated=state.scenario.tree.pregenerated,
|
||||
)
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
return state
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
|
||||
"""Одновременная выдача учебных карточек ДДС в рамках одного занятия."""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.state import DdsLiveCard, SessionState, now_utc
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
|
|
@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.required_fields = list(scenario.required_fields)
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
|
||||
caller_phone = next((
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
), None)
|
||||
caller_names = re.findall(
|
||||
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
|
||||
caller_fact,
|
||||
)
|
||||
# Some ticket facts name multiple witnesses; don't silently attribute the
|
||||
# contact number to the first person when the source doesn't disambiguate.
|
||||
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
|
|
@ -41,13 +56,110 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
|
||||
state.status_log.clear()
|
||||
state.dds_log.clear()
|
||||
state.bounced_fields.clear()
|
||||
# A live DДС card keeps references to the current working collections.
|
||||
# Never clear them in place when preparing the next card: that would also
|
||||
# erase the previous card's history. Replace the working collections so
|
||||
# every queued card retains an isolated snapshot.
|
||||
state.status_log = []
|
||||
state.dds_log = []
|
||||
state.bounced_fields = []
|
||||
state.crew_selected = None
|
||||
state.crew_assignments.clear()
|
||||
state.phone_reports.clear()
|
||||
state.crew_assignments = {}
|
||||
state.phone_reports = []
|
||||
state.phone_lines = []
|
||||
state.phone_pending = None
|
||||
state.reply_text = ""
|
||||
state.reply_log.clear()
|
||||
state.timers = SessionTimers()
|
||||
state.reply_grammar = None
|
||||
state.reply_log = []
|
||||
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
|
||||
state.timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
state.on_event("dds.dispatch")
|
||||
|
||||
|
||||
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
|
||||
state.dds_card_index = index
|
||||
prepare_card(state, scenario)
|
||||
card = DdsLiveCard(
|
||||
original_index=index,
|
||||
scenario=state.scenario,
|
||||
kio=state.kio,
|
||||
dispatched_card=state.dispatched_card,
|
||||
dispatched_at=state.dispatched_at,
|
||||
timers=state.timers,
|
||||
bounced_fields=state.bounced_fields,
|
||||
dds_log=state.dds_log,
|
||||
status_log=state.status_log,
|
||||
crew_selected=state.crew_selected,
|
||||
crew_assignments=state.crew_assignments,
|
||||
phone_reports=state.phone_reports,
|
||||
phone_lines=state.phone_lines,
|
||||
phone_pending=state.phone_pending,
|
||||
reply_text=state.reply_text,
|
||||
reply_grammar=state.reply_grammar,
|
||||
reply_log=state.reply_log,
|
||||
)
|
||||
state.dds_live_cards.append(card)
|
||||
return card
|
||||
|
||||
|
||||
def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
|
||||
"""Выдать всё, чему настал срок и для чего есть место в ожидании.
|
||||
|
||||
Активная карточка не считается ожидающей. Каждый вызов prepare_card
|
||||
запускает собственный 30-секундный таймер ровно при поступлении.
|
||||
"""
|
||||
now = now or now_utc()
|
||||
if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now:
|
||||
return 0
|
||||
|
||||
delivered = 0
|
||||
while state.dds_next_scenario_index < len(state.dds_scenarios):
|
||||
active_id = state.dds_active_card_id if any(
|
||||
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
|
||||
) else None
|
||||
active_exists = active_id is not None
|
||||
waiting_count = len(state.dds_live_cards) - int(active_exists)
|
||||
if waiting_count >= state.dds_max_waiting:
|
||||
break
|
||||
|
||||
index = state.dds_next_scenario_index
|
||||
card = _append_live_card(state, state.dds_scenarios[index], index)
|
||||
state.dds_next_scenario_index += 1
|
||||
delivered += 1
|
||||
if not active_exists:
|
||||
state.activate_dds_card(card.card_id, capture=False)
|
||||
else:
|
||||
# prepare_card builds the new snapshot through SessionState's
|
||||
# working fields; restore the trainee's active card immediately.
|
||||
state.activate_dds_card(active_id, capture=False)
|
||||
|
||||
if state.dds_next_scenario_index >= len(state.dds_scenarios):
|
||||
state.dds_next_arrival_at = None
|
||||
break
|
||||
if state.dds_arrival_interval_seconds:
|
||||
# Pace from actual delivery time. If the cap was full, the next
|
||||
# card becomes due one full interval after this release.
|
||||
state.dds_next_arrival_at = card.dispatched_at + timedelta(
|
||||
seconds=state.dds_arrival_interval_seconds
|
||||
)
|
||||
break
|
||||
# Zero interval means fill active + available waiting slots now.
|
||||
state.dds_next_arrival_at = now
|
||||
|
||||
return delivered
|
||||
|
||||
|
||||
def prepare_queue(
|
||||
state: SessionState,
|
||||
scenarios: list[Scenario],
|
||||
arrival_interval_seconds: int = 0,
|
||||
max_waiting: int = 3,
|
||||
) -> None:
|
||||
"""Начать поступление очереди с первой карточки; остальные идут по темпу."""
|
||||
state.dds_live_cards.clear()
|
||||
state.dds_scenarios = scenarios
|
||||
state.dds_arrival_interval_seconds = arrival_interval_seconds
|
||||
state.dds_max_waiting = max_waiting
|
||||
state.dds_next_scenario_index = 0
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
deliver_due_cards(state)
|
||||
|
|
|
|||
|
|
@ -7,17 +7,18 @@
|
|||
событие сразу: им ждать нечего.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, Metric, ScoreReady
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
|
@ -28,34 +29,16 @@ log = logging.getLogger(__name__)
|
|||
def score_current_dds(state) -> DdsCardRecord:
|
||||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
services=state.managed_services(),
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
|
||||
reply = state.reply_text.strip()
|
||||
if reply or len(state.dds_scenarios) > 1:
|
||||
passed = len(reply) >= 20 and len(reply.split()) >= 3
|
||||
metrics.append(Metric(
|
||||
key="dds_reply", title="Свободный ответ по карточке",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="содержательный текст не короче 20 символов и 3 слов",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
passed=passed, weight=1.5,
|
||||
))
|
||||
if not passed:
|
||||
findings.append(Finding(
|
||||
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
|
||||
summary="Свободный ответ отсутствует или слишком краток",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
apply_weights(weighted, state.scenario.score_weights)
|
||||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
"comment": mark.comment, "at": mark.at.isoformat()}
|
||||
|
|
@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
|
||||
|
||||
async def finish(session_id: UUID, state) -> None:
|
||||
if state.recorder is not None:
|
||||
path = await asyncio.to_thread(state.recorder.finalize)
|
||||
state.recording_path = str(path) if path else None
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
if scenario is None:
|
||||
|
|
@ -95,22 +81,20 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
|
||||
# сохранены отдельными результатами; активную оцениваем лишь однажды.
|
||||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||||
result = GostResult()
|
||||
cards = list(state.dds_completed)
|
||||
if state.dispatched_card and not any(
|
||||
item.card_id == state.dispatched_card.card_id for item in cards
|
||||
):
|
||||
state.capture_active_dds()
|
||||
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
|
||||
if any(item.card_id == live.card_id for item in cards):
|
||||
continue
|
||||
state.activate_dds_card(live.card_id, capture=False)
|
||||
cards.append(score_current_dds(state))
|
||||
state.dds_completed = cards
|
||||
for card in cards:
|
||||
result.metrics.extend(card.metrics)
|
||||
result.findings.extend(card.findings)
|
||||
result.unavailable.append(
|
||||
"Грамматика свободного ответа: автоматическая проверка не настроена; "
|
||||
"оценивались только наличие и минимальная полнота текста"
|
||||
)
|
||||
else:
|
||||
result = evaluate(
|
||||
scenario=scenario,
|
||||
|
|
@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if state.dispatched_card is not None and (
|
||||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||||
):
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
services=state.managed_services(),
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||||
# DDS cards were weighted individually in score_current_dds using each
|
||||
# card's scenario defaults plus the lesson override. Reapplying the first
|
||||
# scenario's weights here would corrupt the other ticket cards.
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, scenario.score_weights)
|
||||
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None:
|
|||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
}
|
||||
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
if hub.journal:
|
||||
|
|
@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None:
|
|||
await release_score(session_id, state)
|
||||
|
||||
|
||||
async def refresh_archived_report(session_id: UUID, state) -> None:
|
||||
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
|
||||
if state.score is None:
|
||||
return
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
if scenario is None:
|
||||
return
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
if hub.journal:
|
||||
await hub.journal.score_snapshot(session_id, state.score)
|
||||
|
||||
|
||||
async def release_score(session_id: UUID, state) -> None:
|
||||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from uuid import UUID
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import TimerTick
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -30,14 +30,20 @@ class Journal(Protocol):
|
|||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]: ...
|
||||
) -> tuple[int, UUID | None, str | None]: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str,
|
||||
) -> None: ...
|
||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||
async def checkpoint(self, state: SessionState) -> None: ...
|
||||
async def restore_active(self) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -58,10 +64,26 @@ class SessionHub:
|
|||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
for state in self._sessions.values()
|
||||
)
|
||||
|
||||
def drop(self, session_id: UUID) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
self.stop_ticker(session_id)
|
||||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
|
@ -138,6 +160,18 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
delivered = deliver_due_cards(state)
|
||||
if state.dds_active_card_id != active_before and state.dds_active_card_id:
|
||||
self.to_station(session_id, state.card_received_event())
|
||||
if delivered:
|
||||
await self.checkpoint(session_id)
|
||||
# Keep the pending count and countdown live even while
|
||||
# the active dispatcher card is being handled.
|
||||
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import logging
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import Score, SelfAssessment, Session
|
||||
from app.db.models import Score, SelfAssessment, Session, User, Utterance
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,8 +35,8 @@ class DbJournal:
|
|||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]:
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
|
|
@ -48,11 +51,75 @@ class DbJournal:
|
|||
mode=mode,
|
||||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
)
|
||||
return row.attempt, row.trainee_id
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
select(User.service)
|
||||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1, trainee_id
|
||||
return 1, trainee_id, None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
async def action(db):
|
||||
values = (
|
||||
{"live_state": None, "checkpoint_at": None}
|
||||
if state.ended
|
||||
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
|
||||
)
|
||||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
utterances = (await db.scalars(
|
||||
select(Utterance)
|
||||
.where(Utterance.session_id == row.id)
|
||||
.order_by(Utterance.at, Utterance.ref)
|
||||
)).all()
|
||||
if utterances:
|
||||
state.transcript = [
|
||||
TranscriptEntry(
|
||||
ref=item.ref,
|
||||
speaker=Speaker(item.speaker),
|
||||
text=item.text,
|
||||
at=item.at,
|
||||
mood=Mood(item.mood) if item.mood else None,
|
||||
)
|
||||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.exception("журнал: снимок занятия %s повреждён", row.id)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
await self._write(
|
||||
|
|
@ -111,6 +178,16 @@ class DbJournal:
|
|||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score).where(Score.session_id == session_id).values(report=report)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
|
|
@ -123,7 +200,12 @@ class DbJournal:
|
|||
await db.execute(
|
||||
update(Session)
|
||||
.where(Session.id == session_id)
|
||||
.values(ended_at=at, end_reason=reason)
|
||||
.values(
|
||||
ended_at=at,
|
||||
end_reason=reason,
|
||||
live_state=None,
|
||||
checkpoint_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,45 +5,52 @@
|
|||
пустой экран (docs/arch/STACK.md).
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Metric,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.dialog.caller import TemplateCaller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
LessonCriteria,
|
||||
Metric,
|
||||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.domain.statuses import (
|
||||
CardStatus,
|
||||
NEXT,
|
||||
DdsCardSummary,
|
||||
DdsQueueCard,
|
||||
PhoneCallPending,
|
||||
PhoneLineRecord,
|
||||
PhoneReportRecord,
|
||||
ServiceStatus,
|
||||
StationSnapshot,
|
||||
StatusEntry,
|
||||
PhoneReportRecord,
|
||||
DdsCardSummary,
|
||||
card_status,
|
||||
check,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.session.timers import SessionTimers
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -63,6 +70,33 @@ class DdsCardRecord:
|
|||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DdsLiveCard:
|
||||
"""Изолированное живое состояние одной одновременно выданной карточки."""
|
||||
|
||||
original_index: int
|
||||
scenario: Scenario
|
||||
kio: KIO
|
||||
dispatched_card: KIO
|
||||
dispatched_at: datetime
|
||||
timers: SessionTimers
|
||||
bounced_fields: list[str] = field(default_factory=list)
|
||||
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
||||
status_log: list[StatusEntry] = field(default_factory=list)
|
||||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def card_id(self) -> UUID:
|
||||
return self.dispatched_card.card_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
session_id: UUID
|
||||
|
|
@ -70,6 +104,8 @@ class SessionState:
|
|||
scenario_title: str
|
||||
level: str
|
||||
mode: SessionMode
|
||||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||||
owner_login: str | None = None
|
||||
exercise: Exercise = Exercise.CALL
|
||||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||||
handoff_to_dds: bool = False
|
||||
|
|
@ -78,7 +114,11 @@ class SessionState:
|
|||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||||
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
||||
trainee_id: UUID | None = None
|
||||
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
|
||||
#: остальные адресаты карточки показываются информационно.
|
||||
dds_service: str | None = None
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria = field(default_factory=LessonCriteria)
|
||||
|
||||
kio: KIO = field(default_factory=KIO)
|
||||
transcript: list[TranscriptEntry] = field(default_factory=list)
|
||||
|
|
@ -100,6 +140,8 @@ class SessionState:
|
|||
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
|
||||
# тогда кадры микрофона только считаются.
|
||||
voice: object | None = None
|
||||
recorder: object | None = None
|
||||
recording_path: str | None = None
|
||||
|
||||
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
||||
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
||||
|
|
@ -124,10 +166,19 @@ class SessionState:
|
|||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
||||
dds_active_card_id: UUID | None = None
|
||||
dds_card_index: int = 0
|
||||
dds_completed: list[DdsCardRecord] = field(default_factory=list)
|
||||
dds_arrival_interval_seconds: int = 0
|
||||
dds_max_waiting: int = 3
|
||||
dds_next_scenario_index: int = 0
|
||||
dds_next_arrival_at: datetime | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
|
|
@ -168,12 +219,106 @@ class SessionState:
|
|||
card = self.dispatched_card or self.kio
|
||||
return list(card.notify)
|
||||
|
||||
def _managed_services_for(self, recipients: list[str]) -> list[str]:
|
||||
"""Служба, от имени которой работает этот обучающийся.
|
||||
|
||||
Старые занятия и demo без профиля безопасно закрепляются за первым
|
||||
адресатом. Обобщённая учебная служба «ДДС района» соответствует
|
||||
территориальному органу, а не всем получателям карточки сразу.
|
||||
"""
|
||||
if not recipients:
|
||||
return []
|
||||
wanted = (self.dds_service or "").strip().casefold()
|
||||
if wanted:
|
||||
exact = next((item for item in recipients if item.casefold() == wanted), None)
|
||||
if exact:
|
||||
return [exact]
|
||||
contained = next(
|
||||
(item for item in recipients
|
||||
if wanted in item.casefold() or item.casefold() in wanted),
|
||||
None,
|
||||
)
|
||||
if contained:
|
||||
return [contained]
|
||||
if "район" in wanted:
|
||||
district = next(
|
||||
(item for item in recipients if "территориальн" in item.casefold()), None
|
||||
)
|
||||
if district:
|
||||
return [district]
|
||||
return []
|
||||
return [recipients[0]]
|
||||
|
||||
def managed_services(self) -> list[str]:
|
||||
return self._managed_services_for(self.notified_services())
|
||||
|
||||
def capture_active_dds(self) -> None:
|
||||
"""Сохранить изменяемые поля активной карточки перед переключением."""
|
||||
if not self.dispatched_card:
|
||||
return
|
||||
card = next(
|
||||
(item for item in self.dds_live_cards
|
||||
if item.card_id == self.dispatched_card.card_id),
|
||||
None,
|
||||
)
|
||||
if card is None:
|
||||
return
|
||||
card.scenario = self.scenario
|
||||
card.kio = self.kio
|
||||
card.dispatched_card = self.dispatched_card
|
||||
card.dispatched_at = self.dispatched_at or card.dispatched_at
|
||||
card.timers = self.timers
|
||||
card.bounced_fields = self.bounced_fields
|
||||
card.dds_log = self.dds_log
|
||||
card.status_log = self.status_log
|
||||
card.crew_selected = self.crew_selected
|
||||
card.crew_assignments = self.crew_assignments
|
||||
card.phone_reports = self.phone_reports
|
||||
card.phone_lines = self.phone_lines
|
||||
card.phone_pending = self.phone_pending
|
||||
card.reply_text = self.reply_text
|
||||
card.reply_grammar = self.reply_grammar
|
||||
card.reply_log = self.reply_log
|
||||
self.dds_active_card_id = card.card_id
|
||||
|
||||
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
|
||||
"""Переключить рабочее место на карточку, не останавливая остальные."""
|
||||
if capture:
|
||||
self.capture_active_dds()
|
||||
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
|
||||
if card is None:
|
||||
return False
|
||||
scenario = card.scenario
|
||||
self.scenario = scenario
|
||||
self.scenario_id = scenario.id
|
||||
self.scenario_title = scenario.title
|
||||
self.level = scenario.level.value
|
||||
self.required_fields = list(scenario.required_fields)
|
||||
self.kio = card.kio
|
||||
self.dispatched_card = card.dispatched_card
|
||||
self.dispatched_at = card.dispatched_at
|
||||
self.timers = card.timers
|
||||
self.bounced_fields = card.bounced_fields
|
||||
self.dds_log = card.dds_log
|
||||
self.status_log = card.status_log
|
||||
self.crew_selected = card.crew_selected
|
||||
self.crew_assignments = card.crew_assignments
|
||||
self.phone_reports = card.phone_reports
|
||||
self.phone_lines = card.phone_lines
|
||||
self.phone_pending = card.phone_pending
|
||||
self.reply_text = card.reply_text
|
||||
self.reply_grammar = card.reply_grammar
|
||||
self.reply_log = card.reply_log
|
||||
self.dds_card_index = card.original_index
|
||||
self.dds_active_card_id = card.card_id
|
||||
return True
|
||||
|
||||
def crew_options(self) -> list[str]:
|
||||
return [f"{service} — бригада №{number}"
|
||||
for service in self.notified_services() for number in (1, 2)]
|
||||
for service in self.managed_services() for number in (1, 2)]
|
||||
|
||||
def crew_service(self, crew: str) -> str | None:
|
||||
return next((service for service in self.notified_services()
|
||||
return next((service for service in self.managed_services()
|
||||
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
||||
|
||||
def set_service_status(
|
||||
|
|
@ -190,31 +335,84 @@ class SessionState:
|
|||
|
||||
def station_snapshot(self) -> StationSnapshot:
|
||||
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
|
||||
services = self.notified_services()
|
||||
self.capture_active_dds()
|
||||
has_active_dds_card = any(
|
||||
item.card_id == self.dds_active_card_id for item in self.dds_live_cards
|
||||
)
|
||||
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
|
||||
services = self._managed_services_for(recipients)
|
||||
statuses = {service: current(self.status_log, service) for service in services}
|
||||
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
|
||||
overdue = self.dispatched_at is not None and (
|
||||
limit = self.timers.limits[TimerCode.DDS_ACK]
|
||||
overdue = has_active_dds_card and self.dispatched_at is not None and (
|
||||
elapsed is None
|
||||
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
|
||||
)
|
||||
now_mono = time.monotonic()
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
|
||||
recipients_for_card = list(item.dispatched_card.notify)
|
||||
managed = self._managed_services_for(recipients_for_card)
|
||||
service = managed[0] if managed else None
|
||||
timer = item.timers.timers.get(TimerCode.DDS_ACK)
|
||||
elapsed_ms = timer.current_ms(now_mono) if timer else 0
|
||||
stopped = timer.stopped if timer else False
|
||||
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
|
||||
queue_cards.append(DdsQueueCard(
|
||||
card_id=item.card_id,
|
||||
scenario_id=item.scenario.id,
|
||||
title=item.scenario.title,
|
||||
address=item.dispatched_card.address,
|
||||
description=item.dispatched_card.description,
|
||||
incident_type=(item.dispatched_card.incident_type.value
|
||||
if item.dispatched_card.incident_type else None),
|
||||
victims_count=item.dispatched_card.victims_count,
|
||||
received_at=item.dispatched_at,
|
||||
managed_service=service,
|
||||
service_status=(current(item.status_log, service)
|
||||
if service else ServiceStatus.ADDED),
|
||||
card_status=card_status(
|
||||
item.status_log, managed,
|
||||
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
|
||||
),
|
||||
elapsed_ms=elapsed_ms,
|
||||
limit_ms=limit_ms,
|
||||
timer_stopped=stopped,
|
||||
active=item.card_id == self.dds_active_card_id,
|
||||
))
|
||||
return StationSnapshot(
|
||||
services=services,
|
||||
recipient_services=recipients,
|
||||
managed_service=services[0] if services else None,
|
||||
statuses=statuses,
|
||||
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
||||
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
|
||||
card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
|
||||
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
|
||||
log=list(self.status_log),
|
||||
crew_options=self.crew_options(),
|
||||
crew_selected=self.crew_selected,
|
||||
phone_reports=list(self.phone_reports),
|
||||
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
|
||||
card_index=self.dds_card_index + 1,
|
||||
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
|
||||
crew_selected=self.crew_selected if has_active_dds_card else None,
|
||||
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
|
||||
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
|
||||
phone_pending=self.phone_pending if has_active_dds_card else None,
|
||||
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
|
||||
card_index=(self.dds_card_index + 1 if has_active_dds_card
|
||||
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
reply_text=self.reply_text,
|
||||
reply_text=self.reply_text if has_active_dds_card else "",
|
||||
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
||||
scenario_id=item.scenario_id,
|
||||
score_auto=item.score_auto)
|
||||
for item in self.dds_completed],
|
||||
queue_cards=queue_cards,
|
||||
pending_cards_count=max(
|
||||
0, len(self.dds_scenarios) - len(self.dds_completed)
|
||||
- (1 if has_active_dds_card else 0),
|
||||
),
|
||||
next_arrival_in_seconds=(
|
||||
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
|
||||
if self.dds_next_arrival_at is not None else None
|
||||
),
|
||||
max_waiting_cards=self.dds_max_waiting,
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -244,6 +442,7 @@ class SessionState:
|
|||
level=self.level,
|
||||
mode=self.mode,
|
||||
exercise=self.exercise,
|
||||
criteria=self.criteria,
|
||||
trainee_name=self.trainee_name,
|
||||
started_at=self.started_at,
|
||||
kio=self.kio,
|
||||
|
|
|
|||
|
|
@ -6,14 +6,18 @@ onnxruntime и torch отпускают GIL на время вычислений
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import wave
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dialog.llm import is_loopback_url
|
||||
from app.voice.text import normalize
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -51,6 +55,47 @@ class Recognizer:
|
|||
self.transcribe((rng.normal(0, 0.05, 16_000)).astype(np.float32))
|
||||
|
||||
|
||||
class WhisperRecognizer:
|
||||
"""Локальный whisper.cpp: GGML Whisper small, без облака и Python-зависимости STT.
|
||||
|
||||
VAD уже отделил реплику. Сервер держит модель в памяти, а Python отправляет
|
||||
ему только 16-кГц WAV через loopback; адрес из конфигурации проверяется.
|
||||
"""
|
||||
|
||||
def __init__(self, server_url: str, client: httpx.Client | None = None) -> None:
|
||||
if not is_loopback_url(server_url):
|
||||
raise ValueError("Whisper server должен слушать только loopback")
|
||||
self._url = server_url.rstrip("/")
|
||||
self._client = client or httpx.Client(trust_env=False, timeout=30.0)
|
||||
|
||||
def transcribe(self, audio: np.ndarray) -> str:
|
||||
pcm = (np.clip(audio, -1, 1) * 32767).astype("<i2")
|
||||
with io.BytesIO() as buffer:
|
||||
with wave.open(buffer, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(16_000)
|
||||
wav.writeframes(pcm.tobytes())
|
||||
payload = buffer.getvalue()
|
||||
response = self._client.post(
|
||||
self._url + "/inference",
|
||||
files={"file": ("utterance.wav", payload, "audio/wav")},
|
||||
data={"language": "ru", "response_format": "json", "temperature": "0",
|
||||
"temperature_inc": "0", "no_context": "true"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if not isinstance(result, dict) or not isinstance(result.get("text"), str):
|
||||
raise ValueError("некорректный ответ локального Whisper server")
|
||||
return result["text"].strip()
|
||||
|
||||
def warmup(self) -> None:
|
||||
# GET / отвечает локальная страница server.cpp. Не запускаем тяжёлый
|
||||
# инференс на тишине: сервер загрузил модель до готовности порта.
|
||||
response = self._client.get(self._url + "/", timeout=2.0)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
class Synthesizer:
|
||||
"""Silero v5: около десятой доли длительности фразы на синтез (docs/LATENCY.md)."""
|
||||
|
||||
|
|
@ -80,7 +125,8 @@ class Synthesizer:
|
|||
|
||||
|
||||
class VoiceModels:
|
||||
def __init__(self, recognizer: Recognizer, synthesizer: Synthesizer, vad_path: Path) -> None:
|
||||
def __init__(self, recognizer: Recognizer | WhisperRecognizer, synthesizer: Synthesizer,
|
||||
vad_path: Path) -> None:
|
||||
self.recognizer = recognizer
|
||||
self.synthesizer = synthesizer
|
||||
self.vad_path = vad_path
|
||||
|
|
@ -100,8 +146,14 @@ def get_voice_models() -> VoiceModels | None:
|
|||
if not settings.voice_enabled:
|
||||
return None
|
||||
models = ROOT / settings.models_dir
|
||||
stt_path = (ROOT / settings.whisper_model_path / "ggml-small-q5_1.bin"
|
||||
if settings.stt_model == "whisper-small"
|
||||
else models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx")
|
||||
if settings.stt_model not in {"whisper-small", "gigaam-v3-rnnt"}:
|
||||
log.error("неизвестный STT_MODEL: %s", settings.stt_model)
|
||||
return None
|
||||
required = [
|
||||
models / "gigaam-v3-onnx" / "v3_rnnt_encoder.int8.onnx",
|
||||
stt_path,
|
||||
models / "silero-vad" / "silero_vad.onnx",
|
||||
models / "silero-tts" / "v5_ru.pt",
|
||||
]
|
||||
|
|
@ -110,12 +162,13 @@ def get_voice_models() -> VoiceModels | None:
|
|||
log.warning("голосовой контур выключен, нет моделей: %s — make models", ", ".join(missing))
|
||||
return None
|
||||
try:
|
||||
recognizer = Recognizer(models / "gigaam-v3-onnx")
|
||||
recognizer = (WhisperRecognizer(settings.whisper_server_url) if settings.stt_model == "whisper-small"
|
||||
else Recognizer(models / "gigaam-v3-onnx"))
|
||||
synthesizer = Synthesizer(models / "silero-tts" / "v5_ru.pt")
|
||||
# Прогрев здесь, на старте стенда, а не на первой реплике курсанта.
|
||||
recognizer.warmup()
|
||||
synthesizer.warmup()
|
||||
return VoiceModels(recognizer, synthesizer, models / "silero-vad" / "silero_vad.onnx")
|
||||
except ImportError as exc:
|
||||
log.warning("голосовой контур выключен: не установлены зависимости (%s) — uv sync --extra voice", exc)
|
||||
except (ImportError, httpx.HTTPError, ValueError) as exc:
|
||||
log.warning("голосовой контур выключен: модели/сервер не готовы (%s)", exc)
|
||||
return None
|
||||
|
|
|
|||
81
backend/app/voice/recording.py
Normal file
81
backend/app/voice/recording.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Локальная WAV-запись обеих сторон учебного голосового вызова."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
TARGET_RATE = 16_000
|
||||
|
||||
|
||||
def recording_path(session_id: UUID) -> Path:
|
||||
return Path(get_settings().recordings_dir).resolve() / f"{session_id}.wav"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Segment:
|
||||
offset: int
|
||||
samples: np.ndarray
|
||||
|
||||
|
||||
class CallRecorder:
|
||||
"""Смешивает PCM16 разных частот на монотаймлайн 16 кГц.
|
||||
|
||||
Вход курсанта приходит по 16 кГц, TTS звонящего — по 24 кГц. Метка
|
||||
monotonic сохраняет паузы и взаимное расположение реплик; системные часы
|
||||
и изменение времени на хосте на запись не влияют.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, clock=time.monotonic) -> None:
|
||||
self.path = path
|
||||
self._clock = clock
|
||||
self._started = clock()
|
||||
self._segments: list[_Segment] = []
|
||||
self._finalized = False
|
||||
|
||||
def add_pcm(self, pcm: bytes, *, sample_rate: int) -> None:
|
||||
if self._finalized or not pcm or sample_rate <= 0 or len(pcm) % 2:
|
||||
return
|
||||
source = np.frombuffer(pcm, dtype="<i2").astype(np.int32)
|
||||
if source.size == 0:
|
||||
return
|
||||
if sample_rate != TARGET_RATE:
|
||||
length = max(1, round(source.size * TARGET_RATE / sample_rate))
|
||||
points = np.linspace(0, source.size - 1, length)
|
||||
source = np.rint(np.interp(points, np.arange(source.size), source)).astype(np.int32)
|
||||
offset = max(0, round((self._clock() - self._started) * TARGET_RATE))
|
||||
self._segments.append(_Segment(offset=offset, samples=source))
|
||||
|
||||
def finalize(self) -> Path | None:
|
||||
if self._finalized:
|
||||
return self.path if self.path.is_file() else None
|
||||
self._finalized = True
|
||||
if not self._segments:
|
||||
return None
|
||||
total = max(item.offset + item.samples.size for item in self._segments)
|
||||
mixed = np.zeros(total, dtype=np.int32)
|
||||
for item in self._segments:
|
||||
mixed[item.offset:item.offset + item.samples.size] += item.samples
|
||||
pcm = np.clip(mixed, -32768, 32767).astype("<i2").tobytes()
|
||||
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = self.path.with_suffix(".wav.tmp")
|
||||
with wave.open(str(temporary), "wb") as target:
|
||||
target.setnchannels(1)
|
||||
target.setsampwidth(2)
|
||||
target.setframerate(TARGET_RATE)
|
||||
target.writeframes(pcm)
|
||||
os.replace(temporary, self.path)
|
||||
return self.path
|
||||
|
||||
|
||||
def start_recording(session_id: UUID) -> CallRecorder | None:
|
||||
if not get_settings().record_calls:
|
||||
return None
|
||||
return CallRecorder(recording_path(session_id))
|
||||
|
|
@ -13,3 +13,4 @@ os.environ.setdefault("VOICE_ENABLED", "false")
|
|||
# ждал бы ответа провайдера и зависел от его настроения.
|
||||
# Живые проверки LLM — в test_llm.py, он читает backend/.env.test.
|
||||
os.environ.setdefault("LLM_API_KEY", "")
|
||||
os.environ.setdefault("LLM_PROVIDER", "disabled")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ dependencies = [
|
|||
"alembic>=1.13",
|
||||
"asyncpg>=0.29",
|
||||
"httpx>=0.27",
|
||||
"certifi>=2024",
|
||||
"pyyaml>=6.0",
|
||||
"reportlab>=4.0",
|
||||
"structlog>=24.1",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
0 3 * * * cd … && make backup в crontab демо-машины
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ from app.admin.backup import BackupError, create, listing # noqa: E402
|
|||
|
||||
#: Сколько копий держать. Место на демо-машине не бесконечно, а копия за
|
||||
#: позапрошлую неделю не нужна никому.
|
||||
KEEP = 14
|
||||
KEEP = max(1, int(os.environ.get("BACKUP_KEEP", "14")))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
|
|
|||
80
backend/scripts/backup_loop.py
Normal file
80
backend/scripts/backup_loop.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Автоматическая резервная копия PostgreSQL не реже раза в сутки.
|
||||
|
||||
Процесс живёт отдельным Compose-сервисом. Он делает копию сразу, если свежей
|
||||
нет, затем ориентируется на фактическое время последнего успешного файла.
|
||||
После ошибки повторяет попытку чаще, но не удаляет существующие копии.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.admin.backup import BackupError, DIR, create, listing # noqa: E402
|
||||
from app.config import get_settings # noqa: E402
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s backup-scheduler %(message)s",
|
||||
)
|
||||
log = logging.getLogger("backup-scheduler")
|
||||
|
||||
|
||||
def seconds_until_due(now: datetime, interval: int) -> float:
|
||||
copies = listing()
|
||||
if not copies:
|
||||
return 0.0
|
||||
age = (now - copies[0]["at"]).total_seconds()
|
||||
return max(0.0, interval - age)
|
||||
|
||||
|
||||
def prune(keep: int) -> int:
|
||||
old = listing()[keep:]
|
||||
deleted = 0
|
||||
for item in old:
|
||||
try:
|
||||
(DIR / item["name"]).unlink(missing_ok=True)
|
||||
deleted += 1
|
||||
except OSError as exc:
|
||||
# A locked/readonly old file must not take down the daily scheduler.
|
||||
log.error("не удалось удалить старую копию %s: %s", item["name"], exc)
|
||||
return deleted
|
||||
|
||||
|
||||
def run_forever() -> None:
|
||||
settings = get_settings()
|
||||
interval = max(60, settings.backup_interval_seconds)
|
||||
retry = max(10, min(settings.backup_retry_seconds, interval))
|
||||
keep = max(1, settings.backup_keep)
|
||||
log.info("запущен: интервал=%s с, хранить=%s", interval, keep)
|
||||
|
||||
while True:
|
||||
try:
|
||||
wait = seconds_until_due(datetime.now(timezone.utc), interval)
|
||||
except BackupError as exc:
|
||||
log.error("не удалось проверить резервные копии: %s; повтор через %s с", exc, retry)
|
||||
time.sleep(retry)
|
||||
continue
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
continue
|
||||
try:
|
||||
made = create()
|
||||
deleted = prune(keep)
|
||||
log.info("готово: %s (%s байт), удалено старых=%s",
|
||||
made["name"], made["size_bytes"], deleted)
|
||||
except BackupError as exc:
|
||||
log.error("цикл резервного копирования не завершён: %s; повтор через %s с", exc, retry)
|
||||
time.sleep(retry)
|
||||
continue
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_forever()
|
||||
131
backend/scripts/download_local_models.py
Normal file
131
backend/scripts/download_local_models.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Скачать ровно проверенные файлы трёх локальных моделей.
|
||||
|
||||
Файлы лежат в backend/models/ (не в Git). Скрипт одинаково работает на
|
||||
Windows, macOS и Linux; при повторном запуске продолжает неполный файл.
|
||||
После переноса каталога models/ интернет для занятия не нужен.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import argparse
|
||||
import shutil
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODELS = ROOT / "models"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Artifact:
|
||||
repo: str
|
||||
name: str
|
||||
target: str
|
||||
size: int
|
||||
sha256: str
|
||||
|
||||
|
||||
ARTIFACTS = (
|
||||
Artifact("Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf",
|
||||
"qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", 1834426016,
|
||||
"061b54daade076b5d3362dac252678d17da8c68f07560be70818cace6590cb1a"),
|
||||
Artifact("Vikhrmodels/Vikhr-Llama-3.2-1B-instruct-GGUF",
|
||||
"Vikhr-Llama-3.2-1B-Q4_K_M.gguf",
|
||||
"vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", 807694432,
|
||||
"a85c0ef4b6476ff5fcf2c961226234ab7008d17087c2152416e61a9b7e74b32d"),
|
||||
Artifact("ggerganov/whisper.cpp", "ggml-small-q5_1.bin",
|
||||
"whisper-small/ggml-small-q5_1.bin", 190085487,
|
||||
"ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb"),
|
||||
)
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1 << 20):
|
||||
sha.update(chunk)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def verify(item: Artifact) -> None:
|
||||
"""Проверить перенесённый вес без сети и без изменения файла."""
|
||||
target = MODELS / item.target
|
||||
if not target.is_file():
|
||||
raise RuntimeError(f"нет локального файла: {target}")
|
||||
if target.stat().st_size != item.size:
|
||||
raise RuntimeError(
|
||||
f"неверный размер {item.target}: {target.stat().st_size}, ожидался {item.size} байт"
|
||||
)
|
||||
actual = digest(target)
|
||||
if actual != item.sha256:
|
||||
raise RuntimeError(f"SHA-256 не совпал для {item.target}: {actual}")
|
||||
print(f"проверено SHA-256: {item.target}", flush=True)
|
||||
|
||||
|
||||
def download(item: Artifact) -> None:
|
||||
target = MODELS / item.target
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if target.exists():
|
||||
try:
|
||||
verify(item)
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
print(f"готово: {item.target}", flush=True)
|
||||
return
|
||||
partial = target.with_name(target.name + ".part")
|
||||
offset = partial.stat().st_size if partial.exists() else 0
|
||||
if offset > item.size:
|
||||
raise RuntimeError(f"некорректный частичный файл: {partial}")
|
||||
free = shutil.disk_usage(MODELS if MODELS.exists() else ROOT).free
|
||||
if free < item.size - offset + 256 * 1024 * 1024:
|
||||
raise RuntimeError(f"мало свободного места для {item.target}: осталось {free // (1 << 20)} МиБ")
|
||||
url = f"https://huggingface.co/{item.repo}/resolve/main/{item.name}"
|
||||
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||
print(f"скачиваю {item.target} ({item.size // (1 << 20)} МиБ)", flush=True)
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=120, context=ssl.create_default_context(cafile=certifi.where())
|
||||
) as response:
|
||||
# Сервер может проигнорировать Range: тогда начинаем заново, не
|
||||
# приписываем полный файл к частичному.
|
||||
resumed = offset > 0 and response.status == 206
|
||||
with partial.open("ab" if resumed else "wb") as out:
|
||||
count = offset if resumed else 0
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
count += len(chunk)
|
||||
if count % (64 << 20) < len(chunk) or count == item.size:
|
||||
print(f" {count // (1 << 20)}/{item.size // (1 << 20)} МиБ", flush=True)
|
||||
if partial.stat().st_size != item.size or digest(partial) != item.sha256:
|
||||
raise RuntimeError(f"контроль размера/SHA-256 не пройден: {partial}")
|
||||
partial.replace(target)
|
||||
print(f"проверено SHA-256: {item.target}", flush=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--verify-only", action="store_true",
|
||||
help="проверить уже загруженные/перенесённые веса без сети и без изменения файлов",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
for item in ARTIFACTS:
|
||||
verify(item) if args.verify_only else download(item)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
action = "проверка прервана" if args.verify_only else "загрузка прервана"
|
||||
print(f"{action}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if args.verify_only:
|
||||
print("Все три локальные модели прошли офлайн-проверку; файлы не изменялись.")
|
||||
else:
|
||||
print("Все три локальные модели проверены. Для голоса нужны также whisper-server, VAD и TTS.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,76 +1,43 @@
|
|||
"""make llm-check: один запрос к LLM, чтобы убедиться, что ключ и адрес рабочие.
|
||||
"""Проверка активного диалогового профиля через тот же клиент, что и занятие.
|
||||
|
||||
Ключ берётся из backend/.env и никуда не печатается. Провайдер отвечает с машины
|
||||
разработки напрямую; таймаут почти всегда означает не отказ провайдера, а окружение —
|
||||
VPN-туннель или песочница, через которые российские адреса не проходят. Поэтому
|
||||
проверять отсюда, а не из обёрток, и с той машины, на которой пойдёт занятие.
|
||||
В локальном режиме ключ не нужен; OFFLINE=true запрещает запрос наружу.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.config import get_settings # noqa: E402
|
||||
from app.dialog.llm import LlmClient, LlmRequest, LlmUnavailable # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
async def check() -> int:
|
||||
settings = get_settings()
|
||||
if not settings.llm_api_key:
|
||||
print("в backend/.env нет LLM_API_KEY")
|
||||
return 1
|
||||
|
||||
print(f"адрес: {settings.llm_base_url}")
|
||||
print(f"модель: {settings.llm_model_caller}")
|
||||
|
||||
body = {
|
||||
"model": settings.llm_model_caller,
|
||||
"messages": [
|
||||
{"role": "system", "content": "Ты звонящий в службу 112, у тебя горит балкон. "
|
||||
"Ответь одной короткой фразой, в панике."},
|
||||
{"role": "user", "content": "Служба 112, что у вас случилось?"},
|
||||
],
|
||||
"max_tokens": 60,
|
||||
"temperature": 0.8,
|
||||
}
|
||||
|
||||
control = settings.dialogue_model_mode == "russian_control"
|
||||
model = settings.llm_model_control if control else settings.llm_model_caller
|
||||
address = settings.llm_control_base_url if control else settings.llm_base_url
|
||||
print(f"адрес: {address}\nмодель: {model}")
|
||||
client = LlmClient(base_url=address, timeout=30)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{settings.llm_base_url}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {settings.llm_api_key}"},
|
||||
json=body,
|
||||
timeout=60,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
print(f"\nсеть: {type(exc).__name__} — до провайдера не достучались.")
|
||||
print("Это чаще про окружение, чем про провайдера: проверь, что российские")
|
||||
print("адреса идут мимо VPN, и повтори с самой машины стенда.")
|
||||
return 2
|
||||
|
||||
elapsed = time.monotonic() - started
|
||||
print(f"\nHTTP {response.status_code}, {elapsed:.2f} с")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print("ответ модели:", data["choices"][0]["message"]["content"].strip())
|
||||
usage = data.get("usage", {})
|
||||
print("токены:", usage.get("prompt_tokens"), "→", usage.get("completion_tokens"))
|
||||
print("\nКЛЮЧ РАБОТАЕТ")
|
||||
return 0
|
||||
|
||||
print("ответ сервера:", response.text[:400])
|
||||
if response.status_code in (401, 403):
|
||||
print("\nСеть в порядке, но ключ не принят: возможно, его нужно менять "
|
||||
"на временный токен — сверься с консолью Cloud.ru.")
|
||||
elif response.status_code == 404:
|
||||
print("\nКлюч принят, но такой модели нет: проверь LLM_MODEL_CALLER.")
|
||||
return 3
|
||||
reply = await client.complete(LlmRequest(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "Ответь одним словом по-русски: работает /no_think"}],
|
||||
temperature=0,
|
||||
max_tokens=32,
|
||||
), use_cache=False)
|
||||
except LlmUnavailable as exc:
|
||||
print(f"модель недоступна: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
await client.aclose()
|
||||
print(f"ответ за {time.monotonic() - started:.2f} с: {reply}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(asyncio.run(check()))
|
||||
|
|
|
|||
118
backend/scripts/local_llms.py
Normal file
118
backend/scripts/local_llms.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Запуск двух локальных GGUF-серверов через llama.cpp на Windows/macOS/Linux.
|
||||
|
||||
Никаких загрузок при старте: GGUF предварительно кладутся в models/ через
|
||||
download_local_models.py. Путь к llama-server задаётся LLAMA_SERVER_BIN либо
|
||||
берётся из PATH. Ctrl+C останавливает дочерние процессы.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PROFILES = {
|
||||
"dialogue": ("qwen3-1.7b/Qwen3-1.7B-Q8_0.gguf", "Qwen3-1.7B", 18080),
|
||||
"russian_control": ("vikhr-1b/Vikhr-Llama-3.2-1B-Q4_K_M.gguf", "Vikhr-1B", 18081),
|
||||
}
|
||||
|
||||
|
||||
def binary_path() -> str:
|
||||
bundled_mac = ROOT / "models" / "bin" / "llama-b10934" / "llama-server"
|
||||
binary = (os.environ.get("LLAMA_SERVER_BIN") or shutil.which("llama-server")
|
||||
or shutil.which("llama-server.exe")
|
||||
# The checked-out helper is Mach-O arm64. Do not select it on a
|
||||
# Windows machine merely because the whole models/ folder was
|
||||
# copied there for its platform-neutral GGUF weights.
|
||||
or (str(bundled_mac) if sys.platform == "darwin" and bundled_mac.is_file() else None))
|
||||
if not binary:
|
||||
raise RuntimeError("нет llama-server; установите бинарник llama.cpp и задайте LLAMA_SERVER_BIN")
|
||||
if not Path(binary).is_file():
|
||||
raise RuntimeError(f"llama-server не найден: {binary}")
|
||||
return binary
|
||||
|
||||
|
||||
def command(binary: str, profile: str, threads: int) -> list[str]:
|
||||
relative, alias, port = PROFILES[profile]
|
||||
model = ROOT / "models" / relative
|
||||
if not model.is_file():
|
||||
raise RuntimeError(f"нет модели: {model} — запустите make local-models")
|
||||
result = [binary, "-m", str(model), "--alias", alias,
|
||||
"--host", "127.0.0.1", "--port", str(port),
|
||||
"--ctx-size", "2048", "--threads", str(threads), "--parallel", "1",
|
||||
"--cors-origins", "localhost"]
|
||||
if profile == "dialogue":
|
||||
result += ["--reasoning-budget", "0"]
|
||||
return result
|
||||
|
||||
|
||||
def ready(port: int) -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
|
||||
return response.status == 200
|
||||
except (OSError, urllib.error.HTTPError):
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--mode", choices=["dialogue", "russian_control", "both"], default="dialogue")
|
||||
parser.add_argument("--threads", type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
if args.threads < 1:
|
||||
parser.error("--threads должен быть положительным")
|
||||
selected = list(PROFILES) if args.mode == "both" else [args.mode]
|
||||
try:
|
||||
binary = binary_path()
|
||||
commands = [command(binary, profile, args.threads) for profile in selected]
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
processes: list[subprocess.Popen] = []
|
||||
try:
|
||||
for profile, argv in zip(selected, commands):
|
||||
port = PROFILES[profile][2]
|
||||
if ready(port):
|
||||
raise RuntimeError(f"порт {port} уже занят сервером — не запускаю дубликат")
|
||||
print(f"запускаю {profile} на 127.0.0.1:{port}", flush=True)
|
||||
# Ctrl+C должен достаться управляющему процессу один раз: он сам
|
||||
# остановит дочерний сервер. Иначе llama.cpp получает двойной
|
||||
# SIGINT и на Metal иногда падает во время освобождения памяти.
|
||||
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
processes.append(subprocess.Popen(
|
||||
argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt"
|
||||
))
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if any(process.poll() is not None for process in processes):
|
||||
raise RuntimeError("один из серверов модели завершился до готовности")
|
||||
if all(ready(PROFILES[profile][2]) for profile in selected):
|
||||
print("локальные модели готовы; Ctrl+C остановит их", flush=True)
|
||||
while all(process.poll() is None for process in processes):
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("один из серверов модели неожиданно завершился")
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("модели не стали готовы за 120 секунд")
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
for process in processes:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
for process in processes:
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
84
backend/scripts/local_stt.py
Normal file
84
backend/scripts/local_stt.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Локальный whisper.cpp server для Whisper small (macOS / Windows / Linux).
|
||||
|
||||
Сначала скачайте GGML-вес через download_local_models.py. Исполняемый файл
|
||||
задаётся WHISPER_SERVER_BIN или ищется в PATH / локальной сборке. Сеть не нужна.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODEL = ROOT / "models" / "whisper-small" / "ggml-small-q5_1.bin"
|
||||
PORT = 18082
|
||||
|
||||
|
||||
def binary_path() -> str:
|
||||
built = ROOT / "models" / "bin" / "whisper.cpp-1.9.4" / "build" / "bin" / "whisper-server"
|
||||
binary = (os.environ.get("WHISPER_SERVER_BIN") or shutil.which("whisper-server")
|
||||
or shutil.which("whisper-server.exe")
|
||||
# The in-tree build is macOS arm64, not a portable executable.
|
||||
or (str(built) if sys.platform == "darwin" and built.is_file() else None))
|
||||
if not binary or not Path(binary).is_file():
|
||||
raise RuntimeError("нет whisper-server; установите бинарник whisper.cpp и задайте WHISPER_SERVER_BIN")
|
||||
return binary
|
||||
|
||||
|
||||
def command(binary: str, threads: int = 4) -> list[str]:
|
||||
if not MODEL.is_file():
|
||||
raise RuntimeError(f"нет модели: {MODEL} — запустите make local-models")
|
||||
return [binary, "-m", str(MODEL), "--host", "127.0.0.1", "--port", str(PORT),
|
||||
"--language", "ru", "--threads", str(threads), "--no-fallback"]
|
||||
|
||||
|
||||
def ready() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/", timeout=1) as response:
|
||||
return response.status == 200
|
||||
except (OSError, urllib.error.HTTPError):
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
if ready():
|
||||
raise RuntimeError(f"порт {PORT} уже занят сервером")
|
||||
argv = command(binary_path())
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
process = subprocess.Popen(argv, cwd=ROOT, creationflags=flags, start_new_session=os.name != "nt")
|
||||
try:
|
||||
deadline = time.monotonic() + 120
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("whisper-server завершился до готовности")
|
||||
if ready():
|
||||
print(f"Whisper small готов на 127.0.0.1:{PORT}; Ctrl+C остановит сервер", flush=True)
|
||||
while process.poll() is None:
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("whisper-server неожиданно завершился")
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("Whisper small не стал готов за 120 секунд")
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
"""make models: веса моделей в backend/models/.
|
||||
"""make models: вспомогательные веса голоса в backend/models/.
|
||||
|
||||
Эмбеддинги, распознавание (GigaAM v3, int8) и VAD качаются с Hugging Face.
|
||||
Эмбеддинги и VAD качаются с Hugging Face. Whisper small, Qwen3 и Vikhr
|
||||
скачиваются отдельным `make local-models` с проверкой SHA-256. Старый GigaAM
|
||||
доступен через `--legacy-gigaam`, но в выбранный стек больше не входит.
|
||||
Синтез (Silero TTS v5) лежит на models.silero.ai — российском хосте, который
|
||||
не отвечает из-под VPN: его скрипт не качает, а проверяет и говорит, что делать.
|
||||
Докачка продолжается с места обрыва: сеть на стенде бывает медленной.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import certifi
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MODELS = ROOT / "models"
|
||||
|
||||
|
|
@ -20,16 +26,16 @@ FILES = {
|
|||
"e5-small/config.json": E5 + "config.json",
|
||||
"e5-small/tokenizer.json": E5 + "tokenizer.json",
|
||||
"e5-small/model_quantized.onnx": E5 + "onnx/model_quantized.onnx",
|
||||
# RNNT — основная модель, CTC — запасная на случай, если RNNT не загрузится
|
||||
# (по скорости CTC не выигрывает — docs/LATENCY.md).
|
||||
"silero-vad/config.json": VAD + "config.json",
|
||||
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
|
||||
}
|
||||
LEGACY_GIGAAM_FILES = {
|
||||
"gigaam-v3-onnx/config.json": GIGAAM + "config.json",
|
||||
"gigaam-v3-onnx/v3_vocab.txt": GIGAAM + "v3_vocab.txt",
|
||||
"gigaam-v3-onnx/v3_rnnt_encoder.int8.onnx": GIGAAM + "v3_rnnt_encoder.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_rnnt_decoder.int8.onnx": GIGAAM + "v3_rnnt_decoder.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_rnnt_joint.int8.onnx": GIGAAM + "v3_rnnt_joint.int8.onnx",
|
||||
"gigaam-v3-onnx/v3_ctc.int8.onnx": GIGAAM + "v3_ctc.int8.onnx",
|
||||
"silero-vad/config.json": VAD + "config.json",
|
||||
"silero-vad/silero_vad.onnx": VAD + "silero_vad.onnx",
|
||||
}
|
||||
|
||||
SILERO_TTS = "silero-tts/v5_ru.pt"
|
||||
|
|
@ -40,19 +46,27 @@ def fetch(url: str, target: Path) -> None:
|
|||
partial = target.with_suffix(target.suffix + ".part")
|
||||
offset = partial.stat().st_size if partial.exists() else 0
|
||||
request = urllib.request.Request(url, headers={"Range": f"bytes={offset}-"} if offset else {})
|
||||
with urllib.request.urlopen(request, timeout=60) as response, partial.open("ab") as out:
|
||||
total = offset + int(response.headers.get("Content-Length", 0))
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
done = out.tell()
|
||||
if total:
|
||||
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
|
||||
with urllib.request.urlopen(
|
||||
request, timeout=60, context=ssl.create_default_context(cafile=certifi.where())
|
||||
) as response:
|
||||
resumed = offset > 0 and response.status == 206
|
||||
with partial.open("ab" if resumed else "wb") as out:
|
||||
total = (offset if resumed else 0) + int(response.headers.get("Content-Length", 0))
|
||||
while chunk := response.read(1 << 20):
|
||||
out.write(chunk)
|
||||
done = out.tell()
|
||||
if total:
|
||||
print(f"\r {target.name}: {done // (1 << 20)} из {total // (1 << 20)} МБ", end="", flush=True)
|
||||
partial.rename(target)
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for relative, url in FILES.items():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--legacy-gigaam", action="store_true")
|
||||
args = parser.parse_args()
|
||||
files = {**FILES, **(LEGACY_GIGAAM_FILES if args.legacy_gigaam else {})}
|
||||
for relative, url in files.items():
|
||||
target = MODELS / relative
|
||||
if target.exists():
|
||||
print(f" {relative}: уже есть")
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ async def main() -> None:
|
|||
|
||||
slots = SlotMachine(scenario, embedder)
|
||||
persona = PersonaState(scenario.persona)
|
||||
caller = build_caller(scenario.id)
|
||||
caller = build_caller(
|
||||
scenario.id,
|
||||
use_pregenerated=scenario.tree.pregenerated,
|
||||
)
|
||||
|
||||
print(f"── {scenario.title} ({scenario.level.value}) ──")
|
||||
print("Вы — оператор 112. Команды: /подсказка /факты /итог /выход\n")
|
||||
|
|
|
|||
40
backend/scripts/smoke_voice.py
Normal file
40
backend/scripts/smoke_voice.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Проверка локального TTS → Whisper small без микрофона и внешней сети.
|
||||
|
||||
Это не замер сквозной задержки звонка: TTS создаёт синтетический образец, а
|
||||
загрузка моделей оплачивается отдельно. На Windows выполнить ту же команду.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from scipy.signal import resample_poly
|
||||
|
||||
from app.voice.models import Synthesizer, WhisperRecognizer
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PHRASE = "Пожар на улице Ленина, дом четырнадцать. Нужна помощь."
|
||||
|
||||
|
||||
def main() -> None:
|
||||
started = time.monotonic()
|
||||
tts = Synthesizer(ROOT / "models" / "silero-tts" / "v5_ru.pt")
|
||||
print(f"TTS загружен за {time.monotonic() - started:.2f} с", flush=True)
|
||||
started = time.monotonic()
|
||||
pcm = tts.synthesize(PHRASE)
|
||||
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768
|
||||
audio = resample_poly(audio, 2, 3).astype(np.float32)
|
||||
print(f"TTS: {time.monotonic() - started:.2f} с, звук {len(audio) / 16000:.2f} с", flush=True)
|
||||
|
||||
started = time.monotonic()
|
||||
stt = WhisperRecognizer("http://127.0.0.1:18082")
|
||||
stt.warmup()
|
||||
print(f"Whisper server готов за {time.monotonic() - started:.2f} с", flush=True)
|
||||
started = time.monotonic()
|
||||
answer = stt.transcribe(audio)
|
||||
print(f"Whisper: {time.monotonic() - started:.2f} с", flush=True)
|
||||
print(f"ожидалось: {PHRASE}\nполучено: {answer}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -30,7 +30,7 @@ from app.domain.roles import Role # noqa: E402
|
|||
PEOPLE = [
|
||||
("admin", "Администратор стенда", Role.ADMIN, None),
|
||||
("teacher", "Преподаватель", Role.INSTRUCTOR, None),
|
||||
("trainee", "Курсант", Role.TRAINEE, "ДДС района"),
|
||||
("trainee", "Курсант", Role.TRAINEE, "Служба 101"),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
backend/tests/test_backup_scheduler.py
Normal file
20
backend/tests/test_backup_scheduler.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
67
backend/tests/test_group_insight.py
Normal file
67
backend/tests/test_group_insight.py
Normal 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": [],
|
||||
})
|
||||
276
backend/tests/test_local_models.py
Normal file
276
backend/tests/test_local_models.py
Normal 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)
|
||||
168
backend/tests/test_materials.py
Normal file
168
backend/tests/test_materials.py
Normal 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)
|
||||
69
backend/tests/test_recording.py
Normal file
69
backend/tests/test_recording.py
Normal 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
|
||||
|
|
@ -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-")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
167
backend/tests/test_session_checkpoint.py
Normal file
167
backend/tests/test_session_checkpoint.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Курсант сначала сверяет своё ощущение с объективной картиной:
|
||||
расхождение самооценки с автооценкой — отдельный материал для преподавателя."""
|
||||
|
|
|
|||
2
backend/uv.lock
generated
2
backend/uv.lock
generated
|
|
@ -397,6 +397,7 @@ dependencies = [
|
|||
{ name = "alembic" },
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "certifi" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "itsdangerous" },
|
||||
|
|
@ -430,6 +431,7 @@ requires-dist = [
|
|||
{ name = "alembic", specifier = ">=1.13" },
|
||||
{ name = "argon2-cffi", specifier = ">=23.1" },
|
||||
{ name = "asyncpg", specifier = ">=0.29" },
|
||||
{ name = "certifi", specifier = ">=2024" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "httpx", specifier = ">=0.27" },
|
||||
{ name = "itsdangerous", specifier = ">=2.1" },
|
||||
|
|
|
|||
28
docker-compose.sip.yml
Normal file
28
docker-compose.sip.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
services:
|
||||
sip:
|
||||
build: ./sip
|
||||
image: lct-hack-sip:local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SIP_6001_PASSWORD: ${SIP_6001_PASSWORD:-}
|
||||
SIP_6002_PASSWORD: ${SIP_6002_PASSWORD:-}
|
||||
SIP_6003_PASSWORD: ${SIP_6003_PASSWORD:-}
|
||||
SIP_6101_PASSWORD: ${SIP_6101_PASSWORD:-}
|
||||
SIP_6102_PASSWORD: ${SIP_6102_PASSWORD:-}
|
||||
SIP_EXTERNAL_MEDIA_ADDRESS: ${SIP_EXTERNAL_MEDIA_ADDRESS:-127.0.0.1}
|
||||
ports:
|
||||
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/udp"
|
||||
- "${BIND_HOST:-127.0.0.1}:${SIP_PORT:-5060}:5060/tcp"
|
||||
- "${BIND_HOST:-127.0.0.1}:${SIPS_PORT:-5061}:5061/tcp"
|
||||
- "${BIND_HOST:-127.0.0.1}:${SIP_WS_PORT:-8088}:8088/tcp"
|
||||
- "${BIND_HOST:-127.0.0.1}:${RTP_PORT_START:-10000}-${RTP_PORT_END:-10099}:10000-10099/udp"
|
||||
volumes:
|
||||
- sipdata:/var/lib/lct-sip
|
||||
- siprecordings:/recordings
|
||||
- type: bind
|
||||
source: ${SIP_TLS_DIR:-./.local/sip-tls}
|
||||
target: /tls
|
||||
|
||||
volumes:
|
||||
sipdata:
|
||||
siprecordings:
|
||||
16
docker-compose.tls.yml
Normal file
16
docker-compose.tls.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
services:
|
||||
backend:
|
||||
environment:
|
||||
SECURE_COOKIES: "true"
|
||||
|
||||
frontend:
|
||||
environment:
|
||||
NGINX_ENVSUBST_TEMPLATE_DIR: /etc/nginx/templates-tls
|
||||
PUBLIC_TLS_PORT: ${TLS_PORT:-5443}
|
||||
TLS_BOOTSTRAP: "true"
|
||||
ports:
|
||||
- "${BIND_HOST:-127.0.0.1}:${TLS_PORT:-5443}:5443"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${TLS_CERT_DIR:-./.local/tls}
|
||||
target: /etc/nginx/tls
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: lct
|
||||
POSTGRES_PASSWORD: lct
|
||||
POSTGRES_DB: lct
|
||||
ports: ["5432:5432"]
|
||||
# БД и backend не публикуются в класс: с рабочих мест доступен только
|
||||
# TLS-терминатор frontend. Loopback-порты нужны для администрирования хоста.
|
||||
ports: ["127.0.0.1:${POSTGRES_PORT:-5432}:5432"]
|
||||
volumes: ["pgdata:/var/lib/postgresql/data"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U lct"]
|
||||
|
|
@ -16,17 +19,67 @@ services:
|
|||
backend:
|
||||
build: ./backend
|
||||
image: lct-hack-backend:local
|
||||
restart: unless-stopped
|
||||
# Один воркер принципиально: состояние живой сессии и реестр наблюдателей
|
||||
# живут в памяти процесса (docs/arch/STACK.md).
|
||||
command: sh -c "alembic upgrade head && python scripts/seed.py && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1"
|
||||
command: >-
|
||||
sh -c '
|
||||
if [ -z "$${SESSION_SECRET:-}" ]; then
|
||||
if [ ! -s /run/lct/session-secret ]; then
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))" > /run/lct/session-secret;
|
||||
chmod 600 /run/lct/session-secret;
|
||||
fi;
|
||||
export SESSION_SECRET="$$(cat /run/lct/session-secret)";
|
||||
fi;
|
||||
alembic upgrade head && python scripts/seed.py &&
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1'
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
|
||||
OFFLINE: "true"
|
||||
VOICE_ENABLED: "false"
|
||||
RECORD_CALLS: "true"
|
||||
RECORDINGS_DIR: /recordings
|
||||
LLM_PROVIDER: local
|
||||
LLM_BASE_URL: ${DOCKER_LLM_BASE_URL:-http://host.docker.internal:18080/v1}
|
||||
LLM_MODEL_CALLER: ${LLM_MODEL_CALLER:-Qwen3-1.7B}
|
||||
LLM_CONTROL_BASE_URL: ${DOCKER_LLM_CONTROL_BASE_URL:-http://host.docker.internal:18081/v1}
|
||||
LLM_MODEL_CONTROL: ${LLM_MODEL_CONTROL:-Vikhr-1B}
|
||||
GRAMMAR_LLM_ENABLED: ${GRAMMAR_LLM_ENABLED:-false}
|
||||
ALLOW_DOCKER_HOST_MODELS: "true"
|
||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||
BACKUP_KEEP: ${BACKUP_KEEP:-14}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ./scenarios:/scenarios:ro
|
||||
ports: ["8000:8000"]
|
||||
- securitydata:/run/lct
|
||||
- recordings:/recordings
|
||||
- backups:/app/backups
|
||||
ports: ["127.0.0.1:${BACKEND_PORT:-8000}:8000"]
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=2).read()"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
backup:
|
||||
build: ./backend
|
||||
image: lct-hack-backend:local
|
||||
restart: unless-stopped
|
||||
command: python scripts/backup_loop.py
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://lct:lct@postgres:5432/lct
|
||||
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
|
||||
BACKUP_RETRY_SECONDS: ${BACKUP_RETRY_SECONDS:-300}
|
||||
BACKUP_KEEP: ${BACKUP_KEEP:-14}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- backups:/app/backups
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
|
@ -34,15 +87,22 @@ services:
|
|||
frontend:
|
||||
build: ./frontend
|
||||
image: lct-hack-frontend:local
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_BACKEND_URL: http://backend:8000
|
||||
VITE_WS_BACKEND_URL: ws://backend:8000
|
||||
ports: ["5173:5173"]
|
||||
depends_on: [backend]
|
||||
BACKEND_HOST: backend
|
||||
BACKEND_PORT: 8000
|
||||
ports: ["${BIND_HOST:-127.0.0.1}:${FRONTEND_PORT:-5173}:5173"]
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
securitydata:
|
||||
recordings:
|
||||
backups:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
FROM node:20-slim
|
||||
FROM node:20-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* .npmrc ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
RUN apk add --no-cache openssl
|
||||
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY nginx.tls.conf.template /etc/nginx/templates-tls/default.conf.template
|
||||
COPY docker-entrypoint.d/15-local-certificate.sh /docker-entrypoint.d/15-local-certificate.sh
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
ENV BACKEND_HOST=backend
|
||||
ENV BACKEND_PORT=8000
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
EXPOSE 5443
|
||||
|
|
|
|||
26
frontend/docker-entrypoint.d/15-local-certificate.sh
Executable file
26
frontend/docker-entrypoint.d/15-local-certificate.sh
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "${TLS_BOOTSTRAP:-false}" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
certificate_dir=/etc/nginx/tls
|
||||
certificate_file="$certificate_dir/tls.crt"
|
||||
private_key_file="$certificate_dir/tls.key"
|
||||
mkdir -p "$certificate_dir"
|
||||
|
||||
if [ -s "$certificate_file" ] && [ -s "$private_key_file" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
openssl req -x509 -nodes -newkey rsa:3072 -sha256 -days 365 \
|
||||
-keyout "$private_key_file" \
|
||||
-out "$certificate_file" \
|
||||
-subj "/CN=localhost/O=LCT local training stand" \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
|
||||
-addext "keyUsage=digitalSignature,keyEncipherment" \
|
||||
-addext "extendedKeyUsage=serverAuth" 2>/dev/null
|
||||
chmod 600 "$private_key_file"
|
||||
chmod 644 "$certificate_file"
|
||||
echo "TLS: создан локальный bootstrap-сертификат $certificate_file" >&2
|
||||
38
frontend/nginx.conf.template
Normal file
38
frontend/nginx.conf.template
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
server {
|
||||
listen 5173;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 75s;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|woff2?|png|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
71
frontend/nginx.tls.conf.template
Normal file
71
frontend/nginx.tls.conf.template
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
server {
|
||||
listen 5173;
|
||||
server_name _;
|
||||
return 308 https://$host:${PUBLIC_TLS_PORT}$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 5443 ssl;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
ssl_certificate /etc/nginx/tls/tls.crt;
|
||||
ssl_certificate_key /etc/nginx/tls/tls.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:LCT_TLS:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "same-origin" always;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/javascript application/json image/svg+xml text/css text/plain;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 75s;
|
||||
}
|
||||
|
||||
# SIP over WebSocket для встроенного WebRTC-телефона. Переменная и
|
||||
# Docker DNS позволяют обычному TLS-стенду стартовать даже до sip-сервиса.
|
||||
location = /sip-ws {
|
||||
resolver 127.0.0.11 ipv6=off valid=10s;
|
||||
set $sip_backend sip;
|
||||
proxy_pass http://$sip_backend:8088/ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location ~* \.(?:css|js|woff2?|png|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"jssip": "^3.13.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
|
|
@ -1340,7 +1341,6 @@
|
|||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
|
@ -1410,6 +1410,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -1467,6 +1476,17 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jssip": {
|
||||
"version": "3.13.8",
|
||||
"resolved": "https://registry.npmjs.org/jssip/-/jssip-3.13.8.tgz",
|
||||
"integrity": "sha512-ErxEdy13vXR5izGo42fpXOU64UmC2VF7Shg4oYG7R2NzZF0FTPeYikffkIIQa9w+/11YvTlgezG1nSaycWYXPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"events": "^3.3.0",
|
||||
"sdp-transform": "^2.14.1"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -1493,7 +1513,6 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
|
|
@ -1683,6 +1702,15 @@
|
|||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"jssip": "^3.13.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
|
||||
import { Call } from "@/pages/trainee/Call";
|
||||
import { Instructor } from "@/pages/instructor/Instructor";
|
||||
import { Dds } from "@/pages/dds/Dds";
|
||||
import { Admin } from "@/pages/admin/Admin";
|
||||
import { Profile } from "@/pages/profile/Profile";
|
||||
import { Groups } from "@/pages/groups/Groups";
|
||||
import { Wall } from "@/pages/wall/Wall";
|
||||
import { RequireAuth } from "@/features/auth/Login";
|
||||
import { DEMO_MODE } from "@/features/auth/useAuth";
|
||||
|
||||
const Call = lazy(() => import("@/pages/trainee/Call").then((module) => ({ default: module.Call })));
|
||||
const Instructor = lazy(() => import("@/pages/instructor/Instructor").then((module) => ({ default: module.Instructor })));
|
||||
const Dds = lazy(() => import("@/pages/dds/Dds").then((module) => ({ default: module.Dds })));
|
||||
const Admin = lazy(() => import("@/pages/admin/Admin").then((module) => ({ default: module.Admin })));
|
||||
const Profile = lazy(() => import("@/pages/profile/Profile").then((module) => ({ default: module.Profile })));
|
||||
const Groups = lazy(() => import("@/pages/groups/Groups").then((module) => ({ default: module.Groups })));
|
||||
const Wall = lazy(() => import("@/pages/wall/Wall").then((module) => ({ default: module.Wall })));
|
||||
const Phone = lazy(() => import("@/pages/phone/Phone").then((module) => ({ default: module.Phone })));
|
||||
const Materials = lazy(() => import("@/pages/materials/Materials").then((module) => ({ default: module.Materials })));
|
||||
|
||||
// Четыре интерфейса — одна SPA (docs/arch/FRONTEND.md).
|
||||
// session_id живёт в URL: /instructor?session=..., монитор открывают ссылкой.
|
||||
// Каждый экран за проверкой роли: курсант не открывает пульт даже по прямой
|
||||
// ссылке. Сервер проверяет то же самое на сокетах — здесь только чтобы человек
|
||||
// увидел понятное объяснение вместо пустого экрана (lct-23).
|
||||
const guarded = (screen: string, element: JSX.Element) => (
|
||||
<RequireAuth screen={screen}>{element}</RequireAuth>
|
||||
<RequireAuth screen={screen}>
|
||||
<Suspense fallback={<main className="page"><p>Загружаем рабочее место…</p></main>}>
|
||||
{element}
|
||||
</Suspense>
|
||||
</RequireAuth>
|
||||
);
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
|
|
@ -27,5 +35,7 @@ export const router = createBrowserRouter([
|
|||
{ path: "/profile", element: guarded("/profile", <Profile />) },
|
||||
{ path: "/groups", element: guarded("/groups", <Groups />) },
|
||||
{ path: "/dds", element: guarded("/dds", <Dds />) },
|
||||
{ path: "/phone", element: guarded("/phone", <Phone />) },
|
||||
{ path: "/materials", element: guarded("/materials", <Materials />) },
|
||||
{ path: "/admin", element: guarded("/admin", <Admin />) },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ function DemoLinks() {
|
|||
<a href="/instructor" onClick={(event) => openScreen(event, "/instructor")}>Преподаватель</a>
|
||||
<a href="/trainee" onClick={(event) => openScreen(event, "/trainee")}>Оператор 112</a>
|
||||
<a href="/dds" onClick={(event) => openScreen(event, "/dds")}>ДДС</a>
|
||||
<a href="/materials" onClick={(event) => openScreen(event, "/materials")}>Материалы</a>
|
||||
</div>
|
||||
</details>
|
||||
</nav>;
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export function useLogout() {
|
|||
/** Какие экраны открыты роли. Повторяет domain/roles.py: список короткий
|
||||
* и меняется редко, а держать его на сервере ради двух строк — лишний запрос. */
|
||||
export const SCREENS: Record<Role, string[]> = {
|
||||
admin: ["/admin", "/profile", "/groups"],
|
||||
instructor: ["/instructor", "/wall", "/profile", "/dds", "/groups"],
|
||||
trainee: ["/trainee", "/dds", "/profile"],
|
||||
admin: ["/admin", "/profile", "/groups", "/materials"],
|
||||
instructor: ["/instructor", "/wall", "/profile", "/dds", "/phone", "/groups", "/materials"],
|
||||
trainee: ["/trainee", "/dds", "/phone", "/profile", "/materials"],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -69,7 +69,20 @@ export function useCall(sessionId: string | null) {
|
|||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
const ch = callChannel(sessionId, {
|
||||
onStatus: setStatus,
|
||||
onStatus: (next) => {
|
||||
setStatus(next);
|
||||
if (next === "open") {
|
||||
// Браузер не подтверждает доставку WebSocket-кадра. Всё, что ещё
|
||||
// отмечено pending и не пришло эхом от сервера, повторяем после
|
||||
// reconnect: kio.patch идемпотентен и не создаёт двойных действий.
|
||||
queueMicrotask(() => setCard((current) => {
|
||||
if (Object.keys(current.pending).length) {
|
||||
channel.current?.send({ type: "kio.patch", fields: current.pending });
|
||||
}
|
||||
return current;
|
||||
}));
|
||||
}
|
||||
},
|
||||
onBinary: (frame) => audio.current?.playback.enqueue(frame),
|
||||
onEvent: (event: ServerToTrainee) => {
|
||||
if (event.type === "card.briefing") {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>CSV</a>
|
||||
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>PDF</a>
|
||||
</p>
|
||||
<p className={report.passed ? "state-ok" : "state-violated"}>
|
||||
{report.passed ? "Зачёт" : "Не зачтено"}: нарушено {report.failed_metrics} метрик,
|
||||
допустимо {report.criteria.allowed_errors}. Лимит решения ДДС —
|
||||
{` ${report.criteria.decision_time_limit_seconds} с`}; грамматика
|
||||
{report.criteria.require_correct_grammar ? " входит" : " не входит"} в оценку.
|
||||
</p>
|
||||
{Object.keys(report.criteria.score_weights ?? {}).length > 0 && <p className="ref">
|
||||
Преподаватель переопределил веса: {Object.entries(report.criteria.score_weights ?? {})
|
||||
.map(([key, weight]) => `${key} — ${weight}`).join("; ")}.
|
||||
</p>}
|
||||
|
||||
<div className="columns">
|
||||
<section>
|
||||
|
|
@ -38,6 +48,7 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
|||
<th>{metric.title}</th>
|
||||
<td className={metric.passed ? "state-ok" : "state-violated"}>
|
||||
{metric.fact} · норматив {metric.norm}
|
||||
{` · вес ${metric.weight ?? 1}`}
|
||||
{metric.ref && <span className="ref"> ({metric.ref})</span>}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function StandStatus() {
|
|||
if (!health.data) return null;
|
||||
|
||||
if (health.data.demo_no_db) {
|
||||
return <div className="stand stand-warn">Локальная демка: карточки 112 и ДДС работают без БД и голоса; история занятий не сохраняется.</div>;
|
||||
return <div className="stand stand-warn">Локальная демка: карточки 112, ДДС и редактор работают без БД и голоса; история и черновики исчезнут после перезапуска.</div>;
|
||||
}
|
||||
|
||||
const troubles: string[] = [];
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export function Director({
|
|||
рвёт звук немедленно.
|
||||
</p>
|
||||
<p>
|
||||
<input type="text" value={free} placeholder="своя реплика — нужна сеть и LLM"
|
||||
<input type="text" value={free} placeholder="своя реплика — нужна локальная модель"
|
||||
onChange={(event) => setFree(event.target.value)} />{" "}
|
||||
<button type="button" disabled={disabled || !free.trim()}
|
||||
onClick={() => { fire(`своя реплика: ${free.trim()}`, free.trim()); setFree(""); }}>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { useState } from "react";
|
|||
import type { ScenarioSummary } from "@/shared/api/http";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type Draft = { id: string; status: "draft"; generation: "template_copy"; body: JsonObject };
|
||||
type Draft = {
|
||||
id: string;
|
||||
status: "draft";
|
||||
generation: "template_copy" | "ai_variant" | "ai_full";
|
||||
body: JsonObject;
|
||||
};
|
||||
type Validation = { valid: boolean; errors: string[]; ground_truth?: JsonObject };
|
||||
|
||||
async function api<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
||||
|
|
@ -48,15 +53,31 @@ function pretty(body: JsonObject): string {
|
|||
return JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
function stringField(body: JsonObject, key: string): string {
|
||||
return typeof body[key] === "string" ? body[key] : "";
|
||||
}
|
||||
|
||||
function generationLabel(generation: Draft["generation"]): string {
|
||||
if (generation === "ai_full") return "новый сюжет и эталон локальной модели";
|
||||
if (generation === "ai_variant") return "вариант локальной модели";
|
||||
return "копия шаблона";
|
||||
}
|
||||
|
||||
export function ScenarioEditor({
|
||||
scenarios,
|
||||
selectedScenarioId,
|
||||
onApproved,
|
||||
onLibraryChanged,
|
||||
}: {
|
||||
scenarios: ScenarioSummary[];
|
||||
selectedScenarioId: string;
|
||||
onApproved: (id: string) => void | Promise<void>;
|
||||
onLibraryChanged: () => void | Promise<void>;
|
||||
}) {
|
||||
const [sourceId, setSourceId] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [instruction, setInstruction] = useState("");
|
||||
const [revisionComment, setRevisionComment] = useState("");
|
||||
const [draftId, setDraftId] = useState("");
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [bodyText, setBodyText] = useState("");
|
||||
|
|
@ -64,6 +85,10 @@ export function ScenarioEditor({
|
|||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [archiveCandidate, setArchiveCandidate] = useState("");
|
||||
const [lastArchived, setLastArchived] = useState<{ id: string; title: string } | null>(null);
|
||||
const effectiveSourceId = sourceId || selectedScenarioId || scenarios[0]?.id || "";
|
||||
const effectiveSource = scenarios.find((scenario) => scenario.id === effectiveSourceId);
|
||||
|
||||
function openDraft(value: Draft) {
|
||||
setDraft(value);
|
||||
|
|
@ -89,7 +114,7 @@ export function ScenarioEditor({
|
|||
async function create() {
|
||||
await perform(async () => {
|
||||
const created = await api<Draft>("/api/scenarios/drafts/from-template", "POST", {
|
||||
source_id: sourceId || scenarios[0]?.id,
|
||||
source_id: effectiveSourceId,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
});
|
||||
openDraft(created);
|
||||
|
|
@ -97,6 +122,30 @@ export function ScenarioEditor({
|
|||
});
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
await perform(async () => {
|
||||
setNotice("Qwen формирует вариацию… Обычно это занимает несколько секунд.");
|
||||
const created = await api<Draft>("/api/scenarios/drafts/generate", "POST", {
|
||||
source_id: effectiveSourceId,
|
||||
instruction: instruction.trim(),
|
||||
});
|
||||
openDraft(created);
|
||||
setNotice("Локальная модель создала вариацию. Проверьте сюжет, факты и эталон перед утверждением.");
|
||||
});
|
||||
}
|
||||
|
||||
async function generateFull() {
|
||||
await perform(async () => {
|
||||
setNotice("Qwen формирует новый сюжет по полям… Обычно это занимает 10–30 секунд.");
|
||||
const created = await api<Draft>("/api/scenarios/drafts/generate-from-description", "POST", {
|
||||
source_id: effectiveSourceId,
|
||||
description: instruction.trim(),
|
||||
});
|
||||
openDraft(created);
|
||||
setNotice("Локальная модель создала новый сюжет, все ответы и мягкий эталон. Класс ЕКП и структура опроса взяты из выбранного каркаса; проверьте результат.");
|
||||
});
|
||||
}
|
||||
|
||||
async function reopen() {
|
||||
if (!draftId.trim()) return;
|
||||
await perform(async () => {
|
||||
|
|
@ -132,6 +181,20 @@ export function ScenarioEditor({
|
|||
});
|
||||
}
|
||||
|
||||
async function revise() {
|
||||
await perform(async () => {
|
||||
setNotice("Qwen перерабатывает черновик по комментарию…");
|
||||
const current = await save();
|
||||
const updated = await api<Draft>(
|
||||
`/api/scenarios/drafts/${encodeURIComponent(current.id)}/revise`, "POST",
|
||||
{ comment: revisionComment.trim() },
|
||||
);
|
||||
openDraft(updated);
|
||||
setRevisionComment("");
|
||||
setNotice("Локальная модель переработала этот же черновик по комментарию. Проверьте изменения заново.");
|
||||
});
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
await perform(async () => {
|
||||
const current = await save();
|
||||
|
|
@ -144,30 +207,107 @@ export function ScenarioEditor({
|
|||
});
|
||||
}
|
||||
|
||||
async function archiveSelected() {
|
||||
await perform(async () => {
|
||||
const selected = scenarios.find((item) => item.id === effectiveSourceId);
|
||||
if (!selected) throw new Error("Выберите сценарий для архивирования");
|
||||
await api(`/api/scenarios/${encodeURIComponent(selected.id)}`, "DELETE");
|
||||
setLastArchived({ id: selected.id, title: selected.title });
|
||||
setArchiveCandidate("");
|
||||
setSourceId("");
|
||||
setNotice(`Сценарий «${selected.title}» перемещён в архив и больше не назначается.`);
|
||||
await onLibraryChanged();
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreLast() {
|
||||
if (!lastArchived) return;
|
||||
await perform(async () => {
|
||||
await api(`/api/scenarios/${encodeURIComponent(lastArchived.id)}/restore`, "POST");
|
||||
const restored = lastArchived;
|
||||
setLastArchived(null);
|
||||
setSourceId(restored.id);
|
||||
setNotice(`Сценарий «${restored.title}» восстановлен в учебной библиотеке.`);
|
||||
await onLibraryChanged();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="scenario-editor">
|
||||
<h2>Редактор учебных сценариев</h2>
|
||||
<p className="ref">Создание работает локально: копия утверждённого шаблона, затем правка и проверка преподавателем.</p>
|
||||
<section className="scenario-editor" id="ai-scenario-editor" aria-busy={busy}>
|
||||
<h2>ИИ-формирователь и редактор сценариев</h2>
|
||||
<p className="ref">Можно сделать безопасную вариацию либо новый сюжет из описания. Выбранный утверждённый сценарий задаёт класс ЕКП и структуру опроса; при новом сюжете Qwen заново создаёт все ответы, адрес и число пострадавших. Публикация — только после вашей проверки.</p>
|
||||
<div className="scenario-editor-create">
|
||||
<label>Шаблон
|
||||
<select value={sourceId || scenarios[0]?.id || ""} onChange={(event) => setSourceId(event.target.value)}>
|
||||
<label>Каркас категории и опроса
|
||||
<select value={effectiveSourceId} onChange={(event) => setSourceId(event.target.value)}>
|
||||
{scenarios.map((scenario) => <option key={scenario.id} value={scenario.id}>{scenario.title} · {scenario.level}</option>)}
|
||||
</select>
|
||||
{effectiveSource && <small className="ref">
|
||||
Фиксированный путь ЕКП: {effectiveSource.signs?.length
|
||||
? effectiveSource.signs.join(" → ") : "без размеченных признаков"}
|
||||
{effectiveSource.incident_code ? ` · код ${effectiveSource.incident_code}` : ""}
|
||||
</small>}
|
||||
</label>
|
||||
<label>Название нового сценария
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="необязательно" maxLength={200} />
|
||||
</label>
|
||||
<button type="button" onClick={create} disabled={busy || !scenarios.length}>Создать черновик</button>
|
||||
</div>
|
||||
<div className="scenario-editor-create">
|
||||
<label>Указание для локальной модели
|
||||
<input value={instruction} onChange={(event) => setInstruction(event.target.value)}
|
||||
placeholder="Например: ночное возгорание, плотный дым, заявитель паникует"
|
||||
maxLength={1000} />
|
||||
<small className="ref">Описание меняет обстоятельства, но не показанный выше путь ЕКП.</small>
|
||||
</label>
|
||||
<button type="button" onClick={generate} disabled={busy || !scenarios.length || instruction.trim().length < 10}>
|
||||
Создать вариант ИИ
|
||||
</button>
|
||||
<button type="button" onClick={generateFull}
|
||||
disabled={busy || !scenarios.length || instruction.trim().length < 20}>
|
||||
Создать новый сюжет и эталон
|
||||
</button>
|
||||
</div>
|
||||
<div className="scenario-editor-open">
|
||||
<label>Номер черновика
|
||||
<input value={draftId} onChange={(event) => setDraftId(event.target.value)} placeholder="draft-…" />
|
||||
</label>
|
||||
<button type="button" onClick={reopen} disabled={busy || !draftId.trim()}>Открыть</button>
|
||||
</div>
|
||||
<div className="scenario-library-actions">
|
||||
{!archiveCandidate ? <button type="button" disabled={busy || !effectiveSourceId || !effectiveSource?.can_manage}
|
||||
onClick={() => setArchiveCandidate(effectiveSourceId)}>Архивировать выбранный сценарий</button> : <>
|
||||
<span>Сценарий исчезнет из назначения, но история занятий сохранится.</span>
|
||||
<button type="button" className="danger" disabled={busy}
|
||||
onClick={archiveSelected}>Подтвердить архивирование</button>
|
||||
<button type="button" disabled={busy} onClick={() => setArchiveCandidate("")}>Отмена</button>
|
||||
</>}
|
||||
{effectiveSource && !effectiveSource.can_manage && <small className="ref">
|
||||
Базовые сценарии и сценарии других преподавателей доступны только для чтения.
|
||||
</small>}
|
||||
{lastArchived && <button type="button" disabled={busy} onClick={restoreLast}>
|
||||
Вернуть «{lastArchived.title}» из архива
|
||||
</button>}
|
||||
</div>
|
||||
{draft && (
|
||||
<>
|
||||
<p className="ref">Черновик {draft.id}. Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
|
||||
<p className="ref">Черновик {draft.id} ({generationLabel(draft.generation)}). Факты и эталон доступны только преподавателю до утверждения. Поле score_weights позволяет задать веса метрик (0–10), например {"{\"address\": 3, \"dds_ack\": 2}"}.</p>
|
||||
<div className="scenario-editor-result">
|
||||
<strong>Предпросмотр: {stringField(draft.body, "title")}</strong>
|
||||
<p>Первая реплика: «{stringField(draft.body, "first_line")}»</p>
|
||||
<p className="ref">Сюжетные факты — проверьте, что они согласуются друг с другом и с эталоном:</p>
|
||||
<ul>{Array.isArray(draft.body.facts) && draft.body.facts.map((item, index) =>
|
||||
isObject(item) ? <li key={`${index}-${String(item.id)}`}>{String(item.id)}: {String(item.value ?? "")}</li> : null
|
||||
)}</ul>
|
||||
</div>
|
||||
<div className="scenario-editor-create">
|
||||
<label>Комментарий для переработки
|
||||
<input value={revisionComment} onChange={(event) => setRevisionComment(event.target.value)}
|
||||
placeholder="Например: убери упоминание газа и сделай дым плотнее"
|
||||
maxLength={1000} />
|
||||
</label>
|
||||
<button type="button" onClick={revise}
|
||||
disabled={busy || revisionComment.trim().length < 10}>Переработать с ИИ</button>
|
||||
</div>
|
||||
<label className="scenario-editor-json">JSON тела сценария
|
||||
<textarea value={bodyText} onChange={(event) => { setBodyText(event.target.value); setValidation(null); }} spellCheck={false} />
|
||||
</label>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue