offer-catcher-agent / src /eval_schema.py
hungryb's picture
Deploy Offer Catcher Agent demo
3161850 verified
Raw
History Blame Contribute Delete
20.7 kB
"""
eval_schema.py — Eval 框架数据结构 + Error Taxonomy
定义以下内容:
1. EvalTrace dataclass(单次运行 trace)
2. ERROR_TAXONOMY(E1–E10 错误类型定义)
3. AGENT_METRICS_SCHEMA(每个 Agent 的评估指标定义)
4. RAG_METRICS_SCHEMA(RAG/检索评估指标,仅底层能力)
5. detect_errors(trace) -> list[dict](根据 trace 自动检测错误)
6. 辅助函数:load_golden_cases, compute_ndcg, compute_mrr, compute_recall_at_k, golden_case_to_relevance
"""
from __future__ import annotations
import json, re, time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# 1. EvalTrace — 单次运行 trace
# ---------------------------------------------------------------------------
@dataclass
class EvalTrace:
"""一次 golden case 运行的完整 trace。"""
# 元信息
run_id: str = ""
case_id: str = ""
timestamp: str = ""
# 输入
resume_text: str = ""
target_role: str = ""
target_city: str = ""
stage: str = ""
# Agent 输出
profile_output: dict = field(default_factory=dict)
jd_intelligence_output: list[dict] = field(default_factory=list)
scout_candidates: list[dict] = field(default_factory=list)
ranker_scores: dict = field(default_factory=dict)
gap_diagnosis_output: dict = field(default_factory=dict)
resume_conversion_output: dict = field(default_factory=dict)
strategy_output: dict = field(default_factory=dict)
# 指标
agent_timings: dict = field(default_factory=dict)
errors: list[dict] = field(default_factory=list)
agent_metrics: dict = field(default_factory=dict)
overall_pass: bool = False
pass_reason: str = ""
def to_dict(self) -> dict:
return {
"run_id": self.run_id,
"case_id": self.case_id,
"timestamp": self.timestamp,
"input": {
"resume_text": self.resume_text,
"target_role": self.target_role,
"target_city": self.target_city,
"stage": self.stage,
},
"agent_outputs": {
"profile": self.profile_output,
"jd_intelligence": self.jd_intelligence_output,
"scout_candidates": self.scout_candidates,
"ranker_scores": self.ranker_scores,
"gap_diagnosis": self.gap_diagnosis_output,
"resume_conversion": self.resume_conversion_output,
"strategy": self.strategy_output,
},
"agent_timings": self.agent_timings,
"errors": self.errors,
"agent_metrics": self.agent_metrics,
"overall_pass": self.overall_pass,
"pass_reason": self.pass_reason,
}
def to_json(self, fp: str | Path) -> None:
Path(fp).write_text(
json.dumps(self.to_dict(), ensure_ascii=False, indent=2),
encoding="utf-8",
)
# ---------------------------------------------------------------------------
# 2. Error Taxonomy(E1–E10)
# ---------------------------------------------------------------------------
ERROR_TAXONOMY = {
"E1_PROFILE_MISS": {
"agent": "ProfileBuilder",
"description": "简历画像漏抽关键技能/项目/指标",
"severity": "high",
},
"E2_PROFILE_HALLUCINATION": {
"agent": "ProfileBuilder",
"description": "画像中出现了简历中不存在的技能/经历",
"severity": "high",
},
"E3_JD_PARSE_ERROR": {
"agent": "JDIntelligence",
"description": "JD 解析错误:required_skills / hard_requirements 不完整",
"severity": "medium",
},
"E4_RECALL_MISS": {
"agent": "OpportunityScout",
"description": "召回漏掉相关岗位(expected_recall_jobs 未进入任一 Top5)",
"severity": "high",
},
"E4_MATCH_RECALL_MISS": {
"agent": "OpportunityScout",
"description": "匹配榜召回不足:expected_top_matches 未进入 Match Top5",
"severity": "high",
},
"E4_PRIORITY_RECALL_MISS": {
"agent": "OpportunityScout",
"description": "优先级榜召回不足:expected_top_priorities 未进入 Priority Top5",
"severity": "high",
},
"E5_RANK_MISORDER": {
"agent": "ApplicationRanker",
"description": "排序不合理:期望岗位排在后面(任一榜单)",
"severity": "high",
},
"E5_MATCH_RANK_MISORDER": {
"agent": "ApplicationRanker",
"description": "Match 榜 Top1 不是 expected_top_matches",
"severity": "high",
},
"E5_PRIORITY_RANK_MISORDER": {
"agent": "ApplicationRanker",
"description": "Priority 榜 Top1 不是 expected_top_priorities",
"severity": "high",
},
"E6_SCORE_CALIBRATION": {
"agent": "ApplicationRanker",
"description": "分数校准不合理:MatchScore/PassScore/RiskScore 与直觉不符",
"severity": "medium",
},
"E7_GAP_UNSUPPORTED": {
"agent": "GapDiagnosis",
"description": "能力缺口诊断无证据支持(简历中无此问题,却指出)",
"severity": "medium",
},
"E8_REWRITE_OVERCLAIM": {
"agent": "ResumeConversion",
"description": "简历改写过度声称/编造经历",
"severity": "high",
},
"E9_STRATEGY_CONFLICT": {
"agent": "StrategyPlanner",
"description": "投递策略冲突:7 天计划不可执行 / 稳妥/平衡/冲刺组合矛盾",
"severity": "medium",
},
"E9_ACTION_MISMATCH": {
"agent": "StrategyPlanner",
"description": "实际投递动作与 expected_action 不一致",
"severity": "medium",
},
"E10_REPORT_ERROR": {
"agent": "ReportGenerator",
"description": "报告生成错误:信息不一致/缺失关键结论",
"severity": "low",
},
}
# ---------------------------------------------------------------------------
# 3. Agent-level 评估指标定义
# ---------------------------------------------------------------------------
AGENT_METRICS_SCHEMA = {
"ProfileBuilder": {
"skill_coverage": {
"type": "float",
"description": "技能抽取覆盖率 = 命中已知技能数 / golden 中 expected_skills 数",
},
"project_metric_hit": {
"type": "bool",
"description": "项目指标(如 HitRate、NDCG)是否被正确识别",
},
"missed_project": {
"type": "bool",
"description": "是否漏掉简历中关键项目",
},
"has_hallucination": {
"type": "bool",
"description": "是否幻觉出简历中不存在的信息",
},
},
"JDIntelligence": {
"required_skills_completeness": {
"type": "float",
"description": "required_skills 抽取完整度(与人工标注对比)",
},
"hard_requirements_correct": {
"type": "bool",
"description": "hard_requirements(如学历/实习时长)是否识别正确",
},
"interview_topics_relevance": {
"type": "float",
"description": "interview_topics 是否与岗位高度相关",
},
},
"OpportunityScout": {
"recall_at_5": {
"type": "float",
"description": "Recall@5 = expected_top_jobs 中出现在 Top5 的比例",
},
"recall_at_3": {
"type": "float",
"description": "Recall@3",
},
"target_job_in_topk": {
"type": "bool",
"description": "目标岗位是否进入 TopK",
},
"irrelevant_job_count": {
"type": "int",
"description": "TopK 中与目标方向无关的岗位数",
},
},
"ApplicationRanker": {
"top1_correct": {
"type": "bool",
"description": "Top1 是否符合 expected_top_jobs",
},
"ndcg_at_5": {
"type": "float",
"description": "NDCG@5(以 expected_top_jobs 为 relelvance)",
},
"mrr_at_5": {
"type": "float",
"description": "MRR@5 = Mean Reciprocal Rank(第一个相关结果排名的倒数)",
},
"score_calibration_error": {
"type": "float",
"description": "分数校准误差:|预测匹配度 - 人工标注匹配度|",
},
"apply_priority_consistent": {
"type": "bool",
"description": "ApplyPriority 是否与风险偏好一致(高风险岗位不应排前)",
},
},
"GapDiagnosis": {
"true_gap_hit": {
"type": "float",
"description": "真实短板命中率(expected_missing_skills 中被指出比例)",
},
"unsupported_claim_count": {
"type": "int",
"description": "无证据批评的数量",
},
"missed_gap_count": {
"type": "int",
"description": "遗漏关键缺口数(expected_missing_skills 中未指出)",
},
},
"ResumeConversion": {
"keyword_coverage_lift": {
"type": "float",
"description": "改写后 JD 关键词覆盖率提升",
},
"has_fabrication": {
"type": "bool",
"description": "是否编造经历",
},
"ats_friendly": {
"type": "bool",
"description": "改写后是否更 ATS 友好(无表格/图片/特殊字符)",
},
},
"StrategyPlanner": {
"plan_executable": {
"type": "bool",
"description": "7 天计划是否可执行(每天任务量合理)",
},
"portfolio_balanced": {
"type": "bool",
"description": "稳妥/平衡/冲刺组合是否合理",
},
"no_conflict": {
"type": "bool",
"description": "是否存在先后矛盾(如同一天既投冲刺岗又投稳妥岗)",
},
},
}
# ---------------------------------------------------------------------------
# 4. RAG / 检索评估指标(仅底层能力,非项目主线)
# ---------------------------------------------------------------------------
RAG_METRICS_SCHEMA = {
"recall_at_3": {
"type": "float",
"description": "Recall@3 = 相关 JD 在前 3 个检索结果中的比例",
},
"recall_at_5": {
"type": "float",
"description": "Recall@5 = 相关 JD 在前 5 个检索结果中的比例",
},
"mrr_at_5": {
"type": "float",
"description": "MRR@5 = Mean Reciprocal Rank(第一个相关结果排名的倒数)",
},
"ndcg_at_5": {
"type": "float",
"description": "NDCG@5 = 归一化折损累积增益",
},
"retrieval_supports_diagnosis": {
"type": "bool",
"description": "检索结果是否支持后续 Gap Diagnosis",
},
"fallback_when_no_results": {
"type": "bool",
"description": "检索不到时是否能 fallback(如返回空列表而不崩溃)",
},
}
# ---------------------------------------------------------------------------
# 5. detect_errors(trace) -> list[dict]
# ---------------------------------------------------------------------------
def _extract_missing_terms_from_gap(gap_text: str) -> list[str]:
"""Extract concrete missing skills from a gap sentence.
The gap generator writes strings such as:
"岗位还要求 LLM 评估、数据分析,建议补充真实项目或技能证据。"
E7 should only fire when a concrete missing term itself appears in the
resume. Splitting the whole sentence creates false positives on generic
words like LLM, 评估, 项目, etc.
"""
if not isinstance(gap_text, str) or "岗位还要求" not in gap_text:
return []
match = re.search(r"岗位还要求\s*(.+?)(?:,|,|。|$)", gap_text)
if not match:
return []
return [term.strip() for term in re.split(r"[、,,]", match.group(1)) if term.strip()]
def detect_errors(trace: EvalTrace, golden_case: dict) -> list[dict]:
"""
根据 EvalTrace + golden_case 中的标注,自动检测错误。
返回 list[dict],每个 dict 包含:code, agent, message, severity
"""
errors: list[dict] = []
# ---- ProfileBuilder ----
profile = trace.profile_output
expected_skills = set(golden_case.get("expected_skills", []))
actual_skills = set(profile.get("skills", []))
# E1: 漏抽
missed = expected_skills - actual_skills
if missed:
errors.append({
"code": "E1_PROFILE_MISS",
"agent": "ProfileBuilder",
"message": f"漏抽技能:{missed}",
"severity": "high",
})
# E2: 幻觉(检查画像中是否有简历中不存在的量化指标)
resume_text = trace.resume_text.lower()
for skill in actual_skills:
if skill.lower() not in resume_text:
errors.append({
"code": "E2_PROFILE_HALLUCINATION",
"agent": "ProfileBuilder",
"message": f"可能幻觉技能:{skill}",
"severity": "high",
})
break # 只报一次
# ---- OpportunityScout ----
expected_jobs = set(golden_case.get("expected_top_jobs", []))
actual_titles = [j.get("title", "") for j in trace.scout_candidates[:5]]
actual_titles_set = set(actual_titles)
if expected_jobs and not expected_jobs & actual_titles_set:
errors.append({
"code": "E4_RECALL_MISS",
"agent": "OpportunityScout",
"message": f"目标岗位 {expected_jobs} 未出现在 Top5:{actual_titles[:5]}",
"severity": "high",
})
# ---- ApplicationRanker ----
top1_title = ""
if trace.scout_candidates:
top1_title = trace.scout_candidates[0].get("title", "")
# E5: Top1 不是 expected
if expected_jobs and top1_title and top1_title not in expected_jobs:
errors.append({
"code": "E5_RANK_MISORDER",
"agent": "ApplicationRanker",
"message": f"Top1={top1_title},但期望 {expected_jobs}",
"severity": "high",
})
# E6: score calibration(简单启发:如果 match_score < 50 但排在 Top1,可能有问题)
if trace.scout_candidates:
top1 = trace.scout_candidates[0]
ms = top1.get("match_score", 0)
ps = top1.get("apply_priority", 0)
if isinstance(ms, (int,float)) and isinstance(ps, (int,float)) and ms < 30 and ps > 70:
errors.append({
"code": "E6_SCORE_CALIBRATION",
"agent": "ApplicationRanker",
"message": f"Top1 match_score={ms:.1f} 过低但 apply_priority={ps:.1f} 过高",
"severity": "medium",
})
# ---- GapDiagnosis ----
gaps = trace.gap_diagnosis_output.get("gaps", [])
expected_risks = set(golden_case.get("expected_risks", []))
resume_text_lower = trace.resume_text.lower()
# E7: 无证据批评(只检查结构化 gap 中明确列出的缺失技能)
for gap_skill in gaps:
if isinstance(gap_skill, str):
missing_terms = _extract_missing_terms_from_gap(gap_skill)
unsupported_terms = [term for term in missing_terms if term.lower() in resume_text_lower]
if unsupported_terms:
errors.append({
"code": "E7_GAP_UNSUPPORTED",
"agent": "GapDiagnosis",
"message": f"缺口诊断称缺失技能「{unsupported_terms[0]}」,但简历中存在相关证据",
"severity": "medium",
})
break
elif isinstance(gap_skill, dict):
skill = gap_skill.get("skill", "")
if skill and skill.lower() in resume_text_lower:
errors.append({
"code": "E7_GAP_UNSUPPORTED",
"agent": "GapDiagnosis",
"message": f"缺口诊断称缺失技能「{skill}」,但简历中存在相关证据",
"severity": "medium",
})
break
# ---- ResumeConversion ----
rewrites = trace.resume_conversion_output.get("rewrites", [])
for rw in rewrites:
new_text = rw.get("suggested", "")
# 简单检查:如果建议文本包含简历中完全没有的技能词汇
if new_text:
# 检查是否过度声称(score 提升 > 30 分可能是过度包装)
score_lift = rw.get("score_lift", 0)
if isinstance(score_lift, (int,float)) and score_lift > 30:
errors.append({
"code": "E8_REWRITE_OVERCLAIM",
"agent": "ResumeConversion",
"message": f"改写声称过大:{rw.get('original','')} -> +{score_lift}",
"severity": "high",
})
# ---- StrategyPlanner ----
strategy = trace.strategy_output
top3 = strategy.get("priority_top3", [])
apply_actions = [t.get("apply_action", "") for t in top3]
# E9: 策略冲突(同时出现"立即投递"和"暂缓")
if "立即投递" in str(apply_actions) and "暂缓" in str(apply_actions):
errors.append({
"code": "E9_STRATEGY_CONFLICT",
"agent": "StrategyPlanner",
"message": "投递策略冲突:同时建议立即投递和暂缓",
"severity": "medium",
})
# E9_ACTION_MISMATCH: actual action vs expected
actual_action = top3[0].get("apply_action", "") if top3 else ""
expected_action = golden_case.get("expected_action", "")
if expected_action and actual_action and expected_action != actual_action:
errors.append({
"code": "E9_ACTION_MISMATCH",
"agent": "StrategyPlanner",
"message": f"expected_action={expected_action}, actual_action={actual_action}",
"severity": "medium",
})
# ---- Report ----
# E10: 报告错误(检查报告是否包含关键信息)
# 暂不实现,因为报告生成是可选的
trace.errors = errors
return errors
# ---------------------------------------------------------------------------
# 6. 辅助函数
# ---------------------------------------------------------------------------
def load_golden_cases(path: str | Path) -> list[dict]:
"""加载 eval/golden_cases.json。"""
p = Path(path)
if not p.exists():
return []
return json.loads(p.read_text(encoding="utf-8"))
def compute_ndcg(relevances: list[float], k: int = 5) -> float:
"""
计算 NDCG@k。
relevances[i] = 第 i 个位置的 relevance score(0~1 或 0~5)。
"""
import math
def dcg(scores: list[float]) -> float:
return sum(
(2 ** s - 1) / math.log2(idx + 2)
for idx, s in enumerate(scores[:k])
)
ideal = sorted(relevances, reverse=True)
denom = dcg(ideal)
return dcg(relevances) / denom if denom > 0 else 0.0
def compute_mrr(relevances: list[float], k: int = 5) -> float:
"""
计算 MRR@k(Mean Reciprocal Rank)。
返回第一个相关结果排名的倒数(1/rank),没有则返回 0.0。
"""
for idx, s in enumerate(relevances[:k], start=1):
if s > 0:
return 1.0 / idx
return 0.0
def compute_recall_at_k(
retrieved: list[str], relevant: set[str], k: int
) -> float:
"""Recall@k = |retrieved[:k] ∩ relevant| / |relevant|。"""
if not relevant:
return 1.0
hits = len(set(retrieved[:k]) & relevant)
return hits / len(relevant)
def golden_case_to_relevance(
retrieved_titles: list[str], golden_case: dict
) -> list[float]:
"""
将 golden_case 中的 expected_top_jobs 转为 relevance list。
命中 expected 的岗位 relevance=3,同类方向=2,其他=0。
"""
expected = set(golden_case.get("expected_top_jobs", []))
target_role = golden_case.get("target_role", "")
relevances: list[float] = []
for title in retrieved_titles:
if title in expected:
relevances.append(3.0)
elif target_role and target_role in title:
relevances.append(2.0)
else:
relevances.append(0.0)
return relevances