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
360
scripts/rehearse-measurement-migration.py
Normal file
360
scripts/rehearse-measurement-migration.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""Rehearse G0 apply/transactional rollback and legacy lossless projection.
|
||||
|
||||
A uniquely named temporary local database is created, initialized through G0's
|
||||
prerequisites, exercised, and removed in ``finally``. No configured database is
|
||||
modified and no connection details are printed or written to evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
TEMP_DATABASE_PREFIX = "vignette_g0_rehearsal_"
|
||||
BASE_MIGRATIONS = (
|
||||
"01_extensions.sql",
|
||||
"02_schema.sql",
|
||||
"03_kb.sql",
|
||||
"04_audit_eval_rls.sql",
|
||||
"05_runtime_auth.sql",
|
||||
"06_session_evaluation.sql",
|
||||
)
|
||||
G0_MIGRATION = "07_measurement_foundation.sql"
|
||||
|
||||
|
||||
class RehearsalError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
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("'"))
|
||||
|
||||
|
||||
def _database_identifier(value: str) -> str:
|
||||
if not value.startswith(TEMP_DATABASE_PREFIX) or not value.replace("_", "").isalnum():
|
||||
raise RehearsalError("refusing unsafe temporary database identifier")
|
||||
return '"' + value + '"'
|
||||
|
||||
|
||||
def _role_identifier(value: str) -> str:
|
||||
if not value.replace("_", "").isalnum():
|
||||
raise RehearsalError("refusing unsafe database owner identifier")
|
||||
return '"' + value + '"'
|
||||
|
||||
|
||||
async def _g0_objects(conn: asyncpg.Connection[Any]) -> dict[str, bool]:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
to_regclass('app.measurement_instrument') IS NOT NULL AS instrument,
|
||||
to_regclass('app.measurement_event') IS NOT NULL AS event,
|
||||
to_regclass('app.alliance_pulse') IS NOT NULL AS pulse,
|
||||
to_regclass('audit.model_run') IS NOT NULL AS model_run,
|
||||
to_regclass('ds.benchmark_case') IS NOT NULL AS benchmark
|
||||
"""
|
||||
)
|
||||
assert row is not None
|
||||
return {key: bool(row[key]) for key in row.keys()}
|
||||
|
||||
|
||||
async def _seed_and_compare_legacy(conn: asyncpg.Connection[Any]) -> dict[str, Any]:
|
||||
learner_id = uuid4()
|
||||
persona_id = uuid4()
|
||||
case_id = uuid4()
|
||||
session_id = uuid4()
|
||||
turn_id = uuid4()
|
||||
await conn.execute(
|
||||
"INSERT INTO app.app_user (user_id, external_id, role) VALUES ($1, $2, 'learner')",
|
||||
learner_id,
|
||||
f"g0-rehearsal:{learner_id}",
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.persona_card (
|
||||
persona_id, code, version, status, display_name, difficulty,
|
||||
demographics, presenting, history, big5, resistance, speech_style,
|
||||
affect_baseline, ccd, dsm5_dimensional, source_provenance
|
||||
) VALUES ($1, 'G0-FIXTURE', 1, 'draft', 'G0 Fixture', 'easy',
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb,
|
||||
'{}'::jsonb, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb,
|
||||
'{}'::jsonb, 'local migration rehearsal')
|
||||
""",
|
||||
persona_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.case_profile (case_id, persona_id, learner_id, alliance_level)
|
||||
VALUES ($1, $2, $3, 0.375)
|
||||
""",
|
||||
case_id,
|
||||
persona_id,
|
||||
learner_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.sessions (
|
||||
id, case_id, learner_id, persona_id, persona_version, session_no, ended_at
|
||||
) VALUES ($1, $2, $3, $4, 1, 1, now())
|
||||
""",
|
||||
session_id,
|
||||
case_id,
|
||||
learner_id,
|
||||
persona_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.turns (id, session_id, seq, speaker, text, text_masked)
|
||||
VALUES ($1, $2, 1, 'counselor', '[masked fixture]', '[masked fixture]')
|
||||
""",
|
||||
turn_id,
|
||||
session_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO app.session_state (session_id, rapport_credit) VALUES ($1, 0.625)",
|
||||
session_id,
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.session_evaluation (session_id, status, source, scope, stage, payload)
|
||||
VALUES ($1, 'ready', 'fixture', 'session', '정리',
|
||||
'{"distribution":{"total":7}}'::jsonb)
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
old = await conn.fetchrow(
|
||||
"""
|
||||
SELECT ss.rapport_credit::double precision AS rapport_credit,
|
||||
cp.alliance_level::double precision AS alliance_level,
|
||||
(se.payload #>> '{distribution,total}')::double precision AS distribution_total,
|
||||
t.id AS turn_id
|
||||
FROM app.sessions s
|
||||
JOIN app.session_state ss ON ss.session_id=s.id
|
||||
JOIN app.case_profile cp ON cp.case_id=s.case_id
|
||||
JOIN app.session_evaluation se ON se.session_id=s.id
|
||||
JOIN app.turns t ON t.session_id=s.id AND t.seq=1
|
||||
WHERE s.id=$1
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
if old is None:
|
||||
raise RehearsalError("legacy persisted fixture could not be read")
|
||||
|
||||
api_path = str((Path("apps/api")).resolve())
|
||||
if api_path not in sys.path:
|
||||
sys.path.insert(0, api_path)
|
||||
from app.services.measurement_legacy import ( # pylint: disable=import-outside-toplevel
|
||||
adapt_deep_evaluation,
|
||||
adapt_legacy_simulation_signals,
|
||||
)
|
||||
|
||||
simulation = adapt_legacy_simulation_signals(
|
||||
session_id=session_id,
|
||||
rapport_credit=float(old["rapport_credit"]),
|
||||
alliance_level=float(old["alliance_level"]),
|
||||
turn_id=UUID(str(old["turn_id"])),
|
||||
)
|
||||
deep = adapt_deep_evaluation(
|
||||
session_id=session_id,
|
||||
evaluation={"distribution": {"total": int(old["distribution_total"])}},
|
||||
model_run_id=uuid4(),
|
||||
evidence_turn_ids=(turn_id,),
|
||||
)
|
||||
projected = {
|
||||
str(event.metadata["legacy_signal"]): event.value
|
||||
for event in (*simulation, deep)
|
||||
}
|
||||
expected = {
|
||||
"session_state.rapport_credit": float(old["rapport_credit"]),
|
||||
"case_profile.alliance_level": float(old["alliance_level"]),
|
||||
"SessionEvaluation.distribution": float(old["distribution_total"]),
|
||||
}
|
||||
persisted_after = await conn.fetchrow(
|
||||
"""
|
||||
SELECT ss.rapport_credit::double precision AS rapport_credit,
|
||||
cp.alliance_level::double precision AS alliance_level,
|
||||
(se.payload #>> '{distribution,total}')::double precision AS distribution_total
|
||||
FROM app.sessions s
|
||||
JOIN app.session_state ss ON ss.session_id=s.id
|
||||
JOIN app.case_profile cp ON cp.case_id=s.case_id
|
||||
JOIN app.session_evaluation se ON se.session_id=s.id
|
||||
WHERE s.id=$1
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
unchanged = persisted_after is not None and [
|
||||
float(persisted_after["rapport_credit"]),
|
||||
float(persisted_after["alliance_level"]),
|
||||
float(persisted_after["distribution_total"]),
|
||||
] == [
|
||||
expected["session_state.rapport_credit"],
|
||||
expected["case_profile.alliance_level"],
|
||||
expected["SessionEvaluation.distribution"],
|
||||
]
|
||||
return {
|
||||
"fixture_kind": "persisted_legacy_rows",
|
||||
"signal_count": len(expected),
|
||||
"old_read_model": expected,
|
||||
"new_read_model": projected,
|
||||
"numeric_projection_lossless": projected == expected,
|
||||
"legacy_rows_unchanged_after_g0_apply": unchanged,
|
||||
}
|
||||
|
||||
|
||||
async def _docker_database_command(
|
||||
container: str,
|
||||
admin_user: str,
|
||||
database: str,
|
||||
sql: str,
|
||||
) -> None:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"psql",
|
||||
"-U",
|
||||
admin_user,
|
||||
"-d",
|
||||
database,
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-c",
|
||||
sql,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode:
|
||||
detail = (stderr or stdout).decode("utf-8", errors="replace")[-500:]
|
||||
raise RehearsalError(
|
||||
f"local Docker PostgreSQL admin command failed ({process.returncode}): {detail}"
|
||||
)
|
||||
|
||||
|
||||
async def rehearse(
|
||||
dsn: str,
|
||||
*,
|
||||
docker_container: str,
|
||||
docker_admin_user: str,
|
||||
database_owner: str,
|
||||
) -> dict[str, Any]:
|
||||
temp_name = TEMP_DATABASE_PREFIX + secrets.token_hex(6)
|
||||
identifier = _database_identifier(temp_name)
|
||||
created = False
|
||||
try:
|
||||
await _docker_database_command(
|
||||
docker_container,
|
||||
docker_admin_user,
|
||||
"postgres",
|
||||
f"CREATE DATABASE {identifier} OWNER {_role_identifier(database_owner)}",
|
||||
)
|
||||
created = True
|
||||
await _docker_database_command(
|
||||
docker_container,
|
||||
docker_admin_user,
|
||||
temp_name,
|
||||
"CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public; "
|
||||
"CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; "
|
||||
"CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public;",
|
||||
)
|
||||
temp = await asyncpg.connect(dsn=dsn, database=temp_name)
|
||||
try:
|
||||
migration_dir = Path("infra/db/init")
|
||||
for name in BASE_MIGRATIONS:
|
||||
await temp.execute((migration_dir / name).read_text(encoding="utf-8"))
|
||||
before = await _g0_objects(temp)
|
||||
if any(before.values()):
|
||||
raise RehearsalError(f"G0 objects existed before apply: {before}")
|
||||
g0_sql = (migration_dir / G0_MIGRATION).read_text(encoding="utf-8")
|
||||
transaction = temp.transaction()
|
||||
await transaction.start()
|
||||
await temp.execute(g0_sql)
|
||||
inside_transaction = await _g0_objects(temp)
|
||||
if not all(inside_transaction.values()):
|
||||
raise RehearsalError(f"G0 apply omitted expected objects: {inside_transaction}")
|
||||
await transaction.rollback()
|
||||
after_rollback = await _g0_objects(temp)
|
||||
if after_rollback != before:
|
||||
raise RehearsalError(
|
||||
f"transactional rollback was not schema-lossless: {after_rollback} != {before}"
|
||||
)
|
||||
await temp.execute(g0_sql)
|
||||
after_committed_apply = await _g0_objects(temp)
|
||||
legacy = await _seed_and_compare_legacy(temp)
|
||||
if not legacy["numeric_projection_lossless"] or not legacy["legacy_rows_unchanged_after_g0_apply"]:
|
||||
raise RehearsalError("legacy old/new read-model comparison was lossy")
|
||||
finally:
|
||||
await temp.close()
|
||||
finally:
|
||||
if created:
|
||||
await _docker_database_command(
|
||||
docker_container,
|
||||
docker_admin_user,
|
||||
"postgres",
|
||||
f"DROP DATABASE {identifier} WITH (FORCE)",
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"temporary_database_removed": True,
|
||||
"base_migration_count": len(BASE_MIGRATIONS),
|
||||
"g0_migration": G0_MIGRATION,
|
||||
"schema_before_apply": before,
|
||||
"schema_inside_transaction": inside_transaction,
|
||||
"schema_after_rollback": after_rollback,
|
||||
"schema_after_committed_apply": after_committed_apply,
|
||||
"transactional_ddl_rollback_lossless": after_rollback == before,
|
||||
"forward_apply_after_rollback_succeeded": all(after_committed_apply.values()),
|
||||
"legacy_read_model": legacy,
|
||||
}
|
||||
|
||||
|
||||
async 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 RehearsalError("DATABASE_URL is required via --database-url or apps/api/.env")
|
||||
return await rehearse(
|
||||
dsn,
|
||||
docker_container=args.docker_container,
|
||||
docker_admin_user=args.docker_admin_user,
|
||||
database_owner=args.database_owner,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database-url", default="")
|
||||
parser.add_argument("--docker-container", default="vignette-dev-db")
|
||||
parser.add_argument("--docker-admin-user", default="vignette_owner")
|
||||
parser.add_argument("--database-owner", default="vignette")
|
||||
parser.add_argument("--out", default="")
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(_run(args))
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue