- 全链路:多源JD接入/清洗交叉验证/结构化抽取(星火+离线双通道)/时序知识图谱/新岗位发现/演化分析/人岗匹配/学习路径 - WebUI 五视图:驾驶舱/图谱工作台/岗位演化/匹配(单人+批量)/学习路径 - 设置中心(config.json)/Token鉴权/SQLite状态库/语义向量通道(BGE-M3本地或联网API) - 71 个 pytest 用例;数据目录仅保留结构(.gitkeep),数据由 scripts/generate_dataset.py 复现
134 lines
4.9 KiB
Python
134 lines
4.9 KiB
Python
"""规范简历 DOCX 导出:照片 + 基本信息表 + 分节正文(关键词高亮)+ 可选匹配诊断。
|
||
|
||
完全离线,仅依赖 python-docx。返回 bytes,由 API 层以文件流下发。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import io
|
||
import re
|
||
|
||
from docx import Document
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
from docx.oxml.ns import qn
|
||
from docx.shared import Cm, Pt, RGBColor
|
||
|
||
from app.models import ResumeProfile
|
||
|
||
_GREEN = RGBColor(0x00, 0xB4, 0x2A) # 命中必备
|
||
_BLUE = RGBColor(0x2F, 0x54, 0xEB) # 命中加分
|
||
_GRAY = RGBColor(0x4E, 0x59, 0x69) # 其他技能
|
||
_DARK = RGBColor(0x1D, 0x21, 0x29)
|
||
|
||
|
||
def _set_cn_font(doc: Document) -> None:
|
||
st = doc.styles["Normal"]
|
||
st.font.name = "Times New Roman"
|
||
st.font.size = Pt(10.5)
|
||
st.element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||
|
||
|
||
def _highlight_runs(paragraph, text: str, cls_of: dict[str, RGBColor]) -> None:
|
||
"""按技能清单把 text 切段写入段落,命中技能加粗上色。"""
|
||
if not cls_of:
|
||
paragraph.add_run(text)
|
||
return
|
||
names = sorted(cls_of, key=len, reverse=True)
|
||
pat = re.compile("|".join(re.escape(n) for n in names), re.IGNORECASE)
|
||
pos = 0
|
||
for m in pat.finditer(text):
|
||
if m.start() > pos:
|
||
paragraph.add_run(text[pos:m.start()])
|
||
hit = m.group(0)
|
||
run = paragraph.add_run(hit)
|
||
run.bold = True
|
||
run.font.color.rgb = cls_of.get(hit.lower(), _DARK)
|
||
pos = m.end()
|
||
if pos < len(text):
|
||
paragraph.add_run(text[pos:])
|
||
|
||
|
||
def build_resume_docx(profile: ResumeProfile, match: dict | None = None) -> bytes:
|
||
"""把脱敏简历画像渲染为规范 DOCX,返回文件 bytes。"""
|
||
doc = Document()
|
||
_set_cn_font(doc)
|
||
|
||
title = doc.add_paragraph()
|
||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
trun = title.add_run("个 人 简 历")
|
||
trun.bold = True
|
||
trun.font.size = Pt(20)
|
||
sub = doc.add_paragraph()
|
||
sub.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||
srun = sub.add_run("(CareerNexus 职联 · 结构化解析导出,已自动脱敏)")
|
||
srun.font.size = Pt(9)
|
||
srun.font.color.rgb = _GRAY
|
||
|
||
# ---- 头部:左照片 + 右基本信息表 ----
|
||
head = doc.add_table(rows=1, cols=2)
|
||
head.style = "Table Grid"
|
||
head.columns[0].width = Cm(3.4)
|
||
head.columns[1].width = Cm(13.4)
|
||
photo_cell, info_cell = head.cell(0, 0), head.cell(0, 1)
|
||
if profile.photo_b64:
|
||
try:
|
||
raw = base64.b64decode(profile.photo_b64.split(",", 1)[1])
|
||
photo_cell.paragraphs[0].add_run().add_picture(io.BytesIO(raw), width=Cm(3.0))
|
||
except Exception:
|
||
photo_cell.paragraphs[0].add_run("(照片解析失败)")
|
||
else:
|
||
photo_cell.paragraphs[0].add_run("(未提供照片)")
|
||
|
||
info_items = [
|
||
("姓名", profile.name or "—"), ("性别", profile.gender or "—"),
|
||
("学历", profile.education or "—"), ("工作年限", f"{profile.years:g} 年" if profile.years else "—"),
|
||
("手机", profile.phone or "—"), ("邮箱", profile.email or "—"),
|
||
("家庭住址", profile.address or "—"),
|
||
]
|
||
info_table = info_cell.add_table(rows=(len(info_items) + 1) // 2, cols=2)
|
||
for i, (k, v) in enumerate(info_items):
|
||
cell = info_table.cell(i // 2, i % 2)
|
||
p = cell.paragraphs[0]
|
||
kr = p.add_run(f"{k}:")
|
||
kr.bold = True
|
||
p.add_run(v)
|
||
|
||
# 技能高亮类别(小写键)
|
||
cls_of: dict[str, RGBColor] = {}
|
||
if match:
|
||
for s in match.get("matched_required", []):
|
||
cls_of[s.lower()] = _GREEN
|
||
for s in match.get("matched_preferred", []):
|
||
cls_of[s.lower()] = _BLUE
|
||
for s in profile.skills:
|
||
cls_of.setdefault(s.lower(), _GRAY)
|
||
|
||
# ---- 分节正文(关键词标注) ----
|
||
for sec in profile.sections or [{"title": "简历全文", "text": profile.raw_masked_text}]:
|
||
h = doc.add_paragraph()
|
||
hrun = h.add_run(f"▍{sec['title']}")
|
||
hrun.bold = True
|
||
hrun.font.size = Pt(13)
|
||
hrun.font.color.rgb = _BLUE
|
||
_highlight_runs(doc.add_paragraph(), sec["text"], cls_of)
|
||
|
||
# ---- 匹配诊断摘要(可选) ----
|
||
if match and match.get("job_title"):
|
||
h = doc.add_paragraph()
|
||
hrun = h.add_run("▍人岗匹配诊断(CareerNexus)")
|
||
hrun.bold = True
|
||
hrun.font.size = Pt(13)
|
||
hrun.font.color.rgb = _BLUE
|
||
for line in [
|
||
f"目标岗位:{match['job_title']} 综合匹配分:{match.get('total_score', 0)}",
|
||
f"命中必备技能:{'、'.join(match.get('matched_required', [])) or '无'}",
|
||
f"命中加分技能:{'、'.join(match.get('matched_preferred', [])) or '无'}",
|
||
f"缺失技能:{'、'.join((match.get('missing_required', [])
|
||
+ match.get('missing_preferred', []))) or '无'}",
|
||
]:
|
||
doc.add_paragraph(line)
|
||
|
||
buf = io.BytesIO()
|
||
doc.save(buf)
|
||
return buf.getvalue()
|