From daa4f8c4f174ff7db811b8b481570ef351a7b376 Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 17:12:29 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BA=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D1=83,=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D1=8E=D1=8E=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D1=83=20=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=20=D0=94?= =?UTF-8?q?=D0=94=D0=A1,=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=82=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B5=20worried?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ws/station.py | 4 +-- backend/app/dialog/persona.py | 1 + backend/app/scoring/report.py | 3 ++ backend/app/session/checkpoint.py | 21 ------------- backend/app/session/dds.py | 2 -- backend/app/session/finish.py | 4 +++ backend/app/session/state.py | 5 --- backend/tests/test_card_exercise.py | 38 +++++++++++++++++++++++ backend/tests/test_scenarios.py | 8 +++++ backend/tests/test_session_checkpoint.py | 3 -- frontend/src/features/debrief/Debrief.tsx | 8 +++++ 11 files changed, 63 insertions(+), 34 deletions(-) diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 3f325c1..3a843f0 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -43,7 +43,6 @@ from app.domain.statuses import ( ) from app.domain.timers import TimerCode from app.scoring.address import address_matches -from app.scoring.grammar import assess from app.session.dds import deliver_due_cards from app.session.finish import finish, score_current_dds from app.session.hub import LEASE_FENCED_MESSAGE, hub @@ -286,12 +285,11 @@ async def _handle(session_id: UUID, state, event) -> None: return # A browser may lose the acknowledgement after the server has # committed this replace-style value. Reconnect retries are safe: - # don't create another journal row (or rerun grammar assessment) + # don't create another journal row # when the current card already contains exactly this text. if state.reply_text == event.text: return state.reply_text = event.text - state.reply_grammar = await assess(event.text) state.reply_log.append((now_utc(), event.text)) case "card.open": if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id): diff --git a/backend/app/dialog/persona.py b/backend/app/dialog/persona.py index f4aa322..4a63b30 100644 --- a/backend/app/dialog/persona.py +++ b/backend/app/dialog/persona.py @@ -13,6 +13,7 @@ from app.scenarios.schema import Persona #: Настроение базового профиля, если дуга не задана. BASE_MOOD: dict[str, Mood] = { "calm": Mood.CALM, + "worried": Mood.WORRIED, "panic": Mood.PANIC, "aggressive": Mood.AGGRESSIVE, "elderly": Mood.CONFUSED, diff --git a/backend/app/scoring/report.py b/backend/app/scoring/report.py index 60cdb1c..81fb9f1 100644 --- a/backend/app/scoring/report.py +++ b/backend/app/scoring/report.py @@ -8,6 +8,7 @@ from uuid import UUID from app.domain.events import ( + AICoaching, CompetencyScore, DdsCardReport, HintShown, @@ -108,4 +109,6 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport: score_final=score.get("score_final", score.get("score_auto", 0.0)), overridden_by=score.get("overridden_by"), override_comment=score.get("override_comment"), + ai_coaching=(AICoaching.model_validate(score["ai_coaching"]) + if score.get("ai_coaching") else None), ) diff --git a/backend/app/session/checkpoint.py b/backend/app/session/checkpoint.py index bd9d1b4..efb84be 100644 --- a/backend/app/session/checkpoint.py +++ b/backend/app/session/checkpoint.py @@ -33,19 +33,12 @@ from app.domain.statuses import ( from app.domain.taxonomy import Finding from app.domain.timers import TimerCode from app.scenarios.schema import Scenario -from app.scoring.grammar import GrammarAssessment from app.session.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc from app.session.timers import SessionTimers, Timer CHECKPOINT_VERSION = 1 -def _grammar(value: GrammarAssessment | None) -> dict | None: - if value is None: - return None - return {"passed": value.passed, "errors": list(value.errors), "source": value.source} - - def _dump_timers(timers: SessionTimers, now: float) -> dict: return { "limits": {code.value: limit for code, limit in timers.limits.items()}, @@ -77,7 +70,6 @@ def _dump_live_card(item: DdsLiveCard, now: float) -> dict: "phone_lines": [entry.model_dump(mode="json") for entry in item.phone_lines], "phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None, "reply_text": item.reply_text, - "reply_grammar": _grammar(item.reply_grammar), "reply_log": [[at.isoformat(), text] for at, text in item.reply_log], } @@ -163,7 +155,6 @@ def dump_state(state: SessionState) -> dict: for item in state.dds_completed ], "reply_text": state.reply_text, - "reply_grammar": _grammar(state.reply_grammar), "reply_log": [[at.isoformat(), text] for at, text in state.reply_log], "resolved_outcome": state.resolved_outcome, "resolve_comment": state.resolve_comment, @@ -205,16 +196,6 @@ def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers: return restored -def _restore_grammar(value: dict | None) -> GrammarAssessment | None: - if not value: - return None - return GrammarAssessment( - passed=bool(value["passed"]), - errors=tuple(value.get("errors") or []), - source=value["source"], - ) - - def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard: return DdsLiveCard( original_index=int(item["original_index"]), @@ -237,7 +218,6 @@ def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard: phone_pending=(PhoneCallPending.model_validate(item["phone_pending"]) if item.get("phone_pending") else None), reply_text=item.get("reply_text", ""), - reply_grammar=_restore_grammar(item.get("reply_grammar")), reply_log=[(datetime.fromisoformat(at), text) for at, text in item.get("reply_log", [])], ) @@ -336,7 +316,6 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState: for item in payload.get("dds_completed", []) ], reply_text=payload.get("reply_text", ""), - reply_grammar=_restore_grammar(payload.get("reply_grammar")), reply_log=[(datetime.fromisoformat(at), text) for at, text in payload.get("reply_log", [])], resolved_outcome=payload.get("resolved_outcome"), diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 52b7d4d..87c9414 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -112,7 +112,6 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None: state.phone_lines = [] state.phone_pending = None state.reply_text = "" - state.reply_grammar = None state.reply_log = [] # Настроенный преподавателем лимит копируется в независимый таймер карточки. state.timers = SessionTimers(limits=dict(state.timers.limits)) @@ -140,7 +139,6 @@ def _append_live_card( phone_lines=state.phone_lines, phone_pending=state.phone_pending, reply_text=state.reply_text, - reply_grammar=state.reply_grammar, reply_log=state.reply_log, ) state.dds_live_cards.append(card) diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py index 1855bad..f0f484c 100644 --- a/backend/app/session/finish.py +++ b/backend/app/session/finish.py @@ -17,6 +17,7 @@ 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.ai_coach import coach from app.scoring.card import evaluate_card from app.scoring.competency import radar from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher @@ -322,6 +323,9 @@ async def finish(session_id: UUID, state) -> None: for card in cards ] if state.exercise is Exercise.DDS or state.handoff_to_dds else [], } + # Модель только поясняет уже посчитанные провалы и балл не трогает; без + # запущенной модели разбор выходит со статусом «недоступно», а не ждёт её. + state.score["ai_coaching"] = (await coach(result.metrics)).model_dump(mode="json") # Полный разбор хранится вместе с оценкой: PDF/CSV и история должны # переживать перезапуск backend, а не зависеть от объекта в hub._sessions. state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json") diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 63936ba..b58dc47 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -44,7 +44,6 @@ from app.domain.statuses import ( from app.domain.taxonomy import Finding from app.domain.timers import TimerCode from app.scenarios.schema import Scenario -from app.scoring.grammar import GrammarAssessment from app.session.timers import SessionTimers @@ -98,7 +97,6 @@ class DdsLiveCard: phone_lines: list[PhoneLineRecord] = field(default_factory=list) phone_pending: PhoneCallPending | None = None reply_text: str = "" - reply_grammar: GrammarAssessment | None = None reply_log: list[tuple[datetime, str]] = field(default_factory=list) @property @@ -196,7 +194,6 @@ class SessionState: dds_next_scenario_index: int = 0 dds_next_arrival_at: datetime | None = None reply_text: str = "" - reply_grammar: GrammarAssessment | None = None reply_log: list[tuple[datetime, str]] = field(default_factory=list) #: Чем курсант закрыл вызов, если не карточкой (lct-36). resolved_outcome: str | None = None @@ -299,7 +296,6 @@ class SessionState: card.phone_lines = self.phone_lines card.phone_pending = self.phone_pending card.reply_text = self.reply_text - card.reply_grammar = self.reply_grammar card.reply_log = self.reply_log self.dds_active_card_id = card.card_id @@ -329,7 +325,6 @@ class SessionState: self.phone_lines = card.phone_lines self.phone_pending = card.phone_pending self.reply_text = card.reply_text - self.reply_grammar = card.reply_grammar self.reply_log = card.reply_log self.dds_card_index = card.original_index self.dds_active_card_id = card.card_id diff --git a/backend/tests/test_card_exercise.py b/backend/tests/test_card_exercise.py index ce7bea5..9e781a3 100644 --- a/backend/tests/test_card_exercise.py +++ b/backend/tests/test_card_exercise.py @@ -359,3 +359,41 @@ def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client): assert state["score_auto"] < 100 finally: control.__exit__(None, None, None) + + +def test_model_coaching_reaches_report_without_changing_score(client, monkeypatch): + from app.domain.events import AICoaching, AIRecommendation + + seen: list[list[str]] = [] + + async def fake_coach(metrics): + failed = [item.key for item in metrics if not item.passed and item.weight > 0] + seen.append(failed) + return AICoaching(status="ready", model="test", recommendations=[ + AIRecommendation(metric_key=failed[0], text="Перечитайте описание перед сдачей карточки."), + ]) + + monkeypatch.setattr("app.session.finish.assess", rules_only_grammar) + monkeypatch.setattr("app.session.finish.coach", fake_coach) + session_id, control = start(client) + try: + with client.websocket_connect(f"/ws/call/{session_id}") as trainee: + read_until(trainee, "card.briefing") + trainee.send_json({"type": "kio.patch", "fields": { + "address": "улица Ленина, 14", "floor": "5", "incident_type": "fire", + "victims_count": 2, "description": "горит балкон", + "signs": ["жилой дом", "балкон", "открытое пламя"], + }}) + wait_for(lambda: hub.get(session_id).kio.incident_code) + trainee.send_json({"type": "card.submit"}) + read_until(trainee, "call.ended") + read_until(trainee, "score.ready") + + score = wait_for(lambda: hub.get(session_id).score) + assert seen and "description_grammar" in seen[0] + coaching = score["full_report"]["ai_coaching"] + assert coaching["status"] == "ready" + assert coaching["recommendations"][0]["metric_key"] == seen[0][0] + assert score["score_auto"] < 100 + finally: + control.__exit__(None, None, None) diff --git a/backend/tests/test_scenarios.py b/backend/tests/test_scenarios.py index b09fff4..19e1863 100644 --- a/backend/tests/test_scenarios.py +++ b/backend/tests/test_scenarios.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from app.dialog.persona import BASE_MOOD from app.scenarios.loader import ScenarioError, load_file, load_library LIBRARY = Path(__file__).resolve().parents[2] / "scenarios" @@ -38,6 +39,13 @@ def test_library_loads(): assert all(s.ground_truth.dds for s in scenarios), "ДДС не выведен" +def test_library_personas_have_base_mood(): + # base — свободная строка: неизвестное значение молча становится «спокойным» + # звонящим, и сценарий теряет задуманную подачу. + unknown = {s.id: s.persona.base for s in load_library(LIBRARY) if s.persona.base not in BASE_MOOD} + assert not unknown, f"персона без настроения в BASE_MOOD: {unknown}" + + def test_extends_inherits_whole_checklist(): """Общий чек-лист по классификатору наследуется целиком, локальные пункты дополняют его.""" diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py index 5f7f301..464522f 100644 --- a/backend/tests/test_session_checkpoint.py +++ b/backend/tests/test_session_checkpoint.py @@ -11,7 +11,6 @@ from app.domain.kio import KIO from app.domain.statuses import PhoneCallPending, ServiceStatus from app.domain.timers import TimerCode from app.scenarios.loader import load_file -from app.scoring.grammar import basic_check from app.session.checkpoint import dump_state, load_state from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub @@ -50,7 +49,6 @@ def dds_state() -> SessionState: service=service, crew=state.crew_selected, phase="dispatched" ) state.reply_text = "Сообщение принято, бригада направлена." - state.reply_grammar = basic_check(state.reply_text) state.reply_log.append((now_utc(), state.reply_text)) state.dds_log.append(("crew.select", now_utc(), state.crew_selected)) return state @@ -74,7 +72,6 @@ def test_active_dds_session_round_trips_without_losing_work(): assert restored.crew_assignments == before.crew_assignments assert restored.phone_pending == before.phone_pending assert restored.reply_text == before.reply_text - assert restored.reply_grammar == before.reply_grammar assert restored.processed_station_commands == before.processed_station_commands assert restored.dds_scenarios[0].id == before.scenario_id # Время простоя backend входит в норматив, а не обнуляет таймер. diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx index 1910fd3..2398064 100644 --- a/frontend/src/features/debrief/Debrief.tsx +++ b/frontend/src/features/debrief/Debrief.tsx @@ -18,6 +18,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: const notes = report.notes ?? []; const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics); const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3); + const metricTitles = new Map(report.metrics.map((metric) => [metric.key, metric.title])); + const coaching = report.ai_coaching?.status === "ready" ? report.ai_coaching.recommendations ?? [] : []; return (
@@ -57,6 +59,12 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?: ) : (

Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.

)} + {coaching.length > 0 && <> +

Рекомендации локальной модели (на балл не влияют):

+ + }

Скачать отчёт: таблица (CSV)