Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
"""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)