chore: обновить паузу занятия от main

# Conflicts:
#	backend/app/scoring/dispatcher.py
#	backend/app/session/store.py
This commit is contained in:
kaifarikman 2026-09-27 23:30:18 +03:00
commit 0d58878cbe
51 changed files with 4545 additions and 87 deletions

View file

@ -51,6 +51,8 @@ _INSTANCE = hashlib.sha256(
).hexdigest()
_generations: dict[str, int] = {}
_active_sockets: dict[str, weakref.WeakKeyDictionary] = {}
# Logins already revoked because their users row disappeared.
_vanished: set[str] = set()
AUTH_GENERATION_SYNC_SECONDS = 1.0
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
_generations_synced_at: float | None = None
@ -121,10 +123,13 @@ async def sync_generations() -> None:
# The local-only dev-token principal is synthetic, never stored in users;
# a DB watcher must not revoke its in-memory generation during test/demo
# flows that deliberately exercise account invalidation.
# The revoked generation stays cached: dropping it would make the next
# lookup fall back to 0 and re-accept cookies issued before the revocation.
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
for login in _generations.keys() - current.keys() - synthetic:
_vanished.intersection_update(_generations.keys() - current.keys())
for login in _generations.keys() - current.keys() - synthetic - _vanished:
invalidate_login(login)
_generations.pop(login, None)
_vanished.add(login)
global _generations_synced_at
_generations_synced_at = time.monotonic()

View file

