feat(candidate): 增加候选人LLM筛选及评分功能

- 在候选人数据库表中新增llm_filtered标记、llm_score评分及详情字段及索引
- 在领域模型Candidate中新增LLM筛选状态和评分相关属性
- 更新ORM映射类添加llm_filtered、llm_score、llm_score_details字段映射
- 扩展候选人数据访问层,支持基于LLM筛选状态和评分范围的查询与分页
- 提供批量标记候选人LLM筛选状态的接口支持
- 新增候选人管理API路由,包含查询、筛选、标记和更新评分的接口
- 定义完整的请求和响应Schema,使用统一响应封装结构
- 更新应用启动代码注册候选人管理接口路由,完善模块导入及初始化逻辑
This commit is contained in:
2026-03-24 18:34:37 +08:00
parent 7e60476175
commit 1343561979
8 changed files with 511 additions and 17 deletions

View File

@@ -54,6 +54,9 @@ CREATE TABLE IF NOT EXISTS candidates (
salary_min INT, -- 期望薪资下限(K)
salary_max INT, -- 期望薪资上限(K)
status VARCHAR(32) DEFAULT 'NEW', -- NEW, ANALYZED, PUSHED, CONTACTED, INTERVIEWED, HIRED, REJECTED
llm_filtered TINYINT DEFAULT 0, -- 是否通过LLM筛选: 0-否, 1-是
llm_score DECIMAL(4,1), -- LLM综合评分 0-100
llm_score_details JSON, -- LLM各维度评分详情
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_source_source_id (source, source_id),
@@ -61,7 +64,10 @@ CREATE TABLE IF NOT EXISTS candidates (
INDEX idx_email (email),
INDEX idx_name (name),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
INDEX idx_created_at (created_at),
INDEX idx_llm_filtered (llm_filtered),
INDEX idx_llm_score (llm_score),
INDEX idx_llm_filtered_score (llm_filtered, llm_score)
);
-- ============================================

View File

@@ -37,7 +37,7 @@ class RecruiterModel(Base):
class CandidateModel(Base):
"""候选人主表"""
__tablename__ = 'candidates'
id = Column(String(64), primary_key=True)
source = Column(String(32), nullable=False)
source_id = Column(String(128), nullable=False)
@@ -56,6 +56,9 @@ class CandidateModel(Base):
salary_min = Column(Integer)
salary_max = Column(Integer)
status = Column(String(32), default='NEW')
llm_filtered = Column(Integer, default=0) # 是否通过LLM筛选: 0-否, 1-是
llm_score = Column(DECIMAL(4, 1)) # LLM综合评分 0-100
llm_score_details = Column(JSON) # LLM各维度评分详情
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

View File

@@ -5,11 +5,12 @@ FastAPI主应用入口
- routes/recruiter.py: 招聘者账号管理
- routes/scheduler.py: 定时任务管理
- routes/system.py: 系统接口
- routes/candidate.py: 候选人管理
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routes import recruiter_router, scheduler_router
from .routes import recruiter_router, scheduler_router, candidate_router
from .routes.system import router as system_router
@@ -22,7 +23,7 @@ def create_app() -> FastAPI:
docs_url="/docs",
redoc_url="/redoc"
)
# CORS配置
app.add_middleware(
CORSMiddleware,
@@ -31,17 +32,20 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
# 系统路由(根路径、健康检查等)
app.include_router(system_router)
# 招聘者账号管理路由
app.include_router(recruiter_router)
# 定时任务管理路由
app.include_router(scheduler_router)
# 候选人管理路由
app.include_router(candidate_router)
return app

View File

@@ -5,6 +5,7 @@ API路由模块
- recruiter: 招聘者账号管理
- scheduler: 定时任务管理
- system: 系统接口
- candidate: 候选人管理
"""
from .recruiter import router as recruiter_router
@@ -22,8 +23,15 @@ except ImportError:
from fastapi import APIRouter
system_router = APIRouter()
try:
from .candidate import router as candidate_router
except ImportError:
from fastapi import APIRouter
candidate_router = APIRouter()
__all__ = [
"recruiter_router",
"scheduler_router",
"system_router"
"system_router",
"candidate_router"
]

View File

@@ -0,0 +1,245 @@
"""
候选人管理 API 路由
提供候选人查询、筛选、标记等功能
"""
from typing import Optional
from fastapi import APIRouter, Query
from ..schemas import (
BaseResponse, PaginationData,
CandidateResponse, CandidateListResponse,
CandidateFilterRequest, CandidateMarkFilteredRequest,
CandidateUpdateScoreRequest
)
from ...mapper.candidate_mapper import CandidateMapper
router = APIRouter(prefix="/candidates", tags=["候选人管理"])
def _candidate_to_response(candidate) -> CandidateResponse:
"""将领域实体转换为响应模型"""
from ...domain.enums import Gender
gender_map = {Gender.MALE: "", Gender.FEMALE: "", Gender.UNKNOWN: "未知"}
return CandidateResponse(
id=candidate.id,
name=candidate.name,
source=candidate.source.value if candidate.source else "",
status=candidate.status.value if candidate.status else "",
phone=candidate.phone,
email=candidate.email,
gender=gender_map.get(candidate.gender, "未知"),
age=candidate.age,
location=candidate.location,
current_company=candidate.current_company,
current_position=candidate.current_position,
education=candidate.education,
school=candidate.school,
llm_filtered=candidate.llm_filtered,
llm_score=float(candidate.llm_score) if candidate.llm_score else None,
llm_score_details=candidate.llm_score_details,
created_at=candidate.created_at,
updated_at=candidate.updated_at
)
@router.get("/filtered", response_model=BaseResponse[CandidateListResponse])
async def get_filtered_candidates(
llm_filtered: bool = Query(True, description="是否通过LLM筛选"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量")
):
"""
获取LLM筛选通过的候选人列表
Args:
llm_filtered: 是否只显示通过LLM筛选的候选人
page: 页码
page_size: 每页数量
Returns:
BaseResponse[CandidateListResponse]: 统一响应格式的候选人列表
"""
try:
mapper = CandidateMapper()
candidates, total = mapper.find_by_llm_filtered(llm_filtered, page, page_size)
response = CandidateListResponse(
total=total,
items=[_candidate_to_response(c) for c in candidates]
)
return BaseResponse.success(data=response, msg="获取候选人列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取候选人列表失败: {str(e)}")
@router.post("/filter", response_model=BaseResponse[CandidateListResponse])
async def filter_candidates(request: CandidateFilterRequest):
"""
筛选查询候选人
支持多条件组合筛选:
- llm_filtered: 是否通过LLM筛选
- status: 候选人状态
- source: 来源渠道
- keyword: 关键词搜索(姓名/公司/职位)
Returns:
BaseResponse[CandidateListResponse]: 统一响应格式的候选人列表
"""
try:
mapper = CandidateMapper()
candidates, total = mapper.find_filtered_candidates(
llm_filtered=request.llm_filtered,
status=request.status,
source=request.source,
keyword=request.keyword,
min_score=request.min_score,
max_score=request.max_score,
page=request.page,
page_size=request.page_size
)
response = CandidateListResponse(
total=total,
items=[_candidate_to_response(c) for c in candidates]
)
return BaseResponse.success(data=response, msg="筛选候选人成功")
except Exception as e:
return BaseResponse.error(msg=f"筛选候选人失败: {str(e)}")
@router.post("/mark-filtered", response_model=BaseResponse[dict])
async def mark_candidates_filtered(request: CandidateMarkFilteredRequest):
"""
批量标记候选人的LLM筛选状态
Args:
request: 包含候选人ID列表和筛选状态
Returns:
BaseResponse[dict]: 统一响应格式,包含更新的记录数
"""
try:
if not request.candidate_ids:
return BaseResponse.error(msg="候选人ID列表不能为空")
mapper = CandidateMapper()
updated_count = mapper.mark_llm_filtered(
candidate_ids=request.candidate_ids,
llm_filtered=request.llm_filtered
)
return BaseResponse.success(
data={"updated_count": updated_count},
msg=f"成功标记 {updated_count} 个候选人"
)
except Exception as e:
return BaseResponse.error(msg=f"标记候选人失败: {str(e)}")
@router.get("/{candidate_id}", response_model=BaseResponse[CandidateResponse])
async def get_candidate_detail(candidate_id: str):
"""
获取候选人详情
Args:
candidate_id: 候选人ID
Returns:
BaseResponse[CandidateResponse]: 统一响应格式的候选人详情
"""
try:
mapper = CandidateMapper()
candidate = mapper.find_by_id(candidate_id)
if not candidate:
return BaseResponse.error(msg="候选人不存在", code=404)
return BaseResponse.success(
data=_candidate_to_response(candidate),
msg="获取候选人详情成功"
)
except Exception as e:
return BaseResponse.error(msg=f"获取候选人详情失败: {str(e)}")
@router.post("/update-score", response_model=BaseResponse[CandidateResponse])
async def update_candidate_score(request: CandidateUpdateScoreRequest):
"""
更新候选人LLM评分
Args:
request: 包含候选人ID、评分和评分详情
Returns:
BaseResponse[CandidateResponse]: 统一响应格式的更新后候选人详情
"""
try:
mapper = CandidateMapper()
candidate = mapper.find_by_id(request.candidate_id)
if not candidate:
return BaseResponse.error(msg="候选人不存在", code=404)
# 更新评分信息
from decimal import Decimal
candidate.llm_score = Decimal(str(request.llm_score))
candidate.llm_score_details = request.llm_score_details
candidate.llm_filtered = True # 有评分即视为已筛选
# 保存更新
mapper.save(candidate)
return BaseResponse.success(
data=_candidate_to_response(candidate),
msg="更新候选人评分成功"
)
except Exception as e:
return BaseResponse.error(msg=f"更新候选人评分失败: {str(e)}")
@router.get("/by-score-range", response_model=BaseResponse[CandidateListResponse])
async def get_candidates_by_score_range(
min_score: float = Query(..., ge=0, le=100, description="最低LLM评分"),
max_score: float = Query(..., ge=0, le=100, description="最高LLM评分"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量")
):
"""
根据LLM评分范围查询候选人
Args:
min_score: 最低LLM评分
max_score: 最高LLM评分
page: 页码
page_size: 每页数量
Returns:
BaseResponse[CandidateListResponse]: 统一响应格式的候选人列表
"""
try:
if min_score > max_score:
return BaseResponse.error(msg="最低评分不能大于最高评分")
mapper = CandidateMapper()
candidates, total = mapper.find_filtered_candidates(
min_score=min_score,
max_score=max_score,
page=page,
page_size=page_size
)
response = CandidateListResponse(
total=total,
items=[_candidate_to_response(c) for c in candidates]
)
return BaseResponse.success(data=response, msg="获取候选人列表成功")
except Exception as e:
return BaseResponse.error(msg=f"获取候选人列表失败: {str(e)}")

View File

@@ -3,12 +3,49 @@ API共享Schema定义
集中定义所有API请求和响应的数据模型
"""
from typing import List, Optional, Any
from typing import List, Optional, Any, TypeVar, Generic
from datetime import datetime
from pydantic import BaseModel, Field
# ============== 通用响应 ==============
# ============== 统一响应封装 ==============
T = TypeVar('T')
class BaseResponse(BaseModel, Generic[T]):
"""
统一API响应封装
Attributes:
code: 状态码200为成功500为失败
msg: 响应消息
data: 响应数据,泛型类型
"""
code: int = Field(..., description="状态码: 200成功, 500失败")
msg: str = Field(..., description="响应消息")
data: Optional[T] = Field(None, description="响应数据")
@classmethod
def success(cls, data: Optional[T] = None, msg: str = "操作成功") -> "BaseResponse[T]":
"""创建成功响应"""
return cls(code=200, msg=msg, data=data)
@classmethod
def error(cls, msg: str = "操作失败", code: int = 500) -> "BaseResponse[T]":
"""创建失败响应"""
return cls(code=code, msg=msg, data=None)
class PaginationData(BaseModel, Generic[T]):
"""分页数据封装"""
total: int = Field(..., description="总记录数")
page: int = Field(..., description="当前页码")
page_size: int = Field(..., description="每页数量")
items: List[T] = Field(..., description="数据列表")
# ============== 通用响应 (兼容旧代码) ==============
class APIResponse(BaseModel):
"""通用API响应"""
@@ -136,20 +173,58 @@ class JobConfigUpdate(BaseModel):
# ============== 候选人(预留) ==============
class CandidateResponse(BaseModel):
"""候选人响应(预留)"""
"""候选人响应"""
id: str
name: str
source: str
status: str
phone: Optional[str] = None
email: Optional[str] = None
gender: Optional[str] = None
age: Optional[int] = None
location: Optional[str] = None
current_company: Optional[str] = None
current_position: Optional[str] = None
education: Optional[str] = None
school: Optional[str] = None
llm_filtered: bool = Field(default=False, description="是否通过LLM筛选")
llm_score: Optional[float] = Field(None, description="LLM综合评分 0-100")
llm_score_details: Optional[Dict[str, Any]] = Field(None, description="LLM各维度评分详情")
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class CandidateListResponse(BaseModel):
"""候选人列表响应(预留)"""
"""候选人列表响应"""
total: int
items: List[CandidateResponse]
class CandidateFilterRequest(BaseModel):
"""候选人筛选请求"""
llm_filtered: Optional[bool] = Field(None, description="是否通过LLM筛选")
status: Optional[str] = Field(None, description="候选人状态")
source: Optional[str] = Field(None, description="来源渠道")
keyword: Optional[str] = Field(None, description="关键词搜索(姓名/公司/职位)")
min_score: Optional[float] = Field(None, ge=0, le=100, description="最低LLM评分")
max_score: Optional[float] = Field(None, ge=0, le=100, description="最高LLM评分")
page: int = Field(default=1, ge=1, description="页码")
page_size: int = Field(default=20, ge=1, le=100, description="每页数量")
class CandidateMarkFilteredRequest(BaseModel):
"""标记候选人通过LLM筛选请求"""
candidate_ids: List[str] = Field(..., description="候选人ID列表")
llm_filtered: bool = Field(default=True, description="是否通过LLM筛选")
class CandidateUpdateScoreRequest(BaseModel):
"""更新候选人LLM评分请求"""
candidate_id: str = Field(..., description="候选人ID")
llm_score: float = Field(..., ge=0, le=100, description="LLM综合评分 0-100")
llm_score_details: Optional[Dict[str, Any]] = Field(None, description="LLM各维度评分详情{'专业能力': 85, '经验匹配': 90}")
# ============== 职位(预留) ==============
class JobPositionResponse(BaseModel):

View File

@@ -70,6 +70,9 @@ class Candidate:
# 状态管理
status: CandidateStatus = CandidateStatus.NEW
llm_filtered: bool = False # 是否通过LLM筛选
llm_score: Optional[Decimal] = None # LLM综合评分 0-100
llm_score_details: Optional[Dict[str, Any]] = None # LLM各维度评分详情
# 元数据
created_at: Optional[datetime] = None

View File

@@ -23,14 +23,14 @@ class CandidateMapper:
"""将模型转换为实体"""
from decimal import Decimal
from ..domain.candidate import SalaryRange
salary_expectation = None
if model.salary_min is not None or model.salary_max is not None:
salary_expectation = SalaryRange(
min_salary=model.salary_min,
max_salary=model.salary_max
)
return Candidate(
id=model.id,
source=CandidateSource(model.source.lower()),
@@ -49,6 +49,9 @@ class CandidateMapper:
school=model.school,
salary_expectation=salary_expectation,
status=CandidateStatus(model.status.lower()) if model.status else CandidateStatus.NEW,
llm_filtered=bool(model.llm_filtered) if model.llm_filtered is not None else False,
llm_score=Decimal(str(model.llm_score)) if model.llm_score else None,
llm_score_details=model.llm_score_details,
created_at=model.created_at,
updated_at=model.updated_at
)
@@ -73,7 +76,10 @@ class CandidateMapper:
school=entity.school,
salary_min=entity.salary_expectation.min_salary if entity.salary_expectation else None,
salary_max=entity.salary_expectation.max_salary if entity.salary_expectation else None,
status=entity.status.value if entity.status else 'new'
status=entity.status.value if entity.status else 'new',
llm_filtered=1 if entity.llm_filtered else 0,
llm_score=entity.llm_score,
llm_score_details=entity.llm_score_details
)
def save(self, candidate: Candidate) -> Candidate:
@@ -102,6 +108,9 @@ class CandidateMapper:
existing.salary_min = candidate.salary_expectation.min_salary if candidate.salary_expectation else None
existing.salary_max = candidate.salary_expectation.max_salary if candidate.salary_expectation else None
existing.status = candidate.status.value if candidate.status else 'new'
existing.llm_filtered = 1 if candidate.llm_filtered else 0
existing.llm_score = candidate.llm_score
existing.llm_score_details = candidate.llm_score_details
existing.updated_at = datetime.now()
else:
# 插入新记录
@@ -159,7 +168,148 @@ class CandidateMapper:
results = session.execute(
select(CandidateModel).where(CandidateModel.name == name)
).scalars().all()
return [self._model_to_entity(r) for r in results]
finally:
session.close()
def find_by_llm_filtered(self, llm_filtered: bool, page: int = 1, page_size: int = 20) -> tuple:
"""
根据LLM筛选状态查找候选人
Args:
llm_filtered: 是否通过LLM筛选
page: 页码
page_size: 每页数量
Returns:
tuple: (候选人列表, 总记录数)
"""
session = self._get_session()
try:
from sqlalchemy import func
# 构建查询条件
stmt = select(CandidateModel).where(
CandidateModel.llm_filtered == (1 if llm_filtered else 0)
)
# 获取总记录数
count_stmt = select(func.count()).select_from(CandidateModel).where(
CandidateModel.llm_filtered == (1 if llm_filtered else 0)
)
total = session.execute(count_stmt).scalar()
# 分页查询
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
results = session.execute(stmt).scalars().all()
return [self._model_to_entity(r) for r in results], total
finally:
session.close()
def find_filtered_candidates(self, llm_filtered: Optional[bool] = None, status: Optional[str] = None,
source: Optional[str] = None, keyword: Optional[str] = None,
min_score: Optional[float] = None, max_score: Optional[float] = None,
page: int = 1, page_size: int = 20) -> tuple:
"""
筛选查询候选人
Args:
llm_filtered: 是否通过LLM筛选
status: 候选人状态
source: 来源渠道
keyword: 关键词搜索(姓名/公司/职位)
min_score: 最低LLM评分
max_score: 最高LLM评分
page: 页码
page_size: 每页数量
Returns:
tuple: (候选人列表, 总记录数)
"""
session = self._get_session()
try:
from sqlalchemy import func, or_, and_
# 构建查询条件
stmt = select(CandidateModel)
conditions = []
if llm_filtered is not None:
conditions.append(CandidateModel.llm_filtered == (1 if llm_filtered else 0))
if status:
conditions.append(CandidateModel.status == status.upper())
if source:
conditions.append(CandidateModel.source == source.upper())
if keyword:
conditions.append(or_(
CandidateModel.name.contains(keyword),
CandidateModel.current_company.contains(keyword),
CandidateModel.current_position.contains(keyword)
))
if min_score is not None:
conditions.append(CandidateModel.llm_score >= min_score)
if max_score is not None:
conditions.append(CandidateModel.llm_score <= max_score)
if conditions:
stmt = stmt.where(*conditions)
# 获取总记录数
count_stmt = select(func.count()).select_from(CandidateModel)
if conditions:
count_stmt = count_stmt.where(*conditions)
total = session.execute(count_stmt).scalar()
# 分页查询按LLM评分降序再按创建时间倒序
stmt = stmt.order_by(
CandidateModel.llm_score.desc().nullslast(),
CandidateModel.created_at.desc()
)
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
results = session.execute(stmt).scalars().all()
return [self._model_to_entity(r) for r in results], total
finally:
session.close()
def find_by_id(self, candidate_id: str) -> Optional[Candidate]:
"""根据ID查找候选人"""
session = self._get_session()
try:
result = session.execute(
select(CandidateModel).where(CandidateModel.id == candidate_id)
).scalar_one_or_none()
return self._model_to_entity(result) if result else None
finally:
session.close()
def mark_llm_filtered(self, candidate_ids: List[str], llm_filtered: bool = True) -> int:
"""
批量标记候选人的LLM筛选状态
Args:
candidate_ids: 候选人ID列表
llm_filtered: 是否通过LLM筛选
Returns:
int: 更新的记录数
"""
session = self._get_session()
try:
from sqlalchemy import update
stmt = update(CandidateModel).where(
CandidateModel.id.in_(candidate_ids)
).values(
llm_filtered=1 if llm_filtered else 0,
updated_at=datetime.now()
)
result = session.execute(stmt)
session.commit()
return result.rowcount
finally:
session.close()