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