修改定制简历保存逻辑

This commit is contained in:
zk
2026-04-28 11:16:50 +08:00
parent dbbc97a836
commit 9daeea9fc5
6 changed files with 102 additions and 48 deletions
+5 -4
View File
@@ -71,7 +71,8 @@ offerpie_python_ai/
│ ├─ resume_diagnosis_report.py # 简历诊断报告表(bg_resume_diagnosis_report │ ├─ resume_diagnosis_report.py # 简历诊断报告表(bg_resume_diagnosis_report
│ ├─ resume_diagnosis_issue.py # 简历诊断问题表(bg_resume_diagnosis_issue │ ├─ resume_diagnosis_issue.py # 简历诊断问题表(bg_resume_diagnosis_issue
│ ├─ job.py # 岗位表(bg_job,只读,用于技能差距分析) │ ├─ job.py # 岗位表(bg_job,只读,用于技能差距分析)
─ job_agent_config.py # 求职助手配置表(bg_job_agent_config ─ job_agent_config.py # 求职助手配置表(bg_job_agent_config
│ └─ user_job_customize_resume.py # 用户岗位定制简历表(bg_user_job_customize_resume
├─ tool/ # **工具层**(无状态、无业务依赖的通用工具) ├─ tool/ # **工具层**(无状态、无业务依赖的通用工具)
│ ├─ file_parser.py # 文件解析工具(PDF/Word/TXT → 纯文本,parse_to_text 入口方法) │ ├─ file_parser.py # 文件解析工具(PDF/Word/TXT → 纯文本,parse_to_text 入口方法)
@@ -90,7 +91,7 @@ offerpie_python_ai/
├─ resume_diagnose_service.py # 简历诊断服务(加载简历→AI并行诊断→统计评级→写入报告) ├─ resume_diagnose_service.py # 简历诊断服务(加载简历→AI并行诊断→统计评级→写入报告)
├─ skill_gap_service.py # 技能差距分析服务(差距分析→定制简历生成→AI对话编辑) ├─ skill_gap_service.py # 技能差距分析服务(差距分析→定制简历生成→AI对话编辑)
├─ resume_loader.py # 简历统一查询模块(按ID查/自动选默认+5张子表,返回 ResumeDetail dataclass ├─ resume_loader.py # 简历统一查询模块(按ID查/自动选默认+5张子表,返回 ResumeDetail dataclass
├─ customize_resume_store.py # 定制简历 Redis 存取模块(保存自动回滚备份、查询、回滚 ├─ customize_resume_store.py # 定制简历存取模块(数据库持久化 + Redis回滚备份、按用户+岗位维度存取
├─ job_agent_chat_service.py # 求职助手对话服务(查简历→序列化→调AI模块完成对话) ├─ job_agent_chat_service.py # 求职助手对话服务(查简历→序列化→调AI模块完成对话)
└─ nova_chat_service.py # Nova 对话助手服务(查简历+查岗位(可选)→调AI,纯对话不持久化) └─ nova_chat_service.py # Nova 对话助手服务(查简历+查岗位(可选)→调AI,纯对话不持久化)
``` ```
@@ -102,9 +103,9 @@ offerpie_python_ai/
| **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py``redis.py``auth.py``middleware.py``exceptions.py``logger.py``StandardResponse` | | **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py``redis.py``auth.py``middleware.py``exceptions.py``logger.py``StandardResponse` |
| **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举、`resume_extractor/`(简历并行提取)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手对话 + 岗位简历优化)、`nova_chat/`Nova 对话助手,纯对话) | | **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举、`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 对话助手) | | **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` | | **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生成) | | **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`(功能权限校验、扣减、回退)、`ResumeParseService`(简历文件解析→AI结构化→入库)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历数据库存取+数据构建,按用户+岗位维度,Redis回滚备份)、`JobAgentChatService`(求职助手对话+岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) |
## 3️⃣ 技术栈 ## 3️⃣ 技术栈
| 类别 | 技术选型 | 说明 | | 类别 | 技术选型 | 说明 |
+8 -8
View File
@@ -1,6 +1,6 @@
"""定制简历接口(查询/修改/回滚)""" """定制简历接口(查询/修改/回滚)"""
from fastapi import APIRouter from fastapi import APIRouter, Query
from app.core.context import RequestContext from app.core.context import RequestContext
from app.schemas.customize_resume import CustomizeResume from app.schemas.customize_resume import CustomizeResume
@@ -10,21 +10,21 @@ router = APIRouter(prefix="/job", tags=["定制简历"])
@router.get("/customize-resume", summary="查询定制简历") @router.get("/customize-resume", summary="查询定制简历")
async def get_customize_resume(): async def get_customize_resume(job_id: int = Query(..., description="岗位ID")):
"""查询当前用户的定制简历""" """查询当前用户针对某岗位的定制简历"""
user_id = RequestContext.user_id.get() user_id = RequestContext.user_id.get()
return await customize_resume_store.get(user_id) return await customize_resume_store.get(user_id, job_id)
@router.put("/customize-resume", summary="修改定制简历") @router.put("/customize-resume", summary="修改定制简历")
async def update_customize_resume(data: CustomizeResume): async def update_customize_resume(job_id: int = Query(..., description="岗位ID"), data: CustomizeResume = ...):
"""手动编辑定制简历(整体覆盖)""" """手动编辑定制简历(整体覆盖)"""
user_id = RequestContext.user_id.get() user_id = RequestContext.user_id.get()
await customize_resume_store.save(user_id, data) await customize_resume_store.save(user_id, job_id, data)
@router.post("/customize-resume/rollback", summary="回滚定制简历") @router.post("/customize-resume/rollback", summary="回滚定制简历")
async def rollback_customize_resume(): async def rollback_customize_resume(job_id: int = Query(..., description="岗位ID")):
"""回滚到上一版本的定制简历""" """回滚到上一版本的定制简历"""
user_id = RequestContext.user_id.get() user_id = RequestContext.user_id.get()
await customize_resume_store.rollback(user_id) await customize_resume_store.rollback(user_id, job_id)
+24
View File
@@ -0,0 +1,24 @@
"""用户岗位定制简历表(bg_user_job_customize_resume
一个用户 + 一个岗位 = 一份定制简历,content 字段存完整 CustomizeResume JSON。
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UserJobCustomizeResume(Base):
"""用户岗位定制简历表 bg_user_job_customize_resume"""
__tablename__ = "bg_user_job_customize_resume"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID")
job_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="岗位ID,关联 bg_job.id")
content: Mapped[Optional[dict]] = mapped_column(JSON, nullable=False, comment="定制简历完整 JSONCustomizeResume 结构)")
create_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+59 -30
View File
@@ -1,22 +1,25 @@
"""定制简历 Redis 存取 + 数据转换模块 """定制简历存取 + 数据转换模块
提供定制简历的保存(自动回滚备份)、查询、回滚、从 ResumeDetail 构建能力。 提供定制简历的保存(数据库持久化)、查询、回滚Redis临时备份)、从 ResumeDetail 构建能力。
各 Service 统一复用,不直接操作 Redis key。 各 Service 统一复用,不直接操作数据库表和 Redis key。
""" """
import random import random
import string import string
from sqlalchemy import select, update
from app.core.database import async_session_factory
from app.core.redis import RedisManager from app.core.redis import RedisManager
from app.models.user_job_customize_resume import UserJobCustomizeResume
from app.schemas.customize_resume import ( from app.schemas.customize_resume import (
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph, CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph,
) )
from app.services.resume_loader import ResumeDetail from app.services.resume_loader import ResumeDetail, load_default_resume_detail
from app.tool.snowflake import next_id
# Redis 常量 # Redis 回滚常量
KEY_PREFIX = "customize:resume:" ROLLBACK_PREFIX = "customize:resume:rollback:"
EXPIRE = 12 * 60 * 60 # 12小时
ROLLBACK_KEY_PREFIX = "customize:resume:rollback:"
ROLLBACK_EXPIRE = 30 * 60 # 30分钟 ROLLBACK_EXPIRE = 30 * 60 # 30分钟
_CHARS = string.ascii_letters + string.digits _CHARS = string.ascii_letters + string.digits
@@ -65,31 +68,57 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume:
) )
async def save(user_id: int, cr: CustomizeResume) -> None: def _rollback_key(user_id: int, job_id: int) -> str:
"""保存定制简历,自动备份旧版本到回滚 key""" return f"{ROLLBACK_PREFIX}{user_id}:{job_id}"
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)
async def get(user_id: int) -> dict | None: async def save(user_id: int, job_id: int, cr: CustomizeResume) -> None:
"""查询定制简历,返回 dict 或 None""" """保存定制简历:备份旧版本到 Redis 用于回滚 + 写数据库"""
key = f"{KEY_PREFIX}{user_id}" content = cr.model_dump(by_alias=True)
data = await RedisManager.client.get(key)
if data: async with async_session_factory() as session:
return CustomizeResume.model_validate_json(data).model_dump(by_alias=True) # 查已有记录,备份旧版本到 Redis 用于回滚
return None result = await session.execute(select(UserJobCustomizeResume).where(UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id))
record = result.scalar_one_or_none()
if record:
old_cr = CustomizeResume.model_validate(record.content)
await RedisManager.client.set(_rollback_key(user_id, job_id), old_cr.model_dump_json(by_alias=True), ex=ROLLBACK_EXPIRE)
await session.execute(update(UserJobCustomizeResume).where(
UserJobCustomizeResume.id == record.id).values(content=content))
else:
session.add(UserJobCustomizeResume(id=next_id(), user_id=user_id, job_id=job_id, content=content))
await session.commit()
async def rollback(user_id: int) -> None: async def get(user_id: int, job_id: int) -> dict | None:
"""回滚定制简历到上一版本""" """查询定制简历,查不到则加载默认简历构建返回"""
rollback_key = f"{ROLLBACK_KEY_PREFIX}{user_id}" async with async_session_factory() as session:
data = await RedisManager.client.get(rollback_key) result = await session.execute(select(UserJobCustomizeResume).where(
UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id))
record = result.scalar_one_or_none()
if record:
return CustomizeResume.model_validate(record.content).model_dump(by_alias=True)
# 没有定制简历,加载默认简历构建
detail = await load_default_resume_detail(session, user_id)
return build_from_detail(detail).model_dump(by_alias=True)
async def rollback(user_id: int, job_id: int) -> None:
"""回滚定制简历到上一版本:从 Redis 取回滚数据写入数据库"""
rollback_k = _rollback_key(user_id, job_id)
data = await RedisManager.client.get(rollback_k)
if not data: if not data:
raise ValueError("没有可回滚的版本") raise ValueError("没有可回滚的版本")
key = f"{KEY_PREFIX}{user_id}" content = CustomizeResume.model_validate_json(data).model_dump(by_alias=True)
await RedisManager.client.set(key, data, ex=EXPIRE)
await RedisManager.client.delete(rollback_key) async with async_session_factory() as session:
result = await session.execute(select(UserJobCustomizeResume).where(
UserJobCustomizeResume.user_id == user_id, UserJobCustomizeResume.job_id == job_id))
record = result.scalar_one_or_none()
if record:
await session.execute(update(UserJobCustomizeResume).where(
UserJobCustomizeResume.id == record.id).values(content=content))
else:
session.add(UserJobCustomizeResume(id=next_id(), user_id=user_id, job_id=job_id, content=content))
await session.commit()
await RedisManager.client.delete(rollback_k)
+2 -2
View File
@@ -96,8 +96,8 @@ class JobAgentChatService:
log.warning(f"岗位简历优化[{key}]失败: {result}") log.warning(f"岗位简历优化[{key}]失败: {result}")
continue continue
self._apply_optimize_result(cr, key, result) self._apply_optimize_result(cr, key, result)
# 4. 存 Redis # 4. 存数据库
await customize_resume_store.save(user_id, cr) await customize_resume_store.save(user_id, job_id, cr)
# 5. 返回 # 5. 返回
return cr.model_dump(by_alias=True) return cr.model_dump(by_alias=True)
+4 -4
View File
@@ -126,8 +126,8 @@ class SkillGapService:
if "skills" in optimize_modules and add_skills: if "skills" in optimize_modules and add_skills:
existing = set(cr.resume.skills) existing = set(cr.resume.skills)
cr.resume.skills.extend([s for s in add_skills if s not in existing]) cr.resume.skills.extend([s for s in add_skills if s not in existing])
# 5. 存 Redis # 5. 存数据库
await customize_resume_store.save(user_id, cr) await customize_resume_store.save(user_id, job_id, cr)
@staticmethod @staticmethod
def _experience_tasks(cr: CustomizeResume, job_title: str, job_desc: str) -> list[tuple[str, str]]: def _experience_tasks(cr: CustomizeResume, job_title: str, job_desc: str) -> list[tuple[str, str]]:
@@ -161,7 +161,7 @@ class SkillGapService:
instruction: str, chat_history: list) -> dict: instruction: str, chat_history: list) -> dict:
"""AI 对话式编辑定制简历(原子化操作版)""" """AI 对话式编辑定制简历(原子化操作版)"""
# 1. 取当前定制简历 # 1. 取当前定制简历
cr_data = await customize_resume_store.get(user_id) cr_data = await customize_resume_store.get(user_id, job_id)
if not cr_data: if not cr_data:
raise ValueError("定制简历不存在,请先生成") raise ValueError("定制简历不存在,请先生成")
cr = CustomizeResume.model_validate(cr_data) cr = CustomizeResume.model_validate(cr_data)
@@ -228,7 +228,7 @@ class SkillGapService:
elif op_type == "add": elif op_type == "add":
self._apply_record_add(cr, mod_name, result) self._apply_record_add(cr, mod_name, result)
# 6. 保存(自动备份回滚) # 6. 保存(自动备份回滚)
await customize_resume_store.save(user_id, cr) await customize_resume_store.save(user_id, job_id, cr)
# 拼接更新模块标签 # 拼接更新模块标签
updated_modules = list(dict.fromkeys(op.get("module", "") for op in operations)) updated_modules = list(dict.fromkeys(op.get("module", "") for op in operations))
label = "".join(_MODULE_LABELS.get(m, m) for m in updated_modules if m) label = "".join(_MODULE_LABELS.get(m, m) for m in updated_modules if m)