# Conversation Context and Follow-up Prompts ## Overview This document describes how the template-based SPARQL system handles **follow-up prompts** - short questions that only make sense in the context of previous conversation turns. DSPy's `History` mechanism is crucial for resolving these elliptical references. ## The Problem: Elliptical Follow-up Questions Consider this conversation: ``` User: Welke archieven zijn er in Den Haag? System: In Den Haag zijn de volgende archieven: Nationaal Archief, Haags Gemeentearchief... User: En in Enschede? ``` The second prompt "En in Enschede?" is **elliptical** - it omits information that must be inferred from context: - **What** is being asked about? → archives (from previous question) - **What kind** of question? → geographic listing (same intent) - **Full resolved question** → "Welke archieven zijn er in Enschede?" Without context resolution, "En in Enschede?" would be unmatchable to any template. ## DSPy History Integration ### The History Object DSPy provides a `History` class for conversation context: ```python from dspy import History # History contains previous turns as messages history = History(messages=[ {"role": "user", "content": "Welke archieven zijn er in Den Haag?"}, {"role": "assistant", "content": "In Den Haag zijn de volgende archieven: ..."}, ]) ``` ### Context Resolution Signature ```python """DSPy signatures for conversation context resolution.""" import dspy from dspy import History from pydantic import BaseModel, Field class ResolvedQuestion(BaseModel): """Resolved question with context filled in.""" original_question: str = Field( description="The original (possibly elliptical) question" ) resolved_question: str = Field( description="Fully resolved question with all implicit references expanded" ) is_follow_up: bool = Field( description="Whether this was a follow-up to a previous question" ) inherited_intent: str | None = Field( default=None, description="Intent inherited from previous turn (if follow-up)" ) inherited_entities: list[str] = Field( default_factory=list, description="Entities inherited from previous turns" ) slot_overrides: dict[str, str] = Field( default_factory=dict, description="Slots that changed from the previous question" ) confidence: float = Field( ge=0.0, le=1.0, description="Confidence in the resolution" ) class ContextResolutionSignature(dspy.Signature): """Resolve elliptical follow-up questions using conversation history. Users often ask follow-up questions that omit information from previous turns. Your task is to resolve these elliptical references into fully specified questions. EXAMPLES OF ELLIPTICAL PATTERNS: 1. Location change (most common): Previous: "Welke archieven zijn er in Den Haag?" Follow-up: "En in Enschede?" Resolved: "Welke archieven zijn er in Enschede?" 2. Type change: Previous: "Hoeveel musea zijn er in Utrecht?" Follow-up: "En bibliotheken?" Resolved: "Hoeveel bibliotheken zijn er in Utrecht?" 3. Pronoun reference: Previous: "Wat is het oudste archief in Nederland?" Follow-up: "Wie is daar de directeur?" Resolved: "Wie is de directeur van [oldest archive name]?" 4. Implicit continuation: Previous: "Welke musea zijn er in Amsterdam?" Follow-up: "Met een Van Gogh collectie?" Resolved: "Welke musea in Amsterdam hebben een Van Gogh collectie?" 5. Comparative follow-up: Previous: "Hoeveel archieven zijn er in Noord-Holland?" Follow-up: "En in Zuid-Holland?" Resolved: "Hoeveel archieven zijn er in Zuid-Holland?" 6. Detail request: Previous: "Welke bibliotheken zijn er in Rotterdam?" Follow-up: "Meer informatie over de eerste" Resolved: "Meer informatie over [first library name from results]" IMPORTANT: - If the question is self-contained, return it unchanged - Only inherit context when the question is clearly elliptical - Preserve the user's language (Dutch/English) - Include ALL necessary information in the resolved question """ question: str = dspy.InputField( desc="Current user question (may be elliptical)" ) history: History = dspy.InputField( desc="Previous conversation turns", default=History(messages=[]) ) language: str = dspy.InputField( desc="Language code (nl, en, de, fr)", default="nl" ) previous_results: str = dspy.InputField( desc="Results from the previous query (for pronoun resolution)", default="" ) resolved: ResolvedQuestion = dspy.OutputField( desc="Fully resolved question with context" ) class ConversationContextResolver(dspy.Module): """Resolve elliptical follow-up questions to full questions. This module is critical for template matching: templates match against resolved questions, not the original elliptical input. """ def __init__(self, fast_lm: dspy.LM | None = None): """Initialize the context resolver. Args: fast_lm: Optional fast LM for context resolution. Recommended: gpt-4o-mini for speed while maintaining quality. """ super().__init__() self.fast_lm = fast_lm self.resolver = dspy.TypedPredictor(ContextResolutionSignature) def forward( self, question: str, history: History | None = None, language: str = "nl", previous_results: str = "", ) -> dspy.Prediction: """Resolve elliptical question using conversation context. Args: question: Current user question history: Previous conversation turns language: Language code previous_results: Formatted results from previous query Returns: Prediction with resolved question and metadata """ if history is None: history = History(messages=[]) # Quick check: if no history, question is self-contained if not history.messages: return dspy.Prediction( original_question=question, resolved_question=question, is_follow_up=False, inherited_intent=None, inherited_entities=[], slot_overrides={}, confidence=1.0, ) # Use fast LM if configured if self.fast_lm: with dspy.settings.context(lm=self.fast_lm): result = self.resolver( question=question, history=history, language=language, previous_results=previous_results, ) else: result = self.resolver( question=question, history=history, language=language, previous_results=previous_results, ) resolved = result.resolved return dspy.Prediction( original_question=resolved.original_question, resolved_question=resolved.resolved_question, is_follow_up=resolved.is_follow_up, inherited_intent=resolved.inherited_intent, inherited_entities=resolved.inherited_entities, slot_overrides=resolved.slot_overrides, confidence=resolved.confidence, ) ``` ## Template Matching with Context ### Integration Flow ``` User Follow-up: "En in Enschede?" | v +------------------------+ | ConversationContext | <-- Resolve elliptical reference | Resolver | +------------------------+ | | resolved_question = "Welke archieven zijn er in Enschede?" v +------------------------+ | TemplateClassifier | <-- Match against resolved question +------------------------+ | | template_id = "city_institution_search" | slots = {city: "Enschede", type: "A"} v +------------------------+ | TemplateInstantiator | <-- Generate SPARQL +------------------------+ | v Valid SPARQL Query ``` ### Slot Override Detection When a follow-up changes just one slot, we can optimize by detecting the change: ```python """Detect which slots changed in a follow-up question.""" from dataclasses import dataclass @dataclass class SlotChange: """A change in slot value between turns.""" slot_name: str previous_value: str new_value: str class SlotChangeDetector: """Detect slot changes between conversation turns.""" # Patterns that indicate slot changes LOCATION_CHANGE_PATTERNS = [ r"^en in (.+)\??$", # "en in Enschede?" r"^in (.+)\??$", # "in Utrecht?" r"^(.+) dan\??$", # "Utrecht dan?" ] TYPE_CHANGE_PATTERNS = [ r"^en (.+)\??$", # "en bibliotheken?" r"^(.+) dan\??$", # "bibliotheken dan?" ] def detect_changes( self, current_question: str, previous_slots: dict[str, str], resolved_slots: dict[str, str], ) -> list[SlotChange]: """Detect which slots changed between turns. Args: current_question: The follow-up question previous_slots: Slots from the previous turn resolved_slots: Slots extracted from resolved question Returns: List of slot changes """ changes = [] for slot_name, new_value in resolved_slots.items(): old_value = previous_slots.get(slot_name) if old_value and old_value != new_value: changes.append(SlotChange( slot_name=slot_name, previous_value=old_value, new_value=new_value, )) return changes # Example detector = SlotChangeDetector() previous_slots = {"institution_type_code": "A", "city_lower": "den haag"} resolved_slots = {"institution_type_code": "A", "city_lower": "enschede"} changes = detector.detect_changes( "En in Enschede?", previous_slots, resolved_slots, ) # -> [SlotChange(slot_name="city_lower", previous_value="den haag", new_value="enschede")] ``` ## Conversation State Management ### Tracking Previous Turn Context ```python """Manage conversation state for follow-up handling.""" from dataclasses import dataclass, field from datetime import datetime from typing import Any @dataclass class ConversationTurn: """A single turn in the conversation.""" timestamp: datetime question: str resolved_question: str intent: str template_id: str | None slots: dict[str, str] sparql_query: str | None results: list[dict] answer: str @dataclass class ConversationState: """State of an ongoing conversation.""" session_id: str turns: list[ConversationTurn] = field(default_factory=list) current_intent: str | None = None current_slots: dict[str, str] = field(default_factory=dict) entity_cache: dict[str, Any] = field(default_factory=dict) def add_turn(self, turn: ConversationTurn) -> None: """Add a turn and update current state.""" self.turns.append(turn) self.current_intent = turn.intent self.current_slots = turn.slots.copy() # Cache entities from results for pronoun resolution for i, result in enumerate(turn.results[:5]): key = f"result_{i}" self.entity_cache[key] = result # Also cache by name for "het eerste" / "the first" references if name := result.get("name"): self.entity_cache[f"name:{name.lower()}"] = result def get_previous_turn(self) -> ConversationTurn | None: """Get the most recent turn.""" return self.turns[-1] if self.turns else None def get_history(self, max_turns: int = 5) -> dspy.History: """Get conversation history for DSPy.""" messages = [] for turn in self.turns[-max_turns:]: messages.append({"role": "user", "content": turn.question}) messages.append({"role": "assistant", "content": turn.answer}) return dspy.History(messages=messages) def format_previous_results(self, max_results: int = 5) -> str: """Format previous results for pronoun resolution.""" prev = self.get_previous_turn() if not prev or not prev.results: return "" lines = ["Previous results:"] for i, result in enumerate(prev.results[:max_results], 1): name = result.get("name", "Unknown") typ = result.get("type", "") lines.append(f"{i}. {name} ({typ})") return "\n".join(lines) ``` ### Session-Aware RAG Module ```python """Heritage RAG with conversation context handling.""" import dspy from uuid import uuid4 class ConversationAwareHeritageRAG(dspy.Module): """Heritage RAG that handles follow-up questions using DSPy History.""" def __init__(self): super().__init__() self.context_resolver = ConversationContextResolver() self.fyke = FykeFilter() self.router = HeritageQueryRouter() self.template_classifier = TemplateClassifier() self.template_instantiator = TemplateInstantiator() self.answer_generator = dspy.ChainOfThought(HeritageAnswerGenerator) # Session storage (in production: Redis or similar) self._sessions: dict[str, ConversationState] = {} def get_or_create_session(self, session_id: str | None = None) -> ConversationState: """Get existing session or create new one.""" if session_id is None: session_id = str(uuid4()) if session_id not in self._sessions: self._sessions[session_id] = ConversationState(session_id=session_id) return self._sessions[session_id] async def answer( self, question: str, session_id: str | None = None, language: str = "nl", ) -> dspy.Prediction: """Answer question with conversation context awareness. Args: question: User's current question (may be elliptical) session_id: Session ID for conversation continuity language: Language code Returns: Prediction with answer and metadata """ # Get session state session = self.get_or_create_session(session_id) # Step 1: Resolve elliptical references using DSPy History history = session.get_history() previous_results = session.format_previous_results() resolution = self.context_resolver( question=question, history=history, language=language, previous_results=previous_results, ) resolved_question = resolution.resolved_question # Step 2: Check relevance (fyke) relevance = self.fyke(question=resolved_question, language=language) if relevance.caught_by_fyke: return dspy.Prediction( answer=relevance.fyke_response, caught_by_fyke=True, session_id=session.session_id, ) # Step 3: Route using resolved question routing = self.router( question=resolved_question, language=language, history=history, # Pass history to router for additional context ) # Step 4: Template classification # Use inherited intent from context resolution if available effective_intent = resolution.inherited_intent or routing.intent template_match = self.template_classifier( question=resolved_question, intent=effective_intent, entities=routing.entities, ) # Step 5: Handle slot inheritance for follow-ups if resolution.is_follow_up and session.current_slots: # Merge: current slots from session + overrides from new question merged_slots = session.current_slots.copy() merged_slots.update(resolution.slot_overrides) merged_slots.update(template_match.slots) effective_slots = merged_slots else: effective_slots = template_match.slots # Step 6: Generate and execute SPARQL sparql_query = None results = [] if template_match.template_id and template_match.confidence > 0.7: try: sparql_query = self.template_instantiator.instantiate( template_id=template_match.template_id, slots=effective_slots, ) results = await self.execute_sparql(sparql_query) except Exception as e: logger.warning(f"Template instantiation failed: {e}") # Step 7: Fallback to vector search if needed if not results: results = await self.qdrant_search(resolved_question) # Step 8: Generate answer context = self._format_results(results) answer_result = self.answer_generator( question=resolved_question, context=context, history=history, sources=["sparql" if sparql_query else "qdrant"], language=language, ) # Step 9: Update session state turn = ConversationTurn( timestamp=datetime.now(), question=question, # Original question resolved_question=resolved_question, intent=effective_intent, template_id=template_match.template_id, slots=effective_slots, sparql_query=sparql_query, results=results, answer=answer_result.answer, ) session.add_turn(turn) return dspy.Prediction( answer=answer_result.answer, original_question=question, resolved_question=resolved_question, is_follow_up=resolution.is_follow_up, slot_changes=resolution.slot_overrides, template_id=template_match.template_id, sparql_query=sparql_query, confidence=answer_result.confidence, session_id=session.session_id, citations=answer_result.citations, follow_up_suggestions=answer_result.follow_up, ) ``` ## Follow-up Pattern Taxonomy ### Common Elliptical Patterns | Pattern | Example | Resolution Strategy | |---------|---------|---------------------| | **Location swap** | "En in Enschede?" | Replace location slot, keep other slots | | **Type swap** | "En bibliotheken?" | Replace type slot, keep location | | **Comparative** | "En in Zuid-Holland?" | Replace region, keep intent and type | | **Pronoun reference** | "Wie is daar de directeur?" | Resolve "daar" to entity from results | | **Ordinal reference** | "Meer over de eerste" | Map "eerste" to first result | | **Implicit continuation** | "Met een Van Gogh collectie?" | Add filter to previous query | | **Negation** | "Welke niet?" | Negate previous filter | | **Quantifier change** | "Hoeveel dan?" | Change SELECT to COUNT | ### Pattern Detection Rules ```python """Rules for detecting follow-up patterns.""" import re from dataclasses import dataclass from enum import Enum class FollowUpPattern(Enum): """Types of follow-up patterns.""" LOCATION_SWAP = "location_swap" TYPE_SWAP = "type_swap" PRONOUN_REFERENCE = "pronoun_reference" ORDINAL_REFERENCE = "ordinal_reference" IMPLICIT_CONTINUATION = "implicit_continuation" NEGATION = "negation" QUANTIFIER_CHANGE = "quantifier_change" DETAIL_REQUEST = "detail_request" SELF_CONTAINED = "self_contained" @dataclass class PatternRule: """Rule for detecting a follow-up pattern.""" pattern: FollowUpPattern regex: str slot_affected: str | None description: str # Dutch follow-up pattern rules DUTCH_PATTERN_RULES = [ PatternRule( pattern=FollowUpPattern.LOCATION_SWAP, regex=r"^en\s+in\s+(.+?)\??$", slot_affected="location", description="Location change: 'en in X?'", ), PatternRule( pattern=FollowUpPattern.LOCATION_SWAP, regex=r"^in\s+(.+?)\??$", slot_affected="location", description="Location query: 'in X?'", ), PatternRule( pattern=FollowUpPattern.TYPE_SWAP, regex=r"^en\s+(musea|bibliotheken|archieven|galerieën)\??$", slot_affected="institution_type", description="Type change: 'en bibliotheken?'", ), PatternRule( pattern=FollowUpPattern.PRONOUN_REFERENCE, regex=r".*(daar|die|deze|dat|daarin|hiervan).*", slot_affected=None, description="Pronoun reference requiring entity resolution", ), PatternRule( pattern=FollowUpPattern.ORDINAL_REFERENCE, regex=r".*(eerste|tweede|derde|laatste|vorige).*", slot_affected=None, description="Ordinal reference to results", ), PatternRule( pattern=FollowUpPattern.DETAIL_REQUEST, regex=r"^meer\s+(informatie|details|over)\s+.*$", slot_affected=None, description="Request for more details", ), PatternRule( pattern=FollowUpPattern.QUANTIFIER_CHANGE, regex=r"^hoeveel\s+(dan|er)?\??$", slot_affected=None, description="Change to count query", ), ] class FollowUpPatternDetector: """Detect follow-up patterns in questions.""" def __init__(self, language: str = "nl"): self.language = language self.rules = DUTCH_PATTERN_RULES # Could extend for other languages def detect(self, question: str) -> tuple[FollowUpPattern, re.Match | None]: """Detect the follow-up pattern in a question. Args: question: The user's question Returns: Tuple of (pattern_type, regex_match) """ question_lower = question.lower().strip() for rule in self.rules: match = re.match(rule.regex, question_lower, re.IGNORECASE) if match: return rule.pattern, match return FollowUpPattern.SELF_CONTAINED, None def get_slot_to_change(self, pattern: FollowUpPattern) -> str | None: """Get which slot should change for a pattern.""" for rule in self.rules: if rule.pattern == pattern: return rule.slot_affected return None ``` ## DSPy Optimization for Context Resolution ### Training Data for Context Resolver ```python """Training examples for context resolution optimization.""" CONTEXT_RESOLUTION_TRAINING = [ # Location swap examples dspy.Example( question="En in Enschede?", history=dspy.History(messages=[ {"role": "user", "content": "Welke archieven zijn er in Den Haag?"}, {"role": "assistant", "content": "In Den Haag zijn de volgende archieven: ..."}, ]), language="nl", resolved=ResolvedQuestion( original_question="En in Enschede?", resolved_question="Welke archieven zijn er in Enschede?", is_follow_up=True, inherited_intent="geographic", inherited_entities=["archive"], slot_overrides={"city_lower": "enschede"}, confidence=0.95, ), ).with_inputs("question", "history", "language"), # Type swap examples dspy.Example( question="En bibliotheken?", history=dspy.History(messages=[ {"role": "user", "content": "Hoeveel musea zijn er in Utrecht?"}, {"role": "assistant", "content": "Er zijn 15 musea in Utrecht."}, ]), language="nl", resolved=ResolvedQuestion( original_question="En bibliotheken?", resolved_question="Hoeveel bibliotheken zijn er in Utrecht?", is_follow_up=True, inherited_intent="statistical", inherited_entities=["library", "Utrecht"], slot_overrides={"institution_type_code": "L"}, confidence=0.95, ), ).with_inputs("question", "history", "language"), # Pronoun resolution dspy.Example( question="Wie is daar de directeur?", history=dspy.History(messages=[ {"role": "user", "content": "Wat is het oudste archief in Nederland?"}, {"role": "assistant", "content": "Het oudste archief is het Nationaal Archief, opgericht in 1802."}, ]), previous_results="Previous results:\n1. Nationaal Archief (Archive)", language="nl", resolved=ResolvedQuestion( original_question="Wie is daar de directeur?", resolved_question="Wie is de directeur van het Nationaal Archief?", is_follow_up=True, inherited_intent="entity_lookup", inherited_entities=["Nationaal Archief"], slot_overrides={"institution_slug": "nationaal-archief"}, confidence=0.90, ), ).with_inputs("question", "history", "language", "previous_results"), # Self-contained question (no context needed) dspy.Example( question="Hoeveel musea zijn er in Amsterdam?", history=dspy.History(messages=[ {"role": "user", "content": "Welke archieven zijn er in Den Haag?"}, {"role": "assistant", "content": "In Den Haag zijn de volgende archieven: ..."}, ]), language="nl", resolved=ResolvedQuestion( original_question="Hoeveel musea zijn er in Amsterdam?", resolved_question="Hoeveel musea zijn er in Amsterdam?", is_follow_up=False, inherited_intent=None, inherited_entities=[], slot_overrides={}, confidence=0.98, ), ).with_inputs("question", "history", "language"), ] def optimize_context_resolver(): """Optimize the context resolver using GEPA.""" resolver = ConversationContextResolver() # Define metric def resolution_metric(example: dspy.Example, prediction: dspy.Prediction) -> float: """Score context resolution quality.""" score = 0.0 # Check resolved question matches if prediction.resolved_question == example.resolved.resolved_question: score += 0.4 elif example.resolved.resolved_question.lower() in prediction.resolved_question.lower(): score += 0.2 # Check is_follow_up detection if prediction.is_follow_up == example.resolved.is_follow_up: score += 0.2 # Check inherited intent if prediction.inherited_intent == example.resolved.inherited_intent: score += 0.2 # Check slot overrides expected_overrides = set(example.resolved.slot_overrides.items()) actual_overrides = set(prediction.slot_overrides.items()) if expected_overrides == actual_overrides: score += 0.2 elif expected_overrides & actual_overrides: score += 0.1 return score # Optimize from dspy import GEPA optimizer = GEPA( metric=resolution_metric, auto="light", ) optimized_resolver = optimizer.compile( resolver, trainset=CONTEXT_RESOLUTION_TRAINING, ) return optimized_resolver ``` ## Testing Follow-up Handling ### Test Cases ```python """Test cases for follow-up handling.""" import pytest @pytest.mark.parametrize("question,history_q,expected_resolved", [ # Location swap ( "En in Enschede?", "Welke archieven zijn er in Den Haag?", "Welke archieven zijn er in Enschede?", ), # Type swap ( "En bibliotheken?", "Hoeveel musea zijn er in Utrecht?", "Hoeveel bibliotheken zijn er in Utrecht?", ), # Province swap ( "En in Zuid-Holland?", "Hoeveel archieven zijn er in Noord-Holland?", "Hoeveel archieven zijn er in Zuid-Holland?", ), # Self-contained (should not change) ( "Hoeveel musea zijn er in Amsterdam?", "Welke archieven zijn er in Den Haag?", "Hoeveel musea zijn er in Amsterdam?", ), ]) def test_context_resolution(question, history_q, expected_resolved): """Test that follow-ups are correctly resolved.""" resolver = ConversationContextResolver() history = dspy.History(messages=[ {"role": "user", "content": history_q}, {"role": "assistant", "content": "..."}, ]) result = resolver(question=question, history=history, language="nl") assert result.resolved_question == expected_resolved @pytest.mark.parametrize("question,expected_pattern", [ ("En in Enschede?", FollowUpPattern.LOCATION_SWAP), ("En bibliotheken?", FollowUpPattern.TYPE_SWAP), ("Wie is daar de directeur?", FollowUpPattern.PRONOUN_REFERENCE), ("Meer over de eerste", FollowUpPattern.DETAIL_REQUEST), ("Hoeveel dan?", FollowUpPattern.QUANTIFIER_CHANGE), ("Welke musea zijn er in Amsterdam?", FollowUpPattern.SELF_CONTAINED), ]) def test_pattern_detection(question, expected_pattern): """Test follow-up pattern detection.""" detector = FollowUpPatternDetector(language="nl") pattern, _ = detector.detect(question) assert pattern == expected_pattern ``` ## Summary Handling follow-up prompts requires: | Component | Purpose | DSPy Integration | |-----------|---------|------------------| | **ConversationContextResolver** | Resolve elliptical references | TypedPredictor with History | | **ConversationState** | Track slots across turns | Enables slot inheritance | | **FollowUpPatternDetector** | Detect pattern type | Guides resolution strategy | | **SlotChangeDetector** | Identify changed slots | Enables efficient query modification | ### Key Principles 1. **Always resolve before matching** - Templates match against resolved questions 2. **Inherit slots from previous turns** - Only override what changed 3. **Use DSPy History** - Native support for conversation context 4. **Optimize with training data** - GEPA can improve resolution quality 5. **Cache entity results** - Enable pronoun/ordinal resolution ### Example Conversation Flow ``` Turn 1: User: "Welke archieven zijn er in Den Haag?" Resolved: "Welke archieven zijn er in Den Haag?" Template: region_institution_search Slots: {type: "A", city: "den haag"} Turn 2: User: "En in Enschede?" Resolved: "Welke archieven zijn er in Enschede?" <-- Context used Template: region_institution_search (same) Slots: {type: "A", city: "enschede"} <-- Only city changed Turn 3: User: "En bibliotheken?" Resolved: "Welke bibliotheken zijn er in Enschede?" <-- Context used Template: region_institution_search (same) Slots: {type: "L", city: "enschede"} <-- Only type changed ```