| """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 |
|
|
| |
| |
| |
|
|
| _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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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 = [] |
| |
| 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." |
| ] |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|