Research a topic with citation checking
A short brief in which every claim links to a source you opened and checked, built by letting a model research, a script check links and quotes, and a person read what matters.
- What this is
- How to research a question with an AI chatbot and confirm that each source it gives is real and says what it claims.
- Why it matters
- You avoid acting on, sharing or quoting a claim whose source does not exist or does not say what the chatbot said it does.
- The short version
- Ask a model with web search for claims, each with a URL and a verbatim quote, and save them to a file.
- Run the script to check each link and quote, then read every flagged claim and the key claims in the source itself.
- Search for the best evidence against your conclusion, check it the same way, and say in the brief what you did not check.
On this page
Produces a short brief in which every claim links to a source that you opened and that says what the brief says it says.
Use it when you need an answer you will act on, share or quote, and it rests on facts from outside your own head.
Not when the stakes are low and a quick skim will do (Check before you trust is the five-minute version), or when the question needs a person's private details, which should not go into a chat (What not to paste).
You need a chat assistant that can search the web, a text editor, and Python 3.9 or later for the script. The script uses the standard library only and installs nothing. Without Python you can do steps 3 and 4 by hand, at about 2 minutes a claim. The steps and times are our suggestion.
Steps
| # | Step | Who | Time | Check |
|---|---|---|---|---|
| 1 | Write the question, the scope (dates, places, depth) and the decision it feeds in question.md. | Person | 5 min | You can name the decision it feeds. If it fails: stop. Without a use, you cannot tell what counts as enough. |
| 2 | Run the research prompt below with web search on. Save the claims in claims.csv. | AI | 5 to 20 min | Every claim has a URL and a verbatim quote. If it fails: delete the claim, or ask again for a source. |
| 3 | Run the script. Its first check is the link: status, final address and whether the site changed. | Script | 1 min | Every claim shows status 200 on the same site. If it fails: the claim is flagged for step 5. |
| 4 | The same run checks the quote: it looks for the quoted words on the page. | Script | 2 min | The quote is found. If it fails: the claim is flagged. PDFs and pages that did not load are marked "read it yourself". |
| 5 | Read every flagged claim, and the three claims the brief depends on most, in the source itself. | Person | 10 to 20 min | Each claim is marked keep, fix or drop, and you confirmed the three key claims by reading the source, not the model's summary. If it fails: drop the claim. |
| 6 | Ask the model for the best evidence against the main conclusion, then put what it returns through steps 3 to 5. | Model, script, person | 5 to 10 min, plus steps 3 to 5 | A strong counter-point that survives checking appears in the brief. If it fails: say in the brief that you looked and found nothing credible. |
| 7 | Write the brief with the template below. | Person | 10 min | Every sentence that holds a number traces to a claim marked keep. If it fails: cut the sentence. |
Prompt for step 2
Paste this into a new chat with web search on:
Research this question: <QUESTION>. Scope: <SCOPE>. I will use the answer to <DECISION>.
Rules:
- Return a numbered list of claims. For each claim give the claim in one sentence, the source URL, and a verbatim quote of one or two sentences from that page that supports it.
- Prefer primary sources (the original study, official statistics, the organisation's own page) over articles about them.
- If you cannot find a source with a quote, write "no source found" for that claim. Do not guess a URL.
- Mark each source as primary, secondary or opinion.
- End with two short lists: "What I could not find" and "Where sources disagree".
Replace the following:
<QUESTION>: the question fromquestion.md.<SCOPE>: dates, places and depth, such as2023 to 2026, UK and US, peer-reviewed or official sources only.<DECISION>: the decision it feeds, such asdecide whether to change our onboarding.
Then put the claims in a file with one row per claim:
id,url,quote
1,<URL_1>,"<QUOTE_1>"
2,<URL_2>,"<QUOTE_2>"
Replace the following:
<URL_1>,<URL_2>and so on: the source address for each claim. Add one row per claim.<QUOTE_1>,<QUOTE_2>and so on: the verbatim quote for that claim, with any inner quotation marks doubled.
Script for steps 3 and 4
Save this as check_claims.py next to claims.csv, and run python3 check_claims.py claims.csv. It sends one request per claim and writes claims_results.csv:
# check_claims.py: link check and quote check for the research workflow.
# Needs Python 3.9 or later and nothing else (standard library only).
# Usage: python3 check_claims.py claims.csv
# claims.csv has three columns with a header row: id,url,quote
# It prints one line per claim and writes claims_results.csv next to claims.csv.
# "CHECK" means a person must read that source (workflow step 5).
import csv
import html
import re
import sys
import urllib.error
import urllib.request
from urllib.parse import urlparse
HEADERS = {"User-Agent": "Mozilla/5.0 (claim checker)"}
def words(text):
"""Lower-case letters and digits only, so quote marks, dashes and spacing never matter."""
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", text)
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text).lower()
return " " + " ".join(re.findall(r"[a-z0-9]+", text)) + " "
def domain(url):
host = urlparse(url).netloc.lower()
return host[4:] if host.startswith("www.") else host
def fetch(url):
request = urllib.request.Request(url, headers=HEADERS)
try:
with urllib.request.urlopen(request, timeout=20) as reply:
return reply.status, reply.geturl(), reply.headers.get("Content-Type", ""), reply.read(3_000_000)
except urllib.error.HTTPError as error:
return error.code, url, "", b""
except Exception as error:
return "ERROR", url, str(error)[:80], b""
def check(url, quote):
status, final, kind, body = fetch(url)
same_site = domain(url) == domain(final)
if status != 200: # the page did not load (error, 403, 404 and so on)
return status, final, same_site, "NOT LOADED: read it yourself", "CHECK"
if "pdf" in kind.lower() or body[:4] == b"%PDF":
return status, final, same_site, "PDF: read it yourself", "CHECK"
page = words(body.decode("utf-8", errors="ignore"))
wanted = words(quote).strip()
if wanted and (" " + wanted + " ") in page:
found = "FOUND"
elif wanted and (" " + " ".join(wanted.split()[:8]) + " ") in page:
found = "PARTIAL"
else:
found = "NOT FOUND"
good = status == 200 and same_site and found == "FOUND"
return status, final, same_site, found, "OK" if good else "CHECK"
def main(path):
with open(path, newline="", encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
out_path = path.rsplit(".", 1)[0] + "_results.csv"
flagged = 0
with open(out_path, "w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["id", "status", "final_url", "same_site", "quote", "result"])
for row in rows:
status, final, same_site, found, result = check(row["url"].strip(), row["quote"])
writer.writerow([row["id"], status, final, same_site, found, result])
print(row["id"], status, "same site" if same_site else "OTHER SITE", found, result)
flagged += result != "OK"
print(f"{len(rows) - flagged} of {len(rows)} claims passed both checks. Results are in {out_path}")
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("Usage: python3 check_claims.py claims.csv")
main(sys.argv[1])
A pass means the page loaded on the same site and the quoted words are on it. It does not mean the page supports your claim, and that is what step 5 is for. Pages behind a paywall or a login, and PDFs, will show as CHECK without being wrong, so read those yourself or drop them.
Prompt for step 6
Paste this into the same chat, with your main conclusion:
Here is a brief and its main conclusion: <CONCLUSION>. Find the strongest evidence that this conclusion is wrong, overstated or out of date. Use the same rules: claim, URL and a verbatim quote for each. If you find nothing credible, say so plainly.
Replace the following:
<CONCLUSION>: the main conclusion in one or two sentences.
Template for step 7
Question: <QUESTION>
Date: <DATE> Decision it feeds: <DECISION>
Answer in three lines:
<ANSWER_LINE_1>
<ANSWER_LINE_2>
<ANSWER_LINE_3>
Claims we checked, each opened, with the quote found:
1. <CLAIM_1> ([<SOURCE_1_NAME>](<SOURCE_1_URL>))
2. <CLAIM_2> ([<SOURCE_2_NAME>](<SOURCE_2_URL>))
Evidence against, and what survived checking: <COUNTER_POINTS>
Read by hand because the script could not check them: <HAND_READ>
What we did not check: <NOT_CHECKED>
Replace the following:
<QUESTION>,<DATE>and<DECISION>: fromquestion.md, with the day you finished.<ANSWER_LINE_1>,<ANSWER_LINE_2>and<ANSWER_LINE_3>: the answer in three lines.<CLAIM_1>,<CLAIM_2>and so on: only claims marked keep. Add one line per claim.<SOURCE_1_NAME>and<SOURCE_1_URL>: the source's name and its address.<COUNTER_POINTS>: the counter-points that survived, ornone found that held up.<HAND_READ>: the sources you read yourself.<NOT_CHECKED>: what you did not verify, such asfigures in the second PDF.
How to tell it worked
- Every claim in the brief has a link you opened, and you can point to the words that support it.
- The brief says what you did not check.
- At least one claim was dropped or fixed along the way. A run in which nothing was ever flagged is a reason to read more closely.
If you used a deep research mode
Deep research modes (an assistant setting that searches for many minutes and writes a long, cited report) produce the same kind of claims, only more of them. Our suggestion: don't check the report top to bottom. Pull out the claims you will act on or repeat, usually five to fifteen, and run steps 3 to 6 on those alone. The report's length and its number of links are not evidence that it is right. If it cites a source you can't open, treat the claim as unchecked. For reading the source itself rather than the summary, see Read the source, use the summary as a map.
Mistakes to watch for
- Asking the same model whether its sources are real. It shares the failure, so this is not a check.
- A real address that does not say what the claim says. The link check passes; the quote check catches much of this, and step 5 catches the rest.
- Secondary drift. A news story misreports a study's number. Go one hop back to the study.
- Old figures. A model may cite a 2023 number when a 2026 one exists, so put the date range in the scope.
- Stopping at the first agreeable answer. Step 6 exists for this.
Why check every source
AI search tools got citation queries wrong more than 60% of the time in a 2025 test (Tow Center, CJR, field study), and a confident tone says nothing about whether a source is right. That is why every claim goes through the same check. For the study and for how much checking a task deserves, see Check before you trust.
Sources
- Tow Center, Columbia Journalism Review (CJR), 2025. Test of eight AI search engines on news citations. Field study. Link.
Changes
- v1.010 Oct 2026first version
- Time to do it
- 40 to 80 minutes, depending on how many claims you read by hand (our estimate)
- Sources
- 1
- Updated
- 19 Oct 2026
- Last checked
- 19 Oct 2026