615 lines
20 KiB
Python
615 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
generate_dashboard_v1.py — Stage 2 자동 실행 스크립트 (v1)
|
||
|
||
v0 대비 변경사항:
|
||
- Section 2, 4의 Mermaid timeline → 커스텀 HTML/CSS 뱀 형태(serpentine) 타임라인으로 교체
|
||
- 본문 폰트사이즈와 동일한 크기로 타임라인 항목 표시
|
||
- 항목이 한 줄에 다 안 들어가면 좌→우, 우→좌 교대로 진행하는 boustrophedon 레이아웃
|
||
|
||
사용법:
|
||
python generate_dashboard_v1.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 = {
|
||
'"': "'",
|
||
"#": "",
|
||
"&": "and",
|
||
"<": "<",
|
||
">": ">",
|
||
"{": "(",
|
||
"}": ")",
|
||
}
|
||
for old, new in replacements.items():
|
||
text = text.replace(old, new)
|
||
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 = 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_serpentine(bo_list: list) -> str:
|
||
"""
|
||
Section 2: BO 타임라인 — HTML serpentine 레이아웃 생성
|
||
좌→우, 우→좌 교대 boustrophedon 방식
|
||
"""
|
||
ITEMS_PER_ROW = 4
|
||
|
||
sorted_bos = sorted(
|
||
bo_list,
|
||
key=lambda x: x.get("BehaviorTime") or "9999-99-99"
|
||
)
|
||
|
||
items_html = []
|
||
for bo in sorted_bos:
|
||
date = html.escape(bo.get("BehaviorTime") or "미상")
|
||
bo_id = html.escape(bo.get("id", "?"))
|
||
performer = html.escape(bo.get("Performer", "?"))
|
||
action = html.escape(truncate(bo.get("Action", ""), 30))
|
||
gap = has_evidence_gap(bo)
|
||
gap_class = " gap-item" if gap else ""
|
||
gap_badge = '<span class="gap-badge">[증거공백]</span>' if gap else ""
|
||
|
||
items_html.append(
|
||
f'<div class="tl-item{gap_class}">'
|
||
f'<div class="tl-date">{date}</div>'
|
||
f'<div class="tl-id">{bo_id}</div>'
|
||
f'<div class="tl-body">{performer}</div>'
|
||
f'<div class="tl-action">{action}</div>'
|
||
f'{gap_badge}'
|
||
f'</div>'
|
||
)
|
||
|
||
# 행(row) 단위로 나누고, 홀수 행은 정방향, 짝수 행은 역방향
|
||
rows_html = []
|
||
for row_idx in range(0, len(items_html), ITEMS_PER_ROW):
|
||
chunk = items_html[row_idx:row_idx + ITEMS_PER_ROW]
|
||
is_reverse = (row_idx // ITEMS_PER_ROW) % 2 == 1
|
||
direction_class = "tl-row-reverse" if is_reverse else "tl-row-forward"
|
||
|
||
cells = "\n".join(chunk)
|
||
rows_html.append(f'<div class="tl-row {direction_class}">\n{cells}\n</div>')
|
||
|
||
# 행 사이 연결 화살표 (마지막 행 제외)
|
||
if row_idx + ITEMS_PER_ROW < len(items_html):
|
||
arrow_align = "tl-arrow-right" if not is_reverse else "tl-arrow-left"
|
||
rows_html.append(f'<div class="tl-arrow-down {arrow_align}"><div class="tl-arrow-shaft"></div><div class="tl-arrow-head">▼</div></div>')
|
||
|
||
return "\n".join(rows_html)
|
||
|
||
|
||
def build_causality_flowchart(bo_list: list) -> str:
|
||
"""Section 3: 인과관계도 — Mermaid flowchart TD (유지)"""
|
||
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_serpentine(fact_list: list) -> str:
|
||
"""
|
||
Section 4: 사실원장 타임라인 — HTML serpentine 레이아웃 생성
|
||
"""
|
||
ITEMS_PER_ROW = 4
|
||
|
||
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"]) + no_date
|
||
|
||
cred_class_map = {
|
||
"high": "cred-high",
|
||
"medium": "cred-medium",
|
||
"low": "cred-low",
|
||
}
|
||
|
||
items_html = []
|
||
for fact in sorted_facts:
|
||
date = html.escape(fact.get("date") or "미상")
|
||
fid = html.escape(fact.get("fact_id", "?"))
|
||
action = html.escape(truncate(fact.get("action", ""), 30))
|
||
cred = fact.get("credibility", "unknown")
|
||
cred_label = html.escape(cred)
|
||
cred_cls = cred_class_map.get(cred, "cred-unknown")
|
||
|
||
items_html.append(
|
||
f'<div class="tl-item">'
|
||
f'<div class="tl-date">{date}</div>'
|
||
f'<div class="tl-id">{fid}</div>'
|
||
f'<div class="tl-action">{action}</div>'
|
||
f'<div class="tl-cred {cred_cls}">{cred_label}</div>'
|
||
f'</div>'
|
||
)
|
||
|
||
rows_html = []
|
||
for row_idx in range(0, len(items_html), ITEMS_PER_ROW):
|
||
chunk = items_html[row_idx:row_idx + ITEMS_PER_ROW]
|
||
is_reverse = (row_idx // ITEMS_PER_ROW) % 2 == 1
|
||
direction_class = "tl-row-reverse" if is_reverse else "tl-row-forward"
|
||
|
||
cells = "\n".join(chunk)
|
||
rows_html.append(f'<div class="tl-row {direction_class}">\n{cells}\n</div>')
|
||
|
||
if row_idx + ITEMS_PER_ROW < len(items_html):
|
||
arrow_align = "tl-arrow-right" if not is_reverse else "tl-arrow-left"
|
||
rows_html.append(f'<div class="tl-arrow-down {arrow_align}"><div class="tl-arrow-shaft"></div><div class="tl-arrow-head">▼</div></div>')
|
||
|
||
return "\n".join(rows_html)
|
||
|
||
|
||
def build_evidence_table(evidence_list: list) -> str:
|
||
"""Section 5: 증거 매핑표 — HTML <tr> 행 생성"""
|
||
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 템플릿 (v1 — serpentine 타임라인)
|
||
# ─────────────────────────────────────────────
|
||
|
||
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;
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
}}
|
||
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;
|
||
overflow-x: auto;
|
||
}}
|
||
table {{
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin: 20px 0;
|
||
}}
|
||
th, td {{
|
||
border: 1px solid #e2e8f0;
|
||
padding: 12px;
|
||
text-align: left;
|
||
font-size: 15px;
|
||
}}
|
||
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;
|
||
}}
|
||
|
||
/* ── Serpentine 타임라인 ── */
|
||
.serpentine-timeline {{
|
||
padding: 10px 0;
|
||
}}
|
||
.tl-row {{
|
||
display: flex;
|
||
gap: 12px;
|
||
margin: 0;
|
||
}}
|
||
.tl-row-forward {{
|
||
flex-direction: row;
|
||
}}
|
||
.tl-row-reverse {{
|
||
flex-direction: row-reverse;
|
||
}}
|
||
.tl-item {{
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 2px solid #93c5fd;
|
||
border-radius: 10px;
|
||
padding: 14px 16px;
|
||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||
text-align: center;
|
||
font-size: 15px;
|
||
position: relative;
|
||
transition: transform 0.15s;
|
||
}}
|
||
.tl-item:hover {{
|
||
transform: translateY(-3px);
|
||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2);
|
||
}}
|
||
.tl-date {{
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
margin-bottom: 4px;
|
||
font-weight: 500;
|
||
}}
|
||
.tl-id {{
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #1e40af;
|
||
margin-bottom: 4px;
|
||
}}
|
||
.tl-body {{
|
||
font-size: 15px;
|
||
color: #1f2937;
|
||
margin-bottom: 2px;
|
||
}}
|
||
.tl-action {{
|
||
font-size: 14px;
|
||
color: #4b5563;
|
||
word-break: keep-all;
|
||
}}
|
||
|
||
/* 증거공백 카드 */
|
||
.gap-item {{
|
||
border-color: #ef4444;
|
||
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||
}}
|
||
.gap-badge {{
|
||
display: inline-block;
|
||
margin-top: 6px;
|
||
padding: 2px 8px;
|
||
background: #ef4444;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}}
|
||
|
||
/* credibility 뱃지 */
|
||
.tl-cred {{
|
||
display: inline-block;
|
||
margin-top: 6px;
|
||
padding: 2px 10px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}}
|
||
.cred-high {{
|
||
background: #dcfce7;
|
||
color: #166534;
|
||
}}
|
||
.cred-medium {{
|
||
background: #fef9c3;
|
||
color: #854d0e;
|
||
}}
|
||
.cred-low {{
|
||
background: #fee2e2;
|
||
color: #991b1b;
|
||
}}
|
||
.cred-unknown {{
|
||
background: #f3f4f6;
|
||
color: #6b7280;
|
||
}}
|
||
|
||
/* 행 사이 연결 화살표 */
|
||
.tl-arrow-down {{
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 4px 0;
|
||
}}
|
||
.tl-arrow-right {{
|
||
margin-left: auto;
|
||
margin-right: 60px;
|
||
}}
|
||
.tl-arrow-left {{
|
||
margin-left: 60px;
|
||
margin-right: auto;
|
||
}}
|
||
.tl-arrow-shaft {{
|
||
width: 3px;
|
||
height: 24px;
|
||
background: #93c5fd;
|
||
}}
|
||
.tl-arrow-head {{
|
||
color: #93c5fd;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
margin-top: -4px;
|
||
}}
|
||
</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: 행위 타임라인 (serpentine) -->
|
||
<h2>2. 행위 타임라인 (Behavioral Objects)</h2>
|
||
<div class="section">
|
||
<div class="serpentine-timeline">
|
||
{bo_timeline}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 3: 인과관계도 (Mermaid 유지) -->
|
||
<h2>3. 인과관계도 (Causality Flow)</h2>
|
||
<div class="section">
|
||
<div class="mermaid">
|
||
{causality_flowchart}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 4: 사실원장 타임라인 (serpentine) -->
|
||
<h2>4. 사실원장 타임라인 (Fact Ledger)</h2>
|
||
<div class="section">
|
||
<div class="serpentine-timeline">
|
||
{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_v1.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 생성 (v1 — serpentine timeline)")
|
||
print("=" * 50)
|
||
|
||
primary_goal, constraints_text = build_section1(goal_data)
|
||
print(" [OK] Section 1 — 사건 개요")
|
||
|
||
bo_timeline = build_bo_serpentine(bo_list)
|
||
print(" [OK] Section 2 — 행위 타임라인 (serpentine)")
|
||
|
||
causality_flowchart = build_causality_flowchart(bo_list)
|
||
print(" [OK] Section 3 — 인과관계도 (Mermaid)")
|
||
|
||
fact_timeline = build_fact_serpentine(fact_list)
|
||
print(" [OK] Section 4 — 사실원장 타임라인 (serpentine)")
|
||
|
||
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)
|
||
|
||
gap_count = sum(1 for b in bo_list if has_evidence_gap(b))
|
||
print(f" [v] 입력 파일 4개 로드")
|
||
print(f" [v] Section 1: Primary Goal + Constraints {len(goal_data.get('constraints', []))}개")
|
||
print(f" [v] Section 2: BO Serpentine Timeline — {len(bo_list)}개 행위 (증거공백 {gap_count}개)")
|
||
print(f" [v] Section 3: Causality Flowchart — {sum(1 for b in bo_list if b.get('PriorAct'))}개 엣지")
|
||
print(f" [v] Section 4: Fact Serpentine Timeline — {len(fact_list)}개 사실")
|
||
print(f" [v] Section 5: Evidence Table — {len(evidence_list)}개 증거")
|
||
print(f" [v] {output_path} 저장 완료")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|