Skill: jackdark425/banker-slides-pptx (version 0.9.7)

--- name: banker-slides-pptx description: Turn a banker-memo analysis.md + data-provenance.md into an investment-banker-craft .pptx (real tables, bar/line charts, risk heatmap, scenario grid — NOT paragraph dumps). Step 2 of 2 in the banker pipeline; pair with banker-memo-md for the MD step. --- # Banker Slides PPTX Step 2 of the banker pipeline: **analysis.md + data-provenance.md → slides-outline.md (structured) → .pptx with real banker visual primitives**. ## Pipeline position ``` banker-memo-md (Step 1) → analysis.md + data-provenance.md │ ▼ banker-slides-pptx (THIS SKILL) │ ┌─────────┴─────────┐ ▼ ▼ slides-outline.md (rendered by) (structured YAML build_outline_deck_v2.py blocks per slide) │ ▼ .pptx ``` ## What makes this different from a generic "write some slides" prompt Most LLM-written slide outlines are prose ("Slide 4: Key takeaway is X"). That's useless — the renderer can only emit a paragraph. This skill forces the agent to write **structured layout data** the renderer can parse into real pptxgenjs primitives: - `stat-cards` → `addShape` + big-number `addText` cards - `3y-table` / `peer-table` / `scenario-table` → real `addTable()` with column widths + cell fill - `bar-chart` / `line-chart` → real `addChart()` with X-axis + Y-series data - `risk-heatmap` → 3×3 colored grid with risk items placed by (severity, likelihood) - `callout-box` → full-width gold-bordered box + large-font verdict text The prompt reads `analysis.md` already written by `banker-memo-md`, extracts the numbers, and emits structured outline blocks. The renderer does no LLM work — it's a deterministic layout emitter. ## Outline schema (what the agent writes) Each slide uses a `## Slide N — <Title>` heading followed by `Layout:` + type-specific YAML-style fields. ### `cover` ``` ## Slide 1 — Cover Layout: cover English-title: China Vanke Co., Ltd. Chinese-subtitle: 万科A · 房地产开发 · 投行深度研究 Ticker: 000002.SZ Date: 2026-04 ``` ### `divider` (section divider between parts) ``` ## Slide N — Section Divider Layout: divider Chinese-title: 财务深度诊断 English-subtitle: Financial Deep-Dive ``` ### `stat-cards` (3-4 big-number cards) ``` ## Slide N — Executive Summary Stats Layout: stat-cards Cards: - label: 2024 营收 value: "3,431.76" unit: 亿元 - label: 归母净亏损 value: "-494.78" unit: 亿元 highlight: red - label: ROE 2024 value: "-21.82" unit: "%" highlight: red - label: 资产负债率 value: "73.66" unit: "%" highlight: amber ``` ### `table` (3Y YoY / peer / any matrix) ``` ## Slide N — 3Y Financial Trend Layout: table Headers: ["指标", "2022", "2023", "2024", "YoY 24v23"] Rows: - ["营收（亿元）", "5,038.4", "4,657.4", "3,431.8", "-26.3%"] - ["归母净利（亿元）", "226.2", "67.7", "-494.8", "N/M"] - ["ROE（%）", "9.45", "4.93", "-21.82", "-26.75pp"] - ["毛利率（%）", "19.55", "15.31", "10.17", "-5.14pp"] - ["资产负债率（%）", "76.14", "75.29", "73.66", "-1.63pp"] Note: YoY 计算: 2024 值 - 2023 值 ``` ### `bar-chart` / `line-chart` ``` ## Slide N — Revenue & Profit Trend Layout: bar-chart X-axis: ["2022", "2023", "2024"] Y-series: - name: 营收（亿元） values: [5038, 4657, 3432] color: "C9A84C" - name: 归母净利（亿元） values: [226, 68, -495] color: "D4AF37" Note: 营收 3 年 -32%，净利 2024 转负 ``` ### `risk-heatmap` (3×3 severity × likelihood) ``` ## Slide N — Risk Heatmap Layout: risk-heatmap Risks: - name: 存量减值压力 severity: 高 likelihood: 高 - name: 美元债重组 severity: 高 likelihood: 中 - name: 交付延期 severity: 中 likelihood: 中 - name: 数据口径风险 severity: 中 likelihood: 低 ``` ### `scenario-table` (valuation scenarios) ``` ## Slide N — Valuation Scenarios Layout: scenario-table Scenarios: - name: 悲观 assumption: "PE 10x · EPS -4.48 → N/M; 2025 仍巨亏" target: "2.0 元" upside: "-49%" color: red - name: 基础 assumption: "化债方案落地 + 2025 净利回正" target: "3.0 元" upside: "-24%" color: amber - name: 乐观 assumption: "政府背书 + OLED 转型兑现（类比 BOE）" target: "5.5 元" upside: "+40%" color: green ``` ### `callout-box` (large verdict stamp) ``` ## Slide N — Credit View Layout: callout-box Title: 授信建议 Tags: ["拒绝承做", "Sell", "Target 2-3 元"] Message: ROE -21.82% + 资产负债率 73.66% + 经营现金流持续失血（估算）+ 2025 多个项目公司贷款逾期 → 当前节点无法给予新增授信。深圳市政府化债方案落地前，仅密切关注，不入授信池。 ``` ### `bullets` (fallback, sparingly) ``` ## Slide N — Key Points Layout: bullets Points: - 论点 1 + 数据支撑 (src: ...) - 论点 2 ``` ## Deck composition (required structure) The prompt enforces this structure (12-15 slides typical): 1. **Cover** (1 slide) 2. **Executive Summary** (1 slide, `stat-cards` layout) 3. **Section Divider: Company** (1 slide, `divider`) 4. **Company Profile** (1 slide, `table`) 5. **Section Divider: Industry** (1 slide, `divider`) 6. **Industry Position** (1 slide, `bar-chart` or `table` with peer share) 7. **Section Divider: Financial** (1 slide, `divider`) 8. **3Y Trend** (1 slide, `table`) 9. **Profitability Chart** (1 slide, `bar-chart` or `line-chart`) 10. **Section Divider: Valuation & Risk** (1 slide, `divider`) 11. **Peer Comparison** (1 slide, `table`) 12. **Valuation Scenarios** (1 slide, `scenario-table`) 13. **Risk Heatmap** (1 slide, `risk-heatmap`) 14. **Credit / Investment View** (1 slide, `callout-box`) 15. **Data Sources** (1 slide, `bullets` listing raw-data files) Agent may merge divider slides into adjacent content when narrative is tight, but target stays 13-15. ## Hard constraints 1. **Every number in outline must already be in analysis.md** — the renderer will run `slide_data_audit` against provenance; numbers not in provenance will fail the gate 2. **Use rounded banker notation for stat cards**: "3,431.76 亿元" → display as `"3,432"` + unit `"亿"` (the underlying analysis.md has the exact value; cards round for visual) 3. **Peer comp numbers stay `[EST]`** — the table cells for peer rows should include `[EST]` suffix in value string 4. **Chart Y-series values must be numeric** (not strings), color codes 6-char hex without `#` 5. **Heatmap severity/likelihood must be one of 高/中/低** — renderer maps to 3×3 grid ## Usage ```bash # Pre-flight: analysis.md + data-provenance.md already written by banker-memo-md ls <deliverable>/{analysis.md,data-provenance.md} # Build + dispatch prompt python3 scripts/build_pptx_prompt.py <deliverable> <ts_code> <name_cn> \ <name_en> > /tmp/prompt.md openclaw agent --agent main --thinking high --json --timeout 600 \ --message "$(cat /tmp/prompt.md)" # Agent writes slides-outline.md in the deliverable dir # Render the deck python3 scripts/build_outline_deck_v2.py <deliverable> <ts_code> <name_cn> <name_en> # Validate python3 <cn-ci-scripts>/sync_provenance.py <deliverable> python3 <cn-ci-scripts>/validate-delivery.py --strict-mcp <deliverable> ``` ## Quality checklist - [ ] slides-outline.md has 12-15 slides - [ ] Contains ≥1 each of: `stat-cards`, `table`, `bar-chart` or `line-chart`, `risk-heatmap`, `scenario-table`, `callout-box` - [ ] NOT all `bullets` (that's the fallback layout — using it for > 2 slides means the prompt failed) - [ ] All numbers traceable to provenance (slide_data_audit PASS) - [ ] Compile passes cn_typo_scan - [ ] Rendered .pptx has actual charts (not placeholders) when outline specifies `bar-chart` / `line-chart`

