From 4c2627dbb2bb06fa4040030692fc9a966d448b64 Mon Sep 17 00:00:00 2001 From: zk Date: Fri, 24 Apr 2026 20:13:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0agent=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=AE=80=E5=8E=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/ai/job_agent/prompts.py | 41 ++++++++++++++- app/ai/job_agent/resume_optimizer.py | 55 ++++++++++++++++++++ app/api/job_agent_chat.py | 12 ++++- app/schemas/job_agent_chat.py | 5 ++ app/services/customize_resume_store.py | 59 +++++++++++++++++++-- app/services/job_agent_chat_service.py | 72 ++++++++++++++++++++++++-- app/services/skill_gap_service.py | 51 +----------------- 7 files changed, 235 insertions(+), 60 deletions(-) create mode 100644 app/ai/job_agent/resume_optimizer.py diff --git a/app/ai/job_agent/prompts.py b/app/ai/job_agent/prompts.py index a80118c..368fef3 100644 --- a/app/ai/job_agent/prompts.py +++ b/app/ai/job_agent/prompts.py @@ -1,4 +1,4 @@ -"""求职助手 Agent 对话 Prompt 模板""" +"""求职助手 Agent 对话 Prompt 模板 + 岗位简历优化 Prompt""" SYSTEM_PROMPT = """你是 OfferPie 求职助手,帮助用户找到合适的工作。 @@ -32,3 +32,42 @@ tool 可选值: 5. 偏好描述要准确概括用户的岗位偏好,如"更偏技术方向的产品岗"、"大厂优先" 6. 如果用户没有明确偏好,preference 填"无特殊偏好" """ + + +# ===== 岗位简历优化 Prompt ===== + +RESUME_SUMMARY_OPTIMIZE_PROMPT = """你是一个求职投递助手。用户即将投递目标岗位,请针对该岗位优化用户的个人概述。 + +【目标岗位】 +{job_title} + +【岗位职责与要求】 +{job_description} + +【原始个人概述】 +{original_summary} + +规则: +1. 保持原文风格和主体内容不变 +2. 突出与目标岗位最相关的经验和能力 +3. 适当融入岗位要求中的关键词,但不要生硬堆砌 +4. 避免过度优化和编造内容,改动越少越好 +5. 直接输出优化后的文本,不要其他内容""" + +RESUME_EXPERIENCE_OPTIMIZE_PROMPT = """你是一个求职投递助手。用户即将投递目标岗位,请针对该岗位微调用户的经历描述。 + +【目标岗位】 +{job_title} + +【岗位职责与要求】 +{job_description} + +【原始经历数据】 +{original_module_data} + +规则: +1. 基本保持原文不变,只在可以优化的地方做轻微调整 +2. 突出与目标岗位最匹配的经历和成果 +3. 适当使用岗位要求中的关键词润色描述,但不要编造内容 +4. description 字段是 [{{"id": "xxx", "text": "xxx"}}] 格式:修改时保留原 id 只改 text,新增段落生成随机8位字符串作为 id,删除段落直接移除 +5. 返回修改后的完整模块数据(JSON 格式,与输入格式一致)""" diff --git a/app/ai/job_agent/resume_optimizer.py b/app/ai/job_agent/resume_optimizer.py new file mode 100644 index 0000000..5621734 --- /dev/null +++ b/app/ai/job_agent/resume_optimizer.py @@ -0,0 +1,55 @@ +"""求职助手 - 岗位简历优化 AI 引擎 + +针对目标岗位并发优化简历(summary + 经历子表)。 +依赖:LLM 枚举、job_agent/prompts、parse_llm_json +""" + +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate + +from app.ai.job_agent.prompts import RESUME_SUMMARY_OPTIMIZE_PROMPT, RESUME_EXPERIENCE_OPTIMIZE_PROMPT +from app.ai.models import LLM +from app.core.logger import log +from app.tool.json_helper import parse_llm_json + +# ===== summary 优化 ===== + +_summary_chain = ( + ChatPromptTemplate.from_messages([("system", RESUME_SUMMARY_OPTIMIZE_PROMPT), ("human", "请开始优化。")]) + | LLM.JIAYU_CLAUDE_SONNET_4_5.create(temperature=0.3) + | StrOutputParser() +) + + +async def optimize_summary(job_title: str, job_description: str, original_summary: str) -> str: + """针对岗位优化个人概述""" + try: + return await _summary_chain.ainvoke({ + "job_title": job_title, "job_description": job_description or "", + "original_summary": original_summary or "暂无", + }) + except Exception as e: + log.warning(f"岗位简历summary优化失败: {e}") + return original_summary + + +# ===== 经历优化 ===== + +_experience_chain = ( + ChatPromptTemplate.from_messages([("system", RESUME_EXPERIENCE_OPTIMIZE_PROMPT), ("human", "请开始优化。")]) + | LLM.JIAYU_CLAUDE_SONNET_4_5.create(temperature=0.3) + | StrOutputParser() +) + + +async def optimize_experience(job_title: str, job_description: str, module_data: str) -> list | dict | None: + """针对岗位优化经历模块描述,返回修改后的完整模块数据""" + try: + raw = await _experience_chain.ainvoke({ + "job_title": job_title, "job_description": job_description or "", + "original_module_data": module_data, + }) + return parse_llm_json(raw) + except Exception as e: + log.warning(f"岗位简历经历优化失败: {e}") + return None diff --git a/app/api/job_agent_chat.py b/app/api/job_agent_chat.py index c5da9b1..64f3157 100644 --- a/app/api/job_agent_chat.py +++ b/app/api/job_agent_chat.py @@ -4,7 +4,7 @@ from fastapi import APIRouter from app.core.context import RequestContext from app.core.database import get_db -from app.schemas.job_agent_chat import JobAgentChatParam +from app.schemas.job_agent_chat import JobAgentChatParam, OptimizeResumeParam from app.services.job_agent_chat_service import JobAgentChatService router = APIRouter(prefix="/job-agent", tags=["求职助手agent"]) @@ -22,3 +22,13 @@ async def chat(param: JobAgentChatParam): param.job_categories, param.regions, param.industries, ) return result + + +@router.post("/optimize-resume", summary="针对岗位优化简历") +async def optimize_resume(param: OptimizeResumeParam): + """根据目标岗位,AI并发优化简历(summary + 5张子表经历),存Redis并返回""" + user_id = RequestContext.user_id.get() + async for session in get_db(): + service = JobAgentChatService(session) + result = await service.optimize_resume(user_id, param.resume_id, param.job_id) + return result diff --git a/app/schemas/job_agent_chat.py b/app/schemas/job_agent_chat.py index a995c0a..be84dd3 100644 --- a/app/schemas/job_agent_chat.py +++ b/app/schemas/job_agent_chat.py @@ -35,3 +35,8 @@ class JobAgentChatDto(BaseModel): tool_params: ToolParams | None = Field(default=None, alias="toolParams", description="工具参数") model_config = {"populate_by_name": True} + + +class OptimizeResumeParam(BaseModel): + resume_id: int = Field(..., alias="resumeId", description="简历ID") + job_id: int = Field(..., alias="jobId", description="岗位ID") diff --git a/app/services/customize_resume_store.py b/app/services/customize_resume_store.py index 0967c0d..eaf44ba 100644 --- a/app/services/customize_resume_store.py +++ b/app/services/customize_resume_store.py @@ -1,11 +1,17 @@ -"""定制简历 Redis 存取模块 +"""定制简历 Redis 存取 + 数据转换模块 -提供定制简历的保存(自动回滚备份)、查询、回滚能力。 +提供定制简历的保存(自动回滚备份)、查询、回滚、从 ResumeDetail 构建能力。 各 Service 统一复用,不直接操作 Redis key。 """ +import random +import string + from app.core.redis import RedisManager -from app.schemas.customize_resume import CustomizeResume +from app.schemas.customize_resume import ( + CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph, +) +from app.services.resume_loader import ResumeDetail # Redis 常量 KEY_PREFIX = "customize:resume:" @@ -13,16 +19,59 @@ EXPIRE = 12 * 60 * 60 # 12小时 ROLLBACK_KEY_PREFIX = "customize:resume:rollback:" ROLLBACK_EXPIRE = 30 * 60 # 30分钟 +_CHARS = string.ascii_letters + string.digits + + +def _rand_id() -> str: + """生成随机8位字符串标识""" + return "".join(random.choices(_CHARS, k=8)) + + +def _build_paragraphs(description: list[dict] | None) -> list[Paragraph]: + """将数据库 description [{id, text}] 转为 Paragraph 列表""" + if not description: + return [] + return [Paragraph(id=_rand_id(), text=item.get("text", "")) for item in description if isinstance(item, dict)] + + +def build_from_detail(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 "", + wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "", + skills=resume.skills or [], certificates=resume.certificates or [], + summary=resume.summary or "", + ) + return CustomizeResume( + resume=profile, + 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 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 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 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 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 detail.competition], + ) + async def save(user_id: int, cr: CustomizeResume) -> None: """保存定制简历,自动备份旧版本到回滚 key""" key = f"{KEY_PREFIX}{user_id}" rollback_key = f"{ROLLBACK_KEY_PREFIX}{user_id}" - # 备份旧数据 old_data = await RedisManager.client.get(key) if old_data: await RedisManager.client.set(rollback_key, old_data, ex=ROLLBACK_EXPIRE) - # 存新数据 await RedisManager.client.set(key, cr.model_dump_json(by_alias=True), ex=EXPIRE) diff --git a/app/services/job_agent_chat_service.py b/app/services/job_agent_chat_service.py index 1b1ec04..739242f 100644 --- a/app/services/job_agent_chat_service.py +++ b/app/services/job_agent_chat_service.py @@ -1,14 +1,23 @@ -"""求职助手 Agent 对话 Service +"""求职助手 Agent 对话 + 岗位简历优化 Service -主要功能:查询简历数据,调用 AI 模块完成对话。 -依赖:resume_loader(简历统一查询)、job_agent.chat AI 模块 -使用表:bg_user_resume + 5张子表(通过 resume_loader 查询) +主要功能:查询简历数据,调用 AI 模块完成对话;针对岗位并发优化简历。 +依赖:resume_loader(简历统一查询)、customize_resume_store(定制简历存取+构建)、job_agent.chat AI 模块、job_agent.resume_optimizer(岗位简历优化) +使用表:bg_user_resume + 5张子表(通过 resume_loader 查询)、bg_job(查岗位) """ +import asyncio +import json + +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.ai.job_agent.chat import agent_chat +from app.ai.job_agent.resume_optimizer import optimize_summary, optimize_experience +from app.core.logger import log +from app.models.job import Job +from app.schemas.customize_resume import CustomizeResume, Education, Work, Internship, Project, Competition from app.services.resume_loader import ResumeDetail, load_resume_detail +from app.services import customize_resume_store class JobAgentChatService: @@ -60,3 +69,58 @@ class JobAgentChatService: for r in detail.competition: parts.append(f" - {r.competition_name or ''} {r.award or ''}") return "\n".join(parts) if parts else "暂无简历信息" + + async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict: + """针对岗位优化简历:查简历+岗位 → 构建定制简历 → 并发AI优化 → 存Redis → 返回""" + # 1. 查简历 + 岗位 + detail = await load_resume_detail(self.session, resume_id, user_id) + job = await self._get_job(job_id) + # 2. 构建定制简历 + cr = customize_resume_store.build_from_detail(detail) + # 3. 并发 AI 优化(summary + 5张子表经历) + tasks = [] + job_desc = f"{job.description or ''}\n{job.requirement or ''}" + if cr.resume.summary: + tasks.append(("summary", optimize_summary(job.title or "", job_desc, cr.resume.summary))) + for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship), + ("project", cr.project), ("competition", cr.competition)]: + if items: + rows_json = json.dumps([item.model_dump(by_alias=True) for item in items], ensure_ascii=False) + tasks.append((name, optimize_experience(job.title or "", job_desc, rows_json))) + # 执行并发 + if tasks: + keys = [t[0] for t in tasks] + results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True) + for key, result in zip(keys, results): + if isinstance(result, Exception): + log.warning(f"岗位简历优化[{key}]失败: {result}") + continue + self._apply_optimize_result(cr, key, result) + # 4. 存 Redis + await customize_resume_store.save(user_id, cr) + # 5. 返回 + return cr.model_dump(by_alias=True) + + async def _get_job(self, job_id: int) -> Job: + """查岗位""" + result = await self.session.execute(select(Job).where(Job.id == job_id)) + job = result.scalar_one_or_none() + if not job: + raise ValueError("岗位不存在") + return job + + @staticmethod + def _apply_optimize_result(cr: CustomizeResume, key: str, result) -> None: + """将 AI 优化结果应用到定制简历""" + if key == "summary" and isinstance(result, str): + cr.resume.summary = result + elif key == "education" and isinstance(result, list): + cr.education = [Education.model_validate(item) for item in result] + elif key == "work" and isinstance(result, list): + cr.work = [Work.model_validate(item) for item in result] + elif key == "internship" and isinstance(result, list): + cr.internship = [Internship.model_validate(item) for item in result] + elif key == "project" and isinstance(result, list): + cr.project = [Project.model_validate(item) for item in result] + elif key == "competition" and isinstance(result, list): + cr.competition = [Competition.model_validate(item) for item in result] diff --git a/app/services/skill_gap_service.py b/app/services/skill_gap_service.py index 37b2032..6fe4759 100644 --- a/app/services/skill_gap_service.py +++ b/app/services/skill_gap_service.py @@ -8,8 +8,6 @@ import asyncio import json -import random -import string from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -21,15 +19,13 @@ from app.ai.skill_gap_analyzer.analyzer import ( from app.ai.skill_gap_analyzer.prompts import MODULE_SCHEMAS from app.core.logger import log from app.schemas.customize_resume import ( - CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph, + CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, ) from app.models.job import Job from app.models.user_resume import UserResume from app.services.resume_loader import ResumeDetail, load_resume_detail, load_default_resume_detail from app.services import customize_resume_store -_CHARS = string.ascii_letters + string.digits - # 模块名 → 中文标签映射 _MODULE_LABELS = { "resume": "个人简介", @@ -41,18 +37,6 @@ _MODULE_LABELS = { } -def _rand_id() -> str: - """生成随机8位字符串标识""" - return "".join(random.choices(_CHARS, k=8)) - - -def _build_paragraphs(description: list[dict] | None) -> list[Paragraph]: - """将数据库 description [{id, text}] 转为 Paragraph 列表,id 用随机8位替换""" - if not description: - return [] - return [Paragraph(id=_rand_id(), text=item.get("text", "")) for item in description if isinstance(item, dict)] - - def _build_resume_json(detail: ResumeDetail) -> str: """拼装简历 JSON 字符串供 AI 使用""" resume = detail.resume @@ -120,7 +104,7 @@ class SkillGapService: detail = await load_resume_detail(self.session, resume_id, user_id) job = await self._get_job(job_id) # 2. 组装基础定制简历 - cr = self._build_customize_resume(detail) + cr = customize_resume_store.build_from_detail(detail) # 3. 并发 AI 优化 tasks = [] job_desc = f"{job.description or ''}\n{job.requirement or ''}" @@ -339,34 +323,3 @@ class SkillGapService: if not job: raise ValueError("岗位不存在") return job - - 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 "", - wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "", - skills=resume.skills or [], certificates=resume.certificates or [], - summary=resume.summary or "", - ) - return CustomizeResume( - resume=profile, - 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 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 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 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 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 detail.competition], - ) -