DEV Community

slawekluzny
slawekluzny

Posted on • Originally published at 24ad.info

How 24ad.info's AI Classifieds Actually Works Under the Hood

How 24ad.info's AI Classifieds Actually Works Under the Hood

This morning I woke up to a support ticket that read: "Your AI suggested I sell my sofa for £3.50 – is this a bug or are you running a charity?" Fair question. Let's peel back the layers on how 24ad.info's AI-powered classifieds actually functions when you upload that photo of your old iPhone.

The Vision Pipeline: From Pixels to Price

When you drag-and-drop an image onto our post creation form, here's what happens before the form auto-fills:

  1. Image Analysis: We pipe your image through OpenRouter's Gemini 2.5 Flash (not the 2.0 version mentioned in some docs – that's text-only). This gives us:

    • Object identification ("iPhone 12, 64GB, good condition")
    • Context clues (a phone on a desk suggests "for sale", while one in a repair shop suggests "services offered")
    • Visible wear/defects (scratches, cracked screen)
  2. Market Positioning: The AI cross-references the identified item with:

    • Recent similar listings in your country
    • Age-based depreciation curves
    • Local demand signals (search volume for "iPhone 12" in your region)
  3. Price Suggestion: This is where the £3.50 sofa incident occurred. The system:

    • Takes the median price of comparable items
    • Applies a nudge discount to encourage faster sales
    • Caps absurd outliers (hence why you'll never see £0.01 suggestions)
// Simplified price suggestion logic (from server/routers/ai.ts)
const suggestPrice = (item: DetectedItem, country: string) => {
  const comparables = await findSimilarListings(item, country);
  const medianPrice = calculateMedian(comparables);
  return applyPsychologicalPricing(
    medianPrice * 0.85, // Encourages pricing slightly under market
    country
  );
};
Enter fullscreen mode Exit fullscreen mode

The key lesson? Always review the AI's work – it's optimized for quick listing, not perfect pricing.

The Payment Bug That Cost Us Refunds

In our Stripe integration, we hit a textbook "assumption failure":

  1. The Flow:

    • User pays for a "Premium" listing package
    • Stripe Checkout completes
    • Our verifyCheckoutSession marks payment as complete... and stopped there
  2. The Missing Step:

   // Pre-fix logic (simplified)
   const handlePaymentSuccess = async (sessionId) => {
     await markPaymentCompleted(sessionId); 
     // applyPackageToPost() WASN'T CALLED HERE
   };
Enter fullscreen mode Exit fullscreen mode
  1. The Fallout:
    • Users paid for features they didn't receive
    • Our support inbox exploded with "Where's my premium badge?"
    • Manual refunds + package activations took developer time to resolve

The fix shipped in v1.4.4 was embarrassingly simple – a single function call added to the verification flow. Now we have end-to-end tests mocking this exact scenario.

Location Search: One SQL Query to Rule Them All

Unlike platforms that layer full-text search with post-filtering, our location handling is brutally simple:

// Location search (from server/db-search.ts)
const posts = await getPostsNearbyAdvanced(...); // bounding-box + Haversine in JS
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. No Fuzzy Location: You're either searching in "London" or within a radius of a point – no magic "near me" expansion
  2. Single-Pass Query: Avoids the common pitfall of:
    • First find matching text
    • Then filter by location
    • Then re-sort by relevance
  3. Postcode Exactitude: UK postcodes (e.g., "SW1A 1AA") resolve to precision via an exact-match lookup against an indexed varchar(20) postcode column on the cities table

The Deployment Script That Saved Our Translations

Early on, we nuked all German/French translations during a routine deploy. The culprit?

rsync --delete ./dist /var/www/24ad.info/  # DELETED locales/ folder
Enter fullscreen mode Exit fullscreen mode

Now, deploy_prod.sh enforces:

rsync --exclude "locales" ...
Enter fullscreen mode Exit fullscreen mode

And we have a pre-deploy checklist:

  1. Verify enabled-languages.json exists on server
  2. Confirm no --delete flag without --exclude
  3. Test on dev.24ad.info first

Why tRPC's Router Composition Matters

Our API structure avoids the "mega-router" anti-pattern:

// How we compose routers (server/routers/index.ts)
const appRouter = createTRPCRouter({
  admin: adminRouter,       // Dashboard ops
  posts: postsRouter,       // CRUD operations
  ai: aiRouter,             // Vision/translation
  payments: paymentsRouter, // Stripe flows
  search: searchRouter,     // Location-aware
  // ...19 more
});

// Client-side usage remains clean:
const { data } = trpc.posts.getById.useQuery({ id });
Enter fullscreen mode Exit fullscreen mode

This gives us:

  • Clear boundaries (admin routes never mix with public ones)
  • Separate middleware stacks
  • Easier code splitting if needed later

What's Next

The current roadmap includes:

  • AI-generated listing quality scores
  • Automated repricing suggestions for stale posts
  • Bulk image analysis for multi-photo uploads

But for now, the system works – even if it occasionally thinks your designer sofa belongs at a car boot sale.

[Edit: The £3.50 sofa was actually a cushion. The user cropped the image poorly. Lesson learned – we now detect item scale better.]

Top comments (0)