vignette/scripts/smoke-rupture-repair-api.py
Yun Chan 16e791e044 G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

481 lines
18 KiB
Python

"""Exercise the G3 rupture/repair HTTP ledger against a live API and DB.
The smoke retains unique dev/E2E identities and records only IDs and metadata in
its output. It proves authenticated evaluator ingestion, append-only retry
semantics, role/cohort isolation, durable turn evidence, and teacher correction.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import secrets
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from http.cookiejar import CookieJar
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
import asyncpg
COHORT_ID = "e2e-hanshin"
class SmokeError(RuntimeError):
pass
@dataclass(frozen=True)
class ApiResponse:
status: int
body: Any
class ApiClient:
def __init__(self, base_url: str, timeout: float) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar())
)
def request(
self,
method: str,
path: str,
payload: dict[str, Any] | None = None,
*,
expected: set[int] | None = None,
headers: dict[str, str] | None = None,
) -> ApiResponse:
data = None
request_headers = {"Accept": "application/json", **(headers or {})}
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request_headers["Content-Type"] = "application/json"
request = urllib.request.Request(
f"{self.base_url}{path}",
data=data,
headers=request_headers,
method=method,
)
try:
with self._opener.open(request, timeout=self.timeout) as response:
raw = response.read().decode("utf-8")
result = ApiResponse(
response.status,
json.loads(raw) if raw else {},
)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
try:
body = json.loads(raw) if raw else {}
except json.JSONDecodeError:
body = {"detail": raw[:500]}
result = ApiResponse(exc.code, body)
except urllib.error.URLError as exc:
raise SmokeError(
f"{method} {path} transport failed: {type(exc.reason).__name__}"
) from exc
allowed = expected or {200}
if result.status not in allowed:
detail = result.body.get("detail") if isinstance(result.body, dict) else None
raise SmokeError(
f"{method} {path} returned HTTP {result.status}; detail={detail!r}"
)
return result
def _load_api_env() -> None:
env_path = Path("apps/api/.env")
if not env_path.exists():
return
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
async def _fetch_db_state_proof(
dsn: str,
*,
session_id: str,
episode_ids: list[str],
teacher_id: str,
) -> dict[str, Any]:
conn = await asyncpg.connect(dsn)
try:
async with conn.transaction():
await conn.execute("SELECT set_config('app.ai_context', '', true)")
await conn.execute("SELECT set_config('app.current_role', 'admin', true)")
await conn.execute("SELECT set_config('app.current_uid', $1, true)", teacher_id)
await conn.execute("SELECT set_config('app.current_cohort', $1, true)", COHORT_ID)
rows = await conn.fetch(
"""
SELECT episode_id::text, sequence_no, event_kind, from_state, to_state,
created_by_role, created_at
FROM app.rupture_observation_event
WHERE session_id = $1::uuid
AND episode_id = ANY($2::uuid[])
ORDER BY episode_id, sequence_no
""",
session_id,
[UUID(item) for item in episode_ids],
)
finally:
await conn.close()
by_episode: dict[str, list[dict[str, Any]]] = {}
for row in rows:
by_episode.setdefault(str(row["episode_id"]), []).append(dict(row))
if set(by_episode) != set(episode_ids):
raise SmokeError("Postgres omitted one or more three-state rupture episodes")
for episode_id, events in by_episode.items():
sequence = [int(item["sequence_no"]) for item in events]
if sequence != list(range(1, len(sequence) + 1)):
raise SmokeError(
f"Postgres rupture sequence is not contiguous for {episode_id}: {sequence}"
)
return {
"episode_count": len(by_episode),
"observation_event_count": len(rows),
"append_only_sequences_contiguous": True,
"terminal_state_by_episode": {
episode_id: str(events[-1]["to_state"])
for episode_id, events in by_episode.items()
},
}
def _sign_in(
client: ApiClient,
*,
suffix: str,
identity: str,
role: str,
cohort_ids: list[str],
) -> str:
login = client.request(
"POST",
"/auth/dev-login",
{
"email": f"dev.e2e.rupture.{identity}.{suffix}@hs.ac.kr",
"role": role,
"display_name": f"Rupture {identity.title()}",
"cohort_ids": cohort_ids,
},
)
client.request(
"POST",
"/users/me/onboarding",
{
"legal_name": f"Rupture {identity.title()}",
"affiliation": "한신대학교",
"department": "상담심리학과",
"grade_level": "통합검증",
"phone": "010-0000-0000",
"contact_address": "경기도 오산시 한신대학교",
"nickname": f"Rupture {identity.title()}",
"self_introduction": "G3 파열·수선 API 검증 fixture입니다.",
"avatar_url": "",
"terms_accepted": True,
"privacy_accepted": True,
},
)
user_id = str(login.body.get("user_id") or "")
if not user_id:
raise SmokeError("dev-login response omitted user_id")
return user_id
def _choose_persona(client: ApiClient) -> str:
response = client.request("GET", "/personas")
usable = [
item
for item in response.body
if isinstance(item, dict)
and item.get("source") == "database"
and not item.get("degraded")
and item.get("code")
]
if not usable:
raise SmokeError("persona catalog has no non-degraded database persona")
return str(next((item for item in usable if item["code"] == "P1"), usable[0])["code"])
def _durable_turn_ids(review: dict[str, Any]) -> list[str]:
ids = [str(item["turn_id"]) for item in review.get("turns", []) if item.get("turn_id")]
if len(ids) < 2:
raise SmokeError("session review did not expose both durable turn UUIDs")
return ids[:2]
def _assert_no_total_score(value: Any, path: str = "response") -> None:
if isinstance(value, dict):
forbidden = {"total", "total_score", "overall_score"} & set(value)
if forbidden:
raise SmokeError(f"{path} exposed forbidden total score keys: {forbidden}")
for key, child in value.items():
_assert_no_total_score(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
_assert_no_total_score(child, f"{path}[{index}]")
def run(args: argparse.Namespace) -> dict[str, Any]:
_load_api_env()
dsn = args.database_url or os.environ.get("DATABASE_URL")
if not dsn:
raise SmokeError("DATABASE_URL is required via --database-url or apps/api/.env")
if len(args.internal_token) < 32:
raise SmokeError("--internal-token must contain at least 32 characters")
health_client = ApiClient(args.api_base_url, args.request_timeout)
health = health_client.request("GET", "/health")
if not health.body.get("db") or not health.body.get("engine"):
raise SmokeError("API health is not DB+engine ready")
suffix = f"{int(time.time())}.{secrets.token_hex(3)}"
learner = ApiClient(args.api_base_url, args.request_timeout)
teacher = ApiClient(args.api_base_url, args.request_timeout)
other_learner = ApiClient(args.api_base_url, args.request_timeout)
other_teacher = ApiClient(args.api_base_url, args.request_timeout)
signed_in: dict[str, str] = {}
for client, identity, role, cohorts in (
(learner, "learner", "learner", [COHORT_ID]),
(teacher, "teacher", "teacher", [COHORT_ID]),
(other_learner, "other-learner", "learner", [COHORT_ID]),
(other_teacher, "other-teacher", "teacher", ["e2e-other-cohort"]),
):
signed_in[identity] = _sign_in(
client,
suffix=suffix,
identity=identity,
role=role,
cohort_ids=cohorts,
)
started = learner.request(
"POST",
"/sessions",
{
"persona_code": _choose_persona(learner),
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
expected={201},
)
session_id = str(started.body["session_id"])
learner.request(
"POST",
f"/sessions/{session_id}/turn",
{"text": "내가 느낀 감정과 다르게 들렸다면 바로 말해줘도 괜찮아요."},
)
learner.request("POST", f"/sessions/{session_id}/end")
review = learner.request("GET", f"/sessions/{session_id}/review")
turn_ids = _durable_turn_ids(review.body)
internal = ApiClient(args.api_base_url, args.request_timeout)
token_header = {"X-Vignette-Rupture-Token": args.internal_token}
observation_idempotency = str(uuid4())
observation = {
"episode_key": f"live-smoke:{uuid4()}",
"idempotency_key": observation_idempotency,
"event_kind": "rupture.detected",
"from_state": None,
"to_state": "onset",
"rupture_type": "empathic_miss",
"source_kind": "observed_runtime",
"perspective": "runtime_observation",
"ai_view": "evaluator",
"confidence": 0.88,
"uncertainty": 0.12,
"evidence_turn_ids": turn_ids,
"counterevidence": ["single_scene_requires_supervisor_review"],
"safety_event_ids": [],
}
path = f"/internal/sessions/{session_id}/ruptures/observations"
created = internal.request(
"POST", path, observation, expected={201}, headers=token_header
)
retried = internal.request(
"POST", path, observation, expected={201}, headers=token_header
)
if retried.body != created.body:
raise SmokeError("same observation retry changed durable IDs")
changed = dict(observation, uncertainty=0.13)
internal.request("POST", path, changed, expected={409}, headers=token_header)
def append_episode(events: list[tuple[str, str | None, str]]) -> tuple[str, str]:
episode_key = f"live-smoke:{uuid4()}"
first_episode_id = ""
last_observation_id = ""
for event_kind, from_state, to_state in events:
payload = {
**observation,
"episode_key": episode_key,
"idempotency_key": str(uuid4()),
"event_kind": event_kind,
"from_state": from_state,
"to_state": to_state,
}
response = internal.request(
"POST", path, payload, expected={201}, headers=token_header
)
current_episode_id = str(response.body["episode_id"])
if first_episode_id and current_episode_id != first_episode_id:
raise SmokeError("one lifecycle sequence split across rupture episodes")
first_episode_id = current_episode_id
last_observation_id = str(response.body["observation_id"])
return first_episode_id, last_observation_id
missed_episode_id, _ = append_episode(
[
("rupture.detected", None, "onset"),
("rupture.missed", "onset", "missed"),
]
)
partial_episode_id, _ = append_episode(
[
("rupture.detected", None, "onset"),
("rupture.recognized", "onset", "recognized"),
("repair.attempted", "recognized", "repair_attempted"),
("repair.partial", "repair_attempted", "partial"),
]
)
episode_id = str(created.body["episode_id"])
observation_id = str(created.body["observation_id"])
learner_read = learner.request("GET", f"/sessions/{session_id}/ruptures")
teacher.request("GET", f"/sessions/{session_id}/ruptures")
other_learner.request("GET", f"/sessions/{session_id}/ruptures", expected={404})
other_teacher.request("GET", f"/sessions/{session_id}/ruptures", expected={404})
if learner_read.body.get("clinical_claim_allowed") is not False:
raise SmokeError("learner rupture read omitted non-clinical boundary")
if learner_read.body.get("requested_view") != "counselor":
raise SmokeError("learner rupture read used the wrong role projection")
_assert_no_total_score(learner_read.body)
correction_idempotency = str(uuid4())
correction = {
"idempotency_key": correction_idempotency,
"supersedes_observation_id": observation_id,
"rupture_type": "empathic_miss",
"corrected_status": "resolved",
"uncertainty": 0.05,
"evidence_turn_ids": turn_ids,
"counterevidence": [],
"correction_reason": "후속 수퍼비전에서 영향 확인과 재합의 근거를 확인함",
}
correction_path = f"/sessions/{session_id}/ruptures/{episode_id}/corrections"
corrected = teacher.request(
"POST", correction_path, correction, expected={201}
)
corrected_retry = teacher.request(
"POST", correction_path, correction, expected={201}
)
if corrected_retry.body != corrected.body:
raise SmokeError("same teacher correction retry changed durable IDs")
teacher.request(
"POST",
correction_path,
dict(correction, uncertainty=0.06),
expected={409},
)
final_read = learner.request("GET", f"/sessions/{session_id}/ruptures")
episodes = final_read.body.get("episodes") or []
target = next((item for item in episodes if item.get("episode_id") == episode_id), None)
if not target or target.get("current_status") != "resolved":
raise SmokeError("teacher correction did not become the current append-only status")
expected_states = {
missed_episode_id: "missed",
partial_episode_id: "partial",
episode_id: "resolved",
}
actual_states = {
str(item.get("episode_id")): str(item.get("current_status"))
for item in episodes
if str(item.get("episode_id")) in expected_states
}
if actual_states != expected_states:
raise SmokeError(
f"API three-state read model differs: {actual_states} != {expected_states}"
)
db_proof = asyncio.run(
_fetch_db_state_proof(
dsn,
session_id=session_id,
episode_ids=list(expected_states),
teacher_id=signed_in["teacher"],
)
)
if db_proof["terminal_state_by_episode"] != expected_states:
raise SmokeError(
"Postgres three-state terminal rows differ from the API read model"
)
return {
"ok": True,
"api_base_url": args.api_base_url,
"fixture_policy": "retained unique dev:e2e identities; no fixture deletion",
"session_id": session_id,
"episode_id": episode_id,
"three_state_episode_ids": {
"missed": missed_episode_id,
"partial": partial_episode_id,
"resolved": episode_id,
},
"observation_id": observation_id,
"correction_id": corrected.body.get("observation_id"),
"proof": {
"durable_turn_uuid_count": len(turn_ids),
"authenticated_internal_ingestion": True,
"same_observation_retry_stable": True,
"changed_observation_retry_rejected": True,
"learner_role_projection": "counselor",
"teacher_cohort_read": True,
"other_learner_rejected": True,
"cross_cohort_teacher_rejected": True,
"same_correction_retry_stable": True,
"changed_correction_retry_rejected": True,
"append_only_current_status": target.get("current_status"),
"api_state_coverage": sorted(set(actual_states.values())),
"api_state_by_episode": actual_states,
"postgres": db_proof,
"no_total_score": True,
"clinical_claim_allowed": final_read.body.get("clinical_claim_allowed"),
},
}
def main() -> None:
_load_api_env()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-base-url", default="http://127.0.0.1:8007")
parser.add_argument(
"--internal-token",
default=os.environ.get("VIGNETTE_RUPTURE_INTERNAL_TOKEN", ""),
)
parser.add_argument("--database-url", default="")
parser.add_argument("--request-timeout", type=float, default=180.0)
parser.add_argument("--out", default="")
args = parser.parse_args()
result = run(args)
text = json.dumps(result, ensure_ascii=False, indent=2)
if args.out:
path = Path(args.out)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text + "\n", encoding="utf-8")
print(text)
if __name__ == "__main__":
main()