""" 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