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:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -40,7 +40,7 @@ from ..contracts.engine_gateway import (
StreamErrorEvent,
StreamTokenEvent,
)
from . import guardrail, persona, state_machine
from . import guardrail, persona, rupture_scenario_director, state_machine
from .llm_audit import LlmAuditHook, generate_with_audit, record_llm_audit
from .persona import PersonaCard, PersonaStateContext, TurnMemory
from .state_machine import SessionState
@ -86,6 +86,8 @@ class TurnContext:
memory: TurnMemory = field(default_factory=TurnMemory)
# 회기 이론모드(학습자 선택: humanistic|cbt|integrative). 평가 이론부합·생성 프레이밍에 사용.
theory_mode: Optional[str] = None
# Scenario Director 내부 선택. ID/유형/provenance는 엔진 request metadata에만 존재한다.
scenario_directive: Optional[rupture_scenario_director.ScenarioDirective] = None
def to_state_context(self) -> PersonaStateContext:
st = self.state_after or self.state_before
@ -134,6 +136,9 @@ def prepare_turn(
memory: Optional[TurnMemory] = None,
theory_mode: Optional[str] = None,
eval_rapport_signal: Optional[float] = None,
scenario_context: Optional[
rupture_scenario_director.StoredScenarioContext
] = None,
) -> TurnContext:
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
@ -182,6 +187,17 @@ def prepare_turn(
ideation_observed=crisis_ideation,
)
ctx.scenario_directive = rupture_scenario_director.select_scenario_directive(
case_id=ctx.case_id,
session_id=ctx.session_id,
turn_seq=ctx.state_after.turn_seq,
safety_escalated=bool(ctx.crisis is not None and ctx.crisis.escalate),
scenario_context=scenario_context,
)
hidden_behavior_cue = rupture_scenario_director.render_hidden_behavior_prompt(
ctx.scenario_directive
)
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
ctx.messages = persona.build_turn_messages(
card,
@ -189,6 +205,7 @@ def prepare_turn(
ctx.learner_text_masked,
memory=ctx.memory,
theory_mode=ctx.theory_mode,
hidden_behavior_cue=hidden_behavior_cue,
)
return ctx
@ -221,6 +238,21 @@ def _latest_client_reply(turns: list[dict[str, str]]) -> Optional[str]:
return None
def _client_request_metadata(ctx: TurnContext) -> dict[str, Any]:
assert ctx.state_after is not None
metadata: dict[str, Any] = {"stage": ctx.state_after.stage.value}
if ctx.scenario_directive is not None:
metadata["scenario_director"] = ctx.scenario_directive.request_metadata()
return metadata
def _safe_engine_error_detail(error: BaseException | str, *, fallback: str) -> str:
detail = str(error).strip() or fallback
if rupture_scenario_director.contains_internal_scenario_leakage(detail):
return fallback
return detail
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
# ════════════════════════════════════════════════════════════════════════════
@ -246,16 +278,44 @@ async def run_turn_generate(
ai_role="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
metadata=_client_request_metadata(ctx),
)
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
resp: GenerateResponse | None = None
reply = ""
safety_flagged = ctx.crisis is not None and ctx.crisis.escalate
for attempt in range(2):
resp = await generate_with_audit(engine, req, audit_hook)
try:
resp = await generate_with_audit(engine, req, audit_hook)
except EngineError as exc:
detail = _safe_engine_error_detail(
exc,
fallback="engine generation failed",
)
if detail != str(exc):
raise EngineError(detail) from exc
raise
# 5) 출력 가드레일 — 수단 차단 + persona 품질 재생성
if rupture_scenario_director.contains_internal_scenario_leakage(resp.text):
if attempt == 0:
continue
return TurnResult(
turn_seq=st.turn_seq,
stage=st.stage.value,
effective_openness=st.effective_openness,
client_reply=None,
safety_flagged=True,
state_after=st,
evaluation=None,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
llm_provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
output_error="client_reply_quality_retryable",
)
guard = guardrail.sanitize_client_reply(
resp.text,
ideation_stage=st.ideation_stage,
@ -360,7 +420,7 @@ async def run_turn_stream(
ai_role="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
metadata=_client_request_metadata(ctx),
)
accumulated = ""
@ -401,6 +461,10 @@ async def run_turn_stream(
if packet.event == ENGINE_GATEWAY_SSE_ERROR:
payload = packet.payload
detail = payload.detail if isinstance(payload, StreamErrorEvent) else "engine stream error"
detail = _safe_engine_error_detail(
detail,
fallback="engine stream error",
)
yield StreamEvent("error", {"detail": detail})
return
if packet.event == ENGINE_GATEWAY_SSE_DONE:
@ -415,20 +479,23 @@ async def run_turn_stream(
text_piece = payload.text
accumulated += text_piece
scenario_leakage = rupture_scenario_director.contains_internal_scenario_leakage(
accumulated
)
guard = guardrail.sanitize_client_reply(
accumulated,
ideation_stage=st.ideation_stage,
turn_seq=st.turn_seq,
previous_client_reply=previous_client_reply,
)
if guard.needs_regeneration:
if scenario_leakage or guard.needs_regeneration:
flagged = True
output_error = "client_reply_quality_retryable"
yield StreamEvent(
"safety",
{
"reason": output_error,
"reasons": guard.reasons,
"reasons": guard.reasons if not scenario_leakage else ["quality"],
},
)
accumulated = ""
@ -475,9 +542,15 @@ async def run_turn_stream(
},
)
except EngineGatewaySseDecodeError as e:
yield StreamEvent("error", {"detail": str(e)})
yield StreamEvent(
"error",
{"detail": _safe_engine_error_detail(e, fallback="engine stream decode error")},
)
except EngineError as e:
yield StreamEvent("error", {"detail": str(e)})
yield StreamEvent(
"error",
{"detail": _safe_engine_error_detail(e, fallback="engine stream error")},
)
def _optional_str(value: Any) -> Optional[str]: