fix: подключить рекомендации модели к разбору, убрать лишнюю проверку грамматики ответа ДДС, задать настроение персоне worried

This commit is contained in:
gglamer 2026-09-26 17:12:29 +00:00
commit daa4f8c4f1
11 changed files with 63 additions and 34 deletions

View file

@ -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):

View file

@ -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,

View file

@ -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),
)

View file

@ -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"),

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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():
"""Общий чек-лист по классификатору наследуется целиком,
локальные пункты дополняют его."""

View file

@ -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 входит в норматив, а не обнуляет таймер.