"""LLM_NARRATIVE_TEMPLATE_LOCK_V1 — LLM 서술 어휘 잠금 도구. operational_report.json 각 section.markdown에서 두 종류의 위반을 스캔한다. (1) INVALID_NARRATIVE — 금지 어휘 블랙리스트: 한국어: 같다, 약간, 괜찮다, 이번엔, 곧, 조만간, 강한 모멘텀 영어: "seems like", "might be", "probably", "soon", "strong momentum", "pretty good" (2) INVALID_SOFTENING — verdict 완화 패턴 (P3 확장): BLOCK/SELL/CRITICAL verdict 근방에서 아래 완화 어휘가 동시 등장하면 차단. 완화 어휘: "그래도", "유연하게", "장기 관점", "재진입 고려", "고려 가능", "상황에 따라", "아직 괜찮", "지켜볼 만" 감지 조건: 동일 섹션 내에 verdict_keyword + softening_keyword 동시 존재. 허용: 공식 ID (FORMULA_ID_V1 형식), 산출 라벨, 산출 숫자만. 게이트 CHECK_71 + CHECK_72(SOFTENING): 총 위반 0건. """ from __future__ import annotations import argparse import json import re from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_REPORT = ROOT / "Temp" / "operational_report.json" DEFAULT_OUT = ROOT / "Temp" / "llm_narrative_template_lock_v1.json" # 금지 어휘 패턴 (정규식) _FORBIDDEN_PATTERNS: list[tuple[str, str]] = [ # (pattern, label) (r"(? list[dict[str, Any]]: """BLOCK/SELL verdict 근방에서 완화 어휘 동시 출현 감지.""" # verdict 키워드가 없으면 검사 생략 if not _VERDICT_RE.search(text): return [] hits = [] for pattern, label in _SOFTENING_RE_LIST: for m in pattern.finditer(text): start = max(0, m.start() - 60) end = min(len(text), m.end() + 60) context = text[start:end].replace("\n", " ").strip() hits.append({ "pattern_label": label, "matched_text": m.group(0), "context": context, "position": m.start(), "violation_type": "INVALID_SOFTENING", }) return hits def _load(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: d = json.loads(path.read_text(encoding="utf-8")) return d if isinstance(d, dict) else {} except Exception: return {} def _scan_text(text: str) -> list[dict[str, Any]]: """텍스트에서 금지 어휘 탐색.""" hits = [] for pattern, label in _COMPILED: for m in pattern.finditer(text): # 컨텍스트 추출 (±30자) start = max(0, m.start() - 30) end = min(len(text), m.end() + 30) context = text[start:end].replace("\n", " ").strip() hits.append({ "pattern_label": label, "matched_text": m.group(0), "context": context, "position": m.start(), }) return hits def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--report", default=str(DEFAULT_REPORT)) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() report_path = Path(args.report) if Path(args.report).is_absolute() else ROOT / args.report out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out report = _load(report_path) sections = report.get("sections") if isinstance(report.get("sections"), list) else [] total_violations = 0 section_results: list[dict[str, Any]] = [] for section in sections: if not isinstance(section, dict): continue name = str(section.get("name") or "") markdown = str(section.get("markdown") or "") if not markdown: continue hits = _scan_text(markdown) softening_hits = _scan_softening(markdown) all_hits = hits + softening_hits sec_status = "OK" if softening_hits: sec_status = "INVALID_SOFTENING" elif hits: sec_status = "INVALID_NARRATIVE" section_results.append({ "section_name": name, "violation_count": len(all_hits), "narrative_violations": len(hits), "softening_violations": len(softening_hits), "violations": all_hits, "status": sec_status, }) total_violations += len(all_hits) total_softening = sum(s["softening_violations"] for s in section_results) total_narrative = sum(s["narrative_violations"] for s in section_results) gate = "PASS" if total_violations == 0 else "FAIL" # 요약 failed_sections = [s for s in section_results if s["status"] != "OK"] result = { "formula_id": "LLM_NARRATIVE_TEMPLATE_LOCK_V1", "gate": gate, "total_violations": total_violations, "narrative_violations": total_narrative, "softening_violations": total_softening, "sections_checked": len(section_results), "sections_failed": len(failed_sections), "forbidden_pattern_count": len(_FORBIDDEN_PATTERNS), "softening_pattern_count": len(_SOFTENING_PATTERNS), "forbidden_patterns": [label for _, label in _FORBIDDEN_PATTERNS], "softening_patterns": [label for _, label in _SOFTENING_PATTERNS], "section_results": section_results, } out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print( f"LLM_NARRATIVE_TEMPLATE_LOCK_V1 gate={gate} " f"total_violations={total_violations} " f"(narrative={total_narrative} softening={total_softening}) " f"sections_checked={len(section_results)} sections_failed={len(failed_sections)}" ) return 0 if __name__ == "__main__": raise SystemExit(main())