lct-03 и lct-04: журнал сессий и библиотека сценариев
БД: 11 таблиц, первая миграция. Группы и связь trainee → group заложены
сразу, даже пустыми — размечать накопленные сессии задним числом значит
делать лишнюю миграцию. Номер попытки живёт в сессии, отдельной таблицы
попыток нет: дельта считается запросом по (trainee_id, scenario_id).
Сценарии: строгая схема — опечатка в имени поля падает на старте, а не
игнорируется молча. ground_truth собирается кодом, попытка задать
incident_type, dds или required_facts в YAML отвергается: иначе генератор
разведёт факты и эталон и курсанта оштрафуют за правильный ответ. Руками
задаются только нормализованные адрес и число пострадавших — из фразы
«улица Ленина, 14, квартира 47, 5-й этаж» кодом «улица Ленина, 14»
не достать.
GET /api/scenarios/{id} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
This commit is contained in:
parent
59d5a6a26d
commit
6b2c96bab6
22 changed files with 1380 additions and 8 deletions
208
backend/app/db/models.py
Normal file
208
backend/app/db/models.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Таблицы. Профиль курсанта, дельта попыток и аналитика группы строятся
|
||||
по этому журналу, а не по памяти процесса (docs/arch/BACKEND.md).
|
||||
|
||||
Группы и связь `trainee → group` заложены здесь с первой миграции, даже пустыми:
|
||||
размечать накопленные сессии задним числом — лишняя работа и лишняя миграция.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
def _uuid_pk() -> Mapped[UUID]:
|
||||
return mapped_column(primary_key=True, default=uuid4)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
"""Учебная группа. Единица аналитики: «70% группы не уточняют этаж»."""
|
||||
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
trainees: Mapped[list["Trainee"]] = relationship(back_populates="group")
|
||||
|
||||
|
||||
class Trainee(Base):
|
||||
__tablename__ = "trainees"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
group: Mapped["Group | None"] = relationship(back_populates="trainees")
|
||||
sessions: Mapped[list["Session"]] = relationship(back_populates="trainee")
|
||||
|
||||
|
||||
class Scenario(Base):
|
||||
"""Сценарий целиком лежит в `body`: библиотека — контент, а не схема.
|
||||
Отдельными колонками вынесено только то, по чему идёт выборка в списке."""
|
||||
|
||||
__tablename__ = "scenarios"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(80), primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
incident_type: Mapped[str] = mapped_column(String(20))
|
||||
level: Mapped[str] = mapped_column(String(4))
|
||||
topics: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
modes: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
status: Mapped[str] = mapped_column(String(16), default="published")
|
||||
body: Mapped[dict] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""Одна попытка одного курсанта по одному сценарию.
|
||||
|
||||
Номер попытки хранится здесь, а не отдельной таблицей: дельта попыток
|
||||
считается запросом по (trainee_id, scenario_id, attempt).
|
||||
"""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
scenario_id: Mapped[str] = mapped_column(ForeignKey("scenarios.id", ondelete="RESTRICT"))
|
||||
trainee_id: Mapped[UUID | None] = mapped_column(ForeignKey("trainees.id", ondelete="SET NULL"))
|
||||
group_id: Mapped[UUID | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"))
|
||||
mode: Mapped[str] = mapped_column(String(16))
|
||||
attempt: Mapped[int] = mapped_column(default=1)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
end_reason: Mapped[str | None] = mapped_column(String(16))
|
||||
kio: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
trainee: Mapped["Trainee | None"] = relationship(back_populates="sessions")
|
||||
utterances: Mapped[list["Utterance"]] = relationship(
|
||||
back_populates="session", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_sessions_trainee_scenario", "trainee_id", "scenario_id"),
|
||||
Index("ix_sessions_group_created", "group_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class Utterance(Base):
|
||||
"""Реплика транскрипта. `ref` — якорь для пометок и отметок разбора."""
|
||||
|
||||
__tablename__ = "utterances"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
ref: Mapped[str] = mapped_column(String(40))
|
||||
speaker: Mapped[str] = mapped_column(String(10))
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
mood: Mapped[str | None] = mapped_column(String(16))
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
session: Mapped["Session"] = relationship(back_populates="utterances")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("session_id", "ref", name="uq_utterance_ref"),
|
||||
Index("ix_utterances_session", "session_id", "at"),
|
||||
)
|
||||
|
||||
|
||||
class Finding(Base):
|
||||
"""Отметка разбора. Без `fact` отметки не бывает: код без обоснования
|
||||
не появляется ни в одном интерфейсе."""
|
||||
|
||||
__tablename__ = "findings"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
code: Mapped[str] = mapped_column(String(4))
|
||||
source: Mapped[str] = mapped_column(String(16))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
fact: Mapped[str] = mapped_column(Text)
|
||||
norm: Mapped[str | None] = mapped_column(Text)
|
||||
ref: Mapped[str | None] = mapped_column(String(80))
|
||||
competency: Mapped[str | None] = mapped_column(String(24))
|
||||
transcript_ref: Mapped[str | None] = mapped_column(String(40))
|
||||
at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (Index("ix_findings_session_code", "session_id", "code"),)
|
||||
|
||||
|
||||
class HintUse(Base):
|
||||
"""Использованная подсказка: какой пункт и на какой минуте.
|
||||
Штрафа нет — это информация преподавателю и материал разбора."""
|
||||
|
||||
__tablename__ = "hint_uses"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
checklist_id: Mapped[str] = mapped_column(String(60))
|
||||
question: Mapped[str] = mapped_column(Text)
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class SelfAssessment(Base):
|
||||
"""Самооценка до показа автооценки. Одна на сессию."""
|
||||
|
||||
__tablename__ = "self_assessments"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("sessions.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
missed: Mapped[list] = mapped_column(JSONB, default=list)
|
||||
comment: Mapped[str] = mapped_column(Text, default="")
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class InstructorNote(Base):
|
||||
"""Пометка преподавателя к реплике. Видна курсанту в его истории."""
|
||||
|
||||
__tablename__ = "instructor_notes"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(ForeignKey("sessions.id", ondelete="CASCADE"))
|
||||
transcript_ref: Mapped[str] = mapped_column(String(40))
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
author: Mapped[str] = mapped_column(String(120))
|
||||
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Score(Base):
|
||||
"""Оценка сессии. Автооценка сохраняется рядом с коррекцией:
|
||||
видно, что скорректировано и кем."""
|
||||
|
||||
__tablename__ = "scores"
|
||||
|
||||
id: Mapped[UUID] = _uuid_pk()
|
||||
session_id: Mapped[UUID] = mapped_column(
|
||||
ForeignKey("sessions.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
score_auto: Mapped[float] = mapped_column()
|
||||
score_final: Mapped[float] = mapped_column()
|
||||
overridden_by: Mapped[str | None] = mapped_column(String(120))
|
||||
override_comment: Mapped[str | None] = mapped_column(Text)
|
||||
report: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class LlmCache(Base):
|
||||
"""Кэш ответов LLM по хешу контекста. Работает и онлайн — экономия
|
||||
на повторах, — и как накопитель материала для офлайн-дерева."""
|
||||
|
||||
__tablename__ = "llm_cache"
|
||||
|
||||
context_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
model: Mapped[str] = mapped_column(String(80))
|
||||
prompt: Mapped[str] = mapped_column(Text)
|
||||
response: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
Loading…
Reference in a new issue