diff --git a/.kiro/steering/项目结构说明.md b/.kiro/steering/项目结构说明.md index 1db00bf..38d0df4 100644 --- a/.kiro/steering/项目结构说明.md +++ b/.kiro/steering/项目结构说明.md @@ -36,9 +36,12 @@ offerpie_python_ai/ │ ├─ resume_extractor/ # 简历 AI 提取模块 │ │ ├─ prompts.py # 5 个提取任务的 System Prompt(个人信息/教育/工作+实习/项目/竞赛) │ │ └─ extractor.py # AI 并行提取(extract_all 入口,asyncio.gather 5 路并行) + │ ├─ resume_polisher/ # 简历段落润色模块 + │ │ ├─ prompts.py # 润色 Prompt 模板(仅格式/错字/表达优化,不改内容) + │ │ └─ polisher.py # AI 段落润色(polish_paragraphs 入口,输出等长数组) │ ├─ resume_diagnoser/ # 简历 AI 诊断模块 - │ │ ├─ prompts.py # 诊断 Prompt 模板(分模块诊断 + 汇总评价 + 润色优化) - │ │ └─ diagnoser.py # AI 并行诊断(diagnose_all 入口 + generate_summary 汇总评价 + polish_content 润色优化) + │ │ ├─ prompts.py # 诊断 Prompt 模板(分模块诊断 + 汇总评价) + │ │ └─ diagnoser.py # AI 并行诊断(diagnose_all 入口 + generate_summary 汇总评价) │ ├─ skill_gap_analyzer/ # 技能差距分析 + 定制简历 AI 模块 │ │ ├─ prompts.py # 差距分析 + 简历优化 + Agent 规划(原子化操作)/ 单条记录修改 / 新增记录 Prompt 模板 + MODULE_SCHEMAS │ │ └─ analyzer.py # AI 调用逻辑(差距分析 + summary优化 + 经历优化 + Agent规划 + 单条记录修改 + 新增记录) @@ -88,7 +91,7 @@ offerpie_python_ai/ │ └─ services/ # **业务逻辑层** ├─ func_permission_service.py # 功能权限服务(校验+扣减+回退,逻辑与Java端一致) - ├─ resume_parse_service.py # 简历解析服务(文件解析→AI结构化→写入主表+5张子表) + ├─ resume_service.py # 简历服务(文件解析→AI结构化→写入主表+5张子表) ├─ resume_diagnose_service.py # 简历诊断服务(加载简历→AI并行诊断→统计评级→写入报告) ├─ skill_gap_service.py # 技能差距分析服务(差距分析→定制简历生成→AI对话编辑) ├─ resume_loader.py # 简历统一查询模块(按ID查/自动选默认+5张子表,返回 ResumeDetail dataclass) @@ -102,11 +105,11 @@ offerpie_python_ai/ |------|----------|-------------| | **config** | 统一配置管理,基于 Pydantic Settings,支持 .env 文件加载 | `Settings`(数据库、Redis、LLM供应商、JWT、CORS、日志等全部配置项) | | **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py`、`redis.py`、`auth.py`、`middleware.py`、`exceptions.py`、`logger.py`、`StandardResponse` | -| **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举(models.py)、`model_config.py`(场景模型配置)、`resume_extractor/`(简历并行提取)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手对话 + 岗位简历优化)、`nova_chat/`(Nova 对话助手,纯对话) | -| **api** | REST API 路由定义 | `health.py`(健康检查)、`resume.py`(简历上传解析)、`resume_diagnose.py`(简历诊断)、`skill_gap.py`(技能差距分析 + 生成定制简历 + AI对话编辑)、`customize_resume.py`(定制简历查询/修改/回滚)、`job_agent_chat.py`(求职助手对话 + 岗位简历优化)、`nova_chat.py`(Nova 对话助手) | +| **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举(models.py)、`model_config.py`(场景模型配置)、`resume_extractor/`(简历并行提取)、`resume_polisher/`(简历段落润色)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手对话 + 岗位简历优化)、`nova_chat/`(Nova 对话助手,纯对话) | +| **api** | REST API 路由定义 | `health.py`(健康检查)、`resume.py`(简历上传解析 + 段落润色)、`resume_diagnose.py`(简历诊断)、`skill_gap.py`(技能差距分析 + 生成定制简历 + AI对话编辑)、`customize_resume.py`(定制简历查询/修改/回滚)、`job_agent_chat.py`(求职助手对话 + 岗位简历优化)、`nova_chat.py`(Nova 对话助手) | | **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`UserResume`、`UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition`、`ResumeDiagnosisReport`、`ResumeDiagnosisIssue`、`Job`(只读)、`JobAgentConfig`、`UserJobCustomizeResume` | | **tool** | 无状态通用工具,不依赖数据库/Redis/用户上下文 | `file_parser.py`(PDF/Word/TXT 文件解析为纯文本)、`json_helper.py`(AI 输出 JSON 解析,去 markdown 代码块 + json_repair 容错)、`snowflake.py`(雪花ID生成) | -| **services** | 业务逻辑实现 | `FuncPermissionService`(功能权限校验、扣减、回退)、`ResumeParseService`(简历文件解析→AI结构化→入库)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历数据库存取+数据构建,按用户+岗位维度,Redis回滚备份)、`JobAgentChatService`(求职助手对话+岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) | +| **services** | 业务逻辑实现 | `FuncPermissionService`(功能权限校验、扣减、回退)、`ResumeService`(简历文件解析→AI结构化→入库 + 段落润色)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历数据库存取+数据构建,按用户+岗位维度,Redis回滚备份)、`JobAgentChatService`(求职助手对话+岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) | ## 3️⃣ 技术栈 | 类别 | 技术选型 | 说明 | diff --git a/app/ai/model_config.py b/app/ai/model_config.py index 4f9460e..83cbb7c 100644 --- a/app/ai/model_config.py +++ b/app/ai/model_config.py @@ -44,14 +44,18 @@ class ResumeExtractorModel: PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0) +class ResumePolisherModel: + """简历段落润色模块""" + # 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定 + POLISH = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.2) + + class DiagnoserModel: """简历诊断模块""" # 模块诊断:逐条分析经历记录的问题(错别字/无量化/弱相关等) MODULE = LLM.DEEPSEEK_V4_FLASH.create(temperature=0) # 整体评价:汇总所有诊断结果生成总结性评语 SUMMARY = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.3) - # 内容润色:用户编辑后的文本做专业润色 - POLISH = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.3) class BrowserPlugModel: diff --git a/app/ai/resume_diagnoser/diagnoser.py b/app/ai/resume_diagnoser/diagnoser.py index fb50d60..e810672 100644 --- a/app/ai/resume_diagnoser/diagnoser.py +++ b/app/ai/resume_diagnoser/diagnoser.py @@ -7,7 +7,7 @@ from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from app.ai.model_config import DiagnoserModel -from app.ai.resume_diagnoser.prompts import DIAGNOSE_MODULE_PROMPT, SUMMARY_PROMPT, POLISH_PROMPT +from app.ai.resume_diagnoser.prompts import DIAGNOSE_MODULE_PROMPT, SUMMARY_PROMPT from app.core.logger import log from app.tool.json_helper import parse_llm_json @@ -54,45 +54,6 @@ async def generate_summary(grade: str, urgent_total: int, important_total: int, return "简历诊断已完成,请查看各模块的详细诊断结果。" -_polish_chain = ( - ChatPromptTemplate.from_messages([("system", POLISH_PROMPT), ("human", "请开始优化。")]) - | DiagnoserModel.POLISH - | StrOutputParser() -) - - -async def polish_content(module_type: str, reference_content: list[dict] | str | None, - user_content: list[str], is_summary: bool) -> list[str]: - """润色用户编辑后的文本""" - ref_text = "" - if reference_content: - if isinstance(reference_content, list): - ref_text = "\n".join( - item.get("text", "") if isinstance(item, dict) else str(item) - for item in reference_content - ) - else: - ref_text = str(reference_content) - if not ref_text: - ref_text = "无" - - inp = { - "module_type": module_type, - "reference_content": ref_text, - "user_content": "\n".join(user_content), - "summary_constraint": "- 注意:此模块只能输出一个段落,数组只能有一个元素" if is_summary else "", - } - try: - raw = await _polish_chain.ainvoke(inp) - result = parse_llm_json(raw) - if isinstance(result, list): - return [str(item) for item in result] - return [str(result)] - except Exception as e: - log.warning(f"AI润色失败: {e}") - return user_content - - async def _safe_invoke(task: dict) -> dict: """单条记录诊断,失败返回空结果""" module_type = task.get("module_type", "unknown") diff --git a/app/ai/resume_diagnoser/prompts.py b/app/ai/resume_diagnoser/prompts.py index b7e28fd..e4c0d1b 100644 --- a/app/ai/resume_diagnoser/prompts.py +++ b/app/ai/resume_diagnoser/prompts.py @@ -82,26 +82,3 @@ SUMMARY_PROMPT = """你是一位资深简历顾问。请根据以下简历诊断 4. 一句鼓励或行动建议 直接输出评价文本,不要输出JSON或其他格式标记。控制在200字以内。""" - -POLISH_PROMPT = """你是一位资深简历顾问。请对用户提供的简历描述文本进行润色优化,让语言更精练、更专业。 - -## 模块类型 -{module_type} - -## AI 之前的优化版本(仅供参考) -{reference_content} - -## 用户提交的文本(以此为主进行优化) -{user_content} - -## 优化要求 -- 以用户提交的文本为主体进行润色,AI之前的版本仅作参考 -- 让语言更精练、更专业,去除冗余表达 -- 尽量使用数据量化成果 -- 保持原意不变,不凭空捏造内容 -- 输出为 JSON 数组格式,每个元素是一个段落的纯文本 -{summary_constraint} - -## 输出格式 -严格输出 JSON 数组,不要输出其他内容: -["优化后的段落1", "优化后的段落2"]""" diff --git a/app/ai/resume_polisher/__init__.py b/app/ai/resume_polisher/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ai/resume_polisher/polisher.py b/app/ai/resume_polisher/polisher.py new file mode 100644 index 0000000..0386b02 --- /dev/null +++ b/app/ai/resume_polisher/polisher.py @@ -0,0 +1,36 @@ +"""简历段落润色 AI 引擎:仅做格式/错字/表达层面的优化""" + +import json + +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate + +from app.ai.model_config import ResumePolisherModel +from app.ai.resume_polisher.prompts import POLISH_PROMPT +from app.core.logger import log +from app.tool.json_helper import parse_llm_json + +# 润色链(StrOutputParser 拿原始文本,再手动解析 JSON,避免 markdown 代码块导致解析失败) +_polish_chain = ( + ChatPromptTemplate.from_messages([("system", POLISH_PROMPT), ("human", "请开始润色。")]) + | ResumePolisherModel.POLISH + | StrOutputParser() +) + + +async def polish_paragraphs(content: list[str]) -> list[str]: + """对段落数组做表达层面的润色,返回与输入等长的数组;失败兜底原样返回""" + if not content: + return [] + + inp = {"content": json.dumps(content, ensure_ascii=False)} + try: + raw = await _polish_chain.ainvoke(inp) + result = parse_llm_json(raw) + if isinstance(result, list) and len(result) == len(content): + return [str(item) for item in result] + log.warning(f"AI润色返回结果不符合预期, 原样返回: {result}") + return content + except Exception as e: + log.warning(f"AI润色失败: {e}") + return content diff --git a/app/ai/resume_polisher/prompts.py b/app/ai/resume_polisher/prompts.py new file mode 100644 index 0000000..5288c4e --- /dev/null +++ b/app/ai/resume_polisher/prompts.py @@ -0,0 +1,21 @@ +"""简历段落润色 Prompt 模板""" + +POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户提交的简历段落进行"表面润色",只做表达层面的优化。 + +## 优化范围(只允许做这些) +- 修正错别字、标点、语法错误 +- 优化文本格式与排版(如多余空格、断句、全半角混用) +- 让表达更通顺、专业,去除明显口语化和冗余措辞 + +## 严格禁止(绝对不能做) +- 不得改变原意,不得增加或删除任何信息点 +- 不得编造、补充任何内容(尤其禁止凭空添加数字、量化成果、技能、成就) +- 不得改变段落的数量和顺序 + +## 输入 +用户提交的段落数组(每个元素是一个段落): +{content} + +## 输出格式 +严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容: +["润色后的段落1", "润色后的段落2"]""" diff --git a/app/api/resume.py b/app/api/resume.py index 69c5f97..09afc23 100644 --- a/app/api/resume.py +++ b/app/api/resume.py @@ -1,10 +1,11 @@ """简历上传解析接口""" from fastapi import APIRouter, UploadFile, File +from pydantic import BaseModel, Field from app.core.context import RequestContext from app.core.database import get_db -from app.services.resume_parse_service import ResumeParseService +from app.services.resume_service import ResumeService router = APIRouter(prefix="/resume", tags=["简历"]) @@ -15,7 +16,7 @@ async def upload_resume(file: UploadFile = File(...)): user_id = RequestContext.user_id.get() content = await file.read() - service = ResumeParseService() + service = ResumeService() # 文件解析 + AI 结构化(不占数据库连接) parsed = await service.parse_and_extract(file.filename, content) # 短事务:只做数据库写入 @@ -23,3 +24,15 @@ async def upload_resume(file: UploadFile = File(...)): async for session in get_db(): resume_id = await service.save_resume(session, user_id, file.filename, parsed) return {"resumeId": resume_id} + + +class PolishParam(BaseModel): + content: list[str] = Field(..., description="待润色的简历段落文本数组") + + +@router.post("/polish", summary="AI润色简历段落") +async def polish_resume(param: PolishParam): + """对前端提交的简历段落做表达层面的润色(格式/错字/表达),不改内容,返回等长数组""" + service = ResumeService() + result = await service.polish_paragraphs(param.content) + return {"content": result} diff --git a/app/api/resume_diagnose.py b/app/api/resume_diagnose.py index bae2aad..6d59006 100644 --- a/app/api/resume_diagnose.py +++ b/app/api/resume_diagnose.py @@ -5,7 +5,7 @@ import time from fastapi import APIRouter, Depends from pydantic import BaseModel, Field -from app.ai.resume_diagnoser.diagnoser import diagnose_all, generate_summary, polish_content +from app.ai.resume_diagnoser.diagnoser import diagnose_all, generate_summary from app.core.auth import func_permission from app.core.context import RequestContext from app.core.database import get_db @@ -98,25 +98,3 @@ async def feedback_issue(issue_id: int, param: FeedbackParam): async for session in get_db(): service = ResumeDiagnoseService(session) await service.update_feedback(issue_id, user_id, param.user_feedback) - - -class PolishParam(BaseModel): - content: list[str] = Field(..., description="用户编辑后的文本段落数组") - - -@router.post("/issue/{issue_id}/polish", summary="AI润色用户编辑的文本") -async def polish_issue_content(issue_id: int, param: PolishParam): - """基于诊断问题上下文,AI润色用户编辑后的文本""" - user_id = RequestContext.user_id.get() - - async for session in get_db(): - service = ResumeDiagnoseService(session) - ctx = await service.get_issue_for_polish(issue_id, user_id) - - result = await polish_content( - module_type=ctx["module_label"], - reference_content=ctx["optimized_content"], - user_content=param.content, - is_summary=ctx["is_summary"], - ) - return {"content": result} diff --git a/app/services/resume_diagnose_service.py b/app/services/resume_diagnose_service.py index d29b42e..10bed7e 100644 --- a/app/services/resume_diagnose_service.py +++ b/app/services/resume_diagnose_service.py @@ -153,21 +153,6 @@ class ResumeDiagnoseService: issue.user_feedback = user_feedback await self.session.flush() - async def get_issue_for_polish(self, issue_id: int, user_id: int) -> dict: - """获取 issue 润色所需的上下文信息""" - result = await self.session.execute( - select(ResumeDiagnosisIssue).where( - ResumeDiagnosisIssue.id == issue_id, ResumeDiagnosisIssue.user_id == user_id)) - issue = result.scalar_one_or_none() - if issue is None: - raise ValueError("诊断问题不存在") - return { - "module_type": issue.module_type, - "module_label": _MODULE_LABELS.get(issue.module_type, issue.module_type), - "optimized_content": issue.optimized_content, - "is_summary": issue.module_type == "summary", - } - # ===== 工具函数 ===== diff --git a/app/services/resume_parse_service.py b/app/services/resume_service.py similarity index 91% rename from app/services/resume_parse_service.py rename to app/services/resume_service.py index db14f22..3765383 100644 --- a/app/services/resume_parse_service.py +++ b/app/services/resume_service.py @@ -1,4 +1,4 @@ -"""简历解析 Service +"""简历 Service 上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。 依赖:file_parser(文件解析工具)、resume_extractor(AI两阶段并行提取) @@ -11,6 +11,7 @@ import shortuuid from sqlalchemy.ext.asyncio import AsyncSession from app.ai.resume_extractor.extractor import extract_all +from app.ai.resume_polisher.polisher import polish_paragraphs from app.core.logger import log from app.models.user_resume import UserResume from app.models.user_resume_competition import UserResumeCompetition @@ -22,7 +23,7 @@ from app.tool.file_parser import parse_to_text from app.tool.snowflake import next_id -class ResumeParseService: +class ResumeService: async def parse_and_extract(self, filename: str, content: bytes) -> dict: """文件解析 + AI 两阶段并行结构化,不涉及数据库操作""" @@ -37,6 +38,13 @@ class ResumeParseService: log.info("AI两阶段并行结构化提取完成") return parsed + async def polish_paragraphs(self, content: list[str]) -> list[str]: + """对简历段落做表达层面的润色(格式/错字/表达),不涉及数据库操作""" + log.info(f"开始简历段落润色, 段落数={len(content)}") + result = await polish_paragraphs(content) + log.info("简历段落润色完成") + return result + async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int: """将解析结果写入主表 + 5张子表,返回简历ID""" resume_id = next_id()