lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
"""Запись хода занятия в БД.
|
|
|
|
|
|
|
|
|
|
|
|
Профиль курсанта, дельта попыток и аналитика группы строятся по журналу,
|
|
|
|
|
|
а не по памяти процесса: всё, что здесь не записано, для оценки не существует.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from uuid import UUID
|
|
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import update
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
|
|
|
|
|
|
|
|
|
from app.db import repo
|
2026-09-17 21:24:34 +03:00
|
|
|
|
from app.db.models import Score, SelfAssessment, Session
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DbJournal:
|
|
|
|
|
|
def __init__(self, sessionmaker: async_sessionmaker) -> None:
|
|
|
|
|
|
self._sessionmaker = sessionmaker
|
|
|
|
|
|
|
|
|
|
|
|
async def _write(self, action, *args, **kwargs) -> None:
|
|
|
|
|
|
"""Ошибка записи не роняет занятие, но и не проглатывается молча:
|
|
|
|
|
|
занятие идёт дальше, в логе остаётся след."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with self._sessionmaker() as db:
|
|
|
|
|
|
await action(db, *args, **kwargs)
|
|
|
|
|
|
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
|
|
|
|
|
log.exception("журнал: запись не удалась")
|
|
|
|
|
|
|
|
|
|
|
|
async def start_lesson(
|
2026-09-21 17:40:54 +03:00
|
|
|
|
self, session_id: UUID, scenario_id: str, mode: str, trainee_name: str | None,
|
|
|
|
|
|
trainee_id: UUID | None = None,
|
|
|
|
|
|
) -> tuple[int, UUID | None]:
|
|
|
|
|
|
"""Завести сессию в журнале и вернуть номер попытки и ID курсанта.
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
Если база недоступна, занятие всё равно идёт: номер попытки
|
|
|
|
|
|
деградирует до первого, и это видно в логе.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with self._sessionmaker() as db:
|
|
|
|
|
|
row = await repo.ensure_session(
|
|
|
|
|
|
db,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
scenario_id=scenario_id,
|
|
|
|
|
|
mode=mode,
|
|
|
|
|
|
trainee_name=trainee_name,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
trainee_id=trainee_id,
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return row.attempt, row.trainee_id
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
except Exception: # noqa: BLE001 — журнал не должен ронять живую сессию
|
|
|
|
|
|
log.exception("журнал: сессию завести не удалось")
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return 1, trainee_id
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
|
|
|
|
|
|
async def utterance(self, session_id: UUID, entry) -> None:
|
|
|
|
|
|
await self._write(
|
|
|
|
|
|
repo.append_utterance,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
ref=entry.ref,
|
|
|
|
|
|
speaker=entry.speaker.value,
|
|
|
|
|
|
text=entry.text,
|
|
|
|
|
|
at=entry.at,
|
|
|
|
|
|
mood=entry.mood.value if entry.mood else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def hint(self, session_id: UUID, checklist_id: str, question: str, at: datetime) -> None:
|
|
|
|
|
|
await self._write(
|
|
|
|
|
|
repo.record_hint, session_id=session_id, checklist_id=checklist_id, question=question, at=at
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def note(self, session_id: UUID, ref: str, text: str, author: str) -> None:
|
|
|
|
|
|
await self._write(repo.add_note, session_id=session_id, transcript_ref=ref, text=text, author=author)
|
|
|
|
|
|
|
|
|
|
|
|
async def self_assessment(
|
|
|
|
|
|
self, session_id: UUID, missed: list[str], comment: str, at: datetime
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
async def action(db):
|
|
|
|
|
|
db.add(
|
|
|
|
|
|
SelfAssessment(
|
|
|
|
|
|
session_id=session_id, missed=missed, comment=comment, submitted_at=at
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
await self._write(lambda db: action(db))
|
|
|
|
|
|
|
2026-09-17 21:24:34 +03:00
|
|
|
|
async def score(self, session_id: UUID, score_auto: float, report: dict) -> None:
|
|
|
|
|
|
async def action(db):
|
|
|
|
|
|
db.add(Score(session_id=session_id, score_auto=score_auto, score_final=score_auto, report=report))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
await self._write(lambda db: action(db))
|
|
|
|
|
|
|
lct-05: состояние сессии, таймеры по событиям, четыре канала
Таймеры объявлены таблицей «событие → старт/стоп», а не разбросаны
по обработчикам: так видно целиком, что чем запускается, и норматив
нельзя потерять по дороге. Опрос стартует на ответе курсанта и
останавливается передачей в ДДС — событием, а не таймаутом.
Канал наблюдателя без единого обработчика входящих: кадры читаются
и выбрасываются, не разбираясь, только чтобы заметить разрыв. Без
чтения задача сокета висела бы на очереди до первой отправки, а
закрытая вкладка монитора оставляла бы подписку. Тест проверяет, что
наблюдатель не может ни изменить карточку, ни завершить занятие.
Пульт теперь заводит сессию в журнале: запущенное с него занятие жило
только в памяти, и реплики с подсказками уходили в нарушение внешнего
ключа — журнал об этом честно сообщал в лог.
Тикер таймеров гасится при остановке приложения, иначе задачи переживают
выключение и держат событийный цикл.
Проверено вживую через uvicorn: монитор, подключённый посреди занятия,
получает снимок с заполненной карточкой; такт таймера идёт раз в секунду.
2026-09-16 02:07:27 +03:00
|
|
|
|
async def session_started(self, session_id: UUID, at: datetime) -> None:
|
|
|
|
|
|
async def action(db):
|
|
|
|
|
|
await db.execute(update(Session).where(Session.id == session_id).values(started_at=at))
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
await self._write(lambda db: action(db))
|
|
|
|
|
|
|
|
|
|
|
|
async def session_ended(self, session_id: UUID, at: datetime, reason: str) -> None:
|
|
|
|
|
|
async def action(db):
|
|
|
|
|
|
await db.execute(
|
|
|
|
|
|
update(Session)
|
|
|
|
|
|
.where(Session.id == session_id)
|
|
|
|
|
|
.values(ended_at=at, end_reason=reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
await self._write(lambda db: action(db))
|