添加简历表子表
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
"""简历一步并发提取
|
||||
|
||||
6 路并发(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),每路独立负责一个模块,
|
||||
7 路并发(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛 / 社团组织),每路独立负责一个模块,
|
||||
各自基于简历全文一次性输出该模块的全部内容(短字段 + description),无第二阶段。
|
||||
相比两阶段方案:少一个串行轮次、全文只 prefill 6 次、无标识名定位环节。
|
||||
相比两阶段方案:少一个串行轮次、全文只 prefill 7 次、无标识名定位环节。
|
||||
description/summary 由 AI 直接按原文结构输出为字符串数组,代码只做类型兜底与清理。
|
||||
最终组装为与两阶段方案完全一致的 dict 结构(description 为 list[str],summary 为 str),上下游无感知。
|
||||
任意模块提取失败:记录日志后丢弃该模块(主表字段退化为空、子表退化为空数组),不影响其余模块。
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -76,9 +77,10 @@ _SUB_MODULES: tuple[tuple[str, str, str], ...] = (
|
||||
("internship", INTERNSHIP_PROMPT, "实习"),
|
||||
("project", PROJECT_PROMPT, "项目"),
|
||||
("competition", COMPETITION_PROMPT, "竞赛"),
|
||||
("organization", ORGANIZATION_PROMPT, "社团组织"),
|
||||
)
|
||||
|
||||
# 无运行时 prompt 替换,6 条链在模块加载时一次性建好,请求期只做 ainvoke
|
||||
# 无运行时 prompt 替换,7 条链在模块加载时一次性建好,请求期只做 ainvoke
|
||||
_profile_chain = _build_chain(PROFILE_PROMPT)
|
||||
_sub_chains = tuple((module, _build_chain(prompt), label) for module, prompt, label in _SUB_MODULES)
|
||||
|
||||
@@ -98,11 +100,11 @@ def _assemble_profile(profile) -> dict:
|
||||
# ==================== 入口 ====================
|
||||
|
||||
async def extract_all(text: str) -> dict:
|
||||
"""一步6路并发提取简历,返回与两阶段方案完全一致的结构化数据
|
||||
"""一步7路并发提取简历,返回与两阶段方案完全一致的结构化数据
|
||||
|
||||
text: 简历纯文本全文(PyMuPDF/docx/txt 提取,保留原始换行)。
|
||||
"""
|
||||
log.info(f"一步6路并发提取开始,文本字符数: {len(text)}")
|
||||
log.info(f"一步7路并发提取开始,文本字符数: {len(text)}")
|
||||
start = time.perf_counter()
|
||||
|
||||
inp = {"text": text}
|
||||
@@ -116,7 +118,7 @@ async def extract_all(text: str) -> dict:
|
||||
result[module] = _clean_records(raw, label)
|
||||
|
||||
log.info(
|
||||
f"一步6路并发提取完成,总耗时: {time.perf_counter() - start:.2f}s - "
|
||||
f"一步7路并发提取完成,总耗时: {time.perf_counter() - start:.2f}s - "
|
||||
+ " ".join(f"{label}:{len(result[module])}" for module, _, label in _sub_chains)
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""简历一步并发提取 Prompt
|
||||
|
||||
6 路并发,每路独立负责一个模块(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛),
|
||||
7 路并发,每路独立负责一个模块(个人信息 / 教育 / 工作 / 实习 / 项目 / 竞赛 / 社团组织),
|
||||
各自基于简历全文一次性输出该模块的**全部内容**(短字段 + description),无第二阶段。
|
||||
|
||||
与两阶段方案的差异:
|
||||
@@ -16,11 +16,13 @@ description/summary 输出格式:字符串数组 list[str],每个元素代
|
||||
- 不主动改写、润色、扩写、编造内容;没有内容则返回空数组 []。
|
||||
|
||||
模块归属总原则(防同一内容被多个模块重复收录):
|
||||
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛,不能同时出现在两个模块。
|
||||
- 原文中的**每一段经历只能归属一个模块**。一条内容要么是工作、要么是实习、要么是项目、要么是竞赛、要么是社团组织,不能同时出现在两个模块。
|
||||
- 工作/实习互为补集:明确标注实习的只归实习模块,未标注实习的只归工作模块,两边都不重复收。
|
||||
- 工作/实习条目内部所描述的职责、成果、子项目,属于该**工作/实习**记录本身的描述,**不要**再把它们抽成独立的"项目"记录。
|
||||
- 只有出现在**独立"项目/项目经历"板块**下、有自己标题的条目,才算项目记录。
|
||||
- "做了作品/项目 + 参加比赛获奖"这类既像项目又像竞赛的条目(如机器人竞赛、创新创业大赛作品),**只归竞赛**,不要在项目里重复。
|
||||
- 社团/学生组织任职、校园活动、志愿服务/公益,**只归社团组织模块**,教育/工作/实习/项目/竞赛五个模块一律不收。
|
||||
- 社团名义参加的竞赛仍**只归竞赛**;社团组织模块只收非竞赛的组织与活动经历。
|
||||
|
||||
各模块通用铁律(防跨记录串扰/重复):
|
||||
- 每条记录只装真正属于它本身的内容;即使原文中相邻的内容属于**另一条**记录
|
||||
@@ -60,16 +62,18 @@ _DESC_RULE = """description 输出规范:为字符串数组,**每个元素
|
||||
PROFILE_PROMPT = """严格根据简历原文提取,不要猜测或编造,没有的填null。
|
||||
输入为简历纯文本全文。从中提取个人基本信息、技能标签、证书和自我评价,输出JSON:
|
||||
```json
|
||||
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接", "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
|
||||
{{ "name": "姓名", "email": "邮箱", "mobileNumber": "手机号", "city": "所在城市", "wechatNumber": "微信号", "portfolioUrl": "作品集链接", "hobbies": "兴趣爱好", "languageSkills": "语言能力", "skills": ["技能1"], "certificates": ["证书1"], "summary": ["自我评价段落1", "自我评价段落2"] }}
|
||||
```
|
||||
规则:
|
||||
- 只提取以上9个字段,**不要提取任何经历**(教育/工作/实习/项目/竞赛由其它模块单独负责)。前6个字段没有的填null,后3个数组字段没有的填[]。
|
||||
- 只提取以上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:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素,一个段落一个元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、经历内容纳入;允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
|
||||
- summary:自我评价/个人概述**正文**的字符串数组,按原文分段拆成数组元素,一个段落一个元素。不要包含板块标题(如"个人优势""自我评价"),不要把技能列表、兴趣爱好、语言能力、经历内容纳入(兴趣爱好归 hobbies,语言能力归 languageSkills);允许轻度清理(去除乱码/水印碎片),不要改写或编造;没有填[]。
|
||||
只输出JSON,不要输出任何解释文字。"""
|
||||
|
||||
# ==================== 教育 ====================
|
||||
@@ -167,3 +171,25 @@ COMPETITION_PROMPT = """严格根据简历原文提取,不要猜测或编造
|
||||
字段规则:短字段直接填值,时间格式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
|
||||
|
||||
@@ -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"}] }]',
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ async def match_score(param: MatchScoreParam):
|
||||
|
||||
@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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -54,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:
|
||||
@@ -77,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:
|
||||
@@ -117,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
|
||||
@@ -136,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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
上传简历文件 → 解析为纯文本 → 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
|
||||
@@ -15,6 +15,7 @@ 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.resume_text_extractor import extract_text
|
||||
@@ -45,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(
|
||||
@@ -57,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 []):
|
||||
@@ -101,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):
|
||||
|
||||
Reference in New Issue
Block a user