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

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

@ -4,7 +4,7 @@ from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .routes import admin as admin_routes
@ -26,6 +26,9 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
case = self
class Conn:
def __init__(self) -> None:
self.fetch_calls = 0
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 2,
@ -36,6 +39,18 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
}
async def fetch(self, query, *args, **kwargs):
self.fetch_calls += 1
if self.fetch_calls == 2:
case.assertIn("date_trunc('day', created_at)", query)
return [
{
"day": "2026-06-28",
"turns": 1,
"tokens_in": 11,
"tokens_out": 13,
"cost_usd": 0.0042,
}
]
case.assertIn(
"COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC",
query,
@ -52,7 +67,11 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
}
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())), patch.object(
admin_routes.evaluator,
"evaluator_semantic_cache_stats",
return_value={"hits": 3, "misses": 1, "stores": 2, "evictions": 0, "entries": 2},
):
usage = await admin_routes._usage_from_database(window_days=7)
self.assertTrue(usage.durable)
@ -60,12 +79,23 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(usage.total_turns, 2)
self.assertEqual(usage.metered_turns, 1)
self.assertEqual(usage.by_provider[0].provider, "claude_cli")
self.assertTrue(usage.evaluator_cache.enabled)
self.assertEqual(usage.evaluator_cache.requests, 4)
self.assertEqual(usage.evaluator_cache.hit_rate, 0.75)
self.assertEqual(usage.daily_cost[0].day, "2026-06-28")
self.assertEqual(usage.daily_cost[0].cost_usd, 0.0042)
async def test_uptime_from_database_aggregates_health_samples(self) -> None:
now = datetime.now(timezone.utc)
class Conn:
def __init__(self):
self.calls = 0
async def fetch(self, *args, **kwargs):
self.calls += 1
if self.calls == 2:
return []
return [
{
"id": 3,
@ -113,6 +143,113 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNotNone(uptime.last_down_at)
self.assertEqual({item.service_key for item in uptime.services}, {"db", "engine", "voice"})
async def test_uptime_from_database_combines_raw_events_and_daily_rollups(self) -> None:
now = datetime.now(timezone.utc)
class Conn:
def __init__(self):
self.calls = 0
async def fetch(self, *args, **kwargs):
self.calls += 1
if self.calls == 1:
return [
{
"id": 10,
"observed_at": now,
"overall_status": "ok",
"service_key": "engine",
"service_name": "응답 생성",
"service_status": "ok",
"detail": "ready",
"metric": "21ms",
"load": 0.02,
}
]
return [
{
"service_key": "engine",
"service_name": "응답 생성",
"sample_count": 3,
"ok_samples": 1,
"degraded_samples": 1,
"down_samples": 1,
"latest_status": "down",
"last_observed_at": now - timedelta(days=2),
"last_down_at": now - timedelta(days=2, hours=1),
}
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
uptime = await admin_routes._uptime_from_database(window_hours=168)
self.assertEqual(uptime.sample_count, 4)
self.assertEqual(uptime.down_events, 1)
self.assertEqual(uptime.degraded_events, 1)
self.assertAlmostEqual(uptime.ok_ratio, 0.5, places=4)
self.assertIsNotNone(uptime.last_down_at)
engine = next(service for service in uptime.services if service.service_key == "engine")
self.assertEqual(engine.samples, 4)
self.assertEqual(engine.latest_status, "ok")
self.assertEqual(len(uptime.events), 1)
async def test_record_health_events_allows_synthetic_capture_without_actor(self) -> None:
calls = []
class Conn:
async def executemany(self, query, rows, **kwargs):
calls.append((query, rows))
service = admin_routes.AdminServiceHealth(
key="engine",
name="응답 생성",
status="ok",
detail="ready",
metric="12ms",
load=0.01,
)
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
recorded = await admin_routes._record_health_events(
principal=None,
overall_status="ok",
environment="dev",
engine_mode="claude_cli",
services=[service],
)
self.assertEqual(recorded, 1)
query, rows = calls[0]
self.assertIn("INSERT INTO app.admin_health_event", query)
self.assertIn("captured_by", query)
self.assertIsNone(rows[0][-1])
async def test_record_admin_health_sample_reuses_health_contract(self) -> None:
engine_config = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
durable=True,
source="database",
)
recorder = AsyncMock(return_value=5)
with (
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=True)),
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
patch.object(admin_routes.voice_service, "is_available", return_value=True),
patch.object(admin_routes, "_runtime_health_metrics", AsyncMock(return_value=admin_routes.RuntimeHealthMetrics())),
patch.object(admin_routes, "_record_health_events", recorder),
):
health, recorded = await admin_routes.record_admin_health_sample(principal=None)
self.assertEqual(recorded, 5)
self.assertEqual(health.status, "ok")
self.assertEqual(health.engine_mode, "claude_cli")
recorder.assert_awaited_once()
self.assertIsNone(recorder.await_args.kwargs["principal"])
self.assertEqual(len(recorder.await_args.kwargs["services"]), 5)
async def test_tickets_from_database_returns_summary_without_synthetic_rows(self) -> None:
now = datetime.now(timezone.utc)
@ -289,6 +426,108 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("body", detail)
self.assertNotIn("subject", detail)
async def test_patch_ticket_links_manual_duplicate_parent(self) -> None:
now = datetime.now(timezone.utc)
ticket_id = "00000000-0000-0000-0000-000000000101"
parent_id = "00000000-0000-0000-0000-000000000102"
audit_calls: list[tuple[str, tuple[object, ...]]] = []
class Conn:
def __init__(self):
self.fetchrow_calls = 0
async def fetchrow(self, query, *args, **kwargs):
self.fetchrow_calls += 1
if self.fetchrow_calls == 1:
return {
"id": ticket_id,
"category": "voice_browser",
"priority": "normal",
"status": "open",
"source_path": "/learn/session/voice",
"assigned_group": "",
"resolution_note": "",
"parent_ticket_id": None,
}
if self.fetchrow_calls == 2:
case.assertIn("WITH RECURSIVE ancestors", query)
return {"parent_exists": True, "creates_cycle": False}
if self.fetchrow_calls == 3:
case.assertIn("parent_ticket_id = CASE", query)
return {
"id": ticket_id,
"reporter_id": "00000000-0000-0000-0000-000000000201",
"reporter_email": "learner@hs.ac.kr",
"reporter_name": "Learner",
"reporter_role": "learner",
"category": "voice_browser",
"priority": "normal",
"status": "open",
"subject": "마이크 권한 오류",
"body": "브라우저 마이크 권한 실패",
"source_path": "/learn/session/voice",
"fingerprint": "dup-fingerprint",
"parent_ticket_id": parent_id,
"assigned_group": "",
"resolution_note": "",
"created_at": now,
"updated_at": now,
"resolved_at": None,
}
return {
"id": ticket_id,
"reporter_id": "00000000-0000-0000-0000-000000000201",
"reporter_email": "learner@hs.ac.kr",
"reporter_name": "Learner",
"reporter_role": "learner",
"category": "voice_browser",
"priority": "normal",
"status": "open",
"subject": "마이크 권한 오류",
"body": "브라우저 마이크 권한 실패",
"source_path": "/learn/session/voice",
"fingerprint": "dup-fingerprint",
"parent_ticket_id": parent_id,
"duplicate_count": 1,
"duplicate_parent_candidate_id": parent_id,
"child_ticket_count": 0,
"assigned_group": "",
"resolution_note": "",
"created_at": now,
"updated_at": now,
"resolved_at": None,
"event_count": 1,
"last_event_at": now,
}
async def execute(self, query, *args, **kwargs):
audit_calls.append((query, args))
case = self
principal = Principal(
user_id="00000000-0000-0000-0000-000000000301",
role=Role.ADMIN,
admin_access=True,
email="admin@hs.ac.kr",
display_name="Admin",
)
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
updated = await admin_routes.patch_ticket(
ticket_id,
admin_routes.AdminTicketPatch(parent_ticket_id=parent_id),
principal,
)
self.assertEqual(updated.parent_ticket_id, parent_id)
self.assertEqual(updated.duplicate_count, 1)
self.assertEqual(updated.event_count, 1)
self.assertEqual(len(audit_calls), 1)
detail = audit_calls[0][1][4]
self.assertEqual(detail["changed_fields"], ["parent_ticket_id"])
self.assertEqual(detail["parent_ticket_id"]["to"], parent_id)
self.assertNotIn("body", detail)
self.assertNotIn("subject", detail)
def test_admin_ops_schema_enables_rls(self) -> None:
from pathlib import Path
@ -297,5 +536,19 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
encoding="utf-8"
)
self.assertIn("ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("CREATE TABLE IF NOT EXISTS app.admin_health_daily_rollup", schema)
self.assertIn("last_down_at TIMESTAMPTZ", schema)
self.assertIn("ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("CREATE POLICY p_admin_health_event_delete", schema)
self.assertIn("CREATE POLICY p_admin_health_daily_rollup_select", schema)
self.assertIn("CREATE POLICY p_admin_health_daily_rollup_update", schema)
self.assertNotIn("CREATE POLICY p_admin_health_daily_rollup_delete", schema)
self.assertIn("CREATE POLICY p_support_ticket_insert", schema)
self.assertIn("reporter_id = app.current_uid()", schema)
self.assertIn("fingerprint TEXT NOT NULL DEFAULT ''", schema)
self.assertIn("parent_ticket_id UUID REFERENCES app.support_ticket(id)", schema)
self.assertIn("idx_support_ticket_fingerprint", schema)
self.assertIn("CREATE TABLE IF NOT EXISTS app.learner_prepost_measure", schema)
self.assertIn("measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction')", schema)
self.assertIn("ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("CREATE POLICY p_learner_prepost_measure_insert", schema)