Files
C.E.L_Slide_test2/src/validators.py

404 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase T-2: Stage 1A/1B 검증 시스템.
Stage 2 이후 검증(content_verifier.py)과 분리된 독립 모듈.
AI(Kei)의 콘텐츠 분석 결과를 원본과 대조하여 검증.
검증 4계층:
1. 형식 검증 (Pydantic) — 값 범위, 유효 enum, null 체크
2. 내용 검증 (코드+대조) — 결과가 원본에 대해 적절한가
3. 모순 탐지 (결정 테이블) — purpose × relation_type 논리 모순
4. 피드백 생성 — Self-Refine 패턴: localization + evidence + instruction
도구:
- kiwipiepy: 한국어 명사/키워드 추출 (T-2 조사: Windows 즉시 동작, Java 불필요)
- regex: 관계 표현 패턴 (T-2 조사: 7개 relation_type별 15개+ 패턴)
"""
from __future__ import annotations
import re
import logging
from typing import Any
logger = logging.getLogger(__name__)
# kiwipiepy lazy loading (첫 import 시 ~50MB 모델 다운로드)
_kiwi = None
def _get_kiwi():
global _kiwi
if _kiwi is None:
from kiwipiepy import Kiwi
_kiwi = Kiwi()
return _kiwi
# ══════════════════════════════════════
# 한국어 키워드 추출 (kiwipiepy)
# ══════════════════════════════════════
def extract_keywords_kiwi(text: str) -> set[str]:
"""kiwipiepy로 명사 + 영문 약어 추출.
기존 content_verifier.py의 regex extract_keywords()보다 정확:
- "정립되지""정립" 추출 가능 (어미 분리)
- "혼용되어""혼용" 추출 가능
- 복합 조사 "에서는" 등 정확 분리
"""
kiwi = _get_kiwi()
tokens = kiwi.tokenize(text)
keywords = set()
for t in tokens:
# NNG: 일반명사, NNP: 고유명사, SL: 외국어(영문약어)
if t.tag in ("NNG", "NNP", "SL") and len(t.form) >= 2:
keywords.add(t.form)
return keywords
# ══════════════════════════════════════
# 관계 표현 패턴 (T-2 조사 결과)
# ══════════════════════════════════════
RELATION_PATTERNS: dict[str, list[str]] = {
"comparison": [
r"[Vv][Ss]\.?", r"\s*비해", r"반면", r"차이점?", r"비교",
r"대비", r"\s*달리", r"\s*달리", r"한편", r"\s*반하여",
r"그에\s*비해", r"상이", r"구분", r"차별화",
],
"sequence": [
r"", r"이후", r"다음", r"먼저", r"\s*후", r"단계",
r"순서", r"[0-9]+차", r"최종적", r"\s*뒤", r"우선",
r"이어서", r"점진적", r"과정", r"\s*거쳐",
],
"hierarchy": [
r"상위", r"하위", r"속하", r"\s*일부", r"범주",
r"구성요소", r"체계", r"분류", r"계층", r"\s*나뉜?다",
r"종속", r"수직적", r"상하\s*관계", r"아우르", r"광의|협의",
],
"inclusion": [
r"포함", r"융합", r"통합", r"안에", r"속에", r"결합",
r"합쳐", r"아우르", r"망라", r"수렴", r"내포", r"포괄",
r"연계", r"접목", r"겹치|중복",
],
"cause_effect": [
r"때문에", r"따라서", r"결과", r"원인", r"\s*인해",
r"하여", r"해서", r"초래", r"야기", r"기인",
r"영향", r"유발", r"\s*결과", r"므로",
r"그래서", r"그러므로", r"\s*의해",
],
"definition": [
r"이란", r"정의", r"의미", r"개념",
r"\s*말한다", r"\s*뜻한다", r"\s*정의", r"가리킨다",
r"\s*함은", r"라고\s*한다", r"줄임말|약어|약자",
r"\s*해당", r"일컫", r"용어",
],
}
def detect_relation_evidence(text: str) -> dict[str, int]:
"""원본 텍스트에서 각 relation_type의 증거 수를 카운트."""
evidence = {}
for rel_type, patterns in RELATION_PATTERNS.items():
count = sum(1 for p in patterns if re.search(p, text))
evidence[rel_type] = count
return evidence
# ══════════════════════════════════════
# 모순 결정 테이블 (데이터로 정의)
# ══════════════════════════════════════
# purpose × relation_type 하드 모순 (이 조합은 논리적으로 불가능)
CONTRADICTIONS: dict[str, list[str]] = {
"결론강조": ["comparison", "sequence"], # 결론은 비교나 순서가 아님
"문제제기": ["sequence", "definition"], # 문제제기는 순서 나열이나 정의가 아님
"용어정의": ["hierarchy", "cause_effect"], # 정의 나열은 상하위나 인과가 아님
"구조시각화": ["none"], # 시각화할 관계가 없으면 구조시각화가 아님
}
# 소프트 경고 (의심 수준)
SOFT_WARNINGS: dict[str, list[str]] = {
"핵심전달": ["definition"], # 핵심전달에 definition은 약간 의심
}
# ══════════════════════════════════════
# Stage 1A 검증
# ══════════════════════════════════════
VALID_PURPOSES = {"문제제기", "근거사례", "핵심전달", "용어정의", "결론강조", "구조시각화"}
VALID_ROLES = {"flow", "reference"}
VALID_LAYERS = {"intro", "core", "supporting", "conclusion"}
def validate_stage_1a(
analysis: dict[str, Any],
clean_text: str,
) -> list[dict]:
"""Stage 1A(Kei 꼭지 추출) 결과 검증.
Args:
analysis: Kei API 반환 dict
clean_text: Stage 0에서 정규화된 텍스트
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
errors = []
topics = analysis.get("topics", [])
page_struct = analysis.get("page_structure", {})
# ── 형식 검증 ──
if not topics:
errors.append({
"severity": "FATAL",
"field": "topics",
"localization": "topics가 비어있음",
"instruction": "콘텐츠에서 최소 1개 꼭지를 추출하라",
})
return errors
# Phase Y: page_structure 검증은 validate_page_structure()에서 별도 수행.
# Stage 1A에서는 Kei 응답의 page_structure를 검증하지 않음.
# 필수 필드 검증
for t in topics:
tid = t.get("id", "?")
if not t.get("title"):
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].title",
"localization": f"topic {tid}에 title 없음",
"instruction": "각 topic에 title을 부여하라",
})
if t.get("purpose") and t["purpose"] not in VALID_PURPOSES:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].purpose",
"localization": f"topic {tid} purpose '{t['purpose']}' 유효하지 않음",
"current_value": t["purpose"],
"instruction": f"유효한 purpose: {VALID_PURPOSES}",
})
# page_structure의 topic_ids가 실제 topics에 존재하는지
all_topic_ids = {t.get("id") for t in topics}
for role, info in page_struct.items():
if not isinstance(info, dict):
continue
for tid in info.get("topic_ids", []):
if tid not in all_topic_ids:
errors.append({
"severity": "RETRYABLE",
"field": f"page_structure.{role}.topic_ids",
"localization": f"{role}에 존재하지 않는 topic_id {tid}",
"instruction": f"topic_ids는 topics[].id에 존재하는 값만 사용하라. 현재 topics: {sorted(all_topic_ids)}",
})
# ── 내용 검증 (원본 대조) ──
if clean_text:
# 원본 ## 섹션 수 vs topic 수 비교
original_sections = re.findall(r"^## .+$", clean_text, re.MULTILINE)
# 유형 B에서는 하나의 섹션을 여러 꼭지로 나눌 수 있으므로 허용 폭 확대
_layout = analysis.get("layout_template", "A")
max_diff = 4 if _layout == "B" else 2
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > max_diff:
errors.append({
"severity": "RETRYABLE",
"field": "topics",
"localization": f"원본 ## 섹션 {len(original_sections)}개, topic {len(topics)}개 (차이 {abs(len(topics) - len(original_sections))})",
"evidence": f"원본 섹션: {[s[3:].strip()[:30] for s in original_sections]}",
"instruction": "원본의 주요 섹션이 topic에 매핑되었는지 확인하라",
})
# topic summary 키워드가 원본에 존재하는지 (kiwipiepy)
try:
orig_keywords = extract_keywords_kiwi(clean_text)
for t in topics:
summary = t.get("summary", "")
if not summary:
continue
summary_kw = extract_keywords_kiwi(summary)
if not summary_kw:
continue
overlap = summary_kw & orig_keywords
rate = len(overlap) / len(summary_kw) if summary_kw else 1.0
if rate < 0.5:
missing = summary_kw - orig_keywords
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{t.get('id', '?')}].summary",
"localization": f"summary 키워드 보존율 {rate:.0%}",
"evidence": f"원본에 없는 키워드: {missing}",
"instruction": f"summary에 원본에 없는 표현을 추가하지 마라. 원본 키워드로 수정하라.",
})
except Exception as e:
logger.warning(f"[T-2] kiwipiepy 키워드 검증 실패: {e}")
return errors
# ══════════════════════════════════════
# Stage 1B 검증
# ══════════════════════════════════════
VALID_RELATION_TYPES = {"hierarchy", "cause_effect", "comparison", "sequence", "definition", "inclusion", "none"}
def validate_stage_1b(
topics: list[dict[str, Any]],
clean_text: str,
raw_content: str = "",
layout_template: str = "A",
) -> list[dict]:
"""Stage 1B(컨셉 구체화) 결과 검증.
Args:
topics: Stage 1B 후 업데이트된 topics 리스트
clean_text: Stage 0에서 정규화된 텍스트
raw_content: 원본 MDX 전체 (popups/details 포함). 대조 범위 확장용.
Returns:
에러 리스트 (빈 리스트 = 통과)
"""
# 대조 범위: clean_text + raw_content (popups/details 내용 포함)
full_text = clean_text
if raw_content:
full_text = clean_text + "\n" + raw_content
errors = []
for t in topics:
tid = t.get("id", "?")
purpose = t.get("purpose", "")
relation_type = t.get("relation_type", "")
expression_hint = t.get("expression_hint", "")
source_data = t.get("source_data", "")
# ── 형식 검증 ──
if relation_type not in VALID_RELATION_TYPES:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: 유효하지 않은 relation_type '{relation_type}'",
"current_value": relation_type,
"instruction": f"유효한 relation_type: {sorted(VALID_RELATION_TYPES)}",
})
if not expression_hint:
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].expression_hint",
"localization": f"topic {tid}: expression_hint 비어있음",
"instruction": "expression_hint를 작성하라. 형식: 관계 선언 + 콘텐츠 설명 + 시각 지침",
})
# ── 모순 탐지 (결정 테이블) ──
# Phase Y: Type B에서는 purpose/relation_type이 블록 선택의 핵심 입력이 아님
# (tag 매칭이 item_count + content_example로 동작)
# → Type B: 경고만 (파이프라인 계속). Type A: hard fail 유지.
if purpose in CONTRADICTIONS:
if relation_type in CONTRADICTIONS[purpose]:
if layout_template == "B":
# Type B: 경고만
logger.warning(
f"[T-2 모순경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' "
f"— Type B에서는 보조 힌트이므로 경고만"
)
else:
# Type A: hard fail 유지
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 모순",
"current_value": f"purpose={purpose}, relation_type={relation_type}",
"evidence": f"'{purpose}''{relation_type}'와 논리적으로 양립 불가",
"instruction": f"relation_type을 재판단하라. '{purpose}'에 적합한 관계는 "
f"{[r for r in VALID_RELATION_TYPES if r not in CONTRADICTIONS.get(purpose, [])]}",
})
if purpose in SOFT_WARNINGS:
if relation_type in SOFT_WARNINGS[purpose]:
logger.warning(
f"[T-2 경고] topic {tid}: purpose '{purpose}' × relation_type '{relation_type}' 의심"
)
# ── 원본 대조: source_data 할루시네이션 감지 ──
# full_text 사용 (popups/details 내용 포함)
if source_data and full_text:
try:
source_kw = extract_keywords_kiwi(source_data)
orig_kw = extract_keywords_kiwi(full_text)
if source_kw:
overlap = source_kw & orig_kw
rate = len(overlap) / len(source_kw)
if rate < 0.4:
missing = source_kw - orig_kw
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].source_data",
"localization": f"topic {tid}: source_data 키워드 보존율 {rate:.0%}",
"evidence": f"원본에 없는 키워드: {missing}",
"instruction": "source_data는 원본에 실제 존재하는 텍스트만 사용하라. 없는 출처를 만들어내지 마라.",
})
except Exception as e:
logger.warning(f"[T-2] source_data 검증 실패: {e}")
# ── 원본 대조: relation_type과 원본 언어 패턴 ──
# full_text 사용 (popups/details 내용 포함)
if relation_type and relation_type != "none" and full_text:
evidence = detect_relation_evidence(full_text)
claimed_count = evidence.get(relation_type, 0)
if claimed_count == 0:
if layout_template == "B":
# 유형 B: relation_type 증거 부족은 warning만 (역할 구조가 자유)
logger.warning(f"[Stage 1B] topic {tid}: '{relation_type}' 증거 0개 — 유형 B warning")
else:
alternatives = [(k, v) for k, v in evidence.items() if v >= 2]
alt_str = ", ".join(f"{k}({v}개)" for k, v in alternatives[:3])
errors.append({
"severity": "RETRYABLE",
"field": f"topics[{tid}].relation_type",
"localization": f"topic {tid}: '{relation_type}' 증거 0개",
"evidence": f"원본에서 '{relation_type}' 패턴 없음. 대안: {alt_str}" if alt_str else f"원본에서 '{relation_type}' 패턴 없음",
"instruction": f"원본 텍스트에 '{relation_type}' 관계를 나타내는 표현이 없음. 재판단하라.",
})
return errors
def validate_page_structure(page_struct: dict) -> list[dict]:
"""Phase Y: section_parser가 생성한 page_structure 검증.
Stage 1A 후, section_parser + 블록 매칭으로 page_structure가 채워진 후 호출.
"""
errors = []
if not page_struct:
errors.append({
"severity": "FATAL",
"field": "page_structure",
"localization": "page_structure가 비어있음",
"instruction": "section_parser가 영역을 생성하지 못함",
})
return errors
# weight 합 검증 (0.9~1.1)
total_weight = sum(
info.get("weight", 0) for info in page_struct.values()
if isinstance(info, dict)
)
if total_weight < 0.9 or total_weight > 1.1:
errors.append({
"severity": "RETRYABLE",
"field": "page_structure.weight",
"localization": f"weight 합 {total_weight:.2f} (범위: 0.9~1.1)",
"instruction": f"weight 합이 1.0에 가깝도록 조정하라. 현재 합: {total_weight:.2f}",
})
return errors