Building an AI-Powered Registration System with LangGraph, FastAPI, and React
Building an AI-Powered Registration System with LangGraph, FastAPI, and React
https://medium.com/@phurlocker/building-an-ai-powered-registration-system-with-langgraph-fastapi-and-react-3e651ce83d71
https://www.imooc.com/article/379279
In this post, I share an AI-powered registration system built with FastAPI, React, LangGraph, DSPy, Guardrails AI, and MLflow. The system guides users through a structured conversation, collecting registration details while leveraging AI validation to ensure input accuracy. It achieves dynamic adaptability by using AI for validation loops, multi-step workflow navigation, and real-time data-driven adjustments. While the system automates structured interactions, it stops short of full agentic behavior, maintaining a balance between AI-driven guidance and a predictable user experience.
A fully agentic system would take this further by autonomously determining the sequence of interactions, adapting workflows based on broader objectives, and self-correcting without predefined rules. Instead of following a structured path, it would make contextual decisions, reprioritize tasks dynamically, and refine its approach.
Trade-offs between predictability and autonomy:
- Predictability ensures compliance, reproducibility, and user trust, especially important in regulated industries like finance and healthcare, where decision-making must be auditable and explainable.
- Autonomy allows AI to adapt and optimize workflows without rigid constraints, potentially improving efficiency but at the cost of reduced oversight.
- Hybrid approaches (like this system) balance AI-driven flexibility with human oversight and rule-based constraints, allowing dynamic interactions while maintaining governance and control.
While fairly simplistic, this system shows how AI can enhance structured workflows without sacrificing transparency and reliability, demonstrating a practical path toward integrating AI in user-facing applications while maintaining explainability.
Key Features:
- Conversational UI: Users interact with a chatbot-like interface to provide registration details step by step with the option to skip some steps.
- FastAPI Backend: Handles session management, state transitions, and AI-powered validation.
- React Frontend: Provides an intuitive user experience, dynamically updating based on user responses.
- LangGraph for Flow Control: Manages multi-step conversation logic, ensuring smooth transitions between registration steps.
- AI-Powered Validation with OpenAI GPT-3.5 Turbo: The system validates user inputs using OpenAI’s GPT-3.5 Turbo, implemented in two ways for comparison:
- Using the OpenAI Python Library: Directly calling OpenAI’s API to generate structured validation responses.
- Using DSPy for Prompt Optimization: Leveraging DSPy, a declarative AI framework, to optimize prompts and enhance the reliability of AI-generated validations.
- Comparison: While the OpenAI library offers a straightforward API call, DSPy improves prompt efficiency by learning from past interactions and refining response generation.
- Guardrails AI for Structured Validation: Ensures inputs conform to expected formats, correcting minor errors and requesting clarifications when needed.
- MLflow for Logging: Tracks validation performance, logs interactions, and supports debugging for model improvement.
Tech Stack:
- Python Backend: FastAPI, LangGraph, DSPy, MLflow, Guardrails AI
- Frontend: React (Vite), Material UI
- Database: SQLite (for session persistence)
- Deployment: Docker, Docker Compose
Conversational UI
The conversational registration system covered in this post transforms traditional form-filling into a dynamic, guided conversation that improves user engagement and data quality. Instead of presenting a static form, the system progressively collects user information, adapting based on responses. While the UI is simple, it serves to highlight how you can create a guided yet open ended experience with powerful real-time validation and formatting.

I’m focusing primarily on the backend functionality and code in this post. because it drives the dynamic nature of the frontend. I provide a link to the full code repository so you can experiment with the frontend and backend code yourself.
Graph-based Workflows with LangGraph
In this example, LangGraph structures the registration flow by defining a graph of interactions, where each node represents a specific question (e.g., “What is your email?”), and edges determine how the conversation progresses based on user responses. The system can dynamically skip steps, request clarifications, or adjust the flow based on validation feedback, ensuring a context-aware and adaptive user experience. By leveraging LangGraph, the app maintains state across interactions, resumes sessions when needed, and ensures that users move through the registration process smoothly, without redundant or irrelevant questions. The image below show the Directed Cyclic Graph (DCG) that defines the flow. Note that the ask_address and ask_phone nodes can be skipped by the user (represented by dashed lines):

