From f795c2fd5d0b8a66d7c0ee750507c2946b10aadc Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 24 Apr 2026 19:04:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E8=B1=A1=E7=AE=80=E5=8E=86=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/job_agent_chat_service.py | 57 +++++---------- app/services/resume_loader.py | 65 +++++++++++++++++ app/services/skill_gap_service.py | 98 ++++++++------------------ 3 files changed, 112 insertions(+), 108 deletions(-) create mode 100644 app/services/resume_loader.py diff --git a/app/services/job_agent_chat_service.py b/app/services/job_agent_chat_service.py index 56ceefd..1b1ec04 100644 --- a/app/services/job_agent_chat_service.py +++ b/app/services/job_agent_chat_service.py @@ -1,20 +1,14 @@ """求职助手 Agent 对话 Service 主要功能:查询简历数据,调用 AI 模块完成对话。 -依赖:UserResume + 5张子表 ORM、job_agent.chat AI 模块 -使用表:bg_user_resume、bg_user_resume_education/work/internship/project/competition(查询简历) +依赖:resume_loader(简历统一查询)、job_agent.chat AI 模块 +使用表:bg_user_resume + 5张子表(通过 resume_loader 查询) """ -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.ai.job_agent.chat import agent_chat -from app.models.user_resume import UserResume -from app.models.user_resume_education import UserResumeEducation -from app.models.user_resume_work import UserResumeWork -from app.models.user_resume_internship import UserResumeInternship -from app.models.user_resume_project import UserResumeProject -from app.models.user_resume_competition import UserResumeCompetition +from app.services.resume_loader import ResumeDetail, load_resume_detail class JobAgentChatService: @@ -26,31 +20,14 @@ class JobAgentChatService: history: list[dict], job_categories: list[str], regions: list[str], industries: list[str]) -> dict: """求职助手对话:查简历 → 序列化 → 调 AI 模块""" - resume = await self._get_resume(resume_id, user_id) - edu, work, intern, proj, comp = await self._load_sub_tables(resume_id) - resume_text = self._build_resume_text(resume, edu, work, intern, proj, comp) + detail = await load_resume_detail(self.session, resume_id, user_id) + resume_text = self._build_resume_text(detail) return await agent_chat(resume_text, message, history, job_categories, regions, industries) - async def _get_resume(self, resume_id: int, user_id: int) -> UserResume: - """查指定简历""" - result = await self.session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id)) - resume = result.scalar_one_or_none() - if not resume: - raise ValueError("简历不存在") - return resume - - async def _load_sub_tables(self, resume_id: int): - """查询简历5张子表""" - edu = (await self.session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all() - work = (await self.session.execute(select(UserResumeWork).where(UserResumeWork.resume_id == resume_id))).scalars().all() - intern = (await self.session.execute(select(UserResumeInternship).where(UserResumeInternship.resume_id == resume_id))).scalars().all() - proj = (await self.session.execute(select(UserResumeProject).where(UserResumeProject.resume_id == resume_id))).scalars().all() - comp = (await self.session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all() - return edu, work, intern, proj, comp - @staticmethod - def _build_resume_text(resume: UserResume, edu_list, work_list, intern_list, proj_list, comp_list) -> str: + def _build_resume_text(detail: ResumeDetail) -> str: """将简历数据序列化为文本供 AI 使用""" + resume = detail.resume parts = [] if resume.name: parts.append(f"姓名:{resume.name}") @@ -62,24 +39,24 @@ class JobAgentChatService: parts.append(f"证书:{'、'.join(resume.certificates)}") if resume.summary: parts.append(f"个人概述:{resume.summary}") - if edu_list: + if detail.education: parts.append("教育经历:") - for r in edu_list: + for r in detail.education: parts.append(f" - {r.school or ''} {r.major or ''} {r.degree or ''}") - if work_list: + if detail.work: parts.append("工作经历:") - for r in work_list: + for r in detail.work: parts.append(f" - {r.company_name or ''} {r.position or ''}") - if intern_list: + if detail.internship: parts.append("实习经历:") - for r in intern_list: + for r in detail.internship: parts.append(f" - {r.company_name or ''} {r.position or ''}") - if proj_list: + if detail.project: parts.append("项目经历:") - for r in proj_list: + for r in detail.project: parts.append(f" - {r.project_name or ''} {r.role or ''}") - if comp_list: + if detail.competition: parts.append("竞赛经历:") - for r in comp_list: + for r in detail.competition: parts.append(f" - {r.competition_name or ''} {r.award or ''}") return "\n".join(parts) if parts else "暂无简历信息" diff --git a/app/services/resume_loader.py b/app/services/resume_loader.py new file mode 100644 index 0000000..cb077d0 --- /dev/null +++ b/app/services/resume_loader.py @@ -0,0 +1,65 @@ +"""简历统一查询模块 + +提供简历主表 + 5张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。 +各 Service 统一复用,避免重复查询逻辑。 +""" + +from dataclasses import dataclass, field + +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user_resume import UserResume +from app.models.user_resume_education import UserResumeEducation +from app.models.user_resume_work import UserResumeWork +from app.models.user_resume_internship import UserResumeInternship +from app.models.user_resume_project import UserResumeProject +from app.models.user_resume_competition import UserResumeCompetition + + +@dataclass +class ResumeDetail: + """简历完整数据,主表 + 5张子表""" + resume: UserResume + education: list[UserResumeEducation] = field(default_factory=list) + work: list[UserResumeWork] = field(default_factory=list) + internship: list[UserResumeInternship] = field(default_factory=list) + project: list[UserResumeProject] = field(default_factory=list) + competition: list[UserResumeCompetition] = field(default_factory=list) + + +async def load_resume_detail(session: AsyncSession, resume_id: int, user_id: int) -> ResumeDetail: + """按ID查简历主表(校验归属)+ 5张子表,返回 ResumeDetail""" + result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id)) + resume = result.scalar_one_or_none() + if not resume: + raise ValueError("简历不存在") + edu, work, intern, proj, comp = await _load_sub_tables(session, resume_id) + return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp) + + +async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail: + """自动选默认简历(先默认再最新)+ 5张子表,返回 ResumeDetail""" + result = await session.execute( + select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1) + .order_by(desc(UserResume.update_time)).limit(1)) + resume = result.scalar_one_or_none() + if not resume: + result = await session.execute( + select(UserResume).where(UserResume.user_id == user_id) + .order_by(desc(UserResume.update_time)).limit(1)) + resume = result.scalar_one_or_none() + if not resume: + raise ValueError("请先创建简历") + edu, work, intern, proj, comp = await _load_sub_tables(session, resume.id) + return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp) + + +async def _load_sub_tables(session: AsyncSession, resume_id: int): + """查询简历5张子表""" + edu = (await session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all() + work = (await session.execute(select(UserResumeWork).where(UserResumeWork.resume_id == resume_id))).scalars().all() + intern = (await session.execute(select(UserResumeInternship).where(UserResumeInternship.resume_id == resume_id))).scalars().all() + proj = (await session.execute(select(UserResumeProject).where(UserResumeProject.resume_id == resume_id))).scalars().all() + comp = (await session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all() + return edu, work, intern, proj, comp diff --git a/app/services/skill_gap_service.py b/app/services/skill_gap_service.py index 6400dfe..2963d5c 100644 --- a/app/services/skill_gap_service.py +++ b/app/services/skill_gap_service.py @@ -11,7 +11,7 @@ import json import random import string -from sqlalchemy import select, desc +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.ai.skill_gap_analyzer.analyzer import ( @@ -26,11 +26,7 @@ from app.schemas.skill_gap import ( ) from app.models.job import Job from app.models.user_resume import UserResume -from app.models.user_resume_competition import UserResumeCompetition -from app.models.user_resume_education import UserResumeEducation -from app.models.user_resume_internship import UserResumeInternship -from app.models.user_resume_project import UserResumeProject -from app.models.user_resume_work import UserResumeWork +from app.services.resume_loader import ResumeDetail, load_resume_detail, load_default_resume_detail # Redis 常量 CUSTOMIZE_RESUME_KEY_PREFIX = "customize:resume:" @@ -63,24 +59,25 @@ def _build_paragraphs(description: list[dict] | None) -> list[Paragraph]: return [Paragraph(id=_rand_id(), text=item.get("text", "")) for item in description if isinstance(item, dict)] -def _build_resume_json(resume: UserResume, edu_list, work_list, intern_list, proj_list, comp_list) -> str: +def _build_resume_json(detail: ResumeDetail) -> str: """拼装简历 JSON 字符串供 AI 使用""" + resume = detail.resume data = { "skills": resume.skills or [], "certificates": resume.certificates or [], "summary": resume.summary or "", "targetPosition": resume.target_position or "", } - if edu_list: - data["education"] = [{"school": r.school, "major": r.major, "degree": r.degree, "description": r.description} for r in edu_list] - if work_list: - data["work"] = [{"companyName": r.company_name, "position": r.position, "description": r.description} for r in work_list] - if intern_list: - data["internship"] = [{"companyName": r.company_name, "position": r.position, "description": r.description} for r in intern_list] - if proj_list: - data["project"] = [{"companyName": r.company_name, "projectName": r.project_name, "role": r.role, "description": r.description} for r in proj_list] - if comp_list: - data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in comp_list] + if detail.education: + data["education"] = [{"school": r.school, "major": r.major, "degree": r.degree, "description": r.description} for r in detail.education] + if detail.work: + data["work"] = [{"companyName": r.company_name, "position": r.position, "description": r.description} for r in detail.work] + if detail.internship: + data["internship"] = [{"companyName": r.company_name, "position": r.position, "description": r.description} for r in detail.internship] + if detail.project: + data["project"] = [{"companyName": r.company_name, "projectName": r.project_name, "role": r.role, "description": r.description} for r in detail.project] + if detail.competition: + data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in detail.competition] return json.dumps(data, ensure_ascii=False) @@ -94,21 +91,20 @@ class SkillGapService: async def analyze_skill_gap(self, user_id: int, job_id: int) -> dict: """差距分析完整流程:查简历 → 查岗位 → AI分析 → 计算匹配分""" # 1. 自动选择简历 - resume = await self._pick_resume(user_id) + detail = await load_default_resume_detail(self.session, user_id) # 2. 查岗位 job = await self._get_job(job_id) skill_tags: list[str] = job.skill_tags or [] # 3. skill_tags 为空 → 满分 if not skill_tags: - return self._gap_result(10.0, job, resume, []) - # 4. 查子表拼 AI 输入 - edu, work, intern, proj, comp = await self._load_sub_tables(resume.id) - resume_json = _build_resume_json(resume, edu, work, intern, proj, comp) + return self._gap_result(10.0, job, detail.resume, []) + # 4. 拼 AI 输入 + resume_json = _build_resume_json(detail) # 5. AI 分析 missing = await analyze_skill_gap(skill_tags, resume_json) # 6. 计算匹配分 score = round((len(skill_tags) - len(missing)) / len(skill_tags) * 10, 1) - return self._gap_result(score, job, resume, missing) + return self._gap_result(score, job, detail.resume, missing) @staticmethod def _gap_result(score: float, job: Job, resume: UserResume, missing: list[str]) -> dict: @@ -127,16 +123,15 @@ class SkillGapService: if not optimize_modules: raise ValueError("请至少选择一个优化模块") # 1. 查简历 + 岗位 - resume = await self._get_resume(resume_id, user_id) + detail = await load_resume_detail(self.session, resume_id, user_id) job = await self._get_job(job_id) - edu_rows, work_rows, intern_rows, proj_rows, comp_rows = await self._load_sub_tables(resume.id) # 2. 组装基础定制简历 - cr = self._build_customize_resume(resume, edu_rows, work_rows, intern_rows, proj_rows, comp_rows) + cr = self._build_customize_resume(detail) # 3. 并发 AI 优化 tasks = [] job_desc = f"{job.description or ''}\n{job.requirement or ''}" if "summary" in optimize_modules: - tasks.append(("summary", optimize_summary(job.title or "", add_skills, resume.summary or ""))) + tasks.append(("summary", optimize_summary(job.title or "", add_skills, detail.resume.summary or ""))) if "experience" in optimize_modules: for module_name, rows_json in self._experience_tasks(cr, job.title or "", job_desc): tasks.append((module_name, optimize_module(job.title or "", job_desc, rows_json))) @@ -371,30 +366,6 @@ class SkillGapService: # ===== 内部工具方法 ===== - async def _pick_resume(self, user_id: int) -> UserResume: - """自动选择简历:先查默认,再查最新""" - result = await self.session.execute( - select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1) - .order_by(desc(UserResume.update_time)).limit(1)) - resume = result.scalar_one_or_none() - if not resume: - result = await self.session.execute( - select(UserResume).where(UserResume.user_id == user_id) - .order_by(desc(UserResume.update_time)).limit(1)) - resume = result.scalar_one_or_none() - if not resume: - raise ValueError("请先创建简历") - return resume - - async def _get_resume(self, resume_id: int, user_id: int) -> UserResume: - """查指定简历""" - result = await self.session.execute( - select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id)) - resume = result.scalar_one_or_none() - if not resume: - raise ValueError("简历不存在") - return resume - async def _get_job(self, job_id: int) -> Job: """查岗位""" result = await self.session.execute(select(Job).where(Job.id == job_id)) @@ -403,18 +374,9 @@ class SkillGapService: raise ValueError("岗位不存在") return job - async def _load_sub_tables(self, resume_id: int): - """查询简历5张子表""" - edu = (await self.session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all() - work = (await self.session.execute(select(UserResumeWork).where(UserResumeWork.resume_id == resume_id))).scalars().all() - intern = (await self.session.execute(select(UserResumeInternship).where(UserResumeInternship.resume_id == resume_id))).scalars().all() - proj = (await self.session.execute(select(UserResumeProject).where(UserResumeProject.resume_id == resume_id))).scalars().all() - comp = (await self.session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all() - return edu, work, intern, proj, comp - - def _build_customize_resume(self, resume: UserResume, edu_rows, work_rows, - intern_rows, proj_rows, comp_rows) -> CustomizeResume: - """从数据库记录组装 CustomizeResume""" + def _build_customize_resume(self, detail: ResumeDetail) -> CustomizeResume: + """从 ResumeDetail 组装 CustomizeResume""" + resume = detail.resume profile = ResumeProfile( avatarUrl=resume.avatar_url or "", name=resume.name or "", email=resume.email or "", mobileNumber=resume.mobile_number or "", city=resume.city or "", @@ -427,19 +389,19 @@ class SkillGapService: education=[Education(id=_rand_id(), school=r.school or "", major=r.major or "", degree=r.degree or "", studyType=r.study_type or "", startDate=r.start_date or "", endDate=r.end_date or "", - description=_build_paragraphs(r.description)) for r in edu_rows], + description=_build_paragraphs(r.description)) for r in detail.education], work=[Work(id=_rand_id(), companyName=r.company_name or "", position=r.position or "", startDate=r.start_date or "", endDate=r.end_date or "", - description=_build_paragraphs(r.description)) for r in work_rows], + description=_build_paragraphs(r.description)) for r in detail.work], internship=[Internship(id=_rand_id(), companyName=r.company_name or "", position=r.position or "", startDate=r.start_date or "", endDate=r.end_date or "", - description=_build_paragraphs(r.description)) for r in intern_rows], + description=_build_paragraphs(r.description)) for r in detail.internship], project=[Project(id=_rand_id(), companyName=r.company_name or "", projectName=r.project_name or "", role=r.role or "", startDate=r.start_date or "", endDate=r.end_date or "", - description=_build_paragraphs(r.description)) for r in proj_rows], + description=_build_paragraphs(r.description)) for r in detail.project], competition=[Competition(id=_rand_id(), competitionName=r.competition_name or "", award=r.award or "", awardDate=r.award_date or "", - description=_build_paragraphs(r.description)) for r in comp_rows], + description=_build_paragraphs(r.description)) for r in detail.competition], ) @staticmethod