diff --git a/.gitignore b/.gitignore index be0fe38..c88d2d4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,19 @@ data/ .DS_Store Thumbs.db +# Database & backup +*.db +*.bak + +# Flutter +.gradle/ +*.iml +.metadata +src/studio/build/ +src/studio/.dart_tool/ +src/hr-kanban/.dart_tool/ +src/hr-kanban/*.log + # Terraform .terraform/ terraform/terraform.tfstate diff --git a/CHANGELOG-human.md b/CHANGELOG-human.md new file mode 100644 index 0000000..37fe5b5 --- /dev/null +++ b/CHANGELOG-human.md @@ -0,0 +1,44 @@ +# Human Module Changelog + +> QtCloud HR — Human resources module changelog. + +--- + +## v0.10.0 — Flutter Kanban UI: Full Pipeline Management (2026-06-16) + +### Added + +- **Main Shell** (`src/hr-kanban/lib/main.dart`) + - Responsive layout: NavigationRail (desktop) / NavigationBar (mobile) + - 4-tab navigation: Pipeline, Queue, Pool, Settings + - IndexedStack for tab state preservation +- **Pipeline Screen** (`src/hr-kanban/lib/screens/pipeline_screen.dart`) + - Kanban-style pipeline grouped by 8 TalentStatus columns + - Candidate cards with name, email, sub-stage, wait-day badges + - Wait-day color coding: yellow (7+), orange (14+) + - Drag-and-drop status transitions + - Candidate detail bottom sheet (email, attachments, timeline) + - Real-time search by name or email +- **Queue Screen** (`src/hr-kanban/lib/screens/queue_screen.dart`) + - Pending email queue sorted by time descending + - Confidence badges: high (green), medium (yellow), low (gray) + - Confirm/adjust/ignore actions + - Recruitment title assignment on confirm +- **Pool Screen** (`src/hr-kanban/lib/screens/pool_screen.dart`) + - Filter by recruitment project + - Unpool with recruitment reassignment +- **Settings Screen** (`src/hr-kanban/lib/screens/settings_screen.dart`) + - AI config form: provider, model, API key (obscured), temperature slider, prompt template + - Server URL field with instant save + - Connection test with loading state +- **API Service** (`src/hr-kanban/lib/services/api_service.dart`) + - Full REST client: pipeline, queue, applications, candidates, messages, pool, AI config + - Mutable baseUrl for runtime server switching +- **Theme System** (`src/hr-kanban/lib/theme/hr_theme.dart`) + - HrThemeExtension: 8 status colors, spacing tokens, font tokens + - Dark theme with ColorScheme.dark +- **Widgets**: StatusBadge, EmptyState, ErrorView, InfoRow + +### Tests + +- Widget smoke test: app renders all 4 navigation tabs diff --git a/CHANGELOG.md b/CHANGELOG.md index 92078b7..f9b0ec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/). +## [0.2.0] - 2026-06-16 + +### Human + +独立发布 `v0.10.0`,详见 [CHANGELOG-human.md](CHANGELOG-human.md)。 + +- **Flutter 看板应用**: 管道看板、邮件队列、人才池、AI 设置 +- **响应式布局**: NavigationRail 桌面端 / NavigationBar 移动端 +- **管道拖拽流转**: 8 状态列、停留天数着色、搜索过滤 +- **主题系统**: 深色主题、8 种状态色彩、间距/字号 token + ## [0.1.0] - 2026-05-09 ### Studio diff --git a/docs/user-guide/human.md b/docs/user-guide/human.md index a5c26f7..0ae477e 100644 --- a/docs/user-guide/human.md +++ b/docs/user-guide/human.md @@ -1,17 +1,475 @@ -# 人力资源职能 +# 人力资源模块 — 零基础使用教程 -## 使用方式 +本教程教你从零开始搭建一套招聘管道管理系统:接收简历邮件 → AI 自动分类 → 人工确认 → 进入招聘看板追踪。 -### 导入招聘邮箱 +## 目录 + +1. [系统概览](#1-系统概览) +2. [环境准备](#2-环境准备) +3. [启动 Provider(数据后端)](#3-启动-provider数据后端) +4. [安装 CLI 命令行工具](#4-安装-cli-命令行工具) +5. [连接飞书邮箱](#5-连接飞书邮箱) +6. [配置 AI 智能分类](#6-配置-ai-智能分类) +7. [完整使用教程](#7-完整使用教程) +8. [打包项目](#8-打包项目) +9. [常见问题](#9-常见问题) + +--- + +## 1. 系统概览 + +整个系统由 3 个部分组成: + +``` +飞书邮箱 ──→ CLI(拉取邮件+分类)──→ Provider API(数据+AI)──→ 看板页面 + ↑ │ + └───── 定时轮询发件箱 ──────┘ +``` + +| 组件 | 作用 | 端口 | +|------|------|------| +| **Provider** | 数据后端,存数据库、提供 API、AI 分类 | 8080 | +| **CLI** | 命令行工具,拉取飞书邮件、推送分类结果 | — | +| **看板页面** | Web 界面,管理候选人管道 | 8000 | + +--- + +## 2. 环境准备 + +### 2.1 安装 Python 3.10+ + +```bash +python3 --version +``` + +如果低于 3.10,请到 [python.org](https://python.org) 下载安装。 + +### 2.2 安装 Node.js(飞书集成需要) + +```bash +node --version +npm --version +``` + +如果未安装,到 [nodejs.org](https://nodejs.org) 下载 LTS 版本。 + +### 2.3 获取项目代码 + +```bash +# 克隆项目(如果还没有) +git clone <项目仓库地址> qtadmin +cd qtadmin +``` + +> 如果已有项目代码,直接进入项目目录即可。 + +### 2.4 目录结构 + +``` +qtadmin/ +├── src/provider/ # Provider API(数据后端) +├── src/cli/ # CLI 命令行工具 +├── examples/human/ # Demo 演示(带 Web 页面) +├── docs/user-guide/ # 本文档 +└── manifests/ # systemd 服务配置(生产用) +``` + +--- + +## 3. 启动 Provider(数据后端) + +Provider 是所有数据的总源头,必须第一个启动。 + +### 3.1 创建虚拟环境并安装 + +```bash +cd src/provider + +# 创建虚拟环境(仅首次) +python3 -m venv .venv + +# 安装依赖 +.venv/bin/pip install -e . +``` + +### 3.2 启动服务 ```bash -qtadmin human xxxxx +.venv/bin/uvicorn app.__main__:app --host 0.0.0.0 --port 8080 ``` -命令行工具使用`lark-cli`获取招聘邮箱数据并提交到服务端。 +看到以下输出即成功: + +``` +INFO: Started server process [12345] +INFO: Uvicorn running on http://0.0.0.0:8080 +``` + +首次启动会自动创建 `hr.db` 数据库文件并写入 40 条示例数据。 + +### 3.3 验证 + +```bash +# 新开一个终端,检查服务是否正常 +curl http://127.0.0.1:8080/health +# 返回 {"status":"ok"} 即正常 + +# 查看管道数据 +curl http://127.0.0.1:8080/pipeline +``` + +> Provider 启动后不要关闭终端,后续所有操作都在新终端中进行。 + +--- + +## 4. 安装 CLI 命令行工具 + +### 4.1 创建虚拟环境并安装 + +```bash +# 新开一个终端 +cd qtadmin/src/cli + +# 创建虚拟环境 +python3 -m venv .venv + +# 安装 +.venv/bin/pip install -e . +``` + +### 4.2 验证安装 + +```bash +.venv/bin/qtadmin --help +``` + +看到帮助信息即安装成功。 + +### 4.3 配置 Provider 地址 + +告诉 CLI 你的 Provider 运行在哪里: + +```bash +.venv/bin/qtadmin human config set-provider http://127.0.0.1:8080 + +# 查看配置 +.venv/bin/qtadmin human config show +``` + +输出应类似: + +``` +当前配置: + provider_url: http://127.0.0.1:8080 + lark_path: lark-cli +``` + +--- + +## 5. 连接飞书邮箱 + +连接飞书邮箱后,系统可以自动拉取招聘邮箱中的简历邮件。 + +### 5.1 安装 lark-cli + +```bash +# 全局安装飞书命令行工具 +npm install -g @larksuite/cli + +# 验证安装 +lark-cli --version +``` + +### 5.2 登录飞书 + +```bash +lark login +``` + +浏览器会自动打开飞书登录页面,扫码登录即可。 + +> 如果浏览器没有自动打开,复制终端显示的链接手动打开。 + +### 5.3 验证登录 + +```bash +# 查看邮箱列表,确认你能访问目标邮箱 +lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"你的邮箱@example.com"}' +``` + +### 5.4 配置 CLI 使用 lark-cli + +```bash +.venv/bin/qtadmin human config set-lark-path $(which lark-cli) +``` + +### 5.5 测试邮件拉取 + +```bash +# 列出收件箱最近 5 封邮件 +.venv/bin/qtadmin human list -n 5 +``` + +如果能列出邮件,说明飞书集成成功。 + +--- + +## 6. 配置 AI 智能分类 + +AI 分类器可以自动判断一封邮件是简历、笔试、面试还是 Offer,并提取候选人姓名。 + +### 6.1 准备工作 -### 查看招聘进度 +你需要一个 **OpenAI 兼容的 API 密钥**。支持: +- OpenAI:`sk-...`(需科学上网) +- 国内替代:DeepSeek、智谱、通义千问等(无需科学上网) + +### 6.2 通过 API 配置(推荐) + +```bash +# 启用 AI 并设置密钥(以 DeepSeek 为例) +curl -X PATCH http://127.0.0.1:8080/ai/config \ + -H "Content-Type: application/json" \ + -d '{ + "enabled": true, + "provider": "openai", + "base_url": "https://api.deepseek.com/v1", + "api_key": "sk-你的密钥", + "model": "deepseek-chat" + }' +``` + +国内常用 AI 服务: + +| 服务商 | base_url | model | +|--------|----------|-------| +| DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` | +| 智谱 | `https://open.bigmodel.cn/api/paas/v4` | `glm-4-flash` | +| 通义千问 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-turbo` | +| OpenAI | `https://api.openai.com/v1` | `gpt-4o-mini` | + +### 6.3 测试 AI 配置 + +```bash +curl -X POST http://127.0.0.1:8080/ai/test +``` + +返回 `{"status":"ok","message":"连接成功"}` 即配置正确。 + +### 6.4 在 Web 页面配置 + +打开 http://127.0.0.1:8000/ → 点击右侧 **⚙️ AI 配置** → 填入: +1. 勾选"启用 AI 分类" +2. 填入 API 地址(如 `https://api.deepseek.com/v1`) +3. 填入 API 密钥 +4. 填入模型名(如 `deepseek-chat`) +5. 点击保存 + +--- + +## 7. 完整使用教程 + +### 7.1 启动看板页面 + +```bash +# 新开一个终端 +cd qtadmin + +# 启动 Demo(使用 Provider 的虚拟环境) +QTADMIN_MAILBOX=你的邮箱@example.com \ + PYTHONPATH=src/provider \ + src/provider/.venv/bin/python examples/human/demo.py +``` + +> 设置 `QTADMIN_MAILBOX` 后,系统会自动轮询该邮箱。 + +打开浏览器访问 **http://127.0.0.1:8000/**。 + +### 7.2 每日工作流程 + +#### 步骤 1:拉取邮件并分类 + +```bash +# 查看收件箱有哪些邮件 +.venv/bin/qtadmin human list -n 20 + +# 预览某封邮件的分类结果 +.venv/bin/qtadmin human classify <邮件ID> +``` + +#### 步骤 2:推送到确认队列 + +```bash +# 推送所有未处理邮件到待确认队列 +.venv/bin/qtadmin human ingest +``` + +#### 步骤 3:在 Web 页面确认 + +打开 http://127.0.0.1:8000/ → 点击 **待确认队列**: +- 查看邮件内容、附件、AI 分类结果 +- 点击 **确认入队** → 候选人自动进入管道 +- 点击 **忽略** → 丢弃该邮件 + +#### 步骤 4:管理招聘管道 + +管道看板将候选人按 8 个阶段排列: + +``` +新进 → 已联系 → 已发卷 → 已收卷 → 评卷中 → 面试 → 发Offer → 关闭 +``` + +操作: +- **拖拽** 候选人卡片到下一阶段 +- **点击候选人** 查看详情、时间线、消息记录 +- **查看附件** — PDF 直接预览,Word 文档自动转 PDF 在线预览 + +#### 步骤 5:查看队列状态 + +```bash +# 查看待确认队列统计 +.venv/bin/qtadmin human status +``` + +### 7.3 自动轮询模式 + +系统支持两种自动模式: + +**邮件拉取轮询**(在 Provider 或 Demo 中): +- 设置 `QTADMIN_MAILBOX` 环境变量后自动启用 +- 每 5 分钟检查一次新邮件 +- 新邮件自动推送至 `/ingest` 端点 + +**发件箱轮询**(邮件发送守护进程): +```bash +# 启动邮件发送循环(每 30 秒检查一次) +.venv/bin/qtadmin human send-loop -i 30 +``` + +--- + +## 8. 打包项目 + +### 8.1 打包 CLI 工具 + +```bash +cd src/cli + +# 构建可分发的 wheel 包 +.venv/bin/pip install build +.venv/bin/python -m build + +# 生成的包在 dist/ 目录 +ls dist/ +# qtadmin_cli-0.0.1-py3-none-any.whl + +# 安装到其他环境 +pip install dist/qtadmin_cli-0.0.1-py3-none-any.whl +``` + +### 8.2 打包 Provider + +```bash +cd src/provider + +# 安装 build 工具 +.venv/bin/pip install build +.venv/bin/python -m build + +# 查看生成的包 +ls dist/ +# qtadmin_provider-0.1.0-py3-none-any.whl +``` + +### 8.3 打包完整项目(含依赖) + +创建一个 requirements.txt 包含所有依赖: + +```bash +cd src/provider +.venv/bin/pip freeze > requirements.txt + +# 这样部署时只需: +# python3 -m venv .venv +# .venv/bin/pip install -r requirements.txt +# .venv/bin/pip install dist/qtadmin_provider-0.1.0-py3-none-any.whl +``` + +### 8.4 配置开机自启(生产环境) + +```bash +# 复制服务配置 +cp manifests/qtadmin-provider.service ~/.config/systemd/user/ +cp manifests/qtadmin-mail-sender.service ~/.config/systemd/user/ + +# 重新加载 systemd +systemctl --user daemon-reload + +# 启动服务 +systemctl --user start qtadmin-provider +systemctl --user start qtadmin-mail-sender + +# 设置开机自启 +systemctl --user enable qtadmin-provider +systemctl --user enable qtadmin-mail-sender +``` + +--- + +## 9. 常见问题 + +### Q:端口被占用怎么办? + +```bash +# 查看谁在用端口 +ss -tlnp | grep 8080 + +# 杀掉进程 +kill -9 +``` + +### Q:lark-cli 找不到命令? + +确保 Node.js 全局 bin 目录在 PATH 中: + +```bash +# 查看 npm 全局安装路径 +npm config get prefix +# 例如输出 /home/你的用户名/.npm-global + +# 将 bin 目录加入 PATH +export PATH=$PATH:/home/你的用户名/.npm-global/bin + +# 永久生效(加到 ~/.bashrc) +echo 'export PATH=$PATH:/home/你的用户名/.npm-global/bin' >> ~/.bashrc +``` + +### Q:数据库被锁定? + +```bash +# 删除数据库后重启 Provider(数据会重新初始化) +rm src/provider/hr.db +``` + +### Q:AI 分类没生效? + +1. 确认 Provider 正在运行 +2. 调用 `GET /ai/config` 检查 `enabled` 是否为 `true` +3. 调用 `POST /ai/test` 测试连接 +4. 检查 API 密钥是否正确 + +### Q:如何重置所有数据? + +```bash +# 停止 Provider +# 删除数据库 +rm src/provider/hr.db +# 重启 Provider(自动重新生成种子数据) +``` -(工作台操作) +### Q:飞书邮件收不到? -可以xxxx看xxxx。 +1. 确认 `lark login` 已成功登录 +2. 确认邮箱地址正确:`lark-cli mail user_mailboxes profile --params '{"user_mailbox_id":"你的邮箱"}'` +3. 确认收件箱中有邮件:`lark-cli mail +triage --format json --max 5 --mailbox 你的邮箱` +4. 检查 Provider 是否设置了 `QTADMIN_MAILBOX` 环境变量 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/hr-kanban/lib/main.dart b/src/hr-kanban/lib/main.dart new file mode 100644 index 0000000..8b82190 --- /dev/null +++ b/src/hr-kanban/lib/main.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'screens/pipeline_screen.dart'; +import 'screens/queue_screen.dart'; +import 'screens/pool_screen.dart'; +import 'screens/settings_screen.dart'; +import 'services/api_service.dart'; +import 'theme/hr_theme.dart'; + +void main() { + runApp(const HrKanbanApp()); +} + +class HrKanbanApp extends StatelessWidget { + const HrKanbanApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: '招聘管道验收看板', + debugShowCheckedModeBanner: false, + theme: buildHrTheme(), + home: const MainShell(), + ); + } +} + +class MainShell extends StatefulWidget { + const MainShell({super.key}); + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + final _api = ApiService(); + int _tab = 0; + + static const _railDestinations = [ + NavigationRailDestination(icon: Icon(Icons.view_column), label: Text('看板')), + NavigationRailDestination(icon: Icon(Icons.inbox), label: Text('确认队列')), + NavigationRailDestination(icon: Icon(Icons.person_search), label: Text('人才库')), + NavigationRailDestination(icon: Icon(Icons.settings), label: Text('AI 设置')), + ]; + + static const _barDestinations = [ + NavigationDestination(icon: Icon(Icons.view_column), label: '看板'), + NavigationDestination(icon: Icon(Icons.inbox), label: '确认队列'), + NavigationDestination(icon: Icon(Icons.person_search), label: '人才库'), + NavigationDestination(icon: Icon(Icons.settings), label: 'AI 设置'), + ]; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 600; + final body = IndexedStack( + index: _tab, + children: [ + PipelineScreen(api: _api), + QueueScreen(api: _api), + PoolScreen(api: _api), + SettingsScreen(api: _api), + ], + ); + + if (isWide) { + return Scaffold( + body: Row( + children: [ + NavigationRail( + selectedIndex: _tab, + onDestinationSelected: (i) => setState(() => _tab = i), + labelType: NavigationRailLabelType.all, + leading: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Icon(Icons.people, size: 24), + ), + destinations: _railDestinations, + ), + const VerticalDivider(width: 1), + Expanded(child: body), + ], + ), + ); + } + + return Scaffold( + body: body, + bottomNavigationBar: NavigationBar( + selectedIndex: _tab, + onDestinationSelected: (i) => setState(() => _tab = i), + destinations: _barDestinations, + ), + ); + }, + ); + } +} diff --git a/src/hr-kanban/lib/models/application_materials.dart b/src/hr-kanban/lib/models/application_materials.dart new file mode 100644 index 0000000..bb84ccb --- /dev/null +++ b/src/hr-kanban/lib/models/application_materials.dart @@ -0,0 +1,144 @@ +class ApplicationMaterials { + final Map? candidate; + final QueueItemMaterials? queueItem; + final ResumeParseResult? resumeParse; + final Map? classifierInfo; + final List? corrections; + + ApplicationMaterials({ + this.candidate, + this.queueItem, + this.resumeParse, + this.classifierInfo, + this.corrections, + }); + + factory ApplicationMaterials.fromJson(Map json) { + return ApplicationMaterials( + candidate: json['candidate'] as Map?, + queueItem: json['queue_item'] != null + ? QueueItemMaterials.fromJson(json['queue_item'] as Map) + : null, + resumeParse: json['resume_parse'] != null + ? ResumeParseResult.fromJson(json['resume_parse'] as Map) + : null, + classifierInfo: json['classifier_info'] as Map?, + corrections: json['corrections'] != null + ? (json['corrections'] as List) + .map((e) => CorrectionEntry.fromJson(e as Map)) + .toList() + : null, + ); + } +} + +class QueueItemMaterials { + final String subject; + final String? senderName; + final String senderEmail; + final String? body; + final String? bodyText; + final List? attachments; + + QueueItemMaterials({ + required this.subject, + this.senderName, + required this.senderEmail, + this.body, + this.bodyText, + this.attachments, + }); + + factory QueueItemMaterials.fromJson(Map json) { + return QueueItemMaterials( + subject: json['subject'] ?? '', + senderName: json['sender_name'] as String?, + senderEmail: json['sender_email'] ?? '', + body: json['body'] as String?, + bodyText: json['body_text'] as String?, + attachments: json['attachments'] != null + ? (json['attachments'] as List) + .map((a) => AttachmentInfo.fromJson(a as Map)) + .toList() + : null, + ); + } +} + +class AttachmentInfo { + final String id; + final String filename; + final String? mimeType; + final int size; + final String? storagePath; + + AttachmentInfo({ + required this.id, + required this.filename, + this.mimeType, + this.size = 0, + this.storagePath, + }); + + factory AttachmentInfo.fromJson(Map json) { + return AttachmentInfo( + id: json['id'] ?? '', + filename: json['filename'] ?? '', + mimeType: json['mime_type'] as String?, + size: json['size'] ?? 0, + storagePath: json['storage_path'] as String?, + ); + } +} + +class ResumeParseResult { + final String status; + final String? textExcerpt; + final String? name; + final String? phone; + final String? email; + final String? error; + + ResumeParseResult({ + required this.status, + this.textExcerpt, + this.name, + this.phone, + this.email, + this.error, + }); + + factory ResumeParseResult.fromJson(Map json) { + return ResumeParseResult( + status: json['status'] ?? '', + textExcerpt: json['text_excerpt'] as String?, + name: json['name'] as String?, + phone: json['phone'] as String?, + email: json['email'] as String?, + error: json['error'] as String?, + ); + } +} + +class CorrectionEntry { + final String fieldName; + final String? originalValue; + final String? correctedValue; + final String createdAt; + + CorrectionEntry({ + required this.fieldName, + this.originalValue, + this.correctedValue, + required this.createdAt, + }); + + factory CorrectionEntry.fromJson(Map json) { + return CorrectionEntry( + fieldName: json['field_name'] ?? '', + originalValue: json['original_value'] as String?, + correctedValue: json['corrected_value'] as String?, + createdAt: json['created_at'] ?? '', + ); + } +} diff --git a/src/hr-kanban/lib/models/mail_message.dart b/src/hr-kanban/lib/models/mail_message.dart new file mode 100644 index 0000000..a2cde41 --- /dev/null +++ b/src/hr-kanban/lib/models/mail_message.dart @@ -0,0 +1,75 @@ +class MailMessage { + final int id; + final int? candidateId; + final int? applicationId; + final String? messageId; + final String? senderEmail; + final String? recipientEmail; + final String subject; + final String? body; + final String? bodyText; + final String? attachmentsJson; + final String? stageSnapshot; + final String direction; + final String sendStatus; + final String occurredAt; + + MailMessage({ + required this.id, + this.candidateId, + this.applicationId, + this.messageId, + this.senderEmail, + this.recipientEmail, + required this.subject, + this.body, + this.bodyText, + this.attachmentsJson, + this.stageSnapshot, + required this.direction, + this.sendStatus = '', + required this.occurredAt, + }); + + factory MailMessage.fromJson(Map json) { + return MailMessage( + id: json['id'] ?? 0, + candidateId: json['candidate_id'], + applicationId: json['application_id'], + messageId: json['message_id'], + senderEmail: json['sender_email'], + recipientEmail: json['recipient_email'], + subject: json['subject'] ?? '', + body: json['body'], + bodyText: json['body_text'], + attachmentsJson: json['attachments_json'], + stageSnapshot: json['stage_snapshot'], + direction: json['direction'] ?? 'inbound', + sendStatus: json['send_status'] ?? '', + occurredAt: json['occurred_at'] ?? '', + ); + } +} + +class TimelineItem { + final String type; + final String timestamp; + final String description; + final Map? detail; + + TimelineItem({ + required this.type, + required this.timestamp, + required this.description, + this.detail, + }); + + factory TimelineItem.fromJson(Map json) { + return TimelineItem( + type: json['type'] ?? '', + timestamp: json['timestamp'] ?? '', + description: json['description'] ?? '', + detail: json['detail'] as Map?, + ); + } +} diff --git a/src/hr-kanban/lib/models/pool_item.dart b/src/hr-kanban/lib/models/pool_item.dart new file mode 100644 index 0000000..a66859c --- /dev/null +++ b/src/hr-kanban/lib/models/pool_item.dart @@ -0,0 +1,51 @@ +class PoolItem { + final int id; + final String candidateName; + final String candidateEmail; + final String status; + final String quality; + final String? pooledAt; + final String? subStage; + final String source; + final String? deactivatedAt; + + const PoolItem({ + required this.id, + required this.candidateName, + required this.candidateEmail, + required this.status, + required this.quality, + this.pooledAt, + this.subStage, + required this.source, + this.deactivatedAt, + }); + + factory PoolItem.fromJson(Map json) { + return PoolItem( + id: json['id'] as int, + candidateName: json['candidate_name'] as String, + candidateEmail: json['candidate_email'] as String, + status: json['status'] as String, + quality: json['quality'] as String, + pooledAt: json['pooled_at'] as String?, + subStage: json['sub_stage'] as String?, + source: json['source'] as String, + deactivatedAt: json['deactivated_at'] as String?, + ); + } +} + +class Headcount { + final int totalOffers; + final int accepted; + + const Headcount({required this.totalOffers, required this.accepted}); + + factory Headcount.fromJson(Map json) { + return Headcount( + totalOffers: json['total_offers'] as int, + accepted: json['accepted'] as int, + ); + } +} diff --git a/src/hr-kanban/lib/models/queue_item.dart b/src/hr-kanban/lib/models/queue_item.dart new file mode 100644 index 0000000..38048d3 --- /dev/null +++ b/src/hr-kanban/lib/models/queue_item.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +class QueueItem { + final int queueId; + final String messageId; + final String subject; + final String? senderName; + final String senderEmail; + final String? suggestedStatus; + final String confidence; + final String? extractedName; + final String? extractedEmail; + final String? suggestedRecruitmentTitle; + final String? attachmentsJson; + final String hrStatus; + final String? hrNotes; + final String createdAt; + + QueueItem({ + required this.queueId, + required this.messageId, + required this.subject, + this.senderName, + required this.senderEmail, + this.suggestedStatus, + required this.confidence, + this.extractedName, + this.extractedEmail, + this.suggestedRecruitmentTitle, + this.attachmentsJson, + required this.hrStatus, + this.hrNotes, + required this.createdAt, + }); + + factory QueueItem.fromJson(Map json) { + return QueueItem( + queueId: json['queue_id'] ?? 0, + messageId: json['message_id'] ?? '', + subject: json['subject'] ?? '', + senderName: json['sender_name'], + senderEmail: json['sender_email'] ?? '', + suggestedStatus: json['suggested_status'], + confidence: json['confidence'] ?? 'low', + extractedName: json['extracted_name'], + extractedEmail: json['extracted_email'], + suggestedRecruitmentTitle: json['suggested_recruitment_title'], + attachmentsJson: json['attachments_json'], + hrStatus: json['hr_status'] ?? 'pending', + hrNotes: json['hr_notes'], + createdAt: json['created_at'] ?? '', + ); + } + + static const Map confidenceColors = { + 'high': Color(0xFF10b981), + 'medium': Color(0xFFf59e0b), + 'low': Color(0xFF9ca3af), + }; + + static const Map statusLabels = { + 'pending': '待处理', + 'confirmed': '已确认', + 'adjusted': '已调整', + 'ignored': '已忽略', + }; +} diff --git a/src/hr-kanban/lib/screens/pipeline_screen.dart b/src/hr-kanban/lib/screens/pipeline_screen.dart new file mode 100644 index 0000000..322f21e --- /dev/null +++ b/src/hr-kanban/lib/screens/pipeline_screen.dart @@ -0,0 +1,781 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import '../services/api_service.dart'; +import '../models/application_materials.dart'; +import '../models/mail_message.dart'; +import '../theme/hr_theme.dart'; +import '../widgets/info_row.dart'; +import '../widgets/state_views.dart'; + +class PipelineScreen extends StatefulWidget { + final ApiService api; + const PipelineScreen({super.key, required this.api}); + + @override + State createState() => _PipelineScreenState(); +} + +class _PipelineScreenState extends State { + Map? _pipeline; + bool _loading = true; + String? _error; + final _searchController = TextEditingController(); + String _searchQuery = ''; + + static const _statusLabels = { + 'new': '新进入', 'contacted': '已联系', 'exam_sent': '笔试已发送', + 'exam_received': '笔试已提交', 'evaluating': '评估中', 'interview': '面试', + 'offer': '已发Offer', 'closed': '已结束', + }; + static const _statusOrder = ['new', 'contacted', 'exam_sent', 'exam_received', 'evaluating', 'interview', 'offer', 'closed']; + + @override + void initState() { + super.initState(); + _searchController.addListener(() => setState(() => _searchQuery = _searchController.text.trim().toLowerCase())); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + int _waitDays(Map t) { + final updated = t['updated_at'] as String?; + if (updated == null) return -1; + try { + final dt = DateTime.parse(updated.replaceAll(' ', 'T')); + return DateTime.now().difference(dt).inDays; + } catch (e) { + debugPrint('_waitDays parse error: $e'); + return -1; + } + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + _pipeline = await widget.api.getPipeline(); + } catch (e) { + _error = e.toString(); + } + if (mounted) setState(() => _loading = false); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('招聘管道看板'), + actions: [ + if (_pipeline != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '总数 ${_pipeline!['summary']['total']} 待关注 ${_pipeline!['summary']['need_attention']}', + style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurface.withAlpha(150)), + ), + ), + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return ErrorView(error: _error!, onRetry: _load); + if (_pipeline == null) return EmptyState(icon: Icons.view_column, message: '暂无数据'); + + final theme = Theme.of(context); + final stages = _pipeline!['stages'] as Map; + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), + child: TextField( + controller: _searchController, + style: TextStyle(color: theme.colorScheme.onSurface, fontSize: 13), + decoration: InputDecoration( + hintText: '搜索姓名或邮箱...', + hintStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(120), fontSize: 13), + prefixIcon: Icon(Icons.search, size: 18, color: theme.colorScheme.onSurface.withAlpha(120)), + filled: true, + fillColor: theme.colorScheme.surface, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(vertical: 8), + ), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _load, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(12), + child: SizedBox( + height: constraints.maxHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: _statusOrder.map((status) { + var items = List>.from(stages[status] ?? []); + if (_searchQuery.isNotEmpty) { + items = items.where((t) { + final name = (t['real_name'] as String? ?? '').toLowerCase(); + final email = (t['email'] as String? ?? '').toLowerCase(); + return name.contains(_searchQuery) || email.contains(_searchQuery); + }).toList(); + } + return _buildColumn(status, items); + }).toList(), + ), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildColumn(String status, List> items) { + final theme = Theme.of(context); + final statusColor = context.statusColor(status); + + Color waitColor(int wd) { + if (wd < 0) return theme.colorScheme.onSurface.withAlpha(100); + if (wd >= 14) return Colors.orange; + if (wd >= 7) return Colors.yellow; + return theme.colorScheme.onSurface.withAlpha(120); + } + + return Container( + width: 190, + margin: const EdgeInsets.only(right: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.onSurface.withAlpha(30)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container(width: 3, height: 14, color: statusColor, margin: const EdgeInsets.only(right: 6)), + Text(_statusLabels[status] ?? status, style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: theme.colorScheme.onSurface)), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration(color: statusColor.withAlpha(30), borderRadius: BorderRadius.circular(8)), + child: Text('${items.length}', style: TextStyle(fontSize: 11, color: statusColor)), + ), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + child: items.isEmpty + ? Center(child: Text('空', style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(80)))) + : ListView( + children: items.map((t) { + final wd = _waitDays(t); + final wc = waitColor(wd); + return Card( + margin: const EdgeInsets.only(bottom: 4), + child: InkWell( + onTap: () => _showDetail(t), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(t['real_name'] ?? '', style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14, color: theme.colorScheme.onSurface))), + if (t['quality'] == 'excellent') + Icon(Icons.star, size: 14, color: Colors.amber), + ], + ), + Text(t['email'] ?? '', style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurface.withAlpha(150))), + if (t['sub_stage'] != null) + Text('子阶段: ${t['sub_stage']}', style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurface.withAlpha(120))), + if (wd >= 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: wc.withAlpha(25), + borderRadius: BorderRadius.circular(3), + ), + child: Text('停留 $wd 天', style: TextStyle(fontSize: 10, color: wc, fontWeight: wd >= 14 ? FontWeight.w700 : FontWeight.normal)), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } + + Future _transition(Map talent, String targetStatus) async { + final id = talent['id']; + if (id is! int && id is! num) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('状态更新失败: id 类型异常 (${id.runtimeType}: $id)'), backgroundColor: Theme.of(context).colorScheme.error), + ); + } + return; + } + final applicationId = (id as num).toInt(); + try { + await widget.api.transitionApplication(applicationId, targetStatus); + if (mounted) { + Navigator.of(context).pop(); + _load(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('状态更新失败: $e'), backgroundColor: Theme.of(context).colorScheme.error), + ); + } + } + } + + void _showDetail(Map talent) async { + final email = talent['email'] as String; + final candidateId = talent['candidate_id'] as int?; + Map? queueData; + ApplicationMaterials? materials; + List messages = []; + List timeline = []; + try { + queueData = await widget.api.getQueueByEmail(email); + final appId = talent['id']; + if (appId is int) { + materials = await widget.api.getApplicationMaterials(appId); + } + if (candidateId != null) { + messages = await widget.api.getCandidateMessages(candidateId); + timeline = await widget.api.getCandidateTimeline(candidateId); + } + } catch (e) { + debugPrint('_showDetail error: $e'); + } + + if (!mounted) return; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => _DetailPanel( + api: widget.api, + talent: talent, + queueData: queueData, + materials: materials, + messages: messages, + timeline: timeline, + onTransition: (target) => _transition(talent, target), + ), + ); + } +} + +class _DetailPanel extends StatelessWidget { + final ApiService api; + final Map talent; + final Map? queueData; + final ApplicationMaterials? materials; + final List messages; + final List timeline; + final void Function(String targetStatus)? onTransition; + + const _DetailPanel({ + required this.api, + required this.talent, + this.queueData, + this.materials, + this.messages = const [], + this.timeline = const [], + this.onTransition, + }); + + String _classifierLabel(String? source) { + return switch (source) { + 'rule' => '规则分类', + 'llm' => 'AI 分类', + _ => source ?? '未知', + }; + } + + void _showReplyDialog(BuildContext context) { + final theme = Theme.of(context); + final subjectCtl = TextEditingController(); + final bodyCtl = TextEditingController(); + final candidateId = talent['candidate_id'] as int?; + final applicationId = talent['id'] as int?; + if (candidateId == null || applicationId == null) return; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: theme.colorScheme.surface, + title: Text('回复候选人', style: TextStyle(color: theme.colorScheme.onSurface)), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: subjectCtl, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration( + labelText: '主题', + labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)), + ), + ), + const SizedBox(height: 8), + TextField( + controller: bodyCtl, + style: TextStyle(color: theme.colorScheme.onSurface), + maxLines: 8, + decoration: InputDecoration( + labelText: '正文', + labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)), + alignLabelWithHint: true, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150))), + ), + TextButton( + onPressed: () async { + if (subjectCtl.text.trim().isEmpty || bodyCtl.text.trim().isEmpty) return; + try { + await api.replyToCandidate(candidateId, applicationId, subjectCtl.text, bodyCtl.text); + if (ctx.mounted) { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('回复已发送'), backgroundColor: theme.colorScheme.secondary), + ); + } + } catch (e) { + if (ctx.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('发送失败: $e'), backgroundColor: theme.colorScheme.error), + ); + } + } + }, + child: Text('发送', style: TextStyle(color: theme.colorScheme.secondary)), + ), + ], + ), + ); + } + + static const _statusLabels = { + 'new': '新进入', 'contacted': '已联系', 'exam_sent': '笔试已发送', + 'exam_received': '笔试已提交', 'evaluating': '评估中', 'interview': '面试', + 'offer': '已发Offer', 'closed': '已结束', + }; + + static const _transitions = { + 'new': ['contacted', 'closed'], + 'contacted': ['exam_sent', 'closed'], + 'exam_sent': ['exam_received', 'closed'], + 'exam_received': ['evaluating', 'closed'], + 'evaluating': ['interview', 'exam_sent', 'closed'], + 'interview': ['offer', 'closed'], + 'offer': ['closed'], + 'closed': [], + }; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final currentStatus = talent['status'] as String? ?? ''; + final availableTargets = _transitions[currentStatus] ?? []; + final onSurface = theme.colorScheme.onSurface; + final m = materials; + final qi = m?.queueItem; + final ci = m?.classifierInfo; + final corrections = m?.corrections; + final atts = qi?.attachments; + + return DraggableScrollableSheet( + initialChildSize: 0.6, + maxChildSize: 0.9, + minChildSize: 0.3, + expand: false, + builder: (ctx, scrollController) => SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: onSurface.withAlpha(50), borderRadius: BorderRadius.circular(2)))), + const SizedBox(height: 16), + + Text(talent['real_name'] ?? '', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: onSurface)), + const SizedBox(height: 12), + InfoRow(label: '邮箱', value: talent['email'] ?? ''), + InfoRow(label: '阶段', value: _statusLabels[currentStatus] ?? currentStatus), + if (talent['sub_stage'] != null) InfoRow(label: '子阶段', value: talent['sub_stage']), + if (talent['quality'] != null) InfoRow(label: '质量', value: talent['quality'] == 'excellent' ? '优秀' : talent['quality'] == 'closed' ? '淘汰' : '普通'), + if (talent['created_at'] != null) InfoRow(label: '创建时间', value: talent['created_at']), + + if (availableTargets.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('推进状态', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: onSurface.withAlpha(180))), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: availableTargets.map((target) { + final isClose = target == 'closed'; + return ElevatedButton( + onPressed: onTransition != null ? () => onTransition!(target) : null, + style: ElevatedButton.styleFrom( + backgroundColor: isClose ? theme.colorScheme.error : theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: Text(_statusLabels[target] ?? target, style: const TextStyle(fontSize: 13)), + ); + }).toList(), + ), + ], + + Divider(height: 24, color: onSurface.withAlpha(30)), + + Text('飞书邮件材料', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: onSurface.withAlpha(180))), + const SizedBox(height: 8), + if (queueData != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: theme.scaffoldBackgroundColor, borderRadius: BorderRadius.circular(8)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('主题: ${queueData!['subject'] ?? ''}', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(180))), + const SizedBox(height: 4), + Text('发件人: ${queueData!['sender_name'] ?? ''} <${queueData!['sender_email'] ?? ''}>', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(180))), + const SizedBox(height: 4), + if (queueData!['suggested_status'] != null) + Text('分类建议: ${queueData!['suggested_status']}', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(180))), + Text('置信度: ${queueData!['confidence'] ?? ''}', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(180))), + if (queueData!['hr_notes'] != null) + Text('HR 备注: ${queueData!['hr_notes']}', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(180))), + + // Email body + if (qi != null) ...[ + const SizedBox(height: 8), + if (qi.bodyText != null && qi.bodyText!.isNotEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: onSurface.withAlpha(10), + borderRadius: BorderRadius.circular(4), + ), + child: Text(qi.bodyText!, style: TextStyle(fontSize: 12, color: onSurface.withAlpha(200))), + ) + else if (qi.body != null && qi.body!.isNotEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: onSurface.withAlpha(10), + borderRadius: BorderRadius.circular(4), + ), + child: Text(qi.body!, style: TextStyle(fontSize: 12, color: onSurface.withAlpha(200))), + ) + else + Text('该邮件无正文内容', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(120))), + ], + + // Attachments + if (atts != null && atts.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('附件 (${atts.length})', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: onSurface.withAlpha(180))), + ...atts.map((a) => Padding( + padding: const EdgeInsets.only(top: 4), + child: GestureDetector( + onTap: a.storagePath != null ? () => api.openAttachmentPreview(a.storagePath!, a.filename) : null, + child: Row( + children: [ + Icon(Icons.attach_file, size: 14, color: a.storagePath != null ? theme.colorScheme.secondary : onSurface.withAlpha(80)), + const SizedBox(width: 4), + Expanded( + child: Text( + '${a.filename} (${a.size > 0 ? "${(a.size / 1024).toStringAsFixed(0)} KB" : "?"})', + style: TextStyle( + fontSize: 12, + color: a.storagePath != null ? theme.colorScheme.secondary : onSurface.withAlpha(120), + decoration: a.storagePath != null ? TextDecoration.underline : null, + ), + ), + ), + ], + ), + ), + )), + ], + + // Classifier info + if (ci != null) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: onSurface.withAlpha(8), + borderRadius: BorderRadius.circular(4), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('分类来源: ${_classifierLabel(ci['classifier_source'] as String?)}', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(150))), + if (ci['classifier_reason'] != null) + Text('分类理由: ${ci['classifier_reason']}', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(150))), + ], + ), + ), + ], + + // Corrections + if (corrections != null && corrections.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('修正记录 (${corrections.length})', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: onSurface.withAlpha(180))), + ...corrections.map((c) => Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${c.fieldName}: ${c.originalValue ?? ""} → ${c.correctedValue ?? ""}', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(150))), + if (c.createdAt.isNotEmpty) + Text(c.createdAt, style: TextStyle(fontSize: 10, color: onSurface.withAlpha(100))), + ], + ), + )), + ], + ], + ), + ), + ] else ...[ + Text('该候选人无关联的飞书邮件记录', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(120))), + ], + + Divider(height: 24, color: onSurface.withAlpha(30)), + + Text('阶段结果', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: onSurface.withAlpha(180))), + const SizedBox(height: 8), + if (talent['stage_results'] != null && (talent['stage_results'] as Map).isNotEmpty) + ...(talent['stage_results'] as Map).entries.map((e) => + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox(width: 80, child: Text(_statusLabels[e.key] ?? e.key, style: TextStyle(fontSize: 13, color: onSurface.withAlpha(150)))), + Text(e.value == 'pass' ? '通过' : '淘汰', style: TextStyle(fontSize: 13, color: e.value == 'pass' ? theme.colorScheme.secondary : theme.colorScheme.error)), + ], + ), + ), + ) + else + Text('暂无阶段结果', style: TextStyle(fontSize: 13, color: onSurface.withAlpha(120))), + + // Messages + if (messages.isNotEmpty) ...[ + Divider(height: 24, color: onSurface.withAlpha(30)), + Text('消息记录 (${messages.length})', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: onSurface.withAlpha(180))), + const SizedBox(height: 8), + ...messages.map((m) { + List> msgAtts = []; + if (m.attachmentsJson != null && m.attachmentsJson!.isNotEmpty) { + try { + final parsed = json.decode(m.attachmentsJson!); + if (parsed is List) { + msgAtts = parsed.cast>(); + } + } catch (e) { + debugPrint('attachmentsJson parse error: $e'); + } + } + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: onSurface.withAlpha(15)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: m.direction == 'outbound' ? Colors.green.withAlpha(25) : Colors.blue.withAlpha(25), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + m.direction == 'outbound' ? '发出' : '收到', + style: TextStyle(fontSize: 10, color: m.direction == 'outbound' ? Colors.green : Colors.blue, fontWeight: FontWeight.w500), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text(m.subject, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: onSurface), maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ], + ), + const SizedBox(height: 4), + if (m.senderEmail != null) + Text(m.senderEmail!, style: TextStyle(fontSize: 11, color: onSurface.withAlpha(120))), + if (m.bodyText != null && m.bodyText!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(m.bodyText!, style: TextStyle(fontSize: 12, color: onSurface.withAlpha(180)), maxLines: 5, overflow: TextOverflow.ellipsis), + ], + if (msgAtts.isNotEmpty) ...[ + const SizedBox(height: 6), + ...msgAtts.map((a) => GestureDetector( + onTap: a['storage_path'] != null ? () => api.openAttachmentPreview(a['storage_path'], a['filename'] ?? 'attachment') : null, + child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + children: [ + Icon(Icons.attach_file, size: 12, color: a['storage_path'] != null ? theme.colorScheme.secondary : onSurface.withAlpha(80)), + const SizedBox(width: 4), + Text( + a['filename'] ?? '附件', + style: TextStyle( + fontSize: 11, + color: a['storage_path'] != null ? theme.colorScheme.secondary : onSurface.withAlpha(120), + decoration: a['storage_path'] != null ? TextDecoration.underline : null, + ), + ), + ], + ), + ), + )), + ], + const SizedBox(height: 4), + Text(m.occurredAt.length >= 16 ? m.occurredAt.substring(0, 16) : m.occurredAt, style: TextStyle(fontSize: 10, color: onSurface.withAlpha(80))), + ], + ), + ); + }), + ], + + // Timeline + if (timeline.isNotEmpty) ...[ + Divider(height: 24, color: onSurface.withAlpha(30)), + Text('时间线', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: onSurface.withAlpha(180))), + const SizedBox(height: 8), + ...timeline.map((t) { + IconData icon; + Color color; + switch (t.type) { + case 'transition': + icon = Icons.swap_horiz; + color = Colors.blue; + case 'reply': + icon = Icons.reply; + color = Colors.green; + case 'note': + icon = Icons.note; + color = Colors.orange; + case 'system': + icon = Icons.settings; + color = onSurface.withAlpha(120); + default: + icon = Icons.circle; + color = onSurface.withAlpha(80); + } + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 2), + width: 24, height: 24, + decoration: BoxDecoration( + color: color.withAlpha(25), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 14, color: color), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t.description, style: TextStyle(fontSize: 13, color: onSurface)), + Text(t.timestamp.length >= 16 ? t.timestamp.substring(0, 16) : t.timestamp, style: TextStyle(fontSize: 11, color: onSurface.withAlpha(100))), + if (t.detail != null && t.detail!.isNotEmpty) + Text(t.detail!.toString(), style: TextStyle(fontSize: 11, color: onSurface.withAlpha(120))), + ], + ), + ), + ], + ), + ); + }), + ], + + // Reply + if (talent['candidate_id'] != null) ...[ + Divider(height: 24, color: onSurface.withAlpha(30)), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => _showReplyDialog(context), + icon: const Icon(Icons.reply, size: 16), + label: const Text('回复候选人', style: TextStyle(fontSize: 13)), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + + const SizedBox(height: 20), + ], + ), + ), + ); + } +} diff --git a/src/hr-kanban/lib/screens/pool_screen.dart b/src/hr-kanban/lib/screens/pool_screen.dart new file mode 100644 index 0000000..2de66a6 --- /dev/null +++ b/src/hr-kanban/lib/screens/pool_screen.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import '../models/pool_item.dart'; +import '../services/api_service.dart'; +import '../widgets/info_row.dart'; +import '../widgets/status_badge.dart'; +import '../widgets/state_views.dart'; + +class PoolScreen extends StatefulWidget { + final ApiService api; + const PoolScreen({super.key, required this.api}); + + @override + State createState() => _PoolScreenState(); +} + +class _PoolScreenState extends State { + List _items = []; + List> _recruitments = []; + Headcount? _headcount; + int? _selectedRecruitmentId; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final recs = await widget.api.getRecruitments(); + if (recs.isNotEmpty && _selectedRecruitmentId == null) { + _selectedRecruitmentId = recs.last['id'] as int; + } + _recruitments = recs; + + final results = await Future.wait([ + widget.api.getPool(), + if (_selectedRecruitmentId != null) widget.api.getHeadcount(_selectedRecruitmentId!) else Future.value(null), + ]); + _items = results[0] as List; + _headcount = results.length > 1 ? results[1] as Headcount? : null; + } catch (e) { + _error = e.toString(); + } + if (mounted) setState(() => _loading = false); + } + + Future _unpool(PoolItem item) async { + final theme = Theme.of(context); + final idCtl = TextEditingController(text: _selectedRecruitmentId?.toString() ?? ''); + final result = await showDialog>( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: theme.colorScheme.surface, + title: Text('出池', style: TextStyle(color: theme.colorScheme.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('将 ${item.candidateName} 出池到新的招聘批次', style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(180))), + const SizedBox(height: 8), + if (_recruitments.isNotEmpty) + DropdownButtonFormField( + initialValue: int.tryParse(idCtl.text), + items: _recruitments.map((r) => DropdownMenuItem(value: r['id'] as int, child: Text('招聘 #${r['id']}', style: TextStyle(color: theme.colorScheme.onSurface)))).toList(), + onChanged: (v) { idCtl.text = v.toString(); setDialogState(() {}); }, + dropdownColor: theme.colorScheme.surface, + decoration: InputDecoration(labelText: '目标招聘批次', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150))), + ) + else + TextField( + controller: idCtl, + style: TextStyle(color: theme.colorScheme.onSurface), + decoration: InputDecoration(labelText: '招聘批次 ID', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150))), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)))), + TextButton(onPressed: () => Navigator.pop(ctx, {'recruitment_id': idCtl.text}), child: Text('确认出池', style: TextStyle(color: theme.colorScheme.secondary))), + ], + ), + ), + ); + if (result == null) return; + final rid = int.tryParse(result['recruitment_id'] ?? ''); + if (rid == null) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('无效的招聘批次 ID'), backgroundColor: theme.colorScheme.error)); + return; + } + try { + await widget.api.unpoolApplication(item.id, rid); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('出池成功'), backgroundColor: theme.colorScheme.secondary)); + _load(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('出池失败: $e'), backgroundColor: theme.colorScheme.error)); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('人才库'), + actions: [ + if (_headcount != null) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '已接受 ${_headcount!.accepted} / 总 Offer ${_headcount!.totalOffers}', + style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface.withAlpha(150)), + ), + ), + ), + ], + ), + body: Column( + children: [ + if (_recruitments.isNotEmpty) + SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + children: _recruitments.map((r) { + final id = r['id'] as int; + final active = id == _selectedRecruitmentId; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text('招聘 #$id', style: TextStyle(fontSize: 12, color: active ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.withAlpha(180))), + selected: active, + selectedColor: theme.colorScheme.secondary, + backgroundColor: theme.colorScheme.surface, + onSelected: (_) { + setState(() => _selectedRecruitmentId = id); + _load(); + }, + ), + ); + }).toList(), + ), + ), + Expanded(child: _buildBody()), + ], + ), + ); + } + + Widget _buildBody() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return ErrorView(error: _error!, onRetry: _load); + if (_items.isEmpty) return EmptyState(icon: Icons.person_off, message: '人才库为空'); + + return RefreshIndicator( + onRefresh: _load, + child: ListView.builder( + padding: const EdgeInsets.all(12), + itemCount: _items.length, + itemBuilder: (ctx, i) => _buildCard(_items[i]), + ), + ); + } + + void _showDetail(PoolItem item) { + final theme = Theme.of(context); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: theme.colorScheme.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => DraggableScrollableSheet( + initialChildSize: 0.5, + maxChildSize: 0.8, + minChildSize: 0.3, + expand: false, + builder: (ctx, scrollController) => SingleChildScrollView( + controller: scrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: theme.colorScheme.onSurface.withAlpha(50), borderRadius: BorderRadius.circular(2)))), + const SizedBox(height: 16), + Text(item.candidateName, style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface)), + const SizedBox(height: 12), + InfoRow(label: '邮箱', value: item.candidateEmail, labelWidth: 80), + InfoRow(label: '原状态', value: item.status, labelWidth: 80), + InfoRow(label: '质量', value: item.quality == 'excellent' ? '优秀' : item.quality == 'closed' ? '淘汰' : '普通', labelWidth: 80), + if (item.pooledAt != null) InfoRow(label: '入池日期', value: item.pooledAt!.substring(0, 10), labelWidth: 80), + if (item.subStage != null) InfoRow(label: '子阶段', value: item.subStage!, labelWidth: 80), + const SizedBox(height: 12), + Text('入池信息', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface.withAlpha(180))), + const SizedBox(height: 8), + InfoRow(label: '来源', value: item.source, labelWidth: 80), + if (item.deactivatedAt != null) InfoRow(label: '停用日期', value: item.deactivatedAt!.substring(0, 10), labelWidth: 80), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 36, + child: OutlinedButton.icon( + onPressed: () { Navigator.pop(ctx); _unpool(item); }, + icon: const Icon(Icons.unarchive, size: 16), + label: const Text('出池', style: TextStyle(fontSize: 13)), + style: OutlinedButton.styleFrom(foregroundColor: theme.colorScheme.secondary, side: BorderSide(color: theme.colorScheme.secondary)), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } + + Widget _buildCard(PoolItem item) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: InkWell( + onTap: () => _showDetail(item), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person, size: 20, color: onSurface.withAlpha(150)), + const SizedBox(width: 8), + Expanded( + child: Text( + item.candidateName, + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 15, color: onSurface), + ), + ), + if (item.quality == 'excellent') + Icon(Icons.star, size: 16, color: Colors.amber), + ], + ), + const SizedBox(height: 4), + Text(item.candidateEmail, style: TextStyle(fontSize: 12, color: onSurface.withAlpha(150))), + const SizedBox(height: 8), + Row( + children: [ + StatusBadge(status: item.status, label: '原状态: ${item.status}'), + if (item.pooledAt != null) ...[ + const SizedBox(width: 4), + StatusBadge(status: 'offer', label: '入池: ${item.pooledAt!.substring(0, 10)}'), + ], + ], + ), + const SizedBox(height: 10), + SizedBox( + height: 32, + child: OutlinedButton.icon( + onPressed: () => _unpool(item), + icon: const Icon(Icons.unarchive, size: 16), + label: const Text('出池', style: TextStyle(fontSize: 13)), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.secondary, + side: BorderSide(color: theme.colorScheme.secondary.withAlpha(150)), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/src/hr-kanban/lib/screens/queue_screen.dart b/src/hr-kanban/lib/screens/queue_screen.dart new file mode 100644 index 0000000..5f96a15 --- /dev/null +++ b/src/hr-kanban/lib/screens/queue_screen.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import '../models/queue_item.dart'; +import '../services/api_service.dart'; +import '../widgets/status_badge.dart'; +import '../widgets/state_views.dart'; + +class QueueScreen extends StatefulWidget { + final ApiService api; + const QueueScreen({super.key, required this.api}); + + @override + State createState() => _QueueScreenState(); +} + +class _QueueScreenState extends State { + List _items = []; + Map _stats = {}; + bool _loading = true; + String? _error; + String? _filter; + + static const _filters = ['pending', 'confirmed', 'ignored']; + static const _statuses = ['new', 'contacted', 'exam_sent', 'exam_received', 'evaluating', 'interview', 'offer', 'closed']; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + _items = await widget.api.getQueueItems(hrStatus: _filter); + _stats = await widget.api.getQueueStats(); + } catch (e) { + _error = e.toString(); + } + if (mounted) setState(() => _loading = false); + } + + Future _confirm(QueueItem item, {String action = 'confirmed'}) async { + final statusCtl = TextEditingController(); + final effectiveName = item.extractedName ?? item.senderName ?? ''; + final effectiveEmail = item.extractedEmail ?? item.senderEmail; + final nameCtl = TextEditingController(text: effectiveName); + final emailCtl = TextEditingController(text: effectiveEmail); + final recTitleCtl = TextEditingController(text: item.suggestedRecruitmentTitle ?? ''); + + final theme = Theme.of(context); + final result = await showDialog>( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: theme.colorScheme.surface, + title: Text(action == 'adjusted' ? '调整' : '确认', style: TextStyle(color: theme.colorScheme.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: nameCtl, style: TextStyle(color: theme.colorScheme.onSurface), decoration: InputDecoration(labelText: '姓名', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)))), + TextField(controller: emailCtl, style: TextStyle(color: theme.colorScheme.onSurface), decoration: InputDecoration(labelText: '邮箱', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)))), + TextField(controller: recTitleCtl, style: TextStyle(color: theme.colorScheme.onSurface), decoration: InputDecoration(labelText: '招聘名称', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)))), + DropdownButtonFormField( + initialValue: _statuses.contains(item.suggestedStatus) ? item.suggestedStatus : null, + items: _statuses.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), + onChanged: (v) => statusCtl.text = v ?? '', + dropdownColor: theme.colorScheme.surface, + decoration: InputDecoration(labelText: '状态', labelStyle: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150))), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text('取消', style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(150)))), + TextButton(onPressed: () => Navigator.pop(ctx, {'name': nameCtl.text, 'email': emailCtl.text, 'status': statusCtl.text, 'recruitmentTitle': recTitleCtl.text}), child: Text('确认', style: TextStyle(color: theme.colorScheme.secondary))), + ], + ), + ); + if (result == null) return; + await widget.api.confirmQueueItem(item.queueId, action: action, status: result['status'] ?? '', realName: result['name'] ?? '', email: result['email'] ?? '', recruitmentTitle: result['recruitmentTitle'] ?? ''); + if (mounted) _load(); + } + + Future _ignore(QueueItem item) async { + await widget.api.ignoreQueueItem(item.queueId); + if (mounted) _load(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('确认队列'), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '待处理 ${_stats['pending'] ?? 0}', + style: TextStyle(color: Colors.orange, fontSize: 13), + ), + ), + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return ErrorView(error: _error!, onRetry: _load); + + return Column( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + _buildFilterChip('全部', null), + for (final f in _filters) + _buildFilterChip('${QueueItem.statusLabels[f] ?? f} (${_stats[f] ?? 0})', f), + ], + ), + ), + Expanded( + child: _items.isEmpty + ? EmptyState(icon: Icons.inbox, message: '暂无邮件') + : ListView.builder( + itemCount: _items.length, + itemBuilder: (ctx, i) => _buildCard(_items[i]), + ), + ), + ], + ); + } + + Widget _buildFilterChip(String label, String? value) { + final theme = Theme.of(context); + final active = _filter == value; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: ChoiceChip( + label: Text(label, style: TextStyle(fontSize: 12, color: active ? theme.colorScheme.onSurface : theme.colorScheme.onSurface.withAlpha(180))), + selected: active, + selectedColor: theme.colorScheme.onSurface.withAlpha(50), + backgroundColor: theme.colorScheme.surface, + onSelected: (_) { + setState(() => _filter = value); + _load(); + }, + ), + ); + } + + Widget _buildCard(QueueItem item) { + final theme = Theme.of(context); + final done = item.hrStatus != 'pending'; + final onSurface = theme.colorScheme.onSurface; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + color: done + ? theme.colorScheme.surface.withAlpha(180) + : theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.subject, style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: done ? onSurface.withAlpha(120) : onSurface, + )), + const SizedBox(height: 2), + Text('${item.senderName ?? ''} <${item.senderEmail}>', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(150))), + const SizedBox(height: 6), + Row( + children: [ + StatusBadge(status: item.confidence), + if (item.suggestedStatus != null) ...[ + const SizedBox(width: 4), + StatusBadge(status: item.suggestedStatus!), + ], + ], + ), + if (!done) ...[ + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + height: 28, + child: ElevatedButton( + onPressed: () => _confirm(item), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const Text('确认', style: TextStyle(fontSize: 12)), + ), + ), + const SizedBox(width: 4), + SizedBox( + height: 28, + child: OutlinedButton( + onPressed: () => _confirm(item, action: 'adjusted'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + foregroundColor: onSurface.withAlpha(180), + side: BorderSide(color: onSurface.withAlpha(60)), + ), + child: const Text('调整', style: TextStyle(fontSize: 12)), + ), + ), + const SizedBox(width: 4), + SizedBox( + height: 28, + child: TextButton( + onPressed: () => _ignore(item), + child: Text('忽略', style: TextStyle(fontSize: 12, color: onSurface.withAlpha(120))), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/src/hr-kanban/lib/screens/settings_screen.dart b/src/hr-kanban/lib/screens/settings_screen.dart new file mode 100644 index 0000000..af53ab5 --- /dev/null +++ b/src/hr-kanban/lib/screens/settings_screen.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import '../services/api_service.dart'; +import '../widgets/state_views.dart'; + +class SettingsScreen extends StatefulWidget { + final ApiService api; + const SettingsScreen({super.key, required this.api}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _loading = true; + bool _saving = false; + bool _testing = false; + String? _error; + Map? _config; + + final _serverUrlCtl = TextEditingController(); + final _providerCtl = TextEditingController(); + final _modelCtl = TextEditingController(); + final _apiKeyCtl = TextEditingController(); + final _apiUrlCtl = TextEditingController(); + final _promptTemplateCtl = TextEditingController(); + final _temperatureCtl = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _serverUrlCtl.dispose(); + _providerCtl.dispose(); + _modelCtl.dispose(); + _apiKeyCtl.dispose(); + _apiUrlCtl.dispose(); + _promptTemplateCtl.dispose(); + _temperatureCtl.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + _serverUrlCtl.text = widget.api.baseUrl; + try { + _config = await widget.api.getAIConfig(); + _providerCtl.text = _config?['provider'] ?? ''; + _modelCtl.text = _config?['model'] ?? ''; + _apiKeyCtl.text = _config?['api_key'] ?? ''; + _apiUrlCtl.text = _config?['api_url'] ?? ''; + _promptTemplateCtl.text = _config?['prompt_template'] ?? ''; + _temperatureCtl.text = _config?['temperature']?.toString() ?? '0.7'; + } catch (e) { + _error = e.toString(); + } + if (mounted) setState(() => _loading = false); + } + + Future _save() async { + setState(() => _saving = true); + try { + await widget.api.updateAIConfig({ + 'provider': _providerCtl.text, + 'model': _modelCtl.text, + 'api_key': _apiKeyCtl.text, + 'api_url': _apiUrlCtl.text, + 'prompt_template': _promptTemplateCtl.text, + 'temperature': double.tryParse(_temperatureCtl.text) ?? 0.7, + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('配置已保存'), + backgroundColor: Theme.of(context).colorScheme.secondary, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + if (mounted) setState(() => _saving = false); + } + + Future _test() async { + setState(() => _testing = true); + try { + final result = await widget.api.testAIConnection(); + if (mounted) { + final ok = result['ok'] == true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(ok ? '连接成功' : '连接失败: ${result['error'] ?? '未知错误'}'), + backgroundColor: ok + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.error, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('测试失败: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + if (mounted) setState(() => _testing = false); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + + return Scaffold( + appBar: AppBar( + title: const Text('AI 设置'), + actions: [ + if (!_loading && _error == null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: SizedBox( + height: 32, + child: ElevatedButton.icon( + onPressed: _testing ? null : _test, + icon: _testing + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSecondary, + ), + ) + : const Icon(Icons.wifi_find, size: 14), + label: Text( + _testing ? '测试中' : '测试连接', + style: const TextStyle(fontSize: 12), + ), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + ), + ), + ), + ], + ), + body: _buildBody(theme, onSurface), + ); + } + + Widget _buildBody(ThemeData theme, Color onSurface) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return ErrorView(error: _error!, onRetry: _load); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '服务端连接', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: onSurface, + ), + ), + const SizedBox(height: 4), + Text( + 'Provider API 地址', + style: TextStyle(fontSize: 13, color: onSurface.withAlpha(150)), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _field('服务器地址', _serverUrlCtl, 'http://127.0.0.1:8080'), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.save, size: 20), + tooltip: '保存服务器地址', + onPressed: () { + final url = _serverUrlCtl.text.trim(); + if (url.isNotEmpty) { + widget.api.baseUrl = url; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('服务器地址已更新: $url'), + backgroundColor: Theme.of( + context, + ).colorScheme.secondary, + ), + ); + } + }, + ), + ], + ), + const SizedBox(height: 20), + Divider(color: onSurface.withAlpha(20)), + const SizedBox(height: 16), + Text( + 'AI 分类配置', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: onSurface, + ), + ), + const SizedBox(height: 4), + Text( + '配置 AI 模型用于简历自动分类', + style: TextStyle(fontSize: 13, color: onSurface.withAlpha(150)), + ), + const SizedBox(height: 20), + + _field('供应商', _providerCtl, '例如: openai, azure, ollama'), + _field('模型', _modelCtl, '例如: gpt-4o, deepseek-chat'), + _field('API Key', _apiKeyCtl, 'API 密钥', obscure: true), + _field('API URL', _apiUrlCtl, 'API 地址(可选)'), + _field( + '温度参数', + _temperatureCtl, + '0.0 - 2.0,默认 0.7', + keyboardType: TextInputType.number, + ), + + const SizedBox(height: 16), + Text( + '提示词模板', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: onSurface.withAlpha(180), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _promptTemplateCtl, + style: TextStyle(fontSize: 13, color: onSurface), + maxLines: 10, + decoration: InputDecoration( + hintText: '自定义分类提示词(可选)', + hintStyle: TextStyle( + color: onSurface.withAlpha(80), + fontSize: 13, + ), + filled: true, + fillColor: theme.scaffoldBackgroundColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: onSurface.withAlpha(30)), + ), + contentPadding: const EdgeInsets.all(12), + ), + ), + + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 44, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondary, + foregroundColor: theme.colorScheme.onSecondary, + ), + child: _saving + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSecondary, + ), + ) + : const Text('保存配置', style: TextStyle(fontSize: 15)), + ), + ), + + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _field( + String label, + TextEditingController ctl, + String hint, { + bool obscure = false, + TextInputType? keyboardType, + }) { + final theme = Theme.of(context); + final onSurface = theme.colorScheme.onSurface; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: ctl, + style: TextStyle(fontSize: 14, color: onSurface), + obscureText: obscure, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: onSurface.withAlpha(150), fontSize: 14), + hintText: hint, + hintStyle: TextStyle(color: onSurface.withAlpha(80), fontSize: 13), + filled: true, + fillColor: theme.scaffoldBackgroundColor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: onSurface.withAlpha(30)), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ); + } +} diff --git a/src/hr-kanban/lib/services/api_service.dart b/src/hr-kanban/lib/services/api_service.dart new file mode 100644 index 0000000..dafcdb8 --- /dev/null +++ b/src/hr-kanban/lib/services/api_service.dart @@ -0,0 +1,235 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; +import '../models/queue_item.dart'; +import '../models/pool_item.dart'; +import '../models/application_materials.dart'; +import '../models/mail_message.dart'; + +class ApiService { + String baseUrl; + + ApiService({this.baseUrl = 'http://127.0.0.1:8080'}); + + Future> getPipeline() async { + final r = await http.get(Uri.parse('$baseUrl/pipeline')); + if (r.statusCode != 200) throw Exception('Failed to load pipeline'); + return json.decode(r.body); + } + + Future>> getRecruitments() async { + final r = await http.get(Uri.parse('$baseUrl/recruitments')); + if (r.statusCode != 200) throw Exception('Failed to load recruitments'); + return List>.from(json.decode(r.body)); + } + + Future>> getTalents( + int recruitmentId, { + String? status, + }) async { + final url = status != null + ? '$baseUrl/recruitments/$recruitmentId/talents?status=$status' + : '$baseUrl/recruitments/$recruitmentId/talents'; + final r = await http.get(Uri.parse(url)); + if (r.statusCode != 200) throw Exception('Failed to load talents'); + return List>.from(json.decode(r.body)); + } + + Future> transitionApplication( + int applicationId, + String status, + ) async { + final r = await http.post( + Uri.parse('$baseUrl/applications/$applicationId/transition'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'status': status}), + ); + if (r.statusCode != 200) throw Exception('Failed to transition'); + return json.decode(r.body); + } + + Future> getQueueItems({String? hrStatus}) async { + final url = hrStatus != null + ? '$baseUrl/queue?hr_status=$hrStatus' + : '$baseUrl/queue'; + final r = await http.get(Uri.parse(url)); + if (r.statusCode != 200) throw Exception('Failed to load queue'); + final data = json.decode(r.body); + return (data['items'] as List).map((e) => QueueItem.fromJson(e)).toList(); + } + + Future confirmQueueItem( + int queueId, { + String action = 'confirmed', + String status = '', + String realName = '', + String email = '', + String recruitmentTitle = '', + }) async { + final r = await http.patch( + Uri.parse('$baseUrl/queue/$queueId/confirm'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'action': action, + 'status': status, + 'real_name': realName, + 'email': email, + 'recruitment_title': recruitmentTitle, + }), + ); + if (r.statusCode != 200) throw Exception('Failed to confirm'); + } + + Future ignoreQueueItem(int queueId) async { + final r = await http.patch( + Uri.parse('$baseUrl/queue/$queueId/ignore'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'action': 'ignored'}), + ); + if (r.statusCode != 200) throw Exception('Failed to ignore'); + } + + Future?> getQueueByEmail(String email) async { + final r = await http.get( + Uri.parse('$baseUrl/queue/by-email?email=${Uri.encodeComponent(email)}'), + ); + if (r.statusCode != 200) return null; + final data = json.decode(r.body); + if (data['found'] == true) return data['item']; + return null; + } + + Future> getQueueStats() async { + final r = await http.get(Uri.parse('$baseUrl/queue/stats')); + if (r.statusCode != 200) throw Exception('Failed to load queue stats'); + return Map.from(json.decode(r.body)); + } + + Future> getPool() async { + final r = await http.get(Uri.parse('$baseUrl/pool')); + if (r.statusCode != 200) throw Exception('Failed to load pool'); + final data = json.decode(r.body) as List; + return data.map((e) => PoolItem.fromJson(e)).toList(); + } + + Future> poolApplication(int applicationId) async { + final r = await http.post( + Uri.parse('$baseUrl/applications/$applicationId/pool'), + headers: {'Content-Type': 'application/json'}, + ); + if (r.statusCode != 200) throw Exception('Failed to pool application'); + return json.decode(r.body); + } + + Future> unpoolApplication( + int applicationId, + int recruitmentId, + ) async { + final r = await http.post( + Uri.parse('$baseUrl/applications/$applicationId/unpool'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({'recruitment_id': recruitmentId}), + ); + if (r.statusCode != 201) throw Exception('Failed to unpool application'); + return json.decode(r.body); + } + + Future getHeadcount(int recruitmentId) async { + final r = await http.get( + Uri.parse('$baseUrl/recruitments/$recruitmentId/headcount'), + ); + if (r.statusCode != 200) throw Exception('Failed to load headcount'); + return Headcount.fromJson(json.decode(r.body)); + } + + Future getApplicationMaterials( + int applicationId, + ) async { + final r = await http.get( + Uri.parse('$baseUrl/applications/$applicationId/materials'), + ); + if (r.statusCode != 200) throw Exception('Failed to load materials'); + return ApplicationMaterials.fromJson(json.decode(r.body)); + } + + // ── Candidate messages ── + + Future> getCandidateMessages(int candidateId) async { + final r = await http.get( + Uri.parse('$baseUrl/candidates/$candidateId/messages'), + ); + if (r.statusCode != 200) throw Exception('Failed to load messages'); + final data = json.decode(r.body) as List; + return data.map((e) => MailMessage.fromJson(e)).toList(); + } + + Future> getCandidateTimeline(int candidateId) async { + final r = await http.get( + Uri.parse('$baseUrl/candidates/$candidateId/timeline'), + ); + if (r.statusCode != 200) throw Exception('Failed to load timeline'); + final data = json.decode(r.body) as List; + return data.map((e) => TimelineItem.fromJson(e)).toList(); + } + + Future replyToCandidate( + int candidateId, + int applicationId, + String subject, + String body, + ) async { + final r = await http.post( + Uri.parse('$baseUrl/candidates/$candidateId/reply'), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'application_id': applicationId, + 'subject': subject, + 'body': body, + 'body_text': body, + }), + ); + if (r.statusCode != 201) throw Exception('Failed to create reply'); + } + + /// Build attachment URL from storage_path and open in browser. + Future openAttachmentPreview( + String storagePath, + String filename, + ) async { + final idx = storagePath.indexOf('/attachments/'); + if (idx == -1) return; + final relPath = storagePath.substring(idx + '/attachments/'.length); + final encoded = relPath + .split('/') + .map((s) => Uri.encodeComponent(s)) + .join('/'); + final url = '$baseUrl/attachments/$encoded'; + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + // ── AI settings ── + + Future> getAIConfig() async { + final r = await http.get(Uri.parse('$baseUrl/ai/config')); + if (r.statusCode != 200) throw Exception('Failed to load AI config'); + return json.decode(r.body); + } + + Future updateAIConfig(Map config) async { + final r = await http.patch( + Uri.parse('$baseUrl/ai/config'), + headers: {'Content-Type': 'application/json'}, + body: json.encode(config), + ); + if (r.statusCode != 200) throw Exception('Failed to save AI config'); + } + + Future> testAIConnection() async { + final r = await http.post(Uri.parse('$baseUrl/ai/test')); + if (r.statusCode != 200) throw Exception('Failed to test AI connection'); + return json.decode(r.body); + } +} diff --git a/src/hr-kanban/lib/theme/hr_theme.dart b/src/hr-kanban/lib/theme/hr_theme.dart new file mode 100644 index 0000000..3ce021a --- /dev/null +++ b/src/hr-kanban/lib/theme/hr_theme.dart @@ -0,0 +1,175 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +class HrThemeExtension extends ThemeExtension { + const HrThemeExtension({ + required this.statusNew, + required this.statusContacted, + required this.statusExamSent, + required this.statusExamReceived, + required this.statusEvaluating, + required this.statusInterview, + required this.statusOffer, + required this.statusClosed, + required this.spacingXs, + required this.spacingSm, + required this.spacingMd, + required this.spacingLg, + required this.fontPageTitle, + required this.fontSectionTitle, + required this.fontBody, + required this.fontCaption, + required this.fontLabel, + }); + + final Color statusNew; + final Color statusContacted; + final Color statusExamSent; + final Color statusExamReceived; + final Color statusEvaluating; + final Color statusInterview; + final Color statusOffer; + final Color statusClosed; + final double spacingXs; + final double spacingSm; + final double spacingMd; + final double spacingLg; + final double fontPageTitle; + final double fontSectionTitle; + final double fontBody; + final double fontCaption; + final double fontLabel; + + @override + HrThemeExtension copyWith({ + Color? statusNew, + Color? statusContacted, + Color? statusExamSent, + Color? statusExamReceived, + Color? statusEvaluating, + Color? statusInterview, + Color? statusOffer, + Color? statusClosed, + double? spacingXs, + double? spacingSm, + double? spacingMd, + double? spacingLg, + double? fontPageTitle, + double? fontSectionTitle, + double? fontBody, + double? fontCaption, + double? fontLabel, + }) { + return HrThemeExtension( + statusNew: statusNew ?? this.statusNew, + statusContacted: statusContacted ?? this.statusContacted, + statusExamSent: statusExamSent ?? this.statusExamSent, + statusExamReceived: statusExamReceived ?? this.statusExamReceived, + statusEvaluating: statusEvaluating ?? this.statusEvaluating, + statusInterview: statusInterview ?? this.statusInterview, + statusOffer: statusOffer ?? this.statusOffer, + statusClosed: statusClosed ?? this.statusClosed, + spacingXs: spacingXs ?? this.spacingXs, + spacingSm: spacingSm ?? this.spacingSm, + spacingMd: spacingMd ?? this.spacingMd, + spacingLg: spacingLg ?? this.spacingLg, + fontPageTitle: fontPageTitle ?? this.fontPageTitle, + fontSectionTitle: fontSectionTitle ?? this.fontSectionTitle, + fontBody: fontBody ?? this.fontBody, + fontCaption: fontCaption ?? this.fontCaption, + fontLabel: fontLabel ?? this.fontLabel, + ); + } + + @override + HrThemeExtension lerp(ThemeExtension? other, double t) { + if (other is! HrThemeExtension) return this; + return HrThemeExtension( + statusNew: Color.lerp(statusNew, other.statusNew, t)!, + statusContacted: Color.lerp(statusContacted, other.statusContacted, t)!, + statusExamSent: Color.lerp(statusExamSent, other.statusExamSent, t)!, + statusExamReceived: Color.lerp(statusExamReceived, other.statusExamReceived, t)!, + statusEvaluating: Color.lerp(statusEvaluating, other.statusEvaluating, t)!, + statusInterview: Color.lerp(statusInterview, other.statusInterview, t)!, + statusOffer: Color.lerp(statusOffer, other.statusOffer, t)!, + statusClosed: Color.lerp(statusClosed, other.statusClosed, t)!, + spacingXs: lerpDouble(spacingXs, other.spacingXs, t)!, + spacingSm: lerpDouble(spacingSm, other.spacingSm, t)!, + spacingMd: lerpDouble(spacingMd, other.spacingMd, t)!, + spacingLg: lerpDouble(spacingLg, other.spacingLg, t)!, + fontPageTitle: lerpDouble(fontPageTitle, other.fontPageTitle, t)!, + fontSectionTitle: lerpDouble(fontSectionTitle, other.fontSectionTitle, t)!, + fontBody: lerpDouble(fontBody, other.fontBody, t)!, + fontCaption: lerpDouble(fontCaption, other.fontCaption, t)!, + fontLabel: lerpDouble(fontLabel, other.fontLabel, t)!, + ); + } +} + +ThemeData buildHrTheme() { + const hrTheme = HrThemeExtension( + statusNew: Color(0xFF6B7280), + statusContacted: Color(0xFF3B82F6), + statusExamSent: Color(0xFF6366F1), + statusExamReceived: Color(0xFFA855F7), + statusEvaluating: Color(0xFFEC4899), + statusInterview: Color(0xFF10B981), + statusOffer: Color(0xFF059669), + statusClosed: Color(0xFF4B5563), + spacingXs: 4.0, + spacingSm: 8.0, + spacingMd: 16.0, + spacingLg: 24.0, + fontPageTitle: 20.0, + fontSectionTitle: 15.0, + fontBody: 13.0, + fontCaption: 11.0, + fontLabel: 12.0, + ); + + return ThemeData( + brightness: Brightness.dark, + colorScheme: const ColorScheme.dark( + primary: Color(0xFF94A3B8), + secondary: Color(0xFF14B8A6), + surface: Color(0xFF1E2A32), + ), + scaffoldBackgroundColor: const Color(0xFF141D24), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF1A2630), + elevation: 0, + titleTextStyle: TextStyle(color: Color(0xFFE2E8F0), fontSize: 18, fontWeight: FontWeight.w600), + iconTheme: IconThemeData(color: Color(0xFF94A3B8)), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: const Color(0xFF1A2630), + indicatorColor: const Color(0xFF334155), + labelTextStyle: WidgetStatePropertyAll(const TextStyle(color: Color(0xFF94A3B8), fontSize: 12)), + ), + cardTheme: const CardThemeData( + color: Color(0xFF1E2A32), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8))), + ), + extensions: [hrTheme], + useMaterial3: true, + ); +} + +extension HrThemeContext on BuildContext { + Color statusColor(String status) { + final theme = Theme.of(this).extension()!; + return switch (status) { + 'new' => theme.statusNew, + 'contacted' => theme.statusContacted, + 'exam_sent' => theme.statusExamSent, + 'exam_received' => theme.statusExamReceived, + 'evaluating' => theme.statusEvaluating, + 'interview' => theme.statusInterview, + 'offer' => theme.statusOffer, + 'closed' => theme.statusClosed, + _ => theme.statusClosed, + }; + } +} diff --git a/src/hr-kanban/lib/widgets/info_row.dart b/src/hr-kanban/lib/widgets/info_row.dart new file mode 100644 index 0000000..e1898c0 --- /dev/null +++ b/src/hr-kanban/lib/widgets/info_row.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class InfoRow extends StatelessWidget { + final String label; + final String value; + final double labelWidth; + + const InfoRow({ + super.key, + required this.label, + required this.value, + this.labelWidth = 70, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: labelWidth, + child: Text( + label, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle(fontSize: 13, color: theme.colorScheme.onSurface), + ), + ), + ], + ), + ); + } +} diff --git a/src/hr-kanban/lib/widgets/state_views.dart b/src/hr-kanban/lib/widgets/state_views.dart new file mode 100644 index 0000000..4b98259 --- /dev/null +++ b/src/hr-kanban/lib/widgets/state_views.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + final IconData icon; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + const EmptyState({ + super.key, + this.icon = Icons.inbox_outlined, + required this.message, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: theme.colorScheme.onSurface.withAlpha(80)), + const SizedBox(height: 12), + Text(message, style: TextStyle(color: theme.colorScheme.onSurface.withAlpha(120), fontSize: 16)), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 12), + ElevatedButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ); + } +} + +class ErrorView extends StatelessWidget { + final String error; + final VoidCallback? onRetry; + + const ErrorView({super.key, required this.error, this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('加载失败: $error', style: TextStyle(color: Theme.of(context).colorScheme.error)), + if (onRetry != null) ...[ + const SizedBox(height: 12), + ElevatedButton(onPressed: onRetry, child: const Text('重试')), + ], + ], + ), + ); + } +} diff --git a/src/hr-kanban/lib/widgets/status_badge.dart b/src/hr-kanban/lib/widgets/status_badge.dart new file mode 100644 index 0000000..eb06f9c --- /dev/null +++ b/src/hr-kanban/lib/widgets/status_badge.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import '../theme/hr_theme.dart'; + +class StatusBadge extends StatelessWidget { + final String status; + final String? label; + + const StatusBadge({super.key, required this.status, this.label}); + + @override + Widget build(BuildContext context) { + final color = context.statusColor(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: color.withAlpha(30), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + label ?? status, + style: TextStyle(fontSize: 11, color: color), + ), + ); + } +} diff --git a/src/hr-kanban/pubspec.yaml b/src/hr-kanban/pubspec.yaml new file mode 100644 index 0000000..65064ca --- /dev/null +++ b/src/hr-kanban/pubspec.yaml @@ -0,0 +1,89 @@ +name: quanttide_hr_kanban +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+3 + +environment: + sdk: ^3.12.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + http: ^1.2.0 + url_launcher: ^6.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package 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"