대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""Validate Phase 3 pilot evidence artifacts without modifying source data."""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
FORBIDDEN_HEADER_TERMS = {
"address",
"api_key",
"birthdate",
"cookie",
"date_of_birth",
"dob",
"email",
"full_name",
"guardian_name",
"identity_map",
"national_id",
"participant_name",
"phone",
"raw_audio",
"raw_source_case",
"raw_voice",
"secret",
"ssn",
"student_id",
"student_name",
"token",
}
EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", re.IGNORECASE)
PHONE_RE = re.compile(r"(?:\+?\d[\d .()-]{7,}\d)")
@dataclass(frozen=True)
class CsvSpec:
path: str
headers: tuple[str, ...]
CSV_SPECS = (
CsvSpec(
"00-intake/pilot_roster.csv",
(
"participant_id",
"cohort_id",
"consent_version",
"consent_signed_at",
"withdrawal_state",
"enrolled_at",
),
),
CsvSpec(
"00-intake/consent_receipts.csv",
("participant_id", "consent_version", "signed_at", "signer_role", "receipt_id"),
),
CsvSpec(
"01-sessions/session_completion.csv",
(
"participant_id",
"session_id",
"persona_id",
"started_at",
"ended_at",
"completion_state",
"turns_count",
"supervisor_reviewed_at",
),
),
CsvSpec(
"02-measures/prepost_measures.csv",
("participant_id", "measure_name", "timepoint", "score", "collected_at"),
),
CsvSpec(
"02-measures/sus_responses.csv",
("participant_id", "item", "response", "collected_at"),
),
CsvSpec(
"04-privacy/withdrawal_log.csv",
("participant_id", "requested_at", "effective_at", "scope", "status", "attestation_path"),
),
)
REQUIRED_MARKDOWN = ("04-privacy/privacy_audit.md",)
KPI_METRICS = {
"embedding_consistency",
"hallucination_rate",
"icc",
"inter_rater_kappa",
"pilot_completion",
"self_efficacy_prepost",
"session_completion",
"sus",
"top1",
}
MANIFEST_KEYS = {
"agreement",
"anonymization",
"approvals",
"consent_scope",
"created_at",
"dataset_name",
"export_id",
"export_status",
"files",
"pii_scan",
"purpose",
}
class Report:
def __init__(self) -> None:
self.errors: list[str] = []
self.warnings: list[str] = []
self.info: list[str] = []
def error(self, message: str) -> None:
self.errors.append(message)
def warn(self, message: str) -> None:
self.warnings.append(message)
def note(self, message: str) -> None:
self.info.append(message)
def to_dict(self, evidence_root: Path) -> dict[str, Any]:
return {
"evidence_root": str(evidence_root),
"status": "pass" if not self.errors else "fail",
"errors": self.errors,
"warnings": self.warnings,
"info": self.info,
}
def read_csv_header(path: Path, report: Report) -> list[str] | None:
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.reader(handle)
header = next(reader, None)
if not header:
report.error(f"{path}: empty CSV or missing header")
return None
return [cell.strip() for cell in header]
except UnicodeDecodeError:
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
except OSError as exc:
report.error(f"{path}: cannot read CSV: {exc}")
return None
def scan_csv_values(path: Path, report: Report, max_rows: int) -> None:
try:
with path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
for row_number, row in enumerate(reader, start=2):
if row_number > max_rows + 1:
report.note(f"{path}: scanned first {max_rows} data rows for identifier patterns")
return
for column, value in row.items():
if not value:
continue
if EMAIL_RE.search(value):
report.error(f"{path}:{row_number}: possible email in column '{column}'")
elif column and column.lower() not in {"turns_count", "score", "response", "item"}:
if PHONE_RE.fullmatch(value.strip()):
report.warn(f"{path}:{row_number}: possible phone-like value in column '{column}'")
except UnicodeDecodeError:
report.error(f"{path}: not valid UTF-8/UTF-8-SIG")
except OSError as exc:
report.error(f"{path}: cannot scan CSV: {exc}")
def validate_csv(root: Path, spec: CsvSpec, report: Report, max_scan_rows: int) -> None:
path = root / spec.path
if not path.exists():
report.error(f"missing required file: {spec.path}")
return
header = read_csv_header(path, report)
if header is None:
return
missing = [name for name in spec.headers if name not in header]
if missing:
report.error(f"{spec.path}: missing required headers: {', '.join(missing)}")
forbidden = sorted(
column for column in header for term in FORBIDDEN_HEADER_TERMS if term in column.lower()
)
if forbidden:
report.error(f"{spec.path}: forbidden identifier/secret-like headers: {', '.join(forbidden)}")
scan_csv_values(path, report, max_rows=max_scan_rows)
def read_json(path: Path, report: Report) -> dict[str, Any] | None:
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except json.JSONDecodeError as exc:
report.error(f"{path}: invalid JSON: {exc}")
return None
except UnicodeDecodeError:
report.error(f"{path}: not valid UTF-8")
return None
except OSError as exc:
report.error(f"{path}: cannot read JSON: {exc}")
return None
if not isinstance(data, dict):
report.error(f"{path}: top-level JSON must be an object")
return None
return data
def validate_kpi_report(root: Path, report: Report) -> None:
rel_path = "02-measures/kpi_report.json"
path = root / rel_path
if not path.exists():
report.error(f"missing required file: {rel_path}")
return
data = read_json(path, report)
if data is None:
return
metrics = data.get("metrics")
if not isinstance(metrics, dict):
report.error(f"{rel_path}: missing object key 'metrics'")
return
missing = sorted(KPI_METRICS - set(metrics))
if missing:
report.error(f"{rel_path}: missing metric keys: {', '.join(missing)}")
for metric_name, metric in metrics.items():
if not isinstance(metric, dict):
report.error(f"{rel_path}: metric '{metric_name}' must be an object")
continue
for key in ("value", "threshold", "pass", "method", "source_files"):
if key not in metric:
report.warn(f"{rel_path}: metric '{metric_name}' missing '{key}'")
def validate_manifest(root: Path, report: Report) -> None:
rel_path = "03-export/export_manifest.json"
path = root / rel_path
if not path.exists():
report.error(f"missing required file: {rel_path}")
return
data = read_json(path, report)
if data is None:
return
missing = sorted(MANIFEST_KEYS - set(data))
if missing:
report.error(f"{rel_path}: missing keys: {', '.join(missing)}")
status = data.get("export_status")
if status == "approved_for_recursive_learning_seed":
approvals = data.get("approvals")
if not isinstance(approvals, dict):
report.error(f"{rel_path}: approved export requires approvals object")
else:
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
if not approvals.get(key):
report.error(f"{rel_path}: approved export missing approval '{key}'")
files = data.get("files")
if isinstance(files, list):
for item in files:
if not isinstance(item, dict):
report.error(f"{rel_path}: files[] entries must be objects")
continue
for key in ("path", "rows", "sha256", "schema"):
if key not in item:
report.warn(f"{rel_path}: file entry missing '{key}'")
elif files is not None:
report.error(f"{rel_path}: 'files' must be a list")
def validate_markdown(root: Path, report: Report) -> None:
for rel_path in REQUIRED_MARKDOWN:
path = root / rel_path
if not path.exists():
report.error(f"missing required file: {rel_path}")
continue
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
report.error(f"{rel_path}: not valid UTF-8")
continue
except OSError as exc:
report.error(f"{rel_path}: cannot read Markdown: {exc}")
continue
if not content.strip():
report.error(f"{rel_path}: file is empty")
if "Legal/privacy reviewer:" not in content:
report.warn(f"{rel_path}: expected legal/privacy reviewer field")
def validate(root: Path, max_scan_rows: int) -> Report:
report = Report()
if not root.exists():
report.error(f"evidence root does not exist: {root}")
return report
if not root.is_dir():
report.error(f"evidence root is not a directory: {root}")
return report
for spec in CSV_SPECS:
validate_csv(root, spec, report, max_scan_rows=max_scan_rows)
validate_kpi_report(root, report)
validate_manifest(root, report)
validate_markdown(root, report)
return report
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Read-only Phase 3 evidence checker. Writes only when --output is provided."
)
parser.add_argument(
"--check",
action="store_true",
help="Run validation checks. This is also the default behavior.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Alias for read-only validation; kept for operator clarity.",
)
parser.add_argument(
"--evidence-root",
type=Path,
default=Path("evidence/phase3"),
help="Directory containing Phase 3 evidence files.",
)
parser.add_argument(
"--output",
type=Path,
help="Optional JSON report path. No files are written unless this is set.",
)
parser.add_argument(
"--json",
action="store_true",
help="Print the validation report as JSON instead of text.",
)
parser.add_argument(
"--max-scan-rows",
type=int,
default=1000,
help="Maximum data rows per CSV to scan for obvious identifier patterns.",
)
return parser.parse_args(argv)
def print_text_report(data: dict[str, Any]) -> None:
print(f"Phase 3 artifact check: {data['status']}")
print(f"Evidence root: {data['evidence_root']}")
for label in ("errors", "warnings", "info"):
items = data[label]
if not items:
continue
print(f"\n{label.upper()}:")
for item in items:
print(f"- {item}")
def main(argv: list[str]) -> int:
args = parse_args(argv)
report = validate(args.evidence_root, max_scan_rows=max(0, args.max_scan_rows))
data = report.to_dict(args.evidence_root)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
if args.json:
print(json.dumps(data, indent=2, ensure_ascii=True))
else:
print_text_report(data)
return 0 if not report.errors else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,626 @@
#!/usr/bin/env python3
"""Local opt-in Postgres RLS/audit smoke checks for Vignette.
The script creates a small set of uniquely tagged fixture rows, verifies the
live RLS/audit behavior that unit tests mock, then removes only those rows.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import socket
import sys
import uuid
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlparse
DSN_ENV_VARS = ("VIGNETTE_RLS_AUDIT_DSN", "POSTGRES_RLS_AUDIT_DSN")
LOCAL_HOSTS = {"", "localhost", "127.0.0.1", "::1", "db", "postgres"}
@dataclass
class CheckResult:
name: str
status: str
detail: str
@dataclass
class SmokeState:
run_id: str
cohort_a: str
cohort_b: str
learner_a: str | None = None
learner_b: str | None = None
teacher_a: str | None = None
admin: str | None = None
session_a: str | None = None
session_b: str | None = None
created_user_ids: list[str] = field(default_factory=list)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Run a read/write smoke test against a local/dev Postgres database "
"to prove session RLS, visible_to filtering, cohort scoping, and "
"audit insert behavior."
)
)
parser.add_argument(
"--dsn",
default=next((os.environ.get(name) for name in DSN_ENV_VARS if os.environ.get(name)), None),
help=(
"Postgres DSN. May also be supplied via VIGNETTE_RLS_AUDIT_DSN "
"or POSTGRES_RLS_AUDIT_DSN. DATABASE_URL is intentionally not read."
),
)
parser.add_argument(
"--write-fixtures",
action="store_true",
help="Required opt-in: insert uniquely tagged fixture rows and clean them up afterward.",
)
parser.add_argument(
"--keep-fixtures",
action="store_true",
help="Leave the created fixture rows in place for manual inspection.",
)
parser.add_argument(
"--run-id",
default=None,
help="Optional run id. Defaults to a random rls-audit-smoke-* id.",
)
parser.add_argument(
"--allow-non-local",
action="store_true",
help="Allow a DSN host outside localhost/db/postgres. Intended only for disposable dev DBs.",
)
parser.add_argument(
"--allow-skips",
action="store_true",
help="Return exit code 0 when checks are skipped. By default skipped proof is nonzero.",
)
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
return parser
def dsn_host(dsn: str) -> str:
parsed = urlparse(dsn)
if parsed.hostname:
return parsed.hostname
if parsed.scheme and parsed.path and not parsed.netloc:
return ""
return parsed.hostname or ""
def is_local_dsn(dsn: str) -> bool:
host = dsn_host(dsn)
if host in LOCAL_HOSTS:
return True
try:
return socket.gethostbyname(host).startswith("127.")
except OSError:
return False
def print_prerequisites(parser: argparse.ArgumentParser) -> None:
parser.print_help(sys.stderr)
print(
"\nPrerequisites:\n"
" - Run against a local/dev Postgres DB initialized with infra/db/init/*.sql.\n"
" - Use a non-owner app role with NOBYPASSRLS, for example vignette_app.\n"
" - Supply a DSN explicitly via --dsn, VIGNETTE_RLS_AUDIT_DSN, or POSTGRES_RLS_AUDIT_DSN.\n"
" - Pass --write-fixtures to permit temporary fixture inserts.\n",
file=sys.stderr,
)
async def set_context(
conn: Any,
*,
role: str | None = None,
uid: str | None = None,
cohort: str | None = None,
ai_view: str | None = None,
) -> None:
await conn.execute("SELECT set_config('app.ai_context', $1, true)", "1" if ai_view else "")
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view or "")
await conn.execute("SELECT set_config('app.current_role', $1, true)", role or "")
await conn.execute("SELECT set_config('app.current_uid', $1, true)", uid or "")
await conn.execute("SELECT set_config('app.current_cohort', $1, true)", cohort or "")
await conn.execute(
"SELECT set_config('app.current_sens_max', $1, true)",
{"client": "1", "counselor": "0", "evaluator": "2"}.get(ai_view or "", ""),
)
async def fetchval_as(
conn: Any,
query: str,
*args: Any,
role: str | None = None,
uid: str | None = None,
cohort: str | None = None,
ai_view: str | None = None,
) -> Any:
async with conn.transaction():
await set_context(conn, role=role, uid=uid, cohort=cohort, ai_view=ai_view)
return await conn.fetchval(query, *args)
async def execute_as(
conn: Any,
query: str,
*args: Any,
role: str | None = None,
uid: str | None = None,
cohort: str | None = None,
ai_view: str | None = None,
) -> str:
async with conn.transaction():
await set_context(conn, role=role, uid=uid, cohort=cohort, ai_view=ai_view)
return await conn.execute(query, *args)
async def preflight(conn: Any) -> list[CheckResult]:
results: list[CheckResult] = []
role_row = await conn.fetchrow(
"""
SELECT r.rolname, r.rolsuper, r.rolbypassrls
FROM pg_roles r
WHERE r.rolname = current_user
"""
)
if role_row and (role_row["rolsuper"] or role_row["rolbypassrls"]):
results.append(
CheckResult(
"preflight.rls_role",
"fail",
f"current_user={role_row['rolname']} can bypass RLS; use a NOBYPASSRLS app role",
)
)
owner_tables = await conn.fetch(
"""
SELECT n.nspname || '.' || c.relname AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname IN ('app','audit')
AND c.relname IN ('sessions','turns','app_user','audit_log')
AND pg_get_userbyid(c.relowner) = current_user
ORDER BY 1
"""
)
if owner_tables:
table_list = ", ".join(row["table_name"] for row in owner_tables)
results.append(
CheckResult(
"preflight.table_owner",
"fail",
f"current_user owns RLS target tables ({table_list}) and can bypass policies",
)
)
required = await conn.fetchrow(
"""
SELECT
to_regclass('app.app_user') IS NOT NULL AS has_user,
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
to_regclass('app.turns') IS NOT NULL AS has_turns,
to_regclass('audit.audit_log') IS NOT NULL AS has_audit,
to_regprocedure('app.current_uid()') IS NOT NULL AS has_current_uid,
to_regprocedure('app.current_role_name()') IS NOT NULL AS has_current_role,
to_regprocedure('app.is_ai_context()') IS NOT NULL AS has_ai_context
"""
)
missing = [key.removeprefix("has_") for key, value in dict(required or {}).items() if not value]
if missing:
results.append(
CheckResult(
"preflight.schema",
"skip",
"cannot prove RLS/audit behavior; missing schema objects: " + ", ".join(sorted(missing)),
)
)
policies = await conn.fetch(
"""
SELECT schemaname, tablename, policyname
FROM pg_policies
WHERE schemaname = 'app'
AND (tablename, policyname) IN (
('sessions','p_sessions_select'),
('turns','p_turns_select')
)
"""
)
found = {(row["tablename"], row["policyname"]) for row in policies}
expected = {("sessions", "p_sessions_select"), ("turns", "p_turns_select")}
missing_policies = sorted(f"{table}.{policy}" for table, policy in expected - found)
if missing_policies:
results.append(
CheckResult(
"preflight.policies",
"skip",
"cannot prove RLS behavior; missing policies: " + ", ".join(missing_policies),
)
)
if not results:
results.append(CheckResult("preflight", "pass", "schema, policies, and DB role are smoke-testable"))
return results
async def create_fixtures(conn: Any, run_id: str) -> SmokeState:
state = SmokeState(
run_id=run_id,
cohort_a=f"{run_id}-cohort-a",
cohort_b=f"{run_id}-cohort-b",
)
prefix = f"rls-audit-smoke:{run_id}"
users = [
("learner_a", "learner", state.cohort_a),
("learner_b", "learner", state.cohort_b),
("teacher_a", "instructor", state.cohort_a),
("admin", "admin", None),
]
for key, role, cohort in users:
row = await conn.fetchrow(
"""
INSERT INTO app.app_user (external_id, email, display_name, role, cohort, consent_at)
VALUES ($1, NULL, $2, $3, $4, now())
RETURNING user_id
""",
f"{prefix}:{key}",
f"RLS smoke {key} {run_id}",
role,
cohort,
)
user_id = str(row["user_id"])
setattr(state, key, user_id)
state.created_user_ids.append(user_id)
async with conn.transaction():
await set_context(conn, role="admin", uid=state.admin)
state.session_a = str(
await conn.fetchval(
"""
INSERT INTO app.sessions (
learner_id, runtime_case_id, persona_code, persona_display_name,
persona_difficulty, session_no, theory_mode, stage_path
)
VALUES ($1::uuid, gen_random_uuid(), $2, $3, 'easy', 1, 'humanistic', '[]'::jsonb)
RETURNING id
""",
state.learner_a,
"RLS_SMOKE_A",
f"RLS Smoke A {run_id}",
)
)
state.session_b = str(
await conn.fetchval(
"""
INSERT INTO app.sessions (
learner_id, runtime_case_id, persona_code, persona_display_name,
persona_difficulty, session_no, theory_mode, stage_path
)
VALUES ($1::uuid, gen_random_uuid(), $2, $3, 'easy', 1, 'humanistic', '[]'::jsonb)
RETURNING id
""",
state.learner_b,
"RLS_SMOKE_B",
f"RLS Smoke B {run_id}",
)
)
await conn.execute(
"""
INSERT INTO app.turns (session_id, seq, speaker, text, text_masked, actor_kind, visible_to)
VALUES
($1::uuid, 1, 'counselor', $3, $3, 'human_learner', ARRAY['client','counselor','evaluator']),
($1::uuid, 2, 'client', $4, $4, 'client_ai', ARRAY['client','counselor','evaluator']),
($1::uuid, 3, 'counselor', $5, $5, 'evaluator_ai', ARRAY['evaluator']),
($2::uuid, 1, 'counselor', $6, $6, 'human_learner', ARRAY['client','counselor','evaluator'])
""",
state.session_a,
state.session_b,
f"{run_id} learner A visible counselor turn",
f"{run_id} learner A visible client turn",
f"{run_id} evaluator-only hidden turn",
f"{run_id} learner B visible counselor turn",
)
return state
async def cleanup_fixtures(conn: Any, state: SmokeState) -> None:
prefix = f"rls-audit-smoke:{state.run_id}:%"
# Sessions/turns/audit are RLS-protected, so deletes must run inside the same
# admin context the fixtures were created under; otherwise the NOBYPASSRLS app
# role silently deletes 0 rows and the app_user delete fails on a FK reference.
async with conn.transaction():
await set_context(conn, role="admin", uid=state.admin)
await conn.execute(
"""
DELETE FROM audit.audit_log
WHERE detail->>'smoke_run_id' = $1 OR target_id LIKE $2
""",
state.run_id,
prefix,
)
await conn.execute(
"DELETE FROM app.turns WHERE session_id = ANY($1::uuid[])",
[sid for sid in (state.session_a, state.session_b) if sid],
)
await conn.execute(
"DELETE FROM app.sessions WHERE learner_id = ANY($1::uuid[])",
state.created_user_ids,
)
# app_user is not RLS-protected; delete it last once sessions no longer ref it.
await conn.execute(
"DELETE FROM app.app_user WHERE external_id LIKE $1",
prefix,
)
async def check_learner_isolation(conn: Any, state: SmokeState) -> CheckResult:
own = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_a,
role="learner",
uid=state.learner_a,
)
other = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_b,
role="learner",
uid=state.learner_a,
)
other_turns = await fetchval_as(
conn,
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid",
state.session_b,
role="learner",
uid=state.learner_a,
)
if own == 1 and other == 0 and other_turns == 0:
return CheckResult(
"learner.session_turn_isolation",
"pass",
"learner A can read own session and cannot read learner B session or turns",
)
return CheckResult(
"learner.session_turn_isolation",
"fail",
f"expected own=1 other=0 other_turns=0; got own={own} other={other} other_turns={other_turns}",
)
async def check_teacher_cohort(conn: Any, state: SmokeState) -> CheckResult:
visible_a = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_a,
role="instructor",
uid=state.teacher_a,
cohort=state.cohort_a,
)
visible_b = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_b,
role="instructor",
uid=state.teacher_a,
cohort=state.cohort_a,
)
if visible_a == 1 and visible_b == 0:
return CheckResult(
"teacher.cohort_scoped_sessions",
"pass",
"instructor scoped to cohort A sees learner A session and not cohort B session",
)
return CheckResult(
"teacher.cohort_scoped_sessions",
"fail",
f"expected cohort A session=1 cohort B session=0; got {visible_a} and {visible_b}",
)
async def check_visible_to(conn: Any, state: SmokeState) -> CheckResult:
learner_count = await fetchval_as(
conn,
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
state.session_a,
role="learner",
uid=state.learner_a,
)
client_ai_count = await fetchval_as(
conn,
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
state.session_a,
ai_view="client",
)
counselor_ai_count = await fetchval_as(
conn,
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
state.session_a,
ai_view="counselor",
)
evaluator_ai_count = await fetchval_as(
conn,
"SELECT count(*) FROM app.turns WHERE session_id = $1::uuid AND seq = 3",
state.session_a,
ai_view="evaluator",
)
if (learner_count, client_ai_count, counselor_ai_count, evaluator_ai_count) == (0, 0, 0, 1):
return CheckResult(
"visible_to.evaluator_only_hidden",
"pass",
"evaluator-only turn is hidden from learner/client/counselor AI and visible to evaluator AI",
)
return CheckResult(
"visible_to.evaluator_only_hidden",
"fail",
"expected learner/client/counselor/evaluator counts 0/0/0/1; got "
f"{learner_count}/{client_ai_count}/{counselor_ai_count}/{evaluator_ai_count}",
)
async def check_audit_insert(conn: Any, state: SmokeState) -> CheckResult:
target_teacher = f"rls-audit-smoke:{state.run_id}:teacher-read:{state.session_a}"
target_admin = f"rls-audit-smoke:{state.run_id}:admin-read:{state.session_a}"
teacher_can_read = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_a,
role="instructor",
uid=state.teacher_a,
cohort=state.cohort_a,
)
admin_can_read = await fetchval_as(
conn,
"SELECT count(*) FROM app.sessions WHERE id = $1::uuid",
state.session_a,
role="admin",
uid=state.admin,
)
if teacher_can_read != 1 or admin_can_read != 1:
return CheckResult(
"audit.teacher_admin_read_insert",
"fail",
f"read path not visible before audit insert; instructor={teacher_can_read} admin={admin_can_read}",
)
await execute_as(
conn,
"""
INSERT INTO audit.audit_log (actor_uid, action, target_kind, target_id, detail)
VALUES ($1::uuid, 'read_session', 'session', $2, $3::jsonb)
""",
state.teacher_a,
target_teacher,
json.dumps({"smoke_run_id": state.run_id, "role": "instructor"}),
role="instructor",
uid=state.teacher_a,
cohort=state.cohort_a,
)
await execute_as(
conn,
"""
INSERT INTO audit.audit_log (actor_uid, action, target_kind, target_id, detail)
VALUES ($1::uuid, 'read_session', 'session', $2, $3::jsonb)
""",
state.admin,
target_admin,
json.dumps({"smoke_run_id": state.run_id, "role": "admin"}),
role="admin",
uid=state.admin,
)
count = await conn.fetchval(
"""
SELECT count(*)
FROM audit.audit_log
WHERE detail->>'smoke_run_id' = $1
AND action = 'read_session'
AND target_id IN ($2, $3)
""",
state.run_id,
target_teacher,
target_admin,
)
if count == 2:
return CheckResult(
"audit.teacher_admin_read_insert",
"pass",
"instructor and admin read_session audit rows inserted and verified",
)
return CheckResult(
"audit.teacher_admin_read_insert",
"fail",
f"expected 2 read_session audit rows for run id; got {count}",
)
async def run_smoke(args: argparse.Namespace) -> tuple[list[CheckResult], SmokeState | None]:
try:
import asyncpg
except ImportError as exc:
return [CheckResult("preflight.asyncpg", "fail", f"asyncpg is not installed: {exc}")], None
conn = await asyncpg.connect(args.dsn)
state: SmokeState | None = None
try:
results = await preflight(conn)
if any(result.status != "pass" for result in results):
return results, None
run_id = args.run_id or f"rls-audit-smoke-{uuid.uuid4().hex[:12]}"
state = await create_fixtures(conn, run_id)
results.extend(
[
await check_learner_isolation(conn, state),
await check_teacher_cohort(conn, state),
await check_visible_to(conn, state),
await check_audit_insert(conn, state),
]
)
return results, state
finally:
if state is not None and not args.keep_fixtures:
await cleanup_fixtures(conn, state)
await conn.close()
def emit(results: list[CheckResult], state: SmokeState | None, *, as_json: bool) -> None:
if as_json:
print(
json.dumps(
{
"run_id": state.run_id if state else None,
"status": (
"fail"
if any(result.status == "fail" for result in results)
else "skip"
if any(result.status == "skip" for result in results)
else "pass"
),
"checks": [result.__dict__ for result in results],
},
indent=2,
)
)
return
if state:
print(f"run_id: {state.run_id}")
for result in results:
print(f"{result.status.upper():4} {result.name}: {result.detail}")
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not args.dsn or not args.write_fixtures:
print_prerequisites(parser)
return 2
if not args.allow_non_local and not is_local_dsn(args.dsn):
print(
f"Refusing non-local DSN host '{dsn_host(args.dsn)}'. "
"Use --allow-non-local only for disposable dev databases.",
file=sys.stderr,
)
return 2
results, state = asyncio.run(run_smoke(args))
emit(results, state, as_json=args.json)
has_fail = any(result.status == "fail" for result in results)
has_skip = any(result.status == "skip" for result in results)
if has_fail or (has_skip and not args.allow_skips):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

