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

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

@ -6,6 +6,7 @@ import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
@ -28,7 +29,7 @@ from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import voice_service
from ..services import rag
from ..services import evaluator, rag
from ..store import store
router = APIRouter(prefix="/admin", tags=["admin"])
@ -73,6 +74,14 @@ class AdminUsageBreakdown(BaseModel):
cost_usd: float
class AdminUsageDailyCost(BaseModel):
day: str
turns: int
tokens_in: int
tokens_out: int
cost_usd: float
class AdminUsageBudget(BaseModel):
limit_usd: float
used_ratio: float
@ -80,6 +89,17 @@ class AdminUsageBudget(BaseModel):
status: UsageBudgetStatus
class AdminUsageEvaluatorCache(BaseModel):
enabled: bool
entries: int
hits: int
misses: int
stores: int
evictions: int
requests: int
hit_rate: float
class AdminUsageResponse(BaseModel):
source: Literal["database", "server_session_registry"]
durable: bool
@ -91,7 +111,9 @@ class AdminUsageResponse(BaseModel):
tokens_out: int
cost_usd: float
budget: AdminUsageBudget
evaluator_cache: AdminUsageEvaluatorCache
by_provider: list[AdminUsageBreakdown]
daily_cost: list[AdminUsageDailyCost] = Field(default_factory=list)
class AdminHealthEvent(BaseModel):
@ -147,6 +169,11 @@ class AdminSupportTicketResponse(BaseModel):
subject: str
body: str
source_path: str
fingerprint: str
parent_ticket_id: str | None = None
duplicate_count: int = 0
duplicate_parent_candidate_id: str | None = None
child_ticket_count: int = 0
assigned_group: str
resolution_note: str
created_at: float
@ -195,6 +222,7 @@ class AdminTicketPatch(BaseModel):
priority: TicketPriority | None = None
assigned_group: str | None = Field(default=None, max_length=120)
resolution_note: str | None = Field(default=None, max_length=2000)
parent_ticket_id: str | None = Field(default=None, max_length=36)
class AdminUserResponse(BaseModel):
@ -298,6 +326,29 @@ def _usage_budget(cost_usd: float) -> AdminUsageBudget:
)
def _usage_evaluator_cache() -> AdminUsageEvaluatorCache:
stats = evaluator.evaluator_semantic_cache_stats()
hits = _safe_usage_int(stats.get("hits", 0))
misses = _safe_usage_int(stats.get("misses", 0))
requests = hits + misses
hit_rate = round(hits / requests, 4) if requests else 0.0
enabled = (
bool(settings.evaluator_semantic_cache_enabled)
and settings.evaluator_semantic_cache_ttl_seconds > 0
and settings.evaluator_semantic_cache_max_entries > 0
)
return AdminUsageEvaluatorCache(
enabled=enabled,
entries=_safe_usage_int(stats.get("entries", 0)),
hits=hits,
misses=misses,
stores=_safe_usage_int(stats.get("stores", 0)),
evictions=_safe_usage_int(stats.get("evictions", 0)),
requests=requests,
hit_rate=hit_rate,
)
async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
metrics = RuntimeHealthMetrics()
@ -401,6 +452,27 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
""",
window_days,
)
daily_rows = await conn.fetch(
"""
SELECT
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
COUNT(*) AS turns,
COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
AND speaker = 'client'
AND (
llm_provider IS NOT NULL OR model IS NOT NULL
OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
OR cost_usd IS NOT NULL
)
GROUP BY 1
ORDER BY 1
""",
window_days,
)
total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6)
return AdminUsageResponse(
@ -414,6 +486,7 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0),
cost_usd=total_cost,
budget=_usage_budget(total_cost),
evaluator_cache=_usage_evaluator_cache(),
by_provider=[
AdminUsageBreakdown(
provider=str(row["provider"] or "unknown"),
@ -425,21 +498,32 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
)
for row in rows
],
daily_cost=[
AdminUsageDailyCost(
day=str(row["day"]),
turns=_safe_usage_int(row["turns"]),
tokens_in=_safe_usage_int(row["tokens_in"]),
tokens_out=_safe_usage_int(row["tokens_out"]),
cost_usd=round(_decimal_to_float(row["cost_usd"]), 6),
)
for row in daily_rows
],
)
async def _record_health_events(
*,
principal: Principal,
principal: Principal | None,
overall_status: HealthStatus,
environment: str,
engine_mode: str,
services: list[AdminServiceHealth],
) -> None:
) -> int:
if not services:
return
return 0
captured_by = principal.user_id if principal is not None else None
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
async with acquire(role="admin", user_id=captured_by) as conn:
await conn.executemany(
"""
INSERT INTO app.admin_health_event (
@ -467,14 +551,15 @@ async def _record_health_events(
service.detail,
service.metric,
service.load,
principal.user_id,
captured_by,
)
for service in services
],
)
return len(services)
except Exception:
# 헬스 화면 자체가 장애 확인 경로라, 이력 적재 실패가 응답을 막으면 안 된다.
return
return 0
async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
@ -498,23 +583,74 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
""",
window_hours,
)
rollup_rows = await conn.fetch(
"""
SELECT
r.service_key,
r.service_name,
r.sample_count,
r.ok_samples,
r.degraded_samples,
r.down_samples,
r.latest_status,
r.last_observed_at,
r.last_down_at
FROM app.admin_health_daily_rollup AS r
WHERE r.rollup_date >= (now() - ($1::int * interval '1 hour'))::date
AND r.rollup_date < current_date
AND NOT EXISTS (
SELECT 1
FROM app.admin_health_event AS e
WHERE e.observed_at::date = r.rollup_date
AND e.environment = r.environment
AND e.engine_mode = r.engine_mode
AND e.service_key = r.service_key
)
ORDER BY r.last_observed_at DESC, r.service_key
LIMIT 1000
""",
window_hours,
)
events = [_health_event_from_row(row) for row in rows]
service_buckets: dict[str, AdminUptimeServiceSummary] = {}
for event in events:
current = service_buckets.get(event.service_key)
def ensure_bucket(
*,
service_key: str,
service_name: str,
latest_status: HealthStatus,
latest_observed_at: float | None,
) -> AdminUptimeServiceSummary:
current = service_buckets.get(service_key)
if current is None:
current = AdminUptimeServiceSummary(
service_key=event.service_key,
service_name=event.service_name,
service_key=service_key,
service_name=service_name,
samples=0,
ok_samples=0,
degraded_samples=0,
down_samples=0,
latest_status=event.service_status,
latest_observed_at=event.observed_at,
latest_status=latest_status,
latest_observed_at=latest_observed_at,
)
service_buckets[event.service_key] = current
service_buckets[service_key] = current
return current
if latest_observed_at is not None and (
current.latest_observed_at is None
or latest_observed_at > current.latest_observed_at
):
current.latest_status = latest_status
current.latest_observed_at = latest_observed_at
return current
for event in events:
current = ensure_bucket(
service_key=event.service_key,
service_name=event.service_name,
latest_status=event.service_status,
latest_observed_at=event.observed_at,
)
current.samples += 1
if event.service_status == "ok":
current.ok_samples += 1
@ -523,9 +659,29 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
else:
current.down_samples += 1
ok_samples = sum(1 for event in events if event.service_status == "ok")
sample_count = len(events)
rollup_last_down: list[float] = []
for row in rollup_rows:
latest_observed_at = _row_ts(row["last_observed_at"])
current = ensure_bucket(
service_key=str(row["service_key"]),
service_name=str(row["service_name"]),
latest_status=row["latest_status"],
latest_observed_at=latest_observed_at,
)
current.samples += int(row["sample_count"] or 0)
current.ok_samples += int(row["ok_samples"] or 0)
current.degraded_samples += int(row["degraded_samples"] or 0)
current.down_samples += int(row["down_samples"] or 0)
last_down = _row_ts(row["last_down_at"])
if last_down is not None:
rollup_last_down.append(last_down)
ok_samples = sum(1 for event in events if event.service_status == "ok") + sum(
int(row["ok_samples"] or 0) for row in rollup_rows
)
sample_count = len(events) + sum(int(row["sample_count"] or 0) for row in rollup_rows)
down_times = [event.observed_at for event in events if event.service_status == "down"]
down_times.extend(rollup_last_down)
return AdminUptimeResponse(
source="database",
durable=True,
@ -533,8 +689,10 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
generated_at=time.time(),
sample_count=sample_count,
ok_ratio=round(ok_samples / sample_count, 4) if sample_count else 0.0,
degraded_events=sum(1 for event in events if event.service_status == "degraded"),
down_events=sum(1 for event in events if event.service_status == "down"),
degraded_events=sum(1 for event in events if event.service_status == "degraded")
+ sum(int(row["degraded_samples"] or 0) for row in rollup_rows),
down_events=sum(1 for event in events if event.service_status == "down")
+ sum(int(row["down_samples"] or 0) for row in rollup_rows),
last_down_at=max(down_times) if down_times else None,
services=sorted(
service_buckets.values(),
@ -573,14 +731,32 @@ async def _tickets_from_database(
t.subject,
t.body,
t.source_path,
t.fingerprint,
t.parent_ticket_id,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
dup.parent_candidate_id AS duplicate_parent_candidate_id,
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
FROM app.support_ticket
WHERE fingerprint <> ''
AND fingerprint = t.fingerprint
) AS dup ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS child_ticket_count
FROM app.support_ticket
WHERE parent_ticket_id = t.id
) AS child ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
@ -652,12 +828,14 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
tokens_out = 0
cost_usd = 0.0
buckets: dict[tuple[str, str], dict[str, int | float]] = {}
daily_buckets: dict[str, dict[str, int | float]] = {}
for sess in store.list():
for turn in getattr(sess, "turns", []) or []:
if getattr(turn, "speaker", "") != "client":
continue
if float(getattr(turn, "created_at", 0.0) or 0.0) < window_start:
created_at = float(getattr(turn, "created_at", 0.0) or 0.0)
if created_at < window_start:
continue
total_turns += 1
provider = str(getattr(turn, "llm_provider", None) or "unknown")
@ -687,6 +865,15 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in
bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out
bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost
day = datetime.fromtimestamp(created_at, timezone.utc).strftime("%Y-%m-%d")
daily_bucket = daily_buckets.setdefault(
day,
{"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0},
)
daily_bucket["turns"] = int(daily_bucket["turns"]) + 1
daily_bucket["tokens_in"] = int(daily_bucket["tokens_in"]) + turn_tokens_in
daily_bucket["tokens_out"] = int(daily_bucket["tokens_out"]) + turn_tokens_out
daily_bucket["cost_usd"] = float(daily_bucket["cost_usd"]) + turn_cost
by_provider = [
AdminUsageBreakdown(
@ -719,7 +906,18 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
tokens_out=tokens_out,
cost_usd=total_cost,
budget=_usage_budget(total_cost),
evaluator_cache=_usage_evaluator_cache(),
by_provider=by_provider,
daily_cost=[
AdminUsageDailyCost(
day=day,
turns=int(values["turns"]),
tokens_in=int(values["tokens_in"]),
tokens_out=int(values["tokens_out"]),
cost_usd=round(float(values["cost_usd"]), 6),
)
for day, values in sorted(daily_buckets.items())
],
)
@ -850,6 +1048,19 @@ def _ticket_from_row(row) -> AdminSupportTicketResponse:
subject=row["subject"],
body=row["body"],
source_path=row["source_path"],
fingerprint=_row_value(row, "fingerprint", "") or "",
parent_ticket_id=(
str(_row_value(row, "parent_ticket_id"))
if _row_value(row, "parent_ticket_id") is not None
else None
),
duplicate_count=int(_row_value(row, "duplicate_count", 0) or 0),
duplicate_parent_candidate_id=(
str(_row_value(row, "duplicate_parent_candidate_id"))
if _row_value(row, "duplicate_parent_candidate_id") is not None
else None
),
child_ticket_count=int(_row_value(row, "child_ticket_count", 0) or 0),
assigned_group=row["assigned_group"],
resolution_note=row["resolution_note"],
created_at=_row_ts(row["created_at"]) or 0.0,
@ -925,12 +1136,31 @@ def _unavailable_tickets() -> AdminTicketsResponse:
)
def _normalize_parent_ticket_id(value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
if not stripped:
return None
try:
return str(UUID(stripped))
except ValueError as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="invalid parent ticket id",
) from exc
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"):
for field in ("status", "priority", "assigned_group", "parent_ticket_id"):
before = _row_value(old_row, field, "")
after = _row_value(new_row, field, "")
if before is not None:
before = str(before)
if after is not None:
after = str(after)
if before != after:
changed_fields.append(field)
detail[field] = {"from": before, "to": after}
@ -1060,9 +1290,7 @@ async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
)
@router.get("/health", response_model=AdminHealthResponse)
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
"""Return operational health from live backend checks."""
async def _build_admin_health_response() -> AdminHealthResponse:
current_engine = await _current_engine_config()
db_ok = await healthcheck()
engine_started = time.perf_counter()
@ -1148,13 +1376,28 @@ async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
engine_mode=current_engine.engine_mode,
services=services,
)
await _record_health_events(
return response
async def record_admin_health_sample(
*, principal: Principal | None = None
) -> tuple[AdminHealthResponse, int]:
"""Collect and persist one synthetic/admin health sample."""
response = await _build_admin_health_response()
recorded_count = await _record_health_events(
principal=principal,
overall_status=response.status,
environment=response.environment,
engine_mode=response.engine_mode,
services=response.services,
)
return response, recorded_count
@router.get("/health", response_model=AdminHealthResponse)
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
"""Return operational health from live backend checks."""
response, _ = await record_admin_health_sample(principal=principal)
return response
@ -1229,7 +1472,8 @@ async def patch_ticket(
status,
source_path,
assigned_group,
resolution_note
resolution_note,
parent_ticket_id
FROM app.support_ticket
WHERE id = $1::uuid
""",
@ -1237,6 +1481,44 @@ async def patch_ticket(
)
if old_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
parent_specified = "parent_ticket_id" in body.model_fields_set
next_parent_id = (
_normalize_parent_ticket_id(body.parent_ticket_id)
if parent_specified
else None
)
if parent_specified and next_parent_id is not None:
if next_parent_id == str(UUID(ticket_id)):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="ticket cannot be its own parent",
)
parent_check = await conn.fetchrow(
"""
WITH RECURSIVE ancestors AS (
SELECT id, parent_ticket_id
FROM app.support_ticket
WHERE id = $1::uuid
UNION ALL
SELECT t.id, t.parent_ticket_id
FROM app.support_ticket AS t
JOIN ancestors AS a ON t.id = a.parent_ticket_id
WHERE a.parent_ticket_id IS NOT NULL
)
SELECT
EXISTS (SELECT 1 FROM ancestors) AS parent_exists,
EXISTS (SELECT 1 FROM ancestors WHERE id = $2::uuid) AS creates_cycle
""",
next_parent_id,
ticket_id,
)
if not parent_check or not parent_check["parent_exists"]:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="parent ticket not found")
if parent_check["creates_cycle"]:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="ticket parent would create a cycle",
)
row = await conn.fetchrow(
"""
UPDATE app.support_ticket SET
@ -1244,6 +1526,10 @@ async def patch_ticket(
priority = COALESCE($3, priority),
assigned_group = COALESCE($4, assigned_group),
resolution_note = COALESCE($5, resolution_note),
parent_ticket_id = CASE
WHEN $6::bool THEN $7::uuid
ELSE parent_ticket_id
END,
resolved_at = CASE
WHEN COALESCE($2, status) IN ('resolved', 'closed')
THEN COALESCE(resolved_at, now())
@ -1266,6 +1552,8 @@ async def patch_ticket(
subject,
body,
source_path,
fingerprint,
parent_ticket_id,
assigned_group,
resolution_note,
created_at,
@ -1277,6 +1565,8 @@ async def patch_ticket(
body.priority,
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,
parent_specified,
next_parent_id,
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
@ -1302,14 +1592,32 @@ async def patch_ticket(
t.subject,
t.body,
t.source_path,
t.fingerprint,
t.parent_ticket_id,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(dup.duplicate_count, 0) AS duplicate_count,
dup.parent_candidate_id AS duplicate_parent_candidate_id,
COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT
count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
(array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
FROM app.support_ticket
WHERE fingerprint <> ''
AND fingerprint = t.fingerprint
) AS dup ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS child_ticket_count
FROM app.support_ticket
WHERE parent_ticket_id = t.id
) AS child ON TRUE
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log

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: