470 lines
15 KiB
Python
470 lines
15 KiB
Python
#!/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>⚖ 사건 시각화 대시보드</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>⚠️ 제약 사항 (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()
|