Complete training workflow and acceptance hardening
This commit is contained in:
parent
4c4b91064f
commit
7237265833
243 changed files with 17014 additions and 1500 deletions
|
|
@ -94,6 +94,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"level": state.level,
|
||||
"mode": state.mode.value,
|
||||
"owner_login": state.owner_login,
|
||||
"backend_fencing_epoch": state.backend_fencing_epoch,
|
||||
"exercise": state.exercise.value,
|
||||
"handoff_to_dds": state.handoff_to_dds,
|
||||
"required_fields": state.required_fields,
|
||||
|
|
@ -135,6 +136,9 @@ def dump_state(state: SessionState) -> dict:
|
|||
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],
|
||||
"pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios],
|
||||
"operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None,
|
||||
"operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None,
|
||||
"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
|
||||
|
|
@ -163,6 +167,7 @@ def dump_state(state: SessionState) -> dict:
|
|||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||
"resolved_outcome": state.resolved_outcome,
|
||||
"resolve_comment": state.resolve_comment,
|
||||
"processed_station_commands": state.processed_station_commands[-512:],
|
||||
}
|
||||
# В actions/score могут быть datetime/UUID из расчёта; JSONB должен
|
||||
# получать только стандартные JSON-типы.
|
||||
|
|
@ -250,6 +255,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
level=payload["level"],
|
||||
mode=SessionMode(payload["mode"]),
|
||||
owner_login=payload.get("owner_login"),
|
||||
backend_fencing_epoch=int(payload.get("backend_fencing_epoch", 0)),
|
||||
exercise=Exercise(payload["exercise"]),
|
||||
handoff_to_dds=bool(payload.get("handoff_to_dds")),
|
||||
required_fields=list(payload.get("required_fields") or []),
|
||||
|
|
@ -296,6 +302,12 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
),
|
||||
dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("dds_scenarios", [])],
|
||||
pending_dds_scenarios=[Scenario.model_validate(item)
|
||||
for item in payload.get("pending_dds_scenarios", [])],
|
||||
operator_kio=(KIO.model_validate(payload["operator_kio"])
|
||||
if payload.get("operator_kio") else None),
|
||||
operator_scenario=(Scenario.model_validate(payload["operator_scenario"])
|
||||
if payload.get("operator_scenario") else None),
|
||||
dds_live_cards=[_restore_live_card(item, saved_at)
|
||||
for item in payload.get("dds_live_cards", [])],
|
||||
dds_active_card_id=(
|
||||
|
|
@ -329,6 +341,7 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
|
|||
for at, text in payload.get("reply_log", [])],
|
||||
resolved_outcome=payload.get("resolved_outcome"),
|
||||
resolve_comment=payload.get("resolve_comment", ""),
|
||||
processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:],
|
||||
)
|
||||
if state.dds_live_cards:
|
||||
# Legacy snapshots had no explicit active ID; newer snapshots may
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from app.domain import ekp
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
|
|
@ -18,13 +19,27 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.level = scenario.level.value
|
||||
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)
|
||||
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,
|
||||
|
|
@ -34,20 +49,48 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
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=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,
|
||||
incident_group=(ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code) else None),
|
||||
dds=truth.dds, signs=list(scenario.signs),
|
||||
notify=list(truth.notify) or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3]) or scenario.first_line,
|
||||
)
|
||||
fallback = {
|
||||
"01": "Служба 101",
|
||||
"02": "МВД",
|
||||
"03": "Скорая помощь",
|
||||
"04": "Аварийная служба",
|
||||
}
|
||||
if scenario.student_card is not None:
|
||||
# A moderated learner-authored KIO is the card itself; do not flatten
|
||||
# it to title/description and silently discard its structured fields.
|
||||
state.kio = scenario.student_card.model_copy(
|
||||
deep=True,
|
||||
update={
|
||||
"card_id": uuid4(),
|
||||
"registered_at": now_utc(),
|
||||
"response_status": ResponseStatus.REGISTERED,
|
||||
"dispatch_order_at": None,
|
||||
"arrival_at": None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
state.kio = KIO(
|
||||
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,
|
||||
incident_group=(
|
||||
ekp.incident(truth.incident_code).group
|
||||
if truth.incident_code and ekp.incident(truth.incident_code)
|
||||
else None
|
||||
),
|
||||
dds=truth.dds,
|
||||
signs=list(scenario.signs),
|
||||
notify=list(truth.notify)
|
||||
or ([fallback[service]] if service in fallback else []),
|
||||
victims_count=truth.victims,
|
||||
description="; ".join(fact.value for fact in scenario.facts[:3])
|
||||
or scenario.first_line,
|
||||
)
|
||||
state.dispatched_card = None
|
||||
if service:
|
||||
state.dispatch(service)
|
||||
|
|
@ -76,7 +119,9 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
|||
state.on_event("dds.dispatch")
|
||||
|
||||
|
||||
def _append_live_card(state: SessionState, scenario: Scenario, index: int) -> DdsLiveCard:
|
||||
def _append_live_card(
|
||||
state: SessionState, scenario: Scenario, index: int
|
||||
) -> DdsLiveCard:
|
||||
state.dds_card_index = index
|
||||
prepare_card(state, scenario)
|
||||
card = DdsLiveCard(
|
||||
|
|
@ -114,9 +159,14 @@ def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int:
|
|||
|
||||
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_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:
|
||||
|
|
@ -163,3 +213,45 @@ def prepare_queue(
|
|||
state.dds_next_scenario_index = 0
|
||||
state.dds_next_arrival_at = now_utc()
|
||||
deliver_due_cards(state)
|
||||
|
||||
|
||||
def prepare_handoff_queue(
|
||||
state: SessionState,
|
||||
additional_scenarios: list[Scenario],
|
||||
arrival_interval_seconds: int = 0,
|
||||
max_waiting: int = 3,
|
||||
) -> None:
|
||||
"""Передать созданную курсантом карточку в ДДС перед готовыми карточками."""
|
||||
if state.dispatched_card is None or state.scenario is None:
|
||||
return
|
||||
now = now_utc()
|
||||
state.operator_kio = state.dispatched_card.model_copy(deep=True)
|
||||
state.operator_scenario = state.scenario.model_copy(deep=True)
|
||||
# Карточка курсанта становится первым живым объектом очереди. Её данные
|
||||
# не переписываются из эталона: именно их будет обрабатывать ДДС.
|
||||
dds_timers = SessionTimers(limits=dict(state.timers.limits))
|
||||
dds_timers.on_event("dds.dispatch")
|
||||
first = DdsLiveCard(
|
||||
original_index=0,
|
||||
scenario=state.operator_scenario,
|
||||
kio=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_card=state.operator_kio.model_copy(deep=True),
|
||||
dispatched_at=state.dispatched_at or now,
|
||||
timers=dds_timers,
|
||||
)
|
||||
state.dds_live_cards = [first]
|
||||
state.dds_scenarios = [state.operator_scenario, *additional_scenarios]
|
||||
state.dds_arrival_interval_seconds = arrival_interval_seconds
|
||||
state.dds_max_waiting = max_waiting
|
||||
state.dds_next_scenario_index = 1
|
||||
state.dds_next_arrival_at = (
|
||||
(
|
||||
now + timedelta(seconds=arrival_interval_seconds)
|
||||
if additional_scenarios and arrival_interval_seconds
|
||||
else now
|
||||
)
|
||||
if additional_scenarios
|
||||
else None
|
||||
)
|
||||
state.activate_dds_card(first.card_id, capture=False)
|
||||
deliver_due_cards(state)
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
from app.domain.events import ErrorKind, ErrorEvent, Exercise, Metric, ScoreReady
|
||||
from app.domain.statuses import ServiceStatus, current
|
||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scenarios import store
|
||||
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.grammar import assess
|
||||
from app.scoring.report import build as build_report
|
||||
from app.scoring.taxonomy import METRIC_WEIGHTS
|
||||
from app.scoring.timing import time_credit
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
|
@ -30,14 +36,67 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = state.scenario.dds_decision
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
state, decision_limit_ms, dds_decision.expected, dds_decision.reason
|
||||
)
|
||||
metrics = dispatcher_metrics(state, decision_limit_ms)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK]
|
||||
work_timer = state.timers.timers.get(TimerCode.DDS_WORK)
|
||||
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK)
|
||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||
terminal = bool(state.managed_services()) and all(
|
||||
current(state.status_log, service) in {
|
||||
ServiceStatus.COMPLETED,
|
||||
ServiceStatus.DECLINED,
|
||||
ServiceStatus.REFUSED,
|
||||
}
|
||||
for service in state.managed_services()
|
||||
)
|
||||
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
|
||||
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
|
||||
if work_elapsed_ms is None:
|
||||
work_fact = "время обработки не зафиксировано"
|
||||
elif work_delta_ms and work_delta_ms > 0:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (+{round(work_delta_ms / 1000)} с сверх норматива)"
|
||||
elif work_delta_ms and work_delta_ms < 0:
|
||||
work_fact = (f"{round(work_elapsed_ms / 1000)} с (на "
|
||||
f"{round(abs(work_delta_ms) / 1000)} с быстрее норматива)")
|
||||
else:
|
||||
work_fact = f"{round(work_elapsed_ms / 1000)} с (точно в норматив)"
|
||||
if not terminal:
|
||||
work_fact = f"карточка не завершена; {work_fact}"
|
||||
weighted.metrics.append(Metric(
|
||||
key="dds_work_time",
|
||||
title="Отработка карточки ДДС",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=work_passed,
|
||||
weight=METRIC_WEIGHTS["dds_work_time"],
|
||||
credit=time_credit(work_elapsed_ms, work_limit_ms) if terminal else 0.0,
|
||||
))
|
||||
if not work_passed:
|
||||
weighted.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
||||
fact=work_fact,
|
||||
norm=f"завершить за {round(work_limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights})
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
|
|
@ -66,6 +125,15 @@ def score_current_dds(state) -> DdsCardRecord:
|
|||
actions=actions,
|
||||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||||
if state.dispatched_at else 0),
|
||||
title=state.scenario.title,
|
||||
address=state.dispatched_card.address,
|
||||
description=state.dispatched_card.description,
|
||||
incident_type=(state.dispatched_card.incident_type.value
|
||||
if state.dispatched_card.incident_type else None),
|
||||
victims_count=state.dispatched_card.victims_count,
|
||||
received_at=state.dispatched_at,
|
||||
managed_service=(state.managed_services()[0] if state.managed_services() else None),
|
||||
recipient_services=list(state.dispatched_card.notify),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -74,12 +142,52 @@ async def finish(session_id: UUID, state) -> 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)
|
||||
scenario = (state.operator_scenario if state.handoff_to_dds and state.operator_scenario
|
||||
else state.scenario or store.get(state.scenario_id))
|
||||
if scenario is None:
|
||||
return
|
||||
|
||||
cards: list[DdsCardRecord] = []
|
||||
if state.exercise is Exercise.CARD:
|
||||
result = evaluate_card(scenario, state.dispatched_card or state.kio)
|
||||
result = evaluate_card(
|
||||
scenario, state.operator_kio or state.dispatched_card or state.kio
|
||||
)
|
||||
limit_ms = state.timers.limits[TimerCode.CARD_FILL]
|
||||
elapsed_ms = state.timers.measured_ms(TimerCode.CARD_FILL)
|
||||
submitted = state.dispatched_card is not None
|
||||
if elapsed_ms is None:
|
||||
elapsed_fact = "время не зафиксировано"
|
||||
else:
|
||||
delta_ms = elapsed_ms - limit_ms
|
||||
elapsed_seconds = round(elapsed_ms / 1000)
|
||||
if delta_ms > 0:
|
||||
deviation = f"+{round(delta_ms / 1000)} с сверх норматива"
|
||||
elif delta_ms < 0:
|
||||
deviation = f"на {round(abs(delta_ms) / 1000)} с быстрее норматива"
|
||||
else:
|
||||
deviation = "точно в норматив"
|
||||
elapsed_fact = f"{elapsed_seconds} с ({deviation})"
|
||||
passed = submitted and elapsed_ms is not None and elapsed_ms <= limit_ms
|
||||
result.metrics.append(Metric(
|
||||
key="card_fill_time",
|
||||
title="Время заполнения карточки",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
passed=passed,
|
||||
weight=METRIC_WEIGHTS["card_fill_time"],
|
||||
credit=time_credit(elapsed_ms, limit_ms) if submitted else 0.0,
|
||||
))
|
||||
if not passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E3,
|
||||
source=FindingSource.TIMERS,
|
||||
summary="Время заполнения карточки: норматив не выполнен",
|
||||
fact=elapsed_fact if submitted else f"карточка не сдана; {elapsed_fact}",
|
||||
norm=f"сдать карточку за {round(limit_ms / 1000)} с",
|
||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
||||
competency=Competency.NORMS,
|
||||
))
|
||||
elif state.exercise is Exercise.DDS:
|
||||
# Все карточки выданы одновременно: при досрочном завершении оцениваем
|
||||
# каждую, включая не открытую, потому что её норматив уже шёл.
|
||||
|
|
@ -107,27 +215,74 @@ async def finish(session_id: UUID, state) -> None:
|
|||
resolved_outcome=state.resolved_outcome,
|
||||
dispatched=state.dispatched_card is not None,
|
||||
)
|
||||
# Грамматика относится к свободному описанию оператора 112, а не к
|
||||
# заполнению карточки на стороне ДДС. Учитывается только по явной настройке
|
||||
# преподавателя и только когда есть текст для проверки.
|
||||
operator_kio = state.operator_kio if state.handoff_to_dds else None
|
||||
description = (operator_kio or state.kio).description or ""
|
||||
if (state.exercise is not Exercise.DDS and state.criteria.require_correct_grammar
|
||||
and description.strip()):
|
||||
grammar = await assess(description)
|
||||
result.metrics.append(Metric(
|
||||
key="description_grammar",
|
||||
title="Грамматика описания происшествия",
|
||||
fact="ошибок не обнаружено" if grammar.passed else "; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
passed=grammar.passed,
|
||||
weight=1.0,
|
||||
))
|
||||
if not grammar.passed:
|
||||
result.findings.append(Finding(
|
||||
code=ErrorCode.E4,
|
||||
source=FindingSource.GRAMMAR,
|
||||
summary="Грамматическая ошибка в описании происшествия",
|
||||
fact="; ".join(grammar.errors),
|
||||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
if state.dispatched_card is not None and (
|
||||
state.exercise is Exercise.CALL or state.handoff_to_dds
|
||||
):
|
||||
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
||||
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK]
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.managed_services(),
|
||||
crew_assignments=state.crew_assignments,
|
||||
deadline_ms=decision_limit_ms,
|
||||
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
|
||||
reply_text=state.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, decision_limit_ms))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
state, decision_limit_ms, scenario.dds_decision.expected,
|
||||
scenario.dds_decision.reason,
|
||||
))
|
||||
# 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, **state.criteria.score_weights})
|
||||
|
||||
if state.handoff_to_dds and state.dds_scenarios:
|
||||
# В связке КИО оценивается относительно эталона и весов упражнения
|
||||
# 112, а каждая карточка очереди уже взвешена собственным сценарием.
|
||||
cards = list(state.dds_completed)
|
||||
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)
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
required = scenario.ground_truth.required_facts
|
||||
|
|
@ -140,6 +295,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
"score_auto": result.score,
|
||||
"summary": {
|
||||
"interview_ms": state.timers.measured_ms(TimerCode.INTERVIEW),
|
||||
"card_fill_ms": state.timers.measured_ms(TimerCode.CARD_FILL),
|
||||
"facts_got": len([fact for fact in required if fact in revealed]),
|
||||
"facts_required": len(required),
|
||||
"hints": len(state.hints_shown),
|
||||
|
|
@ -155,18 +311,28 @@ async def finish(session_id: UUID, state) -> None:
|
|||
{"card_id": str(card.card_id), "scenario_id": card.scenario_id,
|
||||
"score_auto": card.score_auto, "reply_text": card.reply_text,
|
||||
"actions": card.actions, "duration_ms": card.duration_ms,
|
||||
"title": card.title, "address": card.address,
|
||||
"description": card.description, "incident_type": card.incident_type,
|
||||
"victims_count": card.victims_count,
|
||||
"received_at": card.received_at.isoformat() if card.received_at else None,
|
||||
"managed_service": card.managed_service,
|
||||
"recipient_services": card.recipient_services,
|
||||
"metrics": [metric.model_dump(mode="json") for metric in card.metrics],
|
||||
"findings": [finding.model_dump(mode="json") for finding in card.findings]}
|
||||
for card in cards
|
||||
] if state.exercise is Exercise.DDS else [],
|
||||
] if state.exercise is Exercise.DDS or state.handoff_to_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:
|
||||
await hub.journal.score(session_id, result.score, state.score)
|
||||
if hub.journal and not await hub.journal.score(session_id, result.score, state.score):
|
||||
hub.to_observers(session_id, ErrorEvent(
|
||||
code=ErrorKind.INTERNAL,
|
||||
message="Не удалось сохранить оценку и аудит; итог не выдан. Обратитесь к преподавателю.",
|
||||
))
|
||||
return
|
||||
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
await release_score(session_id, state)
|
||||
|
|
|
|||
|
|
@ -7,13 +7,23 @@
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from contextvars import ContextVar, Token
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domain.events import CardReceived, Exercise, StationState, TimerTick
|
||||
from app.domain.events import (
|
||||
CardReceived,
|
||||
ErrorEvent,
|
||||
ErrorKind,
|
||||
Exercise,
|
||||
StationState,
|
||||
TimerTick,
|
||||
)
|
||||
from app.session.state import SessionState
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
|
|
@ -21,6 +31,15 @@ from app.session.state import SessionState
|
|||
QUEUE_SIZE = 256
|
||||
|
||||
TICK_SECONDS = 1.0
|
||||
LEASE_FENCED_MESSAGE = "Занятие передано другому backend-узлу; переподключитесь."
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _current_task():
|
||||
try:
|
||||
return asyncio.current_task()
|
||||
except RuntimeError: # synchronous tests and tooling have no running loop
|
||||
return None
|
||||
|
||||
|
||||
class Journal(Protocol):
|
||||
|
|
@ -29,21 +48,26 @@ 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, str | None]: ...
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | 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 self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at
|
||||
) -> bool: ...
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool: ...
|
||||
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: ...
|
||||
) -> bool: ...
|
||||
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]: ...
|
||||
async def renew(self, session_id: UUID) -> None: ...
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]: ...
|
||||
|
||||
|
||||
class SessionHub:
|
||||
|
|
@ -54,6 +78,9 @@ class SessionHub:
|
|||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
self._event_batch: ContextVar[dict | None] = ContextVar(
|
||||
f"session-event-batch-{id(self)}", default=None
|
||||
)
|
||||
|
||||
# ── реестр ──
|
||||
|
||||
|
|
@ -62,12 +89,47 @@ class SessionHub:
|
|||
return state
|
||||
|
||||
def get(self, session_id: UUID) -> SessionState | None:
|
||||
return self._sessions.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
return None if state is not None and state.lease_fenced else state
|
||||
|
||||
def is_lease_fenced(self, session_id: UUID) -> bool:
|
||||
state = self._sessions.get(session_id)
|
||||
return state is not None and state.lease_fenced
|
||||
|
||||
def active_sessions(self, owner_login: str) -> list[SessionState]:
|
||||
"""Живые занятия только преподавателя-владельца для группового обзора."""
|
||||
return [
|
||||
state for state in self._sessions.values()
|
||||
if not state.ended and not state.lease_fenced and state.owner_login == owner_login
|
||||
]
|
||||
|
||||
def history(
|
||||
self, *, owner_login: str | None = None, trainee_id: UUID | None = None,
|
||||
mode: str | None = None, since: datetime | None = None, limit: int = 100,
|
||||
) -> list[SessionState]:
|
||||
"""Volatile session history for the explicit no-database demo mode."""
|
||||
if since is not None and since.tzinfo is None:
|
||||
since = since.replace(tzinfo=UTC)
|
||||
states = [
|
||||
state for state in self._sessions.values()
|
||||
if not state.lease_fenced
|
||||
and (owner_login is None or state.owner_login == owner_login)
|
||||
and (trainee_id is None or state.trainee_id == trainee_id)
|
||||
and (mode is None or state.mode.value == mode)
|
||||
and (since is None or (state.started_at is not None and state.started_at >= since))
|
||||
]
|
||||
# Hub insertion order is creation order; completed lessons sort by
|
||||
# their finish time, while unanswered calls retain their start time.
|
||||
states.sort(
|
||||
key=lambda state: state.ended_at or state.started_at or datetime.min.replace(tzinfo=UTC),
|
||||
reverse=True,
|
||||
)
|
||||
return states[:max(0, limit)]
|
||||
|
||||
def has_active_scenario(self, scenario_id: str) -> bool:
|
||||
"""Архивирование контента не должно менять уже идущее занятие."""
|
||||
return any(
|
||||
not state.ended and (
|
||||
not state.ended and not state.lease_fenced and (
|
||||
state.scenario_id == scenario_id
|
||||
or any(item.id == scenario_id for item in state.dds_scenarios)
|
||||
)
|
||||
|
|
@ -80,9 +142,100 @@ class SessionHub:
|
|||
|
||||
async def checkpoint(self, session_id: UUID) -> None:
|
||||
"""Зафиксировать подтверждённое состояние, если журнал доступен."""
|
||||
state = self.get(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if state is not None and state.lease_fenced:
|
||||
raise RuntimeError(LEASE_FENCED_MESSAGE)
|
||||
if state is not None and self.journal is not None:
|
||||
await self.journal.checkpoint(state)
|
||||
try:
|
||||
await self.journal.checkpoint(state)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
await self.fence(state)
|
||||
raise
|
||||
self._flush_event_batch(session_id)
|
||||
|
||||
def begin_event_stream(self, session_id: UUID) -> Token:
|
||||
"""Stage controller output until each explicit checkpoint in its loop."""
|
||||
return self._event_batch.set({
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": True, "owner_task": _current_task(),
|
||||
})
|
||||
|
||||
async def end_event_stream(self, token: Token) -> None:
|
||||
batch = self._event_batch.get()
|
||||
try:
|
||||
if batch is not None and batch["events"]:
|
||||
session_id = batch["session_id"]
|
||||
batch["events"].clear()
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def durable_transition(self, session_id: UUID):
|
||||
"""Do not publish state-changing events until its checkpoint commits."""
|
||||
batch = {
|
||||
"session_id": session_id, "events": [], "committed": False,
|
||||
"persistent": False, "owner_task": _current_task(),
|
||||
}
|
||||
token: Token = self._event_batch.set(batch)
|
||||
try:
|
||||
yield
|
||||
if not batch["committed"]:
|
||||
await self.checkpoint(session_id)
|
||||
else:
|
||||
self._flush_event_batch(session_id)
|
||||
except Exception:
|
||||
self._discard_event_batch(session_id)
|
||||
state = self._sessions.get(session_id)
|
||||
if self.journal is not None and state is not None and not state.lease_fenced:
|
||||
await self.fence(state)
|
||||
raise
|
||||
finally:
|
||||
self._event_batch.reset(token)
|
||||
|
||||
def _discard_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is not None and batch["session_id"] == session_id:
|
||||
batch["events"].clear()
|
||||
|
||||
def _flush_event_batch(self, session_id: UUID) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if batch is None or batch["session_id"] != session_id:
|
||||
return
|
||||
pending, batch["events"] = batch["events"], []
|
||||
batch["committed"] = not batch.get("persistent", False)
|
||||
for registry, target_session_id, event in pending:
|
||||
self._put(registry.get(target_session_id, set()), event)
|
||||
|
||||
def _send(self, registry: dict[UUID, set[asyncio.Queue]], session_id: UUID,
|
||||
event: BaseModel) -> None:
|
||||
batch = self._event_batch.get()
|
||||
if isinstance(event, ErrorEvent):
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
elif (batch is not None and batch["session_id"] == session_id
|
||||
and batch["owner_task"] is _current_task()):
|
||||
batch["events"].append((registry, session_id, event))
|
||||
else:
|
||||
self._put(registry.get(session_id, set()), event)
|
||||
|
||||
async def fence(self, state: SessionState) -> None:
|
||||
"""Fail closed when durable ownership is lost or cannot be confirmed."""
|
||||
if state.lease_fenced:
|
||||
return
|
||||
state.lease_fenced = True
|
||||
self.stop_ticker(state.session_id)
|
||||
if state.voice is not None:
|
||||
try:
|
||||
await state.voice.close()
|
||||
except Exception as exc: # noqa: BLE001 — fencing must still close data channels
|
||||
log.error("не удалось закрыть голос при fencing занятия %s (%s)",
|
||||
state.session_id, type(exc).__name__)
|
||||
event = ErrorEvent(code=ErrorKind.INTERNAL, message=LEASE_FENCED_MESSAGE)
|
||||
self.broadcast(state.session_id, event)
|
||||
self.to_station(state.session_id, event)
|
||||
|
||||
# ── подписки ──
|
||||
|
||||
|
|
@ -115,13 +268,16 @@ class SessionHub:
|
|||
queues.discard(queue)
|
||||
|
||||
def to_observers(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._observers.get(session_id, set()), event)
|
||||
self._send(self._observers, session_id, event)
|
||||
|
||||
def to_trainee(self, session_id: UUID, event: BaseModel | bytes) -> None:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
if isinstance(event, BaseModel):
|
||||
self._send(self._trainees, session_id, event)
|
||||
else:
|
||||
self._put(self._trainees.get(session_id, set()), event)
|
||||
|
||||
def to_station(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self._put(self._stations.get(session_id, set()), event)
|
||||
self._send(self._stations, session_id, event)
|
||||
|
||||
def broadcast(self, session_id: UUID, event: BaseModel) -> None:
|
||||
self.to_trainee(session_id, event)
|
||||
|
|
@ -160,7 +316,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.exercise is Exercise.DDS:
|
||||
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios):
|
||||
from app.session.dds import deliver_due_cards
|
||||
|
||||
active_before = state.dds_active_card_id
|
||||
|
|
|
|||
|
|
@ -5,45 +5,99 @@
|
|||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
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, User, Utterance
|
||||
from app.db.models import AuditLog, 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__)
|
||||
LEASE_SECONDS = 15
|
||||
|
||||
|
||||
class SessionLeaseLost(RuntimeError):
|
||||
"""This process no longer owns the durable session generation."""
|
||||
|
||||
|
||||
class DbJournal:
|
||||
def __init__(self, sessionmaker: async_sessionmaker) -> None:
|
||||
def __init__(self, sessionmaker: async_sessionmaker, node_id: str | None = None) -> None:
|
||||
self._sessionmaker = sessionmaker
|
||||
self._node_id = node_id
|
||||
self._epochs: dict[UUID, int] = {}
|
||||
|
||||
async def _write(self, action, *args, **kwargs) -> None:
|
||||
async def _fence(self, db, session_id: UUID, expected_epoch: int | None = None) -> None:
|
||||
"""Renew and fence this write in the same transaction as its mutation."""
|
||||
if self._node_id is None:
|
||||
return
|
||||
epoch = expected_epoch if expected_epoch is not None else self._epochs.get(session_id)
|
||||
if epoch is None:
|
||||
raise SessionLeaseLost(f"session {session_id} has no local fencing epoch")
|
||||
now = now_utc()
|
||||
result = await db.execute(
|
||||
update(Session)
|
||||
.where(
|
||||
Session.id == session_id,
|
||||
Session.backend_node_id == self._node_id,
|
||||
Session.backend_fencing_epoch == epoch,
|
||||
)
|
||||
.values(backend_lease_until=now + timedelta(seconds=LEASE_SECONDS))
|
||||
.returning(Session.id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise SessionLeaseLost(f"session {session_id} owner epoch {epoch} was fenced")
|
||||
|
||||
async def _write(
|
||||
self, action, *args, _fence_session_id: UUID | None = None,
|
||||
_fence_epoch: int | None = None, _raise_errors: bool = False, **kwargs
|
||||
) -> None:
|
||||
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
|
||||
занятие идёт дальше, в логе остаётся след."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
if _fence_session_id is not None:
|
||||
await self._fence(db, _fence_session_id, _fence_epoch)
|
||||
await action(db, *args, **kwargs)
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: запись не удалась")
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
session_id = _fence_session_id or kwargs.get("session_id")
|
||||
log.error("журнал: запись не удалась для сессии %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
if _raise_errors:
|
||||
raise
|
||||
|
||||
async def start_lesson(
|
||||
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
||||
trainee_id: UUID | None = None, owner_login: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None]:
|
||||
backend_node_id: str | None = None,
|
||||
) -> tuple[int, UUID | None, str | None, int] | None:
|
||||
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
||||
|
||||
Если база недоступна, занятие всё равно идёт: номер попытки
|
||||
деградирует до первого, и это видно в логе.
|
||||
Строка сессии и событие аудита фиксируются вместе. При сбое транзакции
|
||||
занятие не запускается без долговечной истории.
|
||||
"""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
node_id = backend_node_id or self._node_id
|
||||
|
||||
def audit_start(transaction, row):
|
||||
if row.backend_fencing_epoch <= 0:
|
||||
row.backend_fencing_epoch = 1
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
transaction.add(AuditLog(
|
||||
actor=owner_login or "system",
|
||||
role="instructor" if owner_login else "system",
|
||||
action="lesson.start",
|
||||
object_id=str(row.id),
|
||||
detail=f"{scenario_id}, mode {mode}",
|
||||
))
|
||||
|
||||
row = await repo.ensure_session(
|
||||
db,
|
||||
session_id=session_id,
|
||||
|
|
@ -52,7 +106,11 @@ class DbJournal:
|
|||
trainee_name=trainee_name,
|
||||
trainee_id=trainee_id,
|
||||
owner_login=owner_login,
|
||||
backend_node_id=node_id,
|
||||
before_commit=audit_start,
|
||||
)
|
||||
epoch = getattr(row, "backend_fencing_epoch", 0) or 1
|
||||
self._epochs[session_id] = epoch
|
||||
service = None
|
||||
if row.trainee_id is not None:
|
||||
service = await db.scalar(
|
||||
|
|
@ -60,12 +118,13 @@ class DbJournal:
|
|||
.where(User.trainee_id == row.trainee_id, User.blocked.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return row.attempt, row.trainee_id, service
|
||||
return row.attempt, row.trainee_id, service, epoch
|
||||
except PermissionError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.exception("журнал: сессию завести не удалось")
|
||||
return 1, trainee_id, None
|
||||
except Exception as exc: # noqa: BLE001 — журнал не должен ронять живую сессию
|
||||
log.error("журнал: не удалось завести сессию %s (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return None
|
||||
|
||||
async def checkpoint(self, state: SessionState) -> None:
|
||||
"""Сохранить снимок после подтверждённого действия пользователя."""
|
||||
|
|
@ -78,25 +137,102 @@ class DbJournal:
|
|||
await db.execute(update(Session).where(Session.id == state.session_id).values(**values))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if state.backend_fencing_epoch > 0:
|
||||
self._epochs.setdefault(state.session_id, state.backend_fencing_epoch)
|
||||
await self._write(
|
||||
lambda db: action(db), _fence_session_id=state.session_id,
|
||||
_fence_epoch=state.backend_fencing_epoch or None,
|
||||
_raise_errors=True,
|
||||
)
|
||||
|
||||
async def restore_active(self) -> list[SessionState]:
|
||||
async def renew(self, session_id: UUID) -> None:
|
||||
"""Refresh an owned session lease; concurrent takeover is row-serialized."""
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
|
||||
async def claim_expired(self, session_id: UUID | None = None) -> list[SessionState]:
|
||||
"""Atomically fence and restore expired owners on this backend node."""
|
||||
if self._node_id is None:
|
||||
return []
|
||||
now = now_utc()
|
||||
conditions = [
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
(Session.backend_node_id.is_(None) | (Session.backend_node_id != self._node_id)),
|
||||
(Session.backend_lease_until.is_(None) | (Session.backend_lease_until <= now)),
|
||||
]
|
||||
if session_id is not None:
|
||||
conditions.append(Session.id == session_id)
|
||||
async with self._sessionmaker() as db:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*conditions).with_for_update(skip_locked=True).limit(100)
|
||||
)).all()
|
||||
for row in rows:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now + timedelta(seconds=LEASE_SECONDS)
|
||||
if rows:
|
||||
await db.commit()
|
||||
if not rows:
|
||||
return []
|
||||
return await self.restore_active(bump_owned_epoch=False)
|
||||
|
||||
async def restore_active(self, *, bump_owned_epoch: bool = True) -> 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()
|
||||
active_with_snapshot = (
|
||||
Session.ended_at.is_(None),
|
||||
Session.live_state.is_not(None),
|
||||
Session.checkpoint_at.is_not(None),
|
||||
)
|
||||
if self._node_id is not None:
|
||||
# Adopt legacy unassigned snapshots exactly once. Concurrent
|
||||
# nodes lock disjoint rows; subsequent restores are owner-only.
|
||||
unassigned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id.is_(None))
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
for row in unassigned:
|
||||
row.backend_node_id = self._node_id
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if unassigned:
|
||||
await db.commit()
|
||||
# A restarted process with the same stable node ID is a new
|
||||
# owner generation. Bump before exposing any restored state.
|
||||
owned = (await db.scalars(
|
||||
select(Session)
|
||||
.where(*active_with_snapshot, Session.backend_node_id == self._node_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)).all()
|
||||
if bump_owned_epoch:
|
||||
for row in owned:
|
||||
row.backend_fencing_epoch = max(1, row.backend_fencing_epoch + 1)
|
||||
row.backend_lease_until = now_utc() + timedelta(seconds=LEASE_SECONDS)
|
||||
if owned:
|
||||
await db.commit()
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(
|
||||
*active_with_snapshot,
|
||||
Session.backend_node_id == self._node_id,
|
||||
)
|
||||
)).all()
|
||||
else:
|
||||
rows = (await db.scalars(
|
||||
select(Session).where(*active_with_snapshot)
|
||||
)).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
|
||||
state.backend_fencing_epoch = row.backend_fencing_epoch
|
||||
self._epochs[row.id] = row.backend_fencing_epoch
|
||||
# Реплики пишутся отдельно сразу после появления. Если
|
||||
# процесс умер между репликой и общим снимком, отдельный
|
||||
# журнал не даёт потерять последний фрагмент диалога.
|
||||
|
|
@ -117,8 +253,9 @@ class DbJournal:
|
|||
for item in utterances
|
||||
]
|
||||
restored.append(state)
|
||||
except Exception: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.exception("журнал: снимок занятия %s повреждён", row.id)
|
||||
except Exception as exc: # noqa: BLE001 — один снимок не блокирует весь стенд
|
||||
log.error("журнал: снимок занятия %s повреждён (%s)",
|
||||
row.id, type(exc).__name__)
|
||||
return restored
|
||||
|
||||
async def utterance(self, session_id: UUID, entry) -> None:
|
||||
|
|
@ -130,53 +267,127 @@ class DbJournal:
|
|||
text=entry.text,
|
||||
at=entry.at,
|
||||
mood=entry.mood.value if entry.mood else None,
|
||||
_fence_session_id=session_id,
|
||||
)
|
||||
|
||||
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
|
||||
await self._write(
|
||||
repo.record_hint, session_id=session_id, checklist_id=checklist_id, question=question, at=at
|
||||
repo.record_hint, _fence_session_id=session_id, session_id=session_id,
|
||||
checklist_id=checklist_id, question=question, at=at
|
||||
)
|
||||
|
||||
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
|
||||
await self._write(repo.add_note, session_id=session_id, transcript_ref=ref, text=text, author=author)
|
||||
await self._write(
|
||||
repo.add_note, _fence_session_id=session_id, session_id=session_id,
|
||||
transcript_ref=ref, text=text, author=author
|
||||
)
|
||||
|
||||
async def self_assessment(
|
||||
self, session_id: UUID, missed: list[str], comment: str, at: datetime
|
||||
) -> None:
|
||||
async def action(db):
|
||||
db.add(
|
||||
SelfAssessment(
|
||||
) -> bool:
|
||||
"""Persist trainee reflection and its security audit together."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
session = await db.get(Session, session_id)
|
||||
if session is None:
|
||||
return False
|
||||
actor = "system"
|
||||
role = "system"
|
||||
if session.trainee_id is not None:
|
||||
login = await db.scalar(
|
||||
select(User.login).where(User.trainee_id == session.trainee_id)
|
||||
)
|
||||
if login:
|
||||
actor, role = login, "trainee"
|
||||
else:
|
||||
actor, role = f"trainee:{session.trainee_id}", "trainee"
|
||||
db.add(SelfAssessment(
|
||||
session_id=session_id, missed=missed, comment=comment, submitted_at=at
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor=actor,
|
||||
role=role,
|
||||
action="self_assessment.submit",
|
||||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(missed)}; comment_chars={len(comment)}",
|
||||
))
|
||||
await self._fence(db, session_id)
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not accept an unaudited reflection
|
||||
log.error("самооценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
|
||||
async def action(db):
|
||||
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
async def score(self, session_id: UUID, score_auto: float, report: dict) -> bool:
|
||||
"""Persist the initial result and its audit event atomically."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
db.add(Score(
|
||||
session_id=session_id, score_auto=score_auto,
|
||||
score_final=score_auto, report=report,
|
||||
))
|
||||
db.add(AuditLog(
|
||||
actor="system", role="system", action="score.calculate",
|
||||
object_id=str(session_id), detail=f"score_auto={score_auto}",
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — result is not complete until durable
|
||||
log.error("итоговая оценка и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_override(
|
||||
self, session_id: UUID, score_final: float, author: str, comment: str
|
||||
) -> None:
|
||||
"""Сохранить решение преподавателя рядом с неизменной автооценкой."""
|
||||
async def action(db):
|
||||
await db.execute(
|
||||
update(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.values(
|
||||
score_final=score_final,
|
||||
overridden_by=author,
|
||||
override_comment=comment,
|
||||
) -> bool:
|
||||
"""Persist a live correction and its security audit as one transaction."""
|
||||
try:
|
||||
async with self._sessionmaker() as db:
|
||||
await self._fence(db, session_id)
|
||||
score = await db.scalar(
|
||||
select(Score)
|
||||
.where(Score.session_id == session_id)
|
||||
.with_for_update()
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
if score is None:
|
||||
return False
|
||||
score.score_final = score_final
|
||||
score.overridden_by = author
|
||||
score.override_comment = comment
|
||||
report = dict(score.report or {})
|
||||
archived = report.get("full_report")
|
||||
if isinstance(archived, dict):
|
||||
archived = dict(archived)
|
||||
archived.update({
|
||||
"score_auto": score.score_auto,
|
||||
"score_final": score_final,
|
||||
"overridden_by": author,
|
||||
"override_comment": comment,
|
||||
})
|
||||
report["full_report"] = archived
|
||||
score.report = report
|
||||
db.add(AuditLog(
|
||||
actor=author,
|
||||
role="instructor",
|
||||
action="score.override",
|
||||
object_id=str(session_id),
|
||||
detail=(f"{score.score_auto} → {score_final}; "
|
||||
f"comment_chars={len(comment)}"),
|
||||
))
|
||||
await db.commit()
|
||||
return True
|
||||
except SessionLeaseLost:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — do not confirm a correction without its audit
|
||||
log.error("корректировка оценки и аудит сессии %s не сохранены (%s)",
|
||||
session_id, type(exc).__name__)
|
||||
return False
|
||||
|
||||
async def score_snapshot(self, session_id: UUID, report: dict) -> None:
|
||||
"""Обновить полный архивный разбор после самооценки курсанта."""
|
||||
|
|
@ -186,14 +397,14 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
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))
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
||||
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
|
||||
async def action(db):
|
||||
|
|
@ -209,4 +420,4 @@ class DbJournal:
|
|||
)
|
||||
await db.commit()
|
||||
|
||||
await self._write(lambda db: action(db))
|
||||
await self._write(lambda db: action(db), _fence_session_id=session_id)
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ from app.domain.events import (
|
|||
)
|
||||
from app.domain.kio import KIO, ResponseStatus, apply_patch
|
||||
from app.domain.statuses import (
|
||||
CardStatus,
|
||||
NEXT,
|
||||
CardStatus,
|
||||
DdsCardSummary,
|
||||
DdsQueueCard,
|
||||
PhoneCallPending,
|
||||
|
|
@ -62,11 +62,20 @@ class DdsCardRecord:
|
|||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]]
|
||||
duration_ms: int
|
||||
title: str | None = None
|
||||
address: str | None = None
|
||||
description: str | None = None
|
||||
incident_type: str | None = None
|
||||
victims_count: int | None = None
|
||||
received_at: datetime | None = None
|
||||
managed_service: str | None = None
|
||||
recipient_services: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def score_auto(self) -> float:
|
||||
total = sum(metric.weight for metric in self.metrics)
|
||||
passed = sum(metric.weight for metric in self.metrics if metric.passed)
|
||||
passed = sum(metric.weight * (metric.credit if metric.credit is not None
|
||||
else float(metric.passed)) for metric in self.metrics)
|
||||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
|
|
@ -106,6 +115,10 @@ class SessionState:
|
|||
mode: SessionMode
|
||||
#: Преподаватель, создавший занятие; чужой пульт не может им управлять.
|
||||
owner_login: str | None = None
|
||||
#: Monotonic DB ownership generation; stale processes may not persist writes.
|
||||
backend_fencing_epoch: int = 0
|
||||
#: Runtime-only: set when this process loses or cannot confirm DB ownership.
|
||||
lease_fenced: bool = False
|
||||
exercise: Exercise = Exercise.CALL
|
||||
#: После заполнения КИО занятие продолжится на АРМ ДДС, а не завершится.
|
||||
handoff_to_dds: bool = False
|
||||
|
|
@ -169,6 +182,11 @@ class SessionState:
|
|||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||
phone_pending: PhoneCallPending | None = None
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
pending_dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной
|
||||
#: карточки ДДС, которая может переключаться по очереди.
|
||||
operator_kio: KIO | None = None
|
||||
operator_scenario: Scenario | None = None
|
||||
dds_live_cards: list[DdsLiveCard] = field(default_factory=list)
|
||||
dds_active_card_id: UUID | None = None
|
||||
dds_card_index: int = 0
|
||||
|
|
@ -183,6 +201,10 @@ class SessionState:
|
|||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
#: Recently committed DDS command IDs; included in the durable checkpoint so
|
||||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||||
processed_station_commands: list[str] = field(default_factory=list)
|
||||
text_revealed_facts: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def on_event(self, event_type: str) -> None:
|
||||
"""Единственная точка, где событие двигает таймеры."""
|
||||
|
|
@ -391,6 +413,11 @@ class SessionState:
|
|||
log=list(self.status_log),
|
||||
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,
|
||||
zone_decision=(
|
||||
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log)
|
||||
if action == "zone.decision"), None)
|
||||
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,
|
||||
|
|
@ -422,6 +449,7 @@ class SessionState:
|
|||
return CardReceived(
|
||||
card=self.dispatched_card,
|
||||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
or (self.handoff_to_dds and self.dds_card_index > 0)
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
card_index=self.dds_card_index + 1,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
|||
"call.incoming": (TimerCode.ANSWER,),
|
||||
"call.answer": (TimerCode.INTERVIEW,),
|
||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||
"dds.open": (TimerCode.DDS_WORK,),
|
||||
"card.start": (TimerCode.CARD_FILL,),
|
||||
"card.received": (TimerCode.ZONE_CHECK,),
|
||||
"call.dropped": (TimerCode.CALLBACK,),
|
||||
"callback.dial": (TimerCode.CALLBACK,),
|
||||
|
|
@ -38,6 +40,10 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
|||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||
"call.resolve": (TimerCode.INTERVIEW,),
|
||||
"card.ack": (TimerCode.DDS_ACK,),
|
||||
"card.submit": (TimerCode.CARD_FILL,),
|
||||
"card.end": (TimerCode.CARD_FILL,),
|
||||
"dds.complete": (TimerCode.DDS_WORK,),
|
||||
"dds.finish": (TimerCode.DDS_WORK,),
|
||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||
"crew.arrived": (TimerCode.CLOSE,),
|
||||
"call.started": (TimerCode.CALLBACK,),
|
||||
|
|
|
|||
Loading…
Reference in a new issue