path: references/banker_slides_prompt.md
content: # Banker Slides PPTX — Canonical Prompt Template

Placeholders: `{ts_code}` / `{name_cn}` / `{name_en}` / `{deliverable_dir}` / `{analysis_md}` / `{provenance_md}`

---

## 身份

你是投行 PPT 设计专家（presentation designer），专攻**投行风格的深度研报 deck**。当前任务：读取已经写好的 `{analysis_md}` + `{provenance_md}`，为 **{name_cn}（{ts_code}）** 设计 **12-15 张 banker-grade PPT 蓝图**，以结构化 YAML-like block 的形式写入 `{deliverable_dir}/slides-outline.md`。

**你不写 analysis 内容——那已经写好了。你的任务是设计 visual layout，让渲染器（build_outline_deck_v2.py）能输出真·投行 PPT（带 chart / table / risk heatmap），不是段落堆 slides。**

## 可用 layout 类型（严格按下面的格式写）

### `cover`
```
## Slide N — Cover
Layout: cover
English-title: <英文全称>
Chinese-subtitle: <中文短名 · 行业 · 投行深度研究>
Ticker: <000002.SZ>
Date: 2026-04
```

### `divider`（section 分隔页）
```
## Slide N — Section Divider
Layout: divider
Chinese-title: <section 大标题>
English-subtitle: <English subtitle>
```

### `stat-cards`（Executive Summary 的关键数字卡片，3-4 个）
```
## Slide N — <Title>
Layout: stat-cards
Cards:
- label: <指标中文>
  value: "<数字字符串，大数加千分号>"
  unit: <单位>
  highlight: green|amber|red|none   # 可选，默认 none
- label: ...
```

### `table`（真实投行表，headers + rows）
```
## Slide N — <Title>
Layout: table
Headers: ["指标", "2022", "2023", "2024", "YoY"]
Rows:
- ["营收", "5,038", "4,657", "3,432", "-26.3%"]
- ["归母净利", "226", "68", "-495", "N/M"]
Note: <可选，表下方一句话注释>
```

### `bar-chart`（柱状图，真实 pptxgenjs addChart）
```
## Slide N — <Title>
Layout: bar-chart
X-axis: ["2022", "2023", "2024"]
Y-series:
- name: <系列名>
  values: [5038, 4657, 3432]
  color: "C9A84C"   # 6-char hex 无 #
- name: <系列名>
  values: [226, 68, -495]
  color: "D4AF37"
Note: <可选一句话 takeaway>
```

### `line-chart`（折线图，季度趋势必备）
```
## Slide N — <Title>
Layout: line-chart
X-axis: ["22Q1", "22Q2", "22Q3", "22Q4", "23Q1", ..., "24Q4"]
Y-series:
- name: ROE（%）
  values: [3.0, 4.6, ..., -21.8]
  color: "C9A84C"
Note: ROE 从 2023Q1 +0.18% 断崖至 2024Q4 −21.82%
```

### `risk-heatmap`（风险矩阵，严重程度×概率）
```
## Slide N — Risk Heatmap
Layout: risk-heatmap
Risks:
- name: <风险简称>
  severity: 高|中|低
  likelihood: 高|中|低
- name: ...
```
渲染器会把每个风险按 (severity, likelihood) 自动放到 3×3 网格对应格子里并上色。

### `scenario-table`（估值悲观/基础/乐观）
```
## Slide N — Valuation Scenarios
Layout: scenario-table
Scenarios:
- name: 悲观
  assumption: "<假设，20-40 字>"
  target: "<目标价+单位>"
  upside: "<-X% / +X%>"
  color: red
- name: 基础
  assumption: "..."
  target: "..."
  upside: "..."
  color: amber
- name: 乐观
  assumption: "..."
  target: "..."
  upside: "..."
  color: green
```

### `callout-box`（授信/投资评级大字号收尾）
```
## Slide N — <Credit View / Investment View>
Layout: callout-box
Title: <授信建议 / 投资评级>
Tags: ["<标签 1>", "<标签 2>", "<标签 3>"]   # 例 ["拒绝承做", "Sell", "Target 2-3元"]
Message: <完整 80-150 字 justified paragraph，说清 credit/invest 结论 + 核心理由>
```

### `bullets`（兜底，谨慎使用）
```
## Slide N — <Title>
Layout: bullets
Points:
- 论点 1（带 src 溯源）
- 论点 2
```

## 必须的 deck 结构（12-15 张）

| 序号 | Slide 功能 | Layout |
|------|-----------|--------|
| 1 | Cover | cover |
| 2 | Executive Summary 关键数字 | stat-cards |
| 3 | Section Divider: Company | divider |
| 4 | Company Profile 工商核验 | table |
| 5 | Section Divider: Industry | divider |
| 6 | Industry Position 同业份额 | bar-chart 或 table |
| 7 | Section Divider: Financial | divider |
| 8 | 3Y Financial Trend | table |
| 9 | Profitability Chart | bar-chart 或 line-chart |
| 10 | Section Divider: Valuation & Risk | divider |
| 11 | Peer Comparison | table |
| 12 | Valuation Scenarios | scenario-table |
| 13 | Risk Heatmap | risk-heatmap |
| 14 | Credit / Investment View | callout-box |
| 15 | Data Sources | bullets（仅此一张 bullets） |

如果 narrative 紧凑可合并分隔页（divider）到相邻内容页，但必须**至少用到**：`stat-cards` × 1、`table` × 2、`bar-chart` 或 `line-chart` × 1、`risk-heatmap` × 1、`scenario-table` × 1、`callout-box` × 1。**不允许 > 2 张 bullets**——那说明你在偷懒写段落堆。

## 硬约束

1. **所有数字必须已经在 `{analysis_md}` 或 `{provenance_md}` 里出现过**。slide_data_audit gate 会校验。如果 outline 里出现了分析文档中没有的数字，会被直接拦截。
2. **Peer comp 数字保留 `[EST]` 后缀**（在 table 单元格字符串里写 `"~18x [EST]"`）。
3. **Chart values 必须是 JSON 数组数字**（不要字符串），color 是 6-char hex 无 `#`。
4. **Heatmap severity/likelihood 只能是 高/中/低** —— 渲染器把这映射到 3×3 网格。
5. **所有 Chinese 文本用 UTF-8 literal 写**（不要 `\uXXXX` escape）。
6. **Cover Title 遵守 Rule 3**：English 是 hero（大字 44pt），Chinese 是 subtitle（≤28pt）。

## 输出

在 `{deliverable_dir}/slides-outline.md` 写完整 12-15 张幅，严格使用上面的 YAML-like layout block 格式。每张幅之间空一行。

全部写完后**仅**告诉我 slides-outline.md 的绝对路径 + 总幅数 + 各 layout 类型用了几次，不要把 outline 内容贴回来。

你的 outline 质量直接决定 PPT 是**投行级 deck** 还是段落堆。请务必按结构化 YAML block 格式写，不要自由发挥成散文。


path: scripts/build_outline_deck_v2.py
content: #!/usr/bin/env python3
"""build_outline_deck_v2.py — render a banker PPT from a structured
slides-outline.md (YAML-style blocks per slide).

v2 vs v1: v1 emitted generic "title + text block" slides for every layout.
v2 emits real pptxgenjs primitives per layout type:

  stat-cards      → 2/4 gold-bordered cards with big numbers + colour highlight
  table           → real addTable() with header row + banded body
  bar-chart       → real addChart(pres.charts.BAR) with X-axis + Y-series
  line-chart      → real addChart(pres.charts.LINE) with quarterly trend
  risk-heatmap    → 3×3 matrix with colour-coded cells and risk labels placed
  scenario-table  → 3-col scenario grid with red/amber/green tinted cells
  callout-box     → full-width gold-bordered verdict box
  divider         → big Chinese + English subtitle section divider
  cover           → Rule-3 compliant cover (English 44pt hero + Chinese ≤28pt)
  bullets         → title + bullet list (fallback)

Outline block format: each slide starts with `## Slide N — Title` then has
`Layout: <type>` and type-specific YAML-like fields. Parser is YAML-ish but
hand-written (no PyYAML dep) to keep the skill light.
"""
from __future__ import annotations
import json, pathlib, re, subprocess, sys


SLIDE_HEADER = re.compile(r"^##\s*Slide\s+(\d+)\s*[—\-–]\s*(.+)$", re.MULTILINE)
LIST_ITEM = re.compile(r"^-\s+(.+)$")


# ============================================================================
# Outline parser — parse YAML-like blocks without a YAML library
# ============================================================================

def parse_value(raw: str):
    """Parse a scalar value: JSON array / string (quoted or unquoted) / number."""
    raw = raw.strip()
    if not raw:
        return None
    # JSON array or object
    if raw.startswith("[") or raw.startswith("{"):
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return raw
    # Quoted string
    if (raw.startswith('"') and raw.endswith('"')) or \
       (raw.startswith("'") and raw.endswith("'")):
        return raw[1:-1]
    # Try number
    try:
        if "." in raw:
            return float(raw)
        return int(raw)
    except ValueError:
        pass
    return raw


def parse_slide_block(body: str) -> dict:
    """Parse one slide's body (everything after `## Slide N — Title`).

    Supports:
      Key: value                 # scalar
      Key: [a, b, c]             # JSON array
      Cards:                     # nested list of dicts
      - label: foo
        value: "1"
      Y-series:                  # nested list of dicts with numeric arrays
      - name: 营收
        values: [1,2,3]
        color: "C9A84C"
    """
    result: dict = {}
    lines = body.splitlines()
    i = 0
    while i < len(lines):
        line = lines[i]
        stripped = line.rstrip()
        if not stripped or stripped.startswith("<!--"):
            i += 1
            continue
        # Top-level key
        m = re.match(r"^([A-Za-z][\w\- ]*?)\s*:\s*(.*)$", stripped)
        if m:
            key = m.group(1).strip()
            val_raw = m.group(2).rstrip()
            if val_raw:
                # Scalar on same line
                result[key] = parse_value(val_raw)
                i += 1
                continue
            # Multi-line nested list
            nested: list = []
            i += 1
            while i < len(lines):
                nxt = lines[i]
                if not nxt.strip():
                    i += 1
                    continue
                # End of this list when we hit a new top-level key
                if re.match(r"^([A-Za-z][\w\- ]*?)\s*:\s*", nxt) and \
                        not nxt.lstrip().startswith("-") and \
                        not nxt.startswith(" "):
                    break
                m_item = re.match(r"^-\s+(.*)$", nxt)
                if m_item:
                    item_dict: dict = {}
                    rest = m_item.group(1)
                    if rest.strip():
                        # Parse "key: value" starting the item
                        m_kv = re.match(r"^([A-Za-z][\w\- ]*?)\s*:\s*(.+)$", rest)
                        if m_kv:
                            item_dict[m_kv.group(1).strip()] = \
                                parse_value(m_kv.group(2))
                        else:
                            item_dict["_scalar"] = parse_value(rest)
                    i += 1
                    # Continuation lines indented under this item
                    while i < len(lines):
                        cont = lines[i]
                        if cont.startswith("  ") and not cont.lstrip().startswith("-"):
                            m_kv = re.match(r"^\s+([A-Za-z][\w\- ]*?)\s*:\s*(.+)$",
                                            cont)
                            if m_kv:
                                item_dict[m_kv.group(1).strip()] = \
                                    parse_value(m_kv.group(2))
                                i += 1
                                continue
                        break
                    nested.append(item_dict)
                    continue
                break
            result[key] = nested
            continue
        i += 1
    return result


def parse_outline(outline_md: str) -> list[dict]:
    """Return ordered list of parsed slide dicts."""
    slides = []
    parts = re.split(r"(?=^##\s*Slide\s+\d+\s*[—\-–])",
                     outline_md, flags=re.MULTILINE)
    for part in parts:
        m = SLIDE_HEADER.match(part)
        if not m:
            continue
        num = int(m.group(1))
        heading = m.group(2).strip()
        body = part[m.end():]
        parsed = parse_slide_block(body)
        parsed["_num"] = num
        parsed["_heading"] = heading
        parsed["_layout"] = str(parsed.get("Layout", "bullets")).strip().lower()
        slides.append(parsed)
    slides.sort(key=lambda s: s["_num"])
    return slides


# ============================================================================
# pptxgenjs code emission — one function per layout type
# ============================================================================

THEME = {
    "bg": "0D1829",
    "primary": "0D1829",
    "secondary": "C9A84C",
    "accent": "D4AF37",
    "light": "2A3A5C",
    "white": "FFFFFF",
    "grey": "AAAAAA",
    "red": "C0392B",
    "amber": "E67E22",
    "green": "27AE60",
}


def js_str(s) -> str:
    """JSON-dump for safe embedding in JS source."""
    return json.dumps("" if s is None else str(s), ensure_ascii=False)


def head_bar() -> str:
    """Top gold accent bar present on every non-cover slide."""
    return f"""  slide.addShape(pres.ShapeType.rect, {{
    x: 0, y: 0, w: 10, h: 0.08,
    fill: {{ color: '{THEME['secondary']}' }}, line: {{ color: '{THEME['secondary']}', width: 0 }},
  }});
"""


def slide_title(title: str) -> str:
    return f"""  slide.addText({js_str(title)}, {{
    x: 0.5, y: 0.25, w: 9, h: 0.65,
    fontSize: 24, bold: true, color: '{THEME['secondary']}',
    fontFace: 'Microsoft YaHei',
  }});
"""


def render_cover(s: dict) -> str:
    en = s.get("English-title") or s.get("english-title") or s.get("_heading")
    cn = s.get("Chinese-subtitle") or s.get("chinese-subtitle") or ""
    ticker = s.get("Ticker") or s.get("ticker") or ""
    date = s.get("Date") or s.get("date") or "2026-04"
    return f"""  // Rule 3: English hero 44pt + Chinese subtitle ≤28pt
  slide.addText({js_str(en)}, {{
    x: 0.7, y: 1.7, w: 8.5, h: 1.2,
    fontSize: 44, bold: true, color: '{THEME['secondary']}', fontFace: 'Arial',
  }});
  slide.addText({js_str(cn)}, {{
    x: 0.7, y: 2.9, w: 8.5, h: 0.8,
    fontSize: 24, color: '{THEME['white']}', fontFace: 'Microsoft YaHei',
  }});
  slide.addText({js_str(ticker)}, {{
    x: 0.7, y: 3.7, w: 8.5, h: 0.5,
    fontSize: 18, color: '{THEME['secondary']}', fontFace: 'Arial',
  }});
  slide.addText({js_str('投行深度研究 · ' + str(date))}, {{
    x: 0.7, y: 4.4, w: 8.5, h: 0.4,
    fontSize: 14, color: '{THEME['grey']}', fontFace: 'Arial',
  }});
  slide.addShape(pres.ShapeType.rect, {{
    x: 0, y: 5.4, w: 10, h: 0.06,
    fill: {{ color: '{THEME['secondary']}' }}, line: {{ color: '{THEME['secondary']}', width: 0 }},
  }});
"""


