전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -18,6 +18,7 @@ import asyncpg # noqa: E402
from app.services.dataset_export import ( # noqa: E402
APPROVED_EXPORT_STATUS,
DRY_RUN_EXPORT_STATUS,
DatasetManifestInput,
ExportKeyMaps,
build_dataset_record,
build_manifest,
@ -130,7 +131,9 @@ def row_to_dict(row: asyncpg.Record) -> dict[str, Any]:
return {key: row[key] for key in row.keys()}
async def fetch_rows(conn: asyncpg.Connection, args: argparse.Namespace) -> list[dict[str, Any]]:
async def fetch_rows(
conn: asyncpg.Connection, args: argparse.Namespace
) -> list[dict[str, Any]]:
rows = await conn.fetch(
TURN_QUERY,
parse_time(args.started_at),
@ -165,13 +168,19 @@ async def write_dataset_rows(
return int(dataset_id)
async def fetch_agreement(conn: asyncpg.Connection, dataset_id: int | None, args: argparse.Namespace) -> dict[str, Any]:
async def fetch_agreement(
conn: asyncpg.Connection, dataset_id: int | None, args: argparse.Namespace
) -> dict[str, Any]:
if dataset_id is None:
return {"kappa": None, "icc": None, "gold_status": "not_gold"}
rows = [row_to_dict(row) for row in await conn.fetch(ANNOTATION_QUERY, dataset_id)]
kappa = cohen_kappa(rows, args.kappa_label)
icc = intraclass_correlation(rows, args.icc_label)
gold_status = "gold_candidate" if kappa is not None and kappa >= 0.70 and icc is not None and icc >= 0.75 else "not_gold"
gold_status = (
"gold_candidate"
if kappa is not None and kappa >= 0.70 and icc is not None and icc >= 0.75
else "not_gold"
)
return {"kappa": kappa, "icc": icc, "gold_status": gold_status}
@ -196,7 +205,9 @@ async def insert_manifest_row(
async def run(args: argparse.Namespace) -> int:
if args.export_status == APPROVED_EXPORT_STATUS and not args.allow_approved:
raise SystemExit("--export-status approved_for_recursive_learning_seed requires --allow-approved")
raise SystemExit(
"--export-status approved_for_recursive_learning_seed requires --allow-approved"
)
if args.write_manifest_row and not args.write_dataset:
raise SystemExit("--write-manifest-row requires --write-dataset")
@ -248,24 +259,27 @@ async def run(args: argparse.Namespace) -> int:
approvals = {
"data_steward": args.data_steward,
"legal_or_privacy_reviewer": args.legal_or_privacy_reviewer,
"technical_operator": args.technical_operator or os.environ.get("USERNAME", ""),
"technical_operator": args.technical_operator
or os.environ.get("USERNAME", ""),
"approved_at": args.approved_at,
}
manifest = build_manifest(
export_id=args.export_id,
dataset_name=args.dataset_name,
export_status=args.export_status,
purpose=args.purpose,
records=records,
jsonl_path=str(jsonl_path.relative_to(output_root)).replace("\\", "/"),
jsonl_sha256=digest,
pii_findings=pii_findings,
participants_included=len(keys.participant),
cohort_id=args.cohort_id,
consent_version=args.consent_version,
agreement=agreement,
approvals=approvals,
known_limitations=args.known_limitation,
DatasetManifestInput(
export_id=args.export_id,
dataset_name=args.dataset_name,
export_status=args.export_status,
purpose=args.purpose,
records=records,
jsonl_path=str(jsonl_path.relative_to(output_root)).replace("\\", "/"),
jsonl_sha256=digest,
pii_findings=pii_findings,
participants_included=len(keys.participant),
cohort_id=args.cohort_id,
consent_version=args.consent_version,
agreement=agreement,
approvals=approvals,
known_limitations=args.known_limitation,
)
)
validate_manifest_gate(manifest)
manifest_path.write_text(
@ -283,18 +297,44 @@ async def run(args: argparse.Namespace) -> int:
finally:
await conn.close()
print(json.dumps({"manifest": str(manifest_path), "jsonl": str(jsonl_path), "rows": len(records)}, ensure_ascii=False))
print(
json.dumps(
{
"manifest": str(manifest_path),
"jsonl": str(jsonl_path),
"rows": len(records),
},
ensure_ascii=False,
)
)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Export a Phase 3 recursive-learning JSONL dry-run dataset.")
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 = argparse.ArgumentParser(
description="Export a Phase 3 recursive-learning JSONL dry-run dataset."
)
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("--export-id", default=make_export_id())
parser.add_argument("--dataset-name", default="vignette_phase3_recursive_learning_seed")
parser.add_argument("--purpose", default="recursive-learning seed dataset for education simulator improvement")
parser.add_argument("--export-status", choices=[DRY_RUN_EXPORT_STATUS, "blocked", APPROVED_EXPORT_STATUS], default=DRY_RUN_EXPORT_STATUS)
parser.add_argument(
"--dataset-name", default="vignette_phase3_recursive_learning_seed"
)
parser.add_argument(
"--purpose",
default="recursive-learning seed dataset for education simulator improvement",
)
parser.add_argument(
"--export-status",
choices=[DRY_RUN_EXPORT_STATUS, "blocked", APPROVED_EXPORT_STATUS],
default=DRY_RUN_EXPORT_STATUS,
)
parser.add_argument("--allow-approved", action="store_true")
parser.add_argument("--started-at")
parser.add_argument("--ended-at")

View file

@ -0,0 +1,175 @@
"""P3 파일럿 대비 동시 세션 부하 테스트 (2026-07-13 한신대 회의).
20 동시 사용 시나리오: 학습자 N명이 동시에 dev-login 온보딩 세션 생성.
세션 동시 생성 RAG warm 동시성 이슈 이력이 있어(회의 P3), 생성 경로의
동시 지연/오류를 실측한다. 기본은 세션 생성까지만이고, --with-turn 주면
학습자당 텍스트 1(/turn 동기 경로)까지 실행한다(엔진 부하 주의).
사용 (dev 스택 기동 , 저장소 루트):
py -3.11 scripts/load-test-sessions.py --base-url http://127.0.0.1:8000 --users 20
py -3.11 scripts/load-test-sessions.py --users 20 --with-turn --json
주의:
- dev 전용(dev-login 필요). prod(8001)에는 절대 돌리지 않는다.
- --with-turn 실제 LLM 생성을 유발한다(claude_cli 상주 부하·비용).
"""
from __future__ import annotations
import argparse
import asyncio
import json
import statistics
import sys
import time
import httpx
ONBOARDING_BODY = {
"legal_name": "부하 테스트",
"affiliation": "한신대학교",
"department": "심리학과",
"grade_level": "석사 1년",
"phone": "010-0000-0000",
"contact_address": "부하 테스트 주소",
"nickname": "loadtester",
"self_introduction": "P3 동시 세션 부하 테스트 계정입니다.",
"terms_accepted": True,
"privacy_accepted": True,
}
async def _one_learner(
base_url: str,
index: int,
*,
persona_code: str,
with_turn: bool,
) -> dict:
result: dict = {"index": index, "ok": False}
async with httpx.AsyncClient(base_url=base_url, timeout=120.0) as client:
try:
t0 = time.perf_counter()
r = await client.post(
"/auth/dev-login",
json={
"email": f"loadtest-{index:02d}@hs.ac.kr",
"role": "learner",
"display_name": f"부하테스트{index:02d}",
},
)
r.raise_for_status()
result["login_ms"] = round((time.perf_counter() - t0) * 1000)
me = (await client.get("/auth/me")).json()
if not me.get("onboarding_completed_at"):
r = await client.post("/users/me/onboarding", json=ONBOARDING_BODY)
r.raise_for_status()
if not me.get("consent_at"):
r = await client.post("/auth/consent", json={"accepted": True})
if r.status_code >= 400 and r.status_code != 404:
r.raise_for_status()
t1 = time.perf_counter()
r = await client.post(
"/sessions",
json={
"persona_code": persona_code,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
)
r.raise_for_status()
payload = r.json()
result["create_ms"] = round((time.perf_counter() - t1) * 1000)
result["session_id"] = payload["session_id"]
result["degraded"] = bool(payload.get("degraded"))
if with_turn:
t2 = time.perf_counter()
r = await client.post(
f"/sessions/{payload['session_id']}/turn",
json={"text": "안녕하세요, 오늘 이렇게 시간 내줘서 고마워요. 요즘 어떻게 지냈어요?"},
)
r.raise_for_status()
result["turn_ms"] = round((time.perf_counter() - t2) * 1000)
result["ok"] = True
except httpx.HTTPStatusError as exc:
result["error"] = f"{exc.response.status_code} {exc.response.text[:180]}"
except Exception as exc: # noqa: BLE001 — 부하 리포트용 수집
result["error"] = f"{type(exc).__name__}: {exc}"
return result
def _percentiles(values: list[int]) -> dict:
if not values:
return {}
ordered = sorted(values)
return {
"min": ordered[0],
"p50": ordered[len(ordered) // 2],
"p95": ordered[min(len(ordered) - 1, int(len(ordered) * 0.95))],
"max": ordered[-1],
"avg": round(statistics.mean(ordered)),
}
async def main() -> int:
parser = argparse.ArgumentParser(description="P3 동시 세션 부하 테스트 (dev 전용)")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--users", type=int, default=20)
parser.add_argument("--persona", default="P4")
parser.add_argument("--with-turn", action="store_true")
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args()
if "8001" in args.base_url or "chanpaca" in args.base_url:
print("거부: prod 대상 부하 테스트는 금지되어 있습니다.", file=sys.stderr)
return 2
started = time.perf_counter()
results = await asyncio.gather(
*(
_one_learner(
args.base_url,
index,
persona_code=args.persona,
with_turn=args.with_turn,
)
for index in range(1, args.users + 1)
)
)
wall_seconds = round(time.perf_counter() - started, 2)
ok = [r for r in results if r.get("ok")]
failed = [r for r in results if not r.get("ok")]
report = {
"target": args.base_url,
"users": args.users,
"with_turn": args.with_turn,
"wall_seconds": wall_seconds,
"ok": len(ok),
"failed": len(failed),
"degraded_sessions": sum(1 for r in ok if r.get("degraded")),
"create_ms": _percentiles([r["create_ms"] for r in ok if "create_ms" in r]),
"turn_ms": _percentiles([r["turn_ms"] for r in ok if "turn_ms" in r]),
"errors": [
{"index": r["index"], "error": r.get("error", "")} for r in failed
][:10],
}
if args.as_json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"대상 {report['target']} · {report['users']}명 동시 · 총 {wall_seconds}s")
print(f"성공 {report['ok']} / 실패 {report['failed']} / degraded {report['degraded_sessions']}")
print(f"세션 생성(ms): {report['create_ms']}")
if report["turn_ms"]:
print(f"턴 왕복(ms): {report['turn_ms']}")
for err in report["errors"]:
print(f" - #{err['index']}: {err['error']}")
return 0 if not failed else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -15,6 +15,13 @@
$ErrorActionPreference = "Stop"
# 엔진 readiness 캐시 TTL. 기본 30초는 워치독 주기(5분)보다 짧아 매 헬스체크마다
# 실제 claude -p 생성을 새로 돌리게 만든다(재시작 폭풍의 근본 원인). 크게 늘려
# /ready 가 거의 항상 캐시를 반환하게 한다 → 헬스체크가 LLM 호출에 묶이지 않는다.
if (-not $env:ENGINE_READY_TTL_SECONDS) {
$env:ENGINE_READY_TTL_SECONDS = "1800"
}
$ApiDir = Join-Path $Workspace "apps\api"
$WebDir = Join-Path $Workspace "apps\web"
$OutLog = Join-Path $ApiDir "api.public.out.log"

View file

@ -9,6 +9,7 @@
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
[string[]]$AdditionalPublicHealthUrls = @(),
[string]$LogPath = "",
[int]$FailuresBeforeRestart = 3,
[switch]$CheckOnly,
[switch]$SkipPublicHealth,
[switch]$SkipCloudflaredRestart
@ -20,6 +21,23 @@ if (!$LogPath) {
$LogPath = Join-Path $Workspace "public-runtime-watchdog.log"
}
# 연속 실패 카운터(재시작 debounce용). 워치독은 매 실행마다 새 프로세스라 파일로 유지한다.
$FailCountPath = Join-Path $Workspace "public-runtime-watchdog.failcount"
function Get-FailCount {
if (Test-Path $FailCountPath) {
$raw = (Get-Content -Raw -Path $FailCountPath -ErrorAction SilentlyContinue)
$n = 0
if ([int]::TryParse(($raw -replace '\s', ''), [ref]$n)) { return $n }
}
return 0
}
function Set-FailCount {
param([int]$Value)
Set-Content -Path $FailCountPath -Value $Value -Encoding ascii
}
function Write-WatchdogLog {
param([string]$Message)
@ -33,7 +51,7 @@ function Test-JsonHealth {
[string]$Name,
[string]$Uri,
[scriptblock]$IsHealthy,
[int]$TimeoutSec = 10
[int]$TimeoutSec = 30
)
try {
@ -105,27 +123,38 @@ if (!$SkipPublicHealth) {
-Name "public-api" `
-Uri $PublicHealthUrl `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
-TimeoutSec 30
foreach ($url in $AdditionalPublicHealthUrls) {
$checks += Test-JsonHealth `
-Name "public-api:$url" `
-Uri $url `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
-TimeoutSec 30
}
}
$failed = @($checks | Where-Object { -not $_.Ok })
if ($failed.Count -eq 0) {
Set-FailCount 0
Write-WatchdogLog "healthy: $($checks.Name -join ', ')"
exit 0
}
Write-WatchdogLog "unhealthy: $((($failed | ForEach-Object { "$($_.Name)=$($_.Detail)" }) -join '; '))"
$failCount = (Get-FailCount) + 1
Set-FailCount $failCount
Write-WatchdogLog "unhealthy ($failCount/$FailuresBeforeRestart): $((($failed | ForEach-Object { "$($_.Name)=$($_.Detail)" }) -join '; '))"
if ($CheckOnly) {
exit 1
}
# 연속 실패 debounce: claude -p readiness probe 는 콜드 스폰 시 10~20초가 정상이라
# 단발 timeout 을 장애로 오판해 전체 재시작하던 것이 재시작 폭풍의 원인이었다.
# 연속 $FailuresBeforeRestart 회 실패해야 실제 재시작한다.
if ($failCount -lt $FailuresBeforeRestart) {
Write-WatchdogLog "defer restart: $FailuresBeforeRestart 연속 실패 전까지 대기 (현재 $failCount)"
exit 1
}
$startArgs = @{
Workspace = $Workspace
ApiPort = $ApiPort
@ -171,7 +200,7 @@ if (!$SkipPublicHealth) {
-Name "public-api" `
-Uri $PublicHealthUrl `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
-TimeoutSec 30
if (!$publicAfter.Ok) {
throw "Public API tunnel still unhealthy after restart: $($publicAfter.Detail)"
}
@ -180,11 +209,12 @@ if (!$SkipPublicHealth) {
-Name "public-api:$url" `
-Uri $url `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
-TimeoutSec 30
if (!$publicExtraAfter.Ok) {
throw "Public API tunnel still unhealthy after restart: $($publicExtraAfter.Detail)"
}
}
}
Set-FailCount 0
Write-WatchdogLog "restart verified"