Compare commits
15
Commits
b79bcb932c
..
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62dc9b4e84 | ||
|
|
35080327d6 | ||
|
|
8051933dcf | ||
|
|
dc1590f404 | ||
|
|
54f5c0ac63 | ||
|
|
3047c47916 | ||
|
|
f98b87379f | ||
|
|
b50eb98653 | ||
|
|
65d711ec25 | ||
|
|
410ab09561 | ||
|
|
7ca1d58721 | ||
|
|
af6195ba4e | ||
|
|
89d033c9f9 | ||
|
|
dfe7cca5a4 | ||
|
|
547cf21bf2 |
+23
@@ -54,3 +54,26 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Kiro 本地配置
|
||||
.kiro/
|
||||
|
||||
# ===== IDE / 编辑器(补充) =====
|
||||
.vs/
|
||||
.fleet/
|
||||
.history/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
|
||||
# ===== AI 编码工具 =====
|
||||
.claude/
|
||||
.codex/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
.windsurf/
|
||||
.continue/
|
||||
.aider*
|
||||
|
||||
# ===== 操作系统 =====
|
||||
desktop.ini
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
# 简历诊断功能 - 技术方案
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
对用户已有简历的**描述文本**进行 AI 诊断,找出问题、给出改进建议和 AI 改写版本,生成诊断报告。
|
||||
|
||||
### 诊断范围(只诊断描述文本)
|
||||
|
||||
| 来源表 | 字段 | module_type |
|
||||
|--------|------|-------------|
|
||||
| bg_user_resume | summary(个人概述) | summary |
|
||||
| bg_user_resume_education | description | education |
|
||||
| bg_user_resume_work | description | work |
|
||||
| bg_user_resume_internship | description | internship |
|
||||
| bg_user_resume_project | description | project |
|
||||
| bg_user_resume_competition | description | competition |
|
||||
|
||||
### 执行策略:先分后合
|
||||
|
||||
```
|
||||
第一阶段:所有模块记录并行 AI 诊断(asyncio.gather)
|
||||
第二阶段:汇总 issues → 代码算评级 → AI 生成整体评价 → 写入数据库
|
||||
```
|
||||
|
||||
AI 模型:`LLM.DOUBAO_SEED_PRO`,暂不接入功能权限校验。
|
||||
|
||||
---
|
||||
|
||||
## 二、问题分类与诊断判断思路
|
||||
|
||||
### 2.1 紧急修复(urgent)
|
||||
|
||||
#### typo — 错别字 / 语病
|
||||
|
||||
**判断思路:**
|
||||
- 检查文本中是否存在明显的**错别字**(如"功则"应为"功能"、"负责"写成"付责")
|
||||
- 检查是否存在**语病**:主谓搭配不当、语序混乱、成分残缺
|
||||
- 检查**标点符号**使用错误:中英文标点混用、缺少句号、逗号过多造成长句
|
||||
- 检查**用词不当**:近义词误用、口语化表达出现在正式简历中
|
||||
|
||||
**AI 判断信号:**
|
||||
- 同音字替换(的/得/地 混用)
|
||||
- 句子读不通顺,需要反复阅读才能理解
|
||||
- 专业术语拼写错误
|
||||
- 一句话中出现多个逗号,缺少句号断句
|
||||
|
||||
---
|
||||
|
||||
### 2.2 重点优化(important)
|
||||
|
||||
#### no_result — 缺少成果
|
||||
|
||||
**判断思路:**
|
||||
- 描述只写了"做了什么"(任务/职责),没有写"做出了什么结果"
|
||||
- 典型的**流水账式描述**:只有动作没有产出
|
||||
- 缺少对业务/团队/项目的**实际贡献**和**影响**
|
||||
|
||||
**AI 判断信号:**
|
||||
- 只有动词+宾语("负责XX系统开发"、"参与XX项目"),没有后续的结果说明
|
||||
- 全是过程描述,找不到"提升了"、"优化了"、"实现了"、"完成了"等结果性表述
|
||||
- 对比 STAR 法则:有 Situation + Task + Action,但缺 Result
|
||||
|
||||
**正面示例(有成果):**
|
||||
> 负责用户中心系统重构,将接口响应时间从 800ms 降至 200ms,用户投诉率下降 60%
|
||||
|
||||
**反面示例(缺成果):**
|
||||
> 负责用户中心系统重构,使用 Spring Boot + Redis 实现了新的架构
|
||||
|
||||
#### no_quantify — 缺少量化
|
||||
|
||||
**判断思路:**
|
||||
- 有成果描述但**没有具体数字**支撑
|
||||
- 使用了模糊表达:"大幅提升"、"显著改善"、"大量用户",但没有具体数值
|
||||
- 缺少以下任何维度的量化:人数、金额、百分比、时间周期、覆盖范围、处理规模
|
||||
|
||||
**AI 判断信号:**
|
||||
- 出现"大幅"、"显著"、"有效"、"极大"等模糊程度副词,但没有跟随数字
|
||||
- 提到了结果但只是定性描述,没有定量数据
|
||||
- 可以合理推断应该有数据但未提供(如"提升了性能"没说提升多少)
|
||||
|
||||
**正面示例(有量化):**
|
||||
> 优化数据库查询,将平均响应时间从 2s 降至 200ms,日处理订单量从 5 万提升至 20 万
|
||||
|
||||
**反面示例(缺量化):**
|
||||
> 优化数据库查询,显著提升了系统性能,改善了用户体验
|
||||
|
||||
#### weak_relevance — 岗位相关性弱
|
||||
|
||||
> **前置条件:仅在 `target_position` 有值时才判断,未填目标岗位则跳过此维度,计数为 0。**
|
||||
|
||||
**判断思路:**
|
||||
- 需要结合 `target_position`(目标岗位)进行判断
|
||||
- 描述内容与目标岗位的**核心职责**关联度低
|
||||
- 花大量篇幅描述与目标岗位**无关的技能或经历**
|
||||
- 对于目标岗位来说,这段描述**无法体现匹配度**
|
||||
|
||||
**AI 判断信号:**
|
||||
- 目标岗位是"Java后端工程师",但描述中全是前端或运营内容
|
||||
- 描述的技能/工具与目标岗位的 JD 常见要求差距大
|
||||
- 可转移技能存在但未被强调,反而突出了无关内容
|
||||
|
||||
---
|
||||
|
||||
### 2.3 表达提升(expression)
|
||||
|
||||
#### not_concise — 表述不精炼
|
||||
|
||||
**判断思路:**
|
||||
- 句子**偏长**(单句超过 50 字),信息密度低
|
||||
- 存在**赘词和重复表达**:"进行了开发"可简化为"开发了"
|
||||
- **信息堆叠**:一句话塞了太多内容,应拆分为多个要点
|
||||
- 使用了**空泛的修饰词**:"充分"、"积极"、"认真"等无实质信息
|
||||
|
||||
**AI 判断信号:**
|
||||
- 单个描述段落超过 80 字但核心信息只有一个
|
||||
- 出现"进行了"、"完成了对...的"、"负责了...的工作"等冗余句式
|
||||
- 同一段落中重复表达相似的意思
|
||||
- 可以删去一半文字而不损失关键信息
|
||||
|
||||
**正面示例(精炼):**
|
||||
> 设计并实现分布式缓存方案,QPS 从 1000 提升至 8000,缓存命中率 95%
|
||||
|
||||
**反面示例(不精炼):**
|
||||
> 在项目中,我积极主动地参与了分布式缓存方案的设计与实现工作,通过对缓存策略的深入研究和反复优化,最终成功地将系统的 QPS 从原来的 1000 提升到了 8000
|
||||
|
||||
#### format_inconsistent — 格式不统一
|
||||
|
||||
**判断思路:**
|
||||
- 同一份简历中**时间格式不统一**(有的写"2023.06",有的写"2023年6月",有的写"2023/06")
|
||||
- **标点风格不统一**:有的段落用分号结尾,有的用句号,有的不加标点
|
||||
- **数字写法不统一**:有的用阿拉伯数字,有的用中文数字
|
||||
- **项目符号不统一**:有的用"•",有的用"-",有的用"1."
|
||||
- **人称不统一**:有的用"我",有的用第三人称,有的省略主语
|
||||
|
||||
**AI 判断信号:**
|
||||
- 同一段描述中出现两种以上的格式风格
|
||||
- 与模块上下文中的时间格式不一致
|
||||
- 段落之间的排版结构差异明显
|
||||
|
||||
---
|
||||
|
||||
## 三、综合评级规则
|
||||
|
||||
由代码硬算,不依赖 AI 判断:
|
||||
|
||||
| 评级 | 条件 | 评语 |
|
||||
|------|------|------|
|
||||
| A(优秀) | urgent=0, important<=1, expression<=1 | 您的简历相当出彩,在求职市场中格外抢眼,能清晰展现您的优势与经历,已经超越绝大多数候选人了。 |
|
||||
| B(良好) | urgent=0, important 2-3, expression<=2 | 简历已经很棒了,但还有提升的潜力。再调整一下细节,会有更具有竞争力! |
|
||||
| C(一般) | urgent=1, 或 important 3-4 | 你的简历还有打磨空间,多推敲细节、补充些具体内容,整体会更出彩。 |
|
||||
| D(待提升) | urgent>=2, 或 (important>=4 且 has_weak_relevance) | 您的简历目前还有较大提升空间,建议尽快补充关键经历、完善内容表达,并优化整体结构。 |
|
||||
|
||||
判断优先级:从 D → C → B → A 依次判断,命中即返回。
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库表设计
|
||||
|
||||
### bg_resume_diagnosis_report
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | BigInteger | 主键,雪花ID |
|
||||
| resume_id | BigInteger | 关联 bg_user_resume.id |
|
||||
| user_id | BigInteger | 用户ID |
|
||||
| grade | VARCHAR(1) | 评级 A/B/C/D |
|
||||
| summary | TEXT | AI 生成的整体评价 |
|
||||
| urgent_total | Integer | 紧急修复总数 |
|
||||
| important_total | Integer | 重点优化总数 |
|
||||
| expression_total | Integer | 表达提升总数 |
|
||||
| create_time | DateTime | 创建时间 |
|
||||
| update_time | DateTime | 更新时间 |
|
||||
|
||||
### bg_resume_diagnosis_issue
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | BigInteger | 主键,雪花ID |
|
||||
| report_id | BigInteger | 关联 report.id |
|
||||
| resume_id | BigInteger | 关联 bg_user_resume.id |
|
||||
| user_id | BigInteger | 用户ID |
|
||||
| module_type | VARCHAR(32) | summary/education/work/internship/project/competition |
|
||||
| module_record_id | BigInteger | 模块记录ID(summary 时为 resume_id) |
|
||||
| finding | TEXT | 诊断发现 |
|
||||
| importance | TEXT | 为什么重要 |
|
||||
| suggestion | TEXT | 改进建议 |
|
||||
| urgent_issues | JSON | {"typo": 0} |
|
||||
| important_issues | JSON | {"no_result": 0, "no_quantify": 0, "weak_relevance": 0} |
|
||||
| expression_issues | JSON | {"not_concise": 0, "format_inconsistent": 0} |
|
||||
| optimized_content | JSON | AI改写后的内容。子表模块(education/work/internship/project/competition)与原 description 格式一致 `[{id, text}]`,保持原 id 不变只改写 text;summary 模块为纯文本字符串 |
|
||||
| status | Integer | 0=待处理 1=已处理 |
|
||||
| user_feedback | Integer | 0=未评价 1=符合 2=不符合 |
|
||||
| create_time | DateTime | 创建时间 |
|
||||
| update_time | DateTime | 更新时间 |
|
||||
|
||||
### 建表 SQL
|
||||
|
||||
```sql
|
||||
CREATE TABLE `bg_resume_diagnosis_report` (
|
||||
`id` bigint NOT NULL COMMENT '主键,雪花ID',
|
||||
`resume_id` bigint NOT NULL COMMENT '关联bg_user_resume.id',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`grade` varchar(1) DEFAULT NULL COMMENT '评级 A/B/C/D',
|
||||
`summary` text COMMENT 'AI生成的整体评价',
|
||||
`urgent_total` int NOT NULL DEFAULT '0' COMMENT '紧急修复总数',
|
||||
`important_total` int NOT NULL DEFAULT '0' COMMENT '重点优化总数',
|
||||
`expression_total` int NOT NULL DEFAULT '0' COMMENT '表达提升总数',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_resume_id` (`resume_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='简历诊断报告表';
|
||||
|
||||
CREATE TABLE `bg_resume_diagnosis_issue` (
|
||||
`id` bigint NOT NULL COMMENT '主键,雪花ID',
|
||||
`report_id` bigint NOT NULL COMMENT '关联report.id',
|
||||
`resume_id` bigint NOT NULL COMMENT '关联bg_user_resume.id',
|
||||
`user_id` bigint NOT NULL COMMENT '用户ID',
|
||||
`module_type` varchar(32) NOT NULL COMMENT '模块类型: summary/education/work/internship/project/competition',
|
||||
`module_record_id` bigint NOT NULL COMMENT '模块记录ID,summary时为resume_id',
|
||||
`finding` text COMMENT '诊断发现',
|
||||
`importance` text COMMENT '为什么重要',
|
||||
`suggestion` text COMMENT '改进建议',
|
||||
`urgent_issues` json DEFAULT NULL COMMENT '紧急修复子类型计数 {"typo": 0}',
|
||||
`important_issues` json DEFAULT NULL COMMENT '重点优化子类型计数 {"no_result": 0, "no_quantify": 0, "weak_relevance": 0}',
|
||||
`expression_issues` json DEFAULT NULL COMMENT '表达提升子类型计数 {"not_concise": 0, "format_inconsistent": 0}',
|
||||
`optimized_content` json DEFAULT NULL COMMENT 'AI改写后的内容,子表模块与原description格式一致[{id,text}]保持原id只改写text,summary模块为纯文本字符串',
|
||||
`status` int NOT NULL DEFAULT '0' COMMENT '0=待处理 1=已处理',
|
||||
`user_feedback` int NOT NULL DEFAULT '0' COMMENT '0=未评价 1=符合 2=不符合',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_report_id` (`report_id`),
|
||||
KEY `idx_resume_id` (`resume_id`),
|
||||
KEY `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='简历诊断问题表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、API 设计
|
||||
|
||||
### 1. POST /resume/diagnose — 触发诊断
|
||||
|
||||
**请求体:** `{"resume_id": 123}`
|
||||
|
||||
**执行流程:**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 1. RequestContext.user_id.get() 获取当前用户 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 2. 短事务1(只读) │
|
||||
│ async for session in get_db(): │
|
||||
│ service = ResumeDiagnoseService(session) │
|
||||
│ resume, tasks = await service │
|
||||
│ .load_resume_data(resume_id, user_id) │
|
||||
│ → 加载主表 + 5 张子表数据 │
|
||||
│ → 组装 AI 任务列表 │
|
||||
│ → 事务结束,释放数据库连接 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 3. 校验 tasks 非空 │
|
||||
│ → 空则 raise ValueError("无可诊断内容") │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 4. 并行 AI 诊断(不持有数据库连接) │
|
||||
│ ai_results = await diagnose_all(tasks) │
|
||||
│ → asyncio.gather 并行调用 N 条诊断链 │
|
||||
│ → 每条链独立容错,失败返回空结果 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 5. 汇总统计 + 评级(纯计算,无 IO) │
|
||||
│ → 遍历 ai_results 统计问题数量 │
|
||||
│ → 过滤无问题的记录(所有计数都为 0 则跳过) │
|
||||
│ → 代码硬算评级(_calc_grade) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 6. AI 生成整体评价(不持有数据库连接) │
|
||||
│ summary = await generate_summary(...) │
|
||||
│ → 传入评级、统计、all_findings │
|
||||
│ → 返回纯文本评价 │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ 7. 短事务2(纯写入,无 AI 调用) │
|
||||
│ async for session in get_db(): │
|
||||
│ service = ResumeDiagnoseService(session) │
|
||||
│ report_id = await service.save_report(...) │
|
||||
│ → 写入 report + issues 表 │
|
||||
│ → 事务提交 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**响应:** `{"reportId": 456}`
|
||||
|
||||
### 2. GET /resume/diagnose/{resume_id} — 查询最近一次报告
|
||||
|
||||
**执行流程:**
|
||||
1. 获取 user_id
|
||||
2. 查 report 表(ORDER BY create_time DESC LIMIT 1)
|
||||
3. 查该 report 下所有 issues
|
||||
4. 返回 `{"report": {...}, "issues": [...]}`
|
||||
|
||||
**响应示例:**
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"id": "123",
|
||||
"resumeId": "456",
|
||||
"grade": "B",
|
||||
"summary": "您的简历整体质量良好...",
|
||||
"urgentTotal": 0,
|
||||
"importantTotal": 3,
|
||||
"expressionTotal": 1,
|
||||
"createTime": "2026-04-07 10:30:00"
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"id": "789",
|
||||
"moduleType": "work",
|
||||
"moduleRecordId": "1001",
|
||||
"finding": "工作描述缺少量化数据...",
|
||||
"importance": "量化数据能让招聘者...",
|
||||
"suggestion": "建议在描述中添加...",
|
||||
"urgentIssues": {"typo": 0},
|
||||
"importantIssues": {"no_result": 0, "no_quantify": 1, "weak_relevance": 0},
|
||||
"expressionIssues": {"not_concise": 0, "format_inconsistent": 0},
|
||||
"optimizedContent": [{"id": "abc123", "text": "负责XX系统后端开发,日均处理100万+请求..."}],
|
||||
"status": 0,
|
||||
"userFeedback": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. PUT /resume/diagnose/issue/{issue_id}/resolve — 标记已处理
|
||||
|
||||
**请求体:** `{"user_feedback": 1}` (1=符合 2=不符合)
|
||||
|
||||
**执行流程:**
|
||||
1. 获取 user_id
|
||||
2. 查 issue 记录(校验 user_id)
|
||||
3. 设置 status=1, user_feedback
|
||||
4. 返回 null
|
||||
|
||||
---
|
||||
|
||||
## 六、文件变更清单
|
||||
|
||||
### 新增 7 个文件
|
||||
|
||||
```
|
||||
app/models/resume_diagnosis_report.py — 诊断报告 ORM
|
||||
app/models/resume_diagnosis_issue.py — 诊断问题 ORM
|
||||
app/ai/resume_diagnoser/__init__.py — 包初始化(空文件)
|
||||
app/ai/resume_diagnoser/prompts.py — Prompt 模板
|
||||
app/ai/resume_diagnoser/diagnoser.py — AI 诊断引擎
|
||||
app/services/resume_diagnose_service.py — 业务逻辑
|
||||
app/api/resume_diagnose.py — API 路由
|
||||
```
|
||||
|
||||
### 修改 1 个文件
|
||||
|
||||
```
|
||||
app/main.py — 注册新路由(加 2 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、模块设计
|
||||
|
||||
### AI 诊断模块 (`app/ai/resume_diagnoser/`)
|
||||
|
||||
**prompts.py** — 两个 Prompt 模板:
|
||||
- `DIAGNOSE_MODULE_PROMPT`:第一阶段,单条记录诊断,输入 module_type/target_position/context/description_text(子表传原始 JSON `[{id,text}]`,summary 传纯文本),输出 JSON
|
||||
- `SUMMARY_PROMPT`:第二阶段,汇总评价,输入统计数据 + 所有 findings,输出纯文本
|
||||
|
||||
**diagnoser.py** — 参照 `app/ai/resume_extractor/extractor.py` 的模式:
|
||||
- 诊断链:`ChatPromptTemplate → DOUBAO_SEED_PRO(temperature=0) → JsonOutputParser`
|
||||
- 汇总链:`ChatPromptTemplate → DOUBAO_SEED_PRO(temperature=0.3) → StrOutputParser`
|
||||
- `diagnose_all(tasks)` — asyncio.gather 并行
|
||||
- `generate_summary(...)` — AI 生成整体评价
|
||||
- `_safe_invoke()` — 容错
|
||||
|
||||
### Service 层 (`app/services/resume_diagnose_service.py`)
|
||||
|
||||
```
|
||||
ResumeDiagnoseService(session: AsyncSession)
|
||||
├─ load_resume_data(resume_id, user_id) → (resume, tasks)
|
||||
│ 加载主表 + 5 张子表,组装 AI 任务列表
|
||||
│
|
||||
├─ save_report(resume_id, user_id, grade, summary, stats, tasks, ai_results) → report_id
|
||||
│ 纯写入:接收已算好的 grade、summary、统计数据,写入 report + issues
|
||||
│ 跳过无问题的记录(所有计数为 0 则不创建 issue 行)
|
||||
│
|
||||
├─ get_latest_report(resume_id, user_id) → dict | None
|
||||
└─ resolve_issue(issue_id, user_id, user_feedback) → None
|
||||
|
||||
工具函数(无状态,路由层或 Service 外部可调用):
|
||||
├─ _build_description_text(description) — 子表传原始 JSON 字符串 [{id,text}],summary 传纯文本
|
||||
├─ _calc_grade(urgent, important, expression, has_weak_relevance) — 评级硬算
|
||||
├─ _aggregate_results(tasks, ai_results) — 统计汇总 + 过滤无问题记录
|
||||
└─ _issue_to_dict(issue) — ORM → camelCase dict
|
||||
```
|
||||
|
||||
**description_text 传入格式:**
|
||||
- 子表模块:传原始 JSON 字符串 `[{"id": "abc123", "text": "负责XX系统..."}, ...]`,AI 能看到每个段落的 id
|
||||
- summary 模块:传纯文本(summary 字段本身就是 VARCHAR)
|
||||
|
||||
**optimized_content 返回格式:**
|
||||
- 子表模块:与原始 description 格式一致 `[{id, text}]`,保持原 id 不变,只改写 text
|
||||
- summary 模块:纯文本字符串(与原始 summary 字段格式一致)
|
||||
|
||||
### API 路由 (`app/api/resume_diagnose.py`)
|
||||
|
||||
```
|
||||
router = APIRouter(prefix="/resume/diagnose", tags=["简历诊断"])
|
||||
├─ POST "" — 触发诊断
|
||||
├─ GET "/{resume_id}" — 查询报告
|
||||
└─ PUT "/issue/{issue_id}/resolve" — 标记已处理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、实施顺序
|
||||
|
||||
1. ORM 模型(`resume_diagnosis_report.py` + `resume_diagnosis_issue.py`)
|
||||
2. AI 模块(`__init__.py` + `prompts.py` + `diagnoser.py`)
|
||||
3. Service 层(`resume_diagnose_service.py`)
|
||||
4. API 路由(`resume_diagnose.py`)+ 修改 `main.py`
|
||||
5. 更新项目结构文档
|
||||
@@ -1,718 +0,0 @@
|
||||
# 岗位简历技能差距分析 + 定制简历 — 完整方案
|
||||
|
||||
## 一、需求概述
|
||||
|
||||
三步流程:
|
||||
1. **差距分析**:根据岗位技能标签和用户简历,AI 判断缺失技能,纯计算匹配分
|
||||
2. **定制简历**:用户选择要优化的模块和要新增的技能,AI 生成优化后的简历内容
|
||||
3. **预览 + AI 对话编辑**:前端渲染定制简历,用户可通过 AI 对话继续编辑,用于投递时使用(不写回原简历)
|
||||
|
||||
定制简历存 Redis,过期时间 12 小时,不落库。一个用户同时只有一份定制简历。
|
||||
|
||||
---
|
||||
|
||||
## 二、接口总览
|
||||
|
||||
| 序号 | 路径 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | `/api/job/skill-gap` | POST | 差距分析 |
|
||||
| 2 | `/api/job/customize-resume` | POST | 生成定制简历 |
|
||||
| 3 | `/api/job/customize-resume` | GET | 查询定制简历 |
|
||||
| 4 | `/api/job/customize-resume` | PUT | 手动编辑定制简历 |
|
||||
| 5 | `/api/job/customize-resume/rollback` | POST | 回滚定制简历 |
|
||||
| 6 | `/api/job/customize-resume/ai-edit` | POST | AI 对话式编辑定制简历 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口一:差距分析
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `POST /api/job/skill-gap` |
|
||||
| 入参 | `{ "jobId": Long }` |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 从 token 取 userId
|
||||
2. 查简历(自动选择,不传 resumeId):
|
||||
- 先查 `bg_user_resume` 中 `user_id=userId AND is_default=1`,按 `update_time DESC` 取第一条
|
||||
- 没有默认简历 → 查 `user_id=userId`,按 `update_time DESC` 取第一条
|
||||
- 没有任何简历 → 报错"请先创建简历"
|
||||
3. 查岗位:
|
||||
- 查 `bg_job` 拿 id、title、skill_tags
|
||||
- 岗位不存在 → 报错
|
||||
- skill_tags 为空 → 直接返回满分 10,gaps 为空数组
|
||||
4. 查简历子表(拼 AI 输入):
|
||||
- `bg_user_resume_education`
|
||||
- `bg_user_resume_work`
|
||||
- `bg_user_resume_internship`
|
||||
- `bg_user_resume_project`
|
||||
- `bg_user_resume_competition`
|
||||
5. 调 AI(一次):
|
||||
- 输入:岗位 skill_tags 列表 + 简历 skills 字段 + 各子表经历描述
|
||||
- 输出:差距条目 JSON 数组,每条含 keyword(缺失技能,skill_tags 子集)、title(≤12字)、description(≤30字)
|
||||
6. 计算匹配分:`score = (skill_tags总数 - gaps条目数) / skill_tags总数 × 10`,保留一位小数
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"score": 2.5,
|
||||
"job": {
|
||||
"jobId": "1234567890",
|
||||
"title": "数据产品经理",
|
||||
"skillTags": ["Python", "SQL", "项目管理", "团队协作", "数据分析", "跨部门沟通"]
|
||||
},
|
||||
"resume": {
|
||||
"resumeId": "1234567890",
|
||||
"resumeName": "李华_产品经理",
|
||||
"targetPosition": "电商产品经理"
|
||||
},
|
||||
"gaps": [
|
||||
{ "keyword": "SQL", "title": "缺少 SQL 关键词", "description": "岗位要求数据查询分析能力,简历未体现。" },
|
||||
{ "keyword": "数据分析", "title": "数据分析证据不足", "description": "建议补充指标拆解与结论产出的经历。" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 边界处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 用户无简历 | 报错"请先创建简历" |
|
||||
| 岗位不存在 | 报错 |
|
||||
| skill_tags 为空 | 满分 10,gaps 为空数组 |
|
||||
| AI 调用失败 | 降级:全部标记为缺失,分数 0 |
|
||||
|
||||
### AI Prompt
|
||||
|
||||
```
|
||||
你是一个技能匹配助手。给定岗位要求的技能标签列表和用户简历信息,找出用户简历中未覆盖的技能并生成差距说明。
|
||||
|
||||
【岗位技能标签】
|
||||
{skill_tags}
|
||||
|
||||
【用户简历】
|
||||
{resume_json}
|
||||
|
||||
规则:
|
||||
1. 逐个判断岗位技能标签,用户简历中是否体现了该技能(包括直接提及、经历中隐含的技能)
|
||||
2. 只针对未覆盖的技能输出差距条目,已覆盖的技能不输出
|
||||
3. 每个差距条目包含三个字段:
|
||||
- keyword:缺失的技能,必须是岗位技能标签中的原文,不要修改
|
||||
- title:差距点的简短标题,不超过 12 个字(例如"缺少 SQL 关键词")
|
||||
- description:结合岗位要求的一句说明或建议,不超过 30 个字
|
||||
4. 返回 JSON 数组格式,如:
|
||||
[{"keyword": "SQL", "title": "缺少 SQL 关键词", "description": "岗位要求数据查询分析能力,简历未体现。"}]
|
||||
5. 如果全部覆盖,返回空数组 []
|
||||
6. 只返回 JSON 数组,不要其他内容
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、接口二:生成定制简历
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `POST /api/job/customize-resume` |
|
||||
| 入参 | 见下方 |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 入参
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "Long",
|
||||
"resumeId": "Long",
|
||||
"optimizeModules": ["summary", "skills", "experience"],
|
||||
"addSkills": ["Python", "SQL"]
|
||||
}
|
||||
```
|
||||
|
||||
- `resumeId`:以哪份简历为模板(来自差距分析返回的 resumeId,用户可能切换过简历)
|
||||
- `optimizeModules`:用户勾选要优化的模块,可选值:summary(个人概述)、skills(技能)、experience(过往经历)
|
||||
- `addSkills`:用户勾选要新增的技能关键词(来自差距分析 gaps 的 keyword)
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 查简历主表 + 所有子表(完整简历数据)
|
||||
2. 查岗位信息(title、description、requirement)
|
||||
3. 按用户选择的模块分别处理(各模块并发执行,最后合并):
|
||||
|
||||
**summary(个人概述)**:
|
||||
- 调 AI,根据岗位信息微调 summary,融入选中的技能关键词
|
||||
- 避免过度优化,保持原文风格,只做轻微润色
|
||||
|
||||
**skills(技能)**:
|
||||
- 把 addSkills 追加到现有 skills 列表,不调 AI,纯内存操作
|
||||
|
||||
**experience(过往经历)**:
|
||||
- 按子表(education/work/internship/project/competition)为单位,每个子表一个 AI 调用,传入该子表的完整数据
|
||||
- 让描述更贴合岗位方向,避免过度优化,基本保持原文不变
|
||||
- 不融入 addSkills,经历描述不硬塞技能关键词
|
||||
|
||||
**addSkills 影响范围**:只影响 skills(直接追加)和 summary(自然融入),不影响 experience。
|
||||
|
||||
**并发策略**:summary 优化 和 各子表优化 全部并发执行(asyncio.gather),skills 纯内存操作不需要等待。最终合并所有结果。
|
||||
|
||||
4. 未勾选的模块保持原数据不动
|
||||
5. 组装完整的定制简历数据,存 Redis(key:`customize:resume:{userId}`,过期 12 小时,重新生成会覆盖)
|
||||
6. 返回成功标识,不返回简历数据(前端通过 GET 接口查询)
|
||||
|
||||
### 返回
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
说明:简历数据前端通过 `GET /api/job/customize-resume` 查询。子表记录的 id 使用随机 8 位字符串作为标识(从数据库查出时生成),不使用数据库原始 id。
|
||||
|
||||
### 边界处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 简历不存在 | 报错 |
|
||||
| 岗位不存在 | 报错 |
|
||||
| optimizeModules 为空 | 报错"请至少选择一个优化模块" |
|
||||
| AI 调用失败 | 该模块保持原数据不动,不影响其他模块 |
|
||||
|
||||
---
|
||||
|
||||
## 五、接口三:查询定制简历
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `GET /api/job/customize-resume` |
|
||||
| 入参 | 无 |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 从 Redis 取定制简历数据(key:`customize:resume:{userId}`)
|
||||
2. 不存在 → 返回 null
|
||||
3. 返回完整简历 JSON
|
||||
|
||||
---
|
||||
|
||||
## 六、接口四:修改定制简历(手动编辑)
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `PUT /api/job/customize-resume` |
|
||||
| 入参 | 完整简历 JSON(整体覆盖) |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 校验入参
|
||||
2. 整体覆盖 Redis 中的定制简历数据(key:`customize:resume:{userId}`)
|
||||
3. 刷新过期时间为 12 小时
|
||||
4. 不存在时也直接写入
|
||||
|
||||
---
|
||||
|
||||
## 七、接口五:回滚定制简历
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `POST /api/job/customize-resume/rollback` |
|
||||
| 入参 | 无 |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 处理流程
|
||||
|
||||
1. 从 Redis 取回滚数据(key:`customize:resume:rollback:{userId}`)
|
||||
2. 不存在 → 报错"没有可回滚的版本"
|
||||
3. 用回滚数据覆盖当前定制简历(key:`customize:resume:{userId}`)
|
||||
4. 删除回滚数据
|
||||
5. 刷新定制简历过期时间为 12 小时
|
||||
|
||||
---
|
||||
|
||||
## 七、接口五:AI 对话式编辑定制简历
|
||||
|
||||
### 接口信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 路径 | `POST /api/job/customize-resume/ai-edit` |
|
||||
| 入参 | 见下方 |
|
||||
| 鉴权 | 需要登录态,从 token 取 userId |
|
||||
|
||||
### 入参
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "Long",
|
||||
"instruction": "精简一下第一段工作经历",
|
||||
"chatHistory": [
|
||||
{ "role": "user", "content": "优化描述" },
|
||||
{ "role": "assistant", "content": "你想优化哪一部分?" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `instruction`:用户当前输入的指令
|
||||
- `chatHistory`:之前的对话历史,前端维护,每次请求带上
|
||||
|
||||
### 消息类型
|
||||
|
||||
返回两种消息类型:
|
||||
|
||||
**message(普通对话)**:AI 追问、引导,不修改简历
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"message": "你想优化哪一部分的描述?是最新的实习还是所有工作经历?"
|
||||
}
|
||||
```
|
||||
|
||||
**updated(修改通知)**:AI 修改了简历,返回新版本
|
||||
```json
|
||||
{
|
||||
"type": "updated",
|
||||
"message": "完成!已更新:个人简介、技能、工作经验"
|
||||
}
|
||||
```
|
||||
|
||||
### 处理流程(两步走)
|
||||
|
||||
核心思路:将用户操作原子化,把模块级并发拆成记录级并发,减少单次 AI 输出量,提升响应速度。
|
||||
|
||||
#### 第一步:准备数据
|
||||
|
||||
1. 从 Redis 取当前定制简历(不存在则报错)
|
||||
2. 查 `bg_job` 拿 title、description、requirement
|
||||
|
||||
#### 第二步:规划 AI(意图识别 + 操作原子化)
|
||||
|
||||
输入:用户指令 + 对话历史 + 当前完整简历内容 + 岗位信息(title + description + requirement)
|
||||
|
||||
输出两种结果:
|
||||
|
||||
**对话(指令不明确)**:
|
||||
```json
|
||||
{ "action": "chat", "message": "你想优化哪一部分?" }
|
||||
```
|
||||
→ 直接返回 `{ "type": "message", "message": "..." }`,结束。
|
||||
|
||||
**操作计划(指令明确)**:
|
||||
```json
|
||||
{
|
||||
"action": "modify",
|
||||
"operations": [
|
||||
{ "type": "delete", "module": "work", "id": "abc12345" },
|
||||
{ "type": "update", "module": "work", "id": "def67890", "instruction": "精简描述,突出量化成果" },
|
||||
{ "type": "update", "module": "resume", "instruction": "在summary中融入数据分析关键词" },
|
||||
{ "type": "add", "module": "internship", "instruction": "新增一段数据分析实习经历" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
操作类型:
|
||||
- **delete**:删除记录,需要 module + id
|
||||
- **update**:修改记录,需要 module + id + instruction(resume 主表不需要 id)
|
||||
- **add**:新增记录,需要 module + instruction
|
||||
|
||||
规则:
|
||||
- 每个操作对应一条原子操作,一个用户指令可拆出多条操作
|
||||
- `instruction` 限 50 字以内
|
||||
- `delete` 和 `update`(非 resume)必须带 `id`,从当前简历中匹配
|
||||
- `update` module 为 `resume` 时不需要 `id`(主表是单对象)
|
||||
- `add` 不需要 `id`
|
||||
|
||||
模块划分(按表结构,共 6 个):
|
||||
|
||||
| 模块名 | 对应表 | 可修改字段 |
|
||||
|--------|--------|-----------|
|
||||
| `resume` | `bg_user_resume` | avatarUrl、name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary |
|
||||
| `education` | `bg_user_resume_education` | 全部字段 |
|
||||
| `work` | `bg_user_resume_work` | 全部字段 |
|
||||
| `internship` | `bg_user_resume_internship` | 全部字段 |
|
||||
| `project` | `bg_user_resume_project` | 全部字段 |
|
||||
| `competition` | `bg_user_resume_competition` | 全部字段 |
|
||||
|
||||
#### 第三步:按操作类型并发执行
|
||||
|
||||
先处理 delete(零 AI 开销),再并发执行所有 update 和 add(asyncio.gather):
|
||||
|
||||
- **delete**:后端直接按 module + id 从数组移除,不调 AI
|
||||
- **update(含 resume 主表和子表记录)**:按 id 取出单条记录 + instruction + schema + 岗位信息(title + description + requirement)+ 最近 10 条对话历史,调 AI 修改,只输出单条记录。resume 主表和子表记录复用同一个 prompt
|
||||
- **add**:instruction + schema + 岗位信息(title + description + requirement)+ 最近 10 条对话历史,调 AI 生成一条新记录
|
||||
|
||||
#### 第四步:合并
|
||||
|
||||
- delete 已直接处理
|
||||
- update 按 module + id 替换回原数据(resume 主表直接整体替换)
|
||||
- add 按 module 追加到对应数组
|
||||
- AI 调用失败的操作跳过,不影响其他操作
|
||||
|
||||
#### 第五步:保存 + 返回
|
||||
|
||||
1. 当前简历存为回滚数据(key:`customize:resume:rollback:{userId}`,过期 30 分钟)
|
||||
2. 新简历覆盖 Redis(key:`customize:resume:{userId}`),刷新过期时间 12 小时
|
||||
3. `updatedModulesLabel` 由后端根据 operations 中的 module 去重映射中文名拼接
|
||||
4. 返回 `type: updated` + 消息(前端通过 GET 接口查询新简历,通过回滚接口恢复)
|
||||
|
||||
### description 字段处理
|
||||
|
||||
子表的 description 字段格式为 `[{id, text}, {id, text}]`,AI 操作规则(通过 prompt 约束):
|
||||
- **修改**:保留原 id,只改 text
|
||||
- **新增**:AI 自行生成随机 8 位字符串作为 id
|
||||
- **删除**:直接从数组中移除
|
||||
|
||||
不做后端校验,完全依靠 prompt 约束 AI 行为。
|
||||
|
||||
### 边界处理
|
||||
|
||||
| 场景 | 处理 |
|
||||
|------|------|
|
||||
| 定制简历不存在 | 报错"定制简历不存在,请先生成" |
|
||||
| 规划 AI 失败 | 返回 `type: message`,提示重试 |
|
||||
| 某个模块修改 AI 失败 | 该模块保持原数据,其他模块正常返回 |
|
||||
|
||||
---
|
||||
|
||||
## 八、AI Prompt 汇总
|
||||
|
||||
### 1. 差距分析 Prompt
|
||||
|
||||
```
|
||||
你是一个技能匹配助手。给定岗位要求的技能标签列表和用户简历信息,找出用户简历中未覆盖的技能并生成差距说明。
|
||||
|
||||
【岗位技能标签】
|
||||
{skill_tags}
|
||||
|
||||
【用户简历】
|
||||
{resume_json}
|
||||
|
||||
规则:
|
||||
1. 逐个判断岗位技能标签,用户简历中是否体现了该技能(包括直接提及、经历中隐含的技能)
|
||||
2. 只针对未覆盖的技能输出差距条目,已覆盖的技能不输出
|
||||
3. 每个差距条目包含三个字段:
|
||||
- keyword:缺失的技能,必须是岗位技能标签中的原文,不要修改
|
||||
- title:差距点的简短标题,不超过 12 个字(例如"缺少 SQL 关键词")
|
||||
- description:结合岗位要求的一句说明或建议,不超过 30 个字
|
||||
4. 返回 JSON 数组格式,如:
|
||||
[{"keyword": "SQL", "title": "缺少 SQL 关键词", "description": "岗位要求数据查询分析能力,简历未体现。"}]
|
||||
5. 如果全部覆盖,返回空数组 []
|
||||
6. 只返回 JSON 数组,不要其他内容
|
||||
```
|
||||
|
||||
### 2. 定制简历 - summary 优化 Prompt
|
||||
|
||||
```
|
||||
你是一个简历优化助手。根据目标岗位信息,微调用户的个人概述。
|
||||
|
||||
【目标岗位】
|
||||
{job_title}
|
||||
|
||||
【需要融入的技能关键词】
|
||||
{add_skills}
|
||||
|
||||
【原始个人概述】
|
||||
{original_summary}
|
||||
|
||||
规则:
|
||||
1. 保持原文风格和主体内容不变
|
||||
2. 只做轻微润色,让概述更贴合目标岗位方向
|
||||
3. 自然融入需要新增的技能关键词,不要生硬堆砌
|
||||
4. 避免过度优化,改动越少越好
|
||||
5. 直接输出优化后的文本,不要其他内容
|
||||
```
|
||||
|
||||
### 3. 定制简历 - experience 优化 Prompt
|
||||
|
||||
```
|
||||
你是一个简历优化助手。根据目标岗位信息,微调用户的经历描述。
|
||||
|
||||
【目标岗位】
|
||||
{job_title}
|
||||
{job_description}
|
||||
|
||||
【原始经历数据】
|
||||
{original_module_data}
|
||||
|
||||
规则:
|
||||
1. 基本保持原文不变,只在可以优化的地方做轻微调整
|
||||
2. 让描述更贴合目标岗位方向,但不要编造内容
|
||||
3. 避免过度优化,改动越少越好
|
||||
4. description 字段是 [{id, text}] 格式:修改时保留原 id 只改 text,新增段落生成随机8位字符串作为 id,删除段落直接移除
|
||||
5. 返回修改后的完整模块数据(JSON 格式,与输入格式一致)
|
||||
```
|
||||
|
||||
### 4. Agent - 规划 Prompt
|
||||
|
||||
```
|
||||
你是一个简历编辑助手。分析用户的指令,将其拆解为原子操作。
|
||||
|
||||
【目标岗位】
|
||||
{job_title}
|
||||
{job_description}
|
||||
|
||||
【当前简历】
|
||||
{resume_json}
|
||||
|
||||
【对话历史】
|
||||
{chat_history}
|
||||
|
||||
【用户指令】
|
||||
{instruction}
|
||||
|
||||
如果用户指令不明确或需要澄清,返回:
|
||||
{"action": "chat", "message": "你的追问内容"}
|
||||
|
||||
如果用户指令明确,将其拆解为原子操作列表,返回:
|
||||
{"action": "modify", "operations": [...]}
|
||||
|
||||
操作类型:
|
||||
1. 删除记录:{"type": "delete", "module": "模块名", "id": "记录id"}
|
||||
2. 修改记录:{"type": "update", "module": "模块名", "id": "记录id", "instruction": "修改说明(50字内)"}
|
||||
3. 修改主表:{"type": "update", "module": "resume", "instruction": "修改说明(50字内)"}
|
||||
4. 新增记录:{"type": "add", "module": "模块名", "instruction": "新增说明(50字内)"}
|
||||
|
||||
模块名可选:resume(主表,包含 name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary、avatarUrl)、education(教育)、work(工作)、internship(实习)、project(项目)、competition(竞赛)
|
||||
|
||||
规则:
|
||||
1. 每条操作对应一个最小粒度的修改,一个用户指令可拆出多条操作
|
||||
2. delete 和 update(非resume)必须带 id,从当前简历中匹配
|
||||
3. instruction 不超过50字,简明扼要
|
||||
4. 只返回 JSON,不要其他内容
|
||||
```
|
||||
|
||||
### 5. Agent - 单条记录修改 Prompt
|
||||
|
||||
```
|
||||
你是一个简历编辑助手。根据修改要求,修改简历中的一条记录。
|
||||
|
||||
【目标岗位】
|
||||
{job_title}
|
||||
{job_description}
|
||||
|
||||
【修改要求】
|
||||
{instruction}
|
||||
|
||||
【最近对话】
|
||||
{chat_history}
|
||||
|
||||
【模块数据结构】
|
||||
{module_schema}
|
||||
|
||||
【当前记录数据】
|
||||
{record_data}
|
||||
|
||||
规则:
|
||||
1. 严格按照修改要求操作
|
||||
2. 未要求修改的字段保持不变
|
||||
3. 不要编造用户简历中不存在的内容
|
||||
4. 保持原文格式和结构
|
||||
5. description 字段是 [{id, text}] 格式:修改时保留原 id 只改 text,新增段落生成随机8位字符串作为 id,删除段落直接移除
|
||||
6. 返回修改后的完整记录数据(JSON 格式,与输入格式一致)
|
||||
```
|
||||
|
||||
### 6. Agent - 新增记录 Prompt
|
||||
|
||||
```
|
||||
你是一个简历编辑助手。根据要求,生成一条新的简历记录。
|
||||
|
||||
【目标岗位】
|
||||
{job_title}
|
||||
{job_description}
|
||||
|
||||
【新增要求】
|
||||
{instruction}
|
||||
|
||||
【最近对话】
|
||||
{chat_history}
|
||||
|
||||
【模块数据结构】
|
||||
{module_schema}
|
||||
|
||||
规则:
|
||||
1. 按照模块数据结构生成完整字段
|
||||
2. id 使用随机8位字符串
|
||||
3. description 中每个段落的 id 也使用随机8位字符串
|
||||
4. 内容要合理真实,贴合目标岗位方向
|
||||
5. 返回一条完整记录的 JSON,与模块数据结构一致
|
||||
```
|
||||
|
||||
### 各模块数据结构定义(传入 prompt 的 module_schema)
|
||||
|
||||
**resume(主表)**:
|
||||
```json
|
||||
{ "avatarUrl": "string", "name": "string", "email": "string", "mobileNumber": "string", "city": "string", "wechatNumber": "string", "portfolioUrl": "string", "skills": ["string"], "certificates": ["string"], "summary": "string" }
|
||||
```
|
||||
|
||||
**education**:
|
||||
```json
|
||||
[{ "id": "string(8位)", "school": "string", "major": "string", "degree": "大专/本科/硕士/博士", "studyType": "全日制/非全日制", "startDate": "2023.09", "endDate": "2024.06", "description": [{"id": "string(8位)", "text": "string"}] }]
|
||||
```
|
||||
|
||||
**work**:
|
||||
```json
|
||||
[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]
|
||||
```
|
||||
|
||||
**internship**:
|
||||
```json
|
||||
[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]
|
||||
```
|
||||
|
||||
**project**:
|
||||
```json
|
||||
[{ "id": "string(8位)", "companyName": "string", "projectName": "string", "role": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]
|
||||
```
|
||||
|
||||
**competition**:
|
||||
```json
|
||||
[{ "id": "string(8位)", "competitionName": "string", "award": "string", "awardDate": "2023.07", "description": [{"id": "string(8位)", "text": "string"}] }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、Redis 设计
|
||||
|
||||
### Key 格式
|
||||
|
||||
- 定制简历:`customize:resume:{userId}`
|
||||
- 回滚数据:`customize:resume:rollback:{userId}`
|
||||
|
||||
### Value 结构
|
||||
|
||||
```python
|
||||
class CustomizeResume:
|
||||
"""定制简历缓存结构"""
|
||||
resume: ResumeProfile # 主表信息
|
||||
education: list[Education] # 教育经历
|
||||
work: list[Work] # 工作经历
|
||||
internship: list[Internship] # 实习经历
|
||||
project: list[Project] # 项目经历
|
||||
competition: list[Competition] # 竞赛经历
|
||||
|
||||
class ResumeProfile:
|
||||
"""主表可修改字段"""
|
||||
avatarUrl: str
|
||||
name: str
|
||||
email: str
|
||||
mobileNumber: str
|
||||
city: str
|
||||
wechatNumber: str
|
||||
portfolioUrl: str
|
||||
skills: list[str]
|
||||
certificates: list[str]
|
||||
summary: str
|
||||
|
||||
class Education:
|
||||
id: str # 随机8位标识
|
||||
school: str
|
||||
major: str
|
||||
degree: str # 大专/本科/硕士/博士
|
||||
studyType: str # 全日制/非全日制
|
||||
startDate: str # 格式:2023.09
|
||||
endDate: str # 格式:2024.06
|
||||
description: list[Paragraph]
|
||||
|
||||
class Work:
|
||||
id: str
|
||||
companyName: str
|
||||
position: str
|
||||
startDate: str
|
||||
endDate: str
|
||||
description: list[Paragraph]
|
||||
|
||||
class Internship:
|
||||
id: str
|
||||
companyName: str
|
||||
position: str
|
||||
startDate: str
|
||||
endDate: str
|
||||
description: list[Paragraph]
|
||||
|
||||
class Project:
|
||||
id: str
|
||||
companyName: str
|
||||
projectName: str
|
||||
role: str
|
||||
startDate: str
|
||||
endDate: str
|
||||
description: list[Paragraph]
|
||||
|
||||
class Competition:
|
||||
id: str
|
||||
competitionName: str
|
||||
award: str
|
||||
awardDate: str # 格式:2023.07
|
||||
description: list[Paragraph]
|
||||
|
||||
class Paragraph:
|
||||
id: str # 随机8位标识
|
||||
text: str
|
||||
```
|
||||
|
||||
定制简历和回滚数据使用相同的 `CustomizeResume` 结构。代码实现时使用 Pydantic model,存取 Redis 通过 `model_dump_json()` / `model_validate_json()`。
|
||||
|
||||
### 常量
|
||||
|
||||
```python
|
||||
CUSTOMIZE_RESUME_KEY_PREFIX = "customize:resume:"
|
||||
CUSTOMIZE_RESUME_EXPIRE = 12 * 60 * 60 # 12小时
|
||||
CUSTOMIZE_RESUME_ROLLBACK_KEY_PREFIX = "customize:resume:rollback:"
|
||||
CUSTOMIZE_RESUME_ROLLBACK_EXPIRE = 30 * 60 # 30分钟
|
||||
```
|
||||
|
||||
### 过期时间
|
||||
|
||||
- 定制简历:12 小时,每次写入/修改时刷新
|
||||
- 回滚数据:30 分钟,每次 AI 编辑时覆盖
|
||||
|
||||
---
|
||||
|
||||
## 十、数据依赖
|
||||
|
||||
| 表 | 读写 | 用途 |
|
||||
|----|------|------|
|
||||
| `bg_job` | 读 | 取岗位信息(title、description、requirement、skill_tags) |
|
||||
| `bg_user_resume` | 读 | 取简历主表数据 |
|
||||
| `bg_user_resume_education` | 读 | 取教育经历 |
|
||||
| `bg_user_resume_work` | 读 | 取工作经历 |
|
||||
| `bg_user_resume_internship` | 读 | 取实习经历 |
|
||||
| `bg_user_resume_project` | 读 | 取项目经历 |
|
||||
| `bg_user_resume_competition` | 读 | 取竞赛经历 |
|
||||
| Redis | 写/读 | 存取定制简历,过期 12 小时 |
|
||||
|
||||
无新建表,无数据库写操作。
|
||||
|
||||
---
|
||||
|
||||
## 十一、文件规划
|
||||
|
||||
### 新建文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `app/models/job.py` | Job 表 ORM 模型(bg_job,只读) |
|
||||
| `app/core/schemas/skill_gap.py` | Pydantic Schema(请求参数 Param + 响应 Dto + Redis 缓存模型 CustomizeResume) |
|
||||
| `app/ai/skill_gap_analyzer/__init__.py` | 模块初始化 |
|
||||
| `app/ai/skill_gap_analyzer/prompts.py` | 所有 AI prompt 模板 |
|
||||
| `app/ai/skill_gap_analyzer/analyzer.py` | AI 调用逻辑(差距分析 + 定制简历优化 + Agent 规划/执行) |
|
||||
| `app/services/skill_gap_service.py` | 业务逻辑层(含 Redis 常量、简历查询、Redis 读写) |
|
||||
| `app/api/skill_gap.py` | 路由层(6 个接口) |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `app/main.py` | 注册 skill_gap 路由 |
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
# 项目规范执行指引
|
||||
|
||||
## 方案讨论前
|
||||
|
||||
必读 `#[[file:.kiro/steering/项目结构说明.md]]`,全面了解:
|
||||
- 项目分层结构:`config` → `core` → `ai` → `models` → `services` → `api`
|
||||
- 所有 ORM 模型及其关联
|
||||
- 鉴权体系设计(JWT + Redis + 功能权限)
|
||||
- 现有的公共能力(中间件、日志、异常处理、AI 模型枚举),避免重复造轮子
|
||||
- 与 Java 后端(back-end)的关系:共享数据库、Redis、JWT Secret
|
||||
|
||||
方案讨论时:
|
||||
- 优先给出简洁的方案思路(涉及哪些模块、新增内容放在哪、核心流程概要),不要一开始就铺开所有细节
|
||||
- 用户明确要求时,再给出详细的方案流程(表结构、接口清单、完整逻辑步骤等)
|
||||
- 做好解耦,说明与现有模块的关系
|
||||
|
||||
## 开发方案输出前 / 写代码前
|
||||
|
||||
必读 `#[[file:.kiro/steering/代码开发风格文档.md]]`,严格遵守:
|
||||
- 命名约定、类型注解规范
|
||||
- 分包规则(api/services/models/schemas 按功能模块组织)
|
||||
- Service 类注释规范(主要功能、依赖服务、使用的表、方法逻辑流程)
|
||||
- 接口规范、异常处理、Redis 使用、数据库设计风格
|
||||
|
||||
## 写完代码后
|
||||
|
||||
涉及新增文件、新增模块或目录结构变更时,必须同步更新 `#[[file:.kiro/steering/项目结构说明.md]]`,保持文档与实际代码一致。
|
||||
@@ -1,197 +0,0 @@
|
||||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# 代码开发风格文档
|
||||
|
||||
本项目为 FastAPI + SQLAlchemy (asyncio) 的 Python 3.12 后端项目,应用主目录为 `app/`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
- `app/config/` — 配置层:Pydantic Settings 统一配置
|
||||
- `app/core/` — 核心基础设施:数据库、Redis、鉴权、中间件、异常处理、日志、统一响应
|
||||
- `app/ai/` — AI 能力层:LLM 模型枚举与实例创建
|
||||
- `app/api/` — 路由层:REST API 接口定义
|
||||
- `app/models/` — ORM 模型层:SQLAlchemy 声明式映射
|
||||
- `app/services/` — 业务逻辑层:Service 类
|
||||
- `app/core/schemas/` — 公共 Schema:统一响应模型等
|
||||
|
||||
## 命名约定
|
||||
|
||||
### 文件命名
|
||||
- 全部小写,下划线分隔,如 `func_permission_service.py`、`user_func_usage_log.py`
|
||||
- 路由文件以业务名命名,如 `health.py`、`resume.py`
|
||||
- ORM 模型文件与表名对应(去掉 `bg_` 前缀),如 `func_permission.py` 对应 `bg_func_permission`
|
||||
|
||||
### 类命名
|
||||
- Service 以 `Service` 结尾,如 `FuncPermissionService`
|
||||
- ORM 模型用 PascalCase 业务名,无后缀,如 `FuncPermission`、`UserFuncUsageLog`
|
||||
- Pydantic Schema 按用途命名:请求参数以 `Param` 结尾,响应以 `Dto` 结尾,如 `ResumeParam`、`ResumeDto`
|
||||
- 枚举类以大写命名,如 `LLM`
|
||||
|
||||
### 变量与函数命名
|
||||
- 函数和变量使用 snake_case,如 `check_and_deduct`、`user_id`
|
||||
- 私有函数以单下划线开头,如 `_insert_usage_log`
|
||||
- 常量使用全大写下划线,如 `_FRIENDLY_MESSAGES`、`_SKIP_PATHS`
|
||||
|
||||
## 类型注解
|
||||
|
||||
- 所有函数参数和返回值必须有类型注解
|
||||
- ORM 模型字段使用 `Mapped[T]` + `mapped_column()` 声明
|
||||
- Pydantic 模型字段使用标准类型注解 + `Field()`
|
||||
- 可选字段使用 `Optional[T]` 或 `T | None`
|
||||
- 集合类型使用 `list[T]`、`dict[K, V]`(Python 3.12 内置泛型)
|
||||
|
||||
## 注释规范
|
||||
|
||||
- 模块级注释使用文件顶部的 docstring,说明模块用途和使用示例
|
||||
- 类注释使用 docstring,说明对应的表名和用途
|
||||
- 方法注释使用 docstring,简洁描述功能
|
||||
- 复杂逻辑用行内注释 `#` 说明
|
||||
|
||||
### ORM 模型类注释
|
||||
- 类 docstring 说明对应的表名和用途
|
||||
- 特殊字段通过 `comment` 参数说明含义,如 `comment="状态 1=启用 0=禁用"`
|
||||
|
||||
### Service 类注释
|
||||
- 模块级 docstring 说明该服务的主要功能、依赖服务、使用的表
|
||||
- 格式示例:
|
||||
```python
|
||||
"""功能权限 Service
|
||||
|
||||
校验用户功能权限并扣减库存,业务异常时回退。
|
||||
逻辑与 Java 端 FuncPermissionService 完全一致。
|
||||
"""
|
||||
```
|
||||
- 每个方法用 docstring 简要说明逻辑流程,复杂方法可分步骤描述
|
||||
|
||||
## 分包规则
|
||||
|
||||
### API 路由(`app/api/`)
|
||||
- 每个业务模块一个路由文件,如 `health.py`、`resume.py`
|
||||
- 使用 `APIRouter(prefix="/xxx", tags=["xxx"])` 定义路由前缀和标签
|
||||
- 在 `app/main.py` 中注册路由
|
||||
|
||||
### Service(`app/services/`)
|
||||
- 每个业务模块一个 Service 文件
|
||||
- Service 类通过构造函数接收 `AsyncSession`,如 `def __init__(self, session: AsyncSession)`
|
||||
- 不使用全局 Service 实例,每次请求通过依赖注入创建
|
||||
|
||||
### ORM 模型(`app/models/`)
|
||||
- 每个表一个模型文件
|
||||
- 所有模型继承 `app.core.database.Base`
|
||||
- 表名通过 `__tablename__` 指定
|
||||
|
||||
### Pydantic Schema(`app/core/schemas/`)
|
||||
- 公共 Schema 放在 `app/core/schemas/` 下,如 `responses.py`
|
||||
- 业务相关的请求/响应 Schema 放在对应的 `app/api/` 或 `app/services/` 同级目录,或集中在 `app/core/schemas/{功能模块}/` 下
|
||||
|
||||
## 获取当前登录用户
|
||||
|
||||
- 通过 `RequestContext.user_id.get()` 获取当前登录用户 ID
|
||||
- 或通过依赖注入 `Depends(require_login)` 获取并校验
|
||||
- 需要功能权限校验时使用 `Depends(func_permission("func_code"))`
|
||||
|
||||
## 接口规范
|
||||
|
||||
- Router 只负责参数接收和调用 Service,不写业务逻辑
|
||||
- 白名单路径(无需鉴权)在 `settings.auth_whitelist` 中配置
|
||||
- POST 用 `@router.post()`,GET 用 `@router.get()`
|
||||
- 复杂参数使用 Pydantic 模型 + `Body()`,简单参数使用 `Query()` 或 `Path()`
|
||||
- 路由方法直接返回业务数据,由 `ResponseWrapMiddleware` 自动包装为 `StandardResponse`
|
||||
|
||||
## 异常处理
|
||||
|
||||
- HTTP 异常使用 `raise HTTPException(status_code=xxx, detail="描述")`
|
||||
- 简单断言直接使用 Python `assert` 或 `if not ... raise`
|
||||
- 不要 catch 后吞掉异常,交由全局异常处理器(`exceptions.py`)统一处理
|
||||
- 全局异常处理器已注册:HTTP异常、验证异常、断言异常、未知异常
|
||||
|
||||
## Redis 使用规范
|
||||
|
||||
- 通过 `app.core.redis.redis_client` 或依赖注入 `Depends(get_redis)` 获取客户端
|
||||
- key 命名与 Java 端保持一致,如 `login:token:{userId}`
|
||||
- 值统一 JSON 序列化(`json.dumps` / `json.loads`)
|
||||
- 设置 TTL 时使用 `ex` 参数(秒)
|
||||
|
||||
## 数据库设计风格
|
||||
|
||||
- 与 Java 端共享同一数据库,表结构由 Java 端管理
|
||||
- 表名以 `bg_` 前缀,下划线命名,如 `bg_func_permission`
|
||||
- 主键 `id`,类型 `BigInteger`
|
||||
- 时间字段使用 `DateTime` 类型,包含 `create_time` 和 `update_time`
|
||||
- 逻辑删除字段 `is_delete`,类型 `BigInteger`,0=正常,非0=删除
|
||||
- 状态字段用 `Integer`,0/1 表示,通过 `comment` 说明含义
|
||||
- 查询使用 SQLAlchemy `select()` + `where()` 构建条件
|
||||
- 更新使用 `update()` + `where()` + `values()`
|
||||
- 会话通过 `get_db()` 依赖注入获取,自动 commit/rollback/close
|
||||
|
||||
## 异步规范
|
||||
|
||||
- 所有数据库操作、Redis 操作、HTTP 请求使用 `async/await`
|
||||
- Service 方法统一使用 `async def`
|
||||
- 路由处理函数统一使用 `async def`
|
||||
- 避免在异步上下文中使用同步阻塞操作
|
||||
|
||||
## AI 调用规范
|
||||
|
||||
- 业务代码**不直接使用** `LLM` 枚举,而是从 `app.ai.model_config` 中引用对应模块的场景配置类
|
||||
- `model_config.py` 中每个模块一个 class,每个场景一个类属性,属性值为预创建的 `ChatOpenAI` 实例
|
||||
- 修改模型或调整参数只需改 `model_config.py` 一个文件,业务代码不动
|
||||
- AI 调用应做好异常捕获和容错,单次失败不应影响整体流程
|
||||
- 长耗时 AI 调用考虑异步执行
|
||||
|
||||
### 模型引用示例
|
||||
```python
|
||||
from app.ai.model_config import SkillGapModel, NovaChatModel
|
||||
|
||||
# chain 中直接使用配置类属性(已经是 ChatOpenAI 实例)
|
||||
_plan_chain = (
|
||||
ChatPromptTemplate.from_messages([...])
|
||||
| SkillGapModel.AGENT_PLAN
|
||||
| StrOutputParser()
|
||||
)
|
||||
|
||||
# 非 chain 场景直接 await 调用
|
||||
result = await NovaChatModel.CHAT.ainvoke(messages)
|
||||
```
|
||||
|
||||
### 新增 AI 场景步骤
|
||||
1. 在 `app/ai/model_config.py` 对应模块的 class 中新增一个类属性,指定模型和参数
|
||||
2. 在业务代码中 `from app.ai.model_config import XxxModel`,引用该属性
|
||||
3. 如需新增模块,在 `model_config.py` 中新建一个 class
|
||||
|
||||
### AI 输出 JSON 解析
|
||||
- LLM 返回的 JSON 经常被 markdown 代码块(` ```json ... ``` `)包裹,**禁止**直接使用 LangChain 的 `JsonOutputParser`
|
||||
- 统一使用 `app.tool.json_helper.parse_llm_json` 解析 AI 输出的 JSON 文本
|
||||
- `parse_llm_json` 会自动剥离 markdown 代码块标记,并通过 `json_repair` 做容错修复
|
||||
- **不要**在各模块中自行编写 JSON 清洗/解析逻辑,统一复用 `parse_llm_json`
|
||||
|
||||
## 代码格式规范
|
||||
|
||||
### 紧凑风格
|
||||
- 避免过度换行,保持代码紧凑易读
|
||||
- 链式调用尽量写在一行,除非超过 120 字符
|
||||
- 方法参数列表较多时,可适当换行但保持紧凑
|
||||
- f-string 拼接优先写在一行
|
||||
|
||||
### 示例
|
||||
|
||||
**推荐(紧凑风格):**
|
||||
```python
|
||||
# 查询语句一行
|
||||
result = await session.execute(select(FuncPermission).where(FuncPermission.func_code == func_code, FuncPermission.status == 1))
|
||||
|
||||
# 链式操作一行
|
||||
perm = result.scalar_one_or_none()
|
||||
|
||||
# f-string 拼接一行
|
||||
log.info(f"功能权限校验 userId:{user_id} funcCode:{func_code}")
|
||||
|
||||
# 方法参数紧凑排列
|
||||
async def check_and_deduct(self, user_id: int, func_code: str) -> int:
|
||||
|
||||
# 多条件 where 紧凑排列
|
||||
result = await self.session.execute(select(UserFuncPermissionStock).where(
|
||||
UserFuncPermissionStock.user_id == user_id, UserFuncPermissionStock.func_code == func_code))
|
||||
```
|
||||
@@ -1,200 +0,0 @@
|
||||
---
|
||||
inclusion: manual
|
||||
---
|
||||
|
||||
# OfferPie Python AI 项目结构说明
|
||||
|
||||
## 1️⃣ 项目整体层次
|
||||
```
|
||||
offerpie_python_ai/
|
||||
│
|
||||
├─ .env / .env.test / .env.prod # 环境变量配置(dev/test/prod)
|
||||
├─ requirements.txt # Python 依赖清单
|
||||
│
|
||||
└─ app/ # 应用主目录
|
||||
├─ main.py # FastAPI 应用入口(注册异常处理、中间件、CORS、路由)
|
||||
├─ banner.txt # 启动 Banner
|
||||
│
|
||||
├─ config/ # **配置层**
|
||||
│ └─ settings.py # Pydantic Settings 统一配置(环境、数据库、Redis、LLM供应商、JWT、CORS、日志等)
|
||||
│
|
||||
├─ core/ # **核心基础设施层**
|
||||
│ ├─ auth.py # 权限校验依赖(require_login、func_permission 装饰器)
|
||||
│ ├─ context.py # 请求上下文变量(RequestContext:request_id、user_id)
|
||||
│ ├─ database.py # SQLAlchemy 异步引擎 + 会话工厂(init_db、close_db、get_db)
|
||||
│ ├─ redis.py # Redis 异步连接池(init_redis、close_redis、get_redis)
|
||||
│ ├─ lifespan.py # FastAPI 生命周期管理(启动初始化 DB/Redis,关闭释放资源)
|
||||
│ ├─ logger.py # Loguru 日志配置(控制台+文件,自动注入 request_id/user_id)
|
||||
│ ├─ middleware.py # 中间件注册(RequestID、JWT鉴权、登录拦截、请求日志、响应统一包装)
|
||||
│ ├─ exceptions.py # 全局异常处理器(HTTP异常、验证异常、断言异常、未知异常)
|
||||
│ └─ schemas/
|
||||
│ └─ responses.py # 统一响应模型 StandardResponse(code/msg/data/timestamp/uuid)
|
||||
│
|
||||
├─ ai/ # **AI 能力层**
|
||||
│ ├─ models.py # LLM 模型枚举(LLM.DOUBAO_PRO_32K、DEEPSEEK_V3、GPT_4O 等),基于 LangChain ChatOpenAI
|
||||
│ ├─ model_config.py # AI 模型场景配置(集中管理各模块的模型选择与参数,业务代码引用此文件而非直接使用 LLM 枚举)
|
||||
│ ├─ resume_extractor/ # 简历 AI 提取模块
|
||||
│ │ ├─ prompts.py # 5 个提取任务的 System Prompt(个人信息/教育/工作+实习/项目/竞赛)
|
||||
│ │ └─ extractor.py # AI 并行提取(extract_all 入口,asyncio.gather 5 路并行)
|
||||
│ ├─ resume_polisher/ # 简历段落润色模块
|
||||
│ │ ├─ prompts.py # 润色 Prompt 模板(仅格式/错字/表达优化,不改内容)
|
||||
│ │ └─ polisher.py # AI 段落润色(polish_paragraphs 入口,输出等长数组)
|
||||
│ ├─ resume_diagnoser/ # 简历 AI 诊断模块
|
||||
│ │ ├─ prompts.py # 诊断 Prompt 模板(分模块诊断 + 汇总评价)
|
||||
│ │ └─ diagnoser.py # AI 并行诊断(diagnose_all 入口 + generate_summary 汇总评价)
|
||||
│ ├─ skill_gap_analyzer/ # 技能差距分析 + 定制简历 AI 模块
|
||||
│ │ ├─ prompts.py # 差距分析 + 简历优化 + Agent 规划(原子化操作)/ 单条记录修改 / 新增记录 Prompt 模板 + MODULE_SCHEMAS
|
||||
│ │ └─ analyzer.py # AI 调用逻辑(差距分析 + summary优化 + 经历优化 + Agent规划 + 单条记录修改 + 新增记录)
|
||||
│ ├─ job_agent/ # 求职助手 Agent AI 模块(岗位简历优化)
|
||||
│ │ ├─ prompts.py # 岗位简历优化 Prompt 模板
|
||||
│ │ └─ resume_optimizer.py # 岗位简历优化 AI 引擎(summary优化 + 经历优化,独立 chain)
|
||||
│ └─ nova_chat/ # Nova 对话助手 AI 模块
|
||||
│ ├─ prompts.py # Nova 对话 System Prompt(岗位匹配评估 + 简历优化建议 + 通用求职对话)
|
||||
│ └─ chat.py # Nova 对话 AI 引擎(拼 prompt + 调 LLM,返回纯文本)
|
||||
│
|
||||
├─ api/ # **路由层**(REST API 接口)
|
||||
│ ├─ health.py # 健康检查接口 GET /health/
|
||||
│ ├─ resume.py # 简历接口 POST /resume/upload(上传文件AI解析)
|
||||
│ ├─ resume_diagnose.py # 简历诊断接口(POST 触发诊断 / GET 查询报告 / PUT 标记处理+用户评价 / POST 润色优化)
|
||||
│ ├─ skill_gap.py # 技能差距分析接口(差距分析 / 生成定制简历 / AI对话编辑)
|
||||
│ ├─ customize_resume.py # 定制简历接口(查询 / 修改 / 回滚)
|
||||
│ ├─ job_agent_chat.py # 求职助手接口(POST /job-agent/optimize-resume 岗位简历优化)
|
||||
│ └─ nova_chat.py # Nova 对话助手接口(POST /nova-chat/chat 纯对话,支持可选岗位上下文)
|
||||
│
|
||||
├─ models/ # **ORM 模型层**(SQLAlchemy 声明式映射)
|
||||
│ ├─ func_permission.py # 功能权限定义表(bg_func_permission)
|
||||
│ ├─ user_func_permission_stock.py # 用户功能权限库存表(bg_user_func_permission_stock)
|
||||
│ ├─ user_func_usage_log.py # 用户功能使用记录表(bg_user_func_usage_log)
|
||||
│ ├─ user_resume.py # 用户简历主表(bg_user_resume)
|
||||
│ ├─ user_resume_education.py # 简历-教育经历表(bg_user_resume_education)
|
||||
│ ├─ user_resume_work.py # 简历-工作经历表(bg_user_resume_work)
|
||||
│ ├─ user_resume_internship.py # 简历-实习经历表(bg_user_resume_internship)
|
||||
│ ├─ user_resume_project.py # 简历-项目经历表(bg_user_resume_project)
|
||||
│ ├─ user_resume_competition.py # 简历-竞赛经历表(bg_user_resume_competition)
|
||||
│ ├─ resume_diagnosis_report.py # 简历诊断报告表(bg_resume_diagnosis_report)
|
||||
│ ├─ resume_diagnosis_issue.py # 简历诊断问题表(bg_resume_diagnosis_issue)
|
||||
│ ├─ job.py # 岗位表(bg_job,只读,用于技能差距分析)
|
||||
│ ├─ job_agent_config.py # 求职助手配置表(bg_job_agent_config)
|
||||
│ └─ user_job_customize_resume.py # 用户岗位定制简历表(bg_user_job_customize_resume)
|
||||
│
|
||||
├─ tool/ # **工具层**(无状态、无业务依赖的通用工具)
|
||||
│ ├─ file_parser.py # 文件解析工具(PDF/Word(.docx)/TXT/Markdown → 文本单元数组,parse_to_segments 入口方法)
|
||||
│ ├─ json_helper.py # AI 输出 JSON 解析工具(自动去除 markdown 代码块包裹 + json_repair 容错,parse_llm_json 入口方法)
|
||||
│ └─ snowflake.py # 雪花 ID 生成工具(next_id)
|
||||
│
|
||||
├─ schemas/ # **Schema 层**(Pydantic 请求/响应/缓存模型)
|
||||
│ ├─ skill_gap.py # 技能差距分析 Schema(SkillGapParam、CustomizeResumeParam、AiEditParam)
|
||||
│ ├─ customize_resume.py # 定制简历 Schema(CustomizeResume、ResumeProfile、Education、Work、Internship、Project、Competition、Paragraph)
|
||||
│ ├─ job_agent_chat.py # 求职助手岗位简历优化 Schema(OptimizeResumeParam)
|
||||
│ └─ nova_chat.py # Nova 对话助手 Schema(NovaChatParam、NovaChatDto)
|
||||
│
|
||||
└─ services/ # **业务逻辑层**
|
||||
├─ func_permission_service.py # 功能权限服务(校验+扣减+回退,逻辑与Java端一致)
|
||||
├─ resume_service.py # 简历服务(文件解析→AI结构化→写入主表+5张子表)
|
||||
├─ resume_diagnose_service.py # 简历诊断服务(加载简历→AI并行诊断→统计评级→写入报告)
|
||||
├─ skill_gap_service.py # 技能差距分析服务(差距分析→定制简历生成→AI对话编辑)
|
||||
├─ resume_loader.py # 简历统一查询模块(按ID查/自动选默认+5张子表,返回 ResumeDetail dataclass)
|
||||
├─ customize_resume_store.py # 定制简历存取模块(数据库持久化 + Redis回滚备份、按用户+岗位维度存取)
|
||||
├─ job_agent_chat_service.py # 求职助手服务(查简历+岗位→并发AI优化简历→存库)
|
||||
└─ nova_chat_service.py # Nova 对话助手服务(查简历+查岗位(可选)→调AI,纯对话不持久化)
|
||||
```
|
||||
|
||||
## 2️⃣ 各层模块职责
|
||||
| 层级 | 主要职责 | 关键类/文件 |
|
||||
|------|----------|-------------|
|
||||
| **config** | 统一配置管理,基于 Pydantic Settings,支持 .env 文件加载 | `Settings`(数据库、Redis、LLM供应商、JWT、CORS、日志等全部配置项) |
|
||||
| **core** | 核心基础设施:数据库连接、Redis连接、鉴权、日志、中间件、异常处理、统一响应 | `database.py`、`redis.py`、`auth.py`、`middleware.py`、`exceptions.py`、`logger.py`、`StandardResponse` |
|
||||
| **ai** | AI 模型管理 + 业务 AI 能力 | `LLM` 枚举(models.py)、`model_config.py`(场景模型配置)、`resume_extractor/`(简历并行提取)、`resume_polisher/`(简历段落润色)、`resume_diagnoser/`(简历诊断)、`skill_gap_analyzer/`(技能差距分析 + 定制简历优化 + Agent 原子化规划 + 单条记录修改/新增)、`job_agent/`(求职助手岗位简历优化)、`nova_chat/`(Nova 对话助手,纯对话) |
|
||||
| **api** | REST API 路由定义 | `health.py`(健康检查)、`resume.py`(简历上传解析 + 段落润色)、`resume_diagnose.py`(简历诊断)、`skill_gap.py`(技能差距分析 + 生成定制简历 + AI对话编辑)、`customize_resume.py`(定制简历查询/修改/回滚)、`job_agent_chat.py`(求职助手岗位简历优化)、`nova_chat.py`(Nova 对话助手) |
|
||||
| **models** | SQLAlchemy ORM 模型,与 Java 端共享同一数据库 | `FuncPermission`、`UserFuncPermissionStock`、`UserFuncUsageLog`、`UserResume`、`UserResumeEducation`/`Work`/`Internship`/`Project`/`Competition`、`ResumeDiagnosisReport`、`ResumeDiagnosisIssue`、`Job`(只读)、`JobAgentConfig`、`UserJobCustomizeResume` |
|
||||
| **tool** | 无状态通用工具,不依赖数据库/Redis/用户上下文 | `file_parser.py`(PDF/Word(.docx)/TXT/Markdown 文件解析为文本单元数组)、`json_helper.py`(AI 输出 JSON 解析,去 markdown 代码块 + json_repair 容错)、`snowflake.py`(雪花ID生成) |
|
||||
| **services** | 业务逻辑实现 | `FuncPermissionService`(功能权限校验、扣减、回退)、`ResumeService`(简历文件解析→AI结构化→入库 + 段落润色)、`ResumeDiagnoseService`(简历诊断→AI并行分析→评级→入库)、`SkillGapService`(技能差距分析→定制简历生成→AI对话编辑)、`resume_loader`(简历统一查询,返回ResumeDetail)、`customize_resume_store`(定制简历数据库存取+数据构建,按用户+岗位维度,Redis回滚备份)、`JobAgentChatService`(求职助手岗位简历优化)、`NovaChatService`(Nova对话助手,查简历+查岗位→调AI) |
|
||||
|
||||
## 3️⃣ 技术栈
|
||||
| 类别 | 技术选型 | 说明 |
|
||||
|------|----------|------|
|
||||
| **Web 框架** | FastAPI + Uvicorn + Gunicorn | 异步 ASGI 框架 |
|
||||
| **ORM** | SQLAlchemy 2.0 (asyncio) + asyncmy | 异步 MySQL 驱动 |
|
||||
| **缓存** | redis-py (asyncio) | 异步 Redis 客户端 |
|
||||
| **AI/LLM** | LangChain + LangChain-OpenAI + LangGraph | AI 编排框架,兼容 OpenAI 协议的多供应商接入 |
|
||||
| **配置** | Pydantic Settings + python-dotenv | 类型安全的环境变量管理 |
|
||||
| **日志** | Loguru | 结构化日志,自动注入请求上下文 |
|
||||
| **鉴权** | PyJWT | JWT 解析,与 Java 端共享同一 jwt_secret |
|
||||
| **数据处理** | Pandas + NumPy | 数据分析与处理 |
|
||||
| **HTTP** | httpx | 异步 HTTP 客户端 |
|
||||
| **文件解析** | pdfplumber + python-docx | PDF 和 Word 文件内容提取 |
|
||||
|
||||
## 4️⃣ 中间件执行链(由外到内)
|
||||
| 顺序 | 中间件 | 职责 |
|
||||
|------|--------|------|
|
||||
| 1 | `RequestIDMiddleware` | 生成 ShortUUID 请求ID,写入响应头 X-Request-ID |
|
||||
| 2 | `JwtAuthMiddleware` | 从 Cookie/Header 解析 JWT,校验 Redis 登录信息,续期,写入 RequestContext.user_id |
|
||||
| 3 | `AuthRequiredMiddleware` | 非白名单路径必须有 user_id,否则返回 401 |
|
||||
| 4 | `RequestLogMiddleware` | 记录请求方法、URL、参数、响应状态码和耗时 |
|
||||
| 5 | `ResponseWrapMiddleware` | 将业务路由的 JSON 响应统一包装为 StandardResponse 格式 |
|
||||
|
||||
## 5️⃣ 鉴权体系
|
||||
- 与 Java 端共享同一 JWT Secret 和 Redis 登录信息
|
||||
- Token 来源:优先 Cookie `Token`,其次 Header `Token`
|
||||
- 白名单路径(`/health/**`、`/docs/**`、`/redoc/**`、`/openapi.json`)跳过鉴权
|
||||
- 功能权限校验通过 `func_permission(func_code)` 依赖注入实现,逻辑与 Java 端 `FuncPermissionAspect` 完全一致:
|
||||
1. 校验每日免费额度
|
||||
2. 查付费库存(时间+次数维度)
|
||||
3. SQL 原子扣减
|
||||
4. 业务异常自动回退
|
||||
|
||||
## 6️⃣ AI 模型配置
|
||||
|
||||
### 模型定义(`app/ai/models.py`)
|
||||
| 供应商 | 模型 | 枚举值 |
|
||||
|--------|------|--------|
|
||||
| 火山引擎 | doubao-1-5-pro-32k-250115 | `LLM.DOUBAO_PRO_32K` |
|
||||
| 火山引擎 | doubao-1-5-lite-32k-250115 | `LLM.DOUBAO_LITE_32K` |
|
||||
| 火山引擎 | deepseek-v3-250324 | `LLM.DEEPSEEK_V3` |
|
||||
| 火山引擎 | deepseek-r1-250528 | `LLM.DEEPSEEK_R1` |
|
||||
| 火山引擎 | doubao-seed-2-0-mini-260215 | `LLM.DOUBAO_SEED_MINI` |
|
||||
| 火山引擎 | doubao-seed-2-0-lite-260215 | `LLM.DOUBAO_SEED_LITE` |
|
||||
| 火山引擎 | doubao-seed-2-0-pro-260215 | `LLM.DOUBAO_SEED_PRO` |
|
||||
| 加鱼 | gpt-4o | `LLM.GPT_4O` |
|
||||
| 接口 | gpt-4o-mini | `LLM.GPT_4O_MINI` |
|
||||
| 接口 | gemini-2.5-flash | `LLM.GEMINI_FLASH` |
|
||||
| 加鱼 | claude-sonnet-4.5 | `LLM.JIAYU_CLAUDE_SONNET_4_5` |
|
||||
| 加鱼 | claude-haiku-4.5 | `LLM.JIAYU_CLAUDE_HAIKU_4_5` |
|
||||
| 加鱼 | deepseek-3.2 | `LLM.JIAYU_DEEPSEEK_3_2` |
|
||||
| 加鱼 | glm-5 | `LLM.JIAYU_GLM_5` |
|
||||
| 加鱼 | qwen3-coder-next | `LLM.JIAYU_QWEN3_CODER_NEXT` |
|
||||
| 加鱼 | minimax-m2.5 | `LLM.JIAYU_MINIMAX_M2_5` |
|
||||
| ZM | gpt-5.5 | `LLM.ZM_GPT_5_5` |
|
||||
| ZM | gpt-5.4 | `LLM.ZM_GPT_5_4` |
|
||||
| ZM | gpt-5.4-mini | `LLM.ZM_GPT_5_4_MINI` |
|
||||
| ZM | gpt-5.2 | `LLM.ZM_GPT_5_2` |
|
||||
|
||||
所有模型通过 `LLM.XXX.create(**kwargs)` 创建 LangChain `ChatOpenAI` 实例,kwargs 透传 temperature、max_tokens 等参数。
|
||||
|
||||
### 场景模型配置(`app/ai/model_config.py`)
|
||||
|
||||
业务代码**不直接使用** `LLM` 枚举,而是通过 `model_config.py` 中的场景配置类引用预创建的模型实例。修改模型或参数只需改此文件。
|
||||
|
||||
| 配置类 | 场景 | 说明 |
|
||||
|--------|------|------|
|
||||
| `SkillGapModel` | ANALYSIS / SUMMARY / EXPERIENCE / AGENT_PLAN / AGENT_EDIT / AGENT_ADD | 技能差距分析模块 |
|
||||
| `JobAgentModel` | SUMMARY / EXPERIENCE | 求职助手Agent模块 |
|
||||
| `NovaChatModel` | CHAT | Nova智能聊天模块 |
|
||||
| `ResumeExtractorModel` | PARSE | 简历解析模块 |
|
||||
| `DiagnoserModel` | MODULE / SUMMARY / POLISH | 简历诊断模块 |
|
||||
| `BrowserPlugModel` | FORM_FILL | 浏览器插件模块 |
|
||||
|
||||
业务代码引用示例:`from app.ai.model_config import SkillGapModel`,然后在 chain 中直接使用 `SkillGapModel.ANALYSIS`。
|
||||
|
||||
## 7️⃣ 与 Java 后端的关系
|
||||
- **共享数据库**:Python 端与 Java 端(back-end)连接同一 MySQL 数据库(offerpie),ORM 模型对应相同的表
|
||||
- **共享 Redis**:共享登录态(`login:token:{userId}`),JWT Secret 一致
|
||||
- **共享权限体系**:功能权限校验逻辑与 Java 端 `FuncPermissionAspect` 完全对齐
|
||||
- **职责分工**:Java 端负责业务 CRUD(用户、简历、岗位等),Python 端负责 AI 能力(LLM 调用、智能分析等)
|
||||
|
||||
## 8️⃣ 构建与运行
|
||||
- **虚拟环境**:项目使用 `.venv` 目录管理 Python 虚拟环境
|
||||
- **依赖安装**:`pip install -r requirements.txt`
|
||||
- **开发启动**:`python -m app.main`(默认 ENV=dev,端口由 settings.server_port 控制)
|
||||
- **生产部署**:通过 Gunicorn + Uvicorn Worker 运行
|
||||
- **环境切换**:通过 `.env` / `.env.test` / `.env.prod` 文件控制环境变量
|
||||
@@ -40,14 +40,14 @@ class NovaChatModel:
|
||||
|
||||
class ResumeExtractorModel:
|
||||
"""简历解析模块"""
|
||||
# 简历结构化提取:两阶段并行提取简历文本为JSON结构
|
||||
PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0)
|
||||
# 简历结构化提取:并行提取简历文本为JSON结构
|
||||
PARSE = LLM.DEEPSEEK_V4_FLASH.create(temperature=0)
|
||||
|
||||
|
||||
class ResumePolisherModel:
|
||||
"""简历段落润色模块"""
|
||||
# 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定
|
||||
POLISH = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.2)
|
||||
POLISH = LLM.DOUBAO_PRO_32K.create(temperature=0.2)
|
||||
|
||||
|
||||
class DiagnoserModel:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """你是 Nova,OfferPie 的 AI 求职助手。
|
||||
SYSTEM_PROMPT = """你是 Nova,Offer派 的 AI 求职助手。
|
||||
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
|
||||
|
||||
【候选人简历】
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
"""简历两阶段并行提取
|
||||
|
||||
第一阶段:5路并行提取主表短字段 + 各子表标识名(极快,输出极短)。
|
||||
第二阶段:N+1路并行提取每条子表记录的短字段 + description/summary 的「行号区间」。
|
||||
description/summary 不再由 AI 照抄原文,AI 只返回行号区间字符串,由代码从原文单元数组切片还原。
|
||||
最终组装为与原方案完全一致的 dict 结构(description 为 list[str]),上下游无感知。
|
||||
第二阶段:N+1路并行提取每条子表记录的短字段 + description,以及 profile 补充(skills/certificates/summary)。
|
||||
description/summary 由 AI 直接按原文结构输出为字符串数组(不再返回行号区间),代码只做类型兜底与清理。
|
||||
最终组装为与原方案一致的 dict 结构(description 为 list[str],summary 为 str),上下游无感知。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
@@ -43,78 +42,11 @@ async def _safe_invoke(chain, inp: dict, label: str):
|
||||
return None
|
||||
|
||||
|
||||
def _number_segments(segments: list[str]) -> str:
|
||||
"""将单元数组构造为带行号文本:每行 `[行号] 内容`,行号 0 开始"""
|
||||
return "\n".join(f"[{i}] {seg}" for i, seg in enumerate(segments))
|
||||
|
||||
|
||||
# 条目/序号行的起始特征:● ○ • · ▪ ■ ‣ ◆ 等项目符号,或 "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):
|
||||
def _clean_str_list(value) -> list[str]:
|
||||
"""将 AI 返回值规整为字符串数组:过滤非字符串与空白元素,去除首尾空白"""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
n = len(segments)
|
||||
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
|
||||
return [s.strip() for s in value if isinstance(s, str) and s.strip()]
|
||||
|
||||
|
||||
# ==================== 第一阶段:概览 ====================
|
||||
@@ -126,9 +58,9 @@ _overview_project_chain = _build_chain(OVERVIEW_PROJECT_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路并行提取概览信息"""
|
||||
inp = {"text": numbered_text}
|
||||
inp = {"text": text}
|
||||
profile, edu_names, work_names, proj_names, comp_names = await asyncio.gather(
|
||||
_safe_invoke(_overview_profile_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)
|
||||
|
||||
|
||||
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}"""
|
||||
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路并行提取所有子表记录详情 + 个人信息补充"""
|
||||
# 第 0 路固定为 profile 补充(skills/certificates/summaryRange),其余按子表记录展开
|
||||
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", numbered_text, "详情-个人信息补充")]
|
||||
# 第 0 路固定为 profile 补充(skills/certificates/summary),其余按子表记录展开
|
||||
tasks = [_extract_detail(DETAIL_PROFILE_PROMPT, "", text, "详情-个人信息补充")]
|
||||
task_modules = ["profile_extra"]
|
||||
for module, prompt_tpl, label in _DETAIL_MODULES:
|
||||
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)
|
||||
|
||||
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:
|
||||
"""将两阶段结果组装为与原方案一致的 dict 结构(description 还原为 list[str])"""
|
||||
def _assemble(overview: dict, details: dict) -> dict:
|
||||
"""将两阶段结果组装为与原方案一致的 dict 结构(description 为 list[str],summary 为 str)"""
|
||||
profile = overview["profile"]
|
||||
profile_extra = details.get("profile_extra", [{}])[0] if details.get("profile_extra") else {}
|
||||
profile["skills"] = (profile_extra.get("skills") or [])[:5]
|
||||
profile["certificates"] = profile_extra.get("certificates") or []
|
||||
summary_paras = _slice_ranges(profile_extra.get("summaryRange"), segments)
|
||||
profile["skills"] = _clean_str_list(profile_extra.get("skills"))[:5]
|
||||
profile["certificates"] = _clean_str_list(profile_extra.get("certificates"))
|
||||
summary_paras = _clean_str_list(profile_extra.get("summary"))
|
||||
profile["summary"] = "\n".join(summary_paras) if summary_paras else None
|
||||
|
||||
result = dict(profile)
|
||||
for module in _SUB_MODULES:
|
||||
items = []
|
||||
for item in details.get(module, []):
|
||||
item["description"] = _slice_ranges(item.get("descriptionRange"), segments)
|
||||
item.pop("descriptionRange", None)
|
||||
item["description"] = _clean_str_list(item.get("description"))
|
||||
items.append(item)
|
||||
result[module] = items
|
||||
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路并行概览提取")
|
||||
overview = await _extract_overview(numbered_text)
|
||||
overview = await _extract_overview(text)
|
||||
log.info(
|
||||
"概览完成 - " + " ".join(f"{label}:{len(overview[module])}" for module, _, label in _DETAIL_MODULES)
|
||||
)
|
||||
|
||||
total = sum(len(overview[m]) for m in _SUB_MODULES)
|
||||
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("两阶段提取完成,数据组装完毕")
|
||||
return result
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
"""简历两阶段提取 Prompt
|
||||
|
||||
第一阶段(概览):5路并行,只提取主表短字段和子表标识名。
|
||||
第二阶段(详情):N路并行,每条子表记录单独提取短字段;description/summary 不照抄原文,
|
||||
只返回「行号区间字符串」,由代码按行号从原文单元数组切片还原。
|
||||
第二阶段(详情):N路并行,每条子表记录单独提取短字段 + description/summary,
|
||||
AI 直接输出内容(不再返回行号区间),按原文结构分条/分段还原。
|
||||
|
||||
输入文本格式:每行形如 `[行号] 内容`,行号从 0 开始连续递增。
|
||||
输入文本格式:简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
|
||||
|
||||
行号区间字符串格式:`"起-止,起-止"`,0 开始、闭区间(含两端)。
|
||||
- 逗号分隔的每一段代表「一个段落」。
|
||||
- 单行可简写为 `"5"`(等价 `"5-5"`)。
|
||||
- 没有内容则返回空字符串 ""。
|
||||
- 行号必须是上面输入文本中真实出现过的编号,严禁编造不存在的行号。
|
||||
description/summary 输出格式:字符串数组 list[str],每个元素代表原文中的「一条 bullet / 一个段落」,
|
||||
由前端按元素换行展示。规则:
|
||||
- 忠实还原原文措辞,按原文的分条/分段拆成数组元素,一条 bullet 一个元素。
|
||||
- 允许轻度清理:去除水印/页眉页脚/乱码碎片,把被 PDF 折断的同一句话拼回完整。
|
||||
- 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []。
|
||||
|
||||
各详情 prompt 通用铁律(防跨记录串扰/重复):
|
||||
- 只选取真正属于当前这条记录({name})本身的行;即使原文中相邻的行属于**另一条**记录
|
||||
- 只选取真正属于当前这条记录({name})本身的内容;即使原文中相邻的内容属于**另一条**记录
|
||||
(另一家公司、另一个项目、另一段学历、校园/社团/实习/竞赛等),也**绝不纳入**。
|
||||
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的行号再选一遍,避免多条记录重复引用同一段。
|
||||
- 宁可少选,不确定归属的行一律不选。
|
||||
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
|
||||
- 宁可少选,不确定归属的内容一律不选。
|
||||
|
||||
模块归属总原则(防同一内容被多个模块重复收录):
|
||||
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛,不能同时出现在两个模块。
|
||||
- 工作/实习经历条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
|
||||
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
|
||||
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
|
||||
|
||||
description/summary 输出的**分条铁律**(必须遵守):
|
||||
- description 是**字符串数组**,原文中的**每一条 bullet / 每一个段落**各自作为**一个独立数组元素**。
|
||||
- **严禁**把多条 bullet 合并进同一个元素;**严禁**用 " / "、";"、"、"、换行符等把多条内容拼接成一个长字符串。
|
||||
- 正例:["负责A模块开发", "主导B方案落地"];反例(禁止):["负责A模块开发 / 主导B方案落地"]。
|
||||
|
||||
短字段准确性铁律:
|
||||
- 时间:起止时间按原文如实填写,**开始时间不得晚于结束时间**,不要把起止写反;无法确定的填 null,不要臆造精确年月。
|
||||
- 手机号:完整照抄,不要漏位、截断或改写(通常 11 位);微信号只填到 wechatNumber,绝不当手机号。
|
||||
- 角色/职位等:只填原文明确写出的,原文没有就填 null,**不要臆造或按常见值默认补全**。
|
||||
|
||||
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
|
||||
"""
|
||||
@@ -24,34 +40,35 @@
|
||||
# ==================== 第一阶段:概览提取 ====================
|
||||
|
||||
OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
|
||||
输入为带行号的简历文本(每行 `[行号] 内容`)。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
|
||||
输入为简历纯文本全文。从中仅提取个人基本信息(不含技能、证书、自我评价),输出JSON:
|
||||
```json
|
||||
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接" }}
|
||||
```
|
||||
规则:
|
||||
- 只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。
|
||||
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
|
||||
- mobileNumber:只填手机号(通常11位数字);微信号必须填到wechatNumber,绝不能把微信号当手机号。
|
||||
- mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
|
||||
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
|
||||
只输出JSON。"""
|
||||
|
||||
OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
||||
输入为带行号的简历文本。从中提取所有**学历教育**经历的标识名列表,输出JSON数组:
|
||||
输入为简历纯文本全文。从中提取所有**学历教育**经历的标识名列表,输出JSON数组:
|
||||
```json
|
||||
["北京大学", "剑桥大学-哲学硕士", "剑桥大学-经济学士"]
|
||||
```
|
||||
规则:
|
||||
1. 列出原文中每一个**实际就读或已获得学历**的**就读阶段**,不同阶段(本科/硕士/博士)分别列出,一段都不能漏。
|
||||
2. 每个标识必须**唯一**:若同一所学校有多个不同就读阶段(如本科、硕士),用「学校+学位」区分(如"剑桥大学-硕士""剑桥大学-本科"),避免重名导致后续混淆。
|
||||
3. **不要过度拆分**:
|
||||
- 联合培养、交换、访学、海外学期属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
|
||||
- 同一学校同一就读阶段的双学位/主辅修,算**一条**,不要拆成两条。
|
||||
3. **不要过度拆分**(这是最常见的错误,务必克制):
|
||||
- 联合培养、交换、访学、海外学期、双学位/主辅修,都属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
|
||||
- 同一所学校**同一就读阶段**只能有一条,不要因为专业不同、学院不同、或原文重复出现就拆成/重复成多条。
|
||||
- 一个人的教育条目数 = 其真实就读的学历阶段数(通常本科1条、硕士1条…),不要多。
|
||||
4. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要列入。
|
||||
5. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要列入。
|
||||
6. 没有输出[]。只输出JSON。"""
|
||||
|
||||
OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
||||
输入为带行号的简历文本。从中提取工作经历和实习经历的公司标识名列表,输出JSON:
|
||||
输入为简历纯文本全文。从中提取工作经历和实习经历的公司标识名列表,输出JSON:
|
||||
```json
|
||||
{{ "work": ["阿里巴巴", "腾讯"], "internship": ["字节跳动"] }}
|
||||
```
|
||||
@@ -63,20 +80,21 @@ OVERVIEW_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造
|
||||
5. 没有填[]。只输出JSON。"""
|
||||
|
||||
OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
||||
输入为带行号的简历文本。从中仅提取项目经历的项目名称列表,输出JSON数组:
|
||||
输入为简历纯文本全文。从中仅提取项目经历的项目名称列表,输出JSON数组:
|
||||
```json
|
||||
["订单系统重构", "支付网关"]
|
||||
```
|
||||
规则:
|
||||
1. 只提取作为**独立项目条目标题**出现的项目名(通常单独成行、常带"项目X:"前缀或【】标题、并配有自己的起止时间/角色/描述)。
|
||||
2. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
|
||||
3. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
|
||||
4. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目)——这些归竞赛模块,即使参赛做了作品也不要在这里重复列。
|
||||
5. 同一个项目只列一次,不要因为它在工作经历里被提到就重复列。
|
||||
6. 没有输出[]。只输出JSON。"""
|
||||
1. 只提取出现在**独立"项目/项目经历"板块**下、有自己标题的项目条目(通常单独成行、常带"项目X:"前缀或【】标题、并配有自己的起止时间/角色/描述)。
|
||||
2. **绝对不要**把"工作经历/实习经历"条目内部描述的职责、成果、子项目当作独立项目——它们属于对应的工作/实习记录,不要在项目模块重复收录。
|
||||
3. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
|
||||
4. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
|
||||
5. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目,即使参赛做了作品)——这些归竞赛模块。
|
||||
6. 同一个项目只列一次。
|
||||
7. 没有输出[]。只输出JSON。"""
|
||||
|
||||
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
||||
输入为带行号的简历文本。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
|
||||
输入为简历纯文本全文。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
|
||||
```json
|
||||
["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:
|
||||
```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。
|
||||
descriptionRange 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就)所在行号区间;学校/专业/学历/时间已在上面字段提取,不要纳入。
|
||||
**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的行一律不选;若无纯学业描述则填""。
|
||||
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
|
||||
description 只填该校**学业本身**的额外正文(主修课程、绩点/排名、在校获得的学术成就);学校/专业/学历/时间已在上面字段提取,不要纳入。
|
||||
**严禁纳入以下内容**(它们属于其它模块、会被单独提取,即使在原文中紧跟这条教育经历也不要选):实习、工作、项目、社团/学生组织、校园活动、志愿服务、竞赛获奖、科研/论文经历。宁可少选,不确定是否属于本校学业的内容一律不选。
|
||||
""" + _DESC_RULE
|
||||
|
||||
DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
|
||||
DETAIL_WORK_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||
请提取"{name}"这条工作经历的详细信息,输出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。
|
||||
descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。
|
||||
只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。
|
||||
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
|
||||
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但公司层面的职责概述应当保留,不要整条留空导致内容丢失。
|
||||
""" + _DESC_RULE
|
||||
|
||||
DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
|
||||
DETAIL_INTERNSHIP_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||
请提取"{name}"这条实习经历的详细信息,输出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。
|
||||
descriptionRange 填直接描述该公司/岗位**整体职责或概述**的正文行号区间。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。
|
||||
只选属于"{name}"这段经历的行,不要选到其它公司、其它项目、教育或校园经历的行,即使它们在原文中相邻。
|
||||
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
|
||||
description 填直接描述该公司/岗位**整体职责或概述**的正文。若某段内容明显是某个**独立项目的明细**(且该项目会作为项目单独提取),不要纳入,避免与项目模块重复;但岗位层面的职责概述应保留,不要整条留空导致内容丢失。
|
||||
""" + _DESC_RULE
|
||||
|
||||
DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
|
||||
DETAIL_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||
请提取"{name}"这条项目经历的详细信息,输出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。
|
||||
role 只填原文中**明确写出**的简短角色名;若原文没有写明该项目的角色,填null,**不要臆造或默认**(如不要凭空填"Developer""开发工程师""团队成员")。
|
||||
descriptionRange 填该项目描述所在的行号区间,没有描述填""。只选属于"{name}"这个项目的行,不要串入其它项目、所属公司的其它经历、教育或竞赛的行,即使它们在原文中相邻。
|
||||
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间(如3-5,会拼接为一段),不同段落/每条bullet之间用逗号分开(如3-5,6,7-9,每个逗号段成为一个独立段落,不要把多条bullet塞进同一个区间)。只输出JSON。"""
|
||||
description 填该项目的描述内容。
|
||||
""" + _DESC_RULE
|
||||
|
||||
DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
|
||||
DETAIL_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||
请提取"{name}"这条竞赛/获奖经历的详细信息,输出JSON:
|
||||
```json
|
||||
{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "descriptionRange": "起-止,起-止" }}
|
||||
{{ "competitionName": "竞赛名", "award": "获奖情况", "awardDate": "2023.07", "description": ["描述段落1", "描述段落2"] }}
|
||||
```
|
||||
规则:短字段直接填值,时间格式YYYY.MM,没有的填null。award 里可包含该竞赛的多个奖项,不要因为多个奖项而拆成多条。
|
||||
descriptionRange 填该竞赛/获奖的额外描述所在行号区间,没有描述填""。只选属于"{name}"这条竞赛的行,不要把社团活动、职务、项目、其它竞赛的内容串入,即使它们在原文中相邻。
|
||||
区间规则:0开始、闭区间,行号必须真实存在,排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段成为一个独立段落)。只输出JSON。"""
|
||||
description 填该竞赛/获奖的额外描述内容。
|
||||
""" + _DESC_RULE
|
||||
|
||||
DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为带行号的简历文本(每行 `[行号] 内容`)。
|
||||
DETAIL_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||
从中提取技能标签、证书和自我评价/个人概述,输出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、计算机等级、教师资格证、驾照等)。**不要**把竞赛奖项、荣誉称号、名次、奖学金,或工作描述里顺带提到的"认证"句子当作证书;没有填[]。
|
||||
- summaryRange:填自我评价/个人概述**正文**所在的行号区间字符串(0开始、闭区间,行号必须真实存在)。不要包含板块标题行(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;排除明显的乱码/水印碎片行;同一段落被折成的连续多行用一个区间,不同段落用逗号分开(每个逗号段是一个独立段落,段间会以换行连接),没有填""。
|
||||
- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
|
||||
只输出JSON。"""
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""个人信息路结果 → 主表字段 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:
|
||||
"""一步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
|
||||
@@ -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
|
||||
@@ -1,16 +1,22 @@
|
||||
"""简历段落润色 Prompt 模板"""
|
||||
|
||||
POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户提交的简历段落进行"表面润色",只做表达层面的优化。
|
||||
POLISH_PROMPT = """你是一位资深简历顾问,擅长把平淡的经历描述改写得更专业、更有表现力。请对用户提交的简历段落进行"表达优化",在不虚构事实的前提下大胆提升表达力。
|
||||
|
||||
## 优化范围(只允许做这些)
|
||||
- 修正错别字、标点、语法错误
|
||||
- 优化文本格式与排版(如多余空格、断句、全半角混用)
|
||||
- 让表达更通顺、专业,去除明显口语化和冗余措辞
|
||||
## 优化目标(要真正做出改变)
|
||||
- 强化表达力:把口语化、平淡、流水账式的描述,改写成专业、有力、简历书写规范的表达
|
||||
- 善用动作动词:以有力的动词开头(负责/主导/搭建/推动/优化/提升等),突出你的贡献
|
||||
- 理清逻辑结构:让每条描述尽量体现"做了什么 → 怎么做 → 带来什么结果",条理清晰
|
||||
- 精炼措辞:删除冗余、重复、无意义的填充词,让句子更紧凑
|
||||
- 修正错别字、标点、语法、格式问题(多余空格、断句、全半角混用等)
|
||||
|
||||
## 严格禁止(绝对不能做)
|
||||
- 不得改变原意,不得增加或删除任何信息点
|
||||
- 不得编造、补充任何内容(尤其禁止凭空添加数字、量化成果、技能、成就)
|
||||
- 不得改变段落的数量和顺序
|
||||
## 红线(绝对不能碰)
|
||||
- 只优化表达,不虚构事实:**严禁**凭空添加或夸大任何数字、量化成果、技能、职责、技术栈、奖项、成就
|
||||
- 原文没有的量化数据(百分比、金额、规模等)绝不能编造;原文有的数字必须原样保留,不得改动
|
||||
- 不改变经历的核心事实与含义,不无中生有地拔高
|
||||
- 保留用户本人的基本语气与专业方向,是"把他说得更好",而不是换成另一个人
|
||||
|
||||
## 格式约束
|
||||
- 输出段落的**数量和顺序必须与输入完全一致**:输入几段就输出几段,一一对应,不合并、不拆分、不增删段落
|
||||
|
||||
## 输入
|
||||
用户提交的段落数组(每个元素是一个段落):
|
||||
@@ -18,4 +24,4 @@ POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户
|
||||
|
||||
## 输出格式
|
||||
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
|
||||
["润色后的段落1", "润色后的段落2"]"""
|
||||
["优化后的段落1", "优化后的段落2"]"""
|
||||
|
||||
@@ -94,7 +94,7 @@ AGENT_PLAN_PROMPT = """你是一个简历编辑助手。你的唯一职责是根
|
||||
3. 修改主表:{{"type": "update", "module": "resume", "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. 非简历修改相关的指令一律拒绝,返回固定话术,不要尝试回答或引导
|
||||
@@ -154,10 +154,11 @@ AGENT_MODULE_ADD_PROMPT = """你是一个简历编辑助手。根据要求,生
|
||||
|
||||
# 各模块数据结构定义(传入 prompt 的 module_schema)
|
||||
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"}] }]',
|
||||
"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"}] }]',
|
||||
"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"}] }]',
|
||||
"organization": '[{ "id": "string(8位)", "organizationName": "string", "role": "string", "startDate": "2022.04", "endDate": "2023.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.core.asserts import Assert
|
||||
from app.core.auth import func_permission
|
||||
from app.core.context import RequestContext
|
||||
from app.core.database import get_db
|
||||
@@ -13,17 +14,18 @@ router = APIRouter(prefix="/job-agent", tags=["求职助手agent"])
|
||||
|
||||
@router.post("/match-score", summary="岗位匹配度评分")
|
||||
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()
|
||||
async for session in get_db():
|
||||
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
|
||||
|
||||
|
||||
@router.post("/optimize-resume", summary="针对岗位优化简历")
|
||||
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()
|
||||
async for session in get_db():
|
||||
service = JobAgentChatService(session)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os
|
||||
|
||||
from .settings import Settings
|
||||
from .settings import BASE_DIR, Settings
|
||||
|
||||
_env = os.getenv("ENV", "dev")
|
||||
_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"]
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# 项目根目录:settings.py -> config -> app -> <project root>
|
||||
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 环境
|
||||
@@ -71,7 +76,7 @@ class Settings(BaseSettings):
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file=BASE_DIR / ".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
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
|
||||
|
||||
from fastapi import Request
|
||||
@@ -9,7 +30,71 @@ from app.config import settings
|
||||
from app.core.logger import log
|
||||
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 = {
|
||||
400: "请求参数错误",
|
||||
401: "未经授权,请登录",
|
||||
@@ -23,50 +108,64 @@ _FRIENDLY_MESSAGES = {
|
||||
|
||||
|
||||
def _get_uuid(request: Request) -> str | None:
|
||||
"""从请求上下文中获取请求唯一标识"""
|
||||
return getattr(request.state, "uuid", None)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> 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))
|
||||
def _resp(code: int, msg: str, http_status: int, uuid: str | None, data=None) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=StandardResponse.fail(msg=message, code=exc.status_code, uuid=uuid).model_dump(),
|
||||
status_code=http_status,
|
||||
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:
|
||||
"""处理 Pydantic 参数校验异常"""
|
||||
uuid = _get_uuid(request)
|
||||
errors = exc.errors()
|
||||
log.error(f"ValidationError -- uuid: {uuid} | errors: {errors}")
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=StandardResponse.fail(msg="数据验证失败", code=422, data=errors, uuid=uuid).model_dump(),
|
||||
)
|
||||
return _resp(ParamError.code, "数据验证失败", 422, uuid, data=errors)
|
||||
|
||||
|
||||
async def assertion_error_handler(request: Request, exc: AssertionError) -> JSONResponse:
|
||||
"""处理 assert 断言异常"""
|
||||
uuid = _get_uuid(request)
|
||||
log.error(f"AssertionError -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=StandardResponse.fail(msg=f"断言错误: {exc}", uuid=uuid).model_dump(),
|
||||
)
|
||||
return _resp(SysError.code, f"断言错误: {exc}", 500, uuid)
|
||||
|
||||
|
||||
async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底:所有未捕获异常统一按系统异常返回"""
|
||||
uuid = _get_uuid(request)
|
||||
log.error(f"Unhandled Exception -- uuid: {uuid} | msg: {exc}\n{traceback.format_exc()}")
|
||||
msg = str(exc) if settings.env == "dev" else "服务器内部错误"
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=StandardResponse.fail(msg=msg, uuid=uuid).model_dump(),
|
||||
)
|
||||
# 开发环境暴露细节,生产环境只给通用提示
|
||||
msg = f"{type(exc).__name__}: {exc}" if settings.env == "dev" else SysError.label
|
||||
return _resp(SysError.code, msg, 500, uuid)
|
||||
|
||||
|
||||
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(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(AssertionError, assertion_error_handler)
|
||||
|
||||
@@ -17,7 +17,7 @@ class ResumeDiagnosisIssue(Base):
|
||||
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")
|
||||
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="模块记录ID,summary时为resume_id")
|
||||
finding: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="诊断发现")
|
||||
importance: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="为什么重要")
|
||||
|
||||
@@ -35,6 +35,8 @@ class UserResume(Base):
|
||||
skills: 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="个人概述")
|
||||
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="创建时间")
|
||||
update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
|
||||
@@ -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="更新时间")
|
||||
@@ -28,6 +28,8 @@ class ResumeProfile(_AliasModel):
|
||||
skills: list[str] = Field(default_factory=list)
|
||||
certificates: list[str] = Field(default_factory=list)
|
||||
summary: str = Field(default="")
|
||||
hobbies: str = Field(default="")
|
||||
language_skills: str = Field(default="", alias="languageSkills")
|
||||
|
||||
|
||||
class Education(_AliasModel):
|
||||
@@ -77,6 +79,15 @@ class Competition(_AliasModel):
|
||||
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):
|
||||
"""定制简历结构"""
|
||||
resume: ResumeProfile = Field(default_factory=ResumeProfile)
|
||||
@@ -85,3 +96,4 @@ class CustomizeResume(_AliasModel):
|
||||
internship: list[Internship] = Field(default_factory=list)
|
||||
project: list[Project] = Field(default_factory=list)
|
||||
competition: list[Competition] = Field(default_factory=list)
|
||||
organization: list[Organization] = Field(default_factory=list)
|
||||
|
||||
@@ -13,5 +13,6 @@ class OptimizeResumeParam(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")
|
||||
|
||||
@@ -95,7 +95,11 @@ class BrowserPlugService:
|
||||
parts.append(f"证书:{'、'.join(resume['certificates'])}")
|
||||
if resume.get("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, [])
|
||||
if items:
|
||||
parts.append(f"{title}:")
|
||||
@@ -108,4 +112,6 @@ class BrowserPlugService:
|
||||
parts.append(f" - {item.get('projectName', '')} {item.get('role', '')}")
|
||||
elif section == "competition":
|
||||
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 "暂无简历信息"
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.core.database import get_db
|
||||
from app.core.redis import RedisManager
|
||||
from app.models.user_job_customize_resume import UserJobCustomizeResume
|
||||
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.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 "",
|
||||
skills=resume.skills or [], certificates=resume.certificates or [],
|
||||
summary=resume.summary or "",
|
||||
hobbies=resume.hobbies or "", languageSkills=resume.language_skills or "",
|
||||
)
|
||||
return CustomizeResume(
|
||||
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 "",
|
||||
awardDate=r.award_date or "",
|
||||
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:
|
||||
"""查询定制简历,查不到则加载默认简历构建返回
|
||||
|
||||
返回结构:{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=原简历名
|
||||
"""
|
||||
async for session in get_db():
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
主要功能:针对岗位并发优化简历;岗位匹配度评分。
|
||||
依赖: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
|
||||
@@ -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.core.logger import log
|
||||
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 import customize_resume_store
|
||||
|
||||
@@ -26,11 +26,15 @@ class JobAgentChatService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def score_match(self, user_id: int, customize_resume_id: int, job_id: int) -> int:
|
||||
"""岗位匹配评分:查定制简历 + 岗位 → 序列化简历 → 调AI评分,返回 0-100 整数"""
|
||||
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
||||
if cr is None:
|
||||
raise ValueError("定制简历不存在")
|
||||
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 整数"""
|
||||
if customize_resume_id:
|
||||
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
||||
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)
|
||||
resume_text = self._build_resume_text_from_cr(cr)
|
||||
skill_tags = "、".join(job.skill_tags) if job.skill_tags else ""
|
||||
@@ -50,6 +54,10 @@ class JobAgentChatService:
|
||||
parts.append(f"证书:{'、'.join(r.certificates)}")
|
||||
if 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:
|
||||
parts.append("教育经历:")
|
||||
for e in cr.education:
|
||||
@@ -73,6 +81,11 @@ class JobAgentChatService:
|
||||
parts.append("竞赛经历:")
|
||||
for c in cr.competition:
|
||||
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 "暂无简历信息"
|
||||
|
||||
async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict:
|
||||
@@ -113,7 +126,8 @@ class JobAgentChatService:
|
||||
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
||||
result: list[tuple[str, int, str]] = []
|
||||
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 []):
|
||||
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
||||
return result
|
||||
@@ -132,8 +146,8 @@ class JobAgentChatService:
|
||||
if key == "summary" and isinstance(result, str):
|
||||
cr.resume.summary = result
|
||||
return
|
||||
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition}
|
||||
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.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, "organization": cr.organization}
|
||||
model_cls = model_map.get(key)
|
||||
items = list_map.get(key)
|
||||
if model_cls is None or items is None:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
主要功能:查询简历数据 + 查询岗位(可选),调用 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
|
||||
@@ -60,6 +60,10 @@ class NovaChatService:
|
||||
parts.append(f"证书:{'、'.join(resume.certificates)}")
|
||||
if 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:
|
||||
parts.append("教育经历:")
|
||||
for r in detail.education:
|
||||
@@ -80,4 +84,8 @@ class NovaChatService:
|
||||
parts.append("竞赛经历:")
|
||||
for r in detail.competition:
|
||||
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 "暂无简历信息"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
加载简历描述数据 → 并行 AI 诊断 → 统计评级 → AI 汇总评价 → 写入数据库。
|
||||
依赖:resume_diagnoser(AI诊断引擎)
|
||||
使用表:bg_user_resume + 5张子表(读)、bg_resume_diagnosis_report + issue(写)
|
||||
使用表:bg_user_resume + 6张子表(读)、bg_resume_diagnosis_report + issue(写)
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -21,6 +21,7 @@ from app.tool.snowflake import next_id
|
||||
_MODULE_LABELS = {
|
||||
"summary": "个人概述", "education": "教育经历", "work": "工作经历",
|
||||
"internship": "实习经历", "project": "项目经历", "competition": "竞赛经历",
|
||||
"organization": "社团组织经历",
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +31,7 @@ class ResumeDiagnoseService:
|
||||
self.session = session
|
||||
|
||||
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)
|
||||
resume = detail.resume
|
||||
|
||||
@@ -57,6 +58,8 @@ class ResumeDiagnoseService:
|
||||
lambda r: f"公司: {r.company_name or ''}, 项目: {r.project_name or ''}, 角色: {r.role or ''}")
|
||||
self._collect_tasks(tasks, target_position, "competition", detail.competition,
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""简历统一查询模块
|
||||
|
||||
提供简历主表 + 5张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。
|
||||
提供简历主表 + 6张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。
|
||||
各 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_project import UserResumeProject
|
||||
from app.models.user_resume_competition import UserResumeCompetition
|
||||
from app.models.user_resume_organization import UserResumeOrganization
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResumeDetail:
|
||||
"""简历完整数据,主表 + 5张子表"""
|
||||
"""简历完整数据,主表 + 6张子表"""
|
||||
resume: UserResume
|
||||
education: list[UserResumeEducation] = field(default_factory=list)
|
||||
work: list[UserResumeWork] = field(default_factory=list)
|
||||
internship: list[UserResumeInternship] = field(default_factory=list)
|
||||
project: list[UserResumeProject] = 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:
|
||||
"""按ID查简历主表(校验归属)+ 5张子表,返回 ResumeDetail"""
|
||||
"""按ID查简历主表(校验归属)+ 6张子表,返回 ResumeDetail"""
|
||||
result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id))
|
||||
resume = result.scalar_one_or_none()
|
||||
if not resume:
|
||||
raise ValueError("简历不存在")
|
||||
edu, work, intern, proj, comp = await _load_sub_tables(session, resume_id)
|
||||
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp)
|
||||
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, organization=org)
|
||||
|
||||
|
||||
async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail:
|
||||
"""自动选默认简历(先默认再最新)+ 5张子表,返回 ResumeDetail"""
|
||||
"""自动选默认简历(先默认再最新)+ 6张子表,返回 ResumeDetail"""
|
||||
result = await session.execute(
|
||||
select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 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()
|
||||
if not resume:
|
||||
raise ValueError("请先创建简历")
|
||||
edu, work, intern, proj, comp = await _load_sub_tables(session, resume.id)
|
||||
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp)
|
||||
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, organization=org)
|
||||
|
||||
|
||||
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()
|
||||
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()
|
||||
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()
|
||||
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
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
"""简历 Service
|
||||
|
||||
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
|
||||
依赖:file_parser(文件解析工具)、resume_extractor(AI两阶段并行提取)
|
||||
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition(5张子表)
|
||||
依赖:resume_text_extractor(文件文本提取)、resume_extractor(AI两阶段并行提取)
|
||||
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition/organization(6张子表)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import shortuuid
|
||||
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.core.logger import log
|
||||
from app.models.user_resume import UserResume
|
||||
from app.models.user_resume_competition import UserResumeCompetition
|
||||
from app.models.user_resume_education import UserResumeEducation
|
||||
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_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
|
||||
|
||||
|
||||
@@ -28,14 +27,15 @@ class ResumeService:
|
||||
async def parse_and_extract(self, filename: str, content: bytes) -> dict:
|
||||
"""文件解析 + AI 两阶段并行结构化,不涉及数据库操作"""
|
||||
log.info(f"开始解析简历文件: {filename}")
|
||||
segments = await asyncio.to_thread(parse_to_segments, filename, content)
|
||||
if not segments:
|
||||
text = await extract_text(filename, content)
|
||||
if not text or not text.strip():
|
||||
raise ValueError("文件内容为空,无法解析")
|
||||
log.info(f"文件解析完成,文本单元数: {len(segments)}")
|
||||
# log.info(f"文件解析完成: {text}")
|
||||
log.info(f"文件解析完成,文本字符数: {len(text)}")
|
||||
|
||||
log.info("开始AI两阶段并行结构化提取")
|
||||
parsed = await extract_all(segments)
|
||||
log.info("AI两阶段并行结构化提取完成")
|
||||
log.info("开始AI并行结构化提取")
|
||||
parsed = await extract_all(text)
|
||||
log.info("AI结构化提取完成")
|
||||
return parsed
|
||||
|
||||
async def polish_paragraphs(self, content: list[str]) -> list[str]:
|
||||
@@ -46,7 +46,7 @@ class ResumeService:
|
||||
return result
|
||||
|
||||
async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int:
|
||||
"""将解析结果写入主表 + 5张子表,返回简历ID"""
|
||||
"""将解析结果写入主表 + 6张子表,返回简历ID"""
|
||||
resume_id = next_id()
|
||||
|
||||
session.add(UserResume(
|
||||
@@ -58,6 +58,7 @@ class ResumeService:
|
||||
wechat_number=parsed.get("wechatNumber"), portfolio_url=parsed.get("portfolioUrl"),
|
||||
skills=parsed.get("skills") or [], certificates=parsed.get("certificates") or [],
|
||||
summary=parsed.get("summary"),
|
||||
hobbies=parsed.get("hobbies"), language_skills=parsed.get("languageSkills"),
|
||||
))
|
||||
|
||||
for i, edu in enumerate(parsed.get("education") or []):
|
||||
@@ -102,6 +103,14 @@ class ResumeService:
|
||||
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()
|
||||
log.info(f"简历保存完成,resumeId: {resume_id}")
|
||||
return resume_id
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
岗位技能差距分析 → 定制简历生成/查询/编辑/回滚 → AI 对话式编辑。
|
||||
依赖:skill_gap_analyzer(AI引擎)
|
||||
使用表:bg_job(读)、bg_user_resume + 5张子表(读)
|
||||
使用表:bg_job(读)、bg_user_resume + 6张子表(读)
|
||||
存储: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.core.logger import log
|
||||
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.user_resume import UserResume
|
||||
@@ -35,6 +35,7 @@ _MODULE_LABELS = {
|
||||
"internship": "实习经历",
|
||||
"project": "项目经历",
|
||||
"competition": "竞赛经历",
|
||||
"organization": "社团组织经历",
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +46,8 @@ def _build_resume_json(detail: ResumeDetail) -> str:
|
||||
"skills": resume.skills or [],
|
||||
"certificates": resume.certificates or [],
|
||||
"summary": resume.summary or "",
|
||||
"hobbies": resume.hobbies or "",
|
||||
"languageSkills": resume.language_skills or "",
|
||||
"targetPosition": resume.target_position or "",
|
||||
}
|
||||
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]
|
||||
if 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)
|
||||
|
||||
|
||||
@@ -141,7 +146,8 @@ class SkillGapService:
|
||||
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
||||
result: list[tuple[str, int, str]] = []
|
||||
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 []):
|
||||
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
||||
return result
|
||||
@@ -152,8 +158,8 @@ class SkillGapService:
|
||||
if key == "summary" and isinstance(result, str):
|
||||
cr.resume.summary = result
|
||||
return
|
||||
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition}
|
||||
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.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, "organization": cr.organization}
|
||||
model_cls = model_map.get(key)
|
||||
items = list_map.get(key)
|
||||
if model_cls is None or items is None:
|
||||
@@ -272,7 +278,7 @@ class SkillGapService:
|
||||
return cr.resume.model_dump_json(by_alias=True)
|
||||
mapping = {
|
||||
"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, [])
|
||||
if not record_id:
|
||||
@@ -290,7 +296,7 @@ class SkillGapService:
|
||||
return
|
||||
mapping = {
|
||||
"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)
|
||||
if items is not None:
|
||||
@@ -308,14 +314,14 @@ class SkillGapService:
|
||||
return
|
||||
model_map = {
|
||||
"education": Education, "work": Work, "internship": Internship,
|
||||
"project": Project, "competition": Competition,
|
||||
"project": Project, "competition": Competition, "organization": Organization,
|
||||
}
|
||||
model_cls = model_map.get(mod_name)
|
||||
if not model_cls or not isinstance(result, dict) or not record_id:
|
||||
return
|
||||
list_map = {
|
||||
"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, [])
|
||||
new_item = model_cls.model_validate(result)
|
||||
@@ -336,6 +342,7 @@ class SkillGapService:
|
||||
"internship": (Internship, cr.internship),
|
||||
"project": (Project, cr.project),
|
||||
"competition": (Competition, cr.competition),
|
||||
"organization": (Organization, cr.organization),
|
||||
}
|
||||
entry = model_map.get(mod_name)
|
||||
if not entry or not isinstance(result, dict):
|
||||
|
||||
@@ -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
|
||||
|
||||
# 文件解析
|
||||
liteparse>=2.1.0
|
||||
pymupdf>=1.24.0
|
||||
python-docx>=1.1.0
|
||||
|
||||
# 雪花ID
|
||||
|
||||
Reference in New Issue
Block a user