Commit 2e37711d authored by 李文光's avatar 李文光

Initial commit

parents
Pipeline #884 failed with stages
# ---------- 环境与密钥 ----------
# 本地配置含密钥,一律不提交;保留 .env.example 模板
.env
.env.*
!.env.example
*.local
# ---------- 运行时数据(数据库、上传文件,默认落在项目根目录) ----------
/data/
/uploads/
*.db
*.sqlite
*.sqlite3
# ---------- 日志 ----------
*.log
# ---------- Python ----------
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
env/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
coverage.xml
# ---------- Node / 前端构建 ----------
node_modules/
dist/
dist-ssr/
.vite/
# ---------- 系统与编辑器 ----------
.DS_Store
Thumbs.db
Desktop.ini
.idea/
.vscode/
# 招聘系统(recruit-sys)
面向招聘 HR / 用人部门的一体化招聘管理工具,覆盖「岗位需求梳理 → JD 生成 → 简历采集与解析 → 候选人评估与匹配 → 面试跟进 → Offer 审批」全流程。三端分离:FastAPI 后端、Vue 3 网页前端、Chrome/Edge 浏览器扩展。
- 版本:`0.5.0`
- 后端:Python 3.11+ / FastAPI / SQLAlchemy 2 / Alembic / SQLite(可切换 MySQL)
- 前端:Vue 3 / Vite / Pinia / Vue Router / Element Plus / SCSS
- 扩展:Manifest V3,支持 BOSS 直聘、猎聘简历采集
## 目录结构
```
recruit-sys/
├── backend/ # FastAPI 后端(含数据模型、迁移、简历解析、AI 服务)
│ ├── app/ # 应用代码(routers / services / repositories)
│ ├── alembic/ # 数据库迁移
│ ├── scripts/ # 辅助脚本(启动、同步、简历解析 CLI)
│ ├── static/ # 网页前端发布目录(FastAPI 托管)
│ ├── tests/ # pytest 测试
│ ├── requirements.txt # 依赖入口(-e .[dev])
│ ├── pyproject.toml # 依赖与开发工具配置
│ ├── .env / .env.local # 后端配置(.env.local 为本机私有覆盖)
│ └── start-server.cmd # Windows 一键启动
├── vue-app/ # Vue 3 网页前端源码
│ └── dist/ # 前端生产构建产物
└── browser-extension/ # Chrome / Edge 简历采集扩展
```
## 快速开始
### 1. 启动后端
要求:Python 3.11+(首次运行需安装依赖)。
```powershell
# 项目根目录(D:\Hgny\Py\recruit-sys)
python -m pip install -e backend\[dev]
# 复制配置模板并按需修改(数据库、密钥、LLM Key 等)
copy backend\.env.example backend\.env
# 启动服务,默认 http://127.0.0.1:4177
python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload
```
首次启动会自动创建 `data/`(SQLite 数据库)与 `uploads/`(简历文件)目录,无需手工建表。也可用 Windows 脚本:
```powershell
backend\start-server.cmd
```
或进入 `backend/` 执行 `npm start`(会先尝试同步旧前端发布文件,见下文「已知问题」)。
### 2. 启动前端(开发模式)
要求:Node.js ≥ 18。
```powershell
cd vue-app
npm install
npm run dev
```
Vite 开发服务器默认运行在 `http://127.0.0.1:5173`,已配置 `/api` 代理到后端 `http://127.0.0.1:4177`(后端地址可用环境变量 `RECRUITMENT_BACKEND_PORT` 覆盖)。请先启动后端再访问前端。
### 3. 生产部署(前端 + 后端同源)
构建前端并让 FastAPI 直接托管页面,最终只需运行一个后端进程:
```powershell
# 构建前端,产物输出到 vue-app/dist/
cd vue-app
npm run build
# 把 dist/ 内容复制到后端静态目录(FastAPI 挂载在 /)
# 然后只需启动后端,访问 http://127.0.0.1:4177 即可
```
> 注意:`vue-app/dist/` 目前不会自动同步到 `backend/static/`,需手动复制或接入 CI。API 基地址默认留空(同源),可通过注入全局变量 `window.RECRUITMENT_API_BASE_URL` 指定后端地址。
### 4. 安装浏览器扩展(可选)
1. 启动后端(扩展通过 `http://127.0.0.1:4177/api/ingest` 提交简历);
2. Chrome / Edge 打开「扩展程序」页面并开启开发者模式;
3. 「加载已解压的扩展程序」,选择 `browser-extension/` 目录;
4. 在扩展弹窗确认 API 地址,可在 BOSS 直聘 / 猎聘候选人页面采集简历 PDF 同步到系统。
详细说明见 [browser-extension/README.md](browser-extension/README.md)
## 主要功能
- **待办项**:HR 跟进事项,关联候选人 / 岗位 / Offer。
- **招聘数据看板**:漏斗、来源、趋势、ROI 等基于本地数据的分析。
- **岗位管理**:岗位维护、JD 生成与版本迭代、招聘目标与状态流转。
- **JD 逼问式访谈**`/api/jd/grill/*` 确定式状态机,从一句话需求逐步追问出结构化岗位字段;敏感项(性别/年龄等)只进内部字段,不落入对外 JD。
- **简历库**:候选人管理、简历上传 / 解析(PDF / DOCX / TXT)、字段推断与匹配度评估。
- **AI 评估**:简历匹配分析走 LLM(OpenAI/DeepSeek 兼容接口);未配置 Key 时自动回退本地结构化规则。发送给 LLM 的候选人信息会做本地脱敏(姓名、手机、邮箱、年龄等)。
- **QwenPaw 接入(可选)**:JD 生成 / 简历分析可走独立的 QwenPaw 智能体服务(SSE 流式),失败自动回退。
- **对外 JD 同步**:接收企微 QwenPaw 产出的 JD 并创建 / 更新岗位(`/api/jd/sync-external`)。
- **录用入职管理**:Offer 草稿生成、审批材料与待入职跟进。
- **数据持久化与导入导出**:状态存 SQLite,支持 `GET/PUT /api/state`、localStorage 导入、JSON 备份导出。
- **简历采集接入**:浏览器扩展或外部系统通过 `/api/ingest` 推送简历,支持幂等队列、PDF 下载与解析。
## API 概览
后端路由前缀 `/api`(完整接口文档:启动后访问 `http://127.0.0.1:4177/docs`):
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/ping` | 健康检查(含待确认采集数) |
| GET / PUT | `/api/state` | 读取 / 全量保存系统状态 |
| POST | `/api/import/local-state` | 导入 localStorage 备份 |
| GET | `/api/export/state` | 导出 JSON 备份 |
| POST | `/api/parse-resume` | 上传简历并解析(PDF / DOCX / TXT) |
| POST | `/api/upload-resume` | 上传并保存简历文件 |
| GET | `/api/resumes/{filename}` | 下载已保存简历 |
| POST | `/api/analyze-resume` | AI 简历匹配分析(LLM,失败回退本地) |
| POST | `/api/generate-jd` | 生成 / 迭代 JD 草稿 |
| POST | `/api/jd/completeness` | 评估岗位信息完整度 |
| POST | `/api/jd/grill/start` / `/advance` | JD 逼问式访谈状态机 |
| POST | `/api/qwenpaw/chat` | QwenPaw SSE 流式聊天 |
| POST | `/api/jd/sync-external` | 对外 JD 同步(创建/更新岗位) |
| POST | `/api/ingest` | 浏览器扩展 / 外部简历采集入口 |
| GET | `/api/ingest-queue` | 查看待确认采集队列 |
| POST | `/api/ingest-queue/clear` | 确认并清空采集队列 |
## 后端配置
复制 `backend/.env.example``backend/.env` 后修改。关键项:
| 变量 | 默认 | 说明 |
| --- | --- | --- |
| `HOST` / `PORT` | `127.0.0.1` / `4177` | 服务监听地址与端口 |
| `DATA_DIR` | `./data` | 运行时数据目录(生产建议放源码目录外) |
| `FILES_DIR` | `./uploads` | 简历等上传文件目录 |
| `DATABASE_URL` | `sqlite:///./data/recruitment.sqlite` | 数据库;可切换 MySQL(`mysql+pymysql://...`,需 `pip install -e backend[mysql]`) |
| `ADMIN_PASSWORD` | 空 | 设置后启用 HTTP Basic 登录(除 ping / 采集接口外) |
| `SESSION_SECRET` | 开发默认值 | 部署时请修改 |
| `INGEST_TOKEN` | 空 | 设置后 `/api/ingest``/api/jd/sync-external``X-Ingest-Token``?token=` |
| `CORS_ORIGINS` | `http://127.0.0.1:5173,...` | 允许的跨域来源,逗号分隔 |
| `MAX_UPLOAD_MB` | `20` | 上传大小限制(MB) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | 空 | OpenAI 兼容模型;或使用 `DEEPSEEK_*` / `OPENAI_*`,优先级 `LLM_*` > `DEEPSEEK_*` > `OPENAI_*` |
| `QWENPAW_ENABLED` 等 | `false` | QwenPaw 独立智能体服务接入(可选) |
## 数据库迁移
项目默认 `create_all` 自动建表;已有 Alembic 迁移支持,需要时执行:
```powershell
python -m alembic -c backend/alembic.ini upgrade head
```
## 测试
```powershell
python -m pytest -c backend/pyproject.toml
```
## 常用命令
```powershell
# 后端(项目根目录)
python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload # 开发
python -m pytest -c backend/pyproject.toml # 测试
python -m alembic -c backend/alembic.ini upgrade head # 迁移
# 简历解析 CLI(不启动服务,直接解析简历文件)
python backend/scripts/parse_resume.py <简历文件>
# 前端(vue-app/ 目录)
npm run dev # 开发服务器 http://127.0.0.1:5173
npm run build # 生产构建到 dist/
npm run preview # 预览生产构建
npm run lint # ESLint 检查
npm run format # Prettier 格式化
```
## 已知问题
- 后端 `package.json``start` / `dev` / `sync:static` 脚本调用 `backend/scripts/sync_frontend_static.py`,该脚本依赖仓库根目录的旧 `frontend/` 模块;该目录已不存在,脚本现在会报「Required frontend asset is missing」。因此请直接用上文的 `uvicorn` 命令启动,不要依赖 `npm start`。旧脚本保留用于兼容历史流程,可随时清理。
- `backend/static/` 目录当前为空,需要「生产部署(同源托管)」时请按上文手动同步 `vue-app/dist/`
## 更多文档
- [backend/README.md](backend/README.md):后端目录说明与常用命令
- [vue-app/README.md](vue-app/README.md):前端技术栈与目录结构
- [browser-extension/README.md](browser-extension/README.md):浏览器扩展安装与使用
# 服务监听
HOST=127.0.0.1
PORT=4177
# 运行时目录。生产/部门部署时建议放在源码目录之外,例如 D:\RecruitmentSystemData
DATA_DIR=./data
FILES_DIR=./uploads
# 数据库。默认 SQLite;正式部署可切换 MySQL。
DATABASE_URL=sqlite:///./data/recruitment.sqlite
# MySQL 示例:
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/recruitment?charset=utf8mb4
# 安全设置
# 首次部门部署请设置为强密码。未设置时默认保持本地 MVP 兼容,不强制登录。
ADMIN_PASSWORD=
SESSION_SECRET=please-change-this-in-deployment
# 浏览器扩展采集接口令牌;设置后 /api/ingest 需要 X-Ingest-Token 或 ?token=
INGEST_TOKEN=
# 逗号分隔,独立前端默认运行在 5173 端口。
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
# 上传限制,单位 MB
MAX_UPLOAD_MB=20
# OpenAI/DeepSeek 兼容模型配置。三组变量按 LLM_* > DEEPSEEK_* > OPENAI_* 优先级读取。
LLM_API_KEY=
LLM_BASE_URL=
LLM_MODEL=
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_MODEL=gpt-4o-mini
# QwenPaw 独立智能体服务接入(可选)。启用后 JD 生成 / 简历分析可走 QwenPaw,信息不全时前端会切换为 AI 追问。
QWENPAW_ENABLED=false
QWENPAW_BASE_URL=http://127.0.0.1:8088
QWENPAW_AGENT_ID=jd-agent
QWENPAW_SESSION_PREFIX=recruitment
QWENPAW_TIMEOUT=120
# ---------- 环境与密钥 ----------
# 本机配置含密钥,一律不提交;保留 .env.example 模板
.env
.env.local
.env.*
!.env.example
# ---------- 运行日志 ----------
server.out.log
server.err.log
*.log
# ---------- Python ----------
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
coverage.xml
# ---------- 生成产物 ----------
# 前端发布目录:由 vue-app/dist 构建产物同步生成,不直接入库
static/
# ---------- 系统与编辑器 ----------
.DS_Store
Thumbs.db
Desktop.ini
.idea/
.vscode/
# 招聘系统后端
本目录包含 FastAPI API、数据模型与迁移、简历解析、后端测试、后端运行配置,以及由 FastAPI 托管的网页发布文件(`static/`)。
## 启动
要求:Python 3.11+。在项目根目录(`recruit-sys/`)执行:
```powershell
python -m pip install -e backend\[dev]
copy backend\.env.example backend\.env # 首次:复制并按需修改配置
python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload
```
- 首次启动自动创建 `data/`(SQLite 数据库)与 `uploads/`(简历文件)目录,并自动建表。
- 启动后访问 `http://127.0.0.1:4177/`;API 位于 `/api/*`,交互式文档在 `/docs`
- Windows 下也可直接运行 `backend\start-server.cmd`(启动并输出日志到 `backend/server.out.log` / `server.err.log`)。
## 前端发布文件
`static/` 是网页前端发布目录,由 FastAPI 挂载在 `/`。当前 `static/` 为空:构建后的 Vue 前端产物在 `vue-app/dist/`,需要同源部署时手动复制过去:
```powershell
# 构建前端
cd vue-app
npm run build
# 将 dist/ 内容复制到 backend/static/(此后只需运行后端即可访问页面)
```
> 历史脚本 `scripts/sync_frontend_static.py`(及 `backend/package.json` 的 `start` / `dev` / `sync:static`)依赖仓库根目录的旧 `frontend/` 模块,该目录已不存在,脚本会报「Required frontend asset is missing」,请勿使用;如需删除可自行清理。
## 配置文件
- `.env.example`:可复制的配置模板。
- `.env`:本机或部署环境的后端配置。
- `.env.local`:本机私有覆盖配置,不提交到 Git。
- `pyproject.toml` / `requirements.txt`:Python 依赖与开发工具配置。
- `alembic.ini`:数据库迁移配置。
## 常用命令
```powershell
# 在项目根目录执行
python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload
python -m pytest -c backend/pyproject.toml
python -m alembic -c backend/alembic.ini upgrade head
python backend/scripts/parse_resume.py <简历文件> # 简历解析 CLI,不启动服务
```
[alembic]
script_location = %(here)s/alembic
prepend_sys_path = %(here)s/..
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
from logging.config import fileConfig
from backend.app import models # noqa: F401
from backend.app.config import get_settings
from backend.app.db import Base, normalize_database_url
from sqlalchemy import engine_from_config, pool
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
settings = get_settings()
config.set_main_option("sqlalchemy.url", normalize_database_url(settings.database_url))
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
"""initial FastAPI schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-08-27
"""
from backend.app import models # noqa: F401
from backend.app.db import Base
from alembic import op
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade() -> None:
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)
from functools import lru_cache
from pathlib import Path
from pydantic import Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_ROOT = Path(__file__).resolve().parents[2]
STATIC_ROOT = PROJECT_ROOT / "backend" / "static"
def _env_files() -> tuple[str, ...]:
import os
if os.getenv("RECRUITMENT_SKIP_ENV_FILES") == "1":
return ()
return tuple(str(path) for path in (PROJECT_ROOT / "backend" / ".env", PROJECT_ROOT / "backend" / ".env.local") if path.exists())
def _resolve_path(value: str | Path) -> Path:
path = Path(value)
return path if path.is_absolute() else PROJECT_ROOT / path
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=_env_files(), env_file_encoding="utf-8", extra="ignore")
host: str = Field(default="127.0.0.1", alias="HOST")
port: int = Field(default=4177, alias="PORT")
data_dir_raw: str = Field(default="./data", alias="DATA_DIR")
files_dir_raw: str = Field(default="./uploads", alias="FILES_DIR")
database_url: str = Field(default="sqlite:///./data/recruitment.sqlite", alias="DATABASE_URL")
admin_password: str = Field(default="", alias="ADMIN_PASSWORD")
session_secret: str = Field(default="dev-session-secret", alias="SESSION_SECRET")
ingest_token: str = Field(default="", alias="INGEST_TOKEN")
cors_origins_raw: str = Field(
default="http://127.0.0.1:5173,http://localhost:5173", alias="CORS_ORIGINS"
)
max_upload_mb: int = Field(default=20, alias="MAX_UPLOAD_MB")
llm_api_key: str = Field(default="", alias="LLM_API_KEY")
llm_base_url: str = Field(default="", alias="LLM_BASE_URL")
llm_model: str = Field(default="", alias="LLM_MODEL")
deepseek_api_key: str = Field(default="", alias="DEEPSEEK_API_KEY")
deepseek_base_url: str = Field(default="https://api.deepseek.com", alias="DEEPSEEK_BASE_URL")
deepseek_model: str = Field(default="deepseek-chat", alias="DEEPSEEK_MODEL")
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
openai_base_url: str = Field(default="https://api.openai.com/v1", alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4o-mini", alias="OPENAI_MODEL")
# QwenPaw 智能体服务接入。启用后 JD 生成 / 简历分析可走 QwenPaw 独立服务。
qwenpaw_enabled: bool = Field(default=False, alias="QWENPAW_ENABLED")
qwenpaw_base_url: str = Field(default="http://127.0.0.1:8088", alias="QWENPAW_BASE_URL")
qwenpaw_agent_id: str = Field(default="jd-agent", alias="QWENPAW_AGENT_ID")
qwenpaw_session_prefix: str = Field(default="recruitment", alias="QWENPAW_SESSION_PREFIX")
qwenpaw_timeout: float = Field(default=120.0, alias="QWENPAW_TIMEOUT")
@computed_field
@property
def data_dir(self) -> Path:
return _resolve_path(self.data_dir_raw)
@computed_field
@property
def files_dir(self) -> Path:
return _resolve_path(self.files_dir_raw)
@computed_field
@property
def resumes_dir(self) -> Path:
return self.files_dir / "resumes"
@computed_field
@property
def cors_origins(self) -> list[str]:
return [item.strip() for item in self.cors_origins_raw.split(",") if item.strip()]
@computed_field
@property
def effective_llm_api_key(self) -> str:
return self.llm_api_key or self.deepseek_api_key or self.openai_api_key
@computed_field
@property
def effective_llm_base_url(self) -> str:
base_url = (
self.llm_base_url
or self.deepseek_base_url
or self.openai_base_url
or ("https://api.deepseek.com" if self.deepseek_api_key else "https://api.openai.com/v1")
)
return base_url.rstrip("/")
@computed_field
@property
def effective_llm_model(self) -> str:
return (
self.llm_model
or self.deepseek_model
or self.openai_model
or ("deepseek-chat" if self.deepseek_api_key else "gpt-4o-mini")
)
def ensure_runtime_dirs(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True)
self.files_dir.mkdir(parents=True, exist_ok=True)
self.resumes_dir.mkdir(parents=True, exist_ok=True)
if self.database_url.startswith("sqlite:///"):
sqlite_path = self.database_url.removeprefix("sqlite:///")
_resolve_path(sqlite_path).parent.mkdir(parents=True, exist_ok=True)
@lru_cache
def get_settings() -> Settings:
return Settings()
from collections.abc import Generator
from pathlib import Path
from sqlalchemy import create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from backend.app.config import PROJECT_ROOT, get_settings
class Base(DeclarativeBase):
pass
def normalize_database_url(url: str) -> str:
if not url.startswith("sqlite:///"):
return url
raw_path = url.removeprefix("sqlite:///")
db_path = Path(raw_path)
if not db_path.is_absolute():
db_path = PROJECT_ROOT / db_path
return f"sqlite:///{db_path.as_posix()}"
settings = get_settings()
settings.ensure_runtime_dirs()
engine = create_engine(
normalize_database_url(settings.database_url),
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
future=True,
)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): # noqa: ARG001
if engine.url.get_backend_name() != "sqlite":
return
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_all() -> None:
from backend.app import models # noqa: F401
Base.metadata.create_all(bind=engine)
import uvicorn
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from backend.app.config import STATIC_ROOT, get_settings
from backend.app.db import create_all
from backend.app.routers import ai, health, ingest, resume, state
from backend.app.security import DeploymentAuthMiddleware
def create_app() -> FastAPI:
settings = get_settings()
settings.ensure_runtime_dirs()
create_all()
app = FastAPI(title="招聘系统", version="0.5.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Ingest-Token", "Authorization"],
allow_credentials=False,
)
app.add_middleware(DeploymentAuthMiddleware)
app.include_router(health.router, prefix="/api")
app.include_router(state.router, prefix="/api")
app.include_router(resume.router, prefix="/api")
app.include_router(ai.router, prefix="/api")
app.include_router(ingest.router, prefix="/api")
@app.get("/", include_in_schema=False)
async def homepage() -> RedirectResponse:
return RedirectResponse(url="/index.html", status_code=307)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: # noqa: ARG001
detail = exc.detail if isinstance(exc.detail, str) else "请求失败"
return JSONResponse({"error": detail}, status_code=exc.status_code)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: # noqa: ARG001
return JSONResponse({"error": "请求参数不正确", "details": exc.errors()}, status_code=422)
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: # noqa: ARG001
return JSONResponse({"error": str(exc) or "服务器错误"}, status_code=500)
app.mount("/", StaticFiles(directory=STATIC_ROOT, html=False), name="frontend")
return app
app = create_app()
def run() -> None:
settings = get_settings()
uvicorn.run("backend.app.main:app", host=settings.host, port=settings.port, reload=False)
if __name__ == "__main__":
run()
from datetime import UTC, datetime
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.db import Base
def utc_now_iso() -> str:
return datetime.now(UTC).isoformat()
class SchemaMigration(Base):
__tablename__ = "schema_migrations"
version: Mapped[int] = mapped_column(Integer, primary_key=True)
applied_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class AppStateMeta(Base):
__tablename__ = "app_state_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
updated_at: Mapped[str] = mapped_column(String(40), nullable=False)
imported_from: Mapped[str] = mapped_column(String(80), default="server", nullable=False)
raw_snapshot: Mapped[str | None] = mapped_column(Text)
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(40), default="admin", nullable=False)
password_hash: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class Job(Base):
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
department: Mapped[str | None] = mapped_column(String(120))
status: Mapped[str | None] = mapped_column(String(80))
owner: Mapped[str | None] = mapped_column(String(120))
priority: Mapped[str | None] = mapped_column(String(40))
headcount: Mapped[int | None] = mapped_column(Integer)
hired: Mapped[int | None] = mapped_column(Integer)
jd_status: Mapped[str | None] = mapped_column(String(80))
approval_status: Mapped[str | None] = mapped_column(String(120))
jd_version: Mapped[str | None] = mapped_column(String(80))
match_rule_status: Mapped[str | None] = mapped_column(String(80))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class JdVersion(Base):
__tablename__ = "jd_versions"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
job_id: Mapped[str] = mapped_column(ForeignKey("jobs.id", ondelete="CASCADE"), nullable=False)
version: Mapped[str] = mapped_column(String(80), nullable=False)
status: Mapped[str | None] = mapped_column(String(80))
note: Mapped[str | None] = mapped_column(Text)
content: Mapped[str | None] = mapped_column(Text)
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class Candidate(Base):
__tablename__ = "candidates"
id: Mapped[str] = mapped_column(String(80), primary_key=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
primary_job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
job_title: Mapped[str | None] = mapped_column(String(200))
source: Mapped[str | None] = mapped_column(String(80))
stage: Mapped[str | None] = mapped_column(String(80))
phone: Mapped[str | None] = mapped_column(String(80))
email: Mapped[str | None] = mapped_column(String(160))
education: Mapped[str | None] = mapped_column(String(80))
school: Mapped[str | None] = mapped_column(String(160))
major: Mapped[str | None] = mapped_column(String(160))
city: Mapped[str | None] = mapped_column(String(80))
years: Mapped[str | None] = mapped_column(String(80))
tag: Mapped[str | None] = mapped_column(String(80))
match_score: Mapped[int | None] = mapped_column(Integer)
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class CandidateApplication(Base):
__tablename__ = "candidate_applications"
__table_args__ = (UniqueConstraint("candidate_id", "job_id", name="uq_candidate_application"),)
id: Mapped[str] = mapped_column(String(160), primary_key=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
stage: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str | None] = mapped_column(String(80))
source: Mapped[str | None] = mapped_column(String(80))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class ResumeFile(Base):
__tablename__ = "resume_files"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
original_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str | None] = mapped_column(String(40))
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
source_channel: Mapped[str | None] = mapped_column(String(80))
file_hash: Mapped[str | None] = mapped_column(String(80), unique=True)
parse_status: Mapped[str] = mapped_column(String(40), default="pending", nullable=False)
size_bytes: Mapped[int | None] = mapped_column(Integer)
data: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
uploaded_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class ResumeText(Base):
__tablename__ = "resume_texts"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
resume_file_id: Mapped[str] = mapped_column(ForeignKey("resume_files.id", ondelete="CASCADE"), nullable=False)
extraction_method: Mapped[str] = mapped_column(String(80), default="text_layer", nullable=False)
raw_text: Mapped[str | None] = mapped_column(Text)
cleaned_markdown: Mapped[str | None] = mapped_column(Text)
text_quality_score: Mapped[int | None] = mapped_column(Integer)
quality_flags: Mapped[str] = mapped_column(Text, default="[]", nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class ResumeProfile(Base):
__tablename__ = "resume_profiles"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"))
resume_file_id: Mapped[str | None] = mapped_column(ForeignKey("resume_files.id", ondelete="SET NULL"))
basic_info: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
profile_json: Mapped[str] = mapped_column(Text, nullable=False)
parse_model: Mapped[str | None] = mapped_column(String(120))
parse_version: Mapped[str | None] = mapped_column(String(120))
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class CandidateMatchReport(Base):
__tablename__ = "candidate_match_reports"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
resume_profile_id: Mapped[str | None] = mapped_column(ForeignKey("resume_profiles.id", ondelete="SET NULL"))
score: Mapped[int | None] = mapped_column(Integer)
segment: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(40), default="generated", nullable=False)
report_json: Mapped[str] = mapped_column(Text, nullable=False)
generated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class Interview(Base):
__tablename__ = "interviews"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), nullable=False)
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
round: Mapped[str | None] = mapped_column(String(80))
scheduled_at: Mapped[str | None] = mapped_column(String(80))
interviewer: Mapped[str | None] = mapped_column(String(120))
result: Mapped[str | None] = mapped_column(String(80))
feedback: Mapped[str | None] = mapped_column(Text)
data: Mapped[str] = mapped_column(Text, default="{}", nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class Offer(Base):
__tablename__ = "offers"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
candidate_id: Mapped[str] = mapped_column(ForeignKey("candidates.id", ondelete="CASCADE"), unique=True, nullable=False)
status: Mapped[str | None] = mapped_column(String(80))
salary: Mapped[str | None] = mapped_column(String(120))
start_date: Mapped[str | None] = mapped_column(String(80))
risk: Mapped[str | None] = mapped_column(String(80))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
module: Mapped[str | None] = mapped_column(String(80))
target: Mapped[str | None] = mapped_column(String(200))
due: Mapped[str | None] = mapped_column(String(80))
priority: Mapped[str | None] = mapped_column(String(40))
status: Mapped[str | None] = mapped_column(String(80))
type: Mapped[str | None] = mapped_column(String(80))
current_node: Mapped[str | None] = mapped_column(String(120))
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
offer_id: Mapped[str | None] = mapped_column(ForeignKey("offers.id", ondelete="SET NULL"))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
updated_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class RecruitmentEvent(Base):
__tablename__ = "recruitment_events"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
type: Mapped[str] = mapped_column(String(80), nullable=False)
at: Mapped[str] = mapped_column(String(40), nullable=False)
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
candidate_name: Mapped[str | None] = mapped_column(String(120))
job_id: Mapped[str | None] = mapped_column(ForeignKey("jobs.id", ondelete="SET NULL"))
job_title: Mapped[str | None] = mapped_column(String(200))
source: Mapped[str | None] = mapped_column(String(80))
stage: Mapped[str | None] = mapped_column(String(80))
data: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
class IngestItem(Base):
__tablename__ = "ingest_items"
id: Mapped[str] = mapped_column(String(120), primary_key=True)
idempotency_key: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
confirmed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confirmed_at: Mapped[str | None] = mapped_column(String(40))
candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidates.id", ondelete="SET NULL"))
resume_file_id: Mapped[str | None] = mapped_column(ForeignKey("resume_files.id", ondelete="SET NULL"))
source: Mapped[str | None] = mapped_column(String(80))
source_url: Mapped[str | None] = mapped_column(String(500))
payload_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String(40), default=utc_now_iso, nullable=False)
Index("idx_candidates_primary_job", Candidate.primary_job_id)
Index("idx_candidates_source", Candidate.source)
Index("idx_candidates_stage", Candidate.stage)
Index("idx_tasks_status", Task.status)
Index("idx_events_at", RecruitmentEvent.at)
Index("idx_ingest_confirmed", IngestItem.confirmed)
import hashlib
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import (
AppStateMeta,
Candidate,
IngestItem,
RecruitmentEvent,
ResumeFile,
ResumeProfile,
ResumeText,
utc_now_iso,
)
from backend.app.repositories.json_utils import dump_json, parse_json
from backend.app.services.file_storage import new_id
def build_ingest_key(raw: dict[str, Any], file_hash: str = "") -> str:
stable = "|".join(
str(item or "")
for item in [
raw.get("source"),
raw.get("sourceUrl"),
raw.get("pdfUrl"),
file_hash,
raw.get("name"),
raw.get("jobTitle"),
]
)
return hashlib.sha256(stable.encode("utf-8")).hexdigest()
def upsert_ingest_candidate(
session: Session, raw: dict[str, Any], parsed: dict[str, Any], saved_file: dict[str, Any] | None
) -> dict[str, Any]:
key = build_ingest_key(raw, saved_file.get("fileHash") if saved_file else "")
existing = session.scalar(select(IngestItem).where(IngestItem.idempotency_key == key))
if existing:
return {"ok": True, "skipped": True, "id": existing.id, "candidateId": existing.candidate_id}
at = utc_now_iso()
candidate_id = new_id("cand")
ingest_id = new_id("ingest")
resume_file_id = new_id("resume-file") if saved_file else None
resume_text_id = new_id("resume-text") if saved_file else None
profile_id = new_id("resume-profile")
source = str(raw.get("source") or parsed.get("source") or "其他")[:40]
resume_text = str(parsed.get("resumeText") or raw.get("summary") or "")
candidate = {
"id": candidate_id,
"name": str(parsed.get("name") or raw.get("name") or "未识别")[:60],
"jobTitle": str(parsed.get("jobTitle") or raw.get("jobTitle") or "")[:100],
"source": source,
"sourceUrl": raw.get("sourceUrl") or "",
"stage": "未筛选",
"phone": parsed.get("phone") or raw.get("phone") or "",
"email": parsed.get("email") or raw.get("email") or "",
"birthDate": parsed.get("birthDate") or "",
"age": parsed.get("age") or raw.get("age") or "",
"years": parsed.get("years") or raw.get("workYears") or raw.get("years") or "",
"education": parsed.get("education") or raw.get("education") or "",
"school": parsed.get("school") or raw.get("school") or "",
"major": parsed.get("major") or raw.get("major") or "",
"schoolTags": parsed.get("schoolTags") or [],
"city": parsed.get("city") or raw.get("city") or "",
"skills": parsed.get("skills") or raw.get("skills") or [],
"resumeText": resume_text,
"resumeName": saved_file.get("originalName") if saved_file else parsed.get("resumeName") or f"{raw.get('name') or '未识别'}_{source}",
"resumeFileDataUrl": f"/api/resumes/{saved_file['storedName']}" if saved_file else "",
"resumeMeta": {
"name": saved_file.get("originalName"),
"type": saved_file.get("fileType"),
"size": saved_file.get("sizeBytes"),
"hash": saved_file.get("fileHash"),
}
if saved_file
else None,
"tag": "新入库",
"evaluation": f"期望薪资:{raw.get('salary')}" if raw.get("salary") else "平台采集入库",
"createdAt": at,
"updatedAt": at,
}
session.add(
Candidate(
id=candidate_id,
name=candidate["name"],
job_title=candidate["jobTitle"],
source=source,
stage="未筛选",
phone=candidate["phone"],
email=candidate["email"],
education=candidate["education"],
school=candidate["school"],
major=candidate["major"],
city=candidate["city"],
years=candidate["years"],
tag="新入库",
match_score=0,
data=dump_json(candidate),
created_at=at,
updated_at=at,
)
)
if saved_file:
session.add(
ResumeFile(
id=resume_file_id,
candidate_id=candidate_id,
original_name=saved_file["originalName"],
file_type=saved_file["fileType"],
storage_path=saved_file["storageKey"],
source_channel=source,
file_hash=saved_file["fileHash"],
parse_status="needs_manual" if parsed.get("parseWarning") else "parsed",
size_bytes=saved_file["sizeBytes"],
data=dump_json(saved_file),
uploaded_at=at,
)
)
session.add(
ResumeText(
id=resume_text_id,
resume_file_id=resume_file_id,
extraction_method="text_layer",
raw_text=resume_text,
cleaned_markdown=resume_text,
text_quality_score=1 if resume_text else 0,
quality_flags="[]",
created_at=at,
)
)
session.add(
ResumeProfile(
id=profile_id,
candidate_id=candidate_id,
resume_file_id=resume_file_id,
basic_info=dump_json(
{"name": candidate["name"], "phone": candidate["phone"], "email": candidate["email"], "city": candidate["city"]}
),
profile_json=dump_json(parsed or {}),
parse_model="local-python",
parse_version="parse_resume.py",
created_at=at,
)
)
event = {
"id": new_id("evt"),
"type": "candidate_created",
"at": at,
"candidateId": candidate_id,
"candidateName": candidate["name"],
"jobId": "",
"jobTitle": candidate["jobTitle"] or "待匹配岗位",
"source": source,
"stage": "未筛选",
"note": "平台采集后端入库",
}
session.add(
RecruitmentEvent(
id=event["id"],
type=event["type"],
at=at,
candidate_id=candidate_id,
candidate_name=candidate["name"],
job_id=None,
job_title=event["jobTitle"],
source=source,
stage="未筛选",
data=dump_json(event),
created_at=at,
)
)
payload = {
"source": source,
"sourceUrl": raw.get("sourceUrl") or "",
"pdfUrl": raw.get("pdfUrl") or "",
"extractedAt": raw.get("extractedAt") or at,
"raw": raw,
"parsed": parsed,
"file": saved_file,
}
session.add(
IngestItem(
id=ingest_id,
idempotency_key=key,
confirmed=False,
candidate_id=candidate_id,
resume_file_id=resume_file_id,
source=source,
source_url=raw.get("sourceUrl") or "",
payload_json=dump_json(payload),
created_at=at,
)
)
session.merge(AppStateMeta(id=1, updated_at=at, imported_from="ingest", raw_snapshot=None))
session.commit()
return {"ok": True, "id": ingest_id, "candidateId": candidate_id, "resumeFileId": resume_file_id}
def list_pending_ingest(session: Session) -> list[dict[str, Any]]:
rows = session.scalars(select(IngestItem).where(IngestItem.confirmed.is_(False)).order_by(IngestItem.created_at.desc())).all()
queue = []
for row in rows:
payload = parse_json(row.payload_json, {})
candidate = session.get(Candidate, row.candidate_id) if row.candidate_id else None
candidate_data = parse_json(candidate.data if candidate else None, {})
queue.append(
{
"id": row.id,
"createdAt": row.created_at,
"candidateId": row.candidate_id,
"resume": {
"name": candidate_data.get("name") or (payload.get("parsed") or {}).get("name") or (payload.get("raw") or {}).get("name") or "",
"jobTitle": candidate_data.get("jobTitle") or (payload.get("parsed") or {}).get("jobTitle") or (payload.get("raw") or {}).get("jobTitle") or "",
"source": candidate_data.get("source") or payload.get("source") or "其他",
"sourceUrl": payload.get("sourceUrl") or "",
"pdfUrl": payload.get("pdfUrl") or "",
"resumeFileUrl": candidate_data.get("resumeFileDataUrl") or "",
"education": candidate_data.get("education") or "",
"workYears": candidate_data.get("years") or "",
"city": candidate_data.get("city") or "",
"skills": candidate_data.get("skills") or [],
"summary": candidate_data.get("resumeText") or (payload.get("raw") or {}).get("summary") or "",
"extractedAt": payload.get("extractedAt") or "",
},
}
)
return queue
def confirm_ingest(session: Session, ids: list[str]) -> None:
at = utc_now_iso()
query = select(IngestItem).where(IngestItem.confirmed.is_(False))
if ids:
query = query.where(IngestItem.id.in_(ids))
for item in session.scalars(query):
item.confirmed = True
item.confirmed_at = at
session.commit()
import json
from typing import Any
def parse_json(value: str | None, fallback: Any = None) -> Any:
if not value:
return fallback
try:
return json.loads(value)
except json.JSONDecodeError:
return fallback
def dump_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def pretty_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2)
def as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
from typing import Any
from uuid import uuid4
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from backend.app.models import (
AppStateMeta,
Candidate,
CandidateApplication,
CandidateMatchReport,
JdVersion,
Job,
Offer,
RecruitmentEvent,
ResumeFile,
ResumeProfile,
ResumeText,
Task,
utc_now_iso,
)
from backend.app.repositories.json_utils import as_list, dump_json, parse_json
EMPTY_STATE: dict[str, Any] = {
"jobs": [],
"candidates": [],
"offers": [],
"tasks": [],
"eventLog": [],
"updatedAt": "",
}
def _id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:16]}"
def _int(value: Any, default: int = 0) -> int:
try:
return int(value or default)
except (TypeError, ValueError):
return default
def state_with_defaults(state: dict[str, Any] | None) -> dict[str, Any]:
state = state or {}
merged = {**EMPTY_STATE, **state}
merged["jobs"] = as_list(merged.get("jobs"))
merged["candidates"] = as_list(merged.get("candidates"))
merged["offers"] = as_list(merged.get("offers"))
merged["tasks"] = as_list(merged.get("tasks"))
merged["eventLog"] = as_list(merged.get("eventLog"))
merged["updatedAt"] = merged.get("updatedAt") or utc_now_iso()
return merged
def database_is_empty(session: Session) -> bool:
tables = [Job, Candidate, Offer, Task, RecruitmentEvent]
return all(session.scalar(select(func.count()).select_from(table)) == 0 for table in tables)
def read_state(session: Session) -> dict[str, Any]:
jobs = [parse_json(item.data, {}) for item in session.scalars(select(Job).order_by(Job.created_at, Job.id))]
candidates = [
parse_json(item.data, {})
for item in session.scalars(select(Candidate).order_by(Candidate.created_at.desc(), Candidate.id.desc()))
]
offers = [
parse_json(item.data, {})
for item in session.scalars(select(Offer).order_by(Offer.created_at.desc(), Offer.id.desc()))
]
tasks = [
parse_json(item.data, {})
for item in session.scalars(select(Task).order_by(Task.created_at.desc(), Task.id.desc()))
]
events = [
parse_json(item.data, {})
for item in session.scalars(
select(RecruitmentEvent).order_by(RecruitmentEvent.at.desc(), RecruitmentEvent.id.desc())
)
]
meta = session.get(AppStateMeta, 1)
if not jobs and not candidates and not offers and not tasks and not events and meta and meta.raw_snapshot:
return state_with_defaults(parse_json(meta.raw_snapshot, EMPTY_STATE))
return state_with_defaults(
{
"jobs": jobs,
"candidates": candidates,
"offers": offers,
"tasks": tasks,
"eventLog": events,
"updatedAt": meta.updated_at if meta else utc_now_iso(),
}
)
def replace_state(session: Session, input_state: dict[str, Any], imported_from: str = "api") -> dict[str, Any]:
state = state_with_defaults(input_state)
state["updatedAt"] = utc_now_iso()
for model in (
CandidateMatchReport,
ResumeProfile,
ResumeText,
ResumeFile,
CandidateApplication,
RecruitmentEvent,
Task,
Offer,
Candidate,
JdVersion,
Job,
):
session.execute(delete(model))
for job in state["jobs"]:
job_id = job.get("id") or _id("job")
job = {**job, "id": job_id}
created_at = job.get("createdAt") or job.get("created_at") or state["updatedAt"]
session.add(
Job(
id=job_id,
title=job.get("title") or "未命名岗位",
department=job.get("department") or "",
status=job.get("status") or "",
owner=job.get("owner") or "",
priority=job.get("priority") or "",
headcount=_int(job.get("headcount")),
hired=_int(job.get("hired")),
jd_status=job.get("jdStatus") or "",
approval_status=job.get("approvalStatus") or "",
jd_version=job.get("jdVersion") or "",
match_rule_status=job.get("matchRuleStatus") or "",
data=dump_json(job),
created_at=created_at,
updated_at=state["updatedAt"],
)
)
session.flush()
for index, item in enumerate(as_list(job.get("jdHistory")), start=1):
session.add(
JdVersion(
id=item.get("id") or f"{job_id}-jd-{index}",
job_id=job_id,
version=item.get("version") or job.get("jdVersion") or f"v{index}",
status=item.get("status") or job.get("jdStatus") or "",
note=item.get("note") or "",
content=item.get("content") or item.get("jd") or job.get("jd") or "",
data=dump_json(item),
created_at=item.get("date") or created_at,
)
)
session.flush()
for candidate in state["candidates"]:
candidate_id = candidate.get("id") or _id("cand")
candidate = {**candidate, "id": candidate_id}
created_at = candidate.get("createdAt") or candidate.get("created_at") or state["updatedAt"]
session.add(
Candidate(
id=candidate_id,
name=candidate.get("name") or "未命名候选人",
primary_job_id=candidate.get("jobId") or None,
job_title=candidate.get("jobTitle") or "",
source=candidate.get("source") or "",
stage=candidate.get("stage") or "",
phone=candidate.get("phone") or "",
email=candidate.get("email") or "",
education=candidate.get("education") or "",
school=candidate.get("school") or "",
major=candidate.get("major") or "",
city=candidate.get("city") or "",
years=candidate.get("years") or candidate.get("workYears") or "",
tag=candidate.get("tag") or "",
match_score=_int(candidate.get("quickMatchScore") or candidate.get("match")),
data=dump_json(candidate),
created_at=created_at,
updated_at=state["updatedAt"],
)
)
if candidate.get("jobId"):
session.add(
CandidateApplication(
id=f"{candidate_id}-{candidate['jobId']}",
candidate_id=candidate_id,
job_id=candidate["jobId"],
stage=candidate.get("stage") or "",
status=candidate.get("stage") or "",
source=candidate.get("source") or "",
data=dump_json(
{
"candidateId": candidate_id,
"jobId": candidate["jobId"],
"stage": candidate.get("stage") or "",
}
),
created_at=created_at,
updated_at=state["updatedAt"],
)
)
for offer in state["offers"]:
if not offer.get("candidateId"):
continue
offer_id = offer.get("id") or _id("offer")
offer = {**offer, "id": offer_id}
created_at = offer.get("generatedAt") or offer.get("createdAt") or state["updatedAt"]
session.add(
Offer(
id=offer_id,
candidate_id=offer["candidateId"],
status=offer.get("status") or "",
salary=offer.get("salary") or "",
start_date=offer.get("startDate") or "",
risk=offer.get("risk") or "",
data=dump_json(offer),
created_at=created_at,
updated_at=state["updatedAt"],
)
)
for task in state["tasks"]:
task_id = task.get("id") or _id("task")
task = {**task, "id": task_id}
created_at = task.get("createdAt") or state["updatedAt"]
session.add(
Task(
id=task_id,
title=task.get("title") or "未命名待办",
module=task.get("module") or "",
target=task.get("target") or "",
due=task.get("due") or "",
priority=task.get("priority") or "",
status=task.get("status") or "",
type=task.get("type") or "",
current_node=task.get("currentNode") or "",
candidate_id=task.get("candidateId") or None,
job_id=task.get("jobId") or None,
offer_id=task.get("offerId") or None,
data=dump_json(task),
created_at=created_at,
updated_at=state["updatedAt"],
)
)
for event in state["eventLog"]:
event_id = event.get("id") or _id("evt")
at = event.get("at") or event.get("timestamp") or state["updatedAt"]
event = {**event, "id": event_id, "at": at}
session.add(
RecruitmentEvent(
id=event_id,
type=event.get("type") or "unknown",
at=at,
candidate_id=event.get("candidateId") or None,
candidate_name=event.get("candidateName") or "",
job_id=event.get("jobId") or None,
job_title=event.get("jobTitle") or "",
source=event.get("source") or "",
stage=event.get("stage") or "",
data=dump_json(event),
created_at=at,
)
)
meta = session.get(AppStateMeta, 1) or AppStateMeta(id=1, updated_at=state["updatedAt"])
meta.updated_at = state["updatedAt"]
meta.imported_from = imported_from
meta.raw_snapshot = dump_json(state)
session.merge(meta)
session.commit()
return read_state(session)
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.security import verify_ingest_token
from backend.app.services.external_sync import sync_external_jd
from backend.app.services.jd_grill import advance as grill_advance
from backend.app.services.jd_grill import start as grill_start
from backend.app.services.llm import analyze_resume, generate_jd_draft
from backend.app.services.qwenpaw_client import (
job_info_completeness,
qwenpaw_chat_stream,
)
router = APIRouter()
@router.post("/analyze-resume")
async def analyze_resume_route(payload: dict[str, Any]) -> dict[str, Any]:
return await analyze_resume(payload)
@router.post("/generate-jd")
async def generate_jd_route(payload: dict[str, Any]) -> dict[str, Any]:
return await generate_jd_draft(payload)
@router.post("/jd/completeness")
async def jd_completeness_route(payload: dict[str, Any]) -> dict[str, Any]:
"""评估岗位信息是否足够直接生成 JD。信息不全时前端应切到聊天追问。"""
job = payload.get("job") or {}
return job_info_completeness(job)
@router.post("/jd/grill/start")
async def jd_grill_start_route(payload: dict[str, Any]) -> dict[str, Any]:
"""启动「逼问式访谈」确定式状态机,返回第一问。
请求体: {"job": {岗位已有信息}, "tech_role": bool(可选)}
响应: 含第一问渲染数据、question_ids、job 累积字段、进度。
"""
job = payload.get("job") or {}
return grill_start({"tech_role": payload.get("tech_role")}, job)
@router.post("/jd/grill/advance")
async def jd_grill_advance_route(payload: dict[str, Any]) -> dict[str, Any]:
"""推进一次问答,返回下一问或完成态。
请求体: {"state": {上一轮返回的 state}, "answer": {"question_id", "type", "option"/"text"}}
响应: 状态推进结果。complete=True 时 job 已采集完整。
"""
state = payload.get("state") or {}
answer = payload.get("answer") or {}
return grill_advance(state, answer)
@router.post("/qwenpaw/chat")
async def qwenpaw_chat_route(payload: dict[str, Any]) -> StreamingResponse:
"""SSE 流式聊天端点:把 QwenPaw 的回复增量实时转发给前端。
请求体: {"text": "...", "session_id": "..."}
响应: text/event-stream,事件为 JSON 行。
"""
text = str(payload.get("text") or "")
session_id = str(payload.get("session_id") or "")
async def event_stream() -> AsyncIterator[str]:
try:
async for event in qwenpaw_chat_stream(text, session_id=session_id):
yield f"data: {_json_dumps(event)}\n\n"
except Exception as exc: # pragma: no cover - defensive
yield f"data: {_json_dumps({'type': 'status', 'status': 'failed', 'error': str(exc)})}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.post("/jd/sync-external")
async def jd_sync_external_route(request: Request, payload: dict, db: Session = Depends(get_db)) -> dict:
"""对外 JD 同步:接收企微 QwenPaw 多轮追问产出的 JD,创建/更新岗位。
认证:使用与 /api/ingest 相同的 X-Ingest-Token(或 ?token=)。未配置
INGEST_TOKEN 时开放,适合内网调用。
"""
token_error = verify_ingest_token(request)
if token_error:
return token_error
try:
return sync_external_jd(db, payload)
except Exception as exc:
return {"ok": False, "error": f"JD 同步失败: {exc}"}
def _json_dumps(value: Any) -> str:
import json
return json.dumps(value, ensure_ascii=False)
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.models import IngestItem
router = APIRouter()
@router.get("/ping")
def ping(db: Session = Depends(get_db)) -> dict:
pending = db.scalar(select(func.count()).select_from(IngestItem).where(IngestItem.confirmed.is_(False))) or 0
return {"status": "ok", "storage": "sqlite", "ingestPending": pending}
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.repositories.ingest_repository import (
confirm_ingest,
list_pending_ingest,
upsert_ingest_candidate,
)
from backend.app.security import verify_ingest_token
from backend.app.services.file_storage import save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path
router = APIRouter()
async def download_pdf(url: str, cookies: dict[str, Any] | None) -> bytes:
headers = {}
if cookies:
cookie = "; ".join(f"{key}={value}" for key, value in cookies.items())
if cookie:
headers["Cookie"] = cookie
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.content
@router.post("/ingest", response_model=None)
async def ingest(request: Request, db: Session = Depends(get_db)):
token_error = verify_ingest_token(request)
if token_error:
return token_error
raw = await request.json()
pdf_url = str(raw.get("pdfUrl") or "").strip()
parsed: dict[str, Any] = {}
saved_file = None
if pdf_url:
try:
content = await download_pdf(pdf_url, raw.get("cookies") or {})
saved_file = save_resume_bytes(content, "platform-resume.pdf", "ingest")
try:
parsed = parse_resume_path(Path(saved_file["storagePath"]))
except Exception as exc:
parsed = {"parseWarning": str(exc), "resumeText": ""}
except Exception as exc:
parsed = {"parseWarning": f"PDF 下载或解析失败:{exc}", "resumeText": str(raw.get("summary") or "")}
elif raw.get("summary"):
parsed = {"resumeText": str(raw.get("summary") or ""), "name": raw.get("name") or "", "jobTitle": raw.get("jobTitle") or ""}
return upsert_ingest_candidate(db, raw, parsed, saved_file)
@router.get("/ingest-queue")
def ingest_queue(db: Session = Depends(get_db)) -> dict[str, Any]:
queue = list_pending_ingest(db)
return {"queue": queue, "count": len(queue), "persisted": True}
@router.post("/ingest-queue/clear")
def clear_ingest_queue(request: Request, db: Session = Depends(get_db)) -> dict[str, bool]:
ids = [item.strip() for item in str(request.query_params.get("ids") or "").split(",") if item.strip()]
confirm_ingest(db, ids)
return {"ok": True}
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from backend.app.services.file_storage import resolve_resume_path, save_resume_bytes
from backend.app.services.resume_parser import parse_resume_path, parse_uploaded_bytes
router = APIRouter()
@router.post("/parse-resume")
async def parse_resume_upload(resume: UploadFile = File(...)) -> dict:
content = await resume.read()
try:
return parse_uploaded_bytes(content, resume.filename or "resume.bin")
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@router.post("/upload-resume")
async def upload_resume(resume: UploadFile = File(...)) -> dict:
content = await resume.read()
saved = save_resume_bytes(content, resume.filename or "resume.bin", "upload")
try:
parsed = parse_resume_path(resolve_resume_path(saved["storedName"]))
except Exception as exc:
parsed = {"parseWarning": str(exc)}
return {"ok": True, "file": {**saved, "url": f"/api/resumes/{saved['storedName']}"}, "parsed": parsed}
@router.get("/resumes/{filename}")
def get_resume(filename: str) -> FileResponse:
path = resolve_resume_path(filename)
if not path:
raise HTTPException(status_code=404, detail="文件未找到")
return FileResponse(path, media_type="application/pdf", headers={"Cache-Control": "private, max-age=3600"})
from typing import Any
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from backend.app.db import get_db
from backend.app.repositories.json_utils import pretty_json
from backend.app.repositories.state_repository import database_is_empty, read_state, replace_state
router = APIRouter()
@router.get("/state")
def get_state(db: Session = Depends(get_db)) -> dict[str, Any]:
state = read_state(db)
return {**state, "meta": {"empty": database_is_empty(db), "persisted": True}}
@router.put("/state")
def put_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
state = replace_state(db, payload, "api-state")
return {"ok": True, "state": state, "updatedAt": state["updatedAt"]}
@router.post("/import/local-state")
def import_local_state(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
state = replace_state(db, payload, "localStorage-import")
return {"ok": True, "imported": True, "state": state, "updatedAt": state["updatedAt"]}
@router.get("/export/state")
def export_state(db: Session = Depends(get_db)) -> Response:
state = read_state(db)
filename = f"recruitment-backup-{state['updatedAt'][:10]}.json"
return Response(
pretty_json(state),
media_type="application/json; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
import base64
import secrets
from collections.abc import Awaitable, Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from backend.app.config import get_settings
class DeploymentAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
settings = get_settings()
if not settings.admin_password:
return await call_next(request)
path = request.url.path
if path == "/api/ping":
return await call_next(request)
if path == "/api/ingest" and settings.ingest_token:
return await call_next(request)
if path == "/api/jd/sync-external" and settings.ingest_token:
return await call_next(request)
password = _read_basic_password(request.headers.get("authorization", ""))
if password and secrets.compare_digest(password, settings.admin_password):
return await call_next(request)
return Response(
"需要登录后访问招聘系统",
status_code=401,
media_type="text/plain; charset=utf-8",
headers={"WWW-Authenticate": 'Basic realm="Recruitment System", charset="UTF-8"'},
)
def _read_basic_password(header: str) -> str:
if not header.lower().startswith("basic "):
return ""
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
except Exception:
return ""
return decoded.split(":", 1)[1] if ":" in decoded else ""
def verify_ingest_token(request: Request) -> Response | None:
settings = get_settings()
if not settings.ingest_token:
return None
token = request.headers.get("X-Ingest-Token") or request.query_params.get("token") or ""
if secrets.compare_digest(token, settings.ingest_token):
return None
return Response('{"error":"采集接口令牌无效"}', status_code=401, media_type="application/json")
"""对外 JD 同步:接收企微 QwenPaw / 用人部门智能体产出的 JD,落库为岗位。
企微里的 QwenPaw 通过多轮追问用人部门产出 JD 后,可用本服务把结果
同步回招聘系统,创建或更新对应岗位。
"""
from __future__ import annotations
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import Session
from backend.app.models import Job
from backend.app.repositories.json_utils import dump_json
def _make_id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:8]}"
def sync_external_jd(db: Session, payload: dict) -> dict:
"""把外部 QwenPaw 产出的 JD 同步为招聘系统岗位。
以 payload["external_id"] 或 payload["job"]["title"] 作为匹配键:
- 命中已有岗位 → 更新其 JD 字段
- 未命中 → 新建岗位
返回 upsert 后的岗位 JSON 与操作类型。
"""
job = payload.get("job") or {}
title = str(job.get("title") or payload.get("title") or "未命名岗位").strip()
external_id = str(payload.get("external_id") or payload.get("externalJobId") or "").strip()
# 匹配:external_id 优先,其次按 title
match_id = external_id or title
existing = None
if match_id:
existing = db.scalar(select(Job).where(Job.id == match_id))
if existing is None and title:
existing = db.scalar(select(Job).where(Job.title == title).order_by(Job.updated_at.desc()))
jd_version = str(job.get("jdVersion") or payload.get("jdVersion") or "v1 外部同步")
jd_status = str(job.get("jdStatus") or payload.get("jdStatus") or "待确认")
approval_status = str(job.get("approvalStatus") or payload.get("approvalStatus") or "企微智能体产出,待用人部门确认")
if existing is not None:
old_data = dump_json(_job_dict(existing))
merged = {
**_job_dict(existing),
**job,
"jdVersion": jd_version,
"jdStatus": jd_status,
"approvalStatus": approval_status,
}
existing.title = merged.get("title") or existing.title
existing.department = merged.get("department") or existing.department
existing.jd_status = jd_status
existing.approval_status = approval_status
existing.jd_version = jd_version
existing.data = dump_json(merged)
operation = "updated"
result = merged
else:
new_id = match_id or _make_id("job")
merged = {
"id": new_id,
"title": title,
"department": job.get("department") or "",
"status": job.get("status") or "",
"owner": job.get("owner") or "",
"priority": job.get("priority") or "",
"headcount": job.get("headcount"),
"hired": job.get("hired"),
"jdVersion": jd_version,
"jdStatus": jd_status,
"approvalStatus": approval_status,
"matchRuleStatus": job.get("matchRuleStatus") or "",
"jd": job.get("jd") or "",
"responsibilities": job.get("responsibilities") or "",
"requirements": job.get("requirements") or "",
"mustHave": job.get("mustHave") or "",
"niceToHave": job.get("niceToHave") or "",
"knockout": job.get("knockout") or "",
"competency": job.get("competency") or "",
"matchKeywords": job.get("matchKeywords") or "",
"nextAction": job.get("nextAction") or "推送用人部门确认 JD 草稿。",
"source": payload.get("source") or "qwenpaw-external",
}
row = Job(
id=new_id,
title=title,
department=merged.get("department") or "",
status=merged.get("status") or "",
owner=merged.get("owner") or "",
priority=merged.get("priority") or "",
headcount=merged.get("headcount"),
hired=merged.get("hired"),
jd_status=jd_status,
approval_status=approval_status,
jd_version=jd_version,
match_rule_status=merged.get("matchRuleStatus") or "",
data=dump_json(merged),
)
db.add(row)
operation = "created"
result = merged
db.commit()
db.refresh(existing) if existing is not None else None
return {"ok": True, "operation": operation, "job": result}
def _job_dict(job: Job) -> dict:
import json as _json
data = job.data
try:
return _json.loads(data) if isinstance(data, str) else (data or {})
except Exception:
return {}
import hashlib
import re
import time
from pathlib import Path
from uuid import uuid4
from backend.app.config import get_settings
_SAFE_FILENAME = re.compile(r"^[\w\-.]+$")
def sanitize_filename(filename: str = "resume.bin") -> str:
name = Path(filename or "resume.bin").name
return re.sub(r'[\\/:*?"<>|]', "_", name) or "resume.bin"
def sha256_bytes(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def save_resume_bytes(content: bytes, original_name: str = "resume.bin", prefix: str = "resume") -> dict:
settings = get_settings()
settings.resumes_dir.mkdir(parents=True, exist_ok=True)
safe_name = sanitize_filename(original_name)
file_hash = sha256_bytes(content)
suffix = Path(safe_name).suffix or ".bin"
stored_name = f"{prefix}-{int(time.time() * 1000)}-{file_hash[:12]}{suffix}"
storage_key = f"resumes/{stored_name}"
storage_path = settings.files_dir / storage_key
storage_path.write_bytes(content)
return {
"originalName": safe_name,
"storedName": stored_name,
"storageKey": storage_key.replace("\\", "/"),
"storagePath": str(storage_path),
"fileHash": file_hash,
"sizeBytes": len(content),
"fileType": suffix.lstrip(".").lower(),
}
def resolve_resume_path(filename: str) -> Path | None:
if not _SAFE_FILENAME.fullmatch(filename or ""):
return None
settings = get_settings()
root = settings.resumes_dir.resolve()
target = (root / filename).resolve()
try:
target.relative_to(root)
except ValueError:
return None
return target if target.exists() else None
def new_id(prefix: str) -> str:
return f"{prefix}-{uuid4().hex[:16]}"
"""岗位需求「逼问式访谈」确定式状态机。
对应 ZCode + recruit-grill 技能的访谈流程:从用人部门的一句话需求出发,
**一次只问一个问题**、每题给**推荐答案**、把模糊需求一步步逼成可判断的结构化
岗位字段(定位、硬门槛、命脉、排除信号、入职约束)。
与 QwenPaw 的 jd-agent 自由访谈不同,这里是**确定式导航**:题单、顺序、
推荐选项、字段映射全部由代码定死,稳定可测。产出是一份可直接交给
``/api/generate-jd`` 生成 JD 草稿的结构化 job。本模块无状态——前端持有
会话状态(``state``),每一轮把「当前状态 + 用户答案」POST 回来,得到下一问。
架构:
start(state, job) -> 第一问
advance(state, answer) -> 下一问或完成态
字段映射:问题答案直接写入结构化 job 字段(title/department/mustHave/...),
敏感项(性别/年龄)只进 ``job.sensitive``,不落入对外 JD。
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
# ---------------------------------------------------------------------------
# 题单(question-bank v2.2;研发/技术岗额外插入 9a 技术栈问)
# ---------------------------------------------------------------------------
# 每条可选答案:label 展示给用户,value 入库,fields 是选中时写入 job 的键值。
# fields 支持 {key: value} 精确写入,或 {key: ("merge", text)} 追加。
_Option = dict[str, Any]
def _opt(label: str, value: str, fields: dict[str, Any] | None = None) -> _Option:
return {"label": label, "value": value, "fields": fields or {}}
# 每个问题的定义。parse_text(s, job) 可自由输入做字段推断,用于开放式回答。
QUESTION_BANK: list[dict[str, Any]] = [
# ---------- 第 0 组 · 入口分流 ----------
{
"id": "entry_path",
"group": "入口分流",
"index": 1,
"title": "为什么招这个岗位?",
"description": "新业务线开荒 / 新岗位需写 JD / 老岗位迭代重发?这决定后面怎么追问。",
"key": "hiringPath",
"options": [
_opt("新业务线开荒", "greenfield", {"hiringPath": "新业务线开荒"}),
_opt("新岗位需写 JD", "new", {"hiringPath": "新岗位需写 JD"}),
_opt("老岗位迭代重发", "iterate", {"hiringPath": "老岗位迭代重发"}),
],
"free_text": True,
"parse_text": lambda text: {"hiringPath": text[:40]},
# 岗位是否已存在(已有标题/职责)说明是老岗位,跳过入口分流直接问变化点
"skip_when": lambda job: bool(job.get("jd") or job.get("responsibilities")),
},
{
"id": "input_form",
"group": "入口分流",
"index": 2,
"title": "你现在手上有什么材料?",
"description": "自带明确需求 / JD,还是只有一句话?只有一句话会先调研再提问。",
"key": "inputForm",
"options": [
_opt("有一句话需求", "one-sentence", {"inputForm": "一句话需求"}),
_opt("有旧 JD / 别人写的 JD", "has-jd", {"inputForm": "已有 JD"}),
_opt("有明确要求清单", "explicit", {"inputForm": "明确要求清单"}),
],
"free_text": True,
"parse_text": lambda text: {"inputForm": text[:40]},
"skip_when": lambda job: bool(job.get("jd")),
},
{
"id": "internal_digest",
"group": "入口分流",
"index": 3,
"title": "这个活现有团队里有没有人能培训 / 胜任?为什么必须外招?",
"description": "确认确实要外招再往下走,避免盲目开招(降成本第一道闸)。",
"key": "internalFeasibility",
"options": [
_opt("现有团队确实无人可培训", "must-hire", {"internalFeasibility": "无人可培训,必须外招"}),
_opt("有人可培训,但人手不足", "shortage", {"internalFeasibility": "有人可培训但人手不足"}),
],
"free_text": True,
"parse_text": lambda text: {"internalFeasibility": text[:60]},
},
# ---------- 第 1 组 · 岗位定位与核心职责 ----------
{
"id": "positioning",
"group": "岗位定位",
"index": 4,
"title": "这个岗位是来解决什么问题的?",
"description": "没有这个人现在是谁在扛、扛不住在哪?半年内最重要的交付物是什么?汇报给谁?开荒还是补强?带不带团队?",
"key": "positioning",
"options": [
_opt("独立扛一条新业务线(开荒)", "greenfield", {
"positioning": "开荒:从 0 到 1 独立搭建并交付 X",
"greenfield": "开荒",
"leadsTeam": True,
}),
_opt("补强现有团队,分担某条线", "backfill", {
"positioning": "补强:分担现有团队某条线的需求与线上问题",
"greenfield": "补强",
"leadsTeam": False,
}),
_opt("带方向、带 1-2 人小团队", "lead", {
"positioning": "带方向:组建并带领 1-2 人小团队",
"leadsTeam": True,
}),
],
"free_text": True,
"parse_text": lambda text: {"positioning": text[:120]},
},
{
"id": "responsibilities",
"group": "岗位定位",
"index": 5,
"title": "这个岗位 80% 的时间花在哪几件事上?",
"description": "最核心的 3-5 个动作是什么?和谁协同、对谁交付?(技术岗:设计/编码/评审/联调/上线/值班)",
"key": "responsibilities",
"options": [
_opt("技术岗:设计/编码/评审/联调/上线/值班", "tech", {
"responsibilities": (
"负责核心模块的设计与编码;参加代码评审与方案评审;"
"参与联调与上线,负责线上问题值班与处置"
),
}),
_opt("业务岗:拓客/方案/谈判/交付/客户经营", "sales", {
"responsibilities": (
"负责客户开拓与需求对接;输出方案与商务谈判;推进招投标与回款;"
"维护客户关系,持续经营客户价值"
),
}),
_opt("数据分析岗:取数/建模/洞察/汇报", "data", {
"responsibilities": (
"负责数据取数与清洗;搭建指标与建模分析;输出业务洞察与专题报告;"
"向上汇报并推动数据驱动决策"
),
}),
],
"free_text": True,
"parse_text": lambda text: {"responsibilities": text.strip()[:300]},
},
# ---------- 第 2 组 · 硬门槛 ----------
{
"id": "hard_gate",
"group": "硬门槛",
"index": 6,
"title": "硬性门槛:学历、专业、经验年限下限?",
"description": "初筛第一道闸。一次把数字逼出来(本科及以上 / 计算机相关专业优先 / 3 年以上)。",
"key": "education",
"options": [
_opt("本科以上 + 相关专业优先", "bachelor-plus", {
"education": "本科及以上,相关专业优先",
"experienceYears": "3年以上",
}),
_opt("大专以上,能力优先", "college", {
"education": "大专及以上,能力优先",
"experienceYears": "3年以上",
}),
_opt("硕士优先(高级岗)", "master", {
"education": "硕士及以上优先",
"experienceYears": "5年以上",
}),
],
"free_text": True,
"parse_text": lambda text: {"education": text[:60]},
"skip_when": lambda job: bool(job.get("education")),
},
{
"id": "sensitive",
"group": "硬门槛",
"index": 7,
"title": "性别 / 年龄有要求吗?",
"description": "不默认不设线,逐岗确认。确认后**仅内部可见**,严禁写入对外 JD / 候选人沟通。",
"key": "sensitive",
"options": [
_opt("不限(无性别年龄线)", "none", {"sensitive": {"gender": "不限", "age": "不限"}}),
_opt("性别有要求(默认就写这里)", "gender", {"sensitive": {"gender": "按公司惯例", "age": "不限"}}),
_opt("年龄设排除/降权线", "age", {"sensitive": {"gender": "不限", "age": "按公司惯例"}}),
],
"free_text": True,
"parse_text": lambda text: {"sensitive": {"gender": "不限", "age": text[:40]}},
},
{
"id": "salary_location",
"group": "硬门槛",
"index": 8,
"title": "薪资带宽(结构)和办公地点?",
"description": "带宽多少、坐班/异地/出差?公司没定带宽会先做市场调研再给推荐。",
"key": "salary",
"options": [
_opt("深圳、现场办公、不接受远程", "shenzhen-onsite", {
"location": "深圳",
"remote": "现场办公,不接受远程",
"salaryRange": "待定",
}),
_opt("可远程/混合办公", "remote", {
"location": "不限",
"remote": "可远程/混合办公",
"salaryRange": "待定",
}),
_opt("有明确带宽(填数字)", "explicit-salary", {
"salaryRange": "待你回填区间",
}),
],
"free_text": True,
"parse_text": lambda text: {"location": "深圳", "salaryRange": text[:40], "remote": text[:40]},
},
# ---------- 第 3 组 · 命脉与验证(技术岗额外 9a) ----------
{
"id": "lifeline",
"group": "命脉与验证",
"index": 9,
"title": "命脉(一票否决项):哪 1-2 项能力缺了,其他条件再好也不对口?",
"description": "这就是硬底子本身;表层(行业/工具/赛道经验)可放宽,三个月追平。",
"key": "mustHave",
"options": [
_opt("技术栈能上手 + 独立交付", "tech-lifeline", {
"mustHave": "核心技能能直接上手;端到端独立交付能力",
}),
_opt("业务理解 + 客户资源", "business-lifeline", {
"mustHave": "业务理解力;客户资源或成交能力",
}),
_opt("线上问题处置 + 抗压", "ops-lifeline", {
"mustHave": "线上问题定位与处置经验;抗压推进能力",
}),
],
"free_text": True,
"parse_text": lambda text: {"mustHave": text.strip()[:200]},
"skip_when": lambda job: bool(job.get("mustHave")),
},
{
"id": "verification",
"group": "命脉与验证",
"index": 10,
"title": "命脉怎么验证?简历看什么信号?面试问什么能问出来?",
"description": "没有验证方式的命脉是空话。答案进对内笔记和面试档案。",
"key": "verification",
"options": [
_opt("简历看项目 + 面试做设计题", "resume+design", {
"verification": "简历看真实项目落地;面试给真实场景现场设计追问。",
}),
_opt("简历看关键词 + 面试问案例", "resume+case", {
"verification": "简历看相关经历关键词;面试问经典案例 STAR 追问。",
}),
_opt("笔试/作业题 + 复杂度评估", "assignment", {
"verification": "笔试或作业题验证硬实力,结合复杂度评估。",
}),
],
"free_text": True,
"parse_text": lambda text: {"verification": text.strip()[:200]},
},
{
"id": "tech_stack",
"group": "命脉与验证",
"index": "9a",
"title": "技术栈与深度(技术岗必问):主语言/框架/版本?要求熟练还是精通?硬性还是可迁移?",
"description": "栈不对口,其他条件再好也干不了活;但「表层可放宽」与「栈硬性」要逼问清楚。",
"tech_only": True,
"options": [
_opt("主栈 X / 要求熟练 / 硬性", "proficient-hard", {
"techStack": "熟练使用团队主语言与框架",
"requirements": "熟悉团队技术栈(主语言/框架/版本),能直接上手现有系统",
}),
_opt("主栈 X / 底子硬可迁移(三个月上手)", "transferable", {
"techStack": "底子硬、可迁移",
"requirements": "技术底子扎实,3 个月内能上手团队技术栈",
}),
],
"free_text": True,
"parse_text": lambda text: {"techStack": text.strip()[:120], "requirements": text.strip()[:200]},
},
# ---------- 第 4 组 · 排除与来源 ----------
{
"id": "knockout",
"group": "排除与来源",
"index": 11,
"title": "排除信号:什么样的背景看着像、其实不对?有没有明确不要的来源?",
"description": "初筛最大的时间黑洞——逼出「简历关键词全命中但干不了活」的画像。",
"key": "knockout",
"options": [
_opt("纯 CRUD / 只会调轮子", "crud", {
"knockout": "纯 CRUD 无架构封装;只会调轮子不懂原理",
}),
_opt("无线上问题经验", "no-ops", {
"knockout": "纯功能开发,无生产环境排查处置经验",
}),
_opt("技术炫技、无业务 sense", "show-off", {
"knockout": "技术炫技,缺少业务落地能力",
}),
_opt("关键词全命中但无成果/无深度", "shallow", {
"knockout": "简历关键词全命中但缺真实成果与深度",
}),
],
"free_text": True,
"parse_text": lambda text: {"knockout": text.strip()[:200]},
"skip_when": lambda job: bool(job.get("knockout")),
},
# ---------- 第 5 组 · 入职约束 ----------
{
"id": "probation",
"group": "入职约束",
"index": 12,
"title": "试用期多长?试用期内看什么信号判断转正?",
"description": "只进对内笔记(入职约束区),不写进对外 JD。考核标准 = 命脉的短期验证版。",
"key": "probation",
"options": [
_opt("3 个月,能否在指导下独立完成一次迭代", "3m-iterate", {
"probationPeriod": "3个月",
"probationCriteria": "在指导下独立完成一次需求迭代",
}),
_opt("6 个月,看是否能独立交付关键任务", "6m-deliver", {
"probationPeriod": "6个月",
"probationCriteria": "独立交付一项关键任务",
}),
_opt("1 个月,快速试用", "1m-quick", {
"probationPeriod": "1个月",
"probationCriteria": "上手速度与基本胜任",
}),
],
"free_text": True,
"parse_text": lambda text: {"probationPeriod": text[:30]},
},
{
"id": "noncompete",
"group": "入职约束",
"index": 13,
"title": "这个岗位入职后需要签竞业协议吗?",
"description": "每个岗位必问,不要默认跳过。答案进对内笔记「入职约束」,JD 不写,Offer 阶段 HR 再谈。",
"key": "nonCompete",
"options": [
_opt("需要签", "yes", {"nonCompete": "需签"}),
_opt("不需要签", "no", {"nonCompete": "不需要"}),
],
"free_text": True,
"parse_text": lambda text: {"nonCompete": text.strip()[:30]},
},
]
# ---------------------------------------------------------------------------
# 状态推进
# ---------------------------------------------------------------------------
def _start_job(job: dict[str, Any]) -> dict[str, Any]:
"""规范化初始 job 字段(含敏感项容器)。"""
base = dict(job or {})
base.setdefault("sensitive", {})
return base
def _completed() -> dict[str, Any]:
return {"complete": True}
def _next_question(question_ids: list[str], job: dict[str, Any], answered: list[str] | None = None) -> dict[str, Any] | None:
"""按 id 顺序找到第一个需要问的问题(返回渲染数据),都答过则完成。
会跳过已作答(answered)或满足 skip_when 的问题。
"""
answered = answered or []
for qid in question_ids:
if qid in answered:
continue
q = _find(qid)
if q is None:
continue
if q.get("skip_when") and q["skip_when"](job):
continue
return _render(q)
return _completed()
def _find(qid: str) -> dict[str, Any] | None:
for q in QUESTION_BANK:
if q["id"] == qid:
return q
return None
def _question_ids(tech_role: bool) -> list[str]:
"""按 bank 顺序生成题单;tech_only 题在 tech_role=False 时剔除。"""
ids: list[str] = []
# 按 (group, index) 保留题目顺序:先按 group 分组顺序,再按 index
# QUESTION_BANK 已按访谈顺序排好,直接过滤即可
for q in QUESTION_BANK:
if q.get("tech_only") and not tech_role:
continue
ids.append(q["id"])
return ids
def _render(q: dict[str, Any]) -> dict[str, Any]:
options = [
{"label": o["label"], "value": o["value"], "fields": o.get("fields", {})}
for o in q.get("options", [])
]
return {
"question_id": q["id"],
"group": q["group"],
"index": q["index"],
"title": q["title"],
"description": q.get("description", ""),
"key": q.get("key", ""),
"options": options,
"free_text": bool(q.get("free_text", False)),
}
def _apply_answer(q: dict[str, Any], answer: dict[str, Any], job: dict[str, Any]) -> dict[str, Any]:
"""把用户答案写入 job(按 options.fields 或 parse_text)。"""
answer_type = answer.get("type") or "option"
job = dict(job)
def _set(key: str, value: Any) -> None:
if isinstance(value, dict) and key == "sensitive":
existing = dict(job.get("sensitive") or {})
existing.update(value)
job["sensitive"] = existing
elif isinstance(value, tuple) and len(value) == 2 and value[0] == "merge":
merged = str(job.get(key) or "").strip()
add = str(value[1]).strip()
job[key] = f"{merged}\n{add}".strip() if merged else add
else:
job[key] = value
if answer_type == "option":
value = answer.get("option")
chosen = next((o for o in q.get("options", []) if o["value"] == value), None)
if chosen:
for field_key, field_value in (chosen.get("fields") or {}).items():
_set(field_key, field_value)
return job
# free text
text = str(answer.get("text") or "").strip()
parser: Callable[[str], dict[str, Any]] | None = q.get("parse_text")
if parser:
fields = parser(text)
for field_key, field_value in fields.items():
_set(field_key, field_value)
else:
# 无解析器时按 key 存原文
key = q.get("key")
if key:
_set(key, text)
return job
def start(state: dict[str, Any] | None, job: dict[str, Any]) -> dict[str, Any]:
"""生成第一问。``state`` 传 null/空即可;``job`` 为岗位现有信息(可已有一部分)。"""
state = state or {}
tech_role = bool(state.get("tech_role", _guess_tech_role(job)))
job = _start_job(job or {})
ids = _question_ids(tech_role)
nxt = _next_question(ids, job, answered=[])
# 记录这道题是「从哪一题开始问」的进度
return {
"session_id": state.get("session_id") or "",
"tech_role": tech_role,
"question_ids": ids,
"job": job,
"answered": [],
"next": nxt,
**({"total_steps": len(ids)} if isinstance(nxt, dict) else {"total_steps": len(ids)}),
}
def advance(state: dict[str, Any], answer: dict[str, Any]) -> dict[str, Any]:
"""根据问卷状态 + 用户答案,推进到下一问或完成态。无状态、可重入。
``state`` 是上线一轮返回的完整会话状态(含 job 累积字段 / question_ids / answered)。
本函数取出当前 job,应用答案,再找下一问。
"""
job = _start_job(state.get("job") or {})
qid = str(answer.get("question_id") or state.get("current_question_id") or "")
q = _find(qid)
if q is None:
q = _find(str(state.get("current_question_id") or ""))
if q is not None:
job = _apply_answer(q, answer, job)
answered = list(state.get("answered") or [])
if qid and qid not in answered:
answered.append(qid)
ids = state.get("question_ids") or _question_ids(bool(state.get("tech_role")))
nxt = _next_question(ids, job, answered=answered)
if isinstance(nxt, dict) and nxt.get("complete"):
return {
"complete": True,
"session_id": state.get("session_id") or "",
"tech_role": bool(state.get("tech_role")),
"question_ids": ids,
"job": job,
"answered": answered,
"progress": {"current": len(answered), "total": len(ids), "percent": 100},
"summary": _completion_summary(job),
}
progress = {
"current": len(answered),
"total": len(ids),
"percent": round(len(answered) / len(ids) * 100),
}
return {
"complete": False,
"session_id": state.get("session_id") or "",
"tech_role": bool(state.get("tech_role")),
"question_ids": ids,
"job": job,
"answered": answered,
"current_question_id": nxt["question_id"] if nxt else "",
"next": nxt,
"progress": progress,
}
def _guess_tech_role(job: dict[str, Any]) -> bool:
"""根据岗位特征粗略判断是否技术岗(也可由前端显式传 tech_role)。"""
haystack = " ".join(str(job.get(k) or "") for k in ("title", "jd", "requirements", "responsibilities"))
tech_words = ["工程师", "开发", "Java", "Python", "前端", "后端", "算法", "架构", "C++", "Go", "测试", "运维", "数据工程师"]
return any(word in haystack for word in tech_words)
def _completion_summary(job: dict[str, Any]) -> str:
title = job.get("title") or "目标岗位"
dept = job.get("department") or ""
edu = job.get("education") or ""
years = job.get("experienceYears") or ""
must = job.get("mustHave") or ""
salary = job.get("salaryRange") or ""
parts = [f"{title}({dept})" if dept else title]
if edu or years:
parts.append(f"学历年限:{edu} {years}".strip())
if salary:
parts.append(f"薪资:{salary}")
if must:
parts.append(f"命脉:{must[:40]}")
return ";".join(p for p in parts if p)
# ---------------------------------------------------------------------------
# 从已采集的结构化 job 组装「可直接生成 JD 的 payload」
# ---------------------------------------------------------------------------
def grillin_job_to_jd_payload(job: dict[str, Any]) -> dict[str, Any]:
"""把 grill 采集的结构化字段归一成 /api/generate-jd 需要的 job 结构。
敏感项(job.sensitive)绝不落入对外 JD 字段。
"""
sensitive = job.get("sensitive") or {}
safe = dict(job)
safe.pop("sensitive", None)
# 敏感信息不入对外字段
safe["genderRestriction"] = sensitive.get("gender") if not _is_not_allowed(sensitive.get("gender")) else ""
safe["ageRestriction"] = sensitive.get("age") if not _is_not_allowed(sensitive.get("age")) else ""
# 主题色/定位词作为加分项来源
return safe
def _is_not_allowed(value: str | None) -> bool:
return not value or value in ("不限", "none", "无")
# 对外暴露主函数,便于路由引用
def make_jd_payload(job: dict[str, Any]) -> dict[str, Any]:
return grillin_job_to_jd_payload(job or {})
import json
import re
from typing import Any
import httpx
from backend.app.config import get_settings
from backend.app.services.qwenpaw_client import qwenpaw_jd_draft
def split_rule_lines(value: str | list[str] | None) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
return [item.strip() for item in re.split(r"[\n,,、;;]+", str(value or "")) if item.strip()]
def job_criteria(job: dict[str, Any]) -> dict[str, list[str]]:
criteria = job.get("criteria") if isinstance(job.get("criteria"), dict) else {}
return {
"mustHave": split_rule_lines(criteria.get("mustHave") or job.get("mustHave")),
"niceToHave": split_rule_lines(criteria.get("niceToHave") or job.get("niceToHave")),
"knockout": split_rule_lines(criteria.get("knockout") or job.get("knockout")),
"competency": split_rule_lines(
criteria.get("competency") or job.get("competency") or "专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进"
),
"matchKeywords": split_rule_lines(criteria.get("matchKeywords") or job.get("matchKeywords")),
"responsibilities": split_rule_lines(criteria.get("responsibilities") or job.get("responsibilities")),
"requirements": split_rule_lines(criteria.get("requirements") or job.get("requirements")),
}
def pick_evidence(text: str, keywords: list[str]) -> list[str]:
compact = re.sub(r"\s+", " ", text or "")
lower = compact.lower()
evidence: list[str] = []
for keyword in [item for item in keywords if item][:8]:
index = lower.find(str(keyword).lower())
if index < 0:
continue
evidence.append(compact[max(0, index - 28) : min(len(compact), index + len(str(keyword)) + 42)])
return evidence
def local_resume_analysis(payload: dict[str, Any]) -> dict[str, Any]:
candidate = payload.get("candidate") or {}
job = payload.get("job") or {}
resume_text = candidate.get("resumeText") or ""
criteria = job_criteria(job)
jd_text = " ".join(
str(item)
for item in [
job.get("title"),
job.get("department"),
job.get("jd"),
*[value for values in criteria.values() for value in values],
]
if item
)
domain_terms = [
"电力交易",
"电力市场",
"交易员",
"售电",
"现货",
"中长期",
"负荷预测",
"新能源",
"储能",
"调度",
"电价",
"价差",
"交易策略",
"市场化",
"结算",
"风控",
"政策",
"数据分析",
"Python",
"SQL",
"Excel",
"产品",
"需求分析",
"销售",
"大客户",
"团队管理",
"招聘",
"HRBP",
]
source_words = re.split(r"[,。;、\s/|,.;:()()]+", f"{jd_text} {' '.join(candidate.get('skills') or [])}")
terms = list(
dict.fromkeys(
[
*criteria["matchKeywords"],
*criteria["mustHave"],
*criteria["niceToHave"],
*criteria["competency"],
*[
term
for term in domain_terms
if term.lower() in jd_text.lower() or term.lower() in resume_text.lower()
],
*[item for item in source_words if 2 <= len(item) <= 10],
]
)
)[:24]
matched = [term for term in terms if str(term).lower() in resume_text.lower()]
missing = [term for term in terms if term not in matched][:8]
must_matched = [term for term in criteria["mustHave"] if str(term).lower() in resume_text.lower()]
must_missing = [term for term in criteria["mustHave"] if term not in must_matched]
nice_matched = [term for term in criteria["niceToHave"] if str(term).lower() in resume_text.lower()]
knockout_hit = [term for term in criteria["knockout"] if str(term).lower() in resume_text.lower()]
evidence = pick_evidence(resume_text, matched)
must_score = round((len(must_matched) / len(criteria["mustHave"])) * 14) - len(must_missing) * 5 if criteria["mustHave"] else 0
nice_score = min(8, len(nice_matched) * 3)
knockout_penalty = min(25, len(knockout_hit) * 10)
score = max(35, min(96, 58 + len(matched) * 4 + must_score + nice_score - knockout_penalty + (8 if len(resume_text) > 1000 else 0)))
decision = "建议优先推进面试" if score >= 85 else "建议 HR 复核后推进" if score >= 72 else "建议进入人才池或暂缓"
risk_adjustment = -10 if knockout_hit else -5 if must_missing else 0
return {
"provider": "local-structured",
"reportVersion": "ai-evaluation-v2",
"score": score,
"decision": decision,
"summary": f"{candidate.get('name') or '该候选人'}与{job.get('title') or candidate.get('jobTitle') or '目标岗位'}的结构化匹配度为 {score}。该结论基于简历正文、岗位 JD 和关键词证据生成,不等同于 LLM 语义判断。",
"hardMatch": [
*[f"简历中出现“{term}”,与岗位要求存在直接关联。" for term in matched[:4]],
*[f"岗位硬性条件待确认:“{term}”。" for term in must_missing[:2]],
],
"experienceMatch": ["本地规则仅能判断关键词命中,不能可靠识别职责深度、业绩结果和组织复杂度。"],
"projectEvidence": evidence,
"competencyFrame": [f"{item}:本地规则暂不评分,仅建议面试中结合简历证据观察。" for item in criteria["competency"][:6]],
"strengths": [f"简历中出现“{term}”,与岗位要求存在直接关联。" for term in matched[:5]],
"risks": [
*[f"触发岗位淘汰项,需 HR 复核:“{term}”。" for term in knockout_hit[:2]],
*[f"硬性条件缺口:“{term}”。" for term in must_missing[:3]],
*[f"未在简历中明确看到“{term}”相关证据,建议面试确认。" for term in missing[:3]],
][:6],
"interviewQuestions": [f"请候选人说明与“{term}”相关的实际项目、职责边界和结果数据。" for term in missing[:4]],
"evidence": evidence,
"nextAction": decision,
"scoreBreakdown": {
"hardMatchScore": max(0, min(35, round(score * 0.35))),
"experienceScore": max(0, min(25, round(score * 0.25))),
"evidenceScore": max(0, min(20, round(score * 0.2))),
"competencyScore": max(0, min(10, round(score * 0.1))),
"riskAdjustment": risk_adjustment,
"finalScore": score,
},
}
def parse_json_from_text(text: str) -> dict[str, Any]:
try:
return json.loads(text)
except json.JSONDecodeError:
match = re.search(r"\{[\s\S]*\}", text)
if match:
return json.loads(match.group(0))
raise ValueError("模型未返回可解析 JSON") from None
def sanitize_resume_for_llm(text: str, candidate: dict[str, Any]) -> str:
sanitized = str(text or "")
for value in [candidate.get("name"), candidate.get("phone"), candidate.get("email"), candidate.get("city"), candidate.get("resumeName")]:
if value:
sanitized = re.sub(re.escape(str(value)), "[本地脱敏]", sanitized)
sanitized = re.sub(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", "[邮箱已本地脱敏]", sanitized, flags=re.I)
sanitized = re.sub(r"(?:\+?86[-\s]?)?1[3-9]\d{9}", "[手机号已本地脱敏]", sanitized)
sanitized = re.sub(r"(?:出生年月|出生日期|出生|生日)[::\s]*(?:19|20)\d{2}[年./\- ]+\d{1,2}(?:[月./\- ]+\d{1,2}日?)?", "出生年月:[出生年月已本地脱敏]", sanitized, flags=re.I)
sanitized = re.sub(r"\b(?:年龄|年纪)[::]?\s?\d{2}\b", "年龄:[年龄已本地脱敏]", sanitized)
return re.sub(r"\b\d{2}\s?岁\b", "[年龄已本地脱敏]", sanitized)
def sanitize_candidate_for_llm(candidate: dict[str, Any]) -> dict[str, Any]:
return {
"id": "local-redacted",
"name": "候选人",
"jobTitle": candidate.get("jobTitle") or "",
"source": candidate.get("source") or "",
"stage": candidate.get("stage") or "",
"years": candidate.get("years") or "",
"education": candidate.get("education") or "",
"school": candidate.get("school") or "",
"major": candidate.get("major") or "",
"schoolTags": candidate.get("schoolTags") or [],
"skills": candidate.get("skills") or [],
"privacyMode": "pii-redacted-local",
"localOnlyFields": ["name", "phone", "email", "age", "city", "resumeName"],
"resumeText": sanitize_resume_for_llm(candidate.get("resumeText") or "", candidate),
}
async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
settings = get_settings()
if not settings.effective_llm_api_key:
return None
safe_candidate = sanitize_candidate_for_llm(payload.get("candidate") or {})
criteria = job_criteria(payload.get("job") or {})
user = (
"请分析候选人与岗位的匹配度,并返回 JSON。字段必须为:score, decision, summary, hardMatch, "
"experienceMatch, projectEvidence, competencyFrame, risks, interviewQuestions, nextAction, scoreBreakdown。\n\n"
f"岗位:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}\n\n"
f"岗位结构化规则:{json.dumps(criteria, ensure_ascii=False, indent=2)}\n\n"
f"候选人脱敏结构:{json.dumps({**safe_candidate, 'resumeText': None}, ensure_ascii=False, indent=2)}\n\n"
f"脱敏简历正文:\n{(safe_candidate.get('resumeText') or '')[:16000]}"
)
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{settings.effective_llm_base_url}/chat/completions",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"},
json={
"model": settings.effective_llm_model,
"temperature": 0,
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深HR初筛分析助手。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "LLM 分析失败")
parsed = parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}")
return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}"}
async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]:
return await llm_resume_analysis(payload) or local_resume_analysis(payload)
def local_jd_draft(payload: dict[str, Any]) -> dict[str, Any]:
job = payload.get("job") or {}
mode = payload.get("mode") or "generate"
title = job.get("title") or "目标岗位"
department = job.get("department") or "业务部门"
return {
"provider": "local-structured",
"mode": mode,
"jdVersion": "v2 待确认" if mode == "iterate" else "v1 待确认",
"jdStatus": "待确认",
"approvalStatus": "用人部门确认中",
"jd": f"{department}{title}负责围绕业务目标完成岗位核心交付,需具备相关行业理解、专业能力和跨部门协同能力。",
"responsibilities": f"负责{title}相关业务规划、执行和结果跟进。\n协同用人部门、HR 和相关团队推进招聘目标。\n沉淀岗位标准、业务要求和候选人评估依据。",
"requirements": f"具备{title}相关经验。\n理解{department}业务场景和关键交付。\n具备良好的沟通协同、问题分析和推进能力。",
"mustHave": "岗位相关经验\n核心业务能力\n稳定的交付记录",
"niceToHave": "能源电力行业经验\n复杂项目推进经验\n数据分析能力",
"knockout": "核心经验明显不匹配\n无法接受岗位关键工作场景\n简历信息关键字段缺失且无法补充",
"competency": job.get("competency") or "专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进",
"matchKeywords": ",".join([item for item in [title, department, "业务理解", "沟通协同", "数据分析"] if item]),
"nextAction": "推送用人部门确认 JD 草稿。",
}
async def llm_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
settings = get_settings()
if not settings.effective_llm_api_key:
return None
mode_text = "基于现有JD进行版本迭代" if payload.get("mode") == "iterate" else "结合岗位信息生成JD草稿"
user = f"请{mode_text},返回 JSON。字段必须为:jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, knockout, competency, matchKeywords, nextAction。\n\n岗位信息:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}"
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{settings.effective_llm_base_url}/chat/completions",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.effective_llm_api_key}"},
json={
"model": settings.effective_llm_model,
"temperature": 0.35,
"messages": [
{"role": "system", "content": "你是企业招聘系统中的资深招聘JD顾问。只返回JSON,不要输出Markdown。"},
{"role": "user", "content": user},
],
"response_format": {"type": "json_object"},
},
)
data = response.json()
if response.status_code >= 400:
raise RuntimeError(data.get("error", {}).get("message") or "JD 生成失败")
return {**parse_json_from_text(data.get("choices", [{}])[0].get("message", {}).get("content") or "{}"), "provider": f"llm:{settings.effective_llm_model}"}
# 对内字段:用于内部访谈/寻源,严禁随岗位信息发送到对外 LLM 或写进对外 JD。
_INTERNAL_ONLY_JOB_KEYS = ("sensitive", "genderRestriction", "ageRestriction", "internalFeasibility", "verification", "probationPeriod", "probationCriteria", "nonCompete")
def _sanitize_job_for_external(job: dict[str, Any]) -> dict[str, Any]:
"""去除仅内部可见的字段,再把剩余信息传入对外 JD 生成。
对应 recruit-grill 规则:性别/年龄、试用期、竞业、验证方式等敏感/内部信息
只进对内笔记与 CONTEXT,严禁落到对外 JD 或 LLM prompt。
"""
safe = {k: v for k, v in (job or {}).items() if k not in _INTERNAL_ONLY_JOB_KEYS}
safe.pop("sensitive", None)
return safe
async def generate_jd_draft(payload: dict[str, Any]) -> dict[str, Any]:
# QwenPaw 独立智能体服务优先;未启用或失败时回退到 LLM 直连 / 本地结构化。
# 先剥离对内字段,避免敏感信息随 job 发送到对外 LLM / 写进对外 JD。
safe_payload = {**payload, "job": _sanitize_job_for_external(payload.get("job") or {})}
qwenpaw_draft = await qwenpaw_jd_draft(safe_payload)
if qwenpaw_draft:
return qwenpaw_draft
return await llm_jd_draft(safe_payload) or local_jd_draft(payload)
"""QwenPaw 独立智能体服务客户端适配器。
招聘系统通过 HTTP 调用 QwenPaw 的 REST API(/api/console/chat,SSE 流式),
让 QwenPaw 上挂着 recruit-grill 技能的 jd-agent 来处理 JD 生成等任务。
本模块封装:
- SSE 流式响应聚合,提取助手最终回复文本
- 面向招聘系统固定 JSON 字段契约的 JD 生成调用
- 失败/未启用时返回 None,由调用方回退到本地结构化生成
"""
from __future__ import annotations
import json
import re
from typing import Any
import httpx
from backend.app.config import get_settings
def _extract_text_delta(event: dict[str, Any]) -> str:
"""从 SSE 事件里抽取文本增量片段。
QwenPaw 的 SSE 有多种对象(response / message / content),
文本内容主要以 {"type":"text","delta":true,...,"text":"..."} 形式出现。
"""
if not isinstance(event, dict):
return ""
# 顶层可能是 content 增量
if event.get("type") == "text":
return str(event.get("text") or "")
# 也可能是 message 下的 content 数组
output = event.get("output")
if isinstance(output, list):
parts: list[str] = []
for item in output:
if not isinstance(item, dict):
continue
for content in item.get("content") or []:
if isinstance(content, dict) and content.get("type") == "text":
parts.append(str(content.get("text") or ""))
return "".join(parts)
return ""
def _is_completed(event: dict[str, Any]) -> bool:
"""判断 SSE 事件是否为完成态。"""
if not isinstance(event, dict):
return False
status = event.get("status")
if status == "completed":
return True
# message 级 content 也可能带 status
return status in {"completed", "failed"} and bool(event.get("error"))
async def qwenpaw_chat(user_text: str, *, session_id: str = "") -> str:
"""向 QwenPaw 某个 agent 发送一条消息,聚合流式回复,返回最终文本。
Raises:
RuntimeError: 服务不可用 / 返回失败事件 / 超时 / 无法解析。
"""
settings = get_settings()
base_url = settings.qwenpaw_base_url.rstrip("/")
url = f"{base_url}/api/console/chat"
agent_id = settings.qwenpaw_agent_id or "jd-agent"
sid = session_id or f"{settings.qwenpaw_session_prefix}-{__import__('uuid').uuid4().hex[:8]}"
payload = {
"input": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
"session_id": sid,
"user_id": "recruitment-system",
"channel": "console",
}
headers = {
"Content-Type": "application/json",
"X-Agent-Id": agent_id,
}
chunks: list[str] = []
errors: list[str] = []
async with httpx.AsyncClient(timeout=settings.qwenpaw_timeout) as client:
async with client.stream("POST", url, headers=headers, json=payload) as response:
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", "ignore")
raise RuntimeError(f"QwenPaw HTTP {response.status_code}: {body[:500]}")
async for line in response.aiter_lines():
if not line.strip():
continue
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if not data:
continue
try:
event = json.loads(data)
except json.JSONDecodeError:
continue
chunks.append(_extract_text_delta(event))
if event.get("status") == "failed":
msg = event.get("error", {})
if isinstance(msg, dict):
errors.append(msg.get("message", ""))
else:
errors.append(str(msg))
if _is_completed(event):
break
text = "".join(chunks).strip()
if not text:
if errors:
raise RuntimeError(f"QwenPaw 返回失败: {'; '.join(e for e in errors if e)}")
raise RuntimeError("QwenPaw 未返回文本内容")
return text
def _strip_json_fence(text: str) -> str:
"""去掉可能的 ```json ... ``` 代码围栏,便于解析。"""
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
if fence:
return fence.group(1).strip()
return text.strip()
def _parse_jd_fields(text: str) -> dict[str, Any]:
"""从 QwenPaw 回复文本中解析出招聘系统 JD 字段。
QwenPaw 的回复常包含大量推理/解释文本,真正的 JSON 对象通常出现在
末尾。这里遍历所有可能的 JSON 块,取最后一个能完整解析的候选,
从而避开推理文本中的花括号干扰。
"""
cleaned = _strip_json_fence(text)
# 1) 整体就是 JSON
try:
parsed = json.loads(cleaned)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
# 2) 遍历所有 JSON 对象候选(从后往前),取第一个能解析的 dict
candidates = list(re.finditer(r"\{[^{}]*\}", cleaned))
for match in reversed(candidates):
try:
parsed = json.loads(match.group(0))
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
return parsed
# 3) 兜底:贪婪匹配,仍失败则返回空
match = re.search(r"\{[\s\S]*\}", cleaned)
if match:
try:
parsed = json.loads(match.group(0))
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return {}
def _normalize_jd_draft(parsed: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
"""补齐 QwenPaw 输出缺失的字段,保证前端契约完整。"""
job = payload.get("job") or {}
mode = payload.get("mode") or "generate"
local = {
"mode": mode,
"jdVersion": "v2 待确认" if mode == "iterate" else "v1 待确认",
"jdStatus": "待确认",
"approvalStatus": "用人部门确认中",
"jd": f"{job.get('department') or '业务部门'}{job.get('title') or '目标岗位'}负责围绕业务目标完成岗位核心交付。",
"responsibilities": f"负责{job.get('title') or '目标岗位'}相关业务规划、执行和结果跟进。",
"requirements": f"具备{job.get('title') or '目标岗位'}相关经验,理解{job.get('department') or '业务部门'}业务场景。",
"mustHave": "岗位相关经验\n核心业务能力",
"niceToHave": "行业经验\n复杂项目推进能力",
"knockout": "核心经验明显不匹配",
"competency": job.get("competency") or "专业能力\n业务理解\n数据分析\n沟通协同\n抗压推进",
"matchKeywords": ",".join(
str(item) for item in [job.get("title"), job.get("department"), "业务理解", "数据分析"] if item
),
"nextAction": "推送用人部门确认 JD 草稿。",
}
merged = {**local, **parsed}
merged.setdefault("mode", mode)
merged.setdefault("jdVersion", local["jdVersion"])
merged.setdefault("jdStatus", local["jdStatus"])
merged.setdefault("approvalStatus", local["approvalStatus"])
merged.setdefault("jd", local["jd"])
merged.setdefault("responsibilities", local["responsibilities"])
merged.setdefault("requirements", local["requirements"])
merged.setdefault("mustHave", local["mustHave"])
merged.setdefault("niceToHave", local["niceToHave"])
merged.setdefault("knockout", local["knockout"])
merged.setdefault("competency", local["competency"])
merged.setdefault("matchKeywords", local["matchKeywords"])
merged.setdefault("nextAction", local["nextAction"])
return merged
def _extract_jd_body(reply: str) -> str:
"""从 QwenPaw 回复中提取可直接作为 JD 正文的文本。
回复常含大量推理文字。此处优先取 JSON 里的 jd 字段;否则尝试取
"岗位职责/任职要求"等小节之后的实义文本;再退化为去 Markdown 后的
末尾段落。返回可能为空字符串。
"""
if not reply:
return ""
parsed = _parse_jd_fields(reply)
if parsed.get("jd"):
# 结构化字段缺失时用整段正文
return str(parsed["jd"])
cleaned = _strip_json_fence(reply)
# 去掉推理/思考痕迹(QwenPaw 推理常以 "Let me ..." / 思考 开头)
# 取最后一段较长的实义文本作为 JD 正文
paragraphs = [seg.strip() for seg in re.split(r"\n\s*\n", cleaned) if seg.strip()]
# 优先找含中文、且非推理口吻的段落(JD 正文通常为中文)
for seg in reversed(paragraphs):
if len(seg) < 20:
continue
if re.match(r"^(let me|i will|i need|i should|i think|嗯|让我|我需要|我先把|json|final|the user)", seg, re.I):
continue
# 中文占比不低于 30% 才认为是 JD 正文
cjk = len(re.findall(r"[\u4e00-\u9fff]", seg))
if cjk / max(len(seg), 1) >= 0.3:
return seg
return ""
async def qwenpaw_jd_draft(payload: dict[str, Any]) -> dict[str, Any] | None:
"""通过 QwenPaw 生成 JD 草稿。
返回三种情况:
- None:未启用或服务不可用,调用方回退现有 LLM/本地逻辑。
- {"needs_chat": True, ...}:岗位信息不全,建议前端切换到聊天追问模式。
- 完整 JD 草稿:信息足够,直接产出。
"""
settings = get_settings()
if not settings.qwenpaw_enabled:
return None
job = payload.get("job") or {}
mode = payload.get("mode") or "generate"
# 信息不全 → 提示前端转聊天追问,而不是静默回退
completeness = job_info_completeness(job)
if not completeness["complete"]:
return {
"needs_chat": True,
"provider": "qwenpaw",
"missing": completeness["missing"],
"message": completeness["message"],
"mode": mode,
}
mode_text = "基于现有JD进行版本迭代" if mode == "iterate" else "结合岗位信息生成JD草稿"
prompt = (
"你是招聘中的资深JD顾问。请按 recruit-grill 技能内部梳理后,直接产出可用于审批的 JD。\n"
"只返回一个 JSON 对象(不要 Markdown 围栏、不要解释),字段必须为:"
"jdVersion, jdStatus, approvalStatus, jd, responsibilities, requirements, mustHave, niceToHave, "
"knockout, competency, matchKeywords, nextAction。\n"
f"本次为:{mode_text}。\n"
f"岗位信息:{json.dumps(job, ensure_ascii=False, indent=2)}"
)
try:
reply = await qwenpaw_chat(prompt)
except (RuntimeError, httpx.HTTPError):
# QwenPaw 服务不可用/超时 → 回退 LLM 直连 / 本地结构化,而非 500
return None
parsed = _parse_jd_fields(reply)
if not parsed:
# 融入式兜底:用本地结构化骨架保证契约完整,再把 QwenPaw 产出正文填进 jd
result = _normalize_jd_draft({}, payload)
body = _extract_jd_body(reply)
if body:
result["jd"] = body
result["provider"] = f"qwenpaw:{settings.qwenpaw_agent_id}"
return result
result = _normalize_jd_draft(parsed, payload)
result["provider"] = f"qwenpaw:{settings.qwenpaw_agent_id}"
return result
# ---------------------------------------------------------------------------
JD_REQUIRED_FIELDS = {
"title": "岗位名称",
"department": "用人部门",
"jd": "职责/使命",
"responsibilities": "核心职责",
"requirements": "任职要求",
"mustHave": "硬性条件",
}
def job_info_completeness(job: dict[str, Any]) -> dict[str, Any]:
"""评估岗位信息是否足够直接生成 JD。
Returns:
{"complete": bool, "missing": [缺失字段], "message": 提示}
"""
job = job or {}
missing = [
{"key": key, "label": label}
for key, label in JD_REQUIRED_FIELDS.items()
if not str(job.get(key) or "").strip()
]
if missing:
label_text = "、".join(item["label"] for item in missing)
return {
"complete": False,
"missing": missing,
"message": f"岗位信息还缺:{label_text}。是否切换为 AI 追问模式补全?",
}
return {"complete": True, "missing": [], "message": ""}
# ---------------------------------------------------------------------------
# SSE 流式聊天:把 QwenPaw 的流式增量逐条转发给前端(EventSource / fetch reader)
# ---------------------------------------------------------------------------
import json as _json
import uuid as _uuid
async def qwenpaw_chat_stream(
user_text: str,
*,
session_id: str = "",
) -> "AsyncIterator[dict[str, Any]]": # type: ignore[valid-type]
"""异步生成器:向 QwenPaw 发送一条消息,并逐事件产出结构化增量。
Yields:
每个事件形如:
{"type": "text", "text": "..."} # 增量文本
{"type": "status", "status": "...", "error": "..."} # 阶段/失败
{"type": "done", "reply": "完整文本"} # 结束时完整回复
"""
settings = get_settings()
base_url = settings.qwenpaw_base_url.rstrip("/")
url = f"{base_url}/api/console/chat"
agent_id = settings.qwenpaw_agent_id or "jd-agent"
sid = session_id or f"{settings.qwenpaw_session_prefix}-{_uuid.uuid4().hex[:8]}"
payload = {
"input": [{"role": "user", "content": [{"type": "text", "text": user_text}]}],
"session_id": sid,
"user_id": "recruitment-system",
"channel": "console",
}
headers = {"Content-Type": "application/json", "X-Agent-Id": agent_id}
chunks: list[str] = []
async with httpx.AsyncClient(timeout=settings.qwenpaw_timeout) as client:
try:
async with client.stream("POST", url, headers=headers, json=payload) as response:
if response.status_code >= 400:
body = (await response.aread()).decode("utf-8", "ignore")
yield {"type": "status", "status": "failed", "error": f"QwenPaw HTTP {response.status_code}: {body[:500]}"}
return
async for line in response.aiter_lines():
if not line.strip() or not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if not data:
continue
try:
event = _json.loads(data)
except _json.JSONDecodeError:
continue
text_delta = _extract_text_delta(event)
if text_delta:
chunks.append(text_delta)
yield {"type": "text", "text": text_delta}
if event.get("status") == "failed":
msg = event.get("error", {})
error_text = msg.get("message", "") if isinstance(msg, dict) else str(msg)
yield {"type": "status", "status": "failed", "error": error_text}
return
if _is_completed(event):
break
except httpx.HTTPError as exc:
yield {"type": "status", "status": "failed", "error": f"QwenPaw 连接失败: {exc}"}
return
reply = "".join(chunks).strip()
yield {"type": "status", "status": "completed"}
yield {"type": "done", "reply": reply, "session_id": sid}
from pathlib import Path
from tempfile import TemporaryDirectory
from backend.app.services.file_storage import sanitize_filename
from backend.app.services.resume_parser_core import parse_resume
def parse_resume_path(path: Path) -> dict:
return parse_resume(path)
def parse_uploaded_bytes(content: bytes, original_name: str = "resume.bin") -> dict:
with TemporaryDirectory(prefix="recruitment-resume-") as temp_dir:
path = Path(temp_dir) / sanitize_filename(original_name)
path.write_bytes(content)
return parse_resume_path(path)
import argparse
import json
import re
import sys
from datetime import date
from pathlib import Path
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
def read_pdf(path: Path) -> str:
from pypdf import PdfReader
reader = PdfReader(str(path))
chunks = []
for page in reader.pages:
chunks.append(page.extract_text() or "")
return "\n".join(chunks)
def read_docx(path: Path) -> str:
import docx
document = docx.Document(str(path))
return "\n".join(paragraph.text for paragraph in document.paragraphs)
def read_text(path: Path) -> str:
data = path.read_bytes()
for encoding in ("utf-8", "gb18030", "utf-16"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="ignore")
def normalize(text: str) -> str:
text = text.replace("\r", "\n")
text = re.sub(r"(?<=[一-龥])\s+(?=[一-龥])", "", text)
return re.sub(r"\n{3,}", "\n\n", text).strip()
def compact_chinese(text: str) -> str:
return re.sub(r"(?<=[一-龥])\s+(?=[一-龥])", "", text or "")
def first_match(patterns, text):
for pattern in patterns:
match = re.search(pattern, text, re.I)
if match:
return match.group(1).strip()
return ""
def infer_birth_date(text: str) -> str:
text = compact_chinese(text)
patterns = [
r"(?:出生年月|出生日期|出生|生日)[::\s]*((?:19|20)\d{2})[年./\- ]+(\d{1,2})(?:[月./\- ]+(\d{1,2})日?)?",
r"((?:19|20)\d{2})[年./\- ]+(\d{1,2})(?:[月./\- ]+(\d{1,2})日?)?\s*(?:出生|生)",
]
for pattern in patterns:
match = re.search(pattern, text, re.I)
if not match:
continue
year = int(match.group(1))
month = int(match.group(2))
day = int(match.group(3) or 1)
if 1950 <= year <= date.today().year and 1 <= month <= 12 and 1 <= day <= 31:
return f"{year:04d}-{month:02d}-{day:02d}"
return ""
def age_from_birth_date(birth_date: str) -> str:
if not birth_date:
return ""
year, month, day = [int(part) for part in birth_date.split("-")]
today = date.today()
age = today.year - year - ((today.month, today.day) < (month, day))
return str(age) if 15 <= age <= 80 else ""
def infer_explicit_age(text: str) -> str:
match = re.search(r"(?:年龄[::\s]*)?(1[6-9]|[2-6]\d)\s*岁", compact_chinese(text))
return match.group(1) if match else ""
def parse_year_month(year: str, month: str):
return int(year), int(month or 1)
def months_between(start, end) -> int:
start_year, start_month = start
end_year, end_month = end
return max(0, (end_year - start_year) * 12 + end_month - start_month + 1)
def format_years(months: int) -> str:
if months < 6:
return ""
years = months / 12
if abs(years - round(years)) < 0.05:
return f"{round(years)}年"
return f"{years:.1f}年"
def infer_work_years(text: str) -> str:
text = compact_chinese(text)
explicit = first_match([
r"(\d{1,2}(?:\.\d)?)\s*年(?:以上)?(?:工作)?经验",
r"工作年限[::\s]+([^\n]{2,12})"
], text)
if explicit:
return explicit if "年" in explicit else f"{explicit}年"
today = date.today()
pattern = re.compile(
r"((?:19|20)\d{2})[./年\-](\d{1,2})\s*(?:月)?\s*(?:-|—|–|~|至|到)\s*((?:19|20)\d{2}|至今|今|现在|目前)[./年\-]?\s*(\d{1,2})?",
re.I
)
intervals = []
education_words = ("教育", "学校", "院校", "大学", "学院", "本科", "硕士", "博士", "大专", "专业", "毕业")
for match in pattern.finditer(text):
line_start = text.rfind("\n", 0, match.start()) + 1
line_end = text.find("\n", match.end())
if line_end < 0:
line_end = len(text)
context = text[line_start:line_end]
if any(word in context for word in education_words):
continue
start_year, start_month, end_year, end_month = match.groups()
start = parse_year_month(start_year, start_month)
if re.search(r"至今|今|现在|目前", end_year):
end = (today.year, today.month)
else:
end = parse_year_month(end_year, end_month or "12")
if 1980 <= start[0] <= today.year and start <= end:
intervals.append((start, end))
if not intervals:
return "应届生" if re.search(r"应届生|应届毕业生|在校生|校园招聘|\d{2}届", text) else ""
# Merge overlapping month ranges to avoid double-counting concurrent jobs.
month_ranges = sorted((start[0] * 12 + start[1], end[0] * 12 + end[1]) for start, end in intervals)
merged = []
for start, end in month_ranges:
if not merged or start > merged[-1][1] + 1:
merged.append([start, end])
else:
merged[-1][1] = max(merged[-1][1], end)
total_months = sum(end - start + 1 for start, end in merged)
return format_years(total_months)
def clean_person_name(value: str) -> str:
clean = re.sub(r"\.(pdf|docx?|txt|md)$", "", value or "", flags=re.I)
clean = re.sub(r"(个人)?简历|resume|cv|求职|应聘", "", clean, flags=re.I)
# 去掉字段标签前缀:姓名 / 名字 / 姓 名
clean = re.sub(r"^(?:姓名|名字|姓\s*名)[::\s]*", "", clean)
clean = re.split(r"[_\-\s·||,,/()()]+", clean)[0]
clean = re.sub(r"[^一-龥]", "", clean)
clean = re.split(r"(?:电话|手机|邮箱|邮件|现居|所在地|城市|年龄|学历|学校|专业)", clean)[0]
if len(clean) == 4 and clean.endswith("简"):
clean = clean[:-1]
invalid_exact = {
"北京", "上海", "深圳", "广州", "杭州", "南京", "成都", "武汉", "西安", "苏州",
"天津", "重庆", "长沙", "合肥", "厦门", "宁波", "无锡", "青岛", "郑州", "应届生",
"实习生", "候选人", "其他",
}
invalid_terms = ("岗位", "招聘", "简历", "交易员", "工程师", "经理", "总监", "顾问", "专员", "应届", "实习", "薪资", "求职")
valid = re.fullmatch(r"[一-龥]{2,4}", clean) and clean not in invalid_exact and not any(term in clean for term in invalid_terms)
return clean if valid else ""
def infer_name(text: str, filename: str) -> str:
# 收集所有候选名字,按优先级尝试
candidates = []
# ① "姓名:XXX" 格式
name_label = first_match([
r"姓名[::\s]+([一-龥]{2,4})",
r"姓\s*名[::\s]+([一-龥]{2,4})",
], text)
if name_label:
candidates.append(name_label)
# ② 简历首行(很多 PDF 第一行就是名字)
first_line = text.strip().split("\n")[0].strip()
if first_line:
candidates.append(first_line)
# ③ 正文前 500 字内所有独立成行的 2-4 字中文
top = text[:500]
for m in re.finditer(r"(?:^|\n)\s*([一-龥]{2,4})\s*(?:\n|$)", top):
candidates.append(m.group(1))
# ④ 全局搜索 "姓名/名字" 附近的中文(有些简历格式不标准)
for m in re.finditer(r"(?:姓名|名字|姓\s*名)[::\s]*([一-龥]{2,4})", text):
candidates.append(m.group(1))
# ⑤ 终极兜底:前 500 字内任意位置的 2-4 字中文(如 "周洋" 和正文连在一起时)
for m in re.finditer(r"[一-龥]{2,4}", text[:500]):
cleaned = clean_person_name(m.group(0))
if cleaned:
candidates.append(cleaned)
# ⑥ 文件名提取
stem = re.sub(r"\.(pdf|docx?|txt|md)$", "", filename, flags=re.I)
for pattern in [
r"[\]】))]\s*([一-龥]{2,4})(?=\s|\d|[_\-]|$)",
r"(?:^|\s)([一-龥]{2,4})(?=\s*\d{2}年?(?:应届|毕业)|\s*应届)",
]:
match = re.search(pattern, stem)
if match:
candidates.append(match.group(1))
for token in re.split(r"[_\-\s]+", stem):
candidates.append(token)
candidates.append(filename)
# 依次验证候选名字,返回第一个有效的
for c in candidates:
cleaned = clean_person_name(c)
if cleaned:
return cleaned
return ""
def infer_job(text: str) -> str:
explicit = first_match([
r"(?:求职意向|应聘岗位|目标岗位|应聘职位|求职岗位)[::\s]+([^\n]{2,24})",
r"(?:岗位|职位)[::\s]+([^\n]{2,24})",
], text)
if explicit:
return re.split(r"[,,/||;;]", explicit)[0].strip()
rules = [
("电力交易员", ["电力交易", "交易员", "售电", "现货", "中长期", "电力市场"]),
("高级产品经理", ["产品经理", "产品规划", "需求分析"]),
("销售总监", ["销售总监", "大客户", "销售策略"]),
("高级前端工程师", ["前端", "React", "Node", "工程化"]),
("HRBP", ["HRBP", "员工关系", "组织发展"]),
]
for job, words in rules:
if any(word.lower() in text.lower() for word in words):
return job
return ""
def infer_source(text: str, filename: str) -> str:
source_text = f"{text} {filename}".lower()
mapping = {
"Boss": ["boss", "boss直聘"],
"猎聘": ["猎聘", "liepin"],
"智联": ["智联", "zhaopin"],
"内推": ["内推", "推荐"],
"官网": ["官网", "官方网站"],
}
for source, keys in mapping.items():
if any(key.lower() in source_text for key in keys):
return source
return "其他"
def extract_skills(text: str):
dictionary = [
"电力交易", "电力市场", "现货", "中长期", "售电", "新能源", "储能", "负荷预测",
"交易策略", "电价", "价差", "结算", "风控", "政策研究", "数据分析",
"Python", "SQL", "Excel", "React", "Node", "SaaS", "AI", "产品规划",
"需求分析", "销售策略", "大客户", "团队管理", "招聘", "员工关系"
]
return [skill for skill in dictionary if skill.lower() in text.lower()]
PROJECT_985 = {
"清华大学", "北京大学", "中国人民大学", "北京航空航天大学", "北京理工大学", "中国农业大学", "北京师范大学", "中央民族大学",
"南开大学", "天津大学", "大连理工大学", "东北大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", "上海交通大学",
"华东师范大学", "南京大学", "东南大学", "浙江大学", "中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学",
"武汉大学", "华中科技大学", "湖南大学", "中南大学", "国防科技大学", "中山大学", "华南理工大学", "四川大学",
"电子科技大学", "重庆大学", "西安交通大学", "西北工业大学", "西北农林科技大学", "兰州大学"
}
PROJECT_211_EXTRA = {
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", "北京林业大学", "北京中医药大学",
"北京外国语大学", "中国传媒大学", "中央财经大学", "对外经济贸易大学", "北京体育大学", "中央音乐学院",
"华北电力大学", "中国政法大学", "中国矿业大学", "中国石油大学", "中国地质大学", "上海财经大学", "上海大学",
"东华大学", "华东理工大学", "上海外国语大学", "第二军医大学", "苏州大学", "南京航空航天大学", "南京理工大学",
"河海大学", "江南大学", "南京农业大学", "中国药科大学", "南京师范大学", "安徽大学", "合肥工业大学", "福州大学",
"南昌大学", "郑州大学", "武汉理工大学", "华中师范大学", "华中农业大学", "中南财经政法大学", "湖南师范大学",
"暨南大学", "华南师范大学", "广西大学", "海南大学", "西南交通大学", "四川农业大学", "西南财经大学",
"西南大学", "云南大学", "贵州大学", "西藏大学", "西北大学", "西安电子科技大学", "长安大学",
"陕西师范大学", "青海大学", "宁夏大学", "新疆大学", "石河子大学", "内蒙古大学", "辽宁大学", "大连海事大学",
"东北师范大学", "哈尔滨工程大学", "东北林业大学", "东北农业大学", "河北工业大学", "太原理工大学",
"延边大学"
}
INDUSTRY_SCHOOLS = {
"东北电力大学", "上海电力大学", "华北水利水电大学", "长沙理工大学", "三峡大学",
"沈阳化工大学", "沈阳化工学院", "沈阳工业大学", "辽宁石油化工大学", "南京工程学院", "浙江水利水电学院"
}
COMMON_MAJORS = [
"电气工程及其自动化", "机械设计制造及其自动化", "新能源科学与工程", "能源与动力工程",
"数据科学与大数据技术", "计算机科学与技术", "化学工程与工艺", "电子信息工程",
"新能源材料与器件", "能源化学工程", "电力系统及其自动化", "电气工程与智能控制",
"人力资源管理", "信息管理与信息系统", "电气工程", "软件工程", "自动化", "应用化学",
"通信工程", "工商管理", "市场营销", "财务管理", "会计学"
]
def clean_school_candidate(value: str) -> str:
value = compact_chinese(value)
value = re.sub(r"^(?:教育背景|教育经历|学习经历|毕业院校|学校|院校|本科|硕士|博士)+", "", value or "")
return re.sub(r"^[::\s]+", "", value).strip()
def is_likely_department(value: str) -> bool:
school = clean_school_candidate(value)
if not school:
return True
if school.endswith("大学") or school in PROJECT_985 or school in PROJECT_211_EXTRA or school in INDUSTRY_SCHOOLS:
return False
return bool(re.search(r"(?:功化|自动化|化工|计算机|信息|电气|能源|材料|管理|经济|商|外国语|理|文|法|艺术|体育|马克思主义)学院$", school))
def infer_school(text: str) -> str:
text = compact_chinese(text)
schools = sorted(PROJECT_985 | PROJECT_211_EXTRA | INDUSTRY_SCHOOLS, key=len, reverse=True)
for school in schools:
if school in text:
return school
if re.search(r"沈阳", text) and re.search(r"(?:化工|功化).{0,4}学院", text):
return "沈阳化工大学"
explicit = first_match([
r"(?:毕业院校|学校|院校)[::\s]+([一-龥]{2,24}(?:大学|学院|学校))",
], text)
explicit = clean_school_candidate(explicit)
if explicit and not is_likely_department(explicit):
return explicit
candidates = []
for match in re.finditer(r"([一-龥]{2,18}(?:大学|学院|学校))", text):
school = clean_school_candidate(match.group(1))
if 4 <= len(school) <= 16 and not is_likely_department(school):
candidates.append(school)
return next((school for school in candidates if school.endswith("大学")), candidates[0] if candidates else "")
def infer_major(text: str) -> str:
text = compact_chinese(text)
explicit = first_match([
r"(?:专业|所学专业)[::\s]+([一-龥A-Za-z0-9()()·\-]{2,30})",
r"(?:大学|学院)\s+([一-龥A-Za-z0-9()()·\-]{2,30}?)(?:专业)?\s+(?:博士|硕士|本科|大专)",
r"(?:大学|学院)[^\n]{0,50}?([一-龥A-Za-z0-9()()·\-]{2,24}(?:专业|工程|科学|技术|管理|自动化))",
], text)
if explicit:
cleaned = re.sub(r"(本科|硕士|博士|大专|专业)$", "", explicit).strip()
if cleaned:
return cleaned
school = infer_school(text)
if school and school in text:
segment = text.split(school, 1)[1][:180]
dictionary_major = next((major for major in sorted(COMMON_MAJORS, key=len, reverse=True) if major in segment), "")
if dictionary_major:
return dictionary_major
match = re.search(r"([^\s,,;;。]{2,30}?)(?:专业)?\s*(?:博士|硕士|本科|大专)", segment.strip())
if match:
return re.sub(r"专业$", "", match.group(1)).strip()
education_block_match = re.search(r"(?:教育背景|教育经历|学习经历)[\s\S]{0,500}?(?:工作经历|项目经历|实习经历|技能|证书|自我评价|$)", text)
education_block = education_block_match.group(0) if education_block_match else ""
dictionary_major = next((major for major in sorted(COMMON_MAJORS, key=len, reverse=True) if major in education_block), "")
if dictionary_major:
return dictionary_major
return ""
def school_tags(school: str):
tags = []
if is_likely_department(school):
return tags
if school in PROJECT_985:
tags.extend(["985", "211"])
elif school in PROJECT_211_EXTRA:
tags.append("211")
elif school:
tags.append("非985/211")
return tags
def parse_resume(path: Path):
suffix = path.suffix.lower()
if suffix == ".pdf":
text = read_pdf(path)
elif suffix == ".docx":
text = read_docx(path)
else:
text = read_text(path)
text = normalize(text)
filename = path.name
# 电话:支持多种格式 13812345678 / 138 1234 5678 / 138-1234-5678 / +86 13812345678
phone = first_match([
r"((?:\+?86[-\s]?)?1[3-9]\d[-\s]?\d{4}[-\s]?\d{4})",
r"电话[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})",
], text)
email = first_match([r"([A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})"], text)
years = infer_work_years(f"{text}\n{filename}")
birth_date = infer_birth_date(text)
education = infer_education(text)
school = infer_school(text)
major = infer_major(text)
city = first_match([r"(?:现居|所在地|城市|地点)[::\s]+([一-龥]{2,12})"], text)
result = {
"name": infer_name(text, filename),
"jobTitle": infer_job(text),
"phone": phone,
"email": email,
"birthDate": birth_date,
"age": infer_explicit_age(text) or age_from_birth_date(birth_date),
"years": years,
"education": education,
"school": school,
"major": major,
"schoolTags": school_tags(school),
"city": city,
"source": infer_source(text, filename),
"skills": extract_skills(text),
"resumeText": text,
"resumeName": filename,
"textLength": len(text),
}
return result
def infer_education(text: str) -> str:
text = compact_chinese(text)
if re.search(r"博士|博士研究生", text):
return "博士"
if re.search(r"硕士|硕士研究生|研究生", text):
return "硕士"
if re.search(r"本科|学士|大学本科", text):
return "本科"
if re.search(r"大专|专科", text):
return "大专"
if "中专" in text:
return "中专"
if "高中" in text:
return "高中"
explicit = first_match([r"学历[::\s]+([^\n]{2,12})"], text)
return explicit or ""
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="解析 PDF/DOCX/TXT 简历并输出 JSON。")
parser.add_argument("file", help="待解析的简历文件路径")
args = parser.parse_args(argv)
try:
print(json.dumps(parse_resume(Path(args.file)), ensure_ascii=False))
return 0
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False))
return 1
if __name__ == "__main__":
sys.exit(main())
qwenpaw @ fef7e64d
Subproject commit fef7e64d984f4332d0b84a343cd209bd3ea5d316
{
"name": "recruitment-backend",
"version": "0.5.0",
"description": "招聘系统后端与集成静态前端的命令包装。",
"private": true,
"scripts": {
"start": "cd .. && python backend/scripts/sync_frontend_static.py && python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177",
"dev": "cd .. && python backend/scripts/sync_frontend_static.py && python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload",
"migrate": "cd .. && python -m alembic -c backend/alembic.ini upgrade head",
"test": "cd .. && python -m pytest -c backend/pyproject.toml",
"check:parser": "cd .. && python backend/scripts/parse_resume.py --help",
"sync:static": "cd .. && python backend/scripts/sync_frontend_static.py",
"check:static": "cd .. && python backend/scripts/sync_frontend_static.py --check"
}
}
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "recruitment-system"
version = "0.5.0"
description = "三端分离的招聘系统:FastAPI 后端、独立网页前端和浏览器扩展"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115,<1",
"uvicorn[standard]>=0.30,<1",
"sqlalchemy>=2.0,<3",
"alembic>=1.13,<2",
"pydantic-settings>=2.4,<3",
"python-multipart>=0.0.9,<1",
"httpx>=0.27,<1",
"pypdf>=5.1,<6",
"python-docx>=1.1,<2"
]
[project.optional-dependencies]
mysql = [
"pymysql>=1.1,<2"
]
dev = [
"pytest>=8.3,<9",
"pytest-cov>=5,<6",
"ruff>=0.6,<1"
]
[project.scripts]
recruitment-server = "backend.app.main:run"
[tool.setuptools]
package-dir = {"" = ".."}
[tool.setuptools.packages.find]
where = [".."]
include = ["backend*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = [".."]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501", "B008"]
console.error("后端已迁移到 Python + FastAPI。请在项目根目录执行:");
console.error("python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177");
process.exit(1);
"""Command-line entry point for the backend resume parser."""
from backend.app.services.resume_parser_core import main
if __name__ == "__main__":
raise SystemExit(main())
$env:DEEPSEEK_API_KEY = "替换为你的 DeepSeek API Key"
$env:DEEPSEEK_MODEL = "deepseek-v4-flash"
node .\server.mjs
"""Publish selected frontend source assets into backend/static."""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
FRONTEND_ROOT = PROJECT_ROOT / "frontend"
STATIC_ROOT = PROJECT_ROOT / "backend" / "static"
ASSET_MAP = {
"app.html": "app.html",
"app.html:index": "index.html",
"app.css": "app.css",
"app.js": "app.js",
"auto-ingest.js": "auto-ingest.js",
"config.js": "config.js",
"prototype.html": "prototype.html",
}
def assets_match() -> bool:
for source_key, target_name in ASSET_MAP.items():
source_name = source_key.split(":", 1)[0]
source = FRONTEND_ROOT / source_name
target = STATIC_ROOT / target_name
if not source.is_file() or not target.is_file() or source.read_bytes() != target.read_bytes():
return False
return True
def sync() -> None:
STATIC_ROOT.mkdir(parents=True, exist_ok=True)
for source_key, target_name in ASSET_MAP.items():
source_name = source_key.split(":", 1)[0]
source = FRONTEND_ROOT / source_name
if not source.is_file():
raise FileNotFoundError(f"Required frontend asset is missing: {source}")
shutil.copy2(source, STATIC_ROOT / target_name)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="同步前端发布文件到 backend/static。")
parser.add_argument("--check", action="store_true", help="只检查静态副本是否与前端源码一致")
args = parser.parse_args(argv)
if args.check:
if assets_match():
print("Frontend static assets are current.")
return 0
print("Frontend static assets are missing or out of date.", file=sys.stderr)
return 1
sync()
print(f"Published frontend assets to {STATIC_ROOT}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@echo off
setlocal
cd /d "%~dp0.."
REM ??????? backend/**/*.py ?????????? backend/static?????????
REM ???????? JD ??? QwenPaw????? QwenPaw ???start-qwenpaw.cmd??
python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177 --reload --reload-dir backend >> "backend\server.out.log" 2>> "backend\server.err.log"
import pytest
from fastapi.testclient import TestClient
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{(tmp_path / 'test.sqlite').as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "")
monkeypatch.setenv("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
import backend.app.config as config
import backend.app.db as db
config.get_settings.cache_clear()
db.engine.dispose()
db.settings = config.get_settings()
db.engine = db.create_engine(
db.normalize_database_url(db.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
db.SessionLocal.configure(bind=db.engine)
from backend.app.main import create_app
app = create_app()
with TestClient(app) as test_client:
yield test_client
db.engine.dispose()
config.get_settings.cache_clear()
def test_ai_fallback_routes(client, monkeypatch):
monkeypatch.setenv("LLM_API_KEY", "")
analysis = client.post(
"/api/analyze-resume",
json={
"candidate": {"name": "候选人", "resumeText": "React Node 工程化 性能优化", "skills": ["React"]},
"job": {"title": "高级前端工程师", "mustHave": "React\n工程化", "matchKeywords": "Node\n性能优化"},
},
)
assert analysis.status_code == 200
assert analysis.json()["provider"] == "local-structured"
assert analysis.json()["reportVersion"] == "ai-evaluation-v2"
jd = client.post("/api/generate-jd", json={"job": {"title": "高级前端工程师", "department": "技术部"}, "mode": "generate"})
assert jd.status_code == 200
assert jd.json()["provider"] == "local-structured"
assert jd.json()["jdStatus"] == "待确认"
def test_grill_start_and_advance(client):
"""确定式逼问访谈:start 返回第一问,逐题 advance 推进到 complete,并采集结构化字段。"""
start = client.post(
"/api/jd/grill/start",
json={"job": {"title": "Java开发工程师", "department": "虚拟电厂业务线"}, "tech_role": True},
)
assert start.status_code == 200
s = start.json()
assert s["next"]["question_id"] == "entry_path"
assert s["total_steps"] == 14
assert any(o["value"] == "new" for o in s["next"]["options"])
answers = {
"entry_path": "new",
"input_form": "one-sentence",
"internal_digest": "must-hire",
"positioning": "backfill",
"responsibilities": "tech",
"hard_gate": "bachelor-plus",
"sensitive": "none",
"salary_location": "shenzhen-onsite",
"lifeline": "tech-lifeline",
"verification": "resume+design",
"tech_stack": "proficient-hard",
"knockout": "crud",
"probation": "3m-iterate",
"noncompete": "yes",
}
state = s
asked = []
while not state.get("complete"):
qid = state["next"]["question_id"]
asked.append(qid)
resp = client.post(
"/api/jd/grill/advance",
json={"state": state, "answer": {"question_id": qid, "type": "option", "option": answers[qid]}},
)
assert resp.status_code == 200
state = resp.json()
assert state["complete"] is True
assert len(asked) == 14
assert state["job"]["title"] == "Java开发工程师"
assert state["job"]["mustHave"] # 命脉已采集
assert state["job"]["knockout"] # 排除信号已采集
assert state["job"]["sensitive"]["gender"] == "不限" # 敏感项单独存放
assert state["progress"]["percent"] == 100
def test_grill_skips_existing_fields(client):
"""老岗位迭代:已有 jd/mustHave/knockout/education 的题自动跳过,只问变化点。"""
start = client.post(
"/api/jd/grill/start",
json={
"job": {
"title": "Java开发工程师",
"department": "虚拟电厂",
"jd": "已有JD正文",
"mustHave": "微服务",
"knockout": "纯CRUD",
"education": "本科以上",
},
"tech_role": True,
},
)
assert start.status_code == 200
s = start.json()
# 第一问不是 entry_path(已有 jd 跳过入口分流)
first_qid = s["next"]["question_id"]
assert first_qid not in ("entry_path", "input_form", "hard_gate", "lifeline", "knockout")
def test_ingest_deduplicates_payload(client):
payload = {"name": "李四", "source": "Boss", "sourceUrl": "https://example.test/resume/1", "summary": "React Node 工程化"}
first = client.post("/api/ingest", json=payload)
assert first.status_code == 200
assert first.json()["ok"] is True
second = client.post("/api/ingest", json=payload)
assert second.status_code == 200
assert second.json()["skipped"] is True
state = client.get("/api/state").json()
assert len(state["candidates"]) == 1
assert state["candidates"][0]["name"] == "李四"
queue = client.get("/api/ingest-queue").json()
assert queue["count"] == 1
client.post(f"/api/ingest-queue/clear?ids={queue['queue'][0]['id']}")
assert client.get("/api/ingest-queue").json()["count"] == 0
from backend.app.services.resume_parser_core import main, parse_resume
def test_parse_text_resume(tmp_path):
resume = tmp_path / "张三_高级前端工程师.txt"
resume.write_text("姓名:张三\n电话:13812345678\n邮箱:zhangsan@example.com\n本科 清华大学 计算机科学与技术\n熟悉 React Node Python", encoding="utf-8")
parsed = parse_resume(resume)
assert parsed["name"] == "张三"
assert parsed["phone"] == "13812345678"
assert parsed["email"] == "zhangsan@example.com"
assert "React" in parsed["skills"]
def test_parser_help(capsys):
try:
main(["--help"])
except SystemExit as exc:
assert exc.code == 0
assert "解析 PDF/DOCX/TXT 简历" in capsys.readouterr().out
def test_state_api_roundtrip(client):
payload = {
"jobs": [{"id": "job-1", "title": "高级前端工程师", "department": "技术部"}],
"candidates": [
{
"id": "cand-1",
"jobId": "job-1",
"name": "张三",
"jobTitle": "高级前端工程师",
"stage": "未筛选",
"source": "内推",
}
],
"offers": [],
"tasks": [{"id": "task-1", "title": "安排面试", "status": "待处理", "candidateId": "cand-1", "jobId": "job-1"}],
"eventLog": [{"id": "evt-1", "type": "candidate_created", "at": "2026-01-01T00:00:00.000Z", "candidateId": "cand-1"}],
}
response = client.put("/api/state", json=payload)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
loaded = client.get("/api/state").json()
assert loaded["meta"]["persisted"] is True
assert loaded["jobs"][0]["title"] == "高级前端工程师"
assert loaded["candidates"][0]["name"] == "张三"
assert loaded["tasks"][0]["title"] == "安排面试"
def test_import_local_state(client):
response = client.post("/api/import/local-state", json={"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []})
assert response.status_code == 200
assert response.json()["imported"] is True
def test_homepage_redirects_to_published_frontend(client):
response = client.get("/", follow_redirects=False)
assert response.status_code == 307
assert response.headers["location"] == "/index.html"
homepage = client.get("/index.html")
assert homepage.status_code == 200
assert "招聘系统" in homepage.text
assert client.get("/app.js").status_code == 200
def test_static_host_does_not_expose_sensitive_paths(client):
assert client.get("/.env").status_code == 404
assert client.get("/.env.local").status_code == 404
assert client.get("/api/ping").status_code == 200
def test_frontend_origin_is_allowed_by_cors(client):
response = client.options(
"/api/state",
headers={
"Origin": "http://127.0.0.1:5173",
"Access-Control-Request-Method": "GET",
},
)
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "http://127.0.0.1:5173"
# ---------- 外部参考副本 ----------
# reference/ 是外部方案参考副本,不属于运行时扩展
reference/
# ---------- 打包产物 ----------
*.zip
*.crx
# ---------- 系统与编辑器 ----------
.DS_Store
Thumbs.db
Desktop.ini
__MACOSX/
.idea/
.vscode/
# 浏览器扩展端
此目录可直接作为 Chrome / Edge 的“加载已解压的扩展程序”目录。
## 安装
1. 启动后端:`python -m uvicorn backend.app.main:app --host 127.0.0.1 --port 4177`
2. 打开 Chrome 或 Edge 的扩展程序页面并开启开发者模式。
3. 选择“加载已解压的扩展程序”,选择本目录。
4. 在扩展弹窗中确认 API 地址为 `http://127.0.0.1:4177/api/ingest`;部署到其他地址时可直接修改并保存。
本扩展从 BOSS 直聘和猎聘页面采集候选人简历并提交给后端。`reference/` 是外部方案参考副本,不属于运行时扩展。
/**
* 简历采集器 — Background Service Worker
* - 通过 chrome.cookies API 获取 HttpOnly cookie(BOSS 的鉴权 cookie 是 HttpOnly 的)
* - 不自动发送任何请求到服务器,所有发送由 content script 浮动按钮触发
*/
// ── 获取指定域名的所有 cookie(包括 HttpOnly) ─────────
async function getCookiesForDomain(domain) {
try {
return await chrome.cookies.getAll({ domain: domain });
} catch (e) {
return [];
}
}
// 尝试多个域名变体
async function getAllCookies(domain) {
var all = [];
// 主域名
var list1 = await getCookiesForDomain(domain);
all = all.concat(list1);
// 带 www 前缀
if (!domain.startsWith('www.')) {
var list2 = await getCookiesForDomain('www.' + domain);
all = all.concat(list2);
}
// 去重(按 name)
var seen = new Set();
return all.filter(function (c) {
if (seen.has(c.name)) return false;
seen.add(c.name);
return true;
});
}
// ── 消息处理 ──────────────────────────────────────────
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
if (msg.action === 'getCookies') {
var domain = msg.domain || 'zhipin.com';
getAllCookies(domain).then(function (cookies) {
sendResponse(cookies);
}).catch(function () {
sendResponse([]);
});
return true; // 保持异步通道
}
});
console.log('[简历采集器 bg] Service Worker 已启动');
/**
* 简历采集器 Content Script — BOSS直聘 + 猎聘
* Shiba Inu 可拖动宠物,多状态切换
*/
(function () {
'use strict';
const DEFAULT_API = 'http://127.0.0.1:4177/api/ingest';
let capturedPdfUrl = '', lastSentHash = '', sending = false;
function isBoss() { return window.location.hostname.indexOf('zhipin.com') >= 0; }
function isLiepin() { return window.location.hostname.indexOf('liepin.com') >= 0; }
window.addEventListener('message', function (e) {
if (e.data && e.data.type === '__RESUME_COLLECTOR_FILEURL__' && e.data.url) capturedPdfUrl = e.data.url;
});
function getBossAttachmentIframeUrl() {
var iframe = document.querySelector('.resume-recommend.resume-common-wrap .attachment-view iframe.attachment-box.attachment-iframe') ||
document.querySelector('.attachment-view iframe.attachment-box.attachment-iframe') ||
document.querySelector('iframe[src*="preview4boss"]') || document.querySelector('iframe[src*="docdownload"]') || null;
return iframe ? convertBossDownloadUrl(iframe.getAttribute('src') || '') : '';
}
function convertBossDownloadUrl(src) {
if (!src) return '';
try {
var u = new URL(src, window.location.href);
if (/docdownload/i.test(u.hostname)) return src;
var m = (u.pathname || '').match(/\/preview4boss\/([^/?#]+)/);
if (m && m[1]) { var g = u.searchParams.get('geekId') || ''; return 'https://docdownload.zhipin.com/wflow/zpgeek/download/download4boss/' + m[1] + (g ? '?geekId=' + g : ''); }
var p = u.searchParams.get('url');
if (p) { try { p = decodeURIComponent(p); } catch (_) {} var m2 = p.match(/\/preview4boss\/([^/?#]+)/); if (m2 && m2[1]) { var g2 = ''; try { g2 = new URL(p).searchParams.get('geekId') || ''; } catch (_) {} return 'https://docdownload.zhipin.com/wflow/zpgeek/download/download4boss/' + m2[1] + (g2 ? '?geekId=' + g2 : ''); } if (/docdownload/i.test(p)) return p; }
} catch (_) {}
return src;
}
function getLiepinPdfUrl() {
var h = window.location.href;
if (/\.pdf(\?|$)/i.test(h)) return h;
if (/\/attachment\//i.test(h) || /\/download\//i.test(h) || /\/resume\/file\//i.test(h)) return h;
var l = document.querySelector('a[class*="download"]') || document.querySelector('a[href*=".pdf"]') || document.querySelector('a[href*="attachment"]') || document.querySelector('a[href*="/resume/file/"]') || null;
if (l) { var lh = l.getAttribute('href') || ''; if (lh) { try { return new URL(lh, window.location.href).href; } catch (_) { return lh; } } }
return '';
}
function clean(s) { return (s || '').replace(/\s+/g, ' ').trim(); }
function getDirectText(el) { var c = el.cloneNode(true); for (var i = c.children.length - 1; i >= 0; i--) c.removeChild(c.children[i]); return clean(c.textContent); }
function looksLikePersonName(s) { if (!s) return false; s = s.trim(); if (!/^[一-鿿]{2,4}$/.test(s)) return false; return !/^(北京|上海|广州|深圳|杭州|成都|武汉|南京|西安|苏州|天津|重庆|长沙|合肥|厦门|宁波|无锡|青岛|郑州|东莞|佛山|珠海|中山|惠州|温州|福州|南昌|昆明|贵阳|南宁|海口|兰州|招聘|职位|薪资|工作|经验|学历|本科|硕士|博士|高中|中专|大专|附件|下载|预览)$/.test(s); }
function parseCookies(r) { var m = {}; if (!r) return m; r.split(';').forEach(function (p) { var e = p.indexOf('='); if (e > 0) m[p.slice(0, e).trim()] = p.slice(e + 1).trim(); }); return m; }
async function getCookies() { return new Promise(function (res) { try { var d = window.location.hostname.replace(/^www\./, ''); var ps = d.split('.'); if (ps.length > 2) d = ps.slice(-2).join('.'); chrome.runtime.sendMessage({ action: 'getCookies', domain: d }, function (c) { var e = chrome.runtime.lastError; if (e) { res(document.cookie || ''); return; } if (!Array.isArray(c) || !c.length) { res(document.cookie || ''); return; } try { res(c.filter(function (x) { return x && x.name; }).map(function (x) { return x.name + '=' + (x.value || ''); }).join('; ')); } catch (_) { res(document.cookie || ''); } }); } catch (_) { res(document.cookie || ''); } }); }
function getApiUrl() { return new Promise(function (resolve) { chrome.storage.local.get('apiUrl', function (data) { resolve((data && data.apiUrl) || DEFAULT_API); }); }); }
async function sendToLocal(d) { var apiUrl = await getApiUrl(); return fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) }).then(function (r) { return r.ok ? r.json() : { ok: false, message: '服务器返回 ' + r.status }; }).catch(function () { return { ok: false, message: '本地服务未启动' }; }); }
function resolvePdfUrl() {
if (capturedPdfUrl) return capturedPdfUrl;
if (isBoss()) { var u = getBossAttachmentIframeUrl(); if (u) return u; }
if (isLiepin()) { var v = getLiepinPdfUrl(); if (v) return v; }
return '';
}
function findResumePanel() {
if (isBoss()) return document.querySelector('.resume-recommend.resume-common-wrap') || document.querySelector('.geek-card') || document.querySelector('.candidate-resume') || document.querySelector('.resume-panel') || null;
if (isLiepin()) return document.querySelector('#water-mark-wrap') || document.querySelector('.resume-page') || document.querySelector('.candidate-detail') || document.querySelector('.resume-container') || document.querySelector('[class*="resume-main"]') || null;
return null;
}
function extractNameFromPanel(panel) {
if (!panel) return '';
var sels = isLiepin() ? ['.name', '.candidate-name', '[class*="realName"]', 'h1', 'h2'] : ['.geek-name', '.candidate-name', '.resume-name', '.name'];
for (var i = 0; i < sels.length; i++) { var els = panel.querySelectorAll(sels[i]); for (var j = 0; j < els.length; j++) { var t = getDirectText(els[j]); if (looksLikePersonName(t)) return t; var f = clean(els[j].textContent); if (f !== t && looksLikePersonName(f)) return f; } }
return '';
}
function extractPanelText(panel) { return panel ? clean(panel.textContent).slice(0, 5000) : ''; }
async function doSend() {
if (sending) return;
sending = true;
showBubble('⏳ 采集中...');
try {
var pdfUrl = resolvePdfUrl();
var platform = isLiepin() ? '猎聘' : 'Boss直聘';
var payload = { source: platform, sourceUrl: window.location.href, extractedAt: new Date().toISOString() };
if (pdfUrl) { var cs = await getCookies(); payload.pdfUrl = pdfUrl; payload.cookies = parseCookies(cs); }
else {
var panel = findResumePanel(); if (!panel) throw new Error('未检测到简历');
var name = extractNameFromPanel(panel), text = extractPanelText(panel);
if (!name && !text) throw new Error('简历无内容');
payload.name = name; payload.summary = text;
}
var hash = (payload.name || '') + '|' + (payload.pdfUrl || pdfUrl || '') + '|' + platform;
if (hash === lastSentHash && lastSentHash) { showBubble('已采集过'); sending = false; setTimeout(hideBubble, 1500); return; }
var result = await sendToLocal(payload);
if (result.ok) { lastSentHash = hash; capturedPdfUrl = ''; showBubble('已入库 ✅'); petEl.className = 'done'; }
else throw new Error(result.message || '失败');
} catch (err) { showBubble('⚠️ ' + err.message); petEl.className = 'error'; }
sending = false;
setTimeout(function () { if (!sending) { petEl.className = ''; hideBubble(); } }, 2500);
}
// ═══════════════════════════════════════════════════
// SVG Shiba — 多状态
// ═══════════════════════════════════════════════════
let petEl = null, bubbleEl = null, idleTimer = null;
const IDLE_STATES = ['sleep', 'lie', 'sit'];
// 气泡
function showBubble(msg) { if (bubbleEl) { bubbleEl.textContent = msg; bubbleEl.className = 'pet-bubble show'; } }
function hideBubble() { if (bubbleEl) bubbleEl.className = 'pet-bubble'; }
function setRandomIdle() {
if (sending) return;
var st = IDLE_STATES[Math.floor(Math.random() * IDLE_STATES.length)];
petEl.className = st;
var msgs = { sleep: 'Zzz...', lie: '💤', sit: '🐕' };
bubbleEl.textContent = msgs[st];
bubbleEl.className = 'pet-bubble show';
setTimeout(function () { if (petEl.className === st) bubbleEl.className = 'pet-bubble'; }, 3000);
}
const PET_STYLE = `
#__shiba { position:fixed; bottom:16px; right:16px; z-index:2147483647; cursor:grab; user-select:none; pointer-events:auto; transition:transform .3s, left .05s, top .05s; }
#__shiba:hover { transform:scale(1.08); }
#__shiba.dragging { cursor:grabbing; transition:transform .3s; }
#__shiba svg { display:block; filter:drop-shadow(0 6px 16px rgba(0,0,0,.22)); }
.pet-bubble { position:absolute; bottom:102px; left:50%; transform:translateX(-50%); pointer-events:none; font-size:12px; color:#555; background:#fff; padding:4px 10px; border-radius:12px; box-shadow:0 2px 8px rgba(0,0,0,.12); white-space:nowrap; opacity:0; transition:opacity .3s; }
.pet-bubble.show { opacity:1; }
.pet-bubble::after { content:''; position:absolute; bottom:-6px; left:50%; margin-left:-6px; width:0; height:0; border-left:6px solid transparent; border-right:6px solid transparent; border-top:6px solid #fff; }
/* ── 每个状态控制不同 SVG 部分的可见性 ── */
.s-eye-open, .s-eye-closed, .s-mouth-closed, .s-mouth-yawn,
.s-ear-up, .s-ear-flop, .s-tail-up, .s-tail-down, .s-leg-stand, .s-leg-lie { transition:opacity .3s; }
/* 默认(坐着):立耳 + 睁眼 + 闭嘴 + 卷尾 + 站腿 */
#__shiba .s-eye-open, #__shiba .s-mouth-closed, #__shiba .s-ear-up, #__shiba .s-tail-up, #__shiba .s-leg-stand { opacity:1; }
#__shiba .s-eye-closed, #__shiba .s-mouth-yawn, #__shiba .s-ear-flop, #__shiba .s-tail-down, #__shiba .s-leg-lie { opacity:0; }
/* sleep: 闭眼 + 耷耳 + 摇尾(尾巴保持) + 趴腿 */
#__shiba.sleep .s-eye-open, #__shiba.sleep .s-mouth-closed, #__shiba.sleep .s-ear-up, #__shiba.sleep .s-leg-stand { opacity:0; }
#__shiba.sleep .s-eye-closed, #__shiba.sleep .s-ear-flop, #__shiba.sleep .s-leg-lie { opacity:1; }
#__shiba.sleep .s-tail-up { opacity:1; animation:tail-wag-slow 3s ease-in-out infinite; transform-origin:62px 68px; }
/* lie: 闭眼 + 耷耳 + 垂尾 + 趴腿 */
#__shiba.lie .s-eye-open, #__shiba.lie .s-mouth-closed, #__shiba.lie .s-ear-up, #__shiba.lie .s-leg-stand, #__shiba.lie .s-tail-up { opacity:0; }
#__shiba.lie .s-eye-closed, #__shiba.lie .s-ear-flop, #__shiba.lie .s-leg-lie, #__shiba.lie .s-tail-down { opacity:1; }
#__shiba.lie .s-tail-down { animation:tail-wag-slow 4s ease-in-out infinite; transform-origin:62px 72px; }
/* sit: 立耳 + 睁眼 + 快摇尾 */
#__shiba.sit .s-tail-up { animation:tail-wag-fast .5s ease-in-out infinite; transform-origin:62px 68px; }
#__shiba.sit .s-ear-up { animation:ear-twitch 2.5s ease-in-out infinite; }
/* stretch */
#__shiba.stretch svg { animation:ani-stretch .8s ease-out; }
#__shiba.stretch .s-tail-up { animation:tail-up .8s ease-out; }
/* sneeze */
#__shiba.sneeze svg { animation:ani-sneeze .5s ease-out; }
/* yawn */
#__shiba.yawn .s-mouth-closed { opacity:0; }
#__shiba.yawn .s-mouth-yawn { opacity:1; }
#__shiba.yawn svg { animation:ani-yawn .9s ease-out; }
/* scratch */
#__shiba.scratch svg { animation:ani-scratch .6s ease-in-out; }
/* done */
#__shiba.done svg { animation:ani-happy .4s ease-out 3; }
#__shiba.done .s-tail-up { animation:tail-wag-fast .3s ease-in-out infinite; }
/* error */
#__shiba.error svg { animation:ani-shake .5s ease-out; }
@keyframes tail-wag-slow { 0%,100% { transform:rotate(-5deg); } 50% { transform:rotate(5deg); } }
@keyframes tail-wag-fast { 0%,100% { transform:rotate(-10deg); } 25% { transform:rotate(5deg); } 75% { transform:rotate(15deg); } }
@keyframes ear-twitch { 0%,85%,100% { transform:rotate(0); } 90% { transform:rotate(-15deg); } 95% { transform:rotate(5deg); } }
@keyframes tail-up { 0% { transform:rotate(0); } 30% { transform:rotate(-15deg); } 100% { transform:rotate(0); } }
@keyframes ani-stretch { 0% { transform:scaleY(1); } 30% { transform:scaleY(.92); } 60% { transform:scaleY(1.06); } 100% { transform:scaleY(1); } }
@keyframes ani-sneeze { 0% { transform:rotate(0); } 20% { transform:rotate(-5deg); } 40% { transform:rotate(6deg); } 60% { transform:rotate(-3deg); } 100% { transform:rotate(0); } }
@keyframes ani-yawn { 0%,100% { transform:rotate(0); } 30%,60% { transform:rotate(4deg) translateY(-2px); } }
@keyframes ani-scratch { 0%,100% { transform:translateY(0); } 25% { transform:translateY(-3px); } 50% { transform:translateY(-5px); } 75% { transform:translateY(-2px); } }
@keyframes ani-happy { 0%,100% { transform:translateY(0); } 50% { transform:translateY(-8px); } }
@keyframes ani-shake { 0%,100% { transform:rotate(0); } 15% { transform:rotate(-4deg); } 30% { transform:rotate(4deg); } 50% { transform:rotate(-3deg); } 70% { transform:rotate(3deg); } }
`;
// SVG: 圆脸柴犬,多组状态部件
const SHIBA_SVG = `
<svg viewBox="0 0 100 108" width="90" height="97">
<!-- 卷尾(在身体后面) -->
<g class="s-tail-up">
<path d="M72 68 Q88 58 84 42 Q82 34 72 32 Q66 32 66 40 Q66 48 70 54" fill="#D4954B" stroke="#B8753A" stroke-width=".5"/>
<path d="M72 34 Q74 30 70 32 Q66 36 68 40" fill="#FEF9F0"/>
</g>
<!-- 垂尾 -->
<g class="s-tail-down">
<path d="M72 68 Q82 80 78 92 Q76 96 70 94 Q66 90 68 80" fill="#D4954B" stroke="#B8753A" stroke-width=".5"/>
<path d="M72 82 Q74 88 70 90 Q68 88 70 84" fill="#FEF9F0"/>
</g>
<!-- 身体 -->
<ellipse cx="50" cy="64" rx="28" ry="24" fill="#E8A84C"/>
<ellipse cx="48" cy="68" rx="20" ry="15" fill="#FEF9F0"/>
<!-- 站腿 -->
<g class="s-leg-stand">
<rect x="26" y="78" width="12" height="18" rx="6" fill="#FEF9F0"/>
<rect x="30" y="80" width="8" height="16" rx="4" fill="#E8DCC8"/>
<rect x="58" y="78" width="12" height="18" rx="6" fill="#FEF9F0"/>
<rect x="62" y="80" width="8" height="16" rx="4" fill="#E8DCC8"/>
</g>
<!-- 趴腿 -->
<g class="s-leg-lie">
<ellipse cx="30" cy="88" rx="12" ry="6" fill="#FEF9F0"/>
<ellipse cx="64" cy="88" rx="12" ry="6" fill="#FEF9F0"/>
</g>
<!-- 头 -->
<g>
<!-- 立耳 -->
<g class="s-ear-up">
<polygon points="24,10 16,-8 32,4" fill="#D4954B"/>
<polygon points="25,8 18,-4 30,5" fill="#F0C8A0"/>
<polygon points="60,10 68,-8 52,4" fill="#D4954B"/>
<polygon points="59,8 66,-4 54,5" fill="#F0C8A0"/>
</g>
<!-- 耷耳 -->
<g class="s-ear-flop">
<ellipse cx="20" cy="12" rx="8" ry="14" fill="#D4954B" transform="rotate(20 20 12)"/>
<ellipse cx="22" cy="14" rx="5" ry="10" fill="#F0C8A0" transform="rotate(20 22 14)"/>
<ellipse cx="64" cy="12" rx="8" ry="14" fill="#D4954B" transform="rotate(-20 64 12)"/>
<ellipse cx="62" cy="14" rx="5" ry="10" fill="#F0C8A0" transform="rotate(-20 62 14)"/>
</g>
<!-- 圆脸 -->
<ellipse cx="42" cy="30" rx="26" ry="24" fill="#E8A84C"/>
<!-- 白面罩 -->
<path d="M22 30 Q22 8 42 6 Q62 8 62 30 Q62 52 42 54 Q22 52 22 30Z" fill="#FEF9F0"/>
<!-- 橙色头顶 -->
<ellipse cx="42" cy="14" rx="22" ry="12" fill="#E8A84C"/>
<ellipse cx="42" cy="10" rx="18" ry="8" fill="#FEF9F0"/>
<!-- 睁眼 -->
<g class="s-eye-open">
<ellipse cx="30" cy="26" rx="3.5" ry="4" fill="#2C1810"/>
<ellipse cx="54" cy="26" rx="3.5" ry="4" fill="#2C1810"/>
<circle cx="29" cy="25" r="1.4" fill="#fff"/>
<circle cx="53" cy="25" r="1.4" fill="#fff"/>
</g>
<!-- 闭眼 -->
<g class="s-eye-closed">
<path d="M26 26 Q30 23 34 26" stroke="#2C1810" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<path d="M50 26 Q54 23 58 26" stroke="#2C1810" stroke-width="1.5" fill="none" stroke-linecap="round"/>
</g>
<!-- 眉毛 -->
<path d="M25 22 Q30 18 35 22" stroke="#C08040" stroke-width=".8" fill="none"/>
<path d="M49 22 Q54 18 59 22" stroke="#C08040" stroke-width=".8" fill="none"/>
<!-- 鼻子 -->
<ellipse cx="42" cy="35" rx="4.5" ry="3.5" fill="#1a1a1a"/>
<ellipse cx="40.5" cy="33.5" rx="1.8" ry="1.2" fill="#555"/>
<!-- 闭嘴 -->
<g class="s-mouth-closed">
<path d="M38 40 Q40 42 42 40 Q44 42 46 40" stroke="#2C1810" stroke-width="1.2" fill="none" stroke-linecap="round"/>
<path d="M42 40 L42 44" stroke="#2C1810" stroke-width="1" fill="none" stroke-linecap="round"/>
</g>
<!-- 打哈欠嘴 -->
<g class="s-mouth-yawn">
<ellipse cx="42" cy="46" rx="8" ry="10" fill="#2C1810"/>
<ellipse cx="42" cy="42" rx="6" ry="4" fill="#E87A7A"/>
</g>
<!-- 腮红 -->
<ellipse cx="24" cy="34" rx="6" ry="3.5" fill="rgba(255,140,140,.22)"/>
<ellipse cx="60" cy="34" rx="6" ry="3.5" fill="rgba(255,140,140,.22)"/>
</g>
</svg>`;
function injectPet() {
if (document.getElementById('__shiba')) return;
var s = document.createElement('style');
s.textContent = PET_STYLE;
document.head.appendChild(s);
petEl = document.createElement('div');
petEl.id = '__shiba';
petEl.innerHTML = '<div class="pet-bubble">Zzz...</div>' + SHIBA_SVG;
bubbleEl = petEl.querySelector('.pet-bubble');
bubbleEl.className = 'pet-bubble show';
// ── 拖拽 ──────────────────────────────────
var dragInfo = null, hasMoved = false;
try { var sv = JSON.parse(localStorage.getItem('__shiba_pos') || '{}'); if (sv.left) { petEl.style.right = 'auto'; petEl.style.bottom = 'auto'; petEl.style.left = sv.left + 'px'; petEl.style.top = sv.top + 'px'; } } catch (_) {}
petEl.addEventListener('mousedown', function (e) {
if (e.button !== 0 || sending) return;
hasMoved = false;
var rect = petEl.getBoundingClientRect();
dragInfo = { sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
petEl.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!dragInfo) return;
var dx = e.clientX - dragInfo.sx, dy = e.clientY - dragInfo.sy;
if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
hasMoved = true;
var l = Math.max(0, Math.min(dragInfo.ox + dx, window.innerWidth - 105));
var t = Math.max(0, Math.min(dragInfo.oy + dy, window.innerHeight - 125));
petEl.style.right = 'auto'; petEl.style.bottom = 'auto';
petEl.style.left = l + 'px'; petEl.style.top = t + 'px';
});
document.addEventListener('mouseup', function () {
if (!dragInfo) return;
petEl.classList.remove('dragging');
try { localStorage.setItem('__shiba_pos', JSON.stringify({ left: parseInt(petEl.style.left), top: parseInt(petEl.style.top) })); } catch (_) {}
dragInfo = null;
});
// ── 点击 ──────────────────────────────────
petEl.addEventListener('click', function () {
if (hasMoved || sending) return;
var actions = ['stretch', 'sneeze', 'yawn', 'scratch'];
var act = actions[Math.floor(Math.random() * actions.length)];
petEl.className = act;
showBubble({ stretch: '伸懒腰~', sneeze: '阿嚏!', yawn: '哈欠~', scratch: '磨爪子~' }[act]);
setTimeout(function () { doSend(); }, 700);
});
document.body.appendChild(petEl);
// 空闲自动切换
setTimeout(function () { setRandomIdle(); hideBubble(); }, 4000);
idleTimer = setInterval(function () {
if (!sending && IDLE_STATES.indexOf(petEl.className) >= 0) setRandomIdle();
}, 12000);
}
function init() {
if (!document.body) { requestAnimationFrame(init); return; }
if (!isBoss() && !isLiepin()) return;
injectPet();
}
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); }
else { init(); }
})();
/**
* 简历采集器 — MAIN world inject script
* 在 BOSS 页面的 JS 上下文中运行,拦截 XHR/fetch 以捕获附件 PDF URL
*/
(function () {
'use strict';
// BOSS 聊天接口:点击「附件链接」时会请求此接口,响应中包含 fileUrl
const REACH_API = '/chat/reach/v2/';
function captureFileUrl(url) {
if (!url) return;
// 通过 postMessage 发送给 content script(isolated world)
try {
window.postMessage({ type: '__RESUME_COLLECTOR_FILEURL__', url: String(url) }, '*');
} catch (_) {}
}
// ── 拦截 XMLHttpRequest ─────────────────────────────
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try { this.__mp_url = typeof url === 'string' ? url : String(url || ''); } catch (_) {}
return origOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
try {
const self = this;
self.addEventListener('load', function () {
const reqUrl = (self.__mp_url || '');
if (reqUrl.indexOf(REACH_API) === -1) return;
try {
const resp = JSON.parse(self.responseText || '{}');
const fileUrl = resp?.data?.fileUrl || '';
if (fileUrl) captureFileUrl(fileUrl);
} catch (_) {}
});
} catch (_) {}
return origSend.apply(this, arguments);
};
// ── 拦截 fetch ──────────────────────────────────────
const origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
const reqUrl = typeof input === 'string' ? input : (input?.url || '');
const p = origFetch(input, init);
if (reqUrl.indexOf(REACH_API) === -1) return p;
p.then(function (resp) {
if (!resp.ok) return;
resp.clone().json().then(function (json) {
const fileUrl = json?.data?.fileUrl || '';
if (fileUrl) captureFileUrl(fileUrl);
}).catch(function () {});
}).catch(function () {});
return p;
};
})();
{
"manifest_version": 3,
"name": "简历采集器",
"version": "1.2",
"description": "从 BOSS 直聘 / 猎聘自动采集简历 PDF,同步到本地招聘系统",
"permissions": ["activeTab", "storage", "cookies", "scripting"],
"host_permissions": [
"*://*.zhipin.com/*",
"*://zhipin.com/*",
"*://*.liepin.com/*",
"*://liepin.com/*",
"http://127.0.0.1:4177/*",
"http://localhost:4177/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["*://*.zhipin.com/*", "*://zhipin.com/*"],
"js": ["inject.js"],
"run_at": "document_start",
"world": "MAIN",
"all_frames": true
},
{
"matches": ["*://*.zhipin.com/*", "*://zhipin.com/*", "*://*.liepin.com/*", "*://liepin.com/*"],
"js": ["content.js"],
"run_at": "document_end"
}
],
"action": {
"default_popup": "popup.html",
"default_title": "提取简历"
},
"icons": {
"16": "icon.png",
"48": "icon.png",
"128": "icon.png"
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 300px; padding: 16px;
font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
font-size: 13px; color: #182033; background: #fff;
}
h3 { font-size: 16px; margin-bottom: 4px; }
.sub { color: #6f7b8d; font-size: 12px; margin-bottom: 12px; font-weight: 600; }
.status {
padding: 8px 12px; border-radius: 8px; margin-bottom: 10px;
font-weight: 700; font-size: 12px;
}
.status.on { background: #e8f5e9; color: #2e7d32; }
.status.off { background: #fff3e0; color: #e65100; }
.section { margin-bottom: 14px; }
.section label { display: block; color: #6f7b8d; font-size: 11px; font-weight: 700; margin-bottom: 4px; }
.section input {
width: 100%; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px;
font-size: 13px; outline: none;
}
.section input:focus { border-color: #5874c8; }
button {
width: 100%; padding: 10px; border: 0; border-radius: 10px;
font-size: 13px; font-weight: 700; cursor: pointer;
}
button.primary {
background: linear-gradient(135deg, #5874c8, #75c3dd);
color: #fff; box-shadow: 0 6px 18px rgba(88,116,200,.25);
}
button.primary:hover { opacity: .92; }
button.primary:disabled { opacity: .5; cursor: not-allowed; }
.log {
max-height: 120px; overflow-y: auto; margin-top: 8px;
font-size: 11px; color: #6f7b8d;
}
.log .item { padding: 3px 0; border-bottom: 1px solid #f0f0f0; }
.log .item.ok { color: #2e7d32; }
.log .item.err { color: #c62828; }
</style>
</head>
<body>
<h3>📋 简历采集器</h3>
<p class="sub">在 BOSS/猎聘 页面自动注入采集按钮</p>
<div id="statusEl" class="status off">当前页面:非招聘平台</div>
<div class="section">
<label>本地系统地址</label>
<input id="apiUrl" type="text" value="http://127.0.0.1:4177/api/ingest">
</div>
<button id="testBtn" class="primary">测试连接</button>
<div class="log" id="log"></div>
<script src="popup.js"></script>
</body>
</html>
const apiUrlInput = document.getElementById("apiUrl");
const statusEl = document.getElementById("statusEl");
const testBtn = document.getElementById("testBtn");
const logEl = document.getElementById("log");
// 读取当前页面信息
(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url || "";
if (url.includes("zhipin.com")) {
statusEl.textContent = "✅ 已检测到 BOSS 直聘页面 — 点击右下角按钮采集";
statusEl.className = "status on";
} else if (url.includes("liepin.com")) {
statusEl.textContent = "✅ 已检测到 猎聘 页面 — 点击右下角按钮采集";
statusEl.className = "status on";
} else {
statusEl.textContent = "请在 BOSS 直聘或猎聘的候选人页面使用";
statusEl.className = "status off";
}
})();
// 加载保存的 API 地址
chrome.storage.local.get("apiUrl", (data) => {
if (data.apiUrl) apiUrlInput.value = data.apiUrl;
});
// 保存 API 地址
apiUrlInput.addEventListener("change", () => {
chrome.storage.local.set({ apiUrl: apiUrlInput.value });
});
// 测试连接
testBtn.addEventListener("click", async () => {
testBtn.disabled = true;
testBtn.textContent = "测试中...";
const apiUrl = apiUrlInput.value;
chrome.storage.local.set({ apiUrl });
try {
const resp = await fetch(apiUrl.replace("/api/ingest", "/api/ping"), {
method: "GET",
signal: AbortSignal.timeout(3000),
});
if (resp.ok) {
addLog("ok", "✅ 本地系统连接正常");
} else {
addLog("err", `⚠️ 服务器返回 ${resp.status}`);
}
} catch {
// 如果 /api/ping 不存在,尝试直接连根路径
try {
const resp = await fetch(apiUrl.replace("/api/ingest", "/"), {
method: "HEAD",
signal: AbortSignal.timeout(3000),
});
addLog("ok", "✅ 本地系统已启动(请确保 ingest 端点已添加)");
} catch {
addLog("err", "❌ 无法连接本地系统,请先启动招聘系统");
}
}
testBtn.disabled = false;
testBtn.textContent = "测试连接";
});
function addLog(type, msg) {
const div = document.createElement("div");
div.className = `item ${type}`;
div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logEl.prepend(div);
if (logEl.children.length > 20) logEl.lastChild.remove();
}
# ---------- 构建产物 ----------
dist
dist-ssr
node_modules
.vite/
# ---------- 环境与本地配置 ----------
*.local
.env
.env.*
!.env.example
# ---------- 日志 ----------
*.log
npm-debug.log*
yarn-error.log*
pnpm-debug.log*
# ---------- 系统与编辑器 ----------
.DS_Store
Thumbs.db
Desktop.ini
.idea/
.vscode/
{
"semi": false,
"singleQuote": true,
"printWidth": 120,
"trailingComma": "es5",
"arrowParens": "always",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false
}
# 招聘系统 Vue 前端
基于 Vue 3 的招聘系统网页前端,位于仓库根目录 `vue-app/`
## 技术栈
- 框架:Vue 3(Composition API)
- 构建工具:Vite
- 状态管理:Pinia
- 路由:Vue Router
- UI 组件库:Element Plus
- 样式:SCSS
- HTTP 客户端:Axios
- 代码规范:ESLint + Prettier
## 目录结构
```
vue-app/
├── index.html
├── vite.config.js
├── eslint.config.js
├── src/
│ ├── main.js # 应用入口,注册 Element Plus / Pinia / Router
│ ├── App.vue # 根组件
│ ├── api/
│ │ ├── index.js # Axios 实例
│ │ └── recruitment.js # 状态 / 简历解析 / AI 评估 / JD 生成 / 访谈接口
│ ├── stores/
│ │ └── recruitment.js # Pinia store:状态、持久化、领域动作
│ ├── router/
│ │ ├── index.js # 路由配置
│ │ └── nav.js # 导航项与页面元信息
│ ├── layouts/
│ │ └── AppShell.vue # 侧边栏 + 顶部栏 + 页面容器
│ ├── views/
│ │ ├── TodoView.vue # 待办项
│ │ ├── DashboardView.vue # 招聘数据看板
│ │ ├── JobsView.vue # 岗位列表
│ │ ├── JobDetailView.vue # 岗位详情
│ │ ├── JobEditView.vue # 新建 / 编辑岗位
│ │ ├── ResumesView.vue # 简历库
│ │ └── OfferView.vue # 录用入职管理
│ ├── components/
│ │ ├── common/ # 通用组件
│ │ ├── todo/ # 待办模块
│ │ ├── dashboard/ # 看板模块
│ │ ├── jobs/ # 岗位模块(含 JD 逼问式访谈弹窗)
│ │ ├── resumes/ # 简历库模块
│ │ └── offer/ # 录用入职模块
│ ├── utils/ # 纯业务逻辑(字段推断、匹配、评分、流程等)
│ └── assets/styles/ # SCSS 变量与全局样式
└── dist/ # 生产构建产物
```
## 运行
后端需先启动(默认端口 `4177`),Vite 开发服务器会把 `/api` 代理到它(可用环境变量 `RECRUITMENT_BACKEND_PORT` 覆盖后端端口)。
```bash
cd vue-app
npm install
npm run dev # 开发,默认 http://127.0.0.1:5173
npm run build # 生产构建,输出 dist/
npm run preview # 预览生产构建
```
## 脚本
| 命令 | 说明 |
| --- | --- |
| `npm run dev` | 启动 Vite 开发服务器 |
| `npm run build` | 生产构建到 `dist/` |
| `npm run preview` | 预览生产构建 |
| `npm run lint` | ESLint 静态检查 |
| `npm run lint:fix` | ESLint 自动修复 |
| `npm run format` | Prettier 格式化 |
| `npm run format:check` | Prettier 检查 |
## 生产部署
生产环境由 FastAPI 同源托管页面(见根目录 README):构建 `dist/` 后复制到 `backend/static/`,只需运行后端即可访问。API 基地址默认留空(同源);如需指向其他后端,可在运行时注入全局变量 `window.RECRUITMENT_API_BASE_URL`
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import vueParser from 'vue-eslint-parser'
import eslintConfigPrettier from '@vue/eslint-config-prettier'
export default [
{ ignores: ['dist/**', 'node_modules/**'] },
js.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: false },
},
globals: {
window: 'readonly',
document: 'readonly',
localStorage: 'readonly',
navigator: 'readonly',
URL: 'readonly',
Blob: 'readonly',
FileReader: 'readonly',
AbortSignal: 'readonly',
process: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
},
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
'vue/require-default-prop': 'off',
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'no-useless-escape': 'off',
},
},
eslintConfigPrettier,
]
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>招聘系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
This diff is collapsed.
{
"name": "recruitment-frontend-vue",
"version": "0.5.0",
"description": "招聘系统 Vue 3 前端。",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .js,.vue",
"lint:fix": "eslint . --ext .js,.vue --fix",
"format": "prettier --write \"src/**/*.{js,vue,scss}\"",
"format:check": "prettier --check \"src/**/*.{js,vue,scss}\""
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.17.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"sass": "^1.83.0",
"vite": "^6.0.5",
"vue-eslint-parser": "^10.4.1"
},
"engines": {
"node": ">=18.0.0"
}
}
<script setup>
// 根组件:只挂载路由出口。
// 应用外壳(侧边栏 + 导航 + 页面容器)由路由布局 AppShell 渲染,
// 避免 AppShell 被重复嵌套。
</script>
<template>
<router-view />
</template>
This diff is collapsed.
This diff is collapsed.
// 全局 SCSS 变量(Vite 通过 additionalData 自动注入)
$bg: #e9edf3;
$panel: #ffffff;
$ink: #182033;
$muted: #6f7b8d;
$line: #e2e8f0;
$blue: #5874c8;
$cyan: #75c3dd;
$green: #63c89b;
$yellow: #f6c95d;
$violet: #7c67df;
$rose: #ef6a6a;
$shadow: 0 10px 28px rgba(39, 49, 70, 0.1);
$sidebar-width: 232px;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment