| """ |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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] = [] |
|
|
| |
| 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", "") |
|
|
| |
| t0 = time.time() |
| profile: dict = {} |
| try: |
| from src.resume_parser import parse_resume |
| profile = parse_resume(trace.resume_text) |
| except Exception: |
| pass |
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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 "" |
|
|
| |
| 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"}) |
| |
| 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"}) |
| |
| 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"}) |
|
|
| |
| 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"}) |
| |
| 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, |
| } |
|
|
| |
| t2 = time.time() |
| |
| |
| 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]} |
|
|
| |
| 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 |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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"}) |
| |
| 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"}) |
|
|
| |
| 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 = _compute_metrics(trace, case, by_match, by_priority, exp_matches, exp_priorities, exp_recall, exp_action, actual_action) |
|
|
| |
| 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 = {} |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| all5 = set(t5m) | set(t5p) |
| m["recall_hit"] = (len(exp_recall & all5))/len(exp_recall) if exp_recall else None |
|
|
| |
| 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 |
|
|
| |
| 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) |
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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("") |
|
|
| |
| 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_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_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("") |
|
|
| |
| 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("") |
|
|
| |
| 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("") |
|
|
| |
| 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)}%" |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|