diff --git a/.gitignore b/.gitignore index be0fe38..5455af8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,17 @@ data/ .DS_Store Thumbs.db +# Database & backup +*.db +*.bak + +# Flutter +.gradle/ +*.iml +.metadata +src/studio/build/ +src/studio/.dart_tool/ + # Terraform .terraform/ terraform/terraform.tfstate diff --git a/CHANGELOG-human.md b/CHANGELOG-human.md new file mode 100644 index 0000000..9af22f8 --- /dev/null +++ b/CHANGELOG-human.md @@ -0,0 +1,251 @@ +# Human Module Changelog + +> QtCloud HR — 招聘人力资源模块(human)功能更新日志,从 v0 开始记录。 + +--- + +## v0.1.0 — Domain Models & Screening Engine (Dart) + +Initial domain logic library, pure Dart with zero Flutter dependency. + +### Added + +- **Recruitment Screening** (`packages/dart/lib/src/models/recruitment.dart`) + - `Resume`, `JobPosition`, `ScreeningRules`, `ScreeningResult` models + - `Decision` enum: `pass`, `priority`, `reject` + - `EducationLevel` enum: `high_school`, `associate`, `bachelor`, `master`, `doctorate` +- **Screening Service** (`packages/dart/lib/src/services/recruitment_service.dart`) + - `RecruitmentService.screen(Resume, JobPosition) → ScreeningResult` + - Three-outcome decision logic: education, experience, required skills + - Hard requirements must all be met for `pass`; bonus skills elevate to `priority` +- **Compensation Calculation** (`packages/dart/lib/src/models/compensation.dart`) + - `CompensationParams`, `CompensationResult`, `CompensationRuleConfig` models + - Non-negative validation via `assert` +- **Compensation Service** (`packages/dart/lib/src/services/compensation_service.dart`) + - `CompensationService.calculate(CompensationParams) → CompensationResult` + - Formula: `netSalary = baseSalary + overtimePay + performanceBonus - deductions` + - Configurable overtime multiplier and performance bonus ratio +- **Tests** (`packages/dart/test/`) + - Full test coverage for recruitment screening and compensation services + +--- + +## v0.2.0 — FastAPI Backend: Talent Pipeline + +First backend release with SQLAlchemy + SQLite, focusing on talent lifecycle management. + +### Added + +- **Database** (`packages/fastapi/src/fastapi_quanttide_hr/database.py`) + - `DeclarativeBase`, `get_db` dependency (abstract — must be overridden by app) +- **Talent Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/talent.py`) + - 8-state state machine: `NEW -> CONTACTED -> EXAM_SENT -> EXAM_RECEIVED -> EVALUATING -> INTERVIEW -> OFFER -> CLOSED` + - Strict `STATUS_TRANSITIONS` dict enforcing valid transitions +- **Recruitment Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/recruitment.py`) + - Basic recruitment entity with title +- **Talent Schemas** (`packages/fastapi/src/fastapi_quanttide_hr/schemas/talent.py`) + - `TalentCreate`, `TalentRead`, `TalentUpdate`, `TalentTransition` +- **Recruitment Schemas** (`packages/fastapi/src/fastapi_quanttide_hr/schemas/recruitment.py`) + - `RecruitmentCreate`, `RecruitmentRead` +- **Recruitment Routers** (`packages/fastapi/src/fastapi_quanttide_hr/routers/recruitments.py`) + - CRUD: list, create, get recruitments + - Talent CRUD: list/create talents under recruitment + - Talent transition with state validation +- **Pipeline Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/pipeline.py`) + - Aggregated pipeline view grouped by status +- **Pipeline Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/pipeline.py`) + - `get_pipeline()` query logic with `_talent_to_card` mapping +- **Seed Data** (`packages/fastapi/src/fastapi_quanttide_hr/seed.py`) + - `DEMO_TALENTS` constant for demo/testing +- **Tests** (`packages/fastapi/tests/test_lib.py`) + - Full model, schema, service, and API test suite + +--- + +## v0.3.0 — Email Screening Gateway + +Added pending email queue, classifier, and mail ingestion — the recruitment email screening gateway. + +### Added + +- **Pending Queue Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/pending_queue.py`) + - `PendingQueueItem` with email metadata, attachments, extracted fields, classifier results +- **Pending Queue Schemas** (`packages/fastapi/src/fastapi_quanttide_hr/schemas/pending_queue.py`) + - `QueueItemRead`, `ConfirmRequest/Response`, `IgnoreRequest`, `IngestItem/Request/Response` +- **Queue Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/queue.py`) + - List queue items (with dedup by email), confirm/ignore/adjust items +- **Ingest Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/ingest.py`) + - Batch ingest emails, dedup by message_id, auto-match candidates +- **Classifier Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/classifier.py`) + - Keyword-based rule classification for suggested status +- **AI Classifier Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/ai_classifier.py`) + - LLM-powered classification with configurable provider/model + - Fallback: keyword rules when AI unavailable +- **Email Matcher Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/email_matcher.py`) + - `effective_email()`, `has_pending_queue_for_email()`, `match_by_email()`, `find_active_application()` +- **Transition Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/transition.py`) + - Application/Talent dual-state management with sync +- **Example Provider App** (`packages/examples/provider/app.py`) + - Full FastAPI app wiring example with dependency overrides + +--- + +## v0.4.0 — Flutter Kanban UI (Pipeline + Queue + Pool) + +First Flutter UI release — responsive kanban board for recruitment pipeline management. + +### Added + +- **App Shell** (`packages/flutter/lib/main.dart`) + - `MaterialApp` with custom HR theme + - `MainShell` responsive layout: `NavigationRail` (>600px) / `NavigationBar` (<=600px) + - 3-tab navigation: Pipeline, Queue, Pool + - `IndexedStack` for tab state preservation +- **Pipeline Screen** (`packages/flutter/lib/screens/pipeline_screen.dart`) + - Kanban-style pipeline grouped by TalentStatus (new -> closed) + - Candidate cards with name, email, sub-stage, wait-days badge + - Wait-days color coding: yellow (7+), orange (14+) + - Drag-and-drop status transitions + - Candidate detail bottom sheet with email, attachments, timeline +- **Queue Screen** (`packages/flutter/lib/screens/queue_screen.dart`) + - Pending email queue list with confidence badges + - Confirm/ignore/adjust actions + - Recruitment title assignment on confirm +- **Pool Screen** (`packages/flutter/lib/screens/pool_screen.dart`) + - Pooled (reserve) candidate list + - Unpool with recruitment reassignment +- **API Service** (`packages/flutter/lib/services/api_service.dart`) + - Full REST client for all backend endpoints + - Mutable `baseUrl` for runtime server switching +- **Widgets** + - `StatusBadge`: Color-coded status pill using theme colors + - `EmptyState`, `ErrorView`: Reusable state views + - `InfoRow`: Label-value detail row + +--- + +## v0.5.0 — Recruitment Applications + Materials + +Replaced Talent-centric model with Application-centric architecture. + +### Added + +- **Application Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/application.py`) + - `Application` entity linking Candidate + Recruitment + - Pooling support (pooled_at timestamp) + - Source queue item reference for traceability +- **Candidate Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/candidate.py`) + - `Candidate` entity (name, email, phone) + - Unique constraint on email +- **Application Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/applications.py`) + - List applications with filters (status, candidate, recruitment, pooled) + - Transition applications (status + sub-stage) + - Pool/unpool applications + - Get application materials (queue source, attachments, classifier info, corrections) +- **Candidate Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/candidates.py`) + - CRUD: list, get, update candidates + - Update syncs changes to associated talents +- **Material Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/material_service.py`) + - Artifact management (by queue, by candidate) +- **Mail Message Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/mail_message.py`) + - Full email thread tracking (inbound/outbound, send status, attachments) +- **Messages Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/messages.py`) + - List messages by candidate/application + - Timeline events + - Reply-to-candidate + - Outbox management with send status + - Dead letter detection and requeue +- **Correction Log Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/correction_log.py`) + - Field-level correction tracking +- **Application Materials (Flutter)** (`packages/flutter/lib/models/application_materials.dart`) + - `ApplicationMaterials`, `QueueItemMaterials`, `AttachmentInfo`, `ResumeParseResult`, `CorrectionEntry` +- **Mail Message Models (Flutter)** (`packages/flutter/lib/models/mail_message.dart`) + - `MailMessage`, `TimelineItem` with direction badges +- **Detail Panel** — Enhanced candidate detail in pipeline: + - Email body display (HTML/text) + - Clickable attachment preview via `url_launcher` + - Classifier info (rule vs AI source) + - Correction history + - Message thread with direction indicators + - Timeline with type-specific icons (transition/reply/note/system) + - Reply dialog for outbound messages +- **Resume Parser Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/resume_parser.py`) + - PDF/text resume parsing + +--- + +## v0.6.0 — AI Configuration & Settings + +Added AI provider configuration UI and server address management. + +### Added + +- **AI Config Model** (`packages/fastapi/src/fastapi_quanttide_hr/models/ai_config.py`) + - Provider, model, API key, base URL, temperature, prompt template +- **AI Config Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/ai_config.py`) + - GET/PUT config, test connection endpoint +- **Settings Screen** (Flutter) (`packages/flutter/lib/screens/settings_screen.dart`) + - AI config form: provider dropdown, model, API key (obscured), URL, temperature slider, prompt template + - Server URL field with inline save + - Connection test with loading state +- **Theme System** (`packages/flutter/lib/theme/hr_theme.dart`) + - `HrThemeExtension` (ThemeExtension): 8 status colors, spacing tokens, font tokens + - `buildHrTheme()` — dark theme with `ColorScheme.dark` + - `HrThemeContext` extension with `statusColor(String status)` lookup + +--- + +## v0.7.0 — Headcount Planning & Export + +### Added + +- **Headcount Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/headcount.py`) + - Headcount planning and tracking +- **Export Router** (`packages/fastapi/src/fastapi_quanttide_hr/routers/export.py`) + - Data export endpoints +- **Export Service** (`packages/fastapi/src/fastapi_quanttide_hr/services/export.py`) + - Export data generation +- **Export Schema** (`packages/fastapi/src/fastapi_quanttide_hr/schemas/export.py`) + - Export request/response models + +--- + +## v0.8.0 — Feishu/Lark Email Integration + +### Added + +- **Mail Reader** (`integrations/feishu/src/feishu_integration/mail_reader.py`) + - Feishu mail API reader for ingesting recruitment emails +- **Mail Ingest Loop** (`integrations/feishu/src/feishu_integration/mail_ingest_loop.py`) + - Polling loop: fetch -> classify -> ingest +- **Mail Sender** (`integrations/feishu/src/feishu_integration/mail_sender.py`) + - Outbound mail via Feishu API +- **Mail Sender Loop** (`integrations/feishu/src/feishu_integration/mail_sender_loop.py`) + - Outbox polling and sending loop +- **Pipeline Writer** (`integrations/feishu/src/feishu_integration/pipeline_writer.py`) + - Write pipeline updates to Feishu documents +- **Feishu Classifier** (`integrations/feishu/src/feishu_integration/classifier.py`) + - Feishu-specific classification integration +- **Tests** (`integrations/feishu/tests/`) + - Classifier integration tests + +--- + +## v0.9.0 — Admin CLI + +### Added + +- **CLI App** (`qtadmin-human-cli/src/qtadmin/cli.py`) + - Command-line interface for HR operations +- **API Client** (`qtadmin-human-cli/src/qtadmin/api_client.py`) + - HTTP client for all backend endpoints +- **Config** (`qtadmin-human-cli/src/qtadmin/config.py`) + - Configuration management +- **Classifier** (`qtadmin-human-cli/src/qtadmin/classifier.py`) + - Rule-based email classification +- **Mail Sender** (`qtadmin-human-cli/src/qtadmin/mail_sender.py`) + - Outbound email sending via API +- **Lark Client** (`qtadmin-human-cli/src/qtadmin/lark_client.py`) + - Feishu API integration +- **Tests** (`qtadmin-human-cli/tests/`) + - Unit tests for CLI, API client, classifier, config, lark client diff --git a/CHANGELOG.md b/CHANGELOG.md index 92078b7..44c59b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,24 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0 ## [0.1.0] - 2026-05-09 +### Human + +独立发布 `v0.9.0`,详见 [CHANGELOG-human.md](CHANGELOG-human.md)。 + +- **Admin CLI**: 命令行工具集(API 客户端、分类器、邮件发送、飞书集成) +- **飞书集成**: 邮件读取/发送循环、管道看板推送 + ### Studio 独立发布 `v0.1.0`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 ## [0.0.9] - 2026-05-09 +### Human + +独立发布 `v0.7.0` — Headcount Planning & Export。 + +- **人数规划**: 招聘编制规划与跟踪服务 +- **数据导出**: 候选人数据导出端点与生成服务 + ### Studio 独立发布 `v0.0.7`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 ## [0.0.8] - 2026-05-08 +### Human + +独立发布 `v0.6.0` — AI 配置与设置页面。 + +- **AI 配置模型与路由**: 供应商、模型、API 密钥、温度、提示词模板 +- **Flutter 设置页面**: AI 配置表单、服务端地址切换、连接测试 +- **主题系统**: `HrThemeExtension`,8 种状态色彩、间距/字号 token、深色主题 + ### Studio 独立发布 `v0.0.6`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 ## [0.0.7] - 2026-05-08 +### Human + +独立发布 `v0.5.0` — 申请中心与邮件往来。 + +- **Application 模型/路由**: Candidate + Recruitment 关联、人才池、来源追踪 +- **Candidate 模型/路由**: 候选人实体、邮箱唯一约束、更新同步 +- **消息系统**: MailMessage 模型、邮件往来列表、时间线、回复、发件箱、死信队列 +- **素材服务**: 简历附件关联与查询 +- **修正日志**: 字段级人工修正追踪 +- **Flutter 详情面板**: 邮件正文展示、附件预览、分类器信息、修正记录、时间线 + ### Added - `docs/dev/pmd.md`:问题管理文档(业务问题 + 技术问题双维度记录) @@ -46,6 +79,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0 - `ROADMAP.md`:项目路线规划文档 +### Human + +独立发布 `v0.4.0` — Flutter 看板(Pipeline + Queue + Pool)。 + +- **Flutter 看板**: 响应式布局(NavigationRail/NavigationBar)、3 标签导航 +- **管道看板**: 候选人状态分组、停留天数、搜索、拖拽流转、详情面板 +- **队列**: 待处理邮件列表、置信度标签、确认/调整/忽略 +- **人才池**: 候选人池管理、重新入池分配 +- **Flutter 通用组件**: StatusBadge、EmptyState、ErrorView、InfoRow + ### Studio 独立发布 `v0.0.6`,详见 [src/studio/CHANGELOG.md](src/studio/CHANGELOG.md)。 @@ -79,6 +122,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0 ## [0.0.5] - 2026-05-08 +### Human + +独立发布 `v0.3.0` — 邮件筛选网关。 + +- **待处理队列**: PendingQueueItem 模型、邮件元数据/AI 提取字段/分类结果 +- **队列路由**: 列表(按邮箱去重)、确认/忽略/调整 +- **邮箱导入路由**: 批量导入、message_id 去重、自动匹配候选人 +- **分类器服务**: 关键词规则分类 + LLM AI 分类(可配置供应商/模型/回退) +- **邮件匹配服务**: 邮箱规范化、去重检查、候选人匹配 + ### Added - `assets/fixtures/metadata.json`:根注册表(Workspace工作空间清单 + 段定义) @@ -149,6 +202,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0 ## [0.0.2] - 2026-05-06 +### Human + +独立发布 `v0.2.0` — FastAPI 人才管道。 + +- **Talent 状态机**: 8 状态(NEW→CONTACTED→EXAM_SENT→EXAM_RECEIVED→EVALUATING→INTERVIEW→OFFER→CLOSED) +- **Recruitment 模型/路由**: 招聘项目 CRUD、Talent CRUD、状态流转校验 +- **管道路由**: 按状态聚合的管道视图 +- **种子数据**: DEMO_TALENTS 常量 + ### Added - `src/studio/`: 全景图今日看板(Flutter 实现) @@ -167,6 +229,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0 ## [0.0.1] - 2026-04-30 +### Human + +独立发布 `v0.1.0` — 领域模型与筛选引擎。 + +- **Dart 招聘筛选**: Resume/JobPosition/ScreeningRules 模型、通过/优先/拒绝三态决策 +- **Dart 薪酬计算**: 净薪公式(base + overtime + bonus - deductions)、可配置规则 +- **FastAPI 后端骨架**: SQLAlchemy + SQLite、抽象 get_db 依赖 + ### Added - `src/provider/`: 基于 FastAPI + uv 的空后端项目骨架 diff --git a/docs/user-guide/human.md b/docs/user-guide/human.md index a5c26f7..50ea975 100644 --- a/docs/user-guide/human.md +++ b/docs/user-guide/human.md @@ -1,17 +1,422 @@ # 人力资源职能 -## 使用方式 +## 1. 项目介绍 -### 导入招聘邮箱 +人力资源职能(human)是量潮管理后台(QtCloud HR)的核心模块之一,覆盖招聘管道的全流程数字化管理。 +### 解决什么问题 + +- **邮件轰炸**:招聘邮箱每天收到大量简历,人工筛选费时费力 → AI 自动分类+优先级排序 +- **流程混乱**:候选人面试进度靠表格追踪,状态不透明 → 可视化管道看板 +- **信息孤岛**:邮件往来、面试评价、附件分散各处 → 统一详情面板 +- **协作困难**:HR 和业务部门信息不同步 → 实时共享的管道视图 + +### 核心流程 + +``` +招聘邮件 → AI 分类 → 队列审核 → 入管道 → 面试流转 → 录用/人才池 + ↓ + 邮件往来 · 附件管理 · 修正追踪 +``` + +### 适用角色 + +| 角色 | 使用场景 | +|------|----------| +| HR 专员 | 审核邮件队列、管理候选人管道、发送通知邮件 | +| HR 负责人 | 查看招聘进度、配置 AI 规则、导出数据 | +| 面试官 | 查看候选人材料、评价记录 | +| 管理员 | 配置服务端、管理飞书集成 | + +--- + +## 2. 项目解析 + +### 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 前端 UI | Flutter 3.11+ / Dart 3 | 跨平台桌面/Web 看板应用 | +| 后端 | Python 3.12+ / FastAPI | REST API 服务 | +| ORM | SQLAlchemy 2.0 | 数据访问层 | +| 数据库 | SQLite | 本地存储(可切换 PostgreSQL) | +| AI 分类 | OpenAI / 任意 LLM API | 邮件智能分类 | +| 飞书集成 | Feishu Open API | 邮件自动拉取与发送 | +| CLI | Python / Click | 命令行管理工具 | + +### 目录结构 + +``` +qtadmin/ +├── src/ +│ ├── provider/ # FastAPI 后端 +│ │ └── app/ +│ │ ├── __main__.py # 应用入口(uvicorn 启动) +│ │ └── human/ +│ │ ├── database.py # SQLite 连接与会话 +│ │ ├── seed.py # 种子数据(演示用) +│ │ ├── models/ # SQLAlchemy 数据模型 +│ │ │ ├── talent.py # 8 状态候选人状态机 +│ │ │ ├── recruitment.py # 招聘项目 +│ │ │ ├── candidate.py # 候选人 +│ │ │ ├── application.py # 申请(候选人+项目关联) +│ │ │ ├── pending_queue.py # 待处理邮件队列 +│ │ │ ├── mail_message.py # 邮件往来 +│ │ │ ├── correction_log.py # 人工修正日志 +│ │ │ ├── material.py # 素材附件 +│ │ │ ├── ai_config.py # AI 配置 +│ │ │ └── processed_mail.py # 已处理邮件记录 +│ │ ├── schemas/ # Pydantic 请求/响应模型 +│ │ ├── routers/ # API 路由 +│ │ │ ├── pipeline.py # 管道视图 +│ │ │ ├── queue.py # 邮件队列 +│ │ │ ├── applications.py # 申请 CRUD +│ │ │ ├── candidates.py # 候选人 CRUD +│ │ │ ├── recruitments.py # 招聘项目 CRUD +│ │ │ ├── messages.py # 邮件往来 +│ │ │ ├── ingest.py # 邮箱导入 +│ │ │ ├── ai_config.py # AI 配置 +│ │ │ ├── export.py # 数据导出 +│ │ │ ├── pool.py # 人才池 +│ │ │ └── materials.py # 素材查询 +│ │ └── services/ # 业务逻辑层 +│ │ ├── pipeline.py # 管道聚合 +│ │ ├── transition.py # 状态流转 + 同步 +│ │ ├── classifier.py # 关键词分类 +│ │ ├── ai_classifier.py # LLM 分类 +│ │ ├── email_matcher.py # 邮箱匹配 +│ │ ├── pool.py # 池管理 +│ │ ├── resume_parser.py # 简历解析 +│ │ ├── material_service.py # 素材管理 +│ │ ├── headcount.py # 编制规划 +│ │ └── export.py # 导出生成 +│ │ +│ ├── hr-kanban/ # Flutter 看板应用 +│ │ └── lib/ +│ │ ├── main.dart # 应用入口 + 导航壳 +│ │ ├── theme/hr_theme.dart # 深色主题 + 状态色 +│ │ ├── services/api_service.dart # API 客户端 +│ │ ├── models/ # Dart 数据模型 +│ │ ├── screens/ # 页面 +│ │ │ ├── pipeline_screen.dart # 管道看板 +│ │ │ ├── queue_screen.dart # 邮件队列 +│ │ │ ├── pool_screen.dart # 人才池 +│ │ │ └── settings_screen.dart # 设置 +│ │ └── widgets/ # 通用组件 +│ │ +│ └── cli/ # CLI 命令行工具 +│ └── app/human/ +│ ├── cli.py # 命令定义 +│ ├── api_client.py # HTTP 客户端 +│ ├── classifier.py # 本地分类 +│ ├── mail_sender.py # 邮件发送 +│ └── lark_client.py # 飞书 API +│ +├── quanttide-hr-toolkit-main/ # HR 工具包(子模块) +│ ├── packages/ +│ │ ├── dart/ # 纯 Dart 领域模型 +│ │ ├── fastapi/ # FastAPI 库代码 +│ │ └── flutter/ # Flutter 库代码 +│ └── integrations/feishu/ # 飞书集成 +│ └── src/feishu_integration/ +│ ├── mail_reader.py # 邮件读取 +│ ├── mail_sender.py # 邮件发送 +│ ├── mail_ingest_loop.py # 拉取循环 +│ ├── mail_sender_loop.py # 发送循环 +│ ├── classifier.py # 分类器 +│ └── pipeline_writer.py # 管道写回 +│ +└── CHANGELOG-human.md # Human 模块更新日志 +``` + +### 数据流 + +``` +1. 邮件进入 + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ 飞书邮箱 │───>│ 分类器 │───>│ 待处理队列 │ + │ API 拉取 │ │ AI/规则 │ │ Pending │ + └──────────┘ └──────────┘ └──────────┘ + +2. HR 审核 + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ 队列看板 │───>│ 确认/调整 │───>│ 申请创建 │ + │ Queue │ │ Confirm │ │Application│ + └──────────┘ └──────────┘ └──────────┘ + +3. 管道流转 + ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ + │ NEW │──>│EXAM │──>│EVAL │──>│OFFER │ + │ │ │SENT │ │UATING│ │ │ + └──────┘ └──────┘ └──────┘ └──────┘ + │ + ▼ + ┌────────┐ ┌────────┐ + │ 人才池 │<───────│ CLOSED │ + │ Pool │ │ │ + └────────┘ └────────┘ +``` + +### 状态机 + +候选人沿以下路径流转,每个状态只能跳转到允许的下一个状态: + +``` +NEW ──→ CONTACTED ──→ EXAM_SENT ──→ EXAM_RECEIVED ──→ EVALUATING ──→ INTERVIEW ──→ OFFER ──→ CLOSED + │ │ │ │ │ │ │ + └──→ CLOSED └──→ CLOSED └──→ CLOSED └──→ CLOSED └──→ CLOSED └──→ CLOSED └── + │ + EXAM_SENT (重评) +``` + +--- + +## 3. 启动教程 + +### 前置条件 + +| 工具 | 版本要求 | 验证命令 | +|------|----------|----------| +| Python | >= 3.12 | `python --version` | +| Flutter | >= 3.11 | `flutter --version` | +| pip | 最新 | `pip --version` | + +### 3.1 启动后端服务 + +```bash +# 1. 进入后端目录 +cd qtadmin/src/provider + +# 2. 安装依赖 +pip install -r requirements.txt + +# 或使用虚拟环境 +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows +pip install -r requirements.txt + +# 3. 初始化数据库 + 启动服务 +python -m app + +# 服务运行在 http://localhost:8080 +``` + +> 数据库文件 `hr.db` 自动创建在当前目录。首次启动时,如果数据库为空会自动填充演示数据。 + +**验证启动成功**: ```bash -qtadmin human xxxxx +curl http://localhost:8080/health +# 返回: {"status":"ok"} ``` -命令行工具使用`lark-cli`获取招聘邮箱数据并提交到服务端。 +打开浏览器访问 `http://localhost:8080/docs` 查看 Swagger API 文档。 + +### 3.2 启动 Flutter 看板 -### 查看招聘进度 +```bash +# 1. 进入看板目录 +cd qtadmin/src/hr-kanban -(工作台操作) +# 2. 安装依赖 +flutter pub get -可以xxxx看xxxx。 +# 3. 启动(默认打开 Chrome 浏览器) +flutter run -d chrome +``` + +看板启动后,在 **设置** 页面将服务端地址修改为 `http://localhost:8080`,点击保存即可连接。 + +> 也可编译为桌面应用:`flutter run -d linux` / `flutter run -d macos` / `flutter run -d windows` + +### 3.3 使用演示数据 + +后端首次启动时自动调用 `seed_data()` 填充演示数据,包含: + +- 1 个招聘项目 +- 10+ 候选人在不同流转阶段 +- 10 条待处理邮件队列 +- 部分候选人有邮件往来记录 + +如果数据库被清空,重启服务会自动重新填充种子数据。 + +### 3.4 CLI 命令行工具 + +```bash +# 进入 CLI 目录 +cd qtadmin/src/cli + +# 查看可用命令 +python -m qtadmin --help + +# 管理队列 +python -m qtadmin human queue list +python -m qtadmin human queue confirm --recruitment "高级后端工程师" +python -m qtadmin human queue ignore +``` + +### 3.5 配置 AI 分类(可选) + +在看板 **设置** 页面或通过 API 配置: + +```json +{ + "provider": "openai", + "model": "gpt-4o-mini", + "api_key": "sk-xxx", + "base_url": "https://api.openai.com/v1", + "temperature": 0.3, + "prompt_template": "请根据邮件内容判断候选人适合的阶段:new/contacted/exam_sent/exam_received/evaluating/interview/offer/closed" +} +``` + +配置后可通过 **连接测试** 按钮验证。 + +### 3.6 飞书邮件集成(可选) + +```bash +# 设置飞书邮箱 +export QTADMIN_MAILBOX="user@feishu.cn" + +# 启动后端(自动开始轮询邮箱) +python -m app +``` + +服务端启动时会自动创建后台任务,每 5 分钟拉取一次飞书邮箱,新邮件自动进入待处理队列。 + +--- + +## 4. 看板功能 + +### 4.1 管道(Pipeline) + +招聘管道看板,按候选人状态分组展示: + +- **状态分组**: 8 列,从 NEW 到 CLOSED +- **停留天数**: 每个卡片底部显示停留天数,超 7 天黄色,超 14 天橙色 +- **搜索**: 按姓名或邮箱实时过滤 +- **拖拽流转**: 拖拽卡片到目标状态列完成状态变更 +- **详情面板**: 点击卡片弹出底部面板,查看完整信息 + +### 4.2 队列(Queue) + +待处理邮件队列: + +- **邮件列表**: 所有未处理招聘邮件,按时间倒序 +- **置信度标签**: high(绿) / medium(黄) / low(灰) +- **操作**: 确认(创建候选人)、调整(修改状态后确认)、忽略 + +### 4.3 人才池(Pool) + +备选候选人池: + +- **筛选**: 按招聘项目过滤 +- **详情**: 查看候选人完整信息 +- **重新入池**: 移出池并分配到新的招聘项目 + +### 4.4 设置(Settings) + +- AI 配置表单(供应商/模型/密钥/地址/温度/提示词) +- 服务端地址切换(运行时修改,即时生效) +- 连接测试按钮 + +--- + +## 5. API 端点参考 + +### 招聘项目 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/recruitments` | 列表所有招聘项目 | +| POST | `/recruitments` | 创建招聘项目 | +| GET | `/recruitments/{id}` | 获取单个项目详情 | +| POST | `/recruitments/{id}/talents` | 手动登记候选人 | +| PATCH | `/recruitments/{id}/talents/{tid}` | 更新候选人信息 | +| POST | `/recruitments/{id}/talents/{tid}/transition` | 流转候选人状态 | + +### 管道 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/pipeline` | 获取聚合管道视图 | + +### 队列 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/queue` | 列表待处理邮件队列 | +| POST | `/queue/{id}/confirm` | 确认并创建候选人 | +| POST | `/queue/{id}/ignore` | 忽略该邮件 | + +### 申请 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/applications` | 列表申请 | +| POST | `/applications/{id}/transition` | 流转申请状态 | +| POST | `/applications/{id}/pool` | 归入人才池 | +| POST | `/applications/{id}/unpool` | 从人才池移出 | +| GET | `/applications/{id}/materials` | 获取申请材料 | + +### 候选人 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/candidates` | 列表候选人 | +| GET | `/candidates/{id}` | 候选人详情 | +| PATCH | `/candidates/{id}` | 更新候选人信息 | + +### 消息 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/candidates/{id}/messages` | 获取候选人邮件往来 | +| GET | `/candidates/{id}/timeline` | 获取候选人时间线 | +| POST | `/candidates/{id}/reply` | 回复候选人 | +| GET | `/messages/outbox` | 待发邮件列表 | +| GET | `/messages/outbox/dead` | 死信队列 | +| POST | `/messages/outbox/{id}/requeue` | 重新入队死信 | + +### 其他 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/ingest` | 批量导入邮件 | +| GET | `/ai-config` | 获取 AI 配置 | +| PUT | `/ai-config` | 更新 AI 配置 | +| POST | `/ai-config/test` | 测试 AI 连接 | +| GET | `/export/talents` | 导出人才数据 | + +--- + +## 6. CLI 工具 + +```bash +# 查看帮助 +python -m qtadmin --help + +# 导入招聘邮箱 +python -m qtadmin human import data/emails.json + +# 管理队列 +python -m qtadmin human queue list +python -m qtadmin human queue confirm -r "招聘项目名" +python -m qtadmin human queue ignore +``` + +--- + +## 7. 飞书集成 + +详见 `quanttide-hr-toolkit-main/integrations/feishu/`。 + +```bash +# 环境变量配置 +export FEISHU_APP_ID="cli_xxx" +export FEISHU_APP_SECRET="xxx" +export QTADMIN_MAILBOX="hr@company.feishu.cn" + +# 启动后端(自动启用邮件轮询) +python -m app +``` diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index d3b9170..8353370 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -2,5 +2,16 @@ 量潮管理后台分为业务和职能两类,以创始人视角统一管理公司各项事务。 -业务:量潮数据、量潮课堂、量潮咨询、量潮云等。 -职能:人力资源、财务管理、商务拓展、数字资产等。 +## 业务模块 + +- 量潮数据 +- 量潮课堂 +- 量潮咨询 +- 量潮云 + +## 职能模块 + +- [人力资源](human.md) — 招聘管道管理、邮件筛选、人才池 +- 财务管理 +- 商务拓展 +- 数字资产 diff --git a/examples/human/__init__.py b/examples/human/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/human/classifier.py b/examples/human/classifier.py new file mode 100644 index 0000000..ca2e7b2 --- /dev/null +++ b/examples/human/classifier.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass + +STATUS_KEYWORDS = { + "contacted": ["应聘", "求职", "简历", "申请"], + "exam_sent": ["笔试邀请", "笔试通知", "在线考试"], + "exam_received": ["笔试答案", "答题", "笔试完成", "提交答卷"], + "evaluating": ["评估", "审核简历", "简历评估"], + "interview": ["面试感谢", "面试反馈", "面试安排", "面试邀请"], + "offer": ["offer", "录用通知", "入职邀请", "薪酬确认"], + "closed": ["放弃", "退出", "拒绝", "不考虑"], +} + +@dataclass +class ClassificationResult: + suggested_status: str | None + confidence: str + suggested_position: str | None + extracted_name: str | None + extracted_email: str | None + extracted_phone: str | None + +def classify(subject: str, sender_name: str, sender_email: str) -> ClassificationResult: + subject_lower = subject.lower() + suggested_status = None; confidence = "low" + matched_keywords = [] + for status, keywords in STATUS_KEYWORDS.items(): + for kw in keywords: + if kw in subject_lower: + matched_keywords.append((status, kw)) + if matched_keywords: + status_groups = {} + for s, _ in matched_keywords: + status_groups[s] = status_groups.get(s, 0) + 1 + suggested_status = max(status_groups, key=status_groups.get) + confidence = "high" if status_groups[suggested_status] >= 2 else "medium" + extracted_name = sender_name if sender_name and sender_name != sender_email else None + return ClassificationResult(suggested_status, confidence, None, extracted_name, sender_email, None) diff --git a/examples/human/database.py b/examples/human/database.py new file mode 100644 index 0000000..745fbce --- /dev/null +++ b/examples/human/database.py @@ -0,0 +1,29 @@ +"""Database setup for example HR module.""" +from collections.abc import Generator +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "hr_demo.db") +DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + import human.models # noqa: F401 + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/examples/human/demo.py b/examples/human/demo.py new file mode 100644 index 0000000..ad40e03 --- /dev/null +++ b/examples/human/demo.py @@ -0,0 +1,237 @@ +"""HR Demo — Standalone server with Feishu integration. + +整合了 quanttide-hr-toolkit-main 的完整 demo 架构: + - 招聘管道 API(所有 routers) + - 飞书邮箱轮询(`_poll_mailbox` 后台任务) + - 附件下载(lark-cli + httpx) + - 种子数据 + 数据库迁移 + - 静态前端 + +Usage: + cd qtadmin + QTADMIN_MAILBOX=xxx@example.com PYTHONPATH=src/provider src/provider/.venv/bin/python examples/human/demo.py +""" +import asyncio +import json +import os +import subprocess +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import httpx +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from starlette.middleware.base import BaseHTTPMiddleware + +from app.human.database import SessionLocal, init_db +from app.human.models.processed_mail import ProcessedMail +from app.human.models.recruitment import Recruitment +from app.human.routers import ( + ai_config, applications, candidates, export, ingest, materials, messages, + pipeline, pool, queue, recruitments, +) + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_DATA_DIR = os.environ.get("QTADMIN_DATA_DIR", os.path.join(_PROJECT_ROOT, "data")) +_ATTACHMENT_DIR = os.path.join(_DATA_DIR, "attachments") +_MATERIALS_DIR = os.path.join(_DATA_DIR, "materials") + + +def seed_data_if_empty(): + db = SessionLocal() + try: + exists = db.query(Recruitment).first() + if not exists: + from app.human.seed import seed_data + seed_data(db) + finally: + db.close() + + +def _download_attachment(message_id: str, attachment: dict, mailbox: str) -> str | None: + """Download attachment via lark-cli download_url, return local path.""" + att_id = attachment.get("message_attachment_id") + if not att_id: + return None + + cmd = [ + "lark-cli", "mail", "user_mailbox.message.attachments", "download_url", + "--params", json.dumps({ + "user_mailbox_id": mailbox or "me", + "message_id": message_id, + "attachment_ids": [att_id], + }), + "--format", "json", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result.check_returncode() + resp = json.loads(result.stdout) + urls = resp.get("data", {}).get("download_urls", []) + if not urls: + return None + download_url = urls[0].get("download_url", "") + if not download_url: + return None + except Exception: + return None + + storage_dir = os.path.join(_ATTACHMENT_DIR, message_id) + os.makedirs(storage_dir, exist_ok=True) + file_path = os.path.join(storage_dir, attachment["filename"]) + + try: + r = httpx.get(download_url, timeout=60, follow_redirects=True) + r.raise_for_status() + with open(file_path, "wb") as f: + f.write(r.content) + attachment["size"] = len(r.content) + return file_path + except Exception: + return None + + +def _fetch_mail(mailbox: str) -> list[dict]: + from feishu_integration.mail_reader import fetch_and_classify, fetch_single_email + items = fetch_and_classify(mailbox=mailbox) + for item in items: + try: + detail = fetch_single_email(item["message_id"], mailbox=mailbox) + item["body"] = detail.get("body", "") + item["body_text"] = detail.get("body_plain_text", "") + item["recipient_email"] = detail.get("to", "") + attachments = [] + for a in detail.get("attachments", []): + att = { + "filename": a.get("filename", ""), + "size": a.get("size", 0), + "mime_type": a.get("content_type", ""), + "message_attachment_id": a.get("message_attachment_id") or a.get("id", ""), + } + if att["mime_type"] in ("application/pdf",) or att["filename"].endswith(".pdf"): + storage_path = _download_attachment(item["message_id"], att, mailbox) + if storage_path: + att["storage_path"] = storage_path + attachments.append(att) + item["attachments"] = attachments + except Exception: + pass + return items + + +async def _poll_mailbox(): + mailbox = os.environ.get("QTADMIN_MAILBOX", "") + if not mailbox: + return + while True: + try: + items = await asyncio.to_thread(_fetch_mail, mailbox) + db = SessionLocal() + try: + known = {row[0] for row in db.query(ProcessedMail.message_id).all()} + new_items = [it for it in items if it["message_id"] not in known] + for item in new_items: + db.add(ProcessedMail(message_id=item["message_id"])) + db.commit() + finally: + db.close() + if new_items: + payload = { + "source": "feishu_api", + "items": [ + { + "message_id": item["message_id"], + "subject": item["subject"], + "sender_name": item.get("sender_name", ""), + "sender_email": item["sender_email"], + "recipient_email": item.get("recipient_email", ""), + "suggested_status": item.get("suggested_status"), + "confidence": item.get("confidence", "low"), + "body": item.get("body"), + "body_text": item.get("body_text"), + "attachments": item.get("attachments"), + } + for item in new_items + ], + } + async with httpx.AsyncClient() as client: + resp = await client.post( + "http://localhost:8000/ingest", + json=payload, + timeout=30, + ) + resp.raise_for_status() + except Exception: + pass + await asyncio.sleep(300) + + +_poll_task: asyncio.Task | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + for d in [_ATTACHMENT_DIR, _MATERIALS_DIR]: + os.makedirs(d, exist_ok=True) + init_db() + seed_data_if_empty() + global _poll_task + _poll_task = asyncio.create_task(_poll_mailbox()) + yield + if _poll_task: + _poll_task.cancel() + + +app = FastAPI(title="HR Demo — 招聘管道看板", version="0.1.0", lifespan=lifespan) + + +@app.middleware("http") +async def no_cache(request, call_next): + response = await call_next(request) + if request.url.path in ("/",) or request.url.path.endswith((".html", ".js", ".css")): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return response + + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://127.0.0.1:8080", "http://localhost:8080", + "http://127.0.0.1:8081", "http://localhost:8081", + "http://127.0.0.1:8000", "http://localhost:8000", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ai_config.router) +app.include_router(export.router) +app.include_router(materials.router) +app.include_router(messages.router) +app.include_router(ingest.router) +app.include_router(queue.router) +app.include_router(pipeline.router) +app.include_router(pool.router) +app.include_router(recruitments.router) +app.include_router(candidates.router) +app.include_router(applications.router) + + +@app.get("/attachments/{message_id}/{filename:path}") +def serve_attachment(message_id: str, filename: str): + """Serve stored attachment files for browser preview.""" + file_path = os.path.join(_ATTACHMENT_DIR, message_id, filename) + if not os.path.isfile(file_path): + raise HTTPException(status_code=404, detail="Attachment not found") + return FileResponse(file_path, filename=filename) + + +static_dir = os.path.join(os.path.dirname(__file__), "static") +app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/examples/human/models/__init__.py b/examples/human/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/human/models/application.py b/examples/human/models/application.py new file mode 100644 index 0000000..5069a0c --- /dev/null +++ b/examples/human/models/application.py @@ -0,0 +1,21 @@ +from datetime import datetime +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from human.database import Base +from human.models.talent import TalentStatus + +class Application(Base): + __tablename__ = "applications" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + candidate_id: Mapped[int] = mapped_column(ForeignKey("candidates.id"), index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + candidate: Mapped["Candidate"] = relationship("Candidate", lazy="joined") + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True) + source: Mapped[str] = mapped_column(String(50), default="manual") + pooled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/examples/human/models/candidate.py b/examples/human/models/candidate.py new file mode 100644 index 0000000..a84156c --- /dev/null +++ b/examples/human/models/candidate.py @@ -0,0 +1,13 @@ +import enum +from datetime import datetime +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class Candidate(Base): + __tablename__ = "candidates" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/pending_queue.py b/examples/human/models/pending_queue.py new file mode 100644 index 0000000..98c064f --- /dev/null +++ b/examples/human/models/pending_queue.py @@ -0,0 +1,17 @@ +from datetime import datetime +from sqlalchemy import DateTime, String, func, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class PendingQueueItem(Base): + __tablename__ = "pending_queue" + __table_args__ = (UniqueConstraint("message_id"),) + id: Mapped[int] = mapped_column(primary_key=True, index=True) + message_id: Mapped[str] = mapped_column(String(100), unique=True, index=True) + subject: Mapped[str] = mapped_column(String(500)) + sender_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + sender_email: Mapped[str | None] = mapped_column(String(200), nullable=False) + suggested_status: Mapped[str | None] = mapped_column(String(30), nullable=True) + confidence: Mapped[str] = mapped_column(String(10), default="low") + hr_status: Mapped[str] = mapped_column(String(20), default="pending") + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/recruitment.py b/examples/human/models/recruitment.py new file mode 100644 index 0000000..88e931d --- /dev/null +++ b/examples/human/models/recruitment.py @@ -0,0 +1,9 @@ +from datetime import datetime +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class Recruitment(Base): + __tablename__ = "recruitments" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/examples/human/models/talent.py b/examples/human/models/talent.py new file mode 100644 index 0000000..49c9b15 --- /dev/null +++ b/examples/human/models/talent.py @@ -0,0 +1,44 @@ +import enum +from datetime import datetime +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column +from human.database import Base + +class TalentStatus(str, enum.Enum): + NEW = "new" + CONTACTED = "contacted" + EXAM_SENT = "exam_sent" + EXAM_RECEIVED = "exam_received" + EVALUATING = "evaluating" + INTERVIEW = "interview" + OFFER = "offer" + CLOSED = "closed" + +ALLOWED_STATUSES_FOR_SUB_STAGE = { + TalentStatus.CONTACTED, TalentStatus.EXAM_SENT, + TalentStatus.EVALUATING, TalentStatus.INTERVIEW, TalentStatus.OFFER, +} + +STATUS_TRANSITIONS = { + TalentStatus.NEW: [TalentStatus.CONTACTED, TalentStatus.CLOSED], + TalentStatus.CONTACTED: [TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.EXAM_SENT: [TalentStatus.EXAM_RECEIVED, TalentStatus.CLOSED], + TalentStatus.EXAM_RECEIVED: [TalentStatus.EVALUATING, TalentStatus.CLOSED], + TalentStatus.EVALUATING: [TalentStatus.EXAM_SENT, TalentStatus.INTERVIEW, TalentStatus.CLOSED], + TalentStatus.INTERVIEW: [TalentStatus.OFFER, TalentStatus.CLOSED], + TalentStatus.OFFER: [TalentStatus.CLOSED], + TalentStatus.CLOSED: [], +} + +class Talent(Base): + __tablename__ = "talents" + id: Mapped[int] = mapped_column(primary_key=True, index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/examples/human/routers/__init__.py b/examples/human/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/human/routers/applications.py b/examples/human/routers/applications.py new file mode 100644 index 0000000..a823dc4 --- /dev/null +++ b/examples/human/routers/applications.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.application import Application +from human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/applications", tags=["human"]) + +@router.get("", response_model=list[ApplicationRead]) +def list_applications(status: str | None = None, pooled: bool | None = None, + skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db)): + qb = db.query(Application) + if status: qb = qb.filter(Application.status == status) + if pooled is True: qb = qb.filter(Application.pooled_at.isnot(None)) + elif pooled is False: qb = qb.filter(Application.pooled_at.is_(None)) + return qb.order_by(Application.created_at.desc()).offset(skip).limit(limit).all() diff --git a/examples/human/routers/candidates.py b/examples/human/routers/candidates.py new file mode 100644 index 0000000..7052e7c --- /dev/null +++ b/examples/human/routers/candidates.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.candidate import CandidateRead +from human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/candidates", tags=["human"]) + +@router.get("", response_model=list[CandidateRead]) +def list_candidates(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + return db.query(Candidate).order_by(Candidate.created_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{candidate_id}/applications", response_model=list[ApplicationRead]) +def get_candidate_applications(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: raise HTTPException(404, "Candidate not found") + return db.query(Application).filter(Application.candidate_id == candidate_id).all() diff --git a/examples/human/routers/ingest.py b/examples/human/routers/ingest.py new file mode 100644 index 0000000..8b81086 --- /dev/null +++ b/examples/human/routers/ingest.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.pending_queue import PendingQueueItem +from human.schemas.pending_queue import IngestRequest, IngestResponse + +router = APIRouter(tags=["human"]) + +@router.post("/ingest", status_code=201, response_model=IngestResponse) +def ingest_items(data: IngestRequest, db: Session = Depends(get_db)): + queued = 0; skipped = 0; errors = [] + for item in data.items: + exists = db.query(PendingQueueItem).filter(PendingQueueItem.message_id == item.message_id).first() + if exists: + skipped += 1; continue + qi = PendingQueueItem(message_id=item.message_id, subject=item.subject, + sender_name=item.sender_name, sender_email=item.sender_email, + suggested_status=item.suggested_status, confidence=item.confidence) + db.add(qi); queued += 1 + db.commit() + return IngestResponse(batch_id=None, queued=queued, skipped=skipped, errors=errors) diff --git a/examples/human/routers/pipeline.py b/examples/human/routers/pipeline.py new file mode 100644 index 0000000..d9881a1 --- /dev/null +++ b/examples/human/routers/pipeline.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from human.database import get_db +from human.services.pipeline import get_pipeline + +router = APIRouter(tags=["human"]) + +@router.get("/pipeline") +def pipeline_view(db: Session = Depends(get_db)): + return get_pipeline(db) diff --git a/examples/human/routers/pool.py b/examples/human/routers/pool.py new file mode 100644 index 0000000..be43b65 --- /dev/null +++ b/examples/human/routers/pool.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.application import Application +from human.services.pool import get_pooled_applications, pool_application, unpool_application +from human.schemas.application import PoolItemRead, UnpoolRequest + +router = APIRouter(prefix="/pool", tags=["human"]) + +def _pool_item_from_orm(app: Application) -> dict: + return { + "id": app.id, "candidate_id": app.candidate_id, "recruitment_id": app.recruitment_id, + "status": app.status.value, "source": app.source, + "pooled_at": app.pooled_at.isoformat() if app.pooled_at else None, + "deactivated_at": app.deactivated_at.isoformat() if app.deactivated_at else None, + "candidate_email": app.candidate.email if app.candidate else "", + "candidate_name": app.candidate.real_name if app.candidate else "", + } + +@router.get("", response_model=list[dict]) +def list_pool(db: Session = Depends(get_db)): + apps = get_pooled_applications(db) + return [_pool_item_from_orm(a) for a in apps] + +@router.post("/{application_id}/pool", response_model=dict) +def pool_app(application_id: int, db: Session = Depends(get_db)): + try: + app = pool_application(db, application_id) + return _pool_item_from_orm(app) + except ValueError as e: + raise HTTPException(404, str(e)) + +@router.post("/{application_id}/unpool", status_code=201, response_model=dict) +def unpool_app(application_id: int, data: UnpoolRequest, db: Session = Depends(get_db)): + try: + app = unpool_application(db, application_id, data.recruitment_id) + return _pool_item_from_orm(app) + except ValueError as e: + raise HTTPException(400, str(e)) diff --git a/examples/human/routers/queue.py b/examples/human/routers/queue.py new file mode 100644 index 0000000..646fbab --- /dev/null +++ b/examples/human/routers/queue.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.pending_queue import PendingQueueItem +from human.models.recruitment import Recruitment +from human.models.talent import Talent, TalentStatus +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.pending_queue import ConfirmRequest, ConfirmResponse, IgnoreRequest + +router = APIRouter(prefix="/queue", tags=["human"]) + +@router.get("") +def list_queue(hr_status: str | None = None, db: Session = Depends(get_db)): + qb = db.query(PendingQueueItem).order_by(PendingQueueItem.created_at.desc()) + if hr_status: + qb = qb.filter(PendingQueueItem.hr_status == hr_status) + items = qb.all() + return {"total": len(items), "items": [{ + "queue_id": qi.id, "message_id": qi.message_id, + "subject": qi.subject, "sender_name": qi.sender_name, + "sender_email": qi.sender_email, "suggested_status": qi.suggested_status, + "confidence": qi.confidence, "hr_status": qi.hr_status, + "created_at": str(qi.created_at), + } for qi in items]} + +@router.patch("/{queue_id}/confirm", response_model=ConfirmResponse) +def confirm_queue_item(queue_id: int, data: ConfirmRequest, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not qi: + raise HTTPException(404, "Queue item not found") + qi.hr_status = "confirmed"; db.flush() + recruitment = db.query(Recruitment).order_by(Recruitment.created_at.desc()).first() + if not recruitment: + recruitment = Recruitment(); db.add(recruitment); db.flush() + email = data.email or qi.sender_email or "unknown@email.com" + name = data.real_name or qi.sender_name or email.split("@")[0] + status = TalentStatus(data.status) if data.status else TalentStatus.CONTACTED + candidate = db.query(Candidate).filter(Candidate.email == email).first() + if not candidate: + candidate = Candidate(email=email, real_name=name); db.add(candidate); db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment.id, status=status, source="email_queue") + db.add(app); db.flush() + talent = Talent(recruitment_id=recruitment.id, email=email, real_name=name, status=status) + db.add(talent); db.commit(); db.refresh(talent) + return ConfirmResponse(queue_id=queue_id, action="confirmed", talent_id=talent.id) + +@router.patch("/{queue_id}/ignore", response_model=ConfirmResponse) +def ignore_queue_item(queue_id: int, data: IgnoreRequest = None, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not qi: + raise HTTPException(404, "Queue item not found") + qi.hr_status = "ignored"; db.commit() + return ConfirmResponse(queue_id=queue_id, action="ignored") + +@router.get("/stats") +def queue_stats(db: Session = Depends(get_db)): + from sqlalchemy import func + counts = db.query(PendingQueueItem.hr_status, func.count(PendingQueueItem.id)).group_by(PendingQueueItem.hr_status).all() + stats = {"pending": 0, "confirmed": 0, "ignored": 0} + for status, count in counts: + if status in stats: stats[status] = count + return stats + +@router.get("/by-email") +def get_queue_by_email(email: str, db: Session = Depends(get_db)): + qi = db.query(PendingQueueItem).filter(PendingQueueItem.sender_email == email).order_by(PendingQueueItem.created_at.desc()).first() + if not qi: + return {"found": False} + return {"found": True, "item": {"queue_id": qi.id, "message_id": qi.message_id, "subject": qi.subject, + "sender_name": qi.sender_name, "sender_email": qi.sender_email, + "suggested_status": qi.suggested_status, "confidence": qi.confidence, + "hr_status": qi.hr_status}} diff --git a/examples/human/routers/recruitments.py b/examples/human/routers/recruitments.py new file mode 100644 index 0000000..7adb54a --- /dev/null +++ b/examples/human/routers/recruitments.py @@ -0,0 +1,105 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from human.database import get_db +from human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus +from human.models.recruitment import Recruitment +from human.models.candidate import Candidate +from human.models.application import Application +from human.schemas.talent import SubStageUpdate, TalentCreate, TalentRead, TalentTransition, TalentUpdate +from human.schemas.recruitment import HeadcountRead, RecruitmentRead +from human.services.headcount import get_headcount + +router = APIRouter(prefix="/recruitments", tags=["human"]) + +@router.get("", response_model=list[RecruitmentRead]) +def list_recruitments(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + return db.query(Recruitment).order_by(Recruitment.created_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{recruitment_id}", response_model=RecruitmentRead) +def get_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: raise HTTPException(404, "Recruitment not found") + return r + +@router.post("", response_model=RecruitmentRead, status_code=201) +def create_recruitment(db: Session = Depends(get_db)): + r = Recruitment(); db.add(r); db.commit(); db.refresh(r); return r + +@router.delete("/{recruitment_id}", status_code=204) +def delete_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: raise HTTPException(404, "Recruitment not found") + db.delete(r); db.commit() + +@router.get("/{recruitment_id}/headcount", response_model=HeadcountRead) +def get_recruitment_headcount(recruitment_id: int, db: Session = Depends(get_db)): + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + return get_headcount(db, recruitment_id) + +@router.get("/{recruitment_id}/talents", response_model=list[TalentRead]) +def list_talents(recruitment_id: int, status: TalentStatus | None = None, + skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), db: Session = Depends(get_db)): + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + qb = db.query(Talent).filter(Talent.recruitment_id == recruitment_id) + if status: qb = qb.filter(Talent.status == status) + return qb.order_by(Talent.updated_at.desc()).offset(skip).limit(limit).all() + +@router.get("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def get_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + return t + +@router.post("/{recruitment_id}/talents", response_model=TalentRead, status_code=201) +def create_talent(recruitment_id: int, data: TalentCreate, db: Session = Depends(get_db)): + recruitment = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not recruitment: raise HTTPException(404, "Recruitment not found") + candidate = db.query(Candidate).filter(Candidate.email == data.email).first() + if not candidate: + candidate = Candidate(email=data.email, real_name=data.real_name); db.add(candidate); db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment_id, source="manual_debug"); db.add(app); db.flush() + t = Talent(recruitment_id=recruitment_id, email=data.email, real_name=data.real_name) + db.add(t); db.commit(); db.refresh(t); return t + +@router.patch("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def update_talent(recruitment_id: int, talent_id: int, data: TalentUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + for k, v in data.model_dump(exclude_unset=True).items(): setattr(t, k, v) + db.commit(); db.refresh(t); return t + +@router.post("/{recruitment_id}/talents/{talent_id}/transition", response_model=TalentRead) +def transition_talent(recruitment_id: int, talent_id: int, data: TalentTransition, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + if data.status not in STATUS_TRANSITIONS.get(t.status, []): + raise HTTPException(400, f"Cannot transition from {t.status.value} to {data.status.value}") + candidate = db.query(Candidate).filter(Candidate.email == t.email).first() + if candidate: + app = db.query(Application).filter(Application.candidate_id == candidate.id, + Application.recruitment_id == recruitment_id).order_by(Application.created_at.desc()).first() + if app: + app.status = data.status + if data.status != t.status: app.sub_stage = None + if data.sub_stage is not None and data.status in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = data.sub_stage + t.status = app.status; t.sub_stage = app.sub_stage; t.stage_results = app.stage_results + else: + t.status = data.status + db.commit(); db.refresh(t); return t + +@router.patch("/{recruitment_id}/talents/{talent_id}/sub-stage", response_model=TalentRead) +def set_talent_sub_stage(recruitment_id: int, talent_id: int, data: SubStageUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + if t.status not in ALLOWED_STATUSES_FOR_SUB_STAGE: + raise HTTPException(400, f"Cannot set sub_stage for status {t.status.value}") + t.sub_stage = data.sub_stage; db.commit(); db.refresh(t); return t + +@router.delete("/{recruitment_id}/talents/{talent_id}", status_code=204) +def delete_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: raise HTTPException(404, "Talent not found") + db.delete(t); db.commit() diff --git a/examples/human/schemas/__init__.py b/examples/human/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/human/schemas/application.py b/examples/human/schemas/application.py new file mode 100644 index 0000000..947e184 --- /dev/null +++ b/examples/human/schemas/application.py @@ -0,0 +1,21 @@ +from datetime import datetime +from pydantic import BaseModel, Field +from human.models.talent import TalentStatus + +class ApplicationRead(BaseModel): + id: int; candidate_id: int; recruitment_id: int + status: TalentStatus; sub_stage: str | None; quality: str + stage_results: dict | None; source: str + pooled_at: datetime | None; deactivated_at: datetime | None + created_at: datetime; updated_at: datetime + model_config = {"from_attributes": True} + +class PoolItemRead(BaseModel): + id: int; candidate_id: int; recruitment_id: int + status: TalentStatus; source: str + pooled_at: datetime | None; deactivated_at: datetime | None + candidate_email: str = ""; candidate_name: str = "" + model_config = {"from_attributes": True} + +class UnpoolRequest(BaseModel): + recruitment_id: int = Field(..., ge=1) diff --git a/examples/human/schemas/candidate.py b/examples/human/schemas/candidate.py new file mode 100644 index 0000000..1749358 --- /dev/null +++ b/examples/human/schemas/candidate.py @@ -0,0 +1,6 @@ +from datetime import datetime +from pydantic import BaseModel + +class CandidateRead(BaseModel): + id: int; email: str; real_name: str; phone: str | None; created_at: datetime + model_config = {"from_attributes": True} diff --git a/examples/human/schemas/pending_queue.py b/examples/human/schemas/pending_queue.py new file mode 100644 index 0000000..ec4d25d --- /dev/null +++ b/examples/human/schemas/pending_queue.py @@ -0,0 +1,29 @@ +from datetime import datetime +from pydantic import BaseModel + +class ConfirmRequest(BaseModel): + action: str = "confirmed"; status: str = "contacted" + real_name: str = ""; email: str = "" + +class ConfirmResponse(BaseModel): + queue_id: int; action: str; talent_id: int | None = None + +class IgnoreRequest(BaseModel): + action: str = "ignored" + +class QueueItemRead(BaseModel): + queue_id: int; message_id: str; subject: str + sender_name: str | None; sender_email: str | None + suggested_status: str | None; confidence: str + hr_status: str; created_at: str + +class IngestItem(BaseModel): + message_id: str; subject: str + sender_name: str = ""; sender_email: str = "" + suggested_status: str = "contacted"; confidence: str = "low" + +class IngestRequest(BaseModel): + source: str = "example"; items: list[IngestItem] + +class IngestResponse(BaseModel): + batch_id: str | None; queued: int; skipped: int; errors: list[str] diff --git a/examples/human/schemas/recruitment.py b/examples/human/schemas/recruitment.py new file mode 100644 index 0000000..1dec058 --- /dev/null +++ b/examples/human/schemas/recruitment.py @@ -0,0 +1,9 @@ +from datetime import datetime +from pydantic import BaseModel + +class RecruitmentRead(BaseModel): + id: int; created_at: datetime + model_config = {"from_attributes": True} + +class HeadcountRead(BaseModel): + recruitment_id: int; total_offers: int; accepted: int diff --git a/examples/human/schemas/talent.py b/examples/human/schemas/talent.py new file mode 100644 index 0000000..394f476 --- /dev/null +++ b/examples/human/schemas/talent.py @@ -0,0 +1,24 @@ +from datetime import datetime +from pydantic import BaseModel, Field +from human.models.talent import TalentStatus + +class TalentCreate(BaseModel): + email: str + real_name: str + auto_screening_result: str | None = None + +class TalentRead(BaseModel): + id: int; recruitment_id: int; email: str; real_name: str + status: TalentStatus; sub_stage: str | None; quality: str + stage_results: dict | None; created_at: datetime; updated_at: datetime + model_config = {"from_attributes": True} + +class TalentUpdate(BaseModel): + email: str | None = None; real_name: str | None = None + model_config = {"extra": "forbid"} + +class TalentTransition(BaseModel): + status: TalentStatus; sub_stage: str | None = None + +class SubStageUpdate(BaseModel): + sub_stage: str | None = None diff --git a/examples/human/seed.py b/examples/human/seed.py new file mode 100644 index 0000000..9dfdb3d --- /dev/null +++ b/examples/human/seed.py @@ -0,0 +1,107 @@ +from datetime import datetime, timedelta +from hashlib import md5 +from sqlalchemy import update +from sqlalchemy.orm import Session +from human.models.application import Application +from human.models.candidate import Candidate +from human.models.pending_queue import PendingQueueItem +from human.models.recruitment import Recruitment +from human.models.talent import Talent, TalentStatus + +SEED_TRANSITIONS = { + s: [] for s in ["new", "contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer", "closed"] +} +SEED_TRANSITIONS["contacted"] = ["contacted"] +SEED_TRANSITIONS["exam_sent"] = ["contacted", "exam_sent"] +SEED_TRANSITIONS["exam_received"] = ["contacted", "exam_sent", "exam_received"] +SEED_TRANSITIONS["evaluating"] = ["contacted", "exam_sent", "exam_received", "evaluating"] +SEED_TRANSITIONS["interview"] = ["contacted", "exam_sent", "exam_received", "evaluating", "interview"] +SEED_TRANSITIONS["offer"] = ["contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer"] +SEED_TRANSITIONS["closed"] = ["closed"] + +DEMO_TALENTS = [ + ("new", f"张{cn}", f"zhang{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("contacted", f"李{cn}", f"li{i}@demo.local", None if i > 3 else "resume_passed") for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("exam_sent", f"王{cn}", f"wang{i}@demo.local", "taking" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("exam_received", f"赵{cn}", f"zhao{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("evaluating", f"孙{cn}", f"sun{i}@demo.local", "exam_passed" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("interview", f"周{cn}", f"zhou{i}@demo.local", "interview_passed" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("offer", f"吴{cn}", f"wu{i}@demo.local", "accepted" if 2 <= i <= 4 else None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + [ + ("closed", f"郑{cn}", f"zheng{i}@demo.local", None) for i, cn in enumerate(["一","二","三","四","五"], 1) +] + +QUALITY_MAP = {"李二": "excellent", "李三": "excellent", "李四": "excellent", + "孙二": "excellent", "孙三": "excellent", "周子": "excellent", + "吴二": "excellent", "吴三": "excellent", "张五": "excellent"} + +def build_transition_chain(target: str) -> list[str]: + return SEED_TRANSITIONS[target] + +def seed_data(db: Session) -> None: + import human.models # noqa: F401 + r = Recruitment() + db.add(r); db.flush() + + for target_status, name, email, sub_stage in DEMO_TALENTS: + t = Talent(recruitment_id=r.id, email=email, real_name=name) + db.add(t); db.flush() + for s in build_transition_chain(target_status): + t.status = TalentStatus(s); db.flush() + t.sub_stage = sub_stage + t.quality = QUALITY_MAP.get(name, "normal") + stage_map = {"exam_sent": {"contacted": "pass"}, "exam_received": {"contacted": "pass"}, + "evaluating": {"contacted": "pass"}, "interview": {"contacted": "pass", "evaluating": "pass"}, + "offer": {"contacted": "pass", "evaluating": "pass", "interview": "pass"}} + t.stage_results = stage_map.get(target_status); db.flush() + db.commit() + + status_age = {"new": 0, "contacted": 2, "exam_sent": 5, "exam_received": 8, + "evaluating": 12, "interview": 15, "offer": 20, "closed": 25} + for target_status, name, email, _ in DEMO_TALENTS: + days = status_age[target_status] + if days > 0: + db.execute(update(Talent).where(Talent.email == email).values(updated_at=datetime.utcnow() - timedelta(days=days))) + db.commit() + + email_to_candidate = {} + for target_status, name, email, _ in DEMO_TALENTS: + if email not in email_to_candidate: + c = Candidate(email=email, real_name=name); db.add(c); db.flush() + email_to_candidate[email] = c + + for target_status, name, email, sub_stage in DEMO_TALENTS: + talent = db.query(Talent).filter(Talent.email == email).first() + if talent: + a = Application(candidate_id=email_to_candidate[email].id, recruitment_id=r.id, + status=talent.status, sub_stage=talent.sub_stage, quality=talent.quality, + stage_results=talent.stage_results, source="manual_seed") + db.add(a); db.flush() + + zhang3 = email_to_candidate.get("zhang3@demo.local") + if zhang3: + db.add(Application(candidate_id=zhang3.id, recruitment_id=r.id, status=TalentStatus.NEW, + source="manual_seed", pooled_at=datetime.utcnow())) + wang5 = email_to_candidate.get("wang5@demo.local") + if wang5: + db.add(Application(candidate_id=wang5.id, recruitment_id=r.id, status=TalentStatus.EXAM_SENT, source="manual_seed")) + db.commit() + + from human.classifier import classify + from human.demo import get_demo_emails + for email in get_demo_emails(): + result = classify(email.subject, email.sender_name, email.sender_email) + qi = PendingQueueItem( + message_id=md5(email.subject.encode()).hexdigest()[:16], + subject=email.subject, sender_name=email.sender_name, + sender_email=email.sender_email, + suggested_status=result.suggested_status, confidence=result.confidence, + ) + db.add(qi) + db.commit() diff --git a/examples/human/services/__init__.py b/examples/human/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/human/services/headcount.py b/examples/human/services/headcount.py new file mode 100644 index 0000000..28fb011 --- /dev/null +++ b/examples/human/services/headcount.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session +from human.models.talent import TalentStatus +from human.models.application import Application + +def get_headcount(db: Session, recruitment_id: int) -> dict: + total = db.query(Application).filter( + Application.recruitment_id == recruitment_id, + Application.status == TalentStatus.OFFER, + Application.pooled_at.is_(None), + ).count() + accepted = db.query(Application).filter( + Application.recruitment_id == recruitment_id, + Application.status == TalentStatus.OFFER, + Application.sub_stage == "accepted", + ).count() + return {"recruitment_id": recruitment_id, "total_offers": total, "accepted": accepted} diff --git a/examples/human/services/pipeline.py b/examples/human/services/pipeline.py new file mode 100644 index 0000000..412c032 --- /dev/null +++ b/examples/human/services/pipeline.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Session +from human.models.talent import Talent, TalentStatus + +def get_pipeline(db: Session) -> dict: + talents = db.query(Talent).filter(Talent.status != TalentStatus.CLOSED).all() + stages = {s.value: [] for s in TalentStatus} + for t in talents: + stages[t.status.value].append(_talent_to_card(t)) + summary = {"total": len(talents), "by_stage": {}} + for s in TalentStatus: + count = len(stages[s.value]) + if count > 0: + summary["by_stage"][s.value] = count + return {"stages": stages, "summary": summary} + +def _talent_to_card(t: Talent) -> dict: + return { + "id": t.id, "email": t.email, "real_name": t.real_name, + "recruitment_id": t.recruitment_id, "status": t.status.value, + "sub_stage": t.sub_stage, "quality": t.quality, + "stage_results": t.stage_results, + "created_at": t.created_at.isoformat() if t.created_at else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } diff --git a/examples/human/services/pool.py b/examples/human/services/pool.py new file mode 100644 index 0000000..0e706da --- /dev/null +++ b/examples/human/services/pool.py @@ -0,0 +1,37 @@ +from datetime import datetime, timezone +from sqlalchemy.orm import Session, joinedload +from human.models.application import Application +from human.models.talent import TalentStatus + +def pool_application(db: Session, application_id: int) -> Application: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + raise ValueError("Application not found") + now = datetime.now(timezone.utc) + app.pooled_at = now + app.deactivated_at = now + app.status = TalentStatus.CLOSED + db.commit() + db.refresh(app) + return app + +def unpool_application(db: Session, application_id: int, recruitment_id: int) -> Application: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + raise ValueError("Application not found") + if app.pooled_at is None: + raise ValueError("Application is not pooled") + new_app = Application( + candidate_id=app.candidate_id, recruitment_id=recruitment_id, + status=TalentStatus.NEW, source="pool", + ) + db.add(new_app) + db.commit() + db.refresh(new_app) + return new_app + +def get_pooled_applications(db: Session) -> list[Application]: + return (db.query(Application) + .options(joinedload(Application.candidate)) + .filter(Application.pooled_at.isnot(None)) + .order_by(Application.pooled_at.desc()).all()) diff --git a/examples/human/static/index.html b/examples/human/static/index.html new file mode 100644 index 0000000..cc51f0a --- /dev/null +++ b/examples/human/static/index.html @@ -0,0 +1,1182 @@ + + + + + +招聘管道看板 + 飞书确认队列 + + + + +
+ +

