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

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

@ -0,0 +1,157 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
sys.path.insert(0, str(API_ROOT))
import asyncpg # noqa: E402
from app.services.phase3_kpi_export import ( # noqa: E402
KPI_REPORT_PATH,
PREPOST_CSV_PATH,
ParticipantKeys,
build_kpi_report,
build_prepost_csv_rows,
write_kpi_report,
write_prepost_csv,
)
PREPOST_QUERY = """
WITH ranked AS (
SELECT
learner_id::text AS learner_id,
pilot_id,
measure_name,
timepoint,
raw_score,
min_score,
max_score,
instrument_version,
item_count,
collected_at,
updated_at,
row_number() OVER (
PARTITION BY learner_id, measure_name, timepoint
ORDER BY updated_at DESC, collected_at DESC, instrument_version DESC
) AS rn
FROM app.learner_prepost_measure
WHERE pilot_id = $1
AND ($2::timestamptz IS NULL OR collected_at >= $2::timestamptz)
AND ($3::timestamptz IS NULL OR collected_at < $3::timestamptz)
)
SELECT
learner_id,
pilot_id,
measure_name,
timepoint,
raw_score,
min_score,
max_score,
instrument_version,
item_count,
collected_at,
updated_at
FROM ranked
WHERE rn = 1
ORDER BY learner_id, measure_name, timepoint
"""
def parse_time(value: str | None) -> datetime | None:
if not value:
return None
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def now_utc() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
async def fetch_rows(args: argparse.Namespace) -> list[dict[str, Any]]:
conn = await asyncpg.connect(args.database_url)
try:
records = await conn.fetch(
PREPOST_QUERY,
args.pilot_id,
parse_time(args.started_at),
parse_time(args.ended_at),
)
finally:
await conn.close()
return [dict(record) for record in records]
async def run(args: argparse.Namespace) -> int:
rows = await fetch_rows(args)
keys = ParticipantKeys()
csv_rows = build_prepost_csv_rows(rows, participant_keys=keys)
generated_at = args.generated_at or now_utc()
source_window = None
if args.started_at or args.ended_at:
source_window = {
"started_at": args.started_at or "",
"ended_at": args.ended_at or "",
}
report = build_kpi_report(
rows,
pilot_id=args.pilot_id,
generated_at=generated_at,
source_window=source_window,
review_operator=args.operator,
)
output_root = Path(args.output_root)
prepost_path = output_root / PREPOST_CSV_PATH
report_path = output_root / KPI_REPORT_PATH
write_prepost_csv(csv_rows, prepost_path)
write_kpi_report(report, report_path)
print(
json.dumps(
{
"prepost_csv": str(prepost_path),
"kpi_report": str(report_path),
"rows": len(csv_rows),
"participants": len(keys),
"pilot_id": args.pilot_id,
},
ensure_ascii=False,
)
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export Phase 3 pre/post KPI evidence CSV and kpi_report.json from app.learner_prepost_measure."
)
parser.add_argument("--database-url", default=os.environ.get("DATABASE_URL"), required=os.environ.get("DATABASE_URL") is None)
parser.add_argument("--output-root", default=str(REPO_ROOT / "data" / "phase3-dry-run"))
parser.add_argument("--pilot-id", default="phase3-pilot-draft")
parser.add_argument("--started-at")
parser.add_argument("--ended-at")
parser.add_argument("--generated-at")
parser.add_argument("--operator", default="")
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return asyncio.run(run(args))
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,85 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[string]$TaskName = "VignetteAdminHealthSampler",
[int]$IntervalMinutes = 5,
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
[switch]$RunNow,
[switch]$PrintOnly
)
$ErrorActionPreference = "Stop"
if ($IntervalMinutes -lt 1) {
throw "IntervalMinutes must be 1 or greater"
}
$sampleScript = Join-Path $Workspace "scripts\record-admin-health-sample.py"
if (!(Test-Path $sampleScript)) {
throw "Health sampler script not found at $sampleScript"
}
if (!(Test-Path $Python)) {
$pythonCommand = Get-Command python.exe -ErrorAction Stop
$Python = $pythonCommand.Source
}
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$actionArguments = @(
"-B",
"`"$sampleScript`"",
"--json"
)
if ($PrintOnly) {
Write-Output "Task: $TaskName"
Write-Output "User: $userId"
Write-Output "Action: $Python $($actionArguments -join ' ')"
Write-Output "WorkingDirectory: $Workspace"
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
return
}
$action = New-ScheduledTaskAction `
-Execute $Python `
-Argument ($actionArguments -join " ") `
-WorkingDirectory $Workspace
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn -User $userId
$repeatTrigger = New-ScheduledTaskTrigger `
-Once `
-At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 3) `
-MultipleInstances IgnoreNew `
-RestartCount 2 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-StartWhenAvailable `
-WakeToRun
$principal = New-ScheduledTaskPrincipal `
-UserId $userId `
-LogonType Interactive `
-RunLevel Limited
$description = "Records Vignette admin health samples into app.admin_health_event as a one-shot synthetic monitor. Secrets stay in apps/api/.env or the user environment; the task command stores no secrets."
$task = New-ScheduledTask `
-Action $action `
-Trigger @($logonTrigger, $repeatTrigger) `
-Settings $settings `
-Principal $principal `
-Description $description
Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
Write-Output "Installed scheduled task '$TaskName' for $userId"
Write-Output "Action: $Python $($actionArguments -join ' ')"
Write-Output "Interval: every $IntervalMinutes minute(s), plus at user logon"
if ($RunNow) {
Start-ScheduledTask -TaskName $TaskName
Write-Output "Started scheduled task '$TaskName'"
}

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Roll up and optionally prune persisted admin health samples."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from dataclasses import asdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Roll up app.admin_health_event into app.admin_health_daily_rollup "
"and optionally prune old raw samples."
)
)
parser.add_argument("--rollup-days", type=int, required=True, help="Roll up raw samples older than this many days.")
parser.add_argument("--retention-days", type=int, required=True, help="Prune raw samples older than this many days.")
parser.add_argument("--apply", action="store_true", help="Mutate the database. Default is dry-run.")
parser.add_argument(
"--allow-non-dev-apply",
action="store_true",
help="Required with --apply outside ENVIRONMENT=dev.",
)
parser.add_argument("--json", action="store_true", help="Print compact JSON.")
return parser
def _prepare_imports() -> None:
os.chdir(API_ROOT)
api_root = str(API_ROOT)
if api_root not in sys.path:
sys.path.insert(0, api_root)
async def _run(args: argparse.Namespace) -> dict[str, object]:
_prepare_imports()
from app.auth_sessions import ensure_runtime_tables
from app.config import settings
from app.db import close_pool, init_pool
from app.services.admin_health_maintenance import maintain_admin_health_events
if args.apply and settings.environment != "dev" and not args.allow_non_dev_apply:
raise RuntimeError("--apply outside ENVIRONMENT=dev requires --allow-non-dev-apply")
await init_pool()
try:
await ensure_runtime_tables()
result = await maintain_admin_health_events(
rollup_days=args.rollup_days,
retention_days=args.retention_days,
apply=args.apply,
)
finally:
await close_pool()
return asdict(result)
def _print_result(result: dict[str, object], *, as_json: bool) -> None:
if as_json:
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return
mode = "applied" if result["applied"] else "dry-run"
print(
"{mode}: rollup_events={rollup_event_count}, rollup_buckets={rollup_bucket_count}, "
"upserted_rollups={upserted_rollups}, prunable_events={prunable_event_count}, "
"deleted_events={deleted_events}".format(mode=mode, **result)
)
def main() -> int:
args = build_parser().parse_args()
try:
result = asyncio.run(_run(args))
except Exception as exc: # noqa: BLE001 - CLI should report compact failure.
payload = {"ok": False, "error_type": exc.__class__.__name__, "error": str(exc)}
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), file=sys.stderr)
return 2
_print_result(result, as_json=args.json)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Record one Vignette admin health sample into app.admin_health_event.
This is a one-shot synthetic monitor entrypoint. It reuses the same health
calculation as GET /admin/health, but does not require an interactive admin
browser session. Schedule it with scripts/install-health-sampler-task.ps1 on
Windows, or run it manually for a local proof.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Collect one live Vignette health sample and persist it to app.admin_health_event."
)
parser.add_argument(
"--json",
action="store_true",
help="Print a compact JSON result instead of a short text summary.",
)
parser.add_argument(
"--allow-unrecorded",
action="store_true",
help="Exit 0 even when the health check ran but no DB event rows were recorded.",
)
return parser
def _prepare_imports() -> None:
os.chdir(API_ROOT)
api_root = str(API_ROOT)
if api_root not in sys.path:
sys.path.insert(0, api_root)
def _service_payload(service: Any) -> dict[str, Any]:
return {
"key": service.key,
"status": service.status,
"metric": service.metric,
"load": service.load,
}
async def _collect() -> dict[str, Any]:
_prepare_imports()
from app.auth_sessions import ensure_runtime_tables
from app.db import close_pool, init_pool
from app.engine_client import engine_client
from app.routes.admin import apply_engine_config_from_store, record_admin_health_sample
from app.services.voice import voice_service
await init_pool()
try:
await ensure_runtime_tables()
await apply_engine_config_from_store()
await engine_client.startup()
await voice_service.startup()
health, recorded_count = await record_admin_health_sample(principal=None)
finally:
await voice_service.shutdown()
await engine_client.shutdown()
await close_pool()
return {
"ok": recorded_count > 0,
"recorded_count": recorded_count,
"generated_at": time.time(),
"status": health.status,
"environment": health.environment,
"engine_mode": health.engine_mode,
"services": [_service_payload(service) for service in health.services],
}
def _error_payload(exc: BaseException) -> dict[str, Any]:
return {
"ok": False,
"recorded_count": 0,
"generated_at": time.time(),
"error_type": exc.__class__.__name__,
"error": str(exc),
}
def _print_result(result: dict[str, Any], *, as_json: bool) -> None:
if as_json:
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return
if result.get("ok"):
print(
"recorded {count} health events: status={status}, env={env}, engine={engine}".format(
count=result["recorded_count"],
status=result["status"],
env=result["environment"],
engine=result["engine_mode"],
)
)
else:
print(
"health sample not recorded: {error}".format(
error=result.get("error") or f"recorded_count={result.get('recorded_count', 0)}"
),
file=sys.stderr,
)
def main() -> int:
args = build_parser().parse_args()
try:
result = asyncio.run(_collect())
except Exception as exc: # noqa: BLE001 - CLI should report a compact failure.
result = _error_payload(exc)
_print_result(result, as_json=args.json)
if result.get("ok") or args.allow_unrecorded:
return 0
return 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python
"""Build a deterministic model-cost report from an admin usage JSON export."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
def _repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _load_builder():
api_root = _repo_root() / "apps" / "api"
sys.path.insert(0, str(api_root))
from app.services.usage_report import build_model_cost_report
return build_model_cost_report
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Create a model/provider AI usage cost report from AdminUsageResponse JSON.",
)
parser.add_argument("--input", required=True, help="Path to AdminUsageResponse JSON.")
parser.add_argument("--output", help="Optional output JSON path. Defaults to stdout.")
return parser.parse_args()
def main() -> int:
args = parse_args()
usage = json.loads(Path(args.input).read_text(encoding="utf-8"))
report = _load_builder()(usage)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.output:
Path(args.output).write_text(payload, encoding="utf-8")
else:
sys.stdout.write(payload)
return 0
if __name__ == "__main__":
raise SystemExit(main())