fix: подключить рекомендации модели к разбору, убрать лишнюю проверку грамматики ответа ДДС, задать настроение персоне worried
This commit is contained in:
parent
3ae682a354
commit
daa4f8c4f1
11 changed files with 63 additions and 34 deletions
|
|
@ -43,7 +43,6 @@ from app.domain.statuses import (
|
||||||
)
|
)
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scoring.address import address_matches
|
from app.scoring.address import address_matches
|
||||||
from app.scoring.grammar import assess
|
|
||||||
from app.session.dds import deliver_due_cards
|
from app.session.dds import deliver_due_cards
|
||||||
from app.session.finish import finish, score_current_dds
|
from app.session.finish import finish, score_current_dds
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
from app.session.hub import LEASE_FENCED_MESSAGE, hub
|
||||||
|
|
@ -286,12 +285,11 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
return
|
return
|
||||||
# A browser may lose the acknowledgement after the server has
|
# A browser may lose the acknowledgement after the server has
|
||||||
# committed this replace-style value. Reconnect retries are safe:
|
# 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.
|
# when the current card already contains exactly this text.
|
||||||
if state.reply_text == event.text:
|
if state.reply_text == event.text:
|
||||||
return
|
return
|
||||||
state.reply_text = event.text
|
state.reply_text = event.text
|
||||||
state.reply_grammar = await assess(event.text)
|
|
||||||
state.reply_log.append((now_utc(), event.text))
|
state.reply_log.append((now_utc(), event.text))
|
||||||
case "card.open":
|
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):
|
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id):
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from app.scenarios.schema import Persona
|
||||||
#: Настроение базового профиля, если дуга не задана.
|
#: Настроение базового профиля, если дуга не задана.
|
||||||
BASE_MOOD: dict[str, Mood] = {
|
BASE_MOOD: dict[str, Mood] = {
|
||||||
"calm": Mood.CALM,
|
"calm": Mood.CALM,
|
||||||
|
"worried": Mood.WORRIED,
|
||||||
"panic": Mood.PANIC,
|
"panic": Mood.PANIC,
|
||||||
"aggressive": Mood.AGGRESSIVE,
|
"aggressive": Mood.AGGRESSIVE,
|
||||||
"elderly": Mood.CONFUSED,
|
"elderly": Mood.CONFUSED,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import (
|
from app.domain.events import (
|
||||||
|
AICoaching,
|
||||||
CompetencyScore,
|
CompetencyScore,
|
||||||
DdsCardReport,
|
DdsCardReport,
|
||||||
HintShown,
|
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)),
|
score_final=score.get("score_final", score.get("score_auto", 0.0)),
|
||||||
overridden_by=score.get("overridden_by"),
|
overridden_by=score.get("overridden_by"),
|
||||||
override_comment=score.get("override_comment"),
|
override_comment=score.get("override_comment"),
|
||||||
|
ai_coaching=(AICoaching.model_validate(score["ai_coaching"])
|
||||||
|
if score.get("ai_coaching") else None),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,19 +33,12 @@ from app.domain.statuses import (
|
||||||
from app.domain.taxonomy import Finding
|
from app.domain.taxonomy import Finding
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.schema import Scenario
|
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.state import DdsCardRecord, DdsLiveCard, SessionState, now_utc
|
||||||
from app.session.timers import SessionTimers, Timer
|
from app.session.timers import SessionTimers, Timer
|
||||||
|
|
||||||
CHECKPOINT_VERSION = 1
|
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:
|
def _dump_timers(timers: SessionTimers, now: float) -> dict:
|
||||||
return {
|
return {
|
||||||
"limits": {code.value: limit for code, limit in timers.limits.items()},
|
"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_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,
|
"phone_pending": item.phone_pending.model_dump(mode="json") if item.phone_pending else None,
|
||||||
"reply_text": item.reply_text,
|
"reply_text": item.reply_text,
|
||||||
"reply_grammar": _grammar(item.reply_grammar),
|
|
||||||
"reply_log": [[at.isoformat(), text] for at, text in item.reply_log],
|
"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
|
for item in state.dds_completed
|
||||||
],
|
],
|
||||||
"reply_text": state.reply_text,
|
"reply_text": state.reply_text,
|
||||||
"reply_grammar": _grammar(state.reply_grammar),
|
|
||||||
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log],
|
||||||
"resolved_outcome": state.resolved_outcome,
|
"resolved_outcome": state.resolved_outcome,
|
||||||
"resolve_comment": state.resolve_comment,
|
"resolve_comment": state.resolve_comment,
|
||||||
|
|
@ -205,16 +196,6 @@ def _restore_timers(payload: dict, saved_at: datetime) -> SessionTimers:
|
||||||
return restored
|
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:
|
def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
|
||||||
return DdsLiveCard(
|
return DdsLiveCard(
|
||||||
original_index=int(item["original_index"]),
|
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"])
|
phone_pending=(PhoneCallPending.model_validate(item["phone_pending"])
|
||||||
if item.get("phone_pending") else None),
|
if item.get("phone_pending") else None),
|
||||||
reply_text=item.get("reply_text", ""),
|
reply_text=item.get("reply_text", ""),
|
||||||
reply_grammar=_restore_grammar(item.get("reply_grammar")),
|
|
||||||
reply_log=[(datetime.fromisoformat(at), text)
|
reply_log=[(datetime.fromisoformat(at), text)
|
||||||
for at, text in item.get("reply_log", [])],
|
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", [])
|
for item in payload.get("dds_completed", [])
|
||||||
],
|
],
|
||||||
reply_text=payload.get("reply_text", ""),
|
reply_text=payload.get("reply_text", ""),
|
||||||
reply_grammar=_restore_grammar(payload.get("reply_grammar")),
|
|
||||||
reply_log=[(datetime.fromisoformat(at), text)
|
reply_log=[(datetime.fromisoformat(at), text)
|
||||||
for at, text in payload.get("reply_log", [])],
|
for at, text in payload.get("reply_log", [])],
|
||||||
resolved_outcome=payload.get("resolved_outcome"),
|
resolved_outcome=payload.get("resolved_outcome"),
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
|
||||||
state.phone_lines = []
|
state.phone_lines = []
|
||||||
state.phone_pending = None
|
state.phone_pending = None
|
||||||
state.reply_text = ""
|
state.reply_text = ""
|
||||||
state.reply_grammar = None
|
|
||||||
state.reply_log = []
|
state.reply_log = []
|
||||||
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
|
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
|
||||||
state.timers = SessionTimers(limits=dict(state.timers.limits))
|
state.timers = SessionTimers(limits=dict(state.timers.limits))
|
||||||
|
|
@ -140,7 +139,6 @@ def _append_live_card(
|
||||||
phone_lines=state.phone_lines,
|
phone_lines=state.phone_lines,
|
||||||
phone_pending=state.phone_pending,
|
phone_pending=state.phone_pending,
|
||||||
reply_text=state.reply_text,
|
reply_text=state.reply_text,
|
||||||
reply_grammar=state.reply_grammar,
|
|
||||||
reply_log=state.reply_log,
|
reply_log=state.reply_log,
|
||||||
)
|
)
|
||||||
state.dds_live_cards.append(card)
|
state.dds_live_cards.append(card)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from app.domain.statuses import ServiceStatus, current
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
|
from app.scoring.ai_coach import coach
|
||||||
from app.scoring.card import evaluate_card
|
from app.scoring.card import evaluate_card
|
||||||
from app.scoring.competency import radar
|
from app.scoring.competency import radar
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
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
|
for card in cards
|
||||||
] if state.exercise is Exercise.DDS or state.handoff_to_dds else [],
|
] 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 и история должны
|
# Полный разбор хранится вместе с оценкой: PDF/CSV и история должны
|
||||||
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
# переживать перезапуск backend, а не зависеть от объекта в hub._sessions.
|
||||||
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
state.score["full_report"] = build_report(session_id, state, scenario).model_dump(mode="json")
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ from app.domain.statuses import (
|
||||||
from app.domain.taxonomy import Finding
|
from app.domain.taxonomy import Finding
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.schema import Scenario
|
from app.scenarios.schema import Scenario
|
||||||
from app.scoring.grammar import GrammarAssessment
|
|
||||||
from app.session.timers import SessionTimers
|
from app.session.timers import SessionTimers
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -98,7 +97,6 @@ class DdsLiveCard:
|
||||||
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
|
||||||
phone_pending: PhoneCallPending | None = None
|
phone_pending: PhoneCallPending | None = None
|
||||||
reply_text: str = ""
|
reply_text: str = ""
|
||||||
reply_grammar: GrammarAssessment | None = None
|
|
||||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -196,7 +194,6 @@ class SessionState:
|
||||||
dds_next_scenario_index: int = 0
|
dds_next_scenario_index: int = 0
|
||||||
dds_next_arrival_at: datetime | None = None
|
dds_next_arrival_at: datetime | None = None
|
||||||
reply_text: str = ""
|
reply_text: str = ""
|
||||||
reply_grammar: GrammarAssessment | None = None
|
|
||||||
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
|
||||||
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
#: Чем курсант закрыл вызов, если не карточкой (lct-36).
|
||||||
resolved_outcome: str | None = None
|
resolved_outcome: str | None = None
|
||||||
|
|
@ -299,7 +296,6 @@ class SessionState:
|
||||||
card.phone_lines = self.phone_lines
|
card.phone_lines = self.phone_lines
|
||||||
card.phone_pending = self.phone_pending
|
card.phone_pending = self.phone_pending
|
||||||
card.reply_text = self.reply_text
|
card.reply_text = self.reply_text
|
||||||
card.reply_grammar = self.reply_grammar
|
|
||||||
card.reply_log = self.reply_log
|
card.reply_log = self.reply_log
|
||||||
self.dds_active_card_id = card.card_id
|
self.dds_active_card_id = card.card_id
|
||||||
|
|
||||||
|
|
@ -329,7 +325,6 @@ class SessionState:
|
||||||
self.phone_lines = card.phone_lines
|
self.phone_lines = card.phone_lines
|
||||||
self.phone_pending = card.phone_pending
|
self.phone_pending = card.phone_pending
|
||||||
self.reply_text = card.reply_text
|
self.reply_text = card.reply_text
|
||||||
self.reply_grammar = card.reply_grammar
|
|
||||||
self.reply_log = card.reply_log
|
self.reply_log = card.reply_log
|
||||||
self.dds_card_index = card.original_index
|
self.dds_card_index = card.original_index
|
||||||
self.dds_active_card_id = card.card_id
|
self.dds_active_card_id = card.card_id
|
||||||
|
|
|
||||||
|
|
@ -359,3 +359,41 @@ def test_handoff_queue_mixes_trainee_card_then_selected_prepared_card(client):
|
||||||
assert state["score_auto"] < 100
|
assert state["score_auto"] < 100
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.dialog.persona import BASE_MOOD
|
||||||
from app.scenarios.loader import ScenarioError, load_file, load_library
|
from app.scenarios.loader import ScenarioError, load_file, load_library
|
||||||
|
|
||||||
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
|
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), "ДДС не выведен"
|
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():
|
def test_extends_inherits_whole_checklist():
|
||||||
"""Общий чек-лист по классификатору наследуется целиком,
|
"""Общий чек-лист по классификатору наследуется целиком,
|
||||||
локальные пункты дополняют его."""
|
локальные пункты дополняют его."""
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from app.domain.kio import KIO
|
||||||
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
from app.domain.statuses import PhoneCallPending, ServiceStatus
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scenarios.loader import load_file
|
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.checkpoint import dump_state, load_state
|
||||||
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
|
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue
|
||||||
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
|
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"
|
service=service, crew=state.crew_selected, phase="dispatched"
|
||||||
)
|
)
|
||||||
state.reply_text = "Сообщение принято, бригада направлена."
|
state.reply_text = "Сообщение принято, бригада направлена."
|
||||||
state.reply_grammar = basic_check(state.reply_text)
|
|
||||||
state.reply_log.append((now_utc(), state.reply_text))
|
state.reply_log.append((now_utc(), state.reply_text))
|
||||||
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
|
state.dds_log.append(("crew.select", now_utc(), state.crew_selected))
|
||||||
return state
|
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.crew_assignments == before.crew_assignments
|
||||||
assert restored.phone_pending == before.phone_pending
|
assert restored.phone_pending == before.phone_pending
|
||||||
assert restored.reply_text == before.reply_text
|
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.processed_station_commands == before.processed_station_commands
|
||||||
assert restored.dds_scenarios[0].id == before.scenario_id
|
assert restored.dds_scenarios[0].id == before.scenario_id
|
||||||
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
# Время простоя backend входит в норматив, а не обнуляет таймер.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
const notes = report.notes ?? [];
|
const notes = report.notes ?? [];
|
||||||
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
|
const passedMetrics = Math.max(0, report.metrics.length - report.failed_metrics);
|
||||||
const improvementPoints = report.metrics.filter((metric) => !metric.passed).slice(0, 3);
|
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 (
|
return (
|
||||||
<section className={big ? "debrief debrief-big" : "debrief"}>
|
<section className={big ? "debrief debrief-big" : "debrief"}>
|
||||||
|
|
@ -57,6 +59,12 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
) : (
|
) : (
|
||||||
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
|
<p>Зачёт не получен. Сверьте отметки и факты ниже с ходом занятия.</p>
|
||||||
)}
|
)}
|
||||||
|
{coaching.length > 0 && <>
|
||||||
|
<p>Рекомендации локальной модели (на балл не влияют):</p>
|
||||||
|
<ul>{coaching.map((item) => <li key={item.metric_key}>
|
||||||
|
{metricTitles.get(item.metric_key) ?? item.metric_key}: {item.text}
|
||||||
|
</li>)}</ul>
|
||||||
|
</>}
|
||||||
</section>
|
</section>
|
||||||
<p className="ref">
|
<p className="ref">
|
||||||
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a>
|
Скачать отчёт: <a href={`/api/sessions/${report.session_id}/report.csv`}>таблица (CSV)</a>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue