feat: add langgraph first tool; pydantic v2

This commit is contained in:
Christopher Gondek 2025-10-03 09:48:32 +02:00
parent 68d6ab9890
commit 98b258ae53
7 changed files with 718 additions and 432 deletions

View file

@ -1,7 +1,7 @@
"""Security models: Token and AuthEvent.""" """Security models: Token and AuthEvent."""
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from modules.shared.attributeUtils import register_model_labels, ModelMixin from modules.shared.attributeUtils import register_model_labels, ModelMixin
from modules.shared.timezoneUtils import get_utc_timestamp from modules.shared.timezoneUtils import get_utc_timestamp
from .datamodelUam import AuthAuthority from .datamodelUam import AuthAuthority
@ -18,21 +18,36 @@ class Token(BaseModel, ModelMixin):
id: Optional[str] = None id: Optional[str] = None
userId: str userId: str
authority: AuthAuthority authority: AuthAuthority
connectionId: Optional[str] = Field(None, description="ID of the connection this token belongs to") connectionId: Optional[str] = Field(
None, description="ID of the connection this token belongs to"
)
tokenAccess: str tokenAccess: str
tokenType: str = "bearer" tokenType: str = "bearer"
expiresAt: float = Field(description="When the token expires (UTC timestamp in seconds)") expiresAt: float = Field(
description="When the token expires (UTC timestamp in seconds)"
)
tokenRefresh: Optional[str] = None tokenRefresh: Optional[str] = None
createdAt: Optional[float] = Field(None, description="When the token was created (UTC timestamp in seconds)") createdAt: Optional[float] = Field(
status: TokenStatus = Field(default=TokenStatus.ACTIVE, description="Token status: active/revoked") None, description="When the token was created (UTC timestamp in seconds)"
revokedAt: Optional[float] = Field(None, description="When the token was revoked (UTC timestamp in seconds)") )
revokedBy: Optional[str] = Field(None, description="User ID who revoked the token (admin/self)") status: TokenStatus = Field(
default=TokenStatus.ACTIVE, description="Token status: active/revoked"
)
revokedAt: Optional[float] = Field(
None, description="When the token was revoked (UTC timestamp in seconds)"
)
revokedBy: Optional[str] = Field(
None, description="User ID who revoked the token (admin/self)"
)
reason: Optional[str] = Field(None, description="Optional revocation reason") reason: Optional[str] = Field(None, description="Optional revocation reason")
sessionId: Optional[str] = Field(None, description="Logical session grouping for logout revocation") sessionId: Optional[str] = Field(
mandateId: Optional[str] = Field(None, description="Mandate ID for tenant scoping of the token") None, description="Logical session grouping for logout revocation"
)
mandateId: Optional[str] = Field(
None, description="Mandate ID for tenant scoping of the token"
)
class Config: model_config = ConfigDict(use_enum_values=True)
use_enum_values = True
register_model_labels( register_model_labels(
@ -59,14 +74,60 @@ register_model_labels(
class AuthEvent(BaseModel, ModelMixin): class AuthEvent(BaseModel, ModelMixin):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", frontend_type="text", frontend_readonly=True, frontend_required=False) id: str = Field(
userId: str = Field(description="ID of the user this event belongs to", frontend_type="text", frontend_readonly=True, frontend_required=True) default_factory=lambda: str(uuid.uuid4()),
eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", frontend_type="text", frontend_readonly=True, frontend_required=True) description="Unique ID of the auth event",
timestamp: float = Field(default_factory=get_utc_timestamp, description="Unix timestamp when the event occurred", frontend_type="datetime", frontend_readonly=True, frontend_required=True) frontend_type="text",
ipAddress: Optional[str] = Field(default=None, description="IP address from which the event originated", frontend_type="text", frontend_readonly=True, frontend_required=False) frontend_readonly=True,
userAgent: Optional[str] = Field(default=None, description="User agent string from the request", frontend_type="text", frontend_readonly=True, frontend_required=False) frontend_required=False,
success: bool = Field(default=True, description="Whether the authentication event was successful", frontend_type="boolean", frontend_readonly=True, frontend_required=True) )
details: Optional[str] = Field(default=None, description="Additional details about the event", frontend_type="text", frontend_readonly=True, frontend_required=False) userId: str = Field(
description="ID of the user this event belongs to",
frontend_type="text",
frontend_readonly=True,
frontend_required=True,
)
eventType: str = Field(
description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
frontend_type="text",
frontend_readonly=True,
frontend_required=True,
)
timestamp: float = Field(
default_factory=get_utc_timestamp,
description="Unix timestamp when the event occurred",
frontend_type="datetime",
frontend_readonly=True,
frontend_required=True,
)
ipAddress: Optional[str] = Field(
default=None,
description="IP address from which the event originated",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
userAgent: Optional[str] = Field(
default=None,
description="User agent string from the request",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
success: bool = Field(
default=True,
description="Whether the authentication event was successful",
frontend_type="boolean",
frontend_readonly=True,
frontend_required=True,
)
details: Optional[str] = Field(
default=None,
description="Additional details about the event",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
register_model_labels( register_model_labels(
@ -83,5 +144,3 @@ register_model_labels(
"details": {"en": "Details", "fr": "Détails"}, "details": {"en": "Details", "fr": "Détails"},
}, },
) )

View file

@ -0,0 +1 @@
"""Contains all tools available for the chatbot to use."""

View file

@ -1 +1,7 @@
"""Tools that are custom to a specific customer go here.""" """Shared tools available across all chatbot implementations."""
from modules.features.chatBot.chatbotTools.sharedTools.toolTavilySearch import (
tavily_search,
)
__all__ = ["tavily_search"]

View file

@ -0,0 +1,55 @@
"""Tavily Search Tool for LangGraph.
This tool provides web search capabilities using the Tavily API.
"""
import logging
from typing import Annotated
from langchain_core.tools import tool
from modules.connectors.connectorAiTavily import ConnectorWeb
logger = logging.getLogger(__name__)
@tool
async def tavily_search(
query: Annotated[str, "The search query to look up on the web"],
) -> str:
"""Search the web using Tavily API.
Use this tool to search for current information, news, or any web content.
The tool returns relevant search results including titles and URLs.
Args:
query: The search query string
Returns:
A formatted string containing search results with titles and URLs
"""
try:
# Create connector instance
connector = await ConnectorWeb.create()
# Perform search with default parameters
results = await connector._search(
query=query,
max_results=5,
search_depth="basic",
include_answer=True,
include_raw_content=False,
)
# Format results
if not results:
return f"No results found for query: {query}"
formatted_results = [f"Search results for '{query}':\n"]
for i, result in enumerate(results, 1):
formatted_results.append(f"{i}. {result.title}")
formatted_results.append(f" URL: {result.url}\n")
return "\n".join(formatted_results)
except Exception as e:
logger.error(f"Error in tavily_search tool: {str(e)}")
return f"Error performing search: {str(e)}"

File diff suppressed because it is too large Load diff

View file

@ -2,52 +2,55 @@
Shared utilities for model attributes and labels. Shared utilities for model attributes and labels.
""" """
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ConfigDict
from typing import Dict, Any, List, Type, Optional, Union from typing import Dict, Any, List, Type, Optional, Union
import inspect import inspect
import importlib import importlib
import os import os
from datetime import datetime from datetime import datetime
class ModelMixin: class ModelMixin:
"""Mixin class that provides serialization methods for Pydantic models.""" """Mixin class that provides serialization methods for Pydantic models."""
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
""" """
Convert a Pydantic model to a dictionary. Convert a Pydantic model to a dictionary.
Handles both Pydantic v1 and v2. Handles both Pydantic v1 and v2.
All timestamp fields remain as float values. All timestamp fields remain as float values.
Returns: Returns:
Dict[str, Any]: Dictionary representation of the model Dict[str, Any]: Dictionary representation of the model
""" """
# Get the raw dictionary # Get the raw dictionary
if hasattr(self, 'model_dump'): if hasattr(self, "model_dump"):
data: Dict[str, Any] = self.model_dump() # Pydantic v2 data: Dict[str, Any] = self.model_dump() # Pydantic v2
else: else:
data: Dict[str, Any] = self.dict() # Pydantic v1 data: Dict[str, Any] = self.dict() # Pydantic v1
# All fields (including timestamps) remain in their original format # All fields (including timestamps) remain in their original format
# No conversions needed - timestamps are already float # No conversions needed - timestamps are already float
return data return data
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ModelMixin': def from_dict(cls, data: Dict[str, Any]) -> "ModelMixin":
""" """
Create a Pydantic model instance from a dictionary. Create a Pydantic model instance from a dictionary.
Args: Args:
data: Dictionary containing the model data data: Dictionary containing the model data
Returns: Returns:
ModelMixin: New instance of the model class ModelMixin: New instance of the model class
""" """
return cls(**data) return cls(**data)
# Define the AttributeDefinition class here instead of importing it # Define the AttributeDefinition class here instead of importing it
class AttributeDefinition(BaseModel, ModelMixin): class AttributeDefinition(BaseModel, ModelMixin):
"""Definition of a model attribute with its metadata.""" """Definition of a model attribute with its metadata."""
name: str name: str
type: str type: str
label: str label: str
@ -64,41 +67,47 @@ class AttributeDefinition(BaseModel, ModelMixin):
order: int = 0 order: int = 0
placeholder: Optional[str] = None placeholder: Optional[str] = None
# Global registry for model labels # Global registry for model labels
MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {} MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {}
def to_dict(model: BaseModel) -> Dict[str, Any]: def to_dict(model: BaseModel) -> Dict[str, Any]:
""" """
Convert a Pydantic model to a dictionary. Convert a Pydantic model to a dictionary.
Handles both Pydantic v1 and v2. Handles both Pydantic v1 and v2.
Args: Args:
model: The Pydantic model instance to convert model: The Pydantic model instance to convert
Returns: Returns:
Dict[str, Any]: Dictionary representation of the model Dict[str, Any]: Dictionary representation of the model
""" """
if hasattr(model, 'model_dump'): if hasattr(model, "model_dump"):
return model.model_dump() # Pydantic v2 return model.model_dump() # Pydantic v2
return model.dict() # Pydantic v1 return model.dict() # Pydantic v1
def from_dict(model_class: Type[BaseModel], data: Dict[str, Any]) -> BaseModel: def from_dict(model_class: Type[BaseModel], data: Dict[str, Any]) -> BaseModel:
""" """
Create a Pydantic model instance from a dictionary. Create a Pydantic model instance from a dictionary.
Args: Args:
model_class: The Pydantic model class to instantiate model_class: The Pydantic model class to instantiate
data: Dictionary containing the model data data: Dictionary containing the model data
Returns: Returns:
BaseModel: New instance of the model class BaseModel: New instance of the model class
""" """
return model_class(**data) return model_class(**data)
def register_model_labels(model_name: str, model_label: Dict[str, str], labels: Dict[str, Dict[str, str]]):
def register_model_labels(
model_name: str, model_label: Dict[str, str], labels: Dict[str, Dict[str, str]]
):
""" """
Register labels for a model's attributes and the model itself. Register labels for a model's attributes and the model itself.
Args: Args:
model_name: Name of the model class model_name: Name of the model class
model_label: Dictionary mapping language codes to model labels model_label: Dictionary mapping language codes to model labels
@ -106,38 +115,37 @@ def register_model_labels(model_name: str, model_label: Dict[str, str], labels:
labels: Dictionary mapping attribute names to their translations labels: Dictionary mapping attribute names to their translations
e.g. {"name": {"en": "Name", "fr": "Nom"}} e.g. {"name": {"en": "Name", "fr": "Nom"}}
""" """
MODEL_LABELS[model_name] = { MODEL_LABELS[model_name] = {"model": model_label, "attributes": labels}
"model": model_label,
"attributes": labels
}
def get_model_labels(model_name: str, language: str = "en") -> Dict[str, str]: def get_model_labels(model_name: str, language: str = "en") -> Dict[str, str]:
""" """
Get labels for a model's attributes in the specified language. Get labels for a model's attributes in the specified language.
Args: Args:
model_name: Name of the model class model_name: Name of the model class
language: Language code (default: "en") language: Language code (default: "en")
Returns: Returns:
Dictionary mapping attribute names to their labels in the specified language Dictionary mapping attribute names to their labels in the specified language
""" """
model_data = MODEL_LABELS.get(model_name, {}) model_data = MODEL_LABELS.get(model_name, {})
attribute_labels = model_data.get("attributes", {}) attribute_labels = model_data.get("attributes", {})
return { return {
attr: translations.get(language, translations.get("en", attr)) attr: translations.get(language, translations.get("en", attr))
for attr, translations in attribute_labels.items() for attr, translations in attribute_labels.items()
} }
def get_model_label(model_name: str, language: str = "en") -> str: def get_model_label(model_name: str, language: str = "en") -> str:
""" """
Get the label for a model in the specified language. Get the label for a model in the specified language.
Args: Args:
model_name: Name of the model class model_name: Name of the model class
language: Language code (default: "en") language: Language code (default: "en")
Returns: Returns:
Model label in the specified language, or model name if no label exists Model label in the specified language, or model name if no label exists
""" """
@ -145,156 +153,205 @@ def get_model_label(model_name: str, language: str = "en") -> str:
model_label = model_data.get("model", {}) model_label = model_data.get("model", {})
return model_label.get(language, model_label.get("en", model_name)) return model_label.get(language, model_label.get("en", model_name))
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:
def getModelAttributeDefinitions(
modelClass: Type[BaseModel] = None, userLanguage: str = "en"
) -> Dict[str, Any]:
""" """
Get attribute definitions for a model class. Get attribute definitions for a model class.
Args: Args:
modelClass: The model class to get attributes for modelClass: The model class to get attributes for
userLanguage: Language code for translations (default: "en") userLanguage: Language code for translations (default: "en")
Returns: Returns:
Dictionary containing model label and attribute definitions Dictionary containing model label and attribute definitions
""" """
if not modelClass: if not modelClass:
return {} return {}
attributes = [] attributes = []
model_name = modelClass.__name__ model_name = modelClass.__name__
labels = get_model_labels(model_name, userLanguage) labels = get_model_labels(model_name, userLanguage)
model_label = get_model_label(model_name, userLanguage) model_label = get_model_label(model_name, userLanguage)
# Handle both Pydantic v1 and v2 # Handle both Pydantic v1 and v2
if hasattr(modelClass, 'model_fields'): # Pydantic v2 if hasattr(modelClass, "model_fields"): # Pydantic v2
fields = modelClass.model_fields fields = modelClass.model_fields
for name, field in fields.items(): for name, field in fields.items():
# Extract frontend metadata from field info # Extract frontend metadata from field info
field_info = field.field_info if hasattr(field, 'field_info') else None field_info = field.field_info if hasattr(field, "field_info") else None
# Check both direct attributes and extra field for frontend metadata # Check both direct attributes and extra field for frontend metadata
frontend_type = None frontend_type = None
frontend_readonly = False frontend_readonly = False
frontend_required = field.is_required() frontend_required = field.is_required()
frontend_options = None frontend_options = None
if field_info: if field_info:
# Try direct attributes first # Try direct attributes first
frontend_type = getattr(field_info, 'frontend_type', None) frontend_type = getattr(field_info, "frontend_type", None)
frontend_readonly = getattr(field_info, 'frontend_readonly', False) frontend_readonly = getattr(field_info, "frontend_readonly", False)
frontend_required = getattr(field_info, 'frontend_required', frontend_required) frontend_required = getattr(
frontend_options = getattr(field_info, 'frontend_options', None) field_info, "frontend_required", frontend_required
)
frontend_options = getattr(field_info, "frontend_options", None)
# If not found, check extra field # If not found, check extra field
if hasattr(field_info, 'extra') and field_info.extra: if hasattr(field_info, "extra") and field_info.extra:
if frontend_type is None: if frontend_type is None:
frontend_type = field_info.extra.get('frontend_type') frontend_type = field_info.extra.get("frontend_type")
if not frontend_readonly: if not frontend_readonly:
frontend_readonly = field_info.extra.get('frontend_readonly', False) frontend_readonly = field_info.extra.get(
if frontend_required == field.is_required(): # Only override if we didn't get it from direct attribute "frontend_readonly", False
frontend_required = field_info.extra.get('frontend_required', frontend_required) )
if (
frontend_required == field.is_required()
): # Only override if we didn't get it from direct attribute
frontend_required = field_info.extra.get(
"frontend_required", frontend_required
)
if frontend_options is None: if frontend_options is None:
frontend_options = field_info.extra.get('frontend_options') frontend_options = field_info.extra.get("frontend_options")
# Use frontend type if available, otherwise fall back to Python type # Use frontend type if available, otherwise fall back to Python type
field_type = frontend_type if frontend_type else (field.annotation.__name__ if hasattr(field.annotation, "__name__") else str(field.annotation)) field_type = (
frontend_type
attributes.append({ if frontend_type
"name": name, else (
"type": field_type, field.annotation.__name__
"required": frontend_required, if hasattr(field.annotation, "__name__")
"description": field.description if hasattr(field, "description") else "", else str(field.annotation)
"label": labels.get(name, name), )
"placeholder": f"Please enter {labels.get(name, name)}", )
"editable": not frontend_readonly,
"visible": True, attributes.append(
"order": len(attributes), {
"readonly": frontend_readonly, "name": name,
"options": frontend_options "type": field_type,
}) "required": frontend_required,
"description": field.description
if hasattr(field, "description")
else "",
"label": labels.get(name, name),
"placeholder": f"Please enter {labels.get(name, name)}",
"editable": not frontend_readonly,
"visible": True,
"order": len(attributes),
"readonly": frontend_readonly,
"options": frontend_options,
}
)
else: # Pydantic v1 else: # Pydantic v1
fields = modelClass.__fields__ fields = modelClass.__fields__
for name, field in fields.items(): for name, field in fields.items():
# Extract frontend metadata from field info # Extract frontend metadata from field info
field_info = field.field_info if hasattr(field, 'field_info') else None field_info = field.field_info if hasattr(field, "field_info") else None
# Check both direct attributes and extra field for frontend metadata # Check both direct attributes and extra field for frontend metadata
frontend_type = None frontend_type = None
frontend_readonly = False frontend_readonly = False
frontend_required = field.required frontend_required = field.required
frontend_options = None frontend_options = None
if field_info: if field_info:
# Try direct attributes first # Try direct attributes first
frontend_type = getattr(field_info, 'frontend_type', None) frontend_type = getattr(field_info, "frontend_type", None)
frontend_readonly = getattr(field_info, 'frontend_readonly', False) frontend_readonly = getattr(field_info, "frontend_readonly", False)
frontend_required = getattr(field_info, 'frontend_required', frontend_required) frontend_required = getattr(
frontend_options = getattr(field_info, 'frontend_options', None) field_info, "frontend_required", frontend_required
)
frontend_options = getattr(field_info, "frontend_options", None)
# If not found, check extra field # If not found, check extra field
if hasattr(field_info, 'extra') and field_info.extra: if hasattr(field_info, "extra") and field_info.extra:
if frontend_type is None: if frontend_type is None:
frontend_type = field_info.extra.get('frontend_type') frontend_type = field_info.extra.get("frontend_type")
if not frontend_readonly: if not frontend_readonly:
frontend_readonly = field_info.extra.get('frontend_readonly', False) frontend_readonly = field_info.extra.get(
if frontend_required == field.required: # Only override if we didn't get it from direct attribute "frontend_readonly", False
frontend_required = field_info.extra.get('frontend_required', frontend_required) )
if (
frontend_required == field.required
): # Only override if we didn't get it from direct attribute
frontend_required = field_info.extra.get(
"frontend_required", frontend_required
)
if frontend_options is None: if frontend_options is None:
frontend_options = field_info.extra.get('frontend_options') frontend_options = field_info.extra.get("frontend_options")
# Use frontend type if available, otherwise fall back to Python type # Use frontend type if available, otherwise fall back to Python type
field_type = frontend_type if frontend_type else (field.type_.__name__ if hasattr(field.type_, "__name__") else str(field.type_)) field_type = (
frontend_type
attributes.append({ if frontend_type
"name": name, else (
"type": field_type, field.type_.__name__
"required": frontend_required, if hasattr(field.type_, "__name__")
"description": field.field_info.description if hasattr(field.field_info, "description") else "", else str(field.type_)
"label": labels.get(name, name), )
"placeholder": f"Please enter {labels.get(name, name)}", )
"editable": not frontend_readonly,
"visible": True, attributes.append(
"order": len(attributes), {
"readonly": frontend_readonly, "name": name,
"options": frontend_options "type": field_type,
}) "required": frontend_required,
"description": field.field_info.description
return { if hasattr(field.field_info, "description")
"model": model_label, else "",
"attributes": attributes "label": labels.get(name, name),
} "placeholder": f"Please enter {labels.get(name, name)}",
"editable": not frontend_readonly,
"visible": True,
"order": len(attributes),
"readonly": frontend_readonly,
"options": frontend_options,
}
)
return {"model": model_label, "attributes": attributes}
def getModelClasses() -> Dict[str, Type[BaseModel]]: def getModelClasses() -> Dict[str, Type[BaseModel]]:
""" """
Dynamically get all model classes from all model modules. Dynamically get all model classes from all model modules.
Returns: Returns:
Dict[str, Type[BaseModel]]: Dictionary of model class names to their classes Dict[str, Type[BaseModel]]: Dictionary of model class names to their classes
""" """
modelClasses = {} modelClasses = {}
# Get the interfaces directory path # Get the interfaces directory path
interfaces_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'interfaces') interfaces_dir = os.path.join(
os.path.dirname(os.path.dirname(__file__)), "interfaces"
)
# Find all model files # Find all model files
for fileName in os.listdir(interfaces_dir): for fileName in os.listdir(interfaces_dir):
if fileName.endswith('Model.py'): if fileName.endswith("Model.py"):
# Convert fileName to module name (e.g., gatewayModel.py -> gatewayModel) # Convert fileName to module name (e.g., gatewayModel.py -> gatewayModel)
module_name = fileName[:-3] module_name = fileName[:-3]
# Import the module dynamically # Import the module dynamically
module = importlib.import_module(f'modules.interfaces.{module_name}') module = importlib.import_module(f"modules.interfaces.{module_name}")
# Get all classes from the module # Get all classes from the module
for name, obj in inspect.getmembers(module): for name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseModel) and obj != BaseModel: if (
inspect.isclass(obj)
and issubclass(obj, BaseModel)
and obj != BaseModel
):
modelClasses[name] = obj modelClasses[name] = obj
return modelClasses return modelClasses
class AttributeResponse(BaseModel): class AttributeResponse(BaseModel):
"""Response model for entity attributes""" """Response model for entity attributes"""
attributes: List[AttributeDefinition] attributes: List[AttributeDefinition]
class Config: model_config = ConfigDict(
schema_extra = { json_schema_extra={
"example": { "example": {
"attributes": [ "attributes": [
{ {
@ -305,8 +362,9 @@ class AttributeResponse(BaseModel):
"placeholder": "Please enter username", "placeholder": "Please enter username",
"editable": True, "editable": True,
"visible": True, "visible": True,
"order": 0 "order": 0,
} }
] ]
} }
} }
)

View file

@ -4,7 +4,7 @@ websockets==12.0
uvicorn==0.23.2 uvicorn==0.23.2
python-multipart==0.0.6 python-multipart==0.0.6
httpx==0.25.0 httpx==0.25.0
pydantic==1.10.13 # Ältere Version ohne Rust-Abhängigkeit pydantic>=2.0.0 # Upgraded to v2 for LangChain compatibility
email-validator==2.0.0 # Required by Pydantic for email validation email-validator==2.0.0 # Required by Pydantic for email validation
slowapi==0.1.8 # For rate limiting slowapi==0.1.8 # For rate limiting
@ -108,3 +108,8 @@ xyzservices>=2021.09.1
# PostgreSQL connector dependencies # PostgreSQL connector dependencies
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
## LangChain & LangGraph
langchain==0.3.27
langgraph==0.6.8
langchain-core==0.3.77