#!/usr/bin/env python3 import argparse import json import html import shutil from pathlib import Path from typing import Any MODES = {'academic', 'business', 'pitch'} LEVELS = {'v2', 'v3', 'v4'} LEVEL_NOTES = { 'v2': '基礎交付版:paper/slides/speaker-notes/deck', 'v3': '洞察強化版:v2 + insights + 每張圖逐頁解讀', 'v4': '正式交付版:v3 + 更正式 deck 視覺 + PDF-ready 工作流', } def read_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding='utf-8')) def read_text(path: Path) -> str: return path.read_text(encoding='utf-8') def find_plots(analysis_dir: Path) -> list[Path]: return sorted([p for p in analysis_dir.glob('*.png') if p.is_file()]) def build_key_findings(summary: dict[str, Any]) -> list[str]: findings: list[str] = [] for name, meta in summary.get('columnProfiles', {}).items(): if 'mean' in meta and meta.get('mean') is not None: findings.append(f"欄位「{name}」平均值約為 {meta['mean']:.2f},總和約為 {meta['sum']:.2f}。") elif meta.get('topValues'): top = meta['topValues'][0] findings.append(f"欄位「{name}」最常見值為「{top['value']}」,出現 {top['count']} 次。") if len(findings) >= 6: break if not findings: findings.append('資料已完成初步整理,但尚缺少足夠特徵以自動歸納具體發現。') return findings def build_method_text(summary: dict[str, Any]) -> str: rows = summary.get('rows', 0) cols = summary.get('columns', 0) parsed_dates = summary.get('parsedDateColumns', []) parts = [f"本研究以一份包含 {rows} 筆資料、{cols} 個欄位的資料集作為分析基礎。"] if parsed_dates: parts.append(f"其中已自動辨識日期欄位:{', '.join(parsed_dates)}。") parts.append("分析流程包含欄位剖析、數值摘要、類別分布觀察,以及圖表化初步探索。") return ''.join(parts) def build_limitations(summary: dict[str, Any], mode: str) -> list[str]: base = [ '本版本內容依據自動分析結果生成,仍需依情境補充背景、語境與論證細節。', '目前主要反映描述性分析與初步視覺化結果,尚未自動進行嚴格因果推論或完整驗證。', ] if mode == 'pitch': base[0] = '本版本適合作為提案底稿,但對外簡報前仍需補上商業敘事、案例與風險說明。' elif mode == 'business': base[0] = '本版本可支援內部決策討論,但正式匯報前仍建議補充商務脈絡與對照基準。' elif mode == 'academic': base[0] = '本版本可作為論文或研究報告草稿,但正式提交前仍需補足文獻回顧、研究問題與方法論細節。' if not summary.get('plots'): base.append('本次分析未包含圖表產物,因此視覺化證據仍需後續補充。') return base def classify_plot(name: str) -> str: low = name.lower() if low.startswith('hist_'): return 'histogram' if low.startswith('bar_'): return 'bar' if low.startswith('line_'): return 'line' return 'plot' def interpret_plot(plot: Path, mode: str) -> dict[str, str]: kind = classify_plot(plot.name) base = { 'histogram': { 'title': f'圖表解讀:{plot.name}', 'summary': '這張 histogram 用來觀察數值欄位的分布狀態、集中區域與可能的離群位置。', 'so_what': '若資料分布偏斜或過度集中,後續可考慮分群、分層或補充異常值檢查。', }, 'bar': { 'title': f'圖表解讀:{plot.name}', 'summary': '這張 bar chart 適合比較不同類別或分組之間的量體差異,幫助快速辨識高低落差。', 'so_what': '若類別差異明顯,後續可針對高表現或低表現組別追查原因與策略。', }, 'line': { 'title': f'圖表解讀:{plot.name}', 'summary': '這張 line chart 用於觀察時間序列變化,幫助辨識趨勢、波動與可能轉折點。', 'so_what': '若趨勢持續上升或下降,建議進一步比對外部事件、季節性與干預因素。', }, 'plot': { 'title': f'圖表解讀:{plot.name}', 'summary': '這張圖表提供一個視覺化切面,有助於快速掌握資料重點與分布特徵。', 'so_what': '建議將圖表與主要論點對齊,補上更具體的背景解讀。', }, }[kind] if mode == 'pitch': base['so_what'] = '簡報時應直接說明這張圖支持了哪個主張,以及它如何增加說服力。' elif mode == 'business': base['so_what'] = '建議把這張圖對應到 KPI、風險或下一步行動,方便管理層做判斷。' elif mode == 'academic': base['so_what'] = '建議將這張圖與研究問題、假設或比較基準一起討論,以提升論證完整度。' return base def build_insights(summary: dict[str, Any], plots: list[Path], mode: str) -> list[str]: insights: list[str] = [] numeric = [] categorical = [] for name, meta in summary.get('columnProfiles', {}).items(): if 'mean' in meta and meta.get('mean') is not None: numeric.append((name, meta)) elif meta.get('topValues'): categorical.append((name, meta)) for name, meta in numeric[:3]: insights.append(f"數值欄位「{name}」平均約 {meta['mean']:.2f},範圍約 {meta['min']:.2f} 到 {meta['max']:.2f}。") for name, meta in categorical[:2]: top = meta['topValues'][0] insights.append(f"類別欄位「{name}」目前以「{top['value']}」最常見({top['count']} 次),值得作為第一輪聚焦對象。") if plots: insights.append(f"本次已生成 {len(plots)} 張圖表,可直接支撐逐頁圖表解讀與口頭報告。") if mode == 'pitch': insights.append('對外提案時,建議把最強的一項數據證據前置,讓聽眾先記住價值主張。') elif mode == 'business': insights.append('內部決策簡報時,建議把洞察轉成 KPI、優先順序與負責人。') elif mode == 'academic': insights.append('學術/研究情境下,建議將洞察進一步轉成研究問題、比較架構與後續驗證方向。') return insights def make_insights_md(title: str, mode: str, summary: dict[str, Any], plots: list[Path]) -> str: insights = build_insights(summary, plots, mode) plot_notes = [interpret_plot(p, mode) for p in plots] lines = [f"# {title}|Insights", '', f"- 模式:`{mode}`", ''] lines.append('## 關鍵洞察') lines.extend([f"- {x}" for x in insights]) lines.append('') if plot_notes: lines.append('## 圖表解讀摘要') for note in plot_notes: lines.append(f"### {note['title']}") lines.append(f"- 解讀:{note['summary']}") lines.append(f"- 延伸:{note['so_what']}") lines.append('') return '\n'.join(lines).strip() + '\n' def make_paper(title: str, audience: str, purpose: str, mode: str, level: str, summary: dict[str, Any], report_md: str, plots: list[Path], insights_md: str | None = None) -> str: findings = build_key_findings(summary) method_text = build_method_text(summary) limitations = build_limitations(summary, mode) plot_refs = '\n'.join([f"- `{p.name}`" for p in plots]) or '- 無' findings_md = '\n'.join([f"- {x}" for x in findings]) limitations_md = '\n'.join([f"- {x}" for x in limitations]) if mode == 'academic': sections = f"## 摘要\n\n本文面向{audience},以「{purpose}」為導向,整理目前資料分析結果並形成學術/研究草稿。\n\n## 研究背景與問題意識\n\n本文件根據既有分析產物自動整理,可作為研究報告、論文初稿或研究提案的起點。\n\n## 研究方法\n\n{method_text}\n\n## 研究發現\n\n{findings_md}\n\n## 討論\n\n目前結果可支撐初步描述性討論,後續可進一步補上研究假設、比較對照與方法嚴謹性。\n\n## 限制\n\n{limitations_md}\n\n## 結論\n\n本分析已形成研究性文件的結構基礎,適合進一步擴展為正式研究報告。" elif mode == 'business': sections = f"## 執行摘要\n\n本文面向{audience},目的是支援「{purpose}」的商務溝通與內部決策。\n\n## 商務背景\n\n本文件根據既有分析產物自動整理,適合作為內部簡報、策略討論或管理層報告的第一版。\n\n## 分析方法\n\n{method_text}\n\n## 關鍵洞察\n\n{findings_md}\n\n## 商業意涵\n\n目前資料已足以支撐一輪決策討論,建議進一步對照 KPI、目標值與外部環境。\n\n## 風險與限制\n\n{limitations_md}\n\n## 建議下一步\n\n建議針對最具決策價值的指標建立定期追蹤與後續驗證流程。" else: sections = f"## Pitch Summary\n\n本文面向{audience},用於支援「{purpose}」的提案、募資或說服型簡報。\n\n## Opportunity\n\n本文件根據既有分析產物自動整理,可作為提案 deck 與口頭簡報的第一版底稿。\n\n## Evidence\n\n{method_text}\n\n## Key Takeaways\n\n{findings_md}\n\n## Why It Matters\n\n目前結果已可形成明確敘事雛形,後續可補上市場機會、競品比較與具體行動方案。\n\n## Risks\n\n{limitations_md}\n\n## Ask / Next Step\n\n建議將數據證據、主張與下一步行動整合成對外一致的提案版本。" insight_section = '' if insights_md: insight_section = f"\n## 洞察摘要\n\n{insights_md}\n" return f"# {title}\n\n- 模式:`{mode}`\n- 等級:`{level}` — {LEVEL_NOTES[level]}\n- 對象:{audience}\n- 目的:{purpose}\n\n{sections}\n\n## 圖表與視覺化資產\n\n{plot_refs}{insight_section}\n## 附錄:原始自動分析摘要\n\n{report_md}\n" def make_slides(title: str, audience: str, purpose: str, mode: str, summary: dict[str, Any], plots: list[Path], level: str) -> str: findings = build_key_findings(summary) rows = summary.get('rows', 0) cols = summary.get('columns', 0) if mode == 'academic': slides = [ ('封面', [f'標題:{title}', f'對象:{audience}', f'目的:{purpose}', f'等級:{LEVEL_NOTES[level]}']), ('研究問題', ['定義研究背景與核心問題', '說明本次分析欲回答的主題']), ('資料概況', [f'資料筆數:{rows}', f'欄位數:{cols}', '已完成基本欄位剖析與摘要']), ('方法', ['描述性統計', '類別分布觀察', '視覺化探索']), ('研究發現', findings[:3]), ('討論', ['解釋主要發現的可能意義', '連結研究問題與資料結果']), ('限制', build_limitations(summary, mode)[:2]), ('後續研究', ['補充文獻回顧', '加入比較基準與進階分析']), ('結論', ['本份簡報可作為研究報告或論文簡報的第一版底稿']), ] elif mode == 'business': slides = [ ('封面', [f'標題:{title}', f'對象:{audience}', f'目的:{purpose}', f'等級:{LEVEL_NOTES[level]}']), ('決策問題', ['這份分析要支援什麼決策', '為什麼現在需要處理']), ('資料概況', [f'資料筆數:{rows}', f'欄位數:{cols}', '已完成基本資料盤點']), ('分析方法', ['描述性統計', '類別分布觀察', '視覺化探索']), ('關鍵洞察', findings[:3]), ('商業意涵', ['把數據結果轉成管理層可理解的含義', '指出可能影響的目標或 KPI']), ('風險與限制', build_limitations(summary, mode)[:2]), ('建議行動', ['列出近期可執行事項', '定義需要追蹤的指標']), ('結語', ['本份簡報可作為正式管理簡報的第一版底稿']), ] else: slides = [ ('封面', [f'標題:{title}', f'對象:{audience}', f'目的:{purpose}', f'等級:{LEVEL_NOTES[level]}']), ('痛點 / 機會', ['說明這份分析解決什麼問題', '點出為什麼值得關注']), ('證據基礎', [f'資料筆數:{rows}', f'欄位數:{cols}', '已完成資料摘要與圖表探索']), ('方法', ['描述性統計', '類別觀察', '關鍵圖表整理']), ('核心亮點', findings[:3]), ('為什麼重要', ['連結價值、影響與說服力', '把發現轉成可傳達的敘事']), ('風險', build_limitations(summary, mode)[:2]), ('Next Step / Ask', ['明確提出下一步', '對齊資源、合作或決策需求']), ('結語', ['本份 deck 可作為提案或募資簡報的第一版底稿']), ] parts = [f"# {title}|簡報稿\n\n- 模式:`{mode}`\n- 等級:`{level}` — {LEVEL_NOTES[level]}\n"] slide_no = 1 for heading, bullets in slides: parts.append(f"## Slide {slide_no} — {heading}") parts.extend([f"- {x}" for x in bullets]) parts.append('') slide_no += 1 if level in {'v3', 'v4'} and plots: for plot in plots: note = interpret_plot(plot, mode) parts.append(f"## Slide {slide_no} — {note['title']}") parts.append(f"- 圖檔:{plot.name}") parts.append(f"- 解讀:{note['summary']}") parts.append(f"- 延伸:{note['so_what']}") parts.append('') slide_no += 1 return '\n'.join(parts).strip() + '\n' def make_speaker_notes(title: str, mode: str, summary: dict[str, Any], plots: list[Path], level: str) -> str: findings = build_key_findings(summary) findings_md = '\n'.join([f"- {x}" for x in findings]) opener = { 'academic': '先交代研究背景、研究問題與資料來源,再說明這份內容是研究草稿第一版。', 'business': '先講這份分析支援哪個決策,再交代這份內容的管理價值與時間敏感性。', 'pitch': '先抓住聽眾注意力,說明痛點、機會與這份資料為何值得相信。', }[mode] closer = { 'academic': '結尾時回到研究限制與後續研究方向。', 'business': '結尾時回到建議行動與追蹤機制。', 'pitch': '結尾時回到 ask、資源需求與下一步承諾。', }[mode] parts = [ f"# {title}|Speaker Notes", '', f"- 模式:`{mode}`", f"- 等級:`{level}` — {LEVEL_NOTES[level]}", '', '## 開場', f"- {opener}", '', '## 重點提示', findings_md, '', ] if level in {'v3', 'v4'} and plots: parts.extend(['## 逐圖口頭提示', '']) for plot in plots: note = interpret_plot(plot, mode) parts.append(f"### {plot.name}") parts.append(f"- {note['summary']}") parts.append(f"- {note['so_what']}") parts.append('') parts.extend(['## 收尾建議', f"- {closer}", '- 針對最重要的一張圖,多講一層其背後的意義與行動建議。', '']) return '\n'.join(parts) def make_deck_html(title: str, audience: str, purpose: str, slides_md: str, plots: list[Path], mode: str, level: str) -> str: if level == 'v4': theme = { 'academic': {'primary': '#0f172a', 'accent': '#334155', 'bg': '#eef2ff', 'hero': 'linear-gradient(135deg,#0f172a 0%,#1e293b 55%,#475569 100%)'}, 'business': {'primary': '#0b3b66', 'accent': '#1d4ed8', 'bg': '#eff6ff', 'hero': 'linear-gradient(135deg,#0b3b66 0%,#1d4ed8 60%,#60a5fa 100%)'}, 'pitch': {'primary': '#4c1d95', 'accent': '#7c3aed', 'bg': '#faf5ff', 'hero': 'linear-gradient(135deg,#4c1d95 0%,#7c3aed 60%,#c084fc 100%)'}, }[mode] primary = theme['primary'] accent = theme['accent'] bg = theme['bg'] hero = theme['hero'] plot_map = {p.name: p for p in plots} else: primary = '#1f2937' accent = '#2563eb' bg = '#f6f8fb' hero = None plot_map = {p.name: p for p in plots} slide_blocks = [] current = [] current_title = None for line in slides_md.splitlines(): if line.startswith('## Slide '): if current_title is not None: slide_blocks.append((current_title, current)) current_title = line.replace('## ', '', 1) current = [] elif line.startswith('- 模式:') or line.startswith('- 等級:') or line.startswith('# '): continue else: current.append(line) if current_title is not None: slide_blocks.append((current_title, current)) sections = [] for heading, body in slide_blocks: body_html = [] referenced_plot = None for line in body: line = line.strip() if not line: continue if line.startswith('- 圖檔:'): plot_name = line.replace('- 圖檔:', '', 1).strip() referenced_plot = plot_map.get(plot_name) body_html.append(f"
{html.escape(line)}
") img_html = '' if referenced_plot and level in {'v3', 'v4'}: img_html = f"')) list_html = f"
適用對象:{html.escape(audience)}
目的:{html.escape(purpose)}
" f"對象:{html.escape(audience)}
" f"目的:{html.escape(purpose)}
等級:{html.escape(level)} — {html.escape(LEVEL_NOTES[level])}