Spaces:
Runtime error
Runtime error
| """ | |
| final_report.py — 统一可信决策报告 | |
| FinalDecisionReport:9 Agent 输出被整合为一个结构化、可验真的决策报告。 | |
| 每个字段都有来源约束,不是大模型的自由发挥。 | |
| """ | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| # --------------------------------------------------------------------------- | |
| # Sub-schemas | |
| # --------------------------------------------------------------------------- | |
| class IntentSummary: | |
| """Agent 判断的求职意图摘要。""" | |
| target_role: str = "" # 如 "LLM应用算法" | |
| stage: str = "" # "实习" / "校招" / "提前批" | |
| city_preference: list[str] = field(default_factory=list) # ["深圳","北京"] | |
| reasoning: list[str] = field(default_factory=list) # 推理步骤 | |
| confidence: float = 0.5 # 0~1,基于简历信息量估算 | |
| class JDSource: | |
| """一个有来源可查的 JD。""" | |
| title: str = "" | |
| company: str = "" | |
| city: str = "" | |
| salary: str = "" | |
| source_type: str = "" # "公开爬取" / "用户粘贴" / "内置语料" / "联网搜索" | |
| source_url: str = "" # 原始链接(公开爬取必须非空) | |
| fetched_at: str = "" # ISO 8601 时间戳 | |
| raw_snippet: str = "" # JD 原文片段(至少 50 字符) | |
| parsed_requirements: list[str] = field(default_factory=list) # 抽取的需求列表 | |
| def has_url(self) -> bool: | |
| return bool(self.source_url and self.source_url.strip()) | |
| def is_verifiable(self) -> bool: | |
| """无来源 URL 的 JD 不可进入推荐列表。""" | |
| return self.source_type == "用户粘贴" or self.has_url() | |
| class ResumeAction: | |
| """一条简历优化动作,必须绑定证据。""" | |
| action_type: str = "" # "rewrite" / "add_project" / "add_skill" / "quantify" | |
| target_text: str = "" # 改写目标或新增内容 | |
| original_fragment: str = "" # 简历原文片段(rewrite 时必填) | |
| jd_requirement_fragment: str = "" # JD 要求片段 | |
| evidence_based: bool = False # True = 有简历证据,False = 需要先补经历 | |
| reason: str = "" # 为什么这样改 | |
| class JobDecision: | |
| """对一个岗位的完整决策。""" | |
| job_id: str = "" # 唯一标识(如 url hash) | |
| title: str = "" | |
| company: str = "" | |
| city: str = "" | |
| salary: str = "" | |
| decision: str = "" # "稳投" / "冲刺" / "暂缓" | |
| match_score: float = 0.0 | |
| pass_likelihood: float = 0.0 | |
| risk_level: str = "中" # "低" / "中" / "高" | |
| jd_evidence: list[str] = field(default_factory=list) # JD 中的具体要求 | |
| resume_evidence: list[str] = field(default_factory=list) # 简历中已有的证据 | |
| missing_evidence: list[str] = field(default_factory=list) # 明显缺口 | |
| why_this_decision: list[str] = field(default_factory=list) # 推理链 | |
| resume_actions: list[ResumeAction] = field(default_factory=list) # 简历优化 | |
| interview_actions: list[str] = field(default_factory=list) # 面试准备 | |
| supporting_evidence: list[str] = field(default_factory=list) # 支撑证据 | |
| can_rewrite: bool = True | |
| need_new_project: bool = False | |
| def is_valid(self) -> bool: | |
| """合约验证:每个决策至少 1 条 jd_evidence。""" | |
| return len(self.jd_evidence) >= 1 | |
| class WhatIfItem: | |
| """反事实规划中的一条补强建议。""" | |
| action: str = "" # 要做什么 | |
| expected_gain: str = "" # 预期收益(文字描述) | |
| why: str = "" # 原因为什么重要 | |
| needed_time: str = "" # 预计耗时 | |
| class Portfolio: | |
| """投递组合。""" | |
| safe: list[JobDecision] = field(default_factory=list) | |
| stretch: list[JobDecision] = field(default_factory=list) | |
| hold: list[JobDecision] = field(default_factory=list) | |
| # --------------------------------------------------------------------------- | |
| # Top-level report | |
| # --------------------------------------------------------------------------- | |
| class FinalDecisionReport: | |
| """9 Agent 协作产出的最终决策报告。""" | |
| # 第一屏 | |
| intent_summary: IntentSummary = field(default_factory=IntentSummary) | |
| # JD 来源(所有被检索到的) | |
| jd_sources: list[JDSource] = field(default_factory=list) | |
| # 投递组合 | |
| portfolio: Portfolio = field(default_factory=Portfolio) | |
| # 每个岗位的详细决策 | |
| job_decisions: list[JobDecision] = field(default_factory=list) | |
| # 反事实补强规划 | |
| what_if_plan: list[WhatIfItem] = field(default_factory=list) | |
| # 生成时间 | |
| generated_at: str = field(default_factory=lambda: datetime.now().isoformat()) | |
| # 测试用:所有 agent trace | |
| trace: list[str] = field(default_factory=list) | |
| # Job Search queries | |
| search_queries: list[str] = field(default_factory=list) | |
| def all_jobs_have_source(self) -> bool: | |
| """所有 jd_sources 中'公开爬取'类型的必须有 URL。""" | |
| for s in self.jd_sources: | |
| if s.source_type == "公开爬取" and not s.is_verifiable(): | |
| return False | |
| return True | |
| def all_resume_actions_tagged(self) -> list[str]: | |
| """检查所有 resume_action:evidence_based=True 的 rewrite 必须有原始片段。""" | |
| issues = [] | |
| for jd in self.job_decisions: | |
| for i, action in enumerate(jd.resume_actions): | |
| if action.action_type == "rewrite" and action.evidence_based and not action.original_fragment: | |
| issues.append( | |
| f"[{jd.company}-{jd.title}] resume_action[{i}] evidence_based rewrite 缺少 original_fragment") | |
| return issues | |
| def validate_contract(self) -> tuple[bool, list[str]]: | |
| """运行完整合约检查。返回 (通过, 问题列表)。""" | |
| issues = [] | |
| # 1. 无来源 JD 不进推荐 | |
| for jd in self.jd_sources: | |
| if jd.source_type == "公开爬取" and not jd.is_verifiable(): | |
| issues.append(f"[{jd.company}-{jd.title}] 公开爬取 JD 无 source_url") | |
| # 2. 每个 job_decision 至少 1 条 jd_evidence | |
| for i, jd in enumerate(self.job_decisions): | |
| if not jd.is_valid(): | |
| issues.append(f"job_decisions[{i}] ({jd.company}-{jd.title}) 缺少 jd_evidence") | |
| # 3. 每个 resume_action 标记 evidence_based 或 need_new_experience | |
| issues.extend(self.all_resume_actions_tagged()) | |
| # 4. 不出现在 portfolio 的 JD 不应在 job_decisions 中 | |
| # (放宽松:允许) | |
| passed = len(issues) == 0 | |
| return passed, issues | |
| def to_markdown(self) -> str: | |
| """将结构化的决策报告序列化为 Markdown 字符串。""" | |
| def _fmt_val(val, pct=False): | |
| try: | |
| f_val = float(val) | |
| if pct: | |
| # 如果 pass_likelihood 是 0.0-1.0 范围,显示为百分比;如果是 0-100 范围,除以 100 | |
| if f_val > 1.0: | |
| f_val = f_val / 100.0 | |
| return f"{f_val:.1%}" | |
| else: | |
| return f"{f_val:.1f}" | |
| except Exception: | |
| return str(val) | |
| parts = [] | |
| parts.append("# 📊 Offer 捕手 · 求职决策报告") | |
| parts.append("") | |
| # 1. Intent Summary | |
| parts.append("## 一、求职意向诊断") | |
| intent = self.intent_summary | |
| parts.append(f"- **目标岗位方向**: {intent.target_role or '待评估'}") | |
| parts.append(f"- **期望求职通道**: {intent.stage or '待评估'}") | |
| city_pref = "、".join(intent.city_preference) if intent.city_preference else "不限" | |
| parts.append(f"- **期望工作城市**: {city_pref}") | |
| parts.append(f"- **意向评估置信度**: {_fmt_val(intent.confidence, pct=True)}") | |
| if intent.reasoning: | |
| parts.append("\n**意向分析依据:**") | |
| for r in intent.reasoning: | |
| parts.append(f"- {r}") | |
| if hasattr(self, "search_queries") and self.search_queries: | |
| parts.append("\n**Job Search Agent 动态检索词:**") | |
| for q in self.search_queries: | |
| parts.append(f"- `{q}`") | |
| parts.append("") | |
| # 2. Portfolio | |
| parts.append("## 二、推荐投递组合") | |
| parts.append("") | |
| def _render_combo_list(jobs, label): | |
| lines = [f"### 📌 {label}"] | |
| if not jobs: | |
| lines.append("_无匹配岗位_") | |
| else: | |
| for j in jobs: | |
| lines.append(f"- **{j.title}** @ **{j.company}** | Match: `{_fmt_val(j.match_score)}` | Pass: `{_fmt_val(j.pass_likelihood, pct=True)}` | Risk: `{j.risk_level}`") | |
| return "\n".join(lines) | |
| parts.append(_render_combo_list(self.portfolio.safe, "建议立即投递岗")) | |
| parts.append("") | |
| parts.append(_render_combo_list(self.portfolio.stretch, "冲刺与简历优化岗")) | |
| parts.append("") | |
| parts.append(_render_combo_list(self.portfolio.hold, "暂缓考虑岗")) | |
| parts.append("") | |
| # 3. Detailed decisions | |
| parts.append("## 三、精细人岗匹配诊断与简历改写") | |
| parts.append("") | |
| for idx, jd in enumerate(self.job_decisions, 1): | |
| parts.append(f"### {idx}. {jd.title} @ {jd.company}") | |
| parts.append(f"- **决策建议**: `{jd.decision}`") | |
| parts.append(f"- **Match Score**: `{_fmt_val(jd.match_score)}` | **Pass Likelihood**: `{_fmt_val(jd.pass_likelihood, pct=True)}` | **Risk Level**: `{jd.risk_level}`") | |
| can_rw_str = "✅ 是" if getattr(jd, "can_rewrite", True) else "❌ 否" | |
| need_pj_str = "⚠️ 是 (需先补项目)" if getattr(jd, "need_new_project", False) else "✅ 否" | |
| parts.append(f"- **是否可改写**: {can_rw_str} | **是否需要先做Demo补强**: {need_pj_str}") | |
| parts.append("") | |
| # Evidence | |
| parts.append("#### 🔍 匹配证据链与缺口对齐") | |
| if jd.jd_evidence: | |
| parts.append("**JD 核心要求:**") | |
| for e in jd.jd_evidence: | |
| parts.append(f" - {e}") | |
| if hasattr(jd, "supporting_evidence") and jd.supporting_evidence: | |
| parts.append("**支撑证据 (Resume Evidence):**") | |
| for e in jd.supporting_evidence: | |
| parts.append(f" - {e}") | |
| elif jd.resume_evidence: | |
| parts.append("**简历匹配要点:**") | |
| for e in jd.resume_evidence: | |
| parts.append(f" - {e}") | |
| if jd.missing_evidence: | |
| parts.append("**缺失/不足证据 (Gaps):**") | |
| for e in jd.missing_evidence: | |
| parts.append(f" - ⚠️ {e}") | |
| parts.append("") | |
| # Why this decision | |
| if jd.why_this_decision: | |
| parts.append("**决策依据推理:**") | |
| for r in jd.why_this_decision: | |
| parts.append(f"> {r}") | |
| parts.append("") | |
| # Resume actions | |
| if jd.resume_actions: | |
| parts.append("#### 📝 简历句式优化建议") | |
| for action in jd.resume_actions: | |
| parts.append(f"- **优化动作**: `{action.action_type}`") | |
| if action.jd_requirement_fragment: | |
| parts.append(f" - **对应岗位需求**: {action.jd_requirement_fragment}") | |
| if action.original_fragment: | |
| parts.append(f" - **原简历描述**: _\"{action.original_fragment}\"_") | |
| parts.append(f" - **推荐修改为**: **\"{action.target_text}\"**") | |
| if action.reason: | |
| parts.append(f" - **修改理由**: {action.reason}") | |
| parts.append("") | |
| # Interview preparation | |
| if jd.interview_actions: | |
| parts.append("#### 🧠 针对性面试考察点") | |
| for q in jd.interview_actions: | |
| parts.append(f"- {q}") | |
| parts.append("") | |
| parts.append("---") | |
| # 4. What-if Plan | |
| if self.what_if_plan: | |
| parts.append("## 四、反事实生涯强化规划") | |
| parts.append("如果针对以下缺失点进行短期补强,预期能够带来显著的匹配度收益:") | |
| parts.append("") | |
| for item in self.what_if_plan: | |
| parts.append(f"- **补强动作**: {item.action}") | |
| parts.append(f" - **预期收益**: {item.expected_gain}") | |
| parts.append(f" - **依据/原因**: {item.why}") | |
| parts.append(f" - **预估成本**: {item.needed_time}") | |
| parts.append("") | |
| parts.append(f"*报告生成时间:{self.generated_at[:19]} | 由 Offer 捕手 LangGraph Multi-Agent 协同引擎自动生成*") | |
| return "\n".join(parts) | |
| # --------------------------------------------------------------------------- | |
| # Builder | |
| # --------------------------------------------------------------------------- | |
| try: | |
| from .agent_state import AgentState, CareerIntent, JDProfile, ResumeEvidence, MatchResult | |
| from .agent_state import CounterfactualPlan, CoachOutput, InterviewPrep, StrategyOutput | |
| except ImportError: # Allows direct execution from the src directory. | |
| from agent_state import AgentState, CareerIntent, JDProfile, ResumeEvidence, MatchResult | |
| from agent_state import CounterfactualPlan, CoachOutput, InterviewPrep, StrategyOutput | |
| class ReportBuilder: | |
| """从 AgentState 构建 FinalDecisionReport。""" | |
| def build(self, state: AgentState) -> FinalDecisionReport: | |
| report = FinalDecisionReport() | |
| # 1. Intent summary | |
| if state.intent: | |
| report.intent_summary = self._build_intent(state.intent) | |
| # 2. Search queries | |
| report.search_queries = state.search_queries | |
| # 3. JD sources | |
| report.jd_sources = self._build_jd_sources(state.jds) | |
| # 4. Job decisions | |
| report.job_decisions = self._build_job_decisions( | |
| state.match_results, state.resume_evidence, state.coach, state.interview_prep) | |
| # 5. Portfolio | |
| report.portfolio = self._build_portfolio(state.strategy, report.job_decisions) | |
| # 6. What-if plan | |
| report.what_if_plan = self._build_what_if(state.counterfactual) | |
| # 7. Trace | |
| report.trace = state.agent_trace | |
| return report | |
| def _build_intent(self, intent: CareerIntent) -> IntentSummary: | |
| return IntentSummary( | |
| target_role=intent.direction or "待确认", | |
| stage=intent.stage or "校招", | |
| city_preference=intent.target_cities or ["深圳", "北京"], | |
| reasoning=[intent.reasoning] if intent.reasoning else [], | |
| confidence=0.7 if intent.direction and intent.direction != "待确认" else 0.4, | |
| ) | |
| def _build_jd_sources(self, jds: list[JDProfile]) -> list[JDSource]: | |
| sources = [] | |
| for jd in jds: | |
| source_url_val = (jd.source_url or "") if hasattr(jd, 'source_url') else "" | |
| source_type = "用户粘贴" if source_url_val == "用户粘贴" else ( | |
| "公开爬取" if source_url_val and source_url_val.startswith("http") else "Demo精选岗位") | |
| snippet = (jd.jd_text or "") | |
| if len(snippet) < 80: | |
| snippet = snippet + "(更多JD详情请联系系统管理员或查阅原始链接)" | |
| sources.append(JDSource( | |
| title=jd.title or "", | |
| company=jd.company or "", | |
| city=jd.city or "", | |
| salary=jd.salary or "面议", | |
| source_type=source_type, | |
| source_url=source_url_val if source_url_val.startswith("http") else "", | |
| fetched_at=datetime.now().isoformat()[:19], | |
| raw_snippet=snippet[:400], | |
| parsed_requirements=list(jd.hard_skills)[:8] if jd.hard_skills else [], | |
| )) | |
| return sources | |
| def _build_job_decisions( | |
| self, | |
| matches: list[MatchResult], | |
| evidence: ResumeEvidence, | |
| coach: CoachOutput, | |
| interview: InterviewPrep, | |
| ) -> list[JobDecision]: | |
| decisions = [] | |
| for i, m in enumerate(matches): | |
| # 构建 JD evidence(从 JD 中提取的具体要求) | |
| jd_evidence_list = [] | |
| if hasattr(m, 'jd') and m.jd and m.jd.hard_skills: | |
| jd_evidence_list = [f"要求:{s}" for s in m.jd.hard_skills[:5]] | |
| if not jd_evidence_list and m.missing_evidence: | |
| jd_evidence_list = [f"缺失要求:{x}" for x in m.missing_evidence[:3]] | |
| if not jd_evidence_list: | |
| jd_evidence_list = ["JD 结构化解析完成(规则提取)"] | |
| # 构建 Resume evidence(从简历中匹配到的证据) | |
| resume_evidence_list = [] | |
| if evidence and evidence.skill_evidence: | |
| for skill, ev_list in list(evidence.skill_evidence.items())[:5]: | |
| if ev_list: | |
| resume_evidence_list.append(f"技能 {skill}:{ev_list[0][:80]}") | |
| # 简历优化动作 | |
| actions = [] | |
| coach_actions = (coach.can_rewrite if coach else []) + (coach.need_project_first if coach else []) | |
| for ca in coach_actions[:4]: | |
| is_rewrite = ca in (coach.can_rewrite if coach else []) | |
| # 尝试从简历证据中提取原始片段 | |
| orig_fragment = "" | |
| if is_rewrite and evidence and evidence.skill_evidence: | |
| for skill, ev_list in evidence.skill_evidence.items(): | |
| if ev_list: | |
| orig_fragment = ev_list[0] | |
| break | |
| actions.append(ResumeAction( | |
| action_type="rewrite" if is_rewrite else "add_project", | |
| target_text=ca[:120], | |
| original_fragment=orig_fragment[:150] if is_rewrite and orig_fragment else "", | |
| jd_requirement_fragment=str(jd_evidence_list[0])[:120] if jd_evidence_list else "", | |
| evidence_based=is_rewrite and bool(orig_fragment), | |
| reason="基于简历原文改写" if (is_rewrite and orig_fragment) else ("需要先补真实项目经历再写入简历" if is_rewrite else "补充缺失经历"), | |
| )) | |
| d = JobDecision( | |
| job_id=f"job_{i}", | |
| title=m.title or "", | |
| company=m.company or "", | |
| decision=m.apply_action or "暂缓", | |
| match_score=float(m.match_score) if m.match_score else 0.0, | |
| pass_likelihood=float(m.pass_likelihood) if m.pass_likelihood else 0.0, | |
| risk_level=m.risk_level or "中", | |
| jd_evidence=jd_evidence_list, | |
| resume_evidence=resume_evidence_list, | |
| supporting_evidence=m.supporting_evidence if hasattr(m, 'supporting_evidence') else [], | |
| missing_evidence=list(m.missing_evidence)[:5] if m.missing_evidence else [], | |
| why_this_decision=[(m.evidence_based_reasoning or "")[:200]], | |
| resume_actions=actions, | |
| interview_actions=(interview.likely_questions[:3] if interview else []), | |
| can_rewrite=m.can_rewrite if hasattr(m, 'can_rewrite') else True, | |
| need_new_project=m.need_new_project if hasattr(m, 'need_new_project') else False, | |
| ) | |
| decisions.append(d) | |
| return decisions | |
| def _build_portfolio( | |
| self, | |
| strategy: StrategyOutput, | |
| all_decisions: list[JobDecision], | |
| ) -> Portfolio: | |
| if strategy is None: | |
| # Fallback: derive from decisions | |
| safe = [d for d in all_decisions if d.decision == "立即投递"] | |
| stretch = [d for d in all_decisions if d.decision in ("先优化再投", "冲刺岗位")] | |
| hold = [d for d in all_decisions if d.decision == "暂缓"] | |
| return Portfolio(safe=safe, stretch=stretch, hold=hold) | |
| # Map strategy MatchResult → JobDecision | |
| def _lookup(mr_list, all_d): | |
| result = [] | |
| for mr in mr_list: | |
| for d in all_d: | |
| if d.title == mr.title and d.company == mr.company: | |
| result.append(d) | |
| break | |
| else: | |
| # 不在 all_decisions 中,创建一个 | |
| result.append(JobDecision( | |
| title=mr.title, company=mr.company, decision="稳投")) | |
| return result | |
| return Portfolio( | |
| safe=_lookup(strategy.safe_jobs if strategy.safe_jobs else [], all_decisions), | |
| stretch=_lookup(strategy.stretch_jobs if strategy.stretch_jobs else [], all_decisions), | |
| hold=_lookup(strategy.skip_jobs if strategy.skip_jobs else [], all_decisions), | |
| ) | |
| def _build_what_if(self, cf: CounterfactualPlan) -> list[WhatIfItem]: | |
| if cf is None or not cf.top3_payoffs: | |
| return [] | |
| items = [] | |
| for p in cf.top3_payoffs: | |
| items.append(WhatIfItem( | |
| action=p.get("action", ""), | |
| expected_gain=f"匹配度预估提升 +{p.get('match_gain','?')}%", | |
| why=p.get("why", ""), | |
| needed_time=f"{p.get('effort_days','?')}天", | |
| )) | |
| return items | |