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 산출물은 커밋에서 제외했다.
817 lines
31 KiB
Python
817 lines
31 KiB
Python
"""G8 source pack -> draft -> red-team -> benchmark agentic worker.
|
|
|
|
The existing continuous-improvement store is deliberately the only persistence
|
|
boundary. This worker owns model calls and local fail-closed validation, then
|
|
hands the resulting immutable metadata to that store only after every agent
|
|
stage has succeeded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import re
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any, Protocol
|
|
from urllib.parse import urlsplit
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
|
|
from ..contracts.continuous_improvement import (
|
|
ContentBenchmarkQualification,
|
|
ContentSourceArtifact,
|
|
GeneratedContentDraft,
|
|
IndependentRedTeamReview,
|
|
OperationalIncident,
|
|
RedTeamFinding,
|
|
ReviewDimension,
|
|
)
|
|
from ..contracts.engine_gateway import (
|
|
EngineMessage,
|
|
GenerateRequest,
|
|
GenerateResponse,
|
|
structured_payload_from_response,
|
|
)
|
|
from . import continuous_improvement_store
|
|
from .guardrail import mask_pii, mask_synthetic_generated_pii
|
|
|
|
|
|
PROMPT_VERSION = "1.0.0"
|
|
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9가-힣_]{4,}")
|
|
_REVIEW_LANES: tuple[tuple[str, tuple[ReviewDimension, ...]], ...] = (
|
|
("safety-identity", ("safety", "identity", "pii", "grounding")),
|
|
("leakage-bias", ("answer_leakage", "cultural_bias", "difficulty")),
|
|
)
|
|
|
|
|
|
class AgenticPipelineError(ValueError):
|
|
"""Base error for the model-owned content pipeline."""
|
|
|
|
|
|
class AgenticPipelineExecutionError(AgenticPipelineError):
|
|
"""A required model call or structured response failed."""
|
|
|
|
|
|
class AgenticPipelineRejectedError(AgenticPipelineError):
|
|
"""Generated content failed a safety, grounding, or benchmark gate."""
|
|
|
|
|
|
class StructuredGenerationEngine(Protocol):
|
|
async def generate(self, req: GenerateRequest) -> GenerateResponse: ...
|
|
|
|
|
|
class HttpRollbackExecutor:
|
|
"""Authenticated control-plane adapter for model and runtime rollback."""
|
|
|
|
TOKEN_HEADER = "X-Vignette-Rollback-Executor-Token"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
endpoint: str,
|
|
token: str,
|
|
timeout_seconds: float,
|
|
transport: httpx.AsyncBaseTransport | None = None,
|
|
) -> None:
|
|
parsed = urlsplit(endpoint)
|
|
is_local_http = parsed.scheme == "http" and (parsed.hostname or "").lower() in {
|
|
"localhost",
|
|
"127.0.0.1",
|
|
"::1",
|
|
}
|
|
if parsed.scheme != "https" and not is_local_http:
|
|
raise ValueError("rollback executor endpoint must use HTTPS")
|
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise ValueError("rollback executor endpoint must not contain credentials or query")
|
|
if len(token) < 32:
|
|
raise ValueError("rollback executor token must contain at least 32 characters")
|
|
self._endpoint = endpoint
|
|
self._token = token
|
|
self._timeout_seconds = timeout_seconds
|
|
self._transport = transport
|
|
|
|
async def execute(
|
|
self, request: continuous_improvement_store.RollbackExecutionRequest
|
|
) -> continuous_improvement_store.RollbackExecutionReceipt:
|
|
try:
|
|
async with httpx.AsyncClient(
|
|
timeout=httpx.Timeout(self._timeout_seconds),
|
|
follow_redirects=False,
|
|
trust_env=False,
|
|
transport=self._transport,
|
|
) as client:
|
|
response = await client.post(
|
|
self._endpoint,
|
|
headers={
|
|
self.TOKEN_HEADER: self._token,
|
|
"Accept": "application/json",
|
|
},
|
|
json=request.model_dump(mode="json"),
|
|
)
|
|
response.raise_for_status()
|
|
if len(response.content) > 65_536:
|
|
raise ValueError("rollback executor receipt is too large")
|
|
payload = response.json()
|
|
except (httpx.HTTPError, ValueError) as exc:
|
|
raise AgenticPipelineExecutionError(
|
|
"rollback executor request failed"
|
|
) from exc
|
|
return continuous_improvement_store.RollbackExecutionReceipt.model_validate(payload)
|
|
|
|
|
|
def build_configured_rollback_executor(
|
|
settings: Any,
|
|
) -> continuous_improvement_store.RollbackExecutor | None:
|
|
"""Return the separately opted-in executor; disabled always means no call."""
|
|
|
|
if not bool(settings.continuous_improvement_rollback_executor_enabled):
|
|
return None
|
|
token = settings.continuous_improvement_rollback_executor_token.get_secret_value()
|
|
return HttpRollbackExecutor(
|
|
endpoint=settings.continuous_improvement_rollback_executor_endpoint,
|
|
token=token,
|
|
timeout_seconds=settings.continuous_improvement_rollback_executor_timeout_seconds,
|
|
)
|
|
|
|
|
|
class AgenticSourcePack(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
artifact: ContentSourceArtifact
|
|
content: str = Field(min_length=1, max_length=100_000)
|
|
|
|
|
|
class GroundedClaim(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
claim: str = Field(min_length=1, max_length=600)
|
|
source_ref: str = Field(min_length=1, max_length=180)
|
|
|
|
|
|
class GeneratedDraftPayload(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
title: str = Field(min_length=1, max_length=180)
|
|
synthetic_profile: str = Field(min_length=1, max_length=1200)
|
|
scenario: str = Field(min_length=1, max_length=6000)
|
|
rupture_or_challenge: str = Field(min_length=1, max_length=2400)
|
|
learner_task: str = Field(min_length=1, max_length=2000)
|
|
success_criteria: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
hidden_answer: str = Field(min_length=4, max_length=2000)
|
|
source_refs: tuple[str, ...] = Field(min_length=1, max_length=100)
|
|
grounded_claims: tuple[GroundedClaim, ...] = Field(min_length=1, max_length=20)
|
|
|
|
@model_validator(mode="after")
|
|
def unique_source_refs(self) -> "GeneratedDraftPayload":
|
|
if len(self.source_refs) != len(set(self.source_refs)):
|
|
raise ValueError("generated draft source refs must be unique")
|
|
return self
|
|
|
|
def visible_payload(self) -> dict[str, Any]:
|
|
payload = self.model_dump(mode="json")
|
|
payload.pop("hidden_answer", None)
|
|
return payload
|
|
|
|
|
|
class RedTeamFindingOutput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
dimension: ReviewDimension
|
|
severity: str = Field(pattern=r"^(blocker|high|moderate|low)$")
|
|
evidence_summary: str = Field(min_length=1, max_length=500)
|
|
|
|
|
|
class RedTeamOutput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
dimensions: tuple[ReviewDimension, ...] = Field(min_length=3)
|
|
findings: tuple[RedTeamFindingOutput, ...] = Field(max_length=50)
|
|
|
|
|
|
class BenchmarkVariantOutput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
visible_scenario: str = Field(min_length=1, max_length=5000)
|
|
learner_task: str = Field(min_length=1, max_length=2000)
|
|
success_criteria: tuple[str, ...] = Field(min_length=1, max_length=10)
|
|
hidden_answer: str = Field(min_length=4, max_length=2000)
|
|
source_refs: tuple[str, ...] = Field(min_length=1, max_length=100)
|
|
|
|
def visible_payload(self) -> dict[str, Any]:
|
|
payload = self.model_dump(mode="json")
|
|
payload.pop("hidden_answer", None)
|
|
return payload
|
|
|
|
|
|
class BenchmarkVariantPackOutput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
variants: tuple[BenchmarkVariantOutput, ...] = Field(min_length=3, max_length=12)
|
|
|
|
|
|
class BenchmarkJudgeOutput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
passed: bool
|
|
answer_leakage: bool
|
|
pii_found: bool
|
|
unsupported_claim: bool
|
|
safety_failure: bool
|
|
reward_hacking: bool
|
|
evidence_summary: str = Field(min_length=1, max_length=500)
|
|
|
|
@model_validator(mode="after")
|
|
def consistent_pass_flag(self) -> "BenchmarkJudgeOutput":
|
|
has_failure = any(
|
|
(
|
|
self.answer_leakage,
|
|
self.pii_found,
|
|
self.unsupported_claim,
|
|
self.safety_failure,
|
|
self.reward_hacking,
|
|
)
|
|
)
|
|
if self.passed == has_failure:
|
|
raise ValueError("benchmark judge pass flag contradicts failure flags")
|
|
return self
|
|
|
|
|
|
class AgenticPipelineResult(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
submission_id: UUID
|
|
pipeline_id: UUID
|
|
qualification_id: UUID
|
|
candidate_catalog_entry_id: str
|
|
state: str
|
|
human_approval_required: bool
|
|
catalog_promoted: bool
|
|
idempotent_replay: bool
|
|
clinical_claim_allowed: bool
|
|
draft_id: str
|
|
benchmark_id: str
|
|
red_team_review_count: int
|
|
benchmark_variant_count: int
|
|
agent_calls_executed: int
|
|
trigger_kind: str
|
|
|
|
|
|
def _canonical_json(value: Any) -> str:
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
|
|
|
|
def _sha256_text(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _answer_overlap_count(hidden_answer: str, visible_payload: Mapping[str, Any]) -> int:
|
|
hidden_tokens = [item.lower() for item in _TOKEN_PATTERN.findall(hidden_answer)]
|
|
visible_tokens = [
|
|
item.lower()
|
|
for item in _TOKEN_PATTERN.findall(_canonical_json(visible_payload))
|
|
]
|
|
hidden_set = set(hidden_tokens)
|
|
visible_set = set(visible_tokens)
|
|
opaque_markers = {
|
|
token
|
|
for token in hidden_set
|
|
if "_" in token or any(character.isdigit() for character in token)
|
|
}
|
|
|
|
def shingles(tokens: Sequence[str], width: int = 4) -> set[tuple[str, ...]]:
|
|
return {
|
|
tuple(tokens[index : index + width])
|
|
for index in range(max(0, len(tokens) - width + 1))
|
|
}
|
|
|
|
marker_overlap = opaque_markers & visible_set
|
|
phrase_overlap = shingles(hidden_tokens) & shingles(visible_tokens)
|
|
return len(marker_overlap) + len(phrase_overlap)
|
|
|
|
|
|
def _assert_pii_free(value: Any, *, stage: str) -> None:
|
|
result = mask_synthetic_generated_pii(_canonical_json(value))
|
|
if result.entities:
|
|
entity_types = ",".join(sorted(set(result.entities)))
|
|
raise AgenticPipelineRejectedError(
|
|
f"{stage} contains PII types: {entity_types}"
|
|
)
|
|
|
|
|
|
def validate_source_packs(
|
|
source_packs: Sequence[AgenticSourcePack],
|
|
) -> list[dict[str, Any]]:
|
|
"""Validate source approval, integrity and PII before any model call."""
|
|
|
|
if not source_packs:
|
|
raise AgenticPipelineRejectedError("at least one source pack is required")
|
|
source_ids = [item.artifact.source_id for item in source_packs]
|
|
if len(source_ids) != len(set(source_ids)):
|
|
raise AgenticPipelineRejectedError("source pack ids must be unique")
|
|
context: list[dict[str, Any]] = []
|
|
for item in source_packs:
|
|
if item.artifact.usage_status != "approved":
|
|
raise AgenticPipelineRejectedError("agentic generation requires approved sources")
|
|
if _sha256_text(item.content) != item.artifact.content_sha256:
|
|
raise AgenticPipelineRejectedError("source pack content hash mismatch")
|
|
masked = mask_pii(item.content)
|
|
if masked.entities:
|
|
raise AgenticPipelineRejectedError("source pack contains PII")
|
|
context.append(
|
|
{
|
|
"source_id": item.artifact.source_id,
|
|
"version": item.artifact.version,
|
|
"content_sha256": item.artifact.content_sha256,
|
|
"provenance_uri": item.artifact.provenance_uri,
|
|
"citation_label": item.artifact.citation_label,
|
|
"content": item.content,
|
|
}
|
|
)
|
|
return context
|
|
|
|
|
|
# Backward-compatible private seam retained for focused contract tests.
|
|
_source_context = validate_source_packs
|
|
|
|
|
|
def _generation_prompt_payload(
|
|
*,
|
|
source_context: Sequence[Mapping[str, Any]],
|
|
content_kind: str,
|
|
difficulty_level: int,
|
|
prompt_version: str,
|
|
trigger_kind: str,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"prompt_version": prompt_version,
|
|
"content_kind": content_kind,
|
|
"difficulty_level": difficulty_level,
|
|
"trigger_kind": trigger_kind,
|
|
"sources": list(source_context),
|
|
}
|
|
|
|
|
|
def source_pack_from_operational_incident(
|
|
incident: OperationalIncident,
|
|
) -> AgenticSourcePack:
|
|
"""Convert persisted metadata-only operational failure into adversarial input."""
|
|
|
|
content = _canonical_json(incident.model_dump(mode="json"))
|
|
fingerprint = incident.error_fingerprint[:24]
|
|
return AgenticSourcePack(
|
|
artifact=ContentSourceArtifact(
|
|
source_id=f"oas-g8-source-incident-{fingerprint}",
|
|
version="1.0.0",
|
|
content_sha256=_sha256_text(content),
|
|
provenance_uri=(
|
|
"audit://continuous-improvement/incidents/"
|
|
f"{incident.incident_id}"
|
|
),
|
|
usage_status="approved",
|
|
citation_label=(
|
|
"운영 오류 재현용 metadata source: "
|
|
f"{incident.affected_contract}"
|
|
),
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
|
|
async def _generate_structured(
|
|
*,
|
|
engine: StructuredGenerationEngine,
|
|
stage: str,
|
|
agent_id: str,
|
|
messages: list[EngineMessage],
|
|
schema_model: type[BaseModel],
|
|
pipeline_id: UUID,
|
|
max_tokens: int = 2400,
|
|
) -> tuple[BaseModel, GenerateResponse, int]:
|
|
repair_message = EngineMessage(
|
|
role="user",
|
|
content=(
|
|
"직전 출력이 JSON schema 검증에 실패했다. 동일한 작업을 다시 수행하되, "
|
|
"제공된 JSON schema를 정확히 만족하는 단일 JSON object만 반환하라. "
|
|
"필수 필드를 모두 포함하고 타입과 enum을 지키며 설명이나 markdown을 덧붙이지 마라. "
|
|
"내용 정책, 승인된 source 범위, 안전 경계는 바꾸지 마라."
|
|
),
|
|
)
|
|
last_error_message = f"{stage} returned no structured output"
|
|
last_validation_error: ValidationError | None = None
|
|
max_attempts = 2
|
|
for attempt in range(1, max_attempts + 1):
|
|
request_messages = messages if attempt == 1 else [*messages, repair_message]
|
|
request = GenerateRequest(
|
|
ai_role="evaluator",
|
|
messages=request_messages,
|
|
max_tokens=max_tokens,
|
|
temperature=0.1 if attempt == 1 else 0.0,
|
|
structured_schema=schema_model.model_json_schema(),
|
|
metadata={
|
|
"purpose": "g8_agentic_content_pipeline",
|
|
"agentic_stage": stage,
|
|
"agent_id": agent_id,
|
|
"pipeline_id": str(pipeline_id),
|
|
"structured_attempt": attempt,
|
|
"structured_repair": attempt > 1,
|
|
},
|
|
)
|
|
try:
|
|
response = await engine.generate(request)
|
|
except Exception as exc:
|
|
raise AgenticPipelineExecutionError(f"{stage} model call failed") from exc
|
|
payload = structured_payload_from_response(response)
|
|
if payload is None:
|
|
last_error_message = f"{stage} returned no structured output"
|
|
continue
|
|
try:
|
|
parsed = schema_model.model_validate(payload)
|
|
except ValidationError as exc:
|
|
last_error_message = f"{stage} returned invalid structured output"
|
|
last_validation_error = exc
|
|
continue
|
|
return parsed, response, attempt
|
|
|
|
error = AgenticPipelineExecutionError(last_error_message)
|
|
if last_validation_error is not None:
|
|
raise error from last_validation_error
|
|
raise error
|
|
|
|
|
|
def _validate_grounding(
|
|
source_ids: set[str], source_refs: Sequence[str], claims: Sequence[GroundedClaim]
|
|
) -> int:
|
|
unknown = set(source_refs) - source_ids
|
|
unknown.update(item.source_ref for item in claims if item.source_ref not in source_ids)
|
|
return len(unknown)
|
|
|
|
|
|
async def run_agentic_content_pipeline(
|
|
*,
|
|
conn: Any,
|
|
engine: StructuredGenerationEngine,
|
|
submission_id: UUID,
|
|
pipeline_id: UUID,
|
|
benchmark_record_id: UUID,
|
|
qualification_id: UUID,
|
|
source_packs: Sequence[AgenticSourcePack],
|
|
content_kind: str,
|
|
difficulty_level: int,
|
|
variant_count: int,
|
|
prompt_version: str = PROMPT_VERSION,
|
|
trigger_kind: str = "source_pack",
|
|
) -> AgenticPipelineResult:
|
|
if content_kind not in {"case", "rupture", "practice", "benchmark"}:
|
|
raise AgenticPipelineRejectedError("unsupported content kind")
|
|
if not 1 <= difficulty_level <= 5:
|
|
raise AgenticPipelineRejectedError("difficulty level must be between 1 and 5")
|
|
if not 3 <= variant_count <= 12:
|
|
raise AgenticPipelineRejectedError("variant count must be between 3 and 12")
|
|
|
|
sources = tuple(source_packs)
|
|
source_context = validate_source_packs(sources)
|
|
source_ids = {item.artifact.source_id for item in sources}
|
|
prompt_payload = _generation_prompt_payload(
|
|
source_context=source_context,
|
|
content_kind=content_kind,
|
|
difficulty_level=difficulty_level,
|
|
prompt_version=prompt_version,
|
|
trigger_kind=trigger_kind,
|
|
)
|
|
prompt_sha256 = _sha256_text(_canonical_json(prompt_payload))
|
|
|
|
replay = await continuous_improvement_store.find_content_pipeline_submission(
|
|
conn, submission_id=submission_id
|
|
)
|
|
if replay is not None:
|
|
expected = {
|
|
"pipeline_id": str(pipeline_id),
|
|
"benchmark_record_id": str(benchmark_record_id),
|
|
"qualification_id": str(qualification_id),
|
|
"prompt_sha256": prompt_sha256,
|
|
}
|
|
actual = {key: str(replay.get(key)) for key in expected}
|
|
if actual != expected:
|
|
raise continuous_improvement_store.ContinuousImprovementConflictError(
|
|
"submission id was already used with different agentic input"
|
|
)
|
|
return AgenticPipelineResult(
|
|
submission_id=submission_id,
|
|
pipeline_id=pipeline_id,
|
|
qualification_id=qualification_id,
|
|
candidate_catalog_entry_id=str(replay["candidate_catalog_entry_id"]),
|
|
state="pending_human_approval",
|
|
human_approval_required=True,
|
|
catalog_promoted=False,
|
|
idempotent_replay=True,
|
|
clinical_claim_allowed=False,
|
|
draft_id=str(replay["draft_id"]),
|
|
benchmark_id=str(replay["benchmark_id"]),
|
|
red_team_review_count=int(replay["red_team_review_count"]),
|
|
benchmark_variant_count=int(replay["benchmark_variant_count"]),
|
|
agent_calls_executed=0,
|
|
trigger_kind=trigger_kind,
|
|
)
|
|
|
|
slug = pipeline_id.hex
|
|
generation_messages = [
|
|
EngineMessage(
|
|
role="system",
|
|
content=(
|
|
"너는 교육용 상담 시뮬레이션 콘텐츠 생성 agent다. 승인된 source만 사용하고 "
|
|
"실존 또는 합성 고유 이름, 이니셜, 기관, 주소, 연락처, 구체 날짜, 금액, 식별 번호를 "
|
|
"절대 만들지 마라. 등장인물은 오직 'fictional client'와 'learner' 같은 역할명으로만 "
|
|
"지칭하라. hidden_answer의 고유 marker나 연속된 답안 구절은 visible 필드에 "
|
|
"반복하지 말고, 모든 grounding claim은 입력 source_id를 참조하라. 출력은 schema만 따른다."
|
|
),
|
|
),
|
|
EngineMessage(role="user", content=_canonical_json(prompt_payload)),
|
|
]
|
|
generated_model, generation_response, generation_attempts = await _generate_structured(
|
|
engine=engine,
|
|
stage="draft_generation",
|
|
agent_id="g8-content-generator-v1",
|
|
messages=generation_messages,
|
|
schema_model=GeneratedDraftPayload,
|
|
pipeline_id=pipeline_id,
|
|
)
|
|
agent_calls_executed = generation_attempts
|
|
generated = GeneratedDraftPayload.model_validate(generated_model.model_dump())
|
|
unsupported_claims = _validate_grounding(
|
|
source_ids, generated.source_refs, generated.grounded_claims
|
|
)
|
|
if unsupported_claims:
|
|
raise AgenticPipelineRejectedError("generated draft has unsupported claims")
|
|
_assert_pii_free(generated.model_dump(mode="json"), stage="generated draft")
|
|
answer_overlap = _answer_overlap_count(
|
|
generated.hidden_answer, generated.visible_payload()
|
|
)
|
|
if answer_overlap:
|
|
raise AgenticPipelineRejectedError("generated draft exposes hidden answer tokens")
|
|
|
|
payload_sha256 = _sha256_text(_canonical_json(generated.model_dump(mode="json")))
|
|
draft_id = f"oas-g8-draft-{slug}"
|
|
draft = GeneratedContentDraft(
|
|
draft_id=draft_id,
|
|
content_kind=content_kind,
|
|
source_refs=tuple(generated.source_refs),
|
|
generation_model=f"{generation_response.provider}/{generation_response.model}",
|
|
prompt_version=prompt_version,
|
|
prompt_sha256=prompt_sha256,
|
|
payload_sha256=payload_sha256,
|
|
synthetic_identity_id=f"synthetic-identity-{slug}",
|
|
difficulty_level=difficulty_level,
|
|
hidden_answer_fingerprint=_sha256_text(generated.hidden_answer),
|
|
visible_answer_overlap_tokens=0,
|
|
pii_findings=0,
|
|
unsupported_clinical_claims=0,
|
|
)
|
|
|
|
async def review_lane(
|
|
lane: str, dimensions: tuple[ReviewDimension, ...]
|
|
) -> tuple[IndependentRedTeamReview, int]:
|
|
agent_id = f"g8-redteam-{lane}-v1"
|
|
messages = [
|
|
EngineMessage(
|
|
role="system",
|
|
content=(
|
|
"너는 다른 reviewer와 대화하거나 결과를 공유하지 않는 독립 red-team agent다. "
|
|
f"오직 {','.join(dimensions)} 차원만 공격적으로 검토하라. 문제를 찾으면 finding을 "
|
|
"반드시 남기되, source와 draft에서 입증되는 실제 계약 위반만 finding이다. 선택적 문구 개선, "
|
|
"취향, 근거 없는 가능성은 finding으로 만들지 말고 실제 위반이 없으면 findings=[]를 반환하라. "
|
|
"출력은 schema만 따른다."
|
|
),
|
|
),
|
|
EngineMessage(
|
|
role="user",
|
|
content=_canonical_json(
|
|
{
|
|
"draft": generated.model_dump(mode="json"),
|
|
"sources": source_context,
|
|
"reviewed_payload_sha256": payload_sha256,
|
|
}
|
|
),
|
|
),
|
|
]
|
|
output_model, _, attempts = await _generate_structured(
|
|
engine=engine,
|
|
stage=f"red_team_{lane}",
|
|
agent_id=agent_id,
|
|
messages=messages,
|
|
schema_model=RedTeamOutput,
|
|
pipeline_id=pipeline_id,
|
|
)
|
|
output = RedTeamOutput.model_validate(output_model.model_dump())
|
|
if set(output.dimensions) != set(dimensions):
|
|
raise AgenticPipelineExecutionError(
|
|
f"red_team_{lane} did not attest assigned dimensions"
|
|
)
|
|
if any(item.dimension not in dimensions for item in output.findings):
|
|
raise AgenticPipelineExecutionError(
|
|
f"red_team_{lane} returned a cross-lane finding"
|
|
)
|
|
findings = tuple(
|
|
RedTeamFinding(
|
|
finding_id=f"oas-g8-finding-{slug}-{lane}-{index}",
|
|
dimension=item.dimension,
|
|
severity=item.severity,
|
|
state="open",
|
|
evidence_ref=(
|
|
f"audit://continuous-improvement/{slug}/redteam/{lane}/{index}"
|
|
),
|
|
)
|
|
for index, item in enumerate(output.findings, start=1)
|
|
)
|
|
return (
|
|
IndependentRedTeamReview(
|
|
review_id=f"oas-g8-review-{slug}-{lane}",
|
|
draft_id=draft_id,
|
|
reviewer_agent_id=agent_id,
|
|
dimensions=dimensions,
|
|
findings=findings,
|
|
reviewed_payload_sha256=payload_sha256,
|
|
),
|
|
attempts,
|
|
)
|
|
|
|
review_results = tuple(
|
|
await asyncio.gather(
|
|
*(review_lane(lane, dimensions) for lane, dimensions in _REVIEW_LANES)
|
|
)
|
|
)
|
|
reviews = tuple(review for review, _ in review_results)
|
|
agent_calls_executed += sum(attempts for _, attempts in review_results)
|
|
if any(review.findings for review in reviews):
|
|
finding_summary = sorted(
|
|
f"{finding.dimension}:{finding.severity}"
|
|
for review in reviews
|
|
for finding in review.findings
|
|
)
|
|
raise AgenticPipelineRejectedError(
|
|
"red-team agents found unresolved issues: " + ",".join(finding_summary)
|
|
)
|
|
|
|
variant_messages = [
|
|
EngineMessage(
|
|
role="system",
|
|
content=(
|
|
f"원본 교육 초안을 의미는 유지하되 표현·관계 맥락·난이도를 바꾼 {variant_count}개 "
|
|
"적대 benchmark variant로 변형하라. 각 hidden_answer 고유 marker나 연속 답안 구절은 visible 필드에 "
|
|
"반복하지 말고 입력 source_id만 참조하라. 출력은 schema만 따른다."
|
|
),
|
|
),
|
|
EngineMessage(
|
|
role="user",
|
|
content=_canonical_json(
|
|
{"draft": generated.model_dump(mode="json"), "sources": source_context}
|
|
),
|
|
),
|
|
]
|
|
variant_model, _, variant_attempts = await _generate_structured(
|
|
engine=engine,
|
|
stage="benchmark_variant_generation",
|
|
agent_id="g8-benchmark-variant-generator-v1",
|
|
messages=variant_messages,
|
|
schema_model=BenchmarkVariantPackOutput,
|
|
pipeline_id=pipeline_id,
|
|
)
|
|
agent_calls_executed += variant_attempts
|
|
variant_pack = BenchmarkVariantPackOutput.model_validate(variant_model.model_dump())
|
|
if len(variant_pack.variants) != variant_count:
|
|
raise AgenticPipelineExecutionError(
|
|
"benchmark variant generator returned an unexpected variant count"
|
|
)
|
|
for index, variant in enumerate(variant_pack.variants, start=1):
|
|
_assert_pii_free(variant.model_dump(mode="json"), stage=f"benchmark variant {index}")
|
|
if set(variant.source_refs) - source_ids:
|
|
raise AgenticPipelineRejectedError(
|
|
f"benchmark variant {index} has unsupported claims"
|
|
)
|
|
if _answer_overlap_count(variant.hidden_answer, variant.visible_payload()):
|
|
raise AgenticPipelineRejectedError(
|
|
f"benchmark variant {index} exposes hidden answer tokens"
|
|
)
|
|
|
|
async def judge_variant(
|
|
index: int, variant: BenchmarkVariantOutput
|
|
) -> tuple[BenchmarkJudgeOutput, int]:
|
|
messages = [
|
|
EngineMessage(
|
|
role="system",
|
|
content=(
|
|
"너는 독립 benchmark judge다. answer leakage, PII, 무근거 임상 주장, 안전 실패, "
|
|
"점수·문구 암기 보상 해킹을 각각 판정하라. 하나라도 있으면 passed=false다. "
|
|
"출력은 schema만 따른다."
|
|
),
|
|
),
|
|
EngineMessage(
|
|
role="user",
|
|
content=_canonical_json(
|
|
{
|
|
"source_ids": sorted(source_ids),
|
|
"original_draft": generated.model_dump(mode="json"),
|
|
"variant": variant.model_dump(mode="json"),
|
|
}
|
|
),
|
|
),
|
|
]
|
|
output_model, _, attempts = await _generate_structured(
|
|
engine=engine,
|
|
stage=f"benchmark_judge_{index}",
|
|
agent_id=f"g8-benchmark-judge-{index}-v1",
|
|
messages=messages,
|
|
schema_model=BenchmarkJudgeOutput,
|
|
pipeline_id=pipeline_id,
|
|
)
|
|
return BenchmarkJudgeOutput.model_validate(output_model.model_dump()), attempts
|
|
|
|
judgment_results = tuple(
|
|
await asyncio.gather(
|
|
*(
|
|
judge_variant(index, variant)
|
|
for index, variant in enumerate(variant_pack.variants, start=1)
|
|
)
|
|
)
|
|
)
|
|
judgments = tuple(judgment for judgment, _ in judgment_results)
|
|
agent_calls_executed += sum(attempts for _, attempts in judgment_results)
|
|
pass_count = sum(1 for item in judgments if item.passed)
|
|
benchmark_id = f"oas-g8-benchmark-{slug}"
|
|
benchmark = ContentBenchmarkQualification(
|
|
benchmark_id=benchmark_id,
|
|
draft_id=draft_id,
|
|
variant_count=len(judgments),
|
|
variant_pass_rate=pass_count / len(judgments),
|
|
answer_leakage_count=sum(1 for item in judgments if item.answer_leakage),
|
|
pii_finding_count=sum(1 for item in judgments if item.pii_found),
|
|
unsupported_claim_count=sum(1 for item in judgments if item.unsupported_claim),
|
|
safety_failure_count=sum(1 for item in judgments if item.safety_failure),
|
|
reward_hacking_count=sum(1 for item in judgments if item.reward_hacking),
|
|
evidence_refs=tuple(
|
|
f"audit://continuous-improvement/{slug}/benchmark/judge/{index}"
|
|
for index in range(1, len(judgments) + 1)
|
|
),
|
|
)
|
|
if not benchmark.qualified:
|
|
raise AgenticPipelineRejectedError("benchmark qualification failed")
|
|
|
|
stored = await continuous_improvement_store.submit_content_pipeline(
|
|
conn,
|
|
submission_id=submission_id,
|
|
pipeline_id=pipeline_id,
|
|
benchmark_record_id=benchmark_record_id,
|
|
qualification_id=qualification_id,
|
|
draft=draft,
|
|
sources=[item.artifact for item in sources],
|
|
reviews=list(reviews),
|
|
benchmark=benchmark,
|
|
draft_payload=generated.model_dump(mode="json"),
|
|
)
|
|
if (
|
|
stored.get("state") != "pending_human_approval"
|
|
or stored.get("human_approval_required") is not True
|
|
or stored.get("catalog_promoted") is not False
|
|
or stored.get("clinical_claim_allowed") is not False
|
|
):
|
|
raise AgenticPipelineExecutionError(
|
|
"content store violated the mandatory human approval boundary"
|
|
)
|
|
return AgenticPipelineResult(
|
|
**stored,
|
|
draft_id=draft_id,
|
|
benchmark_id=benchmark_id,
|
|
red_team_review_count=len(reviews),
|
|
benchmark_variant_count=len(judgments),
|
|
agent_calls_executed=agent_calls_executed,
|
|
trigger_kind=trigger_kind,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"AgenticPipelineError",
|
|
"AgenticPipelineExecutionError",
|
|
"AgenticPipelineRejectedError",
|
|
"AgenticPipelineResult",
|
|
"AgenticSourcePack",
|
|
"BenchmarkJudgeOutput",
|
|
"BenchmarkVariantOutput",
|
|
"BenchmarkVariantPackOutput",
|
|
"GeneratedDraftPayload",
|
|
"GroundedClaim",
|
|
"HttpRollbackExecutor",
|
|
"PROMPT_VERSION",
|
|
"RedTeamOutput",
|
|
"build_configured_rollback_executor",
|
|
"run_agentic_content_pipeline",
|
|
"source_pack_from_operational_incident",
|
|
"validate_source_packs",
|
|
]
|