Skill: songnb1021/ai-pm-resume-hub (version 1.0.0)

--- name: ai-pm-resume-hub description: 用于维护 AI 产品经理校招简历中枢。当用户要提炼工作日志、去重沉淀简历要点、维护要点池、生成或更新中文一页纸简历、输出 gap 清单时使用。适合触发词包括“提炼本周日志”“更新简历”“生成一页简历”“整理简历要点池”。 --- # /ai-pm-resume-hub — AI 产品经理校招简历中枢 维护固定目录下的日志、要点池和简历输出，让用户可以用短指令反复执行同一套流程。 ## 固定工作区 - 默认根目录：`career/ai-pm-campus/` - 如果目录不存在，先创建以下结构： - [REDACTED_SECRET] - [REDACTED_SECRET] - [REDACTED_SECRET] - [REDACTED_SECRET] - `career/ai-pm-campus/outputs/` - 必备文件： - `career/ai-pm-campus/inputs/worklog/_processed-index.md` - `career/ai-pm-campus/inputs/points-pool/master-points.md` - `career/ai-pm-campus/inputs/points-pool/label-registry.md` - `career/ai-pm-campus/outputs/resume-outline.md` - `career/ai-pm-campus/outputs/resume-onepage.md` ## 触发方式 - `/ai-pm-resume-hub extract` - `/ai-pm-resume-hub build` - `/ai-pm-resume-hub visualize` - `/ai-pm-resume-hub export` 如果用户只说“提炼本周日志”“整理简历要点池”，默认走 `extract`。 如果用户只说“更新简历”“生成一页简历”“输出校招简历”，默认走 `build`。 如果用户说“可视化”“仪表盘”“dashboard”“预览简历”，默认走 `visualize`。 如果用户说“导出PDF”“生成PDF”“打印简历”，默认走 `export`。 ## 通用约束 - 始终先读已有文件，再写新内容，不要覆盖用户已有材料。 - 输出必须落盘写文件，不能只在聊天中给总结。 - 严禁编造指标；缺失时必须写成 `[待补数据：...]`。 - 项目标签优先复用 `label-registry.md` 中已有写法，避免标签漂移。 - 每次运行结束都在聊天中汇报： - 读取了哪些文件 - 写入了哪些文件 - 新增了多少条要点或更新了哪些简历段落 - 若发现现有文件格式与本 skill 约定不一致，优先兼容，不要粗暴重写整份文件。 ## 1) extract 用于把最近一批原始工作日志转成可复用的简历要点，并追加到要点池。 ### 输入 - 用户直接粘贴的日志文本 - 或 [REDACTED_SECRET] 下尚未处理的 `.md` 文件 ### 执行流程 1. 读取： - `career/ai-pm-campus/inputs/worklog/_processed-index.md` - `career/ai-pm-campus/inputs/points-pool/master-points.md` - `career/ai-pm-campus/inputs/points-pool/label-registry.md` 2. 如果用户是直接粘贴日志： - 先将原始文本保存为 `career/ai-pm-campus/inputs/worklog/manual-YYYY-MM-DD-weekly.md` - 若同名已存在，则追加短后缀避免覆盖 3. 如果用户没有贴文本，则扫描 `inputs/worklog/` 中未出现在 `_processed-index.md` 的 `.md` 文件 4. 对日志做信噪比过滤： - 忽略纯同步会议、简单 bug 修复、例行巡检、没有动作和结果的泛化表述 - 优先保留核心功能迭代、策略优化、数据分析闭环、Prompt/评测迭代、LLM 应用落地、模型效果/成本/延迟优化、跨部门关键推进 5. 对保留内容做 STAR 重构： - 重写成适合简历的短句 - 必须体现“动作 + 结果/影响” - 若结果缺失，显式写 `[待补数据：需补充XX]` 6. 生成或复用项目标签： - 优先复用 `label-registry.md` - 若必须新增，命名优先级： - `[公司名-项目/方向]` - `[校园项目-主题]` - `[课程项目-主题]` 7. 写入 `master-points.md` 时使用以下格式追加： ```markdown ## YYYY-MM-DD ### 要点 N - 项目标签：... - 时间：... - STAR短句：... - 待补数据：... - 来源：... ``` 8. 追加前做去重： - 与 `master-points.md` 中已有条目比较 - 语义高度重复时不重复写入 - 如果只是旧条目缺少来源或待补数据，可补充旧条目而不是新建重复条目 9. 更新 `_processed-index.md`，至少记录： - 原始日志文件名 - 处理日期 - 新增要点数 ### 输出 - 必写文件： - `career/ai-pm-campus/inputs/points-pool/master-points.md` - `career/ai-pm-campus/inputs/worklog/_processed-index.md` - 按需更新： - `career/ai-pm-campus/inputs/points-pool/label-registry.md` - 聊天里补充简短摘要： - 本次新增要点数 - 复用了哪些标签 - 新建了哪些标签 - 最值得优先补的 3 条数据 ## 2) build 用于基于历史简历、经历材料和要点池，生成 AI 产品经理校招方向的简历。 ### 输入 - [REDACTED_SECRET] - [REDACTED_SECRET] - `career/ai-pm-campus/inputs/points-pool/master-points.md` - `career/ai-pm-campus/inputs/points-pool/label-registry.md` ### 执行流程 1. 先从历史简历提取已验证信息： - 教育背景 - 基础技能 - 已有实习/项目名称 - 已经写得足够好的 bullet 2. 再从 `master-points.md` 按“项目标签 + 时间线”归并要点，**优先突出实习经历** 3. 去重与合并： - 删除重复表述 - 保留证据更强、结果更明确、动作更具体的版本 4. 按 AI 产品经理校招方向调优： - 强调 LLM 应用落地 - 强调数据闭环与产品思维 - 强调跨团队推进能力 5. 输出两个文件： - `career/ai-pm-campus/outputs/resume-outline.md`：完整大纲，含Gap分析 - `career/ai-pm-campus/outputs/resume-onepage.md`：精简版，用于生成投递简历 ### 输出 - `career/ai-pm-campus/outputs/resume-outline.md` - 完整 Markdown 大纲 - 包含所有经历要点和Gap分析 - 用于后期检索和调整 - `career/ai-pm-campus/outputs/resume-onepage.md` - 精简版，优先突出实习经历 - 内容精炼，适合生成投递用PDF - 聊天里补充简短摘要： - 当前简历最强的 3 个卖点 - 最需要补强的 3 个缺口 ## 失败处理 - 若 `resume/` 或 `experiences/` 为空：继续生成，但在 `Gap 清单` 中明确提醒缺少原始材料。 - 若 `master-points.md` 不存在：先创建空文件，再提示用户先运行 `extract`。 - 若标签混乱或同一项目有多个别名：主动统一命名，并在聊天中说明归并规则。 - 若输入日志内容过短、几乎没有高价值信息：不要硬凑要点，直接说明“本次无新增高价值要点”并更新处理记录。 ## 使用示例 - `请用 /ai-pm-resume-hub extract 处理我接下来贴的最近 7 天工作日志` - `用 /ai-pm-resume-hub build 更新我的 AI 产品经理校招简历` - `请用 /ai-pm-resume-hub visualize 生成白色背景可视化仪表盘` - `请用 /ai-pm-resume-hub export 导出HR投递版和手机预览版PDF` ## 4) export 导出两种PDF版本简历，满足不同场景需求： ### 输出版本 1. **HR投递版**：A4标准大小，内容精炼，重点突出实习经历，适合正式投递和打印 2. **手机预览版**：竖屏长图格式，适配手机屏幕，字体清晰，用于快速审查简历 ### 执行流程 1. 先运行 `build` 生成最新的简历内容 2. 运行脚本生成两种PDF： ```bash python3 ~/.openclaw/workspace/skills/ai-pm-resume-hub/scripts/export_pdf.py ``` 3. 产出： - `career/ai-pm-campus/outputs/resume-hr.pdf`（A4标准，投递用） - `career/ai-pm-campus/outputs/resume-mobile.pdf`（竖屏长图，手机预览用） ### 依赖与提示 - HR投递版：自动优化排版，优先展示实习经历，控制在1页内 - 手机预览版：基于可视化仪表盘生成，白色背景，竖屏适配，无需放大即可查看 - 两种版本都支持中文显示，字体清晰 ## 3) visualize 生成白色/透明背景的Bento风格可视化仪表盘，用于电脑端预览： - 一页简历（清晰渲染Markdown格式） - 要点池统计（按项目标签聚合展示） - Gap汇总（集中展示所有待补数据） - 简历质量评分（可选） ### 执行流程 1. 读取： - `career/ai-pm-campus/outputs/resume-onepage.md` - `career/ai-pm-campus/inputs/points-pool/master-points.md` 2. 运行脚本生成仪表盘： ```bash python3 ~/.openclaw/workspace/skills/ai-pm-resume-hub/scripts/render_dashboard.py ``` 3. 产出： - `career/ai-pm-campus/outputs/resume-dashboard.html`（白色/透明背景，Bento布局） ### 输出要求 - 设计风格：极简白色/透明背景，无多余装饰元素 - 保持Bento网格布局，内容清晰易读 - 聊天中返回可打开的文件路径，并简述包含模块。

