fix: норматив доклада только по выбору

This commit is contained in:
kaifarikman 2026-09-27 23:37:57 +03:00
commit 5e459c449b
14 changed files with 100 additions and 21 deletions

View file

@ -54,6 +54,7 @@ class LessonCriteria(BaseModel):
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
dds_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_enabled: bool = False
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True

View file

@ -200,6 +200,7 @@ def evaluate_dispatcher(
expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -392,6 +393,7 @@ def evaluate_dispatcher(
)
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
if report_reaction_enabled:
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None and reaction[1] is not None:
findings.append(reaction[1])
@ -407,6 +409,7 @@ def dispatcher_metrics(
*,
services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
report_reaction_enabled: bool = False,
pause_intervals: Sequence[tuple[datetime, datetime]] | None = None,
) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -441,6 +444,7 @@ def dispatcher_metrics(
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in card.phone_reports if report.service == service]
if report_reaction_enabled:
reaction = report_reaction(service, marks, reports, report_reaction_ms, pause_intervals)
if reaction is not None:
metrics.append(reaction[0])

View file

@ -55,7 +55,8 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Попытка", report.attempt)
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Лимит реакции на доклад бригады, с",
report.criteria.dds_report_reaction_limit_seconds)
report.criteria.dds_report_reaction_limit_seconds
if report.criteria.dds_report_reaction_enabled else "не применяется")
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
@ -172,7 +173,9 @@ def to_pdf(report: SessionReport) -> bytes:
("Режим", report.mode.value),
("Попытка", report.attempt),
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Лимит реакции на доклад бригады", f"{report.criteria.dds_report_reaction_limit_seconds} с"),
("Лимит реакции на доклад бригады",
f"{report.criteria.dds_report_reaction_limit_seconds} с"
if report.criteria.dds_report_reaction_enabled else "не применяется"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),

View file

@ -61,11 +61,13 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
pause_intervals=state.pause_intervals,
)
weighted = GostResult(metrics=metrics, findings=findings)

View file

@ -234,11 +234,13 @@ async def score_session(
expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
pause_intervals=snapshot.pause_intervals,
))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой

View file

@ -455,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180,
"dds_report_reaction_enabled": False,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50,
"require_correct_grammar": False,
@ -749,7 +750,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
session_id, control = start(client)
session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -835,6 +836,7 @@ def send_status(station, service, status):
def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, passed):
"""Лимит из `scenario.start` доходит до метрики, отметки D6 и отчёта."""
session_id, control = start(client, criteria={
"dds_report_reaction_enabled": True,
"dds_report_reaction_limit_seconds": 20,
"score_weights": {"dds_report_reaction": 3.5},
})
@ -876,6 +878,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"]["dds_report_reaction_limit_seconds"] == 20
assert report["criteria"]["dds_report_reaction_enabled"] is True
assert [item for item in report["metrics"]
if item["key"] == "dds_report_reaction"] == metrics
if reaction == "slow":
@ -889,7 +892,7 @@ def test_dds_report_reaction_goes_from_criteria_to_report(client, reaction, pass
def test_refusal_after_crew_report_does_not_lower_score_without_finding(client):
"""Бригада доложила о прибытии, но работы не проводились: отказ с причиной
— терминальный исход, неотмеченное «Прибытие» не штрафуется."""
session_id, control = start(client)
session_id, control = start(client, criteria={"dds_report_reaction_enabled": True})
try:
state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}") as station:

View file

@ -324,10 +324,12 @@ def reaction(status_log, reports, limit_ms=45_000, pause_intervals=None):
entries=status_log, services=SERVICES, crew_assignments=card.crew_assignments,
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=reports,
report_reaction_ms=limit_ms,
report_reaction_enabled=True,
pause_intervals=pause_intervals,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms,
report_reaction_enabled=True,
pause_intervals=pause_intervals)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric

View file

@ -68,11 +68,21 @@ def test_csv_contains_sections_and_blocks_formula_injection():
assert any(row[0] == "Занятие" and row[3] == "'=1+1" for row in rows)
assert any(row[0] == "Ошибки" and row[3] == "'+HYPERLINK(\"x\")" for row in rows)
assert any(row[0] == "Самооценка" and row[3] == "'@SUM(1,2)" for row in rows)
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "45", ""] in rows
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "не применяется", ""] in rows
assert _cell(" =cmd") == "' =cmd"
assert _cell("\tОбычный текст") == "'\tОбычный текст"
def test_csv_shows_enabled_report_reaction_limit():
report = sample_report()
report.criteria.dds_report_reaction_enabled = True
report.criteria.dds_report_reaction_limit_seconds = 20
rows = list(csv.reader(io.StringIO(to_csv(report).decode("utf-8-sig"))))
assert ["Критерии", "", "Лимит реакции на доклад бригады, с", "20", ""] in rows
def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
data = to_pdf(sample_report(long=True))
assert data.startswith(b"%PDF-")
@ -91,6 +101,7 @@ def test_pdf_supports_cyrillic_and_spans_pages(tmp_path):
assert "Адрес должен быть уточнён" in extracted
assert "Заявитель сообщает о дыме" in extracted
assert "Лимит реакции на доклад бригады" in extracted
assert "не применяется" in extracted
def test_certificate_pdf_contains_saved_result(tmp_path):

View file

