| """ |
| Corpus Eval: 评估大库下检索质量。 |
| |
| Usage: |
| python scripts/eval_corpus_quality.py |
| |
| Outputs: |
| reports/corpus_eval_report.md |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import sys |
| import time |
| from collections import Counter |
| from pathlib import Path |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(ROOT)) |
|
|
| from src.matcher import rank_job_list |
| from src.resume_parser import parse_resume |
|
|
|
|
| |
| TEST_QUERIES = [ |
| { |
| "name": "CV 学生(目标:计算机视觉)", |
| "resume": """张伟 |
| 计算机视觉方向硕士生 |
| 技能:PyTorch, ResNet, YOLO, OpenCV, Python, 目标检测, 图像分类 |
| 项目: |
| 1. 基于 YOLOv8 的实时目标检测系统(mAP 0.68) |
| 2. 细粒度图像分类(ResNet + 注意力机制,Top1 准确率 89%) |
| """, |
| "target_role": "计算机视觉", |
| "target_city": "北京", |
| "stage": "实习", |
| }, |
| { |
| "name": "LLM Agent 学生(目标:大模型应用算法)", |
| "resume": """李明 |
| 大模型应用算法方向 |
| 技能:Python, PyTorch, LangChain, RAG, Agent, LLM, Embedding, Prompt |
| 项目: |
| 1. 基于 RAG 的知识库问答系统(召回率 85%,准确率 78%) |
| 2. 多 Agent 协作任务规划框架(成功率达 72%) |
| """, |
| "target_role": "大模型应用算法", |
| "target_city": "深圳", |
| "stage": "实习", |
| }, |
| { |
| "name": "推荐算法学生(目标:推荐算法)", |
| "resume": """王芳 |
| 推荐算法方向 |
| 技能:Python, PyTorch, 推荐系统, 召回, 排序, NDCG, A/B Test, Embedding |
| 项目: |
| 1. 短视频推荐召回排序优化(线上 CTR + 12%) |
| 2. 多目标推荐优化(CTR + CVR 联合优化) |
| """, |
| "target_role": "推荐算法", |
| "target_city": "北京", |
| "stage": "校招", |
| }, |
| { |
| "name": "后端开发(目标:后端研发)", |
| "resume": """赵强 |
| 后端研发方向 |
| 技能:Go, Python, Docker, Kubernetes, MySQL, Redis, FastAPI, 微服务 |
| 项目: |
| 1. 高并发订单系统(QPS 5000+,P99 延迟 < 50ms) |
| 2. 微服务治理平台(服务发现、熔断、限流) |
| """, |
| "target_role": "后端研发", |
| "target_city": "上海", |
| "stage": "校招", |
| }, |
| { |
| "name": "数据分析(目标:数据分析)", |
| "resume": """陈静 |
| 数据分析方向 |
| 技能:Python, SQL, Pandas, A/B Test, 指标体系, 可视化, Hive |
| 项目: |
| 1. 用户增长漏斗分析(识别 3 个关键流失点,转化率 + 18%) |
| 2. A/B 实验平台搭建(支持 50+ 并行实验) |
| """, |
| "target_role": "数据分析", |
| "target_city": "杭州", |
| "stage": "实习", |
| }, |
| { |
| "name": "NLP 转 LLM(目标:大模型应用算法)", |
| "resume": """王磊 |
| NLP 转大模型应用算法 |
| 技能:Python, PyTorch, BERT, 文本分类, 命名实体识别, LLM, Prompt |
| 项目: |
| 1. 基于 BERT 的文本分类系统(准确率 92%) |
| 2. 命名实体识别模型(F1 值 0.87) |
| 3. 正在学习 RAG 和 Agent,有一个简单的 demo |
| """, |
| "target_role": "大模型应用算法", |
| "target_city": "北京", |
| "stage": "实习", |
| }, |
| { |
| "name": "弱项目背景(目标:推荐算法)", |
| "resume": """刘洋 |
| 推荐算法方向 |
| 技能:Python, 机器学习, 数据分析 |
| 项目: |
| 1. 课程作业:MovieLens 推荐系统(协同过滤) |
| 2. 无实习经历,无论文发表 |
| """, |
| "target_role": "推荐算法", |
| "target_city": "上海", |
| "stage": "校招", |
| }, |
| ] |
|
|
|
|
| def load_corpus() -> list[dict]: |
| corpus_path = ROOT / "data" / "jobs_corpus.json" |
| if not corpus_path.exists(): |
| print(f"[FAIL] {corpus_path} not found") |
| return [] |
| with corpus_path.open("r", encoding="utf-8") as f: |
| data = json.load(f) |
| return data if isinstance(data, list) else [] |
|
|
|
|
| def check_topk(jobs: list[dict], query: dict, k: int = 5) -> dict: |
| """检查 TopK 检索质量。""" |
| profile = parse_resume(query["resume"]) |
| profile["_target_role"] = query["target_role"] |
| profile["_city"] = query["target_city"] |
| profile["_stage"] = query["stage"] |
|
|
| start = time.time() |
| ranked = rank_job_list( |
| resume_text=query["resume"], |
| profile=profile, |
| target_role=query["target_role"], |
| target_city=query["target_city"], |
| stage=query["stage"], |
| top_k=k, |
| jobs=jobs, |
| ) |
| elapsed = time.time() - start |
|
|
| topk = ranked[:k] |
|
|
| |
| direction_hit = sum( |
| 1 for j in topk if query["target_role"] in (j.get("direction") or "") |
| ) |
| direction_recall = direction_hit / max(k, 1) |
|
|
| |
| city_hit = sum( |
| 1 for j in topk |
| if j.get("city") == query["target_city"] or j.get("city") == "不限" |
| ) |
| stage_hit = sum( |
| 1 for j in topk |
| if j.get("stage") == query["stage"] or j.get("stage") == "不限" |
| ) |
|
|
| |
| titles = [j.get("title", "") for j in topk] |
| duplicate = k - len(set(titles)) |
| duplicate_ratio = duplicate / max(k, 1) |
|
|
| |
| empty_skills = sum(1 for j in topk if not j.get("skills")) |
| empty_jd = sum(1 for j in topk if not j.get("jd") and not j.get("description")) |
|
|
| |
| sources = [j.get("source", "unknown") for j in topk] |
| source_counter = Counter(sources) |
| top_source, top_source_count = source_counter.most_common(1)[0] |
| source_diversity = 1.0 - (top_source_count / max(k, 1)) |
|
|
| |
| topk_detail = [] |
| for j in topk: |
| topk_detail.append({ |
| "title": j.get("title", "?"), |
| "company": j.get("company", "?"), |
| "city": j.get("city", "?"), |
| "direction": j.get("direction", "?"), |
| "source": j.get("source", "?"), |
| "match_score": j.get("match_score", "?"), |
| "apply_priority": j.get("apply_priority", "?"), |
| "pass_score": j.get("pass_score", "?"), |
| "risk_score": j.get("risk_score", "?"), |
| }) |
|
|
| return { |
| "query_name": query["name"], |
| "target_role": query["target_role"], |
| "elapsed": round(elapsed, 3), |
| "topk_detail": topk_detail, |
| "direction_hit": direction_hit, |
| "direction_recall": direction_recall, |
| "city_hit": city_hit, |
| "stage_hit": stage_hit, |
| "duplicate": duplicate, |
| "duplicate_ratio": duplicate_ratio, |
| "empty_skills": empty_skills, |
| "empty_jd": empty_jd, |
| "source_diversity": source_diversity, |
| "top_source": top_source, |
| } |
|
|
|
|
| def generate_report(results: list[dict]) -> str: |
| lines = [] |
| lines.append("# Corpus Eval Report\n") |
| lines.append(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}\n") |
| lines.append(f"**Corpus**: `data/jobs_corpus.json`\n") |
| lines.append(f"**Queries**: {len(results)}\n") |
|
|
| |
| total_direction_hit = sum(r["direction_hit"] for r in results) |
| total_empty = sum(r["empty_skills"] + r["empty_jd"] for r in results) |
| avg_diversity = sum(r["source_diversity"] for r in results) / max(len(results), 1) |
|
|
| lines.append("## 汇总指标\n") |
| lines.append(f"- **Top1 方向命中率**: {total_direction_hit}/{len(results)} ({total_direction_hit/max(len(results),1)*100:.1f}%)\n") |
| lines.append(f"- **Top5 方向召回率**: 见各 case\n") |
| lines.append(f"- **空字段数**: {total_empty}\n") |
| lines.append(f"- **平均来源多样性**: {avg_diversity*100:.1f}%\n") |
|
|
| |
| for r in results: |
| lines.append(f"## {r['query_name']}\n") |
| lines.append(f"- **Target**: {r['target_role']}\n") |
| lines.append(f"- **Elapsed**: {r['elapsed']}s\n") |
| lines.append(f"- **Direction Hit**: {r['direction_hit']}/5 ({r['direction_recall']*100:.1f}%)\n") |
| lines.append(f"- **City Hit**: {r['city_hit']}/5\n") |
| lines.append(f"- **Stage Hit**: {r['stage_hit']}/5\n") |
| lines.append(f"- **Duplicate Titles**: {r['duplicate']}/5 ({r['duplicate_ratio']*100:.1f}%)\n") |
| lines.append(f"- **Empty Skills**: {r['empty_skills']}\n") |
| lines.append(f"- **Empty JD**: {r['empty_jd']}\n") |
| lines.append(f"- **Source Diversity**: {r['source_diversity']*100:.1f}% (top: {r['top_source']})\n") |
|
|
| lines.append("\n**Top 5 Results**:\n\n") |
| lines.append("| Rank | Title | Company | Direction | City | Source | Match | Priority | Pass | Risk |\n") |
| lines.append("|---|---|---|---|---|---|---|---|---|---|\n") |
| for i, job in enumerate(r["topk_detail"]): |
| lines.append( |
| f"| {i+1} | {job['title']} | {job['company']} " |
| f"| {job['direction']} | {job['city']} | {job['source']} " |
| f"| {job['match_score']} | {job['apply_priority']} " |
| f"| {job['pass_score']} | {job['risk_score']} |\n" |
| ) |
| lines.append("\n") |
|
|
| |
| lines.append("## 建议\n") |
| if total_empty > 0: |
| lines.append("- [WARN] 发现空字段,检查数据源。\n") |
| if avg_diversity < 0.4: |
| lines.append("- [WARN] 来源多样性低,增加真实公开数据。\n") |
| lines.append("\n---\n") |
| lines.append("*Report generated by `scripts/eval_corpus_quality.py`*\n") |
|
|
| return "".join(lines) |
|
|
|
|
| def main() -> None: |
| print("[INFO] Loading job corpus...") |
| jobs = load_corpus() |
| if not jobs: |
| return |
|
|
| print(f"[INFO] Loaded {len(jobs)} jobs. Running corpus eval...") |
|
|
| results = [] |
| for query in TEST_QUERIES: |
| print(f"[INFO] Query: {query['name']}") |
| result = check_topk(jobs, query, k=5) |
| results.append(result) |
|
|
| print("[INFO] Generating report...") |
| report = generate_report(results) |
|
|
| output_path = ROOT / "reports" / "corpus_eval_report.md" |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| output_path.write_text(report, encoding="utf-8") |
|
|
| print(f"[OK] Report saved to {output_path}") |
| print(f"[SUMMARY] Queries: {len(results)}, Empty Fields: {sum(r['empty_skills'] + r['empty_jd'] for r in results)}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|