Implement DDS exercise, customer UI and local demo
This commit is contained in:
parent
cec84ffcd0
commit
0526b11f91
46 changed files with 2504 additions and 253 deletions
50
backend/app/session/dds.py
Normal file
50
backend/app/session/dds.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Выдача очередной учебной карточки ДДС в рамках одного занятия."""
|
||||
|
||||
import re
|
||||
|
||||
from app.domain.kio import KIO, ResponseStatus
|
||||
from app.scenarios.schema import Scenario
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.timers import SessionTimers
|
||||
|
||||
|
||||
def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
||||
"""Сменить активную карточку, сохранив общий ID занятия и прошлые результаты."""
|
||||
state.scenario = scenario.model_copy(deep=True)
|
||||
state.scenario_id = scenario.id
|
||||
state.scenario_title = scenario.title
|
||||
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), "")
|
||||
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",
|
||||
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,
|
||||
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)
|
||||
else:
|
||||
state.kio.response_status = ResponseStatus.TRANSFERRED
|
||||
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()
|
||||
state.crew_selected = None
|
||||
state.crew_assignments.clear()
|
||||
state.phone_reports.clear()
|
||||
state.reply_text = ""
|
||||
state.reply_log.clear()
|
||||
state.timers = SessionTimers()
|
||||
state.on_event("dds.dispatch")
|
||||
|
|
@ -10,18 +10,82 @@
|
|||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import Exercise, ScoreReady
|
||||
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.scenarios import store
|
||||
from app.scoring.competency import radar
|
||||
from app.scoring.card import evaluate_card
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
from app.scoring.gost import GostResult, evaluate
|
||||
from app.scoring.weights import apply_weights
|
||||
from app.session.hub import hub
|
||||
from app.session.state import DdsCardRecord, now_utc
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def score_current_dds(state) -> DdsCardRecord:
|
||||
"""Оценить активную карточку отдельно, до выдачи следующей."""
|
||||
number = state.dds_card_index + 1
|
||||
findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
deadline_ms=NORMATIVES[TimerCode.DDS_ACK].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,
|
||||
))
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
apply_weights(weighted, state.scenario.score_weights)
|
||||
actions = [
|
||||
{"type": "card.status", "service": mark.service, "status": mark.status.value,
|
||||
"comment": mark.comment, "at": mark.at.isoformat()}
|
||||
for mark in state.status_log
|
||||
] + [
|
||||
{"type": "phone.report", "service": report.service, "crew": report.crew,
|
||||
"phase": report.phase, "text": report.text, "at": report.at.isoformat()}
|
||||
for report in state.phone_reports
|
||||
] + [
|
||||
{"type": "card.reply", "text": text, "at": at.isoformat()}
|
||||
for at, text in state.reply_log
|
||||
] + [
|
||||
{"type": kind, "text": text or "", "at": at.isoformat()}
|
||||
for kind, at, text in state.dds_log
|
||||
]
|
||||
actions.sort(key=lambda item: item["at"])
|
||||
return DdsCardRecord(
|
||||
card_id=state.dispatched_card.card_id,
|
||||
scenario_id=state.scenario_id,
|
||||
reply_text=state.reply_text,
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
||||
for metric in weighted.metrics],
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
||||
for finding in weighted.findings],
|
||||
actions=actions,
|
||||
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000))
|
||||
if state.dispatched_at else 0),
|
||||
)
|
||||
|
||||
|
||||
async def finish(session_id: UUID, state) -> None:
|
||||
# Сценарий занятия, а не библиотечный: директивы могли поправить эталон.
|
||||
scenario = state.scenario or store.get(state.scenario_id)
|
||||
|
|
@ -31,9 +95,22 @@ 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:
|
||||
# В готовой карточке не было входящего звонка, опроса и действий
|
||||
# оператора 112. Ни одна E-метрика здесь не применима.
|
||||
# В ДДС могут последовательно пройти несколько карточек. Уже закрытые
|
||||
# сохранены отдельными результатами; активную оцениваем лишь однажды.
|
||||
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
|
||||
):
|
||||
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,
|
||||
|
|
@ -49,7 +126,7 @@ async def finish(session_id: UUID, state) -> None:
|
|||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
|
||||
if state.dispatched_card is not None and state.exercise is not Exercise.CARD:
|
||||
if state.dispatched_card is not None and state.exercise is Exercise.CALL:
|
||||
dispatcher_findings = evaluate_dispatcher(
|
||||
entries=state.status_log,
|
||||
services=state.notified_services(),
|
||||
|
|
@ -58,6 +135,8 @@ async def finish(session_id: UUID, state) -> None:
|
|||
)
|
||||
result.findings.extend(dispatcher_findings)
|
||||
result.metrics.extend(dispatcher_metrics(state, NORMATIVES[TimerCode.DDS_ACK].limit_ms))
|
||||
if state.exercise is not Exercise.DDS:
|
||||
apply_weights(result, scenario.score_weights)
|
||||
|
||||
# Сводка числами: по ней считается дельта между попытками в профиле.
|
||||
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
|
||||
|
|
@ -82,6 +161,14 @@ async def finish(session_id: UUID, state) -> None:
|
|||
"unavailable": result.unavailable,
|
||||
# Статус карточки — готовая красная метка, понятная любому диспетчеру.
|
||||
"card_status": state.station_snapshot().card.value,
|
||||
"card_results": [
|
||||
{"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,
|
||||
"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 [],
|
||||
}
|
||||
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,24 @@ class DbJournal:
|
|||
|
||||
await self._write(lambda db: action(db))
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.domain.events import (
|
|||
Mood,
|
||||
SessionMode,
|
||||
SessionSnapshot,
|
||||
Metric,
|
||||
Speaker,
|
||||
TranscriptEntry,
|
||||
)
|
||||
|
|
@ -29,6 +30,7 @@ from app.domain.statuses import (
|
|||
StationSnapshot,
|
||||
StatusEntry,
|
||||
PhoneReportRecord,
|
||||
DdsCardSummary,
|
||||
card_status,
|
||||
check,
|
||||
current,
|
||||
|
|
@ -36,6 +38,7 @@ from app.domain.statuses import (
|
|||
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
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
|
|
@ -43,6 +46,23 @@ def now_utc() -> datetime:
|
|||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DdsCardRecord:
|
||||
card_id: UUID
|
||||
scenario_id: str
|
||||
reply_text: str
|
||||
metrics: list[Metric]
|
||||
findings: list[Finding]
|
||||
actions: list[dict[str, Any]]
|
||||
duration_ms: int
|
||||
|
||||
@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)
|
||||
return round(100 * passed / total, 1) if total else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
session_id: UUID
|
||||
|
|
@ -102,6 +122,11 @@ class SessionState:
|
|||
crew_selected: str | None = None
|
||||
crew_assignments: dict[str, str] = field(default_factory=dict)
|
||||
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
|
||||
dds_scenarios: list[Scenario] = field(default_factory=list)
|
||||
dds_card_index: int = 0
|
||||
dds_completed: list[DdsCardRecord] = field(default_factory=list)
|
||||
reply_text: str = ""
|
||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||
resolved_outcome: str | None = None
|
||||
resolve_comment: str = ""
|
||||
|
|
@ -180,6 +205,14 @@ class SessionState:
|
|||
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,
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
reply_text=self.reply_text,
|
||||
completed_cards=[DdsCardSummary(card_id=item.card_id,
|
||||
scenario_id=item.scenario_id,
|
||||
score_auto=item.score_auto)
|
||||
for item in self.dds_completed],
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -191,6 +224,8 @@ class SessionState:
|
|||
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
|
||||
else self.trainee_name or "оператор 112"),
|
||||
at=self.dispatched_at or now_utc(),
|
||||
card_index=self.dds_card_index + 1,
|
||||
card_total=len(self.dds_scenarios) or 1,
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
|
|||
Loading…
Reference in a new issue