Skip to main content

Clarifying Questions

Problem Statement

Hospital visitors frequently describe symptoms in everyday language rather than naming a specific condition or department. A query like "ik ben altijd moe" (I'm always tired) is perfectly valid, but fatigue is a symptom shared across many medical disciplines: endocrinologie (hormonal), hematologie (blood disorders), gastro-enterologie (digestive), psychiatrie (mental health), and more. The pattern of resolving information-need ambiguity through a follow-up query rather than guessing aligns with the conversational-IR tradition of clarification questions (@manning2008ir) and with the broader RAG move toward interactive grounding (@gao2024ragsurvey).

When a symptom maps to three or more departments, the system faces a dilemma. It can either:

  1. Guess which department is most relevant -- risking a wrong answer that sends the patient to the wrong specialist.
  2. Mention all departments in a broad response -- overwhelming the patient with information and no clear next step.

Neither approach serves the patient well. The clarifying questions system solves this by asking the user to narrow down before generating a response. Instead of guessing, it presents clickable department cards so the patient can self-select the most relevant specialization with a single tap.


Architecture Overview

The clarifying questions system operates through three layers that work in sequence:

Layer 1: Intent Classification

The LLM-based intent classifier analyzes the user query and assigns one of several intent labels. When the query describes symptoms without naming a specific condition or department, it returns:

intent: AMBIGUOUS_SYMPTOM_DESCRIPTION

The classifier may also extract a condition entity (e.g., "vermoeidheid" from "ik ben altijd moe"), though this is not guaranteed -- informal phrasing often results in entities = None.

Layer 2: Ambiguity Detection

Once the intent is classified as ambiguous, the system checks whether the described condition actually maps to multiple departments. This check has two paths:

Primary path -- when the classifier extracts a condition entity:

  1. Look up the condition in CONDITION_TO_DEPT_MAP (an auto-inverted index of condition → departments)
  2. If the condition maps to ≥ 3 departments, trigger clarification

Fallback path -- when entities is None (common with informal phrasing):

  1. Take the reformulated query from the classifier
  2. Scan it against all known conditions in CONDITION_TO_DEPT_MAP
  3. Match the first condition with ≥ 3 departments

The fallback scanner sorts condition keys by length (descending) to avoid partial matches -- for example, "borstpijn" (chest pain) is checked before "pijn" (pain).

Layer 3: Clarification Cards

When ambiguity is confirmed, the backend builds a StreamChunk of type "clarification" containing up to 4 department options, each with:

  • Department name (e.g., "Endocrinologie")
  • Patient-friendly reason (e.g., "Hormonen en stofwisselingsziekten")
  • Icon (emoji representing the specialty)
  • Pre-built query (e.g., "Welke artsen werken bij de afdeling Endocrinologie van ZOL?")

The frontend renders these as clickable cards. When the user clicks one, the pre-built query is sent as a new message, and the normal RAG pipeline processes it with full context.


Pipeline Integration

The ambiguity check runs inside _qs_early_exit_chunks(), which evaluates exit conditions in a strict order:

Order matters. The ambiguity check runs before the cache check. If the cache check ran first, a previously cached text response for the same query would be returned, bypassing the clarification cards entirely. This was an explicit design decision documented in ADR-0046.

The full pipeline flow for an ambiguous query:

Classification
-> Early Exits: error -> guardrails -> AMBIGUITY -> cache
-> [short-circuit: yield clarification StreamChunk, set early_exit = True]

No retrieval, no LLM generation, no wasted compute. The clarification cards are built from static data in approximately 0ms.

The relevant code path in rag_service.py:

# _qs_early_exit_chunks() — ambiguity runs before cache
if c and c.intent == UserIntent.AMBIGUOUS_SYMPTOM_DESCRIPTION:
s["detected_intent"] = c.intent
s["detected_entities"] = c.entities
clarification_chunk = self._qs_check_ambiguity(s)
if clarification_chunk is not None:
yield clarification_chunk
s["early_exit"] = True
return

# Cache check runs AFTER ambiguity
cache_gen = await self._qs_check_cache(s)

Data Sources

Three static data structures power the clarification system. All are initialized at taxonomy startup and remain in memory.

DEPT_CONDITION_KNOWLEDGE

File: backend/app/services/graph/medical_knowledge/department_conditions.py

A curated mapping of department → conditions, covering standard Belgian hospital departments. Includes both formal medical terminology and 22+ patient-language Dutch symptom descriptions (e.g., "vermoeidheid", "buikpijn", "duizeligheid", "kortademigheid").

Example entries:

DepartmentConditions (sample)
endocrinologievermoeidheid, diabetes, schildklier, gewichtsverlies, haaruitval
hematologievermoeidheid, bloedziekten, leukemie, trombose
gastro-enterologievermoeidheid, buikpijn, maagklachten, gewichtsverlies
psychiatrievermoeidheid, depressie, angst, slaapproblemen

This is the source of truth for condition-department associations. It is maintained manually and enriched via LLM-assisted extraction (scripts/enrich_taxonomy_llm.py), with human review before deployment.

CONDITION_TO_DEPT_MAP

Built at: hospital_taxonomy.py_init_domain_knowledge()

An auto-inverted version of DEPT_CONDITION_KNOWLEDGE. For each condition, it lists all departments that handle it:

CONDITION_TO_DEPT_MAP: dict[str, list[str]] = {}
for dept_name, conditions in self.DEPT_CONDITION_MAP.items():
for cond in conditions:
cond_lower = cond.lower()
if cond_lower not in self.CONDITION_TO_DEPT_MAP:
self.CONDITION_TO_DEPT_MAP[cond_lower] = [dept_name]
elif dept_name not in self.CONDITION_TO_DEPT_MAP[cond_lower]:
self.CONDITION_TO_DEPT_MAP[cond_lower].append(dept_name)

This inversion means a lookup like CONDITION_TO_DEPT_MAP["vermoeidheid"] returns ["endocrinologie", "gastro-enterologie", "hematologie", "psychiatrie"] -- four departments, which exceeds the threshold of 3.

DEPT_PATIENT_DESCRIPTIONS

File: backend/app/services/graph/medical_knowledge/department_descriptions.py

Patient-friendly descriptions and icons for each department. Used to build the clarification card UI. Covers all standard Belgian hospital departments (55+).

Example:

"Endocrinologie": {
"reason": "Hormonen en stofwisselingsziekten",
"icon": "⚗️",
}

Departments not found in this mapping receive a generic fallback: {"reason": "Meer informatie", "icon": "🏥"}.


The Fallback Scanner

When the LLM classifies intent as AMBIGUOUS_SYMPTOM_DESCRIPTION but does not extract a condition entity (common with informal phrasing like "ik ben moe" or "ik heb altijd pijn"), the _scan_query_for_ambiguous_conditions() function provides a fallback.

File: backend/app/services/rag_service.py (module-level function)

def _scan_query_for_ambiguous_conditions(
query: str,
condition_map: dict[str, list[str]],
threshold: int = 3,
) -> list[str] | None:
sorted_keys = sorted(condition_map.keys(), key=len, reverse=True)
for key in sorted_keys:
if key in query:
depts = condition_map[key]
if len(depts) >= threshold:
return depts
return None

Key design decisions:

  1. Longest-first matching. Keys are sorted by length descending. This prevents "pijn" (pain) from matching before "buikpijn" (abdominal pain) or "borstpijn" (chest pain), which would produce overly broad department lists.

  2. First match wins. The function returns as soon as it finds a condition meeting the threshold. This is intentional -- ambiguous queries typically center on a single symptom.

  3. Substring matching. The check is if key in query, not an exact match. This allows the reformulated query "vermoeidheid en altijd moe" to match the condition key "vermoeidheid".

  4. Configurable threshold. The default threshold of 3 can be overridden per call, though in practice the default is always used.


Frontend Component

File: frontend/src/components/PublicChat/ClarificationCards.tsx

The ClarificationCards component renders the department options as an animated card list using Framer Motion.

Structure

Each card displays:

  • An emoji icon representing the medical specialty
  • A patient-friendly reason in bold (e.g., "Hormonen en stofwisselingsziekten")
  • The department name in smaller text below

The last card is always a phone fallback -- a styled link to the hospital's phone number for patients who prefer human guidance.

Interaction

When a patient clicks a department card, the component calls onSelect(option.query), which sends the pre-built query (e.g., "Welke artsen werken bij de afdeling Endocrinologie van ZOL?") as a new user message. The conversation history is preserved, so the system understands the context.

Styling

The component uses ZOL brand colors from frontend/src/constants/theme.ts:

  • Card borders and text use ZOL_COLORS.border and ZOL_COLORS.text
  • Hover state highlights with ZOL_COLORS.teal
  • Phone card uses ZOL_COLORS.tealLight background
  • Cards have subtle scale animations on hover (1.01x) and tap (0.98x)

WebSocket Protocol

The backend sends a StreamChunk with type: "clarification":

{
"type": "clarification",
"content": "",
"clarification_options": [
{
"department": "Endocrinologie",
"reason": "Hormonen en stofwisselingsziekten",
"icon": "⚗️",
"query": "Welke artsen werken bij de afdeling Endocrinologie van ZOL?"
},
{
"department": "Gastro-enterologie",
"reason": "Maag, darmen en spijsvertering",
"icon": "🫁",
"query": "Welke artsen werken bij de afdeling Gastro-enterologie van ZOL?"
}
]
}

The frontend checks for clarificationOptions on assistant messages and renders the ClarificationCards component when present.


Configuration

ParameterDefaultDescription
Department threshold3Minimum number of departments before clarification triggers. Queries mapping to 2 departments use the existing "mention both" behavior.
Max cards4Maximum department options shown. If a condition maps to more than 4 departments, only the first 4 are displayed.
Phone number089/80 80 80Hospital helpdesk number shown on the phone fallback card. Configurable per ClarificationCards instance via the phone prop.
Hospital nameConfig key (uppercase)Used in the pre-built query template: "Welke artsen werken bij de afdeling {dept} van {hospital}?"

Cost and Latency

The clarification system adds zero LLM cost and near-zero latency because:

  • All data comes from static in-memory maps (no database queries)
  • The pipeline short-circuits before retrieval and generation
  • The only LLM call is the intent classification, which runs regardless

ADR Reference

ADR-0046 (Clarifying Questions for Ambiguous Queries) documents the full design decision, including:

  • Why the threshold was set at 3 (not 2 or 4)
  • Why static mappings were chosen over LLM-generated options
  • The trade-off of requiring one extra click for previously-answered queries
  • The new "clarification" stream chunk type in the WebSocket protocol