Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions src/api/routes/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


# =============================================================================
Expand Down
8 changes: 7 additions & 1 deletion src/core/admin_ops/application/verified_qa_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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}
)
Expand Down
2 changes: 1 addition & 1 deletion src/core/generation/application/prompts/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions src/shared/refusal.py
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions tests/unit/test_refusal_prompt.py
Original file line number Diff line number Diff line change
@@ -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("")
Loading