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 (
Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия. Рекомендации локальной модели (на балл не влияют):{coaching.map((item) =>
+ >}
Скачать отчёт: таблица (CSV)