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/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/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 new file mode 100644 index 00000000..b5d63966 --- /dev/null +++ b/tests/unit/test_refusal_prompt.py @@ -0,0 +1,77 @@ +"""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" + + +# --- 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("")