#!/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 [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 = '[증거공백]' if gap else "" items_html.append( f'
' f'
{date}
' f'
{bo_id}
' f'
{performer}
' f'
{action}
' f'{gap_badge}' f'
' ) 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'
\n{cells}\n
') 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'
' f'
' f'
▼
' ) 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 = '[증거공백]' if gap else "" inc = "" if nid in incoming_cross: ids = ", ".join(html.escape(x) for x in incoming_cross[nid]) inc = f'
\u2191 {ids}
' out = "" if nid in outgoing_cross: ids = ", ".join(html.escape(x) for x in outgoing_cross[nid]) out = f'
\u2193 {ids}
' cards.append( f'
' f'{inc}' f'
{html.escape(nid)}
' f'
{performer}: {action}
' f'{gb}{out}' f'
' ) layer_rows.append( '
\n' + "\n".join(cards) + "\n
" ) edges_json = json.dumps(page_edges, ensure_ascii=False) label = "" if len(pages) > 1: label = ( f'
' f'인과관계도 ({page_idx + 1}/{len(pages)})
\n' ) page_html = ( f'{label}' f'
\n' f'\n' + "\n".join(layer_rows) + "\n\n" + "
" ) 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'
' f'
{date}
' f'
{fid}
' f'
{action}
' f'
{html.escape(cred)}
' f'
' ) 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'
\n{cells}\n
') 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'
' f'
' f'
▼
' ) 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" \n" f" {idx}\n" f" {doc_type}\n" f" {title}\n" f" {key}\n" f" " ) return "\n".join(rows) # ───────────────────────────────────────────── # 3. HTML 템플릿 (v3 — 전면 커스텀, Mermaid 제거) # ───────────────────────────────────────────── HTML_TEMPLATE = """ 사건 시각화 대시보드 v3

⚖ 사건 시각화 대시보드

1. 사건 개요

의뢰 목표 (Primary Goal):

{primary_goal}

⚠️ 제약 사항 (Constraints):

{constraints_text}

2. 행위 타임라인 (Behavioral Objects)

{bo_timeline}

3. 인과관계도 (Causality Flow)

{causality_dag}

4. 사실원장 타임라인 (Fact Ledger)

{fact_timeline}

5. 증거 매핑표 (Evidence Index)

{evidence_table_rows}
증거번호 문서유형 제목 핵심사실
""" # ───────────────────────────────────────────── # 4. 메인 실행 # ───────────────────────────────────────────── def main(): if len(sys.argv) < 2: print("사용법: python generate_dashboard_v3.py [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()