quotegate
中文 · English
Let a language model answer only with sentences copied from your passages, and check every one in code before it reaches a user.
The model is told to reply with lines like [2] a sentence, copied. quotegate.check() keeps a line only when the sentence is really in passage 2 and still reads right on its own. Everything else is dropped with a reason. No second model, no network, no dependencies: plain string work that gives the same answer every time.
It was pulled out of the 马莫百科 WeChat bot, which answers visa, enrolment and exam questions for Monash University students and parents, where a confident extra step is worse than no answer.
Why not let the model write and cite?
| Approach | What it gives you | What it doesn't stop |
|---|---|---|
| The model writes, sources attached | A fluent reply with links | Sentences no source says. An audit of four generative search engines (Liu, Zhang and Liang, Findings of EMNLP 2023) found only 51.5% of generated sentences fully supported by their citations |
| A second model checks the reply, e.g. Ragas faithfulness | A score: the share of claims the judge finds supported | It is a judge, so it can be wrong both ways, and it scores after the fact instead of gating what is sent |
| Provider citations, e.g. Claude's citations | Cited text guaranteed to point into your documents | Text between citations is still the model's own words, and it ties you to one paid API |
| quotegate | Only sentences that are in the passages, whole, each with its passage number | A true sentence that answers a different question. That is what the prompt and your retrieval are for |
In the bot this came from, a free model asked to answer in its own words added steps no passage has ("the lecturer will apply for special consideration for you") and turned "ISP takes your passport when the sticker has 14 days or less left" into "there is a 14-day grace period after it expires". A second model checking the sentences missed some of these and cut correct ones. Quoting plus a string check had neither problem.
What gets dropped
A quote is widened to the whole sentence it sits in, then dropped when:
| Reason | Example |
|---|---|
not found |
A paraphrase, a quote on the wrong passage, a passage number that doesn't exist |
too short |
[1] upload: under 6 characters says nothing |
needs context |
Starts with "This must...", "However", "不,", "此流程"; ends with ":", "and", ";并且" |
list item |
"- you must contact ISP" under "If you don't meet any of the requirements above:" |
a question |
The passage's own FAQ question |
table row |
"Oct intake | OCT-MY-01 | 6 Jan": the header row isn't there |
past year |
"From 2022 exams are online" in 2026. "Until 31 December 2026" is kept |
too long |
Widened past 180 characters: a paragraph with no full stop |
duplicate, over the limit |
The same sentence twice, or more than 4 |
Line breaks end a sentence unless the passage says otherwise (soft_breaks=True for WeChat articles and PDF text, where lines break mid-sentence). A latin full stop ends one only before whitespace, so "50.0" and "monash.edu" don't.
Use
pip install quotegate
from quotegate import Passage, check
from quotegate.prompts import EN, user_message
passages = [
Passage("Special consideration\nApply online within two working days of the due date. "
"This must include supporting documents.", title="Special consideration"),
Passage("Exams\nFinal exams run in weeks 13 and 14."),
]
messages = [
{"role": "system", "content": EN},
{"role": "user", "content": user_message("How long do I have to apply?", [p.text for p in passages])},
]
reply = call_your_model(messages) # any chat model, e.g. a free tier
# reply == "[1] Apply online within two working days\n[1] This must include supporting documents"
result = check(reply, passages)
result.status # "ok"
result.kept # [Quote(passage=1, text="Apply online within two working days of the due date.", start=22, end=75)]
result.dropped # [Dropped(passage=1, quote="This must include supporting documents.", reason="needs context")]
When result.status isn't "ok", send nothing from the model: "unsure" (it said it doesn't know), "uncited" (no quote lines), "unsupported" (no quote is in its passage) or "fragment" (found, but none reads right alone). The bot this came from forwards those questions to a person.
start and end locate the sentence in the passage, so you can link or highlight it. Send kept with the pages they came from and a line saying they are excerpts.
Rules
check(reply, passages, Rules(...)):
| Field | Default | |
|---|---|---|
min_chars, max_chars, max_quotes |
6, 180, 4 | |
leans_back, leans_on |
Chinese and English connectives | Regexes for a start or end that leans on text not shown. quotegate.core has the Chinese and English parts separately |
stops |
✓✔| |
Characters that end a sentence with no full stop |
current_year |
this year | For past year; 0 turns it off |
unsure |
不确定, 不知道, not sure, ... | A short reply containing one of these is "unsure" |
checks |
none | Your own checks: fn(sentence, passage) -> reason or None |
Passage(text, title=None, soft_breaks=False, qa=False): a text starting with its title line never has a sentence widened into the title; qa=True marks a passage that answers the question in its title, so "Yes, ..." or "可以,..." right after "答:" is fine.
Numbers
From the bot's evaluation (docs/EVAL.md, 50 questions, 2026-10-25, Groq's free qwen/qwen3.8-27b):
| Rules only | Rules + quote-only model | |
|---|---|---|
| Questions with no answer in any source left to a person | 1 of 7 | 6 of 7 |
| Model calls where the check removed at least one quote | - | 8 of 29 |
| Model calls where nothing could be sent | - | 2 of 29 |
The check itself takes about 0.3 ms for a reply of four quotes against five 500-character passages (measured 2026-10-26 on one core of a cloud container).
Not for
- Answers that need reasoning or arithmetic across passages. A quote can only say what a passage says.
- Deciding whether a sentence answers the question. Bad retrieval gives true, irrelevant quotes.
- Translation: quote in the passages' language and translate the passages ahead of time.
Development
uv venv && uv pip install -e . pytest ruff
.venv/bin/pytest -q && .venv/bin/ruff check .
Releases: bump __version__ in src/quotegate/__init__.py, add a CHANGELOG.md section, merge, then push a v<version> tag. release.yml checks the tag against the version, runs the tests and publishes to PyPI with Trusted Publishing.
License
MIT © Waldo
Metadata
Release files for quotegate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| quotegate-0.1.0.tar.gz | 19.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| quotegate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 30.9 kB
Release files / quotegate-0.1.0.tar.gz
| Download URL | quotegate-0.1.0.tar.gz |
|---|---|
| Size | 19.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a8beff0a3bf75777131e4400ff64fdf34748a0ca79c4ce08ff13afcf9d000fce
|
|
BLAKE2b-256 checksum How to use checksums |
04773aa94fffec41724ca88b189144604381cfcbcaed74967f1fbb04719430ed
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logRelease files / quotegate-0.1.0-py3-none-any.whl
| Download URL | quotegate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3a9252a99984977edc5942932c879f8c342c4aadb2c614d3ace3f64ddd26fc4e
|
|
BLAKE2b-256 checksum How to use checksums |
91ac93715dc0c54f3fb961d8669f5f79cee570cce986ec364476d01a76db48f3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logSecurity for quotegate 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for quotegate-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 25, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.