@ -88,6 +88,7 @@ def test_active_dds_session_round_trips_without_losing_work():
def test_report_reaction_keeps_times_and_limit_after_restore():
before = dds_state()
before.criteria.dds_report_reaction_enabled = True
before.criteria.dds_report_reaction_limit_seconds = 20
card = before.desk.active
service = before.card_services(card)[0]
@ -102,6 +103,7 @@ def test_report_reaction_keeps_times_and_limit_after_restore():
restored = load_state(dump_state(before), now_utc())
assert restored.criteria.dds_report_reaction_limit_seconds == 20
assert restored.criteria.dds_report_reaction_enabled
assert restored.desk.active.phone_reports == card.phone_reports
def reaction(state):
@ -118,6 +120,31 @@ def test_report_reaction_keeps_times_and_limit_after_restore():
assert reaction(restored) == reaction(before), "оценка после восстановления та же"
def test_report_reaction_default_off_keeps_status_completeness_d6():
state = dds_state()
card = state.desk.active
service = state.card_services(card)[0]
card.set_service_status(service, ServiceStatus.RESPONDING,
"Основание: доклад.\nСведения: бригада выехала.")
without_report = state.model_copy(deep=True)
card.phone_reports.append(PhoneReportRecord(
service=service, crew=card.crew_selected, phase="dispatched",
text="Бригада выехала", at=card.status_log[-1].at - timedelta(seconds=70),
))
result = score_dds_card(state, card)
baseline = score_dds_card(without_report, without_report.desk.active)
assert state.criteria.dds_report_reaction_enabled is False
assert result.score_auto == baseline.score_auto
assert [(item.code, item.metric_key, item.fact) for item in result.findings] == [
(item.code, item.metric_key, item.fact) for item in baseline.findings
]
assert not any(item.key == "dds_report_reaction" for item in result.metrics)
assert not any(item.metric_key == "dds_report_reaction" for item in result.findings)
assert any(item.code is ErrorCode.D6 for item in result.findings)
def completed_card() -> DdsCardRecord:
return DdsCardRecord(
card_id=uuid4(),

View file

@ -628,6 +628,7 @@ def test_repeated_pause_and_resume_are_idempotent(clock, monkeypatch):
def test_report_reaction_excludes_pause_after_control_and_checkpoint(clock, monkeypatch):
base = datetime(2026, 9, 27, 10, tzinfo=UTC)
state, card = _dds_state_with_active_card(clock)
state.criteria.dds_report_reaction_enabled = True
service = state.card_services(card)[0]
monkeypatch.setattr("app.session.dds.now_utc", lambda: base)
card.set_service_status(service, ServiceStatus.ACCEPTED, "Карточка принята")

View file

@ -83,7 +83,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{/* 112 → ДДС приходит как exercise "card": работу ДДС выдают карточки пульта. */}
{(report.exercise === "dds" || Boolean(report.card_results?.length)) && <>; статус по докладу бригады —
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.criteria.dds_report_reaction_enabled
? ` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`
: " не применяется"}</>}
{report.exercise && report.exercise !== "dds" && <>
; грамматика описания КИО {report.criteria.require_correct_grammar ? "входит" : "не входит"} в оценку
</>}.

View file

@ -47,10 +47,12 @@ const REVIEW_METRICS: MetricOption[] = [
export function ScoreWeightsEditor({
exercise,
reportReactionEnabled,
values,
onChange,
}: {
exercise: LessonExercise;
reportReactionEnabled: boolean;
values: Record<string, number>;
onChange: (weights: Record<string, number>) => void;
}) {
@ -63,6 +65,11 @@ export function ScoreWeightsEditor({
? [["Карточка КИО", CARD_METRICS], ["Реагирование ДДС", DDS_METRICS]]
: [["Опрос 112", CALL_METRICS], ["Реагирование ДДС", DDS_METRICS]];
groups.push(["Разбор", REVIEW_METRICS]);
if (!reportReactionEnabled) {
for (const group of groups) {
group[1] = group[1].filter(({ key }) => key !== "dds_report_reaction");
}
}
function setWeight(key: string, raw: string) {
const next = { ...values };

View file

@ -93,6 +93,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
@ -292,6 +293,7 @@ export function Instructor() {
&& !(exercise === "dds" && wholeTicket)
? visibleScenarios.filter((item) => item.outcome === "card").map((item) => item.id)
: undefined;
const reportReactionEnabled = (exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled;
const sent = control.start(sessionId, {
scenarioId: primaryScenarioId,
mode,
@ -308,15 +310,18 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_enabled: reportReactionEnabled,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights,
score_weights: reportReactionEnabled
? lessonScoreWeights
: Object.fromEntries(Object.entries(lessonScoreWeights).filter(([key]) => key !== "dds_report_reaction")),
},
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@ -664,13 +669,20 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Реакция на доклад бригады, секунд
{(exercise === "dds" || exercise === "card_dds") && <>
<label className="mode-choice">
<input type="checkbox" checked={ddsReportReactionEnabled}
onChange={(event) => setDdsReportReactionEnabled(event.target.checked)} />
Оценивать срок отметки статуса после доклада бригады
</label>
{ddsReportReactionEnabled && <label className="lesson-criterion">
Лимит реакции на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
</label>}
</>}
<label className="lesson-criterion">
Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}
@ -682,6 +694,7 @@ export function Instructor() {
Грамматика карточки оператора 112 входит в оценку
</label>}
<ScoreWeightsEditor exercise={exercise === "card_dds" ? "card_dds" : exercise}
reportReactionEnabled={(exercise === "dds" || exercise === "card_dds") && ddsReportReactionEnabled}
values={lessonScoreWeights} onChange={setLessonScoreWeights} />
<small className="ref">Нормативы ГОСТ для приёма вызова не изменяются.</small>
</details>

View file

@ -400,6 +400,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_enabled?: boolean;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;