Merge remote-tracking branch 'origin/main' into feat/dds-timers
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/scoring/export.py # backend/app/session/finish.py # docs/arch/CONTRACT.md # docs/spec/GAP.md # frontend/src/features/instructor/ScoreWeightsEditor.tsx
This commit is contained in:
commit
4abdf0a901
61 changed files with 4084 additions and 167 deletions
|
|
@ -28,7 +28,7 @@ from argon2 import PasswordHasher
|
|||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
|
|
@ -56,6 +56,26 @@ _vanished: set[str] = set()
|
|||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||
_generations_synced_at: float | None = None
|
||||
# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
|
||||
# разделяют один запрос к БД до очередной синхронизации (lct-42). Запись
|
||||
# удаляется, когда lock больше никто не ждёт: иначе словарь растёт с каждым
|
||||
# логином, который узел когда-либо проверял.
|
||||
_lookup_locks: dict[str, "_LoginLookup"] = {}
|
||||
# Логины, которых разовая проверка не нашла в users. Держатся до следующей
|
||||
# сверки: повтор старой cookie не должен давать SELECT на каждый запрос.
|
||||
_missing_logins: set[str] = set()
|
||||
|
||||
|
||||
class _LoginLookup:
|
||||
__slots__ = ("lock", "users")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = asyncio.Lock()
|
||||
self.users = 0
|
||||
|
||||
|
||||
class _AuthStateUnavailable(Exception):
|
||||
"""Разовая проверка неизвестного узлу логина не смогла обратиться к БД."""
|
||||
|
||||
|
||||
async def _close_revoked(ws: WebSocket) -> None:
|
||||
|
|
@ -98,6 +118,7 @@ def prime_generations(values: dict[str, int]) -> None:
|
|||
global _generations_synced_at
|
||||
_generations.clear()
|
||||
_generations.update(values)
|
||||
_missing_logins.clear()
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
|
|
@ -108,15 +129,38 @@ async def load_generations() -> None:
|
|||
|
||||
|
||||
async def sync_generations() -> None:
|
||||
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
|
||||
"""Сверить версии полномочий и закрыть отозванные на другом узле сокеты."""
|
||||
known_before_query = set(_generations)
|
||||
async with get_sessionmaker()() as db:
|
||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||
current = {login: version for login, version in rows}
|
||||
current = {login: version for login, version in rows}
|
||||
# Версия узла выше БД, если отзыв не удалось записать (неудачный
|
||||
# logout) или учётку пересоздали после удаления. Без записи в БД
|
||||
# узлы расходятся навсегда: соседний узел выдаёт cookie со старой
|
||||
# версией, а этот её отвергает. Поднимаем БД до версии узла — отзыв
|
||||
# сохраняется, остальные узлы догоняют за одну сверку. При гонке со
|
||||
# снимком БД уже выше, и UPDATE ничего не меняет.
|
||||
ahead = {
|
||||
login: _generations[login]
|
||||
for login, version in current.items()
|
||||
if login in _generations and _generations[login] > version
|
||||
}
|
||||
if ahead:
|
||||
try:
|
||||
for login, local in ahead.items():
|
||||
await db.execute(_raise_auth_version(login, local))
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001 — повторим на следующей сверке
|
||||
log.error("не удалось записать версию полномочий узла (%s)",
|
||||
type(exc).__name__)
|
||||
await db.rollback()
|
||||
for login, version in current.items():
|
||||
previous = _generations.get(login)
|
||||
# Снимок БД мог устареть за время запроса; меньшая версия не должна
|
||||
# отменять локальный отзыв, который уже поднял поколение.
|
||||
if previous is None:
|
||||
_generations[login] = version
|
||||
elif previous != version:
|
||||
elif version > previous:
|
||||
invalidate_login(login, version)
|
||||
# Account deletion is not exposed by the application. Still close active
|
||||
# sockets if an operator removes one directly from the shared directory DB.
|
||||
|
|
@ -127,13 +171,88 @@ async def sync_generations() -> None:
|
|||
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||
_vanished.intersection_update(_generations.keys() - current.keys())
|
||||
for login in _generations.keys() - current.keys() - synthetic - _vanished:
|
||||
# Разовая проверка могла найти учётку после снимка этого запроса.
|
||||
# Её отсутствие в старом снимке не означает отзыв.
|
||||
for login in known_before_query - current.keys() - synthetic - _vanished:
|
||||
invalidate_login(login)
|
||||
_vanished.add(login)
|
||||
# Снимок только что прочитан: учётка, созданная до него, уже в кэше.
|
||||
_missing_logins.clear()
|
||||
global _generations_synced_at
|
||||
_generations_synced_at = time.monotonic()
|
||||
|
||||
|
||||
def _raise_auth_version(login: str, version: int):
|
||||
# Только вверх: параллельная запись в БД могла уже поднять версию выше.
|
||||
return (
|
||||
update(User)
|
||||
.where(User.login == login, User.auth_version < version)
|
||||
.values(auth_version=version)
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_unknown_login(login: str) -> int | None:
|
||||
"""Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
|
||||
|
||||
Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
|
||||
отсутствие в кэше означает, что этот узел ещё не видел учётку.
|
||||
Возвращает версию или None, если строки в `users` нет. При недоступной
|
||||
или зависшей БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт
|
||||
с 503/1013.
|
||||
"""
|
||||
if login in _missing_logins:
|
||||
return None
|
||||
entry = _lookup_locks.get(login)
|
||||
if entry is None:
|
||||
entry = _lookup_locks[login] = _LoginLookup()
|
||||
entry.users += 1
|
||||
try:
|
||||
# Тот же предел, что у сверки: при partition handshake не ждёт
|
||||
# таймаута TCP, а очередь за lock не растягивает ожидание сверх него.
|
||||
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
|
||||
async with entry.lock:
|
||||
cached = _generations.get(login)
|
||||
if cached is not None:
|
||||
return cached # параллельный запрос уже получил версию
|
||||
if login in _missing_logins:
|
||||
return None
|
||||
async with get_sessionmaker()() as db:
|
||||
version = await db.scalar(
|
||||
select(User.auth_version).where(User.login == login)
|
||||
)
|
||||
# Пока шёл SELECT, локальный отзыв или синхронизация могли
|
||||
# записать новую версию. Старый ответ не должен вернуть
|
||||
# отозванную cookie.
|
||||
cached = _generations.get(login)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if version is None:
|
||||
_missing_logins.add(login)
|
||||
return None
|
||||
_generations[login] = version
|
||||
log.warning(
|
||||
"учётка %s найдена разовой проверкой до сверки узла",
|
||||
login_log_marker(login),
|
||||
)
|
||||
return version
|
||||
except Exception as exc: # noqa: BLE001 — ошибка или таймаут БД закрывают вход
|
||||
log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
|
||||
raise _AuthStateUnavailable from exc
|
||||
finally:
|
||||
entry.users -= 1
|
||||
if entry.users == 0 and _lookup_locks.get(login) is entry:
|
||||
del _lookup_locks[login]
|
||||
|
||||
|
||||
def login_log_marker(login: str) -> str:
|
||||
"""Метка логина для журнала без самого логина.
|
||||
|
||||
По ней кластерный смоук доказывает, что узел прошёл через разовую
|
||||
проверку, а не увидел учётку обычной сверкой.
|
||||
"""
|
||||
return hashlib.sha256(f"lct-login:{login}".encode()).hexdigest()[:12]
|
||||
|
||||
|
||||
async def watch_generations() -> None:
|
||||
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
||||
while True:
|
||||
|
|
@ -151,6 +270,21 @@ async def watch_generations() -> None:
|
|||
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
||||
|
||||
|
||||
async def _send_auth_state_unavailable(scope, send) -> None:
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1013})
|
||||
else:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 503,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"detail":"auth_state_unavailable"}',
|
||||
})
|
||||
|
||||
|
||||
class AuthVersionMiddleware:
|
||||
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
||||
|
||||
|
|
@ -186,22 +320,17 @@ class AuthVersionMiddleware:
|
|||
synced_at is None
|
||||
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||
):
|
||||
if scope["type"] == "websocket":
|
||||
await send({"type": "websocket.close", "code": 1013})
|
||||
else:
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 503,
|
||||
"headers": [(b"content-type", b"application/json")],
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": b'{"detail":"auth_state_unavailable"}',
|
||||
})
|
||||
await _send_auth_state_unavailable(scope, send)
|
||||
return
|
||||
|
||||
cookie_version = session.get("auth_generation")
|
||||
version = _generations.get(login)
|
||||
if version is None:
|
||||
try:
|
||||
version = await _resolve_unknown_login(login)
|
||||
except _AuthStateUnavailable:
|
||||
await _send_auth_state_unavailable(scope, send)
|
||||
return
|
||||
if version is None or cookie_version != version:
|
||||
if session is not None:
|
||||
session.clear()
|
||||
|
|
@ -536,7 +665,20 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
|||
await audit_required(user.login, user.role, "login.blocked")
|
||||
raise HTTPException(status_code=403, detail="blocked")
|
||||
|
||||
if _generations.get(user.login) != user.auth_version:
|
||||
local_version = _generations.get(user.login)
|
||||
if local_version is not None and local_version > user.auth_version:
|
||||
# Отзыв на этом узле не дошёл до БД (неудачный logout). Сброс к версии
|
||||
# БД вернул бы силу cookie, выданной до выхода; выдача cookie с
|
||||
# версией узла без записи в БД развела бы узлы. Поднимаем БД.
|
||||
try:
|
||||
async with get_sessionmaker()() as db:
|
||||
await db.execute(_raise_auth_version(user.login, local_version))
|
||||
await db.commit()
|
||||
except Exception as exc: # noqa: BLE001 — cookie без записанной версии не выдаём
|
||||
log.error("вход: версию полномочий не удалось записать (%s)",
|
||||
type(exc).__name__)
|
||||
raise HTTPException(status_code=503, detail="auth_state_unavailable") from exc
|
||||
elif local_version != user.auth_version:
|
||||
invalidate_login(user.login, user.auth_version)
|
||||
|
||||
who = Principal(
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ from app.db import repo
|
|||
from app.db.base import get_session
|
||||
from app.db.models import Group, Score, Session, Trainee
|
||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||
from app.domain.taxonomy import (
|
||||
ERRORS,
|
||||
ErrorCode,
|
||||
Finding,
|
||||
FindingDecision,
|
||||
FindingReview,
|
||||
FindingSource,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
from app.domain.statuses import (
|
||||
SERVICE_STATUS_LABELS,
|
||||
|
|
@ -34,9 +42,13 @@ from app.domain.statuses import (
|
|||
from app.domain.timers import TimerCode, TimerState
|
||||
from app.scoring.export import to_csv, to_pdf
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.review import has_finding
|
||||
from app.scoring.taxonomy import METRIC_MAP
|
||||
from app.session.access import can_access
|
||||
from app.session.checkpoint import load_state
|
||||
from app.session.finish import override_score
|
||||
from app.session.finish import change_findings, override_score
|
||||
from app.session.store import FindingAdded, FindingReviewed
|
||||
from app.session.timers import now_utc
|
||||
from app.session.score import scoring_scenario
|
||||
from app.session.hub import hub
|
||||
from app.session.state import SessionState
|
||||
|
|
@ -117,6 +129,7 @@ class ActiveSessionOut(BaseModel):
|
|||
dds_overdue_cards: int
|
||||
dds_work_overdue_cards: int
|
||||
dds_statuses: dict[str, str]
|
||||
paused: bool = False
|
||||
dds_snapshot: StationSnapshot | None = None
|
||||
signals: list[Signal] = []
|
||||
presence_known: bool = True
|
||||
|
|
@ -298,7 +311,10 @@ async def active(
|
|||
checkpoint_only.add(state.session_id)
|
||||
|
||||
for state in states.values():
|
||||
elapsed = (max(0, int((now - state.started_at).total_seconds()))
|
||||
paused_ms = state.total_paused_ms
|
||||
if state.paused and state.paused_at is not None:
|
||||
paused_ms += max(0, int((now - state.paused_at).total_seconds() * 1000))
|
||||
elapsed = (max(0, int((now - state.started_at).total_seconds() - paused_ms / 1000))
|
||||
if state.started_at else 0)
|
||||
station = state.station_snapshot() if state.dds_phase else None
|
||||
queue = station.queue_cards if station else []
|
||||
|
|
@ -326,12 +342,15 @@ async def active(
|
|||
),
|
||||
dds_work_overdue_cards=sum(
|
||||
(timer := card.timers.timers.get(TimerCode.DDS_WORK)) is not None
|
||||
and timer.started_at is not None
|
||||
# Пауза держит `started_at=None`, хотя таймер уже шёл: судить
|
||||
# по нему одному спрятало бы уже случившееся нарушение (lct-39).
|
||||
and (timer.started_at is not None or timer.paused)
|
||||
and not timer.stopped
|
||||
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
|
||||
for card in state.desk.cards.values()
|
||||
),
|
||||
dds_statuses=latest_statuses,
|
||||
paused=state.paused,
|
||||
dds_snapshot=station,
|
||||
signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
|
||||
presence_known=state.session_id not in checkpoint_only,
|
||||
|
|
@ -657,6 +676,157 @@ async def override(
|
|||
return SessionReport.model_validate(report["full_report"])
|
||||
|
||||
|
||||
def _required_text(value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("поле обязательно")
|
||||
return cleaned
|
||||
|
||||
|
||||
class FindingReviewIn(BaseModel):
|
||||
"""Решение по отметке: причина обязательна и остаётся в разборе."""
|
||||
|
||||
decision: FindingDecision
|
||||
reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
_reason = field_validator("reason")(_required_text)
|
||||
|
||||
|
||||
class FindingIn(BaseModel):
|
||||
"""Отметка преподавателя: код, факт и норма — то же обоснование, что у автоматической."""
|
||||
|
||||
code: ErrorCode
|
||||
fact: str = Field(min_length=1, max_length=1000)
|
||||
norm: str = Field(min_length=1, max_length=1000)
|
||||
#: Номер карточки очереди ДДС (с 1); без него отметка относится к занятию.
|
||||
card: int | None = Field(default=None, ge=1)
|
||||
#: Ключ идемпотентности с клиента: повтор запроса не добавит отметку второй раз.
|
||||
client_id: UUID | None = None
|
||||
|
||||
_fact = field_validator("fact")(_required_text)
|
||||
_norm = field_validator("norm")(_required_text)
|
||||
|
||||
|
||||
async def _change_findings(
|
||||
session_id: UUID, who, db: AsyncSession | None, make,
|
||||
) -> SessionReport:
|
||||
"""Общий путь решений по отметкам — тот же, что у правки итога.
|
||||
|
||||
`make(who, score)` строит запись изменения по сохранённой оценке или
|
||||
отвечает HTTP-ошибкой. Роль проверяет маршрут: администратору сюда нельзя,
|
||||
как и к правке балла.
|
||||
"""
|
||||
state = hub.get(session_id)
|
||||
if state is not None:
|
||||
_require_access(who, state)
|
||||
if state.score is None:
|
||||
raise HTTPException(status_code=409, detail="score_not_ready")
|
||||
scenario = scoring_scenario(state)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=409, detail="scenario_not_found")
|
||||
change = make(who, state.score)
|
||||
async with hub.operation(session_id):
|
||||
if not _repeated(state.score, change):
|
||||
change_findings(state, change)
|
||||
return build_report(session_id, state, scenario)
|
||||
|
||||
if db is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
session = await repo.get_session(db, session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
_require_access(who, session)
|
||||
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")
|
||||
if (score.report or {}).get("full_report") is None:
|
||||
raise HTTPException(status_code=409, detail="report_not_archived")
|
||||
current = dict(score.report)
|
||||
change = make(who, current)
|
||||
await db.rollback() # чтение закончено; запись — одной транзакцией хранилища
|
||||
if not _repeated(current, change):
|
||||
await hub.store.commit_archived(session_id, [change])
|
||||
# Ответ — по строке после commit: пересчёт шёл под блокировкой и мог учесть
|
||||
# решение из соседней вкладки, которого не было в прочитанном выше отчёте.
|
||||
score = await db.scalar(
|
||||
select(Score).where(Score.session_id == session_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
report = SessionReport.model_validate(score.report["full_report"])
|
||||
await db.rollback()
|
||||
return report
|
||||
|
||||
|
||||
def _repeated(report: dict, change: FindingReviewed | FindingAdded) -> bool:
|
||||
return isinstance(change, FindingAdded) and has_finding(report, change.finding.client_id)
|
||||
|
||||
|
||||
@router.post("/{session_id}/findings/{index}/review", response_model=SessionReport)
|
||||
async def review_finding(
|
||||
session_id: UUID,
|
||||
index: int,
|
||||
body: FindingReviewIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
"""Подтвердить или снять отметку; снятая не штрафует свою метрику."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
|
||||
def make(who, score: dict) -> FindingReviewed:
|
||||
if not 0 <= index < len(score.get("findings", [])):
|
||||
raise HTTPException(status_code=404, detail="finding_not_found")
|
||||
return FindingReviewed(index=index, role=who.role.value, review=FindingReview(
|
||||
decision=body.decision, reason=body.reason, author=who.login, at=now_utc(),
|
||||
))
|
||||
|
||||
return await _change_findings(session_id, who, db, make)
|
||||
|
||||
|
||||
@router.post("/{session_id}/findings", response_model=SessionReport)
|
||||
async def add_finding(
|
||||
session_id: UUID,
|
||||
body: FindingIn,
|
||||
request: Request,
|
||||
db: AsyncSession | None = Depends(optional_session),
|
||||
) -> SessionReport:
|
||||
"""Отметка преподавателя штрафует связанную метрику по той же методике."""
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
|
||||
def make(who, score: dict) -> FindingAdded:
|
||||
service = None
|
||||
prefix = ""
|
||||
exercise = (score.get("full_report") or {}).get("exercise")
|
||||
# E1–E6 в связке 112 → ДДС — ошибки приёма вызова: их метрики у занятия,
|
||||
# а не у карточки очереди. С номером карточки отметка не нашла бы свою
|
||||
# метрику 112 и штрафовала бы отдельной метрикой преподавателя.
|
||||
if (body.card is not None and body.code.value.startswith("E")
|
||||
and exercise != Exercise.DDS.value):
|
||||
raise HTTPException(status_code=422, detail="call_code_without_card")
|
||||
if body.card is not None:
|
||||
cards = score.get("card_results") or []
|
||||
if body.card > len(cards):
|
||||
raise HTTPException(status_code=422, detail="card_not_found")
|
||||
service = cards[body.card - 1].get("managed_service")
|
||||
prefix = f"Карточка {body.card}: " + (f"{service}: " if service else "")
|
||||
competency = next((item for code, item in METRIC_MAP.values() if code is body.code), None)
|
||||
return FindingAdded(role=who.role.value, finding=Finding(
|
||||
code=body.code,
|
||||
source=FindingSource.INSTRUCTOR,
|
||||
summary=f"{prefix}{ERRORS[body.code].title}",
|
||||
fact=body.fact,
|
||||
norm=body.norm,
|
||||
ref="отметка преподавателя на разборе",
|
||||
competency=competency,
|
||||
at=now_utc(),
|
||||
card=body.card,
|
||||
service=service,
|
||||
author=who.login,
|
||||
client_id=body.client_id,
|
||||
))
|
||||
|
||||
return await _change_findings(session_id, who, db, make)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SessionOut])
|
||||
async def listing(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from app.domain.events import (
|
|||
PatchSource,
|
||||
ScoreReady,
|
||||
SessionMode,
|
||||
SessionPaused,
|
||||
StationState,
|
||||
TimerTick,
|
||||
TextTurnAccepted,
|
||||
|
|
@ -192,6 +193,14 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
code=ErrorKind.UNSUPPORTED_EVENT, message="Занятие уже завершено",
|
||||
))
|
||||
return
|
||||
if state.paused:
|
||||
# Отказ временный, в отличие от отклонённого значения: `kio.patch`
|
||||
# без `auto`-отката. useCall держит правку в pending и повторяет её
|
||||
# после `session.paused: false` — откат стёр бы ввод курсанта.
|
||||
hub.to_trainee(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT, message="Пауза, ждите преподавателя",
|
||||
))
|
||||
return
|
||||
if (event.type == "text.turn" and state.exercise is not Exercise.CARD) or state.exercise is Exercise.DDS or (
|
||||
state.exercise is Exercise.CARD and event.type not in {"kio.patch", "card.submit", "text.turn"}
|
||||
) or (state.exercise is Exercise.CALL and event.type == "card.submit"):
|
||||
|
|
@ -230,6 +239,9 @@ async def _handle(session_id: UUID, state, event) -> None:
|
|||
code=ErrorKind.INTERNAL, message="Не удалось получить ответ заявителя. Попробуйте ещё раз.",
|
||||
))
|
||||
return
|
||||
if state.paused:
|
||||
# Ответ модели мог закончиться уже после команды преподавателя.
|
||||
return
|
||||
caller_entry = state.append(Speaker.CALLER, line.text, line.mood)
|
||||
hub.to_trainee(session_id, CallerUtterance(
|
||||
utterance_id=uuid4(), text=line.text,
|
||||
|
|
@ -420,6 +432,8 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
if hub.is_lease_fenced(session_id):
|
||||
await close_fenced(ws)
|
||||
return
|
||||
# Переподключившийся клиент сначала узнаёт, разрешён ли ввод.
|
||||
queue.put_nowait(SessionPaused(paused=state.paused))
|
||||
if state.exercise is Exercise.CARD:
|
||||
from app.api.ws.control import card_briefing
|
||||
|
||||
|
|
@ -462,6 +476,10 @@ async def call(ws: WebSocket, session_id: UUID) -> None:
|
|||
# Бинарные кадры — аудио, текстовые — события. Направление определяется
|
||||
# каналом, обёртки JSON вокруг звука нет (docs/arch/CONTRACT.md).
|
||||
if message.get("bytes") is not None:
|
||||
if state.paused:
|
||||
# JSON-команды отказывает `_handle`; бинарные кадры сюда
|
||||
# не заходят, поэтому пауза глушит звук здесь же (lct-39).
|
||||
continue
|
||||
if state.exercise is Exercise.CALL:
|
||||
_on_audio(session_id, state, message["bytes"])
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ from app.domain.events import (
|
|||
ModeSet,
|
||||
ReferenceStarted,
|
||||
SessionEnded,
|
||||
SessionPaused,
|
||||
StationState,
|
||||
)
|
||||
from app.domain.roles import Role
|
||||
|
|
@ -51,7 +52,14 @@ from app.session.access import can_access
|
|||
from app.session.hub import hub
|
||||
from app.session.finish import end_session, override_score
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import LessonIdentity, LessonRequest, NoteAdded, ScoreOverridden
|
||||
from app.session.store import (
|
||||
LessonIdentity,
|
||||
LessonPaused,
|
||||
LessonRequest,
|
||||
LessonResumed,
|
||||
NoteAdded,
|
||||
ScoreOverridden,
|
||||
)
|
||||
from app.voice.models import get_voice_models
|
||||
from app.voice.pipeline import FILLERS, prefetch
|
||||
|
||||
|
|
@ -345,6 +353,26 @@ async def _command(session_id: UUID, event, who) -> None:
|
|||
await _start(session_id, event, who)
|
||||
case "session.stop":
|
||||
await _stop(session_id)
|
||||
case "session.pause":
|
||||
if state is None or state.ended or state.paused:
|
||||
return
|
||||
state.pause()
|
||||
hub.record(session_id, LessonPaused(
|
||||
at=state.paused_at, author=who.login, role=who.role.value,
|
||||
))
|
||||
hub.broadcast(session_id, SessionPaused(paused=True))
|
||||
hub.to_station(session_id, SessionPaused(paused=True))
|
||||
case "session.resume":
|
||||
if state is None or state.ended or not state.paused:
|
||||
return
|
||||
paused_ms_before = state.total_paused_ms
|
||||
state.resume()
|
||||
hub.record(session_id, LessonResumed(
|
||||
at=now_utc(), author=who.login, role=who.role.value,
|
||||
paused_ms=state.total_paused_ms - paused_ms_before,
|
||||
))
|
||||
hub.broadcast(session_id, SessionPaused(paused=False))
|
||||
hub.to_station(session_id, SessionPaused(paused=False))
|
||||
case "instructor_note.add":
|
||||
if state is not None:
|
||||
state.notes.append({
|
||||
|
|
@ -391,6 +419,14 @@ async def _command(session_id: UUID, event, who) -> None:
|
|||
case "director.inject":
|
||||
if state is None:
|
||||
return
|
||||
if state.paused:
|
||||
# Обрыв на паузе запустил бы норматив обратного дозвона, и простой
|
||||
# ушёл бы в него; курсант же за баннером паузы не может ответить.
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
message="Занятие на паузе",
|
||||
))
|
||||
return
|
||||
if state.exercise is not Exercise.CALL:
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
Учебный исходящий звонок бригаде имитируется событиями JSON. Для целевого
|
||||
локального прототипа не нужна внешняя телефония или аудиомодель.
|
||||
|
||||
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
|
||||
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
|
||||
в отчёте и становится сорванным выездом с конкретной причиной.
|
||||
`card.bounce` сервер отклоняет: ДДС не возвращает карточку в 112 при принятии
|
||||
и не правит её поля. Об ошибке, которую вскрыл доклад бригады с места,
|
||||
диспетчер сообщает в 112 по телефону (ответ заказчика, П.3).
|
||||
|
||||
Правила пульта — в `DdsDesk.apply`; здесь разбор команды и рассылка итога.
|
||||
Отсев повторов и операция хранилища — в `run_command`.
|
||||
|
|
@ -39,6 +39,9 @@ def _error(session_id: UUID, message: str) -> None:
|
|||
|
||||
|
||||
async def _handle(session_id: UUID, state, event) -> None:
|
||||
if state.paused:
|
||||
_error(session_id, "Пауза, ждите преподавателя")
|
||||
return
|
||||
outcome = state.desk.apply(event, state)
|
||||
if outcome.error is not None:
|
||||
_error(session_id, outcome.error)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ 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
|
||||
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
|
||||
|
||||
|
||||
class SessionMode(StrEnum):
|
||||
|
|
@ -53,6 +53,9 @@ class LessonCriteria(BaseModel):
|
|||
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
|
||||
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
|
||||
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
|
||||
dds_report_reaction_enabled: bool = False
|
||||
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
|
||||
allowed_errors: int = Field(default=0, ge=0, le=50)
|
||||
require_correct_grammar: bool = True
|
||||
score_weights: dict[str, float] = Field(default_factory=dict)
|
||||
|
|
@ -60,7 +63,7 @@ class LessonCriteria(BaseModel):
|
|||
@field_validator("score_weights")
|
||||
@classmethod
|
||||
def validate_score_weights(cls, weights: dict[str, float]) -> dict[str, float]:
|
||||
unknown = weights.keys() - METRIC_MAP.keys()
|
||||
unknown = weights.keys() - METRIC_MAP.keys() - {INSTRUCTOR_METRIC}
|
||||
if unknown:
|
||||
raise ValueError(f"неизвестные метрики весов: {', '.join(sorted(unknown))}")
|
||||
if any(not 0 <= weight <= 10 for weight in weights.values()):
|
||||
|
|
@ -274,6 +277,13 @@ class ErrorEvent(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
class SessionPaused(BaseModel):
|
||||
"""Пульт остановил или возобновил время занятия (lct-39)."""
|
||||
|
||||
type: Literal["session.paused"] = "session.paused"
|
||||
paused: bool
|
||||
|
||||
|
||||
ServerToTrainee = Annotated[
|
||||
CallIncoming
|
||||
| CardBriefing
|
||||
|
|
@ -293,6 +303,7 @@ ServerToTrainee = Annotated[
|
|||
| TimerTick
|
||||
| CallEnded
|
||||
| ScoreReady
|
||||
| SessionPaused
|
||||
| ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -404,6 +415,8 @@ class SessionSnapshot(BaseModel):
|
|||
timers: list[TimerSnapshot]
|
||||
hints_used: int = 0
|
||||
ended: bool = False
|
||||
#: Пульт остановил время занятия (lct-39).
|
||||
paused: bool = False
|
||||
|
||||
|
||||
class TranscriptAppend(BaseModel):
|
||||
|
|
@ -458,6 +471,7 @@ ServerToObserver = Annotated[
|
|||
| ScoreReady
|
||||
| InstructorNoteShown
|
||||
| ReferenceStarted
|
||||
| SessionPaused
|
||||
| ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
|
@ -518,13 +532,25 @@ class SessionStop(BaseModel):
|
|||
type: Literal["session.stop"] = "session.stop"
|
||||
|
||||
|
||||
class SessionPause(BaseModel):
|
||||
"""Остановить время занятия; карточку курсанта команда не меняет (lct-39)."""
|
||||
|
||||
type: Literal["session.pause"] = "session.pause"
|
||||
|
||||
|
||||
class SessionResume(BaseModel):
|
||||
type: Literal["session.resume"] = "session.resume"
|
||||
|
||||
|
||||
InstructorToServer = Annotated[
|
||||
ScenarioStart
|
||||
| DirectorInject
|
||||
| ReferencePlay
|
||||
| InstructorNoteAdd
|
||||
| ScoreOverride
|
||||
| SessionStop,
|
||||
| SessionStop
|
||||
| SessionPause
|
||||
| SessionResume,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -665,7 +691,7 @@ class CrewArrived(BaseModel):
|
|||
|
||||
ServerToStation = Annotated[
|
||||
CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded
|
||||
| ScoreReady | CommandAck | ErrorEvent,
|
||||
| ScoreReady | CommandAck | SessionPaused | ErrorEvent,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
|
@ -692,6 +718,12 @@ class Metric(BaseModel):
|
|||
passed: bool
|
||||
weight: float = 1.0
|
||||
credit: float | None = Field(default=None, ge=0, le=1)
|
||||
#: Номер карточки очереди ДДС (с 1) и служба — область метрики для разбора.
|
||||
card: int | None = None
|
||||
service: str | None = None
|
||||
#: Автоматический вердикт, если его изменило решение преподавателя по отметке.
|
||||
passed_auto: bool | None = None
|
||||
credit_auto: float | None = Field(default=None, ge=0, le=1)
|
||||
|
||||
|
||||
class CompetencyScore(BaseModel):
|
||||
|
|
@ -741,6 +773,8 @@ class DdsCardReport(BaseModel):
|
|||
card_id: UUID
|
||||
scenario_id: str
|
||||
score_auto: float
|
||||
#: Балл карточки после решений преподавателя по отметкам; None — решений не было.
|
||||
score_reviewed: float | None = None
|
||||
reply_text: str
|
||||
metrics: list[Metric]
|
||||
findings: list[Finding]
|
||||
|
|
@ -780,9 +814,13 @@ class SessionReport(BaseModel):
|
|||
self_assessment_diff: SelfAssessmentDiff | None = None
|
||||
notes: list[InstructorNoteShown] = []
|
||||
score_auto: float
|
||||
#: Балл после решений преподавателя по отметкам; None — решений не было.
|
||||
score_reviewed: float | None = None
|
||||
score_final: float
|
||||
overridden_by: str | None = None
|
||||
override_comment: str | None = None
|
||||
#: Суммарная длительность пауз преподавателя — время объяснимо (lct-39).
|
||||
total_paused_ms: int = 0
|
||||
|
||||
|
||||
class Principal(BaseModel):
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ class KIO(BaseModel):
|
|||
description: str | None = None
|
||||
victims_count: int | None = None
|
||||
is_emergency: bool = False
|
||||
offence_signs: bool | None = None
|
||||
life_threat: bool = False
|
||||
evacuation_needed: bool = False
|
||||
|
||||
|
|
@ -153,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
|||
"caller_name", "caller_contact", "phone_on_scene", "language",
|
||||
"okato", "address", "street", "building", "entrance", "floor",
|
||||
"intercom_code", "description", "incident_group", "signs",
|
||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||
"incident_type", "victims_count", "is_emergency", "offence_signs", "life_threat",
|
||||
"evacuation_needed", "dds", "notify_extra",
|
||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||
"fire.people_inside", "fire.smoke_spread",
|
||||
|
|
@ -193,7 +194,10 @@ def missing_fields(card: KIO, required: list[str]) -> list[str]:
|
|||
#: те же «признаки», только не про тип происшествия, а про обстоятельства:
|
||||
#: пострадавшие поднимают скорую, газификация — МОСГАЗ (domain/ekp.py).
|
||||
MODIFIER_FIELDS: frozenset[str] = frozenset(
|
||||
{"incident_group", "signs", "victims_count", "life_threat", "evacuation_needed", "fire.gasified"}
|
||||
{
|
||||
"incident_group", "signs", "victims_count", "life_threat",
|
||||
"evacuation_needed", "fire.gasified", "offence_signs", "police.offence_kind",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -208,6 +212,12 @@ def modifiers(card: KIO) -> list[str]:
|
|||
keys.append("evacuation")
|
||||
if card.fire is not None and card.fire.gasified:
|
||||
keys.append("gas")
|
||||
if card.offence_signs is True or (
|
||||
card.police is not None
|
||||
and card.police.offence_kind is not None
|
||||
and bool(card.police.offence_kind.strip())
|
||||
):
|
||||
keys.append("offence")
|
||||
return keys
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -276,3 +276,5 @@ class StationSnapshot(BaseModel):
|
|||
pending_cards_count: int = 0
|
||||
next_arrival_in_seconds: int | None = None
|
||||
max_waiting_cards: int = 3
|
||||
#: Пульт на паузе: время не идёт, новая карточка не поступит (lct-39).
|
||||
paused: bool = False
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
|
||||
class ErrorCode(StrEnum):
|
||||
|
|
@ -152,6 +154,22 @@ COMPETENCY_LABELS: dict[Competency, str] = {
|
|||
}
|
||||
|
||||
|
||||
class FindingDecision(StrEnum):
|
||||
"""Решение преподавателя по отметке на разборе."""
|
||||
|
||||
CONFIRMED = "confirmed"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
class FindingReview(BaseModel):
|
||||
"""Кто, когда и почему подтвердил или снял отметку. Сама отметка остаётся."""
|
||||
|
||||
decision: FindingDecision
|
||||
reason: str
|
||||
author: str
|
||||
at: datetime
|
||||
|
||||
|
||||
class Finding(BaseModel):
|
||||
"""Отметка в разборе. `fact` и `norm` — то самое обоснование."""
|
||||
|
||||
|
|
@ -164,3 +182,32 @@ class Finding(BaseModel):
|
|||
competency: Competency | None = None
|
||||
transcript_ref: str | None = None
|
||||
at: datetime | None = None
|
||||
#: Метрика, чей провал объясняет отметка. Без неё связь с метриками идёт
|
||||
#: по методике METRIC_MAP в пределах той же карточки и службы.
|
||||
metric_key: str | None = None
|
||||
#: Номер карточки очереди ДДС (с 1) и служба, к которым относится отметка.
|
||||
card: int | None = None
|
||||
service: str | None = None
|
||||
#: Автор ручной отметки преподавателя (`source=instructor`).
|
||||
author: str | None = None
|
||||
#: Ключ идемпотентности ручной отметки: повтор запроса не добавляет её снова.
|
||||
client_id: UUID | None = None
|
||||
#: Все решения по отметке по порядку; действует последнее. Прежние не
|
||||
#: затираются: «кто, когда, почему» нужно по каждой правке, а не по итогу.
|
||||
reviews: list[FindingReview] = []
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _single_review(cls, data: Any) -> Any:
|
||||
# Разборы, сохранённые до истории решений, хранили одно поле `review`.
|
||||
if isinstance(data, dict) and "review" in data:
|
||||
data = dict(data)
|
||||
single = data.pop("review")
|
||||
if single and not data.get("reviews"):
|
||||
data["reviews"] = [single]
|
||||
return data
|
||||
|
||||
@property
|
||||
def review(self) -> FindingReview | None:
|
||||
"""Действующее решение — последнее."""
|
||||
return self.reviews[-1] if self.reviews else None
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ def evaluate_card(scenario: Scenario, kio: KIO) -> GostResult:
|
|||
source=FindingSource.KIO if code.value == "E5" else FindingSource.GROUND_TRUTH,
|
||||
summary=f"{title}: {fact}; требуется {norm}",
|
||||
fact=fact, norm=norm, ref=ref, competency=competency,
|
||||
metric_key=key,
|
||||
))
|
||||
|
||||
if truth.incident_code:
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@
|
|||
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
|
||||
from app.domain.statuses import (
|
||||
COMMENT_REQUIRED,
|
||||
PRIMARY,
|
||||
REFUSAL_COMMENT_REQUIRED,
|
||||
SERVICE_STATUS_LABELS,
|
||||
PhoneReportRecord,
|
||||
ServiceStatus,
|
||||
StatusEntry,
|
||||
current,
|
||||
|
|
@ -23,19 +28,121 @@ from app.domain.statuses import (
|
|||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.events import Metric
|
||||
from app.domain.timers import GOST_REF, TimerCode
|
||||
from app.scoring.timing import TimeNorm, time_metric
|
||||
|
||||
MEMO_REF = "памятка «Работа на АРМ-112», раздел «Статусы реагирования»"
|
||||
|
||||
#: Статусы, означающие, что реагирование действительно шло.
|
||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||
|
||||
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
|
||||
REPORT_STATUS = {
|
||||
"dispatched": ServiceStatus.RESPONDING,
|
||||
"arrived": ServiceStatus.ARRIVED,
|
||||
"working": ServiceStatus.WORKING,
|
||||
"completed": ServiceStatus.COMPLETED,
|
||||
}
|
||||
REPORT_LABELS = {
|
||||
"dispatched": "о выезде",
|
||||
"arrived": "о прибытии",
|
||||
"working": "о начале работ",
|
||||
"completed": "о завершении работ",
|
||||
}
|
||||
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
|
||||
REPORT_REACTION_MS = 45_000
|
||||
#: Реакция на доклад — норматив времени, как отработка карточки, но отметка
|
||||
#: D6 из памятки: опоздавший статус — тот же неполный ход работ.
|
||||
DDS_REPORT_REACTION = TimeNorm(
|
||||
key="dds_report_reaction",
|
||||
title="реакция на доклад бригады",
|
||||
summary="статус по докладу бригады отмечен с нарушением норматива",
|
||||
action="отметить статус по докладу",
|
||||
unmeasured="не отмечен",
|
||||
incomplete="не отмечен",
|
||||
code=ErrorCode.D6,
|
||||
source=FindingSource.DISPATCHER,
|
||||
ref=MEMO_REF,
|
||||
)
|
||||
#: Терминальный отказ: работ не было, поэтому неотмеченный по докладу статус
|
||||
#: не штрафуется ни отметкой, ни метрикой.
|
||||
TERMINAL_REFUSAL = {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
|
||||
def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
|
||||
|
||||
def report_reactions(
|
||||
marks: list[StatusEntry], reports: list[PhoneReportRecord],
|
||||
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
|
||||
) -> list[tuple[PhoneReportRecord, int | None]]:
|
||||
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
|
||||
|
||||
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
|
||||
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
|
||||
"""
|
||||
result: list[tuple[PhoneReportRecord, int | None]] = []
|
||||
for report in reports:
|
||||
status = REPORT_STATUS.get(report.phase)
|
||||
if status is None:
|
||||
continue
|
||||
times = [mark.at for mark in marks if mark.status is status]
|
||||
if any(at <= report.at for at in times):
|
||||
result.append((report, 0))
|
||||
continue
|
||||
after = min(times, default=None)
|
||||
if after is None:
|
||||
result.append((report, None))
|
||||
continue
|
||||
elapsed_ms = int((after - report.at).total_seconds() * 1000)
|
||||
for start, end in pause_intervals or ():
|
||||
overlap_start = max(report.at, start)
|
||||
overlap_end = min(after, end)
|
||||
if overlap_end > overlap_start:
|
||||
elapsed_ms -= int((overlap_end - overlap_start).total_seconds() * 1000)
|
||||
result.append((report, max(0, elapsed_ms)))
|
||||
return result
|
||||
|
||||
|
||||
def report_reaction(
|
||||
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
|
||||
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
|
||||
) -> tuple[Metric, Finding | None] | None:
|
||||
"""Метрика и отметка D6 по самой поздней реакции службы на доклады бригады.
|
||||
|
||||
Одна функция для отметок и для балла: иначе метрика провалится там,
|
||||
где отметки нет. None — реагировать было не на что.
|
||||
"""
|
||||
refused = current(marks, service) in TERMINAL_REFUSAL
|
||||
reactions = [(report, delay) for report, delay in report_reactions(marks, reports, pause_intervals)
|
||||
if delay is not None or not refused]
|
||||
if not reactions:
|
||||
return None
|
||||
report, delay = max(reactions, key=lambda item: math.inf if item[1] is None else item[1])
|
||||
metric, finding = time_metric(DDS_REPORT_REACTION, delay, limit_ms, completed=True)
|
||||
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
|
||||
detail = "отмечен не позже доклада" if delay == 0 else metric.fact
|
||||
fact = (f"статус «{label}» по докладу бригады «{report.crew}» "
|
||||
f"{REPORT_LABELS[report.phase]}: {detail}")
|
||||
if len(reactions) > 1:
|
||||
late = sum(item is None or item > limit_ms for _, item in reactions)
|
||||
fact += f"; с нарушением {late} из {len(reactions)} докладов"
|
||||
metric = metric.model_copy(update={
|
||||
"title": f"{service}: {metric.title}", "fact": fact, "service": service,
|
||||
})
|
||||
if finding is not None:
|
||||
finding = finding.model_copy(update={
|
||||
"summary": f"{service}: {finding.summary}", "fact": fact, "service": service,
|
||||
})
|
||||
return metric, finding
|
||||
|
||||
|
||||
def _finding(service: str, code: ErrorCode, summary: str, fact: str,
|
||||
norm: str | None = None) -> Finding:
|
||||
return Finding(
|
||||
service=service,
|
||||
code=code,
|
||||
source=FindingSource.DISPATCHER,
|
||||
summary=summary,
|
||||
fact=fact,
|
||||
norm=norm,
|
||||
ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
|
||||
ref=MEMO_REF,
|
||||
competency=(
|
||||
Competency.COMMUNICATION if code is ErrorCode.D5 else Competency.CARD
|
||||
),
|
||||
|
|
@ -91,6 +198,10 @@ def evaluate_dispatcher(
|
|||
reply_text: str = "",
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
phone_reports: list[PhoneReportRecord] | None = None,
|
||||
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||
report_reaction_enabled: bool = False,
|
||||
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
|
||||
) -> list[Finding]:
|
||||
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
|
||||
|
||||
|
|
@ -117,7 +228,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if missing_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: к статусу не добавлены основание и сведения",
|
||||
fact="не заполнены комментарии: " + ", ".join(missing_comment_statuses),
|
||||
norm="к каждой ручной отметке добавить основание и содержание полученных сведений",
|
||||
|
|
@ -132,7 +243,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if incomplete_comment_statuses:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: комментарии к статусам не разделяют основание и сведения",
|
||||
fact="неполные комментарии: " + ", ".join(incomplete_comment_statuses),
|
||||
norm="в каждом комментарии заполнить отдельные поля «Основание» и «Сведения»",
|
||||
|
|
@ -152,7 +263,7 @@ def evaluate_dispatcher(
|
|||
]
|
||||
if missing_recipients:
|
||||
findings.append(_finding(
|
||||
ErrorCode.D5,
|
||||
service, ErrorCode.D5,
|
||||
f"{service}: отдельный комментарий не указывает получателя сведений",
|
||||
fact="; ".join(missing_recipients)[:500],
|
||||
norm=(
|
||||
|
|
@ -167,7 +278,7 @@ def evaluate_dispatcher(
|
|||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
service, ErrorCode.D1,
|
||||
(f"{service}: карточка открыта с нарушением срока"
|
||||
if elapsed_ms is not None else f"{service}: карточка не открыта"),
|
||||
fact=(
|
||||
|
|
@ -184,7 +295,7 @@ def evaluate_dispatcher(
|
|||
if primary is None:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D1,
|
||||
service, ErrorCode.D1,
|
||||
f"{service}: статус реагирования не проставлен",
|
||||
fact="карточка осталась без первичного статуса",
|
||||
norm="проставить «Принята» или «Не принята» с комментарием",
|
||||
|
|
@ -197,7 +308,7 @@ def evaluate_dispatcher(
|
|||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D4,
|
||||
service, ErrorCode.D4,
|
||||
f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
|
||||
fact="причина отказа не указана",
|
||||
norm="комментарий к отказу обязателен",
|
||||
|
|
@ -209,7 +320,7 @@ def evaluate_dispatcher(
|
|||
if latest is ServiceStatus.DECLINED and expected_decision == "accept":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D3,
|
||||
service, ErrorCode.D3,
|
||||
f"{service}: отказ от происшествия, которое в её компетенции",
|
||||
fact="служба есть в списке оповещения по ЕКП",
|
||||
norm="принять согласно эталону сценария",
|
||||
|
|
@ -218,7 +329,7 @@ def evaluate_dispatcher(
|
|||
elif latest is ServiceStatus.ACCEPTED and expected_decision == "decline":
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: принято вопреки эталону сценария",
|
||||
fact="карточка принята службой",
|
||||
norm=expected_decision_reason or "отказать с указанной причиной",
|
||||
|
|
@ -232,7 +343,7 @@ def evaluate_dispatcher(
|
|||
if service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: «Принята» без назначения бригады и хода реагирования",
|
||||
fact="бригада не выбрана, после приёма статусов не было",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
|
|
@ -241,7 +352,7 @@ def evaluate_dispatcher(
|
|||
else:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: «Принята», но о реагировании ничего не отмечено",
|
||||
fact="после приёма статусов не было",
|
||||
norm="статус должен соответствовать фактическому состоянию заявки",
|
||||
|
|
@ -252,7 +363,7 @@ def evaluate_dispatcher(
|
|||
) and service not in crew_assignments:
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D2,
|
||||
service, ErrorCode.D2,
|
||||
f"{service}: ход реагирования без назначения бригады",
|
||||
fact="статусы хода работ проставлены, бригада не выбрана",
|
||||
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
|
||||
|
|
@ -262,15 +373,18 @@ def evaluate_dispatcher(
|
|||
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
|
||||
# это приводит к повторным звонкам и скрывает от других служб факт реакции.
|
||||
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
|
||||
reports = [report for report in phone_reports or () if report.service == service]
|
||||
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
|
||||
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
||||
and (missing_progress or latest is not ServiceStatus.COMPLETED)):
|
||||
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
|
||||
reported_done = any(report.phase == "completed" for report in reports)
|
||||
findings.append(
|
||||
_finding(
|
||||
ErrorCode.D6,
|
||||
service, ErrorCode.D6,
|
||||
f"{service}: ход реагирования не доведён до конца",
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"),
|
||||
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
|
||||
+ ("; бригада доложила о завершении работ" if reported_done else ""),
|
||||
norm=(
|
||||
"отметить по факту начало реагирования, прибытие, проведение работ "
|
||||
"и завершение; если работы не проводились — оформить отказ с причиной"
|
||||
|
|
@ -278,16 +392,25 @@ def evaluate_dispatcher(
|
|||
)
|
||||
)
|
||||
|
||||
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
|
||||
if report_reaction_enabled:
|
||||
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
|
||||
if reaction is not None and reaction[1] is not None:
|
||||
findings.append(reaction[1])
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def dispatcher_metrics(
|
||||
state,
|
||||
card,
|
||||
deadline_ms: int,
|
||||
expected_decision: str = "accept",
|
||||
expected_decision_reason: str | None = None,
|
||||
*,
|
||||
services: list[str] | None = None,
|
||||
report_reaction_ms: int = REPORT_REACTION_MS,
|
||||
report_reaction_enabled: bool = False,
|
||||
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
|
||||
) -> list[Metric]:
|
||||
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
|
||||
|
||||
|
|
@ -295,21 +418,21 @@ def dispatcher_metrics(
|
|||
источник информации, но не обязательный шлюз статуса.
|
||||
"""
|
||||
metrics: list[Metric] = []
|
||||
for service in (services if services is not None else state.managed_services()):
|
||||
marks = [mark for mark in state.status_log if mark.service == service]
|
||||
# Через таймер, а не `primary.at - dispatched_at`: календарная разница
|
||||
# включила бы паузу преподавателя в норматив (lct-39).
|
||||
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
for service in (services if services is not None else card.managed_services()):
|
||||
marks = [mark for mark in card.status_log if mark.service == service]
|
||||
statuses = {mark.status for mark in marks}
|
||||
|
||||
def add(key: str, title: str, passed: bool, fact: str, norm: str, weight: float = 1.0):
|
||||
metrics.append(Metric(
|
||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm,
|
||||
key=key, title=f"{service}: {title}", fact=fact, norm=norm, service=service,
|
||||
ref=GOST_REF if key == "dds_ack" else "памятка «Работа на АРМ-112», статусы реагирования",
|
||||
passed=passed, weight=weight,
|
||||
))
|
||||
|
||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||
# Время открытия — то, что зафиксировал таймер событием, а не разница
|
||||
# отметок: 30 с по ответу заказчика идут до открытия карточки.
|
||||
elapsed = state.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
add("dds_primary", "решение по карточке", primary is not None,
|
||||
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||
add("dds_ack", f"открытие карточки за {deadline_ms // 1000} с",
|
||||
|
|
@ -318,6 +441,13 @@ def dispatcher_metrics(
|
|||
f"≤ {deadline_ms // 1000} с")
|
||||
if primary is None:
|
||||
continue
|
||||
# Метрика есть только там, где бригада докладывала: без докладов
|
||||
# реагировать не на что, и отсутствие звонков не штраф.
|
||||
reports = [report for report in card.phone_reports if report.service == service]
|
||||
if report_reaction_enabled:
|
||||
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
|
||||
if reaction is not None:
|
||||
metrics.append(reaction[0])
|
||||
expected_status = (ServiceStatus.ACCEPTED if expected_decision == "accept"
|
||||
else ServiceStatus.DECLINED)
|
||||
expected_label = "Принята" if expected_decision == "accept" else "Не принята"
|
||||
|
|
@ -327,7 +457,7 @@ def dispatcher_metrics(
|
|||
expected_decision_reason or f"по эталону ожидается «{expected_label}»", 2.0)
|
||||
if primary.status is ServiceStatus.DECLINED or expected_decision == "decline":
|
||||
continue
|
||||
crew = state.crew_assignments.get(service)
|
||||
crew = card.crew_assignments.get(service)
|
||||
add("dds_crew", "назначение бригады", bool(crew),
|
||||
crew or "бригада не выбрана",
|
||||
"необходимую бригаду выбирает ДДС вручную", 2.0)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from html import escape
|
|||
from pathlib import Path
|
||||
|
||||
from app.domain.events import SessionReport
|
||||
from app.domain.taxonomy import Finding, FindingDecision, FindingSource
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
|
|
@ -18,12 +19,27 @@ def _cell(value: object) -> str:
|
|||
if value is None:
|
||||
return ""
|
||||
result = str(value)
|
||||
probe = result.lstrip(" \t\r\n\ufeff\u200b")
|
||||
probe = result.lstrip(" \t\r\n\N{ZERO WIDTH NO-BREAK SPACE}\N{ZERO WIDTH SPACE}")
|
||||
if probe.startswith(("=", "+", "-", "@")) or result.startswith(("\t", "\r", "\n")):
|
||||
return "'" + result
|
||||
return result
|
||||
|
||||
|
||||
def _finding_origin(item: Finding) -> str:
|
||||
if item.source is FindingSource.INSTRUCTOR:
|
||||
return f"; добавлена преподавателем {item.author or ''}".rstrip()
|
||||
return ""
|
||||
|
||||
|
||||
def _finding_decision(item: Finding) -> str:
|
||||
"""Решения по отметке рядом с ней, все по порядку: действует последнее."""
|
||||
return "".join(
|
||||
f"; {'снята' if review.decision is FindingDecision.DISMISSED else 'подтверждена'} "
|
||||
f"преподавателем {review.author}: {review.reason}"
|
||||
for review in item.reviews
|
||||
)
|
||||
|
||||
|
||||
def to_csv(report: SessionReport) -> bytes:
|
||||
"""UTF-8 с BOM: кириллица открывается в Excel без выбора кодировки."""
|
||||
output = io.StringIO(newline="")
|
||||
|
|
@ -38,12 +54,16 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Занятие", "", "Режим", report.mode.value)
|
||||
row("Занятие", "", "Попытка", report.attempt)
|
||||
row("Критерии", "", "Лимит открытия карточки ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||
row("Критерии", "", "Лимит реакции на доклад бригады, с",
|
||||
report.criteria.dds_report_reaction_limit_seconds
|
||||
if report.criteria.dds_report_reaction_enabled else "не применяется")
|
||||
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_reviewed)
|
||||
row("Оценка", "", "Итоговая", report.score_final)
|
||||
row("Оценка", "", "Изменена преподавателем", report.overridden_by)
|
||||
row("Оценка", "", "Причина изменения", report.override_comment)
|
||||
|
|
@ -54,7 +74,9 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
f"Норматив: {item.norm}; результат: {'да' if item.passed else 'нет'}; "
|
||||
f"вес: {item.weight:g}{credit}; источник: {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 ''}")
|
||||
row("Ошибки", number, item.code.value, item.summary,
|
||||
f"Факт: {item.fact}; норматив: {item.norm or ''}; источник: {item.ref or ''}"
|
||||
f"{_finding_origin(item)}{_finding_decision(item)}")
|
||||
for number, item in enumerate(report.competencies, 1):
|
||||
row("Компетенции", number, item.competency, item.value)
|
||||
for number, item in enumerate(report.transcript, 1):
|
||||
|
|
@ -77,7 +99,7 @@ def to_csv(report: SessionReport) -> bytes:
|
|||
row("Сверка самооценки", "", label, ", ".join(values))
|
||||
for number, item in enumerate(report.notes, 1):
|
||||
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
|
||||
return ("\ufeff" + output.getvalue()).encode("utf-8")
|
||||
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _font_paths() -> tuple[Path, Path | None]:
|
||||
|
|
@ -151,6 +173,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
("Режим", report.mode.value),
|
||||
("Попытка", report.attempt),
|
||||
("Лимит открытия карточки ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||
("Лимит реакции на доклад бригады",
|
||||
f"{report.criteria.dds_report_reaction_limit_seconds} с"
|
||||
if report.criteria.dds_report_reaction_enabled else "не применяется"),
|
||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||
|
|
@ -160,6 +185,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
("Итоговая оценка", report.score_final),
|
||||
):
|
||||
story.append(Paragraph(f"<b>{escape(label)}:</b> {escape(str(value))}", base))
|
||||
# Когда итог правили вручную, без этой строки не понять, какой балл дал разбор.
|
||||
if report.score_reviewed is not None:
|
||||
story.append(Paragraph(f"<b>После разбора отметок:</b> {report.score_reviewed}", base))
|
||||
if report.overridden_by:
|
||||
story.append(Paragraph(f"<b>Оценка изменена:</b> {escape(report.overridden_by)}", base))
|
||||
if report.override_comment:
|
||||
|
|
@ -183,7 +211,9 @@ def to_pdf(report: SessionReport) -> bytes:
|
|||
story.append(p("Ошибок не выявлено", muted))
|
||||
for item in report.findings:
|
||||
story.append(p(f"{item.code.value} - {item.summary}"))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. Источник: {item.ref or 'не указан'}", muted))
|
||||
story.append(p(f"Факт: {item.fact}. Норматив: {item.norm or 'не указан'}. "
|
||||
f"Источник: {item.ref or 'не указан'}"
|
||||
f"{_finding_origin(item)}{_finding_decision(item)}", muted))
|
||||
|
||||
section("Компетенции")
|
||||
for item in report.competencies:
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class _Builder:
|
|||
norm=norm,
|
||||
ref=ref,
|
||||
competency=competency,
|
||||
metric_key=key,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -174,6 +175,7 @@ def evaluate(
|
|||
norm=f"эталонный вопрос: «{question}»" if question else "обязательный факт сценария",
|
||||
ref="чек-лист сценария",
|
||||
competency=Competency.INTERVIEW,
|
||||
metric_key="checklist_completeness",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -106,9 +106,11 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
|
|||
self_assessment_diff=difference,
|
||||
notes=[InstructorNoteShown.model_validate(note) for note in state.notes],
|
||||
score_auto=score.get("score_auto", 0.0),
|
||||
score_reviewed=score.get("score_reviewed"),
|
||||
score_final=score.get("score_final", score.get("score_auto", 0.0)),
|
||||
overridden_by=score.get("overridden_by"),
|
||||
override_comment=score.get("override_comment"),
|
||||
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
||||
if score.get("ai_coaching") else None),
|
||||
total_paused_ms=state.total_paused_ms,
|
||||
)
|
||||
|
|
|
|||
193
backend/app/scoring/review.py
Normal file
193
backend/app/scoring/review.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Разбор по отметкам: преподаватель подтверждает, снимает и добавляет отметку.
|
||||
|
||||
Баллы по-прежнему считает код. Преподаватель меняет входы — какие отметки
|
||||
действуют, — а не формулу: балл пересчитывается теми же метриками и весами.
|
||||
Связь «отметка → метрика» — методика `METRIC_MAP` в пределах карточки и службы;
|
||||
если генератор знает метрику точно, она записана в `Finding.metric_key`.
|
||||
|
||||
Функции чистые и работают со словарём сохранённой оценки: живое занятие,
|
||||
строка `Score` и архивный разбор меняются одним и тем же путём, как правка
|
||||
итога в `apply_score_override`.
|
||||
"""
|
||||
|
||||
from app.domain.events import Metric
|
||||
from app.domain.taxonomy import Finding, FindingDecision, FindingReview, FindingSource
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.gost import GostResult
|
||||
from app.scoring.taxonomy import INSTRUCTOR_METRIC, METRIC_MAP
|
||||
|
||||
__all__ = ["INSTRUCTOR_METRIC", "add_finding", "has_finding", "linked", "review_finding",
|
||||
"reviewed_metrics"]
|
||||
|
||||
|
||||
def _in_scope(finding: Finding, metric: Metric) -> bool:
|
||||
if finding.card != metric.card:
|
||||
return False
|
||||
return finding.service is None or metric.service is None or finding.service == metric.service
|
||||
|
||||
|
||||
def linked(finding: Finding, metric: Metric) -> bool:
|
||||
"""Отметка объясняет провал этой метрики."""
|
||||
if not _in_scope(finding, metric):
|
||||
return False
|
||||
if finding.metric_key is not None:
|
||||
return finding.metric_key == metric.key
|
||||
mapping = METRIC_MAP.get(metric.key)
|
||||
return mapping is not None and mapping[0] is finding.code
|
||||
|
||||
|
||||
def _dismissed(finding: Finding) -> bool:
|
||||
return finding.review is not None and finding.review.decision is FindingDecision.DISMISSED
|
||||
|
||||
|
||||
def _credit(metric: Metric) -> float:
|
||||
return metric.credit if metric.credit is not None else float(metric.passed)
|
||||
|
||||
|
||||
def reviewed_metrics(metrics: list[Metric], findings: list[Finding],
|
||||
instructor_weight: float = 1.0) -> list[Metric]:
|
||||
"""Метрики с учётом решений преподавателя.
|
||||
|
||||
Проваленная метрика засчитывается, только если все её автоматические
|
||||
отметки сняты. Ручная отметка проваливает связанные метрики; если их нет,
|
||||
появляется отдельная метрика преподавателя. Исходный вердикт остаётся в
|
||||
`passed_auto`/`credit_auto`, повторный пересчёт начинается с него.
|
||||
"""
|
||||
base = [
|
||||
metric.model_copy(update={
|
||||
"passed": metric.passed_auto if metric.passed_auto is not None else metric.passed,
|
||||
"credit": metric.credit_auto if metric.passed_auto is not None else metric.credit,
|
||||
"passed_auto": None,
|
||||
"credit_auto": None,
|
||||
})
|
||||
for metric in metrics
|
||||
if metric.key != INSTRUCTOR_METRIC
|
||||
]
|
||||
result: list[Metric] = []
|
||||
for metric in base:
|
||||
links = [finding for finding in findings if linked(finding, metric)]
|
||||
manual = [finding for finding in links
|
||||
if finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)]
|
||||
automatic = [finding for finding in links if finding.source is not FindingSource.INSTRUCTOR]
|
||||
if manual and _credit(metric) > 0:
|
||||
metric = metric.model_copy(update={
|
||||
"passed": False, "credit": 0.0,
|
||||
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||
})
|
||||
elif (not manual and _credit(metric) < 1 and automatic
|
||||
and all(_dismissed(finding) for finding in automatic)):
|
||||
metric = metric.model_copy(update={
|
||||
"passed": True, "credit": 1.0,
|
||||
"passed_auto": metric.passed, "credit_auto": metric.credit,
|
||||
})
|
||||
result.append(metric)
|
||||
|
||||
for finding in findings:
|
||||
if (finding.source is FindingSource.INSTRUCTOR and not _dismissed(finding)
|
||||
and not any(linked(finding, metric) for metric in base)):
|
||||
result.append(Metric(
|
||||
key=INSTRUCTOR_METRIC,
|
||||
title=f"Отметка преподавателя: {finding.summary}",
|
||||
fact=finding.fact,
|
||||
norm=finding.norm or "по решению преподавателя",
|
||||
ref=finding.ref,
|
||||
passed=False,
|
||||
weight=instructor_weight,
|
||||
card=finding.card,
|
||||
service=finding.service,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _instructor_weight(report: dict) -> float:
|
||||
"""Вес метрики преподавателя из весов занятия: веса настраивает преподаватель."""
|
||||
criteria = (report.get("full_report") or {}).get("criteria") or {}
|
||||
return float((criteria.get("score_weights") or {}).get(INSTRUCTOR_METRIC, 1.0))
|
||||
|
||||
|
||||
def _codes(findings: list[Finding]) -> dict[str, int]:
|
||||
"""Коды действующих отметок: по ним строятся частые ошибки группы и профиль."""
|
||||
codes: dict[str, int] = {}
|
||||
for finding in findings:
|
||||
if not _dismissed(finding):
|
||||
codes[finding.code.value] = codes.get(finding.code.value, 0) + 1
|
||||
return codes
|
||||
|
||||
|
||||
def _cards(cards: list[dict], metrics: list[Metric], findings: list[Finding]) -> list[dict]:
|
||||
"""Карточки очереди ДДС с тем же разбором: их метрики — часть метрик занятия."""
|
||||
updated = []
|
||||
for number, card in enumerate(cards, 1):
|
||||
own = [metric for metric in metrics if metric.card == number]
|
||||
updated.append({
|
||||
**card,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in own],
|
||||
"findings": [finding.model_dump(mode="json")
|
||||
for finding in findings if finding.card == number],
|
||||
"score_reviewed": GostResult(metrics=own).score,
|
||||
})
|
||||
return updated
|
||||
|
||||
|
||||
def _rescore(report: dict, findings: list[Finding]) -> dict:
|
||||
metrics = reviewed_metrics(
|
||||
[Metric.model_validate(item) for item in report.get("metrics", [])], findings,
|
||||
_instructor_weight(report),
|
||||
)
|
||||
score = GostResult(metrics=metrics).score
|
||||
fields = {
|
||||
"findings": [finding.model_dump(mode="json") for finding in findings],
|
||||
"metrics": [metric.model_dump(mode="json") for metric in metrics],
|
||||
"competencies": [item.model_dump() for item in radar(metrics)],
|
||||
"score_reviewed": score,
|
||||
}
|
||||
# Правка итога — последнее слово преподавателя; решение по отметке её не отменяет.
|
||||
if not report.get("overridden_by"):
|
||||
fields["score_final"] = score
|
||||
updated = {**report, **fields}
|
||||
if "summary" in report:
|
||||
updated["summary"] = {**(report.get("summary") or {}), "codes": _codes(findings)}
|
||||
if report.get("card_results"):
|
||||
updated["card_results"] = _cards(report["card_results"], metrics, findings)
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
failed = sum(not metric.passed and metric.weight > 0 for metric in metrics)
|
||||
allowed = (archived.get("criteria") or {}).get("allowed_errors", 0)
|
||||
updated["full_report"] = {
|
||||
**archived, **fields,
|
||||
"card_results": _cards(archived.get("card_results") or [], metrics, findings),
|
||||
"failed_metrics": failed,
|
||||
"passed": failed <= allowed,
|
||||
"score_final": updated.get("score_final", archived.get("score_final")),
|
||||
}
|
||||
return updated
|
||||
|
||||
|
||||
def _findings(report: dict) -> list[Finding]:
|
||||
return [Finding.model_validate(item) for item in report.get("findings", [])]
|
||||
|
||||
|
||||
def review_finding(report: dict, index: int, review: FindingReview) -> dict:
|
||||
"""Подтвердить или снять отметку. Решение дописывается, действует последнее."""
|
||||
findings = _findings(report)
|
||||
if not 0 <= index < len(findings):
|
||||
raise LookupError(f"нет отметки {index}")
|
||||
findings[index] = findings[index].model_copy(
|
||||
update={"reviews": [*findings[index].reviews, review]})
|
||||
return _rescore(report, findings)
|
||||
|
||||
|
||||
def has_finding(report: dict, client_id) -> bool:
|
||||
"""Ручная отметка с этим ключом уже в разборе — повтор запроса."""
|
||||
return client_id is not None and any(
|
||||
finding.client_id == client_id for finding in _findings(report))
|
||||
|
||||
|
||||
def add_finding(report: dict, finding: Finding) -> dict:
|
||||
"""Добавить отметку преподавателя; она штрафует связанную метрику."""
|
||||
if finding.source is not FindingSource.INSTRUCTOR:
|
||||
raise ValueError("вручную добавляется только отметка преподавателя")
|
||||
# Повтор (ретрай сети, вторая вкладка) не штрафует второй раз.
|
||||
if has_finding(report, finding.client_id):
|
||||
return report
|
||||
return _rescore(report, [*_findings(report), finding])
|
||||
|
|
@ -31,10 +31,16 @@ 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_report_reaction": (ErrorCode.D6, Competency.NORMS),
|
||||
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
|
||||
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
|
||||
}
|
||||
|
||||
#: Метрика ручной отметки, которой нечего провалить (у кода нет метрики в
|
||||
#: METRIC_MAP или в карточке она не считалась). Кода у неё нет: код несёт сама
|
||||
#: отметка. Вес задаёт преподаватель в весах занятия, по умолчанию 1.
|
||||
INSTRUCTOR_METRIC = "instructor_finding"
|
||||
|
||||
#: Вес метрики в детерминированной оценке.
|
||||
#:
|
||||
#: **Предварительные значения, требуют утверждения методистом.** Без весов все
|
||||
|
|
@ -59,4 +65,5 @@ METRIC_WEIGHTS: dict[str, float] = {
|
|||
"description_grammar": 1.0,
|
||||
"card_fill_time": 1.5,
|
||||
"dds_work_time": 1.5,
|
||||
"dds_report_reaction": 1.5,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ def time_credit(elapsed_ms: int | None, limit_ms: int) -> float:
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class TimeNorm:
|
||||
"""Формулировки норматива времени: карточка 112 и карточка ДДС различаются
|
||||
только словами, расчёт и отметка E3 у них общие."""
|
||||
"""Формулировки норматива времени: нормативы различаются словами и кодом
|
||||
отметки, расчёт вклада и формат факта у них общие."""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
|
|
@ -32,6 +32,10 @@ class TimeNorm:
|
|||
action: str
|
||||
unmeasured: str
|
||||
incomplete: str
|
||||
code: ErrorCode = ErrorCode.E3
|
||||
source: FindingSource = FindingSource.TIMERS
|
||||
competency: Competency = Competency.NORMS
|
||||
ref: str = TIME_REF
|
||||
|
||||
|
||||
CARD_FILL_TIME = TimeNorm(
|
||||
|
|
@ -55,7 +59,7 @@ DDS_WORK_TIME = TimeNorm(
|
|||
def time_metric(
|
||||
norm: TimeNorm, elapsed_ms: int | None, limit_ms: int, *, completed: bool
|
||||
) -> tuple[Metric, Finding | None]:
|
||||
"""Метрика норматива и отметка E3, если он не выполнен.
|
||||
"""Метрика норматива и отметка (по умолчанию E3), если он не выполнен.
|
||||
|
||||
Незавершённая работа не получает временного вклада: быстрое, но брошенное
|
||||
заполнение не должно выглядеть скоростью.
|
||||
|
|
@ -64,13 +68,20 @@ def time_metric(
|
|||
fact = norm.unmeasured
|
||||
else:
|
||||
delta_ms = elapsed_ms - limit_ms
|
||||
# Превышение до полусекунды при округлении до целых читалось бы
|
||||
# как «45 с (+0 с)» при нормативе 45 с — тогда нужна десятая доля.
|
||||
precise = 0 < delta_ms <= 500
|
||||
|
||||
def seconds(ms: int) -> str:
|
||||
return f"{ms / 1000:.1f}" if precise else f"{round(ms / 1000)}"
|
||||
|
||||
if delta_ms > 0:
|
||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
||||
deviation = f"+{seconds(delta_ms)} с сверх норматива"
|
||||
elif delta_ms < 0:
|
||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||
else:
|
||||
deviation = "точно в норматив"
|
||||
fact = f"{round(elapsed_ms / 1000)} с ({deviation})"
|
||||
fact = f"{seconds(elapsed_ms)} с ({deviation})"
|
||||
if not completed:
|
||||
fact = f"{norm.incomplete}; {fact}"
|
||||
norm_text = f"{norm.action} за {round(limit_ms / 1000)} с"
|
||||
|
|
@ -80,7 +91,7 @@ def time_metric(
|
|||
title=norm.title,
|
||||
fact=fact,
|
||||
norm=norm_text,
|
||||
ref=TIME_REF,
|
||||
ref=norm.ref,
|
||||
passed=passed,
|
||||
weight=METRIC_WEIGHTS[norm.key],
|
||||
credit=time_credit(elapsed_ms, limit_ms) if completed else 0.0,
|
||||
|
|
@ -88,11 +99,12 @@ def time_metric(
|
|||
if passed:
|
||||
return metric, None
|
||||
return metric, Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
code=norm.code,
|
||||
source=norm.source,
|
||||
summary=norm.summary,
|
||||
fact=fact,
|
||||
norm=norm_text,
|
||||
ref=TIME_REF,
|
||||
competency=Competency.NORMS,
|
||||
ref=norm.ref,
|
||||
competency=norm.competency,
|
||||
metric_key=norm.key,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -597,8 +597,13 @@ class DdsDesk(BaseModel):
|
|||
self.finish_work()
|
||||
outcome.finished = True
|
||||
case "card.bounce":
|
||||
# Возврата карточки на этапе принятия нет; об ошибке, которую
|
||||
# вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
|
||||
# (ответ заказчика, П.3).
|
||||
return _refused(
|
||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
|
||||
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
|
||||
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
|
||||
"в 112 по телефону"
|
||||
)
|
||||
case "zone.decision":
|
||||
previous = next(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.ai_coach import coach
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
|
|
@ -25,10 +25,14 @@ from app.session.hub import hub
|
|||
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
LessonEnded,
|
||||
LessonResumed,
|
||||
ScoreArchived,
|
||||
ScoreCalculated,
|
||||
ScoreOverridden,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
|
||||
|
|
@ -44,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
services = state.card_services(card)
|
||||
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = card.scenario.dds_decision
|
||||
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
findings = evaluate_dispatcher(
|
||||
entries=card.status_log,
|
||||
services=services,
|
||||
|
|
@ -53,10 +58,16 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
reply_text=card.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
phone_reports=card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=state.pause_intervals,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
||||
services=services,
|
||||
services=services, report_reaction_ms=reaction_ms,
|
||||
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=state.pause_intervals,
|
||||
)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||
|
|
@ -94,9 +105,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
card_id=card.card_id,
|
||||
scenario_id=card.scenario.id,
|
||||
reply_text=card.reply_text,
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
|
||||
"card": number})
|
||||
for metric in weighted.metrics],
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
|
||||
"card": number})
|
||||
for finding in weighted.findings],
|
||||
actions=actions,
|
||||
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
||||
|
|
@ -175,7 +188,14 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
|||
"""
|
||||
if state.ended:
|
||||
return
|
||||
was_paused = state.paused
|
||||
paused_ms_before = state.total_paused_ms
|
||||
ended_at = state.end(reason)
|
||||
if was_paused:
|
||||
hub.record(session_id, LessonResumed(
|
||||
at=ended_at, author="система", role="system",
|
||||
paused_ms=state.total_paused_ms - paused_ms_before,
|
||||
))
|
||||
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
||||
state.on_event("card.end")
|
||||
# Норматив отработки фиксируется событием, а не текущим значением часов.
|
||||
|
|
@ -194,6 +214,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
|||
hub.to_station(session_id, SessionEnded(reason=reason))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=reason))
|
||||
if was_paused:
|
||||
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
|
||||
# на паузе правки карточки в уже закрытое занятие.
|
||||
hub.broadcast(session_id, SessionPaused(paused=False))
|
||||
hub.to_station(session_id, SessionPaused(paused=False))
|
||||
if state.score is None:
|
||||
return
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
|
|
@ -228,3 +253,14 @@ def override_score(state, override: ScoreOverridden) -> None:
|
|||
hub.record(state.session_id, override)
|
||||
state.score = apply_score_override(state.score, override)
|
||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||
|
||||
|
||||
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
|
||||
"""Решение преподавателя по отметке — тем же путём, что правка итога.
|
||||
|
||||
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
|
||||
разбор и строка аудита уходят одним commit.
|
||||
"""
|
||||
state.score = apply_finding_change(state.score, change)
|
||||
hub.record(state.session_id, change)
|
||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.dds_phase:
|
||||
if state.dds_phase and not state.paused:
|
||||
async with self.operation(session_id) as op:
|
||||
active_before = state.desk.active_id
|
||||
op.persist = bool(state.desk.deliver_due())
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ 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
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonIdentity,
|
||||
LessonPaused,
|
||||
LessonRequest,
|
||||
LessonResumed,
|
||||
LessonStarted,
|
||||
NoteAdded,
|
||||
Record,
|
||||
|
|
@ -42,8 +46,10 @@ from app.session.store import (
|
|||
SelfAssessed,
|
||||
SessionLeaseLost,
|
||||
UtteranceAppended,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
from app.scoring.review import has_finding
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
|
@ -196,6 +202,16 @@ class PostgresSessionStore:
|
|||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
|
||||
))
|
||||
case LessonPaused(at=at, author=author, role=role):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.pause",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}",
|
||||
))
|
||||
case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.resume",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
|
||||
))
|
||||
case LessonStarted(at=at):
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id).values(started_at=at)
|
||||
|
|
@ -239,6 +255,40 @@ class PostgresSessionStore:
|
|||
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||
f"comment_chars={len(record.comment)}"),
|
||||
))
|
||||
case FindingReviewed() | FindingAdded():
|
||||
await db.flush()
|
||||
score = await db.scalar(
|
||||
select(Score).where(Score.session_id == session_id).with_for_update()
|
||||
)
|
||||
if score is None:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
|
||||
if (isinstance(record, FindingAdded)
|
||||
and has_finding(score.report or {}, record.finding.client_id)):
|
||||
return
|
||||
before = score.score_final
|
||||
findings = (score.report or {}).get("findings", [])
|
||||
score.report = apply_finding_change(dict(score.report or {}), record)
|
||||
score.score_final = score.report.get("score_final", score.score_final)
|
||||
if isinstance(record, FindingReviewed):
|
||||
code = (findings[record.index].get("code", "?")
|
||||
if 0 <= record.index < len(findings) else "?")
|
||||
actor, action = record.review.author, "finding.review"
|
||||
what = (f"#{record.index} {code} {record.review.decision.value}; "
|
||||
f"причина: {record.review.reason}")
|
||||
else:
|
||||
finding = record.finding
|
||||
actor, action = finding.author or "instructor", "finding.add"
|
||||
where = f", карточка {finding.card}" if finding.card is not None else ""
|
||||
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
|
||||
f"норма: {finding.norm or ''}")
|
||||
# Решение по отметке меняет чужой балл: аудит сам по себе должен
|
||||
# ответить «кто, когда, что и почему», даже если разбор потом правили.
|
||||
db.add(AuditLog(
|
||||
actor=actor, role=record.role, action=action,
|
||||
object_id=str(session_id),
|
||||
detail=f"{what}; {before} → {score.score_final}",
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
|
||||
|
|
@ -68,6 +69,7 @@ class ScoringSnapshot:
|
|||
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
|
||||
call_card: DdsLiveCard | None
|
||||
call_card_services: tuple[str, ...]
|
||||
pause_intervals: tuple[tuple[datetime, datetime], ...]
|
||||
|
||||
@property
|
||||
def reports_cards(self) -> bool:
|
||||
|
|
@ -112,6 +114,7 @@ class ScoringSnapshot:
|
|||
call_card=live_card.model_copy(deep=True) if live_card is not None else None,
|
||||
call_card_services=(tuple(state.card_services(live_card))
|
||||
if live_card is not None else ()),
|
||||
pause_intervals=tuple(state.pause_intervals),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -210,6 +213,7 @@ async def score_session(
|
|||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
metric_key="description_grammar",
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
|
|
@ -218,6 +222,7 @@ async def score_session(
|
|||
if live_card is not None:
|
||||
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
||||
services = list(snapshot.call_card_services)
|
||||
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
result.findings.extend(evaluate_dispatcher(
|
||||
entries=live_card.status_log,
|
||||
services=services,
|
||||
|
|
@ -227,10 +232,16 @@ async def score_session(
|
|||
reply_text=live_card.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
phone_reports=live_card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=snapshot.pause_intervals,
|
||||
))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
live_card, decision_limit_ms, scenario.dds_decision.expected,
|
||||
scenario.dds_decision.reason, services=services,
|
||||
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
|
||||
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=snapshot.pause_intervals,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ class PersistedSession(BaseModel):
|
|||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
end_reason: CallEndReason | None = None
|
||||
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
|
||||
paused: bool = False
|
||||
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
|
||||
paused_at: datetime | None = None
|
||||
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
|
||||
total_paused_ms: int = 0
|
||||
#: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
|
||||
pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
|
||||
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
||||
dispatched_card: KIO | None = None
|
||||
dispatched_at: datetime | None = None
|
||||
|
|
@ -190,6 +198,38 @@ class SessionState(PersistedSession):
|
|||
"""Единственная точка, где событие двигает таймеры."""
|
||||
self.timers.on_event(event_type)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
|
||||
if self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.pause(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.pause(now)
|
||||
self.paused = True
|
||||
self.paused_at = now_utc()
|
||||
if self.voice is not None:
|
||||
self.voice.pause()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
||||
if not self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.resume(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.resume(now)
|
||||
if self.paused_at is not None:
|
||||
resumed_at = now_utc()
|
||||
idle = resumed_at - self.paused_at
|
||||
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
|
||||
if resumed_at > self.paused_at:
|
||||
self.pause_intervals.append((self.paused_at, resumed_at))
|
||||
if self.desk.next_arrival_at is not None:
|
||||
self.desk.next_arrival_at += idle
|
||||
self.paused = False
|
||||
self.paused_at = None
|
||||
|
||||
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
||||
entry = TranscriptEntry(
|
||||
ref=f"u{len(self.transcript) + 1}",
|
||||
|
|
@ -331,14 +371,15 @@ class SessionState(PersistedSession):
|
|||
services = self._managed_services_for(recipients)
|
||||
log = list(card.status_log) if card is not None else []
|
||||
statuses = {service: current(log, service) for service in services}
|
||||
now_mono = time.monotonic()
|
||||
overdue = False
|
||||
if card is not None:
|
||||
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
# Через таймер, а не через `dispatched_at`: часы разошлись бы с
|
||||
# паузой, которая держит `current_ms` на месте (lct-39).
|
||||
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
|
||||
limit = card.timers.limits[TimerCode.DDS_ACK]
|
||||
overdue = elapsed is None and (
|
||||
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
|
||||
)
|
||||
now_mono = time.monotonic()
|
||||
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
|
||||
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
for item in (desk.ordered() if self.dds_phase else []):
|
||||
recipients_for_card = list(item.dispatched_card.notify)
|
||||
|
|
@ -409,6 +450,7 @@ class SessionState(PersistedSession):
|
|||
if desk.next_arrival_at is not None else None
|
||||
),
|
||||
max_waiting_cards=desk.max_waiting,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -431,7 +473,14 @@ class SessionState(PersistedSession):
|
|||
return self.ended_at is not None
|
||||
|
||||
def end(self, reason: CallEndReason) -> datetime:
|
||||
"""Закрыть занятие; возвращает момент окончания для журнала."""
|
||||
"""Закрыть занятие; возвращает момент окончания для журнала.
|
||||
|
||||
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
|
||||
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
|
||||
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
|
||||
потеряет последний, ещё не закрытый интервал паузы (lct-39).
|
||||
"""
|
||||
self.resume()
|
||||
self.ended_at = now_utc()
|
||||
self.end_reason = reason
|
||||
return self.ended_at
|
||||
|
|
@ -457,4 +506,5 @@ class SessionState(PersistedSession):
|
|||
timers=self.shown_timers().snapshot(),
|
||||
hints_used=len(self.hints_shown),
|
||||
ended=self.ended,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from typing import Protocol
|
|||
from uuid import UUID
|
||||
|
||||
from app.domain.events import TranscriptEntry
|
||||
from app.domain.taxonomy import Finding, FindingReview
|
||||
from app.scoring.review import add_finding, review_finding
|
||||
from app.session.checkpoint import dump_state
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
|
@ -83,8 +85,47 @@ class ScoreOverridden:
|
|||
comment: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonPaused:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingReviewed:
|
||||
"""Преподаватель подтвердил или снял отметку разбора."""
|
||||
index: int
|
||||
review: FindingReview
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonResumed:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
#: Длительность этой конкретной паузы — видно в аудите без пересчёта.
|
||||
paused_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingAdded:
|
||||
"""Преподаватель добавил свою отметку (`source=instructor`)."""
|
||||
finding: Finding
|
||||
role: str
|
||||
|
||||
|
||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
|
||||
|
||||
|
||||
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
|
||||
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
|
||||
if isinstance(change, FindingReviewed):
|
||||
return review_finding(report, change.index, change.review)
|
||||
return add_finding(report, change.finding)
|
||||
|
||||
|
||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||
|
|
@ -200,6 +241,10 @@ class MemorySessionStore:
|
|||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
||||
elif isinstance(record, FindingReviewed | FindingAdded):
|
||||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
|
||||
self.records.setdefault(session_id, []).extend(records)
|
||||
|
||||
def snapshot(self, session_id: UUID) -> dict | None:
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ class Timer(BaseModel):
|
|||
elapsed_ms: int = 0
|
||||
attempt: int = 1
|
||||
stopped: bool = False
|
||||
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
|
||||
#: считает новой попыткой, в отличие от `stopped` (lct-39).
|
||||
paused: bool = False
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
||||
|
|
@ -104,13 +107,20 @@ class Timer(BaseModel):
|
|||
data = dict(data)
|
||||
elapsed = max(0, int(data.get("elapsed_ms", 0)))
|
||||
stopped = bool(data.get("stopped"))
|
||||
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
|
||||
paused = bool(data.get("paused"))
|
||||
# На паузе занятие не простаивало без присмотра — оно ждало
|
||||
# преподавателя, и это время не досчитывается таймеру при перезапуске.
|
||||
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
|
||||
started = data.pop("started")
|
||||
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
|
||||
data["elapsed_ms"] = elapsed if stopped else 0
|
||||
data["elapsed_ms"] = elapsed if (stopped or paused) else 0
|
||||
return data
|
||||
|
||||
def start(self, now: float) -> None:
|
||||
if self.paused:
|
||||
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
|
||||
# набранное время, а ход до `resume` посчитал бы паузу.
|
||||
return
|
||||
if self.stopped:
|
||||
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
|
||||
self.attempt += 1
|
||||
|
|
@ -129,6 +139,19 @@ class Timer(BaseModel):
|
|||
return self.elapsed_ms
|
||||
return int((now - self.started_at) * 1000)
|
||||
|
||||
def pause(self, now: float) -> None:
|
||||
"""Зафиксировать прошедшее время и остановить ход часов до `resume`."""
|
||||
if self.started_at is not None and not self.stopped:
|
||||
self.elapsed_ms = self.current_ms(now)
|
||||
self.started_at = None
|
||||
self.paused = True
|
||||
|
||||
def resume(self, now: float) -> None:
|
||||
"""Продолжить с той же отметки — простой в счёт не идёт."""
|
||||
if self.paused:
|
||||
self.started_at = now - self.elapsed_ms / 1000
|
||||
self.paused = False
|
||||
|
||||
|
||||
class SessionTimers(BaseModel):
|
||||
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
||||
|
|
@ -146,6 +169,16 @@ class SessionTimers(BaseModel):
|
|||
for code in STOPS.get(event_type, ()):
|
||||
self.timers.setdefault(code, Timer(code=code)).stop(now)
|
||||
|
||||
def pause(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.pause(now)
|
||||
|
||||
def resume(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.resume(now)
|
||||
|
||||
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
|
||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ class VoiceSession:
|
|||
# ── вход: кадры микрофона ──
|
||||
|
||||
def feed(self, frame: bytes) -> None:
|
||||
if self.state.paused:
|
||||
return
|
||||
for event in self._vad.push(frame):
|
||||
if isinstance(event, SpeechStarted) and self.speaking:
|
||||
self.barge_in()
|
||||
|
|
@ -104,6 +106,8 @@ class VoiceSession:
|
|||
первая фраза «Алло! Помогите!» — ровно та, которую оператор перебивает
|
||||
чаще всего, — не гасилась вовсе.
|
||||
"""
|
||||
if self.state.paused:
|
||||
return asyncio.create_task(asyncio.sleep(0))
|
||||
if self.speaking:
|
||||
self._reply.cancel()
|
||||
self._reply = asyncio.create_task(self.say(text, mood))
|
||||
|
|
@ -119,6 +123,17 @@ class VoiceSession:
|
|||
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="barge_in"))
|
||||
log.info("сессия %s: перебивание", self.session_id)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Прервать текущую реплику и убрать речь, накопленную до паузы."""
|
||||
if self.speaking:
|
||||
self._reply.cancel()
|
||||
if self._utterance_id is not None:
|
||||
self.send_event(TtsCancel(utterance_id=self._utterance_id, reason="director"))
|
||||
self._utterance_id = None
|
||||
while not self._queue.empty():
|
||||
self._queue.get_nowait()
|
||||
self._vad.reset()
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in (self._reply, self._worker):
|
||||
if task is not None:
|
||||
|
|
@ -129,6 +144,8 @@ class VoiceSession:
|
|||
async def _work(self) -> None:
|
||||
while True:
|
||||
audio, ended_at = await self._queue.get()
|
||||
if self.state.paused:
|
||||
continue
|
||||
# Новая фраза оператора, пока звонящий ещё говорит, — тоже перебивание.
|
||||
if self.speaking:
|
||||
self.barge_in()
|
||||
|
|
@ -143,6 +160,8 @@ class VoiceSession:
|
|||
timing = TurnTiming()
|
||||
started = time.monotonic()
|
||||
text = await self.models.transcribe(audio)
|
||||
if self.state.paused:
|
||||
return
|
||||
timing.stt_ms = (time.monotonic() - started) * 1000
|
||||
if not text:
|
||||
return
|
||||
|
|
@ -155,6 +174,8 @@ class VoiceSession:
|
|||
|
||||
started = time.monotonic()
|
||||
line = await self._caller_line(text)
|
||||
if self.state.paused:
|
||||
return
|
||||
timing.caller_ms = (time.monotonic() - started) * 1000
|
||||
await self.say(line.text, line.mood, ended_at=ended_at, timing=timing)
|
||||
|
||||
|
|
@ -171,6 +192,8 @@ class VoiceSession:
|
|||
self, text: str, mood: Mood, *, ended_at: float | None = None, timing: TurnTiming | None = None
|
||||
) -> None:
|
||||
"""Произнести реплику: событие с текстом, звук по предложениям, ожидание конца."""
|
||||
if self.state.paused:
|
||||
return
|
||||
self._utterance_id = utterance_id = uuid4()
|
||||
entry = self.state.append(Speaker.CALLER, text, mood)
|
||||
self.send_event(CallerUtterance(utterance_id=utterance_id, text=text, at=entry.at, mood=mood))
|
||||
|
|
@ -186,6 +209,8 @@ class VoiceSession:
|
|||
pcm = await self._first_sentence(sentence, mood, ended_at, timing)
|
||||
else:
|
||||
pcm = await self.synthesize(sentence)
|
||||
if self.state.paused:
|
||||
return
|
||||
if index == 0 and timing is not None:
|
||||
timing.tts_first_ms = (time.monotonic() - synth_started) * 1000
|
||||
if not pcm:
|
||||
|
|
@ -205,6 +230,7 @@ class VoiceSession:
|
|||
# Ждём, пока курсант дослушает: перебивание в это время отменит задачу.
|
||||
await asyncio.sleep(max(0.0, playback_ends - time.monotonic()))
|
||||
self.send_event(TtsEnd(utterance_id=utterance_id))
|
||||
self._utterance_id = None
|
||||
|
||||
async def _first_sentence(
|
||||
self, sentence: str, mood: Mood, ended_at: float, timing: TurnTiming | None
|
||||
|
|
@ -212,17 +238,25 @@ class VoiceSession:
|
|||
"""Первое предложение с филлером: если к секунде после конца фразы звука
|
||||
ещё нет, звонящий «переспрашивает», а ответ встанет в очередь за ним."""
|
||||
synthesis = asyncio.ensure_future(self.synthesize(sentence))
|
||||
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
|
||||
if remaining > 0:
|
||||
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
|
||||
if done:
|
||||
return synthesis.result()
|
||||
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
|
||||
if filler:
|
||||
self.send_audio(filler)
|
||||
if timing is not None:
|
||||
timing.filler = True
|
||||
return await synthesis
|
||||
try:
|
||||
remaining = FILLER_AFTER_S - (time.monotonic() - ended_at)
|
||||
if remaining > 0:
|
||||
done, _ = await asyncio.wait({synthesis}, timeout=remaining)
|
||||
if done:
|
||||
return synthesis.result()
|
||||
filler = await self.synthesize(FILLERS.get(mood, FILLERS[Mood.PANIC]))
|
||||
if filler and not self.state.paused:
|
||||
self.send_audio(filler)
|
||||
if timing is not None:
|
||||
timing.filler = True
|
||||
return await synthesis
|
||||
finally:
|
||||
if not synthesis.done():
|
||||
synthesis.cancel()
|
||||
try:
|
||||
await synthesis
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def synthesize(self, text: str) -> bytes:
|
||||
return await cached_synthesize(self.models, text)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,17 @@ class StreamingVad:
|
|||
def in_speech(self) -> bool:
|
||||
return self._in_speech
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Не склеивать фрагменты речи до и после паузы занятия."""
|
||||
self._state.fill(0)
|
||||
self._context.fill(0)
|
||||
self._pending = np.zeros(0, dtype=np.float32)
|
||||
self._preroll.clear()
|
||||
self._speech.clear()
|
||||
self._voiced_ms = 0
|
||||
self._silence_ms = 0
|
||||
self._in_speech = False
|
||||
|
||||
def _probability(self, window: np.ndarray) -> float:
|
||||
frame = np.concatenate([self._context, window])[None, :]
|
||||
output, self._state = self._session.run(
|
||||
|
|
|
|||
Loading…
Reference in a new issue