diff --git a/COMMANDS_OVERVIEW.md b/COMMANDS_OVERVIEW.md new file mode 100644 index 0000000..b0b217f --- /dev/null +++ b/COMMANDS_OVERVIEW.md @@ -0,0 +1,276 @@ +# boss-agent-cli 命令总览 + +## 先回答你的问题 + +- **有没有聊天界面**:没有内置独立的网页聊天 UI。 +- **有没有聊天相关能力**:有,体现在 `chat`、`chatmsg`、`chat-summary`、`hr chat` 这些命令,以及 `mcp-server/` 提供给 Claude Desktop / Cursor 的 MCP 接入。 +- **这个项目更像什么**:更像“可被 Agent 调用的 CLI / MCP 工具集”,不是自己带完整前端聊天界面的智能体应用。 + +## 命令真源 + +- 面向人类的命令说明:`docs/commands.md` +- 实际注册入口:[register.py](file:///d:/download/py_test/boss-agent-cli/src/boss_agent_cli/commands/register.py) +- 机器可读能力真源:`boss schema` +- 你当前本地运行方式不是 `boss ...`,而是: + +```bash +python run.py +``` + +例如: + +```bash +python run.py doctor +python run.py login +python run.py search "Python" +``` + +## 一、顶层命令 + +### 认证与环境 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `schema` | 输出完整能力描述 JSON | 是 | +| `login` | 登录平台 | 是 | +| `logout` | 退出登录 | 是 | +| `status` | 检查登录态 | 是 | +| `platforms` | 查看平台注册和能力状态 | 是 | +| `doctor` | 检查环境、依赖、登录态、网络 | 是 | + +### 职位发现 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `search ` | 搜索职位 | 是 | +| `detail ` | 查看职位详情 | 是 | +| `show <编号>` | 查看上次搜索结果中的某条职位 | 是 | +| `recommend` | 推荐职位 | 受限 | +| `cities` | 查看支持城市 | 是 | +| `history` | 浏览历史 | 是 | + +### 求职动作 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `greet ` | 向招聘者打招呼 | 受限 | +| `batch-greet ` | 批量打招呼 | 受限 | +| `apply ` | 发起投递 | 受限 | +| `exchange ` | 请求交换联系方式 | 受限 | + +### 沟通与跟进 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `chat` | 查看沟通列表 | 受限 | +| `chatmsg ` | 查看聊天消息 | 受限 | +| `chat-summary ` | 聊天摘要 | 受限 | +| `mark ` | 打标签/标记联系人 | 受限 | +| `interviews` | 面试邀请 | 是 | + +### 本地整理与监控 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `watch ...` | 搜索条件监控 | 部分可用 | +| `pipeline` | 流程状态聚合 | 受限 | +| `follow-up` | 跟进建议 | 受限 | +| `shortlist ...` | 候选池管理 | 是 | +| `preset ...` | 搜索预设管理 | 是 | +| `digest` | 沟通/流程摘要 | 受限 | +| `stats` | 漏斗统计 | 是 | + +### 用户与简历 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `me` | 我的信息、简历、求职期望、投递记录 | 是 | +| `resume ...` | 本地简历管理 | 是 | + +### AI 增强 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `ai ...` | AI 求职辅助命令组 | 是 | + +### 系统管理 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `export` | 导出结果 | 是 | +| `config ...` | 配置管理 | 是 | +| `clean` | 清理缓存和临时文件 | 是 | + +### 招聘者模式 + +| 命令 | 作用 | 默认是否可用 | +|------|------|--------------| +| `hr ...` | 招聘者快捷命令组 | 部分可用 | + +## 二、子命令展开 + +### `watch` + +| 子命令 | 作用 | +|--------|------| +| `watch add` | 保存一个监控搜索条件 | +| `watch list` | 列出所有监控条件 | +| `watch remove` | 删除监控条件 | +| `watch run` | 执行监控检查 | + +说明: + +- `add/list/remove` 是本地能力。 +- `run` 默认可能被合规策略限制,因为它会自动拉取平台数据。 + +### `shortlist` + +| 子命令 | 作用 | +|--------|------| +| `shortlist add` | 加入本地候选池 | +| `shortlist list` | 查看候选池 | +| `shortlist annotate` | 给候选职位加标签/备注 | +| `shortlist compare` | 对比候选职位 | +| `shortlist remove` | 移除候选职位 | + +### `preset` + +| 子命令 | 作用 | +|--------|------| +| `preset add` | 新增搜索预设 | +| `preset list` | 列出搜索预设 | +| `preset remove` | 删除搜索预设 | + +### `resume` + +| 子命令 | 作用 | +|--------|------| +| `resume init` | 初始化本地简历 | +| `resume list` | 列出本地简历 | +| `resume show` | 查看简历详情 | +| `resume edit` | 编辑简历字段 | +| `resume delete` | 删除简历 | +| `resume export` | 导出简历 | +| `resume import` | 导入简历 | +| `resume clone` | 复制简历 | +| `resume diff` | 比较两份简历差异 | +| `resume link` | 关联平台信息 | +| `resume applications` | 查看简历相关投递记录 | + +### `ai` + +| 子命令 | 作用 | +|--------|------| +| `ai config` | 配置 AI 服务 | +| `ai analyze-jd` | 分析 JD | +| `ai polish` | 润色简历 | +| `ai optimize` | 面向目标岗位优化简历 | +| `ai suggest` | 给出求职建议 | +| `ai reply` | 生成沟通回复草稿 | +| `ai interview-prep` | 生成面试准备内容 | +| `ai chat-coach` | 给出聊天沟通建议 | + +### `config` + +| 子命令 | 作用 | +|--------|------| +| `config list` | 查看当前配置 | +| `config set` | 设置配置项 | +| `config reset` | 恢复默认配置 | + +### `hr` + +| 子命令 | 作用 | 默认状态 | +|--------|------|----------| +| `hr applications` | 候选人申请列表 | 受限 | +| `hr resume` | 查看候选人在线简历/交换联系方式 | 受限 | +| `hr chat` | 招聘者沟通列表 | 受限 | +| `hr chatmsg` | 招聘者聊天记录 | 受限 | +| `hr last-messages` | 最近消息摘要 | 受限 | +| `hr jobs list` | 查看已发布职位 | 可用 | +| `hr jobs offline` | 下线职位 | 可用 | +| `hr jobs online` | 上线职位 | 可用 | +| `hr jobs detail` | 查看职位详情 | 可用 | +| `hr candidates ` | 搜索候选人 | 受限 | +| `hr reply ` | 回复候选人 | 受限 | +| `hr request-resume ` | 请求附件简历 | 受限 | + +## 三、哪些命令和“聊天”有关 + +虽然没有单独聊天界面,但这些命令都和聊天/沟通有关: + +### 求职者侧 + +- `chat` +- `chatmsg` +- `chat-summary` +- `mark` +- `exchange` +- `ai reply` +- `ai chat-coach` + +### 招聘者侧 + +- `hr chat` +- `hr chatmsg` +- `hr last-messages` +- `hr reply` +- `hr request-resume` + +注意: + +- 这类命令大多默认被低风险合规模式限制。 +- 项目设计更偏向“辅助分析”和“本地整理”,不是鼓励自动批量沟通。 + +## 四、你本地最常用的启动方式 + +因为你当前没有把项目完整安装成 `boss` 命令,所以建议直接用: + +```bash +cd d:\download\py_test\boss-agent-cli +python run.py --help +python run.py doctor +python run.py login +python run.py search "Python" +``` + +## 五、怎么看所有命令 + +### 看顶层命令 + +```bash +python run.py --help +``` + +### 看某个命令组选项 + +```bash +python run.py shortlist --help +python run.py resume --help +python run.py ai --help +python run.py hr --help +``` + +### 看机器可读完整能力 + +```bash +python run.py schema +``` + +## 六、命令来源文件 + +如果你想从源码里看“这些命令到底是哪里注册的”,重点看: + +- [register.py](file:///d:/download/py_test/boss-agent-cli/src/boss_agent_cli/commands/register.py) +- [main.py](file:///d:/download/py_test/boss-agent-cli/src/boss_agent_cli/main.py) +- [commands.md](file:///d:/download/py_test/boss-agent-cli/docs/commands.md) + +其中: + +- `main.py` 是总入口 +- `register.py` 负责把所有命令挂到 CLI 上 +- `docs/commands.md` 是人类阅读版命令表 + +## 七、一句话总结 + +这个项目**没有独立聊天界面**,但**有聊天相关命令和 MCP 对接能力**;我已经把命令整理在这份文件里,你之后优先看这份即可。 diff --git a/INSTALL_GUIDE.md b/INSTALL_GUIDE.md new file mode 100644 index 0000000..4902416 --- /dev/null +++ b/INSTALL_GUIDE.md @@ -0,0 +1,46 @@ +# 快速配置启动指南 + +## 方法一:开发模式安装(推荐用于修改) + +1. 在项目目录打开终端,安装项目依赖 +```bash +cd d:\download\py_test\boss-agent-cli +pip install -e . +``` + +2. 验证安装 +```bash +boss --help +boss --version +``` + +## 方法二:uv tool install(推荐用于使用) + +```bash +uv tool install . +``` + +## 验证环境 + +```bash +boss doctor +``` + +## 登录 BOSS 直聘 + +```bash +boss login +``` +会弹出浏览器,扫码登录即可。 + +## 验证登录状态 + +```bash +boss status +``` + +## 开始搜索 + +```bash +boss search "Python" --city 北京 +``` diff --git a/PROJECT_FRAMEWORK.md b/PROJECT_FRAMEWORK.md new file mode 100644 index 0000000..8f80ca7 --- /dev/null +++ b/PROJECT_FRAMEWORK.md @@ -0,0 +1,493 @@ +# boss-agent-cli 项目架构详解 + +## 📋 项目概述 + +**boss-agent-cli** 是一个专为 AI Agent 设计的 BOSS 直聘本地辅助 CLI 工具,提供: +- 职位搜索与筛选 +- 福利匹配(核心差异化功能) +- 本地候选池管理 +- AI 求职增强 +- 多平台抽象 +- 默认低风险合规模式 + +--- + +## 🏗️ 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI 入口 (main.py) │ +│ (Click 框架) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 合规护栏 (compliance.py) │ +│ 默认低风险模式,阻断敏感写操作 │ +└────────────────────────────┬────────────────────────────────────┘ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ AuthManager │ │ Platform 双注 │ │ BossClient │ +│ (auth/) │ │ 册表 │ │ (api/) │ +│ 用户认证管理 │ │ (platforms/) │ │ HTTP + 浏览器 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ TokenStore │ │ CacheStore │ +│ 令牌加密存储 │ │ SQLite 缓存 │ +└─────────────────┘ └─────────────────┘ + │ │ + └──────────────┬───────────────┘ + ▼ + ┌─────────────────┐ + │ 命令层 (commands/) │ + │ 35+ 子命令注册 │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ JSON 信封输出 │ + │ (output.py) │ + └─────────────────┘ +``` + +--- + +## 📂 目录结构详解 + +``` +boss-agent-cli/ +├── src/boss_agent_cli/ # 源代码主目录 +│ ├── __init__.py # 包初始化,版本信息 +│ ├── main.py # [核心] CLI 入口点 +│ ├── config.py # [核心] 配置管理 +│ ├── compliance.py # [核心] 合规边界控制 +│ ├── output.py # [核心] JSON 信封输出 +│ ├── display.py # 终端显示格式化 +│ ├── hooks.py # 钩子总线系统 +│ ├── mcp_server.py # MCP 服务器入口 +│ ├── match_score.py # 匹配分数计算 +│ ├── digest.py # 摘要生成 +│ ├── chat_summary.py # 聊天摘要 +│ │ +│ ├── ai/ # [模块] AI 服务层 +│ │ ├── __init__.py +│ │ ├── config.py # AI 配置(OpenAI/Anthropic) +│ │ ├── prompts.py # Prompt 模板库 +│ │ └── service.py # AI 服务核心 +│ │ +│ ├── api/ # [模块] API 客户端层 +│ │ ├── __init__.py +│ │ ├── client.py # [核心] BossClient 混合客户端 +│ │ ├── browser_client.py # 浏览器 CDP 客户端 +│ │ ├── endpoints.py # API 端点定义 +│ │ ├── endpoints_loader.py # YAML 端点加载 +│ │ ├── recruiter_client.py # 招聘者侧客户端 +│ │ ├── zhilian_client.py # 智联招聘客户端 +│ │ ├── models.py # 数据模型定义 +│ │ ├── throttle.py # 请求节流器 +│ │ ├── httpx_helpers.py # HTTPX 辅助函数 +│ │ ├── boss.yaml # BOSS 直聘 API 定义 +│ │ └── recruiter.yaml # 招聘者 API 定义 +│ │ +│ ├── auth/ # [模块] 认证层 +│ │ ├── __init__.py +│ │ ├── manager.py # [核心] AuthManager 认证管理器 +│ │ ├── token_store.py # 令牌加密存储 +│ │ ├── browser.py # 浏览器认证 +│ │ ├── qr_login.py # 二维码登录 +│ │ ├── cookie_extract.py # Cookie 提取 +│ │ └── health.py # 健康检查 +│ │ +│ ├── bridge/ # [模块] 浏览器桥接层 +│ │ ├── __init__.py +│ │ ├── daemon.py # 桥接守护进程 +│ │ ├── client.py # 桥接客户端 +│ │ └── protocol.py # 桥接协议定义 +│ │ +│ ├── cache/ # [模块] 缓存层 +│ │ ├── __init__.py +│ │ └── store.py # SQLite WAL 缓存 +│ │ +│ ├── commands/ # [模块] 命令层(35+ 子命令) +│ │ ├── __init__.py +│ │ ├── register.py # 命令注册 +│ │ ├── _platform.py # 平台基类命令 +│ │ ├── login.py # 登录 +│ │ ├── logout.py # 登出 +│ │ ├── status.py # 状态检查 +│ │ ├── doctor.py # 诊断 +│ │ ├── search.py # [核心] 职位搜索 + 福利筛选 +│ │ ├── detail.py # 职位详情 +│ │ ├── show.py # 展示缓存 +│ │ ├── cities.py # 城市列表 +│ │ ├── history.py # 搜索历史 +│ │ ├── shortlist.py # 候选池 +│ │ ├── stats.py # 统计 +│ │ ├── watch.py # 监控 +│ │ ├── preset.py # 预设 +│ │ ├── resume_cmd.py # 简历 +│ │ ├── ai_cmd.py # [核心] AI 增强命令 +│ │ ├── chat.py # 聊天 +│ │ ├── apply.py # 投递 +│ │ ├── greet.py # 打招呼 +│ │ ├── exchange.py # 联系方式交换 +│ │ ├── export.py # 导出 +│ │ ├── config_cmd.py # 配置 +│ │ ├── clean.py # 清理 +│ │ ├── schema.py # Schema 自描述 +│ │ ├── platforms.py # 平台列表 +│ │ └── recruiter/ # 招聘者子命令 +│ │ ├── jobs.py # 职位管理 +│ │ ├── candidates.py # 候选人 +│ │ ├── chat.py # 招聘者聊天 +│ │ └── ... +│ │ +│ ├── platforms/ # [模块] 平台适配层 +│ │ ├── __init__.py # 平台注册表 +│ │ ├── base.py # [核心] Platform 抽象基类 +│ │ ├── recruiter_base.py # RecruiterPlatform 基类 +│ │ ├── zhipin.py # BOSS 直聘求职者适配 +│ │ ├── zhipin_recruiter.py # BOSS 直聘招聘者适配 +│ │ ├── zhilian.py # 智联招聘适配 +│ │ └── qiancheng.py # 前程无忧适配 +│ │ +│ └── resume/ # [模块] 简历管理 +│ ├── __init__.py +│ ├── models.py # 简历数据模型 +│ ├── store.py # 简历存储 +│ ├── templates.py # 简历模板 +│ └── export.py # 简历导出 +│ +├── mcp-server/ # MCP 服务器 +│ └── server.py # MCP 服务器入口 +│ +├── extension/ # 浏览器扩展 +│ ├── manifest.json +│ └── background.js +│ +├── scripts/ # 脚本工具 +│ ├── smoke_p0.py # P0 冒烟测试 +│ ├── quality_baseline.py # 质量基线 +│ └── probe_recruiter_chat.py # 招聘者聊天前端探测 +│ +├── tests/ # 测试套件(100+ 测试用例) +│ ├── conftest.py # pytest 配置 +│ ├── test_*.py # 各模块测试 +│ └── ... +│ +├── docs/ # 文档 +│ ├── getting-started.md # 快速上手 +│ ├── agent-quickstart.md # Agent 集成 +│ ├── commands.md # 命令参考 +│ ├── platform-abstraction.md # 平台抽象设计 +│ ├── platform-risk.md # 平台风险边界 +│ ├── troubleshooting.md # 排障指南 +│ └── ... +│ +├── demo/ # 演示 +│ ├── demo-zh.gif # 中文演示 +│ └── ... +│ +└── pyproject.toml # 项目配置 +``` + +--- + +## 🎯 核心概念 + +### 1. 合规边界(Compliance) + +默认启用**低风险辅助模式**: +- ✅ 本地辅助、只读优先 +- ❌ 打招呼、投递、联系方式交换默认阻断 +- ❌ 招聘者候选人搜索 / 简历 / 聊天默认阻断 +- 返回 `COMPLIANCE_BLOCKED` 错误码 + +### 2. JSON 信封输出 + +所有命令输出统一格式: +```json +{ + "ok": true, + "data": {...}, + "pagination": {...}, + "error": {...}, + "hints": [...] +} +``` + +### 3. 平台抽象(Platform Abstraction) + +双注册表设计: +- `Platform`:求职者接口 +- `RecruiterPlatform`:招聘者接口 + +支持多平台: +- `zhipin`:BOSS 直聘(默认) +- `zhilian`:智联招聘 +- `qiancheng`:前程无忧 + +--- + +## 🔑 核心模块详解 + +### 一、认证模块 (auth/) + +#### 1.1 AuthManager +- **职责**:统一认证管理,令牌生命周期 +- **核心方法**: + - `login()` - 多种登录方式 + - `get_token()` - 获取加密令牌 + - `check_auth()` - 认证状态检查 +- **安全特性**:Fernet + PBKDF2 机器绑定加密 + +#### 1.2 TokenStore +- **存储**:`~/.boss-agent/tokens.json` +- **加密**:AES-256-GCM +- **绑定**:机器指纹绑定 + +### 二、API 客户端 (api/) + +#### 2.1 BossClient(混合客户端) +- **设计**:低风险用 httpx,高风险用浏览器 +- **核心特性**: + - 请求节流(高斯延迟) + - 自动重试(3次) + - Cookie 合并 + - 合规检查 + +#### 2.2 RequestThrottle +- **策略**:高斯分布延迟 +- **范围**:可配置(如 1.5-3.0 秒) + +### 三、平台适配 (platforms/) + +#### 3.1 Platform 基类 +```python +class Platform(ABC): + # 元信息 + name: str + display_name: str + base_url: str + + # 核心方法 + @abstractmethod + def search_jobs(self, keyword, **kwargs) + @abstractmethod + def get_job_detail(self, security_id, job_id) + @abstractmethod + def get_user_info(self) +``` + +#### 3.2 平台实现 +- `ZhipinPlatform`:BOSS 直聘 +- `ZhipinRecruiterPlatform`:BOSS 直聘招聘者 +- `ZhilianPlatform`:智联招聘 +- `QianchengPlatform`:前程无忧(占位) + +### 四、命令层 (commands/) + +#### 4.1 命令注册 +- `register_candidate_commands()`:求职者命令 +- `register_recruiter_commands()`:招聘者命令 + +#### 4.2 核心命令 +| 命令 | 功能 | +|------|------| +| `search` | 职位搜索 + 福利筛选 | +| `detail` | 查看详情 | +| `shortlist` | 本地候选池 | +| `ai` | AI 增强(6个子命令) | +| `hr jobs` | 招聘者职位管理 | + +### 五、AI 增强 (ai/) + +#### 5.1 AI 命令 +- `ai analyze-jd`:JD 分析 +- `ai polish`:简历润色 +- `ai optimize`:定向优化 +- `ai fit`:候选池匹配 +- `ai interview-prep`:模拟面试 +- `ai chat-coach`:沟通指导 + +#### 5.2 AIService +- 支持 OpenAI / Anthropic +- Prompt 模板库 +- Token 用量追踪 + +### 六、福利筛选(核心差异化) + +#### 6.1 工作原理 +```python +--welfare "双休,五险一金" +``` +- 自动翻页补抓 +- AND 逻辑真实匹配 +- 本地匹配分排序 + +#### 6.2 匹配分数 +- 精确匹配权重 +- 同义词支持 +- 组合优先 + +--- + +## 🔄 数据流程 + +### 典型搜索流程 + +``` +1. 用户输入 + ↓ +2. CLI 解析参数 → 加载配置 + ↓ +3. 合规检查 + ↓ +4. AuthManager 获取令牌 + ↓ +5. Platform.search_jobs() + ↓ +6. BossClient 调用 API + ↓ +7. RequestThrottle 节流 + ↓ +8. CacheStore 查询缓存 + ↓ +9. 福利筛选(如指定 --welfare) + ↓ +10. JSON 信封输出 + ↓ +11. 终端展示 +``` + +### 候选池流程 + +``` +1. detail → 提取信息 + ↓ +2. shortlist add → 本地保存 + ↓ +3. 标签/备注管理 + ↓ +4. stats → 统计分析 + ↓ +5. watch → 监控变化 +``` + +--- + +## 🤖 Agent 集成方式 + +### 方式一:MCP(推荐) +```json +{ + "mcpServers": { + "boss-agent": { + "command": "uvx", + "args": ["--from", "boss-agent-cli[mcp]", "boss-mcp"] + } + } +} +``` + +### 方式二:Subprocess +```bash +boss schema # 能力自描述 +boss search "Python" +``` + +### 方式三:Python SDK +```python +from boss_agent_cli import AuthManager, BossClient + +with BossClient(AuthManager(...)) as client: + result = client.search_jobs("Python") +``` + +--- + +## 🛡️ 安全设计 + +### 1. 令牌安全 +- Fernet 加密存储 +- PBKDF2 密钥派生 +- 机器指纹绑定 + +### 2. 合规边界 +- 敏感操作默认阻断 +- 平台风险分级 +- 不规避风控 + +### 3. 请求节流 +- 高斯分布延迟 +- 避免被识别为机器人 + +--- + +## 📊 技术栈 + +| 组件 | 技术 | +|------|------| +| CLI 框架 | Click | +| HTTP 客户端 | httpx | +| 浏览器自动化 | patchright / CDP | +| 缓存 | SQLite WAL | +| 加密 | cryptography (Fernet) | +| AI | OpenAI / Anthropic API | +| 测试 | pytest | +| 类型 | Python >= 3.10 + py.typed | + +--- + +## 🎓 快速入门路径 + +### 1. 环境准备 +```bash +uv tool install boss-agent-cli +patchright install chromium +``` + +### 2. 基础流程 +```bash +boss doctor +boss login +boss search "Python" --city 北京 --welfare "双休" +boss detail +boss shortlist add +``` + +### 3. 进阶 +```bash +boss ai analyze-jd --id +boss ai polish +boss stats +``` + +--- + +## 🔍 关键文件索引 + +| 路径 | 重要度 | 说明 | +|------|--------|------| +| `main.py` | ⭐⭐⭐⭐⭐ | CLI 入口 | +| `platforms/base.py` | ⭐⭐⭐⭐⭐ | 平台抽象设计 | +| `auth/manager.py` | ⭐⭐⭐⭐⭐ | 认证管理器 | +| `api/client.py` | ⭐⭐⭐⭐⭐ | 混合客户端 | +| `compliance.py` | ⭐⭐⭐⭐ | 合规边界 | +| `commands/search.py` | ⭐⭐⭐⭐ | 搜索 + 福利筛选 | +| `ai/service.py` | ⭐⭐⭐⭐ | AI 服务 | +| `output.py` | ⭐⭐⭐ | JSON 信封 | + +--- + +## 📚 相关文档 + +- [快速上手](docs/getting-started.md) +- [命令参考](docs/commands.md) +- [Agent 集成](docs/agent-quickstart.md) +- [平台抽象](docs/platform-abstraction.md) +- [平台风险](docs/platform-risk.md) +- [排障指南](docs/troubleshooting.md) diff --git a/QUICKSTART_GUIDE.md b/QUICKSTART_GUIDE.md new file mode 100644 index 0000000..b5eb862 --- /dev/null +++ b/QUICKSTART_GUIDE.md @@ -0,0 +1,403 @@ +# boss-agent-cli 快速入门指南 + +## 🚀 5分钟上手 + +### 1. 安装 +```bash +# 使用 uv(推荐) +uv tool install boss-agent-cli + +# 或者使用 pip +pip install boss-agent-cli + +# 安装浏览器内核(用于登录) +patchright install chromium +``` + +### 2. 运行环境检查 +```bash +boss doctor +``` + +### 3. 登录 +```bash +boss login +``` +会弹出浏览器,扫码登录即可。 + +### 4. 搜索职位 +```bash +# 基础搜索 +boss search "Python" + +# 带福利筛选(核心功能!) +boss search "Python" --city 北京 --welfare "双休,五险一金" + +# 按匹配分排序 +boss search "Python" --welfare "双休,五险一金" --sort score +``` + +### 5. 查看详情 +```bash +boss detail +``` + +### 6. 加入候选池 +```bash +boss shortlist add --tags 后端,远程 --notes "薪资合适" +``` + +### 7. 查看统计 +```bash +boss stats +``` + +--- + +## 🎯 核心功能详解 + +### 1️⃣ 福利筛选(最核心!) + +#### 工作原理 +``` +搜索 → 多页补抓 → 匹配福利 → 按匹配分排序 +``` + +#### 使用示例 +```bash +# 简单单福利 +boss search "Python" --welfare "双休" + +# 多福利(AND 逻辑,必须同时满足) +boss search "Python" --welfare "双休,五险一金,弹性工作" + +# 从预设选择 +boss preset +boss search "Python" --preset my-favorite +``` + +#### 匹配分数规则 +- 精确匹配 → 高分 +- 同义词匹配 → 中分 +- 部分匹配 → 低分 +- 组合匹配 → 额外加分 + +### 2️⃣ 本地候选池 + +#### 候选池操作 +```bash +# 加入候选池 +boss shortlist add --tags 远程 --notes "CEO 直招" + +# 查看候选池 +boss shortlist list + +# 按标签筛选 +boss shortlist list --tag 远程 + +# 对比多个职位 +boss shortlist compare --tag 远程 + +# 导出候选池 +boss export shortlist --format json +``` + +#### 候选池字段 +- `security_id` / `job_id` - 职位唯一标识 +- `tags` - 标签(可多个) +- `notes` - 备注 +- `timestamp` - 添加时间 +- `match_score` - 福利匹配分(如有) + +### 3️⃣ AI 求职增强 + +#### AI 命令列表 +```bash +# JD 分析 +boss ai analyze-jd --id + +# 简历润色 +boss ai polish + +# 定向优化(为特定职位优化简历) +boss ai optimize --id + +# 候选池匹配(找出最适合你的) +boss ai fit + +# 模拟面试 +boss ai interview-prep --id + +# 沟通指导(帮你准备给 HR 发消息) +boss ai chat-coach --id +``` + +#### 配置 AI +```bash +boss config set openai-api-key sk-xxx +boss config set model gpt-4 +``` + +### 4️⃣ 多平台支持 + +#### 切换平台 +```bash +# 单次使用 +boss --platform zhilian search "Python" + +# 设为默认 +boss config set platform zhilian +``` + +#### 支持平台 +| 平台 | 求职者 | 招聘者 | +|------|--------|--------| +| BOSS 直聘 | ✅ | ✅ | +| 智联招聘 | 🟡 | ❌ | +| 前程无忧 | 🚧 | ❌ | + +--- + +## 📊 典型工作流 + +### 求职工作流 +``` +1. boss preset ← 设置常用筛选条件 +2. boss search ← 搜索职位 + 福利筛选 +3. boss detail ← 查看感兴趣的职位 +4. boss shortlist add ← 加入候选池 +5. boss ai optimize ← AI 优化简历 +6. boss watch ← 监控候选池变化 +7. boss stats ← 查看统计 +``` + +### 探索工作流 +``` +1. boss search "Python" --city 上海 +2. boss show ← 查看缓存的搜索结果 +3. boss history ← 查看搜索历史 +4. boss shortlist compare ← 对比候选职位 +``` + +--- + +## ⚙️ 配置详解 + +### 配置文件位置 +``` +~/.boss-agent/config.json +``` + +### 常用配置项 +```bash +# 查看所有配置 +boss config list + +# 设置默认城市 +boss config set default_city 北京 + +# 设置默认薪资 +boss config set default_salary "20-40K" + +# 设置请求延迟 +boss config set request_delay 1.5 3.0 + +# 设置日志级别 +boss config set log_level info + +# 重置为默认 +boss config reset +``` + +--- + +## 🔒 合规边界(重要!) + +### 默认低风险模式 +- ✅ 允许:搜索、查看详情、本地候选池、查看推荐 +- ❌ 阻断:打招呼、投递、交换联系方式、聊天、候选人搜索 + +### 低风险模式的好处 +- 安全,不会触发平台风控 +- 专注于辅助,而不是自动化 +- 符合平台服务条款 + +### 手动操作 +``` +# 浏览到职位页面 +boss detail --open + +# 然后在浏览器中手动操作 +``` + +--- + +## 🛠️ 诊断与排障 + +### 环境检查 +```bash +boss doctor +``` + +### 登录状态 +```bash +boss status +boss status --live # 实时检查 +``` + +### 常见问题 + +#### Q: 提示认证过期? +```bash +boss login +``` + +#### Q: 提示风控拦截? +- 等待一段时间再试 +- 减少请求频率 +- 在浏览器手动操作几次 + +#### Q: Cookie 提取失败? +```bash +boss doctor --live-probe +``` + +--- + +## 🤖 Agent 集成 + +### 方式一:MCP(推荐) +```json +{ + "mcpServers": { + "boss-agent": { + "command": "uvx", + "args": ["--from", "boss-agent-cli[mcp]", "boss-mcp"] + } + } +} +``` + +### 方式二:Subprocess +```bash +# 1. 获取能力自描述 +boss schema + +# 2. 调用命令 +boss search "Python" --format json +``` + +### 方式三:Python SDK +```python +from boss_agent_cli import AuthManager, BossClient + +# 使用上下文管理器 +with BossClient(AuthManager()) as client: + result = client.search_jobs("Python", city="北京") + print(result) +``` + +--- + +## 📚 进阶主题 + +### 1. 预设(Preset)管理 +```bash +# 创建预设 +boss preset create my-preset --welfare "双休,五险一金" --city 上海 + +# 使用预设 +boss search "Python" --preset my-preset + +# 列出预设 +boss preset list +``` + +### 2. 监控(Watch) +```bash +# 监控候选池变化 +boss watch --interval 300 + +# 有变化时发通知 +boss watch --notify +``` + +### 3. 数据导出 +```bash +# 导出候选池 +boss export shortlist --format json + +# 导出搜索历史 +boss export history --format csv + +# 自定义导出目录 +boss export shortlist --dir ./exports +``` + +--- + +## 💡 使用技巧 + +### 1. 利用缓存 +```bash +# 查看缓存的搜索结果 +boss show + +# 从缓存重新筛选 +boss show --filter "双休" +``` + +### 2. 标签系统 +```bash +# 使用标签分类 +boss shortlist add --tags 远程,高薪,国企 + +# 按标签查看 +boss shortlist list --tag 远程 +``` + +### 3. 笔记功能 +```bash +boss shortlist add --notes "需要 3 年经验,HR 态度很好" +``` + +--- + +## 🎓 学习路径 + +### 第1天:基础操作 +- ✅ 安装 +- ✅ 登录 +- ✅ 基础搜索 +- ✅ 查看详情 + +### 第2-3天:进阶功能 +- ✅ 福利筛选 +- ✅ 候选池管理 +- ✅ 标签和笔记 +- ✅ 统计分析 + +### 第4-7天:AI 增强 +- ✅ AI 配置 +- ✅ JD 分析 +- ✅ 简历优化 +- ✅ 模拟面试 + +### 第2周+:Agent 集成 +- ✅ MCP 集成 +- ✅ Python SDK +- ✅ 自定义工作流 + +--- + +## 📖 更多文档 + +- [完整项目架构](./PROJECT_FRAMEWORK.md) +- [命令参考](./docs/commands.md) +- [Agent 集成](./docs/agent-quickstart.md) +- [平台风险](./docs/platform-risk.md) +- [排障指南](./docs/troubleshooting.md) + +--- + +祝你求职顺利!🎯 diff --git a/START_HERE.md b/START_HERE.md new file mode 100644 index 0000000..913f64d --- /dev/null +++ b/START_HERE.md @@ -0,0 +1,92 @@ +# 🎉 boss-agent-cli 已成功配置! + +## 📁 项目位置 +``` +d:\download\py_test\boss-agent-cli\ +``` + +## 🚀 快速启动命令 + +### 方式一:使用 `run.py` 启动(推荐) +```bash +cd d:\download\py_test\boss-agent-cli +python run.py --help +python run.py doctor +``` + +### 方式二:所有命令的运行方式 +所有 `boss xxx` 命令,替换为: +```bash +python run.py xxx +``` + +--- + +## 🔐 登录 BOSS 直聘 + +```bash +python run.py login +``` + +这会打开浏览器让你扫码登录。 + +--- + +## 📊 登录成功后常用命令 + +```bash +# 检查登录状态 +python run.py status + +# 搜索职位 +python run.py search "Python" --city 北京 + +# 搜索 + 福利筛选(核心功能) +python run.py search "Python" --city 北京 --welfare "双休,五险一金" + +# 查看职位详情 +python run.py detail + +# 加入候选池 +python run.py shortlist add --tags 远程 + +# 查看统计 +python run.py stats +``` + +--- + +## 📚 文档索引 + +- `INSTALL_GUIDE.md` - 安装指南 +- `QUICKSTART_GUIDE.md` - 5分钟快速入门 +- `PROJECT_FRAMEWORK.md` - 完整架构文档 + +--- + +## ⚠️ 注意事项 + +1. 如果需要浏览器登录功能,需要安装 chromium: +```bash +pip install patchright +patchright install chromium +``` + +2. 推荐先试用小模型/简单功能,确认没问题再深入! + +--- + +## 🎯 下一步行动 + +```bash +# 1. 登录(必须先做) +python run.py login + +# 2. 检查登录状态 +python run.py status + +# 3. 搜索职位(试试水) +python run.py search "Python" +``` + +祝你使用愉快!🚀 diff --git a/examples/deepseek_agent.py b/examples/deepseek_agent.py new file mode 100644 index 0000000..f6a51f9 --- /dev/null +++ b/examples/deepseek_agent.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""DeepSeek Agent 示例 — 驱动 boss-agent-cli 自主求职。 + +用法: + python run.py agent config --api-key + python run.py login + python examples/deepseek_agent.py "搜索北京 Python 岗位,过滤外包和低薪" +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from boss_agent_cli.agent.runner import AgentRunner + + +def main() -> None: + goal = sys.argv[1] if len(sys.argv) > 1 else ( + "搜索 Python 开发岗位,过滤外包和劳务派遣,列出推荐职位并说明原因" + ) + data_dir = Path.home() / ".boss-agent" + + with AgentRunner(data_dir) as runner: + result = runner.run_autonomous( + goal, + extra_rules="最低薪资 15K;排除外包、劳务派遣、异地远程", + ) + + print("=== Agent 总结 ===") + print(result.get("summary", "")) + if result.get("tool_calls"): + print(f"\n共调用 {len(result['tool_calls'])} 次工具") + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..006c340 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +click>=8.0 +httpx>=0.27 +cryptography>=42.0 +patchright>=1.58.2 +browser-cookie3>=0.16.2 +rich>=13.0 +pyyaml>=6.0 +websockets>=12.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..e94dceb --- /dev/null +++ b/run.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +直接运行 boss-agent-cli 入口 +无需安装 pip install -e . +""" + +import sys +from pathlib import Path + +# 把 src 目录加入到 Python 路径 +project_root = Path(__file__).parent +src_dir = project_root / "src" +if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + +# 导入并运行 CLI +from boss_agent_cli.main import cli + +if __name__ == "__main__": + cli() diff --git a/src/boss_agent_cli/agent/__init__.py b/src/boss_agent_cli/agent/__init__.py new file mode 100644 index 0000000..26b5d6e --- /dev/null +++ b/src/boss_agent_cli/agent/__init__.py @@ -0,0 +1,17 @@ +"""自主决策 Agent 层 — 基于 LLM 的智能决策与执行。""" + +from boss_agent_cli.agent.chat_agent import ChatAgent +from boss_agent_cli.agent.config import AgentConfig +from boss_agent_cli.agent.job_agent import JobAgent +from boss_agent_cli.agent.orchestrator import AgentOrchestrator +from boss_agent_cli.agent.runner import AgentRunner +from boss_agent_cli.agent.toolkit import AgentToolkit + +__all__ = [ + "AgentConfig", + "AgentOrchestrator", + "AgentRunner", + "AgentToolkit", + "ChatAgent", + "JobAgent", +] diff --git a/src/boss_agent_cli/agent/chat_agent.py b/src/boss_agent_cli/agent/chat_agent.py new file mode 100644 index 0000000..cae568e --- /dev/null +++ b/src/boss_agent_cli/agent/chat_agent.py @@ -0,0 +1,103 @@ +"""聊天智能 Agent — 基于 LLM 的聊天分析与自动交互决策。""" + +from typing import Any + +from boss_agent_cli.agent.utils import parse_llm_json +from boss_agent_cli.ai.service import AIService + + +class ChatAgent: + """聊天智能决策 Agent。""" + + def __init__(self, ai_service: AIService): + self.ai_service = ai_service + + def analyze_chat_context( + self, + chat_history: list[dict[str, Any]], + job_info: dict[str, Any] | None = None, + ) -> dict[str, Any]: + chat_text = self._format_chat_history(chat_history) + job_text = self._format_job_info(job_info) if job_info else "无职位信息" + + prompt = f"""你是一位智能求职助手。请分析以下聊天记录,判断是否需要上传文件。 + +职位信息: +{job_text} + +聊天记录: +{chat_text} + +请以 JSON 格式返回分析结果: +{{ + "should_upload_resume": false, + "should_upload_salary_proof": false, + "should_upload_education": false, + "should_reply": false, + "reply_message": "建议的回复消息(30-80字)", + "reason": "决策原因", + "urgency": "high" +}} + +判断标准: +- 招聘者询问简历、要求发简历 → should_upload_resume: true +- 招聘者询问薪资、期望薪资 → should_upload_salary_proof: true +- 招聘者询问学历、学位 → should_upload_education: true +- 招聘者提出问题需要回复 → should_reply: true +- 若岗位/聊天涉及外包、劳务派遣,在 reason 中注明 + +只返回 JSON,不要包含其他内容。""" + + try: + response = self.ai_service.chat([{"role": "user", "content": prompt}]) + return parse_llm_json(response) + except Exception as exc: + return { + "should_upload_resume": False, + "should_upload_salary_proof": False, + "should_upload_education": False, + "should_reply": False, + "reason": f"LLM 分析失败: {exc}", + "urgency": "low", + } + + def _format_chat_history(self, chat_history: list[dict[str, Any]]) -> str: + if not chat_history: + return "无聊天记录" + lines = [] + for msg in chat_history: + role = msg.get("from", "unknown") + if role in ("boss", "招聘者"): + role_name = "招聘者" + elif role in ("me", "我"): + role_name = "我" + else: + role_name = str(role) + content = msg.get("text") or msg.get("msg", "") + lines.append(f"{role_name}: {content}") + return "\n".join(lines) + + def _format_job_info(self, job_info: dict[str, Any]) -> str: + return f""" +职位名称: {job_info.get('title', '')} +公司名称: {job_info.get('brandName') or job_info.get('company', '')} +薪资: {job_info.get('salary', '')} +""" + + def generate_reply(self, chat_history: list[dict[str, Any]], context: str = "") -> str: + chat_text = self._format_chat_history(chat_history) + prompt = f"""你是一位专业的求职者。请根据以下聊天记录生成一条回复。 + +聊天记录: +{chat_text} + +上下文: +{context} + +请生成一条 30-80 字的回复,专业、礼貌。只返回回复内容。""" + + try: + response = self.ai_service.chat([{"role": "user", "content": prompt}]) + return response.strip() + except Exception: + return "您好,我对该岗位很感兴趣,希望能进一步了解。" diff --git a/src/boss_agent_cli/agent/config.py b/src/boss_agent_cli/agent/config.py new file mode 100644 index 0000000..5c2dad8 --- /dev/null +++ b/src/boss_agent_cli/agent/config.py @@ -0,0 +1,76 @@ +"""Agent配置管理""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from boss_agent_cli.ai.config import AIConfigStore + +_DEFAULT_ASSETS: dict[str, str | None] = { + "resume_path": None, + "salary_proof_path": None, + "education_proof_path": None, +} + + +class AgentConfig: + """Agent 配置:AI 密钥 + 本地上传素材路径。""" + + def __init__(self, data_dir: Path): + self.data_dir = data_dir + self.ai_config = AIConfigStore(data_dir) + self._assets_path = data_dir / "ai" / "agent_assets.json" + + def configure_deepseek(self, api_key: str, model: str = "deepseek-chat") -> None: + self.ai_config.save_api_key(api_key) + self.ai_config.save_config( + ai_provider="deepseek", + ai_model=model, + ai_base_url="https://api.deepseek.com/v1", + ai_temperature=0.7, + ai_max_tokens=4096, + ) + + def get_ai_config(self) -> dict[str, Any]: + return self.ai_config.load_config() + + def get_api_key(self) -> str | None: + return self.ai_config.get_api_key() + + def get_base_url(self) -> str | None: + return self.ai_config.get_base_url() + + def is_configured(self) -> bool: + return self.ai_config.is_configured() + + def save_assets( + self, + *, + resume_path: str | None = None, + salary_proof_path: str | None = None, + education_proof_path: str | None = None, + ) -> None: + current = self.get_assets() + if resume_path is not None: + current["resume_path"] = resume_path + if salary_proof_path is not None: + current["salary_proof_path"] = salary_proof_path + if education_proof_path is not None: + current["education_proof_path"] = education_proof_path + self._assets_path.parent.mkdir(parents=True, exist_ok=True) + self._assets_path.write_text( + json.dumps(current, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def get_assets(self) -> dict[str, str | None]: + assets = dict(_DEFAULT_ASSETS) + if self._assets_path.exists(): + try: + saved = json.loads(self._assets_path.read_text(encoding="utf-8")) + assets.update(saved) + except (json.JSONDecodeError, OSError): + pass + return assets diff --git a/src/boss_agent_cli/agent/job_agent.py b/src/boss_agent_cli/agent/job_agent.py new file mode 100644 index 0000000..6e25374 --- /dev/null +++ b/src/boss_agent_cli/agent/job_agent.py @@ -0,0 +1,72 @@ +"""职位智能 Agent — 基于 LLM 的职位筛选与决策。""" + +from typing import Any + +from boss_agent_cli.agent.utils import parse_llm_json +from boss_agent_cli.ai.service import AIService +from boss_agent_cli.tools.filter_tools import FilterTools + + +class JobAgent: + """职位智能决策 Agent。""" + + def __init__(self, ai_service: AIService, filter_tools: FilterTools): + self.ai_service = ai_service + self.filter_tools = filter_tools + + def analyze_job(self, job_data: dict[str, Any]) -> dict[str, Any]: + should_filter, filter_reason = self.filter_tools.should_filter_job(job_data) + if should_filter: + return {"recommend": False, "reason": filter_reason, "method": "rule_based"} + + job_text = self._format_job_text(job_data) + prompt = f"""你是一位资深的求职顾问。请分析以下职位是否值得推荐。 + +职位信息: +{job_text} + +请以 JSON 格式返回分析结果: +{{ + "recommend": true, + "score": 85, + "reason": "推荐/不推荐的原因", + "highlights": ["亮点1"], + "concerns": ["顾虑1"], + "suggested_action": "建议采取的行动" +}} + +只返回 JSON,不要包含其他内容。""" + + try: + response = self.ai_service.chat([{"role": "user", "content": prompt}]) + result = parse_llm_json(response) + result["method"] = "llm_based" + return result + except Exception: + return { + "recommend": True, + "reason": "LLM 分析失败,通过规则过滤", + "method": "fallback", + } + + def _format_job_text(self, job_data: dict[str, Any]) -> str: + return f""" +职位名称: {job_data.get('title', '')} +公司名称: {job_data.get('company') or job_data.get('brandName', '')} +薪资: {job_data.get('salary', '')} +城市: {job_data.get('city', '')} +经验要求: {job_data.get('experience', '')} +学历要求: {job_data.get('education', '')} +公司规模: {job_data.get('scale', '')} +行业: {job_data.get('industry', '')} +职位描述: {job_data.get('description', '')} +""" + + def filter_and_analyze_jobs(self, jobs: list[dict[str, Any]]) -> list[dict[str, Any]]: + results = [] + for job in jobs: + analysis = self.analyze_job(job) + job = {**job, "_analysis": analysis} + if analysis.get("recommend"): + results.append(job) + return results diff --git a/src/boss_agent_cli/agent/orchestrator.py b/src/boss_agent_cli/agent/orchestrator.py new file mode 100644 index 0000000..b05f914 --- /dev/null +++ b/src/boss_agent_cli/agent/orchestrator.py @@ -0,0 +1,91 @@ +"""DeepSeek / OpenAI 兼容 tool-calling Agent 循环。""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from boss_agent_cli.agent.toolkit import AgentToolkit +from boss_agent_cli.ai.service import AIService + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """你是 BOSS 直聘求职自动化助手。你可以调用工具搜索职位、读聊天记录、过滤不合适岗位、打标签、规划附件上传。 + +## 过滤规则(必须遵守) +- 排除外包、外派、驻场、第三方人力 +- 排除劳务派遣、劳务公司 +- 若用户要求排除异地/远程,则过滤含「异地」「远程办公」「居家」等描述的岗位 +- 排除薪资低于用户指定最低 K 数的岗位 + +## 聊天交互 +- 读 chat 后分析招聘者是否索要简历、薪资、学历证明 +- 需要上传时调用 get_upload_assets 和 plan_upload +- 对明确的外包/劳务派遣岗位,mark_contact 标签「不合适」 +- 生成专业、简短的回复建议(30–80 字);当前版本不能自动发送消息,在最终回答里列出建议回复 + +## 输出 +- 每轮工具调用后根据结果继续决策 +- 任务完成时用中文总结:推荐岗位、已过滤原因、聊天处理建议、待上传文件路径 +""" + + +class AgentOrchestrator: + """LLM + tools 自主决策循环。""" + + def __init__( + self, + ai_service: AIService, + toolkit: AgentToolkit, + *, + max_rounds: int = 12, + ) -> None: + self.ai_service = ai_service + self.toolkit = toolkit + self.max_rounds = max_rounds + + def run(self, user_goal: str, *, extra_system: str = "") -> dict[str, Any]: + system = SYSTEM_PROMPT + if extra_system: + system = f"{system}\n\n## 用户附加规则\n{extra_system}" + + messages: list[dict[str, Any]] = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_goal}, + ] + tool_calls_log: list[dict[str, Any]] = [] + + for round_idx in range(self.max_rounds): + completion = self.ai_service.chat_completion( + messages, + tools=self.toolkit.openai_tools(), + ) + assistant_msg = completion["assistant_message"] + messages.append(assistant_msg) + + if not completion["tool_calls"]: + return { + "ok": True, + "summary": completion["content"] or "", + "rounds": round_idx + 1, + "tool_calls": tool_calls_log, + } + + for call in completion["tool_calls"]: + args = json.loads(call["arguments"]) if call["arguments"] else {} + result = self.toolkit.execute(call["name"], args) + tool_calls_log.append({"tool": call["name"], "arguments": args, "result": result}) + logger.debug("tool %s -> %s", call["name"], result.get("ok")) + messages.append({ + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps(result, ensure_ascii=False), + }) + + return { + "ok": False, + "summary": "已达到最大工具调用轮次,请缩小任务范围后重试。", + "rounds": self.max_rounds, + "tool_calls": tool_calls_log, + } diff --git a/src/boss_agent_cli/agent/runner.py b/src/boss_agent_cli/agent/runner.py new file mode 100644 index 0000000..bf98385 --- /dev/null +++ b/src/boss_agent_cli/agent/runner.py @@ -0,0 +1,140 @@ +"""Agent主循环 - 自主决策与执行""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from boss_agent_cli.agent.chat_agent import ChatAgent +from boss_agent_cli.agent.config import AgentConfig +from boss_agent_cli.agent.job_agent import JobAgent +from boss_agent_cli.agent.orchestrator import AgentOrchestrator +from boss_agent_cli.agent.toolkit import AgentToolkit +from boss_agent_cli.ai.service import AIService, AIServiceError +from boss_agent_cli.api.client import BossClient +from boss_agent_cli.api.models import JobItem +from boss_agent_cli.auth.manager import AuthManager +from boss_agent_cli.platforms import get_platform +from boss_agent_cli.tools.chat_tools import ChatTools +from boss_agent_cli.tools.filter_tools import FilterTools +from boss_agent_cli.tools.job_tools import JobTools + +logger = logging.getLogger(__name__) + + +class AgentRunner: + """Agent 主运行器。""" + + def __init__(self, data_dir: Path, platform_name: str = "zhipin", cdp_url: str | None = None): + self.data_dir = data_dir + self.platform_name = platform_name + self.agent_config = AgentConfig(data_dir) + + if not self.agent_config.is_configured(): + raise RuntimeError("AI 服务未配置,请先执行: python run.py agent config --api-key ") + + ai_cfg = self.agent_config.get_ai_config() + api_key = self.agent_config.get_api_key() + base_url = self.agent_config.get_base_url() + if not api_key or not base_url: + raise RuntimeError("AI 配置不完整,请重新执行 agent config") + + self.ai_service = AIService( + base_url=base_url, + api_key=api_key, + model=str(ai_cfg["ai_model"]), + temperature=float(ai_cfg.get("ai_temperature", 0.7)), + max_tokens=int(ai_cfg.get("ai_max_tokens", 4096)), + ) + + self.auth = AuthManager(data_dir, platform=platform_name) + self.client = BossClient(self.auth, cdp_url=cdp_url) + self.platform = get_platform(platform_name)(self.client) + + self.filter_tools = FilterTools( + min_salary=15, + exclude_outsourcing=True, + exclude_dispatch=True, + exclude_remote=True, + ) + self.job_tools = JobTools(self.client) + self.chat_tools = ChatTools(self.client) + self.job_agent = JobAgent(self.ai_service, self.filter_tools) + self.chat_agent = ChatAgent(self.ai_service) + self.toolkit = AgentToolkit( + self.platform, + self.job_tools, + self.chat_tools, + self.filter_tools, + self.job_agent, + self.chat_agent, + self.agent_config, + ) + self.orchestrator = AgentOrchestrator(self.ai_service, self.toolkit) + + def run_job_search_and_filter(self, query: str, **filters: Any) -> list[dict[str, Any]]: + logger.info("搜索职位: %s", query) + resp = self.job_tools.search_jobs(query, **filters) + if resp.get("code") != 0: + logger.error("搜索失败: %s", resp.get("message")) + return [] + raw_jobs = (resp.get("zpData") or {}).get("jobList") or [] + jobs = [JobItem.from_api(item).to_dict() for item in raw_jobs] + logger.info("搜索到 %d 个职位", len(jobs)) + filtered = self.job_agent.filter_and_analyze_jobs(jobs) + logger.info("过滤后剩余 %d 个", len(filtered)) + return filtered + + def run_chat_analysis(self, security_id: str, job_info: dict[str, Any] | None = None) -> dict[str, Any]: + result = self.toolkit.execute("analyze_chat", { + "security_id": security_id, + "job_title": (job_info or {}).get("title", ""), + "company": (job_info or {}).get("company", ""), + }) + return result + + def run_autonomous(self, goal: str, *, extra_rules: str = "") -> dict[str, Any]: + try: + return self.orchestrator.run(goal, extra_system=extra_rules) + except AIServiceError as exc: + return {"ok": False, "summary": f"AI 调用失败: {exc}", "tool_calls": []} + + def process_all_chats(self, *, auto_mark_unsuitable: bool = True) -> dict[str, Any]: + """批量分析沟通列表中的会话。""" + listing = self.toolkit.execute("list_chats", {"page": 1}) + if not listing.get("ok"): + return listing + sessions: list[dict[str, Any]] = [] + for friend in listing.get("friends") or []: + sid = friend.get("security_id") + if not sid: + continue + analysis_result = self.toolkit.execute("analyze_chat", { + "security_id": sid, + "job_title": friend.get("title", ""), + "company": friend.get("company", ""), + }) + entry: dict[str, Any] = { + "friend": friend, + "analysis": analysis_result.get("analysis") if analysis_result.get("ok") else None, + "upload_plan": analysis_result.get("upload_plan") if analysis_result.get("ok") else None, + "error": analysis_result.get("error") if not analysis_result.get("ok") else None, + } + if auto_mark_unsuitable and analysis_result.get("ok"): + analysis = analysis_result.get("analysis") or {} + reason = str(analysis.get("reason", "")) + if any(kw in reason for kw in ("外包", "劳务派遣", "外派")): + mark = self.toolkit.execute("mark_contact", {"security_id": sid, "label": "不合适"}) + entry["marked_unsuitable"] = mark.get("ok", False) + sessions.append(entry) + return {"ok": True, "sessions": sessions, "count": len(sessions)} + + def close(self) -> None: + self.client.close() + + def __enter__(self) -> "AgentRunner": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.close() diff --git a/src/boss_agent_cli/agent/toolkit.py b/src/boss_agent_cli/agent/toolkit.py new file mode 100644 index 0000000..9cb5bb1 --- /dev/null +++ b/src/boss_agent_cli/agent/toolkit.py @@ -0,0 +1,386 @@ +"""Agent 原生工具集 — 直接调用 Platform / BossClient,供 LLM tool calling 使用。""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from boss_agent_cli.agent.config import AgentConfig +from boss_agent_cli.agent.chat_agent import ChatAgent +from boss_agent_cli.agent.job_agent import JobAgent +from boss_agent_cli.agent.utils import parse_llm_json +from boss_agent_cli.api.models import JobItem +from boss_agent_cli.commands.contact_lookup import FriendLookupLimitExceeded, find_friend_by_security_id +from boss_agent_cli.platforms.base import Platform +from boss_agent_cli.tools.filter_tools import FilterTools +from boss_agent_cli.tools.job_tools import JobTools +from boss_agent_cli.tools.chat_tools import ChatTools + +_LABEL_MAP = { + "新招呼": 1, "沟通中": 2, "已约面": 3, "已获取简历": 4, + "已交换电话": 5, "已交换微信": 6, "不合适": 7, "牛人发起": 8, "收藏": 11, +} + + +def _openai_tool(name: str, description: str, parameters: dict[str, Any]) -> dict[str, Any]: + return { + "type": "function", + "function": {"name": name, "description": description, "parameters": parameters}, + } + + +class AgentToolkit: + """封装 Agent 可调用的底层操作。""" + + def __init__( + self, + platform: Platform, + job_tools: JobTools, + chat_tools: ChatTools, + filter_tools: FilterTools, + job_agent: JobAgent, + chat_agent: ChatAgent, + agent_config: AgentConfig, + ) -> None: + self.platform = platform + self.job_tools = job_tools + self.chat_tools = chat_tools + self.filter_tools = filter_tools + self.job_agent = job_agent + self.chat_agent = chat_agent + self.agent_config = agent_config + + def openai_tools(self) -> list[dict[str, Any]]: + return [ + _openai_tool( + "search_jobs", + "按关键词搜索 BOSS 直聘职位,返回列表页摘要(不含完整 JD)。", + { + "type": "object", + "properties": { + "query": {"type": "string", "description": "搜索关键词"}, + "city": {"type": "string", "description": "城市,如 北京、上海"}, + "salary": {"type": "string", "description": "薪资区间,如 15-25K"}, + "page": {"type": "integer", "description": "页码,默认 1"}, + }, + "required": ["query"], + }, + ), + _openai_tool( + "get_job_detail", + "获取职位详情(含完整描述),用于判断外包/劳务派遣/异地/低薪。", + { + "type": "object", + "properties": { + "job_id": {"type": "string", "description": "encryptJobId"}, + "security_id": {"type": "string", "description": "securityId(可选,用于关联卡片)"}, + }, + "required": ["job_id"], + }, + ), + _openai_tool( + "filter_jobs", + "对职位列表做规则过滤(外包/劳务派遣/异地/低薪),可选 LLM 二次分析。", + { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": {"type": "object"}, + "description": "search_jobs 返回的 jobs 数组", + }, + "use_llm": {"type": "boolean", "description": "是否对通过规则的岗位做 LLM 分析,默认 false"}, + }, + "required": ["jobs"], + }, + ), + _openai_tool( + "list_chats", + "获取沟通列表(招聘者会话摘要)。", + { + "type": "object", + "properties": {"page": {"type": "integer", "description": "页码,默认 1"}}, + }, + ), + _openai_tool( + "get_chat_messages", + "获取与指定联系人的聊天记录。", + { + "type": "object", + "properties": { + "security_id": {"type": "string", "description": "联系人 securityId"}, + "page": {"type": "integer"}, + "count": {"type": "integer", "description": "条数,默认 30"}, + }, + "required": ["security_id"], + }, + ), + _openai_tool( + "analyze_chat", + "分析聊天记录,判断是否需要上传简历/薪资证明/学历截图,并生成回复建议。", + { + "type": "object", + "properties": { + "security_id": {"type": "string"}, + "job_title": {"type": "string"}, + "company": {"type": "string"}, + }, + "required": ["security_id"], + }, + ), + _openai_tool( + "mark_contact", + "给联系人打标签,如 不合适、沟通中、已获取简历。", + { + "type": "object", + "properties": { + "security_id": {"type": "string"}, + "label": {"type": "string", "description": "标签名:不合适/沟通中/已获取简历 等"}, + "remove": {"type": "boolean", "description": "是否移除标签"}, + }, + "required": ["security_id", "label"], + }, + ), + _openai_tool( + "get_upload_assets", + "读取本地配置的简历/薪资截图/学历截图路径,供上传决策使用。", + {"type": "object", "properties": {}}, + ), + _openai_tool( + "plan_upload", + "根据分析结果返回应上传的文件路径(实际上传需用户在 BOSS 网页或后续版本完成)。", + { + "type": "object", + "properties": { + "upload_resume": {"type": "boolean"}, + "upload_salary_proof": {"type": "boolean"}, + "upload_education": {"type": "boolean"}, + }, + }, + ), + ] + + def execute(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + handlers = { + "search_jobs": self._search_jobs, + "get_job_detail": self._get_job_detail, + "filter_jobs": self._filter_jobs, + "list_chats": self._list_chats, + "get_chat_messages": self._get_chat_messages, + "analyze_chat": self._analyze_chat, + "mark_contact": self._mark_contact, + "get_upload_assets": self._get_upload_assets, + "plan_upload": self._plan_upload, + } + handler = handlers.get(name) + if handler is None: + return {"ok": False, "error": f"未知工具: {name}"} + try: + return handler(arguments) + except Exception as exc: + return {"ok": False, "error": str(exc)} + + def _search_jobs(self, args: dict[str, Any]) -> dict[str, Any]: + query = args["query"] + filters: dict[str, Any] = {} + if city := args.get("city"): + filters["city"] = city + if salary := args.get("salary"): + filters["salary"] = salary + resp = self.job_tools.search_jobs(query, **filters) + if resp.get("code") != 0: + return {"ok": False, "error": resp.get("message", "搜索失败"), "raw": resp} + zp = resp.get("zpData") or {} + raw_jobs = zp.get("jobList") or [] + jobs = [JobItem.from_api(item).to_dict() for item in raw_jobs] + return {"ok": True, "count": len(jobs), "jobs": jobs} + + def _get_job_detail(self, args: dict[str, Any]) -> dict[str, Any]: + resp = self.job_tools.get_job_detail(args["job_id"]) + if resp.get("code") != 0: + return {"ok": False, "error": resp.get("message", "详情获取失败")} + zp = resp.get("zpData") or {} + job_info = zp.get("jobInfo") or zp + brand = zp.get("brandComInfo") or {} + merged = { + "job_id": args["job_id"], + "security_id": args.get("security_id", ""), + "title": job_info.get("jobName") or job_info.get("positionName", ""), + "company": brand.get("brandName") or job_info.get("brandName", ""), + "salary": job_info.get("salaryDesc", ""), + "city": job_info.get("locationName", ""), + "description": job_info.get("postDescription") or job_info.get("jobDesc", ""), + "experience": job_info.get("experienceName", ""), + "education": job_info.get("degreeName", ""), + } + should_filter, reason = self.filter_tools.should_filter_job(merged) + merged["rule_filter"] = {"filtered": should_filter, "reason": reason} + return {"ok": True, "job": merged} + + def _filter_jobs(self, args: dict[str, Any]) -> dict[str, Any]: + jobs = args.get("jobs") or [] + use_llm = bool(args.get("use_llm")) + kept: list[dict[str, Any]] = [] + rejected: list[dict[str, Any]] = [] + for job in jobs: + should_filter, reason = self.filter_tools.should_filter_job(job) + if should_filter: + rejected.append({**job, "_reject_reason": reason}) + continue + if use_llm: + analysis = self.job_agent.analyze_job(job) + job = {**job, "_analysis": analysis} + if not analysis.get("recommend"): + rejected.append({**job, "_reject_reason": analysis.get("reason", "LLM 不推荐")}) + continue + kept.append(job) + return {"ok": True, "kept": kept, "rejected": rejected, "kept_count": len(kept)} + + def _list_chats(self, args: dict[str, Any]) -> dict[str, Any]: + page = int(args.get("page") or 1) + resp = self.chat_tools.get_friend_list(page=page) + if not self.platform.is_success(resp): + code, message = self.platform.parse_error(resp) + return {"ok": False, "error": message, "code": code} + data = self.platform.unwrap_data(resp) or {} + items = data.get("result") or data.get("friendList") or [] + friends = [] + for item in items: + friends.append({ + "name": item.get("name", ""), + "title": item.get("title", ""), + "company": item.get("brandName", ""), + "last_msg": item.get("lastMsg", ""), + "security_id": item.get("securityId", ""), + "job_id": item.get("encryptJobId", ""), + "uid": str(item.get("uid", "")), + "unread": item.get("unreadMsgCount", 0), + }) + return {"ok": True, "friends": friends, "count": len(friends)} + + def _get_chat_messages(self, args: dict[str, Any]) -> dict[str, Any]: + security_id = args["security_id"] + try: + friend, err = find_friend_by_security_id(self.platform, security_id) + except FriendLookupLimitExceeded as exc: + return {"ok": False, "error": str(exc)} + if err is not None: + _, message = self.platform.parse_error(err) + return {"ok": False, "error": message or "沟通列表获取失败"} + if friend is None: + return {"ok": False, "error": f"未找到 security_id={security_id}"} + gid = str(friend.get("uid", "")) + resp = self.chat_tools.get_chat_history( + gid, security_id, + page=int(args.get("page") or 1), + count=int(args.get("count") or 30), + ) + if not self.platform.is_success(resp): + _, message = self.platform.parse_error(resp) + return {"ok": False, "error": message or "聊天记录获取失败"} + msg_data = self.platform.unwrap_data(resp) or {} + raw = msg_data.get("messages") or msg_data.get("historyMsgList") or [] + messages = [] + for msg in raw: + from_obj = msg.get("from") or {} + is_self = isinstance(from_obj, dict) and str(from_obj.get("uid", "")) != gid + messages.append({ + "from": "我" if is_self else "招聘者", + "text": msg.get("text") or msg.get("body", {}).get("text", ""), + "type": msg.get("type"), + }) + return { + "ok": True, + "security_id": security_id, + "friend_name": friend.get("name", ""), + "gid": gid, + "messages": messages, + } + + def _analyze_chat(self, args: dict[str, Any]) -> dict[str, Any]: + msg_result = self._get_chat_messages(args) + if not msg_result.get("ok"): + return msg_result + job_info = None + if args.get("job_title") or args.get("company"): + job_info = {"title": args.get("job_title", ""), "brandName": args.get("company", "")} + analysis = self.chat_agent.analyze_chat_context(msg_result["messages"], job_info) + assets = self.agent_config.get_assets() + upload_plan = self._plan_upload({ + "upload_resume": analysis.get("should_upload_resume"), + "upload_salary_proof": analysis.get("should_upload_salary_proof"), + "upload_education": analysis.get("should_upload_education"), + }) + return { + "ok": True, + "security_id": args["security_id"], + "friend_name": msg_result.get("friend_name"), + "analysis": analysis, + "upload_plan": upload_plan, + "message_count": len(msg_result["messages"]), + } + + def _mark_contact(self, args: dict[str, Any]) -> dict[str, Any]: + security_id = args["security_id"] + label = args["label"] + label_id = _LABEL_MAP.get(label) + if label_id is None and label.isdigit(): + label_id = int(label) + if label_id is None: + return {"ok": False, "error": f"未知标签: {label}"} + try: + friend, err = find_friend_by_security_id(self.platform, security_id) + except FriendLookupLimitExceeded as exc: + return {"ok": False, "error": str(exc)} + if err is not None: + _, message = self.platform.parse_error(err) + return {"ok": False, "error": message} + if friend is None: + return {"ok": False, "error": f"未找到联系人 {security_id}"} + friend_id = str(friend.get("uid", "")) + resp = self.platform.friend_label( + friend_id, label_id, + friend_source=int(friend.get("friendSource") or 0), + remove=bool(args.get("remove")), + ) + if not self.platform.is_success(resp): + _, message = self.platform.parse_error(resp) + return {"ok": False, "error": message} + return {"ok": True, "security_id": security_id, "label": label, "removed": bool(args.get("remove"))} + + def _get_upload_assets(self, _args: dict[str, Any]) -> dict[str, Any]: + assets = self.agent_config.get_assets() + resolved = {} + for key, path in assets.items(): + if not path: + resolved[key] = None + continue + p = Path(path).expanduser() + resolved[key] = {"path": str(p), "exists": p.is_file()} + return {"ok": True, "assets": resolved} + + def _plan_upload(self, args: dict[str, Any]) -> dict[str, Any]: + assets = self.agent_config.get_assets() + plan: list[dict[str, Any]] = [] + mapping = [ + ("upload_resume", "resume_path", "在线简历/附件简历"), + ("upload_salary_proof", "salary_proof_path", "期望薪资/薪资证明截图"), + ("upload_education", "education_proof_path", "学历/学位截图"), + ] + for arg_key, asset_key, label in mapping: + if not args.get(arg_key): + continue + path = assets.get(asset_key) + if not path: + plan.append({"type": label, "status": "not_configured", "hint": f"agent config --{asset_key.replace('_', '-')} <路径>"}) + continue + p = Path(path).expanduser() + plan.append({ + "type": label, + "path": str(p), + "exists": p.is_file(), + "status": "ready" if p.is_file() else "missing_file", + "note": "当前版本需用户在 BOSS 聊天页手动上传;Agent 已识别上传意图并给出文件路径", + }) + return {"ok": True, "plan": plan} diff --git a/src/boss_agent_cli/agent/utils.py b/src/boss_agent_cli/agent/utils.py new file mode 100644 index 0000000..f575d77 --- /dev/null +++ b/src/boss_agent_cli/agent/utils.py @@ -0,0 +1,15 @@ +"""Agent 辅助函数。""" + +from __future__ import annotations + +import json +from typing import Any + + +def parse_llm_json(text: str) -> dict[str, Any]: + """解析 LLM 返回的 JSON(兼容 markdown 代码块)。""" + raw = text.strip() + if raw.startswith("```"): + lines = [ln for ln in raw.split("\n") if not ln.strip().startswith("```")] + raw = "\n".join(lines).strip() + return json.loads(raw) diff --git a/src/boss_agent_cli/ai/service.py b/src/boss_agent_cli/ai/service.py index 9c73a3d..f155f8f 100644 --- a/src/boss_agent_cli/ai/service.py +++ b/src/boss_agent_cli/ai/service.py @@ -82,3 +82,59 @@ def chat( return cast("str", data["choices"][0]["message"]["content"]) except (KeyError, IndexError, TypeError) as exc: raise AIServiceError(f"响应格式异常: {exc}") from exc + + def chat_completion( + self, + messages: list[dict[str, Any]], + *, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + max_tokens: int | None = None, + ) -> dict[str, Any]: + """Chat completion with optional OpenAI-compatible tool calling.""" + url = f"{self.base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload: dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": temperature if temperature is not None else self.temperature, + "max_tokens": max_tokens if max_tokens is not None else self.max_tokens, + } + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + try: + response = httpx.post(url, json=payload, headers=headers, timeout=120) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise AIServiceError( + f"API 请求失败: HTTP {exc.response.status_code}", + status_code=exc.response.status_code, + ) from exc + except httpx.RequestError as exc: + raise AIServiceError(f"网络请求失败: {exc}") from exc + + try: + data = response.json() + message = data["choices"][0]["message"] + except (KeyError, IndexError, TypeError) as exc: + raise AIServiceError(f"响应格式异常: {exc}") from exc + + raw_calls = message.get("tool_calls") or [] + tool_calls = [ + { + "id": call["id"], + "name": call["function"]["name"], + "arguments": call["function"].get("arguments") or "{}", + } + for call in raw_calls + ] + return { + "content": message.get("content"), + "tool_calls": tool_calls, + "assistant_message": message, + } diff --git a/src/boss_agent_cli/api/client_COMMENTED.py b/src/boss_agent_cli/api/client_COMMENTED.py new file mode 100644 index 0000000..a44b4d4 --- /dev/null +++ b/src/boss_agent_cli/api/client_COMMENTED.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +[混合 API 客户端] BOSS 直聘 API 调用核心模块 + +设计理念: +- 低风险操作用 httpx(快速、轻量) +- 高风险操作用浏览器 CDP(更像真人,避免风控) +- 请求节流:高斯分布延迟,模拟真人操作节奏 +- 自动重试:失败重试 3 次 +- Cookie 自动合并:保持会话一致性 +""" + +import atexit +import random +import time +import weakref +from types import TracebackType +from typing import TYPE_CHECKING, Any, cast + +import httpx + +from boss_agent_cli.api import endpoints +from boss_agent_cli.api.httpx_helpers import ( + add_stoken_to_get_params, + browser_headers, + merge_response_cookies, + referer_header, +) +from boss_agent_cli.api.throttle import RequestThrottle + +if TYPE_CHECKING: + from boss_agent_cli.api.browser_client import BrowserSession + from boss_agent_cli.auth.manager import AuthManager + +_MAX_RETRIES = 3 # 最大重试次数 + +# atexit 安全机制:程序退出时自动关闭未显式关闭的 BossClient 实例 +_OPEN_CLIENTS: weakref.WeakSet["BossClient"] = weakref.WeakSet() + + +def _close_open_clients() -> None: + """ + [退出清理] atexit 回调:清理所有未关闭的客户端,防止资源泄漏。 + """ + for client in list(_OPEN_CLIENTS): + try: + client.close() + except Exception: + pass + + +atexit.register(_close_open_clients) + + +class AuthError(Exception): + """ + [认证错误] 未登录或令牌过期时抛出。 + """ + pass + + +class AccountRiskError(Exception): + """ + [账户风险错误] BOSS 直聘风控拦截(code 36):检测到异常行为。 + + 属性: + - is_cdp: 是否通过浏览器 CDP 操作被拦截 + """ + def __init__(self, message: str = "", is_cdp: bool = False): + self.is_cdp = is_cdp + super().__init__(message) + + +class BossClient: + """ + [混合 API 客户端] 高风险操作用浏览器,低风险操作用 httpx。 + + 核心特性: + 1. 双通道:httpx(快)+ Browser CDP(稳) + 2. 请求节流:高斯分布延迟,避免被识别为机器人 + 3. 自动重试:3次重试 + 退避策略 + 4. Cookie 合并:保持会话一致性 + 5. 上下文管理器支持:with BossClient() as client: + """ + + def __init__(self, auth_manager: "AuthManager", *, delay: tuple[float, float] = (1.5, 3.0), cdp_url: str | None = None) -> None: + """ + [构造函数] 初始化 BossClient 实例。 + + 参数: + - auth_manager: 认证管理器,提供令牌 + - delay: 请求延迟范围(秒),如 (1.5, 3.0),高斯分布随机延迟 + - cdp_url: Chrome CDP 调试地址,如 http://localhost:9222 + """ + self._auth = auth_manager + self._delay = delay + self._client: httpx.Client | None = None # httpx 客户端(懒加载) + self._browser_session: "BrowserSession | None" = None # 浏览器会话(懒加载) + self._throttle = RequestThrottle(delay) # 请求节流器 + self._cdp_url = cdp_url + self._closed = False + _OPEN_CLIENTS.add(self) # 注册到清理列表 + + def _get_client(self) -> httpx.Client: + """ + [获取 httpx 客户端] 懒加载模式:第一次调用时才创建。 + + 每次调用会检查令牌是否过期,如果过期会刷新。 + """ + if self._client is None: + token = self._auth.get_token() + headers = browser_headers(endpoints.DEFAULT_HEADERS, token) + self._client = httpx.Client( + base_url=endpoints.BASE_URL, + cookies=token.get("cookies", {}), + headers=headers, + follow_redirects=True, + timeout=30, + ) + return self._client + + def _get_browser(self) -> "BrowserSession": + """ + [获取浏览器会话] 懒加载模式:第一次调用时才创建。 + + 用于高风险操作(如投递、打招呼),更像真人,避免风控。 + """ + if self._browser_session is None: + from boss_agent_cli.api.browser_client import BrowserSession + token = self._auth.get_token() + self._browser_session = BrowserSession( + cookies=token.get("cookies", {}), + user_agent=token.get("user_agent", ""), + delay=self._delay, + cdp_url=self._cdp_url, + logger=getattr(self._auth, '_logger', None), + ) + return self._browser_session + + # ── Anti-detection 辅助(httpx 通道)────────────────────────────── + + def _headers_for(self, url: str) -> dict[str, str]: + """ + [生成 Referer Header] 根据目标 URL 生成合适的 Referer Header。 + + Referer 用于模拟正常的浏览器跳转行为,减少被识别为机器人的风险。 + """ + return referer_header(url, endpoints.REFERER_MAP, f"{endpoints.BASE_URL}/") + + def _merge_cookies(self, resp: httpx.Response) -> None: + """ + [合并响应 Cookie] 将响应的 Set-Cookie 合并到客户端 CookieJar。 + + 保持会话一致性,避免被踢下线。 + """ + merge_response_cookies(self._get_client(), resp) + + # ── httpx 请求(低风险操作)──────────────────────────────────────── + + def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + data: Any = None, + use_stoken: bool = True, + **kwargs: Any, + ) -> httpx.Response: + """ + [通用 HTTP 请求] 封装 httpx 请求,添加节流、重试、Cookie 合并。 + + 参数: + - method: HTTP 方法(GET/POST/...) + - path: API 路径 + - params: URL 查询参数 + - json: JSON 请求体 + - data: Form 数据 + - use_stoken: 是否添加 stoken 参数(BOSS直聘安全令牌) + + 返回:httpx.Response + """ + # [1] 获取客户端,确保 Cookie 和 Headers 最新 + client = self._get_client() + + # [2] 构建请求 URL 和参数 + # BOSS直聘有些 API 需要 stoken 参数来防 CSRF + params = dict(params or {}) + if use_stoken and method == "GET": + token = self._auth.get_token() + params = add_stoken_to_get_params(params, token) + + # [3] 节流:等待一段时间,模拟真人操作 + self._throttle.wait() + + # [4] 构建请求参数 + url = f"{endpoints.BASE_URL}{path}" + headers = self._headers_for(url) + + # [5] 执行请求(带重试机制) + last_err: Exception | None = None + for attempt in range(_MAX_RETRIES): + try: + resp = client.request( + method=method, + url=url, + params=params, + json=json, + data=data, + headers=headers, + **kwargs, + ) + + # [6] 合并 Cookie + self._merge_cookies(resp) + + # [7] 返回响应(即使失败也返回,让上层判断) + return resp + except Exception as e: + last_err = e + # 指数退避:第1次等1秒,第2次等2秒,第3次等4秒 + wait = 2 ** attempt + time.sleep(wait) + + # [8] 重试次数用尽,抛出最后一个错误 + if last_err: + raise last_err + raise RuntimeError("request failed without error") + + # ── 浏览器请求(高风险操作)───────────────────────────────────────── + + def _browser_request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + data: Any = None, + **kwargs: Any, + ) -> dict[str, Any]: + """ + [浏览器请求] 通过 Chrome CDP 执行请求(用于高风险操作)。 + + 优势: + - 更像真人操作(包括 JS 执行、渲染、cookie 更新) + - 更难被风控识别 + + 劣势: + - 比 httpx 慢 + - 需要启动浏览器 + """ + browser = self._get_browser() + url = f"{endpoints.BASE_URL}{path}" + return browser.request( + method=method, + url=url, + params=params, + json=json, + data=data, + **kwargs, + ) + + # ── 核心 API 方法(搜索、详情等)──────────────────────────────────── + + def search_jobs( + self, + keyword: str, + *, + city: str | None = None, + city_code: str | None = None, + salary: str | None = None, + experience: str | None = None, + degree: str | None = None, + page: int = 1, + **kwargs: Any, + ) -> dict[str, Any]: + """ + [搜索职位] 调用 BOSS 直聘搜索 API。 + + 参数: + - keyword: 搜索关键词 + - city: 城市名(如 '北京') + - city_code: 城市代码(优先级更高) + - salary: 薪资范围 + - experience: 经验要求 + - degree: 学历要求 + - page: 页码 + + 返回:API 原始响应 + """ + path = endpoints.SEARCH_PATH + params = dict(endpoints.make_search_params( + keyword=keyword, + city=city, + city_code=city_code, + salary=salary, + experience=experience, + degree=degree, + page=page, + **kwargs, + )) + + # 使用 httpx(搜索是低风险操作) + resp = self._request("GET", path, params=params) + return resp.json() + + def get_job_detail(self, security_id: str, job_id: str) -> dict[str, Any]: + """ + [获取职位详情] 获取单个职位的详细信息。 + + 参数: + - security_id: BOSS直聘职位安全 ID(URL 中的 securityId 参数) + - job_id: 职位 ID + + 返回:职位详情 + """ + path = endpoints.DETAIL_PATH + params = endpoints.make_detail_params(security_id=security_id, job_id=job_id) + + # 使用 httpx(查看详情是低风险操作) + resp = self._request("GET", path, params=params) + return resp.json() + + # ── 资源清理 ──────────────────────────────────────────────────────── + + def close(self) -> None: + """ + [关闭客户端] 释放所有资源(httpx 和浏览器)。 + """ + if self._closed: + return + + if self._client: + self._client.close() + self._client = None + + if self._browser_session: + self._browser_session.close() + self._browser_session = None + + self._closed = True + try: + _OPEN_CLIENTS.remove(self) + except KeyError: + pass + + def __enter__(self) -> "BossClient": + """ + [上下文管理器入口] 支持 with 语法。 + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """ + [上下文管理器出口] 自动关闭客户端。 + """ + self.close() diff --git a/src/boss_agent_cli/commands/agent_cmd.py b/src/boss_agent_cli/commands/agent_cmd.py new file mode 100644 index 0000000..bf29a2c --- /dev/null +++ b/src/boss_agent_cli/commands/agent_cmd.py @@ -0,0 +1,202 @@ +"""Agent 命令 — 配置和运行自主决策 Agent。""" + +from __future__ import annotations + +import click + +from boss_agent_cli.agent.config import AgentConfig +from boss_agent_cli.agent.runner import AgentRunner +from boss_agent_cli.display import handle_auth_errors, handle_error_output, handle_output + + +@click.group("agent") +@click.pass_context +def agent_cmd(ctx: click.Context) -> None: + """自主决策 Agent(DeepSeek + 工具调用)。""" + + +@agent_cmd.command("config") +@click.option("--api-key", default=None, help="DeepSeek API 密钥(加密存储)") +@click.option("--model", default="deepseek-chat", help="模型名称") +@click.option("--resume-path", default=None, help="本地简历 PDF/文件路径") +@click.option("--salary-proof-path", default=None, help="期望薪资截图路径") +@click.option("--education-proof-path", default=None, help="学历/学位截图路径") +@click.pass_context +@handle_auth_errors("agent") +def agent_config_cmd( + ctx: click.Context, + api_key: str | None, + model: str, + resume_path: str | None, + salary_proof_path: str | None, + education_proof_path: str | None, +) -> None: + """配置 DeepSeek API 与上传素材路径。""" + data_dir = ctx.obj["data_dir"] + agent_config = AgentConfig(data_dir) + + has_ai = api_key is not None + has_assets = any([resume_path, salary_proof_path, education_proof_path]) + + if not has_ai and not has_assets: + cfg = agent_config.get_ai_config() + cfg["api_key_set"] = agent_config.get_api_key() is not None + handle_output(ctx, "agent-config", { + "ai": cfg, + "assets": agent_config.get_assets(), + }) + return + + if api_key: + agent_config.configure_deepseek(api_key, model) + if has_assets: + agent_config.save_assets( + resume_path=resume_path, + salary_proof_path=salary_proof_path, + education_proof_path=education_proof_path, + ) + + handle_output(ctx, "agent-config", { + "message": "Agent 配置已更新", + "provider": "deepseek", + "model": model, + "assets": agent_config.get_assets(), + }) + + +@agent_cmd.command("test") +@click.pass_context +@handle_auth_errors("agent") +def agent_test_cmd(ctx: click.Context) -> None: + """测试 Agent / AI 配置。""" + agent_config = AgentConfig(ctx.obj["data_dir"]) + if not agent_config.is_configured(): + handle_error_output( + ctx, "agent", + code="AI_NOT_CONFIGURED", + message="AI 未配置", + recoverable=True, + recovery_action="python run.py agent config --api-key ", + ) + return + cfg = agent_config.get_ai_config() + handle_output(ctx, "agent-test", { + "configured": True, + "provider": cfg.get("ai_provider"), + "model": cfg.get("ai_model"), + "assets": agent_config.get_assets(), + }) + + +@agent_cmd.command("search") +@click.argument("query") +@click.option("--min-salary", default=15, help="最低薪资(K)") +@click.option("--exclude-outsourcing/--no-exclude-outsourcing", default=True) +@click.option("--exclude-dispatch/--no-exclude-dispatch", default=True) +@click.option("--exclude-remote/--no-exclude-remote", default=True) +@click.option("--use-llm/--no-llm", default=False, help="对通过规则的岗位做 LLM 分析") +@click.pass_context +@handle_auth_errors("agent") +def agent_search_cmd( + ctx: click.Context, + query: str, + min_salary: int, + exclude_outsourcing: bool, + exclude_dispatch: bool, + exclude_remote: bool, + use_llm: bool, +) -> None: + """规则 + 可选 LLM 过滤职位(不消耗多轮 tool calling)。""" + try: + with AgentRunner(ctx.obj["data_dir"], platform_name=ctx.obj.get("platform", "zhipin")) as runner: + runner.filter_tools.min_salary = min_salary + runner.filter_tools.exclude_outsourcing = exclude_outsourcing + runner.filter_tools.exclude_dispatch = exclude_dispatch + runner.filter_tools.exclude_remote = exclude_remote + if use_llm: + jobs = runner.run_job_search_and_filter(query) + else: + resp = runner.toolkit.execute("search_jobs", {"query": query}) + if not resp.get("ok"): + handle_error_output(ctx, "agent", code="SEARCH_FAILED", message=resp.get("error", "搜索失败")) + return + filtered = runner.toolkit.execute("filter_jobs", {"jobs": resp["jobs"], "use_llm": False}) + jobs = filtered.get("kept") or [] + handle_output(ctx, "agent-search", {"count": len(jobs), "jobs": jobs}) + except RuntimeError as exc: + handle_error_output( + ctx, "agent", + code="AI_NOT_CONFIGURED", + message=str(exc), + recoverable=True, + recovery_action="python run.py agent config --api-key ", + ) + + +@agent_cmd.command("run") +@click.argument("goal") +@click.option("--min-salary", default=15, help="最低薪资(K)") +@click.option("--exclude-outsourcing/--no-exclude-outsourcing", default=True) +@click.option("--exclude-dispatch/--no-exclude-dispatch", default=True) +@click.option("--exclude-remote/--no-exclude-remote", default=True) +@click.option("--max-rounds", default=12, help="最大 tool calling 轮次") +@click.pass_context +@handle_auth_errors("agent") +def agent_run_cmd( + ctx: click.Context, + goal: str, + min_salary: int, + exclude_outsourcing: bool, + exclude_dispatch: bool, + exclude_remote: bool, + max_rounds: int, +) -> None: + """自主决策:DeepSeek 多轮调用工具完成求职任务。""" + rules = ( + f"最低薪资 {min_salary}K;" + f"排除外包={'是' if exclude_outsourcing else '否'};" + f"排除劳务派遣={'是' if exclude_dispatch else '否'};" + f"排除异地远程={'是' if exclude_remote else '否'}" + ) + try: + with AgentRunner(ctx.obj["data_dir"], platform_name=ctx.obj.get("platform", "zhipin")) as runner: + runner.filter_tools.min_salary = min_salary + runner.filter_tools.exclude_outsourcing = exclude_outsourcing + runner.filter_tools.exclude_dispatch = exclude_dispatch + runner.filter_tools.exclude_remote = exclude_remote + runner.orchestrator.max_rounds = max_rounds + result = runner.run_autonomous(goal, extra_rules=rules) + handle_output(ctx, "agent-run", result) + except RuntimeError as exc: + handle_error_output( + ctx, "agent", + code="AI_NOT_CONFIGURED", + message=str(exc), + recoverable=True, + recovery_action="python run.py agent config --api-key ", + ) + + +@agent_cmd.command("chat-run") +@click.option("--auto-mark/--no-auto-mark", default=True, help="对外包/劳务派遣会话自动打「不合适」") +@click.option("--security-id", default=None, help="仅处理指定联系人") +@click.pass_context +@handle_auth_errors("agent") +def agent_chat_run_cmd(ctx: click.Context, auto_mark: bool, security_id: str | None) -> None: + """批量分析沟通列表:识别索要简历/薪资/学历,规划上传与回复。""" + try: + with AgentRunner(ctx.obj["data_dir"], platform_name=ctx.obj.get("platform", "zhipin")) as runner: + if security_id: + result = runner.run_chat_analysis(security_id) + handle_output(ctx, "agent-chat-run", result) + return + result = runner.process_all_chats(auto_mark_unsuitable=auto_mark) + handle_output(ctx, "agent-chat-run", result) + except RuntimeError as exc: + handle_error_output( + ctx, "agent", + code="AI_NOT_CONFIGURED", + message=str(exc), + recoverable=True, + recovery_action="python run.py agent config --api-key ", + ) diff --git a/src/boss_agent_cli/commands/register.py b/src/boss_agent_cli/commands/register.py index 3485c97..0fdda44 100644 --- a/src/boss_agent_cli/commands/register.py +++ b/src/boss_agent_cli/commands/register.py @@ -4,6 +4,7 @@ from boss_agent_cli.commands import ( ai_cmd, + agent_cmd, apply, chat, chat_summary, @@ -82,6 +83,7 @@ def register_candidate_commands(cli: click.Group) -> None: cli.add_command(clean.clean_cmd, "clean") cli.add_command(resume_cmd.resume_group, "resume") cli.add_command(ai_cmd.ai_group, "ai") + cli.add_command(agent_cmd.agent_cmd, "agent") cli.add_command(stats.stats_cmd, "stats") diff --git a/src/boss_agent_cli/main_COMMENTED.py b/src/boss_agent_cli/main_COMMENTED.py new file mode 100644 index 0000000..dc252dc --- /dev/null +++ b/src/boss_agent_cli/main_COMMENTED.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +[BOSS CLI 主入口] 整个命令行应用的起点 +基于 Click 框架构建,提供统一的 CLI 入口点 + +核心功能: +- 全局参数解析(平台、角色、延迟、CDP 地址等) +- 配置加载与初始化 +- 候选者/招聘者命令注册 +- 错误处理与 JSON 信封输出 +""" + +from pathlib import Path +from collections.abc import Sequence +from typing import Any + +import click + +from boss_agent_cli import __version__ +from boss_agent_cli.commands.register import register_candidate_commands, register_recruiter_commands +from boss_agent_cli.config import load_config +from boss_agent_cli.hooks import create_hook_bus +from boss_agent_cli.output import emit_error, Logger +from boss_agent_cli.platforms import list_platforms + + +class BossCliGroup(click.Group): + """ + [自定义 Click Group] 继承自 click.Group,确保 JSON 信封契约在用法错误时也保持一致性 + + 主要作用: + - 拦截 ClickException 并转换为 JSON 信封输出 + - 保持 CLI 的输出格式一致性(即使报错也返回 JSON) + """ + + def main( + self, + args: Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: bool = True, + **extra: Any, + ) -> Any: + try: + # 调用父类 main,但使用 standalone_mode=False 来捕获异常 + return super().main( + args=args, + prog_name=prog_name, + complete_var=complete_var, + standalone_mode=False, + **extra, + ) + except click.ClickException as exc: + # 如果不是 standalone 模式,重新抛出 + if not standalone_mode: + raise + + # 构建 JSON 信封错误输出 + ctx = getattr(exc, "ctx", None) + command = getattr(ctx, "info_name", None) or self.name or "boss" + emit_error( + command, + code="INVALID_PARAM", + message=exc.format_message(), + recoverable=False, + recovery_action="修正参数", + ) + return None + + +@click.group(name="boss", cls=BossCliGroup, context_settings={"allow_interspersed_args": False}) +@click.version_option(version=__version__, prog_name="boss") +@click.option("--data-dir", default="~/.boss-agent", help="数据存储目录,默认 ~/.boss-agent") +@click.option("--delay", default=None, help="请求间隔范围(秒),如 1.5-3.0,用于防止被识别为机器人") +@click.option("--cdp-url", default=None, help="Chrome CDP 调试地址(如 http://localhost:9222),启用则优先用用户 Chrome") +@click.option("--platform", "platform_name", default=None, help="指定招聘平台适配器(默认 zhipin,即 BOSS 直聘)") +@click.option("--role", default=None, type=click.Choice(["candidate", "recruiter"]), help="角色模式:candidate(求职者,默认)/ recruiter(招聘者)") +@click.option("--log-level", default=None, type=click.Choice(["error", "warning", "info", "debug"]), help="日志级别") +@click.option("--json/--no-json", "json_output", default=False, help="强制 JSON 输出(即使在终端中)") +@click.pass_context +def cli(ctx: click.Context, data_dir: str, delay: str | None, cdp_url: str | None, platform_name: str | None, role: str | None, log_level: str | None, json_output: bool) -> None: + """ + [CLI 主入口函数] 这是所有 boss 命令的起点 + + 执行流程: + 1. 初始化目录结构 + 2. 加载配置 + 3. 解析全局参数(延迟、CDP、平台、角色等) + 4. 初始化日志、钩子总线 + 5. 注册所有子命令 + """ + # [1] 确保 Context 对象存在,并设置目录 + ctx.ensure_object(dict) + resolved_dir = Path(data_dir).expanduser() + resolved_dir.mkdir(parents=True, exist_ok=True) + ctx.obj["data_dir"] = resolved_dir + ctx.obj["json_output"] = json_output + + # [2] 加载配置文件(~/.boss-agent/config.json) + cfg = load_config(resolved_dir / "config.json") + + # [3] 解析请求延迟范围(用于防风控) + if delay: + try: + low, high = delay.split("-", 1) + ctx.obj["delay"] = (float(low), float(high)) + except ValueError as exc: + raise click.BadParameter( + "delay must be a range like 1.5-3.0", + param_hint="--delay", + ) from exc + else: + # 使用配置文件中的默认值 + ctx.obj["delay"] = tuple(cfg["request_delay"]) + + # [4] 初始化日志系统 + level = log_level or cfg["log_level"] + ctx.obj["log_level"] = level + ctx.obj["logger"] = Logger(level) + ctx.obj["cdp_url"] = cdp_url or cfg.get("cdp_url") + + # [5] 解析和验证平台选择 + resolved_platform = platform_name or cfg.get("platform") or "zhipin" + available = list_platforms() + if resolved_platform not in available: + raise click.BadParameter( + f"unknown platform {resolved_platform!r}, supported: {', '.join(available)}", + param_hint="--platform", + ) + ctx.obj["platform"] = resolved_platform + + # [6] 解析和验证角色选择 + resolved_role = role or cfg.get("role") or "candidate" + ctx.obj["role"] = resolved_role + + # [7] 存储配置和初始化钩子总线 + ctx.obj["config"] = cfg + ctx.obj["hooks"] = create_hook_bus() + + +# [8] 注册求职者命令(默认角色) +register_candidate_commands(cli) + +# [9] 注册招聘者命令 +register_recruiter_commands(cli) + + +if __name__ == "__main__": + cli() diff --git a/src/boss_agent_cli/platforms/base_COMMENTED.py b/src/boss_agent_cli/platforms/base_COMMENTED.py new file mode 100644 index 0000000..fa79e93 --- /dev/null +++ b/src/boss_agent_cli/platforms/base_COMMENTED.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +[招聘平台抽象基类] 定义跨平台统一契约 + +Platform 接口设计理念: +- 定义 search/detail/greet/apply 等统一方法签名 +- 让 CLI 命令层通过 Platform 抽象调用,不耦合具体平台协议 +- 支持多平台扩展(BOSS直聘、智联、前程无忧) + +Week 1 交付:接口定义 + BOSS 直聘 adapter,不改动现有命令行为。 +详见 Issue #129。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from types import TracebackType +from typing import Any + + +class Platform(ABC): + """ + [招聘平台抽象基类] + + 每个平台实现需覆盖: + - 基础元信息(name / display_name / base_url) + - 包络适配方法(is_success / unwrap_data / parse_error) + - P0 只读能力(search / detail / recommend / user_info) + + 写操作(greet / apply)和沟通接口(friend_list / chat_messages)为可选, + 平台不支持时抛 NotImplementedError。 + + 资源管理:支持 ``with`` 上下文管理器语法,``__exit__`` 自动调用 ``close()`` + 释放底层 client 持有的 httpx / 浏览器资源。 + """ + + name: str # 平台内部名称(如 'zhipin', 'zhilian') + display_name: str # 平台展示名称 + base_url: str # 平台主页 URL + + def __init__(self, client: Any) -> None: + """ + [ABC 构造签名] 所有实现都接收一个平台专用 client。 + + 具体实现可以覆盖参数类型(如 ``BossPlatform`` 声明 ``BossClient``)。 + """ + self._client: Any = client + + # ── 资源生命周期管理 ─────────────────────────────────────────────── + + def close(self) -> None: + """ + [释放底层资源] 默认委托给 ``client.close()``(若存在)。] + + 子类可以覆盖以释放特定资源(如关闭 httpx.Client、浏览器会话等)。 + """ + close_fn = getattr(self._client, "close", None) + if callable(close_fn): + close_fn() + + def __enter__(self) -> "Platform": + """ + [上下文管理器入口] 进入 with 块时返回自身。 + """ + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """ + [上下文管理器出口] 退出 with 块时调用 close() 释放资源。 + """ + self.close() + + # ── 平台错误分类与统一处理 ─────────────────────────────────────────── + + def _classify_platform_error( + self, + response: dict[str, Any], + code_map: dict[int, str] | None = None, + *, + default: str = "UNKNOWN", + ) -> tuple[str, str]: + """ + [把平台错误包络统一分类为 CLI 稳定错误码] + + 平台 adapter 可先传入各自的服务端错误码映射;未命中时按通用 + HTTP 状态码与消息关键词兜底,避免 401/429/网络错误等跨平台 + 常见失败都退化为 UNKNOWN。 + + 返回值:(error_code, error_message) + """ + # 提取响应中的 code 和 message + code = response.get("code") + status_code = response.get("status_code") or response.get("status") + message = str( + response.get("message") + or response.get("msg") + or response.get("error") + or response.get("zpData") + or "" + ) + + # 优先使用平台特定的错误码映射 + mapping = code_map or {} + if isinstance(code, int) and code in mapping: + return mapping[code], message + + # 通用 HTTP 状态码分类 + numeric_code = code if isinstance(code, int) else status_code + if numeric_code in (401, 40301): + return "AUTH_EXPIRED", message # 认证过期 + if numeric_code == 403: + return "ACCOUNT_RISK", message # 账户风险拦截 + if numeric_code == 429: + return "RATE_LIMITED", message # 请求限流 + if isinstance(numeric_code, int) and 500 <= numeric_code < 600: + return "NETWORK_ERROR", message # 服务器错误 + + # 通过消息关键词分类 + lower_msg = message.lower() + if any(token in lower_msg for token in ("stoken", "token", "unauthorized", "登录", "登陆", "未认证")): + return "AUTH_EXPIRED", message + if any(token in lower_msg for token in ("blocked", "forbidden", "禁止访问", "风控")): + return "ACCOUNT_RISK", message + if any(token in lower_msg for token in ("too many", "frequent", "频繁", "太快")): + return "RATE_LIMITED", message + if any(token in lower_msg for token in ("timeout", "网络", "连接", "network")): + return "NETWORK_ERROR", message + + # 默认返回 UNKNOWN 错误码 + return default, message + + # ── 抽象方法:包络处理(每个平台必须实现)─────────────────────────── + + @abstractmethod + def is_success(self, response: dict[str, Any]) -> bool: + """ + [判断响应是否成功] 判断平台返回的响应是否表示成功。 + + 不同平台的成功判断逻辑不同: + - BOSS直聘:code == 0 + - 智联:code == "1" + """ + ... + + @abstractmethod + def unwrap_data(self, response: dict[str, Any]) -> Any: + """ + [解包数据层] 从平台响应中解包出实际数据部分。 + + 不同平台的数据嵌套层级不同: + - BOSS直聘:zpData + - 智联:data + """ + ... + + @abstractmethod + def parse_error(self, response: dict[str, Any]) -> tuple[str, str]: + """ + [解析错误信息] 从失败响应中解析出错误码和错误消息。 + + 返回:(error_code, error_message) + """ + ... + + # ── 抽象方法:P0 只读核心能力(每个平台必须实现)────────────────── + + @abstractmethod + def search_jobs( + self, + keyword: str, + *, + city: str | None = None, + city_code: str | None = None, + salary: str | None = None, + experience: str | None = None, + degree: str | None = None, + page: int = 1, + **kwargs: Any, + ) -> dict[str, Any]: + """ + [搜索职位] 根据关键词搜索职位。 + + 参数: + - keyword: 搜索关键词(如 'Python', 'Golang') + - city: 城市名(如 '北京') + - city_code: 城市代码(优先级高于 city 名) + - salary: 薪资范围(如 '10-20K') + - experience: 经验要求 + - degree: 学历要求 + - page: 页码 + + 返回:平台原始响应(由 unwrap_data 解包) + """ + ... + + @abstractmethod + def get_job_detail(self, security_id: str, job_id: str) -> dict[str, Any]: + """ + [获取职位详情] 获取单个职位的详细信息。 + + 参数: + - security_id: 职位安全 ID(BOSS直聘特有) + - job_id: 职位 ID + + 返回:职位详细信息 + """ + ... + + @abstractmethod + def get_user_info(self) -> dict[str, Any]: + """ + [获取当前用户信息] 获取当前登录用户的个人信息。 + + 返回:用户信息 + """ + ... + + @abstractmethod + def get_recommendations(self, **kwargs: Any) -> dict[str, Any]: + """ + [获取推荐职位] 获取平台为当前用户推荐的职位列表。 + + 返回:推荐职位列表 + """ + ... + + # ── 可选方法:写操作与沟通接口(平台不支持时抛 NotImplementedError)────────────────────────────────────── + + def greet(self, security_id: str, job_id: str, **kwargs: Any) -> dict[str, Any]: + """ + [打招呼] 向招聘者打招呼(可选实现)。 + + 注意:在默认低风险模式下,此操作会被合规护栏阻断。 + """ + raise NotImplementedError(f"greet not supported on {self.name}") + + def apply(self, security_id: str, job_id: str, **kwargs: Any) -> dict[str, Any]: + """ + [投递简历] 投递简历给招聘者(可选实现)。 + + 注意:在默认低风险模式下,此操作会被合规护栏阻断。 + """ + raise NotImplementedError(f"apply not supported on {self.name}") + + def get_friend_list(self, **kwargs: Any) -> dict[str, Any]: + """ + [获取好友列表] 获取与当前用户沟通过的招聘者列表(可选实现)。 + + 注意:在默认低风险模式下,此操作会被合规护栏阻断。 + """ + raise NotImplementedError(f"get_friend_list not supported on {self.name}") + + def get_chat_messages(self, friend_id: str, **kwargs: Any) -> dict[str, Any]: + """ + [获取聊天记录] 获取与某个招聘者的聊天记录(可选实现)。 + + 注意:在默认低风险模式下,此操作会被合规护栏阻断。 + """ + raise NotImplementedError(f"get_chat_messages not supported on {self.name}") diff --git a/src/boss_agent_cli/tools/__init__.py b/src/boss_agent_cli/tools/__init__.py new file mode 100644 index 0000000..1b60d3d --- /dev/null +++ b/src/boss_agent_cli/tools/__init__.py @@ -0,0 +1,18 @@ +"""底层操作工具层 - 封装API调用供Agent使用 + +提供: +- 职位搜索与筛选 +- 聊天消息发送 +- 职位详情获取 +- 文件上传(待实现) +""" + +from boss_agent_cli.tools.job_tools import JobTools +from boss_agent_cli.tools.chat_tools import ChatTools +from boss_agent_cli.tools.filter_tools import FilterTools + +__all__ = [ + "JobTools", + "ChatTools", + "FilterTools", +] diff --git a/src/boss_agent_cli/tools/chat_tools.py b/src/boss_agent_cli/tools/chat_tools.py new file mode 100644 index 0000000..d1c7369 --- /dev/null +++ b/src/boss_agent_cli/tools/chat_tools.py @@ -0,0 +1,84 @@ +"""聊天操作工具 - 封装聊天消息发送、历史获取等操作""" + +from typing import Any + +from boss_agent_cli.api.client import BossClient + + +class ChatTools: + """聊天相关操作工具类""" + + def __init__(self, client: BossClient): + self.client = client + + def get_chat_history(self, gid: str, security_id: str, page: int = 1, count: int = 20) -> dict[str, Any]: + """获取聊天历史 + + Args: + gid: 会话ID + security_id: 安全ID + page: 页码 + count: 每页数量 + + Returns: + 聊天历史 + """ + return self.client.chat_history(gid, security_id, page=page, count=count) + + def get_friend_list(self, page: int = 1) -> dict[str, Any]: + """获取好友列表 + + Args: + page: 页码 + + Returns: + 好友列表 + """ + return self.client.friend_list(page=page) + + def send_message(self, gid: str, security_id: str, message: str) -> dict[str, Any]: + """发送聊天消息 + + 注意:当前API层可能没有直接发送消息的接口, + 这个方法需要通过浏览器客户端实现 + + Args: + gid: 会话ID + security_id: 安全ID + message: 消息内容 + + Returns: + 发送结果 + """ + # TODO: 需要通过浏览器客户端实现 + raise NotImplementedError("发送消息功能需要通过浏览器客户端实现") + + def upload_attachment(self, gid: str, file_path: str) -> dict[str, Any]: + """上传附件 + + 注意:当前API层没有上传附件的接口, + 这个方法需要通过浏览器客户端实现 + + Args: + gid: 会话ID + file_path: 文件路径 + + Returns: + 上传结果 + """ + # TODO: 需要通过浏览器客户端实现 + raise NotImplementedError("上传附件功能需要通过浏览器客户端实现") + + def exchange_contact(self, security_id: str, uid: str, name: str, exchange_type: int = 1) -> dict[str, Any]: + """请求交换联系方式 + + Args: + security_id: 安全ID + uid: 用户唯一ID + name: 姓名 + exchange_type: 交换类型(1=手机, 2=微信) + + Returns: + 操作结果 + """ + return self.client.exchange_contact(security_id, uid, name, exchange_type) diff --git a/src/boss_agent_cli/tools/filter_tools.py b/src/boss_agent_cli/tools/filter_tools.py new file mode 100644 index 0000000..7c6eb94 --- /dev/null +++ b/src/boss_agent_cli/tools/filter_tools.py @@ -0,0 +1,155 @@ +"""岗位过滤工具 - 实现智能岗位筛选逻辑""" + +import re +from typing import Any + + +class FilterTools: + """岗位过滤工具类""" + + # 外包相关关键词 + OUTSOURCING_KEYWORDS = [ + "外包", "外派", "第三方", "外包公司", "人力外包", + "项目外包", "外包项目", "驻场", "驻场开发" + ] + + # 劳务派遣相关关键词 + DISPATCH_KEYWORDS = [ + "劳务派遣", "派遣", "人才派遣", "派遣公司", + "劳务公司", "人力资源服务" + ] + + # 异地相关关键词 + REMOTE_KEYWORDS = [ + "异地", "远程", "异地办公", "远程办公", + "居家办公", "home office", "remote" + ] + + def __init__(self, min_salary: int | None = None, exclude_outsourcing: bool = True, + exclude_dispatch: bool = True, exclude_remote: bool = True): + """初始化过滤规则 + + Args: + min_salary: 最低薪资要求(K) + exclude_outsourcing: 是否排除外包 + exclude_dispatch: 是否排除劳务派遣 + exclude_remote: 是否排除异地/远程 + """ + self.min_salary = min_salary + self.exclude_outsourcing = exclude_outsourcing + self.exclude_dispatch = exclude_dispatch + self.exclude_remote = exclude_remote + + def should_filter_job(self, job_data: dict[str, Any]) -> tuple[bool, str]: + """判断是否应该过滤该岗位 + + Args: + job_data: 职位数据 + + Returns: + (是否过滤, 过滤原因) + """ + # 检查薪资 + if self.min_salary and not self._check_salary(job_data): + return True, f"薪资低于{self.min_salary}K" + + # 检查外包 + if self.exclude_outsourcing and self._is_outsourcing(job_data): + return True, "外包岗位" + + # 检查劳务派遣 + if self.exclude_dispatch and self._is_dispatch(job_data): + return True, "劳务派遣岗位" + + # 检查异地 + if self.exclude_remote and self._is_remote(job_data): + return True, "异地/远程岗位" + + return False, "" + + def _check_salary(self, job_data: dict[str, Any]) -> bool: + """检查薪资是否符合要求""" + salary_str = job_data.get("salary", "") + if not salary_str: + return False + + # 解析薪资范围,如 "15-25K", "15-25·15薪" + match = re.search(r"(\d+)-(\d+)", salary_str) + if match: + min_sal = int(match.group(1)) + return min_sal >= self.min_salary + + # 单一薪资,如 "20K" + match = re.search(r"(\d+)", salary_str) + if match: + sal = int(match.group(1)) + return sal >= self.min_salary + + return False + + def _is_outsourcing(self, job_data: dict[str, Any]) -> bool: + """判断是否为外包岗位""" + # 检查公司名称 + company_name = job_data.get("brandName", "") or job_data.get("company", "") + if any(keyword in company_name for keyword in self.OUTSOURCING_KEYWORDS): + return True + + # 检查职位名称 + title = job_data.get("title", "") or job_data.get("jobName", "") + if any(keyword in title for keyword in self.OUTSOURCING_KEYWORDS): + return True + + # 检查职位描述 + description = job_data.get("description", "") or job_data.get("jobDetail", "") + if any(keyword in description for keyword in self.OUTSOURCING_KEYWORDS): + return True + + return False + + def _is_dispatch(self, job_data: dict[str, Any]) -> bool: + """判断是否为劳务派遣岗位""" + # 检查公司名称 + company_name = job_data.get("brandName", "") or job_data.get("company", "") + if any(keyword in company_name for keyword in self.DISPATCH_KEYWORDS): + return True + + # 检查职位描述 + description = job_data.get("description", "") or job_data.get("jobDetail", "") + if any(keyword in description for keyword in self.DISPATCH_KEYWORDS): + return True + + return False + + def _is_remote(self, job_data: dict[str, Any]) -> bool: + """判断是否为异地/远程岗位""" + # 检查工作地点 + location = job_data.get("city", "") or job_data.get("workCity", "") + if any(keyword in location for keyword in self.REMOTE_KEYWORDS): + return True + + # 检查职位描述 + description = job_data.get("description", "") or job_data.get("jobDetail", "") + if any(keyword in description for keyword in self.REMOTE_KEYWORDS): + return True + + return False + + def filter_jobs(self, jobs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """批量过滤岗位 + + Args: + jobs: 职位列表 + + Returns: + 过滤后的职位列表 + """ + filtered = [] + for job in jobs: + should_filter, reason = self.should_filter_job(job) + if not should_filter: + filtered.append(job) + else: + # 可以记录过滤原因 + job["_filter_reason"] = reason + + return filtered diff --git a/src/boss_agent_cli/tools/job_tools.py b/src/boss_agent_cli/tools/job_tools.py new file mode 100644 index 0000000..721f51b --- /dev/null +++ b/src/boss_agent_cli/tools/job_tools.py @@ -0,0 +1,73 @@ +"""职位操作工具 - 封装职位搜索、详情获取等操作""" + +from typing import Any + +from boss_agent_cli.api.client import BossClient + + +class JobTools: + """职位相关操作工具类""" + + def __init__(self, client: BossClient): + self.client = client + + def search_jobs(self, query: str, **filters: Any) -> dict[str, Any]: + """搜索职位 + + Args: + query: 搜索关键词 + **filters: 筛选条件(city, salary, experience等) + + Returns: + 搜索结果字典 + """ + return self.client.search_jobs(query, **filters) + + def get_job_detail(self, job_id: str) -> dict[str, Any]: + """获取职位详情 + + Args: + job_id: 职位ID + + Returns: + 职位详情字典 + """ + return self.client.job_detail(job_id) + + def get_job_card(self, security_id: str, lid: str = "") -> dict[str, Any]: + """获取职位卡片信息 + + Args: + security_id: 安全ID + lid: 职位ID(可选) + + Returns: + 职位卡片信息 + """ + return self.client.job_card(security_id, lid) + + def greet_recruiter(self, security_id: str, job_id: str, message: str = "") -> dict[str, Any]: + """向招聘者打招呼 + + Args: + security_id: 安全ID + job_id: 职位ID + message: 打招呼消息 + + Returns: + 操作结果 + """ + return self.client.greet(security_id, job_id, message) + + def apply_job(self, security_id: str, job_id: str, lid: str = "") -> dict[str, Any]: + """投递职位 + + Args: + security_id: 安全ID + job_id: 职位ID + lid: 简历ID(可选) + + Returns: + 操作结果 + """ + return self.client.apply(security_id, job_id, lid) diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..1da201f --- /dev/null +++ b/tests/test_agent.py @@ -0,0 +1,136 @@ +"""Agent 层单元测试。""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from click.testing import CliRunner + +from boss_agent_cli.agent.config import AgentConfig +from boss_agent_cli.agent.orchestrator import AgentOrchestrator +from boss_agent_cli.agent.toolkit import AgentToolkit +from boss_agent_cli.agent.utils import parse_llm_json +from boss_agent_cli.main import cli +from boss_agent_cli.tools.filter_tools import FilterTools + + +def test_parse_llm_json_strips_markdown_fence(): + raw = '```json\n{"recommend": true}\n```' + assert parse_llm_json(raw)["recommend"] is True + + +def test_filter_tools_rejects_outsourcing(): + ft = FilterTools(min_salary=10, exclude_outsourcing=True) + job = {"title": "Java 开发", "company": "某外包科技公司", "salary": "20-30K", "city": "北京"} + filtered, reason = ft.should_filter_job(job) + assert filtered is True + assert "外包" in reason + + +def test_filter_tools_rejects_low_salary(): + ft = FilterTools(min_salary=20) + job = {"title": "开发", "company": "正规公司", "salary": "10-15K", "city": "北京"} + filtered, reason = ft.should_filter_job(job) + assert filtered is True + assert "薪资" in reason + + +def test_filter_tools_accepts_good_job(): + ft = FilterTools(min_salary=15) + job = {"title": "Python", "company": "正规互联网", "salary": "25-35K", "city": "上海"} + filtered, _ = ft.should_filter_job(job) + assert filtered is False + + +def test_agent_config_assets(tmp_path: Path): + cfg = AgentConfig(tmp_path) + cfg.configure_deepseek("sk-test", "deepseek-chat") + cfg.save_assets(resume_path="/tmp/resume.pdf") + assets = cfg.get_assets() + assert assets["resume_path"] == "/tmp/resume.pdf" + assert cfg.is_configured() + + +def test_toolkit_filter_jobs(): + platform = MagicMock() + job_tools = MagicMock() + chat_tools = MagicMock() + filter_tools = FilterTools(min_salary=20) + job_agent = MagicMock() + chat_agent = MagicMock() + agent_config = MagicMock() + agent_config.get_assets.return_value = {} + + toolkit = AgentToolkit( + platform, job_tools, chat_tools, filter_tools, job_agent, chat_agent, agent_config, + ) + jobs = [ + {"title": "A", "company": "外包公司", "salary": "30K", "city": "北京"}, + {"title": "B", "company": "正规公司", "salary": "25-35K", "city": "北京"}, + ] + result = toolkit.execute("filter_jobs", {"jobs": jobs, "use_llm": False}) + assert result["ok"] is True + assert result["kept_count"] == 1 + assert result["kept"][0]["title"] == "B" + + +def test_orchestrator_single_turn_no_tools(): + ai = MagicMock() + ai.chat_completion.return_value = { + "content": "任务完成", + "tool_calls": [], + "assistant_message": {"role": "assistant", "content": "任务完成"}, + } + toolkit = MagicMock() + toolkit.openai_tools.return_value = [] + + orch = AgentOrchestrator(ai, toolkit, max_rounds=3) + result = orch.run("测试目标") + assert result["ok"] is True + assert result["summary"] == "任务完成" + ai.chat_completion.assert_called_once() + + +def test_orchestrator_executes_tool_then_replies(): + ai = MagicMock() + toolkit = MagicMock() + toolkit.openai_tools.return_value = [{"type": "function", "function": {"name": "search_jobs"}}] + toolkit.execute.return_value = {"ok": True, "count": 0, "jobs": []} + + ai.chat_completion.side_effect = [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "search_jobs", "arguments": '{"query": "Python"}'}], + "assistant_message": { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "c1", + "type": "function", + "function": {"name": "search_jobs", "arguments": '{"query": "Python"}'}, + }], + }, + }, + { + "content": "没有找到岗位", + "tool_calls": [], + "assistant_message": {"role": "assistant", "content": "没有找到岗位"}, + }, + ] + + orch = AgentOrchestrator(ai, toolkit, max_rounds=5) + result = orch.run("搜索 Python") + assert result["ok"] is True + assert "没有找到" in result["summary"] + toolkit.execute.assert_called_once_with("search_jobs", {"query": "Python"}) + + +def test_agent_cli_test_not_configured(tmp_path: Path): + runner = CliRunner() + result = runner.invoke(cli, ["--data-dir", str(tmp_path), "--json", "agent", "test"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["ok"] is False