Files
Liti-agent-Development/generate_dashboard_v1.py

615 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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">&#9660;</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">&#9660;</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>&#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: 행위 타임라인 (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()