Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user