POST /v1/guard (REST) · guard_answer (MCP tool). Code: backend/app/guard.py, templates
backend/app/guard_messages.py, tests backend/tests/test_guard.py. Every output below is pasted from a real run
(2026-10-03, full corpus, mock provider).
«This chatbot answer contains N Quran/Hadith quotations; K of them are not byte-exact in Basira's licensed sources — review them before publishing.»
That is the whole claim. The Guard says nothing about the answer (its reasoning, its fiqh, its tone) and nothing about the authenticity of a hadith. It only tells you whether each quoted text is the text.
verdict |
condition | what the integrator should do |
|---|---|---|
clear |
every detected quotation is found |
publish |
flagged |
at least one quotation is partial_match / needs_review / not_found |
hold, annotate, or send to review — never rewrite the quotation automatically |
no_quotes |
no Quran/Hadith quotation detected | publish (Basira had nothing to check) |
Response fields: verdict, counts {quotes, found, flagged, by_status}, flagged_quote_ids, quotes[] (the compact
per-quotation detail: status, kind, quoted_text, review_reason, matches with the verbatim source_text and
source_url, notices as sentences), flags (chain message / refusal / PII suspected), summary_ar, summary_en,
determinism_hash, corpus. The same hash is in the X-Basira-Determinism-Hash header.
user ──prompt──▶ your LLM ──draft answer──▶ ┌──────────── Guard ────────────┐
│ POST /v1/guard {answer} │
│ extract quotations │
│ exact / fuzzy match (Basira) │
│ verdict + fixed summaries │
└───────┬───────────────────────┘
clear / no_quotes ◀─────────────┤
→ show answer │ flagged
▼
show answer + summary banner, or hold for human review,
or ask the LLM to *cite the source_text verbatim* (never to "fix" it)
Latency budget: the Guard is the same pipeline as /v1/check — match ≈ 3 ms per quote; with the mock provider a
two-quotation answer returns in well under 100 ms; with a real extraction provider the provider call dominates (E-035).
import json
import urllib.request
BASIRA = "http://localhost:8000"
def guarded_reply(model_answer: str, lang: str = "ar") -> str:
body = json.dumps({"answer": model_answer, "ui_lang": lang}).encode("utf-8")
req = urllib.request.Request(f"{BASIRA}/v1/guard", body, {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
g = json.load(resp)
if g["verdict"] == "flagged": # hold or annotate — never rewrite the quotation
return f"{model_answer}\n\n⚠ {g['summary_' + lang]}"
return model_answerReal run:
$ backend/.venv/bin/python docs/examples/guard_client.py
قال تعالى: ﴿إن الله مع الصابرين﴾ وقال ﷺ: «الدين المعاملة»
⚠ في الإجابة اقتباسان؛ واحد منها يحتاج مراجعة قبل النشر (لم يُوجد مطابقًا في مصادر بصيرة أو يختلف عن النص المصدر). بصيرة أداة مساعدة حتمية؛ «لم يوجد في مصادرنا» ليس حكمًا على النص. راجع أهل العلم عند الشك.
Same thing from an MCP-capable assistant: call guard_answer(answer, ui_lang) — identical output (tested).
summary_ar / summary_en are rendered from fixed strings in guard_messages.py with only counts substituted
(Arabic number agreement: «اقتباس واحد / اقتباسان / 3 اقتباسات / 11 اقتباسًا»). Guaranteed by tests:
- every template and every rendered summary passes
app.messages.scan_forbidden(the forbidden-lexicon scanner that guards all of Basira's prose) — no «محرّف / ضعيف / صحيح / fabricated / weak …»; - no quoted text and no
source_text(not even a 4-word window of it) appears in a summary; the Arabic summary's vocabulary is a closed set equal to the templates' vocabulary; - every summary ends with the fixed footer: «لم يوجد في مصادرنا» ليس حكمًا على النص / "not found in our sources" is not a verdict.
The templates live outside messages/*.json on purpose (that file is owned by another work stream); they follow the
same rules and the same scanner.
$ curl -s localhost:8000/v1/guard -H "Content-Type: application/json" -d '{"answer":"قال تعالى: ﴿إن الله مع الصابرين﴾ وقال ﷺ: «طلب العلم فريضة على كل مسلم ومسلمة»","ui_lang":"ar"}' | jq "del(.quotes)"
{
"verdict": "flagged",
"counts": { "quotes": 2, "found": 1, "flagged": 1, "by_status": { "found": 1, "partial_match": 1 } },
"flagged_quote_ids": [ "q2" ],
"flags": { "chain_message": false, "refusal": false, "pii_suspected": false },
"extraction_degraded": false,
"summary_ar": "في الإجابة اقتباسان؛ واحد منها يحتاج مراجعة قبل النشر (لم يُوجد مطابقًا في مصادر بصيرة أو يختلف عن النص المصدر). بصيرة أداة مساعدة حتمية؛ «لم يوجد في مصادرنا» ليس حكمًا على النص. راجع أهل العلم عند الشك.",
"summary_en": "The answer contains 2 quotation(s); 1 of them need review before publishing (not found verbatim in Basira's sources, or differing from the source text). Basira is a deterministic aid; \"not found in our sources\" is not a verdict on the text. Consult qualified scholars when in doubt.",
"determinism_hash": "e8bb60f86941daa614b42f601ad52388943dd4104ea6f84ef8ca714b2321ae0a",
"corpus": { "tanzil": "1.1", "ohd_commit": "1515f6cb", "hadeethenc": "1.7.0" }
}
quotes[1] (id q2) carries status: partial_match, diff_kinds: ["word_added_in_quote"] and the Ibn Majah 220
record verbatim as source_text — that is what a reviewer (or the LLM, instructed to quote, not to fix) works from.
- Detection is rule-based in mock mode (introducers, brackets, corpus anchors); an unmarked, unintroduced paraphrase may not
be detected →
no_quotesis «nothing detected», not «nothing quoted». A real extraction provider adds spans (ADR-005). - English/transliterated quotations are
needs_review/non_arabictoday (→flagged). The English gate (PR #1) is the data layer for a future English path. flaggedis a review signal, not a score of the answer.partial_matchon a hadith may be a legitimate transmission variant.- Max 5 000 characters per call (
text_too_longotherwise); rate limit 30/min per IP withoutX-Eval-Key. - Nothing is stored. If you need an audit trail, store the
determinism_hashand the corpus build (/health.index_sha256); re-running the same answer on the same build reproduces the verdict.