Files
AV-Watch/avwatch/sources/websearch.py
T

261 lines
9.6 KiB
Python
Raw Normal View History

"""Claude-with-web-search version lookup.
Why this exists: almost every AV vendor's support site is now a
JavaScript-rendered single-page app. Verified on 2026-09-08 -- Shure,
Resolume, Focusrite, Teradek, Line 6, Novastar and Rational Acoustics all
return HTTP 200 with essentially no version text in the HTML. A regex
scraper cannot read them, and a headless browser for 70 vendors is a
maintenance sink.
So for that long tail, Claude does the lookup with the server-side web search
tool and reports the version plus the page it came from. This is a factual
retrieval job rather than a judgement call, so it runs on a cheaper model than
the verdict layer by default.
Guardrails, because a confidently wrong version number is worse than none:
* The model must return a source URL, and must say "unknown" when unsure.
* Answers are cached in SQLite (default 6 days), so the Monday digest pays
for the lookup and mid-week scans reuse it.
* The result is labelled in the report as web-sourced, not vendor-verified.
Inventory usage:
source:
kind: websearch
query: "Shure Axient Digital AD4Q receiver firmware"
hint: "Firmware is distributed through Shure Update Utility."
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from ..models import Item, VersionCheck
from ..versions import parse
from .base import Context, error, manual, unconfigured
log = logging.getLogger(__name__)
SYSTEM = """\
You look up the current released firmware or software version for professional \
audio/video production equipment, using web search.
Return ONLY a JSON object, no prose around it:
{
"version": "<the version string exactly as the vendor writes it, or null>",
"released": "<YYYY-MM-DD if you can establish it, else null>",
"source_url": "<the page you took it from, or null>",
"confidence": "high" | "medium" | "low",
"notes": "<one short sentence: caveats, or what a human should verify>"
}
Rules that matter more than being helpful:
* Report the current GENERAL RELEASE version. Not a beta, not a release \
candidate, not a rumour, not a version discussed on a forum as upcoming.
* The vendor's own download or support page is the only authoritative \
source. A forum post or reseller listing is at best "low" confidence.
* If you cannot find a version you are confident is current, set version to \
null and confidence to "low" and say why in notes. That is a correct answer. \
A wrong version number here causes a production team to make a bad decision, \
so never guess or interpolate.
* Many products share one firmware package across a family. If the version \
you find covers a family rather than this exact model, say so in notes.
* Do not report the version of a different product with a similar name.
"""
def _extract_json(text: str) -> dict | None:
"""Tolerant JSON extraction -- the model may wrap the object in prose or a
code fence despite instructions."""
text = text.strip()
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
if fence:
text = fence.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end <= start:
return None
text = text[start:end + 1]
try:
data = json.loads(text)
except ValueError:
return None
return data if isinstance(data, dict) else None
class WebSearchSource:
kind = "websearch"
def check(self, item: Item, ctx: Context) -> VersionCheck:
cfg = ctx.scratch.get("lookup_cfg")
store = ctx.scratch.get("store")
if cfg is None or not getattr(cfg, "enabled", False):
return unconfigured(
item,
"Version lookup is switched off for this run "
"(--no-llm, offline, or lookup.enabled = false).",
)
if not getattr(cfg, "api_key", ""):
return unconfigured(
item,
"Needs ANTHROPIC_API_KEY to look up the current version.",
)
# 1. cache
if store is not None:
cached = store.get_lookup(item.id, cfg.ttl_days)
if cached:
return self._from_cache(item, cached)
# 2. budget for this run
spent = ctx.scratch.get("lookup_calls", 0)
if spent >= cfg.max_items_per_run:
return unconfigured(
item,
f"Lookup budget for this run is used up "
f"(lookup.max_items_per_run = {cfg.max_items_per_run}). "
f"This item will be picked up on the next run.",
)
client = self._client(cfg, ctx)
if client is None:
return unconfigured(
item,
"The 'anthropic' package is not installed; "
"run: pip install anthropic",
)
query = item.source.get("query") or f"{item.vendor} {item.name}"
hint = item.source.get("hint") or ""
prompt = (
f"What is the current released firmware/software version for:\n\n"
f" Product: {item.name}\n"
f" Vendor: {item.vendor}\n"
f" Search: {query}\n"
+ (f" Context: {hint}\n" if hint else "")
+ "\nSearch the web, then answer with the JSON object only."
)
try:
resp = client.messages.create(
model=cfg.model,
max_tokens=1200,
system=[{
"type": "text",
"text": SYSTEM,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": prompt}],
tools=[{
"type": "web_search_20260209",
"name": "web_search",
"max_uses": cfg.max_searches_per_item,
}],
)
ctx.scratch["lookup_calls"] = spent + 1
except Exception as exc: # noqa: BLE001
log.warning("web-search lookup failed for %s: %s", item.id, exc)
return error(item, f"web-search lookup failed: {type(exc).__name__}")
if getattr(resp, "stop_reason", None) == "refusal":
return manual(item, "Web-search lookup was declined; check by hand.")
text = "\n".join(b.text for b in resp.content if b.type == "text")
data = _extract_json(text)
if data is None:
return error(item, "web-search lookup returned no parseable JSON")
version = data.get("version")
version = str(version).strip() if version else None
released = data.get("released")
source_url = data.get("source_url")
confidence = str(data.get("confidence", "low")).lower()
notes = str(data.get("notes", "")).strip() or None
if store is not None:
store.put_lookup(item.id, version, released, source_url,
confidence, notes)
return self._build(item, version, released, source_url, confidence,
notes, cached_age=None)
# ------------------------------------------------------------------
def _client(self, cfg, ctx: Context):
if "lookup_client" in ctx.scratch:
return ctx.scratch["lookup_client"]
try:
import anthropic
except ImportError:
ctx.scratch["lookup_client"] = None
return None
client = anthropic.Anthropic(api_key=cfg.api_key, timeout=180.0)
ctx.scratch["lookup_client"] = client
return client
def _from_cache(self, item: Item, cached: dict) -> VersionCheck:
return self._build(
item,
cached.get("version"),
cached.get("released"),
cached.get("source_url"),
cached.get("confidence"),
cached.get("notes"),
cached_age=cached.get("age_days"),
)
def _build(self, item: Item, version, released, source_url, confidence,
notes, cached_age) -> VersionCheck:
if not version or str(version).lower() in ("null", "none", "unknown"):
reason = notes or "No current version could be established."
return manual(item, f"Web lookup could not confirm a version. {reason}")
ver = parse(str(version))
if not ver.valid:
return manual(
item,
f"Web lookup returned {version!r}, which is not a version "
f"number we can compare. {notes or ''}".strip(),
)
released_dt = None
if released:
try:
released_dt = datetime.fromisoformat(
str(released).replace("Z", "+00:00")
)
if released_dt.tzinfo is None:
released_dt = released_dt.replace(tzinfo=timezone.utc)
except ValueError:
released_dt = None
# Low confidence is not good enough to drive an install decision.
if str(confidence).lower() == "low":
return manual(
item,
f"Web lookup suggests {version} but is not confident. "
f"{notes or ''} Verify on the vendor page before acting.".strip(),
)
bits = [f"web lookup, {confidence} confidence"]
if cached_age is not None:
bits.append(f"cached {cached_age:.1f}d")
if notes:
bits.append(notes)
return VersionCheck(
item_id=item.id,
latest=ver.raw,
released=released_dt,
status="ok",
release_notes=notes,
release_url=source_url or item.check_url,
detail=" | ".join(bits),
)