Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |