CoolFace
Apppublic

SoftwareResearch/g2dm-research

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
research_engine.py242 linesDownload Raw Back to root
1"""Claude-powered research engine for product/category fitment.2 3One product -> one Claude conversation that browses the official vendor site4(web_search + web_fetch) and returns a strict JSON verdict, combined with the5deterministic verbatim title/meta extracted by html_extract.py.6"""7import json8import os9import re10import threading11import time12from urllib.parse import urlparse13 14import anthropic15 16import html_extract17import prompts18 19MODEL = "claude-opus-4-8"20 21# web_search attaches citations, which are incompatible with output_config.format,22# so we ask for a trailing JSON block and parse it defensively instead.23TOOLS = [24    {"type": "web_search_20260209", "name": "web_search"},25    {"type": "web_fetch_20260209", "name": "web_fetch"},26]27 28_client = None29_client_lock = threading.Lock()30 31 32def get_client():33    global _client34    with _client_lock:35        if _client is None:36            _client = anthropic.Anthropic()37        return _client38 39 40def _registrable(host: str) -> str:41    """Crude registrable domain (last two labels), ignoring www/locale subdomains."""42    host = (host or "").lower().split(":")[0]43    parts = [p for p in host.split(".") if p]44    return ".".join(parts[-2:]) if len(parts) >= 2 else host45 46 47def _domain_changed(input_url: str, final_url: str) -> bool:48    try:49        a = _registrable(urlparse(html_extract.normalize_url(input_url)).netloc)50        b = _registrable(urlparse(final_url).netloc)51        return bool(a) and bool(b) and a != b52    except Exception:53        return False54 55 56def auth_status() -> dict:57    """Report whether credentials look available, without making a network call."""58    has_key = bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"))59    return {60        "has_credentials": has_key,61        "base_url": os.environ.get("ANTHROPIC_BASE_URL", ""),62    }63 64 65# ---------------------------------------------------------------------------66# JSON parsing helpers67# ---------------------------------------------------------------------------68 69_JSON_FENCE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.S)70 71 72def _extract_json(text: str):73    if not text:74        return None75    m = _JSON_FENCE.search(text)76    candidate = m.group(1) if m else None77    if candidate is None:78        # Fall back to the last balanced-looking object in the text.79        start = text.rfind("{")80        end = text.rfind("}")81        if start != -1 and end != -1 and end > start:82            candidate = text[start:end + 1]83    if not candidate:84        return None85    try:86        return json.loads(candidate)87    except json.JSONDecodeError:88        return None89 90 91def _final_text(message) -> str:92    return "".join(b.text for b in message.content if getattr(b, "type", "") == "text")93 94 95def _run_conversation(messages, max_continuations: int = 6):96    """Run the server-tool agentic loop; return the final assistant text."""97    client = get_client()98    last_message = None99    for _ in range(max_continuations):100        with client.messages.stream(101            model=MODEL,102            max_tokens=8000,103            thinking={"type": "adaptive"},104            output_config={"effort": "high"},105            tools=TOOLS,106            system=prompts.RESEARCH_SYSTEM,107            messages=messages,108        ) as stream:109            last_message = stream.get_final_message()110        if last_message.stop_reason == "pause_turn":111            # Server tool loop paused; resend to continue.112            messages = messages[:1] + [113                {"role": "assistant", "content": last_message.content}114            ]115            continue116        break117    return _final_text(last_message) if last_message else ""118 119 120def _structuring_fallback(raw_text: str):121    """Ask the model to convert prior prose into the JSON object (no tools)."""122    client = get_client()123    try:124        resp = client.messages.create(125            model=MODEL,126            max_tokens=2000,127            messages=[{128                "role": "user",129                "content": (130                    "Convert the following research notes into the required JSON object "131                    "(same keys as specified) and output ONLY a single fenced ```json block.\n\n"132                    + raw_text[-6000:]133                ),134            }],135            system=prompts.RESEARCH_SYSTEM,136        )137        return _extract_json(_final_text(resp))138    except Exception:139        return None140 141 142# ---------------------------------------------------------------------------143# Public entry point144# ---------------------------------------------------------------------------145 146def research_product(*, category, special_considerations, market,147                     product_name, vendor, url, prevalidated_status="", prior_status=""):148    """Research a single product. Returns a dict of input + output columns."""149    base = {150        "vendor": vendor,151        "product_name": product_name,152        "url": url,153        "prevalidated_status": prevalidated_status,154        "prior_status": prior_status,155        "reviews_us_only": "Yes" if market.strip().upper() == "US" else "No",156        "pricing_in_usd": "",157        "website_in_english": "",158        "market_fit": "",159        "recommended": "",160        "reason": "",161        "source_url": "",162        "standalone_or_suite": "",163        "title_tag": "",164        "meta_tag": "",165        "new_url": "",166        "lifecycle_status": "",167        "approach_a": "",168        "approach_b": "",169        "confidence": None,170        "needs_human_review": False,171        "evidence": [],172        "error": "",173    }174 175    # 1) Verbatim title/meta (deterministic).176    tags = html_extract.extract_tags(url)177    base["title_tag"] = tags["title"]178    base["meta_tag"] = tags["meta_description"]179    if tags["final_url"] and _domain_changed(url, tags["final_url"]):180        base["new_url"] = tags["final_url"]181 182    site_unreachable = bool(tags["error"]) and not tags["title"]183 184    # 2) Claude research.185    user_msg = prompts.build_research_user_message(186        category=category,187        special_considerations=special_considerations,188        market=market,189        product_name=product_name,190        vendor=vendor,191        url=tags["final_url"] or url,192        prevalidated_status=prevalidated_status,193        prior_status=prior_status,194        title_tag=tags["title"],195        meta_tag=tags["meta_description"],196    )197    if site_unreachable:198        user_msg += (199            f"\n\nNOTE: a direct fetch of the URL failed ({tags['error']}). Try web_fetch "200            "yourself; if the official site is genuinely unreachable, follow the "201            "site-not-accessible rule."202        )203 204    # Retry transient failures / parse misses so a single flaky product doesn't205    # fail a large batch. Auth/permission errors won't change, so don't retry those.206    data = None207    last_err = ""208    for attempt in range(3):209        try:210            text = _run_conversation([{"role": "user", "content": user_msg}])211            data = _extract_json(text) or _structuring_fallback(text)212            if data is not None:213                break214            last_err = "Could not parse model output"215        except (anthropic.AuthenticationError, anthropic.PermissionDeniedError) as exc:216            last_err = f"{type(exc).__name__}: {exc}"217            break218        except Exception as exc:  # noqa: BLE001 - surface to the UI after retries219            last_err = f"{type(exc).__name__}: {exc}"220        if attempt < 2:221            time.sleep(1.5 * (attempt + 1))222 223    if data is None:224        base["error"] = last_err or "Unknown error"225        base["needs_human_review"] = True226        return base227 228    base["pricing_in_usd"] = data.get("pricing_in_usd", "")229    base["website_in_english"] = data.get("website_in_english", "")230    base["market_fit"] = data.get("market_fit", "")231    base["recommended"] = data.get("recommended", "")232    base["reason"] = data.get("reason", "")233    base["source_url"] = data.get("source_url", "")234    base["standalone_or_suite"] = data.get("standalone_or_suite", "")235    base["lifecycle_status"] = data.get("lifecycle_status", "")236    base["approach_a"] = data.get("approach_a", "")237    base["approach_b"] = data.get("approach_b", "")238    base["confidence"] = data.get("confidence")239    base["needs_human_review"] = bool(data.get("needs_human_review", False))240    base["evidence"] = data.get("evidence", []) or []241    return base242