def render_divider(s: dict) -> str:
    ct = s.get("Chinese-title") or s.get("chinese-title") or s.get("_heading")
    es = s.get("English-subtitle") or s.get("english-subtitle") or ""
    return f"""  slide.addShape(pres.ShapeType.rect, {{
    x: 0, y: 2.3, w: 10, h: 1.5,
    fill: {{ color: '{THEME['light']}' }}, line: {{ color: '{THEME['light']}', width: 0 }},
  }});
  slide.addText({js_str(ct)}, {{
    x: 0.7, y: 2.45, w: 8.5, h: 0.9,
    fontSize: 36, bold: true, color: '{THEME['secondary']}', fontFace: 'Microsoft YaHei',
  }});
  slide.addText({js_str(es)}, {{
    x: 0.7, y: 3.25, w: 8.5, h: 0.5,
    fontSize: 14, italic: true, color: '{THEME['grey']}', fontFace: 'Arial',
  }});
"""


def render_stat_cards(s: dict) -> str:
    cards = s.get("Cards") or s.get("cards") or []
    n = len(cards)
    if n == 0:
        return render_bullets({**s, "Points": ["(no cards provided)"]})
    # 1-3 cards in one row, 4 cards in a 2x2 grid
    card_w = min(2.9, 9.0 / max(1, n))
    if n == 4:
        card_w = 4.5
    out = [slide_title(s["_heading"])]
    for idx, c in enumerate(cards):
        if n == 4:
            x = 0.5 + (idx % 2) * (card_w + 0.1)
            y = 1.2 + (idx // 2) * 1.9
            w, h = card_w, 1.7
        else:
            x = 0.5 + idx * (card_w + 0.1)
            y = 1.5
            w, h = card_w, 2.8
        label = c.get("label", "")
        value = c.get("value", "")
        unit = c.get("unit", "")
        highlight = (c.get("highlight") or "none").lower()
        color_map = {"red": THEME["red"], "amber": THEME["amber"],
                     "green": THEME["green"], "none": THEME["white"]}
        val_color = color_map.get(highlight, THEME["white"])
        out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: {x}, y: {y}, w: {w}, h: {h},
    fill: {{ color: '{THEME['light']}' }},
    line: {{ color: '{THEME['secondary']}', width: 1 }},
  }});
  slide.addText({js_str(label)}, {{
    x: {x + 0.15}, y: {y + 0.15}, w: {w - 0.3}, h: 0.5,
    fontSize: 14, color: '{THEME['secondary']}', fontFace: 'Microsoft YaHei',
  }});
  slide.addText({js_str(value)}, {{
    x: {x + 0.15}, y: {y + 0.6}, w: {w - 0.3}, h: {h - 1.1},
    fontSize: 36, bold: true, color: '{val_color}', fontFace: 'Arial',
  }});
  slide.addText({js_str(unit)}, {{
    x: {x + 0.15}, y: {y + h - 0.5}, w: {w - 0.3}, h: 0.4,
    fontSize: 14, color: '{THEME['grey']}', fontFace: 'Microsoft YaHei',
  }});
""")
    return "".join(out)


def render_table(s: dict) -> str:
    headers = s.get("Headers") or s.get("headers") or []
    rows = s.get("Rows") or s.get("rows") or []
    note = s.get("Note") or s.get("note") or ""
    if not headers or not rows:
        return slide_title(s["_heading"]) + \
               "  slide.addText('(table data missing)', {x: 0.5, y: 1.2, w: 9, h: 4, fontSize: 12, color: '#FFFFFF'});"
    # Build rows array as JS
    js_rows = []
    # Header row with gold fill + dark text
    hdr_cells = [
        f"{{text: {js_str(h)}, options: {{bold: true, color: '{THEME['primary']}', fill: '{THEME['secondary']}', align: 'center'}}}}"
        for h in headers
    ]
    js_rows.append(f"[{', '.join(hdr_cells)}]")
    for row in rows:
        if not isinstance(row, list):
            row = [row]
        cells = []
        for i, cell in enumerate(row):
            align = "'left'" if i == 0 else "'right'"
            color = THEME["white"]
            # Highlight negative numbers red, green for positive gains
            if isinstance(cell, str) and (cell.startswith("-") or "N/M" in cell):
                color = THEME["red"]
            cells.append(
                f"{{text: {js_str(cell)}, options: {{color: '{color}', align: {align}}}}}"
            )
        js_rows.append(f"[{', '.join(cells)}]")
    rows_js = ",\n    ".join(js_rows)
    col_count = len(headers)
    # Column widths: first col wider, others equal
    first_w = 2.5 if col_count >= 3 else 3.0
    rest_w = round((9.0 - first_w) / max(1, col_count - 1), 2)
    col_w = [first_w] + [rest_w] * (col_count - 1)
    out = [slide_title(s["_heading"])]
    out.append(f"""  slide.addTable([
    {rows_js}
  ], {{
    x: 0.5, y: 1.15, w: 9, h: {min(3.8, 0.4 * (len(rows) + 1))},
    fontSize: 12, fontFace: 'Microsoft YaHei',
    border: {{ type: 'solid', pt: 0.5, color: '{THEME['light']}' }},
    fill: {{ color: '{THEME['primary']}' }},
    colW: {json.dumps(col_w)},
  }});
""")
    if note:
        out.append(f"""  slide.addText({js_str('注: ' + str(note))}, {{
    x: 0.5, y: 5.15, w: 9, h: 0.35,
    fontSize: 11, italic: true, color: '{THEME['grey']}', fontFace: 'Microsoft YaHei',
  }});
""")
    return "".join(out)


def _chart(s: dict, chart_kind: str) -> str:
    x_axis = s.get("X-axis") or s.get("x-axis") or []
    series = s.get("Y-series") or s.get("y-series") or []
    note = s.get("Note") or s.get("note") or ""
    if not x_axis or not series:
        return slide_title(s["_heading"]) + "  slide.addText('(chart data missing)', {x: 0.5, y: 1.2, w: 9, h: 4, fontSize: 12, color: '#FFFFFF'});"
    # Build chart data array
    data_entries = []
    chart_colors = []
    for sr in series:
        name = sr.get("name", "")
        values = sr.get("values", [])
        color = sr.get("color", THEME["secondary"]).lstrip("#")
        chart_colors.append(f"'{color}'")
        values_js = json.dumps(values)
        data_entries.append(
            f"{{name: {js_str(name)}, labels: {json.dumps(x_axis)}, values: {values_js}}}"
        )
    data_js = ",\n    ".join(data_entries)
    chart_type_js = "pres.charts.BAR" if chart_kind == "bar" else "pres.charts.LINE"
    out = [slide_title(s["_heading"])]
    out.append(f"""  slide.addChart({chart_type_js}, [
    {data_js}
  ], {{
    x: 0.5, y: 1.15, w: 9, h: 3.9,
    chartColors: [{', '.join(chart_colors)}],
    catAxisLabelColor: '{THEME['white']}',
    catAxisLabelFontFace: 'Arial',
    valAxisLabelColor: '{THEME['white']}',
    valAxisLabelFontFace: 'Arial',
    showLegend: true,
    legendPos: 'b',
    legendColor: '{THEME['white']}',
    legendFontFace: 'Microsoft YaHei',
    plotArea: {{ fill: {{ color: '{THEME['primary']}' }} }},
  }});
""")
    if note:
        out.append(f"""  slide.addText({js_str('Takeaway: ' + str(note))}, {{
    x: 0.5, y: 5.1, w: 9, h: 0.4,
    fontSize: 11, italic: true, color: '{THEME['grey']}', fontFace: 'Microsoft YaHei',
  }});
""")
    return "".join(out)


def render_bar_chart(s: dict) -> str:
    return _chart(s, "bar")


def render_line_chart(s: dict) -> str:
    return _chart(s, "line")


def render_risk_heatmap(s: dict) -> str:
    risks = s.get("Risks") or s.get("risks") or []
    # 3x3 grid: likelihood on X, severity on Y; top-right (高, 高) = red, bottom-left (低, 低) = green
    sev_map = {"高": 2, "中": 1, "低": 0}
    lik_map = {"高": 2, "中": 1, "低": 0}
    # Color grid (bottom-left green, top-right red)
    grid_colors = [
        [THEME["green"], THEME["amber"], THEME["red"]],
        [THEME["green"], THEME["amber"], THEME["red"]],
        [THEME["amber"], THEME["red"], THEME["red"]],
    ]
    # Cells indexed [sev_row][lik_col]
    cells: dict = {}  # (sev, lik) -> list of risk names
    for r in risks:
        sev = sev_map.get(str(r.get("severity", "")).strip(), 1)
        lik = lik_map.get(str(r.get("likelihood", "")).strip(), 1)
        cells.setdefault((sev, lik), []).append(r.get("name", "?"))
    out = [slide_title(s["_heading"])]
    # Grid dimensions
    grid_x0, grid_y0 = 1.5, 1.3
    cell_w, cell_h = 2.5, 1.2
    # Y axis label (severity)
    out.append(f"""  slide.addText('严重程度 →', {{
    x: 0.15, y: 2.6, w: 1.3, h: 0.4, fontSize: 11, color: '{THEME['grey']}',
    fontFace: 'Microsoft YaHei', rotate: -90,
  }});
  slide.addText('发生概率 →', {{
    x: {grid_x0}, y: {grid_y0 + 3 * cell_h + 0.1}, w: 3 * {cell_w}, h: 0.35,
    fontSize: 11, color: '{THEME['grey']}', fontFace: 'Microsoft YaHei', align: 'center',
  }});
""")
    severity_labels = ["高", "中", "低"]  # top → bottom
    likelihood_labels = ["低", "中", "高"]  # left → right
    for row_idx in range(3):  # sev axis (top is 高=2)
        for col_idx in range(3):  # lik axis (right is 高=2)
            sev_rank = 2 - row_idx
            lik_rank = col_idx
            x = grid_x0 + col_idx * cell_w
            y = grid_y0 + row_idx * cell_h
            color = grid_colors[sev_rank][lik_rank]
            risk_names = cells.get((sev_rank, lik_rank), [])
            # Cell background
            out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: {x}, y: {y}, w: {cell_w}, h: {cell_h},
    fill: {{ color: '{color}', transparency: 40 }},
    line: {{ color: '{THEME['secondary']}', width: 0.5 }},
  }});
""")
            if risk_names:
                txt = "\n".join("• " + n for n in risk_names)
                out.append(f"""  slide.addText({js_str(txt)}, {{
    x: {x + 0.1}, y: {y + 0.1}, w: {cell_w - 0.2}, h: {cell_h - 0.2},
    fontSize: 10, color: '{THEME['white']}', fontFace: 'Microsoft YaHei', valign: 'top',
  }});
""")
    # Axis tick labels
    for i, lab in enumerate(severity_labels):
        out.append(f"""  slide.addText({js_str(lab)}, {{
    x: {grid_x0 - 0.6}, y: {grid_y0 + i * cell_h + cell_h / 2 - 0.15}, w: 0.5, h: 0.3,
    fontSize: 11, color: '{THEME['secondary']}', fontFace: 'Microsoft YaHei', align: 'right',
  }});
""")
    for i, lab in enumerate(likelihood_labels):
        out.append(f"""  slide.addText({js_str(lab)}, {{
    x: {grid_x0 + i * cell_w}, y: {grid_y0 - 0.35}, w: {cell_w}, h: 0.3,
    fontSize: 11, color: '{THEME['secondary']}', fontFace: 'Microsoft YaHei', align: 'center',
  }});
""")
    return "".join(out)


def render_scenario_table(s: dict) -> str:
    scenarios = s.get("Scenarios") or s.get("scenarios") or []
    out = [slide_title(s["_heading"])]
    col_w = 3.0
    row_y = 1.3
    # Header row
    for idx, name in enumerate(["情景", "核心假设", "目标价", "空间"]):
        out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: {0.5 + idx * 2.25}, y: {row_y}, w: 2.25, h: 0.5,
    fill: {{ color: '{THEME['secondary']}' }}, line: {{ width: 0 }},
  }});
  slide.addText({js_str(name)}, {{
    x: {0.5 + idx * 2.25}, y: {row_y}, w: 2.25, h: 0.5,
    fontSize: 14, bold: true, color: '{THEME['primary']}',
    fontFace: 'Microsoft YaHei', align: 'center', valign: 'middle',
  }});
""")
    # Data rows with colored left indicator
    color_map = {"red": THEME["red"], "amber": THEME["amber"], "green": THEME["green"]}
    for s_idx, sc in enumerate(scenarios):
        ry = row_y + 0.6 + s_idx * 1.0
        color = color_map.get(str(sc.get("color", "amber")).lower(), THEME["amber"])
        cells_vals = [
            sc.get("name", ""),
            sc.get("assumption", ""),
            sc.get("target", ""),
            sc.get("upside", ""),
        ]
        for idx, val in enumerate(cells_vals):
            x = 0.5 + idx * 2.25
            out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: {x}, y: {ry}, w: 2.25, h: 0.9,
    fill: {{ color: '{THEME['light']}' }}, line: {{ color: '{THEME['secondary']}', width: 0.3 }},
  }});
""")
            if idx == 0:  # Scenario name column gets colored bar
                out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: {x}, y: {ry}, w: 0.12, h: 0.9, fill: {{ color: '{color}' }}, line: {{ width: 0 }},
  }});
""")
            align = "'left'" if idx == 1 else "'center'"
            font_size = 11 if idx == 1 else 14
            bold = "false" if idx == 1 else "true"
            out.append(f"""  slide.addText({js_str(val)}, {{
    x: {x + 0.2}, y: {ry + 0.1}, w: 2.05, h: 0.7,
    fontSize: {font_size}, bold: {bold}, color: '{THEME['white']}',
    fontFace: 'Microsoft YaHei', align: {align}, valign: 'middle',
  }});
""")
    return "".join(out)


def render_callout_box(s: dict) -> str:
    title = s.get("Title") or s.get("title") or s["_heading"]
    tags = s.get("Tags") or s.get("tags") or []
    message = s.get("Message") or s.get("message") or ""
    out = [slide_title(s["_heading"])]
    # Large gold-bordered box
    out.append(f"""  slide.addShape(pres.ShapeType.rect, {{
    x: 0.5, y: 1.2, w: 9, h: 4,
    fill: {{ color: '{THEME['light']}' }},
    line: {{ color: '{THEME['secondary']}', width: 2 }},
  }});
  slide.addText({js_str(title)}, {{
    x: 0.8, y: 1.4, w: 8.4, h: 0.6,
    fontSize: 22, bold: true, color: '{THEME['secondary']}',
    fontFace: 'Microsoft YaHei',
  }});
""")
    # Tag badges
    if tags:
        for t_idx, tag in enumerate(tags):
            tx = 0.8 + t_idx * 2.3
            out.append(f"""  slide.addShape(pres.ShapeType.roundRect, {{
    x: {tx}, y: 2.1, w: 2.1, h: 0.5, rectRadius: 0.1,
    fill: {{ color: '{THEME['secondary']}' }}, line: {{ width: 0 }},
  }});
  slide.addText({js_str(tag)}, {{
    x: {tx}, y: 2.1, w: 2.1, h: 0.5,
    fontSize: 13, bold: true, color: '{THEME['primary']}',
    fontFace: 'Microsoft YaHei', align: 'center', valign: 'middle',
  }});
""")
    out.append(f"""  slide.addText({js_str(message)}, {{
    x: 0.8, y: 2.8, w: 8.4, h: 2.3,
    fontSize: 14, color: '{THEME['white']}', fontFace: 'Microsoft YaHei',
    valign: 'top', lineSpacingMultiple: 1.3,
  }});
""")
    return "".join(out)


def render_bullets(s: dict) -> str:
    points = s.get("Points") or s.get("points") or []
    out = [slide_title(s["_heading"])]
    text = "\n".join("• " + str(p) for p in points)
    out.append(f"""  slide.addText({js_str(text)}, {{
    x: 0.8, y: 1.2, w: 8.4, h: 4,
    fontSize: 14, color: '{THEME['white']}', fontFace: 'Microsoft YaHei',
    valign: 'top', lineSpacingMultiple: 1.4,
  }});
""")
    return "".join(out)


RENDERERS = {
    "cover": render_cover,
    "divider": render_divider,
    "stat-cards": render_stat_cards,
    "table": render_table,
    "bar-chart": render_bar_chart,
    "line-chart": render_line_chart,
    "risk-heatmap": render_risk_heatmap,
    "scenario-table": render_scenario_table,
    "callout-box": render_callout_box,
    "bullets": render_bullets,
}


def emit_slide_file(s: dict) -> str:
    layout = s["_layout"]
    renderer = RENDERERS.get(layout, render_bullets)
    body = renderer(s)
    # Cover gets its own background; everything else gets the standard chrome
    if layout == "cover":
        chrome = f"  slide.background = {{ color: '{THEME['bg']}' }};\n"
    else:
        chrome = f"  slide.background = {{ color: '{THEME['bg']}' }};\n" + head_bar()
    heading = s.get("_heading", "")
    return f"""// slide-{s['_num']:02d}.js — {layout} — {heading[:40]}
