261 lines
9.6 KiB
Python
261 lines
9.6 KiB
Python
"""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),
|
|
)
|