Skill: sxffly/resume-jd-matcher (version 2.0.3)

--- name: resume-jd-matcher version: 2.0.3 description: 批量解析简历并与岗位 JD 进行 AI 智能匹配，生成结构化匹配报告（Excel） author: fly authorEmail: [REDACTED_SECRET] license: MIT tags: [hr, resume, jd, matching, excel, ai] --- # Resume-JD-Matcher 批量解析简历并与岗位 JD 进行 AI 智能匹配，生成结构化匹配报告（Excel）。 ## 🎯 功能 - 从 Word (.docx) 文件提取岗位任职要求 - 解析 PDF/Word 格式简历 - AI 智能分析简历与 JD 的匹配度 - 生成 Excel 匹配报告（含分析详情和总体评估表） - 支持增量处理（跳过已处理的简历） ## 🚀 使用方式 ### 方式 1：OpenClaw 消息触发（推荐，Subagent 模式） 在 OpenClaw 中发送消息： ``` 帮我匹配 D:\JL 目录下的简历 ``` 或使用命令： ``` /skill resume-jd-matcher ``` **优势**： - ✅ 无需配置 API - ✅ 自动使用你已配置的大模型 - ✅ 支持批量并发处理（默认 3 个并发） - ✅ 支持自然语言交互 **批量处理流程**： 1. 扫描简历目录（按岗位分类） 2. 为每份简历的每条任职要求创建子 Agent 任务 3. 并发调用多个子 Agent 分析（默认最多 3 个同时运行） 4. 收集所有结果，生成 Excel 报告 **性能参考**： - 单次分析：~6-8 秒 - 10 份简历 × 5 条要求 = 50 个任务 ≈ 2-3 分钟（并发 3） - 50 份简历 × 5 条要求 = 250 个任务 ≈ 10-15 分钟（并发 3） ### 方式 2：独立脚本运行（API 模式） ```bash cd C:\Users\Administrator\.openclaw\workspace python resume_match.py ``` **适用场景**：批量离线处理、定时任务、无 OpenClaw 环境 ## ⚙️ 配置 编辑 `config_resume_match.yaml`： ### 运行模式（必填） ```yaml # "subagent" - OpenClaw 模式（无需 API 配置） # "api" - 独立脚本模式（需要配置 API） mode: "subagent" ``` ### 路径配置（必填） ```yaml paths: jd_folder: "D:\\JD" # JD 文件目录 jl_folder: "D:\\JL" # 简历目录（按岗位分类） output_folder: "D:\\jg" # 输出目录 ``` ### API 配置（仅当 mode="api" 时需要） ```yaml mode: "api" api: active_provider: tencent api_providers: tencent: name: "腾讯 Hunyuan" [REDACTED_SECRET]" api_url: "https://api.hunyuan.tencent.com/v1/chat/completions" model: "hunyuan-t1" ``` ## 📁 目录结构要求 ``` D:\JD\ # 岗位 JD 目录 ├── 投资岗.docx ├── 合规岗.docx └── ... D:\JL\ # 简历目录（按岗位文件夹分类） ├── 投资岗/ │ ├── 张三.pdf │ └── 李四.docx ├── 合规岗/ │ ├── 王五.pdf │ └── ... └── ... D:\jg\ # 输出目录（自动生成） └── AI_Resume_All_20260403_1415.xlsx ``` ## 📊 输出格式 ### Excel 包含两个工作表： **1. 简历匹配结果** | 应聘者名称 | 应聘岗位 | 任职要求 | 匹配度 | AI 分析详情 | 处理时间 | |-----------|---------|---------|-------|-----------|---------| | 张三 | 投资岗 | 3 年以上投资经验... | 完全匹配 | {"分析": "..."} | 2026-04-03 14:15 | **2. 应聘者总体评估表** | 应聘岗位 | 应聘者名称 | 得分 | |---------|-----------|-----| | 投资岗 | 张三 | 95.00 | | 投资岗 | 李四 | 87.50 | ### 匹配等级 - 完全匹配 - 高度匹配 - 匹配 - 部分匹配 - 不匹配 - 无相关信息 ## 🔧 高级配置 ### 批量性能优化 对于大量简历（50+ 份），建议： ```yaml # 子 Agent 模式超时时间（秒） timeout: 60 # 并发限制（未来版本支持） max_concurrent: 3 ``` ### 日志配置 日志自动输出到： - 控制台（实时显示） - `D:\jg\resume_match_YYYYMMDD_HHMM.log` ## 🆚 模式对比 | 特性 | subagent 模式 | api 模式 | |------|-------------|---------| | **配置** | 无需 | 需 API Key | | **执行环境** | OpenClaw | 任意 Python 环境 | | **单次耗时** | ~6 秒 | ~2 秒 | | **批量 50 份** | ~5-10 分钟 | ~2-3 分钟 | | **费用** | OpenClaw 配额 | API 供应商收费 | | **适用场景** | OpenClaw 用户 | 独立脚本/定时任务 | ## 📝 版本历史 - **V2.0.0** (2026-04-03) - 双模式架构重构 - **V1.9.6** (2026-03-20) - 修复 AI 返回语言 - **V1.9.4** (2026-03-16) - 新增多 API 供应商切换 ## 🐛 常见问题 ### Q: subagent 模式报错 "session_spawn not found" A: 确保在 OpenClaw 环境中运行，检查 `sessions_spawn` 工具是否可用。 ### Q: API 模式报错 "No module named 'fitz'" A: 安装依赖：`pip install PyMuPDF python-docx openpyxl pyyaml requests pdfplumber` ### Q: 如何处理扫描件简历？ A: 当前版本仅支持文本型 PDF/Word，扫描件需要 OCR 支持（未来版本）。 ## 📦 依赖 ```txt openpyxl>=3.0.0 requests>=2.28.0 python-docx>=0.8.0 pyyaml>=6.0.0 pdfplumber>=0.11.0 ``` ## 📄 License MIT

path: main.py
content: # -*- coding: utf-8 -*-
"""
Resume-JD-Matcher Skill - OpenClaw 入口处理器

当用户在 OpenClaw 中发送 /skill resume-jd-matcher 时触发
"""

import os
import sys

# 添加工作区路径
sys.path.insert(0, r"C:\Users\Administrator\.openclaw\workspace")

def handle_skill(context):
    """
    Skill 入口函数
    
    Args:
        context: OpenClaw 上下文（包含用户消息、配置等）
    
    Returns:
        str: 回复用户的消息
    """
    from sessions_spawn import sessions_spawn
    from subagents import subagents
    from sessions_history import sessions_history
    
    # 配置路径
    JD_FOLDER = r"C:\ResumeJD\JD"
    JL_FOLDER = r"C:\ResumeJD\JL"
    OUTPUT_FOLDER = r"C:\ResumeJD\JG"
    
    # 动态导入处理函数（避免直接 import 带版本号的模块）
    import importlib.util
    spec = importlib.util.spec_from_file_location("resume_match", r"C:\Users\Administrator\.openclaw\workspace\resume_match_v2.0.2.py")
    resume_match = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(resume_match)
    
    get_jd_contents = resume_match.get_jd_contents
    find_matching_jd = resume_match.find_matching_jd
    extract_text = resume_match.extract_text
    call_subagent_batch = resume_match.call_subagent_batch
    create_excel = resume_match.create_excel
    
    # 1. 读取 JD
    print("[1] 读取 JD 文件...")
    jd_data = get_jd_contents(JD_FOLDER)
    
    # 2. 扫描简历
    print("[2] 扫描简历目录...")
    jl_folders = [f for f in os.listdir(JL_FOLDER) if os.path.isdir(os.path.join(JL_FOLDER, f))]
    
    # 3. 创建任务
    all_tasks = []
    
    for folder_name in jl_folders:
        folder_path = os.path.join(JL_FOLDER, folder_name)
        print(f"[3] 处理：{folder_name}")
        
        jd_requirements = find_matching_jd(folder_name, jd_data)
        if not jd_requirements:
            print(f"  未找到匹配 JD，跳过")
            continue
        
        resume_files = [
            f for f in os.listdir(folder_path)
            if f.endswith(('.pdf', '.docx')) and not f.startswith('~$')
        ]
        
        for resume_file in resume_files:
            resume_path = os.path.join(folder_path, resume_file)
            resume_key = resume_file.replace('.docx', '').replace('.pdf', '').strip()
            resume_content = extract_text(resume_path)
            
            if not resume_content.strip():
                continue
            
            for (position_name, req_list) in jd_requirements:
                for requirement in req_list:
                    all_tasks.append({
                        'resume_name': resume_key,
                        'resume_text': resume_content,
                        'position_name': position_name,
                        'job_requirement': requirement
                    })
    
    print(f"共 {len(all_tasks)} 个分析任务")
    
    # 4. 批量调用子 Agent
    results = call_subagent_batch(all_tasks, max_concurrent=3)
    
    # 5. 格式化结果
    from datetime import datetime
    formatted_results = []
    for r in results:
        formatted_results.append({
            'resume_name': r['task']['resume_name'],
            'position': r['task']['position_name'],
            'requirement': r['task']['job_requirement'],
            'match_level': r['match_level'],
            'ai_result': r['result'],
            'process_time': datetime.now().strftime('%Y-%m-%d %H:%M')
        })
    
    # 6. 生成 Excel
    import os
    output_path = os.path.join(OUTPUT_FOLDER, f"AI_Resume_All_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx")
    create_excel(formatted_results, output_path)
    
    # 7. 返回结果
    return f"""✅ 简历匹配完成！

**处理统计**：
- JD 文件：{len(jd_data)} 个
- 简历文件夹：{len(jl_folders)} 个
- 分析任务：{len(all_tasks)} 个
- 成功：{len(results)} 个

**输出文件**：
{output_path}

**Excel 包含**：
1. 简历匹配结果（明细表）- 每条任职要求一行
2. 应聘者总体评估表（汇总表）- 按人平均分排序
"""

