#!/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 [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 = '[증거공백]' if gap else "" items_html.append( f'
' f'
{date}
' f'
{bo_id}
' f'
{performer}
' f'
{action}
' f'{gap_badge}' f'
' ) # 행(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'
\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'
▼
') 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'
' f'
{date}
' f'
{fid}
' f'
{action}
' f'
{cred_label}
' 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'
▼
') return "\n".join(rows_html) def build_evidence_table(evidence_list: list) -> str: """Section 5: 증거 매핑표 — HTML 행 생성""" 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 템플릿 (v1 — serpentine 타임라인) # ───────────────────────────────────────────── HTML_TEMPLATE = """ 사건 시각화 대시보드

⚖ 사건 시각화 대시보드

1. 사건 개요

의뢰 목표 (Primary Goal):

{primary_goal}

⚠️ 제약 사항 (Constraints):

{constraints_text}

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

{bo_timeline}

3. 인과관계도 (Causality Flow)

{causality_flowchart}

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

{fact_timeline}

5. 증거 매핑표 (Evidence Index)

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