"""Deterministic evidence verification and conservative reliability scoring. Runs after the LLM synthesis_node. Walks every SourcedFact-like dict in the brief, recomputes its ``reliability`` via ``agent.reliability.reliability_for`` and keeps absent or failed verification at LOW. Corroboration is explanatory only; it never promotes an unverified claim. The LLM picks reliability by hard-coded rule (10-K=HIGH, transcript=MEDIUM). This module replaces that with a fail-closed score based on verification and source class. """ from __future__ import annotations import re from datetime import datetime from typing import Iterable, Optional from agent.evidence import verify_brief_evidence as _verify_brief_evidence from agent.reliability import reliability_for # --------------------------------------------------------------------------- # Heuristics # --------------------------------------------------------------------------- _RISK_KEYWORDS = re.compile( r"\b(risk|risks|exposure|exposures|uncertainty|uncertainties|" r"subject to|could adversely|may adversely|materially harm|" r"litigation|cybersecurity|breach|tariff|sanction)\b", re.IGNORECASE, ) _STOPWORDS = { "the", "and", "for", "with", "from", "that", "this", "have", "been", "their", "into", "more", "than", "what", "when", "where", "which", "while", "would", "could", "should", "about", "after", "before", "these", "those", "such", "also", "then", "thus", "well", "very", } _MAX_AUTO_NOTES = 3 _JACCARD_THRESHOLD = 0.3 _MIN_TOKENS = 4 def _tokenize(snippet: str) -> set[str]: """Lower-case, alpha-only tokens longer than 3 chars, stopwords removed.""" if not snippet: return set() return { w for w in re.findall(r"[a-zA-Z]+", snippet.lower()) if len(w) > 3 and w not in _STOPWORDS } def _jaccard(a: set[str], b: set[str]) -> float: if not a or not b: return 0.0 return len(a & b) / len(a | b) def _is_risk_factors(fact: dict) -> bool: """True if the fact text or evidence_snippet looks like Risk Factors content.""" if fact.get("source") not in ("10-K", "10-Q"): return False haystack = (fact.get("evidence_snippet", "") or "") + " " + (fact.get("text", "") or "") return bool(_RISK_KEYWORDS.search(haystack)) def _news_age_days(fact: dict, brief_filing_date: Optional[str]) -> Optional[int]: """Approximate age of a news fact in days using the brief's filing date as proxy.""" if fact.get("source") != "news" or not brief_filing_date: return None try: ref = datetime.strptime(brief_filing_date[:10], "%Y-%m-%d") return max(0, (datetime.now() - ref).days) except ValueError: return None # --------------------------------------------------------------------------- # Fact collection # --------------------------------------------------------------------------- def _collect_facts(brief: dict) -> list[tuple[str, dict]]: """Return (label, fact_dict) pairs for every reliability-bearing object in the brief. Label is human-readable for evidence_notes ("Bull #2", "Risk: Regulatory", etc.). """ facts: list[tuple[str, dict]] = [] sn = brief.get("standout_number") if isinstance(sn, dict): facts.append(("Standout number", sn)) for i, f in enumerate(brief.get("what_changed", []) or [], 1): if isinstance(f, dict): facts.append((f"What changed #{i}", f)) for i, f in enumerate(brief.get("bull_points", []) or [], 1): if isinstance(f, dict): facts.append((f"Bull #{i}", f)) for i, f in enumerate(brief.get("bear_points", []) or [], 1): if isinstance(f, dict): facts.append((f"Bear #{i}", f)) for i, r in enumerate(brief.get("risks_categorized", []) or [], 1): if isinstance(r, dict): facts.append((f"Risk: {r.get('category', '?')}", r)) for i, t in enumerate(brief.get("management_commentary", []) or [], 1): if isinstance(t, dict): facts.append((f"Mgmt: {t.get('topic', '?')}", t)) for i, guidance in enumerate(brief.get("guidance_history", []) or [], 1): if isinstance(guidance, dict): facts.append((f"Guidance #{i}", guidance)) for i, tension in enumerate(brief.get("analytical_tensions", []) or [], 1): if not isinstance(tension, dict): continue for side, key in (("bull", "bullish_evidence"), ("bear", "bearish_evidence")): evidence = tension.get(key) if isinstance(evidence, dict): facts.append((f"Tension #{i} {side}", evidence)) for i, signal in enumerate(brief.get("earnings_quality_signals", []) or [], 1): evidence = signal.get("evidence") if isinstance(signal, dict) else None if isinstance(evidence, dict): facts.append((f"Quality signal #{i}", evidence)) for i, subtext in enumerate(brief.get("between_the_lines", []) or [], 1): evidence = subtext.get("evidence") if isinstance(subtext, dict) else None if isinstance(evidence, dict): facts.append((f"Between the lines #{i}", evidence)) mda = brief.get("mda_summary") or {} if isinstance(mda, dict): for i, f in enumerate(mda.get("drivers", []) or [], 1): if isinstance(f, dict): facts.append((f"MD&A driver #{i}", f)) for i, f in enumerate(mda.get("headwinds", []) or [], 1): if isinstance(f, dict): facts.append((f"MD&A headwind #{i}", f)) kq = mda.get("key_quote") if isinstance(kq, dict): facts.append(("MD&A key quote", kq)) return facts # --------------------------------------------------------------------------- # Corroboration # --------------------------------------------------------------------------- def _build_corroboration_index(facts: list[tuple[str, dict]]) -> dict[int, list[tuple[str, str]]]: """For each fact (by id), return list of (other_label, other_source) that corroborate it. Two facts corroborate each other when they come from DIFFERENT sources AND their evidence_snippets share a Jaccard score >= _JACCARD_THRESHOLD on tokens. """ tokens = [ ( label, fact, _tokenize(fact.get("evidence_snippet", "")) if fact.get("verification_status") in (None, "VERIFIED") else set(), ) for label, fact in facts ] index: dict[int, list[tuple[str, str]]] = {} for i, (label_i, fact_i, toks_i) in enumerate(tokens): if len(toks_i) < _MIN_TOKENS: index[id(fact_i)] = [] continue matches: list[tuple[str, str]] = [] for j, (label_j, fact_j, toks_j) in enumerate(tokens): if i == j: continue if fact_i.get("source") == fact_j.get("source"): continue if len(toks_j) < _MIN_TOKENS: continue if _jaccard(toks_i, toks_j) >= _JACCARD_THRESHOLD: matches.append((label_j, str(fact_j.get("source", "")))) index[id(fact_i)] = matches return index # --------------------------------------------------------------------------- # Public entrypoint # --------------------------------------------------------------------------- def verify_evidence(brief: dict, evidence_payloads) -> dict: """Verify brief facts against retrieved evidence.v1 tool payloads.""" return _verify_brief_evidence(brief, evidence_payloads) def apply_reliability(brief: dict, evidence_payloads=None) -> dict: """Recompute reliability for every fact-like object in the brief, in place. Also appends up to _MAX_AUTO_NOTES auto-generated entries to brief['evidence_notes'] documenting cross-source corroborations and lone-source weaknesses. Returns the same brief dict (mutated) for convenience. """ if not isinstance(brief, dict): return brief if evidence_payloads is not None: _verify_brief_evidence(brief, evidence_payloads) facts = _collect_facts(brief) if not facts: return brief corro = _build_corroboration_index(facts) filing_date = brief.get("filing_date") auto_notes: list[str] = [] seen_note_keys: set[str] = set() for label, fact in facts: source = fact.get("source", "") section = "Risk Factors" if label.startswith("Risk:") else None age_days = _news_age_days(fact, filing_date) corroborated = bool(corro.get(id(fact))) new_reliability = reliability_for( source=source, section=section, age_days=age_days, corroborated=corroborated, verification_status=fact.get("verification_status"), ) old_reliability = fact.get("reliability") fact["reliability"] = new_reliability if len(auto_notes) >= _MAX_AUTO_NOTES: continue if fact.get("verification_status") in ("UNVERIFIED", "FAILED"): key = f"verification:{label}" if key not in seen_note_keys: reason = fact.get("verification_reason") or "evidence not verified" auto_notes.append(f"{label}: {reason} -> reliability held at LOW.") seen_note_keys.add(key) continue # Note 1: meaningful uplift (transcript or stale-news boost) if corroborated and source in ("transcript",) and new_reliability == "HIGH": other_sources = sorted({src for _, src in corro[id(fact)] if src}) key = f"uplift:{label}" if other_sources and key not in seen_note_keys: auto_notes.append( f"{label} ({source}) corroborated by {' + '.join(other_sources)} → reliability uplift to HIGH." ) seen_note_keys.add(key) continue # Note 2: lone news fact (not corroborated) if source == "news" and not corroborated and new_reliability == "LOW": key = f"lone_news:{label}" if key not in seen_note_keys: auto_notes.append( f"{label} sourced only from news; no filing/transcript cross-confirmation → LOW reliability." ) seen_note_keys.add(key) continue # Note 3: Risk Factors downgrade (filing → MEDIUM) if section == "Risk Factors" and old_reliability == "HIGH" and new_reliability == "MEDIUM": key = f"risk_factor:{label}" if key not in seen_note_keys: auto_notes.append( f"{label} drawn from Risk Factors boilerplate → downgraded to MEDIUM." ) seen_note_keys.add(key) if auto_notes: existing = brief.get("evidence_notes") or [] if not isinstance(existing, list): existing = [] # Keep all existing notes + appended auto notes, capped at 6 total brief["evidence_notes"] = (existing + auto_notes)[:6] _prune_tension_duplicates(brief) return brief def _prune_tension_duplicates(brief: dict) -> None: """Remove analytical_tensions that duplicate existing bull/bear points. A tension is considered a duplicate when its bullish_evidence or bearish_evidence snippet has Jaccard similarity >= _JACCARD_THRESHOLD with any bull or bear point snippet. Forces the LLM to produce synthesis (interplay), not copy-paste. """ tensions = brief.get("analytical_tensions") if not tensions or not isinstance(tensions, list): return reference_snippets: list[set[str]] = [] for lst_key in ("bull_points", "bear_points"): for fact in (brief.get(lst_key) or []): if isinstance(fact, dict): toks = _tokenize(fact.get("evidence_snippet", "")) if len(toks) >= _MIN_TOKENS: reference_snippets.append(toks) if not reference_snippets: return pruned: list[dict] = [] pruned_count = 0 for tension in tensions: if not isinstance(tension, dict): continue bull_ev = tension.get("bullish_evidence") or {} bear_ev = tension.get("bearish_evidence") or {} bull_toks = _tokenize(bull_ev.get("evidence_snippet", "") if isinstance(bull_ev, dict) else "") bear_toks = _tokenize(bear_ev.get("evidence_snippet", "") if isinstance(bear_ev, dict) else "") is_dup = any( _jaccard(toks, ref) >= _JACCARD_THRESHOLD for toks in (bull_toks, bear_toks) if len(toks) >= _MIN_TOKENS for ref in reference_snippets ) if is_dup: pruned_count += 1 else: pruned.append(tension) brief["analytical_tensions"] = pruned if pruned_count: existing_notes = brief.get("evidence_notes") or [] if isinstance(existing_notes, list) and len(existing_notes) < 6: brief["evidence_notes"] = existing_notes + [ f"Pruned {pruned_count} analytical tension(s) that duplicated bull/bear point evidence." ] # --------------------------------------------------------------------------- # Edge signal attach (authoritative computed data — never LLM-generated) # --------------------------------------------------------------------------- def attach_edge_signals(brief: dict, edge_signals: Optional[list[dict]]) -> dict: """Write deterministically-computed edge signals into the brief dict. The LLM produces explanations via the synthesis prompt; this function writes the authoritative computed numbers so they are never absent or fabricated. Called in synthesis_node after apply_reliability(). """ if not isinstance(brief, dict): return brief if not edge_signals: brief.setdefault("quarter_deltas", []) return brief brief["quarter_deltas"] = edge_signals return brief