124 lines
3.9 KiB
Python
124 lines
3.9 KiB
Python
"""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)
|