Compare commits
12
Commits
b79bcb932c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc1590f404 | ||
|
|
54f5c0ac63 | ||
|
|
3047c47916 | ||
|
|
f98b87379f | ||
|
|
b50eb98653 | ||
|
|
65d711ec25 | ||
|
|
410ab09561 | ||
|
|
7ca1d58721 | ||
|
|
af6195ba4e | ||
|
|
89d033c9f9 | ||
|
|
dfe7cca5a4 | ||
|
|
547cf21bf2 |
@@ -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 使用规范
|
||||||
|
|
||||||
|
|||||||
@@ -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 # 统一响应模型 StandardResponse(code/msg/data/timestamp/uuid)
|
│ └─ responses.py # 统一响应模型 StandardResponse(code/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` |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
|
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是 Nova,OfferPie 的 AI 求职助手。
|
SYSTEM_PROMPT = """你是 Nova,Offer派 的 AI 求职助手。
|
||||||
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
|
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
|
||||||
|
|
||||||
【候选人简历】
|
【候选人简历】
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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。"""
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""简历一步并发提取
|
||||||
|
|
||||||
|
6 路并发(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),每路独立负责一个模块,
|
||||||
|
各自基于简历全文一次性输出该模块的全部内容(短字段 + description),无第二阶段。
|
||||||
|
相比两阶段方案:少一个串行轮次、全文只 prefill 6 次、无标识名定位环节。
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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, "竞赛"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 无运行时 prompt 替换,6 条链在模块加载时一次性建好,请求期只做 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:
|
||||||
|
"""个人信息路结果 → 主表字段 dict(skills 最多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:
|
||||||
|
"""一步6路并发提取简历,返回与两阶段方案完全一致的结构化数据
|
||||||
|
|
||||||
|
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
|
||||||
|
"""
|
||||||
|
log.info(f"一步6路并发提取开始,文本字符数: {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"一步6路并发提取完成,总耗时: {time.perf_counter() - start:.2f}s - "
|
||||||
|
+ " ".join(f"{label}:{len(result[module])}" for module, _, label in _sub_chains)
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""简历一步并发提取 Prompt
|
||||||
|
|
||||||
|
6 路并发,每路独立负责一个模块(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),
|
||||||
|
各自基于简历全文一次性输出该模块的**全部内容**(短字段 + 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": "作品集链接", "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
|
||||||
|
```
|
||||||
|
规则:
|
||||||
|
- 只提取以上9个字段,**不要提取任何经历**(教育/工作/实习/项目/竞赛由其它模块单独负责)。前6个字段没有的填null,后3个数组字段没有的填[]。
|
||||||
|
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
|
||||||
|
- mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
|
||||||
|
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
|
||||||
|
- skills:仅当简历中有明确的"技能"/"专业技能"等**独立模块**时才提取,按原文逐字填,最多10个;如果没有专门的技能模块,填[]。**不要**从工作/项目/自我评价里归纳技能,尤其不要把"团队沟通""抗压能力""责任心"等软素质描述当作技能。**证书/语言等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证等)一律不计入 skills,它们只归 certificates。**
|
||||||
|
- certificates:填真正的**证书/证件/标准化考试成绩**,包括语言与等级类(如CET-4/6、雅思、托福、GRE、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。
|
||||||
|
- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素,一个段落一个元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
|
||||||
|
只输出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
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
"""简历段落润色 Prompt 模板"""
|
"""简历段落润色 Prompt 模板"""
|
||||||
|
|
||||||
POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户提交的简历段落进行"表面润色",只做表达层面的优化。
|
POLISH_PROMPT = """你是一位资深简历顾问,擅长把平淡的经历描述改写得更专业、更有表现力。请对用户提交的简历段落进行"表达优化",在不虚构事实的前提下大胆提升表达力。
|
||||||
|
|
||||||
## 优化范围(只允许做这些)
|
## 优化目标(要真正做出改变)
|
||||||
- 修正错别字、标点、语法错误
|
- 强化表达力:把口语化、平淡、流水账式的描述,改写成专业、有力、简历书写规范的表达
|
||||||
- 优化文本格式与排版(如多余空格、断句、全半角混用)
|
- 善用动作动词:以有力的动词开头(负责/主导/搭建/推动/优化/提升等),突出你的贡献
|
||||||
- 让表达更通顺、专业,去除明显口语化和冗余措辞
|
- 理清逻辑结构:让每条描述尽量体现"做了什么 → 怎么做 → 带来什么结果",条理清晰
|
||||||
|
- 精炼措辞:删除冗余、重复、无意义的填充词,让句子更紧凑
|
||||||
|
- 修正错别字、标点、语法、格式问题(多余空格、断句、全半角混用等)
|
||||||
|
|
||||||
## 严格禁止(绝对不能做)
|
## 红线(绝对不能碰)
|
||||||
- 不得改变原意,不得增加或删除任何信息点
|
- 只优化表达,不虚构事实:**严禁**凭空添加或夸大任何数字、量化成果、技能、职责、技术栈、奖项、成就
|
||||||
- 不得编造、补充任何内容(尤其禁止凭空添加数字、量化成果、技能、成就)
|
- 原文没有的量化数据(百分比、金额、规模等)绝不能编造;原文有的数字必须原样保留,不得改动
|
||||||
- 不得改变段落的数量和顺序
|
- 不改变经历的核心事实与含义,不无中生有地拔高
|
||||||
|
- 保留用户本人的基本语气与专业方向,是"把他说得更好",而不是换成另一个人
|
||||||
|
|
||||||
|
## 格式约束
|
||||||
|
- 输出段落的**数量和顺序必须与输入完全一致**:输入几段就输出几段,一一对应,不合并、不拆分、不增删段落
|
||||||
|
|
||||||
## 输入
|
## 输入
|
||||||
用户提交的段落数组(每个元素是一个段落):
|
用户提交的段落数组(每个元素是一个段落):
|
||||||
@@ -18,4 +24,4 @@ POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户
|
|||||||
|
|
||||||
## 输出格式
|
## 输出格式
|
||||||
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
|
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
|
||||||
["润色后的段落1", "润色后的段落2"]"""
|
["优化后的段落1", "优化后的段落2"]"""
|
||||||
|
|||||||
@@ -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,11 +14,12 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 整数"""
|
||||||
|
if customize_resume_id:
|
||||||
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
||||||
if cr is None:
|
if cr is None:
|
||||||
raise ValueError("定制简历不存在")
|
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 ""
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"""简历 Service
|
"""简历 Service
|
||||||
|
|
||||||
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
|
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
|
||||||
依赖:file_parser(文件解析工具)、resume_extractor(AI两阶段并行提取)
|
依赖:resume_text_extractor(文件文本提取)、resume_extractor(AI两阶段并行提取)
|
||||||
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition(5张子表)
|
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition(5张子表)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
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
|
||||||
@@ -19,7 +17,7 @@ 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_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 +26,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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user