vignette/apps/api/app/services/continuous_improvement_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

511 lines
18 KiB
Python

"""Durable scheduled producer for the G8 agentic content pipeline.
The producer is intentionally opt-in. It only claims source packs already
classified as synthetic research material, revalidates source approval/hash/PII
before every engine run, and can only persist a pending human-review candidate.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from pathlib import Path
from typing import Any, Literal
from uuid import UUID, uuid5
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from .. import db
from ..config import settings
from ..engine_client import EngineClient, engine_client
from . import continuous_improvement_agentic, continuous_improvement_store
logger = logging.getLogger(__name__)
DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift"
_PRODUCER_NAMESPACE = UUID("87cd17b8-2e03-5ea9-9454-7ff60d7ba142")
_REPO_SOURCE_PATH = (
Path(__file__).resolve().parents[1]
/ "data"
/ "continuous_improvement"
/ "synthetic_source_pack.v5.json"
)
_PRODUCER_TASK: asyncio.Task[None] | None = None
class AgenticJobError(ValueError):
"""Durable queue specification or transition failed."""
class ScheduledAgenticJobSpec(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
job_key: str = Field(pattern=r"^oas-g8-job-[a-z0-9-]+$")
data_classification: Literal["synthetic_replay_red_team_coverage_drift"]
content_kind: Literal["case", "rupture", "practice", "benchmark"]
difficulty_level: int = Field(ge=1, le=5)
variant_count: int = Field(ge=3, le=12)
prompt_version: str = Field(min_length=1, max_length=80)
trigger_kind: Literal["scheduled_repo_source", "scheduled_incident"]
source_packs: tuple[continuous_improvement_agentic.AgenticSourcePack, ...] = Field(
min_length=1,
max_length=20,
)
@model_validator(mode="after")
def validate_sources_before_enqueue(self) -> "ScheduledAgenticJobSpec":
continuous_improvement_agentic.validate_source_packs(self.source_packs)
return self
class ClaimedAgenticJob(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
job_id: UUID
spec: ScheduledAgenticJobSpec
source_fingerprint: str = Field(pattern=r"^[a-f0-9]{64}$")
attempt_count: int = Field(ge=1)
class AgenticJobOutcome(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
job_id: UUID
status: Literal["completed", "retry_wait", "rejected"]
agent_calls_executed: int = Field(ge=0)
idempotent_replay: bool = False
error_code: str | None = None
class _ProducerEngine:
"""Apply the G8 call timeout without changing other EngineClient users."""
def __init__(self, engine: Any) -> None:
self.engine = engine
async def generate(self, request: Any) -> Any:
if isinstance(self.engine, EngineClient):
return await self.engine.generate(
request,
timeout=settings.continuous_improvement_producer_engine_timeout_seconds,
)
return await self.engine.generate(request)
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
def _fingerprint(spec: ScheduledAgenticJobSpec) -> str:
return hashlib.sha256(
_canonical_json(spec.model_dump(mode="json")).encode("utf-8")
).hexdigest()
def _job_ids(job_id: UUID) -> dict[str, UUID]:
return {
name: uuid5(job_id, name)
for name in (
"submission",
"pipeline",
"benchmark_record",
"qualification",
)
}
def load_repo_approved_job(
path: Path = _REPO_SOURCE_PATH,
) -> ScheduledAgenticJobSpec:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return ScheduledAgenticJobSpec.model_validate(payload)
except (OSError, json.JSONDecodeError, ValidationError) as exc:
raise AgenticJobError("repo-approved G8 source pack is invalid") from exc
async def enqueue_agentic_job(conn: Any, spec: ScheduledAgenticJobSpec) -> UUID:
"""Idempotently enqueue an immutable validated source/configuration tuple."""
# Revalidate at the persistence boundary even when caller already has a model.
continuous_improvement_agentic.validate_source_packs(spec.source_packs)
if spec.data_classification != DATA_CLASSIFICATION:
raise AgenticJobError("unsupported agentic job data classification")
job_id = uuid5(_PRODUCER_NAMESPACE, spec.job_key)
source_fingerprint = _fingerprint(spec)
await conn.execute(
"""
INSERT INTO app.ci_agentic_job (
job_id, job_key, source_packs, source_fingerprint, data_classification,
content_kind, difficulty_level, variant_count, prompt_version, trigger_kind
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (job_id) DO NOTHING
""",
job_id,
spec.job_key,
[item.model_dump(mode="json") for item in spec.source_packs],
source_fingerprint,
spec.data_classification,
spec.content_kind,
spec.difficulty_level,
spec.variant_count,
spec.prompt_version,
spec.trigger_kind,
)
row = await conn.fetchrow(
"""
SELECT job_key, source_fingerprint, data_classification
FROM app.ci_agentic_job WHERE job_id = $1
""",
job_id,
)
if row is None:
raise AgenticJobError("agentic job is not visible after enqueue")
if (
str(row["job_key"]) != spec.job_key
or str(row["source_fingerprint"]) != source_fingerprint
or str(row["data_classification"]) != DATA_CLASSIFICATION
):
raise AgenticJobError("agentic job key was reused with changed source input")
return job_id
async def ensure_repo_approved_job() -> UUID:
spec = load_repo_approved_job()
async with db.acquire(ai_view="research", ai_context=True) as conn:
return await enqueue_agentic_job(conn, spec)
async def claim_next_agentic_job(
*, job_id: UUID | None = None
) -> ClaimedAgenticJob | None:
async with db.acquire(ai_view="research", ai_context=True) as conn:
row = await conn.fetchrow(
"""
WITH candidate AS (
SELECT job_id
FROM app.ci_agentic_job
WHERE (
(status IN ('pending','retry_wait') AND next_attempt_at <= now())
OR (
status = 'processing'
AND lease_started_at <= now() - ($1::double precision * interval '1 second')
)
)
AND ($2::uuid IS NULL OR job_id = $2)
ORDER BY next_attempt_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE app.ci_agentic_job job
SET status = 'processing',
attempt_count = job.attempt_count + 1,
lease_started_at = now(),
updated_at = now(),
last_error_code = NULL,
last_error_message = NULL
FROM candidate
WHERE job.job_id = candidate.job_id
RETURNING job.*
""",
settings.continuous_improvement_producer_lease_timeout_seconds,
job_id,
)
if row is None:
return None
try:
spec = ScheduledAgenticJobSpec.model_validate(
{
"job_key": row["job_key"],
"data_classification": row["data_classification"],
"content_kind": row["content_kind"],
"difficulty_level": row["difficulty_level"],
"variant_count": row["variant_count"],
"prompt_version": row["prompt_version"],
"trigger_kind": row["trigger_kind"],
"source_packs": row["source_packs"],
}
)
return ClaimedAgenticJob(
job_id=row["job_id"],
spec=spec,
source_fingerprint=str(row["source_fingerprint"]),
attempt_count=int(row["attempt_count"]),
)
except (ValidationError, continuous_improvement_agentic.AgenticPipelineRejectedError) as exc:
# The row was durable before validation failed. Preserve a terminal,
# inspectable rejection without exposing source/model payloads in errors.
job_id = UUID(str(row["job_id"]))
await _mark_rejected(job_id, "invalid_source_contract", str(exc))
raise AgenticJobError("claimed job failed source validation") from exc
def _safe_error_message(exc: BaseException) -> str:
return f"{type(exc).__name__}: {str(exc)}"[:500]
async def _mark_retry(job_id: UUID, error_code: str, exc: BaseException) -> None:
async with db.acquire(ai_view="research", ai_context=True) as conn:
await conn.execute(
"""
UPDATE app.ci_agentic_job
SET status = 'retry_wait', lease_started_at = NULL,
next_attempt_at = now() + ($2::double precision * interval '1 second'),
last_error_code = $3, last_error_message = $4, updated_at = now()
WHERE job_id = $1 AND status = 'processing'
""",
job_id,
settings.continuous_improvement_producer_retry_delay_seconds,
error_code,
_safe_error_message(exc),
)
async def _mark_rejected(job_id: UUID, error_code: str, message: str) -> None:
async with db.acquire(ai_view="research", ai_context=True) as conn:
await conn.execute(
"""
UPDATE app.ci_agentic_job
SET status = 'rejected', lease_started_at = NULL,
last_error_code = $2, last_error_message = $3, updated_at = now()
WHERE job_id = $1 AND status = 'processing'
""",
job_id,
error_code,
message[:500],
)
async def execute_claimed_agentic_job(
job: ClaimedAgenticJob,
*,
engine: Any = engine_client,
) -> AgenticJobOutcome:
"""Execute one claimed job without allowing it to block later jobs."""
ids = _job_ids(job.job_id)
try:
if job.spec.data_classification != DATA_CLASSIFICATION:
raise continuous_improvement_agentic.AgenticPipelineRejectedError(
"unsupported scheduled source classification"
)
continuous_improvement_agentic.validate_source_packs(job.spec.source_packs)
if _fingerprint(job.spec) != job.source_fingerprint:
raise continuous_improvement_agentic.AgenticPipelineRejectedError(
"scheduled source fingerprint mismatch"
)
async with db.acquire(ai_view="research", ai_context=True) as conn:
result = await continuous_improvement_agentic.run_agentic_content_pipeline(
conn=conn,
engine=_ProducerEngine(engine),
submission_id=ids["submission"],
pipeline_id=ids["pipeline"],
benchmark_record_id=ids["benchmark_record"],
qualification_id=ids["qualification"],
source_packs=job.spec.source_packs,
content_kind=job.spec.content_kind,
difficulty_level=job.spec.difficulty_level,
variant_count=job.spec.variant_count,
prompt_version=job.spec.prompt_version,
trigger_kind=job.spec.trigger_kind,
)
if (
result.state != "pending_human_approval"
or not result.human_approval_required
or result.catalog_promoted
or result.clinical_claim_allowed
):
raise continuous_improvement_agentic.AgenticPipelineExecutionError(
"scheduled pipeline crossed the mandatory human approval boundary"
)
await conn.execute(
"""
UPDATE app.ci_agentic_job
SET status = 'completed', lease_started_at = NULL,
result_submission_id = $2, result_qualification_id = $3,
completed_at = now(), updated_at = now()
WHERE job_id = $1 AND status = 'processing'
""",
job.job_id,
result.submission_id,
result.qualification_id,
)
return AgenticJobOutcome(
job_id=job.job_id,
status="completed",
agent_calls_executed=result.agent_calls_executed,
idempotent_replay=result.idempotent_replay,
)
except asyncio.CancelledError:
raise
except continuous_improvement_agentic.AgenticPipelineRejectedError as exc:
await _mark_rejected(job.job_id, "safety_gate_rejected", _safe_error_message(exc))
return AgenticJobOutcome(
job_id=job.job_id,
status="rejected",
agent_calls_executed=0,
error_code="safety_gate_rejected",
)
except continuous_improvement_store.ContinuousImprovementConflictError as exc:
await _mark_rejected(job.job_id, "idempotency_conflict", _safe_error_message(exc))
return AgenticJobOutcome(
job_id=job.job_id,
status="rejected",
agent_calls_executed=0,
error_code="idempotency_conflict",
)
except continuous_improvement_agentic.AgenticPipelineExecutionError as exc:
await _mark_retry(job.job_id, "engine_execution_failed", exc)
return AgenticJobOutcome(
job_id=job.job_id,
status="retry_wait",
agent_calls_executed=0,
error_code="engine_execution_failed",
)
except Exception as exc:
await _mark_retry(job.job_id, "unexpected_execution_failed", exc)
logger.exception("G8 scheduled agentic job failed: job_id=%s", job.job_id)
return AgenticJobOutcome(
job_id=job.job_id,
status="retry_wait",
agent_calls_executed=0,
error_code="unexpected_execution_failed",
)
async def produce_queued_agentic_jobs_once() -> dict[str, int]:
"""Bootstrap the repo source and process an isolated bounded job batch."""
# Imported lazily to keep the trigger's job-spec dependency acyclic. This
# runs only when the existing default-off producer scheduler (or an explicit
# one-shot caller) invokes a cycle.
from . import continuous_improvement_trigger
drift_trigger = {
"drift_signals_scanned": 0,
"drift_invalid_signals": 0,
"drift_incidents_created": 0,
"drift_incident_replays": 0,
"drift_jobs_enqueued": 0,
"drift_trigger_failed": 0,
}
if settings.continuous_improvement_drift_trigger_enabled:
try:
triggered = (
await continuous_improvement_trigger.enqueue_drift_adversarial_jobs_once()
)
drift_trigger.update(
{
"drift_signals_scanned": triggered.scanned,
"drift_invalid_signals": triggered.invalid_signals,
"drift_incidents_created": triggered.incidents_created,
"drift_incident_replays": triggered.incident_replays,
"drift_jobs_enqueued": triggered.jobs_enqueued,
}
)
except asyncio.CancelledError:
raise
except Exception:
drift_trigger["drift_trigger_failed"] = 1
logger.exception("G8 operational drift trigger failed closed")
enqueued = 0
bootstrap_failed = 0
try:
await ensure_repo_approved_job()
enqueued = 1
except asyncio.CancelledError:
raise
except Exception:
bootstrap_failed = 1
logger.exception("G8 repo-approved source enqueue failed")
counts = {"completed": 0, "retry_wait": 0, "rejected": 0}
claimed = 0
for _ in range(settings.continuous_improvement_producer_batch_size):
try:
job = await claim_next_agentic_job()
if job is None:
break
claimed += 1
outcome = await execute_claimed_agentic_job(job)
counts[outcome.status] += 1
except asyncio.CancelledError:
raise
except Exception:
counts["retry_wait"] += 1
logger.exception("G8 scheduler isolated an unhandled job failure")
return {
**drift_trigger,
"repo_source_enqueued": enqueued,
"bootstrap_failed": bootstrap_failed,
"claimed": claimed,
**counts,
}
async def _producer_loop() -> None:
delay = settings.continuous_improvement_producer_startup_delay_seconds
if delay:
await asyncio.sleep(delay)
while True:
try:
result = await produce_queued_agentic_jobs_once()
if result["claimed"] or result["bootstrap_failed"]:
logger.info("G8 scheduled agentic cycle: %s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("G8 scheduled agentic cycle failed")
await asyncio.sleep(settings.continuous_improvement_producer_interval_seconds)
def schedule_continuous_improvement_producer() -> asyncio.Task[None] | None:
global _PRODUCER_TASK
if not settings.continuous_improvement_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="continuous-improvement-agentic-producer",
)
return _PRODUCER_TASK
async def stop_continuous_improvement_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__ = [
"AgenticJobError",
"AgenticJobOutcome",
"ClaimedAgenticJob",
"ScheduledAgenticJobSpec",
"claim_next_agentic_job",
"enqueue_agentic_job",
"ensure_repo_approved_job",
"execute_claimed_agentic_job",
"load_repo_approved_job",
"produce_queued_agentic_jobs_once",
"schedule_continuous_improvement_producer",
"stop_continuous_improvement_producer",
]