path: scripts/export_pdf.py
content: #!/usr/bin/env python3
"""
AI PM Resume Hub - PDF导出脚本
支持两种输出：
1. HR投递版：A4标准大小，重点突出实习经历
2. 手机预览版：竖屏长图，适配手机屏幕
"""
import os
import sys
from pathlib import Path
from fpdf import FPDF

# 配置路径
WORKSPACE_ROOT = Path.home() / ".openclaw" / "workspace"
RESUME_ROOT = WORKSPACE_ROOT / "career" / "ai-pm-campus"
INPUT_MD = RESUME_ROOT / "outputs" / "resume-onepage.md"
INPUT_HTML = RESUME_ROOT / "outputs" / "resume-dashboard.html"
OUTPUT_HR_PDF = RESUME_ROOT / "outputs" / "resume-hr.pdf"
OUTPUT_MOBILE_PDF = RESUME_ROOT / "outputs" / "resume-mobile.pdf"

def load_resume_content():
    """加载简历Markdown内容"""
    if not INPUT_MD.exists():
        print(f"❌ 找不到简历文件：{INPUT_MD}")
        print("请先运行 /ai-pm-resume-hub build 生成简历")
        sys.exit(1)
    
    with open(INPUT_MD, "r", encoding="utf-8") as f:
        return f.read()

def parse_markdown(md_content):
    """简单解析Markdown内容"""
    lines = md_content.split("\n")
    parsed = []
    
    for line in lines:
        line = line.rstrip()
        if not line:
            continue
            
        if line.startswith("# "):
            parsed.append(("h1", line[2:].strip()))
        elif line.startswith("## "):
            parsed.append(("h2", line[3:].strip()))
        elif line.startswith("### "):
            parsed.append(("h3", line[4:].strip()))
        elif line.startswith("- "):
            parsed.append(("bullet", line[2:].strip()))
        elif line == "---":
            parsed.append(("divider", None))
        else:
            parsed.append(("text", line.strip()))
    
    return parsed

def export_hr_pdf(content):
    """导出HR投递版PDF：A4标准，突出实习经历"""
    print("📄 正在生成HR投递版PDF...")
    
    pdf = FPDF(orientation="P", unit="mm", format="A4")
    pdf.add_page()
    pdf.set_auto_page_break(auto=True, margin=15)
    
    # 配置字体
    pdf.add_font("Songti", "", "/System/Library/Fonts/Supplemental/Songti.ttc")
    pdf.add_font("Songti", "B", "/System/Library/Fonts/Supplemental/Songti.ttc")
    
    # 设置边距
    pdf.set_left_margin(20)
    pdf.set_right_margin(20)
    
    # 渲染内容
    for item_type, text in content:
        if item_type == "h1":
            pdf.set_font("Songti", "B", 22)
            pdf.set_text_color(29, 29, 31)
            pdf.cell(0, 15, txt=text, ln=True, align="C")
            pdf.ln(5)
            
        elif item_type == "h2":
            pdf.set_font("Songti", "B", 16)
            pdf.set_text_color(0, 113, 227)
            pdf.cell(0, 10, txt=text, ln=True, align="L")
            pdf.line(20, pdf.get_y(), 190, pdf.get_y())
            pdf.ln(3)
            
            # 实习经历标题添加特殊标记，突出显示
            if "实习" in text:
                pdf.set_fill_color(240, 248, 255)
                pdf.rect(15, pdf.get_y() - 8, 180, 8, style="F")
            
        elif item_type == "h3":
            pdf.set_font("Songti", "B", 14)
            pdf.set_text_color(29, 29, 31)
            pdf.cell(0, 8, txt=text, ln=True, align="L")
            
        elif item_type == "bullet":
            pdf.set_font("Songti", "", 12)
            pdf.set_text_color(66, 66, 69)
            pdf.cell(10)
            pdf.multi_cell(160, 6, txt="• " + text)
            pdf.ln(1)
            
        elif item_type == "divider":
            pdf.ln(3)
            pdf.line(20, pdf.get_y(), 190, pdf.get_y())
            pdf.ln(5)
            
        elif item_type == "text":
            if "[待补" in text or "待补:" in text:
                pdf.set_text_color(255, 59, 48)
                pdf.set_font("Songti", "", 11)
            else:
                pdf.set_text_color(66, 66, 69)
                pdf.set_font("Songti", "", 12)
            pdf.multi_cell(170, 6, txt=text)
            pdf.ln(2)
    
    # 保存PDF
    pdf.output(OUTPUT_HR_PDF)
    print(f"✅ HR投递版PDF已生成：{OUTPUT_HR_PDF}")