招聘管道看板

+
+ + + + + +
+
+ +
+ +
+ 加载失败 + +
+
+ 后端服务连接断开 + +
+ +
+
+
+ 飞书邮件确认队列 + 0 +
+
+
+
+ +
+
+
+ +
+
+

人才库

+
+
+
+
+
+ +
+

AI 配置

+
+

加载中...

+
+
+ +
+
+

候选人材料

+ × +
+
+
点击候选人查看详情
+
+
+
+ +
+
+
+ 预览 + × +
+
+
+
+
加载中...
+
+ + +
+
+
+ +
+
+
加载中...
+
+ +
+ + + + diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 0000000..68c717d --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "qtadmin-example" +version = "0.1.0" +description = "qtadmin HR demo example" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.136.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.0", + "pydantic>=2.0.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["human"] diff --git a/manifests/qtadmin-mail-sender.service b/manifests/qtadmin-mail-sender.service new file mode 100644 index 0000000..2e03df5 --- /dev/null +++ b/manifests/qtadmin-mail-sender.service @@ -0,0 +1,30 @@ +[Unit] +Description=QtAdmin Mail Sender — outbox polling daemon +Documentation=https://github.com/quanttide/qtadmin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=linli +WorkingDirectory=/home/linli/桌面/qt-hr/qtadmin/src/cli +ExecStart=/home/linli/桌面/qt-hr/qtadmin/src/cli/.venv/bin/python -m app.human.mail_sender_loop +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=300 +StartLimitBurst=10 + +# Environment +Environment=QTADMIN_SERVER_URL=http://localhost:8080 +Environment=QTADMIN_MAILBOX=tc@huaxiadiyishenyi.online +Environment=HOME=/home/linli + +# PATH must include ~/.npm-global/bin for lark-cli +Environment=PATH=/home/linli/.npm-global/bin:/usr/local/bin:/usr/bin:/bin + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/manifests/qtadmin-provider.service b/manifests/qtadmin-provider.service new file mode 100644 index 0000000..bdbec4d --- /dev/null +++ b/manifests/qtadmin-provider.service @@ -0,0 +1,22 @@ +[Unit] +Description=QtAdmin Provider — HR recruitment pipeline API +Documentation=https://github.com/quanttide/qtadmin +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=linli +WorkingDirectory=/home/linli/桌面/qt-hr/qtadmin/src/provider +ExecStart=/home/linli/桌面/qt-hr/qtadmin/src/provider/.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 +Restart=on-failure +RestartSec=5 +StartLimitIntervalSec=300 +StartLimitBurst=10 + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9da7912 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = src/provider diff --git a/scripts/install-services.sh b/scripts/install-services.sh new file mode 100755 index 0000000..25944f0 --- /dev/null +++ b/scripts/install-services.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Install qtadmin systemd services +# Run as root: sudo bash scripts/install-services.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFESTS_DIR="$SCRIPT_DIR/../manifests" + +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: must run as root (sudo)" + exit 1 +fi + +SERVICES=( + "qtadmin-provider.service" + "qtadmin-mail-sender.service" +) + +for svc in "${SERVICES[@]}"; do + src="$MANIFESTS_DIR/$svc" + if [ ! -f "$src" ]; then + echo "WARNING: $src not found, skipping" + continue + fi + cp "$src" "/etc/systemd/system/$svc" + echo "Installed $svc" +done + +systemctl daemon-reload + +for svc in "${SERVICES[@]}"; do + systemctl enable "$svc" + systemctl restart "$svc" || echo "WARNING: $svc failed to start (may need user/config setup)" +done + +echo "" +echo "=== Status ===" +for svc in "${SERVICES[@]}"; do + systemctl status "$svc" --no-pager 2>&1 | head -5 + echo "" +done + +echo "" +echo "Commands:" +echo " systemctl status qtadmin-provider # check provider status" +echo " journalctl -u qtadmin-provider -f # tail provider logs" +echo " systemctl status qtadmin-mail-sender # check mail sender status" +echo " journalctl -u qtadmin-mail-sender -f # tail mail sender logs" diff --git a/scripts/qtadmin b/scripts/qtadmin new file mode 100755 index 0000000..c8813c8 --- /dev/null +++ b/scripts/qtadmin @@ -0,0 +1,2 @@ +#!/bin/bash +exec /home/linli/桌面/qt-hr/qtadmin/src/cli/.venv/bin/qtadmin "$@" diff --git a/scripts/start-all.sh b/scripts/start-all.sh new file mode 100755 index 0000000..86ba513 --- /dev/null +++ b/scripts/start-all.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Start all qtadmin services for development +# Provider API → :8080 | Demo frontend → :8000 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR/.." + +cleanup() { + echo "" + echo "Stopping all services..." + pkill -f "uvicorn app.__main__:app" 2>/dev/null || true + pkill -f "examples/human/app.py" 2>/dev/null || true + echo "All services stopped." +} +trap cleanup EXIT + +# Start provider +echo "Starting Provider API on :8080..." +cd "$PROJECT_DIR/src/provider" +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 & +PROVIDER_PID=$! +echo " Provider PID: $PROVIDER_PID" + +# Wait for provider to be ready +sleep 2 + +# Start demo +echo "Starting Demo frontend on :8000..." +cd "$PROJECT_DIR" +python examples/human/app.py & +DEMO_PID=$! +echo " Demo PID: $DEMO_PID" + +echo "" +echo "=== All services started ===" +echo " Provider API: http://localhost:8080" +echo " Demo frontend: http://localhost:8000" +echo " Health check: http://localhost:8080/health" +echo "" +echo "Press Ctrl+C to stop all services." + +# Wait for any process to exit +wait diff --git a/src/cli/app/cli.py b/src/cli/app/cli.py index 4c6f7ca..347f4fa 100644 --- a/src/cli/app/cli.py +++ b/src/cli/app/cli.py @@ -7,6 +7,7 @@ from app.asset import backup as asset_backup from app.asset import audit as asset_audit +from app.human import cli as human_cli app = typer.Typer(no_args_is_help=True, invoke_without_command=True) @@ -16,6 +17,7 @@ asset_app.command()(asset_audit.audit) app.add_typer(asset_app, name="asset") +app.add_typer(human_cli.app, name="human") @app.callback(invoke_without_command=True) diff --git a/src/cli/app/human/__init__.py b/src/cli/app/human/__init__.py new file mode 100644 index 0000000..f0e4b91 --- /dev/null +++ b/src/cli/app/human/__init__.py @@ -0,0 +1 @@ +"""Human module: recruitment email classification and ingestion.""" diff --git a/src/cli/app/human/api_client.py b/src/cli/app/human/api_client.py new file mode 100644 index 0000000..f99fde6 --- /dev/null +++ b/src/cli/app/human/api_client.py @@ -0,0 +1,78 @@ +"""HTTP client for communicating with the qtadmin provider HR API.""" +import httpx + + +class ApiClient: + """Client for the qtadmin provider HR API.""" + + def __init__(self, base_url: str = "http://127.0.0.1:8080") -> None: + self._base_url = base_url.rstrip("/") + + def ingest(self, source: str, items: list[dict]) -> dict: + """POST /ingest — push classified emails to pending queue.""" + r = httpx.post(f"{self._base_url}/ingest", json={"source": source, "items": items}) + if r.status_code != 201: + raise RuntimeError(f"Ingest failed (HTTP {r.status_code}): {r.text}") + return r.json() + + def get_queue_stats(self) -> dict[str, int]: + """GET /queue/stats — get pending/confirmed/ignored counts.""" + r = httpx.get(f"{self._base_url}/queue/stats") + if r.status_code != 200: + raise RuntimeError(f"Queue stats failed (HTTP {r.status_code}): {r.text}") + return r.json() + + def claim_outbox(self) -> dict: + """POST /messages/outbox/claim — claim pending outbox messages.""" + r = httpx.post(f"{self._base_url}/messages/outbox/claim", timeout=30) + r.raise_for_status() + return r.json() + + def get_outbox_detail(self, mid: int, lease_id: str) -> dict: + """GET /messages/outbox/{id}?lease_id= — get full message detail.""" + r = httpx.get( + f"{self._base_url}/messages/outbox/{mid}", + params={"lease_id": lease_id}, + timeout=30, + ) + r.raise_for_status() + return r.json() + + def update_send_status( + self, mid: int, lease_id: str, status: str, + platform_message_id: str = "", failure_reason: str = "", + ) -> int: + """PATCH /messages/{id}/send-status — update send status. + + Returns HTTP status code (200=ok, 409=conflict). + """ + body = {"lease_id": lease_id, "send_status": status} + if platform_message_id: + body["platform_message_id"] = platform_message_id + if failure_reason: + body["failure_reason"] = failure_reason + r = httpx.patch( + f"{self._base_url}/messages/{mid}/send-status", + json=body, + timeout=30, + ) + return r.status_code + + def get_outbox_count(self, status: str | None = None) -> int: + """GET /messages/outbox — count outbox messages, optionally filtered by status.""" + params = {"status": status} if status else {} + r = httpx.get(f"{self._base_url}/messages/outbox", params=params, timeout=30) + r.raise_for_status() + return r.json()["count"] + + def list_dead_letters(self) -> list[dict]: + """GET /messages/outbox/dead — list dead letters.""" + r = httpx.get(f"{self._base_url}/messages/outbox/dead", timeout=30) + r.raise_for_status() + return r.json() + + def requeue_dead_letter(self, message_id: int) -> dict: + """POST /messages/outbox/{id}/requeue — reset dead letter to pending.""" + r = httpx.post(f"{self._base_url}/messages/outbox/{message_id}/requeue", timeout=30) + r.raise_for_status() + return r.json() diff --git a/src/cli/app/human/classifier.py b/src/cli/app/human/classifier.py new file mode 100644 index 0000000..0a9804a --- /dev/null +++ b/src/cli/app/human/classifier.py @@ -0,0 +1,35 @@ +"""Keyword-based email classifier for recruitment emails.""" + + +_RULES: list[tuple[list[str], str]] = [ + (["应聘", "求职"], "contacted"), + (["笔试答案", "答题", "试卷"], "exam_received"), + (["面试感谢", "面试反馈", "面试结果"], "interview"), + (["放弃", "退出", "辞职", "离职"], "closed"), +] + +_HEADHUNTER_DOMAINS = ["liepin", "zhaopin", "51job", "hunter", "猎聘"] +_HEADHUNTER_BODY_KEYWORDS = ["推荐候选人"] + + +def classify(subject: str, body: str = "", sender_email: str = "") -> tuple[str | None, str]: + """Classify a recruitment email. + + Returns (suggested_status, confidence). + """ + for keywords, status in _RULES: + for kw in keywords: + if kw in subject: + return (status, "high") + + if sender_email: + domain = sender_email.split("@")[-1].lower() if "@" in sender_email else "" + for hd in _HEADHUNTER_DOMAINS: + if hd in domain: + return ("contacted", "low") + + for kw in _HEADHUNTER_BODY_KEYWORDS: + if kw in body: + return ("contacted", "low") + + return (None, "low") diff --git a/src/cli/app/human/cli.py b/src/cli/app/human/cli.py new file mode 100644 index 0000000..c924acb --- /dev/null +++ b/src/cli/app/human/cli.py @@ -0,0 +1,277 @@ +"""Human CLI commands — recruitment email classification and ingestion.""" +import json +import logging +import sys + +import httpx +import typer + +from app.human.api_client import ApiClient +from app.human.classifier import classify +from app.human.config import Config +from app.human.lark_client import LarkClient + +app = typer.Typer(help="人力资源职能:招聘邮件处理") +config_app = typer.Typer(help="查看和修改人力资源模块配置") + + +@config_app.command(name="set-provider") +def config_set_provider(url: str = typer.Argument(..., help="服务端地址,如 http://127.0.0.1:8000")): + """配置服务端地址。""" + Config().set("provider_url", url) + typer.echo(f"服务端地址已设为: {url}") + + +@config_app.command(name="set-lark-path") +def config_set_lark_path(path: str = typer.Argument(..., help="lark-cli 路径")): + """配置 lark-cli 路径。""" + Config().set("lark_path", path) + typer.echo(f"lark-cli 路径已设为: {path}") + + +@config_app.command(name="show") +def config_show(): + """查看当前配置。""" + cfg = Config().show() + for k, v in cfg.items(): + typer.echo(f" {k} = {v}") + + +app.add_typer(config_app, name="config") + + +@app.command(name="list") +def mail_list( + limit: int = typer.Option(20, "-n", "--limit", help="最大条数"), + since: str = typer.Option("7d", "--since", help="时间范围(7d/24h/日期)"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """列出收件箱中的招聘邮件。""" + cfg = Config() + lark = LarkClient(lark_path=cfg.get("lark_path")) + emails = lark.list_emails(limit=limit, since=since) + + if not emails: + typer.echo("未找到招聘邮件。请确认 lark-cli 已安装并登录。", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps( + [{"mail_id": e.mail_id, "subject": e.subject, "sender": e.sender_name, "date": e.date} + for e in emails], ensure_ascii=False, + )) + return + + typer.echo(f" {'#':>3} │ {'发件人':<8} │ {'主题':<40} │ {'建议阶段':<14} │ {'可信度':<6}") + typer.echo("─────┼──────────┼──────────────────────────────────────────┼────────────────┼────────") + for i, email in enumerate(emails, 1): + status, conf = classify(subject=email.subject, sender_email=email.sender_email) + status_str = status or "待确认" + typer.echo(f" {i:>3} │ {email.sender_name:<8} │ {email.subject:<40} │ {status_str:<14} │ {conf:<6}") + + +@app.command(name="classify") +def mail_classify( + mail_id: str = typer.Argument(..., help="邮件 ID"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """对单封邮件运行分类并预览。""" + cfg = Config() + lark = LarkClient(lark_path=cfg.get("lark_path")) + email = lark.read_email(mail_id) + if not email: + typer.echo(f"邮件 {mail_id} 未找到。用 list 命令查看可用 ID。", err=True) + raise typer.Exit(1) + + status, conf = classify(subject=email.subject, body=email.body, sender_email=email.sender_email) + + if as_json: + typer.echo(json.dumps({ + "mail_id": mail_id, "subject": email.subject, + "sender_name": email.sender_name, "sender_email": email.sender_email, + "suggested_status": status, "confidence": conf, + }, ensure_ascii=False)) + return + + typer.echo(f" 发件人: {email.sender_name} <{email.sender_email}>") + typer.echo(f" 主题: {email.subject}") + typer.echo(f" 建议: {status or '无法分类'} (可信度: {conf})") + + +@app.command(name="ingest") +def mail_ingest( + limit: int = typer.Option(20, "-n", "--limit", help="最多处理条数"), + dry_run: bool = typer.Option(False, "--dry-run", help="只预览,不推送"), + status_filter: str = typer.Option(None, "--status", help="只推送指定阶段的邮件"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """推送分类结果到服务端待确认队列。""" + cfg = Config() + provider_url = cfg.get("provider_url") + if not provider_url: + typer.echo("未配置服务端地址。运行: qtadmin human config set-provider ", err=True) + raise typer.Exit(1) + + lark = LarkClient(lark_path=cfg.get("lark_path")) + emails = lark.list_emails(limit=limit) + + items = [] + for email in emails: + status, conf = classify(subject=email.subject, sender_email=email.sender_email) + if not status: + continue + if status_filter and status != status_filter: + continue + items.append({ + "message_id": email.mail_id, "subject": email.subject, + "sender_name": email.sender_name, "sender_email": email.sender_email or "", + "suggested_status": status, "confidence": conf, + }) + + if dry_run or not items: + if as_json: + typer.echo(json.dumps({"dry_run": True, "count": len(items), "items": items}, ensure_ascii=False)) + return + typer.echo(f"\n {'发件人':<8} │ {'主题':<30} │ {'建议阶段':<14} │ {'可信度':<6}") + typer.echo(" ─────────┼─────────────────────────────────┼────────────────┼────────") + for item in items: + typer.echo(f" {item['sender_name']:<8} │ {item['subject']:<30} │ {item['suggested_status']:<14} │ {item['confidence']:<6}") + if dry_run: + typer.echo(f"\n 预览: {len(items)} 条。去掉 --dry-run 执行推送。", err=True) + else: + typer.echo("没有可推送的邮件。", err=True) + return + + try: + api = ApiClient(base_url=provider_url) + result = api.ingest(source="feishu_api", items=items) + except (httpx.ConnectError, httpx.TimeoutException) as e: + typer.echo(f"连接服务端失败: {e}", err=True) + typer.echo(f"确认服务端已启动且 provider_url 配置正确。", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps(result, ensure_ascii=False)) + return + + typer.echo(f" 已入队列: {result['queued']} 已跳过: {result['skipped']}", err=True) + if result["errors"]: + typer.echo(f" 错误: {len(result['errors'])}", err=True) + typer.echo(f" 数据已在待确认队列,请通过管理后台确认。", err=True) + + +@app.command() +def status( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看待确认队列计数。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + stats = api.get_queue_stats() + except (httpx.ConnectError, httpx.TimeoutException) as e: + typer.echo(f"连接服务端失败: {e}", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps(stats, ensure_ascii=False)) + return + + typer.echo(f" 待确认: {stats.get('pending', 0)}", err=True) + typer.echo(f" 已确认: {stats.get('confirmed', 0)}", err=True) + typer.echo(f" 已忽略: {stats.get('ignored', 0)}", err=True) + + +@app.command(name="send") +def mail_send( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """领取并发送发件箱中的待发邮件(单次轮询)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + from app.human.mail_sender import send_pending + sent = send_pending(api) + except httpx.ConnectError as e: + typer.echo(f"连接服务端失败: {e}", err=True) + raise typer.Exit(1) + + if as_json: + typer.echo(json.dumps({"sent": sent}, ensure_ascii=False)) + return + + if sent: + typer.echo(f"已发送 {sent} 封邮件。", err=True) + else: + typer.echo("发件箱中没有待发邮件。", err=True) + + +@app.command(name="send-loop") +def mail_send_loop( + interval: int = typer.Option(30, "-i", "--interval", help="轮询间隔(秒)"), +): + """持续轮询发件箱并发送邮件(守护进程模式)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + try: + from app.human.mail_sender import run_loop + run_loop(api, interval=interval) + except KeyboardInterrupt: + typer.echo("\n发件循环已停止。", err=True) + + +@app.command(name="outbox") +def mail_outbox( + status: str = typer.Option(None, "--status", help="筛选状态: pending/sending/sent/failed"), + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看发件箱统计。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + count = api.get_outbox_count(status=status) + if as_json: + typer.echo(json.dumps({"count": count, "status": status}, ensure_ascii=False)) + return + label = status or "待发/发送中" + typer.echo(f" {label}: {count} 封", err=True) + + +@app.command(name="dead-letters") +def mail_dead_letters( + as_json: bool = typer.Option(False, "--json", help="输出 JSON"), +): + """查看死信队列(发送失败超过最大重试次数)。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + items = api.list_dead_letters() + if as_json: + typer.echo(json.dumps(items, ensure_ascii=False)) + return + + if not items: + typer.echo(" 没有死信。", err=True) + return + + typer.echo(f" {'#':>3} │ {'收件人':<24} │ {'主题':<40} │ {'失败原因':<20} │ {'重试次数'}") + typer.echo(" ─────┼──────────────────────────┼──────────────────────────────────────────┼──────────────────────┼────────────") + for i, item in enumerate(items, 1): + typer.echo(f" {i:>3} │ {item['recipient_email'] or '':<24} │ {item['subject'][:38]:<40} │ {(item['failure_reason'] or '')[:18]:<20} │ {item['retry_count']}") + + +@app.command(name="requeue") +def mail_requeue( + message_id: int = typer.Argument(..., help="死信消息 ID"), +): + """将死信重新放入发件队列。""" + cfg = Config() + api = ApiClient(base_url=cfg.get("provider_url")) + try: + result = api.requeue_dead_letter(message_id) + typer.echo(f" 消息 {result['id']} 已重新入队,状态: {result['send_status']}", err=True) + except httpx.HTTPStatusError as e: + typer.echo(f" 操作失败 (HTTP {e.response.status_code}): {e.response.text}", err=True) + raise typer.Exit(1) diff --git a/src/cli/app/human/config.py b/src/cli/app/human/config.py new file mode 100644 index 0000000..8207ca2 --- /dev/null +++ b/src/cli/app/human/config.py @@ -0,0 +1,48 @@ +"""Configuration management for human module.""" +import json +import os + +_DEFAULTS = { + "provider_url": "http://127.0.0.1:8000", + "lark_path": "lark-cli", +} +_CONFIG_PATH = os.path.expanduser("~/.config/qtadmin/human.json") + + +class Config: + """Manages human module config stored as JSON.""" + + def __init__(self, path: str | None = None) -> None: + self._path = path or _CONFIG_PATH + self._data: dict[str, str] = {} + + def _load(self) -> None: + try: + with open(self._path) as f: + raw = json.load(f) + if isinstance(raw, dict): + self._data = {k: str(v) for k, v in raw.items()} + return + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + self._data = {} + + def _save(self) -> None: + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + def get(self, key: str) -> str: + self._load() + return self._data.get(key, _DEFAULTS.get(key, "")) + + def set(self, key: str, value: str) -> None: + self._load() + self._data[key] = value + self._save() + + def show(self) -> dict[str, str]: + self._load() + merged = dict(_DEFAULTS) + merged.update(self._data) + return merged diff --git a/src/cli/app/human/lark_client.py b/src/cli/app/human/lark_client.py new file mode 100644 index 0000000..bce4d62 --- /dev/null +++ b/src/cli/app/human/lark_client.py @@ -0,0 +1,77 @@ +"""Wrapper around lark-cli subprocess.""" +import subprocess +from dataclasses import dataclass + + +@dataclass +class LarkEmail: + mail_id: str + sender_name: str = "" + sender_email: str = "" + subject: str = "" + body: str = "" + date: str = "" + + +class LarkClient: + """Wraps lark-cli commands via subprocess.""" + + def __init__(self, lark_path: str = "lark-cli") -> None: + self._lark_path = lark_path + + def _run(self, cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout + + def list_emails(self, limit: int = 20, since: str = "7d") -> list[LarkEmail]: + cmd = [self._lark_path, "mail", "list", "--limit", str(limit), "--since", since] + raw = self._run(cmd) + return self._parse_list_output(raw) + + def read_email(self, mail_id: str) -> LarkEmail | None: + cmd = [self._lark_path, "mail", "read", mail_id] + raw = self._run(cmd) + return self._parse_read_output(mail_id, raw) + + def _parse_list_output(self, raw: str) -> list[LarkEmail]: + emails: list[LarkEmail] = [] + for line in raw.strip().splitlines(): + parts = line.strip().split(maxsplit=3) + if len(parts) >= 2: + emails.append(LarkEmail( + mail_id=parts[0], + sender_name=parts[1] if len(parts) > 1 else "", + subject=parts[2] if len(parts) > 2 else "", + date=parts[3] if len(parts) > 3 else "", + )) + return emails + + def _parse_read_output(self, mail_id: str, raw: str) -> LarkEmail | None: + if not raw.strip(): + return None + sender_name = "" + sender_email = "" + subject = "" + body = "" + in_body = False + for line in raw.splitlines(): + if line.startswith("From:"): + rest = line[5:].strip() + if "<" in rest and ">" in rest: + sender_name = rest.split("<")[0].strip() + sender_email = rest.split("<")[1].rstrip(">").strip() + else: + sender_name = rest + elif line.startswith("Subject:"): + subject = line[8:].strip() + elif line.startswith("Body:"): + in_body = True + elif in_body: + body += line + "\n" + return LarkEmail( + mail_id=mail_id, + sender_name=sender_name, + sender_email=sender_email, + subject=subject, + body=body.strip(), + ) diff --git a/src/cli/app/human/mail_sender.py b/src/cli/app/human/mail_sender.py new file mode 100644 index 0000000..5b5b3f2 --- /dev/null +++ b/src/cli/app/human/mail_sender.py @@ -0,0 +1,88 @@ +"""飞书邮件发送:从 outbox 获取待发邮件,通过 lark-cli 发送。""" + +import json +import logging +import subprocess +import time + +logger = logging.getLogger(__name__) + + +def _lark_send(recipient: str, subject: str, body: str) -> dict: + """调用 lark-cli mail +send 发送邮件,返回解析后的 JSON。""" + cmd = [ + "lark-cli", "mail", "+send", + "--to", recipient, + "--subject", subject, + "--body", body, + "--confirm-send", + "--format", "json", + ] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + result.check_returncode() + return json.loads(result.stdout) + + +def send_pending(api) -> int: + """Claim outbox messages and send them via lark-cli. Returns number sent.""" + sent_count = 0 + data = api.claim_outbox() + claimed = data.get("claimed", []) + + if not claimed: + logger.info("No pending messages to send.") + return 0 + + for msg in claimed: + mid = msg["id"] + lease_id = msg["lease_id"] + recipient = msg.get("recipient_email", "") + + if not recipient: + logger.warning("Message %d has no recipient_email, skipping", mid) + continue + + detail = api.get_outbox_detail(mid, lease_id) + body = detail.get("body_text") or detail.get("body") or "" + + try: + logger.info("Sending message %d to %s: %s", mid, recipient, msg["subject"]) + lark_resp = _lark_send(recipient, msg["subject"], body) + platform_id = "" + if isinstance(lark_resp, dict): + platform_id = lark_resp.get("data", {}).get("id", "") + if not platform_id: + platform_id = lark_resp.get("id", str(lark_resp)) + + status_code = api.update_send_status( + mid, lease_id, "sent", + platform_message_id=platform_id, + ) + if status_code == 409: + logger.warning("Message %d lease_id mismatch (concurrent send?)", mid) + else: + sent_count += 1 + logger.info("Message %d sent successfully (platform_id=%s)", mid, platform_id) + + except subprocess.CalledProcessError as e: + err_msg = e.stderr or str(e) + logger.error("lark-cli failed for message %d: %s", mid, err_msg) + api.update_send_status(mid, lease_id, "failed", failure_reason=err_msg[:500]) + except Exception as e: + logger.error("Unexpected error for message %d: %s", mid, str(e)) + api.update_send_status(mid, lease_id, "failed", failure_reason=str(e)[:500]) + + return sent_count + + +def run_loop(api, interval: int = 30): + """Continuous send loop.""" + logger.info("Mail sender loop started (interval=%ds)", interval) + while True: + try: + n = send_pending(api) + if n: + logger.info("Sent %d messages this cycle", n) + except Exception as e: + logger.error("Send cycle failed: %s", str(e)) + time.sleep(interval) diff --git a/src/cli/app/human/mail_sender_loop.py b/src/cli/app/human/mail_sender_loop.py new file mode 100644 index 0000000..0744898 --- /dev/null +++ b/src/cli/app/human/mail_sender_loop.py @@ -0,0 +1,23 @@ +"""systemd entry point — runs the mail sender polling loop. + +Usage: + python -m app.human.mail_sender_loop + +Environment variables: + QTADMIN_SERVER_URL — provider base URL (default: http://localhost:8080) +""" +import logging +import os + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", +) + +if __name__ == "__main__": + from app.human.api_client import ApiClient + from app.human.mail_sender import run_loop + + server_url = os.environ.get("QTADMIN_SERVER_URL", "http://localhost:8080") + api = ApiClient(base_url=server_url) + run_loop(api) diff --git a/src/cli/pyproject.toml b/src/cli/pyproject.toml index 15b63ef..6038f49 100644 --- a/src/cli/pyproject.toml +++ b/src/cli/pyproject.toml @@ -10,6 +10,7 @@ requires-python = ">=3.10" dependencies = [ "typer>=0.12.0", "pyyaml>=6.0.1", + "httpx>=0.27.0", ] [project.scripts] diff --git a/src/provider/app/__main__.py b/src/provider/app/__main__.py index b6b6ad9..b1e9a0b 100644 --- a/src/provider/app/__main__.py +++ b/src/provider/app/__main__.py @@ -1,11 +1,200 @@ +import asyncio +import json +import os +import subprocess +from contextlib import asynccontextmanager +from datetime import datetime, timezone + +import httpx import uvicorn from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.human.database import SessionLocal, init_db +from app.human.models.processed_mail import ProcessedMail +from app.human.routers import ( + ai_config, applications, candidates, export, ingest, materials, messages, + pipeline, pool, queue, recruitments, +) + +_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_DATA_DIR = os.environ.get("QTADMIN_DATA_DIR", os.path.join(_PROJECT_ROOT, "data")) +_ATTACHMENT_DIR = os.path.join(_DATA_DIR, "attachments") + + +def seed_data_if_empty(): + """Check if DB is empty and seed demo data if so.""" + db = SessionLocal() + try: + from app.human.models.recruitment import Recruitment + exists = db.query(Recruitment).first() + if not exists: + from app.human.seed import seed_data + seed_data(db) + finally: + db.close() + + +def _download_attachment(message_id: str, attachment: dict, mailbox: str) -> str | None: + """Download attachment via lark-cli download_url, return local path.""" + att_id = attachment.get("message_attachment_id") + if not att_id: + return None + + cmd = [ + "lark-cli", "mail", "user_mailbox.message.attachments", "download_url", + "--params", json.dumps({ + "user_mailbox_id": mailbox or "me", + "message_id": message_id, + "attachment_ids": [att_id], + }), + "--format", "json", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result.check_returncode() + resp = json.loads(result.stdout) + urls = resp.get("data", {}).get("download_urls", []) + if not urls: + return None + download_url = urls[0].get("download_url", "") + if not download_url: + return None + except Exception: + return None + + storage_dir = os.path.join(_ATTACHMENT_DIR, message_id) + os.makedirs(storage_dir, exist_ok=True) + file_path = os.path.join(storage_dir, attachment["filename"]) + + try: + r = httpx.get(download_url, timeout=60, follow_redirects=True) + r.raise_for_status() + with open(file_path, "wb") as f: + f.write(r.content) + attachment["size"] = len(r.content) + return file_path + except Exception: + return None + + +def _fetch_mail(mailbox: str) -> list[dict]: + from feishu_integration.mail_reader import fetch_and_classify, fetch_single_email + items = fetch_and_classify(mailbox=mailbox) + for item in items: + try: + detail = fetch_single_email(item["message_id"], mailbox=mailbox) + item["body"] = detail.get("body", "") + item["body_text"] = detail.get("body_plain_text", "") + item["recipient_email"] = detail.get("to", "") + attachments = [] + for a in detail.get("attachments", []): + att = { + "filename": a.get("filename", ""), + "size": a.get("size", 0), + "mime_type": a.get("content_type", ""), + "message_attachment_id": a.get("message_attachment_id") or a.get("id", ""), + } + if att["mime_type"] in ("application/pdf",) or att["filename"].endswith(".pdf"): + storage_path = _download_attachment(item["message_id"], att, mailbox) + if storage_path: + att["storage_path"] = storage_path + attachments.append(att) + item["attachments"] = attachments + except Exception: + pass + return items + + +async def _poll_mailbox(): + mailbox = os.environ.get("QTADMIN_MAILBOX", "") + if not mailbox: + return + while True: + try: + items = await asyncio.to_thread(_fetch_mail, mailbox) + db = SessionLocal() + try: + known = {row[0] for row in db.query(ProcessedMail.message_id).all()} + new_items = [it for it in items if it["message_id"] not in known] + for item in new_items: + db.add(ProcessedMail(message_id=item["message_id"])) + db.commit() + finally: + db.close() + if new_items: + payload = { + "source": "feishu_api", + "items": [ + { + "message_id": item["message_id"], + "subject": item["subject"], + "sender_name": item.get("sender_name", ""), + "sender_email": item["sender_email"], + "recipient_email": item.get("recipient_email", ""), + "suggested_status": item.get("suggested_status"), + "confidence": item.get("confidence", "low"), + "body": item.get("body"), + "body_text": item.get("body_text"), + "attachments": item.get("attachments"), + } + for item in new_items + ], + } + async with httpx.AsyncClient() as client: + resp = await client.post( + "http://localhost:8080/ingest", + json=payload, + timeout=30, + ) + resp.raise_for_status() + except Exception: + pass + await asyncio.sleep(300) + + +_poll_task: asyncio.Task | None = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + os.makedirs(_ATTACHMENT_DIR, exist_ok=True) + init_db() + seed_data_if_empty() + global _poll_task + _poll_task = asyncio.create_task(_poll_mailbox()) + yield + if _poll_task: + _poll_task.cancel() + + +app = FastAPI(title="qtadmin API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(ai_config.router) +app.include_router(export.router) +app.include_router(materials.router) +app.include_router(messages.router) +app.include_router(ingest.router) +app.include_router(queue.router) +app.include_router(pipeline.router) +app.include_router(pool.router) +app.include_router(recruitments.router) +app.include_router(candidates.router) +app.include_router(applications.router) -app = FastAPI(title="qtadmin API", version="0.1.0") @app.get("/health") def health(): return {"status": "ok"} + if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/src/provider/app/human/__init__.py b/src/provider/app/human/__init__.py new file mode 100644 index 0000000..05a5ccd --- /dev/null +++ b/src/provider/app/human/__init__.py @@ -0,0 +1 @@ +"""Human resources module — recruitment pipeline management.""" diff --git a/src/provider/app/human/database.py b/src/provider/app/human/database.py new file mode 100644 index 0000000..cb3d258 --- /dev/null +++ b/src/provider/app/human/database.py @@ -0,0 +1,29 @@ +"""Database setup for HR module.""" +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +DB_PATH = "hr.db" +DATABASE_URL = f"sqlite:///{DB_PATH}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def init_db() -> None: + """Create all HR tables.""" + import app.human.models # noqa: F401 + Base.metadata.create_all(bind=engine) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/provider/app/human/models/__init__.py b/src/provider/app/human/models/__init__.py new file mode 100644 index 0000000..a4eae20 --- /dev/null +++ b/src/provider/app/human/models/__init__.py @@ -0,0 +1,16 @@ +"""HR models.""" +from app.human.models.talent import Talent, TalentStatus +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.processed_mail import ProcessedMail + +__all__ = [ + "Talent", "TalentStatus", + "Recruitment", + "Candidate", + "Application", + "PendingQueueItem", + "ProcessedMail", +] diff --git a/src/provider/app/human/models/ai_config.py b/src/provider/app/human/models/ai_config.py new file mode 100644 index 0000000..afe83b4 --- /dev/null +++ b/src/provider/app/human/models/ai_config.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class AIConfig(Base): + __tablename__ = "ai_configs" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + provider: Mapped[str] = mapped_column(String(50), default="openai") + base_url: Mapped[str] = mapped_column(String(500), default="") + api_key_encrypted: Mapped[str] = mapped_column(String(500), default="") + model: Mapped[str] = mapped_column(String(100), default="") + prompt_template: Mapped[str] = mapped_column(Text, default="") + timeout_seconds: Mapped[int] = mapped_column(Integer, default=30) + retry_times: Mapped[int] = mapped_column(Integer, default=2) + + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/application.py b/src/provider/app/human/models/application.py new file mode 100644 index 0000000..f768024 --- /dev/null +++ b/src/provider/app/human/models/application.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.human.database import Base +from app.human.models.talent import TalentStatus + + +class Application(Base): + __tablename__ = "applications" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + candidate_id: Mapped[int] = mapped_column(ForeignKey("candidates.id"), index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + source_queue_item_id: Mapped[int | None] = mapped_column(ForeignKey("pending_queue.id"), nullable=True, index=True) + + last_message_id: Mapped[int | None] = mapped_column( + ForeignKey("mail_messages.id"), nullable=True, index=True + ) + last_message_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + candidate: Mapped["Candidate"] = relationship("Candidate") + talent: Mapped["Talent | None"] = relationship("Talent", back_populates="application", uselist=False) + + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None) + source: Mapped[str] = mapped_column(String(50), default="manual_seed") + + pooled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + deactivated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/candidate.py b/src/provider/app/human/models/candidate.py new file mode 100644 index 0000000..ab20859 --- /dev/null +++ b/src/provider/app/human/models/candidate.py @@ -0,0 +1,18 @@ +"""Candidate model — person entity, not tied to a specific recruitment.""" +from datetime import datetime + +from sqlalchemy import DateTime, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class Candidate(Base): + __tablename__ = "candidates" + __table_args__ = (UniqueConstraint("email", name="uq_candidates_email"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/correction_log.py b/src/provider/app/human/models/correction_log.py new file mode 100644 index 0000000..e24df34 --- /dev/null +++ b/src/provider/app/human/models/correction_log.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class CorrectionLog(Base): + __tablename__ = "correction_logs" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + queue_item_id: Mapped[int] = mapped_column(ForeignKey("pending_queue.id"), index=True) + application_id: Mapped[int | None] = mapped_column(ForeignKey("applications.id"), nullable=True, index=True) + field_name: Mapped[str] = mapped_column(String(50)) + original_value: Mapped[str | None] = mapped_column(String(500), nullable=True) + corrected_value: Mapped[str | None] = mapped_column(String(500), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/mail_message.py b/src/provider/app/human/models/mail_message.py new file mode 100644 index 0000000..709f469 --- /dev/null +++ b/src/provider/app/human/models/mail_message.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class MailMessage(Base): + __tablename__ = "mail_messages" + __table_args__ = (UniqueConstraint("message_id", name="uq_mail_messages_message_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + source_queue_item_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("pending_queue.id"), nullable=True, index=True + ) + candidate_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("candidates.id"), nullable=True, index=True + ) + application_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("applications.id"), nullable=True, index=True + ) + message_id: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + platform_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + + sender_email: Mapped[str] = mapped_column(String(255)) + recipient_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + subject: Mapped[str] = mapped_column(String(500)) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + attachments_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + stage_snapshot: Mapped[str | None] = mapped_column(String(50), nullable=True) + direction: Mapped[str] = mapped_column(String(20), default="inbound") + + send_status: Mapped[str | None] = mapped_column(String(20), nullable=True) + lease_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + leased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + retry_count: Mapped[int] = mapped_column(Integer, default=0) + last_retry_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + occurred_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/material.py b/src/provider/app/human/models/material.py new file mode 100644 index 0000000..ef636a7 --- /dev/null +++ b/src/provider/app/human/models/material.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class MaterialArtifact(Base): + __tablename__ = "material_artifacts" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + queue_item_id: Mapped[int] = mapped_column( + Integer, ForeignKey("pending_queue.id"), index=True, nullable=True + ) + candidate_id: Mapped[int] = mapped_column( + Integer, ForeignKey("candidates.id"), index=True, nullable=True + ) + artifact_type: Mapped[str] = mapped_column(String(50)) + content_json: Mapped[str | None] = mapped_column(Text, nullable=True) + file_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/pending_queue.py b/src/provider/app/human/models/pending_queue.py new file mode 100644 index 0000000..0e06fe5 --- /dev/null +++ b/src/provider/app/human/models/pending_queue.py @@ -0,0 +1,29 @@ +"""Pending queue — emails awaiting HR confirmation before entering pipeline.""" +from datetime import datetime + +from sqlalchemy import DateTime, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class PendingQueueItem(Base): + __tablename__ = "pending_queue" + __table_args__ = (UniqueConstraint("message_id"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + source: Mapped[str] = mapped_column(String(50), default="feishu_api") + message_id: Mapped[str] = mapped_column(String(255)) + subject: Mapped[str] = mapped_column(String(500)) + sender_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + sender_email: Mapped[str] = mapped_column(String(255)) + suggested_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + confidence: Mapped[str] = mapped_column(String(20), default="low") + suggested_recruitment_title: Mapped[str | None] = mapped_column(String(255), nullable=True) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + body_text: Mapped[str | None] = mapped_column(Text, nullable=True) + attachments_json: Mapped[str | None] = mapped_column(Text, nullable=True) + hr_status: Mapped[str] = mapped_column(String(20), default="pending") + hr_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/models/processed_mail.py b/src/provider/app/human/models/processed_mail.py new file mode 100644 index 0000000..50eb7cb --- /dev/null +++ b/src/provider/app/human/models/processed_mail.py @@ -0,0 +1,12 @@ +"""Processed mail tracking for Feishu mailbox polling dedup.""" +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, String + +from app.human.database import Base + + +class ProcessedMail(Base): + __tablename__ = "processed_mails" + message_id: str = Column(String(255), primary_key=True) + processed_at: datetime = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/src/provider/app/human/models/recruitment.py b/src/provider/app/human/models/recruitment.py new file mode 100644 index 0000000..cc41e93 --- /dev/null +++ b/src/provider/app/human/models/recruitment.py @@ -0,0 +1,14 @@ +"""Recruitment model — job posting entity.""" +from datetime import datetime + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.human.database import Base + + +class Recruitment(Base): + __tablename__ = "recruitments" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/src/provider/app/human/models/talent.py b/src/provider/app/human/models/talent.py new file mode 100644 index 0000000..9747d6e --- /dev/null +++ b/src/provider/app/human/models/talent.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import enum +from datetime import datetime + +from sqlalchemy import DateTime, Enum, ForeignKey, JSON, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.human.database import Base + + +class TalentStatus(str, enum.Enum): + NEW = "new" + CONTACTED = "contacted" + EXAM_SENT = "exam_sent" + EXAM_RECEIVED = "exam_received" + EVALUATING = "evaluating" + INTERVIEW = "interview" + OFFER = "offer" + CLOSED = "closed" + + +ALLOWED_STATUSES_FOR_SUB_STAGE = { + TalentStatus.CONTACTED, + TalentStatus.EXAM_SENT, + TalentStatus.EVALUATING, + TalentStatus.INTERVIEW, + TalentStatus.OFFER, +} + +STATUS_TRANSITIONS = { + TalentStatus.NEW: [TalentStatus.CONTACTED, TalentStatus.CLOSED], + TalentStatus.CONTACTED: [TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.EXAM_SENT: [TalentStatus.EXAM_RECEIVED, TalentStatus.CLOSED], + TalentStatus.EXAM_RECEIVED: [TalentStatus.EVALUATING, TalentStatus.CLOSED], + TalentStatus.EVALUATING: [TalentStatus.INTERVIEW, TalentStatus.EXAM_SENT, TalentStatus.CLOSED], + TalentStatus.INTERVIEW: [TalentStatus.OFFER, TalentStatus.CLOSED], + TalentStatus.OFFER: [TalentStatus.CLOSED], + TalentStatus.CLOSED: [], +} + + +class Talent(Base): + __tablename__ = "talents" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + recruitment_id: Mapped[int] = mapped_column(ForeignKey("recruitments.id"), index=True) + application_id: Mapped[int | None] = mapped_column(ForeignKey("applications.id"), nullable=True, index=True) + email: Mapped[str] = mapped_column(String(200)) + real_name: Mapped[str] = mapped_column(String(100)) + + status: Mapped[TalentStatus] = mapped_column(Enum(TalentStatus), default=TalentStatus.NEW, index=True) + sub_stage: Mapped[str | None] = mapped_column(String(30), nullable=True) + quality: Mapped[str] = mapped_column(String(10), default="normal") + stage_results: Mapped[dict | None] = mapped_column(JSON, nullable=True, default=None) + + application: Mapped[Application | None] = relationship("Application", back_populates="talent") + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/src/provider/app/human/routers/__init__.py b/src/provider/app/human/routers/__init__.py new file mode 100644 index 0000000..3eca8e4 --- /dev/null +++ b/src/provider/app/human/routers/__init__.py @@ -0,0 +1 @@ +"""HR routers.""" diff --git a/src/provider/app/human/routers/ai_config.py b/src/provider/app/human/routers/ai_config.py new file mode 100644 index 0000000..d00908b --- /dev/null +++ b/src/provider/app/human/routers/ai_config.py @@ -0,0 +1,108 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.ai_config import AIConfig + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +class AIConfigRead(BaseModel): + enabled: bool = False + provider: str = "openai" + base_url: str = "" + api_key: str = "" + model: str = "" + prompt_template: str = "" + timeout_seconds: int = 30 + retry_times: int = 2 + + model_config = {"from_attributes": True} + + +class AIConfigUpdate(BaseModel): + enabled: bool | None = None + provider: str | None = None + base_url: str | None = None + api_key: str | None = None + model: str | None = None + prompt_template: str | None = None + timeout_seconds: int | None = None + retry_times: int | None = None + + +class AIConfigTestResult(BaseModel): + success: bool + message: str + + +def _mask_api_key(key: str) -> str: + if len(key) <= 4: + return "****" + return key[:4] + "****" + + +def _get_or_create_config(db: Session) -> AIConfig: + cfg = db.query(AIConfig).first() + if not cfg: + cfg = AIConfig() + db.add(cfg) + db.flush() + return cfg + + +@router.get("/config", response_model=AIConfigRead) +def get_ai_config(db: Session = Depends(get_db)): + cfg = _get_or_create_config(db) + data = AIConfigRead.model_validate(cfg) + if cfg.api_key_encrypted: + data.api_key = _mask_api_key(cfg.api_key_encrypted) + return data + + +@router.patch("/config", response_model=AIConfigRead) +def update_ai_config(body: AIConfigUpdate, db: Session = Depends(get_db)): + cfg = _get_or_create_config(db) + updates = body.model_dump(exclude_unset=True) + if "api_key" in updates: + cfg.api_key_encrypted = updates.pop("api_key") + for field, val in updates.items(): + setattr(cfg, field, val) + db.commit() + db.refresh(cfg) + + data = AIConfigRead.model_validate(cfg) + if cfg.api_key_encrypted: + data.api_key = _mask_api_key(cfg.api_key_encrypted) + return data + + +@router.post("/test", response_model=AIConfigTestResult) +def test_ai_config(db: Session = Depends(get_db)): + import httpx + + cfg = db.query(AIConfig).first() + if not cfg or not cfg.enabled: + return AIConfigTestResult(success=False, message="AI 未启用") + if not cfg.api_key_encrypted: + return AIConfigTestResult(success=False, message="API Key 未配置") + + url = (cfg.base_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions" + headers = {"Authorization": f"Bearer {cfg.api_key_encrypted}", "Content-Type": "application/json"} + payload = { + "model": cfg.model or "gpt-4o-mini", + "messages": [{"role": "user", "content": "回复 OK 表示连接正常"}], + "max_tokens": 10, + } + + try: + resp = httpx.post(url, headers=headers, json=payload, timeout=cfg.timeout_seconds or 30) + resp.raise_for_status() + return AIConfigTestResult(success=True, message="AI 连接成功") + except httpx.TimeoutException: + return AIConfigTestResult(success=False, message="连接超时") + except httpx.HTTPStatusError as e: + return AIConfigTestResult(success=False, message=f"HTTP {e.response.status_code}: {e.response.text[:200]}") + except Exception as e: + return AIConfigTestResult(success=False, message=f"连接失败: {str(e)[:200]}") diff --git a/src/provider/app/human/routers/applications.py b/src/provider/app/human/routers/applications.py new file mode 100644 index 0000000..16d28e8 --- /dev/null +++ b/src/provider/app/human/routers/applications.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.models.talent import TalentStatus +from app.human.schemas.application import ApplicationRead, UnpoolRequest +from app.human.services.pool import pool_application, unpool_application + +router = APIRouter(prefix="/applications", tags=["human"]) + + +@router.get("", response_model=list[ApplicationRead]) +def list_applications( + status: TalentStatus | None = None, + candidate_id: int | None = Query(default=None, ge=1), + recruitment_id: int | None = Query(default=None, ge=1), + pooled: bool | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + qb = db.query(Application) + if status: + qb = qb.filter(Application.status == status) + if candidate_id: + qb = qb.filter(Application.candidate_id == candidate_id) + if recruitment_id: + qb = qb.filter(Application.recruitment_id == recruitment_id) + if pooled is True: + qb = qb.filter(Application.pooled_at.isnot(None)) + elif pooled is False: + qb = qb.filter(Application.pooled_at.is_(None)) + return qb.order_by(Application.updated_at.desc()).offset(skip).limit(limit).all() + + +@router.post("/{application_id}/pool", response_model=ApplicationRead) +def pool_application_endpoint(application_id: int, db: Session = Depends(get_db)): + app = pool_application(db, application_id) + if not app: + raise HTTPException(404, "Application not found") + return app + + +@router.post("/{application_id}/unpool", response_model=ApplicationRead, status_code=201) +def unpool_application_endpoint(application_id: int, body: UnpoolRequest, db: Session = Depends(get_db)): + original = db.query(Application).filter(Application.id == application_id).first() + if not original: + raise HTTPException(404, "Application not found") + if original.pooled_at is None: + raise HTTPException(400, "Application is not pooled") + new_app = unpool_application(db, application_id, body.recruitment_id) + return new_app diff --git a/src/provider/app/human/routers/candidates.py b/src/provider/app/human/routers/candidates.py new file mode 100644 index 0000000..849aa58 --- /dev/null +++ b/src/provider/app/human/routers/candidates.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.schemas.candidate import CandidateRead +from app.human.schemas.application import ApplicationRead + +router = APIRouter(prefix="/candidates", tags=["human"]) + + +@router.get("", response_model=list[CandidateRead]) +def list_candidates( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + return db.query(Candidate).order_by(Candidate.created_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{candidate_id}/applications", response_model=list[ApplicationRead]) +def get_candidate_applications(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + return db.query(Application).filter(Application.candidate_id == candidate_id).all() diff --git a/src/provider/app/human/routers/export.py b/src/provider/app/human/routers/export.py new file mode 100644 index 0000000..523273d --- /dev/null +++ b/src/provider/app/human/routers/export.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.schemas.export import TrainingPairResponse +from app.human.services.export import count_training_pairs, get_training_pairs + +router = APIRouter(prefix="/export", tags=["export"]) + + +@router.get("/training-pairs", response_model=TrainingPairResponse) +def list_training_pairs( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + items = get_training_pairs(db, skip=skip, limit=limit) + total = count_training_pairs(db) + return TrainingPairResponse(items=items, total=total) diff --git a/src/provider/app/human/routers/ingest.py b/src/provider/app/human/routers/ingest.py new file mode 100644 index 0000000..dadc91e --- /dev/null +++ b/src/provider/app/human/routers/ingest.py @@ -0,0 +1,114 @@ +"""Ingest endpoint — receive raw emails from CLI, classify server-side, queue for HR review.""" +import json +import logging + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.pending_queue import PendingQueueItem +from app.human.services.classifier import classify + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/ingest", tags=["human"]) + + +class IngestAttachment(BaseModel): + filename: str + size: int + + +class IngestItem(BaseModel): + message_id: str + subject: str + sender_name: str | None = None + sender_email: str + suggested_status: str | None = None + confidence: str = "low" + suggested_recruitment_title: str | None = None + body: str | None = None + body_text: str | None = None + attachments: list[IngestAttachment] | None = None + + +class IngestRequest(BaseModel): + source: str = "feishu_api" + batch_id: str | None = None + items: list[IngestItem] + + +class IngestItemResult(BaseModel): + message_id: str + queue_id: int | None = None + action: str + + +class IngestResponse(BaseModel): + batch_id: str | None = None + queued: int = 0 + skipped: int = 0 + errors: list[str] = [] + items: list[IngestItemResult] + + +@router.post("", response_model=IngestResponse, status_code=201) +def ingest_items(body: IngestRequest, db: Session = Depends(get_db)): + existing = { + row[0] + for row in db.query(PendingQueueItem.message_id) + .filter(PendingQueueItem.message_id.in_([i.message_id for i in body.items])) + .all() + } + + queued = 0 + skipped = 0 + results: list[IngestItemResult] = [] + errors: list[str] = [] + + for item in body.items: + if item.message_id in existing: + results.append(IngestItemResult(message_id=item.message_id, action="skipped")) + skipped += 1 + continue + + attachments_json = None + if item.attachments: + attachments_json = json.dumps([a.model_dump() for a in item.attachments], ensure_ascii=False) + + # Run server-side classification + classification = classify( + subject=item.subject, + body_text=item.body_text, + sender_name=item.sender_name, + sender_email=item.sender_email, + db=db, + ) + + qi = PendingQueueItem( + source=body.source, + message_id=item.message_id, + subject=item.subject, + sender_name=item.sender_name, + sender_email=item.sender_email, + body=item.body, + body_text=item.body_text, + suggested_status=classification.suggested_status, + confidence=classification.confidence, + suggested_recruitment_title=item.suggested_recruitment_title, + attachments_json=attachments_json, + ) + db.add(qi) + db.flush() + results.append(IngestItemResult(message_id=item.message_id, queue_id=qi.id, action="queued")) + queued += 1 + + db.commit() + return IngestResponse( + batch_id=body.batch_id, + queued=queued, + skipped=skipped, + errors=errors, + items=results, + ) diff --git a/src/provider/app/human/routers/materials.py b/src/provider/app/human/routers/materials.py new file mode 100644 index 0000000..44eaab9 --- /dev/null +++ b/src/provider/app/human/routers/materials.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.services.material_service import ( + get_artifacts_by_candidate, + get_artifacts_by_queue, +) + +router = APIRouter(prefix="/materials", tags=["materials"]) + + +class ArtifactItem(BaseModel): + id: int + queue_item_id: int | None + candidate_id: int | None + artifact_type: str + content_json: str | None = None + file_path: str | None = None + created_at: str = "" + + model_config = {"from_attributes": True} + + +class ArtifactListResponse(BaseModel): + items: list[ArtifactItem] + + +@router.get("/by-queue/{queue_id}", response_model=ArtifactListResponse) +def list_by_queue(queue_id: int, db: Session = Depends(get_db)): + items = get_artifacts_by_queue(db, queue_id) + return ArtifactListResponse( + items=[ArtifactItem( + id=a.id, queue_item_id=a.queue_item_id, candidate_id=a.candidate_id, + artifact_type=a.artifact_type, content_json=a.content_json, + file_path=a.file_path, created_at=str(a.created_at), + ) for a in items] + ) + + +@router.get("/by-candidate/{candidate_id}", response_model=ArtifactListResponse) +def list_by_candidate(candidate_id: int, db: Session = Depends(get_db)): + items = get_artifacts_by_candidate(db, candidate_id) + return ArtifactListResponse( + items=[ArtifactItem( + id=a.id, queue_item_id=a.queue_item_id, candidate_id=a.candidate_id, + artifact_type=a.artifact_type, content_json=a.content_json, + file_path=a.file_path, created_at=str(a.created_at), + ) for a in items] + ) diff --git a/src/provider/app/human/routers/messages.py b/src/provider/app/human/routers/messages.py new file mode 100644 index 0000000..e2fbadd --- /dev/null +++ b/src/provider/app/human/routers/messages.py @@ -0,0 +1,338 @@ +import os +from datetime import datetime, timedelta +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.correction_log import CorrectionLog +from app.human.models.mail_message import MailMessage +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.schemas.messages import ( + ClaimOutboxResponse, + DeadLetterItem, + MailMessageRead, + OutboxCountResponse, + OutboxMessageDetail, + ReplyRequest, + ReplyResponse, + RequeueResponse, + SendStatusUpdate, + TimelineItem, +) + +router = APIRouter(tags=["messages"]) + + +# ── Batch C: Candidate messages ── + +@router.get("/candidates/{candidate_id}/messages", response_model=list[MailMessageRead]) +def list_candidate_messages(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + msgs = ( + db.query(MailMessage) + .filter(MailMessage.candidate_id == candidate_id) + .order_by(MailMessage.occurred_at.desc()) + .all() + ) + return [ + MailMessageRead( + id=m.id, candidate_id=m.candidate_id, application_id=m.application_id, + message_id=m.message_id, sender_email=m.sender_email, + recipient_email=m.recipient_email, subject=m.subject, + body=m.body, body_text=m.body_text, attachments_json=m.attachments_json, + stage_snapshot=m.stage_snapshot, direction=m.direction, + send_status=m.send_status, occurred_at=str(m.occurred_at), + created_at=str(m.created_at), + ) + for m in msgs + ] + + +@router.get("/candidates/{candidate_id}/timeline", response_model=list[TimelineItem]) +def list_candidate_timeline(candidate_id: int, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + items: list[TimelineItem] = [] + + # Mail messages + msgs = ( + db.query(MailMessage) + .filter(MailMessage.candidate_id == candidate_id) + .order_by(MailMessage.occurred_at.desc()) + .all() + ) + for m in msgs: + direction_label = "收信" if m.direction == "inbound" else "发信" + items.append(TimelineItem( + type="message", + timestamp=str(m.occurred_at), + description=f"{direction_label}: {m.subject}", + detail={ + "id": m.id, + "direction": m.direction, + "subject": m.subject, + "stage_snapshot": m.stage_snapshot, + "send_status": m.send_status, + }, + )) + + # Correction logs (stage changes) + apps = db.query(Application).filter(Application.candidate_id == candidate_id).all() + app_ids = [a.id for a in apps] + if app_ids: + logs = ( + db.query(CorrectionLog) + .filter( + CorrectionLog.application_id.in_(app_ids), + CorrectionLog.field_name == "status", + ) + .order_by(CorrectionLog.created_at.desc()) + .all() + ) + for log in logs: + items.append(TimelineItem( + type="stage_change", + timestamp=str(log.created_at), + description=f"HR 调整阶段: {log.original_value or '空'} → {log.corrected_value}", + detail={ + "queue_item_id": log.queue_item_id, + "original_value": log.original_value, + "corrected_value": log.corrected_value, + }, + )) + + items.sort(key=lambda x: x.timestamp, reverse=True) + return items + + +@router.post("/candidates/{candidate_id}/reply", response_model=ReplyResponse, status_code=201) +def create_reply(candidate_id: int, body: ReplyRequest, db: Session = Depends(get_db)): + c = db.query(Candidate).filter(Candidate.id == candidate_id).first() + if not c: + raise HTTPException(404, "Candidate not found") + + app = db.query(Application).filter(Application.id == body.application_id).first() + if not app or app.candidate_id != candidate_id: + raise HTTPException(400, "Application not found for this candidate") + + # Look up original inbound message to determine system mailbox (sender) + original_msg = ( + db.query(MailMessage) + .filter( + MailMessage.application_id == body.application_id, + MailMessage.direction == "inbound", + ) + .order_by(MailMessage.occurred_at.asc()) + .first() + ) + + _system_mailbox = os.environ.get("QTADMIN_MAILBOX", "") + sender_email = ( + body.sender_email + or (original_msg.recipient_email if original_msg else None) + or _system_mailbox + or "" + ) + + mm = MailMessage( + candidate_id=candidate_id, + application_id=body.application_id, + sender_email=sender_email, + recipient_email=body.recipient_email or c.email, + subject=body.subject, + body=body.body, + body_text=body.body_text, + stage_snapshot=app.status.value, + direction="outbound", + send_status="pending", + occurred_at=func.now(), + ) + db.add(mm) + db.commit() + db.refresh(mm) + + return ReplyResponse( + id=mm.id, + subject=mm.subject, + send_status="pending", + created_at=str(mm.created_at), + ) + + +# ── Batch C: Outbox ── + +_OUTBOX_CLAIM_LIMIT = 10 +_OUTBOX_TIMEOUT_MINUTES = 5 +_OUTBOX_MAX_RETRIES = 5 + + +@router.get("/messages/outbox", response_model=OutboxCountResponse) +def outbox_count( + db: Session = Depends(get_db), + status: str | None = Query(None, description="Filter by send_status"), +): + filter_statuses = [status] if status else ["pending", "sending"] + count = ( + db.query(func.count(MailMessage.id)) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status.in_(filter_statuses), + ) + .scalar() + ) + return OutboxCountResponse(count=count or 0) + + +@router.post("/messages/outbox/claim", response_model=ClaimOutboxResponse) +def claim_outbox(db: Session = Depends(get_db)): + now = datetime.now() + + # Pending messages — apply exponential backoff for retries + pending_raw = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "pending", + ) + .order_by(MailMessage.created_at.asc()) + .all() + ) + pending = [] + for m in pending_raw: + if m.retry_count == 0 or m.last_retry_at is None: + pending.append(m) + else: + backoff_minutes = 2 ** (m.retry_count - 1) + if m.last_retry_at + timedelta(minutes=backoff_minutes) <= now: + pending.append(m) + pending = pending[:_OUTBOX_CLAIM_LIMIT] + + expired = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "sending", + MailMessage.leased_at < (now - timedelta(minutes=_OUTBOX_TIMEOUT_MINUTES)), + ) + .limit(_OUTBOX_CLAIM_LIMIT) + .all() + ) + + to_claim = pending + expired + for m in to_claim: + m.send_status = "sending" + m.lease_id = str(uuid4()) + m.leased_at = now + + db.commit() + + claimed = [ + { + "id": m.id, + "lease_id": m.lease_id, + "subject": m.subject, + "recipient_email": m.recipient_email, + } + for m in to_claim + ] + return ClaimOutboxResponse(claimed=claimed) + + +@router.get("/messages/outbox/dead", response_model=list[DeadLetterItem]) +def list_dead_letters(db: Session = Depends(get_db)): + items = ( + db.query(MailMessage) + .filter( + MailMessage.direction == "outbound", + MailMessage.send_status == "failed", + MailMessage.retry_count >= _OUTBOX_MAX_RETRIES, + ) + .order_by(MailMessage.created_at.desc()) + .all() + ) + return [ + DeadLetterItem( + id=m.id, application_id=m.application_id, candidate_id=m.candidate_id, + subject=m.subject, recipient_email=m.recipient_email, + failure_reason=m.failure_reason, retry_count=m.retry_count or 0, + last_retry_at=str(m.last_retry_at) if m.last_retry_at else None, + created_at=str(m.created_at), + ) + for m in items + ] + + +@router.post("/messages/outbox/{message_id}/requeue", response_model=RequeueResponse) +def requeue_dead_letter(message_id: int, db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.send_status != "failed" or (m.retry_count or 0) < _OUTBOX_MAX_RETRIES: + raise HTTPException(400, "Message is not a dead letter (send_status must be 'failed' with retry_count >= 5)") + + m.send_status = "pending" + m.retry_count = 0 + m.lease_id = None + m.leased_at = None + m.last_retry_at = None + m.failure_reason = None + db.commit() + return RequeueResponse(id=m.id, send_status="pending", retry_count=0) + + +@router.get("/messages/outbox/{message_id}", response_model=OutboxMessageDetail) +def get_outbox_message(message_id: int, lease_id: str = Query(...), db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.lease_id != lease_id: + raise HTTPException(403, "lease_id mismatch") + return OutboxMessageDetail( + id=m.id, lease_id=m.lease_id, subject=m.subject, + body=m.body, body_text=m.body_text, + recipient_email=m.recipient_email, attachments_json=m.attachments_json, + ) + + +# ── Batch D: Send status callback ── + +@router.patch("/messages/{message_id}/send-status") +def update_send_status(message_id: int, body: SendStatusUpdate, db: Session = Depends(get_db)): + m = db.query(MailMessage).filter(MailMessage.id == message_id).first() + if not m: + raise HTTPException(404, "Message not found") + if m.lease_id != body.lease_id: + raise HTTPException(409, "lease_id mismatch — callback rejected") + + m.send_status = body.send_status + if body.send_status == "sent": + m.sent_at = datetime.fromisoformat(body.sent_at) if body.sent_at else func.now() + m.platform_message_id = body.platform_message_id + elif body.send_status == "failed": + now = datetime.now() + m.retry_count = (m.retry_count or 0) + 1 + m.last_retry_at = now + m.failure_reason = body.failure_reason + if m.retry_count >= _OUTBOX_MAX_RETRIES: + m.send_status = "failed" # 死信:永久失败 + m.lease_id = None + m.leased_at = None + else: + # 重置为 pending,让下一轮 claim 按指数退避重新领取 + m.send_status = "pending" + m.lease_id = None + m.leased_at = None + + db.commit() + return {"ok": True} diff --git a/src/provider/app/human/routers/pipeline.py b/src/provider/app/human/routers/pipeline.py new file mode 100644 index 0000000..e66609d --- /dev/null +++ b/src/provider/app/human/routers/pipeline.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.services.pipeline import get_pipeline + +router = APIRouter(prefix="/pipeline", tags=["human"]) + + +@router.get("") +def pipeline(db: Session = Depends(get_db)): + return get_pipeline(db) diff --git a/src/provider/app/human/routers/pool.py b/src/provider/app/human/routers/pool.py new file mode 100644 index 0000000..f4ad861 --- /dev/null +++ b/src/provider/app/human/routers/pool.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.application import Application +from app.human.schemas.application import PoolItemRead +from app.human.services.pool import get_pooled_applications + +router = APIRouter(prefix="/pool", tags=["human"]) + + +def _pool_item_from_orm(app: Application) -> dict: + return { + "id": app.id, + "candidate_id": app.candidate_id, + "recruitment_id": app.recruitment_id, + "status": app.status, + "sub_stage": app.sub_stage, + "quality": app.quality, + "stage_results": app.stage_results, + "source": app.source, + "pooled_at": app.pooled_at, + "deactivated_at": app.deactivated_at, + "created_at": app.created_at, + "updated_at": app.updated_at, + "candidate_email": app.candidate.email, + "candidate_name": app.candidate.real_name, + } + + +@router.get("", response_model=list[PoolItemRead]) +def list_pooled_applications( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + apps = get_pooled_applications(db, skip=skip, limit=limit) + return [_pool_item_from_orm(a) for a in apps] diff --git a/src/provider/app/human/routers/queue.py b/src/provider/app/human/routers/queue.py new file mode 100644 index 0000000..c3302f3 --- /dev/null +++ b/src/provider/app/human/routers/queue.py @@ -0,0 +1,188 @@ +"""Queue management — HR confirm, ignore, and stats.""" +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.talent import Talent, TalentStatus + +router = APIRouter(prefix="/queue", tags=["human"]) + + +class ConfirmRequest(BaseModel): + action: str = "confirmed" + status: str = "contacted" + real_name: str = "" + email: str = "" + recruitment_title: str | None = None + + +class ConfirmResponse(BaseModel): + queue_id: int + action: str + talent_id: int | None = None + + +class IgnoreRequest(BaseModel): + action: str = "ignored" + + +class QueueItemRead(BaseModel): + queue_id: int + message_id: str + subject: str + sender_name: str | None = None + sender_email: str = "" + suggested_status: str | None = None + confidence: str = "low" + hr_status: str = "pending" + created_at: str = "" + + model_config = {"from_attributes": True} + + +class QueueListResponse(BaseModel): + items: list[QueueItemRead] + total: int + + +@router.get("", response_model=QueueListResponse) +def list_queue( + hr_status: str | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), +): + qb = db.query(PendingQueueItem) + if hr_status: + qb = qb.filter(PendingQueueItem.hr_status == hr_status) + total = qb.count() + items = qb.order_by(PendingQueueItem.created_at.desc()).offset(skip).limit(limit).all() + + return QueueListResponse( + items=[QueueItemRead( + queue_id=item.id, + message_id=item.message_id, + subject=item.subject, + sender_name=item.sender_name, + sender_email=item.sender_email, + suggested_status=item.suggested_status, + confidence=item.confidence, + hr_status=item.hr_status, + created_at=str(item.created_at), + ) for item in items], + total=total, + ) + + +@router.patch("/{queue_id}/confirm", response_model=ConfirmResponse) +def confirm_queue_item(queue_id: int, body: ConfirmRequest, db: Session = Depends(get_db)): + item = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not item: + raise HTTPException(404, "Queue item not found") + if item.hr_status != "pending": + raise HTTPException(400, f"Queue item is not pending (current: {item.hr_status})") + + item.hr_status = body.action + db.flush() + + recruitment = db.query(Recruitment).order_by(Recruitment.created_at.desc()).first() + if not recruitment: + recruitment = Recruitment() + db.add(recruitment) + db.flush() + + target_email = (body.email or item.sender_email or "").lower() + candidate = db.query(Candidate).filter(Candidate.email == target_email).first() + if not candidate: + candidate = Candidate( + email=target_email, + real_name=body.real_name or item.sender_name or "未知", + ) + db.add(candidate) + db.flush() + + app = Application( + candidate_id=candidate.id, + recruitment_id=recruitment.id, + source="feishu_api", + ) + db.add(app) + db.flush() + + target_status = body.status or item.suggested_status + if target_status and target_status != "new": + try: + status_order = ["new", "contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer", "closed"] + from app.human.models.talent import STATUS_TRANSITIONS + current_idx = status_order.index(app.status.value) + target_idx = status_order.index(target_status) + for s in status_order[current_idx + 1 : target_idx + 1]: + if TalentStatus(s) in STATUS_TRANSITIONS.get(app.status, []): + app.status = TalentStatus(s) + db.flush() + except (ValueError, KeyError): + pass + + talent = Talent( + recruitment_id=recruitment.id, + email=candidate.email, + real_name=candidate.real_name, + status=app.status, + ) + db.add(talent) + db.commit() + db.refresh(talent) + + return ConfirmResponse(queue_id=item.id, action=body.action, talent_id=talent.id) + + +@router.patch("/{queue_id}/ignore", response_model=ConfirmResponse) +def ignore_queue_item(queue_id: int, body: IgnoreRequest, db: Session = Depends(get_db)): + item = db.query(PendingQueueItem).filter(PendingQueueItem.id == queue_id).first() + if not item: + raise HTTPException(404, "Queue item not found") + if item.hr_status != "pending": + raise HTTPException(400, f"Queue item is not pending (current: {item.hr_status})") + item.hr_status = "ignored" + db.commit() + return ConfirmResponse(queue_id=item.id, action="ignored") + + +@router.get("/by-email") +def get_queue_by_email(email: str, db: Session = Depends(get_db)): + qi = ( + db.query(PendingQueueItem) + .filter(PendingQueueItem.sender_email == email) + .order_by(PendingQueueItem.created_at.desc()) + .first() + ) + if not qi: + return {"found": False} + return { + "found": True, + "item": { + "queue_id": qi.id, + "message_id": qi.message_id, + "subject": qi.subject, + "sender_name": qi.sender_name, + "sender_email": qi.sender_email, + "suggested_status": qi.suggested_status, + "confidence": qi.confidence, + "hr_status": qi.hr_status, + "hr_notes": qi.hr_notes, + }, + } + + +@router.get("/stats") +def queue_stats(db: Session = Depends(get_db)): + rows = db.execute( + text("SELECT hr_status, COUNT(*) as cnt FROM pending_queue GROUP BY hr_status") + ).all() + return {row[0]: row[1] for row in rows} or {"pending": 0, "confirmed": 0, "ignored": 0} diff --git a/src/provider/app/human/routers/recruitments.py b/src/provider/app/human/routers/recruitments.py new file mode 100644 index 0000000..54e3e5e --- /dev/null +++ b/src/provider/app/human/routers/recruitments.py @@ -0,0 +1,180 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.human.database import get_db +from app.human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.schemas.talent import SubStageUpdate, TalentCreate, TalentRead, TalentTransition, TalentUpdate +from app.human.schemas.recruitment import HeadcountRead, RecruitmentRead +from app.human.services.headcount import get_headcount + +router = APIRouter(prefix="/recruitments", tags=["human"]) + + +@router.get("", response_model=list[RecruitmentRead]) +def list_recruitments( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + return db.query(Recruitment).order_by(Recruitment.created_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{recruitment_id}", response_model=RecruitmentRead) +def get_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: + raise HTTPException(404, "Recruitment not found") + return r + + +@router.post("", response_model=RecruitmentRead, status_code=201) +def create_recruitment(db: Session = Depends(get_db)): + r = Recruitment() + db.add(r) + db.commit() + db.refresh(r) + return r + + +@router.delete("/{recruitment_id}", status_code=204) +def delete_recruitment(recruitment_id: int, db: Session = Depends(get_db)): + r = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not r: + raise HTTPException(404, "Recruitment not found") + db.delete(r) + db.commit() + + +@router.get("/{recruitment_id}/headcount", response_model=HeadcountRead) +def get_recruitment_headcount(recruitment_id: int, db: Session = Depends(get_db)): + _recruitment_exists(recruitment_id, db) + return get_headcount(db, recruitment_id) + + +def _recruitment_exists(recruitment_id: int, db: Session) -> None: + if not db.query(Recruitment).filter(Recruitment.id == recruitment_id).first(): + raise HTTPException(404, "Recruitment not found") + + +@router.get("/{recruitment_id}/talents", response_model=list[TalentRead]) +def list_talents( + recruitment_id: int, + status: TalentStatus | None = None, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + _recruitment_exists(recruitment_id, db) + qb = db.query(Talent).filter(Talent.recruitment_id == recruitment_id) + if status: + qb = qb.filter(Talent.status == status) + return qb.order_by(Talent.updated_at.desc()).offset(skip).limit(limit).all() + + +@router.get("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def get_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + return t + + +@router.post("/{recruitment_id}/talents", response_model=TalentRead, status_code=201) +def create_talent(recruitment_id: int, data: TalentCreate, db: Session = Depends(get_db)): + recruitment = db.query(Recruitment).filter(Recruitment.id == recruitment_id).first() + if not recruitment: + raise HTTPException(404, "Recruitment not found") + + candidate = db.query(Candidate).filter(Candidate.email == data.email.lower()).first() + if not candidate: + candidate = Candidate(email=data.email.lower(), real_name=data.real_name) + db.add(candidate) + db.flush() + app = Application(candidate_id=candidate.id, recruitment_id=recruitment_id, source="manual_debug") + db.add(app) + db.flush() + + t = Talent(recruitment_id=recruitment_id, email=data.email, real_name=data.real_name) + db.add(t) + db.commit() + db.refresh(t) + return t + + +@router.patch("/{recruitment_id}/talents/{talent_id}", response_model=TalentRead) +def update_talent(recruitment_id: int, talent_id: int, data: TalentUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(t, k, v) + db.commit() + db.refresh(t) + return t + + +@router.post("/{recruitment_id}/talents/{talent_id}/transition", response_model=TalentRead) +def transition_talent(recruitment_id: int, talent_id: int, data: TalentTransition, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + + target = data.status + if target not in STATUS_TRANSITIONS.get(t.status, []): + raise HTTPException(400, f"Cannot transition from {t.status.value} to {target.value}") + + old_status = t.status + + candidate = db.query(Candidate).filter(Candidate.email == t.email).first() + if candidate: + app = (db.query(Application) + .filter(Application.candidate_id == candidate.id, + Application.recruitment_id == recruitment_id) + .order_by(Application.created_at.desc()) + .first()) + if app: + app.status = target + if target != old_status: + app.sub_stage = None + if data.sub_stage is not None and target in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = data.sub_stage + + stage_key = old_status.value + if stage_key in ("contacted", "evaluating", "interview", "offer"): + if not (stage_key == "evaluating" and target.value == "exam_sent"): + if app.stage_results is None: + app.stage_results = {} + app.stage_results[stage_key] = "pass" if target.value != "closed" else "fail" + + t.status = app.status + t.sub_stage = app.sub_stage + t.stage_results = app.stage_results + + db.commit() + db.refresh(t) + return t + + +@router.patch("/{recruitment_id}/talents/{talent_id}/sub-stage", response_model=TalentRead) +def set_talent_sub_stage(recruitment_id: int, talent_id: int, data: SubStageUpdate, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + if t.status not in ALLOWED_STATUSES_FOR_SUB_STAGE: + raise HTTPException(400, f"Cannot set sub_stage for status {t.status.value}") + t.sub_stage = data.sub_stage + db.commit() + db.refresh(t) + return t + + +@router.delete("/{recruitment_id}/talents/{talent_id}", status_code=204) +def delete_talent(recruitment_id: int, talent_id: int, db: Session = Depends(get_db)): + t = db.query(Talent).filter(Talent.id == talent_id, Talent.recruitment_id == recruitment_id).first() + if not t: + raise HTTPException(404, "Talent not found") + db.delete(t) + db.commit() diff --git a/src/provider/app/human/schemas/__init__.py b/src/provider/app/human/schemas/__init__.py new file mode 100644 index 0000000..e550ce1 --- /dev/null +++ b/src/provider/app/human/schemas/__init__.py @@ -0,0 +1,11 @@ +from app.human.schemas.pending_queue import ( + ConfirmRequest, ConfirmResponse, IgnoreRequest, +) +from app.human.schemas.recruitment import HeadcountRead, RecruitmentRead +from app.human.schemas.talent import TalentCreate, TalentRead, TalentTransition, TalentUpdate, SubStageUpdate + +__all__ = [ + "ConfirmRequest", "ConfirmResponse", "IgnoreRequest", + "HeadcountRead", "RecruitmentRead", + "TalentCreate", "TalentRead", "TalentUpdate", "TalentTransition", "SubStageUpdate", +] diff --git a/src/provider/app/human/schemas/application.py b/src/provider/app/human/schemas/application.py new file mode 100644 index 0000000..fb3eb42 --- /dev/null +++ b/src/provider/app/human/schemas/application.py @@ -0,0 +1,45 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.human.models.talent import TalentStatus + + +class ApplicationRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + candidate_id: int + recruitment_id: int + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + source: str = "manual_seed" + pooled_at: datetime | None = None + deactivated_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class UnpoolRequest(BaseModel): + recruitment_id: int = Field(..., ge=1) + + +class PoolItemRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + candidate_id: int + recruitment_id: int + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + source: str = "manual_seed" + pooled_at: datetime | None = None + deactivated_at: datetime | None = None + created_at: datetime + updated_at: datetime + candidate_email: str = "" + candidate_name: str = "" diff --git a/src/provider/app/human/schemas/candidate.py b/src/provider/app/human/schemas/candidate.py new file mode 100644 index 0000000..06d37dd --- /dev/null +++ b/src/provider/app/human/schemas/candidate.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class CandidateRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + real_name: str + phone: str | None = None + created_at: datetime diff --git a/src/provider/app/human/schemas/export.py b/src/provider/app/human/schemas/export.py new file mode 100644 index 0000000..416a97f --- /dev/null +++ b/src/provider/app/human/schemas/export.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class TrainingPairItem(BaseModel): + queue_id: int + subject: str + body: str | None = None + sender_email: str + suggested_status: str | None = None + final_status: str | None = None + final_real_name: str | None = None + final_email: str | None = None + hr_action: str | None = None + corrected_fields: list[str] = [] + + +class TrainingPairResponse(BaseModel): + items: list[TrainingPairItem] + total: int diff --git a/src/provider/app/human/schemas/messages.py b/src/provider/app/human/schemas/messages.py new file mode 100644 index 0000000..d485752 --- /dev/null +++ b/src/provider/app/human/schemas/messages.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class AttachmentInfo(BaseModel): + filename: str + size: int = 0 + mime_type: str | None = None + message_attachment_id: str | None = None + storage_path: str | None = None + + +class MailMessageRead(BaseModel): + id: int + candidate_id: int | None = None + application_id: int | None = None + message_id: str | None = None + sender_email: str + recipient_email: str | None = None + subject: str + body: str | None = None + body_text: str | None = None + attachments_json: str | None = None + stage_snapshot: str | None = None + direction: str + send_status: str | None = None + occurred_at: str = "" + created_at: str = "" + + model_config = {"from_attributes": True} + + +class ReplyRequest(BaseModel): + application_id: int + subject: str + body: str | None = None + body_text: str | None = None + sender_email: str | None = None + recipient_email: str | None = None + + +class ReplyResponse(BaseModel): + id: int + direction: str = "outbound" + send_status: str = "pending" + subject: str + created_at: str = "" + + +class OutboxCountResponse(BaseModel): + count: int + + +class ClaimOutboxResponse(BaseModel): + claimed: list[dict] + + +class OutboxMessageDetail(BaseModel): + id: int + lease_id: str + subject: str + body: str | None = None + body_text: str | None = None + recipient_email: str | None = None + attachments_json: str | None = None + + +class SendStatusUpdate(BaseModel): + lease_id: str + send_status: str # "sent" | "failed" + sent_at: str | None = None + platform_message_id: str | None = None + failure_reason: str | None = None + + +class TimelineItem(BaseModel): + type: str # "message" | "stage_change" + timestamp: str + description: str + detail: dict | None = None + + +class DeadLetterItem(BaseModel): + id: int + application_id: int | None = None + candidate_id: int | None = None + subject: str + recipient_email: str | None = None + failure_reason: str | None = None + retry_count: int = 0 + last_retry_at: str | None = None + created_at: str = "" + + +class RequeueResponse(BaseModel): + id: int + send_status: str = "pending" + retry_count: int = 0 diff --git a/src/provider/app/human/schemas/pending_queue.py b/src/provider/app/human/schemas/pending_queue.py new file mode 100644 index 0000000..57b1171 --- /dev/null +++ b/src/provider/app/human/schemas/pending_queue.py @@ -0,0 +1,21 @@ +"""Shared pending queue schemas.""" + +from pydantic import BaseModel + + +class ConfirmRequest(BaseModel): + action: str = "confirmed" + status: str = "contacted" + real_name: str = "" + email: str = "" + recruitment_title: str | None = None + + +class ConfirmResponse(BaseModel): + queue_id: int + action: str + talent_id: int | None = None + + +class IgnoreRequest(BaseModel): + action: str = "ignored" diff --git a/src/provider/app/human/schemas/recruitment.py b/src/provider/app/human/schemas/recruitment.py new file mode 100644 index 0000000..fd34c4c --- /dev/null +++ b/src/provider/app/human/schemas/recruitment.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class RecruitmentRead(BaseModel): + id: int + created_at: datetime + + model_config = {"from_attributes": True} + + +class HeadcountRead(BaseModel): + recruitment_id: int + total_offers: int + accepted: int diff --git a/src/provider/app/human/schemas/talent.py b/src/provider/app/human/schemas/talent.py new file mode 100644 index 0000000..57effe6 --- /dev/null +++ b/src/provider/app/human/schemas/talent.py @@ -0,0 +1,42 @@ +from datetime import datetime + +from pydantic import BaseModel + +from app.human.models.talent import TalentStatus + + +class TalentCreate(BaseModel): + email: str + real_name: str + auto_screening_result: str | None = None + + +class TalentUpdate(BaseModel): + real_name: str | None = None + quality: str | None = None + + model_config = {"extra": "forbid"} + + +class TalentTransition(BaseModel): + status: TalentStatus + sub_stage: str | None = None + + +class SubStageUpdate(BaseModel): + sub_stage: str | None = None + + +class TalentRead(BaseModel): + id: int + recruitment_id: int + email: str + real_name: str + status: TalentStatus + sub_stage: str | None = None + quality: str = "normal" + stage_results: dict | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/src/provider/app/human/seed.py b/src/provider/app/human/seed.py new file mode 100644 index 0000000..059fa3f --- /dev/null +++ b/src/provider/app/human/seed.py @@ -0,0 +1,187 @@ +"""Seed data constants for demo/testing.""" +from datetime import datetime, timedelta +from hashlib import md5 + +from sqlalchemy import update +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.pending_queue import PendingQueueItem +from app.human.models.recruitment import Recruitment +from app.human.models.talent import Talent, TalentStatus + +SEED_TRANSITIONS = { + "new": [], + "contacted": ["contacted"], + "exam_sent": ["contacted", "exam_sent"], + "exam_received": ["contacted", "exam_sent", "exam_received"], + "evaluating": ["contacted", "exam_sent", "exam_received", "evaluating"], + "interview": ["contacted", "exam_sent", "exam_received", "evaluating", "interview"], + "offer": ["contacted", "exam_sent", "exam_received", "evaluating", "interview", "offer"], + "closed": ["closed"], +} + +DEMO_TALENTS = [ + ("new", "张一", "zhang1@demo.local", None), + ("new", "张二", "zhang2@demo.local", None), + ("new", "张三", "zhang3@demo.local", None), + ("new", "张四", "zhang4@demo.local", None), + ("new", "张五", "zhang5@demo.local", None), + ("contacted", "李一", "li1@demo.local", None), + ("contacted", "李二", "li2@demo.local", "resume_passed"), + ("contacted", "李三", "li3@demo.local", "resume_passed"), + ("contacted", "李四", "li4@demo.local", "resume_passed"), + ("contacted", "李五", "li5@demo.local", None), + ("exam_sent", "王一", "wang1@demo.local", None), + ("exam_sent", "王二", "wang2@demo.local", "taking"), + ("exam_sent", "王三", "wang3@demo.local", "taking"), + ("exam_sent", "王四", "wang4@demo.local", "taking"), + ("exam_sent", "王五", "wang5@demo.local", None), + ("exam_received", "赵一", "zhao1@demo.local", None), + ("exam_received", "赵二", "zhao2@demo.local", None), + ("exam_received", "赵三", "zhao3@demo.local", None), + ("exam_received", "赵四", "zhao4@demo.local", None), + ("exam_received", "赵五", "zhao5@demo.local", None), + ("evaluating", "孙一", "sun1@demo.local", None), + ("evaluating", "孙二", "sun2@demo.local", "exam_passed"), + ("evaluating", "孙三", "sun3@demo.local", "exam_passed"), + ("evaluating", "孙四", "sun4@demo.local", "exam_passed"), + ("evaluating", "孙五", "sun5@demo.local", None), + ("interview", "周一", "zhou1@demo.local", None), + ("interview", "周子", "zhou2@demo.local", "interview_passed"), + ("interview", "周三", "zhou3@demo.local", "interview_passed"), + ("interview", "周四", "zhou4@demo.local", "interview_passed"), + ("interview", "周五", "zhou5@demo.local", None), + ("offer", "吴一", "wu1@demo.local", None), + ("offer", "吴二", "wu2@demo.local", "accepted"), + ("offer", "吴三", "wu3@demo.local", "accepted"), + ("offer", "吴四", "wu4@demo.local", "accepted"), + ("offer", "吴五", "wu5@demo.local", None), + ("closed", "郑一", "zheng1@demo.local", None), + ("closed", "郑二", "zheng2@demo.local", None), + ("closed", "郑三", "zheng3@demo.local", None), + ("closed", "郑四", "zheng4@demo.local", None), + ("closed", "郑五", "zheng5@demo.local", None), +] + +QUALITY_MAP = { + "李二": "excellent", "李三": "excellent", "李四": "excellent", + "孙二": "excellent", "孙三": "excellent", + "周子": "excellent", + "吴二": "excellent", "吴三": "excellent", + "张五": "excellent", +} + +DEMO_EMAILS = [ + {"subject": "求职申请 - 前端开发", "sender_name": "王小明", "sender_email": "wxm@demo.local"}, + {"subject": "简历: 3年Python后端经验", "sender_name": "李芳", "sender_email": "lifang@demo.local"}, + {"subject": "应聘产品经理岗位", "sender_name": "赵磊", "sender_email": "zhaolei@demo.local"}, + {"subject": "高级Java开发求职", "sender_name": "陈静", "sender_email": "chenjing@demo.local"}, + {"subject": "【求职】数据分析师", "sender_name": "刘洋", "sender_email": "liuyang@demo.local"}, + {"subject": "UI设计师求职作品集", "sender_name": "周婷", "sender_email": "zhouting@demo.local"}, + {"subject": "寻求前端实习机会", "sender_name": "林小华", "sender_email": "linxh@demo.local"}, + {"subject": "DevOps工程师求职", "sender_name": "黄伟", "sender_email": "huangwei@demo.local"}, + {"subject": "测试工程师简历投递", "sender_name": "孙磊", "sender_email": "sunlei@demo.local"}, + {"subject": "市场运营专员求职", "sender_name": "张薇", "sender_email": "zhangwei@demo.local"}, +] + + +def build_transition_chain(target: str) -> list[str]: + """从 new 走到 target 的合法路径(不含 new 自身)。""" + return SEED_TRANSITIONS[target] + + +def seed_data(db: Session) -> None: + """Populate the database with demo talents and pending queue items.""" + import app.human.models # noqa: F401 + + r = Recruitment() + db.add(r) + db.flush() + + for target_status, name, email, sub_stage in DEMO_TALENTS: + t = Talent(recruitment_id=r.id, email=email, real_name=name) + db.add(t) + db.flush() + for s in build_transition_chain(target_status): + t.status = TalentStatus(s) + db.flush() + t.sub_stage = sub_stage + t.quality = QUALITY_MAP.get(name, "normal") + stage_map = { + "exam_sent": {"contacted": "pass"}, + "exam_received": {"contacted": "pass"}, + "evaluating": {"contacted": "pass"}, + "interview": {"contacted": "pass", "evaluating": "pass"}, + "offer": {"contacted": "pass", "evaluating": "pass", "interview": "pass"}, + } + t.stage_results = stage_map.get(target_status) + db.flush() + + db.commit() + + status_age = {"new": 0, "contacted": 2, "exam_sent": 5, "exam_received": 8, + "evaluating": 12, "interview": 15, "offer": 20, "closed": 25} + for target_status, name, email, _ in DEMO_TALENTS: + days = status_age[target_status] + if days > 0: + past = datetime.utcnow() - timedelta(days=days) + db.execute(update(Talent).where(Talent.email == email).values(updated_at=past)) + db.commit() + + email_to_candidate = {} + for target_status, name, email, _ in DEMO_TALENTS: + if email not in email_to_candidate: + c = Candidate(email=email, real_name=name) + db.add(c) + db.flush() + email_to_candidate[email] = c + + for target_status, name, email, sub_stage in DEMO_TALENTS: + talent = db.query(Talent).filter(Talent.email == email).first() + if talent: + a = Application( + candidate_id=email_to_candidate[email].id, + recruitment_id=r.id, + status=talent.status, + sub_stage=talent.sub_stage, + quality=talent.quality, + stage_results=talent.stage_results, + source="manual_seed", + ) + db.add(a) + db.flush() + + zhang3 = email_to_candidate.get("zhang3@demo.local") + if zhang3: + pooled = Application( + candidate_id=zhang3.id, recruitment_id=r.id, + status=TalentStatus.NEW, source="manual_seed", + pooled_at=datetime.utcnow(), + ) + db.add(pooled) + db.flush() + + wang5 = email_to_candidate.get("wang5@demo.local") + if wang5: + extra = Application( + candidate_id=wang5.id, recruitment_id=r.id, + status=TalentStatus.EXAM_SENT, source="manual_seed", + ) + db.add(extra) + db.flush() + + db.commit() + + for email in DEMO_EMAILS: + qi = PendingQueueItem( + message_id=md5(email["subject"].encode()).hexdigest()[:16], + subject=email["subject"], + sender_name=email["sender_name"], + sender_email=email["sender_email"], + suggested_status="contacted", + confidence="medium", + ) + db.add(qi) + db.commit() diff --git a/src/provider/app/human/services/__init__.py b/src/provider/app/human/services/__init__.py new file mode 100644 index 0000000..b7a7a42 --- /dev/null +++ b/src/provider/app/human/services/__init__.py @@ -0,0 +1,4 @@ +"""HR services.""" +from app.human.services.pipeline import get_pipeline + +__all__ = ["get_pipeline"] diff --git a/src/provider/app/human/services/ai_classifier.py b/src/provider/app/human/services/ai_classifier.py new file mode 100644 index 0000000..16a08a2 --- /dev/null +++ b/src/provider/app/human/services/ai_classifier.py @@ -0,0 +1,177 @@ +"""AI分类器 — 可插拔,未配置时返回 None 回退到规则分类。""" + +import json +import logging +from dataclasses import dataclass + +import httpx +from sqlalchemy.orm import Session + +from app.human.models.ai_config import AIConfig +from app.human.services.email_matcher import MatchResult + +logger = logging.getLogger(__name__) + +_DEFAULT_PROMPT = """你是一个招聘邮件分类助手。根据邮件内容判断候选人处于招聘管道的哪个阶段,并提取候选人真实姓名。 + +规则: +1. suggested_status 必须是以下英文值之一(不能是中文): + new — 新投递简历/新应聘 + contacted — 回复了HR的联系邮件/普通咨询 + exam_sent — 询问笔试相关/笔试通知 + exam_received — 提交笔试答案/完成笔试 + evaluating — 询问评估进度/审核中 + interview — 面试相关沟通/面试感谢信 + offer — Offer沟通/接受Offer + closed — 放弃机会/拒绝offer +2. extracted_name:从邮件正文或署名中提取候选人真实姓名,找不到则返回null""" + + +@dataclass +class AiClassification: + suggested_status: str | None = None + confidence: str = "low" + classifier_reason: str | None = None + extracted_name: str | None = None + merge_result: str | None = None + match: MatchResult | None = None + + +def ai_classify( + subject: str, + body_text: str | None, + sender_name: str | None, + sender_email: str, + attachments: list[dict] | None = None, + match: MatchResult | None = None, + db: Session | None = None, +) -> AiClassification | None: + """AI分类入口。读取 DB 中的 AI 配置,调用 AI 接口分类。 + + 当 AI 未配置或调用失败时返回 None,由 classifier.py 的回退机制接管。 + """ + if db is None: + return None + + cfg = db.query(AIConfig).first() + if not cfg or not cfg.enabled or not cfg.api_key_encrypted: + return None + + body_text_truncated = (body_text or "")[:2000] + user_prompt = cfg.prompt_template or _DEFAULT_PROMPT + + # Inject email context — this works whether or not the prompt template has placeholders + email_context = ( + f"\n\n---\n邮件信息:\n" + f"发件人: {sender_name or ''} <{sender_email}>\n" + f"主题: {subject}\n" + f"正文: {body_text_truncated}\n" + f"---\n" + f"请根据邮件内容选择最匹配的阶段值(必须用英文值)并提取候选人姓名。尽量给出判断而非null。\n" + f'仅返回以下JSON格式:\n' + f'{{"suggested_status": "...", "confidence": "high/medium/low", "reason": "...", "extracted_name": "姓名或null"}}' + ) + full_content = user_prompt + email_context + + messages = [ + { + "role": "user", + "content": full_content, + } + ] + + url = (cfg.base_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions" + headers = { + "Authorization": f"Bearer {cfg.api_key_encrypted}", + "Content-Type": "application/json", + } + payload = { + "model": cfg.model or "gpt-4o-mini", + "messages": messages, + "temperature": 0.1, + "max_tokens": 300, + } + + last_error: Exception | None = None + for attempt in range(max(1, cfg.retry_times + 1)): + try: + resp = httpx.post( + url, + headers=headers, + json=payload, + timeout=cfg.timeout_seconds or 30, + ) + logger.warning("AI API response status=%d body_preview=%s", resp.status_code, resp.text[:500]) + resp.raise_for_status() + data = resp.json() + content = data["choices"][0]["message"]["content"].strip() + # Reasoning models (DeepSeek-R1, deepseek-v4-flash etc.) may return + # reasoning_content before content — only the final content is in "content". + # Try to extract JSON from the content (find first { and last }) + if "{" in content and "}" in content: + json_start = content.index("{") + json_end = content.rindex("}") + 1 + content = content[json_start:json_end] + # Strip markdown code fence if present + if content.startswith("```"): + content = content.split("\n", 1)[-1] if "\n" in content else content[3:] + content = content.rsplit("```", 1)[0].strip() + result = json.loads(content) + status = result.get("suggested_status") + confidence = result.get("confidence", "low") + reason = result.get("reason", "") + extracted_name = result.get("extracted_name") + + if status and status not in _VALID_STATUSES: + # Try fuzzy match — map Chinese/creative labels to valid statuses + status_lower = status.lower() + fuzzy_map = { + "新投递": "new", "新应聘": "new", "求职": "new", "投递": "new", + "面试结束": "interview", "面试": "interview", "面试反馈": "interview", + "笔试提交": "exam_received", "笔试": "exam_received", + "笔试发送": "exam_sent", "笔试通知": "exam_sent", + "评估": "evaluating", + "offer": "offer", "录用": "offer", + "放弃": "closed", "拒绝": "closed", + } + mapped = None + for k, v in fuzzy_map.items(): + if k in status or k in status_lower: + mapped = v + break + if mapped: + status = mapped + else: + logger.warning("AI returned unknown status: %s", status) + status = None + confidence = "low" + + if status is None: + logger.warning("AI returned no valid status, falling back to keyword rules") + return None + + return AiClassification( + suggested_status=status, + confidence=confidence or "low", + classifier_reason=reason, + extracted_name=extracted_name, + merge_result=match.merge_result if match else None, + match=match, + ) + + except httpx.TimeoutException as e: + last_error = e + logger.warning("AI request timeout (attempt %d/%d)", attempt + 1, cfg.retry_times + 1) + except (httpx.HTTPStatusError, json.JSONDecodeError, KeyError, IndexError) as e: + last_error = e + logger.warning("AI request failed (attempt %d/%d): %s", attempt + 1, cfg.retry_times + 1, e) + break # Don't retry on HTTP errors or parse errors + + logger.error("AI classification failed after %d attempts: %s", cfg.retry_times + 1, last_error) + return None + + +_VALID_STATUSES = { + "new", "contacted", "exam_sent", "exam_received", + "evaluating", "interview", "offer", "closed", +} diff --git a/src/provider/app/human/services/classifier.py b/src/provider/app/human/services/classifier.py new file mode 100644 index 0000000..3795447 --- /dev/null +++ b/src/provider/app/human/services/classifier.py @@ -0,0 +1,134 @@ +"""服务端分类引擎 — 三层分类:快速过滤 → 历史关联 → 独立分类。""" + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.human.services.ai_classifier import AiClassification, ai_classify +from app.human.services.email_matcher import MatchResult, match_by_email + +_INTERNAL_DOMAINS: list[str] = [] +_AUTO_REPLY_KEYWORDS = ["自动回复", "外出", "休假", "out of office", "auto-reply"] + +_STATUS_KEYWORDS: dict[str, list[str]] = { + "contacted": ["应聘", "求职", "简历", "申请", "投递", "个人简历"], + "exam_sent": ["笔试邀请", "笔试通知", "在线考试"], + "exam_received": ["笔试答案", "答题", "笔试完成", "提交答卷", "作答", "试卷", "已完成"], + "evaluating": ["评估", "审核简历", "简历评估"], + "interview": ["面试感谢", "面试反馈", "面试安排", "面试邀请", "确认参加", "时间安排"], + "offer": ["offer", "录用通知", "入职邀请", "薪酬确认", "接受 offer", "入职"], + "closed": ["放弃", "退出", "拒绝", "不考虑", "辞职"], +} + + +@dataclass +class EmailClassification: + suggested_status: str | None + confidence: str + classifier_source: str + classifier_reason: str | None + merge_result: str | None + extracted_name: str | None = None + match: MatchResult | None = None + + +def classify( + subject: str, + body_text: str | None, + sender_name: str | None, + sender_email: str, + db: Session, + attachments: list[dict] | None = None, +) -> EmailClassification: + """三层分类入口。""" + # Layer 1: 快速过滤 + filtered = _fast_filter(subject, sender_email) + if filtered: + return EmailClassification( + suggested_status=None, + confidence="reject", + classifier_source="rule", + classifier_reason=filtered, + merge_result=None, + ) + + # Layer 2: 历史关联 + match = match_by_email(sender_email, db, subject=subject) + + # Layer 3: AI 分类(可插拔,未配置时回退到关键词) + ai_result = ai_classify( + subject=subject, + body_text=body_text, + sender_name=sender_name, + sender_email=sender_email, + attachments=attachments, + match=match, + db=db, + ) + if ai_result is not None: + return EmailClassification( + suggested_status=ai_result.suggested_status, + confidence=ai_result.confidence, + classifier_source="ai", + classifier_reason=ai_result.classifier_reason, + extracted_name=ai_result.extracted_name, + merge_result=ai_result.merge_result or match.merge_result, + match=ai_result.match or match, + ) + + # Layer 4: 关键词分类(AI 回退) + status, conf, reason = _keyword_classify(subject, body_text, attachments) + + return EmailClassification( + suggested_status=status, + confidence=conf, + classifier_source="rule", + classifier_reason=reason, + merge_result=match.merge_result, + match=match, + ) + + +def _fast_filter(subject: str, sender_email: str) -> str | None: + subject_lower = subject.lower() + for kw in _AUTO_REPLY_KEYWORDS: + if kw in subject_lower: + return f"自动回复邮件: 命中关键词 '{kw}'" + for domain in _INTERNAL_DOMAINS: + if sender_email.endswith(f"@{domain}"): + return f"内部邮箱: {sender_email}" + return None + + +def _keyword_classify( + subject: str, + body_text: str | None, + attachments: list[dict] | None = None, +) -> tuple[str | None, str, str | None]: + subject_lower = subject.lower() + combined = subject_lower + if body_text: + combined += " " + body_text.lower() + + matched: list[tuple[str, str]] = [] + for status, keywords in _STATUS_KEYWORDS.items(): + for kw in keywords: + if kw in combined: + matched.append((status, kw)) + + if not matched: + return None, "low", None + + groups: dict[str, int] = {} + for s, _ in matched: + groups[s] = groups.get(s, 0) + 1 + best = max(groups, key=groups.get) + cnt = groups[best] + + conf = "high" if cnt >= 2 else "medium" + kw_str = ", ".join(f"{s}({kw})" for s, kw in matched) + has_att = attachments and len(attachments) > 0 + att_note = "+附件" if has_att else "" + reason = f"命中关键词: [{kw_str}]{att_note}" + + return best, conf, reason diff --git a/src/provider/app/human/services/email_matcher.py b/src/provider/app/human/services/email_matcher.py new file mode 100644 index 0000000..4efe115 --- /dev/null +++ b/src/provider/app/human/services/email_matcher.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate + + +@dataclass +class MatchResult: + exists: bool + candidate_id: int | None = None + candidate_name: str | None = None + active_application_id: int | None = None + merge_result: str = "new" # "new" | "existing_auto" | "existing_review" + + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def _subject_matches_recruitment(subject: str, recruitment_title: str) -> bool: + if not recruitment_title: + return False + keywords = recruitment_title.lower().split() + subject_lower = subject.lower() + return any(kw in subject_lower for kw in keywords) + + +def match_by_email(email: str, db: Session, subject: str = "") -> MatchResult: + if not email: + return MatchResult(exists=False) + + normalized = _normalize_email(email) + + candidates = ( + db.query(Candidate) + .filter(func.lower(Candidate.email) == normalized) + .order_by(Candidate.created_at.desc()) + .all() + ) + + if not candidates: + return MatchResult(exists=False) + + # Multiple Candidates with same email → ambiguous, escalate + if len(candidates) > 1: + return MatchResult( + exists=True, + merge_result="existing_review", + ) + + candidate = candidates[0] + active_apps = ( + db.query(Application) + .filter( + Application.candidate_id == candidate.id, + Application.deactivated_at.is_(None), + ) + .order_by(Application.created_at.desc()) + .all() + ) + + if not active_apps: + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + merge_result="existing_review", + ) + + # Single active application + if len(active_apps) == 1: + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + active_application_id=active_apps[0].id, + merge_result="existing_auto", + ) + + # Multiple active applications: try to disambiguate by subject + for app in active_apps: + recruitment_title = app.recruitment.title if hasattr(app, "recruitment") and app.recruitment else "" + if recruitment_title and _subject_matches_recruitment(subject, recruitment_title): + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + active_application_id=app.id, + merge_result="existing_auto", + ) + + return MatchResult( + exists=True, + candidate_id=candidate.id, + candidate_name=candidate.real_name, + merge_result="existing_review", + ) diff --git a/src/provider/app/human/services/export.py b/src/provider/app/human/services/export.py new file mode 100644 index 0000000..0e39726 --- /dev/null +++ b/src/provider/app/human/services/export.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.candidate import Candidate +from app.human.models.correction_log import CorrectionLog +from app.human.models.pending_queue import PendingQueueItem +from app.human.schemas.export import TrainingPairItem + + +def get_training_pairs( + db: Session, + skip: int = 0, + limit: int = 100, +) -> list[TrainingPairItem]: + rows = ( + db.query(Application, PendingQueueItem) + .join(PendingQueueItem, Application.source_queue_item_id == PendingQueueItem.id) + .filter(Application.source_queue_item_id.isnot(None)) + .order_by(Application.created_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + result = [] + for app, queue_item in rows: + corrections = ( + db.query(CorrectionLog) + .filter(CorrectionLog.queue_item_id == queue_item.id) + .all() + ) + corrected_fields = [c.field_name for c in corrections] + + candidate = db.query(Candidate).filter(Candidate.id == app.candidate_id).first() + + result.append(TrainingPairItem( + queue_id=queue_item.id, + subject=queue_item.subject, + body=queue_item.body, + sender_email=queue_item.sender_email, + suggested_status=queue_item.suggested_status, + final_status=app.status.value if app.status else None, + final_real_name=candidate.real_name if candidate else None, + final_email=candidate.email if candidate else None, + hr_action=queue_item.hr_status, + corrected_fields=corrected_fields, + )) + + return result + + +def count_training_pairs(db: Session) -> int: + return ( + db.query(Application) + .filter(Application.source_queue_item_id.isnot(None)) + .count() + ) diff --git a/src/provider/app/human/services/headcount.py b/src/provider/app/human/services/headcount.py new file mode 100644 index 0000000..84c19f0 --- /dev/null +++ b/src/provider/app/human/services/headcount.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import Session + +from app.human.models.application import Application +from app.human.models.talent import TalentStatus + + +def get_headcount(db: Session, recruitment_id: int) -> dict: + base = db.query(Application).filter(Application.recruitment_id == recruitment_id) + total_offers = base.filter(Application.status == TalentStatus.OFFER).count() + accepted = base.filter( + Application.status == TalentStatus.OFFER, + Application.sub_stage == "accepted", + ).count() + return { + "recruitment_id": recruitment_id, + "total_offers": total_offers, + "accepted": accepted, + } diff --git a/src/provider/app/human/services/material_service.py b/src/provider/app/human/services/material_service.py new file mode 100644 index 0000000..3fc9d9f --- /dev/null +++ b/src/provider/app/human/services/material_service.py @@ -0,0 +1,92 @@ +"""材料生成服务 — 从邮件原始数据生成结构化材料产物。""" + +import json +import os + +from sqlalchemy.orm import Session + +from app.human.models.material import MaterialArtifact + + +def write_material_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + artifact_type: str, + content_json: str | None = None, + file_path: str | None = None, +) -> MaterialArtifact: + artifact = MaterialArtifact( + queue_item_id=queue_item_id, + candidate_id=candidate_id, + artifact_type=artifact_type, + content_json=content_json, + file_path=file_path, + ) + db.add(artifact) + db.flush() + return artifact + + +def generate_body_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + body: str | None, + body_text: str | None, + materials_dir: str = "", +) -> MaterialArtifact | None: + if not body and not body_text: + return None + + content = {"body_html": body or "", "body_text": body_text or ""} + content_str = json.dumps(content, ensure_ascii=False) + + file_path = None + if materials_dir: + artifact_dir = os.path.join(materials_dir, str(queue_item_id)) + os.makedirs(artifact_dir, exist_ok=True) + fp = os.path.join(artifact_dir, "body.json") + with open(fp, "w", encoding="utf-8") as f: + f.write(content_str) + file_path = fp + + return write_material_artifact( + db=db, queue_item_id=queue_item_id, candidate_id=candidate_id, + artifact_type="body_text", content_json=content_str, file_path=file_path, + ) + + +def generate_attachment_artifact( + db: Session, + queue_item_id: int | None, + candidate_id: int | None, + attachments: list[dict] | None, + materials_dir: str = "", +) -> MaterialArtifact | None: + if not attachments: + return None + + content_str = json.dumps(attachments, ensure_ascii=False) + + file_path = None + if materials_dir: + artifact_dir = os.path.join(materials_dir, str(queue_item_id)) + os.makedirs(artifact_dir, exist_ok=True) + fp = os.path.join(artifact_dir, "attachments.json") + with open(fp, "w", encoding="utf-8") as f: + f.write(content_str) + file_path = fp + + return write_material_artifact( + db=db, queue_item_id=queue_item_id, candidate_id=candidate_id, + artifact_type="attachment_meta", content_json=content_str, file_path=file_path, + ) + + +def get_artifacts_by_queue(db: Session, queue_id: int) -> list[MaterialArtifact]: + return db.query(MaterialArtifact).filter(MaterialArtifact.queue_item_id == queue_id).all() + + +def get_artifacts_by_candidate(db: Session, candidate_id: int) -> list[MaterialArtifact]: + return db.query(MaterialArtifact).filter(MaterialArtifact.candidate_id == candidate_id).all() diff --git a/src/provider/app/human/services/pipeline.py b/src/provider/app/human/services/pipeline.py new file mode 100644 index 0000000..9d11833 --- /dev/null +++ b/src/provider/app/human/services/pipeline.py @@ -0,0 +1,43 @@ +"""Pipeline aggregation service.""" +from sqlalchemy.orm import Session + +from app.human.models.talent import Talent, TalentStatus + + +def get_pipeline(db: Session) -> dict: + stages = {} + total = 0 + for status in TalentStatus: + talents = ( + db.query(Talent) + .filter(Talent.status == status) + .order_by(Talent.updated_at.desc()) + .all() + ) + stages[status.value] = [_talent_to_card(t) for t in talents] + total += len(talents) + + need_attention = len(stages.get("exam_received", [])) + len(stages.get("evaluating", [])) + return { + "stages": stages, + "summary": { + "total": total, + "by_stage": {s.value: len(stages.get(s.value, [])) for s in TalentStatus}, + "need_attention": need_attention, + }, + } + + +def _talent_to_card(t: Talent) -> dict: + return { + "id": t.id, + "email": t.email, + "real_name": t.real_name, + "recruitment_id": t.recruitment_id, + "status": t.status.value, + "sub_stage": t.sub_stage, + "quality": t.quality, + "stage_results": t.stage_results, + "created_at": t.created_at.isoformat() if t.created_at else "", + "updated_at": t.updated_at.isoformat() if t.updated_at else "", + } diff --git a/src/provider/app/human/services/pool.py b/src/provider/app/human/services/pool.py new file mode 100644 index 0000000..f8a0d82 --- /dev/null +++ b/src/provider/app/human/services/pool.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone + +from sqlalchemy.orm import Session, joinedload + +from app.human.models.application import Application +from app.human.models.talent import TalentStatus + + +def pool_application(db: Session, application_id: int) -> Application | None: + app = db.query(Application).filter(Application.id == application_id).first() + if not app: + return None + if app.pooled_at is not None: + return app + now = datetime.now(timezone.utc) + app.pooled_at = now + app.deactivated_at = now + app.status = TalentStatus.CLOSED + app.sub_stage = None + db.commit() + db.refresh(app) + return app + + +def unpool_application(db: Session, application_id: int, recruitment_id: int) -> Application | None: + original = db.query(Application).filter(Application.id == application_id).first() + if not original: + return None + new_app = Application( + candidate_id=original.candidate_id, + recruitment_id=recruitment_id, + source=original.source, + ) + db.add(new_app) + db.commit() + db.refresh(new_app) + return new_app + + +def get_pooled_applications(db: Session, skip: int = 0, limit: int = 100) -> list[Application]: + return ( + db.query(Application) + .options(joinedload(Application.candidate)) + .filter(Application.pooled_at.isnot(None)) + .order_by(Application.pooled_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) diff --git a/src/provider/app/human/services/resume_parser.py b/src/provider/app/human/services/resume_parser.py new file mode 100644 index 0000000..b3f1ad9 --- /dev/null +++ b/src/provider/app/human/services/resume_parser.py @@ -0,0 +1,105 @@ +import re + +from dataclasses import dataclass, field + + +@dataclass +class ParseResult: + name: str | None = None + phone: str | None = None + email: str | None = None + education: list[dict] = field(default_factory=list) + experience: list[dict] = field(default_factory=list) + raw_text: str | None = None + + +class ResumeParser: + """Interface for resume parsing. Override `parse` to implement actual parsing.""" + + def parse(self, file_path: str) -> ParseResult: + raise NotImplementedError + + +class NoopResumeParser(ResumeParser): + """Placeholder parser that returns an empty result.""" + + def parse(self, file_path: str) -> ParseResult: + return ParseResult() + + +class PdfPlumberResumeParser(ResumeParser): + """PDF resume parser using pdfplumber. + + Extracts text from text-based PDFs and applies regex patterns to + extract structured fields (name, phone, email, education, experience). + """ + + _PHONE_RE = re.compile(r"1[3-9]\d{9}") + _EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+") + _NAME_RE = re.compile(r"姓名[::]\s*(\S+)") + _EDU_KEYWORDS = ("大学", "学院", "本科", "硕士", "博士", "毕业", "专业", "学位") + _EXP_KEYWORDS = ("公司", "任职", "担任", "工作经历", "工作") + + def parse(self, file_path: str) -> ParseResult: + try: + import pdfplumber + + with pdfplumber.open(file_path) as pdf: + raw_text = "\n".join( + page.extract_text() or "" for page in pdf.pages + ) + except Exception as exc: + import logging + + logging.warning("PdfPlumberResumeParser: failed to parse %s: %s", file_path, exc) + return ParseResult(raw_text=None) + + if not raw_text.strip(): + return ParseResult(raw_text=None) + + name = self._extract_name(raw_text) + phone = self._extract_phone(raw_text) + email = self._extract_email(raw_text) + education = self._extract_education(raw_text) + experience = self._extract_experience(raw_text) + + return ParseResult( + name=name, + phone=phone, + email=email, + education=education, + experience=experience, + raw_text=raw_text, + ) + + def _extract_name(self, text: str) -> str | None: + m = self._NAME_RE.search(text) + if m: + return m.group(1) + return None + + def _extract_phone(self, text: str) -> str | None: + m = self._PHONE_RE.search(text) + return m.group(0) if m else None + + def _extract_email(self, text: str) -> str | None: + m = self._EMAIL_RE.search(text) + return m.group(0) if m else None + + def _extract_education(self, text: str) -> list[dict]: + lines = text.split("\n") + items = [] + for line in lines: + line = line.strip() + if any(kw in line for kw in self._EDU_KEYWORDS): + items.append({"raw": line}) + return items + + def _extract_experience(self, text: str) -> list[dict]: + lines = text.split("\n") + items = [] + for line in lines: + line = line.strip() + if any(kw in line for kw in self._EXP_KEYWORDS): + items.append({"raw": line}) + return items diff --git a/src/provider/app/human/services/transition.py b/src/provider/app/human/services/transition.py new file mode 100644 index 0000000..c27e6a9 --- /dev/null +++ b/src/provider/app/human/services/transition.py @@ -0,0 +1,42 @@ +from app.human.models.application import Application +from app.human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus + + +def transition_application( + app: Application, + target: TalentStatus, + sub_stage: str | None = None, +) -> Application: + """Transition an Application to a new status. + + Pure Application logic — no Talent awareness. + Caller is responsible for syncing Talent separately. + """ + if target not in STATUS_TRANSITIONS.get(app.status, []): + raise ValueError(f"Cannot transition from {app.status.value} to {target.value}") + + old_status = app.status + app.status = target + + if target != old_status: + app.sub_stage = None + + if sub_stage is not None and target in ALLOWED_STATUSES_FOR_SUB_STAGE: + app.sub_stage = sub_stage + + stage_key = old_status.value + if stage_key in ("contacted", "evaluating", "interview", "offer"): + if not (stage_key == "evaluating" and target.value == "exam_sent"): + if app.stage_results is None: + app.stage_results = {} + app.stage_results[stage_key] = "pass" if target.value != "closed" else "fail" + + return app + + +def sync_talent_from_application(talent: Talent, app: Application) -> None: + """Copy derived state fields from Application to an existing Talent.""" + talent.status = app.status + talent.sub_stage = app.sub_stage + talent.quality = app.quality + talent.stage_results = app.stage_results diff --git a/src/provider/pyproject.toml b/src/provider/pyproject.toml index 9363aba..69f65de 100644 --- a/src/provider/pyproject.toml +++ b/src/provider/pyproject.toml @@ -7,4 +7,9 @@ requires-python = ">=3.12" dependencies = [ "fastapi>=0.136.1", "uvicorn[standard]>=0.46.0", + "sqlalchemy>=2.0.0", + "pydantic>=2.0.0", ] + +[tool.setuptools.packages.find] +include = ["app*"] diff --git a/src/provider/run.sh b/src/provider/run.sh new file mode 100644 index 0000000..9585741 --- /dev/null +++ b/src/provider/run.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd /home/linli/桌面/qt-hr/qtadmin/src/provider +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8000 diff --git a/tests/human/conftest.py b/tests/human/conftest.py new file mode 100644 index 0000000..ef17c47 --- /dev/null +++ b/tests/human/conftest.py @@ -0,0 +1,102 @@ +import os +import tempfile +from collections.abc import Generator +from datetime import datetime, timezone + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.human.database import Base, get_db +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.recruitment import Recruitment +from app.human.models.talent import Talent, TalentStatus +from app.human.routers import candidates, ingest, pipeline, pool, queue, recruitments, applications + + +@pytest.fixture +def db() -> Generator[Session, None, None]: + """Create a temporary SQLite database for testing.""" + db_fd, db_path = tempfile.mkstemp(suffix=".db") + os.close(db_fd) + + engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + import app.human.models # noqa: F401 + Base.metadata.create_all(bind=engine) + + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + os.unlink(db_path) + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(ingest.router) + app.include_router(queue.router) + app.include_router(pipeline.router) + app.include_router(pool.router) + app.include_router(recruitments.router) + app.include_router(candidates.router) + app.include_router(applications.router) + return app + + +@pytest.fixture +def client(db: Session) -> Generator[TestClient, None, None]: + """FastAPI TestClient with all HR routers using temp DB.""" + app = _build_app() + app.dependency_overrides[get_db] = lambda: db + with TestClient(app) as c: + yield c + + +@pytest.fixture +def seeded_db(db: Session) -> Session: + """Pre-seed DB with a recruitment, two candidates, two applications, two talents.""" + r = Recruitment() + db.add(r) + db.flush() + + c1 = Candidate(email="test1@test.com", real_name="测试一号") + c2 = Candidate(email="test2@test.com", real_name="测试二号") + db.add(c1) + db.add(c2) + db.flush() + + a1 = Application( + candidate_id=c1.id, recruitment_id=r.id, + status=TalentStatus.INTERVIEW, source="test_seed", + ) + a2 = Application( + candidate_id=c2.id, recruitment_id=r.id, + status=TalentStatus.CLOSED, source="test_seed", + pooled_at=datetime.now(timezone.utc), + deactivated_at=datetime.now(timezone.utc), + ) + db.add(a1) + db.add(a2) + db.flush() + + t1 = Talent(recruitment_id=r.id, email="test1@test.com", real_name="测试一号", status=TalentStatus.INTERVIEW) + t2 = Talent(recruitment_id=r.id, email="test2@test.com", real_name="测试二号", status=TalentStatus.CLOSED) + db.add(t1) + db.add(t2) + db.commit() + return db + + +@pytest.fixture +def seeded_client(seeded_db: Session) -> Generator[TestClient, None, None]: + """Client with pre-seeded data (recruitment, candidates, applications, talents).""" + app = _build_app() + app.dependency_overrides[get_db] = lambda: seeded_db + with TestClient(app) as c: + yield c diff --git a/tests/human/test_api.py b/tests/human/test_api.py new file mode 100644 index 0000000..16fd3ac --- /dev/null +++ b/tests/human/test_api.py @@ -0,0 +1,395 @@ +"""Integration tests for all HR API endpoints.""" + + +class TestRecruitmentAPI: + def test_create(self, client): + resp = client.post("/recruitments") + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert "created_at" in data + + def test_list(self, client): + client.post("/recruitments") + client.post("/recruitments") + resp = client.get("/recruitments") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_get(self, client): + created = client.post("/recruitments").json() + resp = client.get(f"/recruitments/{created['id']}") + assert resp.status_code == 200 + assert resp.json()["id"] == created["id"] + + def test_get_not_found(self, client): + resp = client.get("/recruitments/999") + assert resp.status_code == 404 + + def test_delete(self, client): + created = client.post("/recruitments").json() + resp = client.delete(f"/recruitments/{created['id']}") + assert resp.status_code == 204 + assert client.get(f"/recruitments/{created['id']}").status_code == 404 + + def test_delete_not_found(self, client): + resp = client.delete("/recruitments/999") + assert resp.status_code == 404 + + +class TestTalentAPI: + def test_create(self, client): + r_id = client.post("/recruitments").json()["id"] + resp = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }) + assert resp.status_code == 201 + data = resp.json() + assert data["email"] == "a@b.com" + assert data["status"] == "new" + + def test_create_no_recruitment(self, client): + resp = client.post("/recruitments/999/talents", json={ + "email": "a@b.com", "real_name": "测试", + }) + assert resp.status_code == 404 + + def test_list(self, client): + r_id = client.post("/recruitments").json()["id"] + client.post(f"/recruitments/{r_id}/talents", json={"email": "a@b.com", "real_name": "A"}) + client.post(f"/recruitments/{r_id}/talents", json={"email": "b@b.com", "real_name": "B"}) + resp = client.get(f"/recruitments/{r_id}/talents") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_list_filter_by_status(self, client): + r_id = client.post("/recruitments").json()["id"] + client.post(f"/recruitments/{r_id}/talents", json={"email": "a@b.com", "real_name": "A"}) + resp = client.get(f"/recruitments/{r_id}/talents", params={"status": "new"}) + assert len(resp.json()) == 1 + resp = client.get(f"/recruitments/{r_id}/talents", params={"status": "closed"}) + assert len(resp.json()) == 0 + + def test_get(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.get(f"/recruitments/{r_id}/talents/{t_id}") + assert resp.status_code == 200 + assert resp.json()["real_name"] == "测试" + + def test_get_not_found(self, client): + resp = client.get("/recruitments/999/talents/999") + assert resp.status_code == 404 + + def test_update(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.patch(f"/recruitments/{r_id}/talents/{t_id}", json={"real_name": "新名字"}) + assert resp.status_code == 200 + assert resp.json()["real_name"] == "新名字" + + def test_delete(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.delete(f"/recruitments/{r_id}/talents/{t_id}") + assert resp.status_code == 204 + + +class TestTransitionAPI: + def test_new_to_contacted(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={ + "status": "contacted", + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "contacted" + + def test_contacted_to_exam_sent(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={"status": "contacted"}) + resp = client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={"status": "exam_sent"}) + assert resp.status_code == 200 + assert resp.json()["status"] == "exam_sent" + + def test_invalid_transition_returns_400(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={ + "status": "offer", + }) + assert resp.status_code == 400 + + def test_transition_with_sub_stage(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={ + "status": "contacted", "sub_stage": "resume_passed", + }) + assert resp.status_code == 200 + assert resp.json()["sub_stage"] == "resume_passed" + + +class TestSubStageAPI: + def test_set_sub_stage(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + client.post(f"/recruitments/{r_id}/talents/{t_id}/transition", json={"status": "contacted"}) + resp = client.patch(f"/recruitments/{r_id}/talents/{t_id}/sub-stage", json={ + "sub_stage": "phone_interview", + }) + assert resp.status_code == 200 + assert resp.json()["sub_stage"] == "phone_interview" + + def test_sub_stage_on_new_fails(self, client): + r_id = client.post("/recruitments").json()["id"] + t_id = client.post(f"/recruitments/{r_id}/talents", json={ + "email": "a@b.com", "real_name": "测试", + }).json()["id"] + resp = client.patch(f"/recruitments/{r_id}/talents/{t_id}/sub-stage", json={ + "sub_stage": "anything", + }) + assert resp.status_code == 400 + + +class TestPipelineAPI: + def test_empty_pipeline(self, client): + resp = client.get("/pipeline") + assert resp.status_code == 200 + data = resp.json() + assert "stages" in data + assert "summary" in data + assert data["summary"]["total"] == 0 + + def test_pipeline_with_talents(self, seeded_client): + resp = seeded_client.get("/pipeline") + assert resp.status_code == 200 + data = resp.json() + assert data["summary"]["total"] == 2 + assert data["summary"]["by_stage"]["interview"] == 1 + assert data["summary"]["by_stage"]["closed"] == 1 + + def test_pipeline_stages_structure(self, seeded_client): + resp = seeded_client.get("/pipeline") + data = resp.json() + for stage in ("new", "contacted", "exam_sent", "exam_received", + "evaluating", "interview", "offer", "closed"): + assert stage in data["stages"] + + +class TestIngestAPI: + def test_ingest_items(self, client): + resp = client.post("/ingest", json={ + "source": "test", + "items": [ + { + "message_id": "m1", "subject": "求职前端", + "sender_name": "张三", "sender_email": "zs@test.com", + "suggested_status": "contacted", "confidence": "high", + }, + ], + }) + assert resp.status_code == 201 + data = resp.json() + assert data["queued"] == 1 + assert data["skipped"] == 0 + + def test_ingest_duplicate_skipped(self, client): + payload = { + "source": "test", + "items": [{ + "message_id": "dup1", "subject": "重复", + "sender_name": "李四", "sender_email": "ls@test.com", + }], + } + client.post("/ingest", json=payload) + resp = client.post("/ingest", json=payload) + assert resp.json()["skipped"] == 1 + assert resp.json()["queued"] == 0 + + def test_ingest_multiple(self, client): + resp = client.post("/ingest", json={ + "source": "test", + "items": [ + {"message_id": "a", "subject": "S1", "sender_email": "a@t.com"}, + {"message_id": "b", "subject": "S2", "sender_email": "b@t.com"}, + ], + }) + assert resp.json()["queued"] == 2 + + +class TestQueueAPI: + def test_list_empty(self, client): + resp = client.get("/queue") + assert resp.status_code == 200 + assert resp.json()["total"] == 0 + assert resp.json()["items"] == [] + + def test_list_with_items(self, client): + client.post("/ingest", json={ + "source": "test", + "items": [{"message_id": "q1", "subject": "测试", "sender_email": "t@t.com"}], + }) + resp = client.get("/queue") + assert resp.json()["total"] == 1 + + def test_confirm_queue_item(self, client): + client.post("/ingest", json={ + "source": "test", + "items": [{ + "message_id": "cq1", "subject": "确认测试", + "sender_name": "王五", "sender_email": "ww@test.com", + "suggested_status": "contacted", + }], + }) + queue_resp = client.get("/queue") + qid = queue_resp.json()["items"][0]["queue_id"] + resp = client.patch(f"/queue/{qid}/confirm", json={}) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "confirmed" + assert data["talent_id"] is not None + + def test_ignore_queue_item(self, client): + client.post("/ingest", json={ + "source": "test", + "items": [{"message_id": "iq1", "subject": "忽略测试", "sender_email": "ig@t.com"}], + }) + qid = client.get("/queue").json()["items"][0]["queue_id"] + resp = client.patch(f"/queue/{qid}/ignore", json={}) + assert resp.status_code == 200 + assert resp.json()["action"] == "ignored" + + def test_confirm_not_found(self, client): + resp = client.patch("/queue/999/confirm", json={}) + assert resp.status_code == 404 + + def test_queue_stats(self, client): + resp = client.get("/queue/stats") + assert resp.status_code == 200 + data = resp.json() + assert "pending" in data + + +class TestPoolAPI: + def test_list_pool_empty(self, client): + resp = client.get("/pool") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_pool_with_data(self, seeded_client): + resp = seeded_client.get("/pool") + assert resp.status_code == 200 + items = resp.json() + assert len(items) >= 1 + + def test_pool_application(self, seeded_client): + apps = seeded_client.get("/applications", params={"pooled": False}).json() + active_apps = [a for a in apps if a.get("pooled_at") is None] + if active_apps: + app_id = active_apps[0]["id"] + resp = seeded_client.post(f"/applications/{app_id}/pool") + assert resp.status_code == 200 + assert resp.json()["pooled_at"] is not None + + def test_pool_twice(self, seeded_client): + apps = seeded_client.get("/applications", params={"pooled": False}).json() + active_apps = [a for a in apps if a.get("pooled_at") is None] + if active_apps: + app_id = active_apps[0]["id"] + seeded_client.post(f"/applications/{app_id}/pool") + resp = seeded_client.post(f"/applications/{app_id}/pool") + assert resp.status_code == 200 + + def test_pool_not_found(self, client): + resp = client.post("/applications/999/pool") + assert resp.status_code == 404 + + def test_unpool_application(self, seeded_client): + pooled = seeded_client.get("/pool").json() + if pooled: + app_id = pooled[0]["id"] + r_id = pooled[0]["recruitment_id"] + resp = seeded_client.post(f"/applications/{app_id}/unpool", json={ + "recruitment_id": r_id, + }) + assert resp.status_code == 201 + assert resp.json()["pooled_at"] is None + + def test_unpool_not_pooled(self, seeded_client): + apps = seeded_client.get("/applications", params={"pooled": False}).json() + active_apps = [a for a in apps if a.get("pooled_at") is None] + if active_apps: + app_id = active_apps[0]["id"] + resp = seeded_client.post(f"/applications/{app_id}/unpool", json={ + "recruitment_id": 1, + }) + assert resp.status_code == 400 + + +class TestApplicationAPI: + def test_list(self, seeded_client): + resp = seeded_client.get("/applications") + assert resp.status_code == 200 + assert len(resp.json()) >= 2 + + def test_list_filter_by_status(self, seeded_client): + resp = seeded_client.get("/applications", params={"status": "interview"}) + assert all(a["status"] == "interview" for a in resp.json()) + + def test_list_filter_pooled(self, seeded_client): + pooled = seeded_client.get("/applications", params={"pooled": True}).json() + assert all(a["pooled_at"] is not None for a in pooled) + + def test_list_filter_not_pooled(self, seeded_client): + active = seeded_client.get("/applications", params={"pooled": False}).json() + assert all(a["pooled_at"] is None for a in active) + + +class TestCandidateAPI: + def test_list(self, seeded_client): + resp = seeded_client.get("/candidates") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + + def test_applications(self, seeded_client): + candidates = seeded_client.get("/candidates").json() + cid = candidates[0]["id"] + resp = seeded_client.get(f"/candidates/{cid}/applications") + assert resp.status_code == 200 + assert len(resp.json()) >= 1 + + def test_applications_not_found(self, client): + resp = client.get("/candidates/999/applications") + assert resp.status_code == 404 + + +class TestHeadcountAPI: + def test_headcount(self, seeded_client): + r_id = seeded_client.get("/recruitments").json()[0]["id"] + resp = seeded_client.get(f"/recruitments/{r_id}/headcount") + assert resp.status_code == 200 + data = resp.json() + assert "total_offers" in data + assert "accepted" in data + + def test_headcount_not_found(self, client): + resp = client.get("/recruitments/999/headcount") + assert resp.status_code == 404 diff --git a/tests/human/test_models.py b/tests/human/test_models.py new file mode 100644 index 0000000..6d049a8 --- /dev/null +++ b/tests/human/test_models.py @@ -0,0 +1,190 @@ +"""Tests for HR domain models and enums.""" +import pytest +from sqlalchemy import text + +from app.human.models.talent import ALLOWED_STATUSES_FOR_SUB_STAGE, STATUS_TRANSITIONS, Talent, TalentStatus +from app.human.models.recruitment import Recruitment +from app.human.models.candidate import Candidate +from app.human.models.application import Application +from app.human.models.pending_queue import PendingQueueItem + + +class TestTalentStatus: + def test_all_values(self): + assert [s.value for s in TalentStatus] == [ + "new", "contacted", "exam_sent", "exam_received", + "evaluating", "interview", "offer", "closed", + ] + + def test_str_values(self): + assert TalentStatus.NEW.value == "new" + assert TalentStatus.CONTACTED.value == "contacted" + + def test_all_keys_in_transitions(self): + for s in TalentStatus: + assert s in STATUS_TRANSITIONS, f"{s} missing from STATUS_TRANSITIONS" + + +class TestStatusTransitions: + def test_new_can_contacted(self): + assert TalentStatus.CONTACTED in STATUS_TRANSITIONS[TalentStatus.NEW] + + def test_new_can_close(self): + assert TalentStatus.CLOSED in STATUS_TRANSITIONS[TalentStatus.NEW] + + def test_new_cannot_exam_sent(self): + assert TalentStatus.EXAM_SENT not in STATUS_TRANSITIONS[TalentStatus.NEW] + + def test_contacted_can_exam_sent(self): + assert TalentStatus.EXAM_SENT in STATUS_TRANSITIONS[TalentStatus.CONTACTED] + + def test_contacted_can_close(self): + assert TalentStatus.CLOSED in STATUS_TRANSITIONS[TalentStatus.CONTACTED] + + def test_evaluating_can_return_exam_sent(self): + assert TalentStatus.EXAM_SENT in STATUS_TRANSITIONS[TalentStatus.EVALUATING] + + def test_evaluating_can_interview(self): + assert TalentStatus.INTERVIEW in STATUS_TRANSITIONS[TalentStatus.EVALUATING] + + def test_offer_only_closed(self): + assert STATUS_TRANSITIONS[TalentStatus.OFFER] == [TalentStatus.CLOSED] + + def test_closed_no_transitions(self): + assert STATUS_TRANSITIONS[TalentStatus.CLOSED] == [] + + def test_invalid_transition_new_to_offer(self): + assert TalentStatus.OFFER not in STATUS_TRANSITIONS[TalentStatus.NEW] + + +class TestAllowedSubStages: + def test_contacted_allowed(self): + assert TalentStatus.CONTACTED in ALLOWED_STATUSES_FOR_SUB_STAGE + + def test_new_not_allowed(self): + assert TalentStatus.NEW not in ALLOWED_STATUSES_FOR_SUB_STAGE + + def test_closed_not_allowed(self): + assert TalentStatus.CLOSED not in ALLOWED_STATUSES_FOR_SUB_STAGE + + +class TestRecruitmentModel: + def test_create_and_read(self, db): + r = Recruitment() + db.add(r) + db.commit() + assert r.id is not None + assert r.created_at is not None + + def test_list(self, db): + for _ in range(3): + db.add(Recruitment()) + db.commit() + rows = db.query(Recruitment).all() + assert len(rows) == 3 + + +class TestTalentModel: + def test_create_minimal(self, db): + r = Recruitment() + db.add(r) + db.flush() + t = Talent(recruitment_id=r.id, email="a@b.com", real_name="测试") + db.add(t) + db.commit() + assert t.id is not None + assert t.status == TalentStatus.NEW + + def test_default_quality(self, db): + r = Recruitment() + db.add(r) + db.flush() + t = Talent(recruitment_id=r.id, email="a@b.com", real_name="测试") + db.add(t) + db.commit() + assert t.quality == "normal" + + def test_sub_stage(self, db): + r = Recruitment() + db.add(r) + db.flush() + t = Talent(recruitment_id=r.id, email="a@b.com", real_name="测试") + t.sub_stage = "resume_passed" + db.add(t) + db.commit() + assert t.sub_stage == "resume_passed" + + +class TestCandidateModel: + def test_create(self, db): + c = Candidate(email="c@d.com", real_name="候选人") + db.add(c) + db.commit() + assert c.id is not None + assert c.phone is None + + def test_email_unique_not_enforced_by_model(self, db): + """Model itself doesn't enforce email uniqueness; that's app-level.""" + db.add(Candidate(email="dup@test.com", real_name="A")) + db.add(Candidate(email="dup@test.com", real_name="B")) + db.commit() + assert db.query(Candidate).count() == 2 + + +class TestApplicationModel: + def test_create(self, db): + r = Recruitment() + db.add(r) + db.flush() + c = Candidate(email="a@b.com", real_name="测试") + db.add(c) + db.flush() + a = Application(candidate_id=c.id, recruitment_id=r.id) + db.add(a) + db.commit() + assert a.id is not None + assert a.status == TalentStatus.NEW + assert a.source == "manual" + + def test_candidate_relationship(self, db): + r = Recruitment() + db.add(r) + db.flush() + c = Candidate(email="rel@test.com", real_name="关系测试") + db.add(c) + db.flush() + a = Application(candidate_id=c.id, recruitment_id=r.id) + db.add(a) + db.commit() + db.refresh(a) + assert a.candidate.email == "rel@test.com" + assert a.candidate.real_name == "关系测试" + + +class TestPendingQueueItemModel: + def test_create(self, db): + qi = PendingQueueItem( + message_id="msg_001", + subject="求职简历", + sender_name="张三", + sender_email="zhangsan@test.com", + suggested_status="contacted", + confidence="high", + ) + db.add(qi) + db.commit() + assert qi.id is not None + assert qi.hr_status == "pending" + + def test_unique_message_id(self, db): + db.add(PendingQueueItem(message_id="unique_1", subject="S1", sender_email="a@b.com")) + db.commit() + db.add(PendingQueueItem(message_id="unique_1", subject="S2", sender_email="a@b.com")) + with pytest.raises(Exception): + db.commit() + + def test_default_confidence(self, db): + qi = PendingQueueItem(message_id="msg_dc", subject="S1", sender_email="a@b.com") + db.add(qi) + db.commit() + assert qi.confidence == "low" diff --git a/tests/human/test_schemas.py b/tests/human/test_schemas.py new file mode 100644 index 0000000..a6c1fd0 --- /dev/null +++ b/tests/human/test_schemas.py @@ -0,0 +1,198 @@ +"""Tests for Pydantic schemas.""" +import pytest +from pydantic import ValidationError + +from app.human.models.talent import TalentStatus +from app.human.schemas.talent import ( + TalentCreate, TalentRead, TalentTransition, TalentUpdate, SubStageUpdate, +) +from app.human.schemas.recruitment import RecruitmentRead, HeadcountRead +from app.human.schemas.candidate import CandidateRead +from app.human.schemas.application import ApplicationRead, PoolItemRead, UnpoolRequest +from app.human.schemas.pending_queue import ConfirmRequest, ConfirmResponse, IgnoreRequest + + +class TestTalentCreate: + def test_valid(self): + s = TalentCreate(email="a@b.com", real_name="测试") + assert s.email == "a@b.com" + assert s.real_name == "测试" + assert s.auto_screening_result is None + + def test_missing_email(self): + with pytest.raises(ValidationError): + TalentCreate(real_name="测试") + + def test_missing_real_name(self): + with pytest.raises(ValidationError): + TalentCreate(email="a@b.com") + + +class TestTalentUpdate: + def test_valid_partial(self): + s = TalentUpdate(email="new@b.com") + assert s.email == "new@b.com" + assert s.real_name is None + + def test_extra_field_forbidden(self): + with pytest.raises(ValidationError): + TalentUpdate(invalid_field="x") + + def test_empty(self): + s = TalentUpdate() + assert s.model_dump(exclude_unset=True) == {} + + +class TestTalentTransition: + def test_valid(self): + s = TalentTransition(status=TalentStatus.CONTACTED) + assert s.status == TalentStatus.CONTACTED + + def test_with_sub_stage(self): + s = TalentTransition(status=TalentStatus.CONTACTED, sub_stage="resume_passed") + assert s.sub_stage == "resume_passed" + + def test_invalid_status(self): + with pytest.raises(ValidationError): + TalentTransition(status="invalid_status") + + +class TestSubStageUpdate: + def test_none(self): + s = SubStageUpdate() + assert s.sub_stage is None + + def test_with_value(self): + s = SubStageUpdate(sub_stage="interview_passed") + assert s.sub_stage == "interview_passed" + + +class TestTalentRead: + def test_from_attributes(self, db): + from app.human.models.recruitment import Recruitment + from app.human.models.talent import Talent + + r = Recruitment() + db.add(r) + db.flush() + t = Talent(recruitment_id=r.id, email="a@b.com", real_name="测试") + db.add(t) + db.commit() + + schema = TalentRead.model_validate(t) + assert schema.id == t.id + assert schema.email == "a@b.com" + assert schema.real_name == "测试" + assert schema.status == TalentStatus.NEW + assert schema.quality == "normal" + + +class TestRecruitmentRead: + def test_from_attributes(self, db): + from app.human.models.recruitment import Recruitment + + r = Recruitment() + db.add(r) + db.commit() + + schema = RecruitmentRead.model_validate(r) + assert schema.id == r.id + + +class TestHeadcountRead: + def test_create(self): + s = HeadcountRead(recruitment_id=1, total_offers=5, accepted=3) + assert s.total_offers == 5 + assert s.accepted == 3 + + +class TestCandidateRead: + def test_from_attributes(self, db): + from app.human.models.candidate import Candidate + + c = Candidate(email="c@d.com", real_name="候选人") + db.add(c) + db.commit() + + schema = CandidateRead.model_validate(c) + assert schema.email == "c@d.com" + assert schema.real_name == "候选人" + assert schema.phone is None + + +class TestApplicationRead: + def test_from_attributes(self, db): + from app.human.models.recruitment import Recruitment + from app.human.models.candidate import Candidate + from app.human.models.application import Application + + r = Recruitment() + db.add(r) + db.flush() + c = Candidate(email="a@b.com", real_name="测试") + db.add(c) + db.flush() + a = Application(candidate_id=c.id, recruitment_id=r.id) + db.add(a) + db.commit() + + schema = ApplicationRead.model_validate(a) + assert schema.id == a.id + assert schema.source == "manual" + + +class TestPoolItemRead: + def test_from_attributes(self, db): + from app.human.models.recruitment import Recruitment + from app.human.models.candidate import Candidate + from app.human.models.application import Application + + r = Recruitment() + db.add(r) + db.flush() + c = Candidate(email="pool@test.com", real_name="人才池") + db.add(c) + db.flush() + a = Application(candidate_id=c.id, recruitment_id=r.id) + db.add(a) + db.commit() + + schema = PoolItemRead.model_validate(a) + assert schema.candidate_email == "" + assert schema.candidate_name == "" + + +class TestUnpoolRequest: + def test_valid(self): + s = UnpoolRequest(recruitment_id=1) + assert s.recruitment_id == 1 + + def test_zero_id_invalid(self): + with pytest.raises(ValidationError): + UnpoolRequest(recruitment_id=0) + + +class TestConfirmRequest: + def test_defaults(self): + s = ConfirmRequest() + assert s.action == "confirmed" + assert s.status == "contacted" + assert s.real_name == "" + assert s.email == "" + + +class TestConfirmResponse: + def test_create(self): + s = ConfirmResponse(queue_id=1, action="confirmed", talent_id=42) + assert s.queue_id == 1 + assert s.talent_id == 42 + + def test_optional_talent_id(self): + s = ConfirmResponse(queue_id=1, action="confirmed") + assert s.talent_id is None + + +class TestIgnoreRequest: + def test_default(self): + s = IgnoreRequest() + assert s.action == "ignored"