전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -52,7 +52,9 @@ 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"),
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")),
@ -62,7 +64,12 @@ PII_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
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")),
(
"secret",
re.compile(
r"\b(?:sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)\b"
),
),
)
@ -155,7 +162,13 @@ def scan_for_pii(value: Any, path: str = "$") -> list[dict[str, Any]]:
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.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):
@ -195,11 +208,19 @@ def build_dataset_record(
"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": export_safe_supervisor_comments(row.get("supervisor_comments") 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": export_safe_supervisor_comments(
row.get("supervisor_comments") or []
),
"source_refs": {
"session_started_at": json_safe(row.get("session_started_at") or row.get("started_at")),
"session_started_at": json_safe(
row.get("session_started_at") or row.get("started_at")
),
"export_manifest_id": export_manifest_id,
},
"privacy": {
@ -214,7 +235,14 @@ 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(
json.dumps(
json_safe(record),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
)
handle.write("\n")
@ -226,7 +254,9 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()
def cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> float | None:
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:
@ -244,13 +274,18 @@ def cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> flo
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))
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:
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 {})
@ -275,7 +310,9 @@ def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key:
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
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):
@ -284,7 +321,9 @@ def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key:
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 = [
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 "",
@ -292,35 +331,39 @@ def infer_source_window(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
}
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}")
@dataclass(frozen=True, slots=True)
class DatasetManifestInput:
"""Approved/dry-run dataset artifact metadata crossing the export boundary."""
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
def build_manifest(spec: DatasetManifestInput) -> dict[str, Any]:
if spec.export_status not in EXPORT_STATUSES:
raise ValueError(f"unsupported export_status: {spec.export_status}")
agreement_payload = {
"kappa": None,
"icc": None,
"gold_status": "not_gold",
}
if agreement:
agreement_payload.update(dict(agreement))
if spec.agreement:
agreement_payload.update(dict(spec.agreement))
approvals_payload = {
"data_steward": "",
@ -328,23 +371,27 @@ def build_manifest(
"technical_operator": "",
"approved_at": "",
}
if approvals:
approvals_payload.update({key: value for key, value in approvals.items() if value is not None})
if spec.approvals:
approvals_payload.update(
{key: value for key, value in spec.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:
pii_status = "pass" if not spec.pii_findings else "fail"
limitations = list(spec.known_limitations or [])
if spec.export_status != APPROVED_EXPORT_STATUS:
limitations.append(
"technical dry-run only; data-steward/legal approval is not complete"
)
if spec.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),
"export_id": spec.export_id,
"dataset_name": spec.dataset_name,
"export_status": spec.export_status,
"created_at": json_safe(spec.created_at or datetime.now(UTC)),
"purpose": spec.purpose,
"source_window": infer_source_window(spec.records),
"source_tables": [
"app.sessions",
"app.turns",
@ -356,17 +403,17 @@ def build_manifest(
"ds.export_manifest",
],
"selection_criteria": {
"cohort_id": cohort_id,
"cohort_id": spec.cohort_id,
"min_completed_sessions": 0,
"include_withdrawn": False,
"excluded_safety_scope": ["self_harm_scenario_primary"],
},
"consent_scope": {
"consent_version": consent_version,
"consent_version": spec.consent_version,
"allowed_uses": ["education_quality_review", "recursive_learning_seed"],
"withdrawal_cutoff_applied_at": "",
"participants_included": participants_included,
"participants_excluded": participants_excluded,
"participants_included": spec.participants_included,
"participants_excluded": spec.participants_excluded,
},
"anonymization": {
"participant_key": "pseudonymous export key; no identity map included",
@ -379,14 +426,14 @@ def build_manifest(
"version": "1",
"ran_at": json_safe(datetime.now(UTC)),
"status": pii_status,
"findings": [json_safe(finding) for finding in pii_findings],
"findings": [json_safe(finding) for finding in spec.pii_findings],
},
"agreement": agreement_payload,
"files": [
{
"path": jsonl_path,
"rows": len(records),
"sha256": jsonl_sha256,
"path": spec.jsonl_path,
"rows": len(spec.records),
"sha256": spec.jsonl_sha256,
"schema": DATASET_ITEM_SCHEMA,
}
],
@ -411,13 +458,28 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75")
selection_criteria = manifest.get("selection_criteria") or {}
if not isinstance(selection_criteria, Mapping) or selection_criteria.get("include_withdrawn") is not False:
if (
not isinstance(selection_criteria, Mapping)
or selection_criteria.get("include_withdrawn") is not False
):
errors.append("include_withdrawn must be false")
consent_scope = manifest.get("consent_scope") or {}
allowed_uses = consent_scope.get("allowed_uses") if isinstance(consent_scope, Mapping) else None
if not isinstance(allowed_uses, list) or "recursive_learning_seed" not in allowed_uses:
allowed_uses = (
consent_scope.get("allowed_uses")
if isinstance(consent_scope, Mapping)
else None
)
if (
not isinstance(allowed_uses, list)
or "recursive_learning_seed" not in allowed_uses
):
errors.append("recursive_learning_seed consent scope is required")
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
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:

View file

@ -0,0 +1,25 @@
"""Shared fast-loop evaluation labels and explicit score projections."""
from __future__ import annotations
from typing import Literal
Appropriateness = Literal["pos", "warn", "neutral"]
APPROPRIATENESS_VALUES: tuple[Appropriateness, ...] = ("pos", "warn", "neutral")
# Persistence uses the rubric's 1..5 scale. Keep this projection distinct from
# the learner-growth 0..1 normalization below.
PERSISTED_APPROPRIATENESS_SCORE_5PT: dict[Appropriateness, float] = {
"warn": 1.0,
"neutral": 3.0,
"pos": 5.0,
}
# ``neg`` is retained only for legacy stored evaluations; current evaluator
# output is restricted to APPROPRIATENESS_VALUES.
GROWTH_APPROPRIATENESS_SCORE_01: dict[str, float] = {
"neg": 0.0,
"warn": 0.25,
"neutral": 0.5,
"pos": 1.0,
}

View file

@ -49,15 +49,16 @@ from ..taxonomy import (
TECHNIQUE_CATEGORY,
TECHNIQUE_KO,
ClientState,
CommentKind,
Technique,
TechniqueCategory,
speaker_ko_label,
)
from . import guardrail
from .evaluation_contract import APPROPRIATENESS_VALUES, Appropriateness
from .llm_audit import LlmAuditHook, generate_with_audit
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
from .orchestrator import LlmAuditHook, TurnContext
from .orchestrator import TurnContext
# ════════════════════════════════════════════════════════════════════════════
@ -66,11 +67,12 @@ if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입
# LLM 은 후보로 한글 라벨을 받지만, 코드값(value)을 돌려줄 수도 있어 둘 다 받는다.
_TECHNIQUE_BY_KO: dict[str, Technique] = {ko: t for t, ko in TECHNIQUE_KO.items()}
_TECHNIQUE_BY_CODE: dict[str, Technique] = {t.value: t for t in Technique}
_CLIENT_STATE_BY_KO: dict[str, ClientState] = {ko: s for s, ko in CLIENT_STATE_KO.items()}
_CLIENT_STATE_BY_KO: dict[str, ClientState] = {
ko: s for s, ko in CLIENT_STATE_KO.items()
}
_CLIENT_STATE_BY_CODE: dict[str, ClientState] = {s.value: s for s in ClientState}
# 적절성 신호 — fast-loop 의 경량 판단(상태머신 라포 추정과 별개 차원).
_APPROPRIATENESS = ("pos", "warn", "neutral")
# 의도이탈 심각도 (taxonomy.SupervisorComment.severity 와 동일 어휘).
_SEVERITY = ("minor", "moderate", "major")
@ -207,17 +209,17 @@ class IntentDeviation(BaseModel):
class TechniqueTag(BaseModel):
"""fast-loop 기법 태그 1건 — taxonomy.Technique 코드 + 한글 + 군집 + 근거."""
code: str # taxonomy.Technique.value
label_ko: str # TECHNIQUE_KO
category: str # TechniqueCategory.value (분포 집계축)
code: str # taxonomy.Technique.value
label_ko: str # TECHNIQUE_KO
category: str # TechniqueCategory.value (분포 집계축)
rationale: Optional[str] = None # 왜 이 기법으로 봤는지(근거 요구)
class ClientStateRead(BaseModel):
"""내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거)."""
code: str # taxonomy.ClientState.value
label_ko: str # CLIENT_STATE_KO
code: str # taxonomy.ClientState.value
label_ko: str # CLIENT_STATE_KO
rationale: Optional[str] = None
@ -236,12 +238,14 @@ class TurnEvaluation(BaseModel):
stage: str
techniques: list[TechniqueTag] = Field(default_factory=list)
client_state_read: list[ClientStateRead] = Field(default_factory=list)
appropriateness: str = "neutral" # pos | warn | neutral
appropriateness: Appropriateness = "neutral"
appropriateness_note: Optional[str] = None
intent_deviation: Optional[IntentDeviation] = None # 있을 때만(1급 시민)
rapport_signal: Optional[float] = None # 평가 AI 가 본 라포 신호(1~+1, 상태머신 주입 가능)
rapport_signal: Optional[float] = (
None # 평가 AI 가 본 라포 신호(1~+1, 상태머신 주입 가능)
)
theory_mode: Optional[str] = None
error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
def to_hook_dict(self) -> dict[str, Any]:
"""orchestrator.EvalHook 가 기대하는 평가 dict(turns.evaluation 적재용)."""
@ -251,10 +255,12 @@ class TurnEvaluation(BaseModel):
class TechniqueDistribution(BaseModel):
"""deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단."""
by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
by_technique: dict[str, int] = Field(default_factory=dict) # technique.value -> count
by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
by_technique: dict[str, int] = Field(
default_factory=dict
) # technique.value -> count
total: int = 0
overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
@ -266,15 +272,15 @@ class SessionEvaluation(BaseModel):
loop: str = "deep"
session_id: str
stage: str # 평가 시점 단계(전환 트리거면 from-stage)
scope: str = "session_end" # 'session_end' | 'stage_transition'
stage: str # 평가 시점 단계(전환 트리거면 from-stage)
scope: str = "session_end" # 'session_end' | 'stage_transition'
turns_evaluated: int = 0
distribution: TechniqueDistribution = Field(default_factory=TechniqueDistribution)
strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
intent_deviations: list[IntentDeviation] = Field(default_factory=list)
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
theory_mode: Optional[str] = None
error: Optional[str] = None
@ -324,7 +330,7 @@ def _fast_schema() -> dict[str, Any]:
"required": ["code"],
},
},
"appropriateness": {"type": "string", "enum": list(_APPROPRIATENESS)},
"appropriateness": {"type": "string", "enum": list(APPROPRIATENESS_VALUES)},
"appropriateness_note": {"type": "string"},
"rapport_signal": {"type": "number", "minimum": -1, "maximum": 1},
"intent_deviation": {
@ -350,7 +356,11 @@ def _deep_schema() -> dict[str, Any]:
"additionalProperties": False,
"properties": {
"strengths": {"type": "array", "items": {"type": "string"}},
"improvements": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
"improvements": {
"type": "array",
"items": {"type": "string"},
"maxItems": 3,
},
"intent_deviations": {
"type": "array",
"items": {
@ -405,7 +415,9 @@ def _client_state_candidates_block() -> str:
# ─ few-shot 골든셋 예시(data/golden) — 명시적으로 켠 환경에서만 로딩 ─────────
# 골든셋은 학습/평가 보정 자료이지 운영 런타임의 기본 데이터가 아니다.
_GOLDEN_FEWSHOT_ENABLED = os.environ.get("EVALUATOR_GOLDEN_FEWSHOT_ENABLED", "").lower() in {
_GOLDEN_FEWSHOT_ENABLED = os.environ.get(
"EVALUATOR_GOLDEN_FEWSHOT_ENABLED", ""
).lower() in {
"1",
"true",
"yes",
@ -456,7 +468,11 @@ def _fewshot_block() -> str:
techs = ",".join(e.get("techniques", []))
text = (e.get("text") or "").replace("\n", " ")[:70]
rat = next(
(c.get("text", "") for c in e.get("comments", []) if c.get("kind") == "rationale"),
(
c.get("text", "")
for c in e.get("comments", [])
if c.get("kind") == "rationale"
),
"",
)
line = f'- "{text}"{techs}'
@ -482,10 +498,13 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_pii(client_reply).text_masked
recent = "\n".join(
f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in (ctx.memory.recent_turns or [])[-4:]
) or "(직전 맥락 없음)"
recent = (
"\n".join(
f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in (ctx.memory.recent_turns or [])[-4:]
)
or "(직전 맥락 없음)"
)
crisis_note = ""
if ctx.crisis is not None and getattr(ctx.crisis, "escalate", False):
@ -495,7 +514,8 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
)
system = "\n\n".join(
p for p in [
p
for p in [
_EVAL_ROLE,
_technique_candidates_block(),
_client_state_candidates_block(),
@ -509,7 +529,8 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
"없으면 null. 이 항목은 가장 중요하다 — 무리한 생성 금지, 진짜 이탈만.\n"
"추가로 rapport_signal(1~+1): 이 발화가 라포에 끼친 방향(공감·반영=+, 조언점프·평가=)."
),
] if p
]
if p
)
user = (
@ -537,11 +558,16 @@ def build_deep_messages(
distribution: "TechniqueDistribution",
) -> list[EngineMessage]:
"""deep-loop 평가 프롬프트(전체 회기 + 코드 집계 분포 + 골든라벨 후보)."""
transcript = "\n".join(
f"{t.get('seq', '')}{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in masked_turns
) or "(축어록 없음)"
dist_lines = ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
transcript = (
"\n".join(
f"{t.get('seq', '')}{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
for t in masked_turns
)
or "(축어록 없음)"
)
dist_lines = (
", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
)
over = ", ".join(distribution.overused) or "(없음)"
under = ", ".join(distribution.underused) or "(없음)"
@ -590,8 +616,9 @@ def _parse_intent_deviation(d: Any) -> Optional[IntentDeviation]:
return IntentDeviation(dimension=dim, expected=exp, actual=act, severity=sev)
def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
theory: Optional[str]) -> TurnEvaluation:
def _parse_fast(
payload: dict[str, Any], *, turn_seq: int, stage: str, theory: Optional[str]
) -> TurnEvaluation:
techniques: list[TechniqueTag] = []
for item in payload.get("techniques") or []:
if not isinstance(item, dict):
@ -604,7 +631,9 @@ def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
code=t.value,
label_ko=TECHNIQUE_KO[t],
category=TECHNIQUE_CATEGORY[t].value,
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
rationale=(str(item.get("rationale")).strip() or None)
if item.get("rationale")
else None,
)
)
@ -619,12 +648,14 @@ def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
ClientStateRead(
code=s.value,
label_ko=CLIENT_STATE_KO[s],
rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
rationale=(str(item.get("rationale")).strip() or None)
if item.get("rationale")
else None,
)
)
appro = str(payload.get("appropriateness") or "neutral").strip()
if appro not in _APPROPRIATENESS:
if appro not in APPROPRIATENESS_VALUES:
appro = "neutral"
rapport = payload.get("rapport_signal")
@ -706,7 +737,9 @@ async def evaluate_turn(
"""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
base = TurnEvaluation(loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory)
base = TurnEvaluation(
loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory
)
try:
req = GenerateRequest(
@ -715,7 +748,7 @@ async def evaluate_turn(
structured_schema=_fast_schema(),
model=_configured_model(settings.evaluator_fast_model),
max_tokens=900,
temperature=0.2, # 평가는 보수적·재현적으로
temperature=0.2, # 평가는 보수적·재현적으로
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
@ -723,20 +756,7 @@ async def evaluate_turn(
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return TurnEvaluation.model_validate(cached)
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,
)
resp = await generate_with_audit(engine, req, audit_hook)
except EngineError:
base.error = "engine_error"
return base
@ -749,7 +769,9 @@ async def evaluate_turn(
base.error = "no_structured_output"
return base
try:
result = _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
result = _parse_fast(
payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory
)
_evaluator_cache_put(cache_key, result.model_dump())
return result
except Exception: # 파싱 방어
@ -806,20 +828,7 @@ async def evaluate_session(
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return SessionEvaluation.model_validate(cached)
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,
)
resp = await generate_with_audit(engine, req, audit_hook)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
@ -834,7 +843,9 @@ async def evaluate_session(
base.strengths = _coerce_str_list(payload.get("strengths"))
base.improvements = _coerce_str_list(payload.get("improvements"))[:3] # 최대 3
base.alternative_utterances = _coerce_str_list(payload.get("alternative_utterances"))
base.alternative_utterances = _coerce_str_list(
payload.get("alternative_utterances")
)
rationale = payload.get("supervisor_rationale")
critique = payload.get("supervisor_critique")
base.supervisor_rationale = str(rationale).strip() if rationale else None
@ -850,18 +861,6 @@ async def evaluate_session(
# ════════════════════════════════════════════════════════════════════════════
# 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩)
# ════════════════════════════════════════════════════════════════════════════
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,
*,
@ -874,7 +873,9 @@ def make_eval_hook(
"""
async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]:
ev = await evaluate_turn(ctx, client_reply, engine=engine, audit_hook=audit_hook)
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

@ -370,9 +370,12 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr
# 3. 출력 가드레일 (내담자 AI 응답 — 자살수단 차단, ideation 상한 R5)
# ════════════════════════════════════════════════════════════════════════════
# 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용.
# 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강.
# 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가.
_MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "", "방법은", "어떻게 죽",
"커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"NAME": "그 이름",

View file

@ -136,6 +136,10 @@ class LiveCoachInput(BaseModel):
client_reply: Optional[str] = None
recent_turns: list[dict[str, str]] = Field(default_factory=list)
evaluation: Optional[dict[str, Any]] = None
# 이번 회기 목표 단계(P1 준비 페이지 선택) — 코칭을 회기 목표에 정렬한다.
goal_stages: list[str] = Field(default_factory=list)
# 직전 코칭 요약(title/focus) — 같은 조언 반복을 막는다.
prior_coach: list[dict[str, str]] = Field(default_factory=list)
class LiveCoachGrounding(BaseModel):
@ -529,7 +533,17 @@ def _fallback_suggestion(
focus: CoachFocus = "exploration"
title = "다음 탐색"
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
next_line = "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?"
# 단계별 기본 다음 발화 — 폴백에서도 회기 흐름에 맞는 제안을 낸다.
stage_next_lines = {
"라포": "오늘 이렇게 시간 내줘서 고마워요. 지금 마음이 어떤지 편한 만큼만 들려줄래요?",
"탐색": "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
"개입": "그 생각이 올라올 때 몸이나 행동은 어떻게 반응하는지 같이 한번 살펴볼까요?",
"정리": "오늘 나눈 이야기 중에 가장 마음에 남는 것 하나를 같이 정리해 볼까요?",
}
next_line = stage_next_lines.get(
str(item.stage),
"방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
)
crisis = guardrail.classify_crisis(text)
if crisis.kind != guardrail.CrisisKind.NONE:
@ -633,10 +647,21 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
"영역의 구체 행동으로 연결한다. DSM/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
)
goals = ", ".join(item.goal_stages) if item.goal_stages else "(미지정)"
prior = (
"\n".join(
f"- {entry.get('title', '')} (focus: {entry.get('focus', '')})"
for entry in item.prior_coach[-2:]
if entry.get("title")
)
or "(이번 회기 첫 코칭)"
)
user = (
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
f"[내담자] {item.persona_name} ({item.persona_code})\n"
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n\n"
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n"
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
f"[최근 맥락]\n{recent}\n\n"
f"[이번 상담자 발화]\n{learner_masked}\n\n"
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
@ -654,13 +679,14 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
async def _record_llm_audit(
audit_hook: Optional["LlmAuditHook"],
**payload: Any,
) -> None:
) -> bool:
if audit_hook is None:
return
return True
try:
await audit_hook(payload)
result = await audit_hook(payload)
return result is not False
except Exception:
return
return False
async def generate_live_coaching(
@ -691,7 +717,7 @@ async def generate_live_coaching(
)
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_ok = await _record_llm_audit(
audit_hook,
session_id=item.session_id,
provider=resp.provider,
@ -702,6 +728,12 @@ async def generate_live_coaching(
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
if not audit_ok:
return _fallback_suggestion(
item,
grounding=all_grounding,
reason="응답 검증 기록을 남기지 못했다",
)
payload = structured_payload_from_response(resp)
if payload is None:
return _fallback_suggestion(

View file

@ -0,0 +1,47 @@
"""LLM 호출 계량과 감사 훅의 공통 실행 경로."""
from __future__ import annotations
import time
from collections.abc import Awaitable, Callable
from typing import Any
from ..engine_client import EngineClient, GenerateRequest, GenerateResponse
LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
async def record_llm_audit(
audit_hook: LlmAuditHook | None,
**payload: Any,
) -> None:
"""감사 저장소 장애가 사용자 응답을 막지 않도록 훅 실패를 격리한다."""
if audit_hook is None:
return
try:
await audit_hook(payload)
except Exception:
return
async def generate_with_audit(
engine: EngineClient,
request: GenerateRequest,
audit_hook: LlmAuditHook | None,
) -> GenerateResponse:
"""비스트리밍 LLM 호출의 지연·토큰·비용 기록을 한 계약으로 고정한다."""
started = time.perf_counter()
response = await engine.generate(request)
latency_ms = int((time.perf_counter() - started) * 1000)
await record_llm_audit(
audit_hook,
session_id=request.session_id,
provider=response.provider,
model=response.model,
tokens_in=response.tokens_in,
tokens_out=response.tokens_out,
cost_usd=response.cost_usd,
inference_geo=response.inference_geo,
latency_ms=latency_ms,
)
return response

View file

@ -20,7 +20,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Optional
from typing import Any, Literal, Optional
from .state_machine import SessionState
from ..taxonomy import speaker_ko_label

View file

@ -15,10 +15,32 @@ from uuid import uuid4
from ..config import settings
from ..db import acquire, get_pool
from ..runtime_schema import (
NOTIFICATION_SCHEMA_CONTRACT,
runtime_schema_bootstrap_required,
schema_contract_ready,
)
logger = logging.getLogger(__name__)
NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_email"]
NotificationKind = Literal[
"account_pending_approval", "session_review_ready", "admin_test_email"
]
ApprovalRecipientScope = Literal["admin", "super_admin"]
ACTIVE_NOTIFICATION_RECIPIENTS_SQL = """
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
"""
@dataclass(slots=True)
@ -38,9 +60,14 @@ class RenderedEmail:
async def ensure_notification_tables() -> None:
"""Create notification queue tables when the DB role allows DDL."""
"""Verify notification tables, with DDL repair restricted to local development."""
get_pool()
async with acquire(role="admin") as conn:
ready = await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT)
if not runtime_schema_bootstrap_required(
NOTIFICATION_SCHEMA_CONTRACT, ready=ready
):
return
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.notification_event (
@ -120,6 +147,10 @@ async def ensure_notification_tables() -> None:
WITH CHECK (app.current_role_name() = 'admin');
"""
)
if not await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT):
raise RuntimeError(
"notification development schema bootstrap did not satisfy readiness"
)
def schedule_delivery_flush() -> None:
@ -127,7 +158,9 @@ def schedule_delivery_flush() -> None:
if settings.notification_email_provider == "disabled":
return
try:
asyncio.get_running_loop().create_task(process_queued_email_notifications(limit=10))
asyncio.get_running_loop().create_task(
process_queued_email_notifications(limit=10)
)
except RuntimeError:
return
@ -321,57 +354,39 @@ async def _enqueue_event(
async def _admin_approval_recipients() -> list[NotificationRecipient]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
return await _approval_recipients(scope="admin")
async def _super_admin_recipients() -> list[NotificationRecipient]:
return await _approval_recipients(scope="super_admin")
async def _approval_recipients(
*, scope: ApprovalRecipientScope
) -> list[NotificationRecipient]:
"""가입 승인 알림 수신 정책을 한 쿼리에서 소유한다."""
super_admin_emails = sorted(
{_normalize_email(value) for value in settings.auth_super_admin_emails}
)
super_admin_only = scope == "super_admin"
if super_admin_only and not super_admin_emails:
return []
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ """
AND (
u.role = 'admin'
OR u.admin_access
OR lower(u.email) = ANY($1::text[])
lower(u.email) = ANY($1::text[])
OR (
NOT $2::boolean
AND (u.role = 'admin' OR u.admin_access)
)
)
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
)
return [_recipient_from_row(row) for row in rows]
async def _super_admin_recipients() -> list[NotificationRecipient]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
if not super_admin_emails:
return []
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
AND lower(u.email) = ANY($1::text[])
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
super_admin_only,
)
return [_recipient_from_row(row) for row in rows]
@ -379,7 +394,9 @@ async def _super_admin_recipients() -> list[NotificationRecipient]:
async def _session_review_payload_and_recipients(
session_id: str,
) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]:
super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
super_admin_emails = sorted(
{_normalize_email(value) for value in settings.auth_super_admin_emails}
)
async with acquire(role="admin") as conn:
session = await conn.fetchrow(
"""
@ -407,18 +424,8 @@ async def _session_review_payload_and_recipients(
return None, []
learner_cohort = str(session["learner_cohort"] or "")
rows = await conn.fetch(
"""
SELECT DISTINCT
u.user_id,
u.email,
COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
u.role
FROM app.app_user u
LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
WHERE u.is_active
AND u.account_status = 'approved'
AND u.email IS NOT NULL
AND u.email <> ''
ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ """
AND (
u.role = 'admin'
OR lower(u.email) = ANY($1::text[])
@ -457,7 +464,9 @@ def _send_rendered_email(
if settings.notification_email_provider == "disabled":
raise NotificationSkipped("email provider is disabled")
if settings.notification_email_provider != "smtp":
raise NotificationSkipped(f"unsupported provider: {settings.notification_email_provider}")
raise NotificationSkipped(
f"unsupported provider: {settings.notification_email_provider}"
)
if not settings.smtp_host.strip() or not settings.smtp_from_email.strip():
raise NotificationSkipped("SMTP host/from email is not configured")
@ -472,7 +481,9 @@ def _send_rendered_email(
if settings.smtp_ssl:
context = ssl.create_default_context()
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15) as smtp:
with smtplib.SMTP_SSL(
settings.smtp_host, settings.smtp_port, context=context, timeout=15
) as smtp:
_smtp_login_if_needed(smtp)
smtp.send_message(msg)
else:
@ -553,7 +564,9 @@ async def _mark_delivery_failed(delivery_id: str, error: str) -> None:
def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
display_name = str(payload.get("display_name") or payload.get("email") or "신규 사용자")
display_name = str(
payload.get("display_name") or payload.get("email") or "신규 사용자"
)
email = str(payload.get("email") or "")
role = _role_label(str(payload.get("role") or "learner"))
approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users"))
@ -563,11 +576,15 @@ def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 승인 상태를 결정해 주세요.
</p>
{_info_box([
("사용자", display_name),
("이메일", email),
("요청 역할", role),
])}
{
_info_box(
[
("사용자", display_name),
("이메일", email),
("요청 역할", role),
]
)
}
{_button("가입 승인 확인하기", approval_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 있습니다.
@ -602,12 +619,16 @@ def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 검토 상태를 남겨 주세요.
</p>
{_info_box([
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
])}
{
_info_box(
[
("학습자", learner_label),
("내담자", persona_name),
("회기", f"{session_no}회기" if session_no else "종료 회기"),
("종료 시각", ended_at or "기록됨"),
]
)
}
{_button("회기 검토하기", review_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 Vignette에서 확인해 주세요.
@ -641,10 +662,14 @@ def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
관리자 메일 알림이 정상적으로 연결되었습니다. 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
</p>
{_info_box([
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
])}
{
_info_box(
[
("요청자", requested_by),
("용도", "운영 메일 발송 테스트"),
]
)
}
{_button("알림 상태 확인하기", notifications_url)}
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.

View file

@ -41,13 +41,13 @@ from ..contracts.engine_gateway import (
StreamTokenEvent,
)
from . import guardrail, persona, state_machine
from .llm_audit import LlmAuditHook, generate_with_audit, record_llm_audit
from .persona import PersonaCard, PersonaStateContext, TurnMemory
from .state_machine import SessionState, Stage
from .state_machine import SessionState
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
def turn_evaluation_error_payload(ctx: "TurnContext", error: BaseException | str) -> dict[str, Any]:
@ -253,20 +253,7 @@ async def run_turn_generate(
reply = ""
safety_flagged = ctx.crisis is not None and ctx.crisis.escalate
for attempt in range(2):
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,
)
resp = await generate_with_audit(engine, req, audit_hook)
# 5) 출력 가드레일 — 수단 차단 + persona 품질 재생성
guard = guardrail.sanitize_client_reply(
@ -451,7 +438,7 @@ async def run_turn_stream(
yield StreamEvent("token", {"text": guard.text})
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
await record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=str(stream_meta.get("provider") or engine.engine_mode),
@ -493,18 +480,6 @@ 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 _optional_str(value: Any) -> Optional[str]:
if value is None:
return None

View file

@ -21,10 +21,13 @@ P1 = 0615 청소년 '서연' 사례의 합성 변형(원문 미적재, F-05).
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
from typing import TYPE_CHECKING, Any, Optional
from ..engine_client import EngineMessage
if TYPE_CHECKING:
from .state_machine import OpennessParams
# ════════════════════════════════════════════════════════════════════════════
# 페르소나 카드 (app.persona_card 컬럼 구조의 in-proc 표현)

View file

@ -17,7 +17,7 @@ from typing import Any, Iterable, Mapping, Sequence
from uuid import UUID
from .phase3_kpi_contract import (
KPI_REPORT_PATH,
KPI_REPORT_PATH as KPI_REPORT_PATH,
PHASE3_KPI_METRICS,
PREPOST_CSV_PATH,
PREPOST_MEASURE_NAMES,

View file

@ -7,14 +7,7 @@ from datetime import datetime
from typing import Any, Callable
from ..store import InProcSession
_APPROPRIATENESS_SCORE = {
"neg": 0.0,
"warn": 0.25,
"neutral": 0.5,
"pos": 1.0,
}
from .evaluation_contract import GROWTH_APPROPRIATENESS_SCORE_01
@dataclass(frozen=True)
@ -80,7 +73,7 @@ def turn_score(ev: dict[str, Any]) -> float | None:
if str(ev.get("error") or "").strip():
return None
raw = str(ev.get("appropriateness") or "").strip().lower()
return _APPROPRIATENESS_SCORE.get(raw)
return GROWTH_APPROPRIATENESS_SCORE_01.get(raw)
def turn_rapport(ev: dict[str, Any]) -> float | None:
@ -179,7 +172,9 @@ def build_learner_growth(
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
points = [session_growth_point(sess) for sess in ordered]
scored = [point for point in points if point.score is not None]
rapport_values = [point.rapport for point in points if point.rapport is not None]
rapport_values = [
point.rapport for point in points if point.rapport is not None
]
technique_counts: dict[str, int] = {}
for sess in ordered:
for turn in sess.turns:
@ -223,8 +218,12 @@ def build_learner_growth(
first_score=first_score,
latest_score=latest_score,
score_delta=score_delta,
avg_score=avg([point.score for point in scored if point.score is not None]),
avg_rapport=avg([value for value in rapport_values if value is not None]),
avg_score=avg(
[point.score for point in scored if point.score is not None]
),
avg_rapport=avg(
[value for value in rapport_values if value is not None]
),
trend=trend,
top_techniques=top_techniques,
points=points if point_limit is None else points[-point_limit:],
@ -234,7 +233,9 @@ def build_learner_growth(
return sorted_result if limit is None else sorted_result[:limit]
def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> list[dict[str, object]]:
def recent_feedback_notes(
sessions: list[InProcSession], *, limit: int = 5
) -> list[dict[str, object]]:
notes: list[dict[str, object]] = []
for sess in sorted(sessions, key=session_activity_time, reverse=True):
for turn in reversed(sess.turns):
@ -254,7 +255,9 @@ def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> l
"session_no": sess.session_no,
"stage": turn.stage,
"turn_seq": turn.turn_seq,
"created_at": iso_datetime(turn.created_at) or iso_datetime(session_activity_time(sess)) or "",
"created_at": iso_datetime(turn.created_at)
or iso_datetime(session_activity_time(sess))
or "",
"score": turn_score(ev),
"rapport": turn_rapport(ev),
"note": note,

View file

@ -0,0 +1,138 @@
"""자유 양식 표 파일(엑셀/CSV) → 텍스트 변환 (P4, 2026-07-13 한신대 회의).
연구팀이 교수자 페이지에 올리는 자유 양식 엑셀(파란 라벨 방식 포함)
페르소나 저작 KB에 넣을 있는 평문으로 결정론 변환한다.
원칙:
- 업로드 원본 바이트는 여기서 파싱만 하고 어디에도 저장하지 않는다(원본 파기 원칙).
파생 텍스트만 기존 `/personas/sources` 마스킹·hash-only 증거 경로로 넘어간다.
- 양식 변형에 견디도록 /서식에 의존하지 않는다 비어있지 않은 셀만 단위로 평탄화한다.
- openpyxl 선택 의존성이다(presidio 패턴). 없으면 명확한 한국어 오류로 안내한다.
"""
from __future__ import annotations
import csv
import io
# 변환 상한 — 파일럿 추고록 기준 여유값. LLM/KB 입력 상한(120k)과 정합.
MAX_TEXT_CHARS = 120_000
MAX_CELLS = 40_000
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8MB
class TabularIngestError(ValueError):
"""사용자에게 그대로 보여줄 수 있는 한국어 사유를 담는다."""
def _try_load_openpyxl():
try:
import openpyxl # noqa: PLC0415 — 선택 의존성 지연 로드
return openpyxl
except ImportError as exc: # pragma: no cover - 설치 환경에선 도달하지 않음
raise TabularIngestError(
"엑셀 변환 모듈(openpyxl)이 설치되어 있지 않습니다. 관리자에게 API 의존성 설치를 요청하세요."
) from exc
def _cell_text(value: object) -> str:
if value is None:
return ""
if isinstance(value, float) and value.is_integer():
return str(int(value))
text = str(value).strip()
return " ".join(text.split())
def _rows_to_lines(rows: list[list[str]]) -> list[str]:
lines: list[str] = []
for cells in rows:
filled = [cell for cell in cells if cell]
if not filled:
continue
if len(filled) == 2:
# 자유 양식에서 가장 흔한 "라벨 | 값" 행 — 읽기 좋은 쌍으로 변환.
lines.append(f"{filled[0]}: {filled[1]}")
else:
lines.append(" | ".join(filled))
return lines
def _extract_xlsx(data: bytes) -> str:
openpyxl = _try_load_openpyxl()
try:
workbook = openpyxl.load_workbook(
io.BytesIO(data), read_only=True, data_only=True
)
except Exception as exc:
raise TabularIngestError(
"엑셀 파일을 열지 못했습니다. 손상되지 않은 .xlsx 파일인지 확인해 주세요."
) from exc
try:
sections: list[str] = []
cell_budget = MAX_CELLS
for sheet in workbook.worksheets:
rows: list[list[str]] = []
for row in sheet.iter_rows(values_only=True):
if cell_budget <= 0:
break
cell_budget -= len(row)
rows.append([_cell_text(value) for value in row])
lines = _rows_to_lines(rows)
if lines:
sections.append(f"## 시트: {sheet.title}\n" + "\n".join(lines))
if cell_budget <= 0:
sections.append("(셀 수 상한에 도달해 이후 내용은 생략했습니다)")
break
return "\n\n".join(sections)
finally:
workbook.close()
def _extract_csv(data: bytes) -> str:
text: str | None = None
for encoding in ("utf-8-sig", "cp949", "utf-8"):
try:
text = data.decode(encoding)
break
except UnicodeDecodeError:
continue
if text is None:
raise TabularIngestError(
"CSV 인코딩을 해석하지 못했습니다. UTF-8 또는 엑셀(xlsx)로 저장해 다시 올려 주세요."
)
rows = [[_cell_text(cell) for cell in row] for row in csv.reader(io.StringIO(text))]
return "\n".join(_rows_to_lines(rows[: MAX_CELLS // 8]))
def extract_tabular_text(*, filename: str, data: bytes) -> str:
"""업로드 파일을 KB 등록용 평문으로 변환한다. 실패 사유는 TabularIngestError."""
if not data:
raise TabularIngestError("업로드된 파일이 비어 있습니다.")
if len(data) > MAX_UPLOAD_BYTES:
raise TabularIngestError("파일이 8MB를 넘습니다. 시트를 나눠 다시 올려 주세요.")
lowered = (filename or "").lower()
if lowered.endswith(".xlsx") or lowered.endswith(".xlsm"):
text = _extract_xlsx(data)
elif lowered.endswith(".xls"):
raise TabularIngestError(
"구형 엑셀(.xls)은 지원하지 않습니다. 엑셀에서 '다른 이름으로 저장 → .xlsx'로 변환해 올려 주세요."
)
elif lowered.endswith(".csv"):
text = _extract_csv(data)
else:
raise TabularIngestError(
"지원하지 않는 파일 형식입니다. 엑셀(.xlsx) 또는 CSV 파일을 올려 주세요."
)
text = text.strip()
if len(text) < 20:
raise TabularIngestError(
"표에서 읽을 수 있는 텍스트가 거의 없습니다. 내용이 있는 시트인지 확인해 주세요."
)
return text[:MAX_TEXT_CHARS]
__all__ = ["TabularIngestError", "extract_tabular_text", "MAX_UPLOAD_BYTES"]