def export_mobile_pdf():
    """导出手机预览版PDF：竖屏长图，基于可视化仪表盘"""
    print("📱 正在生成手机预览版PDF...")
    
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        print("❌ 缺少依赖 playwright，请先安装：pip3 install playwright")
        print("   安装后还需要运行：playwright install chromium")
        return
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page(viewport={"width": 430, "height": 932})  # iPhone 14 Pro尺寸
        
        # 打开本地HTML文件
        html_path = f"file://{INPUT_HTML.resolve()}"
        page.goto(html_path)
        page.wait_for_load_state("networkidle")
        
        # 获取完整页面高度
        page_height = page.evaluate("document.body.scrollHeight")
        page.set_viewport_size({"width": 430, "height": page_height})
        
        # 生成PDF
        page.pdf(
            path=OUTPUT_MOBILE_PDF,
            width="430px",
            height=f"{page_height}px",
            print_background=True,
            margin={"top": "0", "right": "0", "bottom": "0", "left": "0"}
        )
        
        browser.close()
    
    print(f"✅ 手机预览版PDF已生成：{OUTPUT_MOBILE_PDF}")

def main():
    print("🚀 开始导出PDF...")
    
    # 1. 生成HR投递版
    md_content = load_resume_content()
    parsed_content = parse_markdown(md_content)
    export_hr_pdf(parsed_content)
    
    # 2. 生成手机预览版
    if INPUT_HTML.exists():
        export_mobile_pdf()
    else:
        print("⚠️  未找到可视化HTML文件，跳过手机预览版生成")
        print("   请先运行 /ai-pm-resume-hub visualize 生成仪表盘")
    
    # 输出统计
    hr_size = os.path.getsize(OUTPUT_HR_PDF) / 1024 if OUTPUT_HR_PDF.exists() else 0
    mobile_size = os.path.getsize(OUTPUT_MOBILE_PDF) / 1024 if OUTPUT_MOBILE_PDF.exists() else 0
    
    print("\n📊 导出完成：")
    if hr_size > 0:
        print(f"   - HR投递版：{hr_size:.1f} KB")
    if mobile_size > 0:
        print(f"   - 手机预览版：{mobile_size:.1f} KB")
    
    return 0

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


path: scripts/render_dashboard.py
content: #!/usr/bin/env python3
"""
Generate a local HTML dashboard (light/white background) to preview resume artifacts.
No external Python dependencies. The HTML pulls Tailwind + Marked + DOMPurify from CDNs.
"""

from __future__ import annotations

import json
import os
import re
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Tuple


ROOT_REL = Path("career/ai-pm-campus")


@dataclass
class Inputs:
    onepage_md: str
    master_points_md: str
    points_by_label: Dict[str, int]
    gaps: List[str]
    generated_at: str


def _read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8")
    except FileNotFoundError:
        return ""


def _ensure_parent(path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)


def _collect_points_by_label(master_points_md: str) -> Dict[str, int]:
    counts: Dict[str, int] = {}
    for m in re.finditer(r"(?m)^\-\s*项目标签：\s*(.+?)\s*$", master_points_md):
        label = m.group(1).strip()
        if not label:
            continue
        counts[label] = counts.get(label, 0) + 1
    return counts


def _collect_gaps(onepage_md: str, master_points_md: str) -> List[str]:
    gaps: List[str] = []
    gap_re = re.compile(r"\[待补(?:数据)?[：:]\s*(.+?)\]", re.IGNORECASE)
    
    for text in [onepage_md, master_points_md]:
        for m in gap_re.finditer(text):
            gap = m.group(1).strip()
            if gap and gap not in gaps:
                gaps.append(gap)
    
    return gaps


def _load_inputs() -> Inputs:
    workspace = Path(os.getcwd())
    root = workspace / ROOT_REL
    
    onepage_md = _read_text(root / "outputs" / "resume-onepage.md")
    master_points_md = _read_text(root / "inputs" / "points-pool" / "master-points.md")
    
    points_by_label = _collect_points_by_label(master_points_md)
    gaps = _collect_gaps(onepage_md, master_points_md)
    
    return Inputs(
        onepage_md=onepage_md,
        master_points_md=master_points_md,
        points_by_label=points_by_label,
        gaps=gaps,
        generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    )


