Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -10,6 +10,7 @@
|
|||
`hint.shown` из живой сессии, эталонные вопросы — только в разборе.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -19,49 +20,126 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.api.auth import audit, require
|
||||
from app.domain import ekp
|
||||
from app.db.base import get_session
|
||||
from app.config import get_settings
|
||||
from app.domain.roles import Role
|
||||
from app.scenarios import store
|
||||
from app.scenarios.editor import validate
|
||||
from app.scenarios.generation import GenerationError, generate, generate_from_description
|
||||
from app.dialog.llm import LlmUnavailable
|
||||
from app.scenarios.loader import ScenarioError
|
||||
from app.session.hub import hub
|
||||
|
||||
router = APIRouter(prefix="/api/scenarios", tags=["scenarios"])
|
||||
|
||||
HIDDEN_FROM_TRAINEE = {"facts", "ground_truth", "tree", "checklist"}
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TemplateDraftIn(BaseModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _draft_out(row) -> dict:
|
||||
if row.id.startswith("ai-full-"):
|
||||
generation = "ai_full"
|
||||
elif row.id.startswith("ai-"):
|
||||
generation = "ai_variant"
|
||||
else:
|
||||
generation = "template_copy"
|
||||
return {
|
||||
"id": row.id,
|
||||
"status": row.status,
|
||||
"generation": "template_copy",
|
||||
"generation": generation,
|
||||
"body": row.body,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/drafts/from-template", status_code=201)
|
||||
async def create_template_draft(
|
||||
body: TemplateDraftIn, request: Request, db: AsyncSession = Depends(get_session)
|
||||
body: TemplateDraftIn, 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")
|
||||
row = await store.create_draft(db, source=source, title=body.title)
|
||||
row = await store.create_draft(db, source=source, title=body.title, owner_login=who.login)
|
||||
await audit(who.login, who.role.value, "scenario.draft.create", row.id, f"template:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@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)
|
||||
row = await store.create_draft(db, source=source, proposal=proposal, owner_login=who.login)
|
||||
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
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate", row.id,
|
||||
f"source:{source.id}")
|
||||
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(
|
||||
db, source=source, full_proposal=proposal, owner_login=who.login
|
||||
)
|
||||
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
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_generate_full", row.id,
|
||||
f"class_source:{source.id}")
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.get("/drafts/{scenario_id}")
|
||||
async def read_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
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")
|
||||
return _draft_out(row)
|
||||
|
|
@ -72,10 +150,10 @@ async def patch_draft(
|
|||
scenario_id: str,
|
||||
body: dict[str, Any],
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
db: AsyncSession | None = Depends(scenario_session),
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
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:
|
||||
|
|
@ -86,12 +164,36 @@ async def patch_draft(
|
|||
return _draft_out(row)
|
||||
|
||||
|
||||
@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)
|
||||
row = await store.revise_draft(db, row, proposal)
|
||||
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
|
||||
await audit(who.login, who.role.value, "scenario.draft.ai_revise", row.id,
|
||||
body.comment.strip()[:500])
|
||||
return _draft_out(row)
|
||||
|
||||
|
||||
@router.post("/drafts/{scenario_id}/validate")
|
||||
async def validate_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
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:
|
||||
|
|
@ -107,10 +209,10 @@ async def validate_draft(
|
|||
|
||||
@router.post("/drafts/{scenario_id}/approve")
|
||||
async def approve_draft(
|
||||
scenario_id: str, request: Request, db: AsyncSession = Depends(get_session)
|
||||
scenario_id: str, request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> dict:
|
||||
who = require(request, Role.INSTRUCTOR)
|
||||
row = await store.draft(db, scenario_id)
|
||||
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:
|
||||
|
|
@ -122,7 +224,15 @@ async def approve_draft(
|
|||
|
||||
|
||||
@router.get("")
|
||||
async def listing() -> list[dict]:
|
||||
async def listing(
|
||||
request: Request, db: AsyncSession | None = Depends(scenario_session)
|
||||
) -> list[dict]:
|
||||
who = require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
owned_ids = (
|
||||
await store.owned_scenario_ids(db, who.login)
|
||||
if who is not None and who.role is Role.INSTRUCTOR
|
||||
else set()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": scenario.id,
|
||||
|
|
@ -131,19 +241,57 @@ async def listing() -> list[dict]:
|
|||
"level": scenario.level.value,
|
||||
"topics": scenario.topics,
|
||||
"modes": scenario.modes,
|
||||
# Преподаватель должен видеть не только название карточки, но и
|
||||
# зафиксированный путь классификатора. ИИ меняет сюжет внутри
|
||||
# этого пути, а не незаметно подменяет код происшествия.
|
||||
"signs": scenario.signs,
|
||||
"incident_code": scenario.ground_truth.incident_code,
|
||||
"dds": scenario.ground_truth.dds.value if scenario.ground_truth.dds else None,
|
||||
"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),
|
||||
"can_manage": scenario.id in owned_ids,
|
||||
}
|
||||
for scenario in store.all_scenarios()
|
||||
]
|
||||
|
||||
|
||||
@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")
|
||||
scenario = await store.archive(db, scenario_id, owner_login=who.login)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.archive", scenario_id)
|
||||
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)
|
||||
scenario = await store.restore_archived(db, scenario_id, owner_login=who.login)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="archived_scenario_not_found")
|
||||
await audit(who.login, who.role.value, "scenario.restore", scenario_id)
|
||||
return {"id": scenario_id, "status": "published", "title": scenario.title}
|
||||
|
||||
|
||||
@router.get("/{scenario_id}")
|
||||
async def read(scenario_id: str) -> dict:
|
||||
async def read(scenario_id: str, request: Request) -> dict:
|
||||
# Training content is local but not public: anonymous clients must not be
|
||||
# able to enumerate cards or inspect even the trainee-safe scenario body.
|
||||
require(request, Role.INSTRUCTOR, Role.ADMIN, Role.TRAINEE)
|
||||
scenario = store.get(scenario_id)
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="scenario_not_found")
|
||||
|
|
|
|||
Loading…
Reference in a new issue