Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
358
backend/app/session/checkpoint.py
Normal file
358
backend/app/session/checkpoint.py
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
"""Переносимый JSON-снимок незавершённого занятия.
|
||||
|
||||
Снимок хранится в PostgreSQL после каждого подтверждённого действия. Он не
|
||||
содержит сокеты, аудиобуферы или объекты моделей: после перезапуска процесса
|
||||
они создаются заново, а учебные данные, таймеры и состояние ДДС восстанавливаются.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from app.dialog.factory import build_caller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.runtime import get_embedder
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
LessonCriteria,
|
||||
Metric,
|
||||
SessionMode,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.domain.kio import KIO
|
||||
from app.domain.statuses import (
|
||||
PhoneCallPending,
|
||||
PhoneLineRecord,
|
||||
PhoneReportRecord,
|
||||
StatusEntry,
|
||||
)
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
|
||||
from app.session.timers import SessionTimers, Timer
|
||||
|
||||
CHECKPOINT_VERSION = 1
|
||||
|
||||
|
||||
def _grammar(value: GrammarAssessment | None) -> dict | None:
|
||||
if value is None:
|
||||
return None
|
||||
return {"passed": value.passed, "errors": list(value.errors), "source": value.source}
|
||||
|
||||
|
||||
def _dump_timers(timers: SessionTimers, now: float) -> dict:
|
||||
return {
|
||||
"limits": {code.value: limit for code, limit in timers.limits.items()},
|
||||
"items": {
|
||||
code.value: {
|
||||
"elapsed_ms": timer.current_ms(now),
|
||||
"attempt": timer.attempt,
|
||||
"stopped": timer.stopped,
|
||||
}
|
||||
for code, timer in timers.timers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
|
||||
return {
|
||||
"original_index": item.original_index,
|
||||
"scenario": item.scenario.model_dump(mode="json"),
|
||||
"kio": item.kio.model_dump(mode="json"),
|
||||
"dispatched_card": item.dispatched_card.model_dump(mode="json"),
|
||||
"dispatched_at": item.dispatched_at.isoformat(),
|
||||
"timers": _dump_timers(item.timers, now),
|
||||
"bounced_fields": item.bounced_fields,
|
||||
"dds_log": [[action, at.isoformat(), detail] for action, at, detail in item.dds_log],
|
||||
"status_log": [entry.model_dump(mode="json") for entry in item.status_log],
|
||||
"crew_selected": item.crew_selected,
|
||||
"crew_assignments": item.crew_assignments,
|
||||
"phone_reports": [entry.model_dump(mode="json") for entry in item.phone_reports],
|
||||
"phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines],
|
||||
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
|
||||
"reply_text": item.reply_text,
|
||||
"reply_grammar": _grammar(item.reply_grammar),
|
||||
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
|
||||
}
|
||||
|
||||
|
||||
def dump_state(state: SessionState) -> dict:
|
||||
"""Сериализовать только данные, необходимые для точного продолжения."""
|
||||
state.capture_active_dds()
|
||||
now = time.monotonic()
|
||||
payload = {
|
||||
"version": CHECKPOINT_VERSION,
|
||||
"session_id": str(state.session_id),
|
||||
"scenario_id": state.scenario_id,
|
||||
"scenario_title": state.scenario_title,
|
||||
"level": state.level,
|
||||
"mode": state.mode.value,
|
||||
"owner_login": state.owner_login,
|
||||
"exercise": state.exercise.value,
|
||||
"handoff_to_dds": state.handoff_to_dds,
|
||||
"required_fields": state.required_fields,
|
||||
"trainee_name": state.trainee_name,
|
||||
"trainee_id": str(state.trainee_id) if state.trainee_id else None,
|
||||
"dds_service": state.dds_service,
|
||||
"attempt": state.attempt,
|
||||
"criteria": state.criteria.model_dump(mode="json"),
|
||||
"kio": state.kio.model_dump(mode="json"),
|
||||
"transcript": [entry.model_dump(mode="json") for entry in state.transcript],
|
||||
"timers": _dump_timers(state.timers, now),
|
||||
"hints_shown": state.hints_shown,
|
||||
"hints_log": [[item, at.isoformat()] for item, at in state.hints_log],
|
||||
"notes": state.notes,
|
||||
"directives": state.directives,
|
||||
"scenario": state.scenario.model_dump(mode="json") if state.scenario else None,
|
||||
"audio_frames": state.audio_frames,
|
||||
"bad_frames": state.bad_frames,
|
||||
"self_assessed": state.self_assessed,
|
||||
"self_assessment": state.self_assessment,
|
||||
"score": state.score,
|
||||
"started_at": state.started_at.isoformat() if state.started_at else None,
|
||||
"ended_at": state.ended_at.isoformat() if state.ended_at else None,
|
||||
"end_reason": state.end_reason.value if state.end_reason else None,
|
||||
"dispatched_card": (
|
||||
state.dispatched_card.model_dump(mode="json") if state.dispatched_card else None
|
||||
),
|
||||
"dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
|
||||
"bounced_fields": state.bounced_fields,
|
||||
"dds_log": [
|
||||
[action, at.isoformat(), detail] for action, at, detail in state.dds_log
|
||||
],
|
||||
"status_log": [item.model_dump(mode="json") for item in state.status_log],
|
||||
"crew_selected": state.crew_selected,
|
||||
"crew_assignments": state.crew_assignments,
|
||||
"phone_reports": [item.model_dump(mode="json") for item in state.phone_reports],
|
||||
"phone_lines": [item.model_dump(mode="json") for item in state.phone_lines],
|
||||
"phone_pending": (
|
||||
state.phone_pending.model_dump(mode="json") if state.phone_pending else None
|
||||
),
|
||||
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios],
|
||||
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards],
|
||||
"dds_active_card_id": (
|
||||
str(state.dds_active_card_id) if state.dds_active_card_id else None
|
||||
),
|
||||
"dds_card_index": state.dds_card_index,
|
||||
"dds_arrival_interval_seconds": state.dds_arrival_interval_seconds,
|
||||
"dds_max_waiting": state.dds_max_waiting,
|
||||
"dds_next_scenario_index": state.dds_next_scenario_index,
|
||||
"dds_next_arrival_at": (
|
||||
state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None
|
||||
),
|
||||
"dds_completed": [
|
||||
{
|
||||
"card_id": str(item.card_id),
|
||||
"scenario_id": item.scenario_id,
|
||||
"reply_text": item.reply_text,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in item.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in item.findings],
|
||||
"actions": item.actions,
|
||||
"duration_ms": item.duration_ms,
|
||||
}
|
||||
for item in state.dds_completed
|
||||
],
|
||||
"reply_text": state.reply_text,
|
||||
"reply_grammar": _grammar(state.reply_grammar),
|
||||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
}
|
||||
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
|
||||
# получать только стандартные JSON-типы.
|
||||
return jsonable_encoder(payload)
|
||||
|
||||
|
||||
def _dt(value: str | None) -> datetime | None:
|
||||
return datetime.fromisoformat(value) if value else None
|
||||
|
||||
|
||||
def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
|
||||
raw = payload.get("timers") or {}
|
||||
limits = {
|
||||
TimerCode(code): int(limit)
|
||||
for code, limit in (raw.get("limits") or {}).items()
|
||||
}
|
||||
restored = SessionTimers(limits=limits or SessionTimers().limits)
|
||||
now_mono = time.monotonic()
|
||||
now_wall = datetime.now(UTC)
|
||||
if saved_at.tzinfo is None:
|
||||
saved_at = saved_at.replace(tzinfo=UTC)
|
||||
downtime_ms = max(0, int((now_wall - saved_at).total_seconds() * 1000))
|
||||
for raw_code, item in (raw.get("items") or {}).items():
|
||||
code = TimerCode(raw_code)
|
||||
stopped = bool(item.get("stopped"))
|
||||
elapsed = max(0, int(item.get("elapsed_ms", 0)))
|
||||
total = elapsed if stopped else elapsed + downtime_ms
|
||||
restored.timers[code] = Timer(
|
||||
code=code,
|
||||
started_at=None if stopped else now_mono - total / 1000,
|
||||
elapsed_ms=elapsed if stopped else 0,
|
||||
attempt=max(1, int(item.get("attempt", 1))),
|
||||
stopped=stopped,
|
||||
)
|
||||
return restored
|
||||
|
||||
|
||||
def _restore_grammar(value: dict | None) -> GrammarAssessment | None:
|
||||
if not value:
|
||||
return None
|
||||
return GrammarAssessment(
|
||||
passed=bool(value["passed"]),
|
||||
errors=tuple(value.get("errors") or []),
|
||||
source=value["source"],
|
||||
)
|
||||
|
||||
|
||||
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
|
||||
return DdsLiveCard(
|
||||
original_index=int(item["original_index"]),
|
||||
scenario=Scenario.model_validate(item["scenario"]),
|
||||
kio=KIO.model_validate(item["kio"]),
|
||||
dispatched_card=KIO.model_validate(item["dispatched_card"]),
|
||||
dispatched_at=datetime.fromisoformat(item["dispatched_at"]),
|
||||
timers=_restore_timers({"timers": item["timers"]}, saved_at),
|
||||
bounced_fields=list(item.get("bounced_fields") or []),
|
||||
dds_log=[(action, datetime.fromisoformat(at), detail)
|
||||
for action, at, detail in item.get("dds_log", [])],
|
||||
status_log=[StatusEntry.model_validate(entry)
|
||||
for entry in item.get("status_log", [])],
|
||||
crew_selected=item.get("crew_selected"),
|
||||
crew_assignments=dict(item.get("crew_assignments") or {}),
|
||||
phone_reports=[PhoneReportRecord.model_validate(entry)
|
||||
for entry in item.get("phone_reports", [])],
|
||||
phone_lines=[PhoneLineRecord.model_validate(entry)
|
||||
for entry in item.get("phone_lines", [])],
|
||||
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
|
||||
if item.get("phone_pending") else None),
|
||||
reply_text=item.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(item.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
for at, text in item.get("reply_log", [])],
|
||||
)
|
||||
|
||||
|
||||
def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
||||
"""Восстановить состояние; неизвестная версия отклоняется явно."""
|
||||
if payload.get("version") != CHECKPOINT_VERSION:
|
||||
raise ValueError("неподдерживаемая версия снимка занятия")
|
||||
scenario = Scenario.model_validate(payload["scenario"]) if payload.get("scenario") else None
|
||||
state = SessionState(
|
||||
session_id=UUID(payload["session_id"]),
|
||||
scenario_id=payload["scenario_id"],
|
||||
scenario_title=payload["scenario_title"],
|
||||
level=payload["level"],
|
||||
mode=SessionMode(payload["mode"]),
|
||||
owner_login=payload.get("owner_login"),
|
||||
exercise=Exercise(payload["exercise"]),
|
||||
handoff_to_dds=bool(payload.get("handoff_to_dds")),
|
||||
required_fields=list(payload.get("required_fields") or []),
|
||||
trainee_name=payload.get("trainee_name"),
|
||||
trainee_id=UUID(payload["trainee_id"]) if payload.get("trainee_id") else None,
|
||||
dds_service=payload.get("dds_service"),
|
||||
attempt=int(payload.get("attempt", 1)),
|
||||
criteria=LessonCriteria.model_validate(payload.get("criteria") or {}),
|
||||
kio=KIO.model_validate(payload.get("kio") or {}),
|
||||
transcript=[TranscriptEntry.model_validate(item) for item in payload.get("transcript", [])],
|
||||
timers=_restore_timers(payload, saved_at),
|
||||
hints_shown=list(payload.get("hints_shown") or []),
|
||||
hints_log=[(item, datetime.fromisoformat(at))
|
||||
for item, at in payload.get("hints_log", [])],
|
||||
notes=list(payload.get("notes") or []),
|
||||
directives=list(payload.get("directives") or []),
|
||||
scenario=scenario,
|
||||
audio_frames=int(payload.get("audio_frames", 0)),
|
||||
bad_frames=int(payload.get("bad_frames", 0)),
|
||||
self_assessed=bool(payload.get("self_assessed")),
|
||||
self_assessment=payload.get("self_assessment"),
|
||||
score=payload.get("score"),
|
||||
started_at=_dt(payload.get("started_at")),
|
||||
ended_at=_dt(payload.get("ended_at")),
|
||||
end_reason=(CallEndReason(payload["end_reason"]) if payload.get("end_reason") else None),
|
||||
dispatched_card=(
|
||||
KIO.model_validate(payload["dispatched_card"])
|
||||
if payload.get("dispatched_card") else None
|
||||
),
|
||||
dispatched_at=_dt(payload.get("dispatched_at")),
|
||||
bounced_fields=list(payload.get("bounced_fields") or []),
|
||||
dds_log=[(action, datetime.fromisoformat(at), detail)
|
||||
for action, at, detail in payload.get("dds_log", [])],
|
||||
status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
|
||||
crew_selected=payload.get("crew_selected"),
|
||||
crew_assignments=dict(payload.get("crew_assignments") or {}),
|
||||
phone_reports=[PhoneReportRecord.model_validate(item)
|
||||
for item in payload.get("phone_reports", [])],
|
||||
phone_lines=[PhoneLineRecord.model_validate(item)
|
||||
for item in payload.get("phone_lines", [])],
|
||||
phone_pending=(
|
||||
PhoneCallPending.model_validate(payload["phone_pending"])
|
||||
if payload.get("phone_pending") else None
|
||||
),
|
||||
dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("dds_scenarios", [])],
|
||||
dds_live_cards=[_restore_live_card(item, saved_at)
|
||||
for item in payload.get("dds_live_cards", [])],
|
||||
dds_active_card_id=(
|
||||
UUID(payload["dds_active_card_id"])
|
||||
if payload.get("dds_active_card_id") else None
|
||||
),
|
||||
dds_card_index=int(payload.get("dds_card_index", 0)),
|
||||
dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
|
||||
dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
|
||||
dds_next_scenario_index=int(payload.get(
|
||||
"dds_next_scenario_index",
|
||||
max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
|
||||
)),
|
||||
dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
|
||||
dds_completed=[
|
||||
DdsCardRecord(
|
||||
card_id=UUID(item["card_id"]),
|
||||
scenario_id=item["scenario_id"],
|
||||
reply_text=item.get("reply_text", ""),
|
||||
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
|
||||
findings=[Finding.model_validate(finding)
|
||||
for finding in item.get("findings", [])],
|
||||
actions=list(item.get("actions") or []),
|
||||
duration_ms=int(item.get("duration_ms", 0)),
|
||||
)
|
||||
for item in payload.get("dds_completed", [])
|
||||
],
|
||||
reply_text=payload.get("reply_text", ""),
|
||||
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
|
||||
reply_log=[(datetime.fromisoformat(at), text)
|
||||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
resolve_comment=payload.get("resolve_comment", ""),
|
||||
)
|
||||
if state.dds_live_cards:
|
||||
# Legacy snapshots had no explicit active ID; newer snapshots may
|
||||
# intentionally be between cards while waiting for the next arrival.
|
||||
active_id = state.dds_active_card_id
|
||||
if active_id is None and "dds_active_card_id" not in payload:
|
||||
active_id = state.dds_live_cards[0].card_id
|
||||
if active_id is not None:
|
||||
state.activate_dds_card(active_id, capture=False)
|
||||
if (state.dds_scenarios
|
||||
and state.dds_next_scenario_index < len(state.dds_scenarios)
|
||||
and state.dds_next_arrival_at is None):
|
||||
# Old checkpoints had no delivery schedule; resume any remaining
|
||||
# selected scenarios immediately rather than strand the session.
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
|
||||
# карточка и оценка при этом остаются прежними.
|
||||
if state.exercise is Exercise.CALL and state.scenario is not None:
|
||||
state.persona = PersonaState(state.scenario.persona)
|
||||
state.caller = build_caller(
|
||||
state.scenario.id,
|
||||
use_pregenerated=state.scenario.tree.pregenerated,
|
||||
)
|
||||
embedder = get_embedder()
|
||||
if embedder is not None:
|
||||
state.slots = SlotMachine(state.scenario, embedder)
|
||||
return state
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
|
||||
"""Одновременная выдача учебных карточек ДДС в рамках одного занятия."""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.state import DdsLiveCard, SessionState, now_utc
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
|
|
@ -18,11 +19,25 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.required_fields = list(scenario.required_fields)
|
||||
truth = scenario.ground_truth
|
||||
address_fact = next((fact.value for fact in scenario.facts if "address" in fact.id), "")
|
||||
caller_fact = next((fact.value for fact in scenario.facts if fact.id in {"f_caller", "f_applicant"}), "")
|
||||
caller_phone = next((
|
||||
match.group(0).strip()
|
||||
for match in re.finditer(r"(?<!\d)(?:\+?7|8)?(?:[\s().-]*\d){10,11}(?!\d)", caller_fact)
|
||||
if len(re.sub(r"\D", "", match.group(0))) in {10, 11}
|
||||
), None)
|
||||
caller_names = re.findall(
|
||||
r"(?<![А-ЯЁа-яё])([А-ЯЁ][а-яё-]+(?:\s+[А-ЯЁ][а-яё-]+){1,2})(?![А-ЯЁа-яё])",
|
||||
caller_fact,
|
||||
)
|
||||
# Some ticket facts name multiple witnesses; don't silently attribute the
|
||||
# contact number to the first person when the source doesn't disambiguate.
|
||||
caller_name = caller_names[0] if len(caller_names) == 1 else None
|
||||
floor = re.search(r"(\d+)[-‑–]?й?\s*этаж", address_fact, re.IGNORECASE)
|
||||
service = truth.dds.value if truth.dds else None
|
||||
fallback = {"01": "Служба 101", "02": "МВД", "03": "Скорая помощь", "04": "Аварийная служба"}
|
||||
state.kio = KIO(
|
||||
registered_at=now_utc(), caller_number="+7 (495) 000-00-00",
|
||||
registered_at=now_utc(), caller_number=caller_phone, caller_name=caller_name,
|
||||
caller_contact=caller_phone,
|
||||
address=truth.address or address_fact or None,
|
||||
floor=floor.group(1) if floor else None,
|
||||
incident_type=truth.incident_type, incident_code=truth.incident_code,
|
||||
|
|
@ -41,13 +56,110 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.dispatched_card = state.kio.model_copy(deep=True)
|
||||
state.dispatched_at = now_utc()
|
||||
|
||||
state.status_log.clear()
|
||||
state.dds_log.clear()
|
||||
state.bounced_fields.clear()
|
||||
# A live DДС card keeps references to the current working collections.
|
||||
# Never clear them in place when preparing the next card: that would also
|
||||
# erase the previous card's history. Replace the working collections so
|
||||
# every queued card retains an isolated snapshot.
|
||||
state.status_log = []
|
||||
state.dds_log = []
|
||||
state.bounced_fields = []
|
||||
state.crew_selected = None
|
||||
state.crew_assignments.clear()
|
||||
state.phone_reports.clear()
|
||||
state.crew_assignments = {}
|
||||
state.phone_reports = []
|
||||
state.phone_lines = []
|
||||
state.phone_pending = None
|
||||
state.reply_text = ""
|
||||
state.reply_log.clear()
|
||||
state.timers = SessionTimers()
|
||||
state.reply_grammar = None
|
||||
state.reply_log = []
|
||||
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
|
||||
state.timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
state.on_event("dds.dispatch")
|
||||
|
||||
|
||||
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
|
||||
state.dds_card_index = index
|
||||
prepare_card(state, scenario)
|
||||
card = DdsLiveCard(
|
||||
original_index=index,
|
||||
scenario=state.scenario,
|
||||
kio=state.kio,
|
||||
dispatched_card=state.dispatched_card,
|
||||
dispatched_at=state.dispatched_at,
|
||||
timers=state.timers,
|
||||
bounced_fields=state.bounced_fields,
|
||||
dds_log=state.dds_log,
|
||||
status_log=state.status_log,
|
||||
crew_selected=state.crew_selected,
|
||||
crew_assignments=state.crew_assignments,
|
||||
phone_reports=state.phone_reports,
|
||||
phone_lines=state.phone_lines,
|
||||
phone_pending=state.phone_pending,
|
||||
reply_text=state.reply_text,
|
||||
reply_grammar=state.reply_grammar,
|
||||
reply_log=state.reply_log,
|
||||
)
|
||||
state.dds_live_cards.append(card)
|
||||
return card
|
||||
|
||||
|
||||
def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
|
||||
"""Выдать всё, чему настал срок и для чего есть место в ожидании.
|
||||
|
||||
Активная карточка не считается ожидающей. Каждый вызов prepare_card
|
||||
запускает собственный 30-секундный таймер ровно при поступлении.
|
||||
"""
|
||||
now = now or now_utc()
|
||||
if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now:
|
||||
return 0
|
||||
|
||||
delivered = 0
|
||||
while state.dds_next_scenario_index < len(state.dds_scenarios):
|
||||
active_id = state.dds_active_card_id if any(
|
||||
item.card_id == state.dds_active_card_id for item in state.dds_live_cards
|
||||
) else None
|
||||
active_exists = active_id is not None
|
||||
waiting_count = len(state.dds_live_cards) - int(active_exists)
|
||||
if waiting_count >= state.dds_max_waiting:
|
||||
break
|
||||
|
||||
index = state.dds_next_scenario_index
|
||||
card = _append_live_card(state, state.dds_scenarios[index], index)
|
||||
state.dds_next_scenario_index += 1
|
||||
delivered += 1
|
||||
if not active_exists:
|
||||
state.activate_dds_card(card.card_id, capture=False)
|
||||
else:
|
||||
# prepare_card builds the new snapshot through SessionState's
|
||||
# working fields; restore the trainee's active card immediately.
|
||||
state.activate_dds_card(active_id, capture=False)
|
||||
|
||||
if state.dds_next_scenario_index >= len(state.dds_scenarios):
|
||||
state.dds_next_arrival_at = None
|
||||
break
|
||||
if state.dds_arrival_interval_seconds:
|
||||
# Pace from actual delivery time. If the cap was full, the next
|
||||
# card becomes due one full interval after this release.
|
||||
state.dds_next_arrival_at = card.dispatched_at + timedelta(
|
||||
seconds=state.dds_arrival_interval_seconds
|
||||
)
|
||||
break
|
||||
# Zero interval means fill active + available waiting slots now.
|
||||
state.dds_next_arrival_at = now
|
||||
|
||||
return delivered
|
||||
|
||||
|
||||
def prepare_queue(
|
||||
state: SessionState,
|
||||
scenarios: list[Scenario],
|
||||
arrival_interval_seconds: int = 0,
|
||||
max_waiting: int = 3,
|
||||
) -> None:
|
||||
"""Начать поступление очереди с первой карточки; остальные идут по темпу."""
|
||||
state.dds_live_cards.clear()
|
||||
state.dds_scenarios = scenarios
|
||||
state.dds_arrival_interval_seconds = arrival_interval_seconds
|
||||
state.dds_max_waiting = max_waiting
|
||||
state.dds_next_scenario_index = 0
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
deliver_due_cards(state)
|
||||
|
|
|
|||
|
|
@ -7,17 +7,18 @@
|
|||
событие сразу: им ждать нечего.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, Metric, ScoreReady
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
|
@ -28,34 +29,16 @@ log = logging.getLogger(__name__)
|
|||
def score_current_dds(state) -> DdsCardRecord:
|
||||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
services=state.managed_services(),
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
metrics = dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms)
|
||||
reply = state.reply_text.strip()
|
||||
if reply or len(state.dds_scenarios) > 1:
|
||||
passed = len(reply) >= 20 and len(reply.split()) >= 3
|
||||
metrics.append(Metric(
|
||||
key="dds_reply", title="Свободный ответ по карточке",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="содержательный текст не короче 20 символов и 3 слов",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
passed=passed, weight=1.5,
|
||||
))
|
||||
if not passed:
|
||||
findings.append(Finding(
|
||||
code=ErrorCode.D5, source=FindingSource.DISPATCHER,
|
||||
summary="Свободный ответ отсутствует или слишком краток",
|
||||
fact=reply or "ответ отсутствует",
|
||||
norm="не менее 20 символов и 3 слов; грамматика отдельно не проверялась",
|
||||
ref="ТЗ: действия с карточками, ввод текста",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
apply_weights(weighted, state.scenario.score_weights)
|
||||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
"comment": mark.comment, "at": mark.at.isoformat()}
|
||||
|
|
@ -87,6 +70,9 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
|
||||
|
||||
async def finish(session_id: UUID, state) -> None:
|
||||
if state.recorder is not None:
|
||||
path = await asyncio.to_thread(state.recorder.finalize)
|
||||
state.recording_path = str(path) if path else None
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
if scenario is None:
|
||||
|
|
@ -95,22 +81,20 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
|
||||
# сохранены отдельными результатами; активную оцениваем лишь однажды.
|
||||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||||
result = GostResult()
|
||||
cards = list(state.dds_completed)
|
||||
if state.dispatched_card and not any(
|
||||
item.card_id == state.dispatched_card.card_id for item in cards
|
||||
):
|
||||
state.capture_active_dds()
|
||||
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index):
|
||||
if any(item.card_id == live.card_id for item in cards):
|
||||
continue
|
||||
state.activate_dds_card(live.card_id, capture=False)
|
||||
cards.append(score_current_dds(state))
|
||||
state.dds_completed = cards
|
||||
for card in cards:
|
||||
result.metrics.extend(card.metrics)
|
||||
result.findings.extend(card.findings)
|
||||
result.unavailable.append(
|
||||
"Грамматика свободного ответа: автоматическая проверка не настроена; "
|
||||
"оценивались только наличие и минимальная полнота текста"
|
||||
)
|
||||
else:
|
||||
result = evaluate(
|
||||
scenario=scenario,
|
||||
|
|
@ -129,16 +113,20 @@ async def finish(session_id: UUID, state) -> None:
|
|||
if state.dispatched_card is not None and (
|
||||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||||
):
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
|
||||
services=state.managed_services(),
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||||
# DDS cards were weighted individually in score_current_dds using each
|
||||
# card's scenario defaults plus the lesson override. Reapplying the first
|
||||
# scenario's weights here would corrupt the other ticket cards.
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, scenario.score_weights)
|
||||
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
@ -172,6 +160,9 @@ async def finish(session_id: UUID, state) -> None:
|
|||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
}
|
||||
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
if hub.journal:
|
||||
|
|
@ -181,6 +172,18 @@ async def finish(session_id: UUID, state) -> None:
|
|||
await release_score(session_id, state)
|
||||
|
||||
|
||||
async def refresh_archived_report(session_id: UUID, state) -> None:
|
||||
"""Дополнить сохранённый разбор самооценкой, пришедшей после hangup."""
|
||||
if state.score is None:
|
||||
return
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
if scenario is None:
|
||||
return
|
||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||
if hub.journal:
|
||||
await hub.journal.score_snapshot(session_id, state.score)
|
||||
|
||||
|
||||
async def release_score(session_id: UUID, state) -> None:
|
||||
"""Отдать оценку курсанту, когда самооценка сдана."""
|
||||
if state.score is not None and (state.self_assessed or state.exercise is Exercise.CARD):
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from uuid import UUID
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import TimerTick
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -30,14 +30,20 @@ class Journal(Protocol):
|
|||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]: ...
|
||||
) -> tuple[int, UUID | None, str | None]: ...
|
||||
async def utterance(self, session_id: UUID, entry) -> None: ...
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at) -> None: ...
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None: ...
|
||||
async def self_assessment(self, session_id: UUID, missed: list[str], comment: str, at) -> None: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None: ...
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None: ...
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str,
|
||||
) -> None: ...
|
||||
async def session_started(self, session_id: UUID, at) -> None: ...
|
||||
async def session_ended(self, session_id: UUID, at, reason: str) -> None: ...
|
||||
async def checkpoint(self, state: SessionState) -> None: ...
|
||||
async def restore_active(self) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -58,10 +64,26 @@ class SessionHub:
|
|||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
for state in self._sessions.values()
|
||||
)
|
||||
|
||||
def drop(self, session_id: UUID) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
self.stop_ticker(session_id)
|
||||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
|
@ -138,6 +160,18 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
delivered = deliver_due_cards(state)
|
||||
if state.dds_active_card_id != active_before and state.dds_active_card_id:
|
||||
self.to_station(session_id, state.card_received_event())
|
||||
if delivered:
|
||||
await self.checkpoint(session_id)
|
||||
# Keep the pending count and countdown live even while
|
||||
# the active dispatcher card is being handled.
|
||||
self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
|
||||
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot()))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import logging
|
|||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.db import repo
|
||||
from app.db.models import Score, SelfAssessment, Session
|
||||
from app.db.models import Score, SelfAssessment, Session, User, Utterance
|
||||
from app.domain.events import Mood, Speaker, TranscriptEntry
|
||||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -32,8 +35,8 @@ class DbJournal:
|
|||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None,
|
||||
) -> tuple[int, UUID | None]:
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
|
|
@ -48,11 +51,75 @@ class DbJournal:
|
|||
mode=mode,
|
||||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
)
|
||||
return row.attempt, row.trainee_id
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
select(User.service)
|
||||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1, trainee_id
|
||||
return 1, trainee_id, None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
async def action(db):
|
||||
values = (
|
||||
{"live_state": None, "checkpoint_at": None}
|
||||
if state.ended
|
||||
else {"live_state": dump_state(state), "checkpoint_at": now_utc()}
|
||||
)
|
||||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
"""Восстановить только незавершённые сессии с валидным снимком."""
|
||||
restored: list[SessionState] = []
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
)).all()
|
||||
for row in rows:
|
||||
try:
|
||||
state = load_state(row.live_state, row.checkpoint_at)
|
||||
if state.session_id != row.id:
|
||||
raise ValueError("ID снимка не совпадает с записью занятия")
|
||||
state.owner_login = row.owner_login
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
utterances = (await db.scalars(
|
||||
select(Utterance)
|
||||
.where(Utterance.session_id == row.id)
|
||||
.order_by(Utterance.at, Utterance.ref)
|
||||
)).all()
|
||||
if utterances:
|
||||
state.transcript = [
|
||||
TranscriptEntry(
|
||||
ref=item.ref,
|
||||
speaker=Speaker(item.speaker),
|
||||
text=item.text,
|
||||
at=item.at,
|
||||
mood=Mood(item.mood) if item.mood else None,
|
||||
)
|
||||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.exception("журнал: снимок занятия %s повреждён", row.id)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
await self._write(
|
||||
|
|
@ -111,6 +178,16 @@ class DbJournal:
|
|||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score).where(Score.session_id == session_id).values(report=report)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
||||
async def action(db):
|
||||
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
||||
|
|
@ -123,7 +200,12 @@ class DbJournal:
|
|||
await db.execute(
|
||||
update(Session)
|
||||
.where(Session.id == session_id)
|
||||
.values(ended_at=at, end_reason=reason)
|
||||
.values(
|
||||
ended_at=at,
|
||||
end_reason=reason,
|
||||
live_state=None,
|
||||
checkpoint_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,45 +5,52 @@
|
|||
пустой экран (docs/arch/STACK.md).
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Metric,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.dialog.caller import TemplateCaller
|
||||
from app.dialog.persona import PersonaState
|
||||
from app.dialog.slots import SlotMachine
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.domain.events import (
|
||||
CallEndReason,
|
||||
Exercise,
|
||||
LessonCriteria,
|
||||
Metric,
|
||||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.domain.statuses import (
|
||||
CardStatus,
|
||||
NEXT,
|
||||
DdsCardSummary,
|
||||
DdsQueueCard,
|
||||
PhoneCallPending,
|
||||
PhoneLineRecord,
|
||||
PhoneReportRecord,
|
||||
ServiceStatus,
|
||||
StationSnapshot,
|
||||
StatusEntry,
|
||||
PhoneReportRecord,
|
||||
DdsCardSummary,
|
||||
card_status,
|
||||
check,
|
||||
current,
|
||||
)
|
||||
from app.domain.timers import NORMATIVES, TimerCode
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.session.timers import SessionTimers
|
||||
from app.domain.taxonomy import Finding
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.scoring.grammar import GrammarAssessment
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -63,6 +70,33 @@ class DdsCardRecord:
|
|||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DdsLiveCard:
|
||||
"""Изолированное живое состояние одной одновременно выданной карточки."""
|
||||
|
||||
original_index: int
|
||||
scenario: Scenario
|
||||
kio: KIO
|
||||
dispatched_card: KIO
|
||||
dispatched_at: datetime
|
||||
timers: SessionTimers
|
||||
bounced_fields: list[str] = field(default_factory=list)
|
||||
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
|
||||
status_log: list[StatusEntry] = field(default_factory=list)
|
||||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def card_id(self) -> UUID:
|
||||
return self.dispatched_card.card_id
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
session_id: UUID
|
||||
|
|
@ -70,6 +104,8 @@ class SessionState:
|
|||
scenario_title: str
|
||||
level: str
|
||||
mode: SessionMode
|
||||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||||
owner_login: str | None = None
|
||||
exercise: Exercise = Exercise.CALL
|
||||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||||
handoff_to_dds: bool = False
|
||||
|
|
@ -78,7 +114,11 @@ class SessionState:
|
|||
#: Чьё это занятие. Проставляется при запуске, когда курсант известен
|
||||
#: по учётной записи: по нему разбор закрывается от чужих (lct-23).
|
||||
trainee_id: UUID | None = None
|
||||
#: Конкретная ДДС обучающегося. Она меняет статусы только своей службы;
|
||||
#: остальные адресаты карточки показываются информационно.
|
||||
dds_service: str | None = None
|
||||
attempt: int = 1
|
||||
criteria: LessonCriteria = field(default_factory=LessonCriteria)
|
||||
|
||||
kio: KIO = field(default_factory=KIO)
|
||||
transcript: list[TranscriptEntry] = field(default_factory=list)
|
||||
|
|
@ -100,6 +140,8 @@ class SessionState:
|
|||
# Голосовой контур звонка. Нет — если голос выключен или моделей нет:
|
||||
# тогда кадры микрофона только считаются.
|
||||
voice: object | None = None
|
||||
recorder: object | None = None
|
||||
recording_path: str | None = None
|
||||
|
||||
# Аудио курсанта. До голосового контура (lct-06) кадры только считаются —
|
||||
# этого достаточно, чтобы доказать, что звук доходит от микрофона до сервера.
|
||||
|
|
@ -124,10 +166,19 @@ class SessionState:
|
|||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
||||
dds_active_card_id: UUID | None = None
|
||||
dds_card_index: int = 0
|
||||
dds_completed: list[DdsCardRecord] = field(default_factory=list)
|
||||
dds_arrival_interval_seconds: int = 0
|
||||
dds_max_waiting: int = 3
|
||||
dds_next_scenario_index: int = 0
|
||||
dds_next_arrival_at: datetime | None = None
|
||||
reply_text: str = ""
|
||||
reply_grammar: GrammarAssessment | None = None
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
|
|
@ -168,12 +219,106 @@ class SessionState:
|
|||
card = self.dispatched_card or self.kio
|
||||
return list(card.notify)
|
||||
|
||||
def _managed_services_for(self, recipients: list[str]) -> list[str]:
|
||||
"""Служба, от имени которой работает этот обучающийся.
|
||||
|
||||
Старые занятия и demo без профиля безопасно закрепляются за первым
|
||||
адресатом. Обобщённая учебная служба «ДДС района» соответствует
|
||||
территориальному органу, а не всем получателям карточки сразу.
|
||||
"""
|
||||
if not recipients:
|
||||
return []
|
||||
wanted = (self.dds_service or "").strip().casefold()
|
||||
if wanted:
|
||||
exact = next((item for item in recipients if item.casefold() == wanted), None)
|
||||
if exact:
|
||||
return [exact]
|
||||
contained = next(
|
||||
(item for item in recipients
|
||||
if wanted in item.casefold() or item.casefold() in wanted),
|
||||
None,
|
||||
)
|
||||
if contained:
|
||||
return [contained]
|
||||
if "район" in wanted:
|
||||
district = next(
|
||||
(item for item in recipients if "территориальн" in item.casefold()), None
|
||||
)
|
||||
if district:
|
||||
return [district]
|
||||
return []
|
||||
return [recipients[0]]
|
||||
|
||||
def managed_services(self) -> list[str]:
|
||||
return self._managed_services_for(self.notified_services())
|
||||
|
||||
def capture_active_dds(self) -> None:
|
||||
"""Сохранить изменяемые поля активной карточки перед переключением."""
|
||||
if not self.dispatched_card:
|
||||
return
|
||||
card = next(
|
||||
(item for item in self.dds_live_cards
|
||||
if item.card_id == self.dispatched_card.card_id),
|
||||
None,
|
||||
)
|
||||
if card is None:
|
||||
return
|
||||
card.scenario = self.scenario
|
||||
card.kio = self.kio
|
||||
card.dispatched_card = self.dispatched_card
|
||||
card.dispatched_at = self.dispatched_at or card.dispatched_at
|
||||
card.timers = self.timers
|
||||
card.bounced_fields = self.bounced_fields
|
||||
card.dds_log = self.dds_log
|
||||
card.status_log = self.status_log
|
||||
card.crew_selected = self.crew_selected
|
||||
card.crew_assignments = self.crew_assignments
|
||||
card.phone_reports = self.phone_reports
|
||||
card.phone_lines = self.phone_lines
|
||||
card.phone_pending = self.phone_pending
|
||||
card.reply_text = self.reply_text
|
||||
card.reply_grammar = self.reply_grammar
|
||||
card.reply_log = self.reply_log
|
||||
self.dds_active_card_id = card.card_id
|
||||
|
||||
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool:
|
||||
"""Переключить рабочее место на карточку, не останавливая остальные."""
|
||||
if capture:
|
||||
self.capture_active_dds()
|
||||
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
|
||||
if card is None:
|
||||
return False
|
||||
scenario = card.scenario
|
||||
self.scenario = scenario
|
||||
self.scenario_id = scenario.id
|
||||
self.scenario_title = scenario.title
|
||||
self.level = scenario.level.value
|
||||
self.required_fields = list(scenario.required_fields)
|
||||
self.kio = card.kio
|
||||
self.dispatched_card = card.dispatched_card
|
||||
self.dispatched_at = card.dispatched_at
|
||||
self.timers = card.timers
|
||||
self.bounced_fields = card.bounced_fields
|
||||
self.dds_log = card.dds_log
|
||||
self.status_log = card.status_log
|
||||
self.crew_selected = card.crew_selected
|
||||
self.crew_assignments = card.crew_assignments
|
||||
self.phone_reports = card.phone_reports
|
||||
self.phone_lines = card.phone_lines
|
||||
self.phone_pending = card.phone_pending
|
||||
self.reply_text = card.reply_text
|
||||
self.reply_grammar = card.reply_grammar
|
||||
self.reply_log = card.reply_log
|
||||
self.dds_card_index = card.original_index
|
||||
self.dds_active_card_id = card.card_id
|
||||
return True
|
||||
|
||||
def crew_options(self) -> list[str]:
|
||||
return [f"{service} — бригада №{number}"
|
||||
for service in self.notified_services() for number in (1, 2)]
|
||||
for service in self.managed_services() for number in (1, 2)]
|
||||
|
||||
def crew_service(self, crew: str) -> str | None:
|
||||
return next((service for service in self.notified_services()
|
||||
return next((service for service in self.managed_services()
|
||||
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
|
||||
|
||||
def set_service_status(
|
||||
|
|
@ -190,31 +335,84 @@ class SessionState:
|
|||
|
||||
def station_snapshot(self) -> StationSnapshot:
|
||||
"""Что видит диспетчер: службы, их статусы и доступные переходы."""
|
||||
services = self.notified_services()
|
||||
self.capture_active_dds()
|
||||
has_active_dds_card = any(
|
||||
item.card_id == self.dds_active_card_id for item in self.dds_live_cards
|
||||
)
|
||||
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else []
|
||||
services = self._managed_services_for(recipients)
|
||||
statuses = {service: current(self.status_log, service) for service in services}
|
||||
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
|
||||
overdue = self.dispatched_at is not None and (
|
||||
limit = self.timers.limits[TimerCode.DDS_ACK]
|
||||
overdue = has_active_dds_card and self.dispatched_at is not None and (
|
||||
elapsed is None
|
||||
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
|
||||
)
|
||||
now_mono = time.monotonic()
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index):
|
||||
recipients_for_card = list(item.dispatched_card.notify)
|
||||
managed = self._managed_services_for(recipients_for_card)
|
||||
service = managed[0] if managed else None
|
||||
timer = item.timers.timers.get(TimerCode.DDS_ACK)
|
||||
elapsed_ms = timer.current_ms(now_mono) if timer else 0
|
||||
stopped = timer.stopped if timer else False
|
||||
limit_ms = item.timers.limits[TimerCode.DDS_ACK]
|
||||
queue_cards.append(DdsQueueCard(
|
||||
card_id=item.card_id,
|
||||
scenario_id=item.scenario.id,
|
||||
title=item.scenario.title,
|
||||
address=item.dispatched_card.address,
|
||||
description=item.dispatched_card.description,
|
||||
incident_type=(item.dispatched_card.incident_type.value
|
||||
if item.dispatched_card.incident_type else None),
|
||||
victims_count=item.dispatched_card.victims_count,
|
||||
received_at=item.dispatched_at,
|
||||
managed_service=service,
|
||||
service_status=(current(item.status_log, service)
|
||||
if service else ServiceStatus.ADDED),
|
||||
card_status=card_status(
|
||||
item.status_log, managed,
|
||||
notify_deadline_passed=not stopped and elapsed_ms > limit_ms,
|
||||
),
|
||||
elapsed_ms=elapsed_ms,
|
||||
limit_ms=limit_ms,
|
||||
timer_stopped=stopped,
|
||||
active=item.card_id == self.dds_active_card_id,
|
||||
))
|
||||
return StationSnapshot(
|
||||
services=services,
|
||||
recipient_services=recipients,
|
||||
managed_service=services[0] if services else None,
|
||||
statuses=statuses,
|
||||
available={service: list(NEXT[status]) for service, status in statuses.items()},
|
||||
card=card_status(self.status_log, services, notify_deadline_passed=overdue),
|
||||
card=(card_status(self.status_log, services, notify_deadline_passed=overdue)
|
||||
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED),
|
||||
log=list(self.status_log),
|
||||
crew_options=self.crew_options(),
|
||||
crew_selected=self.crew_selected,
|
||||
phone_reports=list(self.phone_reports),
|
||||
card_id=self.dispatched_card.card_id if self.dispatched_card else None,
|
||||
card_index=self.dds_card_index + 1,
|
||||
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []),
|
||||
crew_selected=self.crew_selected if has_active_dds_card else None,
|
||||
phone_reports=list(self.phone_reports) if has_active_dds_card else [],
|
||||
phone_lines=list(self.phone_lines) if has_active_dds_card else [],
|
||||
phone_pending=self.phone_pending if has_active_dds_card else None,
|
||||
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None),
|
||||
card_index=(self.dds_card_index + 1 if has_active_dds_card
|
||||
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))),
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
reply_text=self.reply_text,
|
||||
reply_text=self.reply_text if has_active_dds_card else "",
|
||||
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
||||
scenario_id=item.scenario_id,
|
||||
score_auto=item.score_auto)
|
||||
for item in self.dds_completed],
|
||||
queue_cards=queue_cards,
|
||||
pending_cards_count=max(
|
||||
0, len(self.dds_scenarios) - len(self.dds_completed)
|
||||
- (1 if has_active_dds_card else 0),
|
||||
),
|
||||
next_arrival_in_seconds=(
|
||||
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999))
|
||||
if self.dds_next_arrival_at is not None else None
|
||||
),
|
||||
max_waiting_cards=self.dds_max_waiting,
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -244,6 +442,7 @@ class SessionState:
|
|||
level=self.level,
|
||||
mode=self.mode,
|
||||
exercise=self.exercise,
|
||||
criteria=self.criteria,
|
||||
trainee_name=self.trainee_name,
|
||||
started_at=self.started_at,
|
||||
kio=self.kio,
|
||||
|
|
|
|||
Loading…
Reference in a new issue