From 5456e4ccda99fcc06ec18fa87aa9a9440a400810 Mon Sep 17 00:00:00 2001 From: ysz Date: Thu, 16 Jul 2026 12:35:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(generation):=20dignified=20refusal=20?= =?UTF-8?q?=E2=80=94=20point=20to=20closest=20documented=20topics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the RAG context has no direct answer, keep the verbatim refusal opening but append a short 'Closest documented topics:' section listing 2-3 related sources already present in the context, each cited [[Source: X]]. Prompt-only change: no new retrieval, no flags, no router. Only sources actually in the context may be listed, so no answer is fabricated. Motivated by prod log triage: of 78 no-doc refusals over 5 months, 50 were hard refusals; most are KB fact-gaps where inventing content would hallucinate, so the win is a dignified pointer to nearby docs, not a reasoning dial. --- .../application/prompts/templates.py | 2 +- tests/unit/test_refusal_prompt.py | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_refusal_prompt.py diff --git a/src/core/generation/application/prompts/templates.py b/src/core/generation/application/prompts/templates.py index 6653200e..a44a76dc 100644 --- a/src/core/generation/application/prompts/templates.py +++ b/src/core/generation/application/prompts/templates.py @@ -9,7 +9,7 @@ SYSTEM_PROMPT_v1 = """You are Amber, a sophisticated AI analyst designed to provide accurate, grounded answers based on document collections and user memory. CRITICAL INSTRUCTIONS: -1. Grounding: Answer using ONLY the provided [[Source: X]] context. Memory Context helps you understand the user's background but does NOT authorize you to provide advice about tools/systems mentioned there unless you have supporting [[Source: X]] documentation. If the information isn't in the documents, say: "I don't have documentation on that topic." +1. Grounding: Answer using ONLY the provided [[Source: X]] context. Memory Context helps you understand the user's background but does NOT authorize you to provide advice about tools/systems mentioned there unless you have supporting [[Source: X]] documentation. If the information isn't in the documents, start your answer with: "I don't have documentation on that topic." and do NOT invent one. Then, if any of the provided sources cover related ground, append a short section titled "Closest documented topics:" with 2-3 bullets, each naming the document and what it covers in one sentence, cited as [[Source: X]]. List only sources actually present in the context; if none are related, stop after the refusal sentence. 2. Priority Hierarchy (ABSOLUTE): - DOMAIN RULES (appended below as "## DOMAIN RULES") are ABSOLUTE and override everything else. - If a Domain Rule says "assume X unless the user specifies otherwise", you MUST follow it, even if Memory Context suggests something different. diff --git a/tests/unit/test_refusal_prompt.py b/tests/unit/test_refusal_prompt.py new file mode 100644 index 00000000..aafc3d4c --- /dev/null +++ b/tests/unit/test_refusal_prompt.py @@ -0,0 +1,47 @@ +"""Guard the "dignified refusal" prompt contract. + +The default RAG system prompt must (a) keep the exact refusal phrase — log +triage and downstream detection key off it — and (b) instruct the model to +list closest documented topics from the already-provided sources, cited. +Prompt-only change: these tests pin the template contract, not LLM behavior. +""" + +from src.core.generation.application.prompts.templates import PROMPTS, SYSTEM_PROMPT_v1 +from src.core.tenants.application.effective_config import resolve_rag_prompts + +REFUSAL = "I don't have documentation on that topic." +POINTER_MARKER = "Closest documented topics:" + + +def test_refusal_phrase_kept_verbatim(): + assert REFUSAL in SYSTEM_PROMPT_v1 + + +def test_closest_topics_instruction_present_and_cited(): + assert POINTER_MARKER in SYSTEM_PROMPT_v1 + tail = SYSTEM_PROMPT_v1[SYSTEM_PROMPT_v1.find(POINTER_MARKER):][:400] + assert "[[Source: X]]" in tail # pointers must be bound to real cited sources + + +def test_registry_serves_updated_prompt(): + assert POINTER_MARKER in PROMPTS["rag_system"]["latest"] + + +def test_default_resolution_path_carries_instruction(): + system, _ = resolve_rag_prompts( + base_system_prompt=PROMPTS["rag_system"]["latest"], + base_user_prompt=PROMPTS["rag_user"]["latest"], + tenant_config={}, + ) + assert REFUSAL in system and POINTER_MARKER in system + + +def test_tenant_override_bypasses_default(): + # Known limitation, pinned on purpose: a tenant-level rag_system_prompt + # override replaces the default template and will NOT get the pointers. + system, _ = resolve_rag_prompts( + base_system_prompt=PROMPTS["rag_system"]["latest"], + base_user_prompt=PROMPTS["rag_user"]["latest"], + tenant_config={"rag_system_prompt": "CUSTOM"}, + ) + assert system == "CUSTOM" From e398fc52a0cc6a4523aafc2fe2c44d027ba447f3 Mon Sep 17 00:00:00 2001 From: ysz Date: Thu, 23 Jul 2026 09:24:50 +0200 Subject: [PATCH 2/2] fix(generation): guard refusal detection + verified-QA against dignified refusals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dignified-refusal prompt makes a refusal look like a real answer (it appends a cited "Closest documented topics:" section), which breaks two assumptions: 1. _looks_like_refusal did a bare substring match on the pinned opener and a no-sources check. Refusals now carry sources, and the model paraphrases the opener ("I don't have direct documentation on ..."), so refusals slipped through — getting persisted as reusable conversation memory that biases the retrieval-time rewriter into repeating the miss. 2. VerifiedQAService.get_similar_examples injected any admin-"verified" Q&A verbatim into future prompts as a positive few-shot example, with no refusal check. A refusal dressed as a helpful answer is easy to thumbs-up / verify, permanently seeding the golden set with a non-answer the model is told to imitate. Add a shared, adverb-tolerant detector (src/shared/refusal.py) that also matches the "Closest documented topics" section marker. _looks_like_refusal now delegates to it; get_similar_examples filters out refusal-shaped answers so they are never injected even if verified. Behavioural tests cover paraphrased openers, the dignified-refusal shape, and real answers. --- src/api/routes/query.py | 24 +++------ .../application/verified_qa_service.py | 8 ++- src/shared/refusal.py | 54 +++++++++++++++++++ tests/unit/test_refusal_prompt.py | 30 +++++++++++ 4 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 src/shared/refusal.py diff --git a/src/api/routes/query.py b/src/api/routes/query.py index 0bc1dd51..5ade965c 100644 --- a/src/api/routes/query.py +++ b/src/api/routes/query.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.deps import get_db_session +from src.shared.refusal import text_looks_like_refusal from src.api.schemas.query import ( QueryRequest, QueryResponse, @@ -329,27 +330,18 @@ def _utc_now_iso() -> str: return datetime.now(UTC).isoformat() -_REFUSAL_PHRASES = ( - "i don't have documentation", - "i do not have documentation", - "i don't have information", - "i do not have information", - "no documentation on", - "couldn't find any relevant", - "could not find any relevant", - "i'm unable to find", - "i am unable to find", -) - - def _looks_like_refusal(answer: str, sources: list[Any] | None = None) -> bool: """Detect a "no information found" answer so it isn't persisted as reusable memory context — a refusal fed back as PAST CONVERSATIONS biases the - retrieval-time query rewriter into repeating/worsening the same miss.""" + retrieval-time query rewriter into repeating/worsening the same miss. + + Refusals now carry sources (the dignified-refusal "Closest documented + topics" section), so a no-sources check alone is insufficient; delegate the + text test to the shared detector, which is adverb-tolerant and matches the + refusal section marker.""" if not sources: return True - lowered = (answer or "").lower() - return any(phrase in lowered for phrase in _REFUSAL_PHRASES) + return text_looks_like_refusal(answer) # ============================================================================= diff --git a/src/core/admin_ops/application/verified_qa_service.py b/src/core/admin_ops/application/verified_qa_service.py index 53496104..14ac3f6f 100644 --- a/src/core/admin_ops/application/verified_qa_service.py +++ b/src/core/admin_ops/application/verified_qa_service.py @@ -15,6 +15,7 @@ from src.core.admin_ops.domain.feedback import Feedback from src.core.generation.domain.memory_models import ConversationSummary from src.core.retrieval.application.embeddings_service import EmbeddingService +from src.shared.refusal import text_looks_like_refusal logger = logging.getLogger(__name__) @@ -108,7 +109,12 @@ async def get_similar_examples( if not qa_query and feedback.metadata_json: qa_query = feedback.metadata_json.get("query") - if qa_query and qa_answer: + # SECURITY/quality: never inject a refusal as a positive + # few-shot example, even if an admin "verified" it. The + # dignified-refusal prompt makes refusals look like real + # answers, so they can slip through human review; feeding one + # back tells the model to imitate a non-answer. + if qa_query and qa_answer and not text_looks_like_refusal(qa_answer): candidates.append( {"query": qa_query, "answer": qa_answer, "similarity": similarity} ) diff --git a/src/shared/refusal.py b/src/shared/refusal.py new file mode 100644 index 00000000..0fd13b34 --- /dev/null +++ b/src/shared/refusal.py @@ -0,0 +1,54 @@ +"""Shared refusal detection. + +A "refusal" is a generated answer that says the knowledge base has no +information for the query. Two consumers must agree on what one looks like: + +- the query routes, which must NOT persist a refusal as reusable conversation + memory (it biases the retrieval-time rewriter into repeating the miss); and +- VerifiedQAService, which must NOT inject a refusal into future prompts as a + positive few-shot example even if an admin accidentally "verified" it. + +The dignified-refusal prompt makes a refusal look like a real answer (it appends +a "Closest documented topics:" section with cited sources), so a bare +substring check on the opening sentence is not enough: the model paraphrases +the pinned opener ("I don't have direct documentation on ...") and the answer +now carries sources. Detection therefore also matches an adverb-tolerant regex +and the section marker the refusal path emits. +""" + +import re + +REFUSAL_PHRASES: tuple[str, ...] = ( + "i don't have documentation", + "i do not have documentation", + "i don't have information", + "i do not have information", + "no documentation on", + "couldn't find any relevant", + "could not find any relevant", + "i'm unable to find", + "i am unable to find", +) + +# Section title the dignified-refusal prompt appends — present only on refusals. +REFUSAL_MARKERS: tuple[str, ...] = ("closest documented topics",) + +# Adverb-tolerant opener, e.g. "I don't have direct/specific/any documentation". +_REFUSAL_RE = re.compile( + r"\bi\s+(?:don'?t|do not)\s+have\s+" + r"(?:any\s+|direct\s+|specific\s+|relevant\s+|enough\s+|much\s+)*" + r"(?:documentation|information|details|specifics|data)\b", + re.IGNORECASE, +) + + +def text_looks_like_refusal(text: str | None) -> bool: + """True if the answer text reads as a 'no information found' refusal.""" + if not text: + return False + lowered = text.lower() + if any(p in lowered for p in REFUSAL_PHRASES): + return True + if any(m in lowered for m in REFUSAL_MARKERS): + return True + return _REFUSAL_RE.search(text) is not None diff --git a/tests/unit/test_refusal_prompt.py b/tests/unit/test_refusal_prompt.py index aafc3d4c..b5d63966 100644 --- a/tests/unit/test_refusal_prompt.py +++ b/tests/unit/test_refusal_prompt.py @@ -45,3 +45,33 @@ def test_tenant_override_bypasses_default(): tenant_config={"rag_system_prompt": "CUSTOM"}, ) assert system == "CUSTOM" + + +# --- Behavioural refusal-detection tests (shared detector) -------------------- +from src.shared.refusal import text_looks_like_refusal + + +def test_paraphrased_refusal_opener_detected(): + # Model deviates from the pinned opener with an adverb — must still count. + assert text_looks_like_refusal( + "I don't have direct documentation on that topic, but here are some pointers." + ) + assert text_looks_like_refusal("I do not have specific information about that.") + + +def test_dignified_refusal_with_sources_detected(): + # New prompt shape: refusal opener + cited "Closest documented topics" section. + ans = ( + "I don't have documentation on how to integrate X.\n\n" + "Closest documented topics:\n- Foo [[Source: 1]]\n- Bar [[Source: 2]]" + ) + assert text_looks_like_refusal(ans) + # The section marker alone is enough even if the opener is reworded. + assert text_looks_like_refusal("No direct match. Closest documented topics: ...") + + +def test_real_answer_not_flagged_as_refusal(): + assert not text_looks_like_refusal( + "To create a distribution list, open the Admin Panel and select Domains [[Source: 1]]." + ) + assert not text_looks_like_refusal("")