운영 지표와 지원 요청 저장소 추가
This commit is contained in:
parent
50fa4ad432
commit
e7ebb38177
20 changed files with 3038 additions and 39 deletions
157
scripts/export-phase3-kpi.py
Normal file
157
scripts/export-phase3-kpi.py
Normal 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())
|
||||
85
scripts/install-health-sampler-task.ps1
Normal file
85
scripts/install-health-sampler-task.ps1
Normal 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'"
|
||||
}
|
||||
98
scripts/maintain-admin-health-events.py
Normal file
98
scripts/maintain-admin-health-events.py
Normal 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())
|
||||
139
scripts/record-admin-health-sample.py
Normal file
139
scripts/record-admin-health-sample.py
Normal 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())
|
||||
46
scripts/report-ai-usage.py
Normal file
46
scripts/report-ai-usage.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue