Files

470 lines
15 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""
generate_dashboard.py — Stage 2 자동 실행 스크립트
Stage 1 결과물(BO.json, client_goal.json, Fact_Ledger.json, evidence_indexed.json)을
읽어 단일 HTML 파일(Case_Dashboard.html)을 생성한다.
사건 데이터에 의존하지 않으며, Stage 1 스키마만 준수하면 어떤 사건에도 적용 가능하다.
사용법:
python generate_dashboard.py <input_dir> [output_dir]
- input_dir : Stage 1 결과물 4개 JSON이 있는 폴더 (필수)
- output_dir: HTML 저장 폴더 (생략 시 input_dir과 동일)
"""
import json
import html
import os
import sys
from itertools import groupby
# ─────────────────────────────────────────────
# 1. 유틸리티
# ─────────────────────────────────────────────
def load_json(filepath: str):
"""UTF-8 JSON 파일을 읽어 파이썬 객체로 반환"""
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def truncate(text: str, max_len: int) -> str:
"""max_len 글자까지 자르고, 초과 시 … 부착"""
if not text:
return ""
return text[:max_len] + ("…" if len(text) > max_len else "")
def sanitize_for_mermaid(text: str) -> str:
"""Mermaid 구문을 깨뜨릴 수 있는 문자를 치환"""
if not text:
return ""
replacements = {
'"': "'", # 쌍따옴표 → 홑따옴표
"#": "", # Mermaid 키워드 충돌 방지
"&": "and", # SVG 엔티티 충돌 방지
"<": "<",
">": ">",
"{": "(",
"}": ")",
}
for old, new in replacements.items():
text = text.replace(old, new)
return text
def sanitize_for_timeline(text: str) -> str:
"""Mermaid Timeline 전용: 콜론도 추가로 치환"""
text = sanitize_for_mermaid(text)
text = text.replace(":", ":") # 전각 콜론으로 치환 (구분자 충돌 방지)
return text
def has_evidence_gap(bo: dict) -> bool:
"""해당 BO가 증거공백인지 판정"""
if not bo.get("Evidence"): # 빈 리스트 또는 없음
return True
if bo.get("Outcome") == "[증거공백]":
return True
return False
def extract_year(date_str: str) -> str:
"""날짜 문자열에서 연도 추출. 없으면 '미상'"""
if date_str and len(date_str) >= 4:
return date_str[:4]
return "미상"
# ─────────────────────────────────────────────
# 2. 섹션별 생성 함수
# ─────────────────────────────────────────────
def build_section1(goal_data: dict) -> tuple:
"""
Section 1: 사건 개요 — primary_goal, constraints 텍스트 생성
Returns: (primary_goal_text, constraints_text)
"""
primary_goal = goal_data.get("primary_goal", "(목표 미기재)")
constraints = goal_data.get("constraints", [])
ordinals = ["첫째", "둘째", "셋째", "넷째", "다섯째",
"여섯째", "일곱째", "여덟째", "아홉째", "열째"]
if constraints:
parts = []
for i, c in enumerate(constraints):
prefix = ordinals[i] if i < len(ordinals) else f"제{i+1}"
parts.append(f"{prefix}, {c}")
constraints_text = ". ".join(parts) + "."
else:
constraints_text = "(제약 사항 없음)"
return primary_goal, constraints_text
def build_bo_timeline(bo_list: list) -> str:
"""
Section 2: BO Timeline — Mermaid timeline 구문 생성
규칙: BehaviorTime 정렬 → 연도 section → {date} : {id} - {Performer}, {Action 앞 20자}
"""
sorted_bos = sorted(
bo_list,
key=lambda x: x.get("BehaviorTime") or "9999-99-99"
)
lines = ["timeline", " title 행위 타임라인"]
for year, group in groupby(sorted_bos, key=lambda b: extract_year(b.get("BehaviorTime", ""))):
lines.append(f" section {year}")
for bo in group:
date = bo.get("BehaviorTime", "미상")
bo_id = bo.get("id", "?")
performer = sanitize_for_timeline(bo.get("Performer", "?"))
action = sanitize_for_timeline(truncate(bo.get("Action", ""), 20))
gap = " [증거공백]" if has_evidence_gap(bo) else ""
lines.append(f" {date} : {bo_id} - {performer}, {action}{gap}")
return "\n".join(lines)
def build_causality_flowchart(bo_list: list) -> str:
"""
Section 3: 인과관계도 — Mermaid flowchart TD 구문 생성
규칙: 노드 {id}["{Performer}: {Action 앞 15자}"], PriorAct → 엣지, 증거공백 → :::gap
"""
lines = [
"flowchart TD",
" classDef gap fill:#fee2e2,stroke:#ef4444,stroke-width:2px",
"",
]
# 노드 정의
for bo in bo_list:
bo_id = bo.get("id", "?")
performer = sanitize_for_mermaid(bo.get("Performer", "?"))
action = sanitize_for_mermaid(truncate(bo.get("Action", ""), 15))
label = f"{performer}: {action}"
gap_class = ":::gap" if has_evidence_gap(bo) else ""
lines.append(f' {bo_id}["{label}"]{gap_class}')
lines.append("")
# 엣지 정의
for bo in bo_list:
prior = bo.get("PriorAct")
if prior:
lines.append(f" {prior} --> {bo.get('id', '?')}")
return "\n".join(lines)
def build_fact_timeline(fact_list: list) -> str:
"""
Section 4: 사실원장 타임라인 — Mermaid timeline 구문 생성
규칙: date 정렬 → 연도 section → {date} : {fact_id} - {action 앞 20자} ({credibility})
"""
with_date = [f for f in fact_list if f.get("date")]
no_date = [f for f in fact_list if not f.get("date")]
sorted_facts = sorted(with_date, key=lambda x: x["date"])
lines = ["timeline", " title 사실원장 타임라인"]
for year, group in groupby(sorted_facts, key=lambda f: extract_year(f["date"])):
lines.append(f" section {year}")
for fact in group:
date = fact["date"]
fid = fact.get("fact_id", "?")
action = sanitize_for_timeline(truncate(fact.get("action", ""), 20))
cred = fact.get("credibility", "unknown")
lines.append(f" {date} : {fid} - {action} ({cred})")
if no_date:
lines.append(" section 일자미상")
for fact in no_date:
fid = fact.get("fact_id", "?")
action = sanitize_for_timeline(truncate(fact.get("action", ""), 20))
cred = fact.get("credibility", "unknown")
lines.append(f" 미상 : {fid} - {action} ({cred})")
return "\n".join(lines)
def build_evidence_table(evidence_list: list) -> str:
"""
Section 5: 증거 매핑표 — HTML <tr> 행 생성
필드명 호환: document_type / doc_type, key_info / key_facts
"""
rows = []
for ev in evidence_list:
idx = html.escape(ev.get("evidence_index", "?"))
doc_type = html.escape(
ev.get("document_type") or ev.get("doc_type", "?")
)
title = html.escape(ev.get("title", "?"))
key = html.escape(
ev.get("key_info") or ev.get("key_facts", "?")
)
rows.append(
f" <tr>\n"
f" <td>{idx}</td>\n"
f" <td>{doc_type}</td>\n"
f" <td>{title}</td>\n"
f" <td>{key}</td>\n"
f" </tr>"
)
return "\n".join(rows)
# ─────────────────────────────────────────────
# 3. HTML 템플릿
# ─────────────────────────────────────────────
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>사건 시각화 대시보드</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {{
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
background: #f5f5f5;
color: #333;
}}
h1 {{
text-align: center;
color: #1a365d;
border-bottom: 3px solid #2563eb;
padding-bottom: 15px;
}}
h2 {{
color: #1e40af;
margin-top: 50px;
padding: 10px 15px;
background: #dbeafe;
border-left: 5px solid #2563eb;
}}
.section {{
background: white;
padding: 25px;
margin: 20px 0;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.mermaid {{
display: flex;
justify-content: center;
margin: 20px 0;
}}
table {{
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}}
th, td {{
border: 1px solid #e2e8f0;
padding: 12px;
text-align: left;
}}
th {{
background: #1e40af;
color: white;
}}
tr:nth-child(even) {{
background: #f8fafc;
}}
.constraint-box {{
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
}}
.goal-box {{
background: #ecfdf5;
border: 1px solid #a7f3d0;
border-radius: 8px;
padding: 15px;
margin: 15px 0;
}}
</style>
</head>
<body>
<h1>&#9878; 사건 시각화 대시보드</h1>
<!-- Section 1: 사건 개요 -->
<h2>1. 사건 개요</h2>
<div class="section">
<div class="goal-box">
<strong>의뢰 목표 (Primary Goal):</strong>
<p>{primary_goal}</p>
</div>
<div class="constraint-box">
<strong>&#9888;&#65039; 제약 사항 (Constraints):</strong>
<p>{constraints_text}</p>
</div>
</div>
<!-- Section 2: 행위 타임라인 -->
<h2>2. 행위 타임라인 (Behavioral Objects)</h2>
<div class="section">
<div class="mermaid">
{bo_timeline}
</div>
</div>
<!-- Section 3: 인과관계도 -->
<h2>3. 인과관계도 (Causality Flow)</h2>
<div class="section">
<div class="mermaid">
{causality_flowchart}
</div>
</div>
<!-- Section 4: 사실원장 타임라인 -->
<h2>4. 사실원장 타임라인 (Fact Ledger)</h2>
<div class="section">
<div class="mermaid">
{fact_timeline}
</div>
</div>
<!-- Section 5: 증거 매핑표 -->
<h2>5. 증거 매핑표 (Evidence Index)</h2>
<div class="section">
<table>
<thead>
<tr>
<th>증거번호</th>
<th>문서유형</th>
<th>제목</th>
<th>핵심사실</th>
</tr>
</thead>
<tbody>
{evidence_table_rows}
</tbody>
</table>
</div>
<script>
mermaid.initialize({{
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
}});
</script>
</body>
</html>"""
# ─────────────────────────────────────────────
# 4. 메인 실행
# ─────────────────────────────────────────────
def main():
# 인자 처리
if len(sys.argv) < 2:
print("사용법: python generate_dashboard.py <input_dir> [output_dir]")
print(" input_dir : Stage 1 결과 JSON 4개가 위치한 폴더")
print(" output_dir : Case_Dashboard.html 저장 폴더 (생략 시 input_dir)")
sys.exit(1)
input_dir = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) >= 3 else input_dir
# ── Task A: 데이터 로드 ──
print("=" * 50)
print("[Task A] 데이터 로드")
print("=" * 50)
file_map = {
"client_goal.json": "goal_data",
"BO.json": "bo_list",
"Fact_Ledger.json": "fact_list",
"evidence_indexed.json": "evidence_list",
}
missing = []
for fname in file_map:
fpath = os.path.join(input_dir, fname)
if os.path.exists(fpath):
print(f" [OK] {fname}")
else:
print(f" [MISSING] {fname}")
missing.append(fname)
if missing:
print(f"\n ERROR: 필수 파일 {len(missing)}개 누락. 중단합니다.")
sys.exit(1)
goal_data = load_json(os.path.join(input_dir, "client_goal.json"))
bo_list = load_json(os.path.join(input_dir, "BO.json"))
fact_list = load_json(os.path.join(input_dir, "Fact_Ledger.json"))
evidence_list = load_json(os.path.join(input_dir, "evidence_indexed.json"))
print(f"\n 로드 완료: BO {len(bo_list)}건, Fact {len(fact_list)}건, Evidence {len(evidence_list)}건")
print("TASK A COMPLETE\n")
# ── Task B: HTML 생성 ──
print("=" * 50)
print("[Task B] HTML 생성")
print("=" * 50)
primary_goal, constraints_text = build_section1(goal_data)
print(" [OK] Section 1 — 사건 개요")
bo_timeline = build_bo_timeline(bo_list)
print(" [OK] Section 2 — 행위 타임라인")
causality_flowchart = build_causality_flowchart(bo_list)
print(" [OK] Section 3 — 인과관계도")
fact_timeline = build_fact_timeline(fact_list)
print(" [OK] Section 4 — 사실원장 타임라인")
evidence_table_rows = build_evidence_table(evidence_list)
print(" [OK] Section 5 — 증거 매핑표")
# 템플릿 조립
html_content = HTML_TEMPLATE.format(
primary_goal=html.escape(primary_goal),
constraints_text=html.escape(constraints_text),
bo_timeline=bo_timeline,
causality_flowchart=causality_flowchart,
fact_timeline=fact_timeline,
evidence_table_rows=evidence_table_rows,
)
# 저장
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "Case_Dashboard.html")
with open(output_path, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"\n → 저장: {output_path}")
print("TASK B COMPLETE\n")
# 요약
print("=" * 50)
print("최종 체크리스트")
print("=" * 50)
print(f" [v] 입력 파일 4개 로드")
print(f" [v] Section 1: Primary Goal + Constraints {len(goal_data.get('constraints', []))}개")
print(f" [v] Section 2: BO Timeline — {len(bo_list)}개 행위")
print(f" [v] Section 3: Causality Flowchart — {sum(1 for b in bo_list if b.get('PriorAct'))}개 엣지")
print(f" [v] Section 4: Fact Ledger — {len(fact_list)}개 사실")
print(f" [v] Section 5: Evidence Table — {len(evidence_list)}개 증거")
print(f" [v] {output_path} 저장 완료")
if __name__ == "__main__":
main()