LangGraph is a framework designed for building structured, stateful, and multi-step AI applications using graph-based workflows. Unlike prompt chaining or sequential APIs, LangGraph enables flexible branching, conditional logic, and memory persistence, making it ideal for complex conversational applications. Note that I use SQLite to store the state of the conversation in this example but I could have used LangGraph’s memory persistence instead. The Python class below is responsible for constructing the DCG which is compiled for use in the FastAPI backend.
from langgraph.graph import END
from db.sqlite_db import RegistrationState
from graph.base_graph import BaseGraphManager
import logging
class RegistrationGraphManager(BaseGraphManager):
"""Specialized graph manager with domain-specific (registration) logic."""
def __init__(self, name: str, question_map: dict):
# We pass RegistrationState as the state_class
super().__init__(name, question_map, RegistrationState)
def _build_graph(self):
"""
Override the base build method to incorporate optional steps,
or domain-specific edges.
"""
def ask_question(state: RegistrationState, question_text: str):
logging.info(f"[Registration] Transitioning to: {question_text}")
return {
"collected_data": state.collected_data,
"current_question": question_text,
}
def create_question_node(question_text):
return lambda s: ask_question(s, question_text)
# Add each node
for key, question_text in self.question_map.items():
self.graph.add_node(key, create_question_node(question_text))
# Set entry point
self.graph.set_entry_point("ask_email")
# Normal edges for the first two nodes
self.graph.add_edge("ask_email", "ask_name")
def path_func(state: RegistrationState):
"""Determine where to go next, considering multiple skips."""
skip_address = state.collected_data.get("skip_ask_address", False)
skip_phone = state.collected_data.get("skip_ask_phone", False)
if skip_address and skip_phone:
return "ask_username" # Skip both address and phone
elif skip_address:
return "ask_phone" # Skip address only
else:
return "ask_address" # Default to asking for address first
self.graph.add_conditional_edges(
source="ask_name",
path=path_func,
path_map={
"ask_address": "ask_address",
"ask_phone": "ask_phone",
"ask_username": "ask_username",
},
)
self.graph.add_edge("ask_address", "ask_phone")
self.graph.add_conditional_edges(
source="ask_phone",
path=lambda state: (
"ask_username"
if state.collected_data.get("skip_ask_phone", False)
else "ask_username"
),
path_map={"ask_username": "ask_username"},
)
self.graph.add_edge("ask_username", "ask_password")
self.graph.add_edge("ask_password", END)
AI-powered User Answer Validation
DSPy is a framework for interacting with language models programmatically. In this application, DSPy defines a validation signature that takes a user’s response and the current question as inputs, then produces structured outputs:
- status (
valid
,clarify
, orerror
) - feedback (explanation for any issues)
- formatted_answer (a properly structured version of the response)
The validation logic is powered by OpenAI’s GPT-3.5-turbo
, which DSPy calls using a declarative dspy.Predict
function. By leveraging DSPy, the registration system ensures consistent response formatting (e.g., lowercase emails, standardized phone numbers, properly capitalized names) while detecting incomplete or unclear inputs.
Additionally, DSPy integrates well with Guardrails AI, adding an extra layer of validation to enforce expected response patterns (covered in a later section). The implementation also incorporates MLflow, which captures traces of each validation step for model monitoring. Below is the DSPy validator code demonstrating this integration.
import guardrails as gd
import dspy
from validation.base_validator import BaseValidator
from pydantic import ValidationError
from validation.validated_response import ValidatedLLMResponse
from typing import Literal
import logging
import json
import mlflow
from helpers.config import OPENAI_API_KEY, MLFLOW_ENABLED, MLFLOW_EXPERIMENT_NAME
dspy.settings.configure(lm=dspy.LM(model="gpt-3.5-turbo", api_key=OPENAI_API_KEY))
if MLFLOW_ENABLED:
mlflow.dspy.autolog()
guard = gd.Guard.for_pydantic(ValidatedLLMResponse)
# Define DSPy Signature
class ValidateUserAnswer(dspy.Signature):
"""Validates and formats user responses. Should return 'valid', 'clarify', or 'error'."""
question: str = dspy.InputField()
user_answer: str = dspy.InputField()
status: Literal["valid", "clarify", "error"] = dspy.OutputField(
desc="Validation status: 'valid', 'clarify', or 'error'. Return 'valid' if the input contains all necessary information."
)
feedback: str = dspy.OutputField(
desc="Explanation if response is incorrect or needs details."
)
formatted_answer: str = dspy.OutputField(
desc="Return the response with proper formatting. Example: "
"- Emails: Lowercase (e.g., 'John@gmail.com' → 'john@gmail.com'). "
"- Names: Capitalize first & last name (e.g., 'john doe' → 'John Doe'). "
"- Addresses: Capitalize & ensure complete info (e.g., '123 main st,newyork,ny' → '123 Main St, New York, NY 10001'). "
"- Phone numbers: Format as (XXX) XXX-XXXX (e.g., '1234567890' → '(123) 456-7890'). "
"An address must include: street number, street name, city, state, and ZIP code. "
"Reject responses that do not meet this format with status='clarify'."
"If the response cannot be formatted, return the original answer."
)
run_llm_validation = dspy.Predict(ValidateUserAnswer)
class DSPyValidator(BaseValidator):
"""Uses DSPy with Guardrails AI for structured validation."""
def validate(self, question: str, user_answer: str):
"""Validates user response, applies guardrails, and logs to MLflow."""
try:
raw_result = run_llm_validation(question=question, user_answer=user_answer)
structured_validation_output = guard.parse(json.dumps(raw_result.toDict()))
validated_dict = dict(structured_validation_output.validated_output)
if MLFLOW_ENABLED:
mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
with mlflow.start_run(nested=True):
mlflow.log_param("validation_engine", "DSPy + Guardrails AI")
mlflow.log_param("question", question)
mlflow.log_param("input_answer", user_answer)
mlflow.log_param("status", validated_dict["status"])
mlflow.log_param(
"formatted_answer", validated_dict["formatted_answer"]
)
return {
"status": validated_dict["status"],
"feedback": validated_dict["feedback"],
"formatted_answer": validated_dict["formatted_answer"],
}
except ValidationError as ve:
logging.error(f"Validation error: {str(ve)}")
return {
"status": "error",
"feedback": "Output validation failed.",
"formatted_answer": user_answer,
}
except Exception as e:
logging.error(f"Validation error: {str(e)}")
return {
"status": "error",
"feedback": "An error occurred during validation.",
"formatted_answer": user_answer,
}
The ValidateUserAnswer
class in the code snippet above defines a DSPy signature, which serves as a declarative specification of the inputs and outputs for the user’s answer. This ensures thatthe LLM adheres to a predefined schema, making validation more predictable and interpretable. In this use case, the signature follows the structure:
question, user_answer → status, feedback, formatted_answer
This structured definition is passed into dspy.Predict
, allowing DSPy to apply it directly to user inputs. MLflow captures and logs the exact requests DSPy sends to gpt-3.5-turbo
, along with the model’s structured responses.
DSPy follows the System, User, and Assistant prompt structure, which is a standard approach when interacting with OpenAI’s GPT models. This format helps maintain conversational context, ensuring that the validation logic is framed consistently for the LLM.
System
Your input fields are:
question (str)
user_answer (str)
Your output fields are:
status (typing.Literal['valid', 'clarify', 'error']): Validation status: 'valid', 'clarify', or 'error'. Return 'valid' if the input contains all necessary information.
feedback (str): Explanation if response is incorrect or needs details.
formatted_answer (str): Return the response with proper formatting. Example: - Emails: Lowercase (e.g., 'John@gmail.com' → 'john@gmail.com'). - Names: Capitalize first & last name (e.g., 'john doe' → 'John Doe'). - Addresses: Capitalize & ensure complete info (e.g., '123 main st,newyork,ny' → '123 Main St, New York, NY 10001'). - Phone numbers: Format as (XXX) XXX-XXXX (e.g., '1234567890' → '(123) 456-7890'). An address must include: street number, street name, city, state, and ZIP code. Reject responses that do not meet this format with status='clarify'.If the response cannot be formatted, return the original answer.
All interactions will be structured in the following way, with the appropriate values filled in.
[[ ## question ## ]] {question}
[[ ## user_answer ## ]] {user_answer}
[[ ## status ## ]] {status} # note: the value you produce must exactly match (no extra characters) one of: valid; clarify; error
[[ ## feedback ## ]] {feedback}
[[ ## formatted_answer ## ]] {formatted_answer}
[[ ## completed ## ]]
In adhering to this structure, your objective is: Validates and formats user responses. Should return 'valid', 'clarify', or 'error'.
User
[[ ## question ## ]] What is your address?
[[ ## user_answer ## ]] 112 main st los angeles ca 90210
Respond with the corresponding output fields, starting with the field [[ ## status ## ]] (must be formatted as a valid Python typing.Literal['valid', 'clarify', 'error']), then [[ ## feedback ## ]], then [[ ## formatted_answer ## ]], and then ending with the marker for [[ ## completed ## ]].
Assistant
[[ ## status ## ]] valid
[[ ## feedback ## ]] N/A
[[ ## formatted_answer ## ]] 112 Main St, Los Angeles, CA 90210
[[ ## completed ## ]]
You can see that the model validated the input as correct and returned a well-formatted address. This structured validation helps maintain data integrity by enforcing standardized formats across responses.
Now, let’s compare the same user input using the OpenAI Python client without DSPy. Below is the validator code that directly interacts with OpenAI’s API.
Note that the application allows seamless switching between the DSPy-based validator and the raw OpenAI Python implementation by modifying an environment variable. This enables easy experimentation, letting you see how DSPy’s structured approach influences validation and formatting.
import openai
import guardrails as gd
import json
from typing import Dict
from validation.base_validator import BaseValidator
from validation.validated_response import ValidatedLLMResponse
from helpers.config import OPENAI_API_KEY, MLFLOW_ENABLED, MLFLOW_EXPERIMENT_NAME
import mlflow
if MLFLOW_ENABLED:
mlflow.openai.autolog()
guard = gd.Guard.for_pydantic(ValidatedLLMResponse)
class ChatGPTValidator(BaseValidator):
"""ChatGPT-based implementation of the validation strategy."""
def validate(self, question: str, user_answer: str) -> Dict[str, str]:
"""Uses OpenAI ChatGPT to validate responses."""
client = openai.OpenAI(api_key=OPENAI_API_KEY)
response = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[
{
"role": "system",
"content": (
"You are a helpful assistant that validates user responses. "
"You must respond in JSON format with a clear validation status. "
"If the response is valid, return: {'status': 'valid', 'feedback': '<feedback message>', 'formatted_answer': '<formatted response>'}. "
"If the response needs clarification, return: {'status': 'clarify', 'feedback': '<clarification message>', 'formatted_answer': '<original response>'}."
"Ensure proper formatting: lowercase emails, capitalized names, standardized phone numbers and addresses."
),
},
{
"role": "user",
"content": f"Question: {question}\nUser Answer: {user_answer}\nValidate the answer.",
},
],
response_format={"type": "json_object"},
)
try:
validation_str = response.choices[0].message.content.strip()
validation_result = json.loads(validation_str)
print(validation_result)
# Apply Guardrails AI
validated_result = guard.parse(json.dumps(validation_result))
validated_dict = validated_result.validated_output
print(validated_dict)
except (json.JSONDecodeError, KeyError):
validation_result = {
"status": "error",
"feedback": "Error processing validation response.",
"formatted_answer": user_answer,
}
if MLFLOW_ENABLED:
mlflow.set_experiment(MLFLOW_EXPERIMENT_NAME)
with mlflow.start_run(nested=True):
mlflow.log_param("validation_engine", "ChatGPT")
mlflow.log_param("question", question)
mlflow.log_param("input_answer", user_answer)
mlflow.log_param("status", validated_dict.get("status", "error"))
mlflow.log_param(
"feedback", validated_dict.get("feedback", "No feedback")
)
mlflow.log_param(
"formatted_answer",
validated_dict.get("formatted_answer", user_answer),
)
return validated_dict
Unlike the DSPy implementation, where validation is structured using a declarative signature, the OpenAI Python client approach requires explicitly defining the system and user roles in the API call.
Without DSPy, the validation logic has to be manually structured in the prompt rather than being programmatically defined in structured input-output fields. This makes it harder to enforce consistency, debug issues, and integrate additional validation layers like Guardrails AI.
To illustrate the difference, here is the actual content being sent to gpt-3.5-turbo
using the OpenAI Python client. The prompt structure is manually designed rather than programmatically enforced like in DSPy:
[
{
"role": "system",
"content": "You are a helpful assistant that validates user responses. You must respond in JSON format with a clear validation status. If the response is valid, return: {'status': 'valid', 'feedback': '<feedback message>', 'formatted_answer': '<formatted response>'}. If the response needs clarification, return: {'status': 'clarify', 'feedback': '<clarification message>', 'formatted_answer': '<original response>'}.Ensure proper formatting: lowercase emails, capitalized names, standardized phone numbers and addresses."
},
{
"role": "user",
"content": "Question: What is your address?\nUser Answer: 112 main st los angeles ca 90210\nValidate the answer."
}
]
[
{
"finish_reason": "stop",
"index": 0,
"logprobs": null,
"message": {
"content": "{\n\t\"status\": \"valid\",\n\t\"feedback\": \"Address is valid.\",\n\t\"formatted_answer\": \"112 Main St, Los Angeles, CA 90210\"\n}",
"refusal": null,
"role": "assistant",
"audio": null,
"function_call": null,
"tool_calls": null
}
}
]
Both validators returned the same response. The input was deemed valid, and the properly formatted address was "112 Main St, Los Angeles, CA 90210"
. That’s because the prompts in the second validator are well structured, which took various iterations to achieve.
While the OpenAI Python client worked as well in this simple case, DSPy’s structured approach shines in more complex scenarios. It allows developers to explicitly define the expected input-output structure, ensuring consistency across multiple requests. Additionally, if the model’s output is invalid or unclear, DSPy can automatically retry the request or apply built-in heuristics to refine the response, reducing the need for manual post-processing.
For further reading, visit the DSPy documentation or check out the research paper that inspired it.
Structured Validation & Safety with Guardrails AI
Guardrails AI enforces structured validation and safety constraints on the outputs generated by the GPT-3.5-turbo
model, ensuring responses stick to predefined formats, constraints, and business logic before they reach the application. In this system, Guardrails AI validates key output fields, status, feedback, and formatted_answer, to guarantee the following:
- Email addresses are consistently lowercased
- Phone numbers follow the proper format (XXX) XXX-XXXX
- Addresses include all required components (street, city, state, ZIP code)
This validation layer is critical for maintaining data integrity, especially in high-stakes applications where incorrect, incomplete, or improperly formatted information can lead to:
- Regulatory and compliance risks
- Operational inefficiencies(e.g., failed transactions, incorrect records)
- Poor user experienceand loss of trust
Combining Guardrails AI with DSPy, the system ensures that errors are caught early, domain-specific rules are enforced, and hallucinated or malformed outputs are prevented from propagating. This enhances the overall reliability and trustworthiness of AI-powered decision-making.
Below is the Pydantic class used by Guardrails AI to enforce output validation.
from pydantic import BaseModel, Field, field_validator
import re
class ValidatedLLMResponse(BaseModel):
"""Validates & formats user responses using Guardrails AI & Pydantic."""
status: str = Field(..., pattern="^(valid|clarify|error)$")
feedback: str
formatted_answer: str
@field_validator("formatted_answer", mode="before")
@classmethod
def validate_and_format(cls, value, values):
"""Formats & validates responses based on the question type."""
if values.get("status") == "error":
return value # Skip validation for errors
question = values.get("question", "").lower()
# Validate Email Format
if "email" in question:
return cls.validate_email(value)
# Validate Name Format (Capitalize First & Last Name)
if "name" in question:
return cls.validate_name(value)
# Validate Phone Number (Format: (XXX) XXX-XXXX)
if "phone" in question:
return cls.validate_phone(value)
# Validate Address (Must contain street, city, state, ZIP)
if "address" in question:
return cls.validate_address(value)
return value # Default: Return unchanged
@staticmethod
def validate_email(email: str) -> str:
"""Validates email format and converts to lowercase."""
return (
email.lower() if re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email) else "clarify"
)
@staticmethod
def validate_name(name: str) -> str:
"""Capitalizes first & last name."""
return " ".join(word.capitalize() for word in name.split())
@staticmethod
def validate_phone(phone: str) -> str:
"""Validates & formats phone numbers as (XXX) XXX-XXXX."""
digits = re.sub(r"\D", "", phone) # Remove non-numeric characters
return (
f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
if len(digits) == 10
else "clarify"
)
@staticmethod
def validate_address(address: str) -> str:
"""Ensures address includes street, city, state, ZIP & formats correctly."""
components = address.split(",")
if len(components) < 3:
return "clarify" # Address is incomplete
formatted_address = ", ".join(comp.strip().title() for comp in components)
return (
formatted_address if re.search(r"\d{5}", formatted_address) else "clarify"
)
Pulling it all together with FastAPI
This FastAPI backend handles session management, user input validation, and workflow progression. It exposes three main endpoints: /start_registration
, /submit_response
, and /edit_field
.
The /start_registration
endpoint initializes a new session, assigning a unique session_id
and starting the registration flow. It uses LangGraph to determine the first question in the workflow and stores the initial state in a SQLite database. The registration graph, defined by RegistrationGraphManager
, structures the sequence of questions, ensuring that the workflow progresses logically.
As a user submit responses through the /submit_response
endpoint, the system retrieves the session state from the database, validates the input using validate_user_input
, and updates the stored responses. Validation is performed using DSPy and Guardrails AI, ensuring inputs stick to expected formats and business rules. If a response is incomplete or invalid, the system prompts the user for clarification instead of advancing to the next step. The workflow then determines the next question dynamically based on the LangGraph definition. Once the registration is complete, a structured summary of collected responses is returned for user validation.
The /edit_field
endpoint allows users to modify previously submitted response. It retrieves the session state, revalidates the updated input, and updates the database if the new value meets the required criteria. This ensures that user edits are processed consistently with the same validation and formatting logic used for their initial responses.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uuid
import logging
from validation.factory import validate_user_input
from db.sqlite_db import fetch_session_from_db, upsert_session_to_db, RegistrationState
from graph.registration_graph import RegistrationGraphManager
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost",
"http://localhost:8080",
"http://localhost:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
registration_questions = {
"ask_email": "What is your email address?",
"ask_name": "What is your full name?",
"ask_address": "What is your address?",
"ask_phone": "What is your phone number?",
"ask_username": "Choose a username.",
"ask_password": "Choose a strong password.",
}
registration_graph = RegistrationGraphManager("registration", registration_questions)
registration_graph.generate_mermaid_diagram()
@app.post("/start_registration")
def start_registration():
session_id = str(uuid.uuid4())
# Our initial state
initial_state: RegistrationState = {
"collected_data": {},
"current_question": "",
"current_node": "ask_email",
"session_id": session_id,
}
# Start the graph & get the first node
execution = registration_graph.compiled_graph.stream(initial_state)
try:
steps = list(execution) # Fully consume the generator
if not steps:
raise RuntimeError("Generator exited before producing any states.")
first_step = steps[0]
except GeneratorExit:
raise RuntimeError("GeneratorExit detected before first transition!")
# Extract state from the first node
first_node_key = list(first_step.keys())[0]
first_node_state = first_step[first_node_key]
first_node_state["current_node"] = first_node_key
first_node_state["session_id"] = session_id
# Save to session
upsert_session_to_db(
session_id,
first_node_state["collected_data"],
first_node_state["current_question"],
first_node_state["current_node"],
)
return {
"session_id": session_id,
"message": first_node_state["current_question"],
"state": first_node_state,
}
@app.post("/submit_response")
def submit_response(response: dict):
session_id = response.get("session_id")
if not session_id:
return {"error": "Missing session_id"}
current_state = fetch_session_from_db(session_id)
if not current_state:
return {"error": "Session not found. Please restart registration."}
skip_steps = response.get("skip_steps", [])
for node_key in skip_steps:
logging.info(f"skip_{node_key}")
current_state[f"skip_{node_key}"] = True
user_answer = response.get("answer", "")
current_question = current_state["current_question"]
current_node = current_state["current_node"]
# Use dspy to validate the answer with fallbacks
if current_node in skip_steps:
# If user is skipping this question, create a dummy validation result
validation_result = {
"status": "valid",
"feedback": "Skipped this question",
"formatted_answer": "-",
}
logging.info(f"Skipping validation for {current_node}")
else:
# Normal validation
validation_result = validate_user_input(current_question, user_answer)
# If there's a clarify/error
if validation_result["status"] in ("clarify", "error"):
return {
"next_question": current_question,
"validation_feedback": validation_result["feedback"],
"user_answer": user_answer,
"formatted_answer": validation_result["formatted_answer"],
"state": current_state,
}
current_state["collected_data"][current_state["current_node"]] = validation_result[
"formatted_answer"
]
if "current_node" not in current_state or not current_state.get("collected_data"):
return {"error": "Corrupt session state, restart registration."}
next_step = registration_graph.resume_and_step_graph(current_state)
if not next_step or next_step == {}:
# Means we've hit the END node or no more steps
return {
"message": "Registration complete!",
"validation_feedback": validation_result["feedback"],
"user_answer": user_answer,
"formatted_answer": validation_result["formatted_answer"],
"state": current_state,
"summary": current_state["collected_data"],
}
next_node_key = list(next_step.keys())[0]
next_node_state = next_step[next_node_key]
next_node_state["current_node"] = next_node_key
upsert_session_to_db(
session_id,
current_state["collected_data"],
next_node_state["current_question"],
next_node_state["current_node"],
)
return {
"next_question": next_node_state["current_question"],
"validation_feedback": validation_result["feedback"],
"user_answer": user_answer,
"formatted_answer": validation_result["formatted_answer"],
"state": next_node_state,
"summary": current_state["collected_data"],
}
@app.post("/edit_field")
def edit_field(request: dict):
session_id = request.get("session_id")
if not session_id:
return {"error": "Missing session_id"}
field_to_edit = request.get("field_to_edit")
new_value = request.get("new_value")
current_state = fetch_session_from_db(session_id)
if not current_state:
logging.error("Session not found. Please restart registration.")
return {"error": "Session not found. Please restart registration."}
question_text = registration_questions.get(field_to_edit)
if not question_text:
logging.error(f"Invalid field_to_edit: {field_to_edit}")
return {"error": f"Invalid field_to_edit: {field_to_edit}"}
validation_result = validate_user_input(
question=question_text, user_answer=new_value
)
if validation_result["status"] == "clarify":
return {
"message": "Needs clarification",
"validation_feedback": validation_result["feedback"],
"raw_answer": new_value,
"formatted_answer": validation_result["formatted_answer"],
}
current_state["collected_data"][field_to_edit] = validation_result[
"formatted_answer"
]
upsert_session_to_db(
session_id,
current_state["collected_data"],
current_state["current_question"],
current_state["current_node"],
)
return {
"message": "Field updated successfully!",
"validation_feedback": validation_result["feedback"],
"raw_answer": new_value,
"formatted_answer": validation_result["formatted_answer"],
"summary": current_state["collected_data"],
}
The FastAPI backend combines workflow management, structured validation, session persistence, and user correction handling to deliver the core of the conversational registration system.
Final Thoughts
In this post, I walked through the implementation of an AI-powered registration system using FastAPI, React, LangGraph, DSPy, Guardrails AI, and MLflow. The system guides users through a structured conversation, validating their responses in real time while ensuring data accuracy and consistency. By leveraging AI-driven validation, the application dynamically adapts to user input, allowing for a more seamless and intelligent onboarding experience. Refining these techniques are key to balancing automation, accuracy, and user trust in high-stakes domains like finance, healthcare, and enterprise workflows.
Key Strengths of Using DSPy, Guardrails AI, and LangGraph
- DSPy enables an elegant declarative approach to validation by defining structured input-output signatures. It ensures that LLM-generated responses meet expectations, applies built-in rules for formatting, and retries when needed.
- Guardrails AI provides an additional safety layer, enforcing constraints on model outputs to prevent incomplete responses. By integrating Pydantic validation, it guarantees compliance with predefined business rules, ensuring that the AI-generated responses are trustworthy and structured.
- LangGraph provides flexible workflow management, allowing for dynamic question sequencing based on user input. This adaptive orchestration ensures that optional steps can be skipped when necessary and that the system always presents users with the proper next step.
Where Could This Work Go Next?
There are many ways to extend and enhance the system to create a richer user experience, including the establishment of quantifiable benchmarks to systematically evaluate its reliability and effectiveness:
- Automated Test Suite & A/B Testing: Develop test cases covering a variety of edge cases and compare different validation strategies (e.g., DSPy vs. OpenAI native validation) in real-time.
- Enhancing Personalization: By incorporating user history and context, the system could pre-fill answers and suggest personalized follow-up questions to improve engagement. This would fundamentally change the use case but would be more interesting.
- Expanding Model Capabilities: Experimenting with retrieval-augmented generation (RAG) could improve response accuracy and ensure domain-specific compliance.
- Multimodal Capabilities: Adding support for voice input could expand the registration process beyond text-based interactions.
- Real-Time Analytics & Monitoring: Leveraging MLflow, or additional model monitoring tools, more extensively for deeper analytics could provide insights into model drift, user behavior, and validation failures, helping refine system performance.
I hope you enjoyed this post, here’s the code repository.
https://github.com/pahurlocker/registration
https://www.guardrailsai.com/docs/getting_started/quickstart
https://dspy.ai/#__tabbed_2_5