lct-55: ошибка адреса и сообщение в 112

This commit is contained in:
kaifarikman 2026-09-28 00:55:28 +03:00 • committed by gglamer
commit f0c9015f2b
14 changed files with 267 additions and 7 deletions

View file

@ -678,6 +678,22 @@ class CardReply(BaseModel):
text: str = Field(max_length=2000)
class CardErrorReport112(BaseModel):
"""Сообщение диспетчера в 112 после доклада бригады с места."""
type: Literal["card.error_report_112"] = "card.error_report_112"
card_id: UUID
text: str = Field(min_length=10, max_length=500)
@field_validator("text")
@classmethod
def meaningful_text(cls, value: str) -> str:
value = value.strip()
if len(value) < 10:
raise ValueError("Укажите, что сообщено оператору 112")
return value
class CardNext(BaseModel):
type: Literal["card.next"] = "card.next"
card_id: UUID
@ -721,7 +737,7 @@ ServerToStation = Annotated[
StationToServer = Annotated[
CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck
| PhoneHangup | PhoneReply | AdjacentDial | StationFinish
| CardReply | CardNext | CardOpen
| CardReply | CardErrorReport112 | CardNext | CardOpen
| ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]

View file

@ -288,6 +288,8 @@ class StationSnapshot(BaseModel):
phone_reports: list[PhoneReportRecord] = []
phone_lines: list[PhoneLineRecord] = []
phone_pending: PhoneCallPending | None = None
card_error_discovered: bool = False
report_112_text: str | None = None
card_id: UUID | None = None
card_index: int = 1
card_total: int = 1

View file

@ -5,12 +5,15 @@
на старте приложения, а не тихо игнорироваться и всплывать посреди занятия.
"""
from pydantic import BaseModel, ConfigDict, Field, model_validator
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.domain.classifiers import DDSCode, IncidentType, Level, Outcome
from app.domain.events import Mood
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO
from app.domain.statuses import NEXT, SERVICE_STATUS_LABELS, ServiceStatus
from app.scoring.address import address_matches
from app.scoring.taxonomy import METRIC_MAP
@ -174,6 +177,21 @@ FALLBACK_NOTIFY: dict[str, str] = {
}
class DdsCardError(Strict):
"""Заложенное расхождение готовой карточки с эталоном для занятия ДДС."""
field: Literal["address"]
card_value: str = Field(min_length=3)
@field_validator("card_value")
@classmethod
def nonempty_card_value(cls, value: str) -> str:
value = value.strip()
if len(value) < 3:
raise ValueError("dds_card_error.card_value: укажите неверный адрес карточки")
return value
class Scenario(Strict):
id: str
title: str
@ -202,6 +220,7 @@ class Scenario(Strict):
# справка и передача в другой регион разбираются в lct-36.
outcome: Outcome = Outcome.CARD
dds_decision: DdsDecision = DdsDecision()
dds_card_error: DdsCardError | None = None
# Готовая КИО, сформированная курсантом и утверждённая преподавателем.
# Для системных сценариев поле отсутствует; ДДС использует снимок как
@ -258,6 +277,19 @@ class Scenario(Strict):
)
return self
@model_validator(mode="after")
def valid_dds_card_error(self):
error = self.dds_card_error
if error is None:
return self
if not self.ground_truth.address or not self.ground_truth.address.strip():
raise ValueError("dds_card_error: для ошибки адреса нужен ground_truth.address")
if self.student_card is not None:
raise ValueError("dds_card_error: используйте отдельный учебный сценарий без student_card")
if address_matches(self.ground_truth.address, error.card_value):
raise ValueError("dds_card_error: адрес карточки должен отличаться от эталона")
return self
@model_validator(mode="after")
def unique_reference_ids(self):
for label, values in (

View file

@ -99,6 +99,11 @@ def to_csv(report: SessionReport) -> bytes:
row("Сверка самооценки", "", label, ", ".join(values))
for number, item in enumerate(report.notes, 1):
row("Пометки преподавателя", number, item.transcript_ref, item.text, item.author)
for card_number, card in enumerate(report.card_results, 1):
for action in card.actions:
if action.get("type") == "card.error_report_112":
row("Сообщение в 112", card_number, "Ошибка карточки",
action.get("text", ""), action.get("at", ""))
return ("\N{ZERO WIDTH NO-BREAK SPACE}" + output.getvalue()).encode("utf-8")
@ -259,6 +264,17 @@ def to_pdf(report: SessionReport) -> bytes:
for item in report.notes:
story.append(p(f"{item.transcript_ref} - {item.author}: {item.text}"))
messages_112 = [
(number, action)
for number, card in enumerate(report.card_results, 1)
for action in card.actions
if action.get("type") == "card.error_report_112"
]
if messages_112:
section("Сообщения в 112 об ошибках карточек")
for number, action in messages_112:
story.append(p(f"Карточка {number}, {action.get('at', '')}: {action.get('text', '')}"))
output = io.BytesIO()
document = SimpleDocTemplate(
output, pagesize=A4, leftMargin=42, rightMargin=42,

View file

@ -195,6 +195,9 @@ def build_card(
)
if service:
kio = apply_patch(kio, {"dds": service})
if scenario.dds_card_error is not None:
# Ошибка относится к выданному снимку; эталон сценария остаётся верным.
kio = kio.model_copy(update={"address": scenario.dds_card_error.card_value})
kio.response_status = ResponseStatus.TRANSFERRED
timers = SessionTimers(limits=dict(limits))
timers.on_event("dds.dispatch")
@ -401,7 +404,14 @@ def _deliver_card_adjacent(state: "SessionState", card: DdsLiveCard, now: dateti
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
call = card.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
error = card.scenario.dds_card_error
if call.phase == "arrived" and error is not None and error.field == "address":
actual = card.scenario.ground_truth.address
text = (f"{call.crew}: прибыли по адресу {actual}. "
f"В карточке указан {card.dispatched_card.address}; "
"адрес не совпадает. Сообщите об ошибке в 112.")
else:
text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
line = _line(card, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
@ -749,6 +759,16 @@ class DdsDesk(BaseModel):
return DeskOutcome()
card.reply_text = command.text
card.reply_log.append((now_utc(), command.text))
case "card.error_report_112":
if command.card_id != card.card_id:
return _refused("Сообщение относится не к текущей карточке")
if card.scenario.dds_card_error is None or not any(
report.phase == "arrived" for report in card.phone_reports
):
return _refused("Сначала получите доклад бригады с места об ошибке карточки")
if any(action == "card.error_report_112" for action, _at, _detail in card.dds_log):
return DeskOutcome()
card.dds_log.append(("card.error_report_112", now_utc(), command.text.strip()))
case "card.open":
if not dds_lesson or not self.open(command.card_id):
return _refused("Карточка отсутствует в текущей очереди")

View file

@ -436,6 +436,13 @@ class SessionState(PersistedSession):
phone_reports=list(card.phone_reports) if card is not None else [],
phone_lines=list(card.phone_lines) if card is not None else [],
phone_pending=card.phone_pending if card is not None else None,
card_error_discovered=(card.scenario.dds_card_error is not None and any(
report.phase == "arrived" for report in card.phone_reports
)) if card is not None else False,
report_112_text=(next(
(detail for action, _at, detail in reversed(card.dds_log)
if action == "card.error_report_112"), None,
) if card is not None else None),
card_id=card.card_id if card is not None else None,
card_index=(desk.card_index + 1 if card is not None
else min(desk.next_index + 1, len(desk.scenarios))),

View file

@ -2,7 +2,7 @@
import asyncio
import time
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
@ -17,6 +17,7 @@ from app.domain.timers import TimerCode
from app.main import app
from app.scenarios import store
from app.session.hub import hub
from app.session.checkpoint import dump_state, load_state
from app.session.store import MemorySessionStore
@ -836,6 +837,76 @@ def send_status(station, service, status):
read_until(station, "station.state")
def test_crew_discovers_card_address_error_and_dispatcher_reports_it_to_112(client):
session_id, control = start(client, scenario_id="dds-card-address-error")
try:
state = hub.get(session_id)
card = state.desk.active
assert card.dispatched_card.address == "улица Ленина, 16"
assert card.scenario.ground_truth.address == "улица Ленина, 14"
with client.websocket_connect(f"/ws/station/{session_id}") as station:
service = accept_with_crew(station)
station.send_json({
"type": "card.error_report_112", "card_id": str(card.card_id),
"text": "В карточке неверный адрес",
})
assert "Сначала получите доклад" in read_until(station, "error")["message"]
complete_phone_call(station, state, "dispatched")
read_until(station, "station.state")
send_status(station, service, "responding")
report = complete_phone_call(station, state, "arrived")
snapshot = read_until(station, "station.state")["snapshot"]
assert "улица Ленина, 14" in report["text"]
assert "улица Ленина, 16" in report["text"]
assert snapshot["card_error_discovered"] is True
assert snapshot["report_112_text"] is None
message = "В карточке дом 16; бригада на месте установила дом 14."
station.send_json({
"type": "card.error_report_112", "card_id": str(card.card_id), "text": message,
})
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["report_112_text"] == message
station.send_json({
"type": "card.error_report_112", "card_id": str(card.card_id), "text": message,
})
assert sum(action == "card.error_report_112" for action, _, _ in card.dds_log) == 1
active = client.get("/api/sessions/active").json()
teacher = next(row for row in active if row["session_id"] == str(session_id))
assert teacher["dds_snapshot"]["report_112_text"] == message
restored = load_state(dump_state(state), datetime.now(UTC))
assert restored.station_snapshot().report_112_text == message
station.send_json({"type": "station.finish"})
read_until(station, "score.ready")
report = client.get(f"/api/sessions/{session_id}/report").json()
actions = report["card_results"][0]["actions"]
assert any(action["type"] == "card.error_report_112" and action["text"] == message
for action in actions)
assert message in client.get(f"/api/sessions/{session_id}/report.csv").text
finally:
control.__exit__(None, None, None)
def test_report_to_112_is_unavailable_without_planted_card_error(client):
session_id, control = start(client)
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
read_until(station, "card.received")
snapshot = read_until(station, "station.state")["snapshot"]
assert snapshot["card_error_discovered"] is False
station.send_json({
"type": "card.error_report_112", "card_id": str(state.desk.active.card_id),
"text": "Сообщаю об ошибке карточки в 112",
})
assert "Сначала получите доклад" in read_until(station, "error")["message"]
assert not state.desk.active.dds_log
finally:
control.__exit__(None, None, None)
@pytest.mark.parametrize(("reaction", "passed"), [
("fast", True), ("slow", False), ("missing", False), ("early", True),
])

View file

@ -16,7 +16,7 @@ from fastapi.testclient import TestClient
from app.api.auth import Principal
from app.api.http import sessions
from app.config import get_settings
from app.domain.events import SessionReport
from app.domain.events import DdsCardReport, SessionReport
from app.domain.roles import Role
from app.main import app
from app.scoring.export import _cell, certificate_pdf, to_csv, to_pdf
@ -83,6 +83,31 @@ def test_csv_shows_enabled_report_reaction_limit():
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
def test_card_error_message_to_112_is_in_csv_and_pdf(tmp_path):
report = sample_report()
message = "В карточке дом 16; по докладу бригады фактически дом 14."
report.card_results = [DdsCardReport(
card_id=uuid4(), scenario_id="dds-card-address-error", score_auto=100,
reply_text="", metrics=[], findings=[], actions=[{
"type": "card.error_report_112", "text": message,
"at": "2026-09-21T12:05:00+00:00",
}],
)]
rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
assert any(row[0] == "Сообщение в 112" and row[3] == message for row in rows)
import shutil
import subprocess
if shutil.which("pdftotext"):
path = tmp_path / "dds-error.pdf"
path.write_bytes(to_pdf(report))
extracted = subprocess.check_output(["pdftotext", str(path), "-"], text=True)
assert "Сообщения в 112 об ошибках карточек" in extracted
assert message in extracted
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-")

View file

@ -236,8 +236,8 @@ def test_dispatcher_metric_dds_ack_excludes_pause_from_elapsed(clock):
clock.advance(60)
state.resume()
clock.advance(15)
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
card.on_event("dds.open")
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято")
metric = next(m for m in dispatcher_metrics(card, 30_000, services=services) if m.key == "dds_ack")
assert metric.passed is True

View file

@ -103,6 +103,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{card.address ? ` · ${card.address}` : ""}</summary>
{card.managed_service && <p><strong>Ответственная служба:</strong> {card.managed_service}</p>}
<p><strong>Ответ диспетчера:</strong> {card.reply_text || "не внесён"}</p>
{(card.actions ?? []).filter((action) => action.type === "card.error_report_112").map((action, actionIndex) =>
<p key={`report-112-${actionIndex}`}><strong>Сообщение об ошибке в 112:</strong> {String(action.text ?? "")}</p>)}
{card.metrics.some((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)) && <ul>
{card.metrics.filter((metric) => !metric.passed || (metric.credit != null && metric.credit < 1)).map((metric) =>
<li key={metric.key} className={metric.passed ? "ref" : "state-violated"}>

View file

@ -242,6 +242,7 @@ export function Dds() {
const [crewReplyText, setCrewReplyText] = useState("");
const [adjacentTarget, setAdjacentTarget] = useState("");
const [adjacentText, setAdjacentText] = useState("");
const [report112Text, setReport112Text] = useState("");
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
@ -295,6 +296,7 @@ export function Dds() {
setCrewReplyText("");
setAdjacentTarget("");
setAdjacentText("");
setReport112Text("");
setZone(null);
setScoreReady(false);
setView(openCardAfterSwitch.current ? "card" : "registry");
@ -719,6 +721,20 @@ export function Dds() {
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
</div>}
</>}
{station?.card_error_discovered && <div className="dds-phone-form">
<h3>Сообщение об ошибке карточки в 112</h3>
{station.report_112_text ? <p>Сообщено в 112: {station.report_112_text}</p> : <>
<p>После доклада бригады сообщите оператору 112, что указано в карточке и что установлено на месте.</p>
<label htmlFor="dds-report-112">Содержание сообщения</label>
<textarea id="dds-report-112" rows={3} value={report112Text}
disabled={!isConnected || ended || paused}
onChange={(event) => setReport112Text(event.target.value)} />
<button type="button" disabled={!cardId || !isConnected || ended || paused || report112Text.trim().length < 10}
onClick={() => send({ type: "card.error_report_112", card_id: cardId, text: report112Text })}>
Зафиксировать сообщение в 112
</button>
</>}
</div>}
</section>}
{pane === "reply" && <section className="dds-panel dds-reply">
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>

View file

@ -393,6 +393,9 @@ export function Instructor() {
<small>{report.text}</small>
</p>)}
</div> : null}
{item.dds_snapshot.report_112_text && <p className="active-session-log">
<strong>Сообщено в 112 об ошибке карточки:</strong> {item.dds_snapshot.report_112_text}
</p>}
</details>}</td>
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>

View file

@ -108,6 +108,13 @@ export interface CardBriefing {
handoff_to_dds?: boolean;
}
/** Сообщение диспетчера в 112 после доклада бригады с места. */
export interface CardErrorReport112 {
type: "card.error_report_112";
card_id: string;
text: string;
}
export interface CardNext {
type: "card.next";
card_id: string;
@ -719,6 +726,8 @@ export interface StationSnapshot {
phone_reports?: Array<PhoneReportRecord>;
phone_lines?: Array<PhoneLineRecord>;
phone_pending?: PhoneCallPending | null;
card_error_discovered?: boolean;
report_112_text?: string | null;
card_id?: string | null;
card_index?: number;
card_total?: number;
@ -847,7 +856,7 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardErrorReport112 | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"adjacent.dial"
@ -864,6 +873,7 @@ export type EventName =
| "card.ack"
| "card.bounce"
| "card.briefing"
| "card.error_report_112"
| "card.next"
| "card.open"
| "card.received"

View file

@ -0,0 +1,40 @@
# Учебная ошибка уже переданной КИО: бригада обнаруживает верный адрес на месте.
# Курсанту ДДС нужно сообщить расхождение оператору 112; автооценки этого шага нет.
id: dds-card-address-error
title: "Ошибка адреса в готовой карточке пожара"
type: fire
level: L2
topics: [fires]
modes: [training]
signs: [жилой дом, балкон, открытое пламя]
persona:
base: worried
arc:
- { stage: registration, mood: worried }
first_line: "На балконе пожар, приезжайте на улицу Ленина, дом 14."
facts:
- id: f_address
value: "улица Ленина, 14, квартира 47"
reveal_on: { question: q_address }
- id: f_what_burns
value: "горит балкон, жильцы выходят из квартиры"
reveal_on: { question: q_what_burns }
checklist:
- { id: q_address, fact: f_address, question: "Уточните адрес происшествия" }
- { id: q_what_burns, fact: f_what_burns, question: "Что именно горит?" }
required_fields: [address, incident_type, description]
ground_truth:
address: "улица Ленина, 14"
victims: 0
dds_card_error:
field: address
card_value: "улица Ленина, 16"
tree:
pregenerated: false