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
scripts/smoke-outcome-trajectory-alerts.py
Normal file
477
scripts/smoke-outcome-trajectory-alerts.py
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Prove G2 warning and noisy-control trajectories on real PostgreSQL rows.
|
||||
|
||||
The smoke creates two synthetic educational five-session cases. It submits each
|
||||
session's three axes through the production route/store measurement producer, then
|
||||
reads the resulting learner/teacher projections and audits the immutable ledger.
|
||||
No source score is copied into the evidence JSON and no clinical claim is made.
|
||||
|
||||
Run only against an expendable development database. Unique fixture rows remain
|
||||
because the measurement and trajectory 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 outcome_trajectories # noqa: E402
|
||||
from app.routes.outcome_trajectories import ( # noqa: E402
|
||||
OutcomeAxisValues,
|
||||
OutcomeObservationSubmissionRequest,
|
||||
)
|
||||
from app.services import memory, state_machine # noqa: E402
|
||||
|
||||
|
||||
COHORT = "g2-trajectory-alert-smoke"
|
||||
AXES = ("distress_load", "daily_functioning", "learning_engagement")
|
||||
ALERT_STATUSES = {"off_track", "deteriorating"}
|
||||
INSTRUMENT_ID = "vignette-session-outcome-checkin"
|
||||
INSTRUMENT_VERSION = "1.0.0"
|
||||
|
||||
# These values are synthetic educational fixture inputs. They intentionally stay
|
||||
# inside the executable fixture and are never copied to the persisted evidence JSON.
|
||||
WORSENING_PROFILE = (
|
||||
{"distress_load": 0.70, "daily_functioning": 0.30, "learning_engagement": 0.40},
|
||||
{"distress_load": 0.62, "daily_functioning": 0.40, "learning_engagement": 0.48},
|
||||
{"distress_load": 0.72, "daily_functioning": 0.50, "learning_engagement": 0.56},
|
||||
{"distress_load": 0.70, "daily_functioning": 0.60, "learning_engagement": 0.64},
|
||||
{"distress_load": 0.68, "daily_functioning": 0.68, "learning_engagement": 0.72},
|
||||
)
|
||||
NOISY_CONTROL_PROFILE = (
|
||||
{"distress_load": 0.70, "daily_functioning": 0.30, "learning_engagement": 0.40},
|
||||
{"distress_load": 0.62, "daily_functioning": 0.40, "learning_engagement": 0.48},
|
||||
{"distress_load": 0.64, "daily_functioning": 0.50, "learning_engagement": 0.56},
|
||||
{"distress_load": 0.46, "daily_functioning": 0.60, "learning_engagement": 0.64},
|
||||
{"distress_load": 0.38, "daily_functioning": 0.68, "learning_engagement": 0.72},
|
||||
)
|
||||
EXPECTED_WORSENING = (
|
||||
"on_track",
|
||||
"on_track",
|
||||
"off_track",
|
||||
"deteriorating",
|
||||
"deteriorating",
|
||||
)
|
||||
EXPECTED_CONTROL = ("on_track", "on_track", "watch", "on_track", "on_track")
|
||||
|
||||
|
||||
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(*user_roles: tuple[UUID, str]) -> None:
|
||||
async with db.acquire(role="admin") as conn:
|
||||
for user_id, role in user_roles:
|
||||
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:g2-trajectory-alert:{user_id}",
|
||||
f"{user_id}@g2-trajectory-alert.invalid",
|
||||
f"G2 synthetic {role} fixture",
|
||||
role,
|
||||
COHORT,
|
||||
)
|
||||
|
||||
|
||||
async def _create_ended_session(learner: Principal) -> Any:
|
||||
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")
|
||||
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=[],
|
||||
open_threads=[],
|
||||
)
|
||||
if not await session_persistence.end_session(session, carry):
|
||||
raise SmokeError(f"S{session.session_no} durable end failed")
|
||||
return session
|
||||
|
||||
|
||||
def _axis(session: dict[str, Any], axis: str) -> dict[str, Any]:
|
||||
for item in session.get("axes") or []:
|
||||
if item.get("axis") == axis:
|
||||
return item
|
||||
raise SmokeError(f"S{session.get('session_no')} omitted {axis}")
|
||||
|
||||
|
||||
def _assert_projection(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
expected_statuses: tuple[str, ...],
|
||||
control: bool,
|
||||
) -> None:
|
||||
expected_arc = payload.get("expected_arc") or {}
|
||||
assessment = payload.get("assessment") or {}
|
||||
sessions = assessment.get("sessions") or []
|
||||
statuses = tuple(item.get("status") for item in sessions)
|
||||
if statuses != expected_statuses:
|
||||
raise SmokeError(f"trajectory statuses differ: {statuses!r}")
|
||||
if expected_arc.get("data_classification") != "synthetic_educational":
|
||||
raise SmokeError("expected arc lost synthetic educational classification")
|
||||
if expected_arc.get("clinical_claim_allowed") is not False:
|
||||
raise SmokeError("expected arc allowed a clinical claim")
|
||||
if assessment.get("data_classification") != "synthetic_educational":
|
||||
raise SmokeError("assessment lost synthetic educational classification")
|
||||
if assessment.get("clinical_claim_allowed") is not False:
|
||||
raise SmokeError("assessment allowed a clinical claim")
|
||||
if len(payload.get("observations") or []) != 15:
|
||||
raise SmokeError("five-session projection did not expose 15 axis observations")
|
||||
if len(payload.get("source_fingerprint") or "") != 64:
|
||||
raise SmokeError("projection omitted its source fingerprint")
|
||||
if control:
|
||||
promoted = ALERT_STATUSES.intersection(statuses)
|
||||
if promoted:
|
||||
raise SmokeError(f"one-off noise was promoted to an alert: {promoted}")
|
||||
noisy_axis = _axis(sessions[2], "distress_load")
|
||||
counterevidence = set(noisy_axis.get("counterevidence") or [])
|
||||
required = {"inside_off_track_threshold", "deviation_not_yet_sustained"}
|
||||
if not required.issubset(counterevidence):
|
||||
raise SmokeError(
|
||||
"noisy control omitted false-alert counterevidence: "
|
||||
f"{sorted(counterevidence)}"
|
||||
)
|
||||
else:
|
||||
deteriorating_axis = _axis(sessions[3], "distress_load")
|
||||
if deteriorating_axis.get("status") != "deteriorating":
|
||||
raise SmokeError("S4 distress did not become deteriorating")
|
||||
if deteriorating_axis.get("adverse_z_change") is None:
|
||||
raise SmokeError("deterioration omitted longitudinal change evidence")
|
||||
if not sessions[3].get("next_check_questions"):
|
||||
raise SmokeError("deterioration omitted next-check questions")
|
||||
|
||||
|
||||
async def _run_case(
|
||||
*,
|
||||
learner: Principal,
|
||||
teacher: Principal,
|
||||
profile: tuple[dict[str, float], ...],
|
||||
expected_statuses: tuple[str, ...],
|
||||
control: bool,
|
||||
) -> dict[str, Any]:
|
||||
session_ids: list[str] = []
|
||||
submitted_measurement_ids: list[str] = []
|
||||
case_id = ""
|
||||
for expected_no, scores in enumerate(profile, start=1):
|
||||
session = await _create_ended_session(learner)
|
||||
if session.session_no != expected_no:
|
||||
raise SmokeError(
|
||||
f"session continuity differs: {session.session_no} != {expected_no}"
|
||||
)
|
||||
if case_id and session.case_id != case_id:
|
||||
raise SmokeError("five sessions did not stay in one case")
|
||||
case_id = session.case_id
|
||||
session_ids.append(session.session_id)
|
||||
created = await outcome_trajectories.create_outcome_observations(
|
||||
UUID(session.session_id),
|
||||
OutcomeObservationSubmissionRequest(
|
||||
submission_id=uuid4(),
|
||||
scores=OutcomeAxisValues(**scores),
|
||||
confidences=OutcomeAxisValues(**{axis: 0.9 for axis in AXES}),
|
||||
),
|
||||
learner,
|
||||
)
|
||||
submitted_measurement_ids.extend(
|
||||
str(item) for item in created.submitted_measurement_ids
|
||||
)
|
||||
|
||||
learner_payload = (
|
||||
await outcome_trajectories.get_outcome_trajectory(
|
||||
UUID(session_ids[-1]), learner
|
||||
)
|
||||
).model_dump(mode="json")
|
||||
teacher_payload = (
|
||||
await outcome_trajectories.get_outcome_trajectory(
|
||||
UUID(session_ids[-1]), teacher
|
||||
)
|
||||
).model_dump(mode="json")
|
||||
_assert_projection(
|
||||
learner_payload, expected_statuses=expected_statuses, control=control
|
||||
)
|
||||
_assert_projection(
|
||||
teacher_payload, expected_statuses=expected_statuses, control=control
|
||||
)
|
||||
learner_statuses = [
|
||||
item["status"] for item in learner_payload["assessment"]["sessions"]
|
||||
]
|
||||
teacher_statuses = [
|
||||
item["status"] for item in teacher_payload["assessment"]["sessions"]
|
||||
]
|
||||
if learner_statuses != teacher_statuses:
|
||||
raise SmokeError("learner and teacher projections disagree")
|
||||
if learner_payload["revision_id"] != teacher_payload["revision_id"]:
|
||||
raise SmokeError("role-safe reads did not project the same latest revision")
|
||||
return {
|
||||
"case_id": case_id,
|
||||
"session_ids": session_ids,
|
||||
"submitted_measurement_ids": submitted_measurement_ids,
|
||||
"revision_id": learner_payload["revision_id"],
|
||||
"revision_no": learner_payload["revision_no"],
|
||||
"source_fingerprint": learner_payload["source_fingerprint"],
|
||||
"session_statuses": learner_statuses,
|
||||
"learner_teacher_projection_equal": True,
|
||||
"next_question_count": len(learner_payload.get("next_questions") or []),
|
||||
"alert_promotion_count": len(ALERT_STATUSES.intersection(learner_statuses)),
|
||||
}
|
||||
|
||||
|
||||
async def _postgres_proof(case_ids: list[str]) -> dict[str, Any]:
|
||||
async with db.acquire(role="admin") as conn:
|
||||
measurement = await conn.fetchrow(
|
||||
"""
|
||||
SELECT count(*)::int AS row_count,
|
||||
count(DISTINCT me.measurement_id)::int AS distinct_measurements,
|
||||
count(DISTINCT me.session_id)::int AS session_count,
|
||||
array_agg(DISTINCT me.source_kind::text ORDER BY me.source_kind::text)
|
||||
AS source_kinds,
|
||||
array_agg(DISTINCT me.perspective::text ORDER BY me.perspective::text)
|
||||
AS perspectives,
|
||||
array_agg(DISTINCT me.instrument_id ORDER BY me.instrument_id)
|
||||
AS instrument_ids,
|
||||
array_agg(DISTINCT me.instrument_version ORDER BY me.instrument_version)
|
||||
AS instrument_versions,
|
||||
bool_and((me.metadata ->> 'clinical_claim_allowed')::boolean = false)
|
||||
AS all_nonclinical,
|
||||
bool_and(me.metadata ? 'submission_id' AND me.metadata ? 'submission_hash')
|
||||
AS all_have_submission_provenance
|
||||
FROM app.measurement_event me
|
||||
JOIN app.sessions s ON s.id = me.session_id
|
||||
WHERE s.case_id = ANY($1::uuid[])
|
||||
AND me.construct = 'session_outcome'
|
||||
""",
|
||||
[UUID(item) for item in case_ids],
|
||||
)
|
||||
revisions = await conn.fetch(
|
||||
"""
|
||||
SELECT case_id, count(*)::int AS revision_count,
|
||||
min(revision_no)::int AS first_revision,
|
||||
max(revision_no)::int AS last_revision,
|
||||
count(DISTINCT source_fingerprint)::int AS fingerprint_count,
|
||||
count(*) FILTER (WHERE supersedes_revision_id IS NOT NULL)::int
|
||||
AS superseding_revision_count,
|
||||
array_agg(DISTINCT computed_role ORDER BY computed_role)
|
||||
AS computed_roles
|
||||
FROM app.outcome_trajectory_revision
|
||||
WHERE case_id = ANY($1::uuid[])
|
||||
GROUP BY case_id
|
||||
ORDER BY case_id
|
||||
""",
|
||||
[UUID(item) for item in case_ids],
|
||||
)
|
||||
latest_observations = await conn.fetchrow(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (case_id) revision_id, case_id
|
||||
FROM app.outcome_trajectory_revision
|
||||
WHERE case_id = ANY($1::uuid[])
|
||||
ORDER BY case_id, revision_no DESC
|
||||
)
|
||||
SELECT count(*)::int AS row_count,
|
||||
count(DISTINCT oto.measurement_id)::int AS distinct_measurements,
|
||||
count(DISTINCT oto.session_id)::int AS session_count,
|
||||
count(DISTINCT oto.axis)::int AS axis_count,
|
||||
bool_and(oto.status = 'observed') AS all_observed,
|
||||
bool_and(oto.source_kind = 'learner_reported') AS source_preserved,
|
||||
bool_and(oto.perspective = 'learner_self_report')
|
||||
AS perspective_preserved
|
||||
FROM latest
|
||||
JOIN app.outcome_trajectory_observation oto
|
||||
ON oto.revision_id = latest.revision_id
|
||||
""",
|
||||
[UUID(item) for item in case_ids],
|
||||
)
|
||||
all_linked = await conn.fetchval(
|
||||
"""
|
||||
SELECT count(DISTINCT oto.measurement_id)::int
|
||||
FROM app.outcome_trajectory_observation oto
|
||||
JOIN app.outcome_trajectory_revision otr
|
||||
ON otr.revision_id = oto.revision_id
|
||||
WHERE otr.case_id = ANY($1::uuid[])
|
||||
AND oto.measurement_id IS NOT NULL
|
||||
""",
|
||||
[UUID(item) for item in case_ids],
|
||||
)
|
||||
|
||||
measurement_payload = dict(measurement or {})
|
||||
latest_payload = dict(latest_observations or {})
|
||||
if measurement_payload.get("row_count") != 30:
|
||||
raise SmokeError(f"measurement ledger count differs: {measurement_payload}")
|
||||
if measurement_payload.get("session_count") != 10:
|
||||
raise SmokeError("measurement ledger did not cover ten real sessions")
|
||||
if measurement_payload.get("source_kinds") != ["learner_reported"]:
|
||||
raise SmokeError("measurement producer source kind drifted")
|
||||
if measurement_payload.get("perspectives") != ["learner_self_report"]:
|
||||
raise SmokeError("measurement producer perspective drifted")
|
||||
if measurement_payload.get("instrument_ids") != [INSTRUMENT_ID]:
|
||||
raise SmokeError("measurement producer instrument id drifted")
|
||||
if measurement_payload.get("instrument_versions") != [INSTRUMENT_VERSION]:
|
||||
raise SmokeError("measurement producer instrument version drifted")
|
||||
if not measurement_payload.get("all_nonclinical"):
|
||||
raise SmokeError("a producer row allowed clinical claims")
|
||||
if not measurement_payload.get("all_have_submission_provenance"):
|
||||
raise SmokeError("a producer row omitted submission provenance")
|
||||
revision_payloads = [dict(row) for row in revisions]
|
||||
if len(revision_payloads) != 2:
|
||||
raise SmokeError("trajectory revision ledger omitted a case")
|
||||
for row in revision_payloads:
|
||||
if row["revision_count"] != 5 or row["first_revision"] != 1:
|
||||
raise SmokeError(f"revision chain is incomplete: {row}")
|
||||
if row["last_revision"] != 5 or row["superseding_revision_count"] != 4:
|
||||
raise SmokeError(f"revision supersession chain is invalid: {row}")
|
||||
if row["fingerprint_count"] != 5 or row["computed_roles"] != ["learner"]:
|
||||
raise SmokeError(f"revision provenance drifted: {row}")
|
||||
if latest_payload.get("row_count") != 30:
|
||||
raise SmokeError("latest revisions do not contain 30 immutable snapshots")
|
||||
if latest_payload.get("distinct_measurements") != 30 or all_linked != 30:
|
||||
raise SmokeError("measurement-to-observation provenance is incomplete")
|
||||
if latest_payload.get("session_count") != 10 or latest_payload.get("axis_count") != 3:
|
||||
raise SmokeError("latest observation snapshots lost session/axis coverage")
|
||||
if not all(
|
||||
latest_payload.get(key)
|
||||
for key in ("all_observed", "source_preserved", "perspective_preserved")
|
||||
):
|
||||
raise SmokeError("observation snapshot provenance drifted")
|
||||
|
||||
return {
|
||||
"measurement_event_count": measurement_payload["row_count"],
|
||||
"distinct_measurement_count": measurement_payload["distinct_measurements"],
|
||||
"session_count": measurement_payload["session_count"],
|
||||
"source_kinds": measurement_payload["source_kinds"],
|
||||
"perspectives": measurement_payload["perspectives"],
|
||||
"instrument_ids": measurement_payload["instrument_ids"],
|
||||
"instrument_versions": measurement_payload["instrument_versions"],
|
||||
"all_nonclinical": measurement_payload["all_nonclinical"],
|
||||
"all_have_submission_provenance": measurement_payload[
|
||||
"all_have_submission_provenance"
|
||||
],
|
||||
"revision_chains": revision_payloads,
|
||||
"latest_observation_snapshot_count": latest_payload["row_count"],
|
||||
"all_measurements_linked_to_observation_ledger": all_linked == 30,
|
||||
"producer_store_evaluator_boundary_proved": True,
|
||||
}
|
||||
|
||||
|
||||
async def run() -> dict[str, Any]:
|
||||
await db.init_pool()
|
||||
try:
|
||||
await persona_repository.materialize_seed_personas()
|
||||
worsening_learner_id = uuid4()
|
||||
control_learner_id = uuid4()
|
||||
teacher_id = uuid4()
|
||||
await _seed_users(
|
||||
(worsening_learner_id, "learner"),
|
||||
(control_learner_id, "learner"),
|
||||
(teacher_id, "instructor"),
|
||||
)
|
||||
teacher = _principal(teacher_id, Role.TEACHER)
|
||||
worsening = await _run_case(
|
||||
learner=_principal(worsening_learner_id, Role.LEARNER),
|
||||
teacher=teacher,
|
||||
profile=WORSENING_PROFILE,
|
||||
expected_statuses=EXPECTED_WORSENING,
|
||||
control=False,
|
||||
)
|
||||
control = await _run_case(
|
||||
learner=_principal(control_learner_id, Role.LEARNER),
|
||||
teacher=teacher,
|
||||
profile=NOISY_CONTROL_PROFILE,
|
||||
expected_statuses=EXPECTED_CONTROL,
|
||||
control=True,
|
||||
)
|
||||
postgres = await _postgres_proof([worsening["case_id"], control["case_id"]])
|
||||
return {
|
||||
"ok": True,
|
||||
"fixture_classification": "synthetic_educational",
|
||||
"clinical_claim_allowed": False,
|
||||
"notice_ko": "교육용 합성 fixture 검증이며 임상적 효과·예후를 주장하지 않는다.",
|
||||
"fixture_policy": (
|
||||
"retained unique dev:e2e rows in expendable development DB; "
|
||||
"append-only ledger"
|
||||
),
|
||||
"entrypoint": (
|
||||
"outcome_trajectories.create_outcome_observations -> "
|
||||
"outcome_trajectory_store measurement producer -> evaluator"
|
||||
),
|
||||
"direct_measurement_or_trajectory_insert_used": False,
|
||||
"source_values_in_evidence_json": False,
|
||||
"worsening_case": worsening,
|
||||
"noisy_one_off_control": control,
|
||||
"postgres": postgres,
|
||||
}
|
||||
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue