운영 지표와 지원 요청 저장소 추가

This commit is contained in:
Yun Chan 2026-06-28 20:13:06 +09:00
parent 50fa4ad432
commit e7ebb38177
20 changed files with 3038 additions and 39 deletions

View file

@ -3,11 +3,12 @@
from __future__ import annotations
import secrets
import time
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from fastapi import APIRouter, File, HTTPException, Query, UploadFile, status
from pydantic import BaseModel, Field, model_validator
from ..auth_types import RoleName
from ..auth_sessions import (
@ -21,6 +22,7 @@ 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.support_tickets import support_ticket_fingerprint
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
router = APIRouter(prefix="/users", tags=["users"])
@ -33,6 +35,14 @@ AVATAR_CONTENT_TYPES = {
"image/jpeg": ("jpg", b"\xff\xd8\xff"),
"image/webp": ("webp", b"RIFF"),
}
DEFAULT_PREPOST_PILOT_ID = "phase3-pilot-draft"
DEFAULT_PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"
PREPOST_MEASURE_NAMES = (
"self_efficacy",
"skill_proficiency",
"training_satisfaction",
)
PREPOST_TIMEPOINTS = ("pre", "post")
TERMS_BODY = """Vignette 서비스 이용약관 초안
@ -209,6 +219,9 @@ TicketCategory = Literal[
"other",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
PrepostMeasureName = Literal["self_efficacy", "skill_proficiency", "training_satisfaction"]
PrepostTimepoint = Literal["pre", "post"]
class UserSupportTicketRequest(BaseModel):
@ -228,6 +241,76 @@ class UserSupportTicketResponse(BaseModel):
created_at: float
class UserSupportTicketListItem(BaseModel):
ticket_id: str
status: TicketStatus
category: TicketCategory
priority: TicketPriority
subject: str
source_path: str
assigned_group: str
resolution_note: str
created_at: float
updated_at: float
resolved_at: float | None = None
class UserSupportTicketsResponse(BaseModel):
source: Literal["database"]
durable: bool
generated_at: float
tickets: list[UserSupportTicketListItem]
class UserPrepostMeasureRequest(BaseModel):
pilot_id: str = Field(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80)
measure_name: PrepostMeasureName
timepoint: PrepostTimepoint
raw_score: float
min_score: float = 1.0
max_score: float = 5.0
instrument_version: str = Field(
default=DEFAULT_PREPOST_INSTRUMENT_VERSION,
min_length=1,
max_length=120,
)
item_count: int = Field(default=1, ge=1, le=80)
@model_validator(mode="after")
def validate_score_range(self) -> "UserPrepostMeasureRequest":
if self.max_score <= self.min_score:
raise ValueError("max_score must be greater than min_score")
if self.raw_score < self.min_score or self.raw_score > self.max_score:
raise ValueError("raw_score must be within min_score and max_score")
return self
class UserPrepostMeasureItem(BaseModel):
measure_id: str
pilot_id: str
measure_name: PrepostMeasureName
timepoint: PrepostTimepoint
raw_score: float
min_score: float
max_score: float
normalized_score: float
instrument_version: str
item_count: int
collected_at: float
updated_at: float
class UserPrepostMeasuresResponse(BaseModel):
source: Literal["database"]
durable: bool
generated_at: float
pilot_id: str
required_measure_names: list[PrepostMeasureName]
required_timepoints: list[PrepostTimepoint]
complete_measure_pairs: int
measures: list[UserPrepostMeasureItem]
_preferences: dict[str, UserPreferencesResponse] = {}
VOICE_PRESET_META = {
@ -514,6 +597,15 @@ async def create_support_ticket(
profile = await _profile_for(principal)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
subject = body.subject.strip()
ticket_body = body.body.strip()
source_path = body.source_path.strip()
fingerprint = support_ticket_fingerprint(
category=body.category,
subject=subject,
body=ticket_body,
source_path=source_path,
)
row = await conn.fetchrow(
"""
INSERT INTO app.support_ticket (
@ -525,7 +617,8 @@ async def create_support_ticket(
priority,
subject,
body,
source_path
source_path,
fingerprint
)
VALUES (
$1::uuid,
@ -536,7 +629,8 @@ async def create_support_ticket(
$6,
$7,
$8,
$9
$9,
$10
)
RETURNING id, category, priority, subject, EXTRACT(EPOCH FROM created_at) AS created_at
""",
@ -546,9 +640,10 @@ async def create_support_ticket(
principal.role.value,
body.category,
body.priority,
body.subject.strip(),
body.body.strip(),
body.source_path.strip(),
subject,
ticket_body,
source_path,
fingerprint,
)
await conn.execute(
"""
@ -564,9 +659,10 @@ async def create_support_ticket(
{
"category": row["category"],
"priority": row["priority"],
"source_path": body.source_path.strip(),
"subject_present": bool(body.subject.strip()),
"body_present": bool(body.body.strip()),
"source_path": source_path,
"fingerprint": fingerprint,
"subject_present": bool(subject),
"body_present": bool(ticket_body),
},
)
except Exception as exc:
@ -584,6 +680,244 @@ async def create_support_ticket(
)
async def _support_tickets_for_user(
principal: CurrentPrincipal,
*,
limit: int = 20,
) -> UserSupportTicketsResponse:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
rows = await conn.fetch(
"""
SELECT
id,
category,
priority,
status,
subject,
source_path,
assigned_group,
resolution_note,
EXTRACT(EPOCH FROM created_at) AS created_at,
EXTRACT(EPOCH FROM updated_at) AS updated_at,
EXTRACT(EPOCH FROM resolved_at) AS resolved_at
FROM app.support_ticket
WHERE reporter_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2
""",
principal.user_id,
limit,
)
return UserSupportTicketsResponse(
source="database",
durable=True,
generated_at=time.time(),
tickets=[
UserSupportTicketListItem(
ticket_id=str(row["id"]),
status=row["status"],
category=row["category"],
priority=row["priority"],
subject=row["subject"],
source_path=row["source_path"] or "",
assigned_group=row["assigned_group"] or "",
resolution_note=row["resolution_note"] or "",
created_at=float(row["created_at"] or 0.0),
updated_at=float(row["updated_at"] or 0.0),
resolved_at=float(row["resolved_at"]) if row["resolved_at"] is not None else None,
)
for row in rows
],
)
@router.get("/support-tickets", response_model=UserSupportTicketsResponse)
async def list_my_support_tickets(
principal: CurrentPrincipal,
) -> UserSupportTicketsResponse:
try:
return await _support_tickets_for_user(principal)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="support ticket persistence unavailable",
) from exc
def _normalized_prepost_score(raw_score: float, min_score: float, max_score: float) -> float:
if max_score <= min_score:
return 0.0
return round(((raw_score - min_score) / (max_score - min_score)) * 100.0, 3)
def _prepost_measure_from_row(row) -> UserPrepostMeasureItem:
raw_score = float(row["raw_score"])
min_score = float(row["min_score"])
max_score = float(row["max_score"])
return UserPrepostMeasureItem(
measure_id=str(row["id"]),
pilot_id=row["pilot_id"],
measure_name=row["measure_name"],
timepoint=row["timepoint"],
raw_score=raw_score,
min_score=min_score,
max_score=max_score,
normalized_score=_normalized_prepost_score(raw_score, min_score, max_score),
instrument_version=row["instrument_version"],
item_count=int(row["item_count"]),
collected_at=float(row["collected_at"] or 0.0),
updated_at=float(row["updated_at"] or 0.0),
)
async def _prepost_measures_for_user(
principal: CurrentPrincipal,
*,
pilot_id: str = DEFAULT_PREPOST_PILOT_ID,
) -> UserPrepostMeasuresResponse:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
rows = await conn.fetch(
"""
SELECT
id,
pilot_id,
measure_name,
timepoint,
raw_score,
min_score,
max_score,
instrument_version,
item_count,
EXTRACT(EPOCH FROM collected_at) AS collected_at,
EXTRACT(EPOCH FROM updated_at) AS updated_at
FROM app.learner_prepost_measure
WHERE learner_id = $1::uuid
AND pilot_id = $2
ORDER BY measure_name, timepoint, instrument_version
""",
principal.user_id,
pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID,
)
measures = [_prepost_measure_from_row(row) for row in rows]
pairs = {
item.measure_name
for item in measures
if {m.timepoint for m in measures if m.measure_name == item.measure_name} == {"pre", "post"}
}
return UserPrepostMeasuresResponse(
source="database",
durable=True,
generated_at=time.time(),
pilot_id=pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID,
required_measure_names=list(PREPOST_MEASURE_NAMES),
required_timepoints=list(PREPOST_TIMEPOINTS),
complete_measure_pairs=len(pairs),
measures=measures,
)
@router.get("/me/prepost-measures", response_model=UserPrepostMeasuresResponse)
async def list_my_prepost_measures(
principal: CurrentPrincipal,
pilot_id: str = Query(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80),
) -> UserPrepostMeasuresResponse:
try:
return await _prepost_measures_for_user(principal, pilot_id=pilot_id)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="prepost measure persistence unavailable",
) from exc
@router.put("/me/prepost-measures", response_model=UserPrepostMeasureItem)
async def upsert_my_prepost_measure(
body: UserPrepostMeasureRequest,
principal: CurrentPrincipal,
) -> UserPrepostMeasureItem:
pilot_id = body.pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID
instrument_version = body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.learner_prepost_measure (
learner_id,
pilot_id,
measure_name,
timepoint,
raw_score,
min_score,
max_score,
instrument_version,
item_count
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (
learner_id,
pilot_id,
measure_name,
timepoint,
instrument_version
)
DO UPDATE SET
raw_score = EXCLUDED.raw_score,
min_score = EXCLUDED.min_score,
max_score = EXCLUDED.max_score,
item_count = EXCLUDED.item_count,
collected_at = now(),
updated_at = now()
RETURNING
id,
pilot_id,
measure_name,
timepoint,
raw_score,
min_score,
max_score,
instrument_version,
item_count,
EXTRACT(EPOCH FROM collected_at) AS collected_at,
EXTRACT(EPOCH FROM updated_at) AS updated_at
""",
principal.user_id,
pilot_id,
body.measure_name,
body.timepoint,
body.raw_score,
body.min_score,
body.max_score,
instrument_version,
body.item_count,
)
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,
"prepost_measure_upsert",
"learner_prepost_measure",
str(row["id"]),
{
"pilot_id": pilot_id,
"measure_name": body.measure_name,
"timepoint": body.timepoint,
"instrument_version": instrument_version,
"item_count": body.item_count,
"score_recorded": True,
},
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="prepost measure persistence unavailable",
) from exc
return _prepost_measure_from_row(row)
@router.get("/me/preferences", response_model=UserPreferencesResponse)
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
try: