offer-catcher-agent-final / src /langgraph_workflow.py
hungryb's picture
Deploy Offer Catcher final public demo
c974f5d verified
Raw
History Blame Contribute Delete
13.9 kB
"""
langgraph_workflow.py — 基于 LangGraph StateGraph 的多 Agent 求职决策工作流。
9 个 Agent 通过有向无环图编排,共享 AgentState,支持条件路由。
"""
from pathlib import Path
import json
from typing import Optional
from langgraph.graph import StateGraph, END
try:
from .agent_state import AgentState
from .llm_client import LLMClient
from .agents import (
CareerIntentAgent,
JobScoutAgent,
JDAnalystAgent,
ResumeEvidenceAgent,
MatchReasoningAgent,
CounterfactualPlanningAgent,
ResumeCoachAgent,
InterviewCoachAgent,
StrategyPlannerAgent,
)
except ImportError: # Allows direct execution from the src directory.
from agent_state import AgentState
from llm_client import LLMClient
from agents import (
CareerIntentAgent,
JobScoutAgent,
JDAnalystAgent,
ResumeEvidenceAgent,
MatchReasoningAgent,
CounterfactualPlanningAgent,
ResumeCoachAgent,
InterviewCoachAgent,
StrategyPlannerAgent,
)
# Singleton LLMClient
_llm: Optional[LLMClient] = None
_use_online: bool = False # 设置为 True 才调 DeepSeek API
def _get_llm() -> Optional[LLMClient]:
"""Return LLMClient if use_online is True, else None (force fallback mode)."""
global _llm, _use_online
if not _use_online:
return None # 所有 Agent 走规则 fallback,不调 API
if _llm is None:
_llm = LLMClient()
return _llm
# ============================================================================
# Node functions — 每个 Agent 包装为一个 LangGraph node
# ============================================================================
def _node_career_intent(state: AgentState) -> AgentState:
agent = CareerIntentAgent(llm_client=_get_llm())
state.intent = agent.run(state.resume_text, state.user_goal)
state.agent_trace.append(f"[CareerIntent] direction={state.intent.direction} stage={state.intent.stage}")
return state
def _node_job_scout(state: AgentState) -> AgentState:
corpus = _load_curated_demo_jobs()
scout = JobScoutAgent(llm_client=_get_llm())
user_jds = []
if state.user_jd_text and state.user_jd_text.strip():
user_jds.append({
"title": "用户粘贴目标岗位",
"company": "用户提供",
"city": state.intent.target_cities[0] if state.intent and state.intent.target_cities else "不限",
"salary": "",
"jd_text": state.user_jd_text.strip(),
"source_url": "用户粘贴",
})
state.jds = scout.scout(state.intent, user_jds=user_jds, local_corpus=corpus)
if hasattr(scout, "queries") and scout.queries:
state.search_queries = scout.queries
state.agent_trace.append(f"[JobScout] Generated queries: {scout.queries}")
state.agent_trace.append(f"[JobScout] found {len(state.jds)} jobs")
return state
def _node_jd_analyst(state: AgentState) -> AgentState:
analyst = JDAnalystAgent(llm_client=_get_llm())
for i, jd in enumerate(state.jds):
if not jd.hard_skills and jd.jd_text:
try:
analyzed = analyst.analyze(jd.jd_text, {
"title": jd.title, "company": jd.company,
"city": jd.city, "salary": jd.salary,
"source_url": jd.source_url,
})
state.jds[i] = analyzed
except Exception:
pass
state.agent_trace.append(f"[JDAnalyst] analyzed {len(state.jds)} JDs")
return state
def _node_resume_evidence(state: AgentState) -> AgentState:
agent = ResumeEvidenceAgent(llm_client=_get_llm())
direction = state.intent.direction if state.intent else ""
state.resume_evidence = agent.run(state.resume_text, direction)
ev = state.resume_evidence
state.agent_trace.append(f"[Evidence] skills={len(ev.skill_evidence)} gaps={len(ev.gap_areas)}")
return state
def _node_match_reasoning(state: AgentState) -> AgentState:
agent = MatchReasoningAgent(llm_client=_get_llm())
state.match_results = []
for jd in state.jds[:15]:
result = agent.evaluate(jd, state.resume_evidence)
intent_text = (state.intent.direction if state.intent else "").lower()
jd_text = " ".join([jd.title or "", jd.company or "", jd.direction or "", jd.jd_text or ""]).lower()
if any(k in intent_text for k in ["agent", "llm", "大模型", "应用"]):
if any(k in jd_text for k in ["agent", "rag", "llm", "langgraph", "langchain", "大模型"]):
result.match_score = min(float(result.match_score) + 8, 100)
if any(k in jd_text for k in ["游戏", "图像", "计算机视觉", "cv"]):
result.match_score = max(float(result.match_score) - 15, 0)
if jd.source_url == "用户粘贴":
result.title = result.title or jd.title or "用户粘贴目标岗位"
result.company = result.company or jd.company or "用户提供"
if result.evidence_based_reasoning:
result.evidence_based_reasoning = "用户提供的目标 JD,优先进行深度诊断。" + result.evidence_based_reasoning
else:
result.evidence_based_reasoning = "用户提供的目标 JD,优先进行深度诊断。"
state.match_results.append(result)
state.match_results.sort(
key=lambda x: (
0 if (x.company == "用户提供" or x.title.startswith("用户粘贴")) else 1,
-x.match_score,
)
)
state.agent_trace.append(f"[Match] {len(state.match_results)} matched")
return state
def _node_counterfactual(state: AgentState) -> AgentState:
agent = CounterfactualPlanningAgent(llm_client=_get_llm())
top_matches = state.match_results[:5] if len(state.match_results) >= 5 else state.match_results
state.counterfactual = agent.plan(state.resume_evidence, top_matches)
cf = state.counterfactual
state.agent_trace.append(f"[Counterfactual] {len(cf.top3_payoffs)} suggestions")
return state
def _node_resume_coach(state: AgentState) -> AgentState:
agent = ResumeCoachAgent(llm_client=_get_llm())
target = state.jds[0] if state.jds else None
state.coach = agent.coach(state.resume_text, state.resume_evidence, target)
c = state.coach
state.agent_trace.append(f"[ResumeCoach] rewrite={len(c.can_rewrite)} need_first={len(c.need_project_first)}")
return state
def _node_interview_coach(state: AgentState) -> AgentState:
agent = InterviewCoachAgent(llm_client=_get_llm())
top_matches = state.match_results[:3]
state.interview_prep = agent.prepare(top_matches, state.resume_evidence)
state.agent_trace.append(f"[Interview] {len(state.interview_prep.likely_questions)} Qs")
return state
def _node_strategy_planner(state: AgentState) -> AgentState:
agent = StrategyPlannerAgent(llm_client=_get_llm())
state.strategy = agent.plan(state.match_results)
s = state.strategy
state.agent_trace.append(f"[Strategy] safe={len(s.safe_jobs)} stretch={len(s.stretch_jobs)} skip={len(s.skip_jobs)}")
return state
# ============================================================================
# Conditional routing
# ============================================================================
def _route_after_job_scout(state: AgentState) -> str:
"""如果 JD 不足 3 个,仍继续(demo 稳定性优先)。"""
return "jd_analyst"
def _route_after_match(state: AgentState) -> str:
"""如果所有匹配都低分,直接走 strategy(跳过 counterfactual + coach)。"""
if state.match_results and all(m.match_score < 30 for m in state.match_results[:5]):
state.agent_trace.append("[Route] all low scores → skip to strategy")
return "strategy_planner"
return "counterfactual"
# ============================================================================
# Graph builder
# ============================================================================
def build_offer_catcher_graph() -> StateGraph:
"""构建 LangGraph StateGraph。"""
workflow = StateGraph(AgentState)
# 添加 9 个节点
workflow.add_node("career_intent", _node_career_intent)
workflow.add_node("job_scout", _node_job_scout)
workflow.add_node("jd_analyst", _node_jd_analyst)
workflow.add_node("resume_evidence", _node_resume_evidence)
workflow.add_node("match_reasoning", _node_match_reasoning)
workflow.add_node("counterfactual", _node_counterfactual)
workflow.add_node("resume_coach", _node_resume_coach)
workflow.add_node("interview_coach", _node_interview_coach)
workflow.add_node("strategy_planner", _node_strategy_planner)
# 主顺序边
workflow.set_entry_point("career_intent")
workflow.add_edge("career_intent", "job_scout")
workflow.add_conditional_edges("job_scout", _route_after_job_scout, {"jd_analyst": "jd_analyst"})
workflow.add_edge("jd_analyst", "resume_evidence")
workflow.add_edge("resume_evidence", "match_reasoning")
workflow.add_conditional_edges("match_reasoning", _route_after_match, {
"counterfactual": "counterfactual",
"strategy_planner": "strategy_planner",
})
workflow.add_edge("counterfactual", "resume_coach")
workflow.add_edge("resume_coach", "interview_coach")
workflow.add_edge("interview_coach", "strategy_planner")
workflow.add_edge("strategy_planner", END)
return workflow
# ============================================================================
# Public API
# ============================================================================
_graph = None # 缓存编译后的图
def run_full_pipeline(resume: str, goal: str = "", use_online: bool = False, user_jd_text: str = ""):
"""
一站式执行:编译 LangGraph 图,运行 9 Agent 工作流,返回 FinalDecisionReport。
"""
global _graph, _use_online, _llm
# P2: 将 use_online 注入到全局 flag,_get_llm() 会根据此标志决定是否返回 LLMClient
_use_online = use_online
if use_online:
_llm = LLMClient() # 每次在线运行重新读取 provider/model/env,支持 UI 动态切换
elif not use_online:
pass # _get_llm() 将返回 None,所有 Agent 走 fallback
if _graph is None:
_graph = build_offer_catcher_graph().compile()
# 构建初始状态
state = AgentState(resume_text=resume, user_goal=goal, user_jd_text=user_jd_text)
# 如果启用 LLM,注入 client(当前 demo 默认规则版)
# 注意:这里 LLM client 暂不通过 graph 注入,agent 内部自行 fallback
# 执行图(返回 dict)
result_dict = _graph.invoke(state)
# 从 dict 重建 AgentState(LangGraph 返回 TypedDict)
final_state = AgentState(
resume_text=result_dict.get("resume_text", resume),
user_goal=result_dict.get("user_goal", goal),
user_jd_text=result_dict.get("user_jd_text", user_jd_text),
intent=result_dict.get("intent"),
search_queries=result_dict.get("search_queries", []),
jds=result_dict.get("jds", []),
resume_evidence=result_dict.get("resume_evidence"),
match_results=result_dict.get("match_results", []),
counterfactual=result_dict.get("counterfactual"),
coach=result_dict.get("coach"),
interview_prep=result_dict.get("interview_prep"),
strategy=result_dict.get("strategy"),
agent_trace=result_dict.get("agent_trace", []),
)
# 构建报告
try:
from .final_report import ReportBuilder
except ImportError:
from final_report import ReportBuilder
builder = ReportBuilder()
return builder.build(final_state)
def _load_curated_demo_jobs() -> list[dict]:
"""加载精选 Demo 岗位。
不再读取公开聚合语料。
这些数据源质量不可控,容易混入海外高管岗、IT 管理岗、非学生岗,
会破坏多 Agent 决策结果。主流程只使用人工精选岗位兜底;
真实岗位应来自用户粘贴 JD 或后续联网搜索工具。
"""
root = Path(__file__).resolve().parent.parent
path = root / "data" / "jobs.json"
if path.exists():
try:
jobs = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return []
return [job for job in jobs if _is_curated_student_algorithm_job(job)]
return []
def _is_curated_student_algorithm_job(job: dict) -> bool:
"""主流程候选岗位硬过滤,宁可少也不要脏。"""
text = " ".join(
str(job.get(k, ""))
for k in ("title", "company", "direction", "stage", "jd", "jd_text", "description")
).lower()
title = str(job.get("title", "")).lower()
direction = str(job.get("direction", "")).lower()
stage = str(job.get("stage", job.get("recruit_type", ""))).lower()
positive = [
"算法", "大模型", "llm", "agent", "rag", "推荐", "搜索",
"nlp", "计算机视觉", "cv", "机器学习", "深度学习",
]
student_markers = ["实习", "校招", "应届", "intern", "campus"]
negative = [
"chief", "manager", "director", "lead", "principal", "senior",
"infrastructure manager", "asset manager", "human resource",
"hr", "sales", "marketing", "finance", "consultant", "contract",
"top secret", "clearance", "u.s.", "united states",
]
if any(word in text for word in negative):
return False
if not any(word in text for word in positive):
return False
if not any(word in text for word in student_markers):
return False
if "算法" not in title and not any(word in direction for word in positive):
return False
return True