<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Benjian Dai</title>
    <description>The latest articles on DEV Community by Benjian Dai (@benjiandai).</description>
    <link>https://dev.to/benjiandai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3893305%2Fceda5657-6b10-49d6-94b2-4e2a9f99c09e.jpeg</url>
      <title>DEV Community: Benjian Dai</title>
      <link>https://dev.to/benjiandai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/benjiandai"/>
    <language>en</language>
    <item>
      <title>I shipped an AI pipeline in a month that reads Reddit, HN, and X for startup ideas. The hardest part wasn't the AI.</title>
      <dc:creator>Benjian Dai</dc:creator>
      <pubDate>Tue, 28 Apr 2026 13:30:23 +0000</pubDate>
      <link>https://dev.to/benjiandai/i-shipped-an-ai-pipeline-in-a-month-that-reads-reddit-hn-and-x-for-startup-ideas-the-hardest-2fkb</link>
      <guid>https://dev.to/benjiandai/i-shipped-an-ai-pipeline-in-a-month-that-reads-reddit-hn-and-x-for-startup-ideas-the-hardest-2fkb</guid>
      <description>&lt;p&gt;For the last month I've been building MonetScope — a pipeline that crawls Reddit, Hacker News, and X, reads what real people are complaining about, and surfaces the complaints as scored startup opportunities.&lt;/p&gt;

&lt;p&gt;Going in, I assumed the hard part would be the AI layer. You know the story: prompt engineering, structured output, temperature tuning. That's where the demos happen and where most of the blog posts get written.&lt;/p&gt;

&lt;p&gt;It wasn't. The LLM layer landed roughly on schedule. What took real engineering time were four other things — each one taught me something I'll carry into the next pipeline I build. Plus one deeply dumb cross-language serialization bug that nearly corrupted my data for a week without me noticing.&lt;/p&gt;

&lt;p&gt;This is a write-up of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, very roughly
&lt;/h2&gt;

&lt;p&gt;Before we dig in, the mental model. I'm keeping this deliberately abstract because the interesting part is the categories, not my specific libraries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   [Reddit]    [Hacker News]    [X]
       \            |            /
        \           |           /
         +---&amp;gt; crawler layer &amp;lt;---+
                    |
             message queue
                    |
          deterministic filters
                    |
           multi-stage LLM layer
                    |
            grounding + storage
                    |
                 product UI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two runtimes: one for crawlers (good at "get data out of places"), one for orchestration plus API (good at "stay up under load"). A message queue between them. Boring, intentionally.&lt;/p&gt;

&lt;p&gt;What's on that diagram today is three public platforms. What's on my whiteboard is more — additional communities, niche forums, and eventually a user-submitted channel where a founder can drop in their own support tickets, a competitor's review stream, or a CSV dump from a private Slack. Each new source is just another input box on the diagram, which is the whole point of having the diagram at this level of abstraction. The cost of adding source N+1 is not in the pipeline; it's in the per-platform quality heuristics, and that's a problem I enjoy having.&lt;/p&gt;

&lt;p&gt;Now — the four things that took more time than the AI. Starting with the most embarrassing.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The scraping tool was wrong for two of three platforms
&lt;/h2&gt;

&lt;p&gt;Every scraping tutorial you'll find online opens with the same heavyweight toolkit — headless browser, stealth plugins, proxy rotation, the works. I started there too.&lt;/p&gt;

&lt;p&gt;This turned out to be wrong for one platform, unnecessary for another, and actively dumb to avoid on the third.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Platform A:&lt;/strong&gt; I had a headless browser dutifully rendering pages for three weeks before I realized the same data was available through a much thinner path that didn't require rendering a single pixel. When I rewrote that spider it went from "needs a beefy worker" to "runs on a potato." I want those three weeks back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform B:&lt;/strong&gt; There was a developer-focused API available the entire time. Not just scrape-able, officially supported. I was reinventing its existence in the browser layer. Pure hubris — I had assumed "if the tutorial uses a browser for it, that must be the right tool."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform C:&lt;/strong&gt; No shortcut. Actively hostile to scraping, continuous cat-and-mouse, and my time is worth more than the subscription. Paid for access. Never looked back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generalizable lesson: &lt;strong&gt;don't start with a tool and hunt for problems to solve with it. Start with how the source actually serves data to its own frontend.&lt;/strong&gt; If it serves structured data, there's usually a path that isn't a browser. If it only renders via JS, that's your answer. If it's hostile, pay or skip.&lt;/p&gt;

&lt;p&gt;The heavyweight-browser default isn't wrong — it's a good fallback when nothing else works. The failure mode is treating it as the starting point.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The cheapest filter is the one that runs before the LLM
&lt;/h2&gt;

&lt;p&gt;Naive version of the pipeline: crawled post arrives → LLM processes it → structured output goes to the database.&lt;/p&gt;

&lt;p&gt;Two problems at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's expensive.&lt;/strong&gt; Every post is tokens, and token cost on a content-scale pipeline dominates the bill within a week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The output is worse.&lt;/strong&gt; "Why is no one building X?" posts waste tokens producing confident-sounding opportunity cards that don't survive human review. A model asked to extract an opportunity from a substance-free rant will dutifully hallucinate one.&lt;/p&gt;

&lt;p&gt;The fix is philosophically simple: &lt;strong&gt;put a deterministic filter in front of the LLM that rejects content the LLM would have rejected anyway, but for free.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What "deterministic" means varies per platform — what counts as a substantive post in one community is a throwaway in another. The thresholds are per-platform, and they drift as community norms change. I'm not going to publish the current values; they're part of the product. But the interesting thing about tuning them isn't the numbers. It's that I ended up building a small tuning harness that was more work than picking any individual threshold.&lt;/p&gt;

&lt;p&gt;Ordering matters too. The deterministic filter runs &lt;em&gt;before&lt;/em&gt; the stateful dedup layer, not after. Posts rejected today can be reconsidered tomorrow if they accumulate engagement — which they sometimes do, especially on HN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generalizable rule:&lt;/strong&gt; before every LLM call, ask "can I cheaply reject this input first?" The answer is usually yes, and the win compounds: less cost per document, better signal on the documents that make it through, fewer false positives to clean up downstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Shipping an "AI-grounded" feature without lying to users
&lt;/h2&gt;

&lt;p&gt;This is the section I most want a reader to take away, so I'll be careful about the level I pitch it at.&lt;/p&gt;

&lt;p&gt;The product makes a specific promise: every claim it generates is backed by a quote from an identifiable, real user. You see "users complain that X breaks every Tuesday" and you can click through to the exact comment where someone said exactly that.&lt;/p&gt;

&lt;p&gt;If that promise leaks — if even a small percentage of the quotes are paraphrased, massaged, or invented — the product has no reason to exist. "We summarize Reddit with AI" is a commodity. "We show you the literal thing the person said" is not.&lt;/p&gt;

&lt;p&gt;LLMs in their default configuration &lt;em&gt;will&lt;/em&gt; break that promise. Paraphrasing is what they're good at. Making up a plausible-sounding quote is easier for them than surfacing the specific boring one that matters. This isn't a flaw of any particular model; it's a property of optimized-for-fluency generation.&lt;/p&gt;

&lt;p&gt;The approach I landed on, at the pattern level:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't ask the LLM to find sources.&lt;/strong&gt; Do extraction of candidate source material deterministically, &lt;em&gt;before&lt;/em&gt; the generation step. The LLM sees a curated candidate pool, not the raw corpus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain generation to operate on those candidates.&lt;/strong&gt; The LLM's job is to synthesize and structure. It is not the layer that decides what's citable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mechanically verify every output claim against a specific source record.&lt;/strong&gt; If it doesn't match a real record, it doesn't ship. This is the step most pipelines skip, and it's the one that determines whether users still trust the output six months in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed.&lt;/strong&gt; If verification can't find the source for a generated claim, drop the claim. If dropping claims leaves the output empty, drop the whole output. Empty is fine. Phantom is not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I won't walk through the matching algorithm, what the candidate pool looks like in this codebase, or how I decide "drop claim vs drop whole output" — those are product-specific tuning and they're where the moat actually lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern itself is freely available, and I wish I'd seen it articulated when I started:&lt;/strong&gt; for any LLM feature where hallucination is a &lt;em&gt;correctness&lt;/em&gt; bug rather than a style flaw, the pattern is &lt;strong&gt;pre-extract → constrain → verify → fail closed&lt;/strong&gt;. Half-measures ship, but they don't keep trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One score is almost never enough
&lt;/h2&gt;

&lt;p&gt;The hardest non-infrastructure problem turned out to be teaching the pipeline the difference between "people are angry" and "people will pay."&lt;/p&gt;

&lt;p&gt;The naive move is one score per item — how good is this opportunity, 0-10. This doesn't work. It tries to answer two reader questions at once ("is there real pain here?" and "would anyone actually buy a solution?") and those are orthogonal.&lt;/p&gt;

&lt;p&gt;A thread full of "someone should build X" is high on the first and near-zero on the second. A thread where one person has duct-taped three tools together and is actively shopping for a replacement is moderate on the first and very high on the second. A single composite score collapses those into noise.&lt;/p&gt;

&lt;p&gt;The fix isn't clever math. It's the recognition that &lt;strong&gt;any time a single number is answering more than one question, it ends up answering none of them well.&lt;/strong&gt; Split the signal into the questions you actually want answered, score those separately, and &lt;em&gt;compose&lt;/em&gt; them deliberately — not average them. A lot of weak signal does not beat a little strong signal, even when the means come out similar.&lt;/p&gt;

&lt;p&gt;One non-obvious finding I'll share because it's directional-only: the "obvious" communities (the big generalist ones) produce noisier signal than niche ones. Volume is a weak proxy for signal strength. Source &lt;em&gt;diversity&lt;/em&gt; turned out to matter as much as source &lt;em&gt;volume&lt;/em&gt; — an opportunity drawn from three different niche communities beats one drawn from thirty posts in one big general community, even though the raw evidence count is an order of magnitude lower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dev-applicable version:&lt;/strong&gt; when a ranking algorithm isn't working, check whether you're averaging two signals that are answering different questions. You'll be surprised how often the answer is yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dumb bug that almost invalidated everything
&lt;/h2&gt;

&lt;p&gt;This one isn't a product moat — it's a general cross-language gotcha — so I'll share it in detail because the lesson is broadly useful to anyone building a polyglot system.&lt;/p&gt;

&lt;p&gt;Two services in two languages, both writing to the same database column. The column stores vectors as text, like &lt;code&gt;[0.123,0.456,...]&lt;/code&gt;. The database happily accepts whatever either language produces.&lt;/p&gt;

&lt;p&gt;The trap: each language's default float-to-string produces &lt;em&gt;slightly&lt;/em&gt; different output. Different widths. Different rounding at the edge case. To the human eye they look identical. To byte-wise comparison they aren't. To cosine similarity on the resulting vector, they're close but not the same vector.&lt;/p&gt;

&lt;p&gt;Nothing broke. No exception. No type error. No failing test. What happened was that semantic similarity rankings drifted depending on which service had last written the record. Results were good, then mysteriously slightly bad, then good again. I chased this for most of a week before I realized what I was looking at.&lt;/p&gt;

&lt;p&gt;The fix, in pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;One&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt; &lt;span class="n"&gt;formatter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;definition&lt;/span&gt; &lt;span class="n"&gt;across&lt;/span&gt; &lt;span class="n"&gt;both&lt;/span&gt; &lt;span class="n"&gt;runtimes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;Verified&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;golden&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;language&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;span class="nf"&gt;format_vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;floats&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;to_string_exact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;to_string_exact&lt;/code&gt; is explicitly pinned to the widest-precision, culture-invariant format available in each language — not whatever the default &lt;code&gt;toString()&lt;/code&gt; happens to do. And the test is a literal string equality check against a hand-written golden output, run from both sides.&lt;/p&gt;

&lt;p&gt;The broader lesson: &lt;strong&gt;"both sides are using the default" is a dangerous sentence in a polyglot system.&lt;/strong&gt; Default serialization isn't a contract. If two runtimes are going to share a serialized format, write the format exactly once as a pure function, and verify its output byte-for-byte from both languages. Repeat for every format that crosses the runtime boundary — JSON casing was the other one that bit me, in passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Nothing kills a launch post faster than "everything went great." So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I picked the wrong primary data store early, for the wrong reason — "flexibility." Future me didn't want flexibility. Future me wanted fewer moving parts. Moved to a boring relational DB with a JSON column type and things got better immediately. Lost about three weeks.&lt;/li&gt;
&lt;li&gt;I wrote my own rate-limit layer before realizing a standard caching-server primitive plus ten lines of script would have done the same job in an afternoon. Lost a week on that one.&lt;/li&gt;
&lt;li&gt;I underestimated observability. The liveness vs. readiness healthcheck split only happened after the third production incident. It should have been there on day one — you don't need it until you need it, and then you need it immediately.&lt;/li&gt;
&lt;li&gt;The grounding / verification layer shipped too late. Weeks of early data had to be re-processed once I added it. It should have been part of the first LLM call, not the twelfth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If there's a theme: &lt;strong&gt;my worst decisions were the ones where I picked the more flexible option so future-me would have more options.&lt;/strong&gt; Future-me didn't want options. Future-me wanted something that worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;That's four things that turned out harder than the AI, plus the serialization bug for flavor.&lt;/p&gt;

&lt;p&gt;The product all this plumbing is in service of is at &lt;a href="https://monetscope.com" rel="noopener noreferrer"&gt;monetscope.com&lt;/a&gt; — free 14-day trial, no card. If you'd rather see what it produces before committing, this week's top 10 opportunities are at &lt;a href="//monetscope.com/this-week"&gt;monetscope.com/this-week&lt;/a&gt; (just email, no card). The output is what the pipeline exists for, but to be honest the feedback I most want right now is on the engineering choices above, not the landing page.&lt;/p&gt;

&lt;p&gt;If you've shipped an LLM-scored content pipeline yourself, I'd genuinely like to hear: &lt;strong&gt;how do you version and regression-test your prompts?&lt;/strong&gt; That's the layer I feel weakest on, and I haven't found a tool I love. Current setup is git commit plus a hand-maintained regression set, and it's starting to creak as the prompt surface grows.&lt;/p&gt;

&lt;p&gt;Also: if you maintain a community, newsletter, or data source you'd be interested in seeing indexed — that's on my roadmap, and I'm actively looking for source-expansion partnerships. DM me.&lt;/p&gt;

&lt;p&gt;Thanks for reading this far.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
  </channel>
</rss>