@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
else None
),
}
@router.get("/services")
async def services() -> dict:
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
курсанту так же безопасно, как группы происшествий.
"""
data = ekp.catalog()
return {"source": data.source, "services": [item.model_dump() for item in data.services]}

View file

@ -23,14 +23,26 @@ 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, DdsQueueCard, StationSnapshot, current
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
@ -656,6 +668,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,

View file

@ -112,6 +112,7 @@ def _build_state(session_id: UUID, event, who, scenario, scenarios,
dds_service=identity.service or event.dds_service,
attempt=identity.attempt,
criteria=event.criteria,
socket_last_seen_at=now_utc(),
)
state.timers.limits[TimerCode.DDS_ACK] = event.criteria.decision_time_limit_seconds * 1000
state.timers.limits[TimerCode.CARD_FILL] = event.criteria.card_fill_time_limit_seconds * 1000

View file

@ -17,7 +17,7 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.api.ws.session import pump, run_command, session_socket
from app.api.ws.session import close_fenced, pump, run_command, session_socket
from app.domain.events import (
CallEndReason,
ErrorEvent,
@ -62,9 +62,14 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
entered = await session_socket(ws, session_id, (Role.INSTRUCTOR, Role.TRAINEE))
if entered is None:
return
_who, state = entered
who, state = entered
with hub.station(session_id) as queue:
async with hub.trainee_socket(
session_id, station=True, trainee=who.role is Role.TRAINEE,
) as queue:
if hub.is_lease_fenced(session_id):
await close_fenced(ws)
return
sender = asyncio.create_task(pump(ws, queue))
try:
# Карточка, переданная до подключения станции, не теряется:

View file

@ -120,6 +120,11 @@ class Settings(BaseSettings):
def limit_ms(self, code: TimerCode) -> int:
return self.timer_limits_ms.get(code, NORMATIVES[code].limit_ms)
# Пороги сигналов реестра живых занятий (пульт преподавателя).
signal_backlog_threshold: int = Field(default=4, ge=1)
signal_refusals_threshold: int = Field(default=2, ge=1)
signal_offline_window_seconds: int = Field(default=45, ge=1)
def validate_deployment_security(self) -> None:
"""Reject known development authentication defaults on a production app."""
if self.app_env != "production":

View file

@ -15,6 +15,9 @@ from pathlib import Path
from pydantic import BaseModel, Field
DATA = Path(__file__).parent / "ekp.json"
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
CATALOG = Path(__file__).parent / "services_catalog.json"
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
#: не показывается — книга помечает их прямо в первом признаке.
@ -89,6 +92,37 @@ def reference() -> Reference:
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
class CatalogService(BaseModel):
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
id: str
name: str
organization: str | None = None
#: «ДДС», если это явно указано в названии в каталоге.
service_level: str | None = None
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
source_reference: str
class Catalog(BaseModel):
source: str
transcription: str
license: str
services: list[CatalogService]
@lru_cache(maxsize=1)
def catalog() -> Catalog:
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
@lru_cache(maxsize=1)
def catalog_names() -> frozenset[str]:
return frozenset(service.name for service in catalog().services)
@lru_cache(maxsize=1)
def _by_code() -> dict[str, Incident]:
return {incident.code: incident for incident in reference().incidents}

View file

@ -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,8 @@ 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_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 +62,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()):
@ -715,6 +717,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):
@ -764,6 +772,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]
@ -803,6 +813,8 @@ 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

View file

@ -118,6 +118,13 @@ class KIO(BaseModel):
# ДДС. `notify` считается из признаков и правится только системой:
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
notify: list[str] = Field(default_factory=list, description="Список оповещения")
#: Службы, добавленные оператором вручную из каталога. Автоматический список
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
#: загрузку сохранённых снимков занятий и сданных карточек.
notify_extra: list[str] = Field(
default_factory=list, max_length=20, description="Дополнительно оповестить"
)
dds: DDSCode | None = Field(
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
)
@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
"okato", "address", "street", "building", "entrance", "floor",
"intercom_code", "description", "incident_group", "signs",
"incident_type", "victims_count", "is_emergency", "life_threat",
"evacuation_needed", "dds",
"evacuation_needed", "dds", "notify_extra",
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
"fire.people_inside", "fire.smoke_spread",
"police.offence_kind", "police.suspects", "police.suspect_fled",
@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
return KIO.model_validate(data)
class PatchRejected(ValueError):
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
def _check_catalog(names: Any) -> None:
"""Только службы каталога: свободный текст в адресатах карточки
на АРМ ДДС выглядел бы как настоящая служба."""
from app.domain import ekp
if not isinstance(names, list):
return # тип и лимит проверит модель
known = ekp.catalog_names()
unknown = [name for name in names if name not in known]
if unknown:
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
def _without_auto_services(card: KIO) -> KIO:
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
иначе на АРМ ДДС одна служба окажется в двух строках."""
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
if extra == card.notify_extra:
return card
return card.model_copy(update={"notify_extra": extra})
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
Недопустимое значение — `PatchRejected` или `ValidationError`.
"""
if "notify_extra" in fields:
_check_catalog(fields["notify_extra"])
data = card.model_dump()
for path, value in fields.items():
if path in READ_ONLY_FIELDS:
@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
touched = MODIFIER_FIELDS & set(fields)
return derive_incident(updated) if touched else updated
return _without_auto_services(derive_incident(updated) if touched else updated)

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -11,11 +11,14 @@
привязывает статус к звонку или SIP: сведения можно получить по любому рабочему каналу.
"""
import math
from app.domain.statuses import (
COMMENT_REQUIRED,
PRIMARY,
REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
current,
@ -23,19 +26,113 @@ 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],
) -> 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)
result.append((
report,
int((after - report.at).total_seconds() * 1000) if after is not None else None,
))
return result
def report_reaction(
service: str, marks: list[StatusEntry], reports: list[PhoneReportRecord], limit_ms: int,
) -> 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)
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 +188,8 @@ 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,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -117,7 +216,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 +231,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 +251,7 @@ def evaluate_dispatcher(
]
if missing_recipients:
findings.append(_finding(
ErrorCode.D5,
service, ErrorCode.D5,
f"{service}: отдельный комментарий не указывает получателя сведений",
fact="; ".join(missing_recipients)[:500],
norm=(
@ -166,7 +265,7 @@ def evaluate_dispatcher(
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -181,7 +280,7 @@ def evaluate_dispatcher(
if elapsed_ms is None or elapsed_ms > deadline_ms:
findings.append(
_finding(
ErrorCode.D1,
service, ErrorCode.D1,
f"{service}: первичный статус проставлен с нарушением срока",
fact=(
f"прошло {elapsed_ms // 1000} с"
@ -197,7 +296,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 +308,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 +317,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 +331,7 @@ def evaluate_dispatcher(
if service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята» без назначения бригады и хода реагирования",
fact="бригада не выбрана, после приёма статусов не было",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -241,7 +340,7 @@ def evaluate_dispatcher(
else:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: «Принята», но о реагировании ничего не отмечено",
fact="после приёма статусов не было",
norm="статус должен соответствовать фактическому состоянию заявки",
@ -252,7 +351,7 @@ def evaluate_dispatcher(
) and service not in crew_assignments:
findings.append(
_finding(
ErrorCode.D2,
service, ErrorCode.D2,
f"{service}: ход реагирования без назначения бригады",
fact="статусы хода работ проставлены, бригада не выбрана",
norm="необходимую бригаду выбирает ДДС; ход отмечается по факту",
@ -262,15 +361,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,6 +380,11 @@ def evaluate_dispatcher(
)
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
reaction = report_reaction(service, marks, reports, report_reaction_ms)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
return findings
@ -288,6 +395,7 @@ def dispatcher_metrics(
expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -304,7 +412,7 @@ def dispatcher_metrics(
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,
))
@ -318,6 +426,12 @@ def dispatcher_metrics(
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
reaction = report_reaction(service, marks, reports, report_reaction_ms)
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 "Не принята"

View file

@ -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,15 @@ 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)
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 +73,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 +98,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 +172,7 @@ 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} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
@ -160,6 +182,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 +208,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:

View file

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

View file

@ -106,6 +106,7 @@ 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"),

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

View file

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

View file

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

View file

@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
# Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY:
card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
session.consecutive_refusals + 1
if command.status is ServiceStatus.DECLINED
else 0
)
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:

View file

@ -26,11 +26,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,
)
@ -46,6 +49,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,
@ -55,10 +59,12 @@ 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,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services,
services=services, report_reaction_ms=reaction_ms,
)
weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
@ -101,9 +107,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)),
@ -247,3 +255,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))

View file

@ -24,6 +24,7 @@ from app.domain.events import (
)
from app.session.state import SessionState
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
from app.session.timers import now_utc
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
#: очередь ограничена, переполнение роняет соединение, а не сессию.
@ -53,6 +54,9 @@ class SessionHub:
self._observers: dict[UUID, set[asyncio.Queue]] = {}
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
self._stations: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
self._pending_adoptions: dict[UUID, SessionState] = {}
self._tickers: dict[UUID, asyncio.Task] = {}
# Задачи, порождённые внутри операции, наследуют контекст; после
# закрытия операции их события идут напрямую (`_Operation.open`).
@ -116,6 +120,7 @@ class SessionHub:
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self._pending_adoptions.pop(session_id, None)
self.stop_ticker(session_id)
def live_count(self) -> int:
@ -135,9 +140,10 @@ class SessionHub:
if not self.store.persistent:
return 0
restored = await self.store.restore_active()
adopted = 0
for state in restored:
self._adopt(state)
return len(restored)
adopted += await self._adopt(state)
return adopted
async def maintain_lease(self) -> None:
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
@ -154,6 +160,18 @@ class SessionHub:
log.warning("продление lease занятия %s не удалось", state.session_id,
exc_info=True)
await self.fence(state)
for state in list(self._pending_adoptions.values()):
try:
await self.store.commit(state)
except SessionLeaseLost:
self._pending_adoptions.pop(state.session_id, None)
except Exception:
log.warning("повторное сохранение занятия %s после takeover не удалось",
state.session_id, exc_info=True)
else:
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
try:
claimed = await self.store.claim_expired()
except Exception: # следующий оборот попробует снова
@ -163,17 +181,32 @@ class SessionHub:
current = self._sessions.get(state.session_id)
if current is not None and not current.lease_fenced:
continue
self._adopt(state)
await self._adopt(state)
async def supervise_lease(self, interval: float) -> None:
while True:
await asyncio.sleep(interval)
await self.maintain_lease()
def _adopt(self, state: SessionState) -> None:
async def _adopt(self, state: SessionState) -> bool:
self.stop_ticker(state.session_id)
if state.socket_connected_at_checkpoint:
# Разрыв произошёл при потере узла; старое время подключения не
# доказывает, что курсант отсутствовал всё это время.
state.socket_last_seen_at = now_utc()
state.socket_connected_at_checkpoint = False
try:
await self.store.commit(state)
except SessionLeaseLost:
return False
except Exception:
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
self._pending_adoptions[state.session_id] = state
return False
self._pending_adoptions.pop(state.session_id, None)
self.register(state)
self.start_ticker(state.session_id)
return True
async def save_all(self) -> None:
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
@ -285,6 +318,44 @@ class SessionHub:
def station(self, session_id: UUID):
return self._subscribe(self._stations, session_id)
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
state = self.get(session_id)
if state is None or state.ended or state.dds_phase != station:
return
async with self.operation(session_id):
state.socket_last_seen_at = now_utc()
present = self._trainee_stations if station else self._trainee_calls
state.socket_connected_at_checkpoint = bool(present.get(session_id))
@contextlib.asynccontextmanager
async def trainee_socket(
self, session_id: UUID, *, station: bool, trainee: bool,
) -> AsyncIterator[asyncio.Queue]:
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
registry = self._stations if station else self._trainees
presence = self._trainee_stations if station else self._trainee_calls
with self._subscribe(registry, session_id) as queue:
if trainee:
presence.setdefault(session_id, set()).add(queue)
try:
if trainee:
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
yield queue
finally:
if trainee:
presence[session_id].discard(queue)
if not presence[session_id]:
presence.pop(session_id)
try:
await self._record_presence(session_id, station=station)
except Exception:
if not self.is_lease_fenced(session_id):
raise
# ── вещание ──
@staticmethod
@ -314,6 +385,12 @@ class SessionHub:
def observer_count(self, session_id: UUID) -> int:
return len(self._observers.get(session_id, set()))
def station_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_stations.get(session_id))
def trainee_connected(self, session_id: UUID) -> bool:
return bool(self._trainee_calls.get(session_id))
# ── такт таймеров ──
def start_ticker(self, session_id: UUID) -> None:

View file

@ -29,6 +29,8 @@ 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,
@ -44,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
@ -251,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

View file

@ -210,6 +210,7 @@ async def score_session(
norm="грамматически корректное описание",
ref="критерий занятия; правила русского языка",
competency=Competency.COMMUNICATION,
metric_key="description_grammar",
))
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
@ -218,6 +219,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 +229,12 @@ 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,
))
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,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -130,6 +130,15 @@ class PersistedSession(BaseModel):
#: a lost WebSocket acknowledgement cannot apply an operation twice.
processed_station_commands: list[str] = Field(default_factory=list)
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
#: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
socket_last_seen_at: datetime | None = None
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
socket_connected_at_checkpoint: bool = False
@field_validator("processed_station_commands")
@classmethod
@ -241,6 +250,9 @@ class SessionState(PersistedSession):
self.dispatched_at = now_utc()
if self.exercise is Exercise.CALL:
self._receive_call_card()
if self.handoff_to_dds:
self.socket_last_seen_at = self.dispatched_at
self.socket_connected_at_checkpoint = False
return self.dispatched_card
def _receive_call_card(self) -> None:

View file

@ -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
@ -90,6 +92,14 @@ class LessonPaused:
role: str
@dataclass(frozen=True)
class FindingReviewed:
"""Преподаватель подтвердил или снял отметку разбора."""
index: int
review: FindingReview
role: str
@dataclass(frozen=True)
class LessonResumed:
at: datetime
@ -99,9 +109,23 @@ class LessonResumed:
paused_ms: int
@dataclass(frozen=True)
class FindingAdded:
"""Преподаватель добавил свою отметку (`source=instructor`)."""
finding: Finding
role: str
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
| LessonPaused | LessonResumed)
| 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:
@ -217,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: