A Product Research Agent That Reads Ratings Honestly

Product listings come with ratings, review counts, and badges. Most shopping agents misuse all three.

Profile picture of Serply
Serply
Product listings sorted by a confidence-adjusted rating

A 5.0-star product with three reviews is not better than a 4.6 with four thousand, but sort by rating and that’s exactly what you’ll get on top. Shopping agents inherit this bug constantly, because “recommend the highest rated” is the obvious instruction and it’s wrong.

The endpoint

Serply’s product search returns listings from Amazon, using the same path-embedded query convention as the other search endpoints:

import os
import requests
from urllib.parse import quote_plus

API_KEY = os.environ["SERPLY_API_KEY"]


def product_search(query: str) -> list[dict]:
    resp = requests.get(
        f"https://api.serply.io/v1/product/search/q={quote_plus(query)}",
        headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
        timeout=30,
    )
    resp.raise_for_status()
    return resp.json().get("products", [])


products = product_search("espresso machine under 500")

Each product carries title, link, asin, price, rating_stars, review_count, img_url, real_position, extras, and the boolean flags bestseller, prime, and is_sponsor. The response also includes an ads array, a ts timing value, and device_type.

Two field-level gotchas. rating_stars comes back as a string, not a float — it’s a scraped display value. And is_sponsor marks paid placements, which you almost certainly want to exclude from a “best product” recommendation and definitely want to disclose if you don’t.

Parsing defensively

import re


def parse_rating(value) -> float | None:
    if value is None:
        return None
    m = re.search(r"(\d+(?:\.\d+)?)", str(value))
    return float(m.group(1)) if m else None


def parse_price(value) -> float | None:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    m = re.search(r"(\d[\d,]*(?:\.\d{1,2})?)", str(value))
    return float(m.group(1).replace(",", "")) if m else None


def normalize(p: dict) -> dict:
    return {
        "title": p.get("title", ""),
        "link": p.get("link", ""),
        "asin": p.get("asin"),
        "price": parse_price(p.get("price")),
        "rating": parse_rating(p.get("rating_stars")),
        "reviews": p.get("review_count") or 0,
        "prime": bool(p.get("prime")),
        "bestseller": bool(p.get("bestseller")),
        "sponsored": bool(p.get("is_sponsor")),
        "position": p.get("real_position"),
    }

Returning None rather than a default for a missing rating matters downstream. A product with no rating is not a zero-star product, and coercing it to 0.0 will bury perfectly good new listings.

Confidence-adjusted ranking

The standard fix is a Bayesian average: pull each product’s rating toward the category mean, weighted by how many reviews back it up.

def bayesian_rank(items: list[dict], prior_weight: int = 50) -> list[dict]:
    rated = [i for i in items if i["rating"] is not None and i["reviews"] > 0]
    if not rated:
        return items

    total_reviews = sum(i["reviews"] for i in rated)
    prior_mean = sum(i["rating"] * i["reviews"] for i in rated) / total_reviews

    for item in items:
        r, n = item["rating"], item["reviews"]
        if r is None or n == 0:
            item["score"] = None
            continue
        item["score"] = (prior_weight * prior_mean + n * r) / (prior_weight + n)

    return sorted(items, key=lambda i: (i["score"] is None, -(i["score"] or 0)))

With prior_weight=50, a 5.0 backed by three reviews scores around the category mean, while a 4.6 backed by four thousand scores essentially 4.6. That’s the ordering a careful human would produce.

Tune prior_weight to the category. For something with tens of thousands of reviews per listing, 50 is too weak to matter; for a niche product where 20 reviews is a lot, it’s too aggressive.

Filtering before the model

def shortlist(products: list[dict], max_price: float | None = None,
              min_reviews: int = 20, include_sponsored: bool = False) -> list[dict]:
    items = [normalize(p) for p in products]
    out = []
    for i in items:
        if not include_sponsored and i["sponsored"]:
            continue
        if i["reviews"] < min_reviews:
            continue
        if max_price is not None and i["price"] is not None and i["price"] > max_price:
            continue
        out.append(i)
    return bayesian_rank(out)

Note the price filter only applies when a price was actually parsed. Dropping listings with an unparseable price silently removes real options — better to keep them and let the model see price: None than to pretend they don’t exist.

The agent layer

Everything above is deterministic and should stay that way. What the model is genuinely good at is reading the messy parts — titles, extras, and the actual product page:

import json
from anthropic import Anthropic

client = Anthropic()

SYSTEM = """You recommend products from a pre-filtered, pre-ranked shortlist.

Rules:
- The list is already ranked by confidence-adjusted rating. Respect that ordering
  unless you have a specific reason from the title or details to deviate.
- Never recommend a product whose title doesn't match what the user asked for.
  Listings frequently include accessories and replacement parts.
- If a spec the user cares about isn't in the data, say it's unverified rather
  than assuming.
- Report the raw rating and review count, not the internal score.

Return JSON: {"pick": {...}, "runner_up": {...}, "why": str, "caveats": [str]}"""


def recommend(request: str, items: list[dict]) -> dict:
    msg = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1500,
        system=SYSTEM,
        messages=[{"role": "user", "content":
                   f"Request: {request}\n\nShortlist:\n{json.dumps(items[:12], indent=2)}"}],
    )
    return json.loads(msg.content[0].text)

The accessory rule catches the single most common shopping-agent embarrassment: a search for “espresso machine” returns a portafilter basket at position four, and an agent that only reads the ranking recommends it.

Reading the listing page

For a final pick, the listing page has detail the search result doesn’t:

def read_listing(url: str) -> str | None:
    try:
        resp = requests.post(
            "https://api.serply.io/v1/request",
            headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
            json={"url": url, "response_type": "markdown"},
            timeout=60,
        )
        resp.raise_for_status()
        return resp.text[:12000]
    except requests.RequestException:
        return None

Markdown mode returns the text directly in the response body, so .text is correct. Do this for the top two or three candidates only — it’s the expensive call in this pipeline, and the shortlist has already done most of the work.