832 lines
28 KiB
Python
832 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
generate_dashboard_v3.py — Stage 2 자동 실행 스크립트 (v3)
|
|
|
|
v1 → v3 변경사항:
|
|
- Section 3 인과관계도: Mermaid flowchart → 커스텀 HTML/CSS/JS DAG
|
|
- 본문 폰트사이즈(15px)와 동일한 크기로 모든 노드 표시
|
|
- DAG가 한 화면에 안 들어가면 자동으로 N개 페이지로 분할
|
|
- Mermaid.js 의존성 완전 제거
|
|
|
|
사용법:
|
|
python generate_dashboard_v3.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
|
|
from collections import defaultdict, deque
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 1. 유틸리티
|
|
# ─────────────────────────────────────────────
|
|
|
|
def load_json(filepath: str):
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def truncate(text: str, max_len: int) -> str:
|
|
if not text:
|
|
return ""
|
|
return text[:max_len] + ("…" if len(text) > max_len else "")
|
|
|
|
|
|
def has_evidence_gap(bo: dict) -> bool:
|
|
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 레이아웃"""
|
|
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>'
|
|
)
|
|
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}">'
|
|
f'<div class="tl-arrow-shaft"></div>'
|
|
f'<div class="tl-arrow-head">▼</div></div>'
|
|
)
|
|
return "\n".join(rows_html)
|
|
|
|
|
|
# ── Section 3: 인과관계도 (커스텀 DAG) ──
|
|
|
|
def compute_dag_layers(bo_list: list):
|
|
"""DAG 레이어 계산 (longest-path from roots)"""
|
|
id_to_bo = {bo["id"]: bo for bo in bo_list}
|
|
children_map = defaultdict(list)
|
|
parents_map = defaultdict(list)
|
|
|
|
for bo in bo_list:
|
|
prior = bo.get("PriorAct")
|
|
if prior and prior in id_to_bo:
|
|
children_map[prior].append(bo["id"])
|
|
parents_map[bo["id"]].append(prior)
|
|
|
|
layers = {}
|
|
roots = [bo["id"] for bo in bo_list if bo["id"] not in parents_map]
|
|
if not roots and bo_list:
|
|
roots = [bo_list[0]["id"]]
|
|
|
|
for r in roots:
|
|
layers[r] = 0
|
|
|
|
queue = deque(roots)
|
|
while queue:
|
|
node = queue.popleft()
|
|
for child in children_map.get(node, []):
|
|
new_layer = layers[node] + 1
|
|
if child not in layers or layers[child] < new_layer:
|
|
layers[child] = new_layer
|
|
queue.append(child)
|
|
|
|
max_layer = max(layers.values()) if layers else 0
|
|
for bo in bo_list:
|
|
if bo["id"] not in layers:
|
|
max_layer += 1
|
|
layers[bo["id"]] = max_layer
|
|
|
|
return layers, children_map, parents_map
|
|
|
|
|
|
def build_causality_dag(bo_list: list) -> str:
|
|
"""Section 3: 인과관계도 — HTML/CSS DAG + JS 엣지 렌더링"""
|
|
SPLIT_THRESHOLD = 8 # 이 이하면 분할 안 함
|
|
LAYERS_PER_PAGE = 5 # 분할 시 페이지당 레이어 수
|
|
|
|
id_to_bo = {bo["id"]: bo for bo in bo_list}
|
|
layers, children_map, parents_map = compute_dag_layers(bo_list)
|
|
max_layer = max(layers.values()) if layers else 0
|
|
total_layers = max_layer + 1
|
|
|
|
# 레이어별 노드 그룹핑
|
|
layer_groups = defaultdict(list)
|
|
for node_id, layer_val in layers.items():
|
|
layer_groups[layer_val].append(node_id)
|
|
|
|
# 교차 최소화: 이전 레이어 부모 중앙값 기준 정렬
|
|
if 0 in layer_groups:
|
|
layer_groups[0].sort()
|
|
for li in range(1, max_layer + 1):
|
|
nodes = layer_groups.get(li, [])
|
|
if not nodes:
|
|
continue
|
|
prev_nodes = layer_groups.get(li - 1, [])
|
|
prev_pos = {n: i for i, n in enumerate(prev_nodes)}
|
|
|
|
def _median_parent(nid):
|
|
pars = parents_map.get(nid, [])
|
|
positions = sorted(prev_pos[p] for p in pars if p in prev_pos)
|
|
return positions[len(positions) // 2] if positions else 0
|
|
|
|
layer_groups[li] = sorted(nodes, key=_median_parent)
|
|
|
|
# 전체 엣지 수집
|
|
all_edges = []
|
|
for bo in bo_list:
|
|
prior = bo.get("PriorAct")
|
|
if prior and prior in id_to_bo:
|
|
all_edges.append((prior, bo["id"]))
|
|
|
|
# 페이지 분할 결정
|
|
lpp = total_layers if total_layers <= SPLIT_THRESHOLD else LAYERS_PER_PAGE
|
|
pages = []
|
|
for start in range(0, total_layers, lpp):
|
|
end = min(start + lpp - 1, max_layer)
|
|
pages.append((start, end))
|
|
|
|
# 페이지별 HTML 생성
|
|
pages_html = []
|
|
for page_idx, (start_l, end_l) in enumerate(pages):
|
|
page_node_ids = set()
|
|
for l in range(start_l, end_l + 1):
|
|
for nid in layer_groups.get(l, []):
|
|
page_node_ids.add(nid)
|
|
|
|
page_edges = [
|
|
(s, t) for s, t in all_edges
|
|
if s in page_node_ids and t in page_node_ids
|
|
]
|
|
|
|
# 페이지간 연결 (들어오는/나가는)
|
|
incoming_cross = defaultdict(list)
|
|
outgoing_cross = defaultdict(list)
|
|
for s, t in all_edges:
|
|
if s not in page_node_ids and t in page_node_ids:
|
|
incoming_cross[t].append(s)
|
|
if s in page_node_ids and t not in page_node_ids:
|
|
outgoing_cross[s].append(t)
|
|
|
|
# 레이어 행 생성
|
|
layer_rows = []
|
|
for l in range(start_l, end_l + 1):
|
|
nodes = layer_groups.get(l, [])
|
|
if not nodes:
|
|
continue
|
|
cards = []
|
|
for nid in nodes:
|
|
bo = id_to_bo[nid]
|
|
performer = html.escape(bo.get("Performer", "?"))
|
|
action = html.escape(truncate(bo.get("Action", ""), 20))
|
|
gap = has_evidence_gap(bo)
|
|
gc = " dag-gap" if gap else ""
|
|
gb = '<span class="gap-badge">[증거공백]</span>' if gap else ""
|
|
|
|
inc = ""
|
|
if nid in incoming_cross:
|
|
ids = ", ".join(html.escape(x) for x in incoming_cross[nid])
|
|
inc = f'<div class="dag-cross-label dag-in">\u2191 {ids}</div>'
|
|
|
|
out = ""
|
|
if nid in outgoing_cross:
|
|
ids = ", ".join(html.escape(x) for x in outgoing_cross[nid])
|
|
out = f'<div class="dag-cross-label dag-out">\u2193 {ids}</div>'
|
|
|
|
cards.append(
|
|
f'<div class="dag-node{gc}" data-node-id="{html.escape(nid)}">'
|
|
f'{inc}'
|
|
f'<div class="dag-node-id">{html.escape(nid)}</div>'
|
|
f'<div class="dag-node-body">{performer}: {action}</div>'
|
|
f'{gb}{out}'
|
|
f'</div>'
|
|
)
|
|
layer_rows.append(
|
|
'<div class="dag-layer">\n' + "\n".join(cards) + "\n</div>"
|
|
)
|
|
|
|
edges_json = json.dumps(page_edges, ensure_ascii=False)
|
|
|
|
label = ""
|
|
if len(pages) > 1:
|
|
label = (
|
|
f'<div class="dag-page-label">'
|
|
f'인과관계도 ({page_idx + 1}/{len(pages)})</div>\n'
|
|
)
|
|
|
|
page_html = (
|
|
f'{label}'
|
|
f'<div class="dag-page">\n'
|
|
f'<script type="application/json" class="dag-edges-data">'
|
|
f'{edges_json}</script>\n'
|
|
+ "\n".join(layer_rows)
|
|
+ "\n<svg class=\"dag-svg\"></svg>\n"
|
|
+ "</div>"
|
|
)
|
|
pages_html.append(page_html)
|
|
|
|
info = {
|
|
"total_layers": total_layers,
|
|
"num_pages": len(pages),
|
|
"total_edges": len(all_edges),
|
|
}
|
|
return "\n".join(pages_html), info
|
|
|
|
|
|
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_cls = {"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")
|
|
cc = cred_cls.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 {cc}">{html.escape(cred)}</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}">'
|
|
f'<div class="tl-arrow-shaft"></div>'
|
|
f'<div class="tl-arrow-head">▼</div></div>'
|
|
)
|
|
return "\n".join(rows_html)
|
|
|
|
|
|
def build_evidence_table(evidence_list: list) -> str:
|
|
"""Section 5: 증거 매핑표"""
|
|
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 템플릿 (v3 — 전면 커스텀, Mermaid 제거)
|
|
# ─────────────────────────────────────────────
|
|
|
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>사건 시각화 대시보드 v3</title>
|
|
<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);
|
|
}}
|
|
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 타임라인 (Sections 2, 4) ── */
|
|
.serpentine-timeline {{
|
|
padding: 10px 0;
|
|
}}
|
|
.tl-row {{
|
|
display: flex;
|
|
gap: 12px;
|
|
}}
|
|
.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;
|
|
}}
|
|
.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;
|
|
}}
|
|
|
|
/* ── DAG 인과관계도 (Section 3) ── */
|
|
.dag-page {{
|
|
position: relative;
|
|
padding: 10px 0 20px 0;
|
|
}}
|
|
.dag-page-label {{
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: #6b7280;
|
|
margin-bottom: 6px;
|
|
font-weight: 600;
|
|
}}
|
|
.dag-layer {{
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
margin: 50px 0;
|
|
flex-wrap: wrap;
|
|
position: relative;
|
|
z-index: 1;
|
|
}}
|
|
.dag-layer:first-of-type {{
|
|
margin-top: 10px;
|
|
}}
|
|
.dag-node {{
|
|
border: 2px solid #93c5fd;
|
|
border-radius: 10px;
|
|
padding: 10px 14px;
|
|
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
|
text-align: center;
|
|
font-size: 15px;
|
|
min-width: 110px;
|
|
max-width: 180px;
|
|
position: relative;
|
|
z-index: 1;
|
|
transition: transform 0.15s;
|
|
}}
|
|
.dag-node:hover {{
|
|
transform: translateY(-3px);
|
|
box-shadow: 0 4px 12px rgba(37,99,235,0.2);
|
|
}}
|
|
.dag-gap {{
|
|
border-color: #ef4444;
|
|
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
|
}}
|
|
.dag-node-id {{
|
|
font-weight: 700;
|
|
color: #1e40af;
|
|
font-size: 15px;
|
|
margin-bottom: 3px;
|
|
}}
|
|
.dag-node-body {{
|
|
font-size: 14px;
|
|
color: #1f2937;
|
|
word-break: keep-all;
|
|
}}
|
|
.dag-cross-label {{
|
|
font-size: 11px;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
margin: 2px 0;
|
|
}}
|
|
.dag-in {{
|
|
color: #4338ca;
|
|
background: #e0e7ff;
|
|
}}
|
|
.dag-out {{
|
|
color: #9333ea;
|
|
background: #f3e8ff;
|
|
}}
|
|
.dag-svg {{
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- 숨김 SVG: 화살촉 마커 정의 -->
|
|
<svg style="position:absolute;width:0;height:0;overflow:hidden">
|
|
<defs>
|
|
<marker id="dag-arrow" viewBox="0 0 10 10"
|
|
refX="10" refY="5" markerWidth="8" markerHeight="8"
|
|
orient="auto-start-reverse">
|
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#60a5fa"/>
|
|
</marker>
|
|
</defs>
|
|
</svg>
|
|
|
|
<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="serpentine-timeline">
|
|
{bo_timeline}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section 3 -->
|
|
<h2>3. 인과관계도 (Causality Flow)</h2>
|
|
<div class="section">
|
|
{causality_dag}
|
|
</div>
|
|
|
|
<!-- Section 4 -->
|
|
<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>
|
|
/* ── DAG 엣지(화살표) 렌더링 ── */
|
|
function drawDAGEdges() {{
|
|
var pages = document.querySelectorAll('.dag-page');
|
|
pages.forEach(function(page) {{
|
|
var svg = page.querySelector('.dag-svg');
|
|
if (!svg) return;
|
|
|
|
/* 기존 내용 제거 */
|
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
|
|
/* SVG 크기를 컨테이너에 맞춤 */
|
|
svg.setAttribute('width', page.scrollWidth);
|
|
svg.setAttribute('height', page.scrollHeight);
|
|
|
|
/* 엣지 데이터 읽기 */
|
|
var script = page.querySelector('.dag-edges-data');
|
|
if (!script) return;
|
|
var edges = JSON.parse(script.textContent);
|
|
if (!edges.length) return;
|
|
|
|
var ns = 'http://www.w3.org/2000/svg';
|
|
var pageRect = page.getBoundingClientRect();
|
|
|
|
edges.forEach(function(edge) {{
|
|
var srcEl = page.querySelector('[data-node-id="' + edge[0] + '"]');
|
|
var tgtEl = page.querySelector('[data-node-id="' + edge[1] + '"]');
|
|
if (!srcEl || !tgtEl) return;
|
|
|
|
var sR = srcEl.getBoundingClientRect();
|
|
var tR = tgtEl.getBoundingClientRect();
|
|
|
|
var x1 = sR.left + sR.width / 2 - pageRect.left;
|
|
var y1 = sR.bottom - pageRect.top;
|
|
var x2 = tR.left + tR.width / 2 - pageRect.left;
|
|
var y2 = tR.top - pageRect.top;
|
|
|
|
var midY = (y1 + y2) / 2;
|
|
var path = document.createElementNS(ns, 'path');
|
|
path.setAttribute('d',
|
|
'M ' + x1 + ' ' + y1 +
|
|
' C ' + x1 + ' ' + midY + ', ' +
|
|
x2 + ' ' + midY + ', ' +
|
|
x2 + ' ' + y2);
|
|
path.setAttribute('fill', 'none');
|
|
path.setAttribute('stroke', '#60a5fa');
|
|
path.setAttribute('stroke-width', '2');
|
|
path.setAttribute('marker-end', 'url(#dag-arrow)');
|
|
svg.appendChild(path);
|
|
}});
|
|
}});
|
|
}}
|
|
|
|
/* 로드 완료 후 엣지 그리기 (약간의 지연으로 레이아웃 안정화) */
|
|
window.addEventListener('load', function() {{
|
|
setTimeout(drawDAGEdges, 150);
|
|
}});
|
|
window.addEventListener('resize', function() {{
|
|
drawDAGEdges();
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 4. 메인 실행
|
|
# ─────────────────────────────────────────────
|
|
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("사용법: python generate_dashboard_v3.py <input_dir> [output_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)
|
|
|
|
required = ["client_goal.json", "BO.json", "Fact_Ledger.json", "evidence_indexed.json"]
|
|
missing = []
|
|
for fname in required:
|
|
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 생성 (v3 — 전면 커스텀)")
|
|
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_dag, dag_info = build_causality_dag(bo_list)
|
|
print(
|
|
f" [OK] Section 3 — 인과관계도 (DAG: "
|
|
f"{dag_info['total_layers']}레이어, "
|
|
f"{dag_info['total_edges']}엣지, "
|
|
f"{dag_info['num_pages']}페이지)"
|
|
)
|
|
|
|
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_dag=causality_dag,
|
|
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_v3.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] Section 1: Primary Goal + Constraints {len(goal_data.get('constraints', []))}개")
|
|
print(f" [v] Section 2: BO Serpentine — {len(bo_list)}개 행위 (증거공백 {gap_count}개)")
|
|
print(f" [v] Section 3: Causality DAG — {dag_info['total_layers']}레이어, "
|
|
f"{dag_info['total_edges']}엣지, {dag_info['num_pages']}페이지")
|
|
print(f" [v] Section 4: Fact Serpentine — {len(fact_list)}개 사실")
|
|
print(f" [v] Section 5: Evidence Table — {len(evidence_list)}개 증거")
|
|
print(f" [v] Mermaid.js 의존성 없음 (전면 커스텀)")
|
|
print(f" [v] {output_path} 저장 완료")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|