vignette/scripts/smoke-session-learning-producer.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

660 lines
28 KiB
Python

#!/usr/bin/env python3
"""Prove the G4/G5 production session-learning caller on PostgreSQL.
The smoke persists a real ended session, durable counselor/client turn UUIDs, a
ready session evaluation, and a learner prediction/lock. G4/G5 artifacts are
created only by ``session_learning_producer`` (including the production lock
callback), then audited for replay, provenance, and non-promotion invariants.
Run only against an expendable development database. Unique fixture rows remain
because the learning ledgers are intentionally append-only.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
API_ENV = API_ROOT / ".env"
def _load_api_env() -> None:
if not API_ENV.exists():
return
for raw_line in API_ENV.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("'"))
_load_api_env()
if str(API_ROOT) not in sys.path:
sys.path.insert(0, str(API_ROOT))
from app import db, persona_repository, session_persistence # noqa: E402
from app.deps import Principal, Role # noqa: E402
from app.routes import calibration_transfer # noqa: E402
from app.routes.calibration_transfer import ( # noqa: E402
PredictionLockRequest,
PredictionRevisionRequest,
)
from app.services import ( # noqa: E402
calibration_transfer_store,
deliberate_practice_store,
memory,
session_learning_producer,
state_machine,
)
from app.store import TurnRecord # noqa: E402
COHORT = "g4-g5-production-caller-smoke"
COMPETENCY_ID = "competency.empathic_reflection"
CALIBRATION_INSTRUMENT_ID = "calibration-mirror-g5"
CALIBRATION_INSTRUMENT_VERSION = "1.0.0"
class SmokeError(RuntimeError):
pass
def _principal(user_id: UUID, role: Role) -> Principal:
return Principal(
user_id=str(user_id),
role=role,
cohort_ids=[COHORT],
consent_at=1.0,
profile_completed_at=1.0,
)
async def _seed_users(learner_id: UUID, teacher_id: UUID) -> None:
async with db.acquire(role="admin") as conn:
for user_id, role in (
(learner_id, "learner"),
(teacher_id, "instructor"),
):
await conn.execute(
"""
INSERT INTO app.app_user (
user_id, external_id, email, display_name, role, cohort,
consent_at, profile_completed_at, terms_agreed_at,
privacy_agreed_at
) VALUES ($1,$2,$3,$4,$5,$6,now(),now(),now(),now())
""",
user_id,
f"dev:e2e:session-learning-producer:{user_id}",
f"{user_id}@session-learning-producer.invalid",
f"G4 G5 synthetic {role} fixture",
role,
COHORT,
)
def _fast_deviation() -> dict[str, Any]:
return {
"loop": "fast",
"turn_seq": 1,
"stage": "rapport",
"appropriateness": "warn",
"appropriateness_note": "정서 반영 전에 다음 질문으로 이동함",
"intent_deviation": {
"dimension": "reflection",
"expected": "정서를 반영한 뒤 이해가 맞는지 확인한다",
"actual": "정서 확인 없이 다음 질문으로 이동했다",
"severity": "moderate",
},
}
def _ready_deep_evaluation(session_id: str) -> dict[str, Any]:
return {
"loop": "deep",
"session_id": session_id,
"stage": "rapport",
"scope": "session_end",
"turns_evaluated": 2,
"intent_deviations": [
{
"dimension": "reflection",
"expected": "정서를 반영한 뒤 이해가 맞는지 확인한다",
"actual": "정서 확인 없이 다음 질문으로 이동했다",
"severity": "moderate",
}
],
"improvements": ["핵심 정서를 짧게 반영하고 이해가 맞는지 확인한다."],
}
async def _create_ready_session(learner: Principal) -> tuple[Any, UUID, UUID]:
await persona_repository.materialize_seed_personas()
catalog = await persona_repository.get_approved_persona("P1")
if catalog is None:
raise SmokeError("approved P1 persona is unavailable")
session = await session_persistence.create_session(
learner_id=learner.user_id,
card=catalog.card,
theory_mode="humanistic",
state=state_machine.init_state(params=catalog.card.openness_params()),
persona_id=catalog.persona_id,
persona_version=catalog.version,
goal_stages=["라포", "탐색"],
)
if session is None:
raise SmokeError("durable session creation fell back or failed")
counselor_turn = TurnRecord(
turn_seq=1,
speaker="counselor",
stage=session.state.stage.value,
text="[MASKED]",
text_masked="[MASKED]",
evaluation=_fast_deviation(),
)
if not await session_persistence.append_turn(
session_id=session.session_id,
learner_id=learner.user_id,
turn=counselor_turn,
):
raise SmokeError("durable counselor turn append failed")
if counselor_turn.turn_id is None:
raise SmokeError("counselor turn did not receive a durable UUID")
session.turns.append(counselor_turn)
client_turn = TurnRecord(
turn_seq=2,
speaker="client",
stage=session.state.stage.value,
text="[MASKED]",
text_masked="[MASKED]",
llm_provider="fixture",
model="synthetic-client-response",
tokens_in=0,
tokens_out=0,
cost_usd=0.0,
)
if not await session_persistence.append_turn(
session_id=session.session_id,
learner_id=learner.user_id,
turn=client_turn,
):
raise SmokeError("durable client turn append failed")
if client_turn.turn_id is None:
raise SmokeError("client turn did not receive a durable UUID")
session.turns.append(client_turn)
carry = memory.make_carry_over(
state=session.state,
session_id=session.session_id,
case_id=session.case_id,
session_no=session.session_no,
masked_turns=session.masked_turns(visible_to="client"),
open_threads=[],
)
if not await session_persistence.end_session(session, carry):
raise SmokeError("durable session end failed")
saved = await session_persistence.save_session_evaluation(
session_persistence.SessionEvaluationWrite(
session_id=session.session_id,
learner_id=learner.user_id,
status="ready",
source="synthetic_fixture",
scope="session_end",
stage=session.state.stage.value,
payload=_ready_deep_evaluation(session.session_id),
)
)
if not saved:
raise SmokeError("ready session evaluation persistence failed")
return session, UUID(counselor_turn.turn_id), UUID(client_turn.turn_id)
async def _counts(session_id: UUID) -> dict[str, int]:
async with db.acquire(role="admin") as conn:
row = await conn.fetchrow(
"""
SELECT
(SELECT count(*) FROM app.practice_prescription_submission
WHERE session_id = $1)::int AS g4_submissions,
(SELECT count(*) FROM app.practice_coaching_card
WHERE session_id = $1)::int AS g4_cards,
(SELECT count(*) FROM app.practice_prescription
WHERE session_id = $1)::int AS g4_prescriptions,
(SELECT count(*) FROM app.competency_graph_snapshot
WHERE session_id = $1)::int AS g4_snapshots,
(SELECT count(*) FROM app.practice_curriculum_decision_event
WHERE session_id = $1)::int AS g4_decisions,
(SELECT count(*) FROM app.practice_episode_submission
WHERE session_id = $1)::int AS g4_episodes,
(SELECT count(*) FROM app.practice_attempt_evidence
WHERE session_id = $1)::int AS g4_attempts,
(SELECT count(*) FROM app.calibration_prediction_history
WHERE session_id = $1)::int AS g5_histories,
(SELECT count(*) FROM app.calibration_prediction_revision
WHERE session_id = $1)::int AS g5_revisions,
(SELECT count(*) FROM app.calibration_prediction_lock
WHERE session_id = $1)::int AS g5_locks,
(SELECT count(*) FROM app.calibration_performance_observation
WHERE session_id = $1)::int AS g5_observations,
(SELECT count(*) FROM audit.model_run
WHERE session_id = $1
AND prompt_bundle_id = 'session-learning-producer')::int
AS producer_model_runs,
(SELECT count(*) FROM app.calibration_assessment_snapshot
WHERE session_id = $1)::int AS g5_assessments,
(SELECT count(*) FROM app.calibration_transfer_suite
WHERE session_id = $1)::int AS g5_transfer_suites
""",
session_id,
)
return dict(row or {})
async def _postgres_proof(
*,
session_id: UUID,
counselor_turn_id: UUID,
client_turn_id: UUID,
history_id: UUID,
) -> dict[str, Any]:
expected_evidence = {counselor_turn_id, client_turn_id}
async with db.acquire(role="admin") as conn:
source = await conn.fetchrow(
"""
SELECT e.status AS evaluation_status, e.scope,
count(t.id)::int AS durable_turn_count,
count(sc.id) FILTER (WHERE sc.intent_deviation IS NOT NULL)::int
AS deviation_comment_count
FROM app.session_evaluation e
JOIN app.turns t ON t.session_id = e.session_id
LEFT JOIN app.supervisor_comment sc ON sc.turn_id = t.id
WHERE e.session_id = $1
GROUP BY e.status, e.scope
""",
session_id,
)
g4 = await conn.fetchrow(
"""
SELECT ps.submission_id, c.coaching_card_record_id, c.card_key,
c.evidence_turn_ids AS card_evidence_turn_ids,
p.prescription_record_id, p.prescription_key,
p.competency_id, p.scenario_novelty,
p.evidence_turn_ids AS prescription_evidence_turn_ids,
gs.snapshot_id, gs.graph_payload,
d.decision_id
FROM app.practice_prescription_submission ps
JOIN app.practice_coaching_card c ON c.submission_id = ps.submission_id
JOIN app.practice_prescription p
ON p.coaching_card_record_id = c.coaching_card_record_id
JOIN app.competency_graph_snapshot gs
ON gs.source_prescription_submission_id = ps.submission_id
JOIN app.practice_curriculum_decision_event d
ON d.source_snapshot_id = gs.snapshot_id
WHERE ps.session_id = $1
""",
session_id,
)
g5 = await conn.fetchrow(
"""
SELECT h.history_id, r.prediction_revision_id, l.lock_id,
o.observation_id, o.status, o.source_kind, o.perspective,
o.model_run_id, o.evidence_turn_ids, o.counterevidence,
o.revealed_sequence, l.locked_sequence,
mr.turn_id AS model_run_turn_id,
mr.agent_role, mr.provider, mr.model, mr.status AS model_run_status,
mr.prompt_bundle_id, mr.prompt_bundle_version,
mr.input_evidence_hash, mr.metadata AS model_run_metadata
FROM app.calibration_prediction_history h
JOIN app.calibration_prediction_revision r ON r.history_id = h.history_id
JOIN app.calibration_prediction_lock l ON l.history_id = h.history_id
JOIN app.calibration_performance_observation o ON o.history_id = h.history_id
JOIN audit.model_run mr ON mr.model_run_id = o.model_run_id
WHERE h.history_id = $1 AND h.session_id = $2
""",
history_id,
session_id,
)
forbidden = await conn.fetchrow(
"""
SELECT
(SELECT count(*) FROM app.practice_episode_submission
WHERE session_id = $1 AND (mastery_allowed OR progress = 'mastered'))::int
AS auto_mastery_rows,
(SELECT count(*) FROM app.competency_graph_snapshot
WHERE session_id = $1
AND graph_payload::text LIKE '%transfer_verified%')::int
AS transfer_verified_snapshots,
(SELECT count(*) FROM app.calibration_performance_observation
WHERE session_id = $1 AND status = 'passed')::int
AS passed_observations,
(SELECT count(*) FROM app.calibration_transfer_trial
WHERE session_id = $1)::int AS transfer_trials,
(SELECT count(*) FROM app.calibration_transfer_assessment
WHERE session_id = $1)::int AS transfer_assessments,
(SELECT count(*) FROM app.calibration_subgroup_drift_report
WHERE session_id = $1)::int AS drift_reports
""",
session_id,
)
source_payload = dict(source or {})
g4_payload = dict(g4 or {})
g5_payload = dict(g5 or {})
forbidden_payload = dict(forbidden or {})
if source_payload != {
"evaluation_status": "ready",
"scope": "session_end",
"durable_turn_count": 2,
"deviation_comment_count": 1,
}:
raise SmokeError(f"durable ready source differs: {source_payload}")
if set(g4_payload.get("card_evidence_turn_ids") or []) != expected_evidence:
raise SmokeError("G4 card did not preserve both durable turn UUIDs")
if set(g4_payload.get("prescription_evidence_turn_ids") or []) != expected_evidence:
raise SmokeError("G4 prescription did not preserve both durable turn UUIDs")
if g4_payload.get("competency_id") != COMPETENCY_ID:
raise SmokeError("G4 prescription competency differs from evaluation")
if g4_payload.get("scenario_novelty") != "familiar":
raise SmokeError("G4 caller claimed unseen transfer")
graph = g4_payload.get("graph_payload") or {}
states = graph.get("states") or []
if not states or not all(item.get("band") == "unassessed" for item in states):
raise SmokeError("G4 caller promoted a competency band")
if not all(item.get("attempt_count") == 0 for item in states):
raise SmokeError("G4 caller invented practice attempts")
if g5_payload.get("status") != "failed":
raise SmokeError("G5 caller did not preserve failed-only status")
if (
g5_payload.get("source_kind"),
g5_payload.get("perspective"),
) != ("model_inferred", "independent_observer"):
raise SmokeError("G5 independent observation provenance drifted")
if set(g5_payload.get("evidence_turn_ids") or []) != expected_evidence:
raise SmokeError("G5 observation did not preserve durable turn UUIDs")
if g5_payload.get("model_run_turn_id") != counselor_turn_id:
raise SmokeError("G5 model run is not anchored to the counselor turn")
if g5_payload.get("revealed_sequence") != g5_payload.get("locked_sequence") + 1:
raise SmokeError("G5 observation was not revealed after prediction lock")
model_metadata = g5_payload.get("model_run_metadata") or {}
if model_metadata.get("auto_mastery") is not False:
raise SmokeError("G5 model run metadata allowed automatic mastery")
if model_metadata.get("auto_transfer") is not False:
raise SmokeError("G5 model run metadata allowed automatic transfer")
if any(forbidden_payload.values()):
raise SmokeError(f"caller auto-promoted forbidden evidence: {forbidden_payload}")
return {
"ready_source": source_payload,
"g4": {
"submission_id": g4_payload["submission_id"],
"coaching_card_record_id": g4_payload["coaching_card_record_id"],
"card_key": g4_payload["card_key"],
"prescription_record_id": g4_payload["prescription_record_id"],
"prescription_key": g4_payload["prescription_key"],
"snapshot_id": g4_payload["snapshot_id"],
"decision_id": g4_payload["decision_id"],
"competency_id": g4_payload["competency_id"],
"scenario_novelty": g4_payload["scenario_novelty"],
"evidence_turn_ids": sorted(str(item) for item in expected_evidence),
"all_competency_bands": sorted({item["band"] for item in states}),
"attempt_count_total": sum(item["attempt_count"] for item in states),
},
"g5": {
"history_id": g5_payload["history_id"],
"prediction_revision_id": g5_payload["prediction_revision_id"],
"lock_id": g5_payload["lock_id"],
"observation_id": g5_payload["observation_id"],
"observation_status": g5_payload["status"],
"source_kind": g5_payload["source_kind"],
"perspective": g5_payload["perspective"],
"model_run_id": g5_payload["model_run_id"],
"model_run_turn_id": g5_payload["model_run_turn_id"],
"model_run_status": g5_payload["model_run_status"],
"agent_role": g5_payload["agent_role"],
"provider": g5_payload["provider"],
"model": g5_payload["model"],
"prompt_bundle_id": g5_payload["prompt_bundle_id"],
"prompt_bundle_version": g5_payload["prompt_bundle_version"],
"input_evidence_hash": g5_payload["input_evidence_hash"],
"revealed_after_lock": True,
"evidence_turn_ids": sorted(str(item) for item in expected_evidence),
"counterevidence_count": len(g5_payload["counterevidence"] or []),
},
"non_promotion": forbidden_payload,
}
async def _projection_proof(
*, learner: Principal, teacher: Principal, learner_id: UUID
) -> dict[str, Any]:
learner_g4 = await deliberate_practice_store.read_deliberate_practice(
principal=learner
)
teacher_g4 = await deliberate_practice_store.read_deliberate_practice(
principal=teacher, learner_id=learner_id
)
learner_g5 = await calibration_transfer_store.read_calibration_transfer(
principal=learner
)
teacher_g5 = await calibration_transfer_store.read_calibration_transfer(
principal=teacher, learner_id=learner_id
)
for payload in (learner_g4, teacher_g4, learner_g5, teacher_g5):
if payload.get("clinical_claim_allowed") is not False:
raise SmokeError("a role projection allowed a clinical claim")
if len(learner_g4["prescriptions"]) != 1 or learner_g4["episodes"]:
raise SmokeError("learner G4 projection invented or omitted artifacts")
if len(teacher_g4["prescriptions"]) != 1 or teacher_g4["episodes"]:
raise SmokeError("teacher G4 projection differs from learner projection")
learner_history = learner_g5["prediction_histories"]
teacher_history = teacher_g5["prediction_histories"]
if len(learner_history) != 1 or len(teacher_history) != 1:
raise SmokeError("G5 role projections omitted prediction history")
learner_observation = learner_history[0].get("external_observation") or {}
teacher_observation = teacher_history[0].get("external_observation") or {}
if learner_observation.get("status") != "failed":
raise SmokeError("learner projection omitted failed observation")
if teacher_observation.get("status") != "failed":
raise SmokeError("teacher projection omitted failed observation")
if learner_g5["calibration_assessments"] or learner_g5["transfer_suites"]:
raise SmokeError("G5 projection auto-created assessment or transfer")
return {
"learner_teacher_g4_prescription_count": 1,
"learner_teacher_g4_episode_count": 0,
"learner_teacher_g5_history_count": 1,
"learner_teacher_g5_observation_status": "failed",
"calibration_assessment_count": 0,
"transfer_suite_count": 0,
"clinical_claim_allowed": False,
}
async def run() -> dict[str, Any]:
await db.init_pool()
try:
learner_id = uuid4()
teacher_id = uuid4()
await _seed_users(learner_id, teacher_id)
learner = _principal(learner_id, Role.LEARNER)
teacher = _principal(teacher_id, Role.TEACHER)
session, counselor_turn_id, client_turn_id = await _create_ready_session(
learner
)
session_id = UUID(session.session_id)
before_lock = await session_learning_producer.produce_session_learning_artifacts(
session_id
)
if before_lock.get("g4", {}).get("status") != "ready":
raise SmokeError(f"G4 was not produced from ready evaluation: {before_lock}")
if before_lock.get("g4", {}).get("idempotent_replay") is not False:
raise SmokeError("first G4 production was not a new append")
if before_lock.get("g5") != {
"status": "skipped",
"reason": "locked_prediction_missing",
}:
raise SmokeError("G5 observation escaped the prediction lock")
counts_before_lock = await _counts(session_id)
if counts_before_lock["g5_observations"] != 0:
raise SmokeError("G5 observation exists before prediction lock")
suffix = learner_id.hex[:12]
history_id = uuid4()
revision_response = await calibration_transfer.create_prediction_revision(
PredictionRevisionRequest(
submission_id=uuid4(),
prediction_revision_id=uuid4(),
history_id=history_id,
session_id=session_id,
competency_id=COMPETENCY_ID,
practice_block_id=f"oas-g5-block-auto-{suffix}",
scenario_variant_id=f"auto-session-{session_id.hex}",
phrase_family_id=f"auto-phrase-{suffix}",
revision_no=1,
predicted_success_probability=0.8,
confidence=0.8,
recorded_sequence=1,
revision_reason="독립 평가 공개 전 자기예측",
instrument_id=CALIBRATION_INSTRUMENT_ID,
instrument_version=CALIBRATION_INSTRUMENT_VERSION,
evidence_turn_ids=[counselor_turn_id],
),
learner,
)
lock_response = await calibration_transfer.lock_prediction_history(
history_id,
PredictionLockRequest(
submission_id=uuid4(),
lock_id=uuid4(),
prediction_revision_id=revision_response.prediction_revision_id,
locked_sequence=2,
),
learner,
)
if revision_response.idempotent_replay or lock_response.idempotent_replay:
raise SmokeError("first prediction revision/lock unexpectedly replayed")
counts_after_lock = await _counts(session_id)
replay = await session_learning_producer.produce_locked_prediction_history(
history_id
)
if replay.get("g4", {}).get("idempotent_replay") is not True:
raise SmokeError("G4 stable submission/card replay was not detected")
if replay.get("g5") != {
"status": "skipped",
"reason": "locked_prediction_missing",
}:
raise SmokeError("G5 replay created or exposed a duplicate observation")
counts_after_replay = await _counts(session_id)
if counts_after_lock != counts_after_replay:
raise SmokeError(
"producer replay changed append-only row counts: "
f"{counts_after_lock} -> {counts_after_replay}"
)
expected_counts = {
"g4_submissions": 1,
"g4_cards": 1,
"g4_prescriptions": 1,
"g4_snapshots": 1,
"g4_decisions": 1,
"g4_episodes": 0,
"g4_attempts": 0,
"g5_histories": 1,
"g5_revisions": 1,
"g5_locks": 1,
"g5_observations": 1,
"producer_model_runs": 1,
"g5_assessments": 0,
"g5_transfer_suites": 0,
}
if counts_after_replay != expected_counts:
raise SmokeError(f"production caller row counts differ: {counts_after_replay}")
postgres = await _postgres_proof(
session_id=session_id,
counselor_turn_id=counselor_turn_id,
client_turn_id=client_turn_id,
history_id=history_id,
)
projections = await _projection_proof(
learner=learner,
teacher=teacher,
learner_id=learner_id,
)
return {
"ok": True,
"fixture_classification": "synthetic_educational",
"clinical_claim_allowed": False,
"notice_ko": (
"production caller와 원장 연결을 검증하는 합성 교육 fixture이며 "
"임상 효과나 실제 역량 숙달을 주장하지 않는다."
),
"fixture_policy": (
"retained unique dev:e2e rows in expendable development DB; "
"append-only ledger"
),
"entrypoints": [
"session_learning_producer.produce_session_learning_artifacts",
"calibration_transfer.lock_prediction_history -> "
"session_learning_producer.produce_locked_prediction_history",
],
"direct_g4_or_g5_artifact_insert_used": False,
"external_model_call_used": False,
"session_id": session_id,
"learner_id": learner_id,
"teacher_id": teacher_id,
"durable_turn_ids": [counselor_turn_id, client_turn_id],
"before_prediction_lock": {
"g4_status": before_lock["g4"]["status"],
"g4_idempotent_replay": before_lock["g4"]["idempotent_replay"],
"g5_status": before_lock["g5"]["status"],
"g5_reason": before_lock["g5"]["reason"],
"g5_observation_count": counts_before_lock["g5_observations"],
},
"prediction_lock": {
"history_id": history_id,
"prediction_revision_id": revision_response.prediction_revision_id,
"lock_id": lock_response.lock_id,
"revision_idempotent_replay": revision_response.idempotent_replay,
"lock_idempotent_replay": lock_response.idempotent_replay,
},
"replay": {
"g4_idempotent_replay": replay["g4"]["idempotent_replay"],
"g5_status": replay["g5"]["status"],
"g5_reason": replay["g5"]["reason"],
"row_counts_stable": True,
"row_counts": counts_after_replay,
},
"postgres": postgres,
"role_projections": projections,
}
finally:
await db.close_pool()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", default="")
args = parser.parse_args()
result = asyncio.run(run())
output = 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(output + "\n", encoding="utf-8")
print(output)
if __name__ == "__main__":
main()