Complete DDS training workflow and delivery package

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

View file

@ -0,0 +1,358 @@
"""Переносимый JSON-снимок незавершённого занятия.
Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не
содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса
они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются.
"""
import time
from datetime import UTC, datetime
from uuid import UUID
from fastapi.encoders import jsonable_encoder
from app.dialog.factory import build_caller
from app.dialog.persona import PersonaState
from app.dialog.runtime import get_embedder
from app.dialog.slots import SlotMachine
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
SessionMode,
TranscriptEntry,
)
from app.domain.kio import KIO
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
StatusEntry,
)
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers, Timer
CHECKPOINT_VERSION = 1
def _grammar(value: GrammarAssessment | None) -> dict | None:
if value is None:
return None
return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
def _dump_timers(timers: SessionTimers, now: float) -> dict:
return {
"limits": {code.value: limit for code, limit in timers.limits.items()},
"items": {
code.value: {
"elapsed_ms": timer.current_ms(now),
"attempt": timer.attempt,
"stopped": timer.stopped,
}
for code, timer in timers.timers.items()
},
}
def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
return {
"original_index": item.original_index,
"scenario": item.scenario.model_dump(mode="json"),
"kio": item.kio.model_dump(mode="json"),
"dispatched_card": item.dispatched_card.model_dump(mode="json"),
"dispatched_at": item.dispatched_at.isoformat(),
"timers": _dump_timers(item.timers, now),
"bounced_fields": item.bounced_fields,
"dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
"status_log": [entry.model_dump(mode="json") for entry in item.status_log],
"crew_selected": item.crew_selected,
"crew_assignments": item.crew_assignments,
"phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
"reply_text": item.reply_text,
"reply_grammar": _grammar(item.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
}
def dump_state(state: SessionState) -> dict:
"""Сериализовать только данные, необходимые для точного продолжения."""
state.capture_active_dds()
now = time.monotonic()
payload = {
"version": CHECKPOINT_VERSION,
"session_id": str(state.session_id),
"scenario_id": state.scenario_id,
"scenario_title": state.scenario_title,
"level": state.level,
"mode": state.mode.value,
"owner_login": state.owner_login,
"exercise": state.exercise.value,
"handoff_to_dds": state.handoff_to_dds,
"required_fields": state.required_fields,
"trainee_name": state.trainee_name,
"trainee_id": str(state.trainee_id) if state.trainee_id else None,
"dds_service": state.dds_service,
"attempt": state.attempt,
"criteria": state.criteria.model_dump(mode="json"),
"kio": state.kio.model_dump(mode="json"),
"transcript": [entry.model_dump(mode="json") for entry in state.transcript],
"timers": _dump_timers(state.timers, now),
"hints_shown": state.hints_shown,
"hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
"notes": state.notes,
"directives": state.directives,
"scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
"audio_frames": state.audio_frames,
"bad_frames": state.bad_frames,
"self_assessed": state.self_assessed,
"self_assessment": state.self_assessment,
"score": state.score,
"started_at": state.started_at.isoformat() if state.started_at else None,
"ended_at": state.ended_at.isoformat() if state.ended_at else None,
"end_reason": state.end_reason.value if state.end_reason else None,
"dispatched_card": (
state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
),
"dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
"bounced_fields": state.bounced_fields,
"dds_log": [
[action, at.isoformat(), detail] for action, at, detail in state.dds_log
],
"status_log": [item.model_dump(mode="json") for item in state.status_log],
"crew_selected": state.crew_selected,
"crew_assignments": state.crew_assignments,
"phone_reports": [item.model_dump(mode="json") for item in state.phone_reports],
"phone_lines": [item.model_dump(mode="json") for item in state.phone_lines],
"phone_pending": (
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
),
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
"dds_active_card_id": (
str(state.dds_active_card_id) if state.dds_active_card_id else None
),
"dds_card_index": state.dds_card_index,
"dds_arrival_interval_seconds": state.dds_arrival_interval_seconds,
"dds_max_waiting": state.dds_max_waiting,
"dds_next_scenario_index": state.dds_next_scenario_index,
"dds_next_arrival_at": (
state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None
),
"dds_completed": [
{
"card_id": str(item.card_id),
"scenario_id": item.scenario_id,
"reply_text": item.reply_text,
"metrics": [metric.model_dump(mode="json") for metric in item.metrics],
"findings": [finding.model_dump(mode="json") for finding in item.findings],
"actions": item.actions,
"duration_ms": item.duration_ms,
}
for item in state.dds_completed
],
"reply_text": state.reply_text,
"reply_grammar": _grammar(state.reply_grammar),
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
"resolved_outcome": state.resolved_outcome,
"resolve_comment": state.resolve_comment,
}
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
# получать только стандартные JSON-типы.
return jsonable_encoder(payload)
def _dt(value: str | None) -> datetime | None:
return datetime.fromisoformat(value) if value else None
def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
raw = payload.get("timers") or {}
limits = {
TimerCode(code): int(limit)
for code, limit in (raw.get("limits") or {}).items()
}
restored = SessionTimers(limits=limits or SessionTimers().limits)
now_mono = time.monotonic()
now_wall = datetime.now(UTC)
if saved_at.tzinfo is None:
saved_at = saved_at.replace(tzinfo=UTC)
downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
for raw_code, item in (raw.get("items") or {}).items():
code = TimerCode(raw_code)
stopped = bool(item.get("stopped"))
elapsed = max(0, int(item.get("elapsed_ms", 0)))
total = elapsed if stopped else elapsed + downtime_ms
restored.timers[code] = Timer(
code=code,
started_at=None if stopped else now_mono - total / 1000,
elapsed_ms=elapsed if stopped else 0,
attempt=max(1, int(item.get("attempt", 1))),
stopped=stopped,
)
return restored
def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
if not value:
return None
return GrammarAssessment(
passed=bool(value["passed"]),
errors=tuple(value.get("errors") or []),
source=value["source"],
)
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
return DdsLiveCard(
original_index=int(item["original_index"]),
scenario=Scenario.model_validate(item["scenario"]),
kio=KIO.model_validate(item["kio"]),
dispatched_card=KIO.model_validate(item["dispatched_card"]),
dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
timers=_restore_timers({"timers": item["timers"]}, saved_at),
bounced_fields=list(item.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in item.get("dds_log", [])],
status_log=[StatusEntry.model_validate(entry)
for entry in item.get("status_log", [])],
crew_selected=item.get("crew_selected"),
crew_assignments=dict(item.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(entry)
for entry in item.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(entry)
for entry in item.get("phone_lines", [])],
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
if item.get("phone_pending") else None),
reply_text=item.get("reply_text", ""),
reply_grammar=_restore_grammar(item.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in item.get("reply_log", [])],
)
def load_state(payload: dict, saved_at: datetime) -> SessionState:
"""Восстановить состояние; неизвестная версия отклоняется явно."""
if payload.get("version") != CHECKPOINT_VERSION:
raise ValueError("неподдерживаемая версия снимка занятия")
scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None
state = SessionState(
session_id=UUID(payload["session_id"]),
scenario_id=payload["scenario_id"],
scenario_title=payload["scenario_title"],
level=payload["level"],
mode=SessionMode(payload["mode"]),
owner_login=payload.get("owner_login"),
exercise=Exercise(payload["exercise"]),
handoff_to_dds=bool(payload.get("handoff_to_dds")),
required_fields=list(payload.get("required_fields") or []),
trainee_name=payload.get("trainee_name"),
trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
dds_service=payload.get("dds_service"),
attempt=int(payload.get("attempt", 1)),
criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
kio=KIO.model_validate(payload.get("kio") or {}),
transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
timers=_restore_timers(payload, saved_at),
hints_shown=list(payload.get("hints_shown") or []),
hints_log=[(item, datetime.fromisoformat(at))
for item, at in payload.get("hints_log", [])],
notes=list(payload.get("notes") or []),
directives=list(payload.get("directives") or []),
scenario=scenario,
audio_frames=int(payload.get("audio_frames", 0)),
bad_frames=int(payload.get("bad_frames", 0)),
self_assessed=bool(payload.get("self_assessed")),
self_assessment=payload.get("self_assessment"),
score=payload.get("score"),
started_at=_dt(payload.get("started_at")),
ended_at=_dt(payload.get("ended_at")),
end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
dispatched_card=(
KIO.model_validate(payload["dispatched_card"])
if payload.get("dispatched_card") else None
),
dispatched_at=_dt(payload.get("dispatched_at")),
bounced_fields=list(payload.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in payload.get("dds_log", [])],
status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
crew_selected=payload.get("crew_selected"),
crew_assignments=dict(payload.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(item)
for item in payload.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(item)
for item in payload.get("phone_lines", [])],
phone_pending=(
PhoneCallPending.model_validate(payload["phone_pending"])
if payload.get("phone_pending") else None
),
dds_scenarios=[Scenario.model_validate(item)
for item in payload.get("dds_scenarios", [])],
dds_live_cards=[_restore_live_card(item, saved_at)
for item in payload.get("dds_live_cards", [])],
dds_active_card_id=(
UUID(payload["dds_active_card_id"])
if payload.get("dds_active_card_id") else None
),
dds_card_index=int(payload.get("dds_card_index", 0)),
dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
dds_next_scenario_index=int(payload.get(
"dds_next_scenario_index",
max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
)),
dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
dds_completed=[
DdsCardRecord(
card_id=UUID(item["card_id"]),
scenario_id=item["scenario_id"],
reply_text=item.get("reply_text", ""),
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
findings=[Finding.model_validate(finding)
for finding in item.get("findings", [])],
actions=list(item.get("actions") or []),
duration_ms=int(item.get("duration_ms", 0)),
)
for item in payload.get("dds_completed", [])
],
reply_text=payload.get("reply_text", ""),
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in payload.get("reply_log", [])],
resolved_outcome=payload.get("resolved_outcome"),
resolve_comment=payload.get("resolve_comment", ""),
)
if state.dds_live_cards:
# Legacy snapshots had no explicit active ID; newer snapshots may
# intentionally be between cards while waiting for the next arrival.
active_id = state.dds_active_card_id
if active_id is None and "dds_active_card_id" not in payload:
active_id = state.dds_live_cards[0].card_id
if active_id is not None:
state.activate_dds_card(active_id, capture=False)
if (state.dds_scenarios
and state.dds_next_scenario_index < len(state.dds_scenarios)
and state.dds_next_arrival_at is None):
# Old checkpoints had no delivery schedule; resume any remaining
# selected scenarios immediately rather than strand the session.
state.dds_next_arrival_at = now_utc()
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
# карточка и оценка при этом остаются прежними.
if state.exercise is Exercise.CALL and state.scenario is not None:
state.persona = PersonaState(state.scenario.persona)
state.caller = build_caller(
state.scenario.id,
use_pregenerated=state.scenario.tree.pregenerated,
)
embedder = get_embedder()
if embedder is not None:
state.slots = SlotMachine(state.scenario, embedder)
return state

