현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -5,20 +5,26 @@ from __future__ import annotations
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from pydantic import BaseModel, Field
from ..deps import CurrentPrincipal, Principal, Role, require_role
from ..persona_repository import (
CatalogPersona,
PersonaDraftRecord,
PersonaReviewAction,
PersonaReviewItem,
create_persona_draft,
get_persona_draft_record,
list_catalog_personas,
list_persona_review_queue,
update_persona_draft,
update_persona_review_status,
)
from ..services.persona import PersonaCard
router = APIRouter(prefix="/personas", tags=["personas"])
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
JSON_OBJECT_FIELD = {"additionalProperties": True}
class PersonaSummary(BaseModel):
@ -51,6 +57,37 @@ class PersonaReviewDecisionRequest(BaseModel):
action: PersonaReviewAction
class PersonaDraftPayload(BaseModel):
code: str = Field(min_length=1, max_length=24)
display_name: str = Field(min_length=1, max_length=80)
difficulty: Literal["easy", "moderate", "hard"]
theory_target: list[str] = Field(default_factory=list)
demographics: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
presenting: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
history: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
big5: dict[str, float] = Field(default_factory=dict)
resistance: dict[str, float] = Field(default_factory=dict)
speech_style: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
affect_baseline: dict[str, float] = Field(default_factory=dict)
ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
source_provenance: str = Field(default="", max_length=240)
is_synthetic: bool = True
submit_for_review: bool = False
class PersonaDraftDetail(PersonaReviewSummary):
demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
history: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
big5: dict[str, float]
resistance: dict[str, float]
speech_style: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
affect_baseline: dict[str, float]
ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
def _first_text_value(data: dict[str, Any]) -> str:
for value in data.values():
if isinstance(value, str) and value.strip():
@ -88,6 +125,49 @@ def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary:
)
def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
card = entry.card
return PersonaDraftDetail(
**_review_summary(entry.review).model_dump(),
demographics=card.demographics,
presenting=card.presenting,
history=card.history,
big5=card.big5,
resistance=card.resistance,
speech_style=card.speech_style,
affect_baseline=card.affect_baseline,
ccd=card.ccd,
dsm5_dimensional=card.dsm5_dimensional,
)
def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
code = request.code.strip().upper()
display_name = request.display_name.strip()
if not code:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="persona code is required")
if not display_name:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="display_name is required")
theory_target = [value.strip().lower() for value in request.theory_target if value.strip()]
return PersonaCard(
code=code,
display_name=display_name,
difficulty=request.difficulty,
theory_target=theory_target,
demographics=request.demographics,
presenting=request.presenting,
history=request.history,
big5=request.big5,
resistance=request.resistance,
speech_style=request.speech_style,
affect_baseline=request.affect_baseline,
ccd=request.ccd,
dsm5_dimensional=request.dsm5_dimensional,
source_provenance=request.source_provenance.strip(),
is_synthetic=request.is_synthetic,
)
def _ensure_teacher_or_admin(principal: Principal) -> None:
if principal.role not in {Role.TEACHER, Role.ADMIN}:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas")
@ -127,6 +207,79 @@ async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewS
return [_review_summary(entry) for entry in queue]
@router.post("/drafts", response_model=PersonaReviewSummary, status_code=status.HTTP_201_CREATED)
async def create_persona_draft_route(
request: PersonaDraftPayload,
principal: TeacherOrAdmin,
) -> PersonaReviewSummary:
"""Create a draft persona card version for faculty review."""
_ensure_teacher_or_admin(principal)
try:
created = await create_persona_draft(
card=_card_from_draft_payload(request),
author_id=principal.user_id,
role=principal.role.value,
submit_for_review=request.submit_for_review,
)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona draft database unavailable",
) from exc
return _review_summary(created)
@router.get("/drafts/{persona_id}", response_model=PersonaDraftDetail)
async def get_persona_draft_route(
persona_id: str,
principal: TeacherOrAdmin,
) -> PersonaDraftDetail:
"""Return a draft/review persona card for editing."""
_ensure_teacher_or_admin(principal)
try:
record = await get_persona_draft_record(persona_id=persona_id, role=principal.role.value)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona draft database unavailable",
) from exc
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found")
return _draft_detail(record)
@router.put("/drafts/{persona_id}", response_model=PersonaReviewSummary)
async def update_persona_draft_route(
persona_id: str,
request: PersonaDraftPayload,
principal: TeacherOrAdmin,
) -> PersonaReviewSummary:
"""Update a draft/review persona card and optionally submit it for review."""
_ensure_teacher_or_admin(principal)
try:
updated = await update_persona_draft(
persona_id=persona_id,
card=_card_from_draft_payload(request),
author_id=principal.user_id,
role=principal.role.value,
submit_for_review=request.submit_for_review,
)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona draft update database unavailable",
) from exc
if updated is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona draft not found or locked")
return _review_summary(updated)
@router.post("/review/{persona_id}", response_model=PersonaReviewSummary)
async def decide_persona_review(
persona_id: str,