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 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,477 @@
"""Prepare one live DB fixture for the returned-practice browser closed loop.
This harness owns setup only. The two authoritative learner writes are left for
Playwright:
* POST /practice/{prescription}/attempts/from-session/{practice_session}
* POST /calibration/transfer-executions
The output contains opaque fixture anchors and must stay in a disposable temp
directory. It is not a shareable evidence artifact.
"""
from __future__ import annotations
import argparse
import asyncio
import copy
import importlib.util
import json
import secrets
import sys
import time
from pathlib import Path
from types import ModuleType
from typing import Any
from uuid import uuid4
REPO_ROOT = Path(__file__).resolve().parents[4]
SCRIPTS_DIR = REPO_ROOT / "scripts"
BENCHMARK_PATH = (
REPO_ROOT
/ "apps"
/ "api"
/ "app"
/ "data"
/ "deliberate_practice_benchmark_g4.v1.json"
)
COHORT_ID = "e2e-hanshin"
class FixtureError(RuntimeError):
pass
def _load_smoke_helper(filename: str, module_name: str) -> ModuleType:
path = SCRIPTS_DIR / filename
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise FixtureError(f"cannot load smoke helper: {filename}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _initial_runtime_count(read_model: dict[str, Any], practice_session_id: str) -> int:
return sum(
len(item.get("attempts") or [])
for item in read_model.get("episodes") or []
if str(item.get("session_id")) == practice_session_id
)
def _initial_transfer_count(
read_model: dict[str, Any],
*,
trial_record_id: str,
practice_session_id: str,
) -> int:
return sum(
1
for item in read_model.get("actual_executions") or []
if str(item.get("original_transfer_trial_record_id")) == trial_record_id
and str(item.get("practice_session_id")) == practice_session_id
)
async def _find_resumable_source(dsn: str) -> dict[str, str] | None:
"""Find the one scratch-only source that already passed session evaluation."""
import asyncpg
conn = await asyncpg.connect(dsn)
try:
row = await conn.fetchrow(
"""
SELECT u.email, u.user_id::text, s.id::text AS session_id,
s.persona_code, p.prescription_key
FROM app.app_user u
JOIN app.sessions s ON s.learner_id = u.user_id
JOIN app.session_evaluation e ON e.session_id = s.id
JOIN app.practice_prescription p ON p.session_id = s.id
WHERE u.external_id LIKE 'dev:%returned-practice%'
AND e.status = 'ready'
AND p.prescription_key = 'oas-g4-practice-reward-replay'
ORDER BY e.created_at DESC
LIMIT 1
"""
)
finally:
await conn.close()
if row is None:
return None
return {key: str(row[key]) for key in row.keys()}
def run(args: argparse.Namespace) -> dict[str, Any]:
if len(args.practice_internal_token) < 32:
raise FixtureError("practice internal token must contain at least 32 characters")
if len(args.transfer_internal_token) < 32:
raise FixtureError("transfer internal token must contain at least 32 characters")
g4 = _load_smoke_helper(
"smoke-deliberate-practice-api.py", "vignette_g4_smoke_helper"
)
g5 = _load_smoke_helper(
"smoke-calibration-transfer-api.py", "vignette_g5_smoke_helper"
)
client = g4.ApiClient(args.api_base_url, args.request_timeout)
health = client.request("GET", "/health")
if not health.body.get("db") or not health.body.get("engine"):
raise FixtureError("API health is not DB+engine ready")
resumable = (
asyncio.run(
_find_resumable_source(args.database_admin_url or args.database_url)
)
if args.resume_ready_source
else None
)
if args.resume_ready_source and resumable is None:
raise FixtureError("no review-ready scratch source is available to resume")
suffix = f"{int(time.time())}.{secrets.token_hex(4)}"
email = (
resumable["email"]
if resumable
else f"dev.e2e.returned-practice.{suffix}@hs.ac.kr"
)
login = {
"email": email,
"role": "learner",
"display_name": "Returned Practice Learner",
"cohort_ids": [COHORT_ID],
}
client.request("POST", "/auth/dev-login", login)
client.request(
"POST",
"/users/me/onboarding",
{
"legal_name": "Returned Practice Learner",
"affiliation": "한신대학교",
"department": "상담심리학과",
"grade_level": "통합검증",
"phone": "010-0000-0000",
"contact_address": "경기도 오산시 한신대학교",
"nickname": "Returned Practice Learner",
"self_introduction": "브라우저 원장 폐루프 검증 fixture입니다.",
"avatar_url": "",
"terms_accepted": True,
"privacy_accepted": True,
},
)
me = client.request("GET", "/auth/me")
learner_id = str(me.body.get("user_id") or "")
if not learner_id:
raise FixtureError("dev-login omitted learner id")
if resumable and learner_id != resumable["user_id"]:
raise FixtureError("resumed login did not resolve to the scratch source owner")
source_persona, practice_persona = g4._choose_distinct_personas(client)
if resumable:
source_persona = resumable["persona_code"]
if practice_persona == source_persona:
catalog = client.request("GET", "/personas").body
practice_persona = next(
str(item["code"])
for item in catalog
if isinstance(item, dict)
and item.get("source") == "database"
and not item.get("degraded")
and item.get("code") != source_persona
)
source_session_id = resumable["session_id"]
review_response = client.request(
"GET", f"/sessions/{source_session_id}/review"
)
if review_response.body.get("reviewReady") is not True:
raise FixtureError("resumed source review is no longer ready")
source_review = {"poll_count": 0, "review": review_response.body}
else:
source_started = client.request(
"POST",
"/sessions",
{
"persona_code": source_persona,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
expected={201},
)
source_session_id = str(source_started.body["session_id"])
client.request(
"POST",
f"/sessions/{source_session_id}/turn",
{
"text": (
"지금 느끼는 막막함을 제가 제대로 이해했는지 "
"먼저 확인해도 괜찮을까요?"
)
},
)
client.request("POST", f"/sessions/{source_session_id}/end")
source_review = g4._wait_for_session_review(
client,
source_session_id,
timeout=args.review_poll_timeout,
interval=args.review_poll_interval,
)
source_turn_ids = g4._durable_turn_ids(source_review["review"])
# G4: prepare an authoritative prescription, but leave the completed-session
# observation absent so the browser owns the first write.
live_case = g4._load_live_case(BENCHMARK_PATH, source_turn_ids)
if resumable:
prescription_id = resumable["prescription_key"]
else:
practice_internal = g4.ApiClient(args.api_base_url, args.request_timeout)
practice_headers = {
"X-Vignette-Practice-Token": args.practice_internal_token
}
prescription_submission = {
"submission_id": str(uuid4()),
"coaching_cards": live_case["coaching_cards"],
"competency_graph": live_case["graph"],
"evidence_turn_ids": source_turn_ids,
}
prescription_path = (
f"/internal/sessions/{source_session_id}/practice/prescriptions"
)
prescription_created = practice_internal.request(
"POST",
prescription_path,
prescription_submission,
expected={201},
headers=practice_headers,
)
prescription_retried = practice_internal.request(
"POST",
prescription_path,
prescription_submission,
expected={201},
headers=practice_headers,
)
if prescription_retried.body.get("idempotent_replay") is not True:
raise FixtureError("G4 prescription setup retry was not idempotent")
prescription_id = str(prescription_created.body["next_prescription_id"])
g4_target = live_case["coaching_cards"][0]["targets"][0]
# G5: establish prediction -> lock -> independent observation -> suite.
# The actual transfer execution remains absent for the browser.
history_id = str(uuid4())
revision_id = str(uuid4())
fixture_suffix = secrets.token_hex(5)
revision = {
"submission_id": str(uuid4()),
"prediction_revision_id": revision_id,
"history_id": history_id,
"session_id": source_session_id,
"competency_id": "competency.empathic_attunement",
"practice_block_id": f"oas-g5-block-browser-{fixture_suffix}",
"scenario_variant_id": f"browser-scenario-{fixture_suffix}",
"phrase_family_id": f"browser-phrase-{fixture_suffix}",
"revision_no": 1,
"supersedes_prediction_revision_id": None,
"predicted_success_probability": 0.72,
"confidence": 0.80,
"recorded_sequence": 1,
"revision_reason": "외부평가 전에 장면 근거로 성공 가능성을 예측함",
"instrument_id": g5.INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
"evidence_turn_ids": source_turn_ids,
}
client.request(
"POST", "/calibration/predictions/revisions", revision, expected={201}
)
client.request(
"POST",
f"/calibration/predictions/{history_id}/lock",
{
"submission_id": str(uuid4()),
"lock_id": str(uuid4()),
"prediction_revision_id": revision_id,
"locked_sequence": 1,
},
expected={201},
)
transfer_internal = g4.ApiClient(args.api_base_url, args.request_timeout)
transfer_headers = {
"X-Vignette-Calibration-Transfer-Token": args.transfer_internal_token
}
transfer_internal.request(
"POST",
"/internal/calibration/performance-observations",
{
"submission_id": str(uuid4()),
"observation_id": str(uuid4()),
"history_id": history_id,
"status": "passed",
"source_kind": "observed_runtime",
"perspective": "runtime_observation",
"model_run_id": None,
"instrument_id": g5.INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
"uncertainty": 0.18,
"evidence_turn_ids": source_turn_ids,
"counterevidence": ["single_scene_transfer_not_yet_verified"],
"revealed_sequence": 2,
},
expected={201},
headers=transfer_headers,
)
suite_model_run_id = asyncio.run(
g5._create_transfer_suite_model_run(
args.database_url,
learner_id=learner_id,
source_session_id=source_session_id,
evidence_turn_ids=source_turn_ids,
)
)
transfer_suite = g5._build_transfer_suite(
fixture_suffix=fixture_suffix,
evidence_turn_ids=source_turn_ids,
)
transfer_suite_record_id = str(uuid4())
suite_created = transfer_internal.request(
"POST",
f"/internal/sessions/{source_session_id}/calibration/transfer-suites",
{
"submission_id": str(uuid4()),
"transfer_suite_record_id": transfer_suite_record_id,
"suite": copy.deepcopy(transfer_suite),
"model_run_id": suite_model_run_id,
"instrument_id": g5.TRANSFER_INSTRUMENT_ID,
"instrument_version": g5.INSTRUMENT_VERSION,
},
expected={201},
headers=transfer_headers,
)
if suite_created.body.get("trial_count") != 1:
raise FixtureError("G5 suite setup omitted its authoritative trial")
calibration_read = client.request("GET", "/calibration/learners/me").body
suite_projection = next(
(
item
for item in calibration_read.get("transfer_suites") or []
if str(item.get("transfer_suite_record_id"))
== transfer_suite_record_id
),
None,
)
if suite_projection is None or len(suite_projection.get("trials") or []) != 1:
raise FixtureError("G5 read model omitted authoritative suite trial")
trial_record_id = str(
suite_projection["trials"][0]["transfer_trial_record_id"]
)
# One distinct-persona, completed follow-up session is shared by G4 and G5.
practice_started = client.request(
"POST",
"/sessions",
{
"persona_code": practice_persona,
"theory_mode": "humanistic",
"goal_stages": ["라포", "탐색"],
},
expected={201},
)
practice_session_id = str(practice_started.body["session_id"])
client.request(
"POST",
f"/sessions/{practice_session_id}/turn",
{
"text": (
"그 말을 꺼내기까지 많이 외롭고 조심스러웠던 것 같아요. "
"제가 이해한 마음이 맞는지 함께 확인해도 괜찮을까요?"
)
},
)
client.request("POST", f"/sessions/{practice_session_id}/end")
practice_review = g4._wait_for_session_review(
client,
practice_session_id,
timeout=args.review_poll_timeout,
interval=args.review_poll_interval,
)
practice_turn_ids = g4._durable_turn_ids(practice_review["review"])
practice_read = client.request("GET", "/practice/learners/me").body
calibration_read = client.request("GET", "/calibration/learners/me").body
runtime_count = _initial_runtime_count(practice_read, practice_session_id)
transfer_count = _initial_transfer_count(
calibration_read,
trial_record_id=trial_record_id,
practice_session_id=practice_session_id,
)
if runtime_count != 0 or transfer_count != 0:
raise FixtureError("browser-owned closed-loop writes already exist")
return {
"schema_version": "vignette.returned-practice-browser-fixture.v1",
"login": login,
"source_session_id": source_session_id,
"practice_session_id": practice_session_id,
"deliberate": {
"prescription_id": prescription_id,
"criterion_id": str(g4_target["criterion_id"]),
"novelty": str(g4_target["activity"]["scenario_novelty"]),
"mode": str(g4_target["activity"]["mode"]),
},
"transfer": {
"prescription_id": str(transfer_suite["suite_id"]),
"suite_id": transfer_suite_record_id,
"trial_id": trial_record_id,
"criterion_id": str(
suite_projection["trials"][0]["competency_id"]
),
"novelty": "unseen_transfer",
"mode": "counterevidence_forecast",
},
"setup_proof": {
"source_review_ready": True,
"follow_up_review_ready": True,
"source_turn_count": len(source_turn_ids),
"follow_up_turn_count": len(practice_turn_ids),
"distinct_persona": source_persona != practice_persona,
"initial_runtime_observation_count": runtime_count,
"initial_actual_transfer_execution_count": transfer_count,
},
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--api-base-url", required=True)
parser.add_argument("--database-url", required=True)
parser.add_argument("--database-admin-url", default="")
parser.add_argument("--practice-internal-token", required=True)
parser.add_argument("--transfer-internal-token", required=True)
parser.add_argument("--out", required=True)
parser.add_argument("--request-timeout", type=float, default=240.0)
parser.add_argument("--review-poll-timeout", type=float, default=240.0)
parser.add_argument("--review-poll-interval", type=float, default=0.5)
parser.add_argument("--resume-ready-source", action="store_true")
args = parser.parse_args()
result = run(args)
output = Path(args.out).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# Keep stdout free of fixture identifiers and account data.
print(
json.dumps(
{
"ok": True,
"schema_version": result["schema_version"],
"setup_proof": result["setup_proof"],
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
main()