Commit ba4d5ed2 authored by 李文光's avatar 李文光

feat: 需求17 风险点固定维度——四维本地规则 + LLM 对齐 + 前端固定维度面板

新增 backend/app/services/risk_profile.py 本地确定性规则(跳槽频率/竞业协议/职业空窗/高层职业道德),从简历原文按章节提取任职时间线(自动排除教育/实习/项目段);local-structured 与 LLM 评估统一合并 riskProfile,LLM prompt 将 risks 限定固定四维;前端评估报告优先展示固定维度命中项,候选人详情「评估」Tab 新增「风险点评估」面板(正常/提示/需确认 + 依据 + 建议)。测试:test_risk_profile.py + test_ai.py prompt 断言,全量 72 passed。文档 requirements_from_interview.md 同步标注 
parent 1249554d
...@@ -7,6 +7,7 @@ from langchain_openai import ChatOpenAI ...@@ -7,6 +7,7 @@ from langchain_openai import ChatOpenAI
from backend.app.config import get_settings from backend.app.config import get_settings
from backend.app.services.qwenpaw_client import qwenpaw_jd_draft from backend.app.services.qwenpaw_client import qwenpaw_jd_draft
from backend.app.services.risk_profile import build_risk_profile
# 需求 14:AI 评估铁律——候选人自述优势/主观表述不作为匹配依据(只作面试待验证线索)。 # 需求 14:AI 评估铁律——候选人自述优势/主观表述不作为匹配依据(只作面试待验证线索)。
# 详见 docs/requirements_from_interview.md 需求 14。 # 详见 docs/requirements_from_interview.md 需求 14。
...@@ -22,6 +23,18 @@ _SELF_CLAIM_RULE = ( ...@@ -22,6 +23,18 @@ _SELF_CLAIM_RULE = (
) )
# 需求 17:风险点固定维度——risks 只围绕四个固定维度输出,不泛化罗列。
# 详见 docs/requirements_from_interview.md 需求 17。
_RISK_DIMENSION_RULE = (
"风险输出规则(必须遵守):risks 只围绕以下固定维度逐条给出「提示 + 依据」,不要泛化罗列其他风险:\n"
"①跳槽频率:按任职时间线判断是否跳槽偏频繁;\n"
"②竞业协议:结合岗位竞业限制要求,判断是否需要 HR/人力确认候选人的竞业情况;\n"
"③职业空窗:任职之间的空窗是否显著(如 4 年 gap 需提示);\n"
"④高层职业道德:仅当目标岗位为总监/副总/总经理/VP/首席等高层时,提示背调职业道德;\n"
"每项依据须来自简历时间线或岗位说明,不写候选人姓名、电话等个人隐私。"
)
def split_rule_lines(value: str | list[str] | None) -> list[str]: def split_rule_lines(value: str | list[str] | None) -> list[str]:
if isinstance(value, list): if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()] return [str(item).strip() for item in value if str(item).strip()]
...@@ -149,6 +162,7 @@ def local_resume_analysis(payload: dict[str, Any]) -> dict[str, Any]: ...@@ -149,6 +162,7 @@ def local_resume_analysis(payload: dict[str, Any]) -> dict[str, Any]:
*[f"硬性条件缺口:“{term}”。" for term in must_missing[:3]], *[f"硬性条件缺口:“{term}”。" for term in must_missing[:3]],
*[f"未在简历中明确看到“{term}”相关证据,建议面试确认。" for term in missing[:3]], *[f"未在简历中明确看到“{term}”相关证据,建议面试确认。" for term in missing[:3]],
][:6], ][:6],
"riskProfile": build_risk_profile(candidate, job),
"interviewQuestions": [f"请候选人说明与“{term}”相关的实际项目、职责边界和结果数据。" for term in missing[:4]], "interviewQuestions": [f"请候选人说明与“{term}”相关的实际项目、职责边界和结果数据。" for term in missing[:4]],
"evidence": evidence, "evidence": evidence,
"nextAction": decision, "nextAction": decision,
...@@ -306,6 +320,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -306,6 +320,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
user = ( user = (
"请分析候选人与岗位的匹配度,并返回 JSON。字段必须为:score, decision, summary, hardMatch, " "请分析候选人与岗位的匹配度,并返回 JSON。字段必须为:score, decision, summary, hardMatch, "
"experienceMatch, projectEvidence, competencyFrame, risks, interviewQuestions, nextAction, scoreBreakdown。\n\n" "experienceMatch, projectEvidence, competencyFrame, risks, interviewQuestions, nextAction, scoreBreakdown。\n\n"
f"{_RISK_DIMENSION_RULE}\n\n"
f"{_SELF_CLAIM_RULE}\n\n" f"{_SELF_CLAIM_RULE}\n\n"
f"岗位:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}\n\n" f"岗位:{json.dumps(payload.get('job') or {}, ensure_ascii=False, indent=2)}\n\n"
f"岗位结构化规则:{json.dumps(criteria, ensure_ascii=False, indent=2)}\n\n" f"岗位结构化规则:{json.dumps(criteria, ensure_ascii=False, indent=2)}\n\n"
...@@ -318,7 +333,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None: ...@@ -318,7 +333,7 @@ async def llm_resume_analysis(payload: dict[str, Any]) -> dict[str, Any] | None:
user, user,
temperature=0, temperature=0,
) )
return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}"} return {**parsed, "reportVersion": "ai-evaluation-v2", "provider": f"llm:{settings.effective_llm_model}", "riskProfile": build_risk_profile(payload.get("candidate") or {}, payload.get("job") or {})}
async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]: async def analyze_resume(payload: dict[str, Any]) -> dict[str, Any]:
......
"""候选人风险点固定维度(需求 17)。
HR 访谈原话(P16/P17):「风险点不要太泛,只评估几个点:跳槽频率、竞业协议、
职业空窗、高层职业道德」——对应 docs/requirements_from_interview.md 需求 17。
实现约定:
- 规则**本地确定性计算**(同一输入结果一致),LLM 只做补充,不直接扣分;
- 每项输出「提示 + 依据(简历时间线 / 岗位说明)」,由 HR 人工确认;
- 输出 riskProfile 为固定 4 个维度数组,每项含
dimension/label/level(ok|hint|warn)/summary/evidence(list)/action。
"""
import re
from datetime import date
from typing import Any
RISK_DIMENSIONS: tuple[tuple[str, str], ...] = (
("jobHopping", "跳槽频率"),
("nonCompete", "竞业协议"),
("careerGap", "职业空窗"),
("seniorEthics", "高层职业道德"),
)
# 阈值常量(可调):跳槽/空窗判定的口径集中在此,便于测试与后续调整。
SHORT_TENURE_MONTHS = 12 # 任职不足 1 年视为短期
HOP_HINT_SHORT_JOBS = 2 # 短期任职 ≥2 段 -> 提示
HOP_WARN_MIN_JOBS = 3 # 至少 3 段才下「跳槽频繁」结论
HOP_WARN_AVG_MONTHS = 18 # 平均任职 < 1.5 年/段 -> 需确认
HOP_HINT_AVG_MONTHS = 24 # 平均任职 < 2 年/段且 ≥3 段 -> 提示
GAP_HINT_MONTHS = 12 # 任职间隔 ≥12 个月 -> 提示
GAP_WARN_MONTHS = 24 # 任职间隔 ≥24 个月(HR 举例 4 年 gap)-> 需确认
_SECTION_HEADERS = (
"工作经历",
"工作履历",
"任职经历",
"职业经历",
"从业经历",
"实习经历",
"项目经历",
"项目经验",
"教育经历",
"教育背景",
"学习经历",
"校园经历",
"培训经历",
"证书",
"技能",
"自我评价",
"个人评价",
"求职意向",
)
_EMPLOYMENT_HEADERS = ("工作经历", "工作履历", "任职经历", "职业经历", "从业经历")
_EXCLUDE_HEADERS = (
"实习经历",
"项目经历",
"项目经验",
"教育经历",
"教育背景",
"学习经历",
"校园经历",
"培训经历",
"证书",
"技能",
"自我评价",
"个人评价",
"求职意向",
)
_DATE_RANGE = re.compile(
r"(?P<sy>(?:19|20)\d{2})[./年\-]?(?P<sm>\d{1,2})?月?[ \u3000]*"
r"(?:-|—|–|-|~|~|至|到)[ \u3000]*"
r"(?P<ey>(?:19|20)\d{2}|至今|今|现在|目前)(?:[./年\-](?P<em>\d{1,2}))?月?",
re.IGNORECASE,
)
_EDU_MARKER = re.compile(r"教育|学校|院校|大学|学院|本科|硕士|博士|大专|专业|毕业|学位|在校|主修|攻读")
_WORK_MARKER = re.compile(r"公司|集团|有限|任职|就职|担任|工作|加入|入职|银行|证券|保险|医院|科技|软件|网络|互联网|厂")
_INTERN_MARKER = re.compile(r"实习|兼职|见习")
_GAP_EXPLAIN_MARKER = re.compile(r"教育|学校|大学|学院|硕士|博士|考研|备考|就读|深造|留学|创业")
_SENIOR_ROLE = re.compile(r"总监|副总|总经理|副总裁|首席|总裁|合伙人|董事|CTO|CFO|COO|VP|Head\s*of|Director", re.IGNORECASE)
_NON_COMPETE_NEEDED = re.compile(r"需签|要签|需要签|竞业|签署|需要|需|签|是")
_NON_COMPETE_NONE = re.compile(r"不需|无需|不用|不签|无竞业|不要求")
def _month_index(year: int, month: int) -> int:
return year * 12 + month
def _months_between(start: tuple[int, int], end: tuple[int, int]) -> int:
return max(0, _month_index(*end) - _month_index(*start) + 1)
def _fmt_month(value: tuple[int, int] | None) -> str:
if not value:
return "至今"
return f"{value[0]}.{value[1]:02d}"
def _fmt_range(start: tuple[int, int] | None, end: tuple[int, int] | None) -> str:
return f"{_fmt_month(start)} - {_fmt_month(end)}"
def _fmt_gap(months: int) -> str:
if months < GAP_WARN_MONTHS:
return f"约 {months} 个月"
years, rest = divmod(months, 12)
return f"约 {years} 年" + (f" {rest} 个月" if rest else "")
def _trim(value: str, limit: int = 42) -> str:
value = re.sub(r"\s+", " ", value or "").strip(" \t-—–·|")
return value if len(value) <= limit else value[:limit] + "…"
def _nearest_section_header(text: str, pos: int) -> str | None:
"""返回 pos 之前最近的章节标题(工作经历/教育经历/项目经历…),没有则 None。"""
found: list[tuple[int, str]] = []
for header in _SECTION_HEADERS:
index = text.rfind(header, 0, pos)
if index >= 0:
found.append((index + len(header), header))
return max(found)[1] if found else None
def _is_work_entry(line: str, next_line: str, header: str | None) -> bool:
"""判断某条日期范围是否属于正式任职经历(排除教育/实习/项目等)。"""
if _INTERN_MARKER.search(line) or _INTERN_MARKER.search(next_line):
return False
if header:
if header in _EMPLOYMENT_HEADERS:
return True
if header in _EXCLUDE_HEADERS:
return False
# 无标题或标题不识别时按行关键词兜底:教育行排除、含公司/任职特征行收录
if _EDU_MARKER.search(line) and not _WORK_MARKER.search(line):
return False
return bool(_WORK_MARKER.search(line) or _WORK_MARKER.search(next_line))
def parse_work_timeline(text: str) -> list[dict[str, Any]]:
"""从简历原文中提取任职时间线(排除教育/实习/项目段)。
返回按开始时间排序的任职条目,每项含
start/end((year, month) 元组或 None)/ongoing/label/pos/end_pos。
"""
if not text:
return []
today = date.today()
raw: list[dict[str, Any]] = []
seen: set[tuple[int, int]] = set()
for match in _DATE_RANGE.finditer(text):
start_year = int(match.group("sy"))
start_month = int(match.group("sm") or 1)
end_year_raw = match.group("ey")
end_month_raw = match.group("em")
ongoing = bool(re.search(r"至今|今|现在|目前", end_year_raw or "", re.IGNORECASE))
if not ongoing:
end_year = int(end_year_raw)
end_month = int(end_month_raw or 12)
else:
end_year, end_month = today.year, today.month
if not (1980 <= start_year <= today.year + 1 and 1980 <= end_year <= today.year + 1):
continue
start = (start_year, start_month)
end = (end_year, end_month) if not ongoing else None
key = (start_year * 100 + start_month, end_year * 100 + end_month)
if key in seen:
continue
line_start = text.rfind("\n", 0, match.start()) + 1
line_end = text.find("\n", match.end())
next_start = text.find("\n", match.end()) + 1
next_end = text.find("\n", next_start) if next_start > 0 else -1
if next_end < 0:
next_end = len(text)
line = text[line_start:line_end].strip()
next_line = text[next_start:next_end].strip() if next_start > 0 else ""
header = _nearest_section_header(text, match.start())
if not _is_work_entry(line, next_line, header):
continue
tail = line[match.end() - line_start :].strip() or next_line.strip()
label = _trim(tail) or f"{_fmt_range(start, end)} 任职"
seen.add(key)
raw.append(
{
"start": start,
"end": end,
"ongoing": ongoing,
"label": label,
"range_text": _fmt_range(start, end),
"pos": match.start(),
"end_pos": match.end(),
}
)
raw.sort(key=lambda item: _month_index(*item["start"]))
return raw
def _entry_tenures(entries: list[dict[str, Any]], today: date) -> list[int]:
tenures: list[int] = []
for entry in entries:
end = entry["end"] or (today.year, today.month)
tenures.append(_months_between(entry["start"], end))
return tenures
def job_hopping_item(entries: list[dict[str, Any]]) -> dict[str, Any]:
label = "跳槽频率"
if not entries:
return {
"dimension": "jobHopping",
"label": label,
"level": "ok",
"summary": "未识别到任职时间线,暂不评估跳槽频率。",
"evidence": [],
"action": "如已有完整履历,可在面试中人工复核。",
}
if len(entries) == 1:
entry = entries[0]
return {
"dimension": "jobHopping",
"label": label,
"level": "ok",
"summary": f"仅识别到 1 段任职({entry['range_text']}),暂不评估跳槽频率。",
"evidence": [f"{entry['range_text']} · {entry['label']}"],
"action": "无需处理。",
}
tenures = _entry_tenures(entries, date.today())
avg = sum(tenures) / len(tenures)
short_count = sum(1 for tenure in tenures if tenure < SHORT_TENURE_MONTHS)
evidence = [f"{entry['range_text']} · {entry['label']}" for entry in entries[:4]]
count = len(entries)
if count >= HOP_WARN_MIN_JOBS and avg < HOP_WARN_AVG_MONTHS:
return {
"dimension": "jobHopping",
"label": label,
"level": "warn",
"summary": f"任职周期偏短:共 {count} 段,平均每段约 {avg / 12:.1f} 年(低于常见 1.5 年/段),跳槽偏频繁。",
"evidence": evidence,
"action": "面试重点核实每段离职原因与职业规划,评估流失与融入风险。",
}
if short_count >= HOP_HINT_SHORT_JOBS or (count >= HOP_WARN_MIN_JOBS and avg < HOP_HINT_AVG_MONTHS):
return {
"dimension": "jobHopping",
"label": label,
"level": "hint",
"summary": f"任职稳定性一般:共 {count} 段,平均每段约 {avg / 12:.1f} 年,其中 {short_count} 段不足 1 年。",
"evidence": evidence,
"action": "面试了解短期任职原因;如属行业/项目特性可放宽。",
}
return {
"dimension": "jobHopping",
"label": label,
"level": "ok",
"summary": f"任职较稳定:共 {count} 段,平均每段约 {avg / 12:.1f} 年。",
"evidence": evidence,
"action": "无需处理。",
}
def career_gap_item(entries: list[dict[str, Any]], resume_text: str) -> dict[str, Any]:
label = "职业空窗"
if len(entries) < 2:
return {
"dimension": "careerGap",
"label": label,
"level": "ok",
"summary": "任职时间线不足两段,暂不评估职业空窗。",
"evidence": [],
"action": "无需处理。",
}
gaps: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
for index in range(len(entries) - 1):
prev_entry, next_entry = entries[index], entries[index + 1]
if not prev_entry["end"]:
continue # 上一段未结束(至今),无空窗可判
if _month_index(*next_entry["start"]) <= _month_index(*prev_entry["end"]):
continue # 任职重叠/衔接,无空窗
gap = _month_index(*next_entry["start"]) - _month_index(*prev_entry["end"]) - 1
if gap < GAP_HINT_MONTHS:
continue
between = resume_text[prev_entry["end_pos"] : next_entry["pos"]]
if _GAP_EXPLAIN_MARKER.search(between):
continue # 空窗期有教育/深造/创业等自述说明,交 HR 判断不自动提示
gaps.append((gap, prev_entry, next_entry))
if not gaps:
return {
"dimension": "careerGap",
"label": label,
"level": "ok",
"summary": "任职时间线连续,未发现显著空窗(≥12 个月)。",
"evidence": [],
"action": "无需处理。",
}
gap, prev_entry, next_entry = max(gaps, key=lambda item: item[0])
evidence = [
f"{prev_entry['range_text']} · {prev_entry['label']}",
f"{next_entry['range_text']} · {next_entry['label']}",
]
level = "warn" if gap >= GAP_WARN_MONTHS else "hint"
summary = (
f"任职间存在空窗:{_fmt_gap(gap)}"
f"({prev_entry['range_text']} → {next_entry['range_text']}),建议面试确认空窗原因与期间经历。"
)
return {
"dimension": "careerGap",
"label": label,
"level": level,
"summary": summary,
"evidence": evidence,
"action": "面试确认空窗原因(求学/家庭/创业/待业),并核实期间是否有未写入简历的经历。",
}
def non_compete_item(job: dict[str, Any]) -> dict[str, Any]:
label = "竞业协议"
text = str((job or {}).get("nonCompete") or "").strip()
if not text:
return {
"dimension": "nonCompete",
"label": label,
"level": "ok",
"summary": "岗位未标注竞业限制要求,默认不触发竞业确认。",
"evidence": [],
"action": "无需处理。",
}
if _NON_COMPETE_NONE.search(text):
return {
"dimension": "nonCompete",
"label": label,
"level": "ok",
"summary": "岗位不要求签署竞业协议。",
"evidence": [f"岗位竞业说明:{text}"],
"action": "无需处理。",
}
if _NON_COMPETE_NEEDED.search(text):
level, summary = "warn", f"岗位需签署竞业协议({text}):需确认候选人上一家竞业限制情况,来自竞对高管须人力专项确认。"
else:
level, summary = "hint", f"岗位竞业要求待确认({text}):建议与用人部门对齐口径后再评估。"
return {
"dimension": "nonCompete",
"label": label,
"level": level,
"summary": summary,
"evidence": [f"岗位竞业说明:{text}"],
"action": "Offer/入职前由 HR 与人力确认候选人竞业限制期与竞对范围;如为竞对高管,须人力专项确认。",
}
def senior_ethics_item(job: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
label = "高层职业道德"
refs = [str(item).strip() for item in [job.get("title"), job.get("level"), candidate.get("jobTitle")] if str(item or "").strip()]
matched = [ref for ref in refs if _SENIOR_ROLE.search(ref)]
if not matched:
return {
"dimension": "seniorEthics",
"label": label,
"level": "ok",
"summary": "非高层岗位,不触发高层职业道德专项背调。",
"evidence": [],
"action": "无需处理。",
}
return {
"dimension": "seniorEthics",
"label": label,
"level": "warn",
"summary": f"目标岗位属高层({'、'.join(matched[:2])}),建议 Offer 前完成职业道德/背景核实。",
"evidence": [f"岗位/头衔:{'、'.join(matched)}"],
"action": "Offer 前背调:联系前雇主核实管理表现、离职原因与职业操守;由人力确认无竞业/纪律/合规问题。",
}
def build_risk_profile(candidate: dict[str, Any], job: dict[str, Any]) -> list[dict[str, Any]]:
"""固定四维度风险画像:本地确定性规则,LLM 只做补充,不直接扣分。"""
resume_text = str((candidate or {}).get("resumeText") or "")
entries = parse_work_timeline(resume_text)
return [
job_hopping_item(entries),
non_compete_item(job or {}),
career_gap_item(entries, resume_text),
senior_ethics_item(job or {}, candidate or {}),
]
...@@ -297,3 +297,39 @@ def test_fields_prompt_excludes_subjective_traits_from_skills(llm_enabled, monke ...@@ -297,3 +297,39 @@ def test_fields_prompt_excludes_subjective_traits_from_skills(llm_enabled, monke
# 解析 prompt 声明:主观品质词不属于 skills、自述不得推断硬字段 # 解析 prompt 声明:主观品质词不属于 skills、自述不得推断硬字段
assert "主观品质" in captured["user"] assert "主观品质" in captured["user"]
assert "自我评价 / 个人优势段落的主观表述仅作参考" in captured["user"] assert "自我评价 / 个人优势段落的主观表述仅作参考" in captured["user"]
def test_analysis_prompt_constrains_risks_to_fixed_dimensions(llm_enabled, monkeypatch):
"""需求17:评估 prompt 必须把 risks 限定在跳槽/竞业/空窗/高层职业道德四个固定维度。"""
captured = {}
async def fake_chat_json(settings, system, user, *, temperature=0):
captured["user"] = user
return {
"score": 80,
"decision": "建议优先推进面试",
"summary": "测试摘要",
"risks": [],
"scoreBreakdown": {"finalScore": 80},
}
monkeypatch.setattr("backend.app.services.llm._chat_json", fake_chat_json)
from backend.app.services.llm import llm_resume_analysis
result = asyncio.run(
llm_resume_analysis(
{
"candidate": {"name": "王五", "resumeText": "工作经历\n2015.06-2018.05 某公司\n2022.07-至今 某科技公司\n"},
"job": {"title": "销售总监", "mustHave": "大客户", "nonCompete": "需签"},
}
)
)
assert result["provider"].startswith("llm:")
# prompt 声明 risks 固定维度
assert "跳槽频率" in captured["user"]
assert "竞业协议" in captured["user"]
assert "职业空窗" in captured["user"]
assert "高层职业道德" in captured["user"]
# 合并的固定维度画像也随报告返回
assert len(result["riskProfile"]) == 4
assert result["riskProfile"][0]["dimension"] == "jobHopping"
"""需求 17:风险点固定维度——本地确定性规则与接口集成的单测。
覆盖:任职时间线解析(排除教育/项目/实习)、跳槽频率、竞业协议、
职业空窗(含被教育说明的空窗豁免、空窗延伸进「至今」任职)、高层职业道德,
以及 analyze-resume 接口把 riskProfile 合并进报告的集成行为。
"""
from backend.app.services.risk_profile import (
RISK_DIMENSIONS,
build_risk_profile,
career_gap_item,
job_hopping_item,
non_compete_item,
parse_work_timeline,
senior_ethics_item,
)
WORK_SECTION = "工作经历\n"
def _entry_years(years):
"""构造任职时间线:years = [(start_year, end_year), ...],end_year=None 表示至今。"""
text = WORK_SECTION
for start, end in years:
end_text = "至今" if end is None else f"{end}.06"
text += f"{start}.01-{end_text} 某科技有限公司 工程师\n"
return text
def test_build_risk_profile_returns_fixed_dimensions():
candidate = {"resumeText": _entry_years([(2019, 2021), (2021, 2023)]), "jobTitle": "Java工程师"}
job = {"title": "Java开发工程师", "nonCompete": "不需要"}
profile = build_risk_profile(candidate, job)
assert [item["dimension"] for item in profile] == [key for key, _ in RISK_DIMENSIONS]
assert [item["label"] for item in profile] == [label for _, label in RISK_DIMENSIONS]
for item in profile:
assert item["level"] in ("ok", "hint", "warn")
assert item["summary"]
assert isinstance(item["evidence"], list)
assert item["action"]
def test_parse_work_timeline_skips_education_and_project_sections():
text = (
"教育经历\n2015.09-2019.06 某大学 计算机科学与技术\n主修课程:数据结构\n\n"
"工作经历\n2019.07-2021.06 深圳市甲网络科技有限公司 Java工程师\n"
"2021.07-至今 上海乙软件有限公司 高级工程师\n\n"
"项目经历\n2021.09-2021.12 XX系统重构\n"
)
entries = parse_work_timeline(text)
assert [(e["start"], e["end"]) for e in entries] == [((2019, 7), (2021, 6)), ((2021, 7), None)]
assert entries[0]["label"].startswith("深圳市甲网络科技有限公司")
def test_parse_work_timeline_headerless_company_lines():
text = "2018.03-2020.06 某科技有限公司\n2020.07-至今 某集团有限公司\n2012.09-2016.06 某大学\n"
entries = parse_work_timeline(text)
assert len(entries) == 2
assert entries[0]["label"].startswith("某科技有限公司")
def test_parse_work_timeline_ignores_internship():
text = WORK_SECTION + "2018.06-2018.09 某公司 实习生\n2019.07-至今 某科技有限公司 工程师\n"
entries = parse_work_timeline(text)
assert len(entries) == 1
def test_job_hopping_warn_on_frequent_short_jobs():
entries = parse_work_timeline(_entry_years([(2016, 2016), (2017, 2018), (2018, 2019), (2019, 2020)]))
item = job_hopping_item(entries)
assert item["level"] == "warn"
assert "跳槽" in item["summary"]
def test_job_hopping_hint_on_some_short_jobs():
entries = parse_work_timeline(_entry_years([(2016, 2016), (2017, 2017), (2018, 2022)]))
item = job_hopping_item(entries)
assert item["level"] == "hint"
def test_job_hopping_ok_on_stable_career():
entries = parse_work_timeline(_entry_years([(2016, 2019), (2019, 2022), (2022, None)]))
item = job_hopping_item(entries)
assert item["level"] == "ok"
def test_job_hopping_single_entry_is_ok():
entries = parse_work_timeline(WORK_SECTION + "2019.07-至今 某科技有限公司 工程师\n")
item = job_hopping_item(entries)
assert item["level"] == "ok"
def test_career_gap_warn_on_long_gap():
text = WORK_SECTION + "2015.06-2018.05 深圳甲科技有限公司\n2022.07-2024.06 广州乙网络有限公司\n"
entries = parse_work_timeline(text)
item = career_gap_item(entries, text)
assert item["level"] == "warn"
assert "空窗" in item["summary"]
assert any("2015.06 - 2018.05" in evidence for evidence in item["evidence"])
def test_career_gap_flags_gap_into_ongoing_job():
text = WORK_SECTION + "2018.03-2021.06 甲公司\n2022.09-至今 乙网络有限公司\n"
entries = parse_work_timeline(text)
item = career_gap_item(entries, text)
assert item["level"] in ("hint", "warn")
def test_career_gap_skips_education_explained_gap():
text = (
"工作经历\n2015.06-2018.05 深圳甲科技有限公司\n\n"
"教育经历\n2018.09-2022.06 某大学 硕士\n\n"
"工作经历\n2022.07-至今 广州乙网络有限公司\n"
)
entries = parse_work_timeline(text)
item = career_gap_item(entries, text)
assert item["level"] == "ok"
def test_career_gap_ok_when_continuous():
text = WORK_SECTION + "2019.07-2021.06 甲公司\n2021.07-2023.01 乙网络有限公司\n"
entries = parse_work_timeline(text)
item = career_gap_item(entries, text)
assert item["level"] == "ok"
def test_non_compete_dimension():
assert non_compete_item({"nonCompete": "需签"})["level"] == "warn"
assert non_compete_item({"nonCompete": "需要签署竞业协议"})["level"] == "warn"
assert non_compete_item({"nonCompete": "不需要"})["level"] == "ok"
assert non_compete_item({})["level"] == "ok"
def test_senior_ethics_dimension_detects_high_level():
job = {"title": "大客户销售总监", "level": ""}
candidate = {"jobTitle": ""}
item = senior_ethics_item(job, candidate)
assert item["level"] == "warn"
assert "高层" in item["summary"]
def test_senior_ethics_ok_for_regular_role():
job = {"title": "Java开发工程师", "level": ""}
item = senior_ethics_item(job, {"jobTitle": "高级前端工程师"})
assert item["level"] == "ok"
def test_analyze_route_merges_risk_profile(client):
"""local-structured 兜底路径也必须在报告中带上固定风险维度。"""
resp = client.post(
"/api/analyze-resume",
json={
"candidate": {
"name": "候选人",
"resumeText": (
"工作经历\n2015.06-2018.05 深圳甲科技有限公司 销售总监\n"
"2022.07-至今 广州乙网络有限公司 销售负责人\n"
),
"jobTitle": "销售总监",
},
"job": {"title": "大客户销售总监", "mustHave": "大客户", "nonCompete": "需签"},
},
)
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "local-structured"
assert [item["dimension"] for item in data["riskProfile"]] == [key for key, _ in RISK_DIMENSIONS]
by_dim = {item["dimension"]: item for item in data["riskProfile"]}
assert by_dim["careerGap"]["level"] == "warn"
assert by_dim["nonCompete"]["level"] == "warn"
assert by_dim["seniorEthics"]["level"] == "warn"
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
> >
> **2026-09-03 增量(基于 main @ f3f85b3)**:需求 14「AI 不得引用候选人自述优势」已实现——评估 prompt 加入「评估铁律」(自述不作为匹配依据、只作面试待验证线索),解析 prompt 同步约束主观品质词不进 skills;详见需求 14 条目。同日,需求 4(招聘人数联动自动关闭)与需求 22(岗位维简历量统计)亦已一并实现,见对应条目。 > **2026-09-03 增量(基于 main @ f3f85b3)**:需求 14「AI 不得引用候选人自述优势」已实现——评估 prompt 加入「评估铁律」(自述不作为匹配依据、只作面试待验证线索),解析 prompt 同步约束主观品质词不进 skills;详见需求 14 条目。同日,需求 4(招聘人数联动自动关闭)与需求 22(岗位维简历量统计)亦已一并实现,见对应条目。
> >
> **2026-09-03(工作区,需求 17)**:风险点固定维度已实现——新增 backend/app/services/risk_profile.py(跳槽频率/竞业协议/职业空窗/高层职业道德四维本地规则),local-structured 与 LLM 评估统一合并 riskProfile,LLM prompt 限定 risks 固定四维,前端「评估」Tab 新增固定维度面板;详见需求 17 条目。
>
> 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。 > 本次复核相对上一版(810e280)新增提交:`ddc5340`(候选人详情抽屉+档案编辑)、`07310dd`(简历库重构+保存原简历+防重复)、`6248f27`/`fc0aa5c`(简历解析真正 LLM 优先并移除脱敏)、`ec9499f`(弹窗蒙层/ESC 不关闭)。
> >
> 依据代码现状核实: > 依据代码现状核实:
...@@ -28,7 +30,7 @@ HR 原话要点: ...@@ -28,7 +30,7 @@ HR 原话要点:
**结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。 **结论**:延续现有系统架构(整包状态 + 前后端分离),按模块迭代。
**核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,需求 14(自述优势约束)已实现(2026-09-03,见条目);余下缺口为风险固定维度(需求 17)、批量移入/复活(需求 22);岗位维简历量统计已于 2026-09-03 落地;另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。 **核实情况(2026-09,最新)**:「下推联动」痛点已基本解决——候选人和岗位的每个状态动作都会同时写事件日志、增删待办(见需求 20);企微智能体产出的 JD 可经 `/api/jd/sync-external` 自动落为岗位并置为「待确认」状态(见需求 2)。**候选人详情已于近期改为「右侧抽屉 + 可收缩 PDF 档案预览」**(提交 `ddc5340`/`07310dd`,见需求 10),HR「弹窗/页面级收敛、简历可收缩预览」的交互诉求已落地。**候选人标记/人才池体系已大部落地(需求 1 总库=人才池 + 需求 12 标签筛选 + 需求 22 人才池独立视图,2026-09-02 工作区补充)**,需求 14(自述优势约束)已实现(2026-09-03,见条目);需求 17(风险点固定维度)亦已实现(2026-09-03,见条目);余下缺口为批量移入/复活(需求 22);岗位维简历量统计已于 2026-09-03 落地;另简历解析自 `fc0aa5c` 起改为**原文直传大模型提取字段(不再本地脱敏)**,采集插件仍不抓「沟通职位/期望薪资」。
--- ---
...@@ -146,8 +148,8 @@ HR 原话要点: ...@@ -146,8 +148,8 @@ HR 原话要点:
**需求 17:风险点评估(固定几个维度,不泛)** **需求 17:风险点评估(固定几个维度,不泛)**
- 原话:「风险点不要太泛,只评估几个点:跳槽频率、竞业协议(标准竞对高管需提示人力确认)、客户名单、中间 gap(如 2020-2024 四年 gap 要提示)、高层职业道德」(P16/P17) - 原话:「风险点不要太泛,只评估几个点:跳槽频率、竞业协议(标准竞对高管需提示人力确认)、客户名单、中间 gap(如 2020-2024 四年 gap 要提示)、高层职业道德」(P16/P17)
- 现状:🆕 **未实现为固定维度**。复核 `llm_resume_analysis` 与本地规则 `local_resume_analysis`:`risks` 仍只是"命中淘汰项 / 硬性缺口 / 缺失关键词"的文本列表(本地规则 `risks` 仅由 `knockout_hit`/`must_missing`/`missing` 拼出,`riskAdjustment` 只按是否触发淘汰/硬缺口给 -10/-5),不结构化识别跳槽频率、竞业、空窗 gap、职业道德;`offer.risk` 也只是手工备注 - 现状:**已实现(2026-09-03)**。新增 `backend/app/services/risk_profile.py`(本地确定性规则,LLM 只做补充、不直接扣分):从简历原文按章节提取任职时间线(`parse_work_timeline`,兼容 `2019.07-2021.06`/`2016年6月-2019年6月`/`…-至今`,按最近章节标题自动排除教育/实习/项目段),对固定四维输出结构化 `riskProfile`(dimension/label/level=ok|hint|warn/summary/evidence/action):①跳槽频率(≥3 段且平均 <1.5 年/段→warn、≥2 段不足 1 年→hint);②竞业协议(岗位对内 `nonCompete` 需签→warn,需人力确认候选人上一家竞业、竞对高管专项确认;不需要/未标注→ok);③职业空窗(相邻任职间隔 ≥12 个月→hint、≥24 个月→warn;空窗期含教育/深造/创业自述说明自动豁免);④高层职业道德(岗位 title/level/求职意向命中 总监/副总/总经理/VP/首席 等才触发背调提示,非高层→ok)。`local-structured` 与 LLM 两条评估链路统一合并 `riskProfile`(risks 文本列表保留),LLM prompt 同步把 risks 限定在固定四维;前端候选人详情「评估」Tab 新增「风险点评估」固定维度面板(状态:正常/提示/需确认 + 依据 + 建议动作),评估报告「风险/待确认点」行优先展示固定维度命中项。测试:`backend/tests/test_risk_profile.py`(时间线解析/四维规则/空窗豁免/接口合并)+ `test_ai.py` prompt 断言,全量 72 passed
- 建议(🆕):候选人风险点固定维度:跳槽频率(每段任职时长推算)、竞业协议(对内 JD `nonCompete` 是否命中)、职业空窗(gap≥N 年提示)、职业道德提示(高层专属);每项输出「提示 + 依据(简历时间线)」,由 HR 确认。规则可本地算,LLM 只做补充 - 建议(✅ 已落地):四项固定维度 + 「提示 + 依据(简历时间线)」+ HR 确认闭环已实现。可选增强:风险命中项一键生成对应面试问题;把 HR 确认结果回写候选人(如 `riskConfirmed`);「客户名单 / 竞对客户资源」结构化识别(当前缺结构化公司/客户数据,暂由竞业条款维度兜底)
**需求 18:快筛评分(本地基础评分)** **需求 18:快筛评分(本地基础评分)**
- 原话:「快筛评分本地做,用基本要求/硬性要求评一下,先看一眼;一般到这边的都是看过一遍的」(P6) - 原话:「快筛评分本地做,用基本要求/硬性要求评一下,先看一眼;一般到这边的都是看过一遍的」(P6)
...@@ -220,7 +222,7 @@ HR 原话要点: ...@@ -220,7 +222,7 @@ HR 原话要点:
| P0 | 需求 13 AI 评估规则化(稳定可复现) | ✅ 已大幅落地 | inputKey 缓存 + 固定 schema + temp=0 + 本地回退;残余为 LLM 重生成时表述波动 | | P0 | 需求 13 AI 评估规则化(稳定可复现) | ✅ 已大幅落地 | inputKey 缓存 + 固定 schema + temp=0 + 本地回退;残余为 LLM 重生成时表述波动 |
| P0 | 需求 20 下推流程联动 | ✅ 已落地 | 事件日志 + 待办自动增删已贯穿候选人/JD/Offer;人才池移入/认领联动已补 | | P0 | 需求 20 下推流程联动 | ✅ 已落地 | 事件日志 + 待办自动增删已贯穿候选人/JD/Offer;人才池移入/认领联动已补 |
| P0 | 需求 14 AI 不得引用自述优势 | ✅ 已实现(2026-09-03) | 评估/解析 prompt 已加「自述不作为匹配依据」硬规则 | | P0 | 需求 14 AI 不得引用自述优势 | ✅ 已实现(2026-09-03) | 评估/解析 prompt 已加「自述不作为匹配依据」硬规则 |
| P1 | 需求 17 风险点固定维度 | 🆕 未实现 | 跳槽/竞业/gap/职业道德四项规则,风控刚需 | | P1 | 需求 17 风险点固定维度 | ✅ 已实现(2026-09-03) | 四维本地规则 + LLM prompt 对齐 + 前端「风险点评估」面板 |
| P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 | | P1 | 需求 12 候选人标记 | 🔄 基本落地 | tags 枚举 + 详情编辑 + 简历库/人才池标签筛选 + 待跟进快捷筛选;独立周报视图待做 |
| P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 | | P2 | 需求 4 岗位自动关闭 | ✅ 已实现(2026-09-03) | hired>=headcount 自动置「已招满」并记事件日志 |
| P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 | | P2 | 需求 9 粘贴 JD 自动结构化 | 🔄 缺入口 | 复用现有 LLM 解析加一个按钮 |
...@@ -230,7 +232,7 @@ HR 原话要点: ...@@ -230,7 +232,7 @@ HR 原话要点:
| P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 | | P3 | 需求 3 岗位类型枚举 / 需求 25 期望薪资展示 | 🆕 | 小改动,按需做 |
| P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 | | P3 | 需求 26 UI 玻璃拟态 | 🔄 部分 | 顶栏已毛玻璃,整体待 HR 截图后统一 |
**近期已补(2026-09,不再列入待办)**:需求 10 候选人详情抽屉 + PDF 档案预览(`ddc5340`/`07310dd`/`ec9499f`);需求 24 简历解析改 LLM 优先、`fc0aa5c` 移除解析链路脱敏(评估链路仍脱敏)。**同日(工作区未提交)再补**:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。 **近期已补(2026-09,不再列入待办)**:需求 10 候选人详情抽屉 + PDF 档案预览(`ddc5340`/`07310dd`/`ec9499f`);需求 24 简历解析改 LLM 优先、`fc0aa5c` 移除解析链路脱敏(评估链路仍脱敏)。**同日(工作区未提交)再补**:需求 1「总库=人才池」(跨账号共享池 + 移入/认领)、需求 12 标签结构化与筛选、需求 20 人才池联动、需求 22 人才池独立视图——详见各需求条目与 docs/adr/0001-company-talent-pool.md。**同日(工作区)续补**:需求 17 风险点固定维度亦已实现(risk_profile.py 四维本地规则 + LLM prompt 限定 + 前端「风险点评估」面板),见需求 17 条目。
--- ---
...@@ -244,4 +246,4 @@ HR 原话要点: ...@@ -244,4 +246,4 @@ HR 原话要点:
--- ---
*整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14(AI 自述优势约束)已于 2026-09-03 实现;建议优先补齐:风险固定维度(需求 17)、批量移入/复活(需求 22),并按需做需求 9/3/25 等小改动(需求 4 自动招满与岗位维简历量统计已于 2026-09-03 落地)。* *整理自 20 页访谈记录,结合当前代码现状标注;最近一次代码核对 2026-09-02(main @ ec9499f,较 810e280 起新增 ddc5340/07310dd/6248f27/fc0aa5c/ec9499f 均已复核)。需求 14/17(自述优势约束、风险点固定维度)、需求 4 自动招满与岗位维简历量统计已于 2026-09-03 落地;建议优先补齐:批量移入/复活(需求 22),并按需做需求 9/3/25 等小改动。*
...@@ -13,7 +13,7 @@ import { isAiEvaluationReport } from '@/utils/scoring' ...@@ -13,7 +13,7 @@ import { isAiEvaluationReport } from '@/utils/scoring'
import { analysisNeedsRefresh } from '@/utils/analysis' import { analysisNeedsRefresh } from '@/utils/analysis'
import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate' import { resumeDisplayName, candidateAge, candidateYears } from '@/utils/candidate'
import { uid } from '@/utils/normalize' import { uid } from '@/utils/normalize'
import { assessmentDecision, assessmentReportRows, assessmentScoreText } from '@/utils/report' import { assessmentDecision, assessmentReportRows, assessmentScoreText, riskProfileRows } from '@/utils/report'
import { getFilteredCandidates } from '@/utils/matching' import { getFilteredCandidates } from '@/utils/matching'
import { jobCandidates } from '@/utils/links' import { jobCandidates } from '@/utils/links'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
...@@ -34,6 +34,15 @@ const scoreText = computed(() => assessmentScoreText(selected.value, store.resum ...@@ -34,6 +34,15 @@ const scoreText = computed(() => assessmentScoreText(selected.value, store.resum
const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode)) const decisionInfo = computed(() => assessmentDecision(selected.value, store.resumeDetailMode))
const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode)) const reportRows = computed(() => assessmentReportRows(selected.value, store.resumeDetailMode))
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
const riskProfile = computed(() => riskProfileRows(selected.value?.analysis || {}))
const riskTagMeta = (level) =>
({
ok: { type: 'success', text: '正常' },
hint: { type: 'warning', text: '提示' },
warn: { type: 'danger', text: '需确认' },
})[level] || { type: 'info', text: level }
const scoreNumber = computed(() => { const scoreNumber = computed(() => {
const value = detailScore.value const value = detailScore.value
return value ? Math.round(value) : '--' return value ? Math.round(value) : '--'
...@@ -828,6 +837,39 @@ const saveInterview = (candidate) => { ...@@ -828,6 +837,39 @@ const saveInterview = (candidate) => {
<p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p> <p v-if="selected.analysis?.summary">{{ selected.analysis.summary }}</p>
<p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p> <p v-else>快速匹配只做岗位关键词命中和缺口识别,不生成 HR 决策报告。</p>
</div> </div>
<div v-if="riskProfile.length && store.resumeDetailMode === 'ai'" class="risk-profile">
<div class="risk-profile-head">
<div class="risk-profile-title">
<span class="ov-eyebrow">需求 17 · 固定维度</span>
<h4>风险点评估</h4>
</div>
<span class="risk-profile-note">按固定维度提示并给出依据,由 HR 人工确认,不直接扣分</span>
</div>
<div
v-for="item in riskProfile"
:key="item.dimension"
class="risk-profile-item"
:class="`level-${item.level}`"
>
<div class="risk-profile-label">
<el-tag :type="riskTagMeta(item.level).type" size="small" effect="light">
{{ riskTagMeta(item.level).text }}
</el-tag>
<b>{{ item.label }}</b>
</div>
<div class="risk-profile-main">
<p class="risk-profile-summary">{{ item.summary }}</p>
<div v-if="item.evidence && item.evidence.length" class="risk-profile-evidence">
<span v-for="(ev, index) in item.evidence" :key="`${item.dimension}-${index}`"
>依据:{{ ev }}</span
>
</div>
<p v-if="item.action && item.level !== 'ok'" class="risk-profile-action">
建议:{{ item.action }}
</p>
</div>
</div>
</div>
<div v-if="selected.matchReport || selected.analysis" class="assessment-table-wrap"> <div v-if="selected.matchReport || selected.analysis" class="assessment-table-wrap">
<el-table :data="reportRows" class="assessment-table"> <el-table :data="reportRows" class="assessment-table">
<el-table-column label="维度" width="130"> <el-table-column label="维度" width="130">
...@@ -1841,4 +1883,106 @@ const saveInterview = (candidate) => { ...@@ -1841,4 +1883,106 @@ const saveInterview = (candidate) => {
transition: none; transition: none;
} }
} }
// 需求 17:风险固定维度
.risk-profile {
margin-bottom: 14px;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--dossier-line);
border-radius: 12px;
.risk-profile-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
.risk-profile-title {
display: flex;
flex-direction: column;
gap: 2px;
h4 {
margin: 0;
font-size: 14px;
}
}
.risk-profile-note {
font-size: 11px;
color: var(--muted);
text-align: right;
}
}
.risk-profile-item {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
padding: 9px 10px;
border-radius: 10px;
border-left: 3px solid transparent;
& + .risk-profile-item {
margin-top: 6px;
}
&.level-ok {
background: rgba(255, 255, 255, 0.45);
}
&.level-hint {
background: color-mix(in srgb, var(--yellow) 10%, #fff);
border-left-color: var(--yellow);
}
&.level-warn {
background: color-mix(in srgb, var(--rose) 9%, #fff);
border-left-color: var(--rose);
}
.risk-profile-label {
display: flex;
align-items: center;
gap: 6px;
b {
font-size: 13px;
}
}
.risk-profile-main {
min-width: 0;
}
.risk-profile-summary {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--ink);
}
.risk-profile-evidence {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 5px;
span {
font-size: 12px;
line-height: 1.45;
color: var(--muted);
}
}
.risk-profile-action {
margin: 5px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--dossier-accent);
}
}
}
</style> </style>
...@@ -2,6 +2,16 @@ function listItems(value) { ...@@ -2,6 +2,16 @@ function listItems(value) {
return Array.isArray(value) ? value.filter((item) => String(item || '').trim()) : [] return Array.isArray(value) ? value.filter((item) => String(item || '').trim()) : []
} }
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
export function riskProfileRows(analysis = {}) {
const profile = Array.isArray(analysis?.riskProfile) ? analysis.riskProfile : []
return profile.filter((item) => item && ['ok', 'hint', 'warn'].includes(item.level))
}
export function riskProfileAttention(analysis = {}) {
return riskProfileRows(analysis).filter((item) => item.level !== 'ok')
}
export function assessmentScoreText(candidate = {}, mode = 'ai') { export function assessmentScoreText(candidate = {}, mode = 'ai') {
const analysis = mode === 'ai' ? candidate.analysis || {} : {} const analysis = mode === 'ai' ? candidate.analysis || {} : {}
const raw = const raw =
...@@ -110,10 +120,14 @@ export function assessmentReportRows(candidate = {}, mode = 'ai') { ...@@ -110,10 +120,14 @@ export function assessmentReportRows(candidate = {}, mode = 'ai') {
if (!matchItems.length) matchItems.push('暂无匹配点') if (!matchItems.length) matchItems.push('暂无匹配点')
const riskItems = [] const riskItems = []
const profileAttention = riskProfileAttention(analysis)
if (profileAttention.length) {
riskItems.push(...profileAttention.map((item) => `【${item.label}${item.summary}`))
}
const risks = listItems(analysis.risks) const risks = listItems(analysis.risks)
if (risks.length) { if (risks.length) {
riskItems.push(...risks.slice(0, 6)) riskItems.push(...risks.slice(0, 4))
} else { } else if (!profileAttention.length) {
if (knockoutHit.length) riskItems.push(`触发淘汰项:${knockoutHit.join('、')}`) if (knockoutHit.length) riskItems.push(`触发淘汰项:${knockoutHit.join('、')}`)
if (mustMissing.length) riskItems.push(`硬性条件缺口:${mustMissing.join('、')}`) if (mustMissing.length) riskItems.push(`硬性条件缺口:${mustMissing.join('、')}`)
if (missing.length) riskItems.push(`简历暂未明确:${missing.slice(0, 6).join('、')}`) if (missing.length) riskItems.push(`简历暂未明确:${missing.slice(0, 6).join('、')}`)
...@@ -125,7 +139,7 @@ export function assessmentReportRows(candidate = {}, mode = 'ai') { ...@@ -125,7 +139,7 @@ export function assessmentReportRows(candidate = {}, mode = 'ai') {
questionItems = [`建议初筛时确认:${missing.slice(0, 4).join('、')}`] questionItems = [`建议初筛时确认:${missing.slice(0, 4).join('、')}`]
} }
const hasRisk = Boolean(knockoutHit.length || mustMissing.length || risks.length) const hasRisk = Boolean(profileAttention.length || knockoutHit.length || mustMissing.length || risks.length)
return [ return [
{ {
label: '评估点', label: '评估点',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment