offer-catcher-agent-v2 / src /final_report.py
hungryb's picture
v2: agent report + filtered corpus + evidence contract
54b5b64 verified
Raw
History Blame Contribute Delete
14.6 kB
"""
final_report.py — 统一可信决策报告
FinalDecisionReport:9 Agent 输出被整合为一个结构化、可验真的决策报告。
每个字段都有来源约束,不是大模型的自由发挥。
"""
from dataclasses import dataclass, field
from datetime import datetime
# ---------------------------------------------------------------------------
# Sub-schemas
# ---------------------------------------------------------------------------
@dataclass
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,基于简历信息量估算
@dataclass
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()
@dataclass
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 = "" # 为什么这样改
@dataclass
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) # 面试准备
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)
# ---------------------------------------------------------------------------
# Top-level report
# ---------------------------------------------------------------------------
@dataclass
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)
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
# ---------------------------------------------------------------------------
# Builder
# ---------------------------------------------------------------------------
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. JD sources
report.jd_sources = self._build_jd_sources(state.jds)
# 3. Job decisions
report.job_decisions = self._build_job_decisions(
state.match_results, state.resume_evidence, state.coach, state.interview_prep)
# 4. Portfolio
report.portfolio = self._build_portfolio(state.strategy, report.job_decisions)
# 5. What-if plan
report.what_if_plan = self._build_what_if(state.counterfactual)
# 6. 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,
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:
# 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