<?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: Deepak Rode</title>
    <description>The latest articles on DEV Community by Deepak Rode (@deepakrodeai).</description>
    <link>https://dev.to/deepakrodeai</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4215480%2F31b5946c-99e2-4d82-9a37-b4728f5fe10f.png</url>
      <title>DEV Community: Deepak Rode</title>
      <link>https://dev.to/deepakrodeai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/deepakrodeai"/>
    <language>en</language>
    <item>
      <title>How I stopped my research agent from inventing its sources</title>
      <dc:creator>Deepak Rode</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:20:48 +0000</pubDate>
      <link>https://dev.to/deepakrodeai/how-i-stopped-my-research-agent-from-inventing-its-sources-1i8j</link>
      <guid>https://dev.to/deepakrodeai/how-i-stopped-my-research-agent-from-inventing-its-sources-1i8j</guid>
      <description>&lt;p&gt;I build research agents: you give one a topic, and it searches the web, writes a short brief, and puts a link after every claim. The output looks trustworthy. Every sentence has a source.&lt;/p&gt;

&lt;p&gt;Then I started actually opening the links.&lt;/p&gt;

&lt;p&gt;The source often said something slightly different. A different number. A different year. "Experimental" where the brief said "default". Not invented out of thin air, just &lt;em&gt;drifted&lt;/em&gt;: the model had read the page, then filled in a detail from memory while writing.&lt;/p&gt;

&lt;p&gt;Here is the example I now use as a test. A short piece about Python releases, six claims, every one linked to an official python.org page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The text said&lt;/th&gt;
&lt;th&gt;The linked page actually says&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.12 runs about 25% faster than 3.11&lt;/td&gt;
&lt;td&gt;"an estimated 5% overall performance improvement"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;distutils was deprecated since Python 3.8&lt;/td&gt;
&lt;td&gt;"In Python 3.10 and 3.11, distutils will be formally marked as deprecated"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.13 runs without the GIL by default&lt;/td&gt;
&lt;td&gt;"experimental feature and therefore is not enabled by default"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of six claims misstate their own sources. The links are right. The details aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the fact-checker sits
&lt;/h2&gt;

&lt;p&gt;The agent is split into a coordinator and three specialists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Researcher&lt;/strong&gt;: search + fetch. Its only output is a citations file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writer&lt;/strong&gt;: turns the citations into a draft. No web access at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fact-Checker&lt;/strong&gt;: can fetch pages but &lt;strong&gt;can't search&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grader&lt;/strong&gt;: scores the brief against a rubric and sends it back for revision, at most 2 rounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Fact-Checker having no search tool is deliberate. If it could search, it would "verify" a claim by finding &lt;em&gt;some&lt;/em&gt; page that agrees. Without search, it can only check what was actually cited.&lt;/p&gt;

&lt;p&gt;The checker reads each linked page and marks every claim &lt;strong&gt;verified&lt;/strong&gt;, &lt;strong&gt;partial&lt;/strong&gt; or &lt;strong&gt;unverified&lt;/strong&gt;, and it has to copy the supporting sentence from the page as evidence.&lt;/p&gt;

&lt;p&gt;That sounded like enough. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the quote can be invented too
&lt;/h2&gt;

&lt;p&gt;After I shared the first version, someone asked the obvious question: what stops the model from inventing a plausible quote? My script only checked that a quote &lt;em&gt;existed&lt;/em&gt; in the report, not that it was really on the page.&lt;/p&gt;

&lt;p&gt;He was right. Asking a model to "quote its source" isn't a safeguard; it's another thing the model can get wrong.&lt;/p&gt;

&lt;p&gt;So in v1.1 the report script downloads every cited page itself and checks the quote against it. The matching ignores case, spacing and punctuation (curly quotes, dashes, line breaks), but every word and number has to match:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Lower-case and drop spaces and punctuation, so formatting differences
    don&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t matter, but every word and number still has to match.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?&amp;lt;=\d)\.(?=\d)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOTX&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# keep 3.12 and 31.2 different
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^\w%$]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DOTX&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;quote_on_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True only if every part of the quote is really on the page.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;squash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&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;quote&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a quote isn't on the page, the report refuses to build. If a page can't be downloaded (paywall, JavaScript-only page, blocked site), the claim is marked &lt;strong&gt;not machine-checked&lt;/strong&gt; instead of quietly passing.&lt;/p&gt;

&lt;p&gt;Testing this on my own example immediately caught a bug of mine: python.org sends its pages gzip-compressed, and my first version couldn't read them. So every quote "failed". A check that has never failed on real data hasn't been tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: a real quote with the wrong number
&lt;/h2&gt;

&lt;p&gt;The same person found the next gap. The quote can be genuinely on the page and still not support the claim. The most common way: the number is different. A real quote saying "5%" attached to a claim of "25%".&lt;/p&gt;

&lt;p&gt;So v1.2 adds a numbers check: every figure in a "verified" claim must also appear in its quote.&lt;/p&gt;

&lt;p&gt;It took some care to make that useful rather than noisy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commas are ignored (Indian-format 1,29,940 = 129,940)&lt;/li&gt;
&lt;li&gt;rounding is allowed to the claim's precision (claim 2.2 matches quote 2.17)&lt;/li&gt;
&lt;li&gt;small whole numbers are skipped ("3 companies", "2 October" caused false alarms)&lt;/li&gt;
&lt;li&gt;labels like Q1 or FY27 are skipped for now, for the same reason&lt;/li&gt;
&lt;li&gt;"525k" counts as 525,000.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Testing it on a real market brief taught me two more things. Many quotes came from &lt;strong&gt;company results PDFs&lt;/strong&gt;, so the checker now reads PDFs. And one PDF had two quoted sentences in the opposite order from how the model quoted them (PDF layout scrambles order), so quote fragments are now accepted in any order.&lt;/p&gt;

&lt;p&gt;The report now shows these as separate lines: "quote found on page" and "figures in quote". Matching figures can stop a claim from being verified, but they can never make it verified on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still can't catch
&lt;/h2&gt;

&lt;p&gt;When I posted this on Reddit, three different people pointed at the same remaining gap: &lt;strong&gt;the quote is real, the number matches, but it's about something else.&lt;/strong&gt; Last year's figure sitting next to this year's. The right percentage, but for a different population.&lt;/p&gt;

&lt;p&gt;Some of that is covered: a claim of "25% in 2025" fails against a quote saying "25% in 2024", because 2025 isn't in the quote. But "FY27" vs "FY26" passes (labels are skipped), and "25% of adults" vs a quote about teenagers passes too. Whether the quote supports the &lt;em&gt;exact&lt;/em&gt; sentence is still the model's judgement, and the report says so openly.&lt;/p&gt;

&lt;p&gt;One commenter shared a useful data point: a small model he measured scored about 80% on an open-ended review that reconciled stale or conflicting evidence, against 98–100% on narrow decisions with a fixed set of answers. That suggests the next step: don't ask one open question ("does this quote support the claim?"). Split it into narrow yes/no checks (same period? same metric? same population?) and let a "no" on any of them block "verified".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"The model must cite a quote" is not a safeguard until code checks the quote.&lt;/strong&gt; Most of the reliability came from what the scripts refuse, not from the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each agent only the tools its job needs.&lt;/strong&gt; The fact-checker without search was the single biggest improvement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say what isn't checked.&lt;/strong&gt; "Not machine-checked" and "this part is the model's judgement" are more useful to a reader than a confident green tick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on real documents, not just unit tests.&lt;/strong&gt; Every real brief found a new failure: gzip, PDFs, reordered quotes.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;I packaged the fact-checking part as a free Claude skill called &lt;strong&gt;claim-checker&lt;/strong&gt;: give Claude any text with links (an AI-written report, a blog draft, a client deliverable) and it returns a claim-by-claim report with the evidence. The ideas above work in any framework; the skill is just one way to use them.&lt;/p&gt;

&lt;p&gt;It's MIT licensed, with tests and the Python example above:&lt;br&gt;
&lt;a href="https://github.com/deeprdai21-ai/practical-claude-skills" rel="noopener noreferrer"&gt;https://github.com/deeprdai21-ai/practical-claude-skills&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd like to hear how others handle this in their own research agents. Do you verify quotes in code, use a second model as a checker, or something else?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
      <category>llm</category>
    </item>
  </channel>
</rss>