const createSlide = (pres, theme) => {{
  const slide = pres.addSlide();
{chrome}{body}
}};

const slideConfig = {{ notes: "", title: {js_str(heading)} }};

module.exports = {{ createSlide, slideConfig }};
"""


# ============================================================================
# Main: parse outline, write slides/, compile
# ============================================================================

def main() -> int:
    if len(sys.argv) < 5:
        sys.exit("usage: build_outline_deck_v2.py <dir> <ts_code> <name_cn> <name_en>")
    d = pathlib.Path(sys.argv[1]).resolve()
    ts_code, name_cn, name_en = sys.argv[2], sys.argv[3], sys.argv[4]

    outline_path = d / "slides-outline.md"
    if not outline_path.exists():
        sys.exit(f"{outline_path} missing — run banker-slides-pptx first")
    slides = parse_outline(outline_path.read_text(encoding="utf-8"))
    if not slides:
        sys.exit("no slide blocks parsed")
    layout_counts: dict = {}
    for sl in slides:
        layout_counts[sl["_layout"]] = layout_counts.get(sl["_layout"], 0) + 1
    print(f"parsed {len(slides)} slides; layouts: {layout_counts}")

    slides_dir = d / "slides"
    slides_dir.mkdir(parents=True, exist_ok=True)
    for old in slides_dir.glob("slide-*.js"):
        old.unlink()
    for s in slides:
        (slides_dir / f"slide-{s['_num']:02d}.js").write_text(
            emit_slide_file(s), encoding="utf-8")

    template_path = (pathlib.Path.home() /
                     ".openclaw/extensions/aigroup-financial-services-openclaw"
                     "/skills/cn-client-investigation/references"
                     "/compile_with_typo_gate.template.js.txt")
    if not template_path.exists():
        template_path = (pathlib.Path(__file__).resolve().parents[3] /
                         "cn-client-investigation/references"
                         "/compile_with_typo_gate.template.js.txt")
    template = template_path.read_text(encoding="utf-8")
    output_pptx = f"{ts_code.replace('.', '_').lower()}_banker_deck.pptx"
    template = (template
        .replace("const SLIDE_COUNT = 20;", f"const SLIDE_COUNT = {len(slides)};")
        .replace("'presentation.pptx'", f"'{output_pptx}'")
        .replace("'2B2D42'", f"'{THEME['primary']}'")
        .replace("'8D99AE'", f"'{THEME['secondary']}'")
        .replace("'EF233C'", f"'{THEME['accent']}'")
        .replace("'EDF2F4'", f"'{THEME['light']}'")
        .replace("'FFFFFF'", f"'{THEME['bg']}'"))
    (slides_dir / "compile.js").write_text(template, encoding="utf-8")
    (slides_dir / "package.json").write_text(json.dumps({
        "name": f"{ts_code.lower().replace('.', '-')}-banker-deck-v2",
        "version": "0.0.1", "private": True,
        "dependencies": {"pptxgenjs": "^3.12.0"},
    }, indent=2))

    if not (slides_dir / "node_modules" / "pptxgenjs").exists():
        r = subprocess.run(["npm", "install", "--omit=dev", "--silent"],
                           cwd=str(slides_dir), capture_output=True,
                           text=True, timeout=180)
        if r.returncode != 0:
            sys.exit(f"npm install failed: {r.stderr[:500]}")

    r = subprocess.run(["node", "compile.js"], cwd=str(slides_dir),
                       capture_output=True, text=True, timeout=300)
    sys.stdout.write(r.stdout)
    sys.stderr.write(r.stderr)
    if r.returncode != 0:
        sys.exit(f"compile failed rc={r.returncode}")
    pptx = d / output_pptx
    print(f"OK: {pptx} ({pptx.stat().st_size} bytes, {len(slides)} slides; "
          f"{layout_counts})")
    return 0


if __name__ == "__main__":
    sys.exit(main())


path: scripts/build_pptx_prompt.py
content: #!/usr/bin/env python3
"""Parameterise the banker-slides-pptx prompt for a company whose analysis.md
is already written by the banker-memo-md skill.

Usage:
    python3 build_pptx_prompt.py <deliverable_dir> <ts_code> <name_cn> <name_en>
"""
from __future__ import annotations
import pathlib, sys


def main() -> int:
    if len(sys.argv) != 5:
        sys.exit("usage: build_pptx_prompt.py <deliverable> <ts_code> <name_cn> <name_en>")
    d, ts_code, name_cn, name_en = sys.argv[1:5]
    dp = pathlib.Path(d).resolve()
    analysis_md = dp / "analysis.md"
    provenance_md = dp / "data-provenance.md"
    if not analysis_md.exists() or not provenance_md.exists():
        sys.exit(f"pre-flight failed: need both {analysis_md} and {provenance_md} "
                 "(run banker-memo-md first)")

    tmpl = (pathlib.Path(__file__).resolve().parent.parent /
            "references" / "banker_slides_prompt.md").read_text(encoding="utf-8")

    out = (tmpl
        .replace("{ts_code}", ts_code)
        .replace("{name_cn}", name_cn)
        .replace("{name_en}", name_en)
        .replace("{deliverable_dir}", str(dp))
        .replace("{analysis_md}", str(analysis_md))
        .replace("{provenance_md}", str(provenance_md)))

    # Include the actual analysis.md + provenance.md content inline so the
    # agent can extract the numbers without needing filesystem reads during
    # its turn. This keeps the prompt self-contained.
    print(out)
    print("\n---\n## Inline `analysis.md` content (已写好，供你提取数字)\n")
    print("```markdown")
    print(analysis_md.read_text(encoding="utf-8"))
    print("```\n")
    print("## Inline `data-provenance.md` content\n")
    print("```markdown")
    print(provenance_md.read_text(encoding="utf-8"))
    print("```\n")
    return 0


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)