"""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": "", "released": "", "source_url": "", "confidence": "high" | "medium" | "low", "notes": "" } 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), )