# OpenClaw 会调用这个函数
if __name__ == "__main__":
    # 测试运行
    result = handle_skill({})
    print(result)


path: README.md
content: # 简历与 JD 智能匹配系统

> 批量解析简历并与岗位 JD 进行 AI 智能匹配，生成结构化匹配报告（Excel）

**作者**: fly  
**版本**: 2.0.3  
**许可**: MIT License  
**联系邮箱**: [REDACTED_SECRET]  

---

## 🎯 功能特性

- ✅ 支持 PDF/Word 格式简历解析
- ✅ 自动匹配岗位 JD 与简历
- ✅ AI 智能分析匹配度（6 个等级）
- ✅ 生成 Excel 匹配报告（含分析详情）
- ✅ 自动生成应聘者总体评估表
- ✅ 支持增量处理（跳过已处理简历）
- ✅ 双模式支持（OpenClaw / 独立脚本）

## 🚀 快速开始

### 方式 1：OpenClaw 中使用（推荐）

1. **确保模式配置为 subagent**

```yaml
# config_resume_match.yaml
mode: "subagent"
```

2. **发送消息触发**

```
帮我匹配 D:\JL 目录下的简历
```

3. **等待完成**

程序会自动处理所有简历，输出到 `D:\jg\AI_Resume_All_YYYYMMDD_HHMM.xlsx`

### 方式 2：独立脚本运行

1. **安装依赖**

```bash
pip install openpyxl requests python-docx pyyaml pdfplumber
```

2. **配置 API**

```yaml
# config_resume_match.yaml
mode: "api"
api:
  active_provider: tencent
  api_providers:
    tencent:
      [REDACTED_SECRET]"
      api_url: "https://..."
```

3. **运行程序**

```bash
cd C:\Users\Administrator\.openclaw\workspace
python resume_match.py
```

## 📁 目录结构

```
D:\JD\                    # 岗位 JD 目录
├── 投资岗.docx
├── 合规岗.docx
└── ...

D:\JL\                    # 简历目录（按岗位文件夹分类）
├── 投资岗/
│   ├── 张三.pdf
│   └── 李四.docx
├── 合规岗/
│   ├── 王五.pdf
│   └── ...
└── ...

D:\jg\                    # 输出目录（自动生成）
├── AI_Resume_All_20260403_1415.xlsx
└── resume_match_20260403_1415.log
```

## 📊 输出示例

### 简历匹配结果

| 应聘者名称 | 应聘岗位 | 任职要求 | 匹配度 | AI 分析详情 | 处理时间 |
|-----------|---------|---------|-------|-----------|---------|
| 张三 | 投资岗 | 3 年以上投资经验... | 完全匹配 | 候选人具备... | 2026-04-03 14:15 |
| 李四 | 投资岗 | 3 年以上投资经验... | 高度匹配 | 候选人具备... | 2026-04-03 14:16 |

### 应聘者总体评估表

| 应聘岗位 | 应聘者名称 | 得分 |
|---------|-----------|-----|
| 投资岗 | 张三 | 95.00 |
| 投资岗 | 李四 | 87.50 |

## ⚙️ 配置说明

### 运行模式

```yaml
mode: "subagent"  # OpenClaw 模式（无需 API）
# 或
mode: "api"       # 独立脚本模式（需 API Key）
```

### 路径配置

```yaml
paths:
  jd_folder: "D:\\JD"      # JD 文件目录
  jl_folder: "D:\\JL"      # 简历目录
  output_folder: "D:\\jg"  # 输出目录
```

## 🔧 高级功能

### 增量处理

程序会自动检测已处理的简历，跳过重复处理。

如需重新处理，删除输出目录中的 Excel 文件即可。

### 日志查看

日志文件位于输出目录：
```
D:\jg\resume_match_YYYYMMDD_HHMM.log
```

包含详细的处理过程、AI 分析结果、错误信息等。

## 🆚 模式对比

| 特性 | subagent 模式 | api 模式 |
|------|-------------|---------|
| 配置 | 无需 | 需 API Key |
| 执行环境 | OpenClaw | 任意 Python |
| 单次耗时 | ~6 秒 | ~2 秒 |
| 批量 50 份 | ~5-10 分钟 | ~2-3 分钟 |
| 费用 | OpenClaw 配额 | API 供应商 |
| 适用场景 | OpenClaw 用户 | 独立脚本 |

## 📝 版本历史

- **V2.0.0** (2026-04-03) - 双模式架构重构
- **V1.9.6** (2026-03-20) - 修复 AI 返回语言
- **V1.9.4** (2026-03-16) - 新增多 API 供应商切换
- **V1.6** (2026-03-10) - 初始版本

## 🐛 常见问题

### Q: 子 Agent 模式超时？
A: 检查网络连接，或增加 `timeout` 配置（默认 60 秒）。

### Q: PDF 解析失败？
A: 当前仅支持文本型 PDF，扫描件需要 OCR 支持（未来版本）。

### Q: 如何切换 API 供应商？
A: 修改 `config_resume_match.yaml` 中 `api.active_provider`。

## 📦 依赖

```txt
openpyxl>=3.0.0
requests>=2.28.0
python-docx>=0.8.0
pyyaml>=6.0.0
pdfplumber>=0.11.0
```

## 📄 License

MIT License

Copyright (c) 2026 fly

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

## 👤 Author

**fly**  
📧 [REDACTED_SECRET]


path: _meta.json
content: {
  "name": "resume-jd-matcher",
  "version": "2.0.3",
  "description": "批量解析简历并与岗位 JD 进行 AI 智能匹配，生成结构化匹配报告（Excel）",
  "entry": "main.py",
  "handler": "handle_skill",
  "mode": "subagent",
  "permissions": ["sessions_spawn", "subagents", "sessions_history"],
  "config": {
    "jd_folder": "C:\\ResumeJD\\JD",
    "jl_folder": "C:\\ResumeJD\\JL",
    "output_folder": "C:\\ResumeJD\\JG"
  },
  "tags": ["hr", "resume", "jd", "matching", "excel", "ai"],
  "author": "fly",
  "authorEmail": "[REDACTED_SECRET]",
  "license": "MIT"
}


path: scripts/batch_processor.py
content: # -*- coding: utf-8 -*-
"""
简历与 JD 智能匹配 - 批量处理演示
展示如何在 OpenClaw 中使用 subagent 模式进行批量简历分析

使用方法：
在 OpenClaw 中运行此脚本，或通过消息触发 skill
"""

import os
import json
import yaml
import glob
import time
from datetime import datetime

# 配置路径
CONFIG_PATH = r"C:\Users\Administrator\.openclaw\workspace\config_resume_match.yaml"
JD_FOLDER = r"C:\ResumeJD\JD"
JL_FOLDER = r"C:\ResumeJD\JL"
OUTPUT_FOLDER = r"C:\ResumeJD\JG"

def load_config():
    with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
        return yaml.safe_load(f)

def read_docx(file_path):
    try:
        import docx
        doc = docx.Document(file_path)
        return '\n'.join([p.text for p in doc.paragraphs])
    except:
        return ""

def read_pdf(file_path):
    try:
        import pdfplumber
        text = []
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                t = page.extract_text() or ''
                if t.strip():
                    text.append(t)
        return '\n'.join(text)
    except:
        return ""

def extract_text(file_path):
    ext = os.path.splitext(file_path)[1].lower()
    if ext == '.docx':
        return read_docx(file_path)
    elif ext == '.pdf':
        return read_pdf(file_path)
    return ""

def get_jd_requirements(jd_file):
    """从 JD 文件中提取任职要求"""
    content = read_docx(jd_file)
    lines = content.split('\n')
    in_req = False
    requirements = []
    
    for line in lines:
        line = line.strip()
        if '任职要求' in line or '岗位要求' in line:
            in_req = True
            continue
        if in_req and line:
            if any(kw in line for kw in ['岗位职责', '福利', '邮箱']):
                break
            requirements.append(line)
    
    return requirements if requirements else [content[:1500]]

