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:
- Guess which department is most relevant -- risking a wrong answer that sends the patient to the wrong specialist.
- 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:
- Look up the condition in
CONDITION_TO_DEPT_MAP(an auto-inverted index of condition → departments) - If the condition maps to ≥ 3 departments, trigger clarification
Fallback path -- when entities is None (common with informal phrasing):
- Take the reformulated query from the classifier
- Scan it against all known conditions in
CONDITION_TO_DEPT_MAP - 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:
| Department | Conditions (sample) |
|---|---|
| endocrinologie | vermoeidheid, diabetes, schildklier, gewichtsverlies, haaruitval |
| hematologie | vermoeidheid, bloedziekten, leukemie, trombose |
| gastro-enterologie | vermoeidheid, buikpijn, maagklachten, gewichtsverlies |
| psychiatrie | vermoeidheid, 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:
-
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. -
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.
-
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". -
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.borderandZOL_COLORS.text - Hover state highlights with
ZOL_COLORS.teal - Phone card uses
ZOL_COLORS.tealLightbackground - 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
| Parameter | Default | Description |
|---|---|---|
| Department threshold | 3 | Minimum number of departments before clarification triggers. Queries mapping to 2 departments use the existing "mention both" behavior. |
| Max cards | 4 | Maximum department options shown. If a condition maps to more than 4 departments, only the first 4 are displayed. |
| Phone number | 089/80 80 80 | Hospital helpdesk number shown on the phone fallback card. Configurable per ClarificationCards instance via the phone prop. |
| Hospital name | Config 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
Related
- Query Pipeline -- Full pipeline flow showing where clarification fits
- Query Enrichment Pipeline -- The enrichment step that runs after clarification is skipped
- ADR-0046 -- Architecture decision record
- Release 2026-04-09 -- Release notes for the clarifying questions feature