Complete DDS training workflow and delivery package

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

View file

@ -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,
}

View file

@ -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,

View file

@ -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:

View file

@ -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)

View 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'",
},
)

View file

@ -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")

View file

@ -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,
)

View file

@ -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 []:

View file

@ -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:

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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())

View file

@ -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

View file

@ -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 ""

View file

@ -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:

View file

@ -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():

View file

@ -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

View file

@ -19,3 +19,5 @@
4. Если состояние — паника или крик: обрывки, повторы, незаконченные фразы.
5. Не задавай оператору вопросов о ходе разговора и не подсказывай ему, что спросить.
6. Отвечай только репликой, без пояснений и без кавычек.
7. Каждый факт из раздела «ЧТО НУЖНО СКАЗАТЬ ЭТОЙ РЕПЛИКОЙ» произнеси
полностью и дословно. Одного «да», «нет» или намёка недостаточно.

View file

@ -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]

View file

@ -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"
# Место

View file

@ -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"),
}

View file

@ -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

View file

@ -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
View 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(),
}

View file

@ -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)

View 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

View file

@ -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

View file

@ -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

View file

@ -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()

View 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()

View 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]}

View file

@ -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", [])],

View file

@ -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),
}
#: Вес метрики в детерминированной оценке.

View 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

View file

@ -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)

View file

@ -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):

View file

@ -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

View file

@ -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()

View file

@ -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,

View file

@ -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

View 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))