25
scripts/dev-down.ps1 Normal file
View file

@ -0,0 +1,25 @@
<#
.SYNOPSIS
Vignette 로컬 개발 스택(게이트웨이 9099 + API 8000 + 5173) 정리한다.
.DESCRIPTION
커맨드라인 기준으로 정확히 종료한다(고아 워커/리로더 포함). scripts/dev-up.ps1 .
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1
#>
$ErrorActionPreference = 'SilentlyContinue'
function Stop-Stale([string]$pattern, [string]$label) {
Get-CimInstance Win32_Process |
Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } |
ForEach-Object {
Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force
}
}
Write-Host "로컬 dev 스택 종료..."
Stop-Stale 'engine_gateway\.gateway' 'gateway'
Stop-Stale 'app\.main:app' 'api'
Stop-Stale 'vite' 'web'
Start-Sleep -Seconds 1
Write-Host "완료."

135
scripts/dev-up.ps1 Normal file
View file

@ -0,0 +1,135 @@
<#
.SYNOPSIS
Vignette 로컬 개발 스택을 깔끔하게 ()기동한다: 엔진 게이트웨이(9099) + API(8000) + (5173).
.DESCRIPTION
- 기존(고아 포함) 프로세스를 커맨드라인 기준으로 정확히 정리한 새로 띄운다(--reload 미사용: 결정론적).
- API는 DB 미가용 in-memory degraded로 기동된다(Postgres 불필요). dev-login + seed 페르소나 활성.
- 로그는 .devlogs/ 남긴다. 종료는 scripts/dev-down.ps1.
.PARAMETER NoGateway
엔진 게이트웨이를 띄우지 않는다(AI 생성 불가, UI만 테스트 ).
.PARAMETER NoWeb
vite 서버를 띄우지 않는다(API만 필요할 ).
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
#>
param(
[switch]$NoGateway,
[switch]$NoWeb,
[switch]$NoDb
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$api = Join-Path $repo 'apps\api'
$web = Join-Path $repo 'apps\web'
$logs = Join-Path $repo '.devlogs'
New-Item -ItemType Directory -Force -Path $logs | Out-Null
# uvicorn 이 설치된 python 을 해석한다(시스템에 3.11/3.14 등 복수 python 공존 — 'python' 별칭이
# uvicorn 없는 인터프리터를 가리킬 수 있다). 후보를 순회해 import uvicorn 성공하는 것을 고른다.
$pyCandidates = @(
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python311\python.exe'),
(Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe'),
'py',
'python'
)
$Python = $null
foreach ($c in $pyCandidates) {
$exe = $c; $pre = @()
if ($c -eq 'py') { $pre = @('-3') }
try {
& $exe @pre '-c' 'import uvicorn' 2>$null
if ($LASTEXITCODE -eq 0) { $Python = $exe; $PyPre = $pre; break }
} catch {}
}
if (-not $Python) { Write-Host 'ERROR: uvicorn 설치된 python 을 못 찾음 (pip install -r apps/api/requirements.txt)'; exit 1 }
Write-Host ("python: {0} {1}" -f $Python, ($PyPre -join ' '))
function Stop-Stale([string]$pattern, [string]$label) {
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { ($_.Name -eq 'python.exe' -or $_.Name -eq 'node.exe') -and $_.CommandLine -and $_.CommandLine -match $pattern } |
ForEach-Object {
Write-Host (" stop {0,-8} PID {1}" -f $label, $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
function Wait-Health([string]$url, [string]$label, [int]$timeoutSec = 40) {
$deadline = (Get-Date).AddSeconds($timeoutSec)
while ((Get-Date) -lt $deadline) {
try {
$r = Invoke-WebRequest -Uri $url -TimeoutSec 4 -UseBasicParsing -ErrorAction Stop
if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { Write-Host " OK $label ($url)"; return $true }
} catch { Start-Sleep -Milliseconds 600 }
}
Write-Host " WARN $label 미응답 ($url) — .devlogs 로그 확인"; return $false
}
function Ensure-DevDb {
# Docker Postgres(pgvector)를 apps/api/.env 의 DATABASE_URL 자격증명에 맞춰 보장한다.
# DB가 있어야 페르소나 source=database 가 되어 UI 회기 시작이 열린다(무DB면 degraded로 막힘).
docker ps *> $null 2>&1
if ($LASTEXITCODE -ne 0) { Write-Host " WARN Docker 데몬 미응답 — DB 없이 degraded(UI 세션 시작 제한). Docker Desktop 실행 필요."; return }
$running = docker ps --filter name=vignette-dev-db --format "{{.Names}}" 2>$null
if ("$running" -match 'vignette-dev-db') { Write-Host " OK db (vignette-dev-db 실행 중)"; return }
docker rm -f vignette-dev-db *> $null 2>&1
$envLines = Get-Content (Join-Path $api '.env')
$dbUrl = (($envLines | Where-Object { $_ -match '^DATABASE_URL=' }) -replace '^DATABASE_URL=','').Trim()
if ($dbUrl -notmatch 'postgresql://([^:]+):([^@]+)@[^:]+:([0-9]+)/(\S+)') { Write-Host " WARN DATABASE_URL 파싱 실패 — DB 스킵"; return }
$u=$Matches[1]; $p=$Matches[2]; $port=$Matches[3]; $db=$Matches[4]
$initPath = Join-Path $repo 'infra\db\init'
docker run -d --name vignette-dev-db -p "$port`:5432" -e POSTGRES_USER=$u -e POSTGRES_PASSWORD=$p -e POSTGRES_DB=$db -e APP_DB_USER=vignette_app -e APP_DB_PASSWORD=vignette_app -v "$initPath`:/docker-entrypoint-initdb.d:ro" pgvector/pgvector:pg16 *> $null
for ($i=0; $i -lt 24; $i++) {
Start-Sleep -Seconds 2
docker exec vignette-dev-db pg_isready -U $u *> $null 2>&1
if ($LASTEXITCODE -eq 0) { Write-Host " OK db (Postgres 준비됨, init 스키마 적용)"; Start-Sleep -Seconds 1; return }
}
Write-Host " WARN db 준비 타임아웃"
}
Write-Host "[1/4] 기존 스택 정리..."
Stop-Stale 'engine_gateway\.gateway' 'gateway'
Stop-Stale 'app\.main:app' 'api'
Stop-Stale 'vite' 'web'
Start-Sleep -Seconds 2
if (-not $NoDb) {
Write-Host "[DB] Postgres(pgvector) 보장..."
Ensure-DevDb
}
if (-not $NoGateway) {
Write-Host "[2/4] 엔진 게이트웨이 :9099 (claude_cli)..."
Start-Process -FilePath $Python `
-ArgumentList (@($PyPre) + @('-m','uvicorn','engine_gateway.gateway:app','--host','127.0.0.1','--port','9099')) `
-WorkingDirectory $api -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $logs 'gateway.out.log') `
-RedirectStandardError (Join-Path $logs 'gateway.err.log')
} else { Write-Host "[2/4] (게이트웨이 건너뜀)" }
Write-Host "[3/4] API :8000 (degraded in-memory OK, seed 페르소나)..."
$env:AUTO_SEED_PERSONAS = 'true'
$env:ALLOW_SEED_PERSONA_FALLBACK = 'true'
Start-Process -FilePath $Python `
-ArgumentList (@($PyPre) + @('-m','uvicorn','app.main:app','--host','127.0.0.1','--port','8000')) `
-WorkingDirectory $api -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $logs 'api.out.log') `
-RedirectStandardError (Join-Path $logs 'api.err.log')
if (-not $NoWeb) {
Write-Host "[4/4] 웹 vite :5173..."
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','npm run dev' `
-WorkingDirectory $web -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $logs 'web.out.log') `
-RedirectStandardError (Join-Path $logs 'web.err.log')
} else { Write-Host "[4/4] (웹 건너뜀)" }
Start-Sleep -Seconds 3
Write-Host "`n헬스 체크:"
if (-not $NoGateway) { Wait-Health 'http://127.0.0.1:9099/health' 'gateway' | Out-Null }
Wait-Health 'http://127.0.0.1:8000/health' 'api' | Out-Null
if (-not $NoWeb) { Wait-Health 'http://localhost:5173/' 'web' | Out-Null }
Write-Host "`n준비 완료. 진입점: http://localhost:5173 (로그인 페이지에서 dev-login)"
Write-Host "종료: powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-down.ps1"

View file

@ -0,0 +1,80 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[string]$TaskName = "VignettePublicRuntimeWatchdog",
[int]$IntervalMinutes = 5,
[switch]$SkipPublicHealth,
[switch]$SkipCloudflaredRestart,
[switch]$RunNow
)
$ErrorActionPreference = "Stop"
if ($IntervalMinutes -lt 1) {
throw "IntervalMinutes must be 1 or greater"
}
$watchScript = Join-Path $Workspace "scripts\watch-public-runtime.ps1"
if (!(Test-Path $watchScript)) {
throw "Watchdog script not found at $watchScript"
}
$powershell = (Get-Command powershell.exe).Source
$userId = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$actionArguments = @(
"-NoProfile",
"-ExecutionPolicy Bypass",
"-File `"$watchScript`"",
"-Workspace `"$Workspace`""
)
if ($SkipPublicHealth) {
$actionArguments += "-SkipPublicHealth"
}
if ($SkipCloudflaredRestart) {
$actionArguments += "-SkipCloudflaredRestart"
}
$action = New-ScheduledTaskAction `
-Execute $powershell `
-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 10) `
-MultipleInstances IgnoreNew `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 1) `
-StartWhenAvailable `
-WakeToRun
$principal = New-ScheduledTaskPrincipal `
-UserId $userId `
-LogonType Interactive `
-RunLevel Limited
$description = "Runs Vignette public runtime watchdog as $userId. Secrets stay in the user profile and apps/api/.env; 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: $powershell $($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,303 @@
#!/usr/bin/env python3
"""Measure a running engine gateway without embedding credentials.
The gateway process owns Claude authentication. This script only sends local HTTP
requests to an already-running gateway and is not used by normal tests.
"""
from __future__ import annotations
import argparse
import json
import os
import statistics
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
class ProbeError(RuntimeError):
pass
def _endpoint(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _json_request(
base_url: str,
method: str,
path: str,
payload: dict[str, Any] | None = None,
timeout: float = 30.0,
) -> dict[str, Any]:
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(
_endpoint(base_url, path),
data=body,
headers=headers,
method=method,
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise ProbeError(f"{method} {path} failed with HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise ProbeError(f"{method} {path} transport failed: {exc}") from exc
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ProbeError(f"{method} {path} returned non-JSON: {raw[:200]}") from exc
def _stream_request(
base_url: str,
payload: dict[str, Any],
timeout: float,
) -> dict[str, Any]:
req = urllib.request.Request(
_endpoint(base_url, "/v1/stream"),
data=json.dumps(payload).encode("utf-8"),
headers={"Accept": "text/event-stream", "Content-Type": "application/json"},
method="POST",
)
started = time.perf_counter()
first_token_at: float | None = None
chunks: list[str] = []
done_meta: dict[str, Any] = {}
done_seen = False
event_name: str | None = None
data_lines: list[str] = []
def flush_event(now: float) -> None:
nonlocal first_token_at, done_meta, done_seen, event_name, data_lines
if not event_name:
data_lines = []
return
data = "\n".join(data_lines)
if event_name == "token":
if first_token_at is None:
first_token_at = now
try:
token_payload = json.loads(data)
chunks.append(str(token_payload.get("text", "")))
except json.JSONDecodeError:
chunks.append(data)
elif event_name == "done":
done_meta = json.loads(data or "{}")
done_seen = True
elif event_name == "error":
try:
err = json.loads(data)
detail = err.get("detail", data)
except json.JSONDecodeError:
detail = data
raise ProbeError(f"stream returned error event: {detail}")
event_name = None
data_lines = []
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
for raw_line in resp:
now = time.perf_counter()
line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n")
if not line:
flush_event(now)
if done_seen:
break
continue
if line.startswith("event:"):
event_name = line[6:].strip()
elif line.startswith("data:"):
data_lines.append(line[5:].lstrip())
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise ProbeError(f"POST /v1/stream failed with HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise ProbeError(f"POST /v1/stream transport failed: {exc}") from exc
finished = time.perf_counter()
return {
"ttft_ms": None if first_token_at is None else round((first_token_at - started) * 1000, 1),
"latency_ms": round((finished - started) * 1000, 1),
"text_chars": len("".join(chunks)),
"done": done_meta,
}
def _generate_payload(
*,
prompt: str,
system_prompt: str,
session_id: str | None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"ai_role": "client",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
"temperature": 0.0,
"max_tokens": 128,
"metadata": {"probe": "engine-gateway-probe"},
}
if session_id:
payload["session_id"] = session_id
return payload
def _summarize(values: list[float | None]) -> dict[str, Any]:
clean = [v for v in values if v is not None]
if not clean:
return {"count": 0}
return {
"count": len(clean),
"min_ms": round(min(clean), 1),
"median_ms": round(statistics.median(clean), 1),
"mean_ms": round(statistics.fmean(clean), 1),
"max_ms": round(max(clean), 1),
}
def _summaries(measurements: list[dict[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for kind in sorted({item["kind"] for item in measurements}):
group = [item for item in measurements if item["kind"] == kind]
result[kind] = {
"ttft": _summarize([item.get("ttft_ms") for item in group]),
"latency": _summarize([item.get("latency_ms") for item in group]),
}
return result
def _print_text(result: dict[str, Any]) -> None:
print(f"Gateway: {result['base_url']}")
health = result["health"]
print(f"Health: ok={health.get('ok')} engine={health.get('engine')} sessions={health.get('sessions')}")
session_id = result.get("session_id")
if session_id:
print(f"Session: {session_id[:12]}... closed={result.get('closed')}")
for kind, summary in result["summary"].items():
print(
f"{kind}: "
f"ttft median={summary['ttft'].get('median_ms')} ms "
f"latency median={summary['latency'].get('median_ms')} ms "
f"n={summary['latency'].get('count', 0)}"
)
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Probe a running Vignette engine gateway for streaming TTFT, total latency, "
"and session reuse. Claude credentials are never passed to this script."
)
)
parser.add_argument(
"--base-url",
default=os.environ.get("ENGINE_URL", "http://127.0.0.1:9099"),
help="Gateway base URL. Defaults to ENGINE_URL or http://127.0.0.1:9099.",
)
parser.add_argument("--prompt", default="Reply with exactly OK.", help="Probe user prompt.")
parser.add_argument(
"--system-prompt",
default="You are an engine gateway probe. Reply concisely.",
help="System prompt used when creating the reusable session.",
)
parser.add_argument("--budget-usd", type=float, default=0.5, help="Budget for the created /session.")
parser.add_argument("--reuse-runs", type=int, default=2, help="Number of /v1/stream calls using one session_id.")
parser.add_argument(
"--ephemeral-runs",
type=int,
default=0,
help="Optional cold /v1/stream calls without session_id. Defaults to 0 to limit spend.",
)
parser.add_argument("--timeout-sec", type=float, default=120.0, help="HTTP timeout per generation.")
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
if args.reuse_runs < 0 or args.ephemeral_runs < 0:
raise ProbeError("run counts must be non-negative")
measurements: list[dict[str, Any]] = []
session_id: str | None = None
closed = False
health = _json_request(args.base_url, "GET", "/health", timeout=10.0)
if not health.get("ok"):
raise ProbeError(f"gateway health is not ok: {health}")
try:
if args.reuse_runs:
created = _json_request(
args.base_url,
"POST",
"/session",
{"system_prompt": args.system_prompt, "budget_usd": args.budget_usd},
timeout=args.timeout_sec,
)
session_id = str(created["session_id"])
for index in range(args.reuse_runs):
measured = _stream_request(
args.base_url,
_generate_payload(
prompt=args.prompt,
system_prompt=args.system_prompt,
session_id=session_id,
),
timeout=args.timeout_sec,
)
measured.update({"kind": "reused_stream", "run": index + 1})
measurements.append(measured)
for index in range(args.ephemeral_runs):
measured = _stream_request(
args.base_url,
_generate_payload(
prompt=args.prompt,
system_prompt=args.system_prompt,
session_id=None,
),
timeout=args.timeout_sec,
)
measured.update({"kind": "ephemeral_stream", "run": index + 1})
measurements.append(measured)
finally:
if session_id:
quoted = urllib.parse.quote(session_id, safe="")
closed_payload = _json_request(args.base_url, "DELETE", f"/session/{quoted}", timeout=10.0)
closed = bool(closed_payload.get("closed"))
result = {
"base_url": args.base_url,
"health": health,
"session_id": session_id,
"closed": closed,
"measurements": measurements,
"summary": _summaries(measurements),
}
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
_print_text(result)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main(sys.argv[1:]))
except ProbeError as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(2)

View file

@ -1,17 +1,68 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[int]$ApiPort = 8001,
[int]$EnginePort = 9099,
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
[switch]$SkipEngineRestart,
[switch]$SkipCloudflaredRestart
)
$ErrorActionPreference = "Stop"
$ApiDir = Join-Path $Workspace "apps\api"
$Python = Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"
$OutLog = Join-Path $ApiDir "api.public.out.log"
$ErrLog = Join-Path $ApiDir "api.public.err.log"
$EngineOutLog = Join-Path $ApiDir "engine.public.out.log"
$EngineErrLog = Join-Path $ApiDir "engine.public.err.log"
function Get-JsonHealth {
param(
[string]$Uri,
[int]$TimeoutSec = 5
)
try {
Invoke-RestMethod -Uri $Uri -TimeoutSec $TimeoutSec
} catch {
$null
}
}
function Wait-JsonHealth {
param(
[string]$Uri,
[scriptblock]$IsHealthy,
[int]$TimeoutSec = 30
)
$deadline = (Get-Date).AddSeconds($TimeoutSec)
do {
$health = Get-JsonHealth -Uri $Uri -TimeoutSec 5
if ($null -ne $health -and (& $IsHealthy $health)) {
return $health
}
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for healthy response from $Uri"
}
function Stop-UvicornByPort {
param(
[string]$AppImport,
[int]$Port
)
Get-CimInstance Win32_Process |
Where-Object {
$_.CommandLine -and
$_.CommandLine -like "*uvicorn $AppImport*" -and
$_.CommandLine -like "*--port $Port*"
} |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
}
if (!(Test-Path $Python)) {
throw "Python 3.11 not found at $Python"
@ -20,16 +71,37 @@ if (!(Test-Path $ApiDir)) {
throw "API directory not found at $ApiDir"
}
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -like "*uvicorn app.main:app*--port $ApiPort*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
$engineHealth = Get-JsonHealth -Uri "http://127.0.0.1:$EnginePort/health"
if ($SkipEngineRestart) {
if ($null -eq $engineHealth -or -not $engineHealth.ok) {
throw "Engine gateway is not healthy on http://127.0.0.1:$EnginePort/health"
}
} elseif ($null -eq $engineHealth -or -not $engineHealth.ok) {
Stop-UvicornByPort -AppImport "engine_gateway.gateway:app" -Port $EnginePort
Start-Process -WindowStyle Hidden -FilePath $Python `
-ArgumentList @("-m", "uvicorn", "engine_gateway.gateway:app", "--host", "127.0.0.1", "--port", "$EnginePort") `
-WorkingDirectory $ApiDir `
-RedirectStandardOutput $EngineOutLog `
-RedirectStandardError $EngineErrLog `
-PassThru | Out-Null
$engineHealth = Wait-JsonHealth `
-Uri "http://127.0.0.1:$EnginePort/health" `
-IsHealthy { param($health) $health.ok -eq $true } `
-TimeoutSec 30
}
Stop-UvicornByPort -AppImport "app.main:app" -Port $ApiPort
$env:ENVIRONMENT = "prod"
$env:ENGINE_URL = "http://127.0.0.1:$EnginePort"
$env:ENGINE_MODE = "claude_cli"
$env:AUTH_DEV_LOGIN_ENABLED = "false"
$env:AUTO_SEED_PERSONAS = "false"
$env:ALLOW_SEED_PERSONA_FALLBACK = "false"
$env:FRONTEND_BASE_URL = "https://vignette.chanpaca.net"
$env:CORS_ORIGINS = '["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev"]'
$env:CORS_ORIGINS = '["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"]'
$proc = Start-Process -WindowStyle Hidden -FilePath $Python `
-ArgumentList @("-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "$ApiPort") `
@ -39,7 +111,10 @@ $proc = Start-Process -WindowStyle Hidden -FilePath $Python `
-PassThru
Start-Sleep -Seconds 3
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$ApiPort/health" -TimeoutSec 20
$health = Wait-JsonHealth `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 30
if ($health.environment -ne "prod" -or -not $health.db -or -not $health.engine) {
throw "Public API health is not production-safe: $($health | ConvertTo-Json -Compress)"
}
@ -89,5 +164,6 @@ if (!$SkipCloudflaredRestart) {
-PassThru | Out-Null
}
Write-Output "Engine gateway healthy on http://127.0.0.1:$EnginePort"
Write-Output "Public API running on http://127.0.0.1:$ApiPort with PID $($proc.Id)"
Write-Output "Health: $($health | ConvertTo-Json -Compress)"

View file

@ -0,0 +1,157 @@
param(
[string]$Workspace = "D:\workspace\vignette",
[int]$ApiPort = 8001,
[int]$EnginePort = 9099,
[string]$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe",
[string]$Cloudflared = "$env:LOCALAPPDATA\Microsoft\WinGet\Links\cloudflared.exe",
[string]$CloudflaredConfig = "$env:USERPROFILE\.cloudflared\vignette-config.yml",
[string]$PublicHealthUrl = "https://api-vignette.chanpaca.net/health",
[string]$LogPath = "",
[switch]$CheckOnly,
[switch]$SkipPublicHealth,
[switch]$SkipCloudflaredRestart
)
$ErrorActionPreference = "Stop"
if (!$LogPath) {
$LogPath = Join-Path $Workspace "public-runtime-watchdog.log"
}
function Write-WatchdogLog {
param([string]$Message)
$line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-ddTHH:mm:ssK"), $Message
Write-Output $line
Add-Content -Path $LogPath -Value $line -Encoding UTF8
}
function Test-JsonHealth {
param(
[string]$Name,
[string]$Uri,
[scriptblock]$IsHealthy,
[int]$TimeoutSec = 10
)
try {
$response = Invoke-RestMethod -Uri $Uri -TimeoutSec $TimeoutSec
$ok = [bool](& $IsHealthy $response)
$detail = $response | ConvertTo-Json -Compress -Depth 5
[pscustomobject]@{
Name = $Name
Ok = $ok
Detail = $detail
}
} catch {
[pscustomobject]@{
Name = $Name
Ok = $false
Detail = $_.Exception.Message
}
}
}
function Test-CloudflaredProcess {
if ($SkipCloudflaredRestart) {
return [pscustomobject]@{
Name = "cloudflared"
Ok = $true
Detail = "skipped"
}
}
$configLeaf = Split-Path -Leaf $CloudflaredConfig
$process = Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "cloudflared.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*$configLeaf*"
} |
Select-Object -First 1
[pscustomobject]@{
Name = "cloudflared"
Ok = $null -ne $process
Detail = if ($process) { "pid=$($process.ProcessId)" } else { "not running" }
}
}
$startScript = Join-Path $PSScriptRoot "start-public-runtime.ps1"
if (!(Test-Path $startScript)) {
throw "Start script not found at $startScript"
}
$checks = @(
(Test-JsonHealth `
-Name "engine" `
-Uri "http://127.0.0.1:$EnginePort/health" `
-IsHealthy { param($health) $health.ok -eq $true }),
(Test-JsonHealth `
-Name "api" `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine }),
(Test-CloudflaredProcess)
)
if (!$SkipPublicHealth) {
$checks += Test-JsonHealth `
-Name "public-api" `
-Uri $PublicHealthUrl `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
}
$failed = @($checks | Where-Object { -not $_.Ok })
if ($failed.Count -eq 0) {
Write-WatchdogLog "healthy: $($checks.Name -join ', ')"
exit 0
}
Write-WatchdogLog "unhealthy: $((($failed | ForEach-Object { "$($_.Name)=$($_.Detail)" }) -join '; '))"
if ($CheckOnly) {
exit 1
}
$startArgs = @{
Workspace = $Workspace
ApiPort = $ApiPort
EnginePort = $EnginePort
Python = $Python
Cloudflared = $Cloudflared
CloudflaredConfig = $CloudflaredConfig
}
if ($SkipCloudflaredRestart) {
$startArgs["SkipCloudflaredRestart"] = $true
}
try {
& $startScript @startArgs 2>&1 | ForEach-Object {
Write-WatchdogLog "$_"
}
} catch {
Write-WatchdogLog "restart failed: $($_.Exception.Message)"
throw
}
$apiAfter = Test-JsonHealth `
-Name "api" `
-Uri "http://127.0.0.1:$ApiPort/health" `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
if (!$apiAfter.Ok) {
throw "Public API still unhealthy after restart: $($apiAfter.Detail)"
}
if (!$SkipPublicHealth) {
$publicAfter = Test-JsonHealth `
-Name "public-api" `
-Uri $PublicHealthUrl `
-IsHealthy { param($health) $health.environment -eq "prod" -and $health.db -and $health.engine } `
-TimeoutSec 20
if (!$publicAfter.Ok) {
throw "Public API tunnel still unhealthy after restart: $($publicAfter.Detail)"
}
}
Write-WatchdogLog "restart verified"