# ========== 批量处理核心 ==========
def process_resume_batch():
    """
    批量处理简历 - 演示 subagent 模式
    
    这个函数展示如何在 OpenClaw 中批量调用子 Agent
    """
    print("=" * 60)
    print("简历与 JD 智能匹配 - Subagent 批量处理演示")
    print("=" * 60)
    
    # 1. 读取 JD
    print("\n[1] 读取 JD 文件...")
    jd_files = glob.glob(os.path.join(JD_FOLDER, '*.docx'))
    
    if not jd_files:
        print(f"  ❌ 未找到 JD 文件：{JD_FOLDER}")
        return
    
    jd_data = {}
    for jd_file in jd_files:
        jd_name = os.path.basename(jd_file).replace('.docx', '')
        requirements = get_jd_requirements(jd_file)
        jd_data[jd_name] = requirements
        print(f"  ✅ {jd_name}: {len(requirements)} 条任职要求")
    
    # 2. 扫描简历
    print(f"\n[2] 扫描简历目录...")
    jl_folders = [f for f in os.listdir(JL_FOLDER) if os.path.isdir(os.path.join(JL_FOLDER, f))]
    print(f"  找到 {len(jl_folders)} 个简历文件夹")
    
    # 3. 批量分析（演示用，实际处理 1-2 份简历）
    print(f"\n[3] 开始批量分析（演示模式）...")
    
    tasks_submitted = []
    
    for folder_name in jl_folders[:2]:  # 演示：只处理前 2 个文件夹
        folder_path = os.path.join(JL_FOLDER, folder_name)
        print(f"\n  处理文件夹：{folder_name}")
        
        # 匹配 JD（简单匹配）
        matched_jd = None
        for jd_name, requirements in jd_data.items():
            if any(kw in folder_name.lower() for kw in jd_name.lower().split() if len(kw) > 2):
                matched_jd = (jd_name, requirements)
                break
        
        if not matched_jd:
            print(f"    ⚠️ 未找到匹配的 JD，跳过")
            continue
        
        jd_name, requirements = matched_jd
        print(f"    匹配 JD: {jd_name}")
        
        # 读取简历
        resume_files = glob.glob(os.path.join(folder_path, '*.pdf')) + \
                       glob.glob(os.path.join(folder_path, '*.docx'))
        resume_files = [f for f in resume_files if not os.path.basename(f).startswith('~$')][:2]  # 演示：每个文件夹只处理 2 份
        
        for resume_file in resume_files:
            resume_name = os.path.basename(resume_file)
            resume_content = extract_text(resume_file)
            
            if not resume_content.strip():
                print(f"    ⚠️ 简历为空：{resume_name}")
                continue
            
            print(f"\n    处理简历：{resume_name}")
            
            # 为每条任职要求调用子 Agent
            for req_idx, requirement in enumerate(requirements[:2], 1):  # 演示：每条 JD 只分析前 2 条要求
                print(f"      提交任务：要求{req_idx}...")
                
                # 这里应该调用 sessions_spawn，但为了演示，我们只显示任务信息
                # 实际使用时取消注释下面的代码
                
                """
                from sessions_spawn import sessions_spawn
                
                prompt = f\"\"\"请分析以下简历与任职要求的匹配度：

简历内容：
{resume_content[:1500]}

任职要求：
{requirement}

岗位名称：{jd_name}

请返回 JSON 格式：
{{\"分析\": \"...\", \"匹配等级\": \"完全匹配\"}}

匹配等级选项：完全匹配、高度匹配、匹配、部分匹配、不匹配、无相关信息
\"\"\"
                
                result = sessions_spawn(
                    task=prompt,
                    runtime="subagent",
                    mode="run",
                    timeoutSeconds=60,
                    label=f"resume-{resume_name[:20]}"
                )
                
                tasks_submitted.append({
                    'child_key': result.get('childSessionKey'),
                    'resume': resume_name,
                    'jd': jd_name,
                    'requirement_idx': req_idx
                })
                """
                
                print(f"        ✅ 任务已提交（演示模式，未实际调用）")
    
    # 4. 等待结果（演示）
    print(f"\n[4] 等待结果...")
    print(f"  已提交 {len(tasks_submitted)} 个子 Agent 任务")
    print(f"  （演示模式，实际会等待所有任务完成并收集结果）")
    
    # 5. 生成报告
    print(f"\n[5] 生成 Excel 报告...")
    print(f"  输出路径：{OUTPUT_FOLDER}\\AI_Resume_All_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx")
    
    print(f"\n" + "=" * 60)
    print("演示完成！")
    print("=" * 60)
    print(f"\n提示：")
    print(f"  - 实际使用时，取消注释 sessions_spawn 调用代码")
    print(f"  - 批量处理时建议设置 max_concurrent=3 控制并发数")
    print(f"  - 每份简历 × N 条任职要求 = N 个子 Agent 任务")

if __name__ == "__main__":
    process_resume_batch()


path: scripts/resume_match.py
content: # -*- coding: utf-8 -*-
"""
简历与 JD 智能匹配 - V2.0
支持双模式：subagent（OpenClaw） / api（外部 API）
"""

import os
import json
import yaml
import glob
import time
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, Alignment
from openpyxl.utils import get_column_letter
import requests
import docx

# 读取配置文件
def load_config():
    config_path = r"C:\Users\Administrator\.openclaw\workspace\config_resume_match.yaml"
    with open(config_path, 'r', encoding='utf-8') as f:
        return yaml.safe_load(f)

config = load_config()

# ========== 运行模式检查 ==========
MODE = config.get('mode', 'subagent')

if MODE == 'api':
    # API 模式 - 验证配置
    if not config.get('api') or not config['api'].get('active_provider'):
        raise Exception("mode='api' 但未配置 API 信息，请检查 config_resume_match.yaml")
    
    # API 配置 - 支持多供应商切换
    def get_active_api_config():
        """获取当前激活的 API 供应商配置"""
        api_config = config['api']
        active_provider = api_config.get('active_provider', 'tencent')
        providers = api_config.get('api_providers', {})
        
        if active_provider not in providers:
            print(f"[警告] 未找到供应商 '{active_provider}'，使用默认供应商 'tencent'")
            active_provider = 'tencent'
        
        provider_config = providers[active_provider]
        return {
            'provider': active_provider,
            'name': provider_config.get('name', active_provider),
            'api_key': provider_config.get('api_key', ''),
            'api_url': provider_config.get('api_url', ''),
            'model': provider_config.get('model', ''),
            'description': provider_config.get('description', '')
        }

    API_CONFIG = get_active_api_config()
    [REDACTED_SECRET]'api_key']
    API_URL = API_CONFIG['api_url']
    MODEL = API_CONFIG['model']
    MAX_TOKENS = config['api'].get('max_tokens', 3000)
    TIMEOUT = config['api'].get('timeout', 120)
else:
    # Subagent 模式 - 无需 API 配置
    API_CONFIG = None
    API_KEY = None
    API_URL = None
    MODEL = None
    MAX_TOKENS = None
    TIMEOUT = 60

# 路径配置
JD_FOLDER = config['paths']['jd_folder']
JL_FOLDER = config['paths']['jl_folder']
OUTPUT_FOLDER = config['paths']['output_folder']

# 简历最大字符数
MAX_CHARS = config['resume']['max_chars']  # 0 表示不限制

# 测试参数
TEST_LIMIT = 0  # 0 表示不限制，处理所有简历

# 日志配置
LOG_FOLDER = OUTPUT_FOLDER  # 日志输出到 D:\jg
LOG_FILE = None  # 在 main() 中初始化

def init_log_file():
    """初始化日志文件名（带时间戳，到分钟级别）"""
    timestamp = datetime.now().strftime('%Y%m%d_%H%M')
    log_file = os.path.join(LOG_FOLDER, f"resume_match_{timestamp}.log")
    return log_file

def log_message(message, log_file=None):
    """写入日志文件"""
    global LOG_FILE
    if log_file:
        LOG_FILE = log_file
    if not LOG_FILE:
        LOG_FILE = init_log_file()
    
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    log_line = f"[{timestamp}] {message}\n"
    print(log_line.strip())  # 同时打印到控制台
    try:
        with open(LOG_FILE, 'a', encoding='utf-8') as f:
            f.write(log_line)
    except Exception as e:
        print(f"写入日志失败：{e}")

def get_latest_excel():
    """获取最新的结果文件路径"""
    excel_files = glob.glob(os.path.join(OUTPUT_FOLDER, "AI_Resume_All_*.xlsx"))
    if excel_files:
        return max(excel_files, key=os.path.getmtime)
    return None

def load_existing_results():
    """从最新 Excel 加载已有结果"""
    results = []
    latest_file = get_latest_excel()
    if not latest_file:
        return results, None
    
    try:
        wb = openpyxl.load_workbook(latest_file, read_only=True)
        ws = wb.worksheets[0]  # 读取第一个工作表（简历匹配结果）
        for row in ws.iter_rows(min_row=2, values_only=True):
            if row[0] and row[1] and row[2]:
                result = {
                    'resume_name': row[0],
                    'position': row[1],
                    'requirement': row[2],
                    'match_level': row[3] if row[3] else '无相关信息',
                    'ai_result': row[4] if row[4] else '',
                    'process_time': row[5] if row[5] else ''
                }
                results.append(result)
        wb.close()
        print(f"    已加载 {len(results)} 条历史记录")
        return results, latest_file
    except Exception as e:
        print(f"    加载历史记录失败：{e}")
        return results, None

