vignette/apps/api/app/services/supervision_research_producer.py
Yun Chan 1306c524c1
Some checks failed
API contract / OpenAPI type drift (push) Failing after 4m46s
회기 집계와 기록 복귀 및 감독 근거 링크 수정
2026-09-09 14:41:54 +09:00

847 lines
31 KiB
Python

"""G6 원천 원장에서 교수자 attention/gap/Phase 3 산출물을 직접 생산한다.
HTTP 호출자가 이미 계산한 신호를 주입하는 기존 control-plane과 달리 이 모듈은
append-only G0~G5 원장을 읽고, 재현 가능한 파생 산출물만 G6 저장소에 기록한다.
원문 발화는 읽거나 복제하지 않는다.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import defaultdict
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from uuid import UUID, uuid5
import asyncpg
from .. import db
from ..config import settings
from ..contracts.supervision_research import (
LedgerEvidencePointer,
LearnerAttentionSignal,
Phase3EvidenceArtifact,
)
from . import supervision_research_store, supervision_research_version_evaluator
_PRODUCER_NAMESPACE = UUID("f28dd79f-cda7-43c2-8b41-93e7ba7f4de7")
_PRODUCER_TASK: asyncio.Task[None] | None = None
logger = logging.getLogger(__name__)
_UNRESOLVED_RUPTURE_STATES = {
"onset",
"recognized",
"repair_attempted",
"missed",
"partial",
}
@dataclass(frozen=True, slots=True)
class DerivedSignal:
learner_id: UUID
competency_id: str
signal: LearnerAttentionSignal
@dataclass(frozen=True, slots=True)
class ManifestInput:
artifacts: tuple[Phase3EvidenceArtifact, ...]
source_by_domain: Mapping[str, tuple[UUID, LedgerEvidencePointer]]
source_fingerprint: str
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
value = row.get(key, default)
return default if value is None else value
def _canonical_hash(payload: Any) -> str:
canonical = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _learner_ref(learner_id: UUID) -> str:
return f"learner-{learner_id.hex[:20]}"
def _signal_id(*parts: object) -> str:
digest = _canonical_hash([str(value) for value in parts])[:24]
return f"oas-g6-signal-{digest}"
def _route(session_id: object) -> str:
return f"/teach/session/{session_id}/review"
def _pointer(
*,
ledger: str,
event_id: object,
session_id: object | None,
) -> LedgerEvidencePointer:
return LedgerEvidencePointer(
ledger=ledger,
event_id=str(event_id),
session_id=str(session_id) if session_id else None,
route_hint=_route(session_id) if session_id else "/teach",
)
def _latest_session_assessment(
assessment: Mapping[str, Any],
) -> Mapping[str, Any] | None:
sessions = assessment.get("sessions")
if not isinstance(sessions, list):
return None
candidates = [item for item in sessions if isinstance(item, dict)]
if not candidates:
return None
return max(candidates, key=lambda item: int(item.get("session_no") or 0))
def _trajectory_uncertainty(session: Mapping[str, Any]) -> float:
axes = session.get("axes")
if not isinstance(axes, list):
return 1.0
values = [
float(item["uncertainty"])
for item in axes
if isinstance(item, dict) and item.get("uncertainty") is not None
]
return max(values) if values else 1.0
def _interval_width(payload: Mapping[str, Any]) -> float:
interval = payload.get("error_interval") or payload.get("success_interval")
if not isinstance(interval, dict):
return 1.0
try:
return max(0.0, min(1.0, float(interval["upper"]) - float(interval["lower"])))
except (KeyError, TypeError, ValueError):
return 1.0
def derive_attention_signals(
*,
trajectory_rows: Sequence[Mapping[str, Any]],
rupture_rows: Sequence[Mapping[str, Any]],
calibration_rows: Sequence[Mapping[str, Any]],
transfer_rows: Sequence[Mapping[str, Any]],
practice_rows: Sequence[Mapping[str, Any]],
safety_rows: Sequence[Mapping[str, Any]] = (),
) -> tuple[DerivedSignal, ...]:
"""파생 규칙은 관찰된 원장 상태만 사용하며 임상 진단을 만들지 않는다."""
output: list[DerivedSignal] = []
for row in safety_rows:
# 원천 테이블은 nullable 참조와 JSON detail을 허용한다. 유효한 회기에
# 커밋된 escalation만 채택하고 detail·trigger 원문·PII는 투영하지 않는다.
if row.get("escalated") is not True:
continue
event_id = row.get("safety_event_id")
trigger_type = row.get("trigger_type")
session_value = row.get("session_id")
learner_value = row.get("learner_id")
sequence_value = row.get("observed_sequence")
if (
not isinstance(event_id, int)
or isinstance(event_id, bool)
or event_id < 1
or not isinstance(trigger_type, str)
or not trigger_type.strip()
or session_value is None
or learner_value is None
or not isinstance(sequence_value, int)
or isinstance(sequence_value, bool)
or sequence_value < 1
):
continue
try:
learner_id = UUID(str(learner_value))
session_id = UUID(str(session_value))
except (TypeError, ValueError):
continue
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id="competency.safety_boundary",
signal=LearnerAttentionSignal(
signal_id=_signal_id("safety", event_id),
learner_ref=_learner_ref(learner_id),
signal_type="safety_boundary",
severity="high",
state="active",
# app.safety_events에는 보정된 uncertainty가 없으므로
# 확신도를 만들어내지 않고 unknown(1.0)을 보존한다.
uncertainty=1.0,
observed_sequence=sequence_value,
evidence=(
_pointer(
ledger="safety_event",
event_id=event_id,
session_id=session_id,
),
),
),
)
)
for row in trajectory_rows:
assessment = _value(row, "assessment", {})
if not isinstance(assessment, dict):
continue
latest = _latest_session_assessment(assessment)
if latest is None:
continue
trajectory_status = str(latest.get("status") or "")
if trajectory_status not in {"off_track", "deteriorating"}:
continue
learner_id = UUID(str(row["learner_id"]))
revision_id = row["revision_id"]
pointer = _pointer(
ledger="outcome_trajectory_revision",
event_id=revision_id,
session_id=row.get("anchor_session_id"),
)
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id="competency.outcome_monitoring",
signal=LearnerAttentionSignal(
signal_id=_signal_id("trajectory", revision_id, trajectory_status),
learner_ref=_learner_ref(learner_id),
signal_type="deterioration",
severity="high"
if trajectory_status == "deteriorating"
else "moderate",
state="active",
uncertainty=_trajectory_uncertainty(latest),
observed_sequence=max(1, int(_value(row, "revision_no", 1))),
evidence=(pointer,),
),
)
)
for row in rupture_rows:
rupture_state = str(_value(row, "to_state", ""))
if rupture_state not in _UNRESOLVED_RUPTURE_STATES:
continue
learner_id = UUID(str(row["learner_id"]))
observation_id = row["observation_id"]
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id="competency.rupture_repair",
signal=LearnerAttentionSignal(
signal_id=_signal_id("rupture", observation_id, rupture_state),
learner_ref=_learner_ref(learner_id),
signal_type="unresolved_rupture",
severity="high" if rupture_state == "missed" else "moderate",
state="active",
uncertainty=float(_value(row, "uncertainty", 1.0)),
observed_sequence=max(1, int(_value(row, "sequence_no", 1))),
evidence=(
_pointer(
ledger="rupture_observation_event",
event_id=observation_id,
session_id=row.get("session_id"),
),
),
),
)
)
calibration_groups: dict[tuple[UUID, str], list[Mapping[str, Any]]] = defaultdict(
list
)
for row in calibration_rows:
key = (UUID(str(row["learner_id"])), str(row["competency_id"]))
calibration_groups[key].append(row)
for (learner_id, competency_id), rows in calibration_groups.items():
ordered = sorted(
rows, key=lambda item: int(_value(item, "snapshot_no", 0)), reverse=True
)
recent = ordered[:2]
if len(recent) < 2:
continue
payloads = [_value(item, "assessment_payload", {}) for item in recent]
if not all(
isinstance(item, dict) and item.get("bias") == "overconfident"
for item in payloads
):
continue
latest = recent[0]
latest_payload = payloads[0]
snapshot_id = latest["assessment_snapshot_id"]
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id=competency_id,
signal=LearnerAttentionSignal(
signal_id=_signal_id("calibration", snapshot_id, "overconfident"),
learner_ref=_learner_ref(learner_id),
signal_type="persistent_overconfidence",
severity="moderate",
state="active",
uncertainty=_interval_width(latest_payload),
observed_sequence=max(1, int(_value(latest, "snapshot_no", 1))),
evidence=(
_pointer(
ledger="calibration_assessment",
event_id=snapshot_id,
session_id=latest.get("session_id"),
),
),
counterevidence=tuple(
str(value)
for value in latest_payload.get("counterevidence", [])
),
),
)
)
for row in transfer_rows:
payload = _value(row, "assessment_payload", {})
if (
not isinstance(payload, dict)
or payload.get("transfer_verified") is not False
):
continue
learner_id = UUID(str(row["learner_id"]))
assessment_id = row["transfer_assessment_id"]
eligible = bool(payload.get("eligible"))
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id=str(row["competency_id"]),
signal=LearnerAttentionSignal(
signal_id=_signal_id("transfer", assessment_id, "failed"),
learner_ref=_learner_ref(learner_id),
signal_type="transfer_failure",
severity="moderate" if eligible else "low",
state="active" if eligible else "monitoring",
uncertainty=_interval_width(payload),
observed_sequence=max(1, int(_value(row, "observed_sequence", 1))),
evidence=(
_pointer(
ledger="transfer_assessment",
event_id=assessment_id,
session_id=row.get("session_id"),
),
),
counterevidence=tuple(
str(value) for value in payload.get("blockers", [])
),
),
)
)
practice_groups: dict[tuple[UUID, str], list[Mapping[str, Any]]] = defaultdict(list)
for row in practice_rows:
key = (UUID(str(row["learner_id"])), str(row["competency_id"]))
practice_groups[key].append(row)
for (learner_id, competency_id), rows in practice_groups.items():
ordered = sorted(
rows, key=lambda item: int(_value(item, "sequence_no", 0)), reverse=True
)
recent = [item for item in ordered if item.get("outcome") == "needs_retry"][:2]
if len(recent) < 2:
continue
newest = recent[0]
evidence = tuple(
_pointer(
ledger="practice_attempt",
event_id=item["attempt_record_id"],
session_id=item.get("session_id"),
)
for item in recent
)
output.append(
DerivedSignal(
learner_id=learner_id,
competency_id=competency_id,
signal=LearnerAttentionSignal(
signal_id=_signal_id(
"practice", newest["attempt_record_id"], "stagnation"
),
learner_ref=_learner_ref(learner_id),
signal_type="growth_stagnation",
severity="moderate",
state="monitoring",
uncertainty=max(
float(_value(item, "uncertainty", 1.0)) for item in recent
),
observed_sequence=max(1, int(_value(newest, "sequence_no", 1))),
evidence=evidence,
counterevidence=tuple(
str(value)
for item in recent
for value in _value(item, "counterevidence", [])
),
),
)
)
return tuple(
sorted(
output,
key=lambda item: (item.signal.learner_ref, item.signal.signal_id),
)
)
async def _load_attention_rows(
conn: asyncpg.Connection, cohort_id: str
) -> dict[str, Sequence[Mapping[str, Any]]]:
trajectory = await conn.fetch(
"""
SELECT DISTINCT ON (r.learner_id, r.case_id)
r.learner_id, r.revision_id, r.anchor_session_id, r.revision_no, r.assessment
FROM app.outcome_trajectory_revision r
JOIN app.app_user u ON u.user_id = r.learner_id
WHERE u.cohort = $1
ORDER BY r.learner_id, r.case_id, r.revision_no DESC
""",
cohort_id,
)
ruptures = await conn.fetch(
"""
SELECT DISTINCT ON (ep.learner_id, ev.episode_id)
ep.learner_id, ev.observation_id, ev.session_id, ev.sequence_no,
ev.to_state, ev.uncertainty
FROM app.rupture_observation_event ev
JOIN app.rupture_episode ep ON ep.episode_id = ev.episode_id
JOIN app.app_user u ON u.user_id = ep.learner_id
WHERE u.cohort = $1
ORDER BY ep.learner_id, ev.episode_id, ev.sequence_no DESC
""",
cohort_id,
)
safety = await conn.fetch(
"""
SELECT se.id AS safety_event_id, s.learner_id, se.session_id,
se.turn_id, se.trigger_type, se.ko_risk_level, se.escalated,
row_number() OVER (
PARTITION BY s.learner_id ORDER BY se.created_at, se.id
)::int AS observed_sequence
FROM app.safety_events se
JOIN app.sessions s ON s.id = se.session_id
JOIN app.app_user u ON u.user_id = s.learner_id
WHERE u.cohort = $1 AND se.escalated = TRUE
ORDER BY s.learner_id, se.created_at, se.id
""",
cohort_id,
)
calibration = await conn.fetch(
"""
SELECT * FROM (
SELECT a.learner_id, a.competency_id, a.assessment_snapshot_id,
a.session_id, a.snapshot_no, a.assessment_payload,
row_number() OVER (
PARTITION BY a.learner_id, a.competency_id ORDER BY a.snapshot_no DESC
) AS recent_rank
FROM app.calibration_assessment_snapshot a
JOIN app.app_user u ON u.user_id = a.learner_id
WHERE u.cohort = $1
) ranked WHERE recent_rank <= 2
""",
cohort_id,
)
transfers = await conn.fetch(
"""
SELECT DISTINCT ON (a.learner_id, a.competency_id)
a.learner_id, a.competency_id, a.transfer_assessment_id,
a.session_id, a.assessment_payload, 1 AS observed_sequence
FROM app.calibration_transfer_assessment a
JOIN app.app_user u ON u.user_id = a.learner_id
WHERE u.cohort = $1
ORDER BY a.learner_id, a.competency_id, a.created_at DESC
""",
cohort_id,
)
practices = await conn.fetch(
"""
SELECT * FROM (
SELECT a.learner_id, p.competency_id, a.attempt_record_id,
a.session_id, a.sequence_no, a.outcome, a.uncertainty,
a.counterevidence,
row_number() OVER (
PARTITION BY a.learner_id, p.competency_id ORDER BY a.created_at DESC
) AS recent_rank
FROM app.practice_attempt_evidence a
JOIN app.practice_episode_submission e
ON e.episode_submission_id = a.episode_submission_id
JOIN app.practice_prescription p
ON p.prescription_record_id = e.prescription_record_id
JOIN app.app_user u ON u.user_id = a.learner_id
WHERE u.cohort = $1 AND a.outcome = 'needs_retry'
) ranked WHERE recent_rank <= 2
""",
cohort_id,
)
return {
"trajectory_rows": trajectory,
"rupture_rows": ruptures,
"safety_rows": safety,
"calibration_rows": calibration,
"transfer_rows": transfers,
"practice_rows": practices,
}
_MANIFEST_QUERIES: Mapping[str, tuple[str, str, str, str]] = {
"alliance": (
"""
SELECT m.measurement_id AS event_id, s.learner_id, m.session_id
FROM app.measurement_event m
JOIN app.sessions s ON s.id = m.session_id
JOIN app.app_user u ON u.user_id = s.learner_id
WHERE u.cohort = $1 AND m.construct = 'working_alliance' AND m.status = 'ready'
ORDER BY m.created_at, m.measurement_id
""",
"measurement_event",
"vignette.measurement-event.v1",
"db://app.measurement_event",
),
"rupture": (
"""
SELECT ev.observation_id AS event_id, ep.learner_id, ev.session_id
FROM app.rupture_observation_event ev
JOIN app.rupture_episode ep ON ep.episode_id = ev.episode_id
JOIN app.app_user u ON u.user_id = ep.learner_id
WHERE u.cohort = $1
ORDER BY ev.created_at, ev.observation_id
""",
"rupture_observation_event",
"vignette.rupture-observation.v1",
"db://app.rupture_observation_event",
),
"transfer": (
"""
SELECT a.transfer_assessment_id AS event_id, a.learner_id, a.session_id
FROM app.calibration_transfer_assessment a
JOIN app.app_user u ON u.user_id = a.learner_id
WHERE u.cohort = $1
ORDER BY a.created_at, a.transfer_assessment_id
""",
"transfer_assessment",
"vignette.transfer-assessment.v1",
"db://app.calibration_transfer_assessment",
),
"calibration": (
"""
SELECT a.assessment_snapshot_id AS event_id, a.learner_id, a.session_id
FROM app.calibration_assessment_snapshot a
JOIN app.app_user u ON u.user_id = a.learner_id
WHERE u.cohort = $1
ORDER BY a.created_at, a.assessment_snapshot_id
""",
"calibration_assessment",
"vignette.calibration-assessment.v1",
"db://app.calibration_assessment_snapshot",
),
}
def derive_manifest_input(
rows_by_domain: Mapping[str, Sequence[Mapping[str, Any]]],
) -> ManifestInput | None:
if set(rows_by_domain) != set(_MANIFEST_QUERIES):
return None
artifacts: list[Phase3EvidenceArtifact] = []
sources: dict[str, tuple[UUID, LedgerEvidencePointer]] = {}
fingerprint_payload: dict[str, list[str]] = {}
for domain in ("alliance", "rupture", "transfer", "calibration"):
rows = rows_by_domain[domain]
if not rows:
return None
ordered = sorted(rows, key=lambda row: str(row["event_id"]))
event_ids = [str(row["event_id"]) for row in ordered]
fingerprint_payload[domain] = event_ids
digest = _canonical_hash(event_ids)
_, ledger, schema_version, provenance_uri = _MANIFEST_QUERIES[domain]
artifacts.append(
Phase3EvidenceArtifact(
domain=domain,
artifact_id=f"oas-g6-artifact-{domain}-{digest[:16]}",
schema_version=schema_version,
content_sha256=digest,
record_count=len(event_ids),
provenance_uri=provenance_uri,
)
)
anchor = ordered[0]
sources[domain] = (
UUID(str(anchor["learner_id"])),
_pointer(
ledger=ledger,
event_id=anchor["event_id"],
session_id=anchor.get("session_id"),
),
)
return ManifestInput(
artifacts=tuple(artifacts),
source_by_domain=sources,
source_fingerprint=_canonical_hash(fingerprint_payload),
)
async def _load_manifest_rows(
conn: asyncpg.Connection, cohort_id: str
) -> dict[str, Sequence[Mapping[str, Any]]]:
output: dict[str, Sequence[Mapping[str, Any]]] = {}
for domain, (query, _, _, _) in _MANIFEST_QUERIES.items():
output[domain] = await conn.fetch(query, cohort_id)
return output
async def produce_supervision_cycle(
conn: asyncpg.Connection,
*,
cohort_id: str,
) -> dict[str, Any]:
"""한 코호트의 원장 상태를 idempotent G6 파생 산출물로 투영한다."""
if not cohort_id.strip():
raise ValueError("cohort_id must not be blank")
rows = await _load_attention_rows(conn, cohort_id)
derived = derive_attention_signals(**rows)
attention_result: dict[str, Any] | None = None
gap_results: list[dict[str, Any]] = []
if derived:
attention_fingerprint = _canonical_hash(
[item.signal.model_dump(mode="json") for item in derived]
)
attention_submission_id = uuid5(
_PRODUCER_NAMESPACE,
f"attention-submission:{cohort_id}:{attention_fingerprint}",
)
attention_result = await supervision_research_store.append_attention_snapshot(
conn,
submission_id=attention_submission_id,
snapshot_id=uuid5(
_PRODUCER_NAMESPACE,
f"attention-snapshot:{cohort_id}:{attention_fingerprint}",
),
cohort_id=cohort_id,
signals=[item.signal for item in derived],
learner_ids_by_ref={
item.signal.learner_ref: item.learner_id for item in derived
},
)
gap_groups: dict[tuple[str, str], list[DerivedSignal]] = defaultdict(list)
gap_kind_by_signal = {
"deterioration": "coverage",
"unresolved_rupture": "rupture_repair",
"persistent_overconfidence": "calibration",
"growth_stagnation": "growth_stagnation",
"transfer_failure": "transfer",
}
for item in derived:
gap_kind = gap_kind_by_signal.get(item.signal.signal_type)
if gap_kind is None:
# 안전 사건은 attention queue 대상이지 교육과정 성과 집계가 아니다.
continue
gap_groups[(item.competency_id, gap_kind)].append(item)
for (competency_id, gap_kind), items in sorted(gap_groups.items()):
evidence = [(item.learner_id, item.signal.evidence[0]) for item in items]
gap_fingerprint = _canonical_hash(
[
competency_id,
gap_kind,
[item.signal.signal_id for item in items],
]
)
result = await supervision_research_store.append_curriculum_gap(
conn,
submission_id=uuid5(
_PRODUCER_NAMESPACE,
f"gap-submission:{cohort_id}:{gap_fingerprint}",
),
gap_snapshot_id=uuid5(
_PRODUCER_NAMESPACE,
f"gap-snapshot:{cohort_id}:{gap_fingerprint}",
),
cohort_id=cohort_id,
competency_id=competency_id,
gap_kind=gap_kind,
status="observed"
if len({item.learner_id for item in items}) >= 2
else "monitoring",
uncertainty=max(item.signal.uncertainty for item in items),
affected_learner_count=len({item.learner_id for item in items}),
evidence=evidence,
)
gap_results.append(result)
manifest_input = derive_manifest_input(await _load_manifest_rows(conn, cohort_id))
manifest_result: dict[str, Any] | None = None
if manifest_input is not None:
manifest_result = await supervision_research_store.append_phase3_manifest(
conn,
submission_id=uuid5(
_PRODUCER_NAMESPACE,
f"manifest-submission:{cohort_id}:{manifest_input.source_fingerprint}",
),
manifest_id=uuid5(
_PRODUCER_NAMESPACE,
f"manifest:{cohort_id}:{manifest_input.source_fingerprint}",
),
cohort_id=cohort_id,
artifacts=manifest_input.artifacts,
source_by_domain=manifest_input.source_by_domain,
)
return {
"cohort_id": cohort_id,
"derived_signal_count": len(derived),
"attention_snapshot": attention_result,
"curriculum_gaps": gap_results,
"phase3_manifest": manifest_result,
"raw_transcript_included": False,
"clinical_claim_allowed": False,
}
async def produce_all_active_cohorts_once() -> dict[str, Any]:
"""현재 활성 학습자 코호트를 한 번 순회한다.
각 코호트는 독립 트랜잭션이다. 한 코호트의 손상된 포인터가 다른 코호트의
파생 산출물을 롤백하지 않는다.
"""
async with db.acquire(ai_view="research", ai_context=True) as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT cohort
FROM app.app_user
WHERE role = 'learner' AND is_active AND cohort IS NOT NULL
AND length(btrim(cohort)) > 0
ORDER BY cohort
"""
)
cohorts = [str(row["cohort"]) for row in rows]
completed = 0
failed = 0
for cohort_id in cohorts:
try:
async with db.acquire(
cohort=cohort_id,
ai_view="supervisor",
ai_context=True,
) as conn:
await produce_supervision_cycle(conn, cohort_id=cohort_id)
completed += 1
except asyncio.CancelledError:
raise
except Exception:
failed += 1
logger.exception("G6 원장 파생 cycle 실패: cohort=%s", cohort_id)
# Repo-approved synthetic evaluator comparison is a global research ledger,
# not a learner cohort score. Give it its own transaction so a malformed or
# incomplete benchmark anchor can never roll back a completed supervisor cycle.
version_comparison: dict[str, Any] = {
"status": "skipped",
"reason": "not_attempted",
"raw_transcript_included": False,
"clinical_claim_allowed": False,
}
version_comparison_failed = 0
try:
async with db.acquire(ai_view="research", ai_context=True) as conn:
version_comparison = await supervision_research_version_evaluator.produce_repository_version_comparison(
conn
)
except asyncio.CancelledError:
raise
except Exception:
version_comparison_failed = 1
version_comparison = {
"status": "error",
"reason": "repository_version_comparison_failed",
"raw_transcript_included": False,
"clinical_claim_allowed": False,
}
logger.exception("G6 repo benchmark version comparison 실패 격리")
return {
"cohort_count": len(cohorts),
"completed": completed,
"failed": failed,
"version_comparison": version_comparison,
"version_comparison_failed": version_comparison_failed,
}
async def _producer_loop() -> None:
delay = settings.supervision_research_producer_startup_delay_seconds
if delay:
await asyncio.sleep(delay)
while True:
try:
result = await produce_all_active_cohorts_once()
if result["cohort_count"]:
logger.info(
"G6 원장 파생 cycle 완료: cohorts=%d completed=%d failed=%d "
"version_comparison=%s comparison_failed=%d",
result["cohort_count"],
result["completed"],
result["failed"],
result["version_comparison"].get("status"),
result["version_comparison_failed"],
)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("G6 원장 파생 scheduler cycle 실패")
await asyncio.sleep(settings.supervision_research_producer_interval_seconds)
def schedule_supervision_research_producer() -> asyncio.Task[None] | None:
global _PRODUCER_TASK
if not settings.supervision_research_producer_enabled:
return None
if _PRODUCER_TASK is not None and not _PRODUCER_TASK.done():
return _PRODUCER_TASK
_PRODUCER_TASK = asyncio.create_task(
_producer_loop(),
name="supervision-research-ledger-producer",
)
return _PRODUCER_TASK
async def stop_supervision_research_producer() -> None:
global _PRODUCER_TASK
task = _PRODUCER_TASK
_PRODUCER_TASK = None
if task is None or task.done():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
__all__ = [
"DerivedSignal",
"ManifestInput",
"derive_attention_signals",
"derive_manifest_input",
"produce_all_active_cohorts_once",
"produce_supervision_cycle",
"schedule_supervision_research_producer",
"stop_supervision_research_producer",
]