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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
477
apps/web/e2e/harness/prepare-returned-practice-db.py
Normal file
477
apps/web/e2e/harness/prepare-returned-practice-db.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue