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 산출물은 커밋에서 제외했다.
481 lines
18 KiB
Python
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()
|