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 — The TypedDict that holds all mutable data across interview turns, including questions_asked, responses, follow_ups_pending, insights, and turn_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.

  • add reducer — An annotation applied to list fields in InterviewState as Annotated[List[T], add] that instructs LangGraph to append new items rather than replace the list on each node return. This is what allows questions_asked and responses to grow across turns without any manual list-merging logic.

  • Node function — A Python function registered with a StateGraph via add_node that receives the complete current InterviewState and returns a partial dictionary of only the fields it modifies. LangGraph merges each node's return into state before routing to the next node in the graph.

  • DiscoveryInsight — A Pydantic model representing one structured finding from the interview, with a category (pain_point, opportunity, constraint, or requirement), a confidence float, and supporting_quotes that anchor the finding to exact stakeholder statements for downstream traceability.

  • InterviewSummary — A Pydantic model that aggregates all DiscoveryInsight objects produced in a session and adds session-level fields — key_themes and recommended_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 to END once turn_count reaches max_turns, turning loosely connected functions into a coherent, stateful interview loop.

Concepts

Loading diagram...

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_questionanalyze_responseextract_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

  1. Do annotate questions_asked and responses in InterviewState with Annotated[List[...], add] — the add reducer tells LangGraph to append items across every cycle rather than replace the list on each node return, which is what gives extract_insights access to the full conversation history without any manual merging logic.
  2. Do populate supporting_quotes on every DiscoveryInsight — 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.
  3. Do route to END by comparing turn_count against max_turns in your conditional edge — explicit termination control is the only thing that prevents the generate → analyze → extract cycle from running indefinitely, and it ensures extract_insights fires only after enough conversation has accumulated to produce a non-empty insights list.

Don'ts

  1. Don't declare questions_asked or responses as plain List[str] or List[dict] without the add reducer — without Annotated[List[...], add], each node's return dict overwrites the field entirely, silently discarding prior turns so that extract_insights sees only the most recent exchange instead of the full session.
  2. Don't collapse DiscoveryInsight and InterviewSummary into a single model — flattening them loses the per-insight confidence score and supporting_quotes granularity that automated scoring depends on; the session-level key_themes and recommended_follow_ups in InterviewSummary need the individual insight objects to aggregate from, not the other way around.
  3. Don't return the full accumulated state from a node function — return only the fields you're changing — each node receives the complete InterviewState but must return a partial dict; returning the whole state object causes LangGraph to interpret unchanged add-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.