def _render_html(inputs: Inputs) -> str:
    payload = {
        "onepage_md": inputs.onepage_md,
        "points_by_label": inputs.points_by_label,
        "gaps": inputs.gaps,
        "generated_at": inputs.generated_at,
    }
    payload_json = json.dumps(payload, ensure_ascii=False)
    
    return f"""<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>AI PM Resume Hub — Dashboard</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      tailwind.config = {{
        theme: {{
          extend: {{
            boxShadow: {{
              card: "0 10px 40px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04)",
            }},
            colors: {{
              primary: '#0071e3',
              secondary: '#34c759',
              neutral: '#86868b',
              danger: '#ff3b30',
            }}
          }},
        }},
      }};
    </script>
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
    <style>
      /* Light mode typography */
      .md h1, .md h2, .md h3 {{ color: #1d1d1f; font-weight: 650; letter-spacing: -0.02em; }}
      .md p, .md li {{ color: #424245; line-height: 1.6; }}
      .md code {{ color: #1d1d1f; background: #f5f5f7; padding: .12rem .35rem; border-radius: .5rem; }}
      .md a {{ color: #0071e3; text-decoration: none; }}
      .md a:hover {{ text-decoration: underline; }}
      .md ul {{ list-style: disc; padding-left: 1.25rem; }}
    </style>
  </head>
  <body class="min-h-screen bg-white text-gray-900 font-sans tracking-tight">
    <main class="relative mx-auto max-w-6xl px-5 py-10 md:px-10">
      <header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
        <div>
          <div class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-600">
            <span class="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(0,113,227,.4)]"></span>
            <span>AI PM Resume Hub</span>
          </div>
          <h1 class="mt-3 text-3xl md:text-4xl font-semibold text-gray-900">简历可视化仪表盘</h1>
          <p class="mt-2 text-sm md:text-base text-gray-600">一页简历、要点池统计与 Gap 清单集中预览。</p>
        </div>
        <div class="flex items-center gap-3">
          <button id="toggleRaw" class="rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 shadow-card transition-all duration-300 ease-out hover:bg-gray-50 hover:scale-[1.01]">
            切换 Raw/渲染
          </button>
          <div class="rounded-2xl border border-gray-200 bg-white px-4 py-2 text-sm text-gray-600 shadow-card">
            生成时间：<span id="genAt" class="text-gray-900"></span>
          </div>
        </div>
      </header>

      <section class="mt-8 grid grid-cols-1 gap-5 md:grid-cols-12">
        <!-- One-page -->
        <div class="group md:col-span-7 rounded-3xl border border-gray-200 bg-white shadow-card transition-all duration-300 ease-out hover:shadow-lg">
          <div class="flex items-center justify-between px-6 pt-6">
            <div>
              <h2 class="text-lg font-semibold text-gray-900">一页简历</h2>
              <p class="mt-1 text-sm text-gray-600">来自 resume-onepage.md</p>
            </div>
            <span class="rounded-full border border-primary/20 bg-primary/5 px-3 py-1 text-xs text-primary">Primary</span>
          </div>
          <div class="px-6 pb-6 pt-4">
            <div id="onepage" class="md prose max-w-none prose-headings:tracking-tight"></div>
            <pre id="onepageRaw" class="hidden whitespace-pre-wrap rounded-2xl border border-gray-200 bg-gray-50 p-4 text-xs text-gray-700"></pre>
            <div id="onepageEmpty" class="hidden rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-6 text-gray-600">
              没有检测到一页简历文件内容。请先生成 <code>resume-onepage.md</code>。
            </div>
          </div>
        </div>

        <!-- Right column -->
        <div class="md:col-span-5 grid grid-cols-1 gap-5">
          <!-- Gaps -->
          <div class="rounded-3xl border border-gray-200 bg-white shadow-card transition-all duration-300 ease-out hover:shadow-lg">
            <div class="px-6 pt-6">
              <h2 class="text-lg font-semibold text-gray-900">Gap 清单</h2>
              <p class="mt-1 text-sm text-gray-600">聚合所有 <code>[待补数据：...]</code></p>
            </div>
            <div class="px-6 pb-6 pt-4">
              <ul id="gaps" class="space-y-2"></ul>
              <div id="gapsEmpty" class="hidden rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-6 text-gray-600">
                暂无待补数据标签。
              </div>
            </div>
          </div>

          <!-- Points by label -->
          <div class="rounded-3xl border border-gray-200 bg-white shadow-card transition-all duration-300 ease-out hover:shadow-lg">
            <div class="px-6 pt-6">
              <h2 class="text-lg font-semibold text-gray-900">要点池统计</h2>
              <p class="mt-1 text-sm text-gray-600">按项目标签聚合（master-points.md）</p>
            </div>
            <div class="px-6 pb-6 pt-4">
              <div id="pointsBars" class="space-y-2"></div>
              <div id="pointsEmpty" class="hidden rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-6 text-gray-600">
                暂无要点统计。请先运行 extract 生成 master-points.md。
              </div>
            </div>
          </div>
        </div>
      </section>

      <footer class="mt-8 text-xs text-gray-500">
        提示：这是本地预览页，不会上传任何内容。你可以反复运行 visualize 覆盖生成。
      </footer>
    </main>

    <script id="resumeData" type="application/json">{payload_json}</script>
    <script>
      const data = JSON.parse(document.getElementById('resumeData').textContent);
      document.getElementById('genAt').textContent = data.generated_at || '-';

      const mdToHtml = (md) => {{
        if (!md) return '';
        const raw = marked.parse(md, {{ gfm: true, breaks: false }});
        return DOMPurify.sanitize(raw);
      }};

      const setMdPane = (id, rawId, emptyId, md) => {{
        const pane = document.getElementById(id);
        const rawPane = document.getElementById(rawId);
        const empty = document.getElementById(emptyId);
        if (!md || !md.trim()) {{
          pane.classList.add('hidden');
          rawPane.classList.add('hidden');
          empty.classList.remove('hidden');
          return;
        }}
        empty.classList.add('hidden');
        pane.innerHTML = mdToHtml(md);
        rawPane.textContent = md;
      }};

      // Render gaps
      const renderGaps = () => {{
        const gaps = data.gaps || [];
        const container = document.getElementById('gaps');
        const empty = document.getElementById('gapsEmpty');
        if (!gaps.length) {{
          container.classList.add('hidden');
          empty.classList.remove('hidden');
          return;
        }}
        empty.classList.add('hidden');
        container.innerHTML = gaps.map(gap => `
          <li class="flex items-start gap-2 text-sm">
            <span class="mt-0.5 h-4 w-4 flex-shrink-0 rounded-full bg-danger/20 flex items-center justify-center">
              <span class="h-1.5 w-1.5 rounded-full bg-danger"></span>
            </span>
            <span class="text-gray-700">${{gap}}</span>
          </li>
        `).join('');
      }};

      // Render points bars
      const renderPointsBars = () => {{
        const points = data.points_by_label || {{}};
        const container = document.getElementById('pointsBars');
        const empty = document.getElementById('pointsEmpty');
        const entries = Object.entries(points).sort((a, b) => b[1] - a[1]);
        if (!entries.length) {{
          container.classList.add('hidden');
          empty.classList.remove('hidden');
          return;
        }}
        empty.classList.add('hidden');
        const max = Math.max(...entries.map(([k, v]) => v));
        container.innerHTML = entries.map(([label, count]) => {{
          const pct = Math.round((count / max) * 100);
          return `
            <div class="space-y-1">
              <div class="flex items-center justify-between text-xs">
                <span class="text-gray-700 font-medium">${{label}}</span>
                <span class="text-gray-500">${{count}} 条</span>
              </div>
              <div class="h-2 rounded-full bg-gray-100 overflow-hidden">
                <div class="h-full rounded-full bg-gradient-to-r from-primary to-secondary" style="width: ${{pct}}%"></div>
              </div>
            </div>
          `;
        }}).join('');
      }};

      // Toggle raw/rendered
      let rawMode = false;
      document.getElementById('toggleRaw').addEventListener('click', () => {{
        rawMode = !rawMode;
        const onepage = document.getElementById('onepage');
        const onepageRaw = document.getElementById('onepageRaw');
        if (rawMode) {{
          onepage.classList.add('hidden');
          onepageRaw.classList.remove('hidden');
          document.getElementById('toggleRaw').textContent = '切换到渲染模式';
        }} else {{
          onepage.classList.remove('hidden');
          onepageRaw.classList.add('hidden');
          document.getElementById('toggleRaw').textContent = '切换 Raw/渲染';
        }}
      }});

      // Initial render
      setMdPane('onepage', 'onepageRaw', 'onepageEmpty', data.onepage_md);
      renderGaps();
      renderPointsBars();
    </script>
  </body>
</html>
"""


def main():
    inputs = _load_inputs()
    html = _render_html(inputs)
    out_path = Path(os.getcwd()) / ROOT_REL / "outputs" / "resume-dashboard.html"
    _ensure_parent(out_path)
    out_path.write_text(html, encoding="utf-8")
    print(out_path.resolve())
    return 0


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


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