현재 작업 전체 반영
This commit is contained in:
parent
5560638e54
commit
c0dddab594
85 changed files with 11322 additions and 539 deletions
400
apps/api/app/services/dataset_export.py
Normal file
400
apps/api/app/services/dataset_export.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue