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

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