答辩级 UI 改造:指挥台驾驶舱/证据地图/演化生命线/成长路线图/候选审核台
- 驾驶舱改'人才能力变化指挥台':变化事件流主图+可信度卡+行动卡+播报卡 - 图谱子图同心分层布局、技能变化角标、系统发现折叠入右栏、主操作收敛 - 演化页双 Tab(岗位报告/全岗位扫描)+ 能力生命线 Swimlane + 更替流 - 学习路径成长路线图:概要条/示范路线/预计投入/标记完成联动 - 新增候选审核台抽屉 + GET /api/stats/credibility - 简历裸姓名/专硕提取修复(桌面真实简历验收通过)+ BGE-M3 加载提示
This commit is contained in:
@@ -336,6 +336,34 @@ def graph() -> dict:
|
||||
|
||||
|
||||
# ---------- JD 导入(异步任务) ----------
|
||||
@app.get("/api/stats/credibility")
|
||||
def credibility_stats() -> dict:
|
||||
"""数据可信度指标(驾驶舱「数据可信度」卡):
|
||||
有效 JD 数 / 独立企业数 / 来源类型数 / 人工复核率 / 异常剔除率。
|
||||
人工复核率 = 审核留痕数 / (留痕数 + 待审候选数)。"""
|
||||
jd_dir = resolve(_cfg, "jd_raw_dir")
|
||||
records, _ = load_jd_dir(jd_dir)
|
||||
total = len(records)
|
||||
valid = [r for r in records if not r.is_noise and not r.is_duplicate]
|
||||
companies = {r.company for r in valid if r.company}
|
||||
sources = sorted({r.source for r in valid if r.source})
|
||||
audit = state_db.get_audit(_db_path(), limit=200)
|
||||
reviewed = sum(1 for r in audit if r.get("kind") != "node_update")
|
||||
pending = len(load_pending(_db_path()))
|
||||
denom = reviewed + pending
|
||||
return _ok({
|
||||
"jd_total": total,
|
||||
"jd_valid": len(valid),
|
||||
"companies": len(companies),
|
||||
"sources": len(sources),
|
||||
"source_names": sources,
|
||||
"reviewed": reviewed,
|
||||
"pending": pending,
|
||||
"review_rate": (reviewed / denom) if denom else None,
|
||||
"excluded_rate": ((total - len(valid)) / total) if total else 0.0,
|
||||
})
|
||||
|
||||
|
||||
def _run_task(task_id: str) -> None:
|
||||
task = _state["tasks"][task_id]
|
||||
try:
|
||||
|
||||
+25
-4
@@ -19,9 +19,12 @@ logger = logging.getLogger(__name__)
|
||||
_PHONE_RE = re.compile(r"1[3-9]\d{9}")
|
||||
_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
_NAME_RE = re.compile(r"姓名[::]\s*([一-龥]{2,4})")
|
||||
# 简历常见写法:首行裸姓名(无"姓名:"前缀),如" 王伟航 "
|
||||
_BARE_NAME_RE = re.compile(r"^\s*([一-龥]{2,4})\s*$", re.MULTILINE)
|
||||
_YEARS_RE = re.compile(r"(\d+(?:\.\d+)?)\s*年(?:以上)?[^\n,。;]{0,6}?经验")
|
||||
_GENDER_RE = re.compile(r"性别[::]?\s*(男|女)")
|
||||
_EDU_RE = re.compile(r"(博士后|博士|硕士|研究生|MBA|本科|大专|专科)")
|
||||
_EDU_RE = re.compile(r"(博士后|博士|专硕|学硕|硕士|研究生|MBA|本科|大专|专科)")
|
||||
_EDU_MAP = {"专硕": "硕士", "学硕": "硕士", "研究生": "硕士"}
|
||||
_ADDR_RE = re.compile(r"(?:家庭住址|现居(?:住)?(?:地|址)?|联系地址|通讯地址|籍贯)[::]\s*([^\n,。;]{2,30})")
|
||||
|
||||
# 常见简历分节标题(整行匹配,可带冒号)
|
||||
@@ -33,11 +36,28 @@ _SECTION_TITLES = {
|
||||
}
|
||||
|
||||
|
||||
def _extract_name(text: str) -> str:
|
||||
"""姓名识别:优先"姓名:X"格式,否则取首个独立的 2-4 字中文裸行(简历常见排版)。"""
|
||||
m = _NAME_RE.search(text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
for line in text.splitlines()[:5]: # 只在开头几行找,避免误伤正文
|
||||
b = _BARE_NAME_RE.match(line)
|
||||
if b:
|
||||
return b.group(1)
|
||||
return ""
|
||||
|
||||
|
||||
def mask_text(text: str) -> str:
|
||||
"""脱敏:手机、邮箱、姓名、住址打码。"""
|
||||
"""脱敏:手机、邮箱、姓名(含首行裸姓名)、住址打码。"""
|
||||
text = _PHONE_RE.sub(lambda m: m.group(0)[:3] + "****" + m.group(0)[-2:], text)
|
||||
text = _EMAIL_RE.sub(lambda m: m.group(0)[0] + "***@" + m.group(0).split("@")[1], text)
|
||||
text = _NAME_RE.sub(lambda m: "姓名:" + m.group(1)[0] + "*" * (len(m.group(1)) - 1), text)
|
||||
name = _extract_name(text)
|
||||
if name: # 裸姓名行整体打码
|
||||
text = _BARE_NAME_RE.sub(
|
||||
lambda m: m.group(0).replace(name, name[0] + "*" * (len(name) - 1))
|
||||
if m.group(1) == name else m.group(0), text, count=1)
|
||||
text = _ADDR_RE.sub(lambda m: m.group(0)[:2] + m.group(1)[:6] + "***"
|
||||
if len(m.group(1)) > 6 else m.group(0), text)
|
||||
return text
|
||||
@@ -122,6 +142,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None,
|
||||
"""从简历文本解析脱敏画像(含结构化基本信息与分节)。"""
|
||||
matcher = matcher or SkillMatcher(load_lexicon())
|
||||
name_m = _NAME_RE.search(text)
|
||||
name = name_m.group(1) if name_m else _extract_name(text)
|
||||
phone_m = _PHONE_RE.search(text)
|
||||
email_m = _EMAIL_RE.search(text)
|
||||
years_m = _YEARS_RE.search(text)
|
||||
@@ -130,7 +151,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None,
|
||||
addr_m = _ADDR_RE.search(text)
|
||||
masked = mask_text(text)
|
||||
return ResumeProfile(
|
||||
name=(name_m.group(1)[0] + "*" * (len(name_m.group(1)) - 1)) if name_m else "",
|
||||
name=(name[0] + "*" * (len(name) - 1)) if name else "",
|
||||
phone=(phone_m.group(0)[:3] + "****" + phone_m.group(0)[-2:]) if phone_m else "",
|
||||
email=(email_m.group(0)[0] + "***@" + email_m.group(0).split("@")[1]) if email_m else "",
|
||||
years=float(years_m.group(1)) if years_m else 0.0,
|
||||
@@ -138,7 +159,7 @@ def parse_resume(text: str, matcher: SkillMatcher | None = None,
|
||||
scenarios=matcher.find_scenarios(text),
|
||||
raw_masked_text=masked,
|
||||
gender=gender_m.group(1) if gender_m else "",
|
||||
education=edu_m.group(1) if edu_m else "",
|
||||
education=_EDU_MAP.get(edu_m.group(1), edu_m.group(1)) if edu_m else "",
|
||||
address=_mask_addr(addr_m.group(1).strip()) if addr_m else "",
|
||||
photo_b64=photo_b64,
|
||||
sections=split_sections(masked),
|
||||
|
||||
Reference in New Issue
Block a user