View file

@ -1,11 +1,12 @@
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
"""Одновременная выдача учебных карточек ДДС в рамках одного занятия."""
import re
from datetime import datetime, timedelta
from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus
from app.scenarios.schema import Scenario
from app.session.state import SessionState, now_utc
from app.session.state import DdsLiveCard, SessionState, now_utc
from app.session.timers import SessionTimers
@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.required_fields = list(scenario.required_fields)
truth = scenario.ground_truth
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
caller_phone = next((
match.group(0).strip()
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
), None)
caller_names = re.findall(
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
caller_fact,
)
# Some ticket facts name multiple witnesses; don't silently attribute the
# contact number to the first person when the source doesn't disambiguate.
caller_name = caller_names[0] if len(caller_names) == 1 else None
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
service = truth.dds.value if truth.dds else None
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
state.kio = KIO(
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
caller_contact=caller_phone,
address=truth.address or address_fact or None,
floor=floor.group(1) if floor else None,
incident_type=truth.incident_type, incident_code=truth.incident_code,
@ -41,13 +56,110 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
state.dispatched_card = state.kio.model_copy(deep=True)
state.dispatched_at = now_utc()
state.status_log.clear()
state.dds_log.clear()
state.bounced_fields.clear()
# A live DДС card keeps references to the current working collections.
# Never clear them in place when preparing the next card: that would also
# erase the previous card's history. Replace the working collections so
# every queued card retains an isolated snapshot.
state.status_log = []
state.dds_log = []
state.bounced_fields = []
state.crew_selected = None
state.crew_assignments.clear()
state.phone_reports.clear()
state.crew_assignments = {}
state.phone_reports = []
state.phone_lines = []
state.phone_pending = None
state.reply_text = ""
state.reply_log.clear()
state.timers = SessionTimers()
state.reply_grammar = None
state.reply_log = []
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
state.timers = SessionTimers(limits=dict(state.timers.limits))
state.on_event("dds.dispatch")
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
state.dds_card_index = index
prepare_card(state, scenario)
card = DdsLiveCard(
original_index=index,
scenario=state.scenario,
kio=state.kio,
dispatched_card=state.dispatched_card,
dispatched_at=state.dispatched_at,
timers=state.timers,
bounced_fields=state.bounced_fields,
dds_log=state.dds_log,
status_log=state.status_log,
crew_selected=state.crew_selected,
crew_assignments=state.crew_assignments,
phone_reports=state.phone_reports,
phone_lines=state.phone_lines,
phone_pending=state.phone_pending,
reply_text=state.reply_text,
reply_grammar=state.reply_grammar,
reply_log=state.reply_log,
)
state.dds_live_cards.append(card)
return card
def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
"""Выдать всё, чему настал срок и для чего есть место в ожидании.
Активная карточка не считается ожидающей. Каждый вызов prepare_card
запускает собственный 30-секундный таймер ровно при поступлении.
"""
now = now or now_utc()
if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now:
return 0
delivered = 0
while state.dds_next_scenario_index < len(state.dds_scenarios):
active_id = state.dds_active_card_id if any(
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
) else None
active_exists = active_id is not None
waiting_count = len(state.dds_live_cards) - int(active_exists)
if waiting_count >= state.dds_max_waiting:
break
index = state.dds_next_scenario_index
card = _append_live_card(state, state.dds_scenarios[index], index)
state.dds_next_scenario_index += 1
delivered += 1
if not active_exists:
state.activate_dds_card(card.card_id, capture=False)
else:
# prepare_card builds the new snapshot through SessionState's
# working fields; restore the trainee's active card immediately.
state.activate_dds_card(active_id, capture=False)
if state.dds_next_scenario_index >= len(state.dds_scenarios):
state.dds_next_arrival_at = None
break
if state.dds_arrival_interval_seconds:
# Pace from actual delivery time. If the cap was full, the next
# card becomes due one full interval after this release.
state.dds_next_arrival_at = card.dispatched_at + timedelta(
seconds=state.dds_arrival_interval_seconds
)
break
# Zero interval means fill active + available waiting slots now.
state.dds_next_arrival_at = now
return delivered
def prepare_queue(
state: SessionState,
scenarios: list[Scenario],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
) -> None:
"""Начать поступление очереди с первой карточки; остальные идут по темпу."""
state.dds_live_cards.clear()
state.dds_scenarios = scenarios
state.dds_arrival_interval_seconds = arrival_interval_seconds
state.dds_max_waiting = max_waiting
state.dds_next_scenario_index = 0
state.dds_next_arrival_at = now_utc()
deliver_due_cards(state)

View file

@ -7,17 +7,18 @@
событие сразу: им ждать нечего.
"""
import asyncio
import logging
from uuid import UUID
from app.domain.events import Exercise, Metric, ScoreReady
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.events import Exercise, ScoreReady
from app.domain.timers import TimerCode
from app.scenarios import store
from app.scoring.competency import radar
from app.scoring.card import evaluate_card
from app.scoring.competency import radar
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
from app.scoring.gost import GostResult, evaluate
from app.scoring.report import build as build_report
from app.scoring.weights import apply_weights
from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc
@ -28,34 +29,16 @@ log = logging.getLogger(__name__)
def score_current_dds(state) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей."""
number = state.dds_card_index + 1
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
services=state.managed_services(),
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
reply = state.reply_text.strip()
if reply or len(state.dds_scenarios) > 1:
passed = len(reply) >= 20 and len(reply.split()) >= 3
metrics.append(Metric(
key="dds_reply", title="Свободный ответ по карточке",
fact=reply or "ответ отсутствует",
norm="содержательный текст не короче 20 символов и 3 слов",
ref="ТЗ: действия с карточками, ввод текста",
passed=passed, weight=1.5,
))
if not passed:
findings.append(Finding(
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
summary="Свободный ответ отсутствует или слишком краток",
fact=reply or "ответ отсутствует",
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
ref="ТЗ: действия с карточками, ввод текста",
competency=Competency.COMMUNICATION,
))
metrics = dispatcher_metrics(state, decision_limit_ms)
weighted = GostResult(metrics=metrics, findings=findings)
apply_weights(weighted, state.scenario.score_weights)
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value,
"comment": mark.comment, "at": mark.at.isoformat()}
@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord:
async def finish(session_id: UUID, state) -> None:
if state.recorder is not None:
path = await asyncio.to_thread(state.recorder.finalize)
state.recording_path = str(path) if path else None
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
@ -95,22 +81,20 @@ async def finish(session_id: UUID, state) -> None:
if state.exercise is Exercise.CARD:
result = evaluate_card(scenario, state.dispatched_card or state.kio)
elif state.exercise is Exercise.DDS:
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
# сохранены отдельными результатами; активную оцениваем лишь однажды.
# Все карточки выданы одновременно: при досрочном завершении оцениваем
# каждую, включая не открытую, потому что её норматив уже шёл.
result = GostResult()
cards = list(state.dds_completed)
if state.dispatched_card and not any(
item.card_id == state.dispatched_card.card_id for item in cards
):
state.capture_active_dds()
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
if any(item.card_id == live.card_id for item in cards):
continue
state.activate_dds_card(live.card_id, capture=False)
cards.append(score_current_dds(state))
state.dds_completed = cards
for card in cards:
result.metrics.extend(card.metrics)
result.findings.extend(card.findings)
result.unavailable.append(
"Грамматика свободного ответа: автоматическая проверка не настроена; "
"оценивались только наличие и минимальная полнота текста"
)
else:
result = evaluate(
scenario=scenario,
@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None:
if state.dispatched_card is not None and (
state.exercise is Exercise.CALL or state.handoff_to_dds
):
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
dispatcher_findings = evaluate_dispatcher(
entries=state.status_log,
services=state.notified_services(),
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
services=state.managed_services(),
deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
)
result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
# DDS cards were weighted individually in score_current_dds using each
# card's scenario defaults plus the lesson override. Reapplying the first
# scenario's weights here would corrupt the other ticket cards.
if state.exercise is not Exercise.DDS:
apply_weights(result, scenario.score_weights)
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None:
for card in cards
] if state.exercise is Exercise.DDS else [],
}
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
if hub.journal:
@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None:
await release_score(session_id, state)
async def refresh_archived_report(session_id: UUID, state) -> None:
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
if state.score is None:
return
scenario = state.scenario or store.get(state.scenario_id)
if scenario is None:
return
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
if hub.journal:
await hub.journal.score_snapshot(session_id, state.score)
async def release_score(session_id: UUID, state) -> None:
"""Отдать оценку курсанту, когда самооценка сдана."""
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):

View file

@ -13,7 +13,7 @@ from uuid import UUID
from pydantic import BaseModel
from app.domain.events import TimerTick
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
from app.session.state import SessionState
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
@ -30,14 +30,20 @@ class Journal(Protocol):
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]: ...
) -> tuple[int, UUID | None, str | None]: ...
async def utterance(self, session_id: UUID, entry) -> None: ...
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
async def score_override(
self, session_id: UUID, score_final: float, author: str, comment: str,
) -> None: ...
async def session_started(self, session_id: UUID, at) -> None: ...
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
async def checkpoint(self, state: SessionState) -> None: ...
async def restore_active(self) -> list[SessionState]: ...
class SessionHub:
@ -58,10 +64,26 @@ class SessionHub:
def get(self, session_id: UUID) -> SessionState | None:
return self._sessions.get(session_id)
def has_active_scenario(self, scenario_id: str) -> bool:
"""Архивирование контента не должно менять уже идущее занятие."""
return any(
not state.ended and (
state.scenario_id == scenario_id
or any(item.id == scenario_id for item in state.dds_scenarios)
)
for state in self._sessions.values()
)
def drop(self, session_id: UUID) -> None:
self._sessions.pop(session_id, None)
self.stop_ticker(session_id)
async def checkpoint(self, session_id: UUID) -> None:
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
state = self.get(session_id)
if state is not None and self.journal is not None:
await self.journal.checkpoint(state)
# ── подписки ──
@contextlib.contextmanager
@ -138,6 +160,18 @@ class SessionHub:
state = self.get(session_id)
if state is None or state.ended:
return
if state.exercise is Exercise.DDS:
from app.session.dds import deliver_due_cards
active_before = state.dds_active_card_id
delivered = deliver_due_cards(state)
if state.dds_active_card_id != active_before and state.dds_active_card_id:
self.to_station(session_id, state.card_received_event())
if delivered:
await self.checkpoint(session_id)
# Keep the pending count and countdown live even while
# the active dispatcher card is being handled.
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
except asyncio.CancelledError:
raise

View file

@ -8,11 +8,14 @@ import logging
from datetime import datetime
from uuid import UUID
from sqlalchemy import update
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.db import repo
from app.db.models import Score, SelfAssessment, Session
from app.db.models import Score, SelfAssessment, Session, User, Utterance
from app.domain.events import Mood, Speaker, TranscriptEntry
from app.session.checkpoint import dump_state, load_state
from app.session.state import SessionState, now_utc
log = logging.getLogger(__name__)
@ -32,8 +35,8 @@ class DbJournal:
async def start_lesson(
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
trainee_id: UUID | None = None,
) -> tuple[int, UUID | None]:
trainee_id: UUID | None = None, owner_login: str | None = None,
) -> tuple[int, UUID | None, str | None]:
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
Если база недоступна, занятие всё равно идёт: номер попытки
@ -48,11 +51,75 @@ class DbJournal:
mode=mode,
trainee_name=trainee_name,
trainee_id=trainee_id,
owner_login=owner_login,
)
return row.attempt, row.trainee_id
service = None
if row.trainee_id is not None:
service = await db.scalar(
select(User.service)
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
.limit(1)
)
return row.attempt, row.trainee_id, service
except PermissionError:
raise
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
log.exception("журнал: сессию завести не удалось")
return 1, trainee_id
return 1, trainee_id, None
async def checkpoint(self, state: SessionState) -> None:
"""Сохранить снимок после подтверждённого действия пользователя."""
async def action(db):
values = (
{"live_state": None, "checkpoint_at": None}
if state.ended
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
)
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
await db.commit()
await self._write(lambda db: action(db))
async def restore_active(self) -> list[SessionState]:
"""Восстановить только незавершённые сессии с валидным снимком."""
restored: list[SessionState] = []
async with self._sessionmaker() as db:
rows = (await db.scalars(
select(Session).where(
Session.ended_at.is_(None),
Session.live_state.is_not(None),
Session.checkpoint_at.is_not(None),
)
)).all()
for row in rows:
try:
state = load_state(row.live_state, row.checkpoint_at)
if state.session_id != row.id:
raise ValueError("ID снимка не совпадает с записью занятия")
state.owner_login = row.owner_login
# Реплики пишутся отдельно сразу после появления. Если
# процесс умер между репликой и общим снимком, отдельный
# журнал не даёт потерять последний фрагмент диалога.
utterances = (await db.scalars(
select(Utterance)
.where(Utterance.session_id == row.id)
.order_by(Utterance.at, Utterance.ref)
)).all()
if utterances:
state.transcript = [
TranscriptEntry(
ref=item.ref,
speaker=Speaker(item.speaker),
text=item.text,
at=item.at,
mood=Mood(item.mood) if item.mood else None,
)
for item in utterances
]
restored.append(state)
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
log.exception("журнал: снимок занятия %s повреждён", row.id)
return restored
async def utterance(self, session_id: UUID, entry) -> None:
await self._write(
@ -111,6 +178,16 @@ class DbJournal:
await self._write(lambda db: action(db))
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
"""Обновить полный архивный разбор после самооценки курсанта."""
async def action(db):
await db.execute(
update(Score).where(Score.session_id == session_id).values(report=report)
)
await db.commit()
await self._write(lambda db: action(db))
async def session_started(self, session_id: UUID, at: datetime) -> None:
async def action(db):
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
@ -123,7 +200,12 @@ class DbJournal:
await db.execute(
update(Session)
.where(Session.id == session_id)
.values(ended_at=at, end_reason=reason)
.values(
ended_at=at,
end_reason=reason,
live_state=None,
checkpoint_at=None,
)
)
await db.commit()

View file

@ -5,45 +5,52 @@
пустой экран (docs/arch/STACK.md).
"""
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
from app.domain.events import (
CallEndReason,
Exercise,
Mood,
SessionMode,
SessionSnapshot,
Metric,
Speaker,
TranscriptEntry,
)
from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.scenarios.schema import Scenario
from app.domain.events import (
CallEndReason,
Exercise,
LessonCriteria,
Metric,
Mood,
SessionMode,
SessionSnapshot,
Speaker,
TranscriptEntry,
)
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
CardStatus,
NEXT,
DdsCardSummary,
DdsQueueCard,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StationSnapshot,
StatusEntry,
PhoneReportRecord,
DdsCardSummary,
card_status,
check,
current,
)
from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.session.timers import SessionTimers
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario
from app.scoring.grammar import GrammarAssessment
from app.session.timers import SessionTimers
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(timezone.utc)
return datetime.now(UTC)
@dataclass
@ -63,6 +70,33 @@ class DdsCardRecord:
return round(100 * passed / total, 1) if total else 0.0
@dataclass
class DdsLiveCard:
"""Изолированное живое состояние одной одновременно выданной карточки."""
original_index: int
scenario: Scenario
kio: KIO
dispatched_card: KIO
dispatched_at: datetime
timers: SessionTimers
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
@property
def card_id(self) -> UUID:
return self.dispatched_card.card_id
@dataclass
class SessionState:
session_id: UUID
@ -70,6 +104,8 @@ class SessionState:
scenario_title: str
level: str
mode: SessionMode
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
owner_login: str | None = None
exercise: Exercise = Exercise.CALL
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
handoff_to_dds: bool = False
@ -78,7 +114,11 @@ class SessionState:
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
trainee_id: UUID | None = None
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
#: остальные адресаты карточки показываются информационно.
dds_service: str | None = None
attempt: int = 1
criteria: LessonCriteria = field(default_factory=LessonCriteria)
kio: KIO = field(default_factory=KIO)
transcript: list[TranscriptEntry] = field(default_factory=list)
@ -100,6 +140,8 @@ class SessionState:
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
# тогда кадры микрофона только считаются.
voice: object | None = None
recorder: object | None = None
recording_path: str | None = None
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
@ -124,10 +166,19 @@ class SessionState:
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
dds_scenarios: list[Scenario] = field(default_factory=list)
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
dds_active_card_id: UUID | None = None
dds_card_index: int = 0
dds_completed: list[DdsCardRecord] = field(default_factory=list)
dds_arrival_interval_seconds: int = 0
dds_max_waiting: int = 3
dds_next_scenario_index: int = 0
dds_next_arrival_at: datetime | None = None
reply_text: str = ""
reply_grammar: GrammarAssessment | None = None
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None
@ -168,12 +219,106 @@ class SessionState:
card = self.dispatched_card or self.kio
return list(card.notify)
def _managed_services_for(self, recipients: list[str]) -> list[str]:
"""Служба, от имени которой работает этот обучающийся.
Старые занятия и demo без профиля безопасно закрепляются за первым
адресатом. Обобщённая учебная служба «ДДС района» соответствует
территориальному органу, а не всем получателям карточки сразу.
"""
if not recipients:
return []
wanted = (self.dds_service or "").strip().casefold()
if wanted:
exact = next((item for item in recipients if item.casefold() == wanted), None)
if exact:
return [exact]
contained = next(
(item for item in recipients
if wanted in item.casefold() or item.casefold() in wanted),
None,
)
if contained:
return [contained]
if "район" in wanted:
district = next(
(item for item in recipients if "территориальн" in item.casefold()), None
)
if district:
return [district]
return []
return [recipients[0]]
def managed_services(self) -> list[str]:
return self._managed_services_for(self.notified_services())
def capture_active_dds(self) -> None:
"""Сохранить изменяемые поля активной карточки перед переключением."""
if not self.dispatched_card:
return
card = next(
(item for item in self.dds_live_cards
if item.card_id == self.dispatched_card.card_id),
None,
)
if card is None:
return
card.scenario = self.scenario
card.kio = self.kio
card.dispatched_card = self.dispatched_card
card.dispatched_at = self.dispatched_at or card.dispatched_at
card.timers = self.timers
card.bounced_fields = self.bounced_fields
card.dds_log = self.dds_log
card.status_log = self.status_log
card.crew_selected = self.crew_selected
card.crew_assignments = self.crew_assignments
card.phone_reports = self.phone_reports
card.phone_lines = self.phone_lines
card.phone_pending = self.phone_pending
card.reply_text = self.reply_text
card.reply_grammar = self.reply_grammar
card.reply_log = self.reply_log
self.dds_active_card_id = card.card_id
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
"""Переключить рабочее место на карточку, не останавливая остальные."""
if capture:
self.capture_active_dds()
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
if card is None:
return False
scenario = card.scenario
self.scenario = scenario
self.scenario_id = scenario.id
self.scenario_title = scenario.title
self.level = scenario.level.value
self.required_fields = list(scenario.required_fields)
self.kio = card.kio
self.dispatched_card = card.dispatched_card
self.dispatched_at = card.dispatched_at
self.timers = card.timers
self.bounced_fields = card.bounced_fields
self.dds_log = card.dds_log
self.status_log = card.status_log
self.crew_selected = card.crew_selected
self.crew_assignments = card.crew_assignments
self.phone_reports = card.phone_reports
self.phone_lines = card.phone_lines
self.phone_pending = card.phone_pending
self.reply_text = card.reply_text
self.reply_grammar = card.reply_grammar
self.reply_log = card.reply_log
self.dds_card_index = card.original_index
self.dds_active_card_id = card.card_id
return True
def crew_options(self) -> list[str]:
return [f"{service} — бригада №{number}"
for service in self.notified_services() for number in (1, 2)]
for service in self.managed_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None:
return next((service for service in self.notified_services()
return next((service for service in self.managed_services()
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status(
@ -190,31 +335,84 @@ class SessionState:
def station_snapshot(self) -> StationSnapshot:
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
services = self.notified_services()
self.capture_active_dds()
has_active_dds_card = any(
item.card_id == self.dds_active_card_id for item in self.dds_live_cards
)
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
services = self._managed_services_for(recipients)
statuses = {service: current(self.status_log, service) for service in services}
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
overdue = self.dispatched_at is not None and (
limit = self.timers.limits[TimerCode.DDS_ACK]
overdue = has_active_dds_card and self.dispatched_at is not None and (
elapsed is None
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
)
now_mono = time.monotonic()
queue_cards: list[DdsQueueCard] = []
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
recipients_for_card = list(item.dispatched_card.notify)
managed = self._managed_services_for(recipients_for_card)
service = managed[0] if managed else None
timer = item.timers.timers.get(TimerCode.DDS_ACK)
elapsed_ms = timer.current_ms(now_mono) if timer else 0
stopped = timer.stopped if timer else False
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
queue_cards.append(DdsQueueCard(
card_id=item.card_id,
scenario_id=item.scenario.id,
title=item.scenario.title,
address=item.dispatched_card.address,
description=item.dispatched_card.description,
incident_type=(item.dispatched_card.incident_type.value
if item.dispatched_card.incident_type else None),
victims_count=item.dispatched_card.victims_count,
received_at=item.dispatched_at,
managed_service=service,
service_status=(current(item.status_log, service)
if service else ServiceStatus.ADDED),
card_status=card_status(
item.status_log, managed,
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
),
elapsed_ms=elapsed_ms,
limit_ms=limit_ms,
timer_stopped=stopped,
active=item.card_id == self.dds_active_card_id,
))
return StationSnapshot(
services=services,
recipient_services=recipients,
managed_service=services[0] if services else None,
statuses=statuses,
available={service: list(NEXT[status]) for service, status in statuses.items()},
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
log=list(self.status_log),
crew_options=self.crew_options(),
crew_selected=self.crew_selected,
phone_reports=list(self.phone_reports),
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
card_index=self.dds_card_index + 1,
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
crew_selected=self.crew_selected if has_active_dds_card else None,
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
phone_pending=self.phone_pending if has_active_dds_card else None,
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
card_index=(self.dds_card_index + 1 if has_active_dds_card
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
card_total=len(self.dds_scenarios) or 1,
reply_text=self.reply_text,
reply_text=self.reply_text if has_active_dds_card else "",
completed_cards=[DdsCardSummary(card_id=item.card_id,
scenario_id=item.scenario_id,
score_auto=item.score_auto)
for item in self.dds_completed],
queue_cards=queue_cards,
pending_cards_count=max(
0, len(self.dds_scenarios) - len(self.dds_completed)
- (1 if has_active_dds_card else 0),
),
next_arrival_in_seconds=(
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
if self.dds_next_arrival_at is not None else None
),
max_waiting_cards=self.dds_max_waiting,
)
def card_received_event(self):
@ -244,6 +442,7 @@ class SessionState:
level=self.level,
mode=self.mode,
exercise=self.exercise,
criteria=self.criteria,
trainee_name=self.trainee_name,
started_at=self.started_at,
kio=self.kio,