diff --git a/.kiro/specs/job-agent-chat/design.md b/.kiro/specs/job-agent-chat/design.md new file mode 100644 index 0000000..c9ea6d3 --- /dev/null +++ b/.kiro/specs/job-agent-chat/design.md @@ -0,0 +1,118 @@ +# 求职助手 Agent 对话接口设计方案 + +## 1. 接口概述 + +`POST /job-agent/chat` — 求职助手对话接口,Python 端实现。 + +用户通过对话与求职助手交互,AI 根据用户简历和求职意向提供求职建议,识别用户意图后触发前端工具调用(岗位推荐 / 调整偏好)。 + +## 2. 入参 Schema + +```python +class ChatMessage(BaseModel): + role: Literal["user", "assistant"] + content: str + +class JobAgentChatParam(BaseModel): + message: str = Field(..., description="用户输入的消息") + resume_id: int = Field(..., alias="resumeId", description="简历ID") + history: list[ChatMessage] = Field(default_factory=list, description="对话历史") + job_categories: list[str] = Field(default_factory=list, alias="jobCategories", description="意向岗位类型名称") + regions: list[str] = Field(default_factory=list, alias="regions", description="意向城市名称") + industries: list[str] = Field(default_factory=list, alias="industries", description="意向行业名称") +``` + +## 3. 出参 Schema + +```python +class ToolParams(BaseModel): + preference: str = Field(default="", description="用户岗位偏好描述,仅tool=recommend时有值") + +class JobAgentChatDto(BaseModel): + message: str = Field(..., description="AI回复文本,不超过200字") + tool: str | None = Field(default=None, description="前端需执行的工具:recommend / editPreference / null") + tool_params: ToolParams | None = Field(default=None, alias="toolParams", description="工具参数") +``` + +## 4. AI 上下文构造 + +每次对话请求,后端自动查询以下数据拼入 system prompt: + +1. **简历信息**:根据 resumeId 查 bg_user_resume 主表 + 5张子表(教育/工作/实习/项目/竞赛),序列化为文本 +2. **求职意向**:直接使用前端传入的 jobCategories / regions / industries(中文名称) + +## 5. System Prompt 设计 + +``` +你是 OfferPie 求职助手,帮助用户找到合适的工作。 + +【用户简历】 +{resume_text} + +【求职意向】 +意向岗位:{job_categories} +意向城市:{regions} +意向行业:{industries} + +【你的能力】 +1. 回答求职相关问题(面试技巧、简历建议、行业分析等),回复不超过200字 +2. 当用户想看岗位推荐时,提取用户的偏好描述,调用岗位推荐工具 +3. 当用户想修改求职偏好/设置时,调用偏好设置工具 + +【输出格式】 +严格返回 JSON,不要其他内容: +{"message":"回复内容","tool":null,"toolParams":null} + +tool 可选值: +- null:普通对话,不触发工具 +- "recommend":岗位推荐,toolParams 必须包含 {"preference":"用户偏好描述"} +- "editPreference":调整偏好,toolParams 为 null + +【规则】 +1. 只聊求职相关话题,其他话题礼貌拒绝 +2. 回复简洁,不超过200字 +3. 用户表达想看岗位、推荐岗位、帮我找工作等意图时,从对话中提取偏好描述,返回 recommend +4. 用户表达想改设置、调整偏好、修改意向等意图时,返回 editPreference +5. 偏好描述要准确概括用户的岗位偏好,如"更偏技术方向的产品岗"、"大厂优先" +``` + +## 6. 处理流程 + +1. 校验登录态,获取 user_id +2. 根据 resume_id 查简历主表 + 5张子表,序列化为文本 +3. 构造 system prompt(简历 + 求职意向 + 规则) +4. 构造 messages 列表:system prompt + history + 当前 message +5. 调用 LLM(使用 LLM.DEEPSEEK_V3 或配置的模型) +6. 用 parse_llm_json 解析 AI 返回的 JSON +7. 构造 JobAgentChatDto 返回 + +## 7. 前端交互流程 + +1. 前端发消息 → 调 `POST /job-agent/chat` +2. 拿到返回后判断 tool 字段: + - `tool=null` → 直接展示 message + - `tool="recommend"` → 展示 message + 用 toolParams.preference 调 Java 端 `POST /job/agent/recommend` 拿岗位列表展示 + - `tool="editPreference"` → 展示 message + 打开偏好设置页面 + +## 8. 涉及文件 + +| 文件 | 位置 | 说明 | +|------|------|------| +| `job_agent_chat.py` | app/schemas/ | Pydantic 请求/响应 Schema | +| `job_agent_chat.py` | app/api/ | 路由定义 | +| `job_agent_chat_service.py` | app/services/ | Service:查简历 + 构造 prompt + 调 LLM | +| `prompts.py` | app/ai/job_agent/ | System Prompt 模板 | +| `main.py` | app/ | 注册新路由 | + +## 9. 依赖的现有模块 + +- `app/models/user_resume.py` + 5张子表 ORM — 查简历数据 +- `app/ai/models.py` — LLM 枚举,创建模型实例 +- `app/tool/json_helper.py` — parse_llm_json 解析 AI 输出 +- `app/core/context.py` — RequestContext 获取 user_id +- `app/core/database.py` — get_db 获取数据库会话 + +## 10. 不需要新增的 ORM 模型 + +- 求职意向由前端传入中文名称,不查 bg_user_job_intention 表 +- 简历相关 ORM 模型已全部存在 diff --git a/app/ai/job_agent/__init__.py b/app/ai/job_agent/__init__.py new file mode 100644 index 0000000..48e33fd --- /dev/null +++ b/app/ai/job_agent/__init__.py @@ -0,0 +1 @@ +"""求职助手 Agent 对话 AI 模块""" diff --git a/app/ai/job_agent/chat.py b/app/ai/job_agent/chat.py new file mode 100644 index 0000000..b5e468f --- /dev/null +++ b/app/ai/job_agent/chat.py @@ -0,0 +1,52 @@ +"""求职助手 Agent 对话 AI 引擎 + +构造 prompt + 调 LLM + 解析返回。 +依赖:LLM 枚举、job_agent/prompts、parse_llm_json +""" + +from app.ai.job_agent.prompts import SYSTEM_PROMPT +from app.ai.models import LLM +from app.core.logger import log +from app.tool.json_helper import parse_llm_json + + +async def agent_chat(resume_text: str, message: str, history: list[dict], + job_categories: list[str], regions: list[str], + industries: list[str]) -> dict: + """求职助手对话 + 1. 构造 system prompt 2. 拼 messages 3. 调 LLM 4. 解析返回 + """ + # 1. 构造 system prompt + system_content = SYSTEM_PROMPT.format( + resume_text=resume_text, + job_categories="、".join(job_categories) if job_categories else "未设置", + regions="、".join(regions) if regions else "未设置", + industries="、".join(industries) if industries else "未设置", + ) + + # 2. 拼 messages + messages = [("system", system_content)] + for msg in history: + messages.append((msg["role"], msg["content"])) + messages.append(("human", message)) + + # 3. 调 LLM + try: + llm = LLM.JIAYU_CLAUDE_SONNET_4_5.create(temperature=0.7) + result = await llm.ainvoke(messages) + raw = result.content + except Exception as e: + log.error(f"求职助手AI调用失败: {e}") + return {"message": "抱歉,我暂时无法回复,请稍后再试。", "tool": None, "toolParams": None} + + # 4. 解析返回 + try: + parsed = parse_llm_json(raw) + return { + "message": parsed.get("message", ""), + "tool": parsed.get("tool"), + "toolParams": parsed.get("toolParams"), + } + except Exception as e: + log.warning(f"求职助手AI返回解析失败, raw={raw}, error={e}") + return {"message": raw.strip() if raw else "抱歉,我暂时无法回复。", "tool": None, "toolParams": None} diff --git a/app/ai/job_agent/prompts.py b/app/ai/job_agent/prompts.py new file mode 100644 index 0000000..a80118c --- /dev/null +++ b/app/ai/job_agent/prompts.py @@ -0,0 +1,34 @@ +"""求职助手 Agent 对话 Prompt 模板""" + +SYSTEM_PROMPT = """你是 OfferPie 求职助手,帮助用户找到合适的工作。 + +【用户简历】 +{resume_text} + +【求职意向】 +意向岗位:{job_categories} +意向城市:{regions} +意向行业:{industries} + +【你的能力】 +1. 回答求职相关问题(面试技巧、简历建议、行业分析等),回复不超过200字 +2. 当用户想看岗位推荐时,提取用户的偏好描述,调用岗位推荐工具 +3. 当用户想修改求职偏好/设置时,调用偏好设置工具 + +【输出格式】 +严格返回 JSON,不要其他内容: +{{"message":"回复内容","tool":null,"toolParams":null}} + +tool 可选值: +- null:普通对话,不触发工具 +- "recommend":岗位推荐,toolParams 必须包含 {{"preference":"用户偏好描述"}} +- "editPreference":调整偏好,toolParams 为 null + +【规则】 +1. 只聊求职相关话题,其他话题礼貌拒绝 +2. 回复简洁,不超过200字 +3. 用户表达想看岗位、推荐岗位、帮我找工作等意图时,从对话中提取偏好描述,返回 recommend +4. 用户表达想改设置、调整偏好、修改意向等意图时,返回 editPreference +5. 偏好描述要准确概括用户的岗位偏好,如"更偏技术方向的产品岗"、"大厂优先" +6. 如果用户没有明确偏好,preference 填"无特殊偏好" +""" diff --git a/app/api/job_agent_chat.py b/app/api/job_agent_chat.py new file mode 100644 index 0000000..c5da9b1 --- /dev/null +++ b/app/api/job_agent_chat.py @@ -0,0 +1,24 @@ +"""求职助手 Agent 对话接口""" + +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.services.job_agent_chat_service import JobAgentChatService + +router = APIRouter(prefix="/job-agent", tags=["求职助手agent"]) + + +@router.post("/chat", summary="求职助手对话") +async def chat(param: JobAgentChatParam): + """求职助手对话,根据用户简历和意向提供求职建议、触发岗位推荐或偏好调整""" + user_id = RequestContext.user_id.get() + async for session in get_db(): + service = JobAgentChatService(session) + result = await service.chat( + user_id, param.resume_id, param.message, + [msg.model_dump() for msg in param.history], + param.job_categories, param.regions, param.industries, + ) + return result diff --git a/app/main.py b/app/main.py index dc203e2..9f570be 100644 --- a/app/main.py +++ b/app/main.py @@ -34,11 +34,13 @@ from app.api.health import router as health_router from app.api.resume import router as resume_router from app.api.resume_diagnose import router as resume_diagnose_router from app.api.skill_gap import router as skill_gap_router +from app.api.job_agent_chat import router as job_agent_chat_router app.include_router(health_router) app.include_router(resume_router) app.include_router(resume_diagnose_router) app.include_router(skill_gap_router) +app.include_router(job_agent_chat_router) # ============================== if __name__ == "__main__": diff --git a/app/schemas/job_agent_chat.py b/app/schemas/job_agent_chat.py new file mode 100644 index 0000000..a995c0a --- /dev/null +++ b/app/schemas/job_agent_chat.py @@ -0,0 +1,37 @@ +"""求职助手 Agent 对话 Schema + +请求参数 Param、响应 Dto。 +字段命名使用 camelCase alias,与前端 JSON 对齐。 +""" + +from typing import Literal + +from pydantic import BaseModel, Field + +ChatRole = Literal["user", "assistant"] + + +class ChatMessage(BaseModel): + role: ChatRole + content: str + + +class JobAgentChatParam(BaseModel): + message: str = Field(..., description="用户输入的消息") + resume_id: int = Field(..., alias="resumeId", description="简历ID") + history: list[ChatMessage] = Field(default_factory=list, description="对话历史") + job_categories: list[str] = Field(default_factory=list, alias="jobCategories", description="意向岗位类型名称") + regions: list[str] = Field(default_factory=list, alias="regions", description="意向城市名称") + industries: list[str] = Field(default_factory=list, alias="industries", description="意向行业名称") + + +class ToolParams(BaseModel): + preference: str = Field(default="", description="用户岗位偏好描述") + + +class JobAgentChatDto(BaseModel): + message: str = Field(..., description="AI回复文本,不超过200字") + tool: str | None = Field(default=None, description="前端需执行的工具:recommend / editPreference / null") + tool_params: ToolParams | None = Field(default=None, alias="toolParams", description="工具参数") + + model_config = {"populate_by_name": True} diff --git a/app/services/job_agent_chat_service.py b/app/services/job_agent_chat_service.py new file mode 100644 index 0000000..56ceefd --- /dev/null +++ b/app/services/job_agent_chat_service.py @@ -0,0 +1,85 @@ +"""求职助手 Agent 对话 Service + +主要功能:查询简历数据,调用 AI 模块完成对话。 +依赖:UserResume + 5张子表 ORM、job_agent.chat AI 模块 +使用表:bg_user_resume、bg_user_resume_education/work/internship/project/competition(查询简历) +""" + +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 + + +class JobAgentChatService: + + def __init__(self, session: AsyncSession): + self.session = session + + async def chat(self, user_id: int, resume_id: int, message: str, + 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) + 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: + """将简历数据序列化为文本供 AI 使用""" + parts = [] + if resume.name: + parts.append(f"姓名:{resume.name}") + if resume.target_position: + parts.append(f"目标岗位:{resume.target_position}") + if resume.skills: + parts.append(f"技能:{'、'.join(resume.skills)}") + if resume.certificates: + parts.append(f"证书:{'、'.join(resume.certificates)}") + if resume.summary: + parts.append(f"个人概述:{resume.summary}") + if edu_list: + parts.append("教育经历:") + for r in edu_list: + parts.append(f" - {r.school or ''} {r.major or ''} {r.degree or ''}") + if work_list: + parts.append("工作经历:") + for r in work_list: + parts.append(f" - {r.company_name or ''} {r.position or ''}") + if intern_list: + parts.append("实习经历:") + for r in intern_list: + parts.append(f" - {r.company_name or ''} {r.position or ''}") + if proj_list: + parts.append("项目经历:") + for r in proj_list: + parts.append(f" - {r.project_name or ''} {r.role or ''}") + if comp_list: + parts.append("竞赛经历:") + for r in comp_list: + parts.append(f" - {r.competition_name or ''} {r.award or ''}") + return "\n".join(parts) if parts else "暂无简历信息"