운영 지표와 지원 요청 저장소 추가
This commit is contained in:
parent
50fa4ad432
commit
e7ebb38177
20 changed files with 3038 additions and 39 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue