Merge remote-tracking branch 'origin/main' into feat/dds-timers
# Conflicts: # backend/app/scoring/dispatcher.py # backend/app/scoring/export.py # backend/app/session/finish.py # docs/arch/CONTRACT.md # docs/spec/GAP.md # frontend/src/features/instructor/ScoreWeightsEditor.tsx
This commit is contained in:
commit
4abdf0a901
61 changed files with 4084 additions and 167 deletions
|
|
@ -597,8 +597,13 @@ class DdsDesk(BaseModel):
|
|||
self.finish_work()
|
||||
outcome.finished = True
|
||||
case "card.bounce":
|
||||
# Возврата карточки на этапе принятия нет; об ошибке, которую
|
||||
# вскрыл доклад бригады с места, ДДС сообщает в 112 по телефону
|
||||
# (ответ заказчика, П.3).
|
||||
return _refused(
|
||||
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
|
||||
"Карточку не возвращают в 112 при принятии и не правят на АРМ ДДС. "
|
||||
"Если ошибка вскрылась по докладу бригады с места — сообщите о ней "
|
||||
"в 112 по телефону"
|
||||
)
|
||||
case "zone.decision":
|
||||
previous = next(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded, SessionPaused
|
||||
from app.domain.timers import TimerCode
|
||||
from app.scoring.ai_coach import coach
|
||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||
|
|
@ -25,10 +25,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,
|
||||
)
|
||||
|
||||
|
|
@ -44,6 +48,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,
|
||||
|
|
@ -53,10 +58,16 @@ 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,
|
||||
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=state.pause_intervals,
|
||||
)
|
||||
metrics = dispatcher_metrics(
|
||||
card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
|
||||
services=services,
|
||||
services=services, report_reaction_ms=reaction_ms,
|
||||
report_reaction_enabled=state.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=state.pause_intervals,
|
||||
)
|
||||
weighted = GostResult(metrics=metrics, findings=findings)
|
||||
work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
|
||||
|
|
@ -94,9 +105,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)),
|
||||
|
|
@ -175,7 +188,14 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
|||
"""
|
||||
if state.ended:
|
||||
return
|
||||
was_paused = state.paused
|
||||
paused_ms_before = state.total_paused_ms
|
||||
ended_at = state.end(reason)
|
||||
if was_paused:
|
||||
hub.record(session_id, LessonResumed(
|
||||
at=ended_at, author="система", role="system",
|
||||
paused_ms=state.total_paused_ms - paused_ms_before,
|
||||
))
|
||||
if state.exercise is Exercise.CARD and state.dispatched_card is None:
|
||||
state.on_event("card.end")
|
||||
# Норматив отработки фиксируется событием, а не текущим значением часов.
|
||||
|
|
@ -194,6 +214,11 @@ async def end_session(session_id: UUID, state, reason: CallEndReason) -> None:
|
|||
hub.to_station(session_id, SessionEnded(reason=reason))
|
||||
else:
|
||||
hub.to_trainee(session_id, CallEnded(reason=reason))
|
||||
if was_paused:
|
||||
# Снятие паузы — после сообщения о конце: иначе клиент дошлёт отложенные
|
||||
# на паузе правки карточки в уже закрытое занятие.
|
||||
hub.broadcast(session_id, SessionPaused(paused=False))
|
||||
hub.to_station(session_id, SessionPaused(paused=False))
|
||||
if state.score is None:
|
||||
return
|
||||
hub.to_observers(session_id, ScoreReady(session_id=session_id))
|
||||
|
|
@ -228,3 +253,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))
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ class SessionHub:
|
|||
state = self.get(session_id)
|
||||
if state is None or state.ended:
|
||||
return
|
||||
if state.dds_phase:
|
||||
if state.dds_phase and not state.paused:
|
||||
async with self.operation(session_id) as op:
|
||||
active_before = state.desk.active_id
|
||||
op.persist = bool(state.desk.deliver_due())
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ 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,
|
||||
LessonPaused,
|
||||
LessonRequest,
|
||||
LessonResumed,
|
||||
LessonStarted,
|
||||
NoteAdded,
|
||||
Record,
|
||||
|
|
@ -42,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
|
||||
|
|
@ -196,6 +202,16 @@ class PostgresSessionStore:
|
|||
object_id=str(session_id),
|
||||
detail=f"missed_count={len(record.missed)}; comment_chars={len(record.comment)}",
|
||||
))
|
||||
case LessonPaused(at=at, author=author, role=role):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.pause",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}",
|
||||
))
|
||||
case LessonResumed(at=at, author=author, role=role, paused_ms=paused_ms):
|
||||
db.add(AuditLog(
|
||||
actor=author, role=role, action="session.resume",
|
||||
object_id=str(session_id), detail=f"at={at.isoformat()}; paused_ms={paused_ms}",
|
||||
))
|
||||
case LessonStarted(at=at):
|
||||
await db.execute(
|
||||
update(Session).where(Session.id == session_id).values(started_at=at)
|
||||
|
|
@ -239,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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.domain.events import CallEndReason, Exercise, LessonCriteria, Metric
|
||||
|
|
@ -68,6 +69,7 @@ class ScoringSnapshot:
|
|||
#: Карточка на пульте в упражнении по звонку: отметки D1–D6 рядом с E1–E6.
|
||||
call_card: DdsLiveCard | None
|
||||
call_card_services: tuple[str, ...]
|
||||
pause_intervals: tuple[tuple[datetime, datetime], ...]
|
||||
|
||||
@property
|
||||
def reports_cards(self) -> bool:
|
||||
|
|
@ -112,6 +114,7 @@ class ScoringSnapshot:
|
|||
call_card=live_card.model_copy(deep=True) if live_card is not None else None,
|
||||
call_card_services=(tuple(state.card_services(live_card))
|
||||
if live_card is not None else ()),
|
||||
pause_intervals=tuple(state.pause_intervals),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -210,6 +213,7 @@ async def score_session(
|
|||
norm="грамматически корректное описание",
|
||||
ref="критерий занятия; правила русского языка",
|
||||
competency=Competency.COMMUNICATION,
|
||||
metric_key="description_grammar",
|
||||
))
|
||||
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
|
||||
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
|
||||
|
|
@ -218,6 +222,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 +232,16 @@ 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,
|
||||
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=snapshot.pause_intervals,
|
||||
))
|
||||
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,
|
||||
report_reaction_enabled=snapshot.criteria.dds_report_reaction_enabled,
|
||||
pause_intervals=snapshot.pause_intervals,
|
||||
))
|
||||
# Карточки ДДС уже взвешены каждая своим сценарием плюс настройкой
|
||||
# занятия. Веса первого сценария поверх них испортили бы остальные.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,14 @@ class PersistedSession(BaseModel):
|
|||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
end_reason: CallEndReason | None = None
|
||||
#: Пульт остановил время занятия; таймеры и очередь ДДС заморожены (lct-39).
|
||||
paused: bool = False
|
||||
#: Момент постановки на паузу — им же считается длительность простоя при снятии.
|
||||
paused_at: datetime | None = None
|
||||
#: Суммарная длительность пауз — для разбора, чтобы время было объяснимо.
|
||||
total_paused_ms: int = 0
|
||||
#: Закрытые интервалы пауз нужны для расчёта активного времени между докладом и статусом.
|
||||
pause_intervals: list[tuple[datetime, datetime]] = Field(default_factory=list)
|
||||
#: Карточка оператора 112, замороженная при передаче в ДДС.
|
||||
dispatched_card: KIO | None = None
|
||||
dispatched_at: datetime | None = None
|
||||
|
|
@ -190,6 +198,38 @@ class SessionState(PersistedSession):
|
|||
"""Единственная точка, где событие двигает таймеры."""
|
||||
self.timers.on_event(event_type)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Заморозить таймеры занятия и карточек пульта; очередь не выдаёт следующую."""
|
||||
if self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.pause(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.pause(now)
|
||||
self.paused = True
|
||||
self.paused_at = now_utc()
|
||||
if self.voice is not None:
|
||||
self.voice.pause()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Снять паузу: таймеры продолжают с прежней отметки, очередь сдвигается на простой."""
|
||||
if not self.paused:
|
||||
return
|
||||
now = time.monotonic()
|
||||
self.timers.resume(now)
|
||||
for card in self.desk.cards.values():
|
||||
card.timers.resume(now)
|
||||
if self.paused_at is not None:
|
||||
resumed_at = now_utc()
|
||||
idle = resumed_at - self.paused_at
|
||||
self.total_paused_ms += max(0, int(idle.total_seconds() * 1000))
|
||||
if resumed_at > self.paused_at:
|
||||
self.pause_intervals.append((self.paused_at, resumed_at))
|
||||
if self.desk.next_arrival_at is not None:
|
||||
self.desk.next_arrival_at += idle
|
||||
self.paused = False
|
||||
self.paused_at = None
|
||||
|
||||
def append(self, speaker: Speaker, text: str, mood: Mood | None = None) -> TranscriptEntry:
|
||||
entry = TranscriptEntry(
|
||||
ref=f"u{len(self.transcript) + 1}",
|
||||
|
|
@ -331,14 +371,15 @@ class SessionState(PersistedSession):
|
|||
services = self._managed_services_for(recipients)
|
||||
log = list(card.status_log) if card is not None else []
|
||||
statuses = {service: current(log, service) for service in services}
|
||||
now_mono = time.monotonic()
|
||||
overdue = False
|
||||
if card is not None:
|
||||
elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
|
||||
# Через таймер, а не через `dispatched_at`: часы разошлись бы с
|
||||
# паузой, которая держит `current_ms` на месте (lct-39).
|
||||
ack_timer = card.timers.timers.get(TimerCode.DDS_ACK)
|
||||
limit = card.timers.limits[TimerCode.DDS_ACK]
|
||||
overdue = elapsed is None and (
|
||||
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
|
||||
)
|
||||
now_mono = time.monotonic()
|
||||
elapsed_ms = ack_timer.current_ms(now_mono) if ack_timer else 0
|
||||
overdue = not (ack_timer.stopped if ack_timer else False) and elapsed_ms > limit
|
||||
queue_cards: list[DdsQueueCard] = []
|
||||
for item in (desk.ordered() if self.dds_phase else []):
|
||||
recipients_for_card = list(item.dispatched_card.notify)
|
||||
|
|
@ -409,6 +450,7 @@ class SessionState(PersistedSession):
|
|||
if desk.next_arrival_at is not None else None
|
||||
),
|
||||
max_waiting_cards=desk.max_waiting,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
||||
def card_received_event(self):
|
||||
|
|
@ -431,7 +473,14 @@ class SessionState(PersistedSession):
|
|||
return self.ended_at is not None
|
||||
|
||||
def end(self, reason: CallEndReason) -> datetime:
|
||||
"""Закрыть занятие; возвращает момент окончания для журнала."""
|
||||
"""Закрыть занятие; возвращает момент окончания для журнала.
|
||||
|
||||
Занятие могут завершить прямо на паузе (преподаватель нажал «Завершить»,
|
||||
не возобновив): снять флаг и досчитать простой сейчас, иначе курсант
|
||||
навсегда останется за баннером «Пауза, ждите преподавателя», а разбор
|
||||
потеряет последний, ещё не закрытый интервал паузы (lct-39).
|
||||
"""
|
||||
self.resume()
|
||||
self.ended_at = now_utc()
|
||||
self.end_reason = reason
|
||||
return self.ended_at
|
||||
|
|
@ -457,4 +506,5 @@ class SessionState(PersistedSession):
|
|||
timers=self.shown_timers().snapshot(),
|
||||
hints_used=len(self.hints_shown),
|
||||
ended=self.ended,
|
||||
paused=self.paused,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -83,8 +85,47 @@ class ScoreOverridden:
|
|||
comment: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonPaused:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingReviewed:
|
||||
"""Преподаватель подтвердил или снял отметку разбора."""
|
||||
index: int
|
||||
review: FindingReview
|
||||
role: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LessonResumed:
|
||||
at: datetime
|
||||
author: str
|
||||
role: str
|
||||
#: Длительность этой конкретной паузы — видно в аудите без пересчёта.
|
||||
paused_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FindingAdded:
|
||||
"""Преподаватель добавил свою отметку (`source=instructor`)."""
|
||||
finding: Finding
|
||||
role: str
|
||||
|
||||
|
||||
Record = (UtteranceAppended | HintRecorded | NoteAdded | SelfAssessed | LessonStarted
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden)
|
||||
| LessonEnded | ScoreCalculated | ScoreArchived | ScoreOverridden
|
||||
| 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:
|
||||
|
|
@ -200,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:
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ class Timer(BaseModel):
|
|||
elapsed_ms: int = 0
|
||||
attempt: int = 1
|
||||
stopped: bool = False
|
||||
#: Пауза занятия — таймер заморожен, но не завершён: `start()` его не
|
||||
#: считает новой попыткой, в отличие от `stopped` (lct-39).
|
||||
paused: bool = False
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _dump(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]:
|
||||
|
|
@ -104,13 +107,20 @@ class Timer(BaseModel):
|
|||
data = dict(data)
|
||||
elapsed = max(0, int(data.get("elapsed_ms", 0)))
|
||||
stopped = bool(data.get("stopped"))
|
||||
downtime = 0 if stopped else (info.context or {}).get("downtime_ms", 0)
|
||||
paused = bool(data.get("paused"))
|
||||
# На паузе занятие не простаивало без присмотра — оно ждало
|
||||
# преподавателя, и это время не досчитывается таймеру при перезапуске.
|
||||
downtime = 0 if stopped or paused else (info.context or {}).get("downtime_ms", 0)
|
||||
started = data.pop("started")
|
||||
data["started_at"] = time.monotonic() - (elapsed + downtime) / 1000 if started else None
|
||||
data["elapsed_ms"] = elapsed if stopped else 0
|
||||
data["elapsed_ms"] = elapsed if (stopped or paused) else 0
|
||||
return data
|
||||
|
||||
def start(self, now: float) -> None:
|
||||
if self.paused:
|
||||
# Таймер уже идёт, просто заморожен: запуск с нуля потерял бы
|
||||
# набранное время, а ход до `resume` посчитал бы паузу.
|
||||
return
|
||||
if self.stopped:
|
||||
# Повторный запуск после остановки — это новая попытка (обратный дозвон).
|
||||
self.attempt += 1
|
||||
|
|
@ -129,6 +139,19 @@ class Timer(BaseModel):
|
|||
return self.elapsed_ms
|
||||
return int((now - self.started_at) * 1000)
|
||||
|
||||
def pause(self, now: float) -> None:
|
||||
"""Зафиксировать прошедшее время и остановить ход часов до `resume`."""
|
||||
if self.started_at is not None and not self.stopped:
|
||||
self.elapsed_ms = self.current_ms(now)
|
||||
self.started_at = None
|
||||
self.paused = True
|
||||
|
||||
def resume(self, now: float) -> None:
|
||||
"""Продолжить с той же отметки — простой в счёт не идёт."""
|
||||
if self.paused:
|
||||
self.started_at = now - self.elapsed_ms / 1000
|
||||
self.paused = False
|
||||
|
||||
|
||||
class SessionTimers(BaseModel):
|
||||
"""Набор таймеров одной сессии. `limits` приходит из конфига —
|
||||
|
|
@ -146,6 +169,16 @@ class SessionTimers(BaseModel):
|
|||
for code in STOPS.get(event_type, ()):
|
||||
self.timers.setdefault(code, Timer(code=code)).stop(now)
|
||||
|
||||
def pause(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.pause(now)
|
||||
|
||||
def resume(self, now: float | None = None) -> None:
|
||||
now = time.monotonic() if now is None else now
|
||||
for timer in self.timers.values():
|
||||
timer.resume(now)
|
||||
|
||||
def snapshot(self, now: float | None = None) -> list[TimerSnapshot]:
|
||||
"""Только запущенные таймеры: показывать нули по нормативам,
|
||||
до которых занятие ещё не дошло, значит пугать курсанта зря."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue