Files
openclaw-skill/skills/research-to-paper-slides/scripts/generate_bundle.py
Selig f1a6df4ca4 add 6 skills to repo + update skill-review for xiaoming
- Add code-interpreter, kokoro-tts, remotion-best-practices,
  research-to-paper-slides, summarize, tavily-tool to source repo
- skill-review: add main/xiaoming agent mapping in handler.ts + SKILL.md
- tts-voice: handler.ts updates from agent workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:59:43 +08:00

499 lines
27 KiB
Python
Raw 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
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"<li>{html.escape(line[2:])}</li>")
elif line.startswith('- '):
body_html.append(f"<li>{html.escape(line[2:])}</li>")
else:
body_html.append(f"<p>{html.escape(line)}</p>")
img_html = ''
if referenced_plot and level in {'v3', 'v4'}:
img_html = f"<div class='plot-single'><img src='{html.escape(referenced_plot.name)}' alt='{html.escape(referenced_plot.name)}' /><div class='plot-caption'>圖:{html.escape(referenced_plot.name)}</div></div>"
list_items = ''.join(x for x in body_html if x.startswith('<li>'))
paras = ''.join(x for x in body_html if x.startswith('<p>'))
list_html = f"<ul>{list_items}</ul>" if list_items else ''
if level == 'v4':
sections.append(
f"<section class='slide'><div class='slide-top'><div class='eyebrow'>{html.escape(mode.upper())}</div>"
f"<div class='page-tag'>{html.escape(heading.split('')[0])}</div></div><h2>{html.escape(heading)}</h2>{paras}{list_html}{img_html}</section>"
)
else:
sections.append(f"<section class='slide'><h2>{html.escape(heading)}</h2>{paras}{list_html}{img_html}</section>")
if level == 'v4':
css = f"""
@page {{ size: A4 landscape; margin: 0; }}
@media print {{
body {{ background: #fff; padding: 0; }}
.slide {{ box-shadow: none; margin: 0; min-height: 100vh; border-radius: 0; page-break-after: always; page-break-inside: avoid; border-top-width: 16px; border-top-style: solid; border-top-color: {accent}; }}
.hero {{ box-shadow: none; margin: 0; min-height: 100vh; border-radius: 0; }}
}}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans CJK TC', sans-serif; background: {bg}; margin: 0; padding: 32px; color: {primary}; }}
.hero {{ max-width: 1180px; margin: 0 auto 32px; padding: 56px 64px; border-radius: 32px; background: {hero}; color: white; box-shadow: 0 32px 64px rgba(15,23,42,.15); display: flex; flex-direction: column; justify-content: center; min-height: 500px; }}
.hero h1 {{ margin: 12px 0 20px; font-size: 52px; line-height: 1.2; letter-spacing: -0.02em; font-weight: 800; text-wrap: balance; }}
.hero p {{ margin: 8px 0; font-size: 20px; opacity: .9; font-weight: 400; }}
.hero-meta {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-top: 48px; }}
.hero-card {{ background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.2); border-radius: 20px; padding: 20px 24px; backdrop-filter: blur(10px); }}
.hero-card strong {{ display: block; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.8; margin-bottom: 6px; }}
.slide {{ background: #fff; border-radius: 32px; padding: 48px 56px; margin: 0 auto 32px; max-width: 1180px; min-height: 660px; box-shadow: 0 16px 48px rgba(15,23,42,.08); page-break-after: always; border-top: 16px solid {accent}; position: relative; overflow: hidden; display: flex; flex-direction: column; }}
.slide::after {{ content: ''; position: absolute; right: -80px; top: -80px; width: 240px; height: 240px; background: radial-gradient(circle, {bg} 0%, rgba(255,255,255,0) 70%); pointer-events: none; }}
.slide-top {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; z-index: 1; }}
h1, h2 {{ margin-top: 0; font-weight: 700; }}
h2 {{ font-size: 36px; margin-bottom: 24px; color: {primary}; letter-spacing: -0.01em; }}
.slide p {{ font-size: 20px; line-height: 1.6; color: #334155; margin-bottom: 16px; }}
.slide ul {{ line-height: 1.6; font-size: 22px; padding-left: 28px; color: #1e293b; margin-top: 8px; flex-grow: 1; }}
.slide li {{ position: relative; padding-left: 8px; }}
.slide li + li {{ margin-top: 14px; }}
.slide li::marker {{ color: {accent}; font-weight: bold; }}
.eyebrow {{ display: inline-flex; align-items: center; padding: 8px 16px; border-radius: 999px; background: {bg}; color: {accent}; font-weight: 800; font-size: 13px; letter-spacing: .1em; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }}
.page-tag {{ color: #94a3b8; font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }}
.plot-single {{ margin-top: auto; text-align: center; padding-top: 24px; position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; }}
.plot-single img {{ max-width: 100%; max-height: 380px; border: 1px solid #e2e8f0; border-radius: 20px; background: #f8fafc; box-shadow: 0 12px 32px rgba(15,23,42,.06); padding: 8px; }}
.plot-caption {{ margin-top: 14px; font-size: 15px !important; color: #64748b !important; font-style: italic; text-align: center; background: #f1f5f9; padding: 6px 16px; border-radius: 999px; }}
""".strip()
hero_html = (
f"<div class='hero'><div class='eyebrow'>{html.escape(mode.upper())}</div>"
f"<h1>{html.escape(title)}</h1><p>適用對象:{html.escape(audience)}</p><p>目的:{html.escape(purpose)}</p>"
f"<div class='hero-meta'>"
f"<div class='hero-card'><strong>等級</strong><br>{html.escape(level)}{html.escape(LEVEL_NOTES[level])}</div>"
f"<div class='hero-card'><strong>圖表數量</strong><br>{len(plots)}</div>"
f"<div class='hero-card'><strong>輸出定位</strong><br>正式 deck / PDF-ready</div>"
f"</div></div>"
)
else:
css = f"""
body {{ font-family: Arial, 'Noto Sans CJK TC', sans-serif; background: {bg}; margin: 0; padding: 24px; color: {primary}; }}
.hero {{ max-width: 1100px; margin: 0 auto 24px; padding: 8px 6px; }}
.slide {{ background: #fff; border-radius: 18px; padding: 32px; margin: 0 auto 24px; max-width: 1100px; box-shadow: 0 8px 28px rgba(0,0,0,.08); page-break-after: always; border-top: 10px solid {accent}; }}
h1, h2 {{ margin-top: 0; }}
h1 {{ font-size: 40px; }}
ul {{ line-height: 1.7; }}
.plot-single {{ margin-top: 18px; text-align: center; }}
img {{ max-width: 100%; border: 1px solid #ddd; border-radius: 12px; background: #fff; }}
.plot-caption {{ margin-top: 10px; font-size: 14px; color: #6b7280; font-style: italic; }}
""".strip()
hero_html = (
f"<div class='hero'><h1>{html.escape(title)}</h1><p>對象:{html.escape(audience)}</p>"
f"<p>目的:{html.escape(purpose)}</p><p>等級:{html.escape(level)}{html.escape(LEVEL_NOTES[level])}</p></div>"
)
return (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<title>{html.escape(title)}</title><style>{css}</style></head><body>"
+ hero_html
+ ''.join(sections)
+ "</body></html>"
)
def main() -> int:
parser = argparse.ArgumentParser(
description='Generate paper/slides bundle from analysis outputs',
epilog=(
'Levels: '
'v2=基礎交付版paper/slides/speaker-notes/deck '
'v3=洞察強化版v2 + insights + 每張圖逐頁解讀); '
'v4=正式交付版v3 + 更正式 deck 視覺 + PDF-ready 工作流)'
),
)
parser.add_argument('--analysis-dir', required=True)
parser.add_argument('--output-dir', required=True)
parser.add_argument('--title', default='研究分析草稿')
parser.add_argument('--audience', default='決策者')
parser.add_argument('--purpose', default='研究報告')
parser.add_argument('--mode', default='business', choices=sorted(MODES))
parser.add_argument(
'--level',
default='v4',
choices=sorted(LEVELS),
help='輸出等級v2=基礎交付版v3=洞察強化版v4=正式交付版(預設)',
)
args = parser.parse_args()
analysis_dir = Path(args.analysis_dir).expanduser().resolve()
output_dir = Path(args.output_dir).expanduser().resolve()
output_dir.mkdir(parents=True, exist_ok=True)
summary_path = analysis_dir / 'summary.json'
report_path = analysis_dir / 'report.md'
if not summary_path.exists():
raise SystemExit(f'Missing summary.json in {analysis_dir}')
if not report_path.exists():
raise SystemExit(f'Missing report.md in {analysis_dir}')
summary = read_json(summary_path)
report_md = read_text(report_path)
plots = find_plots(analysis_dir)
insights_md = make_insights_md(args.title, args.mode, summary, plots) if args.level in {'v3', 'v4'} else None
paper_md = make_paper(args.title, args.audience, args.purpose, args.mode, args.level, summary, report_md, plots, insights_md)
slides_md = make_slides(args.title, args.audience, args.purpose, args.mode, summary, plots, args.level)
speaker_notes = make_speaker_notes(args.title, args.mode, summary, plots, args.level)
deck_html = make_deck_html(args.title, args.audience, args.purpose, slides_md, plots, args.mode, args.level)
for plot in plots:
dest = output_dir / plot.name
if dest != plot:
shutil.copy2(plot, dest)
(output_dir / 'paper.md').write_text(paper_md, encoding='utf-8')
(output_dir / 'slides.md').write_text(slides_md, encoding='utf-8')
(output_dir / 'speaker-notes.md').write_text(speaker_notes, encoding='utf-8')
(output_dir / 'deck.html').write_text(deck_html, encoding='utf-8')
if insights_md:
(output_dir / 'insights.md').write_text(insights_md, encoding='utf-8')
manifest_outputs = {
'paper': str(output_dir / 'paper.md'),
'slides': str(output_dir / 'slides.md'),
'speakerNotes': str(output_dir / 'speaker-notes.md'),
'deckHtml': str(output_dir / 'deck.html'),
}
if insights_md:
manifest_outputs['insights'] = str(output_dir / 'insights.md')
manifest = {
'title': args.title,
'audience': args.audience,
'purpose': args.purpose,
'mode': args.mode,
'level': args.level,
'levelNote': LEVEL_NOTES[args.level],
'analysisDir': str(analysis_dir),
'outputs': manifest_outputs,
'plots': [str(p) for p in plots],
}
(output_dir / 'bundle.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
print(json.dumps(manifest, ensure_ascii=False, indent=2))
return 0
if __name__ == '__main__':
raise SystemExit(main())