음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -152,6 +152,8 @@ class AdminSupportTicketResponse(BaseModel):
|
|||
created_at: float
|
||||
updated_at: float
|
||||
resolved_at: float | None = None
|
||||
event_count: int = 0
|
||||
last_event_at: float | None = None
|
||||
|
||||
|
||||
class AdminTicketSummary(BaseModel):
|
||||
|
|
@ -545,31 +547,67 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
|
|||
async def _tickets_from_database(
|
||||
*,
|
||||
ticket_status: TicketStatus | None,
|
||||
category: TicketCategory | None = None,
|
||||
priority: TicketPriority | None = None,
|
||||
assigned_group: str | None = None,
|
||||
source_path: str | None = None,
|
||||
stale_only: bool = False,
|
||||
search: str = "",
|
||||
window_days: int,
|
||||
) -> AdminTicketsResponse:
|
||||
assigned_group_filter = (assigned_group or "").strip()
|
||||
source_path_filter = (source_path or "").strip()
|
||||
search_filter = search.strip().lower()
|
||||
async with acquire(role="admin") as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
reporter_id,
|
||||
reporter_email,
|
||||
reporter_name,
|
||||
reporter_role,
|
||||
category,
|
||||
priority,
|
||||
status,
|
||||
subject,
|
||||
body,
|
||||
source_path,
|
||||
assigned_group,
|
||||
resolution_note,
|
||||
created_at,
|
||||
updated_at,
|
||||
resolved_at
|
||||
FROM app.support_ticket
|
||||
t.id,
|
||||
t.reporter_id,
|
||||
t.reporter_email,
|
||||
t.reporter_name,
|
||||
t.reporter_role,
|
||||
t.category,
|
||||
t.priority,
|
||||
t.status,
|
||||
t.subject,
|
||||
t.body,
|
||||
t.source_path,
|
||||
t.assigned_group,
|
||||
t.resolution_note,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.resolved_at,
|
||||
COALESCE(ev.event_count, 0) AS event_count,
|
||||
ev.last_event_at
|
||||
FROM app.support_ticket AS t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
|
||||
FROM audit.audit_log
|
||||
WHERE action = 'support_ticket_update'
|
||||
AND target_kind = 'support_ticket'
|
||||
AND target_id = t.id::text
|
||||
) AS ev ON TRUE
|
||||
WHERE ($1::text IS NULL OR status = $1)
|
||||
AND created_at >= now() - ($2::int * interval '1 day')
|
||||
AND ($2::text IS NULL OR category = $2)
|
||||
AND ($3::text IS NULL OR priority = $3)
|
||||
AND ($4::text = '' OR assigned_group = $4)
|
||||
AND ($5::text = '' OR source_path = $5)
|
||||
AND (
|
||||
NOT $6::bool
|
||||
OR (
|
||||
status NOT IN ('resolved', 'closed')
|
||||
AND updated_at < now() - interval '1 day'
|
||||
)
|
||||
)
|
||||
AND (
|
||||
$7::text = ''
|
||||
OR lower(subject) LIKE '%' || $7 || '%'
|
||||
OR lower(body) LIKE '%' || $7 || '%'
|
||||
OR lower(source_path) LIKE '%' || $7 || '%'
|
||||
OR lower(reporter_email) LIKE '%' || $7 || '%'
|
||||
)
|
||||
AND created_at >= now() - ($8::int * interval '1 day')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN status = 'open' THEN 0
|
||||
|
|
@ -588,6 +626,12 @@ async def _tickets_from_database(
|
|||
LIMIT 120
|
||||
""",
|
||||
ticket_status,
|
||||
category,
|
||||
priority,
|
||||
assigned_group_filter,
|
||||
source_path_filter,
|
||||
stale_only,
|
||||
search_filter,
|
||||
window_days,
|
||||
)
|
||||
tickets = [_ticket_from_row(row) for row in rows]
|
||||
|
|
@ -758,6 +802,13 @@ def _row_ts(value: object) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
def _row_value(row, key: str, default=None):
|
||||
try:
|
||||
return row[key]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||
|
|
@ -804,6 +855,8 @@ def _ticket_from_row(row) -> AdminSupportTicketResponse:
|
|||
created_at=_row_ts(row["created_at"]) or 0.0,
|
||||
updated_at=_row_ts(row["updated_at"]) or 0.0,
|
||||
resolved_at=_row_ts(row["resolved_at"]),
|
||||
event_count=int(_row_value(row, "event_count", 0) or 0),
|
||||
last_event_at=_row_ts(_row_value(row, "last_event_at")),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -872,6 +925,50 @@ def _unavailable_tickets() -> AdminTicketsResponse:
|
|||
)
|
||||
|
||||
|
||||
def _ticket_change_detail(old_row, new_row) -> dict[str, object]:
|
||||
changed_fields: list[str] = []
|
||||
detail: dict[str, object] = {"changed_fields": changed_fields}
|
||||
for field in ("status", "priority", "assigned_group"):
|
||||
before = _row_value(old_row, field, "")
|
||||
after = _row_value(new_row, field, "")
|
||||
if before != after:
|
||||
changed_fields.append(field)
|
||||
detail[field] = {"from": before, "to": after}
|
||||
old_note = (_row_value(old_row, "resolution_note", "") or "").strip()
|
||||
new_note = (_row_value(new_row, "resolution_note", "") or "").strip()
|
||||
if old_note != new_note:
|
||||
changed_fields.append("resolution_note")
|
||||
detail["resolution_note"] = {
|
||||
"from_present": bool(old_note),
|
||||
"to_present": bool(new_note),
|
||||
}
|
||||
detail["category"] = _row_value(new_row, "category", "")
|
||||
detail["source_path"] = _row_value(new_row, "source_path", "")
|
||||
return detail
|
||||
|
||||
|
||||
async def _record_ticket_update_audit(
|
||||
conn,
|
||||
*,
|
||||
principal: Principal,
|
||||
ticket_id: str,
|
||||
detail: dict[str, object],
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO audit.audit_log (
|
||||
actor_uid, action, target_kind, target_id, detail
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
principal.user_id,
|
||||
"support_ticket_update",
|
||||
"support_ticket",
|
||||
ticket_id,
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def _current_engine_config() -> AdminEngineConfigResponse:
|
||||
if _ENGINE_CONFIG is not None:
|
||||
return _ENGINE_CONFIG
|
||||
|
|
@ -1090,11 +1187,26 @@ async def admin_uptime(
|
|||
async def list_tickets(
|
||||
principal: AdminPrincipal,
|
||||
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
|
||||
category: TicketCategory | None = None,
|
||||
priority: TicketPriority | None = None,
|
||||
assigned_group: Annotated[str | None, Query(max_length=120)] = None,
|
||||
source_path: Annotated[str | None, Query(max_length=300)] = None,
|
||||
stale_only: bool = False,
|
||||
search: Annotated[str, Query(max_length=120)] = "",
|
||||
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
) -> AdminTicketsResponse:
|
||||
"""Return user-submitted operational tickets without synthetic fallback rows."""
|
||||
try:
|
||||
return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days)
|
||||
return await _tickets_from_database(
|
||||
ticket_status=ticket_status,
|
||||
category=category,
|
||||
priority=priority,
|
||||
assigned_group=assigned_group,
|
||||
source_path=source_path,
|
||||
stale_only=stale_only,
|
||||
search=search,
|
||||
window_days=window_days,
|
||||
)
|
||||
except Exception:
|
||||
return _unavailable_tickets()
|
||||
|
||||
|
|
@ -1108,6 +1220,23 @@ async def patch_ticket(
|
|||
"""Update ticket triage state for administrators."""
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
old_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
category,
|
||||
priority,
|
||||
status,
|
||||
source_path,
|
||||
assigned_group,
|
||||
resolution_note
|
||||
FROM app.support_ticket
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
ticket_id,
|
||||
)
|
||||
if old_row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.support_ticket SET
|
||||
|
|
@ -1149,13 +1278,56 @@ async def patch_ticket(
|
|||
body.assigned_group.strip() if body.assigned_group is not None else None,
|
||||
body.resolution_note.strip() if body.resolution_note is not None else None,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
|
||||
detail = _ticket_change_detail(old_row, row)
|
||||
if detail["changed_fields"]:
|
||||
await _record_ticket_update_audit(
|
||||
conn,
|
||||
principal=principal,
|
||||
ticket_id=ticket_id,
|
||||
detail=detail,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.reporter_id,
|
||||
t.reporter_email,
|
||||
t.reporter_name,
|
||||
t.reporter_role,
|
||||
t.category,
|
||||
t.priority,
|
||||
t.status,
|
||||
t.subject,
|
||||
t.body,
|
||||
t.source_path,
|
||||
t.assigned_group,
|
||||
t.resolution_note,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
t.resolved_at,
|
||||
COALESCE(ev.event_count, 0) AS event_count,
|
||||
ev.last_event_at
|
||||
FROM app.support_ticket AS t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
|
||||
FROM audit.audit_log
|
||||
WHERE action = 'support_ticket_update'
|
||||
AND target_kind = 'support_ticket'
|
||||
AND target_id = t.id::text
|
||||
) AS ev ON TRUE
|
||||
WHERE t.id = $1::uuid
|
||||
""",
|
||||
ticket_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if isinstance(exc, HTTPException):
|
||||
raise
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="ticket persistence unavailable",
|
||||
) from exc
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
|
||||
return _ticket_from_row(row)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
|
|||
|
||||
from ..db import acquire, get_pool
|
||||
from ..deps import AIView, Principal, Role, require_role
|
||||
from ..services import rag
|
||||
from ..services import live_coach, rag
|
||||
|
||||
router = APIRouter(prefix="/kb", tags=["kb"])
|
||||
|
||||
|
|
@ -104,6 +104,24 @@ class IndexResponse(BaseModel):
|
|||
degraded: bool = False
|
||||
|
||||
|
||||
class LiveCoachSourcePackSyncItem(BaseModel):
|
||||
source_id: str
|
||||
doc_id: Optional[int]
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: bool
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
class LiveCoachSourcePackSyncResponse(BaseModel):
|
||||
sources_upserted: int
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: int
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
items: list[LiveCoachSourcePackSyncItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── 헬퍼: rag.NotConfigured → 503 ───────────────────────
|
||||
def _to_chunk_out(c: rag.RetrievedChunk) -> ChunkOut:
|
||||
return ChunkOut(
|
||||
|
|
@ -222,6 +240,7 @@ async def eval_grounding(body: KBSearchRequest) -> KBSearchResponse:
|
|||
query=body.query,
|
||||
k=body.k,
|
||||
kinds=body.kb_kind,
|
||||
source_ids=body.source_id,
|
||||
rerank=body.rerank,
|
||||
)
|
||||
try:
|
||||
|
|
@ -319,3 +338,80 @@ async def index_document(
|
|||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/live-coach/source-packs/sync",
|
||||
response_model=LiveCoachSourcePackSyncResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def sync_live_coach_source_packs(
|
||||
principal: Annotated[Principal, Depends(require_role(Role.ADMIN))],
|
||||
) -> LiveCoachSourcePackSyncResponse:
|
||||
"""허가된 라이브 코칭 source pack을 kb.source/kb.chunk RAG 색인에 적재한다.
|
||||
|
||||
로컬 `data/kb/live_coaching_*.json`은 UI 즉시 코칭의 기본 근거이고, 이 경로는 같은 자료를
|
||||
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 뒤 content_hash 기반 증분 색인을
|
||||
수행한다. 임베딩 모델 미가용 시 BM25-only degraded 색인으로 이어진다.
|
||||
"""
|
||||
source_rows = live_coach.build_rag_source_rows()
|
||||
index_payloads = live_coach.build_rag_index_payloads()
|
||||
if not source_rows or not index_payloads:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="live coach source packs are empty",
|
||||
)
|
||||
|
||||
items: list[LiveCoachSourcePackSyncItem] = []
|
||||
source_row_by_id = {row["source_id"]: row for row in source_rows}
|
||||
try:
|
||||
async with acquire() as conn:
|
||||
for row in source_rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.source
|
||||
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
kb_kind = EXCLUDED.kb_kind,
|
||||
license_class = EXCLUDED.license_class,
|
||||
origin_path = EXCLUDED.origin_path,
|
||||
citation = EXCLUDED.citation,
|
||||
external_llm_ok = EXCLUDED.external_llm_ok
|
||||
""",
|
||||
row["source_id"],
|
||||
row["title"],
|
||||
row["kb_kind"],
|
||||
row["license_class"],
|
||||
row["origin_path"],
|
||||
row["citation"],
|
||||
row["external_llm_ok"],
|
||||
)
|
||||
for payload in index_payloads:
|
||||
if payload["source_id"] not in source_row_by_id:
|
||||
continue
|
||||
result = await rag.index_document(conn, rag.IndexRequest(**payload))
|
||||
items.append(
|
||||
LiveCoachSourcePackSyncItem(
|
||||
source_id=payload["source_id"],
|
||||
doc_id=result.doc_id,
|
||||
chunks_indexed=result.chunks_indexed,
|
||||
skipped_unchanged=result.skipped_unchanged,
|
||||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
)
|
||||
)
|
||||
except rag.NotConfigured as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
|
||||
|
||||
return LiveCoachSourcePackSyncResponse(
|
||||
sources_upserted=len(source_rows),
|
||||
chunks_indexed=sum(item.chunks_indexed for item in items),
|
||||
skipped_unchanged=sum(1 for item in items if item.skipped_unchanged),
|
||||
embedded=all(item.embedded for item in items) if items else False,
|
||||
degraded=any(item.degraded for item in items),
|
||||
items=items,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,33 +2,58 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..db import acquire
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..deps import AIView
|
||||
from ..persona_repository import (
|
||||
CatalogPersona,
|
||||
PersonaDraftRecord,
|
||||
PersonaReviewAction,
|
||||
PersonaReviewItem,
|
||||
archive_persona_family,
|
||||
create_persona_draft,
|
||||
create_persona_revision_from_existing,
|
||||
get_persona_draft_record,
|
||||
list_catalog_personas,
|
||||
list_persona_review_queue,
|
||||
update_persona_draft,
|
||||
update_persona_review_status,
|
||||
)
|
||||
from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
|
||||
from ..services import rag
|
||||
from ..services.guardrail import mask_pii
|
||||
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}
|
||||
PersonaSourceKind = Literal["client_record", "textbook_guide", "mixed_notes"]
|
||||
PERSONA_SOURCE_KB_KIND: dict[str, str] = {
|
||||
"client_record": "diagnostic",
|
||||
"textbook_guide": "theory",
|
||||
"mixed_notes": "ko_context",
|
||||
}
|
||||
PERSONA_SOURCE_CITATION: dict[str, str] = {
|
||||
"client_record": "교수자 첨부 PII 마스킹 파생본 — 실사례 원문은 저장하지 않음",
|
||||
"textbook_guide": "교수자 첨부 교재/가이드 환언·발췌 근거 — 저작권 검수 필요",
|
||||
"mixed_notes": "교수자 첨부 혼합 메모 PII 마스킹 파생본",
|
||||
}
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
persona_id: str | None = None
|
||||
code: str
|
||||
version: int | None = None
|
||||
status: Literal["approved"] = "approved"
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
|
|
@ -57,6 +82,10 @@ class PersonaReviewDecisionRequest(BaseModel):
|
|||
action: PersonaReviewAction
|
||||
|
||||
|
||||
class PersonaRevisionRequest(BaseModel):
|
||||
submit_for_review: bool = False
|
||||
|
||||
|
||||
class PersonaDraftPayload(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=24)
|
||||
display_name: str = Field(min_length=1, max_length=80)
|
||||
|
|
@ -71,11 +100,66 @@ class PersonaDraftPayload(BaseModel):
|
|||
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)
|
||||
triggers: 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 PersonaSourceDocumentRequest(BaseModel):
|
||||
filename: str = Field(min_length=1, max_length=240)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
text: str = Field(min_length=20, max_length=120000)
|
||||
title: str | None = Field(default=None, max_length=160)
|
||||
source_note: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaSourceDocumentResponse(BaseModel):
|
||||
source_id: str
|
||||
doc_id: int | None
|
||||
doc_uri: str
|
||||
title: str
|
||||
source_kind: PersonaSourceKind
|
||||
kb_kind: str
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok: bool = True
|
||||
content_hash: str
|
||||
chunk_count: int
|
||||
chunks_indexed: int
|
||||
embedded: bool
|
||||
degraded: bool = False
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PersonaGenerationEvidence(BaseModel):
|
||||
chunk_id: int
|
||||
source_id: str
|
||||
score: float
|
||||
kb_kind: str
|
||||
heading_path: str | None = None
|
||||
excerpt: str
|
||||
|
||||
|
||||
class PersonaDraftGenerateRequest(BaseModel):
|
||||
source_text: str | None = Field(default=None, min_length=20, max_length=30000)
|
||||
source_ids: list[str] = Field(default_factory=list, max_length=12)
|
||||
source_kind: PersonaSourceKind = "mixed_notes"
|
||||
code_hint: str | None = Field(default=None, max_length=24)
|
||||
display_name_hint: str | None = Field(default=None, max_length=80)
|
||||
difficulty: Literal["easy", "moderate", "hard"] = "moderate"
|
||||
theory_target: list[str] = Field(default_factory=lambda: ["humanistic"])
|
||||
generation_goal: str = Field(default="", max_length=800)
|
||||
|
||||
|
||||
class PersonaDraftGenerateResponse(BaseModel):
|
||||
draft: PersonaDraftPayload
|
||||
source_summary: str = ""
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
pii_entities_masked: list[str] = Field(default_factory=list)
|
||||
source_references: list[PersonaSourceDocumentResponse] = Field(default_factory=list)
|
||||
evidence_chunks: list[PersonaGenerationEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -86,6 +170,7 @@ class PersonaDraftDetail(PersonaReviewSummary):
|
|||
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)
|
||||
triggers: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
|
|
@ -98,7 +183,10 @@ def _first_text_value(data: dict[str, Any]) -> str:
|
|||
def _summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
persona_id=entry.persona_id,
|
||||
code=card.code,
|
||||
version=entry.version,
|
||||
status="approved",
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
|
|
@ -138,6 +226,7 @@ def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
|
|||
affect_baseline=card.affect_baseline,
|
||||
ccd=card.ccd,
|
||||
dsm5_dimensional=card.dsm5_dimensional,
|
||||
triggers=card.triggers,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -163,11 +252,430 @@ def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
|
|||
affect_baseline=request.affect_baseline,
|
||||
ccd=request.ccd,
|
||||
dsm5_dimensional=request.dsm5_dimensional,
|
||||
triggers=request.triggers,
|
||||
source_provenance=request.source_provenance.strip(),
|
||||
is_synthetic=request.is_synthetic,
|
||||
)
|
||||
|
||||
|
||||
def _safe_doc_segment(value: str) -> str:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
|
||||
return safe.strip("-")[:120] or "source"
|
||||
|
||||
|
||||
def _chunk_source_text(text: str, *, max_chars: int = 1800) -> list[str]:
|
||||
paragraphs = [item.strip() for item in re.split(r"\n\s*\n", text) if item.strip()]
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for paragraph in paragraphs or [text.strip()]:
|
||||
pending = paragraph
|
||||
while len(pending) > max_chars:
|
||||
chunks.append(pending[:max_chars].strip())
|
||||
pending = pending[max_chars:].strip()
|
||||
if not pending:
|
||||
continue
|
||||
if current and len(current) + len(pending) + 2 > max_chars:
|
||||
chunks.append(current.strip())
|
||||
current = pending
|
||||
else:
|
||||
current = f"{current}\n\n{pending}".strip() if current else pending
|
||||
if current:
|
||||
chunks.append(current.strip())
|
||||
return chunks
|
||||
|
||||
|
||||
def _source_content_hash(chunks: list[dict[str, Any]]) -> str:
|
||||
h = hashlib.sha256()
|
||||
for chunk in chunks:
|
||||
stable = {
|
||||
"chunk_text": chunk.get("chunk_text") or "",
|
||||
"context_prefix": chunk.get("context_prefix") or "",
|
||||
"kb_kind": chunk.get("kb_kind") or "",
|
||||
"visible_to": chunk.get("visible_to") or [],
|
||||
"sensitivity": chunk.get("sensitivity"),
|
||||
"meta": chunk.get("meta") or {},
|
||||
}
|
||||
h.update(json.dumps(stable, ensure_ascii=False, sort_keys=True).encode("utf-8"))
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _source_title(request: PersonaSourceDocumentRequest) -> str:
|
||||
return (request.title or request.filename).strip()
|
||||
|
||||
|
||||
async def _register_persona_source_document(
|
||||
request: PersonaSourceDocumentRequest,
|
||||
principal: Principal,
|
||||
) -> PersonaSourceDocumentResponse:
|
||||
"""Register a persona-authoring attachment as masked evaluator-only KB chunks."""
|
||||
masked = mask_pii(request.text)
|
||||
title = _source_title(request)
|
||||
source_id = f"persona_authoring_{uuid.uuid4().hex[:16]}"
|
||||
doc_uri = (
|
||||
f"persona-authoring/{principal.user_id}/"
|
||||
f"{source_id}/{_safe_doc_segment(request.filename)}"
|
||||
)
|
||||
kb_kind = PERSONA_SOURCE_KB_KIND.get(request.source_kind, "ko_context")
|
||||
license_class: Literal["A", "B", "C", "D"] = "B"
|
||||
external_llm_ok = True
|
||||
chunks = [
|
||||
{
|
||||
"seq": index,
|
||||
"chunk_text": chunk,
|
||||
"heading_path": title,
|
||||
"context_prefix": (
|
||||
"페르소나 저작 첨부 자료. "
|
||||
f"자료종류={request.source_kind}; 파일={request.filename}; "
|
||||
"PII 마스킹본이며 evaluator 전용 근거로만 사용한다."
|
||||
),
|
||||
"kb_kind": kb_kind,
|
||||
"visible_to": ["evaluator"],
|
||||
"sensitivity": 2,
|
||||
"meta": {
|
||||
"persona_authoring": True,
|
||||
"source_kind": request.source_kind,
|
||||
"filename": request.filename,
|
||||
"title": title,
|
||||
"source_note": request.source_note,
|
||||
"pii_entities_masked": masked.entities,
|
||||
"license_class": license_class,
|
||||
"external_llm_ok": external_llm_ok,
|
||||
"raw_source_not_stored": True,
|
||||
},
|
||||
"token_count": max(1, len(chunk) // 4),
|
||||
}
|
||||
for index, chunk in enumerate(_chunk_source_text(masked.text_masked))
|
||||
]
|
||||
if not chunks:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="source document is empty")
|
||||
content_hash = _source_content_hash(chunks)
|
||||
index_req = rag.IndexRequest(
|
||||
source_id=source_id,
|
||||
doc_uri=doc_uri,
|
||||
version=1,
|
||||
content_hash=content_hash,
|
||||
chunks=chunks,
|
||||
)
|
||||
try:
|
||||
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.source
|
||||
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
|
||||
VALUES ($1, $2, $3, 'B', $4, $5, TRUE)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
origin_path = EXCLUDED.origin_path,
|
||||
citation = EXCLUDED.citation
|
||||
""",
|
||||
source_id,
|
||||
title,
|
||||
kb_kind,
|
||||
request.filename,
|
||||
PERSONA_SOURCE_CITATION.get(request.source_kind, "교수자 첨부 PII 마스킹 파생본"),
|
||||
)
|
||||
result = await rag.index_document(conn, index_req)
|
||||
except rag.NotConfigured as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"persona source RAG index unavailable: {exc}",
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
|
||||
return PersonaSourceDocumentResponse(
|
||||
source_id=source_id,
|
||||
doc_id=result.doc_id,
|
||||
doc_uri=doc_uri,
|
||||
title=title,
|
||||
source_kind=request.source_kind,
|
||||
kb_kind=kb_kind,
|
||||
license_class=license_class,
|
||||
external_llm_ok=external_llm_ok,
|
||||
content_hash=content_hash,
|
||||
chunk_count=len(chunks),
|
||||
chunks_indexed=result.chunks_indexed,
|
||||
embedded=result.embedded,
|
||||
degraded=result.degraded,
|
||||
pii_entities_masked=masked.entities,
|
||||
)
|
||||
|
||||
|
||||
async def _retrieve_persona_generation_evidence(
|
||||
*,
|
||||
source_ids: list[str],
|
||||
query: str,
|
||||
) -> list[PersonaGenerationEvidence]:
|
||||
if not source_ids:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="persona generation requires at least one RAG source",
|
||||
)
|
||||
try:
|
||||
async with acquire(ai_view=AIView.EVALUATOR.value) as conn:
|
||||
result = await rag.search_kb(
|
||||
conn,
|
||||
query=query,
|
||||
role=rag.AIRole.EVALUATOR,
|
||||
k=8,
|
||||
filters={"source_id": source_ids, "sensitivity_max": 2},
|
||||
rerank=True,
|
||||
)
|
||||
try:
|
||||
await rag.log_retrieval(conn, result=result, ai_role="evaluator")
|
||||
except Exception:
|
||||
pass
|
||||
except rag.NotConfigured as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"persona source RAG search unavailable: {exc}",
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
|
||||
evidence = [
|
||||
PersonaGenerationEvidence(
|
||||
chunk_id=chunk.chunk_id,
|
||||
source_id=chunk.source_id or "",
|
||||
score=round(chunk.score, 6),
|
||||
kb_kind=chunk.kb_kind,
|
||||
heading_path=chunk.heading_path,
|
||||
excerpt=(chunk.body or chunk.behavior_cue or "")[:1200],
|
||||
)
|
||||
for chunk in result.chunks
|
||||
if (chunk.body or chunk.behavior_cue)
|
||||
]
|
||||
if not evidence:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="RAG evidence was not found for the selected persona sources",
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
async def _load_persona_source_references(
|
||||
source_ids: list[str],
|
||||
) -> list[PersonaSourceDocumentResponse]:
|
||||
if not source_ids:
|
||||
return []
|
||||
try:
|
||||
async with acquire(role="admin") as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
WITH latest_doc AS (
|
||||
SELECT DISTINCT ON (source_id)
|
||||
source_id, doc_id, doc_uri, content_hash
|
||||
FROM kb.document
|
||||
WHERE is_active AND source_id = ANY($1::text[])
|
||||
ORDER BY source_id, version DESC
|
||||
),
|
||||
first_chunk AS (
|
||||
SELECT DISTINCT ON (source_id)
|
||||
source_id,
|
||||
COALESCE(meta->>'source_kind', 'mixed_notes') AS source_kind
|
||||
FROM kb.chunk
|
||||
WHERE source_id = ANY($1::text[])
|
||||
ORDER BY source_id, seq
|
||||
),
|
||||
chunk_counts AS (
|
||||
SELECT source_id, COUNT(*)::int AS chunk_count
|
||||
FROM kb.chunk
|
||||
WHERE source_id = ANY($1::text[])
|
||||
GROUP BY source_id
|
||||
)
|
||||
SELECT
|
||||
s.source_id, s.title, s.kb_kind, s.license_class, s.external_llm_ok,
|
||||
d.doc_id, d.doc_uri, d.content_hash,
|
||||
COALESCE(fc.source_kind, 'mixed_notes') AS source_kind,
|
||||
COALESCE(cc.chunk_count, 0) AS chunk_count
|
||||
FROM kb.source s
|
||||
LEFT JOIN latest_doc d ON d.source_id = s.source_id
|
||||
LEFT JOIN first_chunk fc ON fc.source_id = s.source_id
|
||||
LEFT JOIN chunk_counts cc ON cc.source_id = s.source_id
|
||||
WHERE s.source_id = ANY($1::text[])
|
||||
""",
|
||||
source_ids,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
|
||||
found = {str(row["source_id"]) for row in rows}
|
||||
missing = [source_id for source_id in source_ids if source_id not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=f"persona source not found: {', '.join(missing)}",
|
||||
)
|
||||
references: list[PersonaSourceDocumentResponse] = []
|
||||
for row in rows:
|
||||
source_kind = str(row["source_kind"] or "mixed_notes")
|
||||
if source_kind not in {"client_record", "textbook_guide", "mixed_notes"}:
|
||||
source_kind = "mixed_notes"
|
||||
references.append(
|
||||
PersonaSourceDocumentResponse(
|
||||
source_id=str(row["source_id"]),
|
||||
doc_id=int(row["doc_id"]) if row["doc_id"] is not None else None,
|
||||
doc_uri=str(row["doc_uri"] or ""),
|
||||
title=str(row["title"] or row["source_id"]),
|
||||
source_kind=source_kind, # type: ignore[arg-type]
|
||||
kb_kind=str(row["kb_kind"] or "ko_context"),
|
||||
license_class=str(row["license_class"] or "B"), # type: ignore[arg-type]
|
||||
external_llm_ok=bool(row["external_llm_ok"]),
|
||||
content_hash=str(row["content_hash"] or ""),
|
||||
chunk_count=int(row["chunk_count"] or 0),
|
||||
chunks_indexed=0,
|
||||
embedded=True,
|
||||
degraded=False,
|
||||
pii_entities_masked=[],
|
||||
)
|
||||
)
|
||||
return references
|
||||
|
||||
|
||||
def _format_generation_evidence(evidence: list[PersonaGenerationEvidence]) -> str:
|
||||
lines: list[str] = []
|
||||
for index, item in enumerate(evidence, start=1):
|
||||
heading = item.heading_path or item.source_id
|
||||
lines.append(
|
||||
f"[근거 {index}] source_id={item.source_id}; chunk_id={item.chunk_id}; "
|
||||
f"score={item.score}; heading={heading}\n{item.excerpt}"
|
||||
)
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _persona_generation_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"draft": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"code": {"type": "string"},
|
||||
"display_name": {"type": "string"},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "moderate", "hard"]},
|
||||
"theory_target": {"type": "array", "items": {"type": "string"}},
|
||||
"demographics": {"type": "object"},
|
||||
"presenting": {"type": "object"},
|
||||
"history": {"type": "object"},
|
||||
"big5": {"type": "object"},
|
||||
"resistance": {"type": "object"},
|
||||
"speech_style": {"type": "object"},
|
||||
"affect_baseline": {"type": "object"},
|
||||
"ccd": {"type": "object"},
|
||||
"dsm5_dimensional": {"type": "object"},
|
||||
"triggers": {"type": "object"},
|
||||
"source_provenance": {"type": "string"},
|
||||
"is_synthetic": {"type": "boolean"},
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"display_name",
|
||||
"difficulty",
|
||||
"theory_target",
|
||||
"demographics",
|
||||
"presenting",
|
||||
"history",
|
||||
"big5",
|
||||
"resistance",
|
||||
"speech_style",
|
||||
"affect_baseline",
|
||||
"ccd",
|
||||
"dsm5_dimensional",
|
||||
"triggers",
|
||||
"source_provenance",
|
||||
"is_synthetic",
|
||||
],
|
||||
},
|
||||
"source_summary": {"type": "string"},
|
||||
"warnings": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["draft", "source_summary", "warnings"],
|
||||
}
|
||||
|
||||
|
||||
def _json_payload_from_generation(text: str) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
parsed = json.loads(text[start : end + 1])
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _float_dict(value: Any) -> dict[str, float]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
result: dict[str, float] = {}
|
||||
for key, item in value.items():
|
||||
if isinstance(item, (int, float)):
|
||||
result[str(key)] = float(item)
|
||||
return result
|
||||
|
||||
|
||||
def _coerce_generated_draft(
|
||||
payload: dict[str, Any],
|
||||
request: PersonaDraftGenerateRequest,
|
||||
) -> PersonaDraftPayload:
|
||||
raw = payload.get("draft") if isinstance(payload.get("draft"), dict) else payload
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
theory_target = raw.get("theory_target")
|
||||
theory_values = (
|
||||
[str(item).strip().lower() for item in theory_target if str(item).strip()]
|
||||
if isinstance(theory_target, list)
|
||||
else [value.strip().lower() for value in request.theory_target if value.strip()]
|
||||
)
|
||||
code = str(raw.get("code") or request.code_hint or "").strip().upper()
|
||||
display_name = str(raw.get("display_name") or request.display_name_hint or "자료 기반 새 페르소나").strip()
|
||||
difficulty = str(raw.get("difficulty") or request.difficulty)
|
||||
if difficulty not in {"easy", "moderate", "hard"}:
|
||||
difficulty = request.difficulty
|
||||
return PersonaDraftPayload(
|
||||
code=code or "P",
|
||||
display_name=display_name,
|
||||
difficulty=difficulty, # type: ignore[arg-type]
|
||||
theory_target=theory_values or ["humanistic"],
|
||||
demographics=_json_object(raw.get("demographics")),
|
||||
presenting=_json_object(raw.get("presenting")),
|
||||
history=_json_object(raw.get("history")),
|
||||
big5=_float_dict(raw.get("big5")) or {"O": 0.5, "C": 0.5, "E": 0.5, "A": 0.5, "N": 0.5},
|
||||
resistance=_float_dict(raw.get("resistance"))
|
||||
or {
|
||||
"base_resistance": 0.5,
|
||||
"unlock_rate": 0.1,
|
||||
"decay_floor": 0.05,
|
||||
"silence_prob": 0.15,
|
||||
"deflection_prob": 0.25,
|
||||
},
|
||||
speech_style=_json_object(raw.get("speech_style")),
|
||||
affect_baseline=_float_dict(raw.get("affect_baseline"))
|
||||
or {
|
||||
"negative_affect": 0.45,
|
||||
"hopelessness": 0.2,
|
||||
"anhedonia": 0.2,
|
||||
"sleep": 0.2,
|
||||
"anxiety": 0.35,
|
||||
"suicide_ideation_stage": 1,
|
||||
},
|
||||
ccd=_json_object(raw.get("ccd")),
|
||||
dsm5_dimensional=_json_object(raw.get("dsm5_dimensional")),
|
||||
triggers=_json_object(raw.get("triggers")),
|
||||
source_provenance=str(raw.get("source_provenance") or f"masked {request.source_kind}"),
|
||||
is_synthetic=bool(raw.get("is_synthetic", True)),
|
||||
submit_for_review=False,
|
||||
)
|
||||
|
||||
|
||||
def _json_object(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
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")
|
||||
|
|
@ -231,6 +739,196 @@ async def create_persona_draft_route(
|
|||
return _review_summary(created)
|
||||
|
||||
|
||||
@router.post("/{persona_id}/revisions", response_model=PersonaDraftDetail, status_code=status.HTTP_201_CREATED)
|
||||
async def create_persona_revision_route(
|
||||
persona_id: str,
|
||||
request: PersonaRevisionRequest,
|
||||
principal: TeacherOrAdmin,
|
||||
) -> PersonaDraftDetail:
|
||||
"""Clone an approved/system persona into an editable draft version."""
|
||||
_ensure_teacher_or_admin(principal)
|
||||
try:
|
||||
record = await create_persona_revision_from_existing(
|
||||
persona_id=persona_id,
|
||||
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 revision database unavailable",
|
||||
) from exc
|
||||
if record is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="approved persona not found")
|
||||
return _draft_detail(record)
|
||||
|
||||
|
||||
@router.delete("/{persona_id}", response_model=PersonaReviewSummary)
|
||||
async def archive_persona_route(
|
||||
persona_id: str,
|
||||
principal: TeacherOrAdmin,
|
||||
) -> PersonaReviewSummary:
|
||||
"""Archive a persona code family instead of hard-deleting historical cards."""
|
||||
_ensure_teacher_or_admin(principal)
|
||||
try:
|
||||
archived = await archive_persona_family(
|
||||
persona_id=persona_id,
|
||||
archiver_id=principal.user_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 archive database unavailable",
|
||||
) from exc
|
||||
if archived is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona not found")
|
||||
return _review_summary(archived)
|
||||
|
||||
|
||||
@router.post("/sources", response_model=PersonaSourceDocumentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_persona_source_route(
|
||||
request: PersonaSourceDocumentRequest,
|
||||
principal: TeacherOrAdmin,
|
||||
) -> PersonaSourceDocumentResponse:
|
||||
"""Attach a source document to the persona-authoring KB before draft generation."""
|
||||
_ensure_teacher_or_admin(principal)
|
||||
return await _register_persona_source_document(request, principal)
|
||||
|
||||
|
||||
@router.post("/drafts/generate", response_model=PersonaDraftGenerateResponse)
|
||||
async def generate_persona_draft_route(
|
||||
request: PersonaDraftGenerateRequest,
|
||||
principal: TeacherOrAdmin,
|
||||
) -> PersonaDraftGenerateResponse:
|
||||
"""Generate an editable persona draft from evaluator-only RAG evidence."""
|
||||
_ensure_teacher_or_admin(principal)
|
||||
source_references: list[PersonaSourceDocumentResponse] = []
|
||||
source_ids = [item.strip() for item in request.source_ids if item.strip()]
|
||||
pii_entities: list[str] = []
|
||||
if request.source_text:
|
||||
inline_source = await _register_persona_source_document(
|
||||
PersonaSourceDocumentRequest(
|
||||
filename="inline-persona-source.txt",
|
||||
source_kind=request.source_kind,
|
||||
text=request.source_text,
|
||||
title="붙여넣은 페르소나 저작 자료",
|
||||
source_note=request.generation_goal,
|
||||
),
|
||||
principal,
|
||||
)
|
||||
source_references.append(inline_source)
|
||||
source_ids.append(inline_source.source_id)
|
||||
pii_entities.extend(inline_source.pii_entities_masked)
|
||||
if not source_ids:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="source_ids or source_text is required for RAG-based persona generation",
|
||||
)
|
||||
known_refs = {item.source_id: item for item in source_references}
|
||||
unknown_source_ids = [source_id for source_id in source_ids if source_id not in known_refs]
|
||||
if unknown_source_ids:
|
||||
for item in await _load_persona_source_references(unknown_source_ids):
|
||||
known_refs[item.source_id] = item
|
||||
source_references = [known_refs[source_id] for source_id in source_ids if source_id in known_refs]
|
||||
blocked_sources = [item.source_id for item in source_references if not item.external_llm_ok]
|
||||
if blocked_sources:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"selected persona sources are not allowed for external LLM generation: "
|
||||
+ ", ".join(blocked_sources)
|
||||
),
|
||||
)
|
||||
code_hint = (request.code_hint or "").strip().upper()
|
||||
display_hint = (request.display_name_hint or "").strip()
|
||||
evidence_query = "\n".join(
|
||||
[
|
||||
request.generation_goal or "교육용 가상내담자 페르소나 초안 생성",
|
||||
request.source_kind,
|
||||
request.difficulty,
|
||||
" ".join(request.theory_target),
|
||||
display_hint,
|
||||
]
|
||||
).strip()
|
||||
evidence = await _retrieve_persona_generation_evidence(
|
||||
source_ids=source_ids,
|
||||
query=evidence_query or "페르소나 저작 근거",
|
||||
)
|
||||
evidence_text = _format_generation_evidence(evidence)
|
||||
prompt = (
|
||||
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
|
||||
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
|
||||
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
|
||||
"원문 표현을 복사하지 말고 "
|
||||
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
|
||||
"내담자 발화에 직접 노출되지 않는 형태로 작성한다.\n\n"
|
||||
f"자료 종류: {request.source_kind}\n"
|
||||
f"RAG source_ids: {source_ids}\n"
|
||||
f"코드 힌트: {code_hint or '미정'}\n"
|
||||
f"표시명 힌트: {display_hint or '미정'}\n"
|
||||
f"난이도: {request.difficulty}\n"
|
||||
f"대상 이론: {request.theory_target}\n"
|
||||
f"저작 목표: {request.generation_goal or '첫 편집 가능한 초안 생성'}\n\n"
|
||||
"[RAG 근거 청크]\n"
|
||||
f"{evidence_text}"
|
||||
)
|
||||
req = GenerateRequest(
|
||||
ai_role="evaluator",
|
||||
messages=[
|
||||
EngineMessage(
|
||||
role="system",
|
||||
content=(
|
||||
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
|
||||
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
|
||||
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
|
||||
"임상 필드 본문에 그대로 노출하지 않는다."
|
||||
),
|
||||
),
|
||||
EngineMessage(role="user", content=prompt),
|
||||
],
|
||||
max_tokens=2200,
|
||||
temperature=0.2,
|
||||
structured_schema=_persona_generation_schema(),
|
||||
metadata={
|
||||
"feature": "persona_draft_generation",
|
||||
"source_kind": request.source_kind,
|
||||
"source_ids": source_ids,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = await engine_client.generate(req)
|
||||
except EngineError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"persona draft generator unavailable: {exc}",
|
||||
) from exc
|
||||
payload = response.structured or _json_payload_from_generation(response.text)
|
||||
draft = _coerce_generated_draft(payload, request)
|
||||
provenance = (
|
||||
f"RAG sources={','.join(source_ids)}; "
|
||||
f"chunks={','.join(str(item.chunk_id) for item in evidence)}"
|
||||
)
|
||||
if draft.source_provenance and draft.source_provenance not in provenance:
|
||||
provenance = f"{provenance}; {draft.source_provenance}"
|
||||
draft.source_provenance = provenance[:240]
|
||||
summary = str(payload.get("source_summary") or "")
|
||||
warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else []
|
||||
return PersonaDraftGenerateResponse(
|
||||
draft=draft,
|
||||
source_summary=summary,
|
||||
warnings=[str(item) for item in warnings],
|
||||
pii_entities_masked=pii_entities,
|
||||
source_references=source_references,
|
||||
evidence_chunks=evidence,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drafts/{persona_id}", response_model=PersonaDraftDetail)
|
||||
async def get_persona_draft_route(
|
||||
persona_id: str,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
309
apps/api/app/routes/share.py
Normal file
309
apps/api/app/routes/share.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Public share/unfurl routes.
|
||||
|
||||
공개 공유 URL은 세션 권한을 우회하지 않는다. 학습자가 명시적으로 생성한
|
||||
토큰으로 app.session_share_link의 sanitized payload만 읽고, 원문 축어록은 조회하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
|
||||
router = APIRouter(tags=["share"])
|
||||
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,160}$")
|
||||
_SHARE_HEADERS = {
|
||||
"cache-control": "public, max-age=300",
|
||||
"x-robots-tag": "noindex, noarchive, max-snippet:160",
|
||||
}
|
||||
|
||||
|
||||
class PublicSessionShareResponse(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
summary: str
|
||||
imageUrl: str
|
||||
appUrl: str
|
||||
clientName: str
|
||||
persona: str
|
||||
date: str
|
||||
durationLabel: str
|
||||
reachedPhase: str
|
||||
sessionSignal: str
|
||||
reviewReady: bool = False
|
||||
goodMoments: list[str] = Field(default_factory=list)
|
||||
growthPoints: list[str] = Field(default_factory=list)
|
||||
worksheetHighlights: list[dict[str, str]] = Field(default_factory=list)
|
||||
privacy: str = ""
|
||||
|
||||
|
||||
def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
|
||||
return PublicSessionShareResponse(
|
||||
title=str(payload.get("title") or "Vignette 회기 리뷰"),
|
||||
description=str(payload.get("description") or "AI 심리상담 시뮬레이션 회기 리뷰 요약"),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
imageUrl=str(payload.get("imageUrl") or ""),
|
||||
appUrl=str(payload.get("appUrl") or ""),
|
||||
clientName=str(payload.get("clientName") or "내담자"),
|
||||
persona=str(payload.get("persona") or ""),
|
||||
date=str(payload.get("date") or ""),
|
||||
durationLabel=str(payload.get("durationLabel") or ""),
|
||||
reachedPhase=str(payload.get("reachedPhase") or ""),
|
||||
sessionSignal=str(payload.get("sessionSignal") or ""),
|
||||
reviewReady=bool(payload.get("reviewReady")),
|
||||
goodMoments=[str(item) for item in payload.get("goodMoments") or []][:3],
|
||||
growthPoints=[str(item) for item in payload.get("growthPoints") or []][:3],
|
||||
worksheetHighlights=[
|
||||
{
|
||||
"section": str(item.get("section") or ""),
|
||||
"label": str(item.get("label") or ""),
|
||||
"value": str(item.get("value") or ""),
|
||||
}
|
||||
for item in (payload.get("worksheetHighlights") or [])
|
||||
if isinstance(item, dict)
|
||||
][:4],
|
||||
privacy=str(payload.get("privacy") or ""),
|
||||
)
|
||||
|
||||
|
||||
async def _load_share_or_404(token: str) -> PublicSessionShareResponse:
|
||||
if not _TOKEN_RE.match(token):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
|
||||
record = await session_persistence.load_public_session_share(
|
||||
session_persistence.share_token_hash(token)
|
||||
)
|
||||
if record is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
|
||||
return _safe_payload(dict(record.get("payload") or {}))
|
||||
|
||||
|
||||
def _request_url(request: Request) -> str:
|
||||
return str(request.url)
|
||||
|
||||
|
||||
def _json_ld(share: PublicSessionShareResponse, url: str) -> str:
|
||||
payload = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CreativeWork",
|
||||
"name": share.title,
|
||||
"description": share.description,
|
||||
"url": url,
|
||||
"image": share.imageUrl,
|
||||
"inLanguage": "ko-KR",
|
||||
"educationalUse": "AI counseling simulation review",
|
||||
"isAccessibleForFree": True,
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": "Vignette",
|
||||
},
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def _meta(name: str, content: str, *, prop: bool = False) -> str:
|
||||
attr = "property" if prop else "name"
|
||||
return f'<meta {attr}="{html.escape(name)}" content="{html.escape(content, quote=True)}">'
|
||||
|
||||
|
||||
def _list_items(values: list[str]) -> str:
|
||||
if not values:
|
||||
return "<li>아직 공유 가능한 항목이 없습니다.</li>"
|
||||
return "".join(f"<li>{html.escape(value)}</li>" for value in values)
|
||||
|
||||
|
||||
def _worksheet_items(values: list[dict[str, str]]) -> str:
|
||||
if not values:
|
||||
return "<li>사례개념화 워크시트 핵심값은 아직 비어 있습니다.</li>"
|
||||
return "".join(
|
||||
"<li>"
|
||||
f"<b>{html.escape(item['label'])}</b>"
|
||||
f"<span>{html.escape(item['value'])}</span>"
|
||||
"</li>"
|
||||
for item in values
|
||||
)
|
||||
|
||||
|
||||
def _share_html(share: PublicSessionShareResponse, url: str) -> str:
|
||||
title = html.escape(share.title)
|
||||
description = html.escape(share.description)
|
||||
image = html.escape(share.imageUrl, quote=True)
|
||||
app_url = html.escape(share.appUrl or "https://vignette.chanpaca.net", quote=True)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title}</title>
|
||||
{_meta("robots", "noindex, noarchive, max-snippet:160")}
|
||||
{_meta("description", share.description)}
|
||||
{_meta("og:type", "article", prop=True)}
|
||||
{_meta("og:site_name", "Vignette", prop=True)}
|
||||
{_meta("og:title", share.title, prop=True)}
|
||||
{_meta("og:description", share.description, prop=True)}
|
||||
{_meta("og:url", url, prop=True)}
|
||||
{_meta("og:image", share.imageUrl, prop=True)}
|
||||
{_meta("og:image:width", "1672", prop=True)}
|
||||
{_meta("og:image:height", "941", prop=True)}
|
||||
{_meta("twitter:card", "summary_large_image")}
|
||||
{_meta("twitter:title", share.title)}
|
||||
{_meta("twitter:description", share.description)}
|
||||
{_meta("twitter:image", share.imageUrl)}
|
||||
<script type="application/ld+json">{_json_ld(share, url)}</script>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light dark;
|
||||
--bg: #f8f5ef;
|
||||
--surface: #ffffff;
|
||||
--ink: #172424;
|
||||
--muted: #65706d;
|
||||
--accent: #2f6f63;
|
||||
--line: #e4ddd2;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
|
||||
line-height: 1.55;
|
||||
}}
|
||||
main {{
|
||||
width: min(920px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 44px 0;
|
||||
}}
|
||||
.hero {{
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 14px 40px rgba(23, 36, 36, .08);
|
||||
}}
|
||||
.hero img {{
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1672 / 941;
|
||||
object-fit: cover;
|
||||
}}
|
||||
.body {{ padding: 28px; }}
|
||||
.eyebrow {{
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
h1 {{ margin: 0; font-size: clamp(26px, 4vw, 42px); line-height: 1.2; letter-spacing: 0; }}
|
||||
.desc {{ margin: 14px 0 0; color: var(--muted); font-size: 17px; }}
|
||||
.facts {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 24px 0;
|
||||
}}
|
||||
.fact {{ border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: color-mix(in srgb, var(--surface) 82%, var(--bg)); }}
|
||||
.fact b {{ display: block; font-size: 12px; color: var(--muted); }}
|
||||
.fact span {{ display: block; margin-top: 4px; font-weight: 750; }}
|
||||
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
|
||||
section {{ border-top: 1px solid var(--line); padding-top: 18px; }}
|
||||
h2 {{ margin: 0 0 10px; font-size: 17px; }}
|
||||
ul {{ margin: 0; padding-left: 20px; color: var(--ink); }}
|
||||
li + li {{ margin-top: 8px; }}
|
||||
li span {{ display: block; color: var(--muted); }}
|
||||
.privacy {{ margin-top: 22px; color: var(--muted); font-size: 13px; }}
|
||||
.cta {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 42px;
|
||||
margin-top: 22px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
}}
|
||||
@media (max-width: 720px) {{
|
||||
main {{ width: min(100% - 20px, 920px); padding: 20px 0; }}
|
||||
.body {{ padding: 20px; }}
|
||||
.facts, .grid {{ grid-template-columns: 1fr; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<article class="hero">
|
||||
<img src="{image}" alt="">
|
||||
<div class="body">
|
||||
<p class="eyebrow">Vignette session review</p>
|
||||
<h1>{title}</h1>
|
||||
<p class="desc">{description}</p>
|
||||
<div class="facts" aria-label="회기 요약">
|
||||
<div class="fact"><b>날짜</b><span>{html.escape(share.date or "-")}</span></div>
|
||||
<div class="fact"><b>시간</b><span>{html.escape(share.durationLabel or "-")}</span></div>
|
||||
<div class="fact"><b>도달 단계</b><span>{html.escape(share.reachedPhase or "-")}</span></div>
|
||||
<div class="fact"><b>상태</b><span>{html.escape(share.sessionSignal or "-")}</span></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<section>
|
||||
<h2>강점 요약</h2>
|
||||
<ul>{_list_items(share.goodMoments)}</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h2>개선 요약</h2>
|
||||
<ul>{_list_items(share.growthPoints)}</ul>
|
||||
</section>
|
||||
</div>
|
||||
<section style="margin-top:18px">
|
||||
<h2>사례개념화 핵심값</h2>
|
||||
<ul>{_worksheet_items(share.worksheetHighlights)}</ul>
|
||||
</section>
|
||||
<p class="privacy">{html.escape(share.privacy)}</p>
|
||||
<a class="cta" href="{app_url}">Vignette 열기</a>
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@router.get("/share/session/{token}", response_class=HTMLResponse, name="get_public_session_share")
|
||||
async def get_public_session_share(token: str, request: Request) -> HTMLResponse:
|
||||
share = await _load_share_or_404(token)
|
||||
return HTMLResponse(_share_html(share, _request_url(request)), headers=_SHARE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/share/session/{token}/summary", response_model=PublicSessionShareResponse)
|
||||
async def get_public_session_share_summary(token: str, response: Response) -> PublicSessionShareResponse:
|
||||
for key, value in _SHARE_HEADERS.items():
|
||||
response.headers[key] = value
|
||||
return await _load_share_or_404(token)
|
||||
|
||||
|
||||
@router.get("/robots.txt", include_in_schema=False)
|
||||
async def robots_txt() -> PlainTextResponse:
|
||||
body = "\n".join(
|
||||
[
|
||||
"User-agent: *",
|
||||
"Disallow: /auth/",
|
||||
"Disallow: /admin/",
|
||||
"Disallow: /sessions/",
|
||||
"Disallow: /teacher/",
|
||||
"Disallow: /users/",
|
||||
"Disallow: /eval/",
|
||||
"Disallow: /voice/",
|
||||
"Disallow: /kb/",
|
||||
"Disallow: /share/",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return PlainTextResponse(body, headers={"cache-control": "public, max-age=3600"})
|
||||
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services import session_metrics
|
||||
from ..store import InProcSession, store
|
||||
|
||||
router = APIRouter(prefix="/teacher", tags=["teacher"])
|
||||
|
|
@ -32,6 +32,23 @@ class TeacherSessionSummary(BaseModel):
|
|||
client_turn_count: int
|
||||
started_at: str
|
||||
ended_at: str | None = None
|
||||
review_status: Literal["pending", "viewed", "closed"] = "pending"
|
||||
review_note: str | None = None
|
||||
reviewed_at: str | None = None
|
||||
|
||||
|
||||
class TeacherSessionReviewStatusRequest(BaseModel):
|
||||
status: Literal["viewed", "closed"] = "closed"
|
||||
note: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class TeacherSessionReviewStatusResponse(BaseModel):
|
||||
session_id: str
|
||||
status: Literal["pending", "viewed", "closed"] = "pending"
|
||||
note: str = ""
|
||||
reviewer_id: str | None = None
|
||||
reviewed_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class TeacherGrowthPoint(BaseModel):
|
||||
|
|
@ -91,168 +108,63 @@ class TeacherDashboardResponse(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
_APPROPRIATENESS_SCORE = {
|
||||
"neg": 0.0,
|
||||
"neutral": 0.5,
|
||||
"pos": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def _iso(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _learner_label(learner_id: str) -> str:
|
||||
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
|
||||
return f"학습자 {suffix}"
|
||||
|
||||
|
||||
def _safe_float(value: object) -> float | None:
|
||||
try:
|
||||
return float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _avg(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return round(sum(values) / len(values), 3)
|
||||
|
||||
|
||||
def _turn_eval(turn: Any) -> dict[str, Any] | None:
|
||||
ev = getattr(turn, "evaluation", None)
|
||||
return ev if isinstance(ev, dict) else None
|
||||
|
||||
|
||||
def _turn_score(ev: dict[str, Any]) -> float | None:
|
||||
raw = str(ev.get("appropriateness") or "").strip().lower()
|
||||
return _APPROPRIATENESS_SCORE.get(raw)
|
||||
|
||||
|
||||
def _turn_rapport(ev: dict[str, Any]) -> float | None:
|
||||
value = _safe_float(ev.get("rapport_signal"))
|
||||
if value is None:
|
||||
return None
|
||||
return max(-1.0, min(1.0, value))
|
||||
|
||||
|
||||
def _turn_techniques(ev: dict[str, Any]) -> list[str]:
|
||||
raw = ev.get("techniques")
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
labels: list[str] = []
|
||||
for item in raw:
|
||||
if isinstance(item, dict):
|
||||
label = item.get("label") or item.get("name") or item.get("id")
|
||||
else:
|
||||
label = item
|
||||
if label:
|
||||
labels.append(str(label))
|
||||
return labels
|
||||
|
||||
|
||||
def _session_growth_point(sess: InProcSession) -> TeacherGrowthPoint:
|
||||
scores: list[float] = []
|
||||
rapports: list[float] = []
|
||||
technique_count = 0
|
||||
watch_count = 0
|
||||
for turn in sess.turns:
|
||||
if turn.speaker != "counselor":
|
||||
continue
|
||||
ev = _turn_eval(turn)
|
||||
if ev is None:
|
||||
continue
|
||||
score = _turn_score(ev)
|
||||
if score is not None:
|
||||
scores.append(score)
|
||||
if score < 1.0:
|
||||
watch_count += 1
|
||||
rapport = _turn_rapport(ev)
|
||||
if rapport is not None:
|
||||
rapports.append(rapport)
|
||||
technique_count += len(_turn_techniques(ev))
|
||||
def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoint:
|
||||
return TeacherGrowthPoint(
|
||||
session_id=sess.session_id,
|
||||
session_no=sess.session_no,
|
||||
persona_code=sess.persona_code,
|
||||
stage=sess.state.stage.value,
|
||||
started_at=_iso(sess.created_at) or "",
|
||||
ended_at=_iso(sess.ended_at),
|
||||
score=_avg(scores),
|
||||
rapport=_avg(rapports),
|
||||
technique_count=technique_count,
|
||||
watch_count=watch_count,
|
||||
session_id=point.session_id,
|
||||
session_no=point.session_no,
|
||||
persona_code=point.persona_code,
|
||||
stage=point.stage,
|
||||
started_at=point.started_at,
|
||||
ended_at=point.ended_at,
|
||||
score=point.score,
|
||||
rapport=point.rapport,
|
||||
technique_count=point.technique_count,
|
||||
watch_count=point.watch_count,
|
||||
)
|
||||
|
||||
|
||||
def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerGrowth]:
|
||||
grouped: dict[str, list[InProcSession]] = {}
|
||||
for sess in sessions:
|
||||
grouped.setdefault(sess.learner_id, []).append(sess)
|
||||
|
||||
result: list[TeacherLearnerGrowth] = []
|
||||
for learner_id, learner_sessions in grouped.items():
|
||||
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
|
||||
points = [_session_growth_point(sess) for sess in ordered]
|
||||
scored = [point for point in points if point.score is not None]
|
||||
rapport_values = [point.rapport for point in points if point.rapport is not None]
|
||||
technique_counts: dict[str, int] = {}
|
||||
for sess in ordered:
|
||||
for turn in sess.turns:
|
||||
if turn.speaker != "counselor":
|
||||
continue
|
||||
ev = _turn_eval(turn)
|
||||
if ev is None:
|
||||
continue
|
||||
for label in _turn_techniques(ev):
|
||||
technique_counts[label] = technique_counts.get(label, 0) + 1
|
||||
|
||||
first_score = scored[0].score if scored else None
|
||||
latest_score = scored[-1].score if scored else None
|
||||
score_delta: float | None = None
|
||||
trend = "insufficient"
|
||||
if first_score is not None and latest_score is not None:
|
||||
score_delta = round(latest_score - first_score, 3)
|
||||
if len(scored) >= 2:
|
||||
if score_delta >= 0.1:
|
||||
trend = "up"
|
||||
elif score_delta <= -0.1:
|
||||
trend = "down"
|
||||
else:
|
||||
trend = "flat"
|
||||
|
||||
latest_session = ordered[-1]
|
||||
top_techniques = [
|
||||
label
|
||||
for label, _count in sorted(
|
||||
technique_counts.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[:3]
|
||||
]
|
||||
result.append(
|
||||
TeacherLearnerGrowth(
|
||||
learner_id=learner_id,
|
||||
learner_label=_learner_label(learner_id),
|
||||
sessions=len(ordered),
|
||||
ended_sessions=sum(1 for sess in ordered if sess.ended),
|
||||
latest_at=_iso(latest_session.ended_at or latest_session.created_at) or "",
|
||||
first_score=first_score,
|
||||
latest_score=latest_score,
|
||||
score_delta=score_delta,
|
||||
avg_score=_avg([point.score for point in scored if point.score is not None]),
|
||||
avg_rapport=_avg([value for value in rapport_values if value is not None]),
|
||||
trend=trend,
|
||||
top_techniques=top_techniques,
|
||||
points=points[-6:],
|
||||
)
|
||||
metrics = session_metrics.build_learner_growth(
|
||||
sessions,
|
||||
learner_label=_learner_label,
|
||||
limit=12,
|
||||
)
|
||||
return [
|
||||
TeacherLearnerGrowth(
|
||||
learner_id=item.learner_id,
|
||||
learner_label=item.learner_label,
|
||||
sessions=item.sessions,
|
||||
ended_sessions=item.ended_sessions,
|
||||
latest_at=item.latest_at,
|
||||
first_score=item.first_score,
|
||||
latest_score=item.latest_score,
|
||||
score_delta=item.score_delta,
|
||||
avg_score=item.avg_score,
|
||||
avg_rapport=item.avg_rapport,
|
||||
trend=item.trend,
|
||||
top_techniques=item.top_techniques,
|
||||
points=[_growth_point(point) for point in item.points],
|
||||
)
|
||||
return sorted(result, key=lambda item: item.latest_at, reverse=True)[:12]
|
||||
for item in metrics
|
||||
]
|
||||
|
||||
|
||||
def _summary(sess: InProcSession) -> TeacherSessionSummary:
|
||||
def _review_status_value(record: dict[str, object] | None) -> Literal["pending", "viewed", "closed"]:
|
||||
value = str((record or {}).get("status") or "pending")
|
||||
if value in {"viewed", "closed"}:
|
||||
return value # type: ignore[return-value]
|
||||
return "pending"
|
||||
|
||||
|
||||
def _summary(
|
||||
sess: InProcSession,
|
||||
review_status: dict[str, object] | None = None,
|
||||
) -> TeacherSessionSummary:
|
||||
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
|
||||
return TeacherSessionSummary(
|
||||
|
|
@ -267,8 +179,11 @@ def _summary(sess: InProcSession) -> TeacherSessionSummary:
|
|||
turn_count=len(sess.turns),
|
||||
learner_turn_count=learner_turns,
|
||||
client_turn_count=client_turns,
|
||||
started_at=_iso(sess.created_at) or "",
|
||||
ended_at=_iso(sess.ended_at),
|
||||
started_at=session_metrics.iso_datetime(sess.created_at) or "",
|
||||
ended_at=session_metrics.iso_datetime(sess.ended_at),
|
||||
review_status=_review_status_value(review_status),
|
||||
review_note=str(review_status.get("note") or "") if review_status else None,
|
||||
reviewed_at=str(review_status.get("reviewed_at") or "") if review_status else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -282,8 +197,19 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
|
|||
if not durable:
|
||||
require_runtime_fallback_allowed("teacher dashboard")
|
||||
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
|
||||
summaries = [_summary(sess) for sess in sessions]
|
||||
pending_reviews = [item for item in summaries if item.status == "ended"]
|
||||
review_statuses, _ = await session_persistence.list_session_review_statuses(
|
||||
[sess.session_id for sess in sessions if sess.ended],
|
||||
principal,
|
||||
)
|
||||
summaries = [
|
||||
_summary(sess, review_statuses.get(sess.session_id))
|
||||
for sess in sessions
|
||||
]
|
||||
pending_reviews = [
|
||||
item
|
||||
for item in summaries
|
||||
if item.status == "ended" and item.review_status != "closed"
|
||||
]
|
||||
learners = {sess.learner_id for sess in sessions}
|
||||
learner_growth = _build_learner_growth(sessions)
|
||||
safety_alerts: list[TeacherSafetyAlert] = []
|
||||
|
|
@ -331,3 +257,46 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
|
|||
recent_sessions=summaries[:20],
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/sessions/{session_id}/review-status",
|
||||
response_model=TeacherSessionReviewStatusResponse,
|
||||
)
|
||||
async def update_session_review_status(
|
||||
session_id: str,
|
||||
request: TeacherSessionReviewStatusRequest,
|
||||
principal: TeacherPrincipal,
|
||||
) -> TeacherSessionReviewStatusResponse:
|
||||
sess = await session_persistence.load_session(
|
||||
session_id,
|
||||
principal,
|
||||
allow_ended=True,
|
||||
)
|
||||
if sess is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="session not found")
|
||||
if not sess.ended and request.status == "closed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="active sessions cannot be closed as reviewed",
|
||||
)
|
||||
saved, _ = await session_persistence.save_session_review_status(
|
||||
session_id=session_id,
|
||||
reviewer_id=principal.user_id,
|
||||
status=request.status,
|
||||
note=request.note,
|
||||
principal=principal,
|
||||
)
|
||||
if saved is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="review status unavailable",
|
||||
)
|
||||
return TeacherSessionReviewStatusResponse(
|
||||
session_id=session_id,
|
||||
status=_review_status_value(saved),
|
||||
note=str(saved.get("note") or ""),
|
||||
reviewer_id=str(saved.get("reviewer_id") or "") or None,
|
||||
reviewed_at=str(saved.get("reviewed_at") or "") or None,
|
||||
updated_at=str(saved.get("updated_at") or "") or None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,30 +2,173 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
|
||||
from ..db import get_pool
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..auth_types import RoleName
|
||||
from ..auth_sessions import (
|
||||
DEFAULT_AFFILIATION,
|
||||
ManagedUserPatch,
|
||||
get_managed_user,
|
||||
record_user_consent,
|
||||
update_managed_user,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..db import acquire, get_pool
|
||||
from ..deps import CurrentPrincipal, Role
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
TERMS_VERSION = "terms-draft-2026-06-27"
|
||||
PRIVACY_VERSION = "privacy-draft-2026-06-27"
|
||||
AVATAR_MAX_BYTES = 3 * 1024 * 1024
|
||||
AVATAR_CONTENT_TYPES = {
|
||||
"image/png": ("png", b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg": ("jpg", b"\xff\xd8\xff"),
|
||||
"image/webp": ("webp", b"RIFF"),
|
||||
}
|
||||
|
||||
TERMS_BODY = """Vignette 서비스 이용약관 초안
|
||||
|
||||
1. 목적
|
||||
본 약관은 Vignette가 제공하는 AI 심리상담 시뮬레이션 훈련 플랫폼의 이용 조건, 권리와 의무, 책임 범위를 정한다. Vignette는 상담 수련생의 교육, 실습, 교수자 피드백, 운영 품질 관리를 위한 비치료 교육 도구이며 실제 진단, 치료, 응급 위기 개입을 대체하지 않는다.
|
||||
|
||||
2. 계정과 이용 자격
|
||||
서비스는 한신대학교 산학협력 교육 과정, 승인된 연구/수업, 운영자가 허용한 기관 계정에 한해 제공된다. 사용자는 본인 계정으로만 로그인해야 하며 타인의 Google 계정, 학교 계정, 세션 쿠키를 빌리거나 공유해서는 안 된다. 교수자와 관리자는 운영자가 별도로 지정한 이메일 allowlist 또는 관리자 사용자 관리 절차를 통해 권한을 부여받는다.
|
||||
|
||||
3. 역할별 이용 범위
|
||||
학습자는 AI 내담자와의 모의 회기, 회기 종료 후 피드백, 사례개념화 워크시트 작성 기능을 사용할 수 있다. 교수자는 담당 코호트 또는 승인 범위 안에서 학습자의 회기 요약, 안전 알림, 성장 지표, 제출물을 검토할 수 있다. 관리자는 사용자 권한, 시스템 상태, 비용, 페르소나 승인, 감사 기록을 관리한다.
|
||||
|
||||
4. AI 시뮬레이션의 성격
|
||||
AI 내담자는 교육용 페르소나를 연기한다. AI 응답은 상담 훈련을 위한 시뮬레이션 산출물이며 의료, 임상, 법률, 행정 판단으로 사용해서는 안 된다. 사용자는 실제 위기 상황, 자해·자살 위험, 폭력 위험, 학대 의심 등 즉시 개입이 필요한 상황에서는 119, 112, 109 또는 기관의 위기 대응 절차를 우선해야 한다.
|
||||
|
||||
5. 사용자 의무
|
||||
사용자는 허위 정보 입력, 타인의 개인정보 입력, 비인가 접근, 취약점 탐색, 데이터 무단 반출, 모델 프롬프트 탈취, 자동화된 대량 요청, 실존 인물 사칭, 교육 목적을 벗어난 민감 정보 입력을 해서는 안 된다. 상담 실습 중 실제 제3자의 이름, 연락처, 주소, 주민등록번호, 진료 정보 등 불필요한 개인정보는 입력하지 않는 것을 원칙으로 한다.
|
||||
|
||||
6. 콘텐츠와 기록
|
||||
학습자가 입력한 발화, AI 내담자 응답, 회기 메타데이터, 평가 결과, 사례개념화 워크시트는 교육 운영, 피드백 제공, 품질 개선, 안전 관리, 연구 검증을 위해 저장될 수 있다. 원문은 접근 권한과 RLS 정책에 따라 제한되고, 외부 AI 처리 경로에는 가능한 범위에서 마스킹된 텍스트를 사용한다.
|
||||
|
||||
7. 서비스 변경과 중단
|
||||
운영자는 교육 일정, 보안, 장애, 비용, 외부 AI 제공자 상태, 학교 또는 연구기관 정책에 따라 기능을 변경하거나 일시 중단할 수 있다. 운영자는 중대한 변경이 있을 때 가능한 범위에서 사전에 안내한다.
|
||||
|
||||
8. 권한 회수와 이용 제한
|
||||
운영자는 계정 오용, 보안 위험, 허위 정보, 교육 목적 외 사용, 법령 또는 기관 정책 위반이 확인되면 이용을 제한하거나 세션을 무효화할 수 있다. 교수자와 관리자의 권한은 직무 변경, 코호트 변경, 산학협력 범위 변경, 퇴직 또는 운영자 결정에 따라 조정될 수 있다.
|
||||
|
||||
9. 책임 제한
|
||||
서비스는 교육과 연구 목적의 보조 도구다. AI 응답의 완전성, 임상적 정확성, 특정 학습 성과를 보장하지 않는다. 다만 운영자는 개인정보 보호, 접근통제, 감사, 안전 게이트 등 합리적인 보호조치를 유지하기 위해 노력한다.
|
||||
|
||||
10. 준거와 개정
|
||||
본 약관은 대한민국 법령과 한신대학교 및 산학협력 운영 기준을 따른다. 본 문서는 운영 전 초안이며, 최종 약관은 윤찬, 한신대학교 담당자, 법무/개인정보 검토 결과에 따라 수정될 수 있다.
|
||||
"""
|
||||
|
||||
PRIVACY_BODY = """Vignette 개인정보 수집·이용 및 처리방침 초안
|
||||
|
||||
1. 처리 목적
|
||||
Vignette는 AI 심리상담 시뮬레이션 교육 운영, 사용자 식별, 역할별 권한 관리, 교수자 피드백, 회기 기록 보존, 사례개념화 과제 관리, 안전 이벤트 대응, 서비스 품질 개선, 연구·평가 지표 산출, 법령과 기관 정책 준수를 위해 개인정보를 처리한다.
|
||||
|
||||
2. 수집 항목
|
||||
필수 항목은 로그인 이메일, 이름, 닉네임, 자기소개, 소속, 학과, 학년 또는 직위, 연락처, 주소 또는 우편물 수령지, 역할, 코호트, 로그인/접속 기록, 동의 이력, 회기 발화와 AI 응답, 회기 메타데이터, 평가 및 피드백 결과다. 선택 항목으로 사용자가 업로드한 프로필 아바타 이미지와 그 저장 URL을 처리할 수 있다. 이메일은 Google 또는 학교 계정 로그인으로 확인되므로 별도 입력을 받지 않는다.
|
||||
|
||||
3. 민감한 교육 데이터
|
||||
상담 실습 과정에서 심리 상태, 위기 표현, 사례개념화 내용, 음성 입력 메타데이터, 교수자 코멘트가 생성될 수 있다. 이는 실제 치료기록이 아니라 교육용 시뮬레이션 기록이지만, 재식별 위험과 민감성을 고려해 접근권한을 제한하고 감사 로그를 남긴다.
|
||||
|
||||
4. 보유와 이용 기간
|
||||
개인정보와 학습 기록은 산학협력 교육·연구 운영, 성과 검증, 감사, 분쟁 대응에 필요한 기간 동안 보유한다. 구체적인 보유 기간, 파기 주기, 연구 데이터 익명화 기준은 기관 검토 후 확정한다. 사용 중지 또는 권한 회수 후에도 법령상 의무, 연구 검증, 감사 목적상 필요한 최소 기록은 별도 기간 동안 보관될 수 있다.
|
||||
|
||||
5. 제3자 제공과 위탁
|
||||
서비스 운영 과정에서 Google 로그인, 외부 AI 모델, 음성 처리, 인프라 제공자 등 외부 서비스가 사용될 수 있다. 외부 AI 경로에는 원칙적으로 마스킹된 텍스트와 필요한 최소 메타데이터만 전달하고, prompt/completion 본문을 비용 감사 로그에 저장하지 않는다. 실제 위탁·제3자 제공 목록과 국외 이전 여부는 배포 전 별도 고지로 확정한다.
|
||||
|
||||
6. 안전성 확보조치
|
||||
운영자는 서버 측 HttpOnly 쿠키 세션, 역할 기반 접근통제, 코호트 범위 제한, DB RLS, 감사 로그, 외부 AI 호출 메타데이터 기록, 개인정보 마스킹, 권한 회수, 비활성 계정 차단, 최소 권한 원칙을 적용한다. 운영 환경에서는 시크릿을 코드에 저장하지 않고, 접근 권한과 로그를 분리 관리한다.
|
||||
|
||||
7. 정보주체 권리
|
||||
사용자는 본인의 개인정보 열람, 정정, 처리정지, 삭제 요청을 할 수 있다. 다만 교육 평가, 연구 검증, 법령상 보존 의무, 다른 사용자의 권리 보호, 감사 목적에 필요한 기록은 즉시 삭제가 제한될 수 있다. 요청 창구와 처리 절차는 운영자 및 한신대학교 담당 부서 확정 후 고지한다.
|
||||
|
||||
8. 미성년자와 보호자 동의
|
||||
서비스가 미성년 학습자 또는 미성년 사례 자료를 다루는 경우 보호자 동의, 기관 승인, IRB 또는 이에 준하는 검토가 필요한지 별도로 확인한다. 현재 문안은 기술 구현용 초안이며 실제 운영 전 법무·개인정보·임상팀 검토가 필요하다.
|
||||
|
||||
9. 국내법 준수
|
||||
개인정보 처리는 개인정보 보호법, 동법 시행령, 개인정보 처리방침 작성지침, 개인정보의 안전성 확보조치 기준 등 대한민국 개인정보보호 법령과 관련 고시를 기준으로 운영한다. 법령 개정 또는 기관 정책 변경 시 처리방침을 개정할 수 있다.
|
||||
|
||||
10. 시행과 개정
|
||||
본 방침은 2026년 6월 27일 개발 초안이다. 최종 시행일, 개인정보 보호책임자, 문의처, 보유 기간, 위탁·제3자 제공 내역은 운영 전 확정해 고지한다.
|
||||
"""
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
role: RoleName
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
legal_name: str = ""
|
||||
department: str = ""
|
||||
grade_level: str = ""
|
||||
phone: str = ""
|
||||
contact_address: str = ""
|
||||
nickname: str = ""
|
||||
self_introduction: str = ""
|
||||
avatar_url: str = ""
|
||||
onboarding_completed_at: float | None = None
|
||||
terms_agreed_at: float | None = None
|
||||
privacy_agreed_at: float | None = None
|
||||
terms_version: str = ""
|
||||
privacy_version: str = ""
|
||||
onboarding_required: bool = True
|
||||
|
||||
|
||||
class UserProfilePatch(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
legal_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
department: str | None = Field(default=None, max_length=120)
|
||||
grade_level: str | None = Field(default=None, max_length=40)
|
||||
phone: str | None = Field(default=None, max_length=30)
|
||||
contact_address: str | None = Field(default=None, max_length=300)
|
||||
nickname: str | None = Field(default=None, min_length=1, max_length=40)
|
||||
self_introduction: str | None = Field(default=None, max_length=600)
|
||||
avatar_url: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class OnboardingRequest(BaseModel):
|
||||
legal_name: str = Field(..., min_length=1, max_length=80)
|
||||
affiliation: str = Field(..., min_length=1, max_length=120)
|
||||
department: str = Field(..., min_length=1, max_length=120)
|
||||
grade_level: str = Field(..., min_length=1, max_length=40)
|
||||
phone: str = Field(..., min_length=1, max_length=30)
|
||||
contact_address: str = Field(..., min_length=1, max_length=300)
|
||||
nickname: str = Field(..., min_length=1, max_length=40)
|
||||
self_introduction: str = Field(..., min_length=1, max_length=600)
|
||||
avatar_url: str = Field(default="", max_length=500)
|
||||
terms_accepted: bool
|
||||
privacy_accepted: bool
|
||||
|
||||
|
||||
class AvatarUploadResponse(BaseModel):
|
||||
avatar_url: str
|
||||
content_type: str
|
||||
size_bytes: int
|
||||
|
||||
|
||||
class LegalDocument(BaseModel):
|
||||
kind: Literal["terms", "privacy"]
|
||||
version: str
|
||||
title: str
|
||||
body: str
|
||||
status: Literal["draft"] = "draft"
|
||||
|
||||
|
||||
class LegalDocumentsResponse(BaseModel):
|
||||
terms: LegalDocument
|
||||
privacy: LegalDocument
|
||||
source_note: str
|
||||
|
||||
|
||||
class NotificationPreferences(BaseModel):
|
||||
|
|
@ -57,6 +200,34 @@ class VoicePresetResponse(BaseModel):
|
|||
persona_hint: str
|
||||
|
||||
|
||||
TicketCategory = Literal[
|
||||
"account_access",
|
||||
"session_review",
|
||||
"voice_browser",
|
||||
"content_scenario",
|
||||
"safety",
|
||||
"other",
|
||||
]
|
||||
TicketPriority = Literal["low", "normal", "high", "urgent"]
|
||||
|
||||
|
||||
class UserSupportTicketRequest(BaseModel):
|
||||
category: TicketCategory = "other"
|
||||
priority: TicketPriority = "normal"
|
||||
subject: str = Field(..., min_length=2, max_length=160)
|
||||
body: str = Field(..., min_length=2, max_length=4000)
|
||||
source_path: str = Field(default="", max_length=500)
|
||||
|
||||
|
||||
class UserSupportTicketResponse(BaseModel):
|
||||
ticket_id: str
|
||||
status: Literal["open"]
|
||||
category: TicketCategory
|
||||
priority: TicketPriority
|
||||
subject: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_preferences: dict[str, UserPreferencesResponse] = {}
|
||||
|
||||
VOICE_PRESET_META = {
|
||||
|
|
@ -135,6 +306,42 @@ def _preferences_from_row(row) -> UserPreferencesResponse:
|
|||
)
|
||||
|
||||
|
||||
def _onboarding_required(managed) -> bool:
|
||||
return not (
|
||||
managed
|
||||
and managed.profile_completed_at is not None
|
||||
and managed.terms_agreed_at is not None
|
||||
and managed.privacy_agreed_at is not None
|
||||
and bool(managed.nickname.strip())
|
||||
and bool(managed.self_introduction.strip())
|
||||
)
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
root = Path(settings.user_upload_dir)
|
||||
if not root.is_absolute():
|
||||
root = Path.cwd() / root
|
||||
avatar_root = root / "profile-avatars"
|
||||
avatar_root.mkdir(parents=True, exist_ok=True)
|
||||
return avatar_root
|
||||
|
||||
|
||||
def _validated_avatar_extension(content_type: str, content: bytes) -> str:
|
||||
normalized = content_type.split(";", 1)[0].strip().lower()
|
||||
if normalized not in AVATAR_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="unsupported_avatar_type",
|
||||
)
|
||||
ext, magic = AVATAR_CONTENT_TYPES[normalized]
|
||||
if normalized == "image/webp":
|
||||
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
|
||||
elif not content.startswith(magic):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
|
||||
return ext
|
||||
|
||||
|
||||
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
managed = await get_managed_user(principal.user_id)
|
||||
return UserProfileResponse(
|
||||
|
|
@ -148,6 +355,42 @@ async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
|
|||
role=(managed.role if managed else principal.role.value),
|
||||
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
|
||||
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
|
||||
legal_name=(managed.legal_name if managed else ""),
|
||||
department=(managed.department if managed else ""),
|
||||
grade_level=(managed.grade_level if managed else ""),
|
||||
phone=(managed.phone if managed else ""),
|
||||
contact_address=(managed.contact_address if managed else ""),
|
||||
nickname=(managed.nickname if managed else ""),
|
||||
self_introduction=(managed.self_introduction if managed else ""),
|
||||
avatar_url=(managed.avatar_url if managed else ""),
|
||||
onboarding_completed_at=(managed.profile_completed_at if managed else None),
|
||||
terms_agreed_at=(managed.terms_agreed_at if managed else None),
|
||||
privacy_agreed_at=(managed.privacy_agreed_at if managed else None),
|
||||
terms_version=(managed.terms_version if managed else ""),
|
||||
privacy_version=(managed.privacy_version if managed else ""),
|
||||
onboarding_required=_onboarding_required(managed),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/legal-docs", response_model=LegalDocumentsResponse)
|
||||
async def get_legal_documents(principal: CurrentPrincipal) -> LegalDocumentsResponse:
|
||||
return LegalDocumentsResponse(
|
||||
terms=LegalDocument(
|
||||
kind="terms",
|
||||
version=TERMS_VERSION,
|
||||
title="Vignette 서비스 이용약관 초안",
|
||||
body=TERMS_BODY,
|
||||
),
|
||||
privacy=LegalDocument(
|
||||
kind="privacy",
|
||||
version=PRIVACY_VERSION,
|
||||
title="Vignette 개인정보 수집·이용 및 처리방침 초안",
|
||||
body=PRIVACY_BODY,
|
||||
),
|
||||
source_note=(
|
||||
"법무 검토 전 개발 초안입니다. 개인정보 보호법 제30조, 개인정보 처리방침 작성지침, "
|
||||
"개인정보의 안전성 확보조치 기준, 약관규제법 취지를 반영했습니다."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -159,14 +402,188 @@ async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
|
|||
@router.patch("/me", response_model=UserProfileResponse)
|
||||
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
profile = await _profile_for(principal)
|
||||
await update_managed_user(
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
display_name=body.display_name if body.display_name is not None else profile.display_name,
|
||||
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
|
||||
ManagedUserPatch(
|
||||
display_name=body.display_name if body.display_name is not None else profile.display_name,
|
||||
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
|
||||
legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
|
||||
department=body.department if body.department is not None else profile.department,
|
||||
grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
|
||||
phone=body.phone if body.phone is not None else profile.phone,
|
||||
contact_address=(
|
||||
body.contact_address
|
||||
if body.contact_address is not None
|
||||
else profile.contact_address
|
||||
),
|
||||
nickname=body.nickname if body.nickname is not None else profile.nickname,
|
||||
self_introduction=(
|
||||
body.self_introduction
|
||||
if body.self_introduction is not None
|
||||
else profile.self_introduction
|
||||
),
|
||||
avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.post("/me/avatar", response_model=AvatarUploadResponse)
|
||||
async def upload_my_avatar(
|
||||
principal: CurrentPrincipal,
|
||||
file: UploadFile = File(...),
|
||||
) -> AvatarUploadResponse:
|
||||
content_type = (file.content_type or "").strip().lower()
|
||||
content = await file.read(AVATAR_MAX_BYTES + 1)
|
||||
await file.close()
|
||||
if not content:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
|
||||
if len(content) > AVATAR_MAX_BYTES:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
|
||||
ext = _validated_avatar_extension(content_type, content)
|
||||
|
||||
root = _upload_root()
|
||||
for existing in root.glob(f"{principal.user_id}-*.png"):
|
||||
existing.unlink(missing_ok=True)
|
||||
for existing in root.glob(f"{principal.user_id}-*.jpg"):
|
||||
existing.unlink(missing_ok=True)
|
||||
for existing in root.glob(f"{principal.user_id}-*.webp"):
|
||||
existing.unlink(missing_ok=True)
|
||||
|
||||
filename = f"{principal.user_id}-{secrets.token_urlsafe(10)}.{ext}"
|
||||
target = root / filename
|
||||
target.write_bytes(content)
|
||||
avatar_url = f"/uploads/profile-avatars/{filename}"
|
||||
return AvatarUploadResponse(
|
||||
avatar_url=avatar_url,
|
||||
content_type=content_type.split(";", 1)[0],
|
||||
size_bytes=len(content),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/onboarding", response_model=UserProfileResponse)
|
||||
async def complete_onboarding(
|
||||
body: OnboardingRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> UserProfileResponse:
|
||||
if not body.terms_accepted:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="terms_not_accepted")
|
||||
if not body.privacy_accepted:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="privacy_not_accepted")
|
||||
|
||||
display_name = body.nickname.strip()
|
||||
updated = await update_managed_user(
|
||||
principal.user_id,
|
||||
ManagedUserPatch(
|
||||
display_name=display_name,
|
||||
affiliation=body.affiliation,
|
||||
legal_name=body.legal_name,
|
||||
department=body.department,
|
||||
grade_level=body.grade_level,
|
||||
phone=body.phone,
|
||||
contact_address=body.contact_address,
|
||||
nickname=body.nickname,
|
||||
self_introduction=body.self_introduction,
|
||||
avatar_url=body.avatar_url,
|
||||
complete_onboarding=True,
|
||||
terms_version=TERMS_VERSION,
|
||||
privacy_version=PRIVACY_VERSION,
|
||||
),
|
||||
)
|
||||
if updated is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
|
||||
principal.display_name = updated.display_name
|
||||
principal.profile_completed_at = updated.profile_completed_at
|
||||
if (principal.role == Role.LEARNER or principal.super_admin) and principal.consent_at is None:
|
||||
principal.consent_at = await record_user_consent(principal.user_id)
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/support-tickets",
|
||||
response_model=UserSupportTicketResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_support_ticket(
|
||||
body: UserSupportTicketRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> UserSupportTicketResponse:
|
||||
profile = await _profile_for(principal)
|
||||
try:
|
||||
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.support_ticket (
|
||||
reporter_id,
|
||||
reporter_email,
|
||||
reporter_name,
|
||||
reporter_role,
|
||||
category,
|
||||
priority,
|
||||
subject,
|
||||
body,
|
||||
source_path
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9
|
||||
)
|
||||
RETURNING id, category, priority, subject, EXTRACT(EPOCH FROM created_at) AS created_at
|
||||
""",
|
||||
principal.user_id,
|
||||
principal.email,
|
||||
profile.display_name,
|
||||
principal.role.value,
|
||||
body.category,
|
||||
body.priority,
|
||||
body.subject.strip(),
|
||||
body.body.strip(),
|
||||
body.source_path.strip(),
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO audit.audit_log (
|
||||
actor_uid, action, target_kind, target_id, detail
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
principal.user_id,
|
||||
"support_ticket_create",
|
||||
"support_ticket",
|
||||
str(row["id"]),
|
||||
{
|
||||
"category": row["category"],
|
||||
"priority": row["priority"],
|
||||
"source_path": body.source_path.strip(),
|
||||
"subject_present": bool(body.subject.strip()),
|
||||
"body_present": bool(body.body.strip()),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="support ticket persistence unavailable",
|
||||
) from exc
|
||||
return UserSupportTicketResponse(
|
||||
ticket_id=str(row["id"]),
|
||||
status="open",
|
||||
category=row["category"],
|
||||
priority=row["priority"],
|
||||
subject=row["subject"],
|
||||
created_at=float(row["created_at"] or 0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=UserPreferencesResponse)
|
||||
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Client sends JSON controls plus binary audio chunks:
|
|||
|
||||
Server emits:
|
||||
ready -> state(listening) -> state(thinking) -> transcript -> reply
|
||||
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
|
||||
-> state(speaking) -> binary audio chunks -> tts_end -> state(idle)
|
||||
|
||||
When voice is not configured, the route reports degraded state and closes
|
||||
cleanly instead of crashing.
|
||||
|
|
@ -23,11 +23,16 @@ from fastapi.responses import JSONResponse
|
|||
from starlette.websockets import WebSocketState
|
||||
|
||||
from .. import session_persistence, turn_runtime
|
||||
from ..auth_sessions import get_session, user_has_consent
|
||||
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
|
||||
from ..config import settings
|
||||
from ..deps import Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
from ..persona_repository import get_catalog_persona
|
||||
from ..persona_repository import (
|
||||
PersonaVoiceMap,
|
||||
get_catalog_persona,
|
||||
get_persona_voice_map,
|
||||
get_session_voice_map,
|
||||
)
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services import evaluator, orchestrator, state_machine
|
||||
from ..services import voice as voice_svc
|
||||
|
|
@ -72,6 +77,8 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
|
||||
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
||||
return
|
||||
if principal.role != Role.LEARNER and principal.super_admin:
|
||||
principal = principal.with_role(Role.LEARNER)
|
||||
if principal.role != Role.LEARNER:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
|
||||
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
||||
|
|
@ -418,6 +425,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|||
session = await get_session(raw_cookie)
|
||||
if session is None:
|
||||
return None
|
||||
if session.account_status != "approved":
|
||||
return None
|
||||
|
||||
try:
|
||||
role = Role(session.role)
|
||||
|
|
@ -427,10 +436,14 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|||
return Principal(
|
||||
user_id=session.user_id,
|
||||
role=role,
|
||||
admin_access=session.admin_access,
|
||||
super_admin=session.super_admin,
|
||||
account_status=session.account_status,
|
||||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
profile_completed_at=session.profile_completed_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -447,7 +460,11 @@ async def _bind_session(
|
|||
sess, err = await _load_voice_session(session_id, principal)
|
||||
if sess is None:
|
||||
return None, None, err or f"unknown session {session_id}", {}
|
||||
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
|
||||
vp = await _resolve_session_voice(
|
||||
session_id=session_id,
|
||||
persona_code=sess.persona.code,
|
||||
explicit_preset=explicit_preset,
|
||||
)
|
||||
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
|
||||
|
||||
# persona_code session creation is local-dev only. Production uses REST start.
|
||||
|
|
@ -457,6 +474,11 @@ async def _bind_session(
|
|||
persona_code = qp.get("persona_code")
|
||||
if not persona_code:
|
||||
return None, None, "session_id or persona_code query required", {}
|
||||
if (
|
||||
principal.profile_completed_at is None
|
||||
and not await user_onboarding_complete(principal.user_id)
|
||||
):
|
||||
return None, None, "onboarding_required", {}
|
||||
if principal.consent_at is None and not await user_has_consent(principal.user_id):
|
||||
return None, None, "consent_required", {}
|
||||
try:
|
||||
|
|
@ -494,7 +516,12 @@ async def _bind_session(
|
|||
session_source = "runtime"
|
||||
else:
|
||||
store.put(sess)
|
||||
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
|
||||
vp = await _resolve_catalog_voice(
|
||||
persona_id=catalog_persona.persona_id,
|
||||
version=catalog_persona.version,
|
||||
persona_code=card.code,
|
||||
explicit_preset=explicit_preset,
|
||||
)
|
||||
degraded_reasons: list[str] = []
|
||||
if catalog_persona.degraded:
|
||||
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
|
||||
|
|
@ -509,6 +536,54 @@ async def _bind_session(
|
|||
return sess.session_id, vp, None, bind_meta
|
||||
|
||||
|
||||
async def _resolve_session_voice(
|
||||
*,
|
||||
session_id: str,
|
||||
persona_code: str,
|
||||
explicit_preset: str | None,
|
||||
) -> VoicePreset:
|
||||
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
|
||||
if explicit_preset:
|
||||
return fallback
|
||||
try:
|
||||
voice_map = await get_session_voice_map(session_id)
|
||||
except Exception:
|
||||
return fallback
|
||||
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
|
||||
|
||||
|
||||
async def _resolve_catalog_voice(
|
||||
*,
|
||||
persona_id: str | None,
|
||||
version: int | None,
|
||||
persona_code: str,
|
||||
explicit_preset: str | None,
|
||||
) -> VoicePreset:
|
||||
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
|
||||
if explicit_preset:
|
||||
return fallback
|
||||
try:
|
||||
voice_map = await get_persona_voice_map(persona_id=persona_id, version=version)
|
||||
except Exception:
|
||||
return fallback
|
||||
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
|
||||
|
||||
|
||||
def _voice_from_map(
|
||||
voice_map: PersonaVoiceMap | None,
|
||||
*,
|
||||
persona_code: str,
|
||||
) -> VoicePreset | None:
|
||||
if voice_map is None:
|
||||
return None
|
||||
return voice_svc.resolve_voice_from_map(
|
||||
provider=voice_map.provider,
|
||||
voice_id=voice_map.voice_id,
|
||||
base_params=voice_map.base_params,
|
||||
persona_code=persona_code,
|
||||
)
|
||||
|
||||
|
||||
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
|
||||
"""Map the browser audio format to upload metadata."""
|
||||
f = (fmt or "webm").lower().lstrip(".")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue