"""Risk analysis orchestration: gather evidence, then decide.""" from __future__ import annotations import logging from ..config import Config from ..http import HttpClient from ..models import Evidence, Item, Verdict, VersionCheck from . import collectors, heuristics from .llm import ClaudeVerdictEngine log = logging.getLogger(__name__) def _search_terms(item: Item) -> list[str]: """Words that must appear for a post to count as being about this item. Drawn from the product name so relevance filtering has something to bite on.""" terms: list[str] = [] for chunk in item.name.replace("/", " ").replace("|", " ").split(): cleaned = chunk.strip("(),.").strip() if len(cleaned) > 2: terms.append(cleaned) if item.vendor and item.vendor.lower() != "unknown": terms.append(item.vendor.split()[0]) return terms[:6] def _queries(item: Item, version: str | None) -> list[str]: """Build the community search queries for one item. Explicit ``risk_queries`` in the inventory always win -- they exist so you can pin down gear whose product name is too generic to search (a "Wing Rack" or a "Quantum 225" on its own returns nothing useful). """ if item.risk_queries: base = list(item.risk_queries) else: base = [f"{item.vendor} {item.name}"] out: list[str] = [] for b in base: b = b.strip() if not b: continue if version: out.append(f"{b} {version} problem OR bug OR issue") out.append(f"{b} firmware update problem OR bricked OR rollback") return out def gather_evidence( item: Item, check: VersionCheck, cfg: Config, http: HttpClient, reddit: collectors.RedditClient | None, ) -> list[Evidence]: """Collect and rank community evidence for one item.""" if not cfg.risk.enabled: return [] version = check.latest found: list[Evidence] = [] queries = _queries(item, version)[: cfg.risk.max_queries_per_item] # GitHub issues first: cheapest and highest signal where it applies. if cfg.risk.use_github_issues and item.github_repo: found += collectors.github_issues( http, item.github_repo, version, limit=cfg.risk.max_evidence_per_item, lookback_days=cfg.risk.lookback_days, ) for q in queries: if cfg.risk.use_reddit and reddit is not None and reddit.configured: found += reddit.search( q, cfg.risk.subreddits, limit=cfg.risk.max_evidence_per_item, lookback_days=cfg.risk.lookback_days, ) if cfg.risk.use_duckduckgo: found += collectors.duckduckgo(http, f"site:reddit.com {q}", limit=5) found += collectors.duckduckgo(http, q, limit=5) if cfg.risk.use_hackernews: found += collectors.hackernews(http, q, limit=4) return collectors.rank_and_filter( found, item_terms=_search_terms(item), version=version, limit=cfg.risk.max_evidence_per_item, ) def decide( item: Item, check: VersionCheck, comparison: str, bump: str, evidence: list[Evidence], cfg: Config, engine: ClaudeVerdictEngine | None, first_seen_age_days: float | None = None, use_llm: bool = True, ) -> Verdict: """Heuristic verdict always; Claude verdict layered on top when enabled. Both scores are kept on the returned Verdict so the report can show a disagreement between the two rather than quietly picking one. """ base = heuristics.score( item, check, comparison, bump, evidence, cfg.policy, first_seen_age_days ) # No point spending a token on items with nothing to decide. if not use_llm or engine is None or not engine.available: return base if comparison != "behind" or item.freeze: return base return engine.verdict(item, check, comparison, bump, evidence, base)