lct-37: норматив реакции ДДС на доклад бригады, отметка D6 и настраиваемый лимит

This commit is contained in:
Ivan Gerasimov 2026-09-27 16:26:10 +03:00
commit a998b0c2f7
12 changed files with 213 additions and 7 deletions

View file

@ -53,6 +53,8 @@ class LessonCriteria(BaseModel):
decision_time_limit_seconds: int = Field(default=30, ge=5, le=300) decision_time_limit_seconds: int = Field(default=30, ge=5, le=300)
card_fill_time_limit_seconds: int = Field(default=180, ge=30, le=1800) 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_card_work_time_limit_seconds: int = Field(default=180, ge=30, le=1800)
#: Сколько секунд после доклада бригады даётся на соответствующий статус.
dds_report_reaction_limit_seconds: int = Field(default=45, ge=5, le=600)
allowed_errors: int = Field(default=0, ge=0, le=50) allowed_errors: int = Field(default=0, ge=0, le=50)
require_correct_grammar: bool = True require_correct_grammar: bool = True
score_weights: dict[str, float] = Field(default_factory=dict) score_weights: dict[str, float] = Field(default_factory=dict)

View file

@ -16,6 +16,7 @@ from app.domain.statuses import (
PRIMARY, PRIMARY,
REFUSAL_COMMENT_REQUIRED, REFUSAL_COMMENT_REQUIRED,
SERVICE_STATUS_LABELS, SERVICE_STATUS_LABELS,
PhoneReportRecord,
ServiceStatus, ServiceStatus,
StatusEntry, StatusEntry,
current, current,
@ -27,6 +28,47 @@ from app.domain.timers import GOST_REF
#: Статусы, означающие, что реагирование действительно шло. #: Статусы, означающие, что реагирование действительно шло.
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING) PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
#: Статус, который диспетчер отмечает по докладу бригады этой фазы.
REPORT_STATUS = {
"dispatched": ServiceStatus.RESPONDING,
"arrived": ServiceStatus.ARRIVED,
"working": ServiceStatus.WORKING,
"completed": ServiceStatus.COMPLETED,
}
REPORT_LABELS = {
"dispatched": "о выезде",
"arrived": "о прибытии",
"working": "о начале работ",
"completed": "о завершении работ",
}
#: Лимит реакции на доклад по умолчанию; занятие задаёт свой в критериях.
REPORT_REACTION_MS = 45_000
def report_reactions(
marks: list[StatusEntry], reports: list[PhoneReportRecord],
) -> list[tuple[PhoneReportRecord, int | None]]:
"""Задержка статуса после каждого доклада бригады, мс; None — статус не отмечен.
Отсчёт — от серверного времени доклада. Статус, отмеченный раньше доклада,
задержки не имеет: диспетчер узнал о ходе работ по другому каналу.
"""
result: list[tuple[PhoneReportRecord, int | None]] = []
for report in reports:
status = REPORT_STATUS.get(report.phase)
if status is None:
continue
times = [mark.at for mark in marks if mark.status is status]
if any(at <= report.at for at in times):
result.append((report, 0))
continue
after = min(times, default=None)
result.append((
report,
int((after - report.at).total_seconds() * 1000) if after is not None else None,
))
return result
def _finding(service: str, code: ErrorCode, summary: str, fact: str, def _finding(service: str, code: ErrorCode, summary: str, fact: str,
norm: str | None = None) -> Finding: norm: str | None = None) -> Finding:
@ -93,6 +135,8 @@ def evaluate_dispatcher(
reply_text: str = "", reply_text: str = "",
expected_decision: str = "accept", expected_decision: str = "accept",
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
phone_reports: list[PhoneReportRecord] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Finding]: ) -> list[Finding]:
"""Отметки по работе диспетчера. Пустой список — работа без нарушений. """Отметки по работе диспетчера. Пустой список — работа без нарушений.
@ -264,15 +308,18 @@ def evaluate_dispatcher(
# D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке # D6 — ход работ неполон к моменту закрытия карточки/занятия. В памятке
# это приводит к повторным звонкам и скрывает от других служб факт реакции. # это приводит к повторным звонкам и скрывает от других служб факт реакции.
# REFUSED — отдельный допустимый терминальный статус с обязательной причиной. # REFUSED — отдельный допустимый терминальный статус с обязательной причиной.
reports = [report for report in phone_reports or () if report.service == service]
missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}] missing_progress = [status for status in PROGRESS if status not in {m.status for m in marks}]
if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} if (latest not in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
and (missing_progress or latest is not ServiceStatus.COMPLETED)): and (missing_progress or latest is not ServiceStatus.COMPLETED)):
missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress) missing = ", ".join(SERVICE_STATUS_LABELS[status] for status in missing_progress)
reported_done = any(report.phase == "completed" for report in reports)
findings.append( findings.append(
_finding( _finding(
service, ErrorCode.D6, service, ErrorCode.D6,
f"{service}: ход реагирования не доведён до конца", f"{service}: ход реагирования не доведён до конца",
fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта"), fact=(f"не отмечены: {missing}" if missing else "карточка не закрыта")
+ ("; бригада доложила о завершении работ" if reported_done else ""),
norm=( norm=(
"отметить по факту начало реагирования, прибытие, проведение работ " "отметить по факту начало реагирования, прибытие, проведение работ "
"и завершение; если работы не проводились — оформить отказ с причиной" "и завершение; если работы не проводились — оформить отказ с причиной"
@ -280,6 +327,32 @@ def evaluate_dispatcher(
) )
) )
# D6 — статус по докладу бригады отмечен позже лимита или не отмечен.
# Код новый не вводится: это тот же неполный ход работ из памятки,
# только с измеримым временем реакции на доклад.
terminal = latest in {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
limit_s = report_reaction_ms // 1000
for report, delay_ms in report_reactions(marks, reports):
label = SERVICE_STATUS_LABELS[REPORT_STATUS[report.phase]]
source = f"доклад бригады «{report.crew}» {REPORT_LABELS[report.phase]}"
if delay_ms is None and not terminal:
finding = _finding(
service, ErrorCode.D6,
f"{service}: по докладу бригады статус «{label}» не отмечен",
fact=f"{source} получен, статус «{label}» не отмечен",
norm=f"отметить статус по докладу не позже {limit_s} с",
)
elif delay_ms is not None and delay_ms > report_reaction_ms:
finding = _finding(
service, ErrorCode.D6,
f"{service}: статус «{label}» отмечен позже норматива после доклада",
fact=f"статус «{label}» отмечен через {round(delay_ms / 1000)} с; {source}",
norm=f"отметить статус по докладу не позже {limit_s} с",
)
else:
continue
findings.append(finding.model_copy(update={"metric_key": "dds_report_reaction"}))
return findings return findings
@ -290,6 +363,7 @@ def dispatcher_metrics(
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
*, *,
services: list[str] | None = None, services: list[str] | None = None,
report_reaction_ms: int = REPORT_REACTION_MS,
) -> list[Metric]: ) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -355,4 +429,17 @@ def dispatcher_metrics(
notes_complete, notes_complete,
"; ".join(notes) if notes else "комментарии к статусам не внесены", "; ".join(notes) if notes else "комментарии к статусам не внесены",
"к каждой ручной отметке добавить основание и содержание полученных сведений") "к каждой ручной отметке добавить основание и содержание полученных сведений")
# Метрика есть только там, где бригада докладывала: без докладов
# реагировать не на что, и отсутствие звонков не штраф.
reports = [report for report in getattr(state, "phone_reports", ()) if report.service == service]
reactions = report_reactions(marks, reports)
if reactions:
add("dds_report_reaction", "реакция на доклады бригады",
all(delay is not None and delay <= report_reaction_ms for _, delay in reactions),
"; ".join(
f"{REPORT_LABELS[report.phase]}: "
+ (f"{round(delay / 1000)} с" if delay is not None else "статус не отмечен")
for report, delay in reactions
),
f"статус по каждому докладу не позже {report_reaction_ms // 1000} с")
return metrics return metrics

View file

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

View file

@ -31,6 +31,7 @@ METRIC_MAP: dict[str, tuple[ErrorCode, Competency]] = {
"dds_progress": (ErrorCode.D6, Competency.CARD), "dds_progress": (ErrorCode.D6, Competency.CARD),
"dds_completion": (ErrorCode.D6, Competency.CARD), "dds_completion": (ErrorCode.D6, Competency.CARD),
"dds_reply": (ErrorCode.D5, Competency.COMMUNICATION), "dds_reply": (ErrorCode.D5, Competency.COMMUNICATION),
"dds_report_reaction": (ErrorCode.D6, Competency.NORMS),
"dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION), "dds_grammar": (ErrorCode.D5, Competency.COMMUNICATION),
"description_grammar": (ErrorCode.E4, Competency.COMMUNICATION), "description_grammar": (ErrorCode.E4, Competency.COMMUNICATION),
} }

View file

@ -48,6 +48,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
services = state.card_services(card) services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK] decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision dds_decision = card.scenario.dds_decision
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
findings = evaluate_dispatcher( findings = evaluate_dispatcher(
entries=card.status_log, entries=card.status_log,
services=services, services=services,
@ -57,10 +58,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
reply_text=card.reply_text, reply_text=card.reply_text,
expected_decision=dds_decision.expected, expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason, expected_decision_reason=dds_decision.reason,
phone_reports=card.phone_reports,
report_reaction_ms=reaction_ms,
) )
metrics = dispatcher_metrics( metrics = dispatcher_metrics(
card, decision_limit_ms, dds_decision.expected, dds_decision.reason, card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services, services=services, report_reaction_ms=reaction_ms,
) )
weighted = GostResult(metrics=metrics, findings=findings) weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK] work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]

View file

@ -219,6 +219,7 @@ async def score_session(
if live_card is not None: if live_card is not None:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK] decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = list(snapshot.call_card_services) services = list(snapshot.call_card_services)
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
result.findings.extend(evaluate_dispatcher( result.findings.extend(evaluate_dispatcher(
entries=live_card.status_log, entries=live_card.status_log,
services=services, services=services,
@ -228,10 +229,12 @@ async def score_session(
reply_text=live_card.reply_text, reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected, expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason, expected_decision_reason=scenario.dds_decision.reason,
phone_reports=live_card.phone_reports,
report_reaction_ms=reaction_ms,
)) ))
result.metrics.extend(dispatcher_metrics( result.metrics.extend(dispatcher_metrics(
live_card, decision_limit_ms, scenario.dds_decision.expected, live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, services=services, scenario.dds_decision.reason, services=services, report_reaction_ms=reaction_ms,
)) ))
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой # Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
# занятия. Веса первого сценария поверх них испортили бы остальные. # занятия. Веса первого сценария поверх них испортили бы остальные.

View file

@ -396,6 +396,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
client, client,
criteria={ criteria={
"decision_time_limit_seconds": 45, "decision_time_limit_seconds": 45,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50, "allowed_errors": 50,
"require_correct_grammar": False, "require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5}, "score_weights": {"dds_ack": 3.5},
@ -454,6 +455,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
"decision_time_limit_seconds": 45, "decision_time_limit_seconds": 45,
"card_fill_time_limit_seconds": 180, "card_fill_time_limit_seconds": 180,
"dds_card_work_time_limit_seconds": 180, "dds_card_work_time_limit_seconds": 180,
"dds_report_reaction_limit_seconds": 20,
"allowed_errors": 50, "allowed_errors": 50,
"require_correct_grammar": False, "require_correct_grammar": False,
"score_weights": {"dds_ack": 3.5}, "score_weights": {"dds_ack": 3.5},
@ -797,6 +799,8 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
] ]
assert all(report.crew == crew for report in state.desk.active.phone_reports) assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"] assert not score["findings"]
reaction = [m for m in score["metrics"] if m["key"] == "dds_report_reaction"]
assert reaction and all(m["passed"] for m in reaction), "каждый доклад отмечен вовремя"
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)

View file

@ -1,10 +1,10 @@
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
from app.domain.statuses import ServiceStatus, StatusEntry from app.domain.statuses import PhoneReportRecord, ServiceStatus, StatusEntry
from app.domain.taxonomy import ErrorCode from app.domain.taxonomy import ErrorCode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
@ -294,3 +294,95 @@ def test_reasoned_refusal_after_acceptance_is_a_valid_terminal_path():
deadline_ms=30_000, elapsed_ms=0, deadline_ms=30_000, elapsed_ms=0,
) )
assert ErrorCode.D6 not in [finding.code for finding in findings] assert ErrorCode.D6 not in [finding.code for finding in findings]
# ── Реакция на доклад бригады (lct-37) ──
T0 = datetime(2026, 9, 27, 10, 0, tzinfo=timezone.utc)
COMMENT = "Основание: доклад старшего.\nСведения: передано дежурному."
def at_second(seconds: int) -> datetime:
return T0 + timedelta(seconds=seconds)
def mark(status: ServiceStatus, seconds: int) -> StatusEntry:
return StatusEntry(service=SERVICES[0], status=status, at=at_second(seconds), comment=COMMENT)
def crew_report(phase: str, seconds: int) -> PhoneReportRecord:
return PhoneReportRecord(service=SERVICES[0], crew="Бригада 12", phase=phase,
text="доклад", at=at_second(seconds))
def reaction(status_log, reports, limit_ms=45_000):
card = live_card(status_log=status_log, crew_assignments={SERVICES[0]: "Бригада 12"},
dispatched_at=T0)
card.phone_reports = reports
findings = evaluate_dispatcher(
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,
)
metric = next((m for m in dispatcher_metrics(card, 30_000, services=SERVICES,
report_reaction_ms=limit_ms)
if m.key == "dds_report_reaction"), None)
return [f for f in findings if f.metric_key == "dds_report_reaction"], metric
def full_run(arrived_at: int) -> list[StatusEntry]:
return [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20),
mark(ServiceStatus.ARRIVED, arrived_at), mark(ServiceStatus.WORKING, arrived_at + 10),
mark(ServiceStatus.COMPLETED, arrived_at + 20)]
def test_status_soon_after_crew_report_passes():
findings, metric = reaction(full_run(arrived_at=110), [crew_report("arrived", 100)])
assert findings == []
assert metric.passed and metric.fact == "о прибытии: 10 с"
def test_status_later_than_limit_after_crew_report_is_d6_with_fact_and_norm():
findings, metric = reaction(full_run(arrived_at=170), [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "«Прибытие» отмечен через 70 с" in findings[0].fact
assert "о прибытии" in findings[0].fact, "факт ссылается на доклад"
assert findings[0].norm == "отметить статус по докладу не позже 45 с"
assert not metric.passed and metric.norm == "статус по каждому докладу не позже 45 с"
def test_missing_status_after_crew_report_is_d6():
status_log = [mark(ServiceStatus.ACCEPTED, 5), mark(ServiceStatus.RESPONDING, 20)]
findings, metric = reaction(status_log, [crew_report("arrived", 100)])
assert [f.code for f in findings] == [ErrorCode.D6]
assert "не отмечен" in findings[0].fact and "Бригада 12" in findings[0].fact
assert not metric.passed and metric.fact == "о прибытии: статус не отмечен"
def test_status_set_before_the_report_and_custom_limit():
findings, metric = reaction(full_run(arrived_at=90), [crew_report("arrived", 100)])
assert findings == [] and metric.passed, "статус раньше доклада — не нарушение"
strict, _ = reaction(full_run(arrived_at=130), [crew_report("arrived", 100)], limit_ms=20_000)
assert "не позже 20 с" in strict[0].norm
def test_no_crew_reports_means_no_reaction_metric():
findings, metric = reaction(full_run(arrived_at=110), [])
assert findings == [] and metric is None
def test_unfinished_work_after_completion_report_names_the_report():
status_log = full_run(arrived_at=110)[:-1]
findings = evaluate_dispatcher(
entries=status_log, services=SERVICES, crew_assignments={SERVICES[0]: "Бригада 12"},
deadline_ms=30_000, elapsed_ms=5_000, phone_reports=[crew_report("completed", 150)],
)
unfinished = next(f for f in findings if f.summary.endswith("не доведён до конца"))
assert "бригада доложила о завершении работ" in unfinished.fact

View file

@ -75,7 +75,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится {report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
{` ${report.criteria.decision_time_limit_seconds} с`}</>} {` ${report.criteria.decision_time_limit_seconds} с`}</>}
{report.exercise === "dds" && <>; норматив отработки карточки ДДС — {report.exercise === "dds" && <>; норматив отработки карточки ДДС —
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>} {` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}; статус по докладу бригады —
{` не позже ${report.criteria.dds_report_reaction_limit_seconds ?? 45} с`}</>}
{report.exercise === "card" && <>Норматив заполнения КИО — {report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>} {` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
{report.exercise && report.exercise !== "dds" && <> {report.exercise && report.exercise !== "dds" && <>

View file

@ -32,6 +32,7 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." }, { key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." }, { key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." }, { key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
]; ];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [ const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [

View file

@ -86,6 +86,7 @@ export function Instructor() {
const [decisionTimeLimit, setDecisionTimeLimit] = useState(30); const [decisionTimeLimit, setDecisionTimeLimit] = useState(30);
const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180); const [cardFillTimeLimit, setCardFillTimeLimit] = useState(180);
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180); const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [allowedErrors, setAllowedErrors] = useState(0); const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true); const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({}); const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -300,6 +301,7 @@ export function Instructor() {
decision_time_limit_seconds: decisionTimeLimit, decision_time_limit_seconds: decisionTimeLimit,
card_fill_time_limit_seconds: cardFillTimeLimit, card_fill_time_limit_seconds: cardFillTimeLimit,
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit, dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
allowed_errors: allowedErrors, allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar, require_correct_grammar: requireCorrectGrammar,
score_weights: lessonScoreWeights, score_weights: lessonScoreWeights,
@ -307,7 +309,7 @@ export function Instructor() {
}); });
if (sent) toStart.current = null; if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data, }, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios, exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data, ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]); randomScenario, visibleScenarios]);
@ -649,6 +651,13 @@ export function Instructor() {
onCommit={setDdsCardWorkTimeLimit} /> onCommit={setDdsCardWorkTimeLimit} />
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small> <small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
</label>} </label>}
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
Реакция на доклад бригады, секунд
<LessonNumberInput label="Лимит реакции на доклад бригады, секунд"
value={ddsReportReactionLimit} min={5} max={600} fallback={45}
onCommit={setDdsReportReactionLimit} />
<small className="ref">По умолчанию 45 секунд: статус по докладу бригады — не позже лимита.</small>
</label>}
<label className="lesson-criterion"> <label className="lesson-criterion">
Допустимо нарушенных метрик Допустимо нарушенных метрик
<LessonNumberInput label="Допустимое число ошибок" value={allowedErrors} <LessonNumberInput label="Допустимое число ошибок" value={allowedErrors}

View file

@ -396,6 +396,7 @@ export interface LessonCriteria {
decision_time_limit_seconds?: number; decision_time_limit_seconds?: number;
card_fill_time_limit_seconds?: number; card_fill_time_limit_seconds?: number;
dds_card_work_time_limit_seconds?: number; dds_card_work_time_limit_seconds?: number;
dds_report_reaction_limit_seconds?: number;
allowed_errors?: number; allowed_errors?: number;
require_correct_grammar?: boolean; require_correct_grammar?: boolean;
score_weights?: Record<string, number>; score_weights?: Record<string, number>;