Compare commits
10
Commits
89d033c9f9
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8051933dcf | ||
|
|
dc1590f404 | ||
|
|
54f5c0ac63 | ||
|
|
3047c47916 | ||
|
|
f98b87379f | ||
|
|
b50eb98653 | ||
|
|
65d711ec25 | ||
|
|
410ab09561 | ||
|
|
7ca1d58721 | ||
|
|
af6195ba4e |
@@ -40,14 +40,14 @@ class NovaChatModel:
|
|||||||
|
|
||||||
class ResumeExtractorModel:
|
class ResumeExtractorModel:
|
||||||
"""简历解析模块"""
|
"""简历解析模块"""
|
||||||
# 简历结构化提取:两阶段并行提取简历文本为JSON结构
|
# 简历结构化提取:并行提取简历文本为JSON结构
|
||||||
PARSE = LLM.DOUBAO_PRO_32K.create(temperature=0)
|
PARSE = LLM.DEEPSEEK_V4_FLASH.create(temperature=0)
|
||||||
|
|
||||||
|
|
||||||
class ResumePolisherModel:
|
class ResumePolisherModel:
|
||||||
"""简历段落润色模块"""
|
"""简历段落润色模块"""
|
||||||
# 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定
|
# 段落润色:仅做格式/错字/表达优化,不改内容,低温度保证稳定
|
||||||
POLISH = LLM.DEEPSEEK_V4_FLASH.create(temperature=0.2)
|
POLISH = LLM.DOUBAO_PRO_32K.create(temperature=0.2)
|
||||||
|
|
||||||
|
|
||||||
class DiagnoserModel:
|
class DiagnoserModel:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
|
Nova 求职对话助手的 system prompt,根据用户意图自行选择回答策略。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SYSTEM_PROMPT = """你是 Nova,OfferPie 的 AI 求职助手。
|
SYSTEM_PROMPT = """你是 Nova,Offer派 的 AI 求职助手。
|
||||||
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
|
你现在是一个兼具客观、犀利的资深技术招聘专家。你的任务是帮候选人进行冷酷的岗位差距分析,而不是一味鼓励。
|
||||||
|
|
||||||
【候选人简历】
|
【候选人简历】
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ description/summary 输出格式:字符串数组 list[str],每个元素代
|
|||||||
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
|
- 同一段原文只应属于一条记录:不要为了描述完整而把其它记录已包含的内容再抄一遍,避免多条记录重复。
|
||||||
- 宁可少选,不确定归属的内容一律不选。
|
- 宁可少选,不确定归属的内容一律不选。
|
||||||
|
|
||||||
|
模块归属总原则(防同一内容被多个模块重复收录):
|
||||||
|
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛,不能同时出现在两个模块。
|
||||||
|
- 工作/实习经历条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
|
||||||
|
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
|
||||||
|
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
|
||||||
|
|
||||||
|
description/summary 输出的**分条铁律**(必须遵守):
|
||||||
|
- description 是**字符串数组**,原文中的**每一条 bullet / 每一个段落**各自作为**一个独立数组元素**。
|
||||||
|
- **严禁**把多条 bullet 合并进同一个元素;**严禁**用 " / "、";"、"、"、换行符等把多条内容拼接成一个长字符串。
|
||||||
|
- 正例:["负责A模块开发", "主导B方案落地"];反例(禁止):["负责A模块开发 / 主导B方案落地"]。
|
||||||
|
|
||||||
|
短字段准确性铁律:
|
||||||
|
- 时间:起止时间按原文如实填写,**开始时间不得晚于结束时间**,不要把起止写反;无法确定的填 null,不要臆造精确年月。
|
||||||
|
- 手机号:完整照抄,不要漏位、截断或改写(通常 11 位);微信号只填到 wechatNumber,绝不当手机号。
|
||||||
|
- 角色/职位等:只填原文明确写出的,原文没有就填 null,**不要臆造或按常见值默认补全**。
|
||||||
|
|
||||||
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
|
花括号用 {{ }} 转义,避免被 ChatPromptTemplate 当作变量。{name} 为运行时替换的记录标识名。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -31,7 +47,7 @@ OVERVIEW_PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编
|
|||||||
规则:
|
规则:
|
||||||
- 只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。
|
- 只提取以上6个字段,不提取skills/certificates/summary/经历。没有的填null。
|
||||||
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
|
- name:填完整姓名,原文怎么写就怎么填,不要截断或只取一个字。
|
||||||
- mobileNumber:只填手机号(通常11位数字);微信号必须填到wechatNumber,绝不能把微信号当手机号。
|
- mobileNumber:只填**一个**手机号(通常11位数字),若原文出现多个手机号只取第一个,绝不能把多个号码拼接或用逗号/顿号等连接填入;微信号必须填到wechatNumber,绝不能把微信号当手机号。
|
||||||
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
|
- portfolioUrl:只填作品集/个人主页/GitHub 等链接。
|
||||||
只输出JSON。"""
|
只输出JSON。"""
|
||||||
|
|
||||||
@@ -43,9 +59,10 @@ OVERVIEW_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或
|
|||||||
规则:
|
规则:
|
||||||
1. 列出原文中每一个**实际就读或已获得学历**的**就读阶段**,不同阶段(本科/硕士/博士)分别列出,一段都不能漏。
|
1. 列出原文中每一个**实际就读或已获得学历**的**就读阶段**,不同阶段(本科/硕士/博士)分别列出,一段都不能漏。
|
||||||
2. 每个标识必须**唯一**:若同一所学校有多个不同就读阶段(如本科、硕士),用「学校+学位」区分(如"剑桥大学-硕士""剑桥大学-本科"),避免重名导致后续混淆。
|
2. 每个标识必须**唯一**:若同一所学校有多个不同就读阶段(如本科、硕士),用「学校+学位」区分(如"剑桥大学-硕士""剑桥大学-本科"),避免重名导致后续混淆。
|
||||||
3. **不要过度拆分**:
|
3. **不要过度拆分**(这是最常见的错误,务必克制):
|
||||||
- 联合培养、交换、访学、海外学期属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
|
- 联合培养、交换、访学、海外学期、双学位/主辅修,都属于对应主学历就读的一部分,**并入该主学历那一条**,不要单列成独立教育记录。
|
||||||
- 同一学校同一就读阶段的双学位/主辅修,算**一条**,不要拆成两条。
|
- 同一所学校**同一就读阶段**只能有一条,不要因为专业不同、学院不同、或原文重复出现就拆成/重复成多条。
|
||||||
|
- 一个人的教育条目数 = 其真实就读的学历阶段数(通常本科1条、硕士1条…),不要多。
|
||||||
4. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要列入。
|
4. 仅"录取通知/offer/拟就读但尚未入学"的,**不算**教育经历,不要列入。
|
||||||
5. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要列入。
|
5. 培训机构、实习/工作单位、科研机构、社团不算学历教育,不要列入。
|
||||||
6. 没有输出[]。只输出JSON。"""
|
6. 没有输出[]。只输出JSON。"""
|
||||||
@@ -68,12 +85,13 @@ OVERVIEW_PROJECT_PROMPT = """严格根据简历原文提取,不要猜测或编
|
|||||||
["订单系统重构", "支付网关"]
|
["订单系统重构", "支付网关"]
|
||||||
```
|
```
|
||||||
规则:
|
规则:
|
||||||
1. 只提取作为**独立项目条目标题**出现的项目名(通常单独成行、常带"项目X:"前缀或【】标题、并配有自己的起止时间/角色/描述)。
|
1. 只提取出现在**独立"项目/项目经历"板块**下、有自己标题的项目条目(通常单独成行、常带"项目X:"前缀或【】标题、并配有自己的起止时间/角色/描述)。
|
||||||
2. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
|
2. **绝对不要**把"工作经历/实习经历"条目内部描述的职责、成果、子项目当作独立项目——它们属于对应的工作/实习记录,不要在项目模块重复收录。
|
||||||
3. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
|
3. **绝对不要**把出现在某段职责/产品描述句子内部、被"如/包括/例如/【】"等列举的功能点、模块、子功能当作项目(这些是描述内容,不是项目)。
|
||||||
4. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目)——这些归竞赛模块,即使参赛做了作品也不要在这里重复列。
|
4. **绝对不要**把社团/学生组织、校园活动、志愿服务、任职/职务、兴趣爱好当作项目——这些不是项目经历。
|
||||||
5. 同一个项目只列一次,不要因为它在工作经历里被提到就重复列。
|
5. **绝对不要**把竞赛/比赛/大赛条目当作项目(如"XX大赛""挑战杯""创新创业大赛""职业规划大赛"等,以参赛/获奖为核心的条目,即使参赛做了作品)——这些归竞赛模块。
|
||||||
6. 没有输出[]。只输出JSON。"""
|
6. 同一个项目只列一次。
|
||||||
|
7. 没有输出[]。只输出JSON。"""
|
||||||
|
|
||||||
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造。
|
||||||
输入为简历纯文本全文。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
|
输入为简历纯文本全文。从中仅提取竞赛/获奖经历的竞赛名称列表,输出JSON数组:
|
||||||
@@ -91,7 +109,7 @@ OVERVIEW_COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测
|
|||||||
# ==================== 第二阶段:详情提取 ====================
|
# ==================== 第二阶段:详情提取 ====================
|
||||||
|
|
||||||
# 通用尾注:description 数组的输出规范 + 清理规范。各详情 prompt 复用。
|
# 通用尾注:description 数组的输出规范 + 清理规范。各详情 prompt 复用。
|
||||||
_DESC_RULE = """description 为字符串数组,每个元素是原文中的一条 bullet 或一个段落:忠实还原原文措辞,按原文分条拆成多个数组元素(一条 bullet 一个元素),不要把多条内容塞进同一个元素;允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。只选属于"{name}"这条记录的内容,不要串入其它公司/项目/教育/竞赛/社团经历的内容,即使它们在原文中相邻。只输出JSON。"""
|
_DESC_RULE = """description 为字符串数组,**每个元素只装原文中的一条 bullet 或一个段落**:忠实还原原文措辞;**严禁把多条 bullet 合并进一个元素,严禁用 " / "、";"、换行等符号把多条内容拼成一个长字符串**(正例 ["做了A","做了B"];反例 ["做了A / 做了B"])。允许轻度清理(去除水印/页眉页脚/乱码碎片、把被折断的同一句话拼回完整),但不要改写、润色、扩写或编造;没有描述则填[]。只选属于"{name}"这条记录的内容,不要串入其它公司/项目/教育/竞赛/社团经历的内容,即使它们在原文中相邻。只输出JSON。"""
|
||||||
|
|
||||||
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
DETAIL_EDUCATION_PROMPT = """严格根据简历原文提取,不要猜测或编造。输入为简历纯文本全文。
|
||||||
请提取"{name}"这条教育经历的详细信息,输出JSON:
|
请提取"{name}"这条教育经历的详细信息,输出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 模板"""
|
"""简历段落润色 Prompt 模板"""
|
||||||
|
|
||||||
POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户提交的简历段落进行"表面润色",只做表达层面的优化。
|
POLISH_PROMPT = """你是一位资深简历顾问,擅长把平淡的经历描述改写得更专业、更有表现力。请对用户提交的简历段落进行"表达优化",在不虚构事实的前提下大胆提升表达力。
|
||||||
|
|
||||||
## 优化范围(只允许做这些)
|
## 优化目标(要真正做出改变)
|
||||||
- 修正错别字、标点、语法错误
|
- 强化表达力:把口语化、平淡、流水账式的描述,改写成专业、有力、简历书写规范的表达
|
||||||
- 优化文本格式与排版(如多余空格、断句、全半角混用)
|
- 善用动作动词:以有力的动词开头(负责/主导/搭建/推动/优化/提升等),突出你的贡献
|
||||||
- 让表达更通顺、专业,去除明显口语化和冗余措辞
|
- 理清逻辑结构:让每条描述尽量体现"做了什么 → 怎么做 → 带来什么结果",条理清晰
|
||||||
|
- 精炼措辞:删除冗余、重复、无意义的填充词,让句子更紧凑
|
||||||
|
- 修正错别字、标点、语法、格式问题(多余空格、断句、全半角混用等)
|
||||||
|
|
||||||
## 严格禁止(绝对不能做)
|
## 红线(绝对不能碰)
|
||||||
- 不得改变原意,不得增加或删除任何信息点
|
- 只优化表达,不虚构事实:**严禁**凭空添加或夸大任何数字、量化成果、技能、职责、技术栈、奖项、成就
|
||||||
- 不得编造、补充任何内容(尤其禁止凭空添加数字、量化成果、技能、成就)
|
- 原文没有的量化数据(百分比、金额、规模等)绝不能编造;原文有的数字必须原样保留,不得改动
|
||||||
- 不得改变段落的数量和顺序
|
- 不改变经历的核心事实与含义,不无中生有地拔高
|
||||||
|
- 保留用户本人的基本语气与专业方向,是"把他说得更好",而不是换成另一个人
|
||||||
|
|
||||||
|
## 格式约束
|
||||||
|
- 输出段落的**数量和顺序必须与输入完全一致**:输入几段就输出几段,一一对应,不合并、不拆分、不增删段落
|
||||||
|
|
||||||
## 输入
|
## 输入
|
||||||
用户提交的段落数组(每个元素是一个段落):
|
用户提交的段落数组(每个元素是一个段落):
|
||||||
@@ -18,4 +24,4 @@ POLISH_PROMPT = """你是一位严谨的简历文字校对助手。请对用户
|
|||||||
|
|
||||||
## 输出格式
|
## 输出格式
|
||||||
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
|
严格输出 JSON 数组,元素个数和顺序必须与输入完全一致,不要输出其他任何内容:
|
||||||
["润色后的段落1", "润色后的段落2"]"""
|
["优化后的段落1", "优化后的段落2"]"""
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ AGENT_PLAN_PROMPT = """你是一个简历编辑助手。你的唯一职责是根
|
|||||||
3. 修改主表:{{"type": "update", "module": "resume", "instruction": "修改说明(30字内)"}}
|
3. 修改主表:{{"type": "update", "module": "resume", "instruction": "修改说明(30字内)"}}
|
||||||
4. 新增记录:{{"type": "add", "module": "模块名", "instruction": "新增说明(30字内)"}}
|
4. 新增记录:{{"type": "add", "module": "模块名", "instruction": "新增说明(30字内)"}}
|
||||||
|
|
||||||
模块名可选:resume(主表,包含 name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary、avatarUrl)、education(教育)、work(工作)、internship(实习)、project(项目)、competition(竞赛)
|
模块名可选:resume(主表,包含 name、email、mobileNumber、city、wechatNumber、portfolioUrl、skills、certificates、summary、avatarUrl、hobbies、languageSkills)、education(教育)、work(工作)、internship(实习)、project(项目)、competition(竞赛)、organization(社团组织)
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 非简历修改相关的指令一律拒绝,返回固定话术,不要尝试回答或引导
|
1. 非简历修改相关的指令一律拒绝,返回固定话术,不要尝试回答或引导
|
||||||
@@ -154,10 +154,11 @@ AGENT_MODULE_ADD_PROMPT = """你是一个简历编辑助手。根据要求,生
|
|||||||
|
|
||||||
# 各模块数据结构定义(传入 prompt 的 module_schema)
|
# 各模块数据结构定义(传入 prompt 的 module_schema)
|
||||||
MODULE_SCHEMAS: dict[str, str] = {
|
MODULE_SCHEMAS: dict[str, str] = {
|
||||||
"resume": '{ "avatarUrl": "string", "name": "string", "email": "string", "mobileNumber": "string", "city": "string", "wechatNumber": "string", "portfolioUrl": "string", "skills": ["string"], "certificates": ["string"], "summary": "string" }',
|
"resume": '{ "avatarUrl": "string", "name": "string", "email": "string", "mobileNumber": "string", "city": "string", "wechatNumber": "string", "portfolioUrl": "string", "skills": ["string"], "certificates": ["string"], "summary": "string", "hobbies": "string", "languageSkills": "string" }',
|
||||||
"education": '[{ "id": "string(8位)", "school": "string", "major": "string", "degree": "大专/本科/硕士/博士", "studyType": "全日制/非全日制", "startDate": "2023.09", "endDate": "2024.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
"education": '[{ "id": "string(8位)", "school": "string", "major": "string", "degree": "大专/本科/硕士/博士", "studyType": "全日制/非全日制", "startDate": "2023.09", "endDate": "2024.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
"work": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
"work": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
"internship": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
"internship": '[{ "id": "string(8位)", "companyName": "string", "position": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
"project": '[{ "id": "string(8位)", "companyName": "string", "projectName": "string", "role": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
"project": '[{ "id": "string(8位)", "companyName": "string", "projectName": "string", "role": "string", "startDate": "2023.06", "endDate": "2023.09", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
"competition": '[{ "id": "string(8位)", "competitionName": "string", "award": "string", "awardDate": "2023.07", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
"competition": '[{ "id": "string(8位)", "competitionName": "string", "award": "string", "awardDate": "2023.07", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
|
"organization": '[{ "id": "string(8位)", "organizationName": "string", "role": "string", "startDate": "2022.04", "endDate": "2023.06", "description": [{"id": "string(8位)", "text": "string"}] }]',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.core.asserts import Assert
|
||||||
from app.core.auth import func_permission
|
from app.core.auth import func_permission
|
||||||
from app.core.context import RequestContext
|
from app.core.context import RequestContext
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@@ -13,17 +14,18 @@ router = APIRouter(prefix="/job-agent", tags=["求职助手agent"])
|
|||||||
|
|
||||||
@router.post("/match-score", summary="岗位匹配度评分")
|
@router.post("/match-score", summary="岗位匹配度评分")
|
||||||
async def match_score(param: MatchScoreParam):
|
async def match_score(param: MatchScoreParam):
|
||||||
"""根据定制简历和岗位,从技能/学历/经验维度评估匹配度,返回 0-100 分"""
|
"""根据定制简历或原始简历和岗位,从技能/学历/经验维度评估匹配度,返回 0-100 分"""
|
||||||
|
Assert.is_true(param.customize_resume_id is not None or param.resume_id is not None, "customizeResumeId 和 resumeId 至少传一个")
|
||||||
user_id = RequestContext.user_id.get()
|
user_id = RequestContext.user_id.get()
|
||||||
async for session in get_db():
|
async for session in get_db():
|
||||||
service = JobAgentChatService(session)
|
service = JobAgentChatService(session)
|
||||||
result = await service.score_match(user_id, param.customize_resume_id, param.job_id)
|
result = await service.score_match(user_id, param.job_id, param.customize_resume_id, param.resume_id)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/optimize-resume", summary="针对岗位优化简历")
|
@router.post("/optimize-resume", summary="针对岗位优化简历")
|
||||||
async def optimize_resume(param: OptimizeResumeParam, _: None = Depends(func_permission("resume_custom"))):
|
async def optimize_resume(param: OptimizeResumeParam, _: None = Depends(func_permission("resume_custom"))):
|
||||||
"""根据目标岗位,AI并发优化简历(summary + 5张子表经历),存Redis并返回"""
|
"""根据目标岗位,AI并发优化简历(summary + 6张子表经历),存Redis并返回"""
|
||||||
user_id = RequestContext.user_id.get()
|
user_id = RequestContext.user_id.get()
|
||||||
async for session in get_db():
|
async for session in get_db():
|
||||||
service = JobAgentChatService(session)
|
service = JobAgentChatService(session)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ResumeDiagnosisIssue(Base):
|
|||||||
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联report.id")
|
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联report.id")
|
||||||
resume_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联bg_user_resume.id")
|
resume_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="关联bg_user_resume.id")
|
||||||
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID")
|
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="用户ID")
|
||||||
module_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="模块类型: summary/education/work/internship/project/competition")
|
module_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="模块类型: summary/education/work/internship/project/competition/organization")
|
||||||
module_record_id: Mapped[int] = mapped_column(BigInteger, nullable=False, comment="模块记录ID,summary时为resume_id")
|
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="诊断发现")
|
finding: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="诊断发现")
|
||||||
importance: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="为什么重要")
|
importance: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="为什么重要")
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class UserResume(Base):
|
|||||||
skills: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="技能标签列表")
|
skills: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="技能标签列表")
|
||||||
certificates: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="证书标签列表")
|
certificates: Mapped[Optional[list]] = mapped_column(JSON, nullable=True, comment="证书标签列表")
|
||||||
summary: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="个人概述")
|
summary: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="个人概述")
|
||||||
|
hobbies: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="兴趣爱好")
|
||||||
|
language_skills: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True, comment="语言能力")
|
||||||
|
|
||||||
create_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
|
create_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment="创建时间")
|
||||||
update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
update_time: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||||
|
|||||||
@@ -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)
|
skills: list[str] = Field(default_factory=list)
|
||||||
certificates: list[str] = Field(default_factory=list)
|
certificates: list[str] = Field(default_factory=list)
|
||||||
summary: str = Field(default="")
|
summary: str = Field(default="")
|
||||||
|
hobbies: str = Field(default="")
|
||||||
|
language_skills: str = Field(default="", alias="languageSkills")
|
||||||
|
|
||||||
|
|
||||||
class Education(_AliasModel):
|
class Education(_AliasModel):
|
||||||
@@ -77,6 +79,15 @@ class Competition(_AliasModel):
|
|||||||
description: list[Paragraph] = Field(default_factory=list)
|
description: list[Paragraph] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(_AliasModel):
|
||||||
|
id: str = Field(default="")
|
||||||
|
organization_name: str = Field(default="", alias="organizationName")
|
||||||
|
role: str = Field(default="")
|
||||||
|
start_date: str = Field(default="", alias="startDate")
|
||||||
|
end_date: str = Field(default="", alias="endDate")
|
||||||
|
description: list[Paragraph] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class CustomizeResume(_AliasModel):
|
class CustomizeResume(_AliasModel):
|
||||||
"""定制简历结构"""
|
"""定制简历结构"""
|
||||||
resume: ResumeProfile = Field(default_factory=ResumeProfile)
|
resume: ResumeProfile = Field(default_factory=ResumeProfile)
|
||||||
@@ -85,3 +96,4 @@ class CustomizeResume(_AliasModel):
|
|||||||
internship: list[Internship] = Field(default_factory=list)
|
internship: list[Internship] = Field(default_factory=list)
|
||||||
project: list[Project] = Field(default_factory=list)
|
project: list[Project] = Field(default_factory=list)
|
||||||
competition: list[Competition] = Field(default_factory=list)
|
competition: list[Competition] = Field(default_factory=list)
|
||||||
|
organization: list[Organization] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -13,5 +13,6 @@ class OptimizeResumeParam(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class MatchScoreParam(BaseModel):
|
class MatchScoreParam(BaseModel):
|
||||||
customize_resume_id: int = Field(..., alias="customizeResumeId", description="定制简历ID")
|
customize_resume_id: int | None = Field(None, alias="customizeResumeId", description="定制简历ID")
|
||||||
|
resume_id: int | None = Field(None, alias="resumeId", description="简历ID")
|
||||||
job_id: int = Field(..., alias="jobId", description="岗位ID")
|
job_id: int = Field(..., alias="jobId", description="岗位ID")
|
||||||
|
|||||||
@@ -95,7 +95,11 @@ class BrowserPlugService:
|
|||||||
parts.append(f"证书:{'、'.join(resume['certificates'])}")
|
parts.append(f"证书:{'、'.join(resume['certificates'])}")
|
||||||
if resume.get("summary"):
|
if resume.get("summary"):
|
||||||
parts.append(f"个人概述:{resume['summary']}")
|
parts.append(f"个人概述:{resume['summary']}")
|
||||||
for section, title in [("education", "教育经历"), ("work", "工作经历"), ("internship", "实习经历"), ("project", "项目经历"), ("competition", "竞赛经历")]:
|
if resume.get("hobbies"):
|
||||||
|
parts.append(f"兴趣爱好:{resume['hobbies']}")
|
||||||
|
if resume.get("languageSkills"):
|
||||||
|
parts.append(f"语言能力:{resume['languageSkills']}")
|
||||||
|
for section, title in [("education", "教育经历"), ("work", "工作经历"), ("internship", "实习经历"), ("project", "项目经历"), ("competition", "竞赛经历"), ("organization", "社团组织经历")]:
|
||||||
items = data.get(section, [])
|
items = data.get(section, [])
|
||||||
if items:
|
if items:
|
||||||
parts.append(f"{title}:")
|
parts.append(f"{title}:")
|
||||||
@@ -108,4 +112,6 @@ class BrowserPlugService:
|
|||||||
parts.append(f" - {item.get('projectName', '')} {item.get('role', '')}")
|
parts.append(f" - {item.get('projectName', '')} {item.get('role', '')}")
|
||||||
elif section == "competition":
|
elif section == "competition":
|
||||||
parts.append(f" - {item.get('competitionName', '')} {item.get('award', '')}")
|
parts.append(f" - {item.get('competitionName', '')} {item.get('award', '')}")
|
||||||
|
elif section == "organization":
|
||||||
|
parts.append(f" - {item.get('organizationName', '')} {item.get('role', '')}")
|
||||||
return "\n".join(parts) if parts else "暂无简历信息"
|
return "\n".join(parts) if parts else "暂无简历信息"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.core.database import get_db
|
|||||||
from app.core.redis import RedisManager
|
from app.core.redis import RedisManager
|
||||||
from app.models.user_job_customize_resume import UserJobCustomizeResume
|
from app.models.user_job_customize_resume import UserJobCustomizeResume
|
||||||
from app.schemas.customize_resume import (
|
from app.schemas.customize_resume import (
|
||||||
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Paragraph,
|
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Organization, Paragraph,
|
||||||
)
|
)
|
||||||
from app.services.resume_loader import ResumeDetail, load_default_resume_detail
|
from app.services.resume_loader import ResumeDetail, load_default_resume_detail
|
||||||
from app.tool.snowflake import next_id
|
from app.tool.snowflake import next_id
|
||||||
@@ -46,6 +46,7 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume:
|
|||||||
wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "",
|
wechatNumber=resume.wechat_number or "", portfolioUrl=resume.portfolio_url or "",
|
||||||
skills=resume.skills or [], certificates=resume.certificates or [],
|
skills=resume.skills or [], certificates=resume.certificates or [],
|
||||||
summary=resume.summary or "",
|
summary=resume.summary or "",
|
||||||
|
hobbies=resume.hobbies or "", languageSkills=resume.language_skills or "",
|
||||||
)
|
)
|
||||||
return CustomizeResume(
|
return CustomizeResume(
|
||||||
resume=profile,
|
resume=profile,
|
||||||
@@ -65,6 +66,9 @@ def build_from_detail(detail: ResumeDetail) -> CustomizeResume:
|
|||||||
competition=[Competition(id=_rand_id(), competitionName=r.competition_name or "", award=r.award or "",
|
competition=[Competition(id=_rand_id(), competitionName=r.competition_name or "", award=r.award or "",
|
||||||
awardDate=r.award_date or "",
|
awardDate=r.award_date or "",
|
||||||
description=_build_paragraphs(r.description)) for r in detail.competition],
|
description=_build_paragraphs(r.description)) for r in detail.competition],
|
||||||
|
organization=[Organization(id=_rand_id(), organizationName=r.organization_name or "", role=r.role or "",
|
||||||
|
startDate=r.start_date or "", endDate=r.end_date or "",
|
||||||
|
description=_build_paragraphs(r.description)) for r in detail.organization],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,7 +108,7 @@ async def get_by_id(user_id: int, customize_resume_id: int) -> CustomizeResume |
|
|||||||
async def get(user_id: int, job_id: int) -> dict | None:
|
async def get(user_id: int, job_id: int) -> dict | None:
|
||||||
"""查询定制简历,查不到则加载默认简历构建返回
|
"""查询定制简历,查不到则加载默认简历构建返回
|
||||||
|
|
||||||
返回结构:{id, resumeId, resumeName, resume, education, work, internship, project, competition}
|
返回结构:{id, resumeId, resumeName, resume, education, work, internship, project, competition, organization}
|
||||||
fallback 默认简历时:id=None, resumeId=默认简历id, resumeName=原简历名
|
fallback 默认简历时:id=None, resumeId=默认简历id, resumeName=原简历名
|
||||||
"""
|
"""
|
||||||
async for session in get_db():
|
async for session in get_db():
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
主要功能:针对岗位并发优化简历;岗位匹配度评分。
|
主要功能:针对岗位并发优化简历;岗位匹配度评分。
|
||||||
依赖:resume_loader(简历统一查询)、customize_resume_store(定制简历存取+构建)、job_agent.resume_optimizer(岗位简历优化)、job_agent.match_scorer(匹配评分)
|
依赖:resume_loader(简历统一查询)、customize_resume_store(定制简历存取+构建)、job_agent.resume_optimizer(岗位简历优化)、job_agent.match_scorer(匹配评分)
|
||||||
使用表:bg_user_resume + 5张子表(通过 resume_loader 查询)、bg_job(查岗位)、bg_user_job_customize_resume(定制简历)
|
使用表:bg_user_resume + 6张子表(通过 resume_loader 查询)、bg_job(查岗位)、bg_user_job_customize_resume(定制简历)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -16,7 +16,7 @@ from app.ai.job_agent.resume_optimizer import optimize_summary, optimize_experie
|
|||||||
from app.ai.job_agent.match_scorer import score_match as ai_score_match
|
from app.ai.job_agent.match_scorer import score_match as ai_score_match
|
||||||
from app.core.logger import log
|
from app.core.logger import log
|
||||||
from app.models.job import Job
|
from app.models.job import Job
|
||||||
from app.schemas.customize_resume import CustomizeResume, Education, Work, Internship, Project, Competition
|
from app.schemas.customize_resume import CustomizeResume, Education, Work, Internship, Project, Competition, Organization
|
||||||
from app.services.resume_loader import load_resume_detail
|
from app.services.resume_loader import load_resume_detail
|
||||||
from app.services import customize_resume_store
|
from app.services import customize_resume_store
|
||||||
|
|
||||||
@@ -26,11 +26,15 @@ class JobAgentChatService:
|
|||||||
def __init__(self, session: AsyncSession):
|
def __init__(self, session: AsyncSession):
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def score_match(self, user_id: int, customize_resume_id: int, job_id: int) -> int:
|
async def score_match(self, user_id: int, job_id: int, customize_resume_id: int | None = None, resume_id: int | None = None) -> int:
|
||||||
"""岗位匹配评分:查定制简历 + 岗位 → 序列化简历 → 调AI评分,返回 0-100 整数"""
|
"""岗位匹配评分:查定制简历或原始简历 + 岗位 → 序列化简历 → 调AI评分,返回 0-100 整数"""
|
||||||
|
if customize_resume_id:
|
||||||
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
cr = await customize_resume_store.get_by_id(user_id, customize_resume_id)
|
||||||
if cr is None:
|
if cr is None:
|
||||||
raise ValueError("定制简历不存在")
|
raise ValueError("定制简历不存在")
|
||||||
|
else:
|
||||||
|
detail = await load_resume_detail(self.session, resume_id, user_id)
|
||||||
|
cr = customize_resume_store.build_from_detail(detail)
|
||||||
job = await self._get_job(job_id)
|
job = await self._get_job(job_id)
|
||||||
resume_text = self._build_resume_text_from_cr(cr)
|
resume_text = self._build_resume_text_from_cr(cr)
|
||||||
skill_tags = "、".join(job.skill_tags) if job.skill_tags else ""
|
skill_tags = "、".join(job.skill_tags) if job.skill_tags else ""
|
||||||
@@ -50,6 +54,10 @@ class JobAgentChatService:
|
|||||||
parts.append(f"证书:{'、'.join(r.certificates)}")
|
parts.append(f"证书:{'、'.join(r.certificates)}")
|
||||||
if r.summary:
|
if r.summary:
|
||||||
parts.append(f"个人概述:{r.summary}")
|
parts.append(f"个人概述:{r.summary}")
|
||||||
|
if r.hobbies:
|
||||||
|
parts.append(f"兴趣爱好:{r.hobbies}")
|
||||||
|
if r.language_skills:
|
||||||
|
parts.append(f"语言能力:{r.language_skills}")
|
||||||
if cr.education:
|
if cr.education:
|
||||||
parts.append("教育经历:")
|
parts.append("教育经历:")
|
||||||
for e in cr.education:
|
for e in cr.education:
|
||||||
@@ -73,6 +81,11 @@ class JobAgentChatService:
|
|||||||
parts.append("竞赛经历:")
|
parts.append("竞赛经历:")
|
||||||
for c in cr.competition:
|
for c in cr.competition:
|
||||||
parts.append(f" - {c.competition_name} {c.award}".rstrip())
|
parts.append(f" - {c.competition_name} {c.award}".rstrip())
|
||||||
|
if cr.organization:
|
||||||
|
parts.append("社团组织经历:")
|
||||||
|
for o in cr.organization:
|
||||||
|
parts.append(f" - {o.organization_name} {o.role}".rstrip())
|
||||||
|
parts.extend(f" {p.text}" for p in o.description if p.text)
|
||||||
return "\n".join(parts) if parts else "暂无简历信息"
|
return "\n".join(parts) if parts else "暂无简历信息"
|
||||||
|
|
||||||
async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict:
|
async def optimize_resume(self, user_id: int, resume_id: int, job_id: int) -> dict:
|
||||||
@@ -113,7 +126,8 @@ class JobAgentChatService:
|
|||||||
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
||||||
result: list[tuple[str, int, str]] = []
|
result: list[tuple[str, int, str]] = []
|
||||||
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
|
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
|
||||||
("project", cr.project), ("competition", cr.competition)]:
|
("project", cr.project), ("competition", cr.competition),
|
||||||
|
("organization", cr.organization)]:
|
||||||
for idx, item in enumerate(items or []):
|
for idx, item in enumerate(items or []):
|
||||||
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
||||||
return result
|
return result
|
||||||
@@ -132,8 +146,8 @@ class JobAgentChatService:
|
|||||||
if key == "summary" and isinstance(result, str):
|
if key == "summary" and isinstance(result, str):
|
||||||
cr.resume.summary = result
|
cr.resume.summary = result
|
||||||
return
|
return
|
||||||
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition}
|
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition, "organization": Organization}
|
||||||
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition}
|
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition, "organization": cr.organization}
|
||||||
model_cls = model_map.get(key)
|
model_cls = model_map.get(key)
|
||||||
items = list_map.get(key)
|
items = list_map.get(key)
|
||||||
if model_cls is None or items is None:
|
if model_cls is None or items is None:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
主要功能:查询简历数据 + 查询岗位(可选),调用 AI 模块完成对话。
|
主要功能:查询简历数据 + 查询岗位(可选),调用 AI 模块完成对话。
|
||||||
依赖:resume_loader(简历统一查询)、nova_chat AI 模块
|
依赖:resume_loader(简历统一查询)、nova_chat AI 模块
|
||||||
使用表:bg_user_resume + 5张子表(通过 resume_loader 查询)、bg_job(查岗位,可选)
|
使用表:bg_user_resume + 6张子表(通过 resume_loader 查询)、bg_job(查岗位,可选)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -60,6 +60,10 @@ class NovaChatService:
|
|||||||
parts.append(f"证书:{'、'.join(resume.certificates)}")
|
parts.append(f"证书:{'、'.join(resume.certificates)}")
|
||||||
if resume.summary:
|
if resume.summary:
|
||||||
parts.append(f"个人概述:{resume.summary}")
|
parts.append(f"个人概述:{resume.summary}")
|
||||||
|
if resume.hobbies:
|
||||||
|
parts.append(f"兴趣爱好:{resume.hobbies}")
|
||||||
|
if resume.language_skills:
|
||||||
|
parts.append(f"语言能力:{resume.language_skills}")
|
||||||
if detail.education:
|
if detail.education:
|
||||||
parts.append("教育经历:")
|
parts.append("教育经历:")
|
||||||
for r in detail.education:
|
for r in detail.education:
|
||||||
@@ -80,4 +84,8 @@ class NovaChatService:
|
|||||||
parts.append("竞赛经历:")
|
parts.append("竞赛经历:")
|
||||||
for r in detail.competition:
|
for r in detail.competition:
|
||||||
parts.append(f" - {r.competition_name or ''} {r.award or ''}")
|
parts.append(f" - {r.competition_name or ''} {r.award or ''}")
|
||||||
|
if detail.organization:
|
||||||
|
parts.append("社团组织经历:")
|
||||||
|
for r in detail.organization:
|
||||||
|
parts.append(f" - {r.organization_name or ''} {r.role or ''}")
|
||||||
return "\n".join(parts) if parts else "暂无简历信息"
|
return "\n".join(parts) if parts else "暂无简历信息"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
加载简历描述数据 → 并行 AI 诊断 → 统计评级 → AI 汇总评价 → 写入数据库。
|
加载简历描述数据 → 并行 AI 诊断 → 统计评级 → AI 汇总评价 → 写入数据库。
|
||||||
依赖:resume_diagnoser(AI诊断引擎)
|
依赖:resume_diagnoser(AI诊断引擎)
|
||||||
使用表:bg_user_resume + 5张子表(读)、bg_resume_diagnosis_report + issue(写)
|
使用表:bg_user_resume + 6张子表(读)、bg_resume_diagnosis_report + issue(写)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -21,6 +21,7 @@ from app.tool.snowflake import next_id
|
|||||||
_MODULE_LABELS = {
|
_MODULE_LABELS = {
|
||||||
"summary": "个人概述", "education": "教育经历", "work": "工作经历",
|
"summary": "个人概述", "education": "教育经历", "work": "工作经历",
|
||||||
"internship": "实习经历", "project": "项目经历", "competition": "竞赛经历",
|
"internship": "实习经历", "project": "项目经历", "competition": "竞赛经历",
|
||||||
|
"organization": "社团组织经历",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class ResumeDiagnoseService:
|
|||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
async def load_resume_data(self, resume_id: int, user_id: int) -> tuple[UserResume, list[dict]]:
|
async def load_resume_data(self, resume_id: int, user_id: int) -> tuple[UserResume, list[dict]]:
|
||||||
"""加载简历主表 + 5 张子表数据,组装 AI 任务列表"""
|
"""加载简历主表 + 6 张子表数据,组装 AI 任务列表"""
|
||||||
detail = await load_resume_detail(self.session, resume_id, user_id)
|
detail = await load_resume_detail(self.session, resume_id, user_id)
|
||||||
resume = detail.resume
|
resume = detail.resume
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ class ResumeDiagnoseService:
|
|||||||
lambda r: f"公司: {r.company_name or ''}, 项目: {r.project_name or ''}, 角色: {r.role or ''}")
|
lambda r: f"公司: {r.company_name or ''}, 项目: {r.project_name or ''}, 角色: {r.role or ''}")
|
||||||
self._collect_tasks(tasks, target_position, "competition", detail.competition,
|
self._collect_tasks(tasks, target_position, "competition", detail.competition,
|
||||||
lambda r: f"竞赛: {r.competition_name or ''}, 获奖: {r.award or ''}")
|
lambda r: f"竞赛: {r.competition_name or ''}, 获奖: {r.award or ''}")
|
||||||
|
self._collect_tasks(tasks, target_position, "organization", detail.organization,
|
||||||
|
lambda r: f"社团/组织: {r.organization_name or ''}, 角色: {r.role or ''}")
|
||||||
return resume, tasks
|
return resume, tasks
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""简历统一查询模块
|
"""简历统一查询模块
|
||||||
|
|
||||||
提供简历主表 + 5张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。
|
提供简历主表 + 6张子表的统一查询能力,返回脱离 session 的 ResumeDetail dataclass。
|
||||||
各 Service 统一复用,避免重复查询逻辑。
|
各 Service 统一复用,避免重复查询逻辑。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -15,31 +15,33 @@ from app.models.user_resume_work import UserResumeWork
|
|||||||
from app.models.user_resume_internship import UserResumeInternship
|
from app.models.user_resume_internship import UserResumeInternship
|
||||||
from app.models.user_resume_project import UserResumeProject
|
from app.models.user_resume_project import UserResumeProject
|
||||||
from app.models.user_resume_competition import UserResumeCompetition
|
from app.models.user_resume_competition import UserResumeCompetition
|
||||||
|
from app.models.user_resume_organization import UserResumeOrganization
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ResumeDetail:
|
class ResumeDetail:
|
||||||
"""简历完整数据,主表 + 5张子表"""
|
"""简历完整数据,主表 + 6张子表"""
|
||||||
resume: UserResume
|
resume: UserResume
|
||||||
education: list[UserResumeEducation] = field(default_factory=list)
|
education: list[UserResumeEducation] = field(default_factory=list)
|
||||||
work: list[UserResumeWork] = field(default_factory=list)
|
work: list[UserResumeWork] = field(default_factory=list)
|
||||||
internship: list[UserResumeInternship] = field(default_factory=list)
|
internship: list[UserResumeInternship] = field(default_factory=list)
|
||||||
project: list[UserResumeProject] = field(default_factory=list)
|
project: list[UserResumeProject] = field(default_factory=list)
|
||||||
competition: list[UserResumeCompetition] = field(default_factory=list)
|
competition: list[UserResumeCompetition] = field(default_factory=list)
|
||||||
|
organization: list[UserResumeOrganization] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
async def load_resume_detail(session: AsyncSession, resume_id: int, user_id: int) -> ResumeDetail:
|
async def load_resume_detail(session: AsyncSession, resume_id: int, user_id: int) -> ResumeDetail:
|
||||||
"""按ID查简历主表(校验归属)+ 5张子表,返回 ResumeDetail"""
|
"""按ID查简历主表(校验归属)+ 6张子表,返回 ResumeDetail"""
|
||||||
result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id))
|
result = await session.execute(select(UserResume).where(UserResume.id == resume_id, UserResume.user_id == user_id))
|
||||||
resume = result.scalar_one_or_none()
|
resume = result.scalar_one_or_none()
|
||||||
if not resume:
|
if not resume:
|
||||||
raise ValueError("简历不存在")
|
raise ValueError("简历不存在")
|
||||||
edu, work, intern, proj, comp = await _load_sub_tables(session, resume_id)
|
edu, work, intern, proj, comp, org = await _load_sub_tables(session, resume_id)
|
||||||
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp)
|
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp, organization=org)
|
||||||
|
|
||||||
|
|
||||||
async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail:
|
async def load_default_resume_detail(session: AsyncSession, user_id: int) -> ResumeDetail:
|
||||||
"""自动选默认简历(先默认再最新)+ 5张子表,返回 ResumeDetail"""
|
"""自动选默认简历(先默认再最新)+ 6张子表,返回 ResumeDetail"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1)
|
select(UserResume).where(UserResume.user_id == user_id, UserResume.is_default == 1)
|
||||||
.order_by(desc(UserResume.update_time)).limit(1))
|
.order_by(desc(UserResume.update_time)).limit(1))
|
||||||
@@ -51,15 +53,16 @@ async def load_default_resume_detail(session: AsyncSession, user_id: int) -> Res
|
|||||||
resume = result.scalar_one_or_none()
|
resume = result.scalar_one_or_none()
|
||||||
if not resume:
|
if not resume:
|
||||||
raise ValueError("请先创建简历")
|
raise ValueError("请先创建简历")
|
||||||
edu, work, intern, proj, comp = await _load_sub_tables(session, resume.id)
|
edu, work, intern, proj, comp, org = await _load_sub_tables(session, resume.id)
|
||||||
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp)
|
return ResumeDetail(resume=resume, education=edu, work=work, internship=intern, project=proj, competition=comp, organization=org)
|
||||||
|
|
||||||
|
|
||||||
async def _load_sub_tables(session: AsyncSession, resume_id: int):
|
async def _load_sub_tables(session: AsyncSession, resume_id: int):
|
||||||
"""查询简历5张子表"""
|
"""查询简历6张子表"""
|
||||||
edu = (await session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all()
|
edu = (await session.execute(select(UserResumeEducation).where(UserResumeEducation.resume_id == resume_id))).scalars().all()
|
||||||
work = (await session.execute(select(UserResumeWork).where(UserResumeWork.resume_id == resume_id))).scalars().all()
|
work = (await session.execute(select(UserResumeWork).where(UserResumeWork.resume_id == resume_id))).scalars().all()
|
||||||
intern = (await session.execute(select(UserResumeInternship).where(UserResumeInternship.resume_id == resume_id))).scalars().all()
|
intern = (await session.execute(select(UserResumeInternship).where(UserResumeInternship.resume_id == resume_id))).scalars().all()
|
||||||
proj = (await session.execute(select(UserResumeProject).where(UserResumeProject.resume_id == resume_id))).scalars().all()
|
proj = (await session.execute(select(UserResumeProject).where(UserResumeProject.resume_id == resume_id))).scalars().all()
|
||||||
comp = (await session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all()
|
comp = (await session.execute(select(UserResumeCompetition).where(UserResumeCompetition.resume_id == resume_id))).scalars().all()
|
||||||
return edu, work, intern, proj, comp
|
org = (await session.execute(select(UserResumeOrganization).where(UserResumeOrganization.resume_id == resume_id))).scalars().all()
|
||||||
|
return edu, work, intern, proj, comp, org
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
|
上传简历文件 → 解析为纯文本 → AI 两阶段并行结构化 → 写入数据库。
|
||||||
依赖:resume_text_extractor(文件文本提取)、resume_extractor(AI两阶段并行提取)
|
依赖:resume_text_extractor(文件文本提取)、resume_extractor(AI两阶段并行提取)
|
||||||
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition(5张子表)
|
使用表:bg_user_resume(主表)、bg_user_resume_education/work/internship/project/competition/organization(6张子表)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shortuuid
|
import shortuuid
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.ai.resume_extractor.extractor import extract_all
|
from app.ai.resume_extractor_v2.extractor import extract_all
|
||||||
from app.ai.resume_polisher.polisher import polish_paragraphs
|
from app.ai.resume_polisher.polisher import polish_paragraphs
|
||||||
from app.core.logger import log
|
from app.core.logger import log
|
||||||
from app.models.user_resume import UserResume
|
from app.models.user_resume import UserResume
|
||||||
from app.models.user_resume_competition import UserResumeCompetition
|
from app.models.user_resume_competition import UserResumeCompetition
|
||||||
from app.models.user_resume_education import UserResumeEducation
|
from app.models.user_resume_education import UserResumeEducation
|
||||||
from app.models.user_resume_internship import UserResumeInternship
|
from app.models.user_resume_internship import UserResumeInternship
|
||||||
|
from app.models.user_resume_organization import UserResumeOrganization
|
||||||
from app.models.user_resume_project import UserResumeProject
|
from app.models.user_resume_project import UserResumeProject
|
||||||
from app.models.user_resume_work import UserResumeWork
|
from app.models.user_resume_work import UserResumeWork
|
||||||
from app.tool.resume_text_extractor import extract_text
|
from app.tool.resume_text_extractor import extract_text
|
||||||
@@ -29,6 +30,7 @@ class ResumeService:
|
|||||||
text = await extract_text(filename, content)
|
text = await extract_text(filename, content)
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
raise ValueError("文件内容为空,无法解析")
|
raise ValueError("文件内容为空,无法解析")
|
||||||
|
# log.info(f"文件解析完成: {text}")
|
||||||
log.info(f"文件解析完成,文本字符数: {len(text)}")
|
log.info(f"文件解析完成,文本字符数: {len(text)}")
|
||||||
|
|
||||||
log.info("开始AI两阶段并行结构化提取")
|
log.info("开始AI两阶段并行结构化提取")
|
||||||
@@ -44,7 +46,7 @@ class ResumeService:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int:
|
async def save_resume(self, session: AsyncSession, user_id: int, filename: str, parsed: dict) -> int:
|
||||||
"""将解析结果写入主表 + 5张子表,返回简历ID"""
|
"""将解析结果写入主表 + 6张子表,返回简历ID"""
|
||||||
resume_id = next_id()
|
resume_id = next_id()
|
||||||
|
|
||||||
session.add(UserResume(
|
session.add(UserResume(
|
||||||
@@ -56,6 +58,7 @@ class ResumeService:
|
|||||||
wechat_number=parsed.get("wechatNumber"), portfolio_url=parsed.get("portfolioUrl"),
|
wechat_number=parsed.get("wechatNumber"), portfolio_url=parsed.get("portfolioUrl"),
|
||||||
skills=parsed.get("skills") or [], certificates=parsed.get("certificates") or [],
|
skills=parsed.get("skills") or [], certificates=parsed.get("certificates") or [],
|
||||||
summary=parsed.get("summary"),
|
summary=parsed.get("summary"),
|
||||||
|
hobbies=parsed.get("hobbies"), language_skills=parsed.get("languageSkills"),
|
||||||
))
|
))
|
||||||
|
|
||||||
for i, edu in enumerate(parsed.get("education") or []):
|
for i, edu in enumerate(parsed.get("education") or []):
|
||||||
@@ -100,6 +103,14 @@ class ResumeService:
|
|||||||
description=_to_paragraphs(comp.get("description")), sort_order=i,
|
description=_to_paragraphs(comp.get("description")), sort_order=i,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
for i, org in enumerate(parsed.get("organization") or []):
|
||||||
|
session.add(UserResumeOrganization(
|
||||||
|
id=next_id(), resume_id=resume_id, user_id=user_id,
|
||||||
|
organization_name=org.get("organizationName"), role=org.get("role"),
|
||||||
|
start_date=org.get("startDate"), end_date=org.get("endDate"),
|
||||||
|
description=_to_paragraphs(org.get("description")), sort_order=i,
|
||||||
|
))
|
||||||
|
|
||||||
await session.flush()
|
await session.flush()
|
||||||
log.info(f"简历保存完成,resumeId: {resume_id}")
|
log.info(f"简历保存完成,resumeId: {resume_id}")
|
||||||
return resume_id
|
return resume_id
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
岗位技能差距分析 → 定制简历生成/查询/编辑/回滚 → AI 对话式编辑。
|
岗位技能差距分析 → 定制简历生成/查询/编辑/回滚 → AI 对话式编辑。
|
||||||
依赖:skill_gap_analyzer(AI引擎)
|
依赖:skill_gap_analyzer(AI引擎)
|
||||||
使用表:bg_job(读)、bg_user_resume + 5张子表(读)
|
使用表:bg_job(读)、bg_user_resume + 6张子表(读)
|
||||||
存储:Redis(定制简历 + 回滚数据)
|
存储:Redis(定制简历 + 回滚数据)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ from app.ai.skill_gap_analyzer.analyzer import (
|
|||||||
from app.ai.skill_gap_analyzer.prompts import MODULE_SCHEMAS
|
from app.ai.skill_gap_analyzer.prompts import MODULE_SCHEMAS
|
||||||
from app.core.logger import log
|
from app.core.logger import log
|
||||||
from app.schemas.customize_resume import (
|
from app.schemas.customize_resume import (
|
||||||
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition,
|
CustomizeResume, ResumeProfile, Education, Work, Internship, Project, Competition, Organization,
|
||||||
)
|
)
|
||||||
from app.models.job import Job
|
from app.models.job import Job
|
||||||
from app.models.user_resume import UserResume
|
from app.models.user_resume import UserResume
|
||||||
@@ -35,6 +35,7 @@ _MODULE_LABELS = {
|
|||||||
"internship": "实习经历",
|
"internship": "实习经历",
|
||||||
"project": "项目经历",
|
"project": "项目经历",
|
||||||
"competition": "竞赛经历",
|
"competition": "竞赛经历",
|
||||||
|
"organization": "社团组织经历",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +46,8 @@ def _build_resume_json(detail: ResumeDetail) -> str:
|
|||||||
"skills": resume.skills or [],
|
"skills": resume.skills or [],
|
||||||
"certificates": resume.certificates or [],
|
"certificates": resume.certificates or [],
|
||||||
"summary": resume.summary or "",
|
"summary": resume.summary or "",
|
||||||
|
"hobbies": resume.hobbies or "",
|
||||||
|
"languageSkills": resume.language_skills or "",
|
||||||
"targetPosition": resume.target_position or "",
|
"targetPosition": resume.target_position or "",
|
||||||
}
|
}
|
||||||
if detail.education:
|
if detail.education:
|
||||||
@@ -57,6 +60,8 @@ def _build_resume_json(detail: ResumeDetail) -> str:
|
|||||||
data["project"] = [{"companyName": r.company_name, "projectName": r.project_name, "role": r.role, "description": r.description} for r in detail.project]
|
data["project"] = [{"companyName": r.company_name, "projectName": r.project_name, "role": r.role, "description": r.description} for r in detail.project]
|
||||||
if detail.competition:
|
if detail.competition:
|
||||||
data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in detail.competition]
|
data["competition"] = [{"competitionName": r.competition_name, "award": r.award, "description": r.description} for r in detail.competition]
|
||||||
|
if detail.organization:
|
||||||
|
data["organization"] = [{"organizationName": r.organization_name, "role": r.role, "description": r.description} for r in detail.organization]
|
||||||
return json.dumps(data, ensure_ascii=False)
|
return json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +146,8 @@ class SkillGapService:
|
|||||||
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
"""构建各子表的 AI 优化任务列表,按单条记录拆分"""
|
||||||
result: list[tuple[str, int, str]] = []
|
result: list[tuple[str, int, str]] = []
|
||||||
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
|
for name, items in [("education", cr.education), ("work", cr.work), ("internship", cr.internship),
|
||||||
("project", cr.project), ("competition", cr.competition)]:
|
("project", cr.project), ("competition", cr.competition),
|
||||||
|
("organization", cr.organization)]:
|
||||||
for idx, item in enumerate(items or []):
|
for idx, item in enumerate(items or []):
|
||||||
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
result.append((name, idx, json.dumps(item.model_dump(by_alias=True), ensure_ascii=False)))
|
||||||
return result
|
return result
|
||||||
@@ -152,8 +158,8 @@ class SkillGapService:
|
|||||||
if key == "summary" and isinstance(result, str):
|
if key == "summary" and isinstance(result, str):
|
||||||
cr.resume.summary = result
|
cr.resume.summary = result
|
||||||
return
|
return
|
||||||
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition}
|
model_map = {"education": Education, "work": Work, "internship": Internship, "project": Project, "competition": Competition, "organization": Organization}
|
||||||
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition}
|
list_map = {"education": cr.education, "work": cr.work, "internship": cr.internship, "project": cr.project, "competition": cr.competition, "organization": cr.organization}
|
||||||
model_cls = model_map.get(key)
|
model_cls = model_map.get(key)
|
||||||
items = list_map.get(key)
|
items = list_map.get(key)
|
||||||
if model_cls is None or items is None:
|
if model_cls is None or items is None:
|
||||||
@@ -272,7 +278,7 @@ class SkillGapService:
|
|||||||
return cr.resume.model_dump_json(by_alias=True)
|
return cr.resume.model_dump_json(by_alias=True)
|
||||||
mapping = {
|
mapping = {
|
||||||
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
||||||
"project": cr.project, "competition": cr.competition,
|
"project": cr.project, "competition": cr.competition, "organization": cr.organization,
|
||||||
}
|
}
|
||||||
items = mapping.get(mod_name, [])
|
items = mapping.get(mod_name, [])
|
||||||
if not record_id:
|
if not record_id:
|
||||||
@@ -290,7 +296,7 @@ class SkillGapService:
|
|||||||
return
|
return
|
||||||
mapping = {
|
mapping = {
|
||||||
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
||||||
"project": cr.project, "competition": cr.competition,
|
"project": cr.project, "competition": cr.competition, "organization": cr.organization,
|
||||||
}
|
}
|
||||||
items = mapping.get(mod_name)
|
items = mapping.get(mod_name)
|
||||||
if items is not None:
|
if items is not None:
|
||||||
@@ -308,14 +314,14 @@ class SkillGapService:
|
|||||||
return
|
return
|
||||||
model_map = {
|
model_map = {
|
||||||
"education": Education, "work": Work, "internship": Internship,
|
"education": Education, "work": Work, "internship": Internship,
|
||||||
"project": Project, "competition": Competition,
|
"project": Project, "competition": Competition, "organization": Organization,
|
||||||
}
|
}
|
||||||
model_cls = model_map.get(mod_name)
|
model_cls = model_map.get(mod_name)
|
||||||
if not model_cls or not isinstance(result, dict) or not record_id:
|
if not model_cls or not isinstance(result, dict) or not record_id:
|
||||||
return
|
return
|
||||||
list_map = {
|
list_map = {
|
||||||
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
"education": cr.education, "work": cr.work, "internship": cr.internship,
|
||||||
"project": cr.project, "competition": cr.competition,
|
"project": cr.project, "competition": cr.competition, "organization": cr.organization,
|
||||||
}
|
}
|
||||||
items = list_map.get(mod_name, [])
|
items = list_map.get(mod_name, [])
|
||||||
new_item = model_cls.model_validate(result)
|
new_item = model_cls.model_validate(result)
|
||||||
@@ -336,6 +342,7 @@ class SkillGapService:
|
|||||||
"internship": (Internship, cr.internship),
|
"internship": (Internship, cr.internship),
|
||||||
"project": (Project, cr.project),
|
"project": (Project, cr.project),
|
||||||
"competition": (Competition, cr.competition),
|
"competition": (Competition, cr.competition),
|
||||||
|
"organization": (Organization, cr.organization),
|
||||||
}
|
}
|
||||||
entry = model_map.get(mod_name)
|
entry = model_map.get(mod_name)
|
||||||
if not entry or not isinstance(result, dict):
|
if not entry or not isinstance(result, dict):
|
||||||
|
|||||||
@@ -37,13 +37,18 @@ async def _parse_txt(content: bytes) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _parse_pdf(content: bytes) -> str:
|
async def _parse_pdf(content: bytes) -> str:
|
||||||
"""解析 PDF:使用 PyMuPDF 逐页 get_text() 拼接为全文文本"""
|
"""解析 PDF:使用 PyMuPDF 逐页 get_text() 拼接为全文文本
|
||||||
|
|
||||||
|
sort=True 按文本块坐标(y,x)排序输出,即视觉阅读顺序。
|
||||||
|
默认的内容流顺序对模板生成的简历完全不可用:这类简历的「公司/职位/时间」表头是绝对定位文本框,
|
||||||
|
会被整批甩到全文最前面,与各自的职责描述相隔几十行,AI 无法还原归属关系。
|
||||||
|
"""
|
||||||
|
|
||||||
def _extract() -> str:
|
def _extract() -> str:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
with fitz.open(stream=content, filetype="pdf") as doc:
|
with fitz.open(stream=content, filetype="pdf") as doc:
|
||||||
for page in doc:
|
for page in doc:
|
||||||
parts.append(page.get_text())
|
parts.append(page.get_text("text", sort=True))
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
return await asyncio.to_thread(_extract)
|
return await asyncio.to_thread(_extract)
|
||||||
|
|||||||
Reference in New Issue
Block a user