Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정
Phase W: - weight 비율 초기 배정 (space_allocator header 높이 반영) - block_assembler 공통 조립 함수 (filled/assembled 통합) - filled → Selenium 측정 → context 저장 - sidebar overflow 확장 + body 재배분 - sub_layouts 사전 계산 (이미지 누락 해결) Phase V': - 팝업 링크 우측상단 배치 (인라인 → position:absolute) - 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약) - 출처 라벨 삭제 + 이미지 아래 캡션 배치 - after 공란 제거 (결론 바로 위까지 body/sidebar 채움) 추가: - V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단 - ** 마크다운 → <strong> 변환 - [이미지:] 마커 제거 (bold 변환 전 처리) - grid-template-rows AFTER 크기 반영 (Sonnet final) - assemble_stage2 CSS font-size override, white-space fix - 하드코딩 전수 검토 완료 - 본심 여러 topic 텍스트 합침 Phase X 계획 문서 작성 (동적 역할 구조) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
380
src/validators.py
Normal file
380
src/validators.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""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
|
||||
|
||||
# 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}",
|
||||
})
|
||||
|
||||
# 본심 존재 + 본심 weight ≥ 0.3
|
||||
core_info = page_struct.get("본심", {})
|
||||
if not core_info or not isinstance(core_info, dict):
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "page_structure.본심",
|
||||
"localization": "본심 역할이 page_structure에 없음",
|
||||
"instruction": "page_structure에 본심 역할을 추가하라. 본심은 슬라이드의 핵심 콘텐츠이다.",
|
||||
})
|
||||
elif core_info.get("weight", 0) < 0.3:
|
||||
errors.append({
|
||||
"severity": "RETRYABLE",
|
||||
"field": "page_structure.본심.weight",
|
||||
"localization": f"본심 weight {core_info['weight']:.2f} < 0.3",
|
||||
"instruction": "본심은 슬라이드의 핵심. weight 0.3 이상 필요.",
|
||||
})
|
||||
|
||||
# 필수 필드 검증
|
||||
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)
|
||||
if len(original_sections) > 0 and abs(len(topics) - len(original_sections)) > 2:
|
||||
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 = "",
|
||||
) -> 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를 작성하라. 형식: 관계 선언 + 콘텐츠 설명 + 시각 지침",
|
||||
})
|
||||
|
||||
# ── 모순 탐지 (결정 테이블) ──
|
||||
|
||||
if purpose in CONTRADICTIONS:
|
||||
if relation_type in CONTRADICTIONS[purpose]:
|
||||
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:
|
||||
# 주장한 관계의 증거가 0개
|
||||
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
|
||||
Reference in New Issue
Block a user