""" evaluator.py — Eval 框架执行器(v2:Match / Priority 分离评估) """ from __future__ import annotations import json, re, sys, time from pathlib import Path from typing import Any, Optional from src.eval_schema import ( EvalTrace, ERROR_TAXONOMY, detect_errors, load_golden_cases, compute_ndcg, compute_mrr, golden_case_to_relevance, ) # --------------------------------------------------------------------------- # Agent 流程条配置 # --------------------------------------------------------------------------- AGENTS = [ {"key": "profile", "name": "Profile Builder", "desc": "解析简历 → 学生画像", "icon": "👤"}, {"key": "jd", "name": "JD Intelligence", "desc": "解析 JD → 岗位画像", "icon": "📋"}, {"key": "scout", "name": "Opportunity Scout", "desc": "召回候选岗位池", "icon": "🔍"}, {"key": "ranker", "name": "Application Ranker", "desc": "计算 Match/Pass/Risk/Growth", "icon": "📊"}, {"key": "gap", "name": "Gap Diagnosis", "desc": "能力缺口 + 简历风险", "icon": "⚠️"}, {"key": "conversion", "name": "Resume Conversion", "desc": "简历改写建议", "icon": "✏️"}, {"key": "strategy", "name": "Strategy Planner", "desc": "投递策略 + 7 天计划", "icon": "🗺️"}, ] STATUS_ICONS = {"completed": "✅", "running": "⏳", "pending": "⬜"} # 技术选型说明(底部折叠) _TECH_EXPLANATION = """ **项目定位:基于多 Agent 协作的大模型应用算法系统**(不是商业 HR 工具)。 --- ### 一、多 Agent 如何分工 | Agent | 职责 | LLM 是否参与 | |---|---|---| | Profile Builder | 简历 → 结构化画像 | 可选(理解非结构化文本) | | JD Intelligence | JD → 结构化岗位画像 | 可选(归一化技能表述) | | Opportunity Scout | 关键词/TF-IDF 召回候选池 | 否(保证稳定可解释) | | Application Ranker | 多维度加权公式计算排序 | 否(**排序必须可解释**) | | Gap Diagnosis | 规则检测能力缺口 | 可选(生成可读解释) | | Resume Conversion | 规则约束的简历改写 | 可选(语言优化) | | Strategy Planner | 规则策略 + 自然语言总结 | 可选(总结生成) | **核心设计:LLM 是"可选增强层",非依赖项。无 API Key 时所有 Agent 仍可完整运行。** --- ### 二、为什么排序/决策用可解释公式,而不是黑盒 LLM 打分? 1. **可解释性约束**:面试官/用户需要知道"为什么推荐这个岗位",黑盒 LLM 打分无法回答 2. **权重可调试**:`ApplyPriority = 0.40×Match + 0.30×Pass - 0.15×Risk + 0.15×Growth`,每个权重都有业务含义 3. **稳定性**:LLM API 可能抖动,排序公式稳定可复现 4. **数据效率**:公式不需要标注数据,LLM 微调才需要 --- ### 三、语义召回为什么用轻量模型(bge-small-zh)而不是满血 LLM? 1. **延迟**:LLM 调用 ~200ms,嵌入模型 ~20ms 2. **成本**:嵌入模型本地运行,LLM API 按 token 计费 3. **稳定性**:嵌入模型输出稳定,LLM 可能返回不一致相似度 4. **可解释性**:嵌入向量可以可视化(t-SNE),LLM 相似度是黑盒 --- ### 四、Eval/Error Analysis 如何保证系统质量? 1. **Golden Cases**:8 个核心 case(覆盖同方向/跨方向/不同城市/不同风险偏好) 2. **Error Taxonomy**:10 类错误(E1–E10),每类有自动检测逻辑 3. **指标**:Top1 Acc、Recall@5、NDCG@5、MRR@5 4. **持续迭代**:每次修改排序公式后跑 eval,确保不降低核心指标 --- ### 五、为什么项目强调"算法深度"而不是"功能完整"? 1. **求职目标**:算法岗位(大模型应用算法/LLM 应用算法/Agent 算法) 2. **核心叙事**:排序公式设计、语义召回优化、证据链构建、Eval/Error Analysis 迭代 3. **技术深度**:不是"调包侠",而是"算法设计者" 4. **差异化**:大多数 Demo 项目只做"功能完整",我们做"算法深度" --- """ # --------------------------------------------------------------------------- # 主流程 # --------------------------------------------------------------------------- def run_case(case: dict, jobs_path: str | Path) -> tuple[Any, list[dict], dict]: """运行单个 golden case,返回 (trace, errors, metrics)。""" if EvalTrace is None: raise RuntimeError("eval_schema.py not loaded") trace = EvalTrace() trace.case_id = case.get("case_id", "unknown") trace.timestamp = time.strftime("%Y-%m-%dT%H:%M:%S") trace.resume_text = case.get("resume_text", "") trace.target_role = case.get("target_role", "") trace.target_city = case.get("target_city", "") trace.stage = case.get("stage", "") errors: list[dict] = [] # ---- read new golden labels ---- exp_matches = set(case.get("expected_top_matches", [])) exp_priorities = set(case.get("expected_top_priorities", [])) exp_recall = set(case.get("expected_recall_jobs", [])) exp_action = case.get("expected_action", "") # ---- Agent 1: Profile Builder ---- t0 = time.time() profile: dict = {} try: from src.resume_parser import parse_resume profile = parse_resume(trace.resume_text) except Exception: pass # 注入用户目标偏好到 profile(供 StrategyPlanner 使用) if profile is None: profile = {} profile["_city"] = trace.target_city profile["_stage"] = trace.stage profile["_target_role"] = trace.target_role trace.profile_output = profile trace.agent_timings["profile"] = round(time.time() - t0, 3) # E1/E2 if profile: actual = set(profile.get("skills", [])) missed = set(case.get("expected_skills", [])) - actual if missed: errors.append({"code": "E1_PROFILE_MISS", "agent": "ProfileBuilder", "message": f"漏抽技能:{missed}", "severity": "high"}) rl = trace.resume_text.lower() for s in actual: if s.lower() not in rl: errors.append({"code": "E2_PROFILE_HALLUCINATION", "agent": "ProfileBuilder", "message": f"可能幻觉技能:{s}", "severity": "high"}) break # ---- Agent 3/4: Scout + Ranker ---- t1 = time.time() scored: list[dict] = [] try: from src.matcher import rank_jobs scored = rank_jobs( resume_text=trace.resume_text, profile=profile, target_role=trace.target_role, target_city=trace.target_city, stage=trace.stage, top_k=8, jobs_path=Path(jobs_path), ) except Exception: pass trace.scout_candidates = scored trace.agent_timings["scout"] = round(time.time() - t1, 3) # construct both rankings by_match = sorted(scored, key=lambda x: x.get("match_score", 0), reverse=True) by_priority = sorted(scored, key=lambda x: x.get("apply_priority", 0), reverse=True) top5_m = [j.get("title", "") for j in by_match[:5]] top5_p = [j.get("title", "") for j in by_priority[:5]] top1_m = by_match[0].get("title", "") if by_match else "" top1_p = by_priority[0].get("title", "") if by_priority else "" # E4: recall miss (expected_recall_jobs not in ANY top5) if exp_recall: hit_recall = exp_recall & set(top5_m) or exp_recall & set(top5_p) if not hit_recall: errors.append({"code": "E4_RECALL_MISS", "agent": "OpportunityScout", "message": f"expected_recall_jobs {exp_recall} 未进入任一 Top5", "severity": "high"}) # E4_MATCH: expected_top_matches not in match top5 if exp_matches and not (exp_matches & set(top5_m)): errors.append({"code": "E4_MATCH_RECALL_MISS", "agent": "OpportunityScout", "message": f"expected_top_matches {exp_matches} 未进入 Match Top5: {top5_m}", "severity": "high"}) # E4_PRIORITY: expected_top_priorities not in priority top5 if exp_priorities and not (exp_priorities & set(top5_p)): errors.append({"code": "E4_PRIORITY_RECALL_MISS", "agent": "OpportunityScout", "message": f"expected_top_priorities {exp_priorities} 未进入 Priority Top5: {top5_p}", "severity": "high"}) # E5: rank misorder (top1 not in expected for each list) if exp_matches and top1_m and top1_m not in exp_matches: errors.append({"code": "E5_MATCH_RANK_MISORDER", "agent": "ApplicationRanker", "message": f"Match Top1={top1_m}, expected {exp_matches}", "severity": "high"}) if exp_priorities and top1_p and top1_p not in exp_priorities: errors.append({"code": "E5_PRIORITY_RANK_MISORDER", "agent": "ApplicationRanker", "message": f"Priority Top1={top1_p}, expected {exp_priorities}", "severity": "high"}) # E6: score calibration if by_priority: ms = by_priority[0].get("match_score", 0) ap = by_priority[0].get("apply_priority", 0) if isinstance(ms, (int,float)) and isinstance(ap, (int,float)) and ms < 30 and ap > 70: errors.append({"code": "E6_SCORE_CALIBRATION", "agent": "ApplicationRanker", "message": f"Top1 match_score={ms:.1f} 过低但 apply_priority={ap:.1f} 过高", "severity": "medium"}) trace.ranker_scores = { "top1_match_title": top1_m, "top1_priority_title": top1_p, "top5_match": top5_m, "top5_priority": top5_p, } # ---- Agent 5/6: Gap + Conversion ---- t2 = time.time() # score_job() in matcher.py already called attach_conversion_scores(), # so conv_scored already has correct pass/risk/growth scores. conv_scored = scored gaps: list[str] = [] rewrites: list[dict] = [] for j in conv_scored: g = j.get("gaps", []) if isinstance(g, list): for item in g: if isinstance(item, str): gaps.append(item) elif isinstance(item, dict): gaps.append(item.get("skill", "")) r = j.get("rewrites", []) if isinstance(r, list): for item in r: if isinstance(item, dict): rewrites.append(item) trace.gap_diagnosis_output = {"gaps": gaps[:5]} trace.resume_conversion_output = {"rewrites": rewrites[:5]} # E7: gap diagnosis unsupported rl = trace.resume_text.lower() for gs in gaps: if isinstance(gs, str): from src.eval_schema import _extract_missing_terms_from_gap missing_terms = _extract_missing_terms_from_gap(gs) unsupported_terms = [term for term in missing_terms if term.lower() in rl] if unsupported_terms: errors.append({"code": "E7_GAP_UNSUPPORTED", "agent": "GapDiagnosis", "message": f"缺口诊断称缺失技能「{unsupported_terms[0]}」,但简历中存在相关证据", "severity": "medium"}) break elif isinstance(gs, dict): skill = gs.get("skill", "") if skill and skill.lower() in rl: errors.append({"code": "E7_GAP_UNSUPPORTED", "agent": "GapDiagnosis", "message": f"缺口诊断称缺失技能「{skill}」,但简历中存在相关证据", "severity": "medium"}) break # E8: rewrite overclaim for rw in rewrites: sl = rw.get("score_lift", 0) if isinstance(sl, (int,float)) and sl > 30: errors.append({"code": "E8_REWRITE_OVERCLAIM", "agent": "ResumeConversion", "message": f"改写声称过大:{rw.get('original','')} -> +{sl}", "severity": "high"}) break trace.agent_timings["conversion"] = round(time.time() - t2, 3) # ---- Agent 7: Strategy Planner ---- t3 = time.time() strategy: dict = {} try: from src.strategy_planner import gen_strategy_package strategy = gen_strategy_package(conv_scored, profile) except Exception: strategy = {} trace.strategy_output = strategy trace.agent_timings["strategy"] = round(time.time() - t3, 3) # E9: strategy conflict top3 = strategy.get("priority_top3", []) actions = [t.get("apply_action", "") for t in top3] if "立即投递" in str(actions) and "暂缓" in str(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 "" if exp_action and actual_action and exp_action != actual_action: errors.append({"code": "E9_ACTION_MISMATCH", "agent": "StrategyPlanner", "message": f"expected_action={exp_action}, actual_action={actual_action}", "severity": "medium"}) # ---- Report ---- t4 = time.time() try: from src.report_generator import generate_report generate_report(profile, conv_scored, strategy) except Exception: pass trace.agent_timings["report"] = round(time.time() - t4, 3) # ---- Metrics ---- metrics = _compute_metrics(trace, case, by_match, by_priority, exp_matches, exp_priorities, exp_recall, exp_action, actual_action) # ---- detect_errors (supplemental) ---- if detect_errors: extra = detect_errors(trace, case) existing = {e["code"] for e in errors} for ee in extra: if ee["code"] not in existing: errors.append(ee) existing.add(ee["code"]) trace.errors = errors trace.overall_pass = len(errors) == 0 trace.pass_reason = "" if trace.overall_pass else "; ".join(e["code"] for e in errors) trace.agent_metrics = metrics return trace, errors, metrics def _compute_metrics(trace, case, by_match, by_priority, exp_matches, exp_priorities, exp_recall, exp_action, actual_action): """Compute metrics for a single case.""" m = {} # Match t1m = by_match[0].get("title", "") if by_match else "" t5m = [j.get("title", "") for j in by_match[:5]] m["match_top1_hit"] = (t1m in exp_matches) if exp_matches else None m["match_recall_at_5"] = (len(exp_matches & set(t5m))/len(exp_matches)) if exp_matches else None # Priority t1p = by_priority[0].get("title", "") if by_priority else "" t5p = [j.get("title", "") for j in by_priority[:5]] m["priority_top1_hit"] = (t1p in exp_priorities) if exp_priorities else None m["priority_recall_at_5"] = (len(exp_priorities & set(t5p))/len(exp_priorities)) if exp_priorities else None # Recall (global) all5 = set(t5m) | set(t5p) m["recall_hit"] = (len(exp_recall & all5))/len(exp_recall) if exp_recall else None # Action m["action_hit"] = (exp_action == actual_action) if exp_action and actual_action else None m["expected_action"] = exp_action m["actual_action"] = actual_action # NDCG / MRR (需要 golden_case_to_relevance / compute_ndcg / compute_mrr) match_titles = [j.get("title", "") for j in by_match] pri_titles = [j.get("title", "") for j in by_priority] if golden_case_to_relevance: match_rel = golden_case_to_relevance(match_titles, case) pri_rel = golden_case_to_relevance(pri_titles, case) if match_rel and compute_ndcg: m["match_ndcg_5"] = round(compute_ndcg(match_rel, k=5), 4) if match_rel and compute_mrr: m["mrr_5"] = round(compute_mrr(match_rel, k=5), 4) if pri_rel and compute_ndcg: m["priority_ndcg_5"] = round(compute_ndcg(pri_rel, k=5), 4) m["top1_match_title"] = t1m m["top1_priority_title"] = t1p m["top5_match"] = t5m m["top5_priority"] = t5p m["expected_top_matches"] = list(exp_matches) m["expected_top_priorities"] = list(exp_priorities) m["expected_recall_jobs"] = list(exp_recall) # errors m["error_count"] = len(trace.errors) m["error_types"] = list({e["code"] for e in trace.errors}) ae: dict[str, int] = {} for e in trace.errors: ae[e.get("agent", "?")] = ae.get(e.get("agent", "?"), 0) + 1 m["agent_error_counts"] = ae return m # --------------------------------------------------------------------------- # Golden Case 管理 # --------------------------------------------------------------------------- CORE_CASE_IDS = {f"case_{i:02d}" for i in range(1, 9)} def _case_split(case: dict) -> str: """Return the evaluation split for a golden case.""" explicit = case.get("eval_split") if explicit: return str(explicit) if "jd_text" in case: return "jd_intake" return "core" if case.get("case_id") in CORE_CASE_IDS else "stress" def _should_run_case(case: dict, eval_split: str) -> bool: """Check if a case should be run.""" if case.get("skip_eval"): return False if "jd_text" in case: return False split = _case_split(case) if eval_split == "all": return split in {"core", "stress"} return split == eval_split def run_all_cases(golden_path: str|Path, jobs_path: str|Path, eval_split: str = "core"): """Run all golden cases and return traces + summary.""" cases = load_golden_cases(golden_path) traces, all_errs, all_met = [], [], [] for c in cases: if not _should_run_case(c, eval_split): continue t, err, met = run_case(c, jobs_path) traces.append(t); all_errs.extend(err); all_met.append(met) summary = _summarize(traces, all_errs, all_met) summary["eval_split"] = eval_split return traces, summary def _summarize(traces, all_errs, all_met): """Summarize eval results.""" n = len(traces) if n == 0: return {"total_cases": 0} def _avg(seq): valid = [x for x in seq if x is not None] return round(sum(valid)/len(valid), 4) if valid else None return { "total_cases": n, "pass_cases": sum(1 for t in traces if t.overall_pass), "pass_rate": round(sum(1 for t in traces if t.overall_pass)/n, 4), "match_top1_acc": _avg([m.get("match_top1_hit") for m in all_met]), "priority_top1_acc": _avg([m.get("priority_top1_hit") for m in all_met]), "action_acc": _avg([m.get("action_hit") for m in all_met]), "match_recall_at_5": _avg([m.get("match_recall_at_5") for m in all_met]), "priority_recall_at_5": _avg([m.get("priority_recall_at_5") for m in all_met]), "error_counts": {c: sum(1 for e in all_errs if e["code"]==c) for c in sorted({e["code"] for e in all_errs})}, "agent_error_counts": dict(__import__("collections").Counter(e.get("agent", "?") for e in all_errs)), } # --------------------------------------------------------------------------- # 报告生成 # --------------------------------------------------------------------------- def generate_eval_report(traces, summary, output_path): """Generate eval report (Markdown).""" lines = ["# Eval Report (v2: Match/Priority split)", "", f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}", ""] lines += ["## Summary", "", f"| Metric | Value |", "|---|---|", f"| Eval split | {summary.get('eval_split','core')} |", f"| Total cases | {summary.get('total_cases','-')} |", f"| Pass cases | {summary.get('pass_cases','-')} |", f"| Pass rate | {round(summary.get('pass_rate',0)*100,1)}% |", f"| Match Top1 Acc | {_pct(summary.get('match_top1_acc'))} |", f"| Priority Top1 Acc | {_pct(summary.get('priority_top1_acc'))} |", f"| Action Acc | {_pct(summary.get('action_acc'))} |", f"| Match Recall@5 | {_pct(summary.get('match_recall_at_5'))} |", f"| Priority Recall@5 | {_pct(summary.get('priority_recall_at_5'))} |", ""] ec = summary.get("error_counts", {}) if ec: lines += ["## Error Taxonomy", "", "| Code | Count | Agent | Severity |", "|---|---|---|---|"] for code, cnt in sorted(ec.items(), key=lambda x:-x[1]): tax = (ERROR_TAXONOMY or {}).get(code, {}) lines.append(f"| {code} | {cnt} | {tax.get('agent','-')} | {tax.get('severity','-')} |") lines.append("") ae = summary.get("agent_error_counts", {}) if ae: lines += ["## Agent-level Errors", "", "| Agent | Count |", "|---|---|"] for a, c in sorted(ae.items(), key=lambda x:-x[1]): lines.append(f"| {a} | {c} |") lines.append("") lines += ["## Per-Case Results", ""] for i, t in enumerate(traces, 1): lines += [f"### Case {i}: {t.case_id}", ""] m = t.agent_metrics lines += [f"- Target: {t.target_role} | City: {t.target_city} | Stage: {t.stage}", f"- Match Top1: {m.get('top1_match_title','-')} | Expected: {m.get('expected_top_matches',[])}", f"- Priority Top1: {m.get('top1_priority_title','-')} | Expected: {m.get('expected_top_priorities',[])}", f"- Action: actual={m.get('actual_action','-')}, expected={m.get('expected_action','-')}", f"- Match Top1 Hit: {m.get('match_top1_hit','-')} | Priority Top1 Hit: {m.get('priority_top1_hit','-')}", f"- Errors({len(t.errors)}): " + (", ".join(e["code"] for e in t.errors) if t.errors else "none")] if t.errors: for e in t.errors: lines.append(f" - `{e['code']}`: {e['message']}") lines.append("") content = "\n".join(lines) Path(output_path).write_text(content, encoding="utf-8") return content def generate_error_analysis(traces, summary, output_path): """Generate error analysis report.""" lines = ["# Error Analysis (v2: Match/Priority split)", "", f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}", ""] ec = summary.get("error_counts", {}) lines += ["## Error Ranking", ""] if ec: lines += ["| Rank | Code | Count | Agent |", "|---|---|---|---|"] for rank, (code, cnt) in enumerate(sorted(ec.items(), key=lambda x:-x[1]), 1): tax = (ERROR_TAXONOMY or {}).get(code, {}) lines.append(f"| {rank} | {code} | {cnt} | {tax.get('agent','-')} |") else: lines.append("[OK] No errors.") lines.append("") # Root cause analysis total = summary.get("total_cases", 0) pass_cases = summary.get("pass_cases", 0) match_top1 = summary.get("match_top1_acc") priority_top1 = summary.get("priority_top1_acc") lines += ["## Root Cause: Matcher vs Label", ""] lines.append(f"- Pass rate: {pass_cases}/{total} ({round(summary.get('pass_rate',0)*100,1)}%)") lines.append(f"- Match Top1 Acc: {_pct(match_top1)}") lines.append(f"- Priority Top1 Acc: {_pct(priority_top1)}") lines.append("") # E4 analysis e4_match = ec.get("E4_MATCH_RECALL_MISS", 0) e4_pri = ec.get("E4_PRIORITY_RECALL_MISS", 0) e4 = ec.get("E4_RECALL_MISS", 0) lines += ["## E4 Recall Miss Analysis", ""] if e4 or e4_match or e4_pri: lines.append(f"- E4_MATCH_RECALL_MISS: {e4_match} | E4_PRIORITY_RECALL_MISS: {e4_pri} | E4_RECALL_MISS: {e4}") lines.append("") lines.append("**Possible causes:**") lines.append("1. If E4_MATCH_RECALL_MISS > 0: match_score is too low for expected roles — skill keyword coverage insufficient or expected label not aligned with jobs.json") lines.append("2. If E4_PRIORITY_RECALL_MISS > 0: apply_priority is too low for expected roles — risk_score is pulling down the priority (by design)") lines.append("3. If E4_RECALL_MISS > 0: expected_recall_jobs has jobs not in the top5 of either list — check if the job title exists in jobs.json") lines.append("") else: lines.append("[OK] No recall issues.") lines.append("") # E5 analysis e5_match = ec.get("E5_MATCH_RANK_MISORDER", 0) e5_pri = ec.get("E5_PRIORITY_RANK_MISORDER", 0) lines += ["## E5 Rank Misorder Analysis", ""] if e5_match or e5_pri: lines.append(f"- E5_MATCH_RANK_MISORDER: {e5_match} | E5_PRIORITY_RANK_MISORDER: {e5_pri}") lines.append("") lines.append("**Diagnosis per case:**") for t in traces: errs = [e["code"] for e in t.errors] if "E5_MATCH_RANK_MISORDER" in errs or "E5_PRIORITY_RANK_MISORDER" in errs: m = t.agent_metrics lines.append(f"- **{t.case_id}**: Match Top1={m.get('top1_match_title','-')}, Expected Match={m.get('expected_top_matches',[])}, Priority Top1={m.get('top1_priority_title','-')}, Expected Pri={m.get('expected_top_priorities',[])}") lines.append("") lines.append("**Verdict:**") if e5_match > 0: lines.append("- E5_MATCH errors: matcher's match_score weights may need tuning, OR expected_top_matches label is unreasonable") if e5_pri > 0: lines.append("- E5_PRIORITY errors: apply_priority incorporates risk/pass scores — this is intentional, not a bug. If expected_top_priorities should be about pure match, adjust labels.") lines.append("") else: lines.append("[OK] No rank issues.") lines.append("") # case_02 analysis lines += ["## Case_02 (Recommendation Algorithm) Analysis", ""] case02 = [t for t in traces if t.case_id == "case_02"] if case02: t = case02[0] m = t.agent_metrics errs = [e["code"] for e in t.errors] lines.append(f"- Expected: {m.get('expected_top_matches',[])}") lines.append(f"- Match Top1: {m.get('top1_match_title','-')}") lines.append(f"- Match Top5: {m.get('top5_match',[])}") lines.append(f"- Errors: {errs if errs else 'none'}") if "E5_MATCH_RANK_MISORDER" in errs or "E4_MATCH_RECALL_MISS" in errs: lines.append("- **Diagnosis**: jobs.json has 'LLM 推荐算法实习生', not '推荐算法实习生'. The expectation (pure recommendation) and actual jobs (LLM + recommendation hybrid) may differ in match_score.") lines.append("- **Action**: Either adjust expected_top_matches to 'LLM 推荐算法实习生' (if that's acceptable), or acknowledge that pure rec roles don't exist in current job pool.") else: lines.append("- [OK] No recall/rank issues for case_02 after fixing job title alignment.") lines.append("") # case_03, case_08 analysis lines += ["## Case_03/08 (NLP/CV -> LLM Transfer) Analysis", ""] for cid in ["case_03", "case_08"]: ct = [t for t in traces if t.case_id == cid] if ct: t = ct[0]; m = t.agent_metrics; errs = [e["code"] for e in t.errors] lines.append(f"**{cid}**: Errors={errs if errs else 'none'}, Match Top1={m.get('top1_match_title','-')}, Priority Top1={m.get('top1_priority_title','-')}") lines.append("") lines.append("**Diagnosis:**") lines.append("- case_03 (NLP -> LLM): Has NLP fundamentals but missing RAG/Agent skills. match_score may be reasonable but mismatched roles could rank higher.") lines.append("- case_08 (CV -> LLM): No LLM skills at all. Expected labels are empty (no good match). If system still suggests an LLM role, it means the ranking is over-optimistic for untransferable skill sets.") lines.append("- **Recommendation**: Adjust GrowthScore/RiskScore to reflect transfer difficulty more aggressively for case_08.") lines.append("") # Next steps lines += ["## Next Steps", ""] lines.append("1. If E5_MATCH errors persist: tune SKILL_KEYWORDS or match weight in matcher.py") lines.append("2. If E5_PRIORITY errors persist: review whether expected_top_priorities should be about pure match (then fix labels) or risk-adjusted priority (then matcher is correct)") lines.append("3. Expand jobs.json with more diverse roles (pure rec, pure CV) to test edge cases better") lines.append("4. Add more golden cases for transfer-learning scenarios (X background -> LLM role)") lines.append("") content = "\n".join(lines) Path(output_path).write_text(content, encoding="utf-8") return content def _pct(val): """Format a float as percentage.""" if val is None: return "N/A" return f"{round(val*100,1)}%" # --------------------------------------------------------------------------- # CLI 入口 # --------------------------------------------------------------------------- def main_cli(): """CLI entry point for eval.""" import argparse ap = argparse.ArgumentParser(description="Offer Catcher Eval v2") ap.add_argument("--golden", default="eval/golden_cases.json") ap.add_argument("--jobs", default="data/jobs.json") ap.add_argument("--report", default="reports/eval_report.md") ap.add_argument("--error-analysis", default="reports/error_analysis.md") ap.add_argument("--split", choices=["core", "stress", "all"], default="core") args = ap.parse_args() Path(args.error_analysis).parent.mkdir(parents=True, exist_ok=True) traces, summary = run_all_cases(args.golden, args.jobs, eval_split=args.split) print(f"EVAL_CASES={summary.get('total_cases',0)}") print(f"PASS_CASES={summary.get('pass_cases',0)}") print(f"PASS_RATE={summary.get('pass_rate',0)}") print(f"MATCH_TOP1_ACC={_pct(summary.get('match_top1_acc'))}") print(f"PRIORITY_TOP1_ACC={_pct(summary.get('priority_top1_acc'))}") print(f"ACTION_ACC={_pct(summary.get('action_acc'))}") print(f"MATCH_RECALL_AT_5={_pct(summary.get('match_recall_at_5'))}") print(f"PRIORITY_RECALL_AT_5={_pct(summary.get('priority_recall_at_5'))}") ec = summary.get('error_counts', {}) print(f"ERROR_COUNTS={ec}") generate_eval_report(traces, summary, args.report) print(f"[OK] Report: {args.report}") generate_error_analysis(traces, summary, args.error_analysis) print(f"[OK] Error Analysis: {args.error_analysis}") return traces, summary if __name__ == "__main__": main_cli()