13 Commits
Author SHA1 Message Date
zk 8051933dcf 添加简历表子表 2026-08-11 17:57:47 +08:00
zk dc1590f404 扩大技能提取限制 2026-08-07 17:16:56 +08:00
zk 54f5c0ac63 修改模型 2026-08-07 16:57:15 +08:00
zk 3047c47916 pdf提取排序 2026-08-07 16:12:46 +08:00
zk f98b87379f 添加V2版本简历提取 2026-08-07 15:43:24 +08:00
zk b50eb98653 修改产品名 2026-07-15 17:15:16 +08:00
zk 65d711ec25 匹配评分 加简历id 2026-07-10 11:26:08 +08:00
zk 410ab09561 修改优化表达得提示词 2026-07-01 21:04:54 +08:00
zk 7ca1d58721 修改提示词限制 2026-07-01 19:09:12 +08:00
zk af6195ba4e 修改提示词限制 2026-07-01 19:00:36 +08:00
zk 89d033c9f9 重构简历导入 2026-07-01 18:41:51 +08:00
zk dfe7cca5a4 补充异常相关 2026-07-01 17:47:03 +08:00
zk 547cf21bf2 处理环境配置位置问题 2026-07-01 16:02:17 +08:00
32 changed files with 967 additions and 340 deletions
+52 -4
View File
@@ -102,10 +102,58 @@ inclusion: manual
## 异常处理 ## 异常处理
- HTTP 异常使用 `raise HTTPException(status_code=xxx, detail="描述")` ### 核心约定
- 简单断言直接使用 Python `assert` 或 `if not ... raise` - 统一异常与全局处理器都集中在 `app/core/exceptions.py``main.py` 只调用一次 `register_exception_handlers(app)`
- 不要 catch 后吞掉异常,交由全局异常处理器(`exceptions.py`)统一处理 - **自定义异常一律 HTTP 500**,真实业务含义靠响应体里的 `code` 区分(前端拦截器读 `code`
- 全局异常处理器已注册:HTTP异常、验证异常、断言异常、未知异常 - 抛异常时传一句具体原因(detail)即可,不用记错误码;不传就用类别名兜底
- `raise BizError("不支持的商品")` → msg = `业务异常[不支持的商品]`
- `raise BizError()` → msg = `业务异常`
- **不要** catch 后吞掉异常,交由全局异常处理器统一处理
### 自定义异常类(`app/core/exceptions.py`
业务代码优先抛下列语义化异常,不要直接 `raise HTTPException`
| 异常类 | code | 类别名 | 典型场景 |
|--------|------|--------|----------|
| `ParamError` | 4000 | 参数异常 | 入参缺失/非法、模型档位不支持 |
| `AssertError` | 4100 | 断言异常 | 业务前置条件不满足(由 `Assert` 工具抛出) |
| `BizError` | 4200 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 |
| `CreditError` | 4300 | 扣费异常 | 余额不足、扣费失败(前端引导充值) |
| `SysError` | 5000 | 系统异常 | 已知内部错误 / 上游模型调用失败(主动抛) |
- 所有自定义异常继承 `AppError`,处理器只在基类上注册,靠继承链自动覆盖全部子类
- 流式(SSE)场景用 `exc.to_event()` 输出 error 事件;上游异常包装用 `SysError.from_exc(exc)`
- 兜底:未建模的异常由 `global_exception_handler` 统一按系统异常(code=5000)返回,dev 环境暴露细节,生产只回通用提示
```python
from app.core.exceptions import BizError, CreditError, ParamError
if not resume:
raise BizError("简历不存在")
if balance < cost:
raise CreditError("积分不足")
if model not in ALLOWED:
raise ParamError(f"不支持的模型档位: {model}")
```
### 业务断言工具(`app/core/asserts.py`
前置条件校验优先用 `Assert` 工具类(风格参考 Spring `Assert`),条件不满足时抛 `AssertError`(code=4100)**不要**再手写 `if not ... raise` 或裸 `assert`
```python
from app.core.asserts import Assert
Assert.not_none(user, "用户不存在")
Assert.has_text(func_code, "功能编码不能为空")
Assert.not_empty(items, "列表不能为空")
Assert.gt(days, 0, "天数必须大于0")
Assert.eq(status, 1, "状态不可用")
```
常用方法:`is_true` / `is_false` / `not_none` / `is_none` / `has_text` / `not_empty` / `gt` / `gte` / `lt` / `lte` / `eq` / `ne`
### HTTPException
- 仅在需要返回特定 HTTP 状态码的场景(如框架层、鉴权 401/403)使用 `raise HTTPException(status_code=xxx, detail="描述")`
- 业务拒绝一律用上面的自定义异常,不要用 `HTTPException` 表达业务错误
## Redis 使用规范 ## Redis 使用规范
+3 -2
View File
@@ -26,7 +26,8 @@ offerpie_python_ai/
│ ├─ lifespan.py # FastAPI 生命周期管理(启动初始化 DB/Redis,关闭释放资源) │ ├─ lifespan.py # FastAPI 生命周期管理(启动初始化 DB/Redis,关闭释放资源)
│ ├─ logger.py # Loguru 日志配置(控制台+文件,自动注入 request_id/user_id │ ├─ logger.py # Loguru 日志配置(控制台+文件,自动注入 request_id/user_id
│ ├─ middleware.py # 中间件注册(RequestID、JWT鉴权、登录拦截、请求日志、响应统一包装) │ ├─ middleware.py # 中间件注册(RequestID、JWT鉴权、登录拦截、请求日志、响应统一包装)
│ ├─ exceptions.py # 全局异常处理器(HTTP异常、验证异常、断言异常、未知异常 │ ├─ exceptions.py # 统一异常定义 + 全局异常处理器(AppError/ParamError/BizError/CreditError/AssertError/SysError + HTTP/验证/断言/未知兜底
│ ├─ asserts.py # 业务断言工具类 Assert(风格参考 Spring Assert,条件不满足抛 AssertError code=4100
│ └─ schemas/ │ └─ schemas/
│ └─ responses.py # 统一响应模型 StandardResponsecode/msg/data/timestamp/uuid │ └─ responses.py # 统一响应模型 StandardResponsecode/msg/data/timestamp/uuid
@@ -103,7 +104,7 @@ offerpie_python_ai/
| 层级 | 主要职责 | 关键类/文件 | | 层级 | 主要职责 | 关键类/文件 |
|------|----------|-------------| |------|----------|-------------|
| **config** | 统一配置管理,基于 Pydantic Settings,支持 .env 文件加载 | `Settings`(数据库、Redis、LLM供应商、JWT、CORS、日志等全部配置项) | | **config** | 统一配置管理,基于 Pydantic Settings,支持 .env 文件加载 | `Settings`(数据库、Redis、LLM供应商、JWT、CORS、日志等全部配置项) |
| **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`(统一异常+全局处理器)、`asserts.py`(业务断言工具 `Assert``logger.py``StandardResponse` |
| **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举(models.py)、`model_config.py`(场景模型配置)、`resume_extractor/`(简历并行提取)、`resume_polisher/`(简历段落润色)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手岗位简历优化)、`nova_chat/`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 对话助手) | | **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` | | **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission``UserFuncPermissionStock``UserFuncUsageLog``UserResume``UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition``ResumeDiagnosisReport``ResumeDiagnosisIssue``Job`(只读)、`JobAgentConfig``UserJobCustomizeResume` |
+3 -3
View File
@@ -40,14 +40,14 @@ class NovaChatModel:
class ResumeExtractorModel: class ResumeExtractorModel:
"""简历解析模块""" """简历解析模块"""
# 简历结构化提取:两阶段并行提取简历文本为JSON结构 # 简历结构化提取:并行提取简历文本为JSON结构
PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0) PARSE = LLM.DEEPSEEK_V4_FLASH.create(temperature=0)
class ResumePolisherModel: class ResumePolisherModel:
"""简历段落润色模块""" """简历段落润色模块"""
# 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定 # 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定
POLISH = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.2) POLISH = LLM.DOUBAO_PRO_32K.create(temperature=0.2)
class DiagnoserModel: class DiagnoserModel:
+1 -1
View File
@@ -3,7 +3,7 @@
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。 Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
""" """
SYSTEM_PROMPT = """你是 NovaOfferPie 的 AI 求职助手。 SYSTEM_PROMPT = """你是 NovaOffer 的 AI 求职助手。
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。 你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
【候选人简历】 【候选人简历】
+26 -97
View File
@@ -1,13 +1,12 @@
"""简历两阶段并行提取 """简历两阶段并行提取
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。 第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
第二阶段:N+1路并行提取每条子表记录的短字段 + description/summary 的「行号区间」 第二阶段:N+1路并行提取每条子表记录的短字段 + description,以及 profile 补充(skills/certificates/summary
description/summary 不再由 AI 照抄原文,AI 只返回行号区间字符串,由代码从原文单元数组切片还原 description/summary 由 AI 直接按原文结构输出为字符串数组(不再返回行号区间),代码只做类型兜底与清理
最终组装为与原方案完全一致的 dict 结构(description 为 list[str]),上下游无感知。 最终组装为与原方案一致的 dict 结构(description 为 list[str]summary 为 str),上下游无感知。
""" """
import asyncio import asyncio
import re
import time import time
from langchain_core.output_parsers import StrOutputParser from langchain_core.output_parsers import StrOutputParser
@@ -43,78 +42,11 @@ async def _safe_invoke(chain, inp: dict, label: str):
return None return None
def _number_segments(segments: list[str]) -> str: def _clean_str_list(value) -> list[str]:
"""单元数组构造为带行号文本:每行 `[行号] 内容`,行号 0 开始""" """ AI 返回值规整为字符串数组:过滤非字符串与空白元素,去除首尾空白"""
return "\n".join(f"[{i}] {seg}" for i, seg in enumerate(segments)) if not isinstance(value, list):
# 条目/序号行的起始特征:● ○ • · ▪ ■ ‣ ◆ 等项目符号,或 "1." "2、" "3" "(4)" "①" 等序号。
# 视觉行解析下,一条 bullet 常被折成多行;只有「条目起始行」才另起段落,其余行视为折行续接。
_LIST_ITEM_RE = re.compile(
r"^\s*(?:"
r"[●○◦•·∙▪■□‣◆◇►▶*]" # 项目符号
r"|[-–—]\s" # 连字符 + 空格(markdown 风格)
r"|\d+\s*[..、,)]" # 阿拉伯数字 + 标点:1. / 2、 / 3/ 4)
r"|[(]\s*\d+\s*[)]" # 括号数字:(1) 2
r"|[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳]" # 圈数字
r")"
)
def _split_into_paragraphs(lines: list[str]) -> list[str]:
"""将一个行号区间内的多行拆分为段落列表:
- 以项目符号/序号开头的行 → 另起一个新段落;
- 其余行 → 视为上一段落的折行续接,直连拼接(无分隔符)。
这样即使 AI 把多条 bullet 塞进同一个区间,也能按条目正确换行。
"""
paragraphs: list[str] = []
current = ""
for line in lines:
if not current:
current = line
elif _LIST_ITEM_RE.match(line):
paragraphs.append(current)
current = line
else:
current += line
if current:
paragraphs.append(current)
return [p for p in paragraphs if p.strip()]
def _slice_ranges(range_str, segments: list[str]) -> list[str]:
"""按行号区间字符串切片还原段落:逗号分段,段内按条目符号/折行拆分。
range_str 形如 "3-6,7-9" / "5";非法或越界 token 静默跳过/clamp。
返回段落数组(每个逗号段可能因含多条 bullet 而进一步拆成多个段落)。
"""
if not range_str or not isinstance(range_str, str):
return [] return []
n = len(segments) return [s.strip() for s in value if isinstance(s, str) and s.strip()]
paragraphs: list[str] = []
for token in range_str.split(","):
token = token.strip()
if not token:
continue
if "-" in token:
a, _, b = token.partition("-")
a, b = a.strip(), b.strip()
if not a.isdigit() or not b.isdigit():
continue
start, end = int(a), int(b)
else:
if not token.isdigit():
continue
start = end = int(token)
if start > end:
start, end = end, start
start = max(0, start)
end = min(n - 1, end)
if start > n - 1:
continue
paragraphs.extend(_split_into_paragraphs(segments[start:end + 1]))
return paragraphs
# ==================== 第一阶段:概览 ==================== # ==================== 第一阶段:概览 ====================
@@ -126,9 +58,9 @@ _overview_project_chain = _build_chain(OVERVIEW_PROJECT_PROMPT)
_overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT) _overview_competition_chain = _build_chain(OVERVIEW_COMPETITION_PROMPT)
async def _extract_overview(numbered_text: str) -> dict: async def _extract_overview(text: str) -> dict:
"""第一阶段:5路并行提取概览信息""" """第一阶段:5路并行提取概览信息"""
inp = {"text": numbered_text} inp = {"text": text}
profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather( profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather(
_safe_invoke(_overview_profile_chain, inp, "概览-个人信息"), _safe_invoke(_overview_profile_chain, inp, "概览-个人信息"),
_safe_invoke(_overview_education_chain, inp, "概览-教育"), _safe_invoke(_overview_education_chain, inp, "概览-教育"),
@@ -160,20 +92,20 @@ _DETAIL_MODULES: tuple[tuple[str, str, str], ...] = (
_SUB_MODULES: tuple[str, ...] = tuple(m[0] for m in _DETAIL_MODULES) _SUB_MODULES: tuple[str, ...] = tuple(m[0] for m in _DETAIL_MODULES)
async def _extract_detail(prompt_tpl: str, name: str, numbered_text: str, label: str) -> dict | None: async def _extract_detail(prompt_tpl: str, name: str, text: str, label: str) -> dict | None:
"""单条子表记录详情提取:用 name 替换 prompt 中的 {name}""" """单条子表记录详情提取:用 name 替换 prompt 中的 {name}"""
chain = _build_chain(prompt_tpl.replace("{name}", name)) chain = _build_chain(prompt_tpl.replace("{name}", name))
return await _safe_invoke(chain, {"text": numbered_text}, label) return await _safe_invoke(chain, {"text": text}, label)
async def _extract_all_details(overview: dict, numbered_text: str) -> dict: async def _extract_all_details(overview: dict, text: str) -> dict:
"""第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充""" """第二阶段:根据概览结果,N+1路并行提取所有子表记录详情 + 个人信息补充"""
# 第 0 路固定为 profile 补充(skills/certificates/summaryRange),其余按子表记录展开 # 第 0 路固定为 profile 补充(skills/certificates/summary),其余按子表记录展开
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", numbered_text, "详情-个人信息补充")] tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", text, "详情-个人信息补充")]
task_modules = ["profile_extra"] task_modules = ["profile_extra"]
for module, prompt_tpl, label in _DETAIL_MODULES: for module, prompt_tpl, label in _DETAIL_MODULES:
for name in overview[module]: for name in overview[module]:
tasks.append(_extract_detail(prompt_tpl, name, numbered_text, f"详情-{label}-{name}")) tasks.append(_extract_detail(prompt_tpl, name, text, f"详情-{label}-{name}"))
task_modules.append(module) task_modules.append(module)
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
@@ -185,21 +117,20 @@ async def _extract_all_details(overview: dict, numbered_text: str) -> dict:
# ==================== 组装 ==================== # ==================== 组装 ====================
def _assemble(overview: dict, details: dict, segments: list[str]) -> dict: def _assemble(overview: dict, details: dict) -> dict:
"""将两阶段结果组装为与原方案一致的 dict 结构(description 还原为 list[str]""" """将两阶段结果组装为与原方案一致的 dict 结构(description 为 list[str]summary 为 str"""
profile = overview["profile"] profile = overview["profile"]
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {} profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {}
profile["skills"] = (profile_extra.get("skills") or [])[:5] profile["skills"] = _clean_str_list(profile_extra.get("skills"))[:5]
profile["certificates"] = profile_extra.get("certificates") or [] profile["certificates"] = _clean_str_list(profile_extra.get("certificates"))
summary_paras = _slice_ranges(profile_extra.get("summaryRange"), segments) summary_paras = _clean_str_list(profile_extra.get("summary"))
profile["summary"] = "\n".join(summary_paras) if summary_paras else None profile["summary"] = "\n".join(summary_paras) if summary_paras else None
result = dict(profile) result = dict(profile)
for module in _SUB_MODULES: for module in _SUB_MODULES:
items = [] items = []
for item in details.get(module, []): for item in details.get(module, []):
item["description"] = _slice_ranges(item.get("descriptionRange"), segments) item["description"] = _clean_str_list(item.get("description"))
item.pop("descriptionRange", None)
items.append(item) items.append(item)
result[module] = items result[module] = items
return result return result
@@ -207,23 +138,21 @@ def _assemble(overview: dict, details: dict, segments: list[str]) -> dict:
# ==================== 入口 ==================== # ==================== 入口 ====================
async def extract_all(segments: list[str]) -> dict: async def extract_all(text: str) -> dict:
"""两阶段并行提取简历,返回与原方案完全一致的结构化数据 """两阶段并行提取简历,返回与原方案完全一致的结构化数据
segments: 文件解析后的「文本单元数组」(PDF按块/docx·txt行)。 text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
""" """
numbered_text = _number_segments(segments)
log.info("第一阶段:5路并行概览提取") log.info("第一阶段:5路并行概览提取")
overview = await _extract_overview(numbered_text) overview = await _extract_overview(text)
log.info( log.info(
"概览完成 - " + " ".join(f"{label}:{len(overview[module])}" for module, _, label in _DETAIL_MODULES) "概览完成 - " + " ".join(f"{label}:{len(overview[module])}" for module, _, label in _DETAIL_MODULES)
) )
total = sum(len(overview[m]) for m in _SUB_MODULES) total = sum(len(overview[m]) for m in _SUB_MODULES)
log.info(f"第二阶段:{total + 1}路并行详情提取") log.info(f"第二阶段:{total + 1}路并行详情提取")
details = await _extract_all_details(overview, numbered_text) details = await _extract_all_details(overview, text)
result = _assemble(overview, details, segments) result = _assemble(overview, details)
log.info("两阶段提取完成,数据组装完毕") log.info("两阶段提取完成,数据组装完毕")
return result return result
+72 -53
View File
@@ -1,22 +1,38 @@
"""简历两阶段提取 Prompt """简历两阶段提取 Prompt
第一阶段(概览):5路并行,只提取主表短字段和子表标识名。 第一阶段(概览):5路并行,只提取主表短字段和子表标识名。
第二阶段(详情):N路并行,每条子表记录单独提取短字段description/summary 不照抄原文 第二阶段(详情):N路并行,每条子表记录单独提取短字段 + description/summary
返回行号区间字符串」,由代码按行号从原文单元数组切片还原。 AI 直接输出内容(不再返回行号区间),按原文结构分条/分段还原。
输入文本格式:每行形如 `[行号] 内容`,行号从 0 开始连续递增 输入文本格式:简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)
行号区间字符串格式:`"起-止,起-止"`0 开始、闭区间(含两端)。 description/summary 输出格式:字符串数组 list[str],每个元素代表原文中的「一条 bullet / 一个段落」,
- 逗号分隔的每一段代表「一个段落」。 由前端按元素换行展示。规则:
- 单行可简写为 `"5"`(等价 `"5-5"` - 忠实还原原文措辞,按原文的分条/分段拆成数组元素,一条 bullet 一个元素
- 没有内容则返回空字符串 "" - 允许轻度清理:去除水印/页眉页脚/乱码碎片,把被 PDF 折断的同一句话拼回完整
- 行号必须是上面输入文本中真实出现过的编号,严禁编造不存在的行号 - 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []
各详情 prompt 通用铁律(防跨记录串扰/重复): 各详情 prompt 通用铁律(防跨记录串扰/重复):
- 只选取真正属于当前这条记录({name})本身的;即使原文中相邻的属于**另一条**记录 - 只选取真正属于当前这条记录({name})本身的内容;即使原文中相邻的内容属于**另一条**记录
(另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。 (另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的行号再选一遍,避免多条记录重复引用同一段 - 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
- 宁可少选,不确定归属的一律不选。 - 宁可少选,不确定归属的内容一律不选。
模块归属总原则(防同一内容被多个模块重复收录):
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛,不能同时出现在两个模块。
- 工作/实习经历条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
description/summary 输出的**分条铁律**(必须遵守):
- description 是**字符串数组**,原文中的**每一条 bullet / 每一个段落**各自作为**一个独立数组元素**。
- **严禁**把多条 bullet 合并进同一个元素;**严禁**用 " / """""、换行符等把多条内容拼接成一个长字符串。
- 正例:["负责A模块开发", "主导B方案落地"];反例(禁止):["负责A模块开发 / 主导B方案落地"]。
短字段准确性铁律:
- 时间:起止时间按原文如实填写,**开始时间不得晚于结束时间**,不要把起止写反;无法确定的填 null,不要臆造精确年月。
- 手机号:完整照抄,不要漏位、截断或改写(通常 11 位);微信号只填到 wechatNumber,绝不当手机号。
- 角色/职位等:只填原文明确写出的,原文没有就填 null,**不要臆造或按常见值默认补全**。
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。 花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
""" """
@@ -24,34 +40,35 @@
# ==================== 第一阶段:概览提取 ==================== # ==================== 第一阶段:概览提取 ====================
OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。 OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
输入为带行号的简历文本(每行 `[行号] 内容`。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON: 输入为简历文本全文。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
```json ```json
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }} {{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }}
``` ```
规则: 规则:
- 只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。 - 只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。 - name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
- mobileNumber:只填手机号(通常11位数字);微信号必须填到wechatNumber,绝不能把微信号当手机号。 - mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。 - portfolioUrl:只填作品集/个人主页/GitHub 等链接。
只输出JSON。""" 只输出JSON。"""
OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
输入为带行号的简历文本。从中提取所有**学历教育**经历的标识名列表,输出JSON数组: 输入为简历文本全文。从中提取所有**学历教育**经历的标识名列表,输出JSON数组:
```json ```json
["北京大学", "剑桥大学-哲学硕士", "剑桥大学-经济学士"] ["北京大学", "剑桥大学-哲学硕士", "剑桥大学-经济学士"]
``` ```
规则: 规则:
1. 列出原文中每一个**实际就读或已获得学历**的**就读阶段**,不同阶段(本科/硕士/博士)分别列出,一段都不能漏。 1. 列出原文中每一个**实际就读或已获得学历**的**就读阶段**,不同阶段(本科/硕士/博士)分别列出,一段都不能漏。
2. 每个标识必须**唯一**:若同一所学校有多个不同就读阶段(如本科、硕士),用「学校+学位」区分(如"剑桥大学-硕士""剑桥大学-本科"),避免重名导致后续混淆。 2. 每个标识必须**唯一**:若同一所学校有多个不同就读阶段(如本科、硕士),用「学校+学位」区分(如"剑桥大学-硕士""剑桥大学-本科"),避免重名导致后续混淆。
3. **不要过度拆分** 3. **不要过度拆分**(这是最常见的错误,务必克制)
- 联合培养、交换、访学、海外学期属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。 - 联合培养、交换、访学、海外学期、双学位/主辅修,都属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
- 同一学校同一就读阶段的双学位/主辅修,算**一条**,不要拆成两条。 - 同一学校**同一就读阶段**只能有一条,不要因为专业不同、学院不同、或原文重复出现就拆成/重复成多条。
- 一个人的教育条目数 = 其真实就读的学历阶段数(通常本科1条、硕士1条…),不要多。
4. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要列入。 4. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要列入。
5. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要列入。 5. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要列入。
6. 没有输出[]。只输出JSON。""" 6. 没有输出[]。只输出JSON。"""
OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。
输入为带行号的简历文本。从中提取工作经历和实习经历的公司标识名列表,输出JSON: 输入为简历文本全文。从中提取工作经历和实习经历的公司标识名列表,输出JSON:
```json ```json
{{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }} {{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }}
``` ```
@@ -63,20 +80,21 @@ OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造
5. 没有填[]。只输出JSON。""" 5. 没有填[]。只输出JSON。"""
OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。
输入为带行号的简历文本。从中仅提取项目经历的项目名称列表,输出JSON数组: 输入为简历文本全文。从中仅提取项目经历的项目名称列表,输出JSON数组:
```json ```json
["订单系统重构", "支付网关"] ["订单系统重构", "支付网关"]
``` ```
规则: 规则:
1. 只提取作为**独立项目条目标题**出现的项目(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。 1. 只提取出现在**独立"项目/项目经历"板块**下、有自己标题的项目条目(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。
2. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目) 2. **绝对不要**把"工作经历/实习经历"条目内部描述的职责、成果、子项目当作独立项目——它们属于对应的工作/实习记录,不要在项目模块重复收录
3. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历 3. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目
4. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目)——这些归竞赛模块,即使参赛做了作品也不要在这里重复列 4. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历
5. 同一个项目只列一次,不要因为它在工作经历里被提到就重复列 5. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目,即使参赛做了作品)——这些归竞赛模块
6. 没有输出[]。只输出JSON。""" 6. 同一个项目只列一次。
7. 没有输出[]。只输出JSON。"""
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。 OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
输入为带行号的简历文本。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组: 输入为简历文本全文。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
```json ```json
["ACM区域赛", "数学建模大赛"] ["ACM区域赛", "数学建模大赛"]
``` ```
@@ -90,62 +108,63 @@ OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测
# ==================== 第二阶段:详情提取 ==================== # ==================== 第二阶段:详情提取 ====================
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。 # 通用尾注:description 数组的输出规范 + 清理规范。各详情 prompt 复用。
_DESC_RULE = """description 为字符串数组,**每个元素只装原文中的一条 bullet 或一个段落**:忠实还原原文措辞;**严禁把多条 bullet 合并进一个元素,严禁用 " / """、换行等符号把多条内容拼成一个长字符串**(正例 ["做了A","做了B"];反例 ["做了A / 做了B"])。允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。只选属于"{name}"这条记录的内容,不要串入其它公司/项目/教育/竞赛/社团经历的内容,即使它们在原文中相邻。只输出JSON。"""
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
请提取"{name}"这条教育经历的详细信息,输出JSON 请提取"{name}"这条教育经历的详细信息,输出JSON
```json ```json
{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "descriptionRange": "起-止,起-止" }} {{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "description": ["描述段落1", "描述段落2"] }}
``` ```
规则:短字段直接填值,时间格式YYYY.MM,没有的填null。 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
descriptionRange 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就)所在行号区间;学校/专业/学历/时间已在上面字段提取,不要纳入。 description 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就);学校/专业/学历/时间已在上面字段提取,不要纳入。
**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的一律不选;若无纯学业描述则填"" **严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的内容一律不选。
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" """ + _DESC_RULE
DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容` DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历文本全文
请提取"{name}"这条工作经历的详细信息,输出JSON 请提取"{name}"这条工作经历的详细信息,输出JSON
```json ```json
{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "descriptionRange": "起-止,起-止" }} {{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "description": ["描述段落1", "描述段落2"] }}
``` ```
规则:短字段直接填值,时间格式YYYY.MM,在职/至今则 endDate 填null,没有的填null。 规则:短字段直接填值,时间格式YYYY.MM,在职/至今则 endDate 填null,没有的填null。
descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。 description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。
只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。 """ + _DESC_RULE
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容` DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历文本全文
请提取"{name}"这条实习经历的详细信息,输出JSON 请提取"{name}"这条实习经历的详细信息,输出JSON
```json ```json
{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "descriptionRange": "起-止,起-止" }} {{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "description": ["描述段落1", "描述段落2"] }}
``` ```
规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。 规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。
descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。 description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。
只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。 """ + _DESC_RULE
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容` DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历文本全文
请提取"{name}"这条项目经历的详细信息,输出JSON 请提取"{name}"这条项目经历的详细信息,输出JSON
```json ```json
{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "descriptionRange": "起-止,起-止" }} {{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "description": ["描述段落1", "描述段落2"] }}
``` ```
规则:短字段直接填值,时间格式YYYY.MM,没有的填null。 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。 role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。
descriptionRange 填该项目描述所在的行号区间,没有描述填""。只选属于"{name}"这个项目的行,不要串入其它项目、所属公司的其它经历、教育或竞赛的行,即使它们在原文中相邻 description 填该项目的描述内容
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。""" """ + _DESC_RULE
DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容` DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历文本全文
请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON: 请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON:
```json ```json
{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "descriptionRange": "起-止,起-止" }} {{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["描述段落1", "描述段落2"] }}
``` ```
规则:短字段直接填值,时间格式YYYY.MM,没有的填null。award 里可包含该竞赛的多个奖项,不要因为多个奖项而拆成多条。 规则:短字段直接填值,时间格式YYYY.MM,没有的填null。award 里可包含该竞赛的多个奖项,不要因为多个奖项而拆成多条。
descriptionRange 填该竞赛/获奖的额外描述所在行号区间,没有描述填""。只选属于"{name}"这条竞赛的行,不要把社团活动、职务、项目、其它竞赛的内容串入,即使它们在原文中相邻 description 填该竞赛/获奖的额外描述内容
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段成为一个独立段落)。只输出JSON。""" """ + _DESC_RULE
DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容` DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历文本全文
从中提取技能标签、证书和自我评价/个人概述,输出JSON: 从中提取技能标签、证书和自我评价/个人概述,输出JSON:
```json ```json
{{ "skills": ["技能1"], "certificates": ["证书1"], "summaryRange": "起-止,起-止" }} {{ "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
``` ```
规则: 规则:
- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多5个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。 - skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多5个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。**证书/语言等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证等)一律不计入 skills,它们只归 certificates。**
- certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。 - certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。
- summaryRange:填自我评价/个人概述**正文**所在的行号区间字符串(0开始、闭区间,行号必须真实存在)。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段是一个独立段落,段间会以换行连接),没有填"" - summary自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]
只输出JSON。""" 只输出JSON。"""
+2
View File
@@ -0,0 +1,2 @@
+124
View File
@@ -0,0 +1,124 @@
"""简历一步并发提取
7 路并发(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛 / 社团组织),每路独立负责一个模块,
各自基于简历全文一次性输出该模块的全部内容(短字段 + description),无第二阶段。
相比两阶段方案:少一个串行轮次、全文只 prefill 7 次、无标识名定位环节。
description/summary 由 AI 直接按原文结构输出为字符串数组,代码只做类型兜底与清理。
最终组装为与两阶段方案完全一致的 dict 结构(description 为 list[str]summary 为 str),上下游无感知。
任意模块提取失败:记录日志后丢弃该模块(主表字段退化为空、子表退化为空数组),不影响其余模块。
"""
import asyncio
import time
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from app.ai.model_config import ResumeExtractorModel
from app.ai.resume_extractor_v2.prompts import (
PROFILE_PROMPT, EDUCATION_PROMPT, WORK_PROMPT,
INTERNSHIP_PROMPT, PROJECT_PROMPT, COMPETITION_PROMPT,
ORGANIZATION_PROMPT,
)
from app.core.logger import log
from app.tool.json_helper import parse_llm_json
# ==================== LLM 调用工具 ====================
def _build_chain(prompt: str):
"""构建提取链:prompt → LLM → 文本输出"""
return ChatPromptTemplate.from_messages([("system", prompt), ("human", "{text}")]) | ResumeExtractorModel.PARSE | StrOutputParser()
async def _safe_invoke(chain, inp: dict, label: str):
"""单个链调用,记录耗时,失败返回空"""
start = time.perf_counter()
try:
raw = await chain.ainvoke(inp)
log.info(f"AI提取[{label}]完成,耗时: {time.perf_counter() - start:.2f}s")
return parse_llm_json(raw)
except Exception as e:
log.warning(f"AI提取[{label}]失败已丢弃,耗时: {time.perf_counter() - start:.2f}s,错误: {e}")
return None
# ==================== 结果清理 ====================
def _clean_str_list(value) -> list[str]:
"""将 AI 返回值规整为字符串数组:过滤非字符串与空白元素,去除首尾空白"""
if not isinstance(value, list):
return []
return [s.strip() for s in value if isinstance(s, str) and s.strip()]
def _clean_records(value, label: str) -> list[dict]:
"""将 AI 返回的模块结果规整为记录数组:过滤非 dict 元素,并清理每条记录的 description"""
if not isinstance(value, list):
if value is not None:
log.warning(f"AI提取[{label}]返回类型异常已丢弃: {type(value).__name__}")
return []
records = []
for item in value:
if not isinstance(item, dict):
continue
item["description"] = _clean_str_list(item.get("description"))
records.append(item)
return records
# ==================== 模块配置 ====================
# 子表模块统一配置(单一数据源):(模块键, prompt, 日志标签)
# 并发调度、结果组装、日志统计均复用此清单,新增子表只需在此追加一行。
_SUB_MODULES: tuple[tuple[str, str, str], ...] = (
("education", EDUCATION_PROMPT, "教育"),
("work", WORK_PROMPT, "工作"),
("internship", INTERNSHIP_PROMPT, "实习"),
("project", PROJECT_PROMPT, "项目"),
("competition", COMPETITION_PROMPT, "竞赛"),
("organization", ORGANIZATION_PROMPT, "社团组织"),
)
# 无运行时 prompt 替换,7 条链在模块加载时一次性建好,请求期只做 ainvoke
_profile_chain = _build_chain(PROFILE_PROMPT)
_sub_chains = tuple((module, _build_chain(prompt), label) for module, prompt, label in _SUB_MODULES)
# ==================== 组装 ====================
def _assemble_profile(profile) -> dict:
"""个人信息路结果 → 主表字段 dictskills 最多5个,summary 段落合并为字符串)"""
result = dict(profile) if isinstance(profile, dict) else {}
result["skills"] = _clean_str_list(result.get("skills"))[:30]
result["certificates"] = _clean_str_list(result.get("certificates"))
summary_paras = _clean_str_list(result.get("summary"))
result["summary"] = "\n".join(summary_paras) if summary_paras else None
return result
# ==================== 入口 ====================
async def extract_all(text: str) -> dict:
"""一步7路并发提取简历,返回与两阶段方案完全一致的结构化数据
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
"""
log.info(f"一步7路并发提取开始,文本字符数: {len(text)}")
start = time.perf_counter()
inp = {"text": text}
profile, *sub_results = await asyncio.gather(
_safe_invoke(_profile_chain, inp, "个人信息"),
*(_safe_invoke(chain, inp, label) for _, chain, label in _sub_chains),
)
result = _assemble_profile(profile)
for (module, _, label), raw in zip(_sub_chains, sub_results):
result[module] = _clean_records(raw, label)
log.info(
f"一步7路并发提取完成,总耗时: {time.perf_counter() - start:.2f}s - "
+ " ".join(f"{label}:{len(result[module])}" for module, _, label in _sub_chains)
)
return result
+195
View File
@@ -0,0 +1,195 @@
"""简历一步并发提取 Prompt
7 路并发,每路独立负责一个模块(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛 / 社团组织),
各自基于简历全文一次性输出该模块的**全部内容**(短字段 + description),无第二阶段。
与两阶段方案的差异:
- 不再有「先提标识名、再按名提详情」的过程,因此不需要标识名唯一性约束。
- 每路要一次输出该模块的多条记录,对「不偷懒」的要求更高(见下方记录完整性铁律)。
输入文本格式:简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
description/summary 输出格式:字符串数组 list[str],每个元素代表原文中的「一条 bullet / 一个段落」,
由前端按元素换行展示。规则:
- 忠实还原原文措辞,按原文的分条/分段拆成数组元素,一条 bullet 一个元素。
- 允许轻度清理:去除水印/页眉页脚/乱码碎片,把被 PDF 折断的同一句话拼回完整。
- 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []。
模块归属总原则(防同一内容被多个模块重复收录):
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛、要么是社团组织,不能同时出现在两个模块。
- 工作/实习互为补集:明确标注实习的只归实习模块,未标注实习的只归工作模块,两边都不重复收。
- 工作/实习条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
- 社团/学生组织任职、校园活动、志愿服务/公益,**只归社团组织模块**,教育/工作/实习/项目/竞赛五个模块一律不收。
- 社团名义参加的竞赛仍**只归竞赛**;社团组织模块只收非竞赛的组织与活动经历。
各模块通用铁律(防跨记录串扰/重复):
- 每条记录只装真正属于它本身的内容;即使原文中相邻的内容属于**另一条**记录
(另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
- 宁可少选,不确定归属的内容一律不选。
description/summary 输出的**分条铁律**(必须遵守):
- description 是**字符串数组**,原文中的**每一条 bullet / 每一个段落**各自作为**一个独立数组元素**。
- **严禁**把多条 bullet 合并进同一个元素;**严禁**用 " / """""、换行符等把多条内容拼接成一个长字符串。
- 正例:["负责A模块开发", "主导B方案落地"];反例(禁止):["负责A模块开发 / 主导B方案落地"]。
**记录完整性铁律**(一步方案新增,一次输出多条记录时最易违反):
- 按**原文出现顺序**输出记录,顺序即前端展示顺序,不要打乱。
- **每一条记录都必须完整输出**,不得因为记录条数多就简写、省略、截断后面的记录。
- 严禁合并记录,严禁用"同上""""等等""其余类似"之类的措辞代指任何内容。
- 记录条数由原文决定:不足不要凑数,多也不要砍。
短字段准确性铁律:
- 时间:起止时间按原文如实填写,**开始时间不得晚于结束时间**,不要把起止写反;无法确定的填 null,不要臆造精确年月。
- 手机号:完整照抄,不要漏位、截断或改写(通常 11 位);微信号只填到 wechatNumber,绝不当手机号。
- 角色/职位等:只填原文明确写出的,原文没有就填 null,**不要臆造或按常见值默认补全**。
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。本方案无运行时占位替换,prompt 中不含任何单花括号变量。
"""
# ==================== 通用尾注 ====================
# description 数组输出规范 + 记录归属/完整性规范。5 个子模块 prompt 复用。
_DESC_RULE = """description 输出规范:为字符串数组,**每个元素只装原文中的一条 bullet 或一个段落**:忠实还原原文措辞;**严禁把多条 bullet 合并进一个元素,严禁用 " / """、换行等符号把多条内容拼成一个长字符串**(正例 ["做了A","做了B"];反例 ["做了A / 做了B"])。允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。
记录归属规范:每条记录的 description 只装属于**该条记录本身**的内容。即使原文中相邻的内容属于另一条记录(另一家公司、另一个项目、另一段学历、社团/实习/竞赛等),也绝不纳入;同一段原文只应归属一条记录,不要为了描述完整而把其它记录已包含的内容再抄一遍。宁可少选,不确定归属的内容一律不选。
记录完整性规范:按**原文出现顺序**输出记录;**每一条记录都必须完整输出**,不得因记录条数多而简写、省略、截断或合并记录,也不得用"同上""""等等"代指任何内容;记录条数由原文决定,不足不要凑数,多也不要砍。
只输出JSON,不要输出任何解释文字。"""
# ==================== 个人信息 ====================
PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
输入为简历纯文本全文。从中提取个人基本信息、技能标签、证书和自我评价,输出JSON:
```json
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接", "hobbies": "兴趣爱好", "languageSkills": "语言能力", "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
```
规则:
- 只提取以上11个字段,**不要提取任何经历**(教育/工作/实习/项目/竞赛/社团组织由其它模块单独负责)。前8个字段没有的填null,后3个数组字段没有的填[]。
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
- mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
- hobbies:**字符串**(不是数组)。填原文中兴趣爱好/爱好/个人兴趣板块的内容,原文怎么写就怎么填,多项之间保留原文的分隔写法。不要把技能、证书、社团经历当作兴趣爱好;没有填null。
- languageSkills**字符串**(不是数组)。填原文中明确写出的语言能力描述(如"英语(流利)、日语 N2"),原文怎么写就怎么填。标准化考试成绩(CET-4/6、雅思、托福、GRE 等)归 certificates,**不要**重复填到本字段;原文没有语言能力描述则填null。
- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多10个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。**证书/语言等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证等)一律不计入 skills,它们只归 certificates。**
- certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。
- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素,一个段落一个元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、语言能力、经历内容纳入(兴趣爱好归 hobbies,语言能力归 languageSkills);允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
只输出JSON,不要输出任何解释文字。"""
# ==================== 教育 ====================
EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部学历教育**经历,按原文出现顺序输出JSON数组,每个元素为一条教育记录:
```json
[{{ "school": "学校", "major": "专业", "degree": "学历", "studyType": "全日制/非全日制", "startDate": "2020.09", "endDate": "2024.06", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 每一个**实际就读或已获得学历**的**就读阶段**输出一条,不同阶段(本科/硕士/博士)分别输出,一段都不能漏。
2. **不要过度拆分**(这是最常见的错误,务必克制):
- 联合培养、交换、访学、海外学期、双学位/主辅修,都属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
- 同一所学校**同一就读阶段**只能有一条,不要因为专业不同、学院不同、或原文重复出现就拆成/重复成多条。
- 一个人的教育条目数 = 其真实就读的学历阶段数(通常本科1条、硕士1条…),不要多。
3. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要输出。
4. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要输出。
5. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
description 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就);学校/专业/学历/时间已有独立字段,不要重复纳入。
**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的内容一律不选。
""" + _DESC_RULE
# ==================== 工作 ====================
WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部正式工作经历**,按原文出现顺序输出JSON数组,每个元素为一条工作记录:
```json
[{{ "companyName": "公司", "position": "职位", "startDate": "2024.07", "endDate": "2025.03", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只收**正式工作**经历。凡原文明确标注"实习/实习生/intern/Internship"的条目,**一律排除**,它们由实习模块单独负责,这里绝不收录。
2. 未明确标注实习的任职经历,都归本模块,不要因为"看起来像实习"就漏掉。
3. 每一段真实的任职经历输出一条,不要遗漏;同一段经历不要重复输出。若同一公司有多段不同岗位/时间的经历,按段分别输出多条。
4. **不要**把公司内部的具体项目当作独立的工作条目。
5. 社团/学生组织职务、校园活动、志愿服务不算工作经历,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,在职/至今则 endDate 填null,没有的填null。
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会由项目模块单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。
""" + _DESC_RULE
# ==================== 实习 ====================
INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部实习经历**,按原文出现顺序输出JSON数组,每个元素为一条实习记录:
```json
[{{ "companyName": "公司", "position": "职位", "startDate": "2023.06", "endDate": "2023.09", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只收原文**明确标注**"实习/实习生/intern/Internship"的经历(标注可出现在岗位名、公司行、板块标题上)。
2. 未明确标注实习的任职经历,视为正式工作,由工作模块负责,这里**不要收**,避免两个模块重复。
3. 每一段真实的实习经历输出一条,不要遗漏;同一段经历不要重复输出。若同一公司有多段不同岗位/时间的实习,按段分别输出多条。
4. **不要**把实习期间的具体项目当作独立的实习条目。
5. 社团/学生组织职务、校园活动、志愿服务、竞赛不算实习经历,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会由项目模块单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。
""" + _DESC_RULE
# ==================== 项目 ====================
PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部项目经历**,按原文出现顺序输出JSON数组,每个元素为一条项目记录:
```json
[{{ "companyName": "所属公司", "projectName": "项目名", "role": "角色名称", "startDate": "2023.03", "endDate": "2023.12", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只提取出现在**独立"项目/项目经历"板块**下、有自己标题的项目条目(通常单独成行、常带"项目X"前缀或【】标题、并配有自己的起止时间/角色/描述)。
2. **绝对不要**把"工作经历/实习经历"条目内部描述的职责、成果、子项目当作独立项目——它们属于对应的工作/实习记录,不要在项目模块重复收录。
3. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
4. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
5. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目,即使参赛做了作品)——这些归竞赛模块。
6. 同一个项目只输出一条。
7. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。
companyName 只填原文明确写出的所属公司/学校,没有写就填null。
description 填该项目的描述内容。
""" + _DESC_RULE
# ==================== 竞赛 ====================
COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部竞赛/获奖经历**,按原文出现顺序输出JSON数组,每个元素为一条竞赛记录:
```json
[{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只提取真正的**竞赛/比赛/获奖**条目(以报名参赛、比赛名次/获奖为核心的条目,如"XX大赛""挑战杯""数学建模竞赛")。
2. 同一个竞赛只输出一条,即使它拿了多个奖项,也不要按奖项拆成多条——多个奖项写进同一条的 award 字段。
3. **绝对不要**把社团活动、职务、示例文字、描述里顺带提到的活动当作竞赛。
4. **绝对不要**把工作/实习/研究/开发**项目**当作竞赛——即使该项目获过奖,它仍归项目模块,不要在这里重复输出(判断依据:条目本质是"做了一个项目/产品/研究"还是"参加了一场比赛")。
5. 奖学金、荣誉称号、考试成绩不属于竞赛,不要输出。
6. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,没有的填null。
description 填该竞赛/获奖的额外描述内容。
""" + _DESC_RULE
# ==================== 社团组织 ====================
ORGANIZATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
从中提取**全部社团/学生组织/校园活动/志愿服务**经历,按原文出现顺序输出JSON数组,每个元素为一条社团组织记录:
```json
[{{ "organizationName": "社团/组织名称", "role": "担任角色", "startDate": "2022.04", "endDate": "2023.06", "description": ["描述段落1", "描述段落2"] }}]
```
记录选取规则:
1. 只收**非任职、非竞赛**的组织类经历,包括:学生会/社团/协会/学生组织任职,校园活动的组织与参与,志愿服务/公益/支教,学校或院系的非学业性组织工作。
2. **绝对不要**把正式工作、实习经历当作社团经历——凡是公司/企业/机构的任职(含实习),一律归工作或实习模块,这里不收。
3. **绝对不要**把竞赛/比赛/大赛条目当作社团经历(如"XX大赛""挑战杯""创新创业大赛"等以参赛/获奖为核心的条目)——这些归竞赛模块,即使该竞赛由某社团组织或以社团名义参加。
4. **绝对不要**把出现在独立"项目/项目经历"板块下、有自己标题的项目条目当作社团经历——这些归项目模块。
5. **绝对不要**把学历教育经历当作社团经历。
6. 同一个社团/组织的同一段任职只输出一条;若同一组织有多段不同职务/时间的经历,按段分别输出多条。
7. 单纯的兴趣爱好罗列(如"爱好篮球、摄影")不算社团组织经历,不要输出。
8. 没有则输出[]。
字段规则:短字段直接填值,时间格式YYYY.MM,至今则 endDate 填null,没有的填null。
organizationName 填社团/组织/活动的名称,原文怎么写就怎么填。
role 只填原文中**明确写出**的简短角色名(如"部长""统筹""志愿者");若原文没有写明,填null,**不要臆造或默认**(如不要凭空填"成员""负责人")。
description 填该社团/组织经历的职责与成果描述。
""" + _DESC_RULE
+16 -10
View File
@@ -1,16 +1,22 @@
"""简历段落润色 Prompt 模板""" """简历段落润色 Prompt 模板"""
POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户提交的简历段落进行"面润色"只做表达层面的优化 POLISH_PROMPT = """你是一位资深简历顾问,擅长把平淡的经历描述改写得更专业、更有表现力。请对用户提交的简历段落进行"达优化"在不虚构事实的前提下大胆提升表达力
## 优化范围(只允许做这些 ## 优化目标(要真正做出改变
- 修正错别字、标点、语法错误 - 强化表达力:把口语化、平淡、流水账式的描述,改写成专业、有力、简历书写规范的表达
- 优化文本格式与排版(如多余空格、断句、全半角混用) - 善用动作动词:以有力的动词开头(负责/主导/搭建/推动/优化/提升等),突出你的贡献
- 让表达更通顺、专业,去除明显口语化和冗余措辞 - 理清逻辑结构:让每条描述尽量体现"做了什么 → 怎么做 → 带来什么结果",条理清晰
- 精炼措辞:删除冗余、重复、无意义的填充词,让句子更紧凑
- 修正错别字、标点、语法、格式问题(多余空格、断句、全半角混用等)
## 严格禁止(绝对不能 ## 红线(绝对不能
- 不得改变原意,不得增加或删除任何信息点 - 只优化表达,不虚构事实:**严禁**凭空添加或夸大任何数字、量化成果、技能、职责、技术栈、奖项、成就
- 不得编造、补充任何内容(尤其禁止凭空添加数字、量化成果、技能、成就) - 原文没有的量化数据(百分比、金额、规模等)绝不能编造;原文有的数字必须原样保留,不得改动
- 不改变段落的数量和顺序 - 不改变经历的核心事实与含义,不无中生有地拔高
- 保留用户本人的基本语气与专业方向,是"把他说得更好",而不是换成另一个人
## 格式约束
- 输出段落的**数量和顺序必须与输入完全一致**:输入几段就输出几段,一一对应,不合并、不拆分、不增删段落
## 输入 ## 输入
用户提交的段落数组(每个元素是一个段落): 用户提交的段落数组(每个元素是一个段落):
@@ -18,4 +24,4 @@ POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户
## 输出格式 ## 输出格式
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容: 严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
["润色后的段落1", "润色后的段落2"]""" ["优化后的段落1", "优化后的段落2"]"""
+3 -2
View File
@@ -94,7 +94,7 @@ AGENT_PLAN_PROMPT = """你是一个简历编辑助手。你的唯一职责是根
3. 修改主表:{{"type": "update", "module": "resume", "instruction": "修改说明(30字内)"}} 3. 修改主表:{{"type": "update", "module": "resume", "instruction": "修改说明(30字内)"}}
4. 新增记录:{{"type": "add", "module": "模块名", "instruction": "新增说明(30字内)"}} 4. 新增记录:{{"type": "add", "module": "模块名", "instruction": "新增说明(30字内)"}}
模块名可选:resume(主表,包含 name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary、avatarUrl)、education(教育)、work(工作)、internship(实习)、project(项目)、competition(竞赛) 模块名可选:resume(主表,包含 name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary、avatarUrl、hobbies、languageSkills)、education(教育)、work(工作)、internship(实习)、project(项目)、competition(竞赛)、organization(社团组织)
规则: 规则:
1. 非简历修改相关的指令一律拒绝,返回固定话术,不要尝试回答或引导 1. 非简历修改相关的指令一律拒绝,返回固定话术,不要尝试回答或引导
@@ -154,10 +154,11 @@ AGENT_MODULE_ADD_PROMPT = """你是一个简历编辑助手。根据要求,生
# 各模块数据结构定义(传入 prompt 的 module_schema # 各模块数据结构定义(传入 prompt 的 module_schema
MODULE_SCHEMAS: dict[str, str] = { MODULE_SCHEMAS: dict[str, str] = {
"resume": '{ "avatarUrl": "string", "name": "string", "email": "string", "mobileNumber": "string", "city": "string", "wechatNumber": "string", "portfolioUrl": "string", "skills": ["string"], "certificates": ["string"], "summary": "string" }', "resume": '{ "avatarUrl": "string", "name": "string", "email": "string", "mobileNumber": "string", "city": "string", "wechatNumber": "string", "portfolioUrl": "string", "skills": ["string"], "certificates": ["string"], "summary": "string", "hobbies": "string", "languageSkills": "string" }',
"education": '[{ "id": "string(8位)", "school": "string", "major": "string", "degree": "大专/本科/硕士/博士", "studyType": "全日制/非全日制", "startDate": "2023.09", "endDate": "2024.06", "description": [{"id": "string(8位)", "text": "string"}] }]', "education": '[{ "id": "string(8位)", "school": "string", "major": "string", "degree": "大专/本科/硕士/博士", "studyType": "全日制/非全日制", "startDate": "2023.09", "endDate": "2024.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
"work": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]', "work": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
"internship": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]', "internship": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
"project": '[{ "id": "string(8位)", "companyName": "string", "projectName": "string", "role": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]', "project": '[{ "id": "string(8位)", "companyName": "string", "projectName": "string", "role": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
"competition": '[{ "id": "string(8位)", "competitionName": "string", "award": "string", "awardDate": "2023.07", "description": [{"id": "string(8位)", "text": "string"}] }]', "competition": '[{ "id": "string(8位)", "competitionName": "string", "award": "string", "awardDate": "2023.07", "description": [{"id": "string(8位)", "text": "string"}] }]',
"organization": '[{ "id": "string(8位)", "organizationName": "string", "role": "string", "startDate": "2022.04", "endDate": "2023.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
} }
+5 -3
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.core.asserts import Assert
from app.core.auth import func_permission from app.core.auth import func_permission
from app.core.context import RequestContext from app.core.context import RequestContext
from app.core.database import get_db from app.core.database import get_db
@@ -13,17 +14,18 @@ router = APIRouter(prefix="/job-agent", tags=["求职助手agent"])
@router.post("/match-score", summary="岗位匹配度评分") @router.post("/match-score", summary="岗位匹配度评分")
async def match_score(param: MatchScoreParam): async def match_score(param: MatchScoreParam):
"""根据定制简历和岗位,从技能/学历/经验维度评估匹配度,返回 0-100 分""" """根据定制简历或原始简历和岗位,从技能/学历/经验维度评估匹配度,返回 0-100 分"""
Assert.is_true(param.customize_resume_id is not None or param.resume_id is not None, "customizeResumeId 和 resumeId 至少传一个")
user_id = RequestContext.user_id.get() user_id = RequestContext.user_id.get()
async for session in get_db(): async for session in get_db():
service = JobAgentChatService(session) service = JobAgentChatService(session)
result = await service.score_match(user_id, param.customize_resume_id, param.job_id) result = await service.score_match(user_id, param.job_id, param.customize_resume_id, param.resume_id)
return result return result
@router.post("/optimize-resume", summary="针对岗位优化简历") @router.post("/optimize-resume", summary="针对岗位优化简历")
async def optimize_resume(param: OptimizeResumeParam, _: None = Depends(func_permission("resume_custom"))): async def optimize_resume(param: OptimizeResumeParam, _: None = Depends(func_permission("resume_custom"))):
"""根据目标岗位,AI并发优化简历(summary + 5张子表经历),存Redis并返回""" """根据目标岗位,AI并发优化简历(summary + 6张子表经历),存Redis并返回"""
user_id = RequestContext.user_id.get() user_id = RequestContext.user_id.get()
async for session in get_db(): async for session in get_db():
service = JobAgentChatService(session) service = JobAgentChatService(session)
+5 -2
View File
@@ -1,10 +1,13 @@
import os import os
from .settings import Settings from .settings import BASE_DIR, Settings
_env = os.getenv("ENV", "dev") _env = os.getenv("ENV", "dev")
_env_files = {"dev": ".env", "test": ".env.test", "pro": ".env.prod"} _env_files = {"dev": ".env", "test": ".env.test", "pro": ".env.prod"}
settings = Settings(_env_file=_env_files.get(_env, ".env")) # 使用基于项目根目录的绝对路径,避免受 PyCharm/命令行工作目录(CWD)影响
_env_file = BASE_DIR / _env_files.get(_env, ".env")
settings = Settings(_env_file=_env_file)
__all__ = ["settings"] __all__ = ["settings"]
+6 -1
View File
@@ -1,5 +1,10 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# 项目根目录:settings.py -> config -> app -> <project root>
BASE_DIR = Path(__file__).resolve().parents[2]
class Settings(BaseSettings): class Settings(BaseSettings):
# 环境 # 环境
@@ -71,7 +76,7 @@ class Settings(BaseSettings):
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=BASE_DIR / ".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
case_sensitive=False, case_sensitive=False,
extra="ignore", extra="ignore",
+90
View File
@@ -0,0 +1,90 @@
"""业务断言工具类(风格参考 Spring Assert
使用方式:
from app.core.asserts import Assert
Assert.is_true(condition, "条件不满足的提示")
Assert.not_none(obj, "对象不能为空")
Assert.gt(days, 0, "天数必须大于0")
"""
from app.core.exceptions import AssertError
class Assert:
"""业务断言工具类
所有方法为静态方法,条件不满足时抛 AssertError(code=4100)。
"""
@staticmethod
def is_true(condition: bool, msg: str = "") -> None:
"""condition 为 False 时抛异常"""
if not condition:
raise AssertError(msg)
@staticmethod
def is_false(condition: bool, msg: str = "") -> None:
"""condition 为 True 时抛异常"""
if condition:
raise AssertError(msg)
@staticmethod
def not_none(obj, msg: str = "") -> None:
"""obj 为 None 时抛异常"""
if obj is None:
raise AssertError(msg)
@staticmethod
def is_none(obj, msg: str = "") -> None:
"""obj 不为 None 时抛异常"""
if obj is not None:
raise AssertError(msg)
@staticmethod
def has_text(text: str | None, msg: str = "") -> None:
"""text 为 None 或空字符串或纯空白时抛异常"""
if not text or not text.strip():
raise AssertError(msg)
@staticmethod
def not_empty(collection, msg: str = "") -> None:
"""集合/列表为 None 或空时抛异常"""
if not collection:
raise AssertError(msg)
@staticmethod
def gt(value, target, msg: str = "") -> None:
"""value <= target 时抛异常"""
if value <= target:
raise AssertError(msg)
@staticmethod
def gte(value, target, msg: str = "") -> None:
"""value < target 时抛异常"""
if value < target:
raise AssertError(msg)
@staticmethod
def lt(value, target, msg: str = "") -> None:
"""value >= target 时抛异常"""
if value >= target:
raise AssertError(msg)
@staticmethod
def lte(value, target, msg: str = "") -> None:
"""value > target 时抛异常"""
if value > target:
raise AssertError(msg)
@staticmethod
def eq(a, b, msg: str = "") -> None:
"""a != b 时抛异常"""
if a != b:
raise AssertError(msg)
@staticmethod
def ne(a, b, msg: str = "") -> None:
"""a == b 时抛异常"""
if a == b:
raise AssertError(msg)
+120 -21
View File
@@ -1,3 +1,24 @@
"""统一异常 + 全局异常处理(单一入口)
设计原则(看一眼就懂):
- 每个异常类把 code / http_status 写死,调用处不用记码。
- 抛的时候传一句具体原因(detail);不传就用类别名兜底。
例:raise BizError("不支持的商品") → msg = "业务异常[不支持的商品]"
raise BizError() → msg = "业务异常"
- 所有 handler(自定义异常 / 参数校验 / HTTP / 断言 / 未知兜底)都注册在本文件,
main.py 只调用 register_exception_handlers(app)。
约定:自定义异常一律 HTTP 500,真实含义靠 body 里的 code 区分(前端拦截器读 code)。
| 异常类 | code | HTTP | 类别名 | 典型场景 |
|-------------|------|------|-----------|--------------------------------------|
| ParamError | 4000 | 500 | 参数异常 | 入参缺失/非法、模型档位不支持 |
| BizError | 4200 | 500 | 业务异常 | 资源不存在、限频、验证码、越权、会员校验 |
| CreditError | 4300 | 500 | 扣费异常 | 余额不足、扣费失败(前端引导充值) |
| SysError | 5000 | 500 | 系统异常 | 已知的内部错误 / 上游模型失败(主动抛) |
| (未捕获) | 5000 | 500 | 系统异常 | 没被建模的异常,兜底接住 |
"""
import traceback import traceback
from fastapi import Request from fastapi import Request
@@ -9,7 +30,71 @@ from app.config import settings
from app.core.logger import log from app.core.logger import log
from app.core.schemas.responses import StandardResponse from app.core.schemas.responses import StandardResponse
# 友好的 HTTP 状态码消息映射
# ==================== 异常定义 ====================
class AppError(Exception):
"""自定义异常基类(不直接抛,统一捕获时用它兜底)
子类只需覆盖 code / label 两个类属性(http_status 一律 500)。
"""
code: int = 5000 # 业务错误码(固定)
http_status: int = 500 # 自定义异常一律 HTTP 500,真实含义靠 code 区分
label: str = "系统异常" # 类别名,detail 为空时作默认描述
def __init__(self, detail: str = ""):
# 传了原因 → "业务异常[不支持的商品]";没传 → "业务异常"
self.msg = f"{self.label}[{detail}]" if detail else self.label
super().__init__(self.msg)
def to_dict(self) -> dict:
"""HTTP 统一响应体字段"""
return {"code": self.code, "msg": self.msg}
def to_event(self) -> dict:
"""SSE error 事件数据体(前端流式解析用)"""
return {"code": self.code, "message": self.msg}
class ParamError(AppError):
"""参数异常:入参缺失 / 非法 / 不在允许范围"""
code = 4000
label = "参数异常"
class BizError(AppError):
"""业务异常:可预期的业务拒绝(资源不存在 / 限频 / 验证码 / 越权 / 会员校验…)"""
code = 4200
label = "业务异常"
class CreditError(AppError):
"""扣费异常:余额不足 / 扣费失败等积分相关问题,前端引导充值"""
code = 4300
label = "扣费异常"
class AssertError(AppError):
"""断言异常:业务前置条件不满足"""
code = 4100
label = "断言异常"
class SysError(AppError):
"""系统异常:已知的内部错误、上游模型调用失败等(主动抛)"""
code = 5000
label = "系统异常"
@classmethod
def from_exc(cls, exc: Exception) -> "SysError":
"""从任意异常包装成系统异常(流式兜底用)"""
return cls(f"{type(exc).__name__}: {exc}")
# ==================== 异常处理器 ====================
# HTTP 状态码 → 友好提示(框架抛出的 HTTPException 用)
_FRIENDLY_MESSAGES = { _FRIENDLY_MESSAGES = {
400: "请求参数错误", 400: "请求参数错误",
401: "未经授权,请登录", 401: "未经授权,请登录",
@@ -23,50 +108,64 @@ _FRIENDLY_MESSAGES = {
def _get_uuid(request: Request) -> str | None: def _get_uuid(request: Request) -> str | None:
"""从请求上下文中获取请求唯一标识"""
return getattr(request.state, "uuid", None) return getattr(request.state, "uuid", None)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse: def _resp(code: int, msg: str, http_status: int, uuid: str | None, data=None) -> JSONResponse:
uuid = _get_uuid(request)
log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | msg: {exc.detail}")
message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail))
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=http_status,
content=StandardResponse.fail(msg=message, code=exc.status_code, uuid=uuid).model_dump(), content=StandardResponse.fail(msg=msg, code=code, data=data, uuid=uuid).model_dump(),
) )
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
"""统一处理所有自定义异常(参数 / 业务 / 扣费 / 系统)
只在基类 AppError 上注册,Starlette 按继承链匹配,自动覆盖全部子类。
"""
uuid = _get_uuid(request)
# 系统异常(5xxx)记 error,其余可预期拒绝记 warning
level = log.error if exc.code >= 5000 else log.warning
level(f"{type(exc).__name__} -- uuid: {uuid} | code: {exc.code} | msg: {exc.msg}")
return _resp(exc.code, exc.msg, exc.http_status, uuid)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""处理框架 / 手动抛出的 HTTPException"""
uuid = _get_uuid(request)
log.error(f"HTTPException -- uuid: {uuid} | status: {exc.status_code} | detail: {exc.detail}")
message = _FRIENDLY_MESSAGES.get(exc.status_code, str(exc.detail))
return _resp(exc.status_code, message, exc.status_code, uuid)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
"""处理 Pydantic 参数校验异常"""
uuid = _get_uuid(request) uuid = _get_uuid(request)
errors = exc.errors() errors = exc.errors()
log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}") log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}")
return JSONResponse( return _resp(ParamError.code, "数据验证失败", 422, uuid, data=errors)
status_code=422,
content=StandardResponse.fail(msg="数据验证失败", code=422, data=errors, uuid=uuid).model_dump(),
)
async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse: async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse:
"""处理 assert 断言异常"""
uuid = _get_uuid(request) uuid = _get_uuid(request)
log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
return JSONResponse( return _resp(SysError.code, f"断言错误: {exc}", 500, uuid)
status_code=500,
content=StandardResponse.fail(msg=f"断言错误: {exc}", uuid=uuid).model_dump(),
)
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""兜底:所有未捕获异常统一按系统异常返回"""
uuid = _get_uuid(request) uuid = _get_uuid(request)
log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}") log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
msg = str(exc) if settings.env == "dev" else "服务器内部错误" # 开发环境暴露细节,生产环境只给通用提示
return JSONResponse( msg = f"{type(exc).__name__}: {exc}" if settings.env == "dev" else SysError.label
status_code=500, return _resp(SysError.code, msg, 500, uuid)
content=StandardResponse.fail(msg=msg, uuid=uuid).model_dump(),
)
def register_exception_handlers(app) -> None: def register_exception_handlers(app) -> None:
"""将异常处理器挂载到 FastAPI 应用""" """所有异常处理器挂载到 FastAPI 应用main.py 只调这一个)"""
app.add_exception_handler(AppError, app_error_handler)
app.add_exception_handler(StarletteHTTPException, http_exception_handler) app.add_exception_handler(StarletteHTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(AssertionError, assertion_error_handler) app.add_exception_handler(AssertionError, assertion_error_handler)
+1 -1
View File
@@ -17,7 +17,7 @@ class ResumeDiagnosisIssue(Base):
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联report.id") report_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联report.id")
resume_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联bg_user_resume.id") resume_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联bg_user_resume.id")
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID") user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID")
module_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="模块类型: summary/education/work/internship/project/competition") module_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="模块类型: summary/education/work/internship/project/competition/organization")
module_record_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="模块记录IDsummary时为resume_id") module_record_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="模块记录IDsummary时为resume_id")
finding: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="诊断发现") finding: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="诊断发现")
importance: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="为什么重要") importance: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="为什么重要")
+2
View File
@@ -35,6 +35,8 @@ class UserResume(Base):
skills: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="技能标签列表") skills: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="技能标签列表")
certificates: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="证书标签列表") certificates: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="证书标签列表")
summary: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="个人概述") summary: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="个人概述")
hobbies: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="兴趣爱好")
language_skills: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="语言能力")
create_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间") 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="更新时间") update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
+26
View File
@@ -0,0 +1,26 @@
"""简历-社团组织经历表(bg_user_resume_organization"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Integer, String, DateTime, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class UserResumeOrganization(Base):
"""简历-社团组织经历表 bg_user_resume_organization"""
__tablename__ = "bg_user_resume_organization"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
resume_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联bg_user_resume.id")
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID")
organization_name: Mapped[Optional[str]] = mapped_column(String(128), nullable=True, comment="社团/组织名称")
role: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, comment="担任角色")
start_date: Mapped[Optional[str]] = mapped_column(String(16), nullable=True, comment="开始时间,格式:2023.06")
end_date: Mapped[Optional[str]] = mapped_column(String(16), nullable=True, comment="结束时间,格式:2023.09")
description: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="描述段落 [{id, text}]")
sort_order: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="排序序号")
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="更新时间")
+12
View File
@@ -28,6 +28,8 @@ class ResumeProfile(_AliasModel):
skills: list[str] = Field(default_factory=list) skills: list[str] = Field(default_factory=list)
certificates: list[str] = Field(default_factory=list) certificates: list[str] = Field(default_factory=list)
summary: str = Field(default="") summary: str = Field(default="")
hobbies: str = Field(default="")
language_skills: str = Field(default="", alias="languageSkills")
class Education(_AliasModel): class Education(_AliasModel):
@@ -77,6 +79,15 @@ class Competition(_AliasModel):
description: list[Paragraph] = Field(default_factory=list) description: list[Paragraph] = Field(default_factory=list)
class Organization(_AliasModel):
id: str = Field(default="")
organization_name: str = Field(default="", alias="organizationName")
role: str = Field(default="")
start_date: str = Field(default="", alias="startDate")
end_date: str = Field(default="", alias="endDate")
description: list[Paragraph] = Field(default_factory=list)
class CustomizeResume(_AliasModel): class CustomizeResume(_AliasModel):
"""定制简历结构""" """定制简历结构"""
resume: ResumeProfile = Field(default_factory=ResumeProfile) resume: ResumeProfile = Field(default_factory=ResumeProfile)
@@ -85,3 +96,4 @@ class CustomizeResume(_AliasModel):
internship: list[Internship] = Field(default_factory=list) internship: list[Internship] = Field(default_factory=list)
project: list[Project] = Field(default_factory=list) project: list[Project] = Field(default_factory=list)
competition: list[Competition] = Field(default_factory=list) competition: list[Competition] = Field(default_factory=list)
organization: list[Organization] = Field(default_factory=list)
+2 -1
View File
@@ -13,5 +13,6 @@ class OptimizeResumeParam(BaseModel):
class MatchScoreParam(BaseModel): class MatchScoreParam(BaseModel):
customize_resume_id: int = Field(..., alias="customizeResumeId", description="定制简历ID") customize_resume_id: int | None = Field(None, alias="customizeResumeId", description="定制简历ID")
resume_id: int | None = Field(None, alias="resumeId", description="简历ID")
job_id: int = Field(..., alias="jobId", description="岗位ID") job_id: int = Field(..., alias="jobId", description="岗位ID")
+7 -1
View File
@@ -95,7 +95,11 @@ class BrowserPlugService:
parts.append(f"证书:{''.join(resume['certificates'])}") parts.append(f"证书:{''.join(resume['certificates'])}")
if resume.get("summary"): if resume.get("summary"):
parts.append(f"个人概述:{resume['summary']}") parts.append(f"个人概述:{resume['summary']}")
for section, title in [("education", "教育经历"), ("work", "工作经历"), ("internship", "实习经历"), ("project", "项目经历"), ("competition", "竞赛经历")]: if resume.get("hobbies"):
parts.append(f"兴趣爱好:{resume['hobbies']}")
if resume.get("languageSkills"):
parts.append(f"语言能力:{resume['languageSkills']}")
for section, title in [("education", "教育经历"), ("work", "工作经历"), ("internship", "实习经历"), ("project", "项目经历"), ("competition", "竞赛经历"), ("organization", "社团组织经历")]:
items = data.get(section, []) items = data.get(section, [])
if items: if items:
parts.append(f"{title}") parts.append(f"{title}")
@@ -108,4 +112,6 @@ class BrowserPlugService:
parts.append(f" - {item.get('projectName', '')} {item.get('role', '')}") parts.append(f" - {item.get('projectName', '')} {item.get('role', '')}")
elif section == "competition": elif section == "competition":
parts.append(f" - {item.get('competitionName', '')} {item.get('award', '')}") parts.append(f" - {item.get('competitionName', '')} {item.get('award', '')}")
elif section == "organization":
parts.append(f" - {item.get('organizationName', '')} {item.get('role', '')}")
return "\n".join(parts) if parts else "暂无简历信息" return "\n".join(parts) if parts else "暂无简历信息"
+6 -2
View File
@@ -13,7 +13,7 @@ from app.core.database import get_db
from app.core.redis import RedisManager from app.core.redis import RedisManager
from app.models.user_job_customize_resume import UserJobCustomizeResume 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, Organization, Paragraph,
) )
from app.services.resume_loader import ResumeDetail, load_default_resume_detail from app.services.resume_loader import ResumeDetail, load_default_resume_detail
from app.tool.snowflake import next_id from app.tool.snowflake import next_id
@@ -46,6 +46,7 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume:
wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "", wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "",
skills=resume.skills or [], certificates=resume.certificates or [], skills=resume.skills or [], certificates=resume.certificates or [],
summary=resume.summary or "", summary=resume.summary or "",
hobbies=resume.hobbies or "", languageSkills=resume.language_skills or "",
) )
return CustomizeResume( return CustomizeResume(
resume=profile, resume=profile,
@@ -65,6 +66,9 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume:
competition=[Competition(id=_rand_id(), competitionName=r.competition_name or "", award=r.award or "", competition=[Competition(id=_rand_id(), competitionName=r.competition_name or "", award=r.award or "",
awardDate=r.award_date or "", awardDate=r.award_date or "",
description=_build_paragraphs(r.description)) for r in detail.competition], description=_build_paragraphs(r.description)) for r in detail.competition],
organization=[Organization(id=_rand_id(), organizationName=r.organization_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.organization],
) )
@@ -104,7 +108,7 @@ async def get_by_id(user_id: int, customize_resume_id: int) -> CustomizeResume |
async def get(user_id: int, job_id: int) -> dict | None: async def get(user_id: int, job_id: int) -> dict | None:
"""查询定制简历,查不到则加载默认简历构建返回 """查询定制简历,查不到则加载默认简历构建返回
返回结构:{id, resumeId, resumeName, resume, education, work, internship, project, competition} 返回结构:{id, resumeId, resumeName, resume, education, work, internship, project, competition, organization}
fallback 默认简历时:id=None, resumeId=默认简历id, resumeName=原简历名 fallback 默认简历时:id=None, resumeId=默认简历id, resumeName=原简历名
""" """
async for session in get_db(): async for session in get_db():
+24 -10
View File
@@ -2,7 +2,7 @@
主要功能:针对岗位并发优化简历;岗位匹配度评分。 主要功能:针对岗位并发优化简历;岗位匹配度评分。
依赖:resume_loader(简历统一查询)、customize_resume_store(定制简历存取+构建)、job_agent.resume_optimizer(岗位简历优化)、job_agent.match_scorer(匹配评分) 依赖:resume_loader(简历统一查询)、customize_resume_store(定制简历存取+构建)、job_agent.resume_optimizer(岗位简历优化)、job_agent.match_scorer(匹配评分)
使用表:bg_user_resume + 5张子表(通过 resume_loader 查询)、bg_job(查岗位)、bg_user_job_customize_resume(定制简历) 使用表:bg_user_resume + 6张子表(通过 resume_loader 查询)、bg_job(查岗位)、bg_user_job_customize_resume(定制简历)
""" """
import asyncio import asyncio
@@ -16,7 +16,7 @@ from app.ai.job_agent.resume_optimizer import optimize_summary, optimize_experie
from app.ai.job_agent.match_scorer import score_match as ai_score_match from app.ai.job_agent.match_scorer import score_match as ai_score_match
from app.core.logger import log from app.core.logger import log
from app.models.job import Job from app.models.job import Job
from app.schemas.customize_resume import CustomizeResume, Education, Work, Internship, Project, Competition from app.schemas.customize_resume import CustomizeResume, Education, Work, Internship, Project, Competition, Organization
from app.services.resume_loader import load_resume_detail from app.services.resume_loader import load_resume_detail
from app.services import customize_resume_store from app.services import customize_resume_store
@@ -26,11 +26,15 @@ class JobAgentChatService:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
async def score_match(self, user_id: int, customize_resume_id: int, job_id: int) -> int: async def score_match(self, user_id: int, job_id: int, customize_resume_id: int | None = None, resume_id: int | None = None) -> int:
"""岗位匹配评分:查定制简历 + 岗位 → 序列化简历 → 调AI评分,返回 0-100 整数""" """岗位匹配评分:查定制简历或原始简历 + 岗位 → 序列化简历 → 调AI评分,返回 0-100 整数"""
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id) if customize_resume_id:
if cr is None: cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
raise ValueError("定制简历不存在") if cr is None:
raise ValueError("定制简历不存在")
else:
detail = await load_resume_detail(self.session, resume_id, user_id)
cr = customize_resume_store.build_from_detail(detail)
job = await self._get_job(job_id) job = await self._get_job(job_id)
resume_text = self._build_resume_text_from_cr(cr) resume_text = self._build_resume_text_from_cr(cr)
skill_tags = "".join(job.skill_tags) if job.skill_tags else "" skill_tags = "".join(job.skill_tags) if job.skill_tags else ""
@@ -50,6 +54,10 @@ class JobAgentChatService:
parts.append(f"证书:{''.join(r.certificates)}") parts.append(f"证书:{''.join(r.certificates)}")
if r.summary: if r.summary:
parts.append(f"个人概述:{r.summary}") parts.append(f"个人概述:{r.summary}")
if r.hobbies:
parts.append(f"兴趣爱好:{r.hobbies}")
if r.language_skills:
parts.append(f"语言能力:{r.language_skills}")
if cr.education: if cr.education:
parts.append("教育经历:") parts.append("教育经历:")
for e in cr.education: for e in cr.education:
@@ -73,6 +81,11 @@ class JobAgentChatService:
parts.append("竞赛经历:") parts.append("竞赛经历:")
for c in cr.competition: for c in cr.competition:
parts.append(f" - {c.competition_name} {c.award}".rstrip()) parts.append(f" - {c.competition_name} {c.award}".rstrip())
if cr.organization:
parts.append("社团组织经历:")
for o in cr.organization:
parts.append(f" - {o.organization_name} {o.role}".rstrip())
parts.extend(f" {p.text}" for p in o.description if p.text)
return "\n".join(parts) if parts else "暂无简历信息" return "\n".join(parts) if parts else "暂无简历信息"
async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict: async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict:
@@ -113,7 +126,8 @@ class JobAgentChatService:
"""构建各子表的 AI 优化任务列表,按单条记录拆分""" """构建各子表的 AI 优化任务列表,按单条记录拆分"""
result: list[tuple[str, int, str]] = [] result: list[tuple[str, int, str]] = []
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship), for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
("project", cr.project), ("competition", cr.competition)]: ("project", cr.project), ("competition", cr.competition),
("organization", cr.organization)]:
for idx, item in enumerate(items or []): for idx, item in enumerate(items or []):
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False))) result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
return result return result
@@ -132,8 +146,8 @@ class JobAgentChatService:
if key == "summary" and isinstance(result, str): if key == "summary" and isinstance(result, str):
cr.resume.summary = result cr.resume.summary = result
return return
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition} model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition, "organization": Organization}
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition} list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition, "organization": cr.organization}
model_cls = model_map.get(key) model_cls = model_map.get(key)
items = list_map.get(key) items = list_map.get(key)
if model_cls is None or items is None: if model_cls is None or items is None:
+9 -1
View File
@@ -2,7 +2,7 @@
主要功能:查询简历数据 + 查询岗位(可选),调用 AI 模块完成对话。 主要功能:查询简历数据 + 查询岗位(可选),调用 AI 模块完成对话。
依赖:resume_loader(简历统一查询)、nova_chat AI 模块 依赖:resume_loader(简历统一查询)、nova_chat AI 模块
使用表:bg_user_resume + 5张子表(通过 resume_loader 查询)、bg_job(查岗位,可选) 使用表:bg_user_resume + 6张子表(通过 resume_loader 查询)、bg_job(查岗位,可选)
""" """
from typing import Optional from typing import Optional
@@ -60,6 +60,10 @@ class NovaChatService:
parts.append(f"证书:{''.join(resume.certificates)}") parts.append(f"证书:{''.join(resume.certificates)}")
if resume.summary: if resume.summary:
parts.append(f"个人概述:{resume.summary}") parts.append(f"个人概述:{resume.summary}")
if resume.hobbies:
parts.append(f"兴趣爱好:{resume.hobbies}")
if resume.language_skills:
parts.append(f"语言能力:{resume.language_skills}")
if detail.education: if detail.education:
parts.append("教育经历:") parts.append("教育经历:")
for r in detail.education: for r in detail.education:
@@ -80,4 +84,8 @@ class NovaChatService:
parts.append("竞赛经历:") parts.append("竞赛经历:")
for r in detail.competition: for r in detail.competition:
parts.append(f" - {r.competition_name or ''} {r.award or ''}") parts.append(f" - {r.competition_name or ''} {r.award or ''}")
if detail.organization:
parts.append("社团组织经历:")
for r in detail.organization:
parts.append(f" - {r.organization_name or ''} {r.role or ''}")
return "\n".join(parts) if parts else "暂无简历信息" return "\n".join(parts) if parts else "暂无简历信息"
+5 -2
View File
@@ -2,7 +2,7 @@
加载简历描述数据 → 并行 AI 诊断 → 统计评级 → AI 汇总评价 → 写入数据库。 加载简历描述数据 → 并行 AI 诊断 → 统计评级 → AI 汇总评价 → 写入数据库。
依赖:resume_diagnoserAI诊断引擎) 依赖:resume_diagnoserAI诊断引擎)
使用表:bg_user_resume + 5张子表(读)、bg_resume_diagnosis_report + issue(写) 使用表:bg_user_resume + 6张子表(读)、bg_resume_diagnosis_report + issue(写)
""" """
import json import json
@@ -21,6 +21,7 @@ from app.tool.snowflake import next_id
_MODULE_LABELS = { _MODULE_LABELS = {
"summary": "个人概述", "education": "教育经历", "work": "工作经历", "summary": "个人概述", "education": "教育经历", "work": "工作经历",
"internship": "实习经历", "project": "项目经历", "competition": "竞赛经历", "internship": "实习经历", "project": "项目经历", "competition": "竞赛经历",
"organization": "社团组织经历",
} }
@@ -30,7 +31,7 @@ class ResumeDiagnoseService:
self.session = session self.session = session
async def load_resume_data(self, resume_id: int, user_id: int) -> tuple[UserResume, list[dict]]: async def load_resume_data(self, resume_id: int, user_id: int) -> tuple[UserResume, list[dict]]:
"""加载简历主表 + 5 张子表数据,组装 AI 任务列表""" """加载简历主表 + 6 张子表数据,组装 AI 任务列表"""
detail = await load_resume_detail(self.session, resume_id, user_id) detail = await load_resume_detail(self.session, resume_id, user_id)
resume = detail.resume resume = detail.resume
@@ -57,6 +58,8 @@ class ResumeDiagnoseService:
lambda r: f"公司: {r.company_name or ''}, 项目: {r.project_name or ''}, 角色: {r.role or ''}") lambda r: f"公司: {r.company_name or ''}, 项目: {r.project_name or ''}, 角色: {r.role or ''}")
self._collect_tasks(tasks, target_position, "competition", detail.competition, self._collect_tasks(tasks, target_position, "competition", detail.competition,
lambda r: f"竞赛: {r.competition_name or ''}, 获奖: {r.award or ''}") lambda r: f"竞赛: {r.competition_name or ''}, 获奖: {r.award or ''}")
self._collect_tasks(tasks, target_position, "organization", detail.organization,
lambda r: f"社团/组织: {r.organization_name or ''}, 角色: {r.role or ''}")
return resume, tasks return resume, tasks
@staticmethod @staticmethod
+13 -10
View File
@@ -1,6 +1,6 @@
"""简历统一查询模块 """简历统一查询模块
提供简历主表 + 5张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。 提供简历主表 + 6张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。
各 Service 统一复用,避免重复查询逻辑。 各 Service 统一复用,避免重复查询逻辑。
""" """
@@ -15,31 +15,33 @@ from app.models.user_resume_work import UserResumeWork
from app.models.user_resume_internship import UserResumeInternship from app.models.user_resume_internship import UserResumeInternship
from app.models.user_resume_project import UserResumeProject from app.models.user_resume_project import UserResumeProject
from app.models.user_resume_competition import UserResumeCompetition from app.models.user_resume_competition import UserResumeCompetition
from app.models.user_resume_organization import UserResumeOrganization
@dataclass @dataclass
class ResumeDetail: class ResumeDetail:
"""简历完整数据,主表 + 5张子表""" """简历完整数据,主表 + 6张子表"""
resume: UserResume resume: UserResume
education: list[UserResumeEducation] = field(default_factory=list) education: list[UserResumeEducation] = field(default_factory=list)
work: list[UserResumeWork] = field(default_factory=list) work: list[UserResumeWork] = field(default_factory=list)
internship: list[UserResumeInternship] = field(default_factory=list) internship: list[UserResumeInternship] = field(default_factory=list)
project: list[UserResumeProject] = field(default_factory=list) project: list[UserResumeProject] = field(default_factory=list)
competition: list[UserResumeCompetition] = field(default_factory=list) competition: list[UserResumeCompetition] = field(default_factory=list)
organization: list[UserResumeOrganization] = field(default_factory=list)
async def load_resume_detail(session: AsyncSession, resume_id: int, user_id: int) -> ResumeDetail: async def load_resume_detail(session: AsyncSession, resume_id: int, user_id: int) -> ResumeDetail:
"""按ID查简历主表(校验归属)+ 5张子表,返回 ResumeDetail""" """按ID查简历主表(校验归属)+ 6张子表,返回 ResumeDetail"""
result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id)) result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id))
resume = result.scalar_one_or_none() resume = result.scalar_one_or_none()
if not resume: if not resume:
raise ValueError("简历不存在") raise ValueError("简历不存在")
edu, work, intern, proj, comp = await _load_sub_tables(session, resume_id) edu, work, intern, proj, comp, org = await _load_sub_tables(session, resume_id)
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp) return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp, organization=org)
async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail: async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail:
"""自动选默认简历(先默认再最新)+ 5张子表,返回 ResumeDetail""" """自动选默认简历(先默认再最新)+ 6张子表,返回 ResumeDetail"""
result = await session.execute( result = await session.execute(
select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1) select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1)
.order_by(desc(UserResume.update_time)).limit(1)) .order_by(desc(UserResume.update_time)).limit(1))
@@ -51,15 +53,16 @@ async def load_default_resume_detail(session: AsyncSession, user_id: int) -> Res
resume = result.scalar_one_or_none() resume = result.scalar_one_or_none()
if not resume: if not resume:
raise ValueError("请先创建简历") raise ValueError("请先创建简历")
edu, work, intern, proj, comp = await _load_sub_tables(session, resume.id) edu, work, intern, proj, comp, org = await _load_sub_tables(session, resume.id)
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp) return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp, organization=org)
async def _load_sub_tables(session: AsyncSession, resume_id: int): async def _load_sub_tables(session: AsyncSession, resume_id: int):
"""查询简历5张子表""" """查询简历6张子表"""
edu = (await session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all() 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() 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() 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() 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() comp = (await session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all()
return edu, work, intern, proj, comp org = (await session.execute(select(UserResumeOrganization).where(UserResumeOrganization.resume_id == resume_id))).scalars().all()
return edu, work, intern, proj, comp, org
+20 -11
View File
@@ -1,25 +1,24 @@
"""简历 Service """简历 Service
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。 上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
依赖:file_parser(文件解析工具)、resume_extractorAI两阶段并行提取) 依赖:resume_text_extractor(文件文本提取)、resume_extractorAI两阶段并行提取)
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition5张子表) 使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition/organization6张子表)
""" """
import asyncio
import shortuuid import shortuuid
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.resume_extractor.extractor import extract_all from app.ai.resume_extractor_v2.extractor import extract_all
from app.ai.resume_polisher.polisher import polish_paragraphs from app.ai.resume_polisher.polisher import polish_paragraphs
from app.core.logger import log from app.core.logger import log
from app.models.user_resume import UserResume from app.models.user_resume import UserResume
from app.models.user_resume_competition import UserResumeCompetition from app.models.user_resume_competition import UserResumeCompetition
from app.models.user_resume_education import UserResumeEducation from app.models.user_resume_education import UserResumeEducation
from app.models.user_resume_internship import UserResumeInternship from app.models.user_resume_internship import UserResumeInternship
from app.models.user_resume_organization import UserResumeOrganization
from app.models.user_resume_project import UserResumeProject from app.models.user_resume_project import UserResumeProject
from app.models.user_resume_work import UserResumeWork from app.models.user_resume_work import UserResumeWork
from app.tool.file_parser import parse_to_segments from app.tool.resume_text_extractor import extract_text
from app.tool.snowflake import next_id from app.tool.snowflake import next_id
@@ -28,13 +27,14 @@ class ResumeService:
async def parse_and_extract(self, filename: str, content: bytes) -> dict: async def parse_and_extract(self, filename: str, content: bytes) -> dict:
"""文件解析 + AI 两阶段并行结构化,不涉及数据库操作""" """文件解析 + AI 两阶段并行结构化,不涉及数据库操作"""
log.info(f"开始解析简历文件: {filename}") log.info(f"开始解析简历文件: {filename}")
segments = await asyncio.to_thread(parse_to_segments, filename, content) text = await extract_text(filename, content)
if not segments: if not text or not text.strip():
raise ValueError("文件内容为空,无法解析") raise ValueError("文件内容为空,无法解析")
log.info(f"文件解析完成,文本单元数: {len(segments)}") # log.info(f"文件解析完成: {text}")
log.info(f"文件解析完成,文本字符数: {len(text)}")
log.info("开始AI两阶段并行结构化提取") log.info("开始AI两阶段并行结构化提取")
parsed = await extract_all(segments) parsed = await extract_all(text)
log.info("AI两阶段并行结构化提取完成") log.info("AI两阶段并行结构化提取完成")
return parsed return parsed
@@ -46,7 +46,7 @@ class ResumeService:
return result return result
async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int: async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int:
"""将解析结果写入主表 + 5张子表,返回简历ID""" """将解析结果写入主表 + 6张子表,返回简历ID"""
resume_id = next_id() resume_id = next_id()
session.add(UserResume( session.add(UserResume(
@@ -58,6 +58,7 @@ class ResumeService:
wechat_number=parsed.get("wechatNumber"), portfolio_url=parsed.get("portfolioUrl"), wechat_number=parsed.get("wechatNumber"), portfolio_url=parsed.get("portfolioUrl"),
skills=parsed.get("skills") or [], certificates=parsed.get("certificates") or [], skills=parsed.get("skills") or [], certificates=parsed.get("certificates") or [],
summary=parsed.get("summary"), summary=parsed.get("summary"),
hobbies=parsed.get("hobbies"), language_skills=parsed.get("languageSkills"),
)) ))
for i, edu in enumerate(parsed.get("education") or []): for i, edu in enumerate(parsed.get("education") or []):
@@ -102,6 +103,14 @@ class ResumeService:
description=_to_paragraphs(comp.get("description")), sort_order=i, description=_to_paragraphs(comp.get("description")), sort_order=i,
)) ))
for i, org in enumerate(parsed.get("organization") or []):
session.add(UserResumeOrganization(
id=next_id(), resume_id=resume_id, user_id=user_id,
organization_name=org.get("organizationName"), role=org.get("role"),
start_date=org.get("startDate"), end_date=org.get("endDate"),
description=_to_paragraphs(org.get("description")), sort_order=i,
))
await session.flush() await session.flush()
log.info(f"简历保存完成,resumeId: {resume_id}") log.info(f"简历保存完成,resumeId: {resume_id}")
return resume_id return resume_id
+16 -9
View File
@@ -2,7 +2,7 @@
岗位技能差距分析 → 定制简历生成/查询/编辑/回滚 → AI 对话式编辑。 岗位技能差距分析 → 定制简历生成/查询/编辑/回滚 → AI 对话式编辑。
依赖:skill_gap_analyzerAI引擎) 依赖:skill_gap_analyzerAI引擎)
使用表:bg_job(读)、bg_user_resume + 5张子表(读) 使用表:bg_job(读)、bg_user_resume + 6张子表(读)
存储:Redis(定制简历 + 回滚数据) 存储:Redis(定制简历 + 回滚数据)
""" """
@@ -20,7 +20,7 @@ from app.ai.skill_gap_analyzer.analyzer import (
from app.ai.skill_gap_analyzer.prompts import MODULE_SCHEMAS from app.ai.skill_gap_analyzer.prompts import MODULE_SCHEMAS
from app.core.logger import log from app.core.logger import log
from app.schemas.customize_resume import ( from app.schemas.customize_resume import (
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Organization,
) )
from app.models.job import Job from app.models.job import Job
from app.models.user_resume import UserResume from app.models.user_resume import UserResume
@@ -35,6 +35,7 @@ _MODULE_LABELS = {
"internship": "实习经历", "internship": "实习经历",
"project": "项目经历", "project": "项目经历",
"competition": "竞赛经历", "competition": "竞赛经历",
"organization": "社团组织经历",
} }
@@ -45,6 +46,8 @@ def _build_resume_json(detail: ResumeDetail) -> str:
"skills": resume.skills or [], "skills": resume.skills or [],
"certificates": resume.certificates or [], "certificates": resume.certificates or [],
"summary": resume.summary or "", "summary": resume.summary or "",
"hobbies": resume.hobbies or "",
"languageSkills": resume.language_skills or "",
"targetPosition": resume.target_position or "", "targetPosition": resume.target_position or "",
} }
if detail.education: if detail.education:
@@ -57,6 +60,8 @@ def _build_resume_json(detail: ResumeDetail) -> str:
data["project"] = [{"companyName": r.company_name, "projectName": r.project_name, "role": r.role, "description": r.description} for r in 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: if detail.competition:
data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in detail.competition] data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in detail.competition]
if detail.organization:
data["organization"] = [{"organizationName": r.organization_name, "role": r.role, "description": r.description} for r in detail.organization]
return json.dumps(data, ensure_ascii=False) return json.dumps(data, ensure_ascii=False)
@@ -141,7 +146,8 @@ class SkillGapService:
"""构建各子表的 AI 优化任务列表,按单条记录拆分""" """构建各子表的 AI 优化任务列表,按单条记录拆分"""
result: list[tuple[str, int, str]] = [] result: list[tuple[str, int, str]] = []
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship), for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
("project", cr.project), ("competition", cr.competition)]: ("project", cr.project), ("competition", cr.competition),
("organization", cr.organization)]:
for idx, item in enumerate(items or []): for idx, item in enumerate(items or []):
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False))) result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
return result return result
@@ -152,8 +158,8 @@ class SkillGapService:
if key == "summary" and isinstance(result, str): if key == "summary" and isinstance(result, str):
cr.resume.summary = result cr.resume.summary = result
return return
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition} model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition, "organization": Organization}
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition} list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition, "organization": cr.organization}
model_cls = model_map.get(key) model_cls = model_map.get(key)
items = list_map.get(key) items = list_map.get(key)
if model_cls is None or items is None: if model_cls is None or items is None:
@@ -272,7 +278,7 @@ class SkillGapService:
return cr.resume.model_dump_json(by_alias=True) return cr.resume.model_dump_json(by_alias=True)
mapping = { mapping = {
"education": cr.education, "work": cr.work, "internship": cr.internship, "education": cr.education, "work": cr.work, "internship": cr.internship,
"project": cr.project, "competition": cr.competition, "project": cr.project, "competition": cr.competition, "organization": cr.organization,
} }
items = mapping.get(mod_name, []) items = mapping.get(mod_name, [])
if not record_id: if not record_id:
@@ -290,7 +296,7 @@ class SkillGapService:
return return
mapping = { mapping = {
"education": cr.education, "work": cr.work, "internship": cr.internship, "education": cr.education, "work": cr.work, "internship": cr.internship,
"project": cr.project, "competition": cr.competition, "project": cr.project, "competition": cr.competition, "organization": cr.organization,
} }
items = mapping.get(mod_name) items = mapping.get(mod_name)
if items is not None: if items is not None:
@@ -308,14 +314,14 @@ class SkillGapService:
return return
model_map = { model_map = {
"education": Education, "work": Work, "internship": Internship, "education": Education, "work": Work, "internship": Internship,
"project": Project, "competition": Competition, "project": Project, "competition": Competition, "organization": Organization,
} }
model_cls = model_map.get(mod_name) model_cls = model_map.get(mod_name)
if not model_cls or not isinstance(result, dict) or not record_id: if not model_cls or not isinstance(result, dict) or not record_id:
return return
list_map = { list_map = {
"education": cr.education, "work": cr.work, "internship": cr.internship, "education": cr.education, "work": cr.work, "internship": cr.internship,
"project": cr.project, "competition": cr.competition, "project": cr.project, "competition": cr.competition, "organization": cr.organization,
} }
items = list_map.get(mod_name, []) items = list_map.get(mod_name, [])
new_item = model_cls.model_validate(result) new_item = model_cls.model_validate(result)
@@ -336,6 +342,7 @@ class SkillGapService:
"internship": (Internship, cr.internship), "internship": (Internship, cr.internship),
"project": (Project, cr.project), "project": (Project, cr.project),
"competition": (Competition, cr.competition), "competition": (Competition, cr.competition),
"organization": (Organization, cr.organization),
} }
entry = model_map.get(mod_name) entry = model_map.get(mod_name)
if not entry or not isinstance(result, dict): if not entry or not isinstance(result, dict):
-92
View File
@@ -1,92 +0,0 @@
"""文件解析工具
将上传的简历文件解析为「文本单元数组」,供 AI 按行号定位 description/summary,避免照抄长文本。
- PDF:使用 LiteParse(JSON 模式)按文本块的阅读顺序提取,每个文本块作为一个单元。
关闭 OCR —— 文本型简历无需 OCR,且 OCR 默认会联网下载字库导致严重阻塞。
- Word(.docx):按段落(paragraph)切分,表格行用 \t 拼成一个单元。
- TXT / Markdown(.md):自动检测编码,按换行切分。
"""
import io
from collections import Counter
from docx import Document
from liteparse import LiteParse
from app.core.logger import log
# LiteParse 解析器复用实例(关闭 OCR、静默日志)。线程安全由调用方的 to_thread 串行保证。
_PDF_PARSER = LiteParse(output_format="json", ocr_enabled=False, quiet=True)
def _drop_repeated(segments: list[str], threshold: int = 3) -> list[str]:
"""过滤重复出现的行(水印/页眉页脚特征):完全相同且出现次数 >= threshold 的行整体丢弃"""
counts = Counter(segments)
return [s for s in segments if counts[s] < threshold]
def _pdf_segments(content: bytes) -> list[str]:
"""解析 PDF:使用 LiteParse 按阅读顺序遍历每页文本块,每块作为一个独立单元,过滤空行。
不在预处理阶段合并行——是否合并/分段/排除噪声全部交给 AI 的行号区间决定。
"""
result = _PDF_PARSER.parse(content)
segments: list[str] = []
for page in result.pages:
for item in page.text_items:
text = item.text.strip()
if text:
segments.append(text)
return segments
def _docx_segments(content: bytes) -> list[str]:
"""解析 Word (.docx),按段落切分为单元,表格行用 \t 拼为一个单元"""
try:
doc = Document(io.BytesIO(content))
except Exception:
raise ValueError("无法解析该 Word 文件,请确认为有效的 .docx 格式")
segments: list[str] = []
for para in doc.paragraphs:
text = para.text.strip()
if text:
segments.append(text)
for table in doc.tables:
for row in table.rows:
row_text = "\t".join(cell.text.strip() for cell in row.cells)
if row_text.strip():
segments.append(row_text)
return segments
def _txt_segments(content: bytes) -> list[str]:
"""解析纯文本 (.txt / .md),自动检测编码,按换行切分为单元"""
text = None
for encoding in ("utf-8", "gbk", "gb2312", "latin-1"):
try:
text = content.decode(encoding)
break
except (UnicodeDecodeError, LookupError):
continue
if text is None:
text = content.decode("utf-8", errors="replace")
return [line.strip() for line in text.splitlines() if line.strip()]
# 后缀 → 解析函数。新增格式只需在此登记一行。
_PARSERS = {
".pdf": _pdf_segments,
".docx": _docx_segments,
".txt": _txt_segments,
".md": _txt_segments,
}
def parse_to_segments(filename: str, content: bytes) -> list[str]:
"""根据文件名后缀选择解析器,返回去重后的「文本单元数组」"""
suffix = filename[filename.rfind("."):].lower() if "." in filename else ""
log.info(f"解析文件: {filename},类型: {suffix}")
parser = _PARSERS.get(suffix)
if parser is None:
raise ValueError(f"不支持的文件类型: {suffix},支持: {', '.join(_PARSERS)}")
return _drop_repeated(parser(content))
+100
View File
@@ -0,0 +1,100 @@
"""简历文字处理
将上传的简历文件提取为纯文本,供后续 AI 重写/结构化使用。
支持格式:.txt / .md / .pdf / .docx
所有解析均为异步(阻塞解析下沉到线程池,避免阻塞事件循环)。
"""
import asyncio
import io
import fitz # PyMuPDF
from docx import Document
from app.core.asserts import Assert
from app.core.logger import log
# 支持的简历文件类型
SUPPORTED_EXTENSIONS = (".txt", ".md", ".pdf", ".docx")
# 纯文本解码尝试的编码顺序
_TEXT_ENCODINGS = ("utf-8", "gbk", "gb2312", "latin-1")
async def _parse_txt(content: bytes) -> str:
"""解析纯文本 (.txt / .md):自动探测编码,返回全文文本"""
def _decode() -> str:
for encoding in _TEXT_ENCODINGS:
try:
return content.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
# 全部失败时以替换字符兜底,保证不抛异常
return content.decode("utf-8", errors="replace")
return await asyncio.to_thread(_decode)
async def _parse_pdf(content: bytes) -> str:
"""解析 PDF:使用 PyMuPDF 逐页 get_text() 拼接为全文文本
sort=True 按文本块坐标(y,x)排序输出,即视觉阅读顺序。
默认的内容流顺序对模板生成的简历完全不可用:这类简历的「公司/职位/时间」表头是绝对定位文本框,
会被整批甩到全文最前面,与各自的职责描述相隔几十行,AI 无法还原归属关系。
"""
def _extract() -> str:
parts: list[str] = []
with fitz.open(stream=content, filetype="pdf") as doc:
for page in doc:
parts.append(page.get_text("text", sort=True))
return "\n".join(parts)
return await asyncio.to_thread(_extract)
async def _parse_docx(content: bytes) -> str:
"""解析 Word (.docx):拼接段落文本,表格行用 \t 连接,返回全文文本"""
def _extract() -> str:
try:
doc = Document(io.BytesIO(content))
except Exception:
raise ValueError("无法解析该 Word 文件,请确认为有效的 .docx 格式")
parts: list[str] = []
for para in doc.paragraphs:
text = para.text.strip()
if text:
parts.append(text)
for table in doc.tables:
for row in table.rows:
row_text = "\t".join(cell.text.strip() for cell in row.cells)
if row_text.strip():
parts.append(row_text)
return "\n".join(parts)
return await asyncio.to_thread(_extract)
# 后缀 → 解析函数。新增格式只需在此登记一行,并同步 SUPPORTED_EXTENSIONS。
_PARSERS = {
".txt": _parse_txt,
".md": _parse_txt,
".pdf": _parse_pdf,
".docx": _parse_docx,
}
async def extract_text(filename: str, content: bytes) -> str:
"""简历文字提取统一入口:先校验文件类型受支持,再按后缀路由提取全文文本"""
suffix = filename[filename.rfind("."):].lower() if "." in filename else ""
Assert.is_true(
suffix in SUPPORTED_EXTENSIONS,
f"不支持的文件类型: {suffix or '未知'},支持: {', '.join(SUPPORTED_EXTENSIONS)}",
)
log.info(f"开始提取简历文本: {filename},类型: {suffix}")
text = await _PARSERS[suffix](content)
log.info(f"简历文本提取完成: {filename},字符数: {len(text)}")
return text
+1 -1
View File
@@ -43,7 +43,7 @@ python-multipart>=0.0.9
python-dotenv>=1.0.0 python-dotenv>=1.0.0
# 文件解析 # 文件解析
liteparse>=2.1.0 pymupdf>=1.24.0
python-docx>=1.1.0 python-docx>=1.1.0
# 雪花ID # 雪花ID