Entity Disambiguation — Architecture¶
Audience: Architects, tech leads, senior engineers evaluating design decisions and cross-module impact. This document answers "how is this module designed, and why?" Assumes technical fluency but explains domain-specific decisions.
This content was migrated from
Documentation/ENTITY_DISAMBIGUATION.mdand restructured into audience sections. Review for accuracy against the current codebase.
Context and Purpose¶
Entity Disambiguation exists as a multi-node subsystem to isolate the complex interactive resolution of ambiguous entity mentions from the rest of the conversation pipeline. When a user mentions a person, the system must determine: (1) does this person exist in the database, (2) if multiple matches exist, which one did the user mean, and (3) does the answer depend on knowing which person was meant. This requires database lookups, embedding similarity searches, LLM-based context analysis, and potentially pausing the pipeline for user input — concerns that would create unmanageable coupling if embedded in the main orchestration flow.
A key architectural driver is the relevance-aware split: the system classifies each ambiguous entity as blocking (must resolve before answering) or non-blocking (can answer first, ask later). This produces four distinct conversational flows depending on relevance and route type, each handled by a dedicated UI node.
Architecture Overview¶
graph TD
subgraph Resolution ["Entity Resolution"]
ER["Entity Resolution\nNode"]
HS["Hybrid Search\n(exact + embedding)"]
FE["Fact Enrichment\n(reranker)"]
CTX["LLM Context\nResolution"]
end
subgraph Enrichment ["Contact Enrichment Cascade"]
ERS["EntityResolution\nService"]
PDB["Person DB\n(golden source)"]
CTR["Contact Table\nResolver"]
EPR["External Provider\nResolver (Google/MS)"]
HITL_ASK["HITL: Ask\nUser for Email"]
end
subgraph Disambiguation ["Disambiguation Flows"]
DB["Disambiguation\nBlocking"]
DS["Disambiguation\nSimple (BTW)"]
DC["Disambiguation\nComplex (BTW)"]
DR["Disambiguation\nResolution"]
DA["Disambiguation\nAcknowledgment"]
CNE["Create New\nEntity"]
end
subgraph HITL_Interpret ["HITL Answer Interpretation"]
T1["Tier 1: Structured\nAction (pill click)"]
T2["Tier 2: Text Match\n(deterministic)"]
T3["Tier 3: LLM\nClassification"]
end
IC["Intent Classification\n(entities)"] --> ER
ER --> HS
HS -->|"0 matches"| NEW["Create Person"]
HS -->|"1 match"| RESOLVED["Resolved\n(direct)"]
HS -->|"2+ matches"| FE
FE --> CTX
CTX -->|"certainty >= 0.85"| RESOLVED
CTX -->|"certainty < 0.85"| REL{"Relevance?"}
RESOLVED --> ERS
ERS --> PDB
PDB -->|"has email"| DONE["Pipeline\nContinues"]
PDB -->|"no email"| CTR
CTR -->|"found"| DONE
CTR -->|"not found"| EPR
EPR -->|"found"| DONE
EPR -->|"not found"| HITL_ASK
HITL_ASK --> DONE
REL -->|"blocking"| DB
REL -->|"non_blocking\n+ simple_chat"| DS
REL -->|"non_blocking\n+ complex_chat"| DC
DB -->|"user responds"| T1
DS -->|"user responds"| T1
DC -->|"user responds"| T1
T1 -->|"no match"| T2
T2 -->|"no match"| T3
T1 -->|"matched"| DR
T2 -->|"matched"| DR
T3 -->|"responsive_answer"| DR
T3 -->|"context_switch"| IC
T3 -->|"recipient_correction"| DR
DR -->|"matched"| DONE
DR -->|"someone else"| CNE
CNE --> DONE
DS --> DA
DC --> DA
The Entity Resolution node is the entry point. It receives entity hints from Intent Classification and runs a hybrid search (exact alias match + embedding similarity) against the user's stored contacts. For single matches, it resolves directly without an LLM call. For multiple matches, it enriches candidates with stored facts, then calls an LLM for context-aware resolution with a certainty score and relevance classification. If certainty is below 0.85, the system triggers one of three disambiguation UI flows based on relevance and route type. The Disambiguation Resolution node processes the user's response (via fast-path keyword matching or LLM semantic matching) and either resolves the entity, creates a new one, or times out.
Component Responsibilities¶
| Component | Responsibility |
|---|---|
Entity Resolution Node (entity_resolution.py) |
Entry point. Receives entity hints from Intent Classification, runs hybrid candidate search, invokes LLM for context-aware resolution, detects singleton role conflicts, creates Person records for new entities. Includes umlaut normalization (normalize_entity_name()) for German name equivalence (Müller = Mueller = Muller). |
Hybrid Search (find_candidates_hybrid()) |
Two-stage candidate selection: exact alias match (fast, certain) then embedding similarity search (catches typos, variations). Merges and deduplicates results. Exact full-name match fast path resolves deterministically without LLM when exactly one candidate matches the full display name. |
| Fact Enrichment (reranker-based) | Replaces hardcoded type-based fact filtering with reranker-based query relevance ranking (ADR-003). Facts are ranked by relevance to the user's message, so "broke her leg" ranks high for "wishing a good recovery" regardless of fact type. Falls back to recency sort if reranker unavailable. |
LLM Context Resolution (_resolve_with_llm_context()) |
Calls LLM with candidate facts and conversation context. Returns certainty score, best match, and relevance classification (blocking vs non-blocking). Uses ContextResolutionResult Pydantic schema. Context resolution prompt uses graduated Q2 scoring: direct event reference (+0.45) vs unique topical match (+0.30). |
EntityResolutionService (services/contact/service.py) |
Enrichment cascade for contact info (spec §5.4): Person table → Contact table → External providers → HITL signal. Domain-agent agnostic — no imports from agent packages. Every resolution that discovers new emails enriches the Person record so the system never asks the same question twice. |
ContactTableResolver (resolvers/contact_table.py) |
Queries the Contact table by person name to find email addresses from synced email headers. Uses tiered matching: exact, substring, then token-based (handles "Surname, First" vs "First Surname"). |
ExternalProviderResolver (resolvers/external_provider.py) |
Wraps Google People API and MS Graph contact lookups into a single resolver. Resolution order: Google first, then Microsoft. Stops at the first provider that returns results. |
HITL Answer Classifier (hitl_answer_classifier.py) |
Three-tier classification for HITL responses. Tier 1: structured JSON action (pill click) — deterministic. Tier 2: text match to presented options — deterministic. Tier 3: LLM classification for ambiguous free text — detects responsive_answer, recipient_correction, context_switch, and escape intents. |
Disambiguation Blocking Node (disambiguation_blocking.py) |
Generates ask-only HITL question for blocking entities. Streams response to frontend with clickable <swi-reply> options. Sets user_in_the_loop to pause pipeline. |
Disambiguation Simple Text (disambiguation_simple_text.py) |
Generates answer + "by the way" question for non-blocking entities on simple_chat route. Answers the user's question first, then appends disambiguation. |
Disambiguation Complex Text (disambiguation_complex_text.py) |
Generates answer + "by the way" question for non-blocking entities on complex_chat route. Runs agent execution first, then appends disambiguation. |
Disambiguation Resolution Node (disambiguation_resolution.py) |
Processes user's disambiguation response. Uses fast-path classifier (action markers, exact name match, number selection) before falling back to LLM semantic matching. Persists pending facts, handles sequential multi-entity flow. |
Disambiguation Acknowledgment (disambiguation_acknowledgment.py) |
Generates brief acknowledgment ("Got it, Thomas Weber!") after non-blocking disambiguation is resolved. |
Create New Entity Node (create_new_entity.py) |
Handles "someone else" flow — creates a new Person record and asks for relationship details. |
Role Lookup (_lookup_person_by_role()) |
Three-strategy role resolution: exact match, synonym-based match (ROLE_SYNONYMS map), semantic embedding search. Used for role-only entities like "my wife." |
| Sequential State Helpers | Functions for tracking multi-entity disambiguation: get_current_disambiguation_entity(), advance_to_next_entity(), mark_entity_resolved(), build_sequential_ambiguity_state(). |
Data Model¶
The module uses both persistent data (Person records in PostgreSQL) and in-flight state (the entity_ambiguity structure in pipeline state).
| Structure | Contents | Lifecycle |
|---|---|---|
Person (PostgreSQL) |
person_id, display_name, role_to_user, aliases, is_singleton_role, embedding, electronic_addresses, phone_numbers |
Persistent. Created by Entity Resolution when a new entity is detected. Queried for candidate matching. |
UserFact (PostgreSQL) |
Facts linked to a Person via subject_entity_id |
Persistent. Used to enrich candidates with distinguishing facts during resolution. Pending facts persisted after disambiguation. |
entity_ambiguity (state) |
active, created_at, current_index, entities[] (each with text, candidates[], relevance, status, resolved_person_id), pending_facts[], original_message, original_intent_classification |
In-flight. Created by Entity Resolution when ambiguity is detected. Updated by Disambiguation Resolution as entities are resolved. Cleared when all entities are resolved or on timeout. |
resolved_entities (state) |
List of {text, is_ambiguous, is_new, matched_person_id, email, phone} |
In-flight. Written by Entity Resolution (for direct matches) or Disambiguation Resolution (after user selection). Consumed by downstream nodes. |
| Resolution flags (state) | btw_disambiguation_resolved, blocking_disambiguation_resolved |
In-flight. Set by Disambiguation Resolution to signal routing: BTW triggers acknowledgment node, blocking triggers answer generation. |
Key Design Decisions¶
Decision 1: Relevance-aware disambiguation (blocking vs non-blocking)
- Chosen: The LLM classifies each ambiguous entity's relevance to the query. Blocking entities trigger ask-first flows; non-blocking entities trigger answer-first flows.
- Rejected: Always asking before answering (the original design before the relevance system).
- Rationale: The old approach produced redundant answers — the system would answer, then ask, then answer again. The relevance split eliminates redundancy and improves UX. The trade-off is a more complex routing graph with four distinct flows instead of one.
Decision 2: Hybrid search (exact + embedding) for candidate selection
- Chosen: Two-stage search: exact alias match first (fast, O(n) scan), then embedding similarity search (catches typos and variations).
- Rejected: Embedding-only search, or database full-text search.
- Rationale: Exact matching handles the common case (user types name correctly) with no latency cost. Embedding search covers edge cases (typos, nicknames, transliterations). The combination provides both speed and robustness.
Decision 3: Fast-path classifier before LLM for disambiguation responses
- Chosen: A deterministic fast-path classifier handles action markers from frontend pill clicks, number selections, and exact name matches. LLM is only called for free-text responses that need semantic understanding.
- Rejected: Always using LLM to interpret disambiguation responses.
- Rationale: Most disambiguation responses come from pill clicks, which include machine-readable action markers (e.g.,
[ACTION:select_0]). Routing these through the LLM would add 200–400ms latency for no accuracy benefit. The LLM is reserved for ambiguous typed responses.
Decision 4: Sequential multi-entity disambiguation
- Chosen: When multiple entities are ambiguous, resolve them one at a time across sequential turns, prioritizing blocking entities.
- Rejected: Resolving all entities in a single multi-option question.
- Rationale: Presenting all ambiguous entities at once creates overwhelming UI and confuses users. Sequential resolution is more natural and allows the system to skip non-blocking entities if they become irrelevant. The trade-off is more conversation turns for multi-entity cases.
Interfaces and Contracts¶
| Interface | Direction | Consumer | Contract |
|---|---|---|---|
state["intent_classification"]["entities"] |
Inbound | Intent Classification node | List of ExtractedEntity dicts with text, type, role, is_singleton |
state["resolved_entities"] |
Outbound | Semantic Retrieval, Fact Extraction, Planner | List of {text, is_ambiguous, is_new, matched_person_id, email, phone} |
state["entity_ambiguity"] |
Outbound/Internal | Disambiguation UI nodes, Disambiguation Resolution | Sequential state structure with entities[], current_index, pending_facts[], original_message |
state["user_in_the_loop"] |
Outbound | Global Supervisor routing | UserInTheLoop object that triggers pipeline interrupt for user response |
Person table (PostgreSQL) |
Inbound/Outbound | Person Service, Fact Extraction | Read for candidate search; write for new entity creation |
UserFact table (PostgreSQL) |
Inbound | Fact Extraction Service | Read for candidate enrichment during context resolution |
<swi-reply> tags |
Outbound | Frontend | HTML tags in streamed response containing clickable disambiguation options with action markers |
Breaking change note: The entity_ambiguity state structure is consumed by all disambiguation UI nodes and the resolution node. Changes to this structure require coordinating updates across 6+ node files and the routing logic.
Decision 5: Enrichment cascade for contact resolution (SA-156)
- Chosen: Four-step cascade: Person DB → Contact table → External providers (Google/MS) → HITL. Each step that discovers emails enriches the Person record. The service is domain-agent agnostic.
- Rejected: Keeping contact resolution inside the productivity facade with direct provider calls.
- Rationale: The old approach created duplicate Person records when HITL provided an email for an already-disambiguated person. The cascade ensures enrichment flows through a single service, and
PendingContact.person_idcarries the identity through the full HITL lifecycle.
Decision 6: Three-tier HITL answer interpretation (SA-156)
- Chosen: Tier 1 (structured action) and Tier 2 (text match) are deterministic and handle all current UI interactions. Tier 3 (LLM) only fires for ambiguous free text. Classifications:
responsive_answer,recipient_correction,context_switch,escape. - Rejected: Always using LLM for all HITL responses.
- Rationale: Tiers 1 and 2 handle >95% of responses with zero latency. Tier 3 catches edge cases like voice input ("no, I meant leo.cozzoli@fintama.com") and context switches ("actually, check my calendar"). Defaults to
responsive_answeron LLM failure (same as previous behavior).
Decision 7: Umlaut normalization via expansion, not collapsing (SA-156)
- Chosen: Expand Unicode umlauts to digraphs (ü→ue, ö→oe, ä→ae, ß→ss), then strip remaining diacritics. Both
MüllerandMuellernormalize tomueller. - Rejected: Collapsing digraphs to base vowels (ue→u, oe→o). This mangled non-German names: Joel→jol, Sue→su, Bauer→bar.
- Rationale: Expansion is safe for all names — only actual Unicode characters are transformed. ASCII names pass through untouched.
Known Trade-offs and Debt¶
- Full table scan for alias matching: The exact alias match queries all Person records for an avatar and checks aliases in Python rather than using a database index. This is acceptable for the current scale (users have <100 contacts) but would need indexing for larger contact lists.
- Embedding search is in-application: Cosine similarity is computed in Python over all Person embeddings rather than using a vector database index (e.g., pgvector ANN). This limits scalability but avoids infrastructure complexity at current scale.
- No disambiguation learning: The system doesn't learn from past disambiguation choices. If the user always means "Thomas Weber (friend)" when they say "Thomas," the system still asks every time there are multiple Thomases. A frequency-based prior could improve this.
- Singleton role detection relies on LLM: Whether a role is "singleton" (typically one person) is determined by the LLM during intent classification. Misclassification (e.g., treating "cousin" as singleton) can cause false role conflict alerts.
- One-turn timeout is aggressive: If the user doesn't respond to the disambiguation question on the immediately next turn, the system times out and drops pending facts. A more forgiving approach would allow the user to return to disambiguation across multiple turns.