def get_processed_records():
    """获取已处理的记录（用于增量处理）"""
    processed = set()
    latest_file = get_latest_excel()
    if not latest_file:
        return processed
    
    try:
        wb = openpyxl.load_workbook(latest_file, read_only=True)
        ws = wb.worksheets[0]  # 读取第一个工作表（简历匹配结果）
        for row in ws.iter_rows(min_row=2, values_only=True):
            if row[0] and row[1] and row[2]:
                # 规范化应聘者名称：去除首尾空格
                resume_name = str(row[0]).strip()
                position = str(row[1]).strip()
                requirement = str(row[2]).strip()
                key = (resume_name, position, requirement[:50])
                processed.add(key)
        wb.close()
        print(f"    已从 Excel 加载 {len(processed)} 条已处理记录")
    except Exception as e:
        print(f"    读取历史记录失败：{e}")
    
    return processed

def read_docx(file_path):
    try:
        doc = docx.Document(file_path)
        return '\n'.join([para.text for para in doc.paragraphs])
    except Exception as e:
        print(f"读取 Word 失败 {file_path}: {e}")
        return ""

def read_pdf(file_path):
    """
    PDF 解析 - 使用 pdfplumber 提取文本层
    适用于文本型 PDF（大部分电子简历）
    扫描版/图片型 PDF 需要 OCR 支持（当前不支持）
    """
    try:
        import pdfplumber
        text = []
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                extracted = page.extract_text() or ''
                if extracted.strip():
                    text.append(extracted)
        
        full_text = '\n'.join(text)
        
        # 检测是否为扫描版 PDF（文本层内容过少）
        if len(full_text) < 50:
            print(f"  警告：{os.path.basename(file_path)} 可能是扫描版 PDF，提取文本较少 ({len(full_text)} 字符)")
            print(f"  建议：提供文本型 PDF 或 Word 格式简历以获得更好效果")
        
        return full_text
    except Exception as e:
        print(f"PDF 解析失败 {file_path}: {e}")
        return ""

def save_as_json(file_path, text_content):
    base_dir = os.path.dirname(file_path)
    parsed_dir = os.path.join(base_dir, 'parsed')
    os.makedirs(parsed_dir, exist_ok=True)
    
    file_name = os.path.basename(file_path) + '.json'
    parsed_json = os.path.join(parsed_dir, file_name)
    
    try:
        data = {
            'file_name': os.path.basename(file_path),
            'folder': os.path.basename(base_dir),
            'full_text': text_content,
            'parse_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        }
        with open(parsed_json, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        return True
    except Exception as e:
        print(f"保存 JSON 失败：{e}")
        return False

def extract_text_from_file(file_path):
    base_dir = os.path.dirname(file_path)
    parsed_dir = os.path.join(base_dir, 'parsed')
    file_name = os.path.basename(file_path) + '.json'
    parsed_json = os.path.join(parsed_dir, file_name)
    
    if os.path.exists(parsed_json):
        try:
            with open(parsed_json, 'r', encoding='utf-8') as f:
                data = json.load(f)
                if isinstance(data, dict):
                    for key in ['full_text', 'text', 'content']:
                        if key in data and data[key]:
                            return data[key]
        except Exception as e:
            print(f"读取 JSON 失败：{e}")
    
    ext = os.path.splitext(file_path)[1].lower()
    text_content = ""
    if ext == '.docx':
        text_content = read_docx(file_path)
    elif ext == '.pdf':
        text_content = read_pdf(file_path)
    
    if text_content and len(text_content) > 10:
        save_as_json(file_path, text_content)
    
    return text_content

def get_jd_content(jd_folder):
    jd_files = glob.glob(os.path.join(jd_folder, '*.docx'))
    jd_contents = {}
    for jd_file in jd_files:
        if not os.path.basename(jd_file).startswith('~$'):
            jd_contents[os.path.basename(jd_file)] = extract_text_from_file(jd_file)
    return jd_contents

def find_matching_jd(folder_name, jd_contents):
    folder_lower = folder_name.lower()
    
    if 'investment' in folder_lower or '投资' in folder_name:
        for jd_name, jd_content in jd_contents.items():
            if '投资' in jd_content or 'Investment' in jd_content:
                return [(folder_name, extract_requirements(jd_content))]
    
    if '合规' in folder_name or 'compliance' in folder_lower:
        results = []
        for jd_name, jd_content in jd_contents.items():
            if '合规' in jd_content:
                domestic = extract_requirements_by_position(jd_content, '境内')
                if domestic:
                    results.append(('合规岗-境内', domestic))
                overseas = extract_requirements_by_position(jd_content, '境外')
                if overseas:
                    results.append(('合规岗-境外', overseas))
        return results if results else None
    
    return None

def extract_requirements(jd_content):
    lines = jd_content.split('\n')
    in_req = False
    requirements = []
    for line in lines:
        line = line.strip()
        if any(kw in line for kw in ['任职要求', '岗位要求', 'Qualifications']):
            in_req = True
            continue
        if in_req and line:
            if '（' in line and '）' in line and ('境内' in line or '境外' in line):
                break
            requirements.append(line)
    return requirements if requirements else [jd_content[:2000]]

def extract_requirements_by_position(jd_content, position_type):
    lines = jd_content.split('\n')
    in_position = False
    in_req = False
    requirements = []
    marker = f'合规岗（{position_type}）'
    
    for line in lines:
        line = line.strip()
        if marker in line:
            in_position = True
            in_req = False
            continue
        if in_position:
            if '任职要求' in line:
                in_req = True
                continue
            if line and '（' in line and '）' in line and '合规岗' in line:
                break
            if in_req and line and any(kw in line for kw in ['岗位职责', '投递邮箱']):
                break
            if in_req and line:
                requirements.append(line)
    
    return requirements if requirements else None

def call_subagent(resume_text, job_requirement, position_name, resume_name=""):
    """
    调用 OpenClaw 子 Agent 分析简历匹配度（mode="subagent"）
    """
    from sessions_spawn import sessions_spawn
    from subagents import subagents
    from sessions_history import sessions_history
    
    prompt = f"""请分析以下简历与该条任职要求的匹配程度：

## 简历内容：
{resume_text}

## 任职要求：
{job_requirement}

## 岗位名称：{position_name}

请针对这条任职要求进行分析，直接返回 JSON 格式：
{{"分析": "针对该条任职要求的匹配分析", "匹配等级": "完全匹配"}}

重要要求：
1. **请统一使用中文返回分析结果**
2. "分析"字段：只针对该条任职要求进行匹配分析，不要分析其他维度
3. "匹配等级"必须从以下选项中选择：完全匹配、高度匹配、匹配、部分匹配、不匹配、无相关信息
4. 即使简历或任职要求中有英文内容，分析内容也必须用中文
5. 如果简历中有英文内容（如英文公司名、英文职位等），请在分析中保留原文并附上中文说明
"""
    
    try:
        # 1. 调用子 Agent
        spawn_result = sessions_spawn(
            task=prompt,
            runtime="subagent",
            mode="run",
            timeoutSeconds=TIMEOUT,
            label=f"resume-{resume_name[:20] if resume_name else 'analysis'}"
        )
        
        child_key = spawn_result.get('childSessionKey')
        if not child_key:
            print(f"[错误] 子 Agent 调用失败：未返回 childSessionKey")
            return None
        
        # 2. 等待子 Agent 完成（轮询状态，间隔 2 秒）
        max_wait = TIMEOUT
        waited = 0
        while waited < max_wait:
            agents = subagents(action="list")
            for agent in agents.get('recent', []):
                if agent.get('sessionKey') == child_key:
                    if agent.get('status') == 'done':
                        # 3. 获取结果
                        history = sessions_history(sessionKey=child_key, limit=10, includeTools=False)
                        if history.get('messages'):
                            last_msg = history['messages'][-1]
                            if last_msg.get('role') == 'assistant':
                                content = last_msg.get('content', [])
                                if content:
                                    # 提取 text 字段
                                    for item in content:
                                        if item.get('type') == 'text':
                                            return item.get('text', '')
            time.sleep(2)
            waited += 2
        
        print(f"[警告] 子 Agent 超时（{TIMEOUT}秒）")
        return None
        
    except Exception as e:
        print(f"[错误] 子 Agent 调用异常：{e}")
        return None

def call_ai_api(resume_text, job_requirement, position_name, requirement_index, resume_name=""):
    """
    AI 分析入口 - 根据模式路由
    """
    if MODE == 'subagent':
        return call_subagent(resume_text, job_requirement, position_name, resume_name)
    else:
        return call_external_api(resume_text, job_requirement, position_name, requirement_index, resume_name)

def call_external_api(resume_text, job_requirement, position_name, requirement_index, resume_name=""):
    """
    调用外部 API 分析简历与 JD 匹配度（mode="api"）
    兼容多种 API 格式：
    - 腾讯/阿里等：使用 content 字段
    - 中国移动 Qwen3：使用 reasoning_content 字段
    """
    # 不限制字符数，发送完整简历
    prompt = f"""请分析以下简历与该条任职要求的匹配程度：

## 简历内容：
{resume_text}

## 任职要求：
{job_requirement}

## 岗位名称：{position_name}

请针对这条任职要求进行分析，直接返回 JSON 格式：
{{"分析": "针对该条任职要求的匹配分析", "匹配等级": "完全匹配"}}

重要要求：
1. **请统一使用中文返回分析结果**
2. "分析"字段：只针对该条任职要求进行匹配分析，不要分析其他维度
3. "匹配等级"必须从以下选项中选择：完全匹配、高度匹配、匹配、部分匹配、不匹配、无相关信息
4. 即使简历或任职要求中有英文内容，分析内容也必须用中文
5. 如果简历中有英文内容（如英文公司名、英文职位等），请在分析中保留原文并附上中文说明

例如：如果任职要求是学历相关，就只分析学历匹配情况；如果是工作经验相关，就只分析工作经验匹配情况。
"""

    # 构建请求头 - 支持自定义头部（中国移动需要 x-gateway-apikey）
    if API_CONFIG.get('provider') == 'cmhk':
        # 中国移动使用 x-gateway-apikey 头部
        headers = {
            "x-gateway-apikey": API_KEY,
            "Content-Type": "application/json"
        }
        # 中国移动不需要 model 参数（URL 已包含）
        data = {
            "messages": [{"role": "user", "content": prompt}],
            "max_tokens": MAX_TOKENS
        }
    else:
        # 其他 API 使用标准 Authorization 头部
        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json"
        }
        data = {
            "model": MODEL,
            "messages": [{"role": "user", "content": prompt}],
            "max_tokens": MAX_TOKENS
        }
    
    try:
        response = requests.post(API_URL, headers=headers, json=data, timeout=TIMEOUT)
        if response.status_code == 200:
            result = response.json()
            if 'choices' in result:
                message = result['choices'][0]['message']
                # 兼容不同 API 的返回格式
                # 优先使用 content 字段（腾讯、阿里等）
                # 如果 content 为空，使用 reasoning_content（中国移动 Qwen3）
                ai_response = message.get('content') or message.get('reasoning_content', '')
                return ai_response
            elif 'content' in result:
                content = result['content']
                if isinstance(content, list):
                    texts = [item.get('text', '') for item in content if item.get('type') == 'text']
                    return '\n'.join(texts)
                return str(content)
            return str(result)
        else:
            print(f"API 失败：{response.status_code}")
            print(f"响应：{response.text[:200]}")
            return None
    except Exception as e:
        print(f"API 异常：{e}")
        return None

def parse_ai_response(ai_response):
    try:
        cleaned = ai_response
        if '```json' in cleaned:
            cleaned = cleaned.split('```json')[1].split('```')[0]
        elif '```' in cleaned:
            cleaned = cleaned.split('```')[1].split('```')[0]
        result = json.loads(cleaned.strip())
        if '匹配等级' not in result:
            result['匹配等级'] = '无相关信息'
        if '分析' not in result:
            result['分析'] = ai_response[:500]
        return result
    except:
        levels = ['完全匹配', '高度匹配', '匹配', '部分匹配', '不匹配', '无相关信息']
        for level in levels:
            if level in ai_response:
                return {"分析": ai_response[:500], "匹配等级": level}
        return {"分析": ai_response[:500], "匹配等级": "无相关信息"}

def get_resume_files(folder_path):
    resume_files = []
    for ext in ['*.docx', '*.pdf']:
        for f in glob.glob(os.path.join(folder_path, ext)):
            if not os.path.basename(f).startswith('~$'):
                resume_files.append(f)
    # 按文件名排序，确保每次遍历顺序一致
    resume_files.sort(key=lambda x: os.path.basename(x).lower())
    return resume_files

def create_output_excel(results, output_path):
    from openpyxl.styles import Border, Side
    
    log_message(f"开始生成 Excel 文件：{output_path}")
    print(f"    正在生成 Excel...")
    
    score_map = {'完全匹配': 100, '高度匹配': 90, '匹配': 80, '部分匹配': 50, '不匹配': 0, '无相关信息': 60}
    
    resume_scores = {}
    for r in results:
        key = (r['position'], r['resume_name'])
        if key not in resume_scores:
            resume_scores[key] = []
        score = score_map.get(r.get('match_level', '无相关信息'), 60)
        resume_scores[key].append(score)
    
    candidate_summary = []
    for (position, name), scores in resume_scores.items():
        avg_score = sum(scores) / len(scores) if scores else 0
        candidate_summary.append({'position': position, 'name': name, 'score': round(avg_score, 2)})
    # 按应聘岗位升序、得分降序排序
    candidate_summary.sort(key=lambda x: (x['position'], -x['score']))
    
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "简历匹配结果"
    
    header_fill = openpyxl.styles.PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
    header_font = Font(color="FFFFFF", bold=True, name="微软雅黑")
    normal_font = Font(name="微软雅黑")
    thin_border = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'), bottom=Side(style='thin'))
    
    headers = ['应聘者名称', '应聘岗位', '任职要求', '匹配度', 'AI 分析详情', '处理时间']
    for col, header in enumerate(headers, 1):
        cell = ws.cell(row=1, column=col, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal='center', vertical='center')
        cell.border = thin_border
    
    color1 = openpyxl.styles.PatternFill(start_color="E6F3FF", end_color="E6F3FF", fill_type="solid")
    color2 = openpyxl.styles.PatternFill(start_color="E6FFE6", end_color="E6FFE6", fill_type="solid")
    
    prev_key = None
    use_color1 = True
    
    for row_idx, result in enumerate(results, 2):
        current_key = (result['resume_name'], result['position'])
        if prev_key is not None and current_key != prev_key:
            use_color1 = not use_color1
        prev_key = current_key
        row_fill = color1 if use_color1 else color2
        
        ai_detail = ""
        try:
            ai_data = json.loads(result['ai_result'])
            # 只显示"分析"字段
            ai_detail = ai_data.get('分析', result['ai_result'])
        except:
            ai_detail = result['ai_result']
        
        data = [result['resume_name'], result['position'], result['requirement'], result.get('match_level', '无相关信息'), ai_detail, result['process_time']]
        for col_idx, value in enumerate(data, 1):
            cell = ws.cell(row=row_idx, column=col_idx, value=value)
            cell.font = normal_font
            cell.fill = row_fill
            cell.alignment = Alignment(wrap_text=True, vertical='top')
            cell.border = thin_border
    
    ws.column_dimensions['A'].width = 20
    ws.column_dimensions['B'].width = 20
    ws.column_dimensions['C'].width = 40
    ws.column_dimensions['D'].width = 15
    ws.column_dimensions['E'].width = 50
    ws.column_dimensions['F'].width = 18
    
    for row_idx in range(2, len(results) + 2):
        ws.row_dimensions[row_idx].height = 80
    ws.freeze_panes = 'A2'
    
    ws2 = wb.create_sheet(title="应聘者总体评估表")
    for col, header in enumerate(['应聘岗位', '应聘者名称', '得分'], 1):
        cell = ws2.cell(row=1, column=col, value=header)
        cell.font = header_font
        cell.fill = header_fill
        cell.alignment = Alignment(horizontal='center', vertical='center')
        cell.border = thin_border
    
    for row_idx, candidate in enumerate(candidate_summary, 2):
        row_fill = color1 if row_idx % 2 == 0 else color2
        for col_idx, value in enumerate([candidate['position'], candidate['name'], candidate['score']], 1):
            cell = ws2.cell(row=row_idx, column=col_idx, value=value)
            cell.font = normal_font
            cell.fill = row_fill
            cell.alignment = Alignment(horizontal='center', vertical='center')
            cell.border = thin_border
    
    ws2.column_dimensions['A'].width = 25
    ws2.column_dimensions['B'].width = 25
    ws2.column_dimensions['C'].width = 15
    
    try:
        wb.save(output_path)
        print(f"结果已保存到：{output_path}")
        log_message(f"结果已保存：{output_path}")
    except Exception as e:
        print(f"[ERROR] 保存文件失败：{e}")
        log_message(f"[ERROR] 保存 Excel 失败：{e}")
        import traceback
        log_message(traceback.format_exc())
        raise

def main():
    # 初始化日志文件
    os.makedirs(OUTPUT_FOLDER, exist_ok=True)
    log_file = init_log_file()
    log_message(f"日志文件：{log_file}", log_file)
    
    log_message("=" * 50, log_file)
    log_message("简历与 JD 智能匹配程序 V2.0 启动", log_file)
    log_message("=" * 50, log_file)
    print("=" * 50)
    print("简历与 JD 智能匹配程序 V2.0")
    print("=" * 50)
    
    # 显示当前运行模式
    if MODE == 'subagent':
        print(f"\n[模式] OpenClaw Subagent 模式")
        print(f"    使用用户配置的大模型（无需 API 配置）")
        log_message(f"运行模式：subagent（OpenClaw）")
    else:
        print(f"\n[模式] 外部 API 模式")
        print(f"    API 供应商：{API_CONFIG['name']} ({API_CONFIG['provider']})")
        print(f"    模型：{MODEL}")
        print(f"    说明：{API_CONFIG['description']}")
        log_message(f"运行模式：api - {API_CONFIG['name']} - {MODEL}")
        
        # 显示可用的供应商列表
        available_providers = list(config['api'].get('api_providers', {}).keys())
        print(f"    可用供应商：{', '.join(available_providers)}")
        print(f"    切换方法：修改 config_resume_match.yaml 中 api.active_provider")
    
    # 加载已有结果
    existing_results, latest_file = load_existing_results()
    if latest_file:
        log_message(f"加载已有结果：{latest_file}，共 {len(existing_results)} 条记录", log_file)
    
    # 获取已处理的记录（增量处理）
    processed_records = get_processed_records()
    log_message(f"已处理记录数：{len(processed_records)} 条", log_file)
    print(f"    已处理 {len(processed_records)} 条记录")
    
    print("\n[1] 读取 JD 文件...")
    jd_contents = get_jd_content(JD_FOLDER)
    log_message(f"找到 JD 文件：{len(jd_contents)} 个", log_file)
    print(f"    找到 {len(jd_contents)} 个 JD 文件")
    
    print("\n[2] 扫描简历目录...")
    jl_folders = [f for f in os.listdir(JL_FOLDER) if os.path.isdir(os.path.join(JL_FOLDER, f))]
    log_message(f"找到简历文件夹：{len(jl_folders)} 个", log_file)
    print(f"    找到 {len(jl_folders)} 个简历文件夹")
    
    # 统计需要处理的简历总数
    total_resumes_to_process = 0
    total_resumes_actual = 0
    
    new_results = []
    processed_resumes = set()
    
    for folder_name in jl_folders:
        if TEST_LIMIT > 0 and len(processed_resumes) >= TEST_LIMIT:
            log_message(f"已达到测试限制 {TEST_LIMIT} 份简历")
            print(f"\n已达到测试限制 {TEST_LIMIT} 份简历")
            break
        
        folder_path = os.path.join(JL_FOLDER, folder_name)
        log_message(f"处理文件夹：{folder_name}")
        print(f"\n[3] 处理文件夹：{folder_name}")
        
        jd_requirements = find_matching_jd(folder_name, jd_contents)
        if jd_requirements is None:
            log_message(f"警告：文件夹 {folder_name} 无法找到匹配的 JD")
            print("    警告：无法找到匹配的 JD")
            continue
        
        resume_files = get_resume_files(folder_path)
        log_message(f"文件夹 {folder_name}: 找到 {len(resume_files)} 份简历，匹配岗位：{[item[0] for item in jd_requirements]}", log_file)
        print(f"    找到 {len(resume_files)} 个简历文件")
        print(f"    匹配岗位：{[item[0] for item in jd_requirements]}")
        
        # 统计需要处理的简历总数
        total_resumes_to_process += len(resume_files)
        
        for resume_file in resume_files:
            if TEST_LIMIT > 0 and len(processed_resumes) >= TEST_LIMIT:
                break
            
            resume_name = os.path.basename(resume_file)
            resume_key = resume_name.replace('.docx', '').replace('.pdf', '').strip()
            
            # 检查这份简历的所有任职要求是否都已处理
            all_done = True
            for (position_name, req_list) in jd_requirements:
                for requirement in req_list:
                    record_key = (resume_key, position_name, requirement[:50])
                    if record_key not in processed_records:
                        all_done = False
                        break
                if not all_done:
                    break
            
            if all_done:
                # 统计已处理的记录数
                skip_count = 0
                for (position_name, req_list) in jd_requirements:
                    skip_count += len(req_list)
                log_message(f"跳过简历 {resume_name} - {skip_count} 条任职要求已处理", log_file)
                print(f"    简历 {resume_name} [已处理，跳过] - {skip_count} 条任职要求")
                continue  # 跳过的不计入限制
            
            # 实际处理的简历
            total_resumes_actual += 1
            
            log_message(f"处理简历：{resume_name}", log_file)
            print(f"    处理简历：{resume_name}")
            resume_content = extract_text_from_file(resume_file)
            
            if not resume_content.strip():
                log_message(f"警告：简历 {resume_name} 内容为空")
                print("    警告：简历内容为空")
                continue
            
            resume_has_new = False
            
            for (position_name, req_list) in jd_requirements:
                for req_idx, requirement in enumerate(req_list):
                    if TEST_LIMIT > 0 and len(processed_resumes) >= TEST_LIMIT:
                        break
                    
                    # 检查该条记录是否已处理
                    record_key = (resume_key, position_name, requirement[:50])
                    if record_key in processed_records:
                        print(f"      任职要求 {req_idx+1}: {requirement[:30]}... [已跳过]")
                        continue
                    
                    print(f"      {position_name} - 任职要求 {req_idx+1}: {requirement[:30]}...")
                    
                    try:
                        ai_response = call_ai_api(resume_content, requirement, position_name, req_idx+1, resume_name)
                    except Exception as e:
                        log_message(f"    [ERROR] AI 调用失败：{e}")
                        log_message(f"    跳过此条要求，继续处理")
                        ai_response = None  # 继续处理下一条
                    
                    if ai_response:
                        parsed = parse_ai_response(ai_response)
                        result = {
                            'resume_name': resume_key,
                            'position': position_name,
                            'requirement': requirement,
                            'ai_result': json.dumps(parsed, ensure_ascii=False, indent=2),
                            'process_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                            'match_level': parsed.get('匹配等级', '无相关信息')
                        }
                        new_results.append(result)
                        resume_has_new = True
                        log_message(f"      -> {result['match_level']}")
                        print(f"      -> {result['match_level']}")
                    else:
                        log_message("      -> AI 分析失败")
                        print("      -> AI 分析失败")
                    
                    time.sleep(1)
            
            if resume_has_new:
                processed_resumes.add(resume_key)
    
    # 合并已有结果和新结果
    if new_results:
        all_results = existing_results + new_results
        
        # 排序
        all_results.sort(key=lambda x: (x['position'], x['resume_name'], x['process_time']))
        
        # 生成输出文件
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        output_file = os.path.join(OUTPUT_FOLDER, f"AI_Resume_All_{timestamp}.xlsx")
        
        try:
            create_output_excel(all_results, output_file)
            log_message(f"匹配完成！新增 {len(new_results)} 条记录，总计 {len(all_results)} 条，处理 {len(processed_resumes)} 份简历", log_file)
            print(f"\n匹配完成！新增 {len(new_results)} 条记录，总计 {len(all_results)} 条")
            print(f"本次处理 {len(processed_resumes)} 份简历")
        except Exception as e:
            log_message(f"[ERROR] 保存 Excel 失败：{e}", log_file)
            print(f"\n[ERROR] 保存 Excel 失败：{e}")
            import traceback
            log_message(traceback.format_exc(), log_file)
    else:
        log_message("没有找到需要处理的新记录", log_file)
        print("\n没有找到需要处理的新记录")
    
    # 执行总结
    log_message("=" * 50, log_file)
    log_message("本次执行总结", log_file)
    log_message("=" * 50, log_file)
    log_message(f"需要处理简历：{total_resumes_to_process} 份", log_file)
    log_message(f"实际处理简历：{total_resumes_actual} 份", log_file)
    log_message(f"跳过简历：{total_resumes_to_process - total_resumes_actual} 份", log_file)
    log_message(f"新增记录：{len(new_results)} 条", log_file)
    log_message(f"总计记录：{len(existing_results) + len(new_results)} 条", log_file)
    log_message("=" * 50, log_file)
    log_message("程序执行完毕", log_file)
    log_message("=" * 50, log_file)

if __name__ == "__main__":
    main()


path: scripts/skill_handler.py
content: # -*- coding: utf-8 -*-
"""
简历与 JD 智能匹配 - OpenClaw Skill 处理器
专用于 OpenClaw 环境，通过 sessions_spawn 调用子 Agent 进行批量分析

使用方式：
在 OpenClaw 中发送消息：/skill resume-jd-matcher
或直接调用：python skill_handler.py（需要 OpenClaw 环境）
"""

import os
import json
import yaml
import glob
import time
from datetime import datetime
from pathlib import Path

# 读取配置文件
def load_config():
    config_path = Path(__file__).parent.parent / 'references' / 'config_resume_match.yaml'
    if not config_path.exists():
        # 回退到工作区配置
        config_path = Path(r"C:\Users\Administrator\.openclaw\workspace\config_resume_match.yaml")
    
    with open(config_path, 'r', encoding='utf-8') as f:
        return yaml.safe_load(f)

config = load_config()

# 路径配置
JD_FOLDER = config['paths']['jd_folder']
JL_FOLDER = config['paths']['jl_folder']
OUTPUT_FOLDER = config['paths']['output_folder']

# 日志配置
LOG_FILE = None

def init_log_file():
    """初始化日志文件"""
    os.makedirs(OUTPUT_FOLDER, exist_ok=True)
    timestamp = datetime.now().strftime('%Y%m%d_%H%M')
    log_file = os.path.join(OUTPUT_FOLDER, f"resume_match_skill_{timestamp}.log")
    return log_file

def log_message(message, log_file=None):
    """写入日志"""
    global LOG_FILE
    if log_file:
        LOG_FILE = log_file
    if not LOG_FILE:
        LOG_FILE = init_log_file()
    
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    log_line = f"[{timestamp}] {message}"
    print(log_line)
    try:
        with open(LOG_FILE, 'a', encoding='utf-8') as f:
            f.write(log_line + '\n')
    except:
        pass

# ========== 文件读取工具 ==========
def read_docx(file_path):
    """读取 Word 文件"""
    try:
        import docx
        doc = docx.Document(file_path)
        return '\n'.join([para.text for para in doc.paragraphs])
    except Exception as e:
        log_message(f"读取 Word 失败 {file_path}: {e}")
        return ""

def read_pdf(file_path):
    """读取 PDF 文件"""
    try:
        import pdfplumber
        text = []
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                extracted = page.extract_text() or ''
                if extracted.strip():
                    text.append(extracted)
        return '\n'.join(text)
    except Exception as e:
        log_message(f"PDF 解析失败 {file_path}: {e}")
        return ""

def extract_text_from_file(file_path):
    """根据文件类型提取文本"""
    ext = os.path.splitext(file_path)[1].lower()
    if ext == '.docx':
        return read_docx(file_path)
    elif ext == '.pdf':
        return read_pdf(file_path)
    return ""

# ========== JD 处理 ==========
def get_jd_content(jd_folder):
    """读取所有 JD 文件"""
    jd_files = glob.glob(os.path.join(jd_folder, '*.docx'))
    jd_contents = {}
    for jd_file in jd_files:
        if not os.path.basename(jd_file).startswith('~$'):
            jd_name = os.path.basename(jd_file).replace('.docx', '')
            jd_contents[jd_name] = extract_text_from_file(jd_file)
            log_message(f"读取 JD: {jd_name}")
    return jd_contents

def extract_requirements(jd_content):
    """从 JD 中提取任职要求"""
    lines = jd_content.split('\n')
    in_req = False
    requirements = []
    
    for line in lines:
        line = line.strip()
        if any(kw in line for kw in ['任职要求', '岗位要求', 'Qualifications', 'Requirements']):
            in_req = True
            continue
        if in_req and line:
            # 检查是否到下一个章节
            if any(kw in line for kw in ['岗位职责', '工作职责', '福利待遇', '投递邮箱']):
                break
            requirements.append(line)
    
    # 如果没有提取到，返回前 2000 字符
    if not requirements:
        return [jd_content[:2000]]
    
    return requirements

def find_matching_jd(folder_name, jd_contents):
    """根据文件夹名称匹配 JD"""
    folder_lower = folder_name.lower()
    
    # 简单匹配：文件夹名称包含 JD 名称中的关键词
    for jd_name, jd_content in jd_contents.items():
        jd_name_lower = jd_name.lower()
        # 检查关键词匹配
        if any(kw in folder_lower for kw in jd_name_lower.split() if len(kw) > 2):
            return [(jd_name, extract_requirements(jd_content))]
    
    # 尝试模糊匹配
    for jd_name, jd_content in jd_contents.items():
        if 'investment' in folder_lower and '投资' in jd_content:
            return [(jd_name, extract_requirements(jd_content))]
        if '合规' in folder_name and '合规' in jd_content:
            return [(jd_name, extract_requirements(jd_content))]
    
    return None

# ========== AI 分析（Subagent 模式） ==========
def analyze_resume_subagent(resume_text, job_requirement, position_name, resume_name=""):
    """
    调用子 Agent 分析简历匹配度
    
    这是 OpenClaw Skill 专用版本，通过 sessions_spawn 工具调用
    """
    prompt = f"""请分析以下简历与该条任职要求的匹配程度：

## 简历内容：
{resume_text}

## 任职要求：
{job_requirement}

## 岗位名称：{position_name}

请针对这条任职要求进行分析，直接返回 JSON 格式：
{{"分析": "针对该条任职要求的匹配分析", "匹配等级": "完全匹配"}}

重要要求：
1. **请统一使用中文返回分析结果**
2. "分析"字段：只针对该条任职要求进行匹配分析，不要分析其他维度
3. "匹配等级"必须从以下选项中选择：完全匹配、高度匹配、匹配、部分匹配、不匹配、无相关信息
4. 即使简历或任职要求中有英文内容，分析内容也必须用中文
5. 如果简历中有英文内容（如英文公司名、英文职位等），请在分析中保留原文并附上中文说明
"""
    
    # 在 OpenClaw 环境中，这会通过工具调用执行
    # 返回格式：{"childSessionKey": "...", "runId": "..."}
    return {
        'task': prompt,
        'label': f"resume-{resume_name[:30] if resume_name else 'analysis'}"
    }

# ========== 批量处理 ==========
def process_resumes_batch(resume_folder, jd_requirements, max_concurrent=3):
    """
    批量处理简历文件夹
    
    Args:
        resume_folder: 简历文件夹路径
        jd_requirements: JD 要求列表 [(岗位名，要求列表), ...]
        max_concurrent: 最大并发子 Agent 数量
    """
    from sessions_spawn import sessions_spawn
    from subagents import subagents
    from sessions_history import sessions_history
    
    resume_files = glob.glob(os.path.join(resume_folder, '*.pdf')) + \
                   glob.glob(os.path.join(resume_folder, '*.docx'))
    resume_files = [f for f in resume_files if not os.path.basename(f).startswith('~$')]
    
    if not resume_files:
        log_message(f"  简历文件夹为空：{resume_folder}")
        return []
    
    log_message(f"  找到 {len(resume_files)} 份简历")
    
    # 存储所有子 Agent 调用
    pending_tasks = []
    results = []
    
    for resume_file in resume_files:
        resume_name = os.path.basename(resume_file)
        resume_key = resume_name.replace('.docx', '').replace('.pdf', '').strip()
        resume_content = extract_text_from_file(resume_file)
        
        if not resume_content.strip():
            log_message(f"  跳过空简历：{resume_name}")
            continue
        
        log_message(f"  处理简历：{resume_name}")
        
        # 为每份简历的每条任职要求创建子 Agent 任务
        for (position_name, req_list) in jd_requirements:
            for req_idx, requirement in enumerate(req_list):
                # 创建分析任务
                task_data = analyze_resume_subagent(
                    resume_content, 
                    requirement, 
                    position_name,
                    resume_key
                )
                
                # 调用子 Agent
                spawn_result = sessions_spawn(
                    task=task_data['task'],
                    runtime="subagent",
                    mode="run",
                    timeoutSeconds=60,
                    label=task_data['label']
                )
                
                pending_tasks.append({
                    'child_key': spawn_result.get('childSessionKey'),
                    'resume_name': resume_key,
                    'position': position_name,
                    'requirement': requirement,
                    'req_idx': req_idx + 1
                })
                
                log_message(f"    已提交任务：{position_name} - 要求{req_idx + 1}")
                
                # 控制并发数
                if len(pending_tasks) >= max_concurrent:
                    # 等待一批完成
                    batch_results = wait_for_batch(pending_tasks, max_concurrent)
                    results.extend(batch_results)
                    pending_tasks = [t for t in pending_tasks if t.get('status') != 'done']
    
    # 等待剩余任务完成
    if pending_tasks:
        log_message(f"等待剩余 {len(pending_tasks)} 个任务完成...")
        batch_results = wait_for_batch(pending_tasks, len(pending_tasks))
        results.extend(batch_results)
    
    return results

def wait_for_batch(tasks, max_wait_seconds=60):
    """
    等待一批子 Agent 任务完成
    
    Returns:
        list: 完成的任务结果
    """
    from subagents import subagents
    from sessions_history import sessions_history
    
    completed = []
    start_time = time.time()
    
    while time.time() - start_time < max_wait_seconds:
        # 检查所有任务状态
        agents = subagents(action="list")
        recent = {a['sessionKey']: a for a in agents.get('recent', [])}
        
        for task in tasks:
            if task.get('status') == 'done':
                continue
            
            child_key = task.get('child_key')
            if child_key in recent:
                agent_info = recent[child_key]
                if agent_info.get('status') == 'done':
                    # 获取结果
                    history = sessions_history(sessionKey=child_key, limit=10, includeTools=False)
                    if history.get('messages'):
                        last_msg = history['messages'][-1]
                        if last_msg.get('role') == 'assistant':
                            content = last_msg.get('content', [])
                            for item in content:
                                if item.get('type') == 'text':
                                    task['result'] = item.get('text', '')
                                    task['status'] = 'done'
                                    task['match_level'] = extract_match_level(item.get('text', ''))
                                    completed.append(task)
                                    log_message(f"    ✅ 完成：{task['resume_name']} - {task['position']} - {task['match_level']}")
                                    break
        
        # 检查是否全部完成
        all_done = all(t.get('status') == 'done' for t in tasks)
        if all_done:
            break
        
        time.sleep(2)
    
    return completed

def extract_match_level(ai_response):
    """从 AI 响应中提取匹配等级"""
    levels = ['完全匹配', '高度匹配', '匹配', '部分匹配', '不匹配', '无相关信息']
    for level in levels:
        if level in ai_response:
            return level
    return '无相关信息'

# ========== 主函数 ==========
def main():
    """Skill 主入口"""
    log_message("=" * 60)
    log_message("简历与 JD 智能匹配 - OpenClaw Skill 启动")
    log_message("=" * 60)
    
    # 读取 JD
    log_message("\n[1] 读取 JD 文件...")
    jd_contents = get_jd_content(JD_FOLDER)
    log_message(f"  找到 {len(jd_contents)} 个 JD 文件")
    
    # 扫描简历目录
    log_message("\n[2] 扫描简历目录...")
    jl_folders = [f for f in os.listdir(JL_FOLDER) if os.path.isdir(os.path.join(JL_FOLDER, f))]
    log_message(f"  找到 {len(jl_folders)} 个简历文件夹")
    
    all_results = []
    
    # 处理每个文件夹
    for folder_name in jl_folders:
        folder_path = os.path.join(JL_FOLDER, folder_name)
        log_message(f"\n[3] 处理文件夹：{folder_name}")
        
        # 匹配 JD
        jd_requirements = find_matching_jd(folder_name, jd_contents)
        if not jd_requirements:
            log_message(f"  ⚠️ 未找到匹配的 JD，跳过")
            continue
        
        log_message(f"  匹配岗位：{[item[0] for item in jd_requirements]}")
        
        # 批量处理简历
        results = process_resumes_batch(folder_path, jd_requirements, max_concurrent=3)
        all_results.extend(results)
    
    # 总结
    log_message("\n" + "=" * 60)
    log_message(f"处理完成！共分析 {len(all_results)} 条任职要求")
    log_message("=" * 60)
    
    return all_results

if __name__ == "__main__":
    main()


path: references/config_resume_match.yaml
content: # 简历 JD 匹配 V2.0 配置文件
# V2.0 更新内容 (2026-04-03):
# - 新增双模式支持：subagent / api
# - subagent 模式：使用 OpenClaw 大模型，无需配置 API
# - api 模式：使用外部 API，需配置 API Key
# 更新时间：2026-04-03

# ========== 运行模式 ==========
# "subagent" - OpenClaw skill 模式（无需 API 配置，推荐使用）
# "api" - 独立脚本模式（需要配置下方 API）
mode: "subagent"

# ========== API 配置（仅当 mode="api" 时需要） ==========
api:
  # 当前使用的供应商（从下方 api_providers 中选择）
  # 可选：tencent, cmhk, alibaba, baidu, deepseek, moonshot
  active_provider: "tencent"  # 使用腾讯 Hunyuan-T1
  
  # 通用参数
  max_tokens: 3000
  timeout: 120
  
  # 多供应商配置（可添加多个）
  api_providers:
    # 中国移动大模型 - Qwen3 235B
    cmhk:
      name: "中国移动大模型"
      [REDACTED_SECRET] YmQzNDQwYzYtNWZjMS00ZDNhLWFmZGQtYjc1MTUyZTg1YjJl"
      api_url: "https://opensseapi.cmhk.com/CMHK-LMMP-PRD_Qwen3_235B/CMHK-LMMP-PRD/v1/chat/completions"
      model: "Qwen3-235B"
      description: "中国移动 Qwen3 235B 模型（使用 reasoning_content 字段）"
      custom_headers:
        x-gateway-[REDACTED_SECRET]"
      use_reasoning_content: true  # 使用 reasoning_content 字段
    
    # 腾讯 - Hunyuan-T1
    tencent:
      name: "腾讯"
      [REDACTED_SECRET]"
      api_url: "https://api.lkeap.cloud.tencent.com/coding/anthropic/v1/messages"
      model: "hunyuan-t1"
      description: "腾讯 Hunyuan-T1，适合长文本分析"
    
    # 阿里 - 通义千问
    alibaba:
      name: "阿里"
      [REDACTED_SECRET]"
      api_url: "https://coding.dashscope.aliyuncs.com/v1"
      model: "qwen3.5-plus"
      description: "阿里通义千问 3.5 Plus，性价比高"
    
    # 百度 - 文心一言
    baidu:
      name: "百度"
      [REDACTED_SECRET]"
      api_url: "https://qianfan.baidubce.com/v2/chat/completions"
      model: "ernie-4.0-turbo"
      description: "百度文心一言，中文理解强"
    
    # DeepSeek - 深度求索
    deepseek:
      name: "DeepSeek"
      [REDACTED_SECRET]"
      api_url: "https://api.deepseek.com/v1/chat/completions"
      model: "deepseek-chat"
      description: "DeepSeek-V3，开源模型，成本低"
    
    # 月之暗面 - Kimi
    moonshot:
      name: "月之暗面"
      [REDACTED_SECRET]"
      api_url: "https://api.moonshot.cn/v1/chat/completions"
      model: "moonshot-v1-8k"
      description: "Kimi，长上下文支持好"

# ========== 路径配置 ==========
paths:
  jd_folder: "C:\\ResumeJD\\JD"
  jl_folder: "C:\\ResumeJD\\JL"
  output_folder: "C:\\ResumeJD\\JG"
  input_file: "AI_Resume_Records.csv"

# ========== 简历配置 ==========
resume:
  max_chars: 0  # 0 表示不限制字符数，发送完整简历给 AI 分析

# ========== 输出格式配置 ==========
output:
  filename_pattern: "AI_Resume_All_{timestamp}.xlsx"  # {timestamp} 会替换为生成时间
  
  # Excel 格式
  columns:
    - name: "应聘者名称"
      width: 20
    - name: "应聘岗位"
      width: 20
    - name: "岗位任职要求"
      width: 70
    - name: "AI 分析结果"
      width: 80
    - name: "处理时间"
      width: 15
  
  # 颜色配置
  colors:
    header_fill: "4472C4"
    header_font: "FFFFFF"
    row_color_1: "E6F3FF"  # 浅蓝
    row_color_2: "E6FFE6"  # 浅绿
  
  # 字体
  font: "微软雅黑"
  
  # 行高计算
  row_height:
    chars_per_line: 35
    line_height: 15
    min_height: 20
    max_height: 400

# ========== 匹配规则配置 ==========
matching:
  # 岗位匹配规则
  rules:
    - keywords: ["investment", "投资", "infrastructure"]
      jd_name: "Overseas Infrastructure Investment Associate"
    - keywords: ["合规", "compliance"]
      jd_names: ["合规岗（境内）", "合规岗（境外）"]
    - folder_name: "合规岗"
      jd_names: ["合规岗（境内）", "合规岗（境外）"]

# ========== AI 评价类型 ==========
evaluation_types:
  - 完全匹配
  - 高度匹配
  - 匹配
  - 部分匹配
  - 不匹配
  - 无相关信息


path: references/config_template.yaml
content: # 简历 JD 匹配 - 配置模板
# 复制此文件为 config_resume_match.yaml 并根据需要修改

# ========== 运行模式 ==========
# "subagent" - OpenClaw skill 模式（无需 API 配置，推荐使用）
# "api" - 独立脚本模式（需要配置 API）
mode: "subagent"

# ========== API 配置（仅当 mode="api" 时需要） ==========
api:
  # 当前使用的供应商（从下方 api_providers 中选择）
  active_provider: "tencent"
  
  # 通用参数
  max_tokens: 3000
  timeout: 120
  
  # 多供应商配置
  api_providers:
    # 腾讯 - Hunyuan-T1
    tencent:
      name: "腾讯 Hunyuan"
      [REDACTED_SECRET]"
      api_url: "https://api.lkeap.cloud.tencent.com/coding/anthropic/v1/messages"
      model: "hunyuan-t1"
      description: "腾讯 Hunyuan-T1，适合长文本分析"
    
    # 阿里 - 通义千问
    alibaba:
      name: "阿里通义千问"
      [REDACTED_SECRET]"
      api_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
      model: "qwen-plus"
      description: "阿里通义千问 Plus，性价比高"
    
    # 中国移动 - Qwen3 235B
    cmhk:
      name: "中国移动 Qwen3"
      [REDACTED_SECRET]"
      api_url: "https://opensseapi.cmhk.com/CMHK-LMMP-PRD_Qwen3_235B/CMHK-LMMP-PRD/v1/chat/completions"
      model: "Qwen3-235B"
      description: "中国移动 Qwen3 235B 模型"

# ========== 路径配置 ==========
paths:
  jd_folder: "D:\\JD"
  jl_folder: "D:\\JL"
  output_folder: "D:\\jg"

# ========== 简历配置 ==========
resume:
  max_chars: 0  # 0 表示不限制字符数，发送完整简历给 AI 分析


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