| """ |
| final_report.py — 统一可信决策报告 |
| |
| FinalDecisionReport:9 Agent 输出被整合为一个结构化、可验真的决策报告。 |
| 每个字段都有来源约束,不是大模型的自由发挥。 |
| """ |
|
|
| from dataclasses import dataclass, field |
| from datetime import datetime |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class IntentSummary: |
| """Agent 判断的求职意图摘要。""" |
| target_role: str = "" |
| stage: str = "" |
| city_preference: list[str] = field(default_factory=list) |
| reasoning: list[str] = field(default_factory=list) |
| confidence: float = 0.5 |
|
|
|
|
| @dataclass |
| class JDSource: |
| """一个有来源可查的 JD。""" |
| title: str = "" |
| company: str = "" |
| city: str = "" |
| salary: str = "" |
| source_type: str = "" |
| source_url: str = "" |
| fetched_at: str = "" |
| raw_snippet: str = "" |
| 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() |
|
|
|
|
| @dataclass |
| class ResumeAction: |
| """一条简历优化动作,必须绑定证据。""" |
| action_type: str = "" |
| target_text: str = "" |
| original_fragment: str = "" |
| jd_requirement_fragment: str = "" |
| evidence_based: bool = False |
| reason: str = "" |
|
|
|
|
| @dataclass |
| class JobDecision: |
| """对一个岗位的完整决策。""" |
| job_id: str = "" |
| 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) |
| 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) |
|
|
| def is_valid(self) -> bool: |
| """合约验证:每个决策至少 1 条 jd_evidence。""" |
| return len(self.jd_evidence) >= 1 |
|
|
|
|
| @dataclass |
| class WhatIfItem: |
| """反事实规划中的一条补强建议。""" |
| action: str = "" |
| expected_gain: str = "" |
| why: str = "" |
| needed_time: str = "" |
|
|
|
|
| @dataclass |
| class Portfolio: |
| """投递组合。""" |
| safe: list[JobDecision] = field(default_factory=list) |
| stretch: list[JobDecision] = field(default_factory=list) |
| hold: list[JobDecision] = field(default_factory=list) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class FinalDecisionReport: |
| """9 Agent 协作产出的最终决策报告。""" |
| |
| intent_summary: IntentSummary = field(default_factory=IntentSummary) |
|
|
| |
| 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()) |
|
|
| |
| trace: 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 = [] |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| issues.extend(self.all_resume_actions_tagged()) |
|
|
| |
| |
|
|
| passed = len(issues) == 0 |
| return passed, issues |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| if state.intent: |
| report.intent_summary = self._build_intent(state.intent) |
|
|
| |
| report.jd_sources = self._build_jd_sources(state.jds) |
|
|
| |
| report.job_decisions = self._build_job_decisions( |
| state.match_results, state.resume_evidence, state.coach, state.interview_prep) |
|
|
| |
| report.portfolio = self._build_portfolio(state.strategy, report.job_decisions) |
|
|
| |
| report.what_if_plan = self._build_what_if(state.counterfactual) |
|
|
| |
| 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_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_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, |
| 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 []), |
| ) |
| decisions.append(d) |
| return decisions |
|
|
| def _build_portfolio( |
| self, |
| strategy: StrategyOutput, |
| all_decisions: list[JobDecision], |
| ) -> Portfolio: |
| if strategy is None: |
| |
| 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) |
|
|
| |
| 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: |
| |
| 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 |
|
|