현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -0,0 +1,400 @@
"""Phase 3 recursive-learning dataset export helpers.
The exporter is intentionally conservative: it only emits masked text, keeps raw
database identifiers out of JSONL records, and never upgrades an artifact to an
approved seed dataset unless the explicit approval and agreement gates pass.
"""
from __future__ import annotations
import hashlib
import json
import math
import re
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
from uuid import UUID
DATASET_ITEM_SCHEMA = "phase3_dataset_item_v1"
APPROVED_EXPORT_STATUS = "approved_for_recursive_learning_seed"
DRY_RUN_EXPORT_STATUS = "technical_dry_run"
BLOCKED_EXPORT_STATUS = "blocked"
EXPORT_STATUSES = {APPROVED_EXPORT_STATUS, DRY_RUN_EXPORT_STATUS, BLOCKED_EXPORT_STATUS}
BLOCKED_FIELD_NAMES = {
"name",
"email",
"phone",
"student_id",
"national_id",
"address",
"date_of_birth",
"raw_audio_path",
"raw_voice",
"raw_source_case",
"identity_map",
"api_key",
"api_keys",
"access_token",
"refresh_token",
"token",
"cookie",
"credentials",
"credential",
"secret",
}
PII_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("email", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I)),
(
"phone",
re.compile(r"\b(?:\+?82[-. ]?)?(?:0?1[016789]|0[2-9]\d?)[-. ]?\d{3,4}[-. ]?\d{4}\b"),
),
("national_id", re.compile(r"\b\d{6}[- ]?[1-4]\d{6}\b")),
("student_id", re.compile(r"\b20\d{2}[- ]?\d{4,8}\b")),
(
"secret",
re.compile(
r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?secret|cookie)\b\s*[:=]\s*\S+"
),
),
("secret", re.compile(r"\b(?:sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)\b")),
)
@dataclass
class ExportKeyMaps:
participant: dict[str, str] = field(default_factory=dict)
session: dict[str, str] = field(default_factory=dict)
def participant_key(self, raw_id: Any) -> str:
key = str(raw_id or "unknown-participant")
if key not in self.participant:
self.participant[key] = f"PX-{len(self.participant) + 1:04d}"
return self.participant[key]
def session_key(self, raw_id: Any) -> str:
key = str(raw_id or "unknown-session")
if key not in self.session:
self.session[key] = f"SX-{len(self.session) + 1:04d}"
return self.session[key]
def json_safe(value: Any) -> Any:
if isinstance(value, (datetime, date)):
if isinstance(value, datetime) and value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat().replace("+00:00", "Z")
if isinstance(value, (UUID, Decimal)):
return str(value)
if isinstance(value, Mapping):
return {str(key): json_safe(item) for key, item in value.items()}
if isinstance(value, list):
return [json_safe(item) for item in value]
if isinstance(value, tuple):
return [json_safe(item) for item in value]
return value
def normalize_json_value(value: Any) -> Any:
if isinstance(value, str):
stripped = value.strip()
if stripped.startswith(("{", "[")):
try:
return json.loads(stripped)
except json.JSONDecodeError:
return value
return value
def _redacted_sample(kind: str, value: str) -> str:
if kind == "email" and "@" in value:
return f"<email:{value.rsplit('@', 1)[1].lower()}>"
return f"<{kind}>"
def _scan_text(value: str, path: str) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
for kind, pattern in PII_PATTERNS:
for match in pattern.finditer(value):
findings.append(
{
"kind": kind,
"path": path,
"sample": _redacted_sample(kind, match.group(0)),
}
)
return findings
def scan_for_pii(value: Any, path: str = "$") -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
if isinstance(value, Mapping):
for raw_key, item in value.items():
key = str(raw_key)
next_path = f"{path}.{key}"
if key.lower() in BLOCKED_FIELD_NAMES:
findings.append({"kind": "blocked_field", "path": next_path, "sample": f"<{key.lower()}>"})
findings.extend(scan_for_pii(item, next_path))
return findings
if isinstance(value, list):
for index, item in enumerate(value):
findings.extend(scan_for_pii(item, f"{path}[{index}]"))
return findings
if isinstance(value, str):
findings.extend(_scan_text(value, path))
return findings
def build_dataset_record(
row: Mapping[str, Any],
*,
item_index: int,
export_manifest_id: str,
keys: ExportKeyMaps,
pii_scan_status: str = "pending",
consent_scope: str = "recursive_learning_seed",
) -> dict[str, Any]:
text_masked = str(row.get("text_masked") or "").strip()
if not text_masked:
raise ValueError("dataset export requires non-empty text_masked")
participant_key = keys.participant_key(row.get("learner_id"))
session_key = keys.session_key(row.get("session_id"))
persona_code = row.get("persona_code") or row.get("persona_id") or "unknown"
return {
"schema": DATASET_ITEM_SCHEMA,
"item_id": f"DI-{item_index:06d}",
"participant_key": participant_key,
"session_key": session_key,
"turn_key": f"TX-{item_index:06d}",
"persona_id": str(persona_code),
"stage": row.get("stage") or "",
"speaker": row.get("speaker") or "",
"text_masked": text_masked,
"techniques": json_safe(normalize_json_value(row.get("techniques") or [])),
"client_states": json_safe(normalize_json_value(row.get("client_states") or [])),
"feedback_scores": json_safe(normalize_json_value(row.get("feedback_scores") or [])),
"supervisor_comments": json_safe(normalize_json_value(row.get("supervisor_comments") or [])),
"source_refs": {
"session_started_at": json_safe(row.get("session_started_at") or row.get("started_at")),
"export_manifest_id": export_manifest_id,
},
"privacy": {
"direct_identifiers_removed": True,
"pii_scan_status": pii_scan_status,
"consent_scope": consent_scope,
},
}
def write_jsonl(records: Sequence[Mapping[str, Any]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="\n") as handle:
for record in records:
handle.write(json.dumps(json_safe(record), ensure_ascii=False, sort_keys=True, separators=(",", ":")))
handle.write("\n")
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> float | None:
pairs: list[tuple[Any, Any]] = []
by_item: dict[Any, list[Any]] = defaultdict(list)
for annotation in annotations:
labels = normalize_json_value(annotation.get("labels") or {})
if not isinstance(labels, Mapping) or label_key not in labels:
continue
by_item[annotation.get("item_id")].append(labels[label_key])
for values in by_item.values():
if len(values) >= 2:
pairs.append((values[0], values[1]))
if not pairs:
return None
total = len(pairs)
observed = sum(1 for left, right in pairs if left == right) / total
left_counts = Counter(left for left, _ in pairs)
right_counts = Counter(right for _, right in pairs)
expected = sum((left_counts[label] / total) * (right_counts[label] / total) for label in set(left_counts) | set(right_counts))
if math.isclose(1.0, expected):
return 1.0 if math.isclose(1.0, observed) else None
return round((observed - expected) / (1.0 - expected), 4)
def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key: str) -> float | None:
by_item: dict[Any, list[float]] = defaultdict(list)
for annotation in annotations:
labels = normalize_json_value(annotation.get("labels") or {})
if not isinstance(labels, Mapping) or score_key not in labels:
continue
try:
by_item[annotation.get("item_id")].append(float(labels[score_key]))
except (TypeError, ValueError):
continue
matrix = [values[:2] for values in by_item.values() if len(values) >= 2]
if len(matrix) < 2:
return None
n = len(matrix)
k = 2
row_means = [sum(row) / k for row in matrix]
col_means = [sum(row[col] for row in matrix) / n for col in range(k)]
grand_mean = sum(row_means) / n
msr = k * sum((mean - grand_mean) ** 2 for mean in row_means) / (n - 1)
msc = n * sum((mean - grand_mean) ** 2 for mean in col_means) / (k - 1)
residual = 0.0
for row_index, row in enumerate(matrix):
for col_index, value in enumerate(row):
residual += (value - row_means[row_index] - col_means[col_index] + grand_mean) ** 2
mse = residual / ((n - 1) * (k - 1))
denominator = msr + (k - 1) * mse + (k * (msc - mse) / n)
if math.isclose(denominator, 0.0):
return None
return round((msr - mse) / denominator, 4)
def infer_source_window(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
starts = [record.get("source_refs", {}).get("session_started_at") for record in records]
starts = [value for value in starts if value]
return {
"started_at": min(starts) if starts else "",
"ended_at": max(starts) if starts else "",
}
def build_manifest(
*,
export_id: str,
dataset_name: str,
export_status: str,
purpose: str,
records: Sequence[Mapping[str, Any]],
jsonl_path: str,
jsonl_sha256: str,
pii_findings: Sequence[Mapping[str, Any]],
participants_included: int,
participants_excluded: int = 0,
cohort_id: str = "phase3",
consent_version: str = "",
agreement: Mapping[str, Any] | None = None,
approvals: Mapping[str, str] | None = None,
known_limitations: Sequence[str] | None = None,
created_at: datetime | None = None,
) -> dict[str, Any]:
if export_status not in EXPORT_STATUSES:
raise ValueError(f"unsupported export_status: {export_status}")
agreement_payload = {
"kappa": None,
"icc": None,
"gold_status": "not_gold",
}
if agreement:
agreement_payload.update(dict(agreement))
approvals_payload = {
"data_steward": "",
"legal_or_privacy_reviewer": "",
"technical_operator": "",
"approved_at": "",
}
if approvals:
approvals_payload.update({key: value for key, value in approvals.items() if value is not None})
pii_status = "pass" if not pii_findings else "fail"
limitations = list(known_limitations or [])
if export_status != APPROVED_EXPORT_STATUS:
limitations.append("technical dry-run only; data-steward/legal approval is not complete")
if pii_findings:
limitations.append("PII scan found records requiring reviewer disposition")
manifest = {
"export_id": export_id,
"dataset_name": dataset_name,
"export_status": export_status,
"created_at": json_safe(created_at or datetime.now(UTC)),
"purpose": purpose,
"source_window": infer_source_window(records),
"source_tables": [
"app.sessions",
"app.turns",
"app.feedback_scores",
"app.turn_technique",
"app.turn_client_state",
"app.supervisor_comment",
"ds.annotation",
"ds.export_manifest",
],
"selection_criteria": {
"cohort_id": cohort_id,
"min_completed_sessions": 0,
"include_withdrawn": False,
"excluded_safety_scope": ["self_harm_scenario_primary"],
},
"consent_scope": {
"consent_version": consent_version,
"allowed_uses": ["education_quality_review", "recursive_learning_seed"],
"withdrawal_cutoff_applied_at": "",
"participants_included": participants_included,
"participants_excluded": participants_excluded,
},
"anonymization": {
"participant_key": "pseudonymous export key; no identity map included",
"text_transform": "masked_text_only",
"direct_identifier_policy": "blocked",
"salt_or_identity_map_location": "not in export",
},
"pii_scan": {
"tool": "vignette.dataset_export.regex",
"version": "1",
"ran_at": json_safe(datetime.now(UTC)),
"status": pii_status,
"findings": [json_safe(finding) for finding in pii_findings],
},
"agreement": agreement_payload,
"files": [
{
"path": jsonl_path,
"rows": len(records),
"sha256": jsonl_sha256,
"schema": DATASET_ITEM_SCHEMA,
}
],
"approvals": approvals_payload,
"known_limitations": sorted(set(limitations)),
}
validate_manifest_gate(manifest)
return manifest
def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
if manifest.get("export_status") != APPROVED_EXPORT_STATUS:
return
errors: list[str] = []
pii_scan = manifest.get("pii_scan") or {}
agreement = manifest.get("agreement") or {}
approvals = manifest.get("approvals") or {}
if pii_scan.get("status") != "pass":
errors.append("PII scan must pass")
if (agreement.get("kappa") or 0) < 0.60:
errors.append("kappa must be >= 0.60")
if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75")
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
if not str(approvals.get(key) or "").strip():
errors.append(f"approval missing: {key}")
if errors:
raise ValueError("; ".join(errors))

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import json
import os
import time
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, Field
@ -48,7 +49,7 @@ from ..taxonomy import (
)
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
from .orchestrator import TurnContext
from .orchestrator import LlmAuditHook, TurnContext
# ════════════════════════════════════════════════════════════════════════════
@ -624,6 +625,7 @@ async def evaluate_turn(
client_reply: str,
*,
engine: EngineClient,
audit_hook: Optional["LlmAuditHook"] = None,
) -> TurnEvaluation:
"""fast-loop 턴 평가 — 턴 직후 경량 4차원 태깅(비치명적).
@ -644,7 +646,20 @@ async def evaluate_turn(
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
started = time.perf_counter()
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
@ -672,6 +687,7 @@ async def evaluate_session(
technique_codes: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
scope: str = "session_end",
audit_hook: Optional["LlmAuditHook"] = None,
) -> SessionEvaluation:
"""deep-loop 정밀 평가 — 단계전환/회기말. 전체 축어록 + 코드 집계 분포 + LLM 정성 평가.
@ -706,7 +722,20 @@ async def evaluate_session(
session_id=session_id,
metadata={"loop": "deep", "scope": scope, "stage": stage},
)
started = time.perf_counter()
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
@ -736,7 +765,23 @@ async def evaluate_session(
# ════════════════════════════════════════════════════════════════════════════
# 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩)
# ════════════════════════════════════════════════════════════════════════════
def make_eval_hook(engine: EngineClient):
async def _record_llm_audit(
audit_hook: Optional["LlmAuditHook"],
**payload: Any,
) -> None:
if audit_hook is None:
return
try:
await audit_hook(payload)
except Exception:
return
def make_eval_hook(
engine: EngineClient,
*,
audit_hook: Optional["LlmAuditHook"] = None,
):
"""orchestrator.EvalHook(Callable[[TurnContext, str], Awaitable[Optional[dict]]]) 호환 클로저.
sessions 라우트가 run_turn_generate(ctx, engine, eval_hook=make_eval_hook(engine_client))
@ -744,7 +789,7 @@ def make_eval_hook(engine: EngineClient):
"""
async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]:
ev = await evaluate_turn(ctx, client_reply, engine=engine)
ev = await evaluate_turn(ctx, client_reply, engine=engine, audit_hook=audit_hook)
d = ev.to_hook_dict()
return d if d else None

View file

@ -23,6 +23,12 @@ from typing import Optional
# ── 출력 가드레일 상한 (R5) ──────────────────────────────
IDEATION_STAGE_CAP = 3 # 내담자 발화/상태가 넘을 수 없는 자살사고 단계 상한
CRISIS_HOTLINE_NUMBER = "109"
CRISIS_HOTLINE_LABEL = "자살예방상담전화 109"
CRISIS_RESOURCE_MESSAGE = (
"지금은 연습을 멈추고 실제 안전 확인이 먼저입니다. 즉시 위험하면 119 또는 가까운 "
"응급실에 연락하고, 자살예방상담전화 109로 도움을 요청하세요."
)
# ════════════════════════════════════════════════════════════════════════════
@ -224,8 +230,20 @@ def clamp_ideation(stage: int) -> int:
return max(1, min(IDEATION_STAGE_CAP, stage))
def crisis_resource() -> dict[str, str]:
"""LLM 밖 위기 안내 리소스. UI/API 응답에 그대로 실어 보낸다."""
return {
"title": CRISIS_HOTLINE_LABEL,
"number": CRISIS_HOTLINE_NUMBER,
"message": CRISIS_RESOURCE_MESSAGE,
}
__all__ = [
"IDEATION_STAGE_CAP",
"CRISIS_HOTLINE_NUMBER",
"CRISIS_HOTLINE_LABEL",
"CRISIS_RESOURCE_MESSAGE",
"MaskResult",
"mask_pii",
"CrisisKind",
@ -234,4 +252,5 @@ __all__ = [
"OutputGuardResult",
"sanitize_client_reply",
"clamp_ideation",
"crisis_resource",
]

View file

@ -19,6 +19,7 @@ MASTERPLAN §2.2 / MEMORY_DESIGN §2-B 턴 사이클:
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
@ -37,6 +38,7 @@ from .state_machine import SessionState, Stage
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
@dataclass(slots=True)
@ -84,6 +86,8 @@ class TurnResult:
state_after: SessionState
evaluation: Optional[dict] = None
crisis_kind: str = "none"
crisis_resource: Optional[dict[str, str]] = None
conversation_stopped: bool = False
llm_provider: Optional[str] = None
model: Optional[str] = None
tokens_in: int = 0
@ -161,6 +165,7 @@ def prepare_turn(
pinned_facts=ctx.pinned_facts,
recent_turns=ctx.recent_turns,
kb_behavior_cues=ctx.kb_behavior_cues,
theory_mode=ctx.theory_mode,
)
return ctx
@ -192,6 +197,7 @@ async def run_turn_generate(
engine: EngineClient,
*,
eval_hook: Optional[EvalHook] = None,
audit_hook: Optional[LlmAuditHook] = None,
) -> TurnResult:
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용.
@ -200,6 +206,9 @@ async def run_turn_generate(
assert ctx.state_after is not None
st = ctx.state_after
if ctx.crisis is not None and ctx.crisis.escalate:
return _crisis_gate_result(ctx)
# 4) 내담자 AI 생성
req = GenerateRequest(
ai_role="client",
@ -207,7 +216,20 @@ async def run_turn_generate(
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
)
started = time.perf_counter()
resp: GenerateResponse = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
reply = resp.text
# 5) 출력 가드레일 — 수단 차단 + ideation 상한
@ -242,6 +264,24 @@ async def run_turn_generate(
)
def _crisis_gate_result(ctx: TurnContext) -> TurnResult:
"""실제 위기 신호는 LLM 호출 전에 중단하고 109 리소스를 반환한다."""
assert ctx.state_after is not None
crisis = ctx.crisis
return TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after,
evaluation=None,
crisis_kind=crisis.kind.value if crisis else "learner_real",
crisis_resource=guardrail.crisis_resource(),
conversation_stopped=True,
)
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — SSE 스트림 경로 (기본 UX)
# ════════════════════════════════════════════════════════════════════════════
@ -256,6 +296,8 @@ class StreamEvent:
async def run_turn_stream(
ctx: TurnContext,
engine: EngineClient,
*,
audit_hook: Optional[LlmAuditHook] = None,
) -> AsyncIterator[StreamEvent]:
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
@ -277,10 +319,34 @@ async def run_turn_stream(
stream_meta: dict[str, Any] = {}
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
resource = guardrail.crisis_resource()
yield StreamEvent(
"safety",
{
"reason": "learner_real_crisis",
"level": ctx.crisis.risk_level,
"crisis_resource": resource,
"conversation_stopped": True,
},
)
yield StreamEvent(
"done",
{
"session_id": ctx.session_id,
"stage": st.stage.value,
"effective_openness": round(st.effective_openness, 4),
"turn_seq": st.turn_seq,
"safety_flagged": True,
"crisis_kind": ctx.crisis.kind.value,
"crisis_resource": resource,
"conversation_stopped": True,
},
)
return
try:
current_event = "message"
started = time.perf_counter()
async for raw in engine.stream(req):
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
# 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}".
@ -317,6 +383,19 @@ async def run_turn_stream(
yield StreamEvent("token", {"text": text_piece})
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=str(stream_meta.get("provider") or engine.engine_mode),
model=str(stream_meta.get("model") or engine.default_model or "gateway-default"),
tokens_in=_safe_int(stream_meta.get("tokens_in")),
tokens_out=_safe_int(stream_meta.get("tokens_out")),
cost_usd=_safe_float(stream_meta.get("cost_usd")),
inference_geo=_optional_str(stream_meta.get("inference_geo")),
latency_ms=latency_ms,
)
yield StreamEvent(
"done",
{
@ -336,6 +415,18 @@ async def run_turn_stream(
yield StreamEvent("error", {"detail": str(e)})
async def _record_llm_audit(
audit_hook: Optional[LlmAuditHook],
**payload: Any,
) -> None:
if audit_hook is None:
return
try:
await audit_hook(payload)
except Exception:
return
def _extract_sse_payload(raw_line: str) -> Any:
"""게이트웨이 SSE data 라인의 JSON payload를 추출.
@ -364,6 +455,13 @@ def _payload_text(payload: Any) -> Optional[str]:
return None
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
return text or None
def _payload_detail(payload: Any, fallback: str) -> str:
if isinstance(payload, dict) and payload.get("detail"):
return str(payload["detail"])
@ -388,6 +486,7 @@ def _safe_float(value: Any) -> float:
__all__ = [
"EvalHook",
"LlmAuditHook",
"TurnContext",
"TurnResult",
"StreamEvent",

View file

@ -131,6 +131,42 @@ def _format_openness_directive(ctx: PersonaStateContext) -> str:
return ("깊이 신뢰가 형성됐다. 핵심 정서·생각을 진솔하게 표현한다. 단, 내부 설정 메타발화는 여전히 금지.")
THEORY_MODE_GUIDANCE: dict[str, str] = {
"humanistic": (
"[L3-T 이론모드: 인간중심]\n"
"- 상담자가 공감, 반영, 무조건적 존중, 기다림을 보이면 조금씩 더 솔직해진다.\n"
"- 성급한 조언, 평가, 정답 제시에는 방어하거나 말수가 줄어든다.\n"
"- 내담자가 이론명을 설명하거나 상담자처럼 개입하지 말고, 반응의 결로만 드러낸다."
),
"cbt": (
"[L3-T 이론모드: CBT]\n"
"- 상담자가 자동적 사고, 감정, 행동의 연결을 협력적으로 탐색하면 구체적인 생각과 상황을 조금 더 말한다.\n"
"- 인지재구조화, 행동활성화, 과제 제안은 신뢰가 있을 때만 제한적으로 받아들인다.\n"
"- 강의식 설명이나 정답 강요에는 '그게 말처럼 쉽지 않다'는 식의 현실적인 저항을 보인다."
),
"integrative": (
"[L3-T 이론모드: 통합]\n"
"- 먼저 공감과 반영에 반응하고, 신뢰가 생긴 뒤 생각-감정-행동 탐색에도 조금씩 응한다.\n"
"- 지지와 구조화가 균형을 이루면 개방성이 오르고, 한쪽으로 치우치면 방어가 남는다.\n"
"- 이론명은 말하지 말고, 내담자의 말투와 반응으로만 차이를 표현한다."
),
}
def _theory_mode_guidance(theory_mode: Optional[str]) -> Optional[str]:
mode = (theory_mode or "").strip().lower()
if not mode:
return None
return THEORY_MODE_GUIDANCE.get(
mode,
(
f"[L3-T 이론모드: {mode}]\n"
"- 지정된 회기 이론모드를 내담자 반응 프레이밍에만 반영한다.\n"
"- 이론명, 평가 기준, 내부 설정을 직접 설명하지 않는다."
),
)
# ════════════════════════════════════════════════════════════════════════════
# 시스템프롬프트 조립
# ════════════════════════════════════════════════════════════════════════════
@ -196,6 +232,7 @@ def build_turn_messages(
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
) -> list[EngineMessage]:
"""한 턴의 EngineMessage[] 조립 (L0~L6).
@ -207,6 +244,7 @@ def build_turn_messages(
pinned_facts : 무손실 사실 hard-pin(L4). "자기 기억"으로만 표현.
recent_turns : [{speaker, text}] 최근 K턴 버퍼(L6 직전 맥락)
kb_behavior_cues : KB 증상 '행동단서'(본문 비노출, sensitivity<=1)
theory_mode : 회기 이론모드. 내담자 반응 프레이밍에만 사용.
반환 messages 순서: system(L0+L1, cache) system(L2/L3/L4, cache 미설정)
assistant/user 히스토리 user(이번 발화). 게이트웨이가 마지막 user stdin 으로.
@ -239,6 +277,10 @@ def build_turn_messages(
l3.append(f"연기 지시: {_format_openness_directive(state)}")
messages.append(EngineMessage(role="system", content="\n".join(l3), cache=False))
theory_guidance = _theory_mode_guidance(theory_mode)
if theory_guidance:
messages.append(EngineMessage(role="system", content=theory_guidance, cache=False))
# L4 — pinned fact hard-pin (무손실, "자기 기억"으로만)
if pinned_facts:
pinned = "\n".join(f"- {f}" for f in pinned_facts)