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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""Библиотека сценариев по HTTP.
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
Курсантский каталог и карточка отдают только заголовок, сложность и доступные
|
|
|
|
|
|
режимы: классификатор, факты, личность звонящего и чек-лист не должны быть
|
|
|
|
|
|
доступны заранее через DevTools. Инструктор и администратор получают редакторскую
|
|
|
|
|
|
карточку. Подсказки в сессии выдаются по одному пункту через `hint.shown`,
|
|
|
|
|
|
эталонные вопросы — только в разборе (docs/product/MODES.md#подсказка-по-запросу).
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from collections.abc import AsyncIterator
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from typing import Any
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from sqlalchemy import select
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.api.auth import add_audit_entry, audit, require
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.config import get_settings
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.db.base import get_session
|
|
|
|
|
|
from app.db.models import Group, Trainee
|
|
|
|
|
|
from app.dialog.llm import LlmUnavailable
|
|
|
|
|
|
from app.domain import ekp
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.domain.roles import Role
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
from app.scenarios import store
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scenarios.editor import validate
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.scenarios.generation import (
|
|
|
|
|
|
GenerationError,
|
|
|
|
|
|
generate,
|
|
|
|
|
|
generate_from_description,
|
|
|
|
|
|
)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
from app.scenarios.loader import ScenarioError
|
2026-09-26 18:12:27 +03:00
|
|
|
|
from app.scoring.grammar import assess
|
2026-09-24 01:10:49 +03:00
|
|
|
|
from app.session.hub import hub
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
|
|
|
|
|
|
async def _hidden_scenario_ids(db: AsyncSession | None, who) -> set[str]:
|
|
|
|
|
|
"""Scenario drafts are private to their instructor and that instructor's class."""
|
|
|
|
|
|
if who.role is Role.ADMIN:
|
|
|
|
|
|
return set()
|
|
|
|
|
|
owner_login = who.login
|
|
|
|
|
|
if who.role is Role.TRAINEE:
|
|
|
|
|
|
if db is None or who.trainee_id is None:
|
|
|
|
|
|
owner_login = ""
|
|
|
|
|
|
else:
|
|
|
|
|
|
owner_login = await db.scalar(
|
|
|
|
|
|
select(Group.owner_login)
|
|
|
|
|
|
.join(Trainee, Trainee.group_id == Group.id)
|
|
|
|
|
|
.where(Trainee.id == who.trainee_id)
|
|
|
|
|
|
) or ""
|
|
|
|
|
|
return await store.scenario_ids_owned_by_other(db, owner_login)
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def scenario_session() -> AsyncIterator[AsyncSession | None]:
|
|
|
|
|
|
"""Только редактор в demo-lite использует временное хранилище без БД."""
|
|
|
|
|
|
if get_settings().demo_no_db:
|
|
|
|
|
|
yield None
|
|
|
|
|
|
else:
|
|
|
|
|
|
async for db in get_session():
|
|
|
|
|
|
yield db
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
class TemplateDraftIn(BaseModel):
|
|
|
|
|
|
source_id: str = Field(min_length=1)
|
|
|
|
|
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
class GenerateDraftIn(BaseModel):
|
|
|
|
|
|
source_id: str = Field(min_length=1)
|
|
|
|
|
|
instruction: str = Field(min_length=10, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GenerateFullDraftIn(BaseModel):
|
|
|
|
|
|
source_id: str = Field(min_length=1)
|
|
|
|
|
|
description: str = Field(min_length=20, max_length=1500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ReviseDraftIn(BaseModel):
|
|
|
|
|
|
comment: str = Field(min_length=10, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
def _draft_out(row) -> dict:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if row.id.startswith("ai-full-"):
|
|
|
|
|
|
generation = "ai_full"
|
|
|
|
|
|
elif row.id.startswith("ai-"):
|
|
|
|
|
|
generation = "ai_variant"
|
|
|
|
|
|
else:
|
|
|
|
|
|
generation = "template_copy"
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return {
|
|
|
|
|
|
"id": row.id,
|
|
|
|
|
|
"status": row.status,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"generation": generation,
|
2026-09-21 17:40:54 +03:00
|
|
|
|
"body": row.body,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
def _draft_grammar_hash(scenario) -> str:
|
|
|
|
|
|
"""Stable fingerprint of the caller dialogue fields covered by grammar QA."""
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"first_line": scenario.first_line,
|
|
|
|
|
|
"facts": [
|
|
|
|
|
|
{"id": fact.id, "value": fact.value, "refined": fact.refined}
|
|
|
|
|
|
for fact in scenario.facts
|
|
|
|
|
|
],
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _audit_before_commit(actor: str, role: str, action: str, detail: str = ""):
|
|
|
|
|
|
return lambda transaction, row: add_audit_entry(
|
|
|
|
|
|
transaction, actor, role, action, str(row.id), detail
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
@router.post("/drafts/from-template", status_code=201)
|
|
|
|
|
|
async def create_template_draft(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
body: TemplateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
source = store.get(body.source_id)
|
|
|
|
|
|
if source is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="published_source_not_found")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
row = await store.create_draft(
|
|
|
|
|
|
db,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
title=body.title,
|
|
|
|
|
|
owner_login=who.login,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.draft.create", f"template:{source.id}"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@router.post("/drafts/generate", status_code=201)
|
|
|
|
|
|
async def create_ai_draft(
|
|
|
|
|
|
body: GenerateDraftIn, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
source = store.get(body.source_id)
|
|
|
|
|
|
if source is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="published_source_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = await generate(source, body.instruction.strip(), require_fact_change=False)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
row = await store.create_draft(
|
|
|
|
|
|
db, source=source, proposal=proposal, owner_login=who.login,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.draft.ai_generate", f"source:{source.id}",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
except LlmUnavailable as exc:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
|
|
|
|
|
except GenerationError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
|
|
|
|
|
f"source:{source.id}")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/drafts/generate-from-description", status_code=201)
|
|
|
|
|
|
async def create_full_ai_draft(
|
|
|
|
|
|
body: GenerateFullDraftIn, request: Request,
|
|
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Новый сюжет и мягкий эталон внутри выбранного класса ЕКП."""
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
source = store.get(body.source_id)
|
|
|
|
|
|
if source is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="published_source_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
proposal = await generate_from_description(source, body.description.strip())
|
|
|
|
|
|
row = await store.create_draft(
|
2026-09-26 18:12:27 +03:00
|
|
|
|
db, source=source, full_proposal=proposal, owner_login=who.login,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.draft.ai_generate_full",
|
|
|
|
|
|
f"class_source:{source.id}",
|
|
|
|
|
|
),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
)
|
|
|
|
|
|
except LlmUnavailable as exc:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
|
|
|
|
|
except GenerationError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
|
|
|
|
|
f"class_source:{source.id}")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
@router.get("/drafts/{scenario_id}")
|
|
|
|
|
|
async def read_draft(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> dict:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/drafts/{scenario_id}")
|
|
|
|
|
|
async def patch_draft(
|
|
|
|
|
|
scenario_id: str,
|
|
|
|
|
|
body: dict[str, Any],
|
|
|
|
|
|
request: Request,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
row = await store.update_draft(
|
|
|
|
|
|
db, row, body,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.draft.update"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.update", row.id)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@router.post("/drafts/{scenario_id}/revise")
|
|
|
|
|
|
async def revise_ai_draft(
|
|
|
|
|
|
scenario_id: str,
|
|
|
|
|
|
body: ReviseDraftIn,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
source = validate(row.body)
|
|
|
|
|
|
proposal = await generate(source, body.comment.strip(), require_fact_change=False)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
row = await store.revise_draft(
|
|
|
|
|
|
db, row, proposal,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.draft.ai_revise",
|
|
|
|
|
|
f"instruction_chars={len(body.comment.strip())}",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
except LlmUnavailable as exc:
|
|
|
|
|
|
raise HTTPException(status_code=503, detail=f"локальная модель недоступна: {exc}") from exc
|
|
|
|
|
|
except (GenerationError, ScenarioError) as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
# Editorial instructions can contain names, addresses, or other sensitive
|
|
|
|
|
|
# details. Keep only non-content metadata in the durable admin audit log.
|
|
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
|
|
|
|
|
f"instruction_chars={len(body.comment.strip())}")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return _draft_out(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
@router.post("/drafts/{scenario_id}/validate")
|
|
|
|
|
|
async def validate_draft(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> dict:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
scenario = validate(row.body)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
return {"valid": False, "errors": [str(exc)]}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"valid": True,
|
|
|
|
|
|
"errors": [],
|
|
|
|
|
|
"ground_truth": scenario.ground_truth.model_dump(mode="json"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-26 18:12:27 +03:00
|
|
|
|
@router.post("/drafts/{scenario_id}/grammar-check")
|
|
|
|
|
|
async def check_draft_grammar(
|
|
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Явная языковая проверка после ручного редактирования сценария.
|
|
|
|
|
|
|
|
|
|
|
|
Это только диагностический результат: проверяются реплика звонящего и
|
|
|
|
|
|
текстовые значения фактов, но содержимое не исправляется и не публикуется.
|
|
|
|
|
|
"""
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
|
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
|
|
|
|
|
scenario = validate(row.body)
|
|
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=f"сначала исправьте структуру: {exc}") from exc
|
|
|
|
|
|
|
|
|
|
|
|
fields = [("first_line", scenario.first_line)]
|
|
|
|
|
|
for fact in scenario.facts:
|
|
|
|
|
|
fields.append((f"facts.{fact.id}.value", fact.value))
|
|
|
|
|
|
if fact.refined:
|
|
|
|
|
|
fields.append((f"facts.{fact.id}.refined", fact.refined))
|
|
|
|
|
|
checks = []
|
|
|
|
|
|
for field, value in fields:
|
|
|
|
|
|
result = await assess(value)
|
|
|
|
|
|
checks.append({
|
|
|
|
|
|
"field": field,
|
|
|
|
|
|
"passed": result.passed,
|
|
|
|
|
|
"errors": list(result.errors),
|
|
|
|
|
|
"source": result.source,
|
|
|
|
|
|
})
|
|
|
|
|
|
passed = all(item["passed"] for item in checks)
|
|
|
|
|
|
row.grammar_check_hash = _draft_grammar_hash(scenario) if passed else None
|
|
|
|
|
|
if db is not None:
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
return {"valid": passed, "checks": checks}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
@router.post("/drafts/{scenario_id}/approve")
|
|
|
|
|
|
async def approve_draft(
|
2026-09-24 01:10:49 +03:00
|
|
|
|
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
row = await store.draft(db, scenario_id, owner_login=who.login)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
if row is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="draft_not_found")
|
|
|
|
|
|
try:
|
2026-09-26 18:12:27 +03:00
|
|
|
|
current = validate(row.body)
|
|
|
|
|
|
if (row.manual_edit_pending
|
|
|
|
|
|
and row.grammar_check_hash != _draft_grammar_hash(current)):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=409,
|
|
|
|
|
|
detail="после ручных правок требуется успешная проверка грамматики",
|
|
|
|
|
|
)
|
|
|
|
|
|
scenario = await store.approve_draft(
|
|
|
|
|
|
db, row,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.approve"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
except ScenarioError as exc:
|
|
|
|
|
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.approve", scenario.id)
|
2026-09-21 17:40:54 +03:00
|
|
|
|
return {"id": scenario.id, "status": "published", "title": scenario.title}
|
|
|
|
|
|
|
|
|
|
|
|
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@router.get("")
|
2026-09-24 01:10:49 +03:00
|
|
|
|
async def listing(
|
|
|
|
|
|
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is not None:
|
|
|
|
|
|
# Published student scenarios may have been approved on a peer backend.
|
|
|
|
|
|
# Refresh this process-local catalog from the shared authoritative DB.
|
|
|
|
|
|
await store.restore_published(db)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
owned_ids = (
|
|
|
|
|
|
await store.owned_scenario_ids(db, who.login)
|
|
|
|
|
|
if who is not None and who.role is Role.INSTRUCTOR
|
|
|
|
|
|
else set()
|
|
|
|
|
|
)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
hidden_ids = await _hidden_scenario_ids(db, who)
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for scenario in store.all_scenarios():
|
|
|
|
|
|
if scenario.id in hidden_ids:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if who.role is Role.TRAINEE:
|
|
|
|
|
|
# A trainee may select a scenario for self-practice, but the catalog
|
|
|
|
|
|
# must not reveal dispatch codes, answer hints, or instructor-only metadata.
|
|
|
|
|
|
if "self" not in scenario.modes:
|
|
|
|
|
|
continue
|
|
|
|
|
|
result.append({
|
|
|
|
|
|
"id": scenario.id,
|
|
|
|
|
|
"title": scenario.title,
|
|
|
|
|
|
"level": scenario.level.value,
|
|
|
|
|
|
"modes": scenario.modes,
|
|
|
|
|
|
})
|
|
|
|
|
|
continue
|
|
|
|
|
|
result.append({
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"id": scenario.id,
|
|
|
|
|
|
"title": scenario.title,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"outcome": scenario.outcome.value,
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"type": scenario.type.value,
|
|
|
|
|
|
"level": scenario.level.value,
|
|
|
|
|
|
"topics": scenario.topics,
|
|
|
|
|
|
"modes": scenario.modes,
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Преподаватель должен видеть не только название карточки, но и
|
|
|
|
|
|
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
|
|
|
|
|
|
# этого пути, а не незаметно подменяет код происшествия.
|
|
|
|
|
|
"signs": scenario.signs,
|
|
|
|
|
|
"incident_code": scenario.ground_truth.incident_code,
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
|
2026-09-21 20:10:57 +03:00
|
|
|
|
"ticket": scenario.ticket,
|
|
|
|
|
|
"position": scenario.position,
|
|
|
|
|
|
"ekp_group": (ekp.incident(scenario.ground_truth.incident_code).group
|
|
|
|
|
|
if scenario.ground_truth.incident_code
|
|
|
|
|
|
and ekp.incident(scenario.ground_truth.incident_code) else None),
|
2026-09-24 01:10:49 +03:00
|
|
|
|
"can_manage": scenario.id in owned_ids,
|
2026-09-26 18:12:27 +03:00
|
|
|
|
"source": "trainee" if "student-created" in scenario.topics else "system",
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
|
|
|
|
|
|
|
2026-09-24 01:10:49 +03:00
|
|
|
|
@router.delete("/{scenario_id}")
|
|
|
|
|
|
async def archive_scenario(
|
|
|
|
|
|
scenario_id: str, request: Request,
|
|
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Мягкое удаление: история занятий остаётся целой, сценарий можно вернуть."""
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
|
|
|
|
|
if hub.has_active_scenario(scenario_id):
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="scenario_is_used_by_active_session")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
scenario = await store.archive(
|
|
|
|
|
|
db, scenario_id, owner_login=who.login,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.archive"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return {"id": scenario_id, "status": "archived", "title": scenario.title}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/{scenario_id}/restore")
|
|
|
|
|
|
async def restore_scenario(
|
|
|
|
|
|
scenario_id: str, request: Request,
|
|
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
who = require(request, Role.INSTRUCTOR)
|
2026-09-26 18:12:27 +03:00
|
|
|
|
scenario = await store.restore_archived(
|
|
|
|
|
|
db, scenario_id, owner_login=who.login,
|
|
|
|
|
|
before_commit=_audit_before_commit(
|
|
|
|
|
|
who.login, who.role.value, "scenario.restore"
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
if scenario is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if db is None:
|
|
|
|
|
|
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
2026-09-24 01:10:49 +03:00
|
|
|
|
return {"id": scenario_id, "status": "published", "title": scenario.title}
|
|
|
|
|
|
|
|
|
|
|
|
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
@router.get("/{scenario_id}")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
async def read(
|
|
|
|
|
|
scenario_id: str,
|
|
|
|
|
|
request: Request,
|
|
|
|
|
|
db: AsyncSession | None = Depends(scenario_session),
|
|
|
|
|
|
) -> dict:
|
2026-09-24 01:10:49 +03:00
|
|
|
|
# Training content is local but not public: anonymous clients must not be
|
|
|
|
|
|
# able to enumerate cards or inspect even the trainee-safe scenario body.
|
2026-09-26 18:12:27 +03:00
|
|
|
|
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
|
|
|
|
|
if scenario_id in await _hidden_scenario_ids(db, who):
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
|
|
|
|
|
if db is not None:
|
|
|
|
|
|
await store.restore_published(db)
|
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} больше не отдаёт чек-лист. Это содержимое
подсказок: отдать его целиком значит выдать в контрольном режиме то,
чего там быть не должно, в обход выдачи по одному пункту.
Тесты базы поднимают свой движок на каждый тест: глобальный кэшируется
и привязывается к первому событийному циклу.
2026-09-15 20:10:05 +03:00
|
|
|
|
scenario = store.get(scenario_id)
|
|
|
|
|
|
if scenario is None:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
2026-09-26 18:12:27 +03:00
|
|
|
|
if who.role is Role.TRAINEE:
|
|
|
|
|
|
if "self" not in scenario.modes:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="scenario_not_found")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": scenario.id,
|
|
|
|
|
|
"title": scenario.title,
|
|
|
|
|
|
"level": scenario.level.value,
|
|
|
|
|
|
"modes": scenario.modes,
|
|
|
|
|
|
}
|
|
|
|
|
|
return scenario.model_dump(mode="json")
|