Jev 기반 내담자 감정 상태와 응답 일관성 개선

This commit is contained in:
Yun Chan 2026-09-22 21:32:26 +09:00
parent 77f8421818
commit 8344bc2ad2
23 changed files with 3384 additions and 25 deletions

View file

@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""Jev 감정 판단 API의 공개 합성 한국어 사례 실측 러너."""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import math
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from pydantic import ValidationError
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
FIXTURE_PATH = REPO_ROOT / "scripts" / "fixtures" / "jev-client-korean-cases.json"
REQUIRED_STATE_KEYS = frozenset(
{
"persona",
"memory",
"recent_turns",
"counselor_utterance",
"previous_emotions",
"current_state",
}
)
SENSITIVE_FIELD_PATTERN = re.compile(r"(?:api[_-]?key|authorization|password|secret|token)", re.IGNORECASE)
SECRET_VALUE_PATTERN = re.compile(r"(?:sk-|bearer\s+|AIza|AKIA)[A-Za-z0-9_\-]{8,}", re.IGNORECASE)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="공개 합성 한국어 가상 내담자 사례로 Jev 감정 판단 API를 실측한다."
)
parser.add_argument(
"--output",
required=True,
help="로컬 JSON 보고서 저장 경로",
)
parser.add_argument(
"--fixtures",
type=Path,
default=FIXTURE_PATH,
help="공개 합성 사례 JSON 경로",
)
parser.add_argument(
"--repeats",
type=int,
default=1,
help="각 사례의 반복 횟수(1~20, 기본 1)",
)
return parser
def load_cases(path: Path) -> list[dict[str, Any]]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError("fixture_load_failed") from exc
if not isinstance(payload, dict) or payload.get("provenance") != "public_synthetic":
raise ValueError("fixture_provenance_invalid")
cases = payload.get("cases")
if not isinstance(cases, list) or not 8 <= len(cases) <= 12:
raise ValueError("fixture_case_count_invalid")
identifiers: set[str] = set()
for case in cases:
validate_case(case, identifiers)
return cases
def validate_case(case: Any, identifiers: set[str]) -> None:
if not isinstance(case, dict) or set(case) != {
"id",
"description",
"state",
"review_questions",
}:
raise ValueError("fixture_case_shape_invalid")
case_id = case["id"]
if not isinstance(case_id, str) or not case_id or case_id in identifiers:
raise ValueError("fixture_case_id_invalid")
identifiers.add(case_id)
if not isinstance(case["description"], str) or not case["description"]:
raise ValueError("fixture_description_invalid")
state = case["state"]
if not isinstance(state, dict) or set(state) != REQUIRED_STATE_KEYS:
raise ValueError("fixture_state_contract_invalid")
persona = state["persona"]
memory = state["memory"]
recent_turns = state["recent_turns"]
emotions = state["previous_emotions"]
current_state = state["current_state"]
context = persona.get("context") if isinstance(persona, dict) else None
if (
not isinstance(persona, dict)
or set(persona) != {"affect_baseline", "context"}
or not isinstance(persona["affect_baseline"], dict)
or not all(isinstance(value, (int, float)) and math.isfinite(value) for value in persona["affect_baseline"].values())
or not isinstance(context, dict)
or set(context) != {
"big5", "resistance", "speech_style", "presenting", "history", "ccd", "triggers"
}
or not all(isinstance(context[key], dict) for key in ("big5", "resistance", "speech_style", "ccd"))
or not all(isinstance(context[key], str) and context[key] for key in ("presenting", "history"))
or not isinstance(context["triggers"], list)
or not all(isinstance(trigger, str) and trigger for trigger in context["triggers"])
):
raise ValueError("fixture_persona_invalid")
if (
not isinstance(memory, dict)
or set(memory) != {"recall_summary", "pinned_facts"}
or not isinstance(memory["recall_summary"], str)
or not isinstance(memory["pinned_facts"], list)
or not all(isinstance(item, str) for item in memory["pinned_facts"])
):
raise ValueError("fixture_memory_invalid")
if (
not isinstance(recent_turns, list)
or len(recent_turns) > 12
or not all(
isinstance(turn, dict)
and set(turn) == {"speaker", "text"}
and turn["speaker"] in {"counselor", "client"}
and isinstance(turn["text"], str)
for turn in recent_turns
)
or not isinstance(state["counselor_utterance"], str)
):
raise ValueError("fixture_recent_turns_invalid")
if (
not isinstance(emotions, dict)
or set(emotions) != {
"anxiety", "sadness", "anger", "shame", "guilt", "loneliness", "relief", "hope", "trust"
}
or not all(isinstance(value, (int, float)) and 0.0 <= value <= 1.0 for value in emotions.values())
):
raise ValueError("fixture_previous_emotions_invalid")
if (
not isinstance(current_state, dict)
or set(current_state) != {"resistance", "effective_openness"}
or not all(isinstance(value, (int, float)) and 0.0 <= value <= 1.0 for value in current_state.values())
):
raise ValueError("fixture_current_state_invalid")
questions = case["review_questions"]
if (
not isinstance(questions, list)
or len(questions) != 2
or not all(isinstance(question, str) and question for question in questions)
):
raise ValueError("fixture_review_questions_invalid")
if contains_sensitive_content(case):
raise ValueError("fixture_sensitive_content")
def contains_sensitive_content(value: Any, field_name: str = "") -> bool:
if SENSITIVE_FIELD_PATTERN.search(field_name):
return True
if isinstance(value, str):
return bool(SECRET_VALUE_PATTERN.search(value))
if isinstance(value, dict):
return any(contains_sensitive_content(item, str(key)) for key, item in value.items())
if isinstance(value, list):
return any(contains_sensitive_content(item) for item in value)
return False
def percentile(values: list[int], percent: float) -> int | None:
if not values:
return None
ordered = sorted(values)
position = (len(ordered) - 1) * percent
lower = math.floor(position)
upper = math.ceil(position)
if lower == upper:
return ordered[lower]
return round(ordered[lower] + (ordered[upper] - ordered[lower]) * (position - lower))
def base_report(
*,
fixture_path: Path,
case_count: int,
repeats: int,
provider: str,
requested_model: str,
timeout_seconds: float,
confidence_threshold: float,
) -> dict[str, Any]:
return {
"status": "blocked",
"fixture": {
"path": str(fixture_path),
"sha256": fixture_sha256(fixture_path),
"case_count": case_count,
"repeats": repeats,
"provenance": "public_synthetic",
"contains_real_patient_data": False,
},
"measurement_started_at_utc": datetime.now(timezone.utc).isoformat(),
"configuration": {
"route_provider": provider,
"requested_model": requested_model,
"timeout_seconds": timeout_seconds,
"confidence_threshold": confidence_threshold,
},
"metrics": {
"attempted_calls": 0,
"success_count": 0,
"failure_count": 0,
"appraisal_latency_ms": {"sample_count": 0, "p50": None, "p95": None},
"input_tokens_total": 0,
"output_tokens_total": 0,
"cost_usd_total": None,
"known_success_cost_usd": None,
"failed_calls_cost_known": None,
"actual_models": [],
},
"results": [],
"limitations": [
"이 결과는 Jev 감정 판단 API 실측이며 전체 응답 지연, TTFT, 임상 타당성의 증거가 아니다.",
"전문가 검토 질문은 사례별 결과와 분리해 fixture에만 보관하며 자동 정답 또는 정확도 판정에 사용하지 않는다.",
],
}
def fixture_sha256(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def write_report(path: Path, report: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
async def collect(report: dict[str, Any], cases: list[dict[str, Any]], repeats: int) -> None:
sys.path.insert(0, str(API_ROOT))
from app.services.jev_client import JevError, jev_client
confidence_threshold = report["configuration"]["confidence_threshold"]
latencies: list[int] = []
models: set[str] = set()
known_costs: list[float] = []
try:
await jev_client.startup()
for repeat in range(1, repeats + 1):
for case in cases:
case_id = case["id"]
report["metrics"]["attempted_calls"] += 1
try:
appraisal = await jev_client.appraise(case["state"])
except JevError as exc:
report["metrics"]["failure_count"] += 1
report["results"].append(
{"case_id": case_id, "repeat": repeat, "status": "failed", "error_code": exc.code}
)
continue
except Exception:
report["metrics"]["failure_count"] += 1
report["results"].append(
{"case_id": case_id, "repeat": repeat, "status": "failed", "error_code": "unexpected_error"}
)
continue
dimensions = {}
for name, estimate in appraisal.emotions.items():
confidence_missing = estimate.confidence is None
dimensions[name] = {
"score": estimate.score,
"confidence": estimate.confidence,
"confidence_missing": confidence_missing,
"below_confidence_threshold": (
True if confidence_missing else estimate.confidence < confidence_threshold
),
"probabilities": estimate.probabilities,
}
report["metrics"]["success_count"] += 1
report["metrics"]["input_tokens_total"] += appraisal.input_tokens
report["metrics"]["output_tokens_total"] += appraisal.output_tokens
latencies.append(appraisal.latency_ms)
models.add(appraisal.model)
if appraisal.cost_usd is not None:
known_costs.append(appraisal.cost_usd)
report["results"].append(
{
"case_id": case_id,
"repeat": repeat,
"status": "collected",
"provider": appraisal.provider,
"actual_model": appraisal.model,
"appraisal_latency_ms": appraisal.latency_ms,
"input_tokens": appraisal.input_tokens,
"output_tokens": appraisal.output_tokens,
"dimensions": dimensions,
}
)
except JevError as exc:
report["metrics"]["failure_count"] += 1
report["results"].append(
{"case_id": "runner", "repeat": 0, "status": "failed", "error_code": exc.code}
)
except Exception:
report["metrics"]["failure_count"] += 1
report["results"].append(
{"case_id": "runner", "repeat": 0, "status": "failed", "error_code": "unexpected_error"}
)
finally:
try:
await jev_client.shutdown()
except Exception:
report["metrics"]["failure_count"] += 1
report["results"].append(
{"case_id": "runner", "repeat": 0, "status": "failed", "error_code": "shutdown_error"}
)
report["metrics"]["actual_models"] = sorted(models)
if known_costs:
report["metrics"]["known_success_cost_usd"] = sum(known_costs)
report["metrics"]["appraisal_latency_ms"] = {
"sample_count": len(latencies),
"p50": percentile(latencies, 0.50),
"p95": percentile(latencies, 0.95),
}
success_count = report["metrics"]["success_count"]
failure_count = report["metrics"]["failure_count"]
report["metrics"]["failed_calls_cost_known"] = failure_count == 0
if (
failure_count == 0
and success_count > 0
and success_count == len(known_costs)
):
report["metrics"]["cost_usd_total"] = report["metrics"]["known_success_cost_usd"]
if failure_count == 0:
report["status"] = "collected"
elif success_count == 0:
report["status"] = "failed"
else:
report["status"] = "partial"
def load_settings() -> Any:
sys.path.insert(0, str(API_ROOT))
original_cwd = Path.cwd()
try:
os.chdir(API_ROOT)
from app.config import settings
return settings
finally:
os.chdir(original_cwd)
def provider_key_present(settings: Any) -> bool:
if settings.jev_provider == "openrouter":
return bool(settings.openrouter_api_key.get_secret_value().strip())
if settings.jev_provider == "typesafe":
return bool(settings.typesafe_api_key.get_secret_value().strip())
return False
def invalid_configuration_report(*, fixture_path: Path, case_count: int, repeats: int) -> dict[str, Any]:
report = base_report(
fixture_path=fixture_path,
case_count=case_count,
repeats=repeats,
provider="unavailable",
requested_model="unavailable",
timeout_seconds=0.0,
confidence_threshold=0.0,
)
report["blocking_reason"] = "configuration_invalid"
return report
def main() -> int:
args = build_parser().parse_args()
if not 1 <= args.repeats <= 20:
raise SystemExit("--repeats must be between 1 and 20")
try:
cases = load_cases(args.fixtures)
except ValueError as exc:
report = {
"status": "failed",
"failure_reason": str(exc),
"measurement_started_at_utc": datetime.now(timezone.utc).isoformat(),
}
write_report(Path(args.output), report)
return 1
try:
settings = load_settings()
except ValidationError:
write_report(
Path(args.output),
invalid_configuration_report(
fixture_path=args.fixtures,
case_count=len(cases),
repeats=args.repeats,
),
)
return 2
report = base_report(
fixture_path=args.fixtures,
case_count=len(cases),
repeats=args.repeats,
provider=settings.jev_provider,
requested_model=settings.jev_model,
timeout_seconds=settings.jev_timeout_seconds,
confidence_threshold=settings.jev_min_confidence,
)
if not provider_key_present(settings):
report["blocking_reason"] = f"{settings.jev_provider}_api_key_missing"
write_report(Path(args.output), report)
return 2
asyncio.run(collect(report, cases, args.repeats))
write_report(Path(args.output), report)
return 0 if report["status"] == "collected" else 1
if __name__ == "__main__":
raise SystemExit(main())