Files
Liti-agent-Development/generate_dashboard_v3.py

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">&#9660;</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">&#9660;</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>&#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="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()