offer-catcher-agent / src /strategy_planner.py
hungryb's picture
Deploy Offer Catcher Agent demo
3161850 verified
Raw
History Blame Contribute Delete
15.3 kB
"""
Strategy Planner Agent — 投递策略规划模块
生成:今日优先投递 Top3、稳妥/平衡/冲刺组合、7 天投递计划、
7 天面试准备计划、需要先改简历再投的岗位列表。
"""
from __future__ import annotations
from typing import Optional
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def _int(v, default: int = 0) -> int:
"""安全转换为 int。"""
try:
return int(v)
except (TypeError, ValueError):
return default
def _pick_top(ranked_jobs: list[dict], n: int = 3) -> list[dict]:
"""取 ApplyPriority 最高(或 score/apply_priority 字段)的前 n 个。"""
key = _priority_key(ranked_jobs)
sorted_jobs = sorted(ranked_jobs, key=lambda x: x.get(key, 0), reverse=True)
return sorted_jobs[:n]
def _priority_key(ranked_jobs: list[dict]) -> str:
"""自动探测优先分数字段名。"""
if not ranked_jobs:
return "score"
sample = ranked_jobs[0]
if "apply_priority" in sample:
return "apply_priority"
if "score" in sample:
return "score"
return "score"
# ---------------------------------------------------------------------------
# 1. 今日优先投递 Top3
# ---------------------------------------------------------------------------
def gen_priority_top3(ranked_jobs: list[dict], profile: Optional[dict] = None) -> list[dict]:
"""
返回每个 Top3 岗位 + 推荐动作说明:
- apply_action: 立即投递 / 先优化再投 / 冲刺岗位 / 暂缓
策略一致性保证:
- 如果 Top1 是"立即投递",则 Top2/3 不允许是"暂缓"
- 如果 Top1 是"暂缓",则 Top2/3 可以是"先优化再投"或"冲刺岗位"
"""
top3 = _pick_top(ranked_jobs, 3)
result = []
# 第一遍:计算所有 action
actions = []
for job in top3:
action = _infer_action(job, profile)
actions.append((job, action))
# 第二遍:策略一致性调整。Top3 是一个投递组合,不能同时出现
# "立即投递"和"暂缓"这种互相打架的建议。
if any(action == "立即投递" for _, action in actions) and any(action == "暂缓" for _, action in actions):
actions = [
(job, "先优化再投" if action == "暂缓" else action)
for job, action in actions
]
# 如果 Top1 本身都需要暂缓,说明整体匹配风险较高,后续岗位不应更激进。
if actions and actions[0][1] == "暂缓":
actions = [
(job, "先优化再投" if action in {"立即投递", "冲刺岗位"} else action)
for job, action in actions
]
# 生成结果
for job, action in actions:
result.append({
"rank": len(result) + 1,
"title": job.get("title", ""),
"company": job.get("company", ""),
"apply_priority": job.get("apply_priority", job.get("score", 0)),
"pass_score": job.get("pass_score", 0),
"risk_score": job.get("risk_score", 0),
"apply_action": action,
"reason": _action_reason(job, action),
})
return result
def _infer_action(job: dict, profile: Optional[dict] = None) -> str:
"""
基于 pass/risk/growth/missing_skills 推断投递动作。
通用规则,不针对特定 case 硬编码。
"""
pass_s = _int(job.get("pass_score"), 50)
risk_s = _int(job.get("risk_score"), 50)
growth_s = _int(job.get("growth_score"), 50)
missing = job.get("missing_skills", [])
missing_count = len(missing) if isinstance(missing, list) else 0
if profile is None:
profile = {}
target_city = profile.get("_city", "")
target_stage = profile.get("_stage", "")
has_llm_project = bool(profile.get("has_llm_project", False))
has_metrics = bool(profile.get("has_metrics", False))
has_rec_project = bool(profile.get("has_rec_project", False))
target_role = profile.get("_target_role", "")
job_direction = job.get("direction") or ""
job_stage = job.get("stage") or ""
# 方向是否一致
if target_role and job_direction:
direction_match = (target_role in job_direction or job_direction in target_role)
else:
direction_match = False
# 城市/阶段错位判断
city_mismatch = bool(
target_city and target_city != "不限" and job.get("city") != target_city
)
stage_mismatch = bool(
target_stage and job_stage and job_stage != "不限" and job_stage != target_stage
)
# 已毕业判断
is_graduated = any(kw in str(target_stage) for kw in ["已毕业", "校招", "2025"])
# 已毕业 + 投实习 = 阶段错位
if is_graduated and job_stage == "实习":
stage_mismatch = True
has_project_signal = has_llm_project or has_rec_project
low_risk = risk_s <= 15
medium_risk = risk_s <= 35
strong_fit = pass_s >= 70 and low_risk and missing_count <= 1
# ========== 1. 硬伤过滤:城市/阶段错位 ==========
if city_mismatch or stage_mismatch:
if strong_fit:
return "冲刺岗位"
if is_graduated and has_project_signal and has_metrics and pass_s >= 55 and risk_s <= 30 and missing_count <= 3:
return "冲刺岗位"
if pass_s >= 50 and medium_risk and missing_count <= 3:
return "先优化再投"
return "暂缓"
# ========== 2. 方向不一致:先判断是否还有可迁移价值 ==========
if target_role and not direction_match:
if pass_s >= 50 and risk_s <= 25 and missing_count <= 2:
return "先优化再投"
if pass_s >= 35 and risk_s <= 45 and ("算法" in job_direction or "NLP" in job_direction or "计算机视觉" in job_direction):
return "先优化再投"
return "暂缓"
# ========== 3. 立即投递:要求低风险且证据足 ==========
if direction_match:
if missing_count == 0 and pass_s >= 55 and risk_s <= 25:
return "立即投递"
if strong_fit:
return "立即投递"
# NLP/相关方向迁移到大模型岗位,若风险极低且有量化证据,可直接投。
if has_metrics and risk_s <= 10 and pass_s >= 55 and missing_count <= 3:
return "立即投递"
# ========== 4. 有项目但证据不完整:优先先优化再投 ==========
if not has_metrics and missing_count > 0:
return "先优化再投"
if missing_count >= 3 and pass_s < 70:
return "先优化再投"
# ========== 5. 冲刺岗位:成长高但仍有明显缺口 ==========
if direction_match and has_project_signal and has_metrics and growth_s >= 70 and pass_s >= 55 and risk_s <= 35:
return "冲刺岗位"
if direction_match and has_llm_project and growth_s >= 75 and pass_s >= 45 and risk_s <= 45:
return "冲刺岗位"
# ========== 6. 兜底 ==========
if pass_s < 30 or risk_s > 70:
return "暂缓"
if pass_s >= 40:
return "先优化再投"
return "暂缓"
def _action_reason(job: dict, action: str) -> str:
if action == "立即投递":
return f"PassScore={job.get('pass_score', '?')},初筛通过潜力高,建议今日投递。"
if action == "先优化再投":
return f"RiskScore={job.get('risk_score', '?')},建议先补齐 {', '.join(job.get('missing_skills', [])[:3])} 再投。"
if action == "冲刺岗位":
return f"GrowthScore={job.get('growth_score', '?')},虽有一定风险,但成长价值高,可冲刺。"
return "多项评分偏低,建议优先投递其他岗位后再考虑。"
# ---------------------------------------------------------------------------
# 2. 稳妥 / 平衡 / 冲刺 岗位组合
# ---------------------------------------------------------------------------
def gen_job_combo(ranked_jobs: list[dict]) -> dict:
"""
按 ApplyPriority 将岗位分为三类(各取最高分 2-3 个):
- stable:PassScore ≥ 70 且 RiskScore ≤ 30
- balanced:PassScore ≥ 55 且 RiskScore ≤ 55
- challenge:GrowthScore ≥ 65(允许 PassScore 较低)
"""
stable, balanced, challenge = [], [], []
for job in ranked_jobs:
pass_s = job.get("pass_score", 0)
risk_s = job.get("risk_score", 100)
growth_s = job.get("growth_score", 0)
if pass_s >= 70 and risk_s <= 30 and len(stable) < 3:
stable.append(_summarize(job))
elif pass_s >= 55 and risk_s <= 55 and len(balanced) < 3:
balanced.append(_summarize(job))
elif growth_s >= 65 and len(challenge) < 3:
challenge.append(_summarize(job))
# 保底:如果某类为空,从剩余岗位里补
all_summary = [_summarize(j) for j in ranked_jobs]
if not stable:
stable = all_summary[:2]
if not balanced:
balanced = all_summary[2:4] if len(all_summary) >= 4 else all_summary[:2]
if not challenge:
challenge = all_summary[:2]
return {
"稳妥岗(高通过率)": stable,
"平衡岗(综合推荐)": balanced,
"冲刺岗(高成长价值)": challenge,
}
def _summarize(job: dict) -> dict:
return {
"title": job.get("title", ""),
"company": job.get("company", ""),
"match_score": job.get("match_score", job.get("score", 0)),
"pass_score": job.get("pass_score", 0),
"risk_score": job.get("risk_score", 0),
"growth_score": job.get("growth_score", 0),
"apply_priority": job.get("apply_priority", 0),
}
# ---------------------------------------------------------------------------
# 3. 7 天投递计划
# ---------------------------------------------------------------------------
def gen_7day_apply_plan(ranked_jobs: list[dict], profile: Optional[dict] = None) -> list[str]:
"""
生成 7 天投递节奏计划,每天 1-2 条可操作步骤。
"""
top5 = _pick_top(ranked_jobs, 5)
names = [f"{j.get('title', '岗位')}({j.get('company', '')})" for j in top5]
plan = [
f"第 1 天:锁定今日优先投递 Top{len(top5)},完善简历中 {', '.join(top5[0].get('matched_skills', ['相关技能'])[:3])} 的项目证据。",
f"第 2 天:投递第 1 个岗位 {names[0] if names else '目标岗位'},并针对第 2 个岗位优化简历关键词。",
f"第 3 天:投递第 2 个岗位,同步准备 {top5[0].get('interview_themes', ['技术问题'])[0] if top5 else '技术问题'} 相关面试题。",
f"第 4 天:根据前两天投递反馈(若有),调整第 3 个岗位简历版本,补充量化指标。",
f"第 5 天:投递第 3 个岗位,开始准备行为面试(自我介绍、项目深挖)。",
f"第 6 天:投递第 4-5 个岗位(或冲刺岗),整理所有投递记录到表格。",
f"第 7 天:复盘本周投递效果,更新简历版本,规划下周投递策略。",
]
return plan
# ---------------------------------------------------------------------------
# 4. 7 天面试准备计划
# ---------------------------------------------------------------------------
def gen_7day_interview_plan(ranked_jobs: list[dict], profile: Optional[dict] = None) -> list[str]:
"""
基于 Top1 岗位的 interview_themes 生成 7 天面试准备计划。
"""
if not ranked_jobs:
return [f"第 {i} 天:请先运行匹配获取岗位信息。" for i in range(1, 8)]
top = ranked_jobs[0]
themes = top.get("interview_themes", ["算法基础", "项目深挖", "系统设计", "业务理解"])
plan = [
f"第 1 天:复盘 {top.get('direction', '目标方向')} 岗位 JD,整理关键词和项目证据清单。",
f"第 2 天:准备 {themes[0] if len(themes) > 0 else '算法基础'},讲清输入、模型、输出和可量化指标。",
f"第 3 天:准备 {themes[1] if len(themes) > 1 else '项目深挖'},覆盖数据、模型、评估、落地四个维度。",
f"第 4 天:准备 {themes[2] if len(themes) > 2 else '系统设计'},重点讲失败 case 和优化方案。",
f"第 5 天:模拟 {top.get('company', '目标公司')} 算法题 3 道,限时 60 分钟。",
"第 6 天:做一次 20 分钟项目深挖 mock,覆盖数据、模型、评估、落地。",
"第 7 天:压缩成 1 分钟自我介绍、3 分钟项目介绍和 5 个高频追问答案。",
]
return plan
# ---------------------------------------------------------------------------
# 5. 哪些岗位需要先改简历再投
# ---------------------------------------------------------------------------
def gen_resume_advice(ranked_jobs: list[dict]) -> list[dict]:
"""
返回需要先改简历再投的岗位列表,附带具体改写建议。
"""
advice = []
for job in ranked_jobs:
pass_s = job.get("pass_score", 50)
risk_s = job.get("risk_score", 50)
missing = job.get("missing_skills", [])
if pass_s < 65 or risk_s >= 50 or missing:
advice.append({
"title": job.get("title", ""),
"company": job.get("company", ""),
"pass_score": pass_s,
"risk_score": risk_s,
"missing_skills": missing[:4],
"advice": _resume_fix_tips(job),
"urgency": "高" if pass_s < 50 else ("中" if pass_s < 65 else "低"),
})
# 按紧急程度排序:高 > 中 > 低
urgency_order = {"高": 0, "中": 1, "低": 2}
advice.sort(key=lambda x: urgency_order.get(x["urgency"], 9))
return advice
def _resume_fix_tips(job: dict) -> list[str]:
tips = []
missing = job.get("missing_skills", [])
if missing:
tips.append(f"补充 {missing[0]} 的真实项目经历或课程项目,不要只写「了解」。")
if job.get("risk_score", 0) >= 50:
tips.append("简历中项目描述缺少量化指标,建议补充 NDCG/HitRate/TopK 等具体数字。")
if job.get("keyword_coverage", 1.0) < 0.4:
tips.append(f"JD 关键词覆盖率偏低,建议在项目描述中自然嵌入:{', '.join(job.get('skills', [])[:4])}。")
if not tips:
tips.append("简历与岗位匹配度尚可,建议再压缩表达、突出亮点。")
return tips
# ---------------------------------------------------------------------------
# 统一入口:生成完整策略包
# ---------------------------------------------------------------------------
def gen_strategy_package(ranked_jobs: list[dict], profile: Optional[dict] = None) -> dict:
"""一次性返回所有策略内容,供 report_generator 和 app.py 使用。"""
return {
"priority_top3": gen_priority_top3(ranked_jobs, profile),
"job_combo": gen_job_combo(ranked_jobs),
"apply_plan_7day": gen_7day_apply_plan(ranked_jobs, profile),
"interview_plan_7day": gen_7day_interview_plan(ranked_jobs, profile),
"resume_advice": gen_resume_advice(ranked_jobs),
}