Free lesson
Run LLM-driven discovery interviews with LangGraph state
You build a DiscoveryInterviewAgent as a LangGraph state machine that generates contextual follow-up questions and extracts structured insights via Pydantic.
~25 min read · Free to read — no subscription required.
Discovery interviews
Introduction
Engineers often spend weeks conducting one-on-one discovery interviews to surface AI use cases, only to find that unstructured notes leave critical pain points buried or missed entirely. Manual facilitation also makes it hard to probe deeper when a stakeholder hints at a past failure or an untested assumption. This lesson teaches you how to build a DiscoveryInterviewAgent using LangGraph — a stateful, adaptive interview system that generates contextual follow-up questions, captures responses with structured metadata, and extracts scored insights about AI opportunities and data readiness constraints from every client conversation.
Key Terminology
-
InterviewState— TheTypedDictthat holds all mutable data across interview turns, includingquestions_asked,responses,follow_ups_pending,insights, andturn_count. Every node in the LangGraph graph reads from and writes back into this single shared object, giving each node access to the full accumulated conversation history. -
addreducer — An annotation applied to list fields inInterviewStateasAnnotated[List[T], add]that instructs LangGraph to append new items rather than replace the list on each nodereturn. This is what allowsquestions_askedandresponsesto grow across turns without any manual list-merging logic. -
Node function — A Python function registered with a
StateGraphviaadd_nodethat receives the complete currentInterviewStateand returns a partial dictionary of only the fields it modifies. LangGraph merges each node'sreturninto state before routing to the next node in the graph. -
DiscoveryInsight— A Pydantic model representing one structured finding from the interview, with acategory(pain_point, opportunity, constraint, or requirement), aconfidencefloat, andsupporting_quotesthat anchor the finding to exact stakeholder statements for downstream traceability. -
InterviewSummary— A Pydantic model that aggregates allDiscoveryInsightobjects produced in a session and adds session-level fields —key_themesandrecommended_follow_ups— giving consultants a prioritized view for deciding which AI use cases warrant a full data readiness assessment. -
StateGraph— The LangGraph construct that registers node functions and directed edges between them, including the conditional route toENDonceturn_countreachesmax_turns, turning loosely connected functions into a coherent, stateful interview loop.
Concepts
Stateful Conversation as a Graph
Discovery interviews are non-linear: a stakeholder's offhand remark about a past failure may be more valuable than their answer to the scripted question that follows it. Representing the interview as a LangGraph StateGraph lets you encode that branching logic explicitly — generate_question routes to analyze_response, which routes to extract_insights or loops back for another turn — without burying the control flow in nested conditionals. The graph's edges describe when to move forward and when to probe deeper, making the interview's logic inspectable and testable. The agent doesn't follow a fixed script; it follows a traversal policy defined by the graph, and the state accumulated so far determines which path each edge takes (see Code Walkthrough).
The Reducer Pattern: Accumulation Without Bookkeeping
LangGraph's default behavior is to replace a state field with whatever a node returns. For a single-turn interaction that's sufficient, but a multi-turn interview needs questions_asked and responses to grow across every cycle. The Annotated[List[T], add] annotation changes the merge behavior from replacement to append: each node returns only the new items it produced, and LangGraph handles the concatenation. By the time extract_insights fires at the end of a session, the complete conversation history is already assembled in state — no manual list management or global accumulator required. This is the core reason the DiscoveryInterviewAgent can run for an arbitrary number of turns without accumulation bugs or state drift between nodes.
Two Levels of Structure: Insight vs. Summary
The lesson distinguishes two Pydantic output shapes that serve different consumers. DiscoveryInsight is granular: each object captures one finding, its category, a confidence score, and supporting_quotes that trace it back to what the stakeholder actually said. Keeping findings at this granularity makes them machine-readable — a downstream scoring step can filter by category, rank by confidence, or cluster by related_use_cases without parsing free text.
InterviewSummary is the session view: it collects all insights from one interview and surfaces key_themes and recommended_follow_ups for the consultant who must decide which use cases warrant a full data readiness assessment. The two-level design means the same raw interview output feeds both an automated scoring pipeline and a human decision-making layer — without duplicating data or losing the traceability that supporting_quotes provides (see Code Walkthrough).
Code Walkthrough
Now that you understand the four states the DiscoveryInterviewAgent moves through — question generation, response capture, follow-up analysis, and insight extraction — the next step is wiring those states into a LangGraph graph and defining the Pydantic models that structure the agent's output.
The foundation is InterviewState, a TypedDict whose list fields use Annotated with the add reducer. This tells LangGraph to append new items onto questions_asked and responses rather than replace them on each node return, which is what lets the agent accumulate a full conversation across turns without manual merging logic.
Code snippetpython
1from langgraph.graph import StateGraph, END 2from typing import TypedDict, List, Annotated 3from operator import add 4 5class InterviewState(TypedDict): 6 """State maintained across interview turns.""" 7 topic: str 8 questions_asked: Annotated[List[str], add] 9 responses: Annotated[List[dict], add] 10 follow_ups_pending: List[str] 11 insights: List[dict] 12 turn_count: int 13 max_turns: int 14 15def generate_question(state: InterviewState) -> dict: 16 """Generate the next contextual question.""" 17 ... 18 19def analyze_response(state: InterviewState) -> dict: 20 """Analyze response and determine follow-up needs.""" 21 ... 22 23def extract_insights(state: InterviewState) -> dict: 24 """Extract structured insights from conversation history.""" 25 ... 26 27graph = StateGraph(InterviewState) 28graph.add_node("generate_question", generate_question) 29graph.add_node("analyze_response", analyze_response) 30graph.add_node("extract_insights", extract_insights)
Each node function receives the full current state and returns a partial dictionary; LangGraph merges the return value back into state before the next node runs. The generate_question → analyze_response → extract_insights cycle continues until turn_count reaches max_turns, at which point the graph routes to END. Fields annotated with add accumulate across every cycle, so by the time extract_insights fires, the complete conversation history is available in questions_asked and responses without any manual bookkeeping.
Once the graph has accumulated enough turns, extract_insights populates structured Pydantic models. DiscoveryInsight captures individual findings — pain points, opportunities, constraints, and requirements — together with a confidence score and supporting_quotes for traceability back to what the stakeholder actually said. InterviewSummary aggregates all insights from a session and surfaces recommended_follow_ups and key_themes that feed the downstream use case scoring step.
Code snippetpython
1from pydantic import BaseModel 2from typing import List 3 4class DiscoveryInsight(BaseModel): 5 """Structured insight from interview analysis.""" 6 category: str # pain_point, opportunity, constraint, requirement 7 description: str 8 stakeholder: str 9 confidence: float 10 supporting_quotes: List[str] 11 related_use_cases: List[str] 12 13class InterviewSummary(BaseModel): 14 """Complete summary of an interview session.""" 15 session_id: str 16 stakeholder_role: str 17 duration_minutes: int 18 insights: List[DiscoveryInsight] 19 recommended_follow_ups: List[str] 20 key_themes: List[str]
The separation between DiscoveryInsight and InterviewSummary is intentional: individual insights stay granular enough for automated scoring while the summary provides the session-level view a consultant needs to prioritize which AI use cases warrant a full data readiness assessment.
You'll know it works when the graph executes without error and extract_insights returns an InterviewSummary whose insights list is non-empty, with each entry carrying a populated confidence score and at least one entry in supporting_quotes.
Do's and Don'ts
Having walked through the material above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do annotate
questions_askedandresponsesinInterviewStatewithAnnotated[List[...], add]— theaddreducer tells LangGraph to append items across every cycle rather than replace the list on each nodereturn, which is what givesextract_insightsaccess to the full conversation history without any manual merging logic. - ✓Do populate
supporting_quoteson everyDiscoveryInsight— anchoring each finding to verbatim stakeholder language keeps insights traceable back to what was actually said, which is what allows the downstream use case scoring step to validate confidence scores rather than treating them as black-box outputs. - ✓Do route to
ENDby comparingturn_countagainstmax_turnsin your conditional edge — explicit termination control is the only thing that prevents the generate → analyze → extract cycle from running indefinitely, and it ensuresextract_insightsfires only after enough conversation has accumulated to produce a non-emptyinsightslist.
Don'ts
- ✗Don't declare
questions_askedorresponsesas plainList[str]orList[dict]without theaddreducer — withoutAnnotated[List[...], add], each node'sreturndict overwrites the field entirely, silently discarding prior turns so thatextract_insightssees only the most recent exchange instead of the full session. - ✗Don't collapse
DiscoveryInsightandInterviewSummaryinto a single model — flattening them loses the per-insightconfidencescore andsupporting_quotesgranularity that automated scoring depends on; the session-levelkey_themesandrecommended_follow_upsinInterviewSummaryneed the individual insight objects to aggregate from, not the other way around. - ✗Don't return the full accumulated state from a node function —
returnonly the fields you're changing — each node receives the completeInterviewStatebut must return a partial dict; returning the whole state object causes LangGraph to interpret unchangedadd-annotated fields as new items to append, duplicating every prior question and response on every cycle.
Keep going with Forward Deployed GenAI Engineering
Create a free account to track your progress and open this lesson in the full learning view. Subscribe to unlock the entire path — every goal, the hands-on labs, quizzes, and your verifiable skill graph — from . Cancel anytime.