chore: обновить паузу занятия от main
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/session/store.py
This commit is contained in:
commit
0d58878cbe
51 changed files with 4545 additions and 87 deletions
|
|
@ -446,6 +446,7 @@ class DdsDesk(BaseModel):
|
|||
services[0], ServiceStatus.ACCEPTED, command.comment,
|
||||
author="диспетчер",
|
||||
)
|
||||
session.consecutive_refusals = 0
|
||||
except StatusError:
|
||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||
case "card.status":
|
||||
|
|
@ -462,6 +463,13 @@ class DdsDesk(BaseModel):
|
|||
# Первичный статус останавливает норматив 30 секунд.
|
||||
if command.status in PRIMARY:
|
||||
card.on_event("card.ack")
|
||||
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||
# отказы, а не сумму за занятие.
|
||||
session.consecutive_refusals = (
|
||||
session.consecutive_refusals + 1
|
||||
if command.status is ServiceStatus.DECLINED
|
||||
else 0
|
||||
)
|
||||
if command.status in {
|
||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
||||
}:
|
||||
|
|
|
|||
|
|
@ -26,11 +26,14 @@ from app.session.hub import hub
|
|||
from app.session.score import ScoringSnapshot, score_session, scoring_scenario
|
||||
from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
LessonEnded,
|
||||
LessonResumed,
|
||||
ScoreArchived,
|
||||
ScoreCalculated,
|
||||
ScoreOverridden,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
|
||||
|
|
@ -46,6 +49,7 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
services = state.card_services(card)
|
||||
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
|
||||
dds_decision = card.scenario.dds_decision
|
||||
reaction_ms = state.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
findings = evaluate_dispatcher(
|
||||
entries=card.status_log,
|
||||
services=services,
|
||||
|
|
@ -55,10 +59,12 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
reply_text=card.reply_text,
|
||||
expected_decision=dds_decision.expected,
|
||||
expected_decision_reason=dds_decision.reason,
|
||||
phone_reports=card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
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)
|
||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||
|
|
@ -101,9 +107,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
|||
card_id=card.card_id,
|
||||
scenario_id=card.scenario.id,
|
||||
reply_text=card.reply_text,
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
|
||||
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}",
|
||||
"card": number})
|
||||
for metric in weighted.metrics],
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
|
||||
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}",
|
||||
"card": number})
|
||||
for finding in weighted.findings],
|
||||
actions=actions,
|
||||
duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
|
||||
|
|
@ -247,3 +255,14 @@ def override_score(state, override: ScoreOverridden) -> None:
|
|||
hub.record(state.session_id, override)
|
||||
state.score = apply_score_override(state.score, override)
|
||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||
|
||||
|
||||
def change_findings(state, change: FindingReviewed | FindingAdded) -> None:
|
||||
"""Решение преподавателя по отметке — тем же путём, что правка итога.
|
||||
|
||||
Вызывается внутри `hub.operation`: отметки, пересчитанный балл, архивный
|
||||
разбор и строка аудита уходят одним commit.
|
||||
"""
|
||||
state.score = apply_finding_change(state.score, change)
|
||||
hub.record(state.session_id, change)
|
||||
hub.to_observers(state.session_id, ScoreReady(session_id=state.session_id))
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from app.domain.events import (
|
|||
)
|
||||
from app.session.state import SessionState
|
||||
from app.session.store import MemorySessionStore, Record, SessionLeaseLost, SessionStore
|
||||
from app.session.timers import now_utc
|
||||
|
||||
#: Очередь одного подписчика. Медленный наблюдатель не тормозит занятие:
|
||||
#: очередь ограничена, переполнение роняет соединение, а не сессию.
|
||||
|
|
@ -53,6 +54,9 @@ class SessionHub:
|
|||
self._observers: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._trainees: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._trainee_calls: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._trainee_stations: dict[UUID, set[asyncio.Queue]] = {}
|
||||
self._pending_adoptions: dict[UUID, SessionState] = {}
|
||||
self._tickers: dict[UUID, asyncio.Task] = {}
|
||||
# Задачи, порождённые внутри операции, наследуют контекст; после
|
||||
# закрытия операции их события идут напрямую (`_Operation.open`).
|
||||
|
|
@ -116,6 +120,7 @@ class SessionHub:
|
|||
|
||||
def drop(self, session_id: UUID) -> None:
|
||||
self._sessions.pop(session_id, None)
|
||||
self._pending_adoptions.pop(session_id, None)
|
||||
self.stop_ticker(session_id)
|
||||
|
||||
def live_count(self) -> int:
|
||||
|
|
@ -135,9 +140,10 @@ class SessionHub:
|
|||
if not self.store.persistent:
|
||||
return 0
|
||||
restored = await self.store.restore_active()
|
||||
adopted = 0
|
||||
for state in restored:
|
||||
self._adopt(state)
|
||||
return len(restored)
|
||||
adopted += await self._adopt(state)
|
||||
return adopted
|
||||
|
||||
async def maintain_lease(self) -> None:
|
||||
"""Один оборот супервизора: продлить свои lease, подхватить просроченные чужие.
|
||||
|
|
@ -154,6 +160,18 @@ class SessionHub:
|
|||
log.warning("продление lease занятия %s не удалось", state.session_id,
|
||||
exc_info=True)
|
||||
await self.fence(state)
|
||||
for state in list(self._pending_adoptions.values()):
|
||||
try:
|
||||
await self.store.commit(state)
|
||||
except SessionLeaseLost:
|
||||
self._pending_adoptions.pop(state.session_id, None)
|
||||
except Exception:
|
||||
log.warning("повторное сохранение занятия %s после takeover не удалось",
|
||||
state.session_id, exc_info=True)
|
||||
else:
|
||||
self._pending_adoptions.pop(state.session_id, None)
|
||||
self.register(state)
|
||||
self.start_ticker(state.session_id)
|
||||
try:
|
||||
claimed = await self.store.claim_expired()
|
||||
except Exception: # следующий оборот попробует снова
|
||||
|
|
@ -163,17 +181,32 @@ class SessionHub:
|
|||
current = self._sessions.get(state.session_id)
|
||||
if current is not None and not current.lease_fenced:
|
||||
continue
|
||||
self._adopt(state)
|
||||
await self._adopt(state)
|
||||
|
||||
async def supervise_lease(self, interval: float) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
await self.maintain_lease()
|
||||
|
||||
def _adopt(self, state: SessionState) -> None:
|
||||
async def _adopt(self, state: SessionState) -> bool:
|
||||
self.stop_ticker(state.session_id)
|
||||
if state.socket_connected_at_checkpoint:
|
||||
# Разрыв произошёл при потере узла; старое время подключения не
|
||||
# доказывает, что курсант отсутствовал всё это время.
|
||||
state.socket_last_seen_at = now_utc()
|
||||
state.socket_connected_at_checkpoint = False
|
||||
try:
|
||||
await self.store.commit(state)
|
||||
except SessionLeaseLost:
|
||||
return False
|
||||
except Exception:
|
||||
log.exception("не удалось сохранить присутствие после takeover %s", state.session_id)
|
||||
self._pending_adoptions[state.session_id] = state
|
||||
return False
|
||||
self._pending_adoptions.pop(state.session_id, None)
|
||||
self.register(state)
|
||||
self.start_ticker(state.session_id)
|
||||
return True
|
||||
|
||||
async def save_all(self) -> None:
|
||||
"""Снимок живых занятий при остановке узла: следующий владелец продолжит с него."""
|
||||
|
|
@ -285,6 +318,44 @@ class SessionHub:
|
|||
def station(self, session_id: UUID):
|
||||
return self._subscribe(self._stations, session_id)
|
||||
|
||||
async def _record_presence(self, session_id: UUID, *, station: bool) -> None:
|
||||
state = self.get(session_id)
|
||||
if state is None or state.ended or state.dds_phase != station:
|
||||
return
|
||||
async with self.operation(session_id):
|
||||
state.socket_last_seen_at = now_utc()
|
||||
present = self._trainee_stations if station else self._trainee_calls
|
||||
state.socket_connected_at_checkpoint = bool(present.get(session_id))
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def trainee_socket(
|
||||
self, session_id: UUID, *, station: bool, trainee: bool,
|
||||
) -> AsyncIterator[asyncio.Queue]:
|
||||
"""Учесть только сокет курсанта; вещание преподавателю остаётся общим."""
|
||||
registry = self._stations if station else self._trainees
|
||||
presence = self._trainee_stations if station else self._trainee_calls
|
||||
with self._subscribe(registry, session_id) as queue:
|
||||
if trainee:
|
||||
presence.setdefault(session_id, set()).add(queue)
|
||||
try:
|
||||
if trainee:
|
||||
try:
|
||||
await self._record_presence(session_id, station=station)
|
||||
except Exception:
|
||||
if not self.is_lease_fenced(session_id):
|
||||
raise
|
||||
yield queue
|
||||
finally:
|
||||
if trainee:
|
||||
presence[session_id].discard(queue)
|
||||
if not presence[session_id]:
|
||||
presence.pop(session_id)
|
||||
try:
|
||||
await self._record_presence(session_id, station=station)
|
||||
except Exception:
|
||||
if not self.is_lease_fenced(session_id):
|
||||
raise
|
||||
|
||||
# ── вещание ──
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -314,6 +385,12 @@ class SessionHub:
|
|||
def observer_count(self, session_id: UUID) -> int:
|
||||
return len(self._observers.get(session_id, set()))
|
||||
|
||||
def station_connected(self, session_id: UUID) -> bool:
|
||||
return bool(self._trainee_stations.get(session_id))
|
||||
|
||||
def trainee_connected(self, session_id: UUID) -> bool:
|
||||
return bool(self._trainee_calls.get(session_id))
|
||||
|
||||
# ── такт таймеров ──
|
||||
|
||||
def start_ticker(self, session_id: UUID) -> None:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ from app.domain.events import Mood, Speaker, TranscriptEntry
|
|||
from app.session.checkpoint import dump_state, load_state
|
||||
from app.session.state import SessionState, now_utc
|
||||
from app.session.store import (
|
||||
FindingAdded,
|
||||
FindingReviewed,
|
||||
HintRecorded,
|
||||
LessonEnded,
|
||||
LessonIdentity,
|
||||
|
|
@ -44,8 +46,10 @@ from app.session.store import (
|
|||
SelfAssessed,
|
||||
SessionLeaseLost,
|
||||
UtteranceAppended,
|
||||
apply_finding_change,
|
||||
apply_score_override,
|
||||
)
|
||||
from app.scoring.review import has_finding
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LEASE_SECONDS = 15
|
||||
|
|
@ -251,6 +255,40 @@ class PostgresSessionStore:
|
|||
detail=(f"{score.score_auto} → {record.score_final}; "
|
||||
f"comment_chars={len(record.comment)}"),
|
||||
))
|
||||
case FindingReviewed() | FindingAdded():
|
||||
await db.flush()
|
||||
score = await db.scalar(
|
||||
select(Score).where(Score.session_id == session_id).with_for_update()
|
||||
)
|
||||
if score is None:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
# Повтор ручной отметки под блокировкой: ни изменения, ни второй строки аудита.
|
||||
if (isinstance(record, FindingAdded)
|
||||
and has_finding(score.report or {}, record.finding.client_id)):
|
||||
return
|
||||
before = score.score_final
|
||||
findings = (score.report or {}).get("findings", [])
|
||||
score.report = apply_finding_change(dict(score.report or {}), record)
|
||||
score.score_final = score.report.get("score_final", score.score_final)
|
||||
if isinstance(record, FindingReviewed):
|
||||
code = (findings[record.index].get("code", "?")
|
||||
if 0 <= record.index < len(findings) else "?")
|
||||
actor, action = record.review.author, "finding.review"
|
||||
what = (f"#{record.index} {code} {record.review.decision.value}; "
|
||||
f"причина: {record.review.reason}")
|
||||
else:
|
||||
finding = record.finding
|
||||
actor, action = finding.author or "instructor", "finding.add"
|
||||
where = f", карточка {finding.card}" if finding.card is not None else ""
|
||||
what = (f"{finding.code.value}{where}; факт: {finding.fact}; "
|
||||
f"норма: {finding.norm or ''}")
|
||||
# Решение по отметке меняет чужой балл: аудит сам по себе должен
|
||||
# ответить «кто, когда, что и почему», даже если разбор потом правили.
|
||||
db.add(AuditLog(
|
||||
actor=actor, role=record.role, action=action,
|
||||
object_id=str(session_id),
|
||||
detail=f"{what}; {before} → {score.score_final}",
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ async def score_session(
|
|||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
metric_key="description_grammar",
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
|
|
@ -218,6 +219,7 @@ async def score_session(
|
|||
if live_card is not None:
|
||||
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
|
||||
services = list(snapshot.call_card_services)
|
||||
reaction_ms = snapshot.criteria.dds_report_reaction_limit_seconds * 1000
|
||||
result.findings.extend(evaluate_dispatcher(
|
||||
entries=live_card.status_log,
|
||||
services=services,
|
||||
|
|
@ -227,10 +229,12 @@ async def score_session(
|
|||
reply_text=live_card.reply_text,
|
||||
expected_decision=scenario.dds_decision.expected,
|
||||
expected_decision_reason=scenario.dds_decision.reason,
|
||||
phone_reports=live_card.phone_reports,
|
||||
report_reaction_ms=reaction_ms,
|
||||
))
|
||||
result.metrics.extend(dispatcher_metrics(
|
||||
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,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
|
|
@ -130,6 +130,15 @@ class PersistedSession(BaseModel):
|
|||
#: a lost WebSocket acknowledgement cannot apply an operation twice.
|
||||
processed_station_commands: list[str] = Field(default_factory=list)
|
||||
text_revealed_facts: dict[str, str] = Field(default_factory=dict)
|
||||
#: Подряд идущих первичных отказов без принятой карточки между ними —
|
||||
#: сигнал реестра преподавателя, не влияет на балл.
|
||||
consecutive_refusals: int = 0
|
||||
#: Последнее подключение/отключение сокета курсанта или начало фазы ДДС.
|
||||
#: Сохраняется вместе с занятием, чтобы чтение реестра не меняло состояние.
|
||||
socket_last_seen_at: datetime | None = None
|
||||
#: На момент последнего checkpoint курсант был подключён на текущем АРМ.
|
||||
#: После аварии узла момент потери связи неизвестен: окно начинается при takeover.
|
||||
socket_connected_at_checkpoint: bool = False
|
||||
|
||||
@field_validator("processed_station_commands")
|
||||
@classmethod
|
||||
|
|
@ -241,6 +250,9 @@ class SessionState(PersistedSession):
|
|||
self.dispatched_at = now_utc()
|
||||
if self.exercise is Exercise.CALL:
|
||||
self._receive_call_card()
|
||||
if self.handoff_to_dds:
|
||||
self.socket_last_seen_at = self.dispatched_at
|
||||
self.socket_connected_at_checkpoint = False
|
||||
return self.dispatched_card
|
||||
|
||||
def _receive_call_card(self) -> None:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from typing import Protocol
|
|||
from uuid import UUID
|
||||
|
||||
from app.domain.events import TranscriptEntry
|
||||
from app.domain.taxonomy import Finding, FindingReview
|
||||
from app.scoring.review import add_finding, review_finding
|
||||
from app.session.checkpoint import dump_state
|
||||
from app.session.state import SessionState
|
||||
|
||||
|
|
@ -90,6 +92,14 @@ class LessonPaused:
|
|||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingReviewed:
|
||||
"""Преподаватель подтвердил или снял отметку разбора."""
|
||||
index: int
|
||||
review: FindingReview
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonResumed:
|
||||
at: datetime
|
||||
|
|
@ -99,9 +109,23 @@ class LessonResumed:
|
|||
paused_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingAdded:
|
||||
"""Преподаватель добавил свою отметку (`source=instructor`)."""
|
||||
finding: Finding
|
||||
role: str
|
||||
|
||||
|
||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||
| LessonPaused | LessonResumed)
|
||||
| LessonPaused | LessonResumed | FindingReviewed | FindingAdded)
|
||||
|
||||
|
||||
def apply_finding_change(report: dict, change: "FindingReviewed | FindingAdded") -> dict:
|
||||
"""Решение по отметке одинаково для живой сессии, строки `Score` и архива."""
|
||||
if isinstance(change, FindingReviewed):
|
||||
return review_finding(report, change.index, change.review)
|
||||
return add_finding(report, change.finding)
|
||||
|
||||
|
||||
def apply_score_override(report: dict, override: ScoreOverridden) -> dict:
|
||||
|
|
@ -217,6 +241,10 @@ class MemorySessionStore:
|
|||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
self.scores[session_id] = apply_score_override(self.scores[session_id], record)
|
||||
elif isinstance(record, FindingReviewed | FindingAdded):
|
||||
if session_id not in self.scores:
|
||||
raise LookupError(f"нет оценки занятия {session_id}")
|
||||
self.scores[session_id] = apply_finding_change(self.scores[session_id], record)
|
||||
self.records.setdefault(session_id, []).extend(records)
|
||||
|
||||
def snapshot(self, session_id: UUID) -> dict | None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue