diff --git a/migrations/001_init_schema.sql b/migrations/001_init_schema.sql index b7d0beb..2fbb466 100644 --- a/migrations/001_init_schema.sql +++ b/migrations/001_init_schema.sql @@ -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) ); -- ============================================ diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py index 90e7270..e330c1a 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/config/database.py @@ -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()) diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py index dc18997..833c285 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/api.py @@ -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 diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py index 6b45f1d..0ad3021 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/__init__.py @@ -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" ] diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/candidate.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/candidate.py new file mode 100644 index 0000000..6ead235 --- /dev/null +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/routes/candidate.py @@ -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)}") diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py index 2420dfc..7f3a8b8 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/controller/schemas.py @@ -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): diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/candidate.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/candidate.py index 2165dc1..15b10d0 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/candidate.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/domain/candidate.py @@ -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 diff --git a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/candidate_mapper.py b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/candidate_mapper.py index 8fee880..3f6a2a6 100644 --- a/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/candidate_mapper.py +++ b/src/main/python/cn/yinlihupo/ylhp_hr_2_0/mapper/candidate_mapper.py @@ -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()