13.2 Data Model Design
13.2.1 Data Flow in Web Applications
When building an intelligent travel assistant, we need to solve a core problem: How to represent and transfer travel plan data?
We need to understand how data flows in a complete Web application. Imagine what happens when a user clicks the "Start Planning" button in the browser?
The form data filled in by the user on the front-end (destination, dates, budget, etc.) needs to be sent to the back-end server through HTTP requests. After the back-end receives the data, it will call the agent system for processing. The agents will then call external services such as Amap API and Unsplash API to obtain data. The data formats returned by these external APIs are different - some use lng, some use lon, and some use longitude. Finally, the back-end needs to return the processed data to the front-end, which then renders it into the page the user sees.
In this process, data undergoes multiple transformations: Front-end form → HTTP request → Back-end Python object → External API response → Back-end Python object → HTTP response → Front-end TypeScript object → Page display. Without a unified data format, each transformation step could go wrong. This is why we need data models.
13.2.2 From Dictionaries to Pydantic Models
Let's start with the simple prototype from Chapter 1. In that prototype, we used Python dictionaries to represent attraction data:
# Chapter 1 approach: using dictionaries
attraction = {
"name": "Forbidden City",
"location": {"lng": 116.397128, "lat": 39.916527},
"price": 60
}
# Access data
lng = attraction["location"]["lng"]This approach is convenient in the prototype stage, but will encounter many problems in actual projects. First is the problem of inconsistent field names. The location data returned by Amap API is a string like "116.397128,39.916527", which needs to be manually split into longitude and latitude. Unsplash API might use longitude and latitude. If we use dictionaries everywhere in the code, we need to handle these differences in every place.
Second is the problem of type safety. Suppose we accidentally set price as a string "60", this won't immediately error in Python, but will cause problems when calculating the total budget. Worse, this kind of error can only be discovered at runtime, and the error message may be difficult to locate.
Finally is the problem of maintainability. When we need to add a new field to attractions (such as rating), we need to modify multiple places in the code. If we miss somewhere, it will lead to data inconsistency.
Pydantic provides a solution. It is a Python data validation library that allows us to define data structures using classes and automatically handle validation, conversion, and serialization. Let's look at a simple example:
from pydantic import BaseModel, Field
class Location(BaseModel):
longitude: float = Field(..., description="Longitude")
latitude: float = Field(..., description="Latitude")
class Attraction(BaseModel):
name: str
location: Location
ticket_price: int = 0
# Create object
attraction = Attraction(
name="Forbidden City",
location=Location(longitude=116.397128, latitude=39.916527),
ticket_price=60
)
# Type-safe access
lng = attraction.location.longitude # IDE will provide code completionThis approach has several benefits. First, if we pass in the wrong type (such as setting ticket_price as a string), Pydantic will immediately throw an exception telling us where the error is. Second, the IDE can provide code completion and type checking based on type definitions, greatly reducing spelling errors. Finally, when we need to modify the data structure, we only need to modify the class definition, and all places using this class will automatically update.
13.2.3 Core Concepts of Pydantic
Before diving into designing our data models, let's first understand several core concepts of Pydantic. The foundation of Pydantic is the BaseModel class, and all data models need to inherit from this class. Each field can specify a type, and Pydantic will automatically perform type checking and conversion.
Field definition uses the Field function, which can specify default values, descriptions, validation rules, etc. ... indicates that this field is required - if this field is not provided when creating an object, Pydantic will throw an exception. We can also use Optional to indicate optional fields, or directly provide default values.
from pydantic import BaseModel, Field
from typing import Optional, List
class Attraction(BaseModel):
name: str = Field(..., description="Attraction name") # Required
rating: float = Field(default=0.0, ge=0, le=5) # Default value, range validation
visit_duration: int = Field(default=60, gt=0) # Greater than 0
description: Optional[str] = None # Optional fieldPydantic also supports nested models and lists. We can use another model as a field type in one model, allowing us to build complex data structures. For example, an attraction contains location information, and an itinerary contains multiple attractions.
class DayPlan(BaseModel):
date: str
attractions: List[Attraction] # Attraction list
hotel: Optional[Hotel] = None # Optional hotel informationOne of the most powerful features is custom validators. Sometimes the data format returned by external APIs doesn't meet our requirements, and we can use the field_validator decorator to customize validation and conversion logic. For example, the temperature returned by Amap is a string like "16°C", and we need to convert it to a number:
from pydantic import field_validator
class WeatherInfo(BaseModel):
temperature: int
@field_validator('temperature', mode='before')
def parse_temperature(cls, v):
"""Parse temperature string: "16°C" -> 16"""
if isinstance(v, str):
v = v.replace('°C', '').replace('℃', '').strip()
return int(v)
return vThis validator will automatically execute before creating the object, converting the string to an integer. This way we don't need to manually handle temperature format in every place in the code.
13.2.4 Bottom-Up Model Design
Now let's start designing the data models for the intelligent travel assistant. A good design principle is bottom-up: first define the most basic models, then gradually combine them into complex structures. The advantage of this approach is that each model is simple, easy to understand and maintain.
The most basic model is location information. Whether it's attractions, hotels, or restaurants, all need location information. We define a Location class to represent longitude and latitude coordinates:
class Location(BaseModel):
"""Location information (longitude and latitude coordinates)"""
longitude: float = Field(..., description="Longitude", ge=-180, le=180)
latitude: float = Field(..., description="Latitude", ge=-90, le=90)Here we use range validation (ge means greater than or equal to, le means less than or equal to) to ensure longitude and latitude values are within reasonable ranges.
Next is attraction information. An attraction contains name, address, location, visit duration, description, rating, image, and ticket price information. Note that we use Location as a field type, which is a nested model:
class Attraction(BaseModel):
"""Attraction information"""
name: str = Field(..., description="Attraction name")
address: str = Field(..., description="Address")
location: Location = Field(..., description="Longitude and latitude coordinates")
visit_duration: int = Field(..., description="Recommended visit duration (minutes)", gt=0)
description: str = Field(..., description="Attraction description")
category: Optional[str] = Field(default="Attraction", description="Attraction category")
rating: Optional[float] = Field(default=None, ge=0, le=5, description="Rating")
image_url: Optional[str] = Field(default=None, description="Image URL")
ticket_price: int = Field(default=0, ge=0, description="Ticket price (yuan)")Similarly, we define meal information and hotel information. These models have similar structures, all containing basic information such as name, address, location, and cost:
class Meal(BaseModel):
"""Meal information"""
type: str = Field(..., description="Meal type: breakfast/lunch/dinner/snack")
name: str = Field(..., description="Meal name")
address: Optional[str] = Field(default=None, description="Address")
location: Optional[Location] = Field(default=None, description="Longitude and latitude coordinates")
description: Optional[str] = Field(default=None, description="Description")
estimated_cost: int = Field(default=0, description="Estimated cost (yuan)")
class Hotel(BaseModel):
"""Hotel information"""
name: str = Field(..., description="Hotel name")
address: str = Field(default="", description="Hotel address")
location: Optional[Location] = Field(default=None, description="Hotel location")
price_range: str = Field(default="", description="Price range")
rating: str = Field(default="", description="Rating")
distance: str = Field(default="", description="Distance to attractions")
type: str = Field(default="", description="Hotel type")
estimated_cost: int = Field(default=0, description="Estimated cost (yuan/night)")Budget information is a special model that doesn't contain location information, but contains a summary of various expenses:
class Budget(BaseModel):
"""Budget information"""
total_attractions: int = Field(default=0, description="Total attraction ticket cost")
total_hotels: int = Field(default=0, description="Total hotel cost")
total_meals: int = Field(default=0, description="Total meal cost")
total_transportation: int = Field(default=0, description="Total transportation cost")
total: int = Field(default=0, description="Total cost")Now we can combine these basic models to build a daily itinerary. A daily itinerary contains date, description, transportation method, accommodation arrangement, hotel, attraction list, and meal list:
class DayPlan(BaseModel):
"""Daily itinerary"""
date: str = Field(..., description="Date")
day_index: int = Field(..., description="Day number (starting from 0)")
description: str = Field(..., description="Daily itinerary description")
transportation: str = Field(..., description="Transportation method")
accommodation: str = Field(..., description="Accommodation arrangement")
hotel: Optional[Hotel] = Field(default=None, description="Hotel information")
attractions: List[Attraction] = Field(default_factory=list, description="Attraction list")
meals: List[Meal] = Field(default_factory=list, description="Meal arrangements")Note that we use List[Attraction] to represent the attraction list, and default_factory=list means the default value is an empty list.
Weather information requires special handling because the temperature format returned by Amap is non-standard. We use a custom validator to handle this:
class WeatherInfo(BaseModel):
"""Weather information"""
date: str = Field(..., description="Date")
day_weather: str = Field(..., description="Daytime weather")
night_weather: str = Field(..., description="Nighttime weather")
day_temp: int = Field(..., description="Daytime temperature (Celsius)")
night_temp: int = Field(..., description="Nighttime temperature (Celsius)")
wind_direction: str = Field(..., description="Wind direction")
wind_power: str = Field(..., description="Wind power")
@field_validator('day_temp', 'night_temp', mode='before')
def parse_temperature(cls, v):
"""Parse temperature string: "16°C" -> 16"""
if isinstance(v, str):
v = v.replace('°C', '').replace('℃', '').replace('°', '').strip()
try:
return int(v)
except ValueError:
return 0 # Error tolerance
return vFinally, we define the complete travel plan. This is the top-level model that contains all information:
class TripPlan(BaseModel):
"""Travel plan"""
city: str = Field(..., description="Destination city")
start_date: str = Field(..., description="Start date")
end_date: str = Field(..., description="End date")
days: List[DayPlan] = Field(default_factory=list, description="Daily itinerary")
weather_info: List[WeatherInfo] = Field(default_factory=list, description="Weather information")
overall_suggestions: str = Field(..., description="Overall suggestions")
budget: Optional[Budget] = Field(default=None, description="Budget information")This way, we have completed the design of the entire data model. From the most basic Location, to Attraction, Meal, Hotel, then to DayPlan, and finally to TripPlan, forming a clear hierarchical structure.
13.2.5 Application of Data Models in Web Applications
Now let's see how these data models are used in actual Web applications. In FastAPI, Pydantic models can be directly used as type definitions for requests and responses. FastAPI will automatically perform data validation, serialization, and documentation generation.
from fastapi import FastAPI
from app.models.schemas import TripPlanRequest, TripPlan
app = FastAPI()
@app.post("/api/trip/plan", response_model=TripPlan)
async def create_trip_plan(request: TripPlanRequest) -> TripPlan:
"""
Create travel plan
FastAPI automatically:
1. Validates request data (TripPlanRequest)
2. Validates response data (TripPlan)
3. Generates OpenAPI documentation
"""
trip_plan = await generate_trip_plan(request)
return trip_planWhen a user sends a POST request to /api/trip/plan, FastAPI will automatically convert the JSON data into a TripPlanRequest object. If the data format is incorrect (such as missing required fields or type mismatch), FastAPI will automatically return a 400 error and tell the user where the error is.
On the front-end, we also need to define corresponding TypeScript types. Although TypeScript and Python are different languages, the data structures are the same:
interface Location {
longitude: number;
latitude: number;
}
interface Attraction {
name: string;
address: string;
location: Location;
visit_duration: number;
ticket_price: number;
}
interface TripPlan {
city: string;
start_date: string;
end_date: string;
days: DayPlan[];
}This way, the front-end and back-end use a unified data format. When the back-end returns a TripPlan object, the front-end can use it directly without any conversion. TypeScript's type checking can also help us avoid many errors.