Spaces:
Running
Running
Commit
·
bc788f6
1
Parent(s):
e50e98b
Add LlamaIndex agent with guardrails and on-demand image
Browse files- .gitignore +18 -5
- MCP_INTEGRATION.md +145 -0
- README.md +151 -82
- app.py +514 -254
- data/spice_images.json +294 -0
- requirements.txt +5 -0
- tools/guardrails.py +788 -0
- tools/image_utils.py +160 -0
- tools/llama_agent.py +293 -0
- tools/mcp_tools.py +41 -0
- tools/neo4j_queries.py +38 -3
.gitignore
CHANGED
|
@@ -6,9 +6,7 @@
|
|
| 6 |
venv/
|
| 7 |
env/
|
| 8 |
|
| 9 |
-
# Cached data
|
| 10 |
-
data/raw/
|
| 11 |
-
data/processed/
|
| 12 |
|
| 13 |
# Python
|
| 14 |
__pycache__/
|
|
@@ -56,7 +54,22 @@ spice_bae/
|
|
| 56 |
# Others
|
| 57 |
.claude/
|
| 58 |
DEPLOYMENT.md
|
| 59 |
-
data/
|
| 60 |
data_ingest/
|
| 61 |
neo4j_setup/
|
| 62 |
-
thoughts/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
venv/
|
| 7 |
env/
|
| 8 |
|
| 9 |
+
# Cached data (moved to end of file)
|
|
|
|
|
|
|
| 10 |
|
| 11 |
# Python
|
| 12 |
__pycache__/
|
|
|
|
| 54 |
# Others
|
| 55 |
.claude/
|
| 56 |
DEPLOYMENT.md
|
|
|
|
| 57 |
data_ingest/
|
| 58 |
neo4j_setup/
|
| 59 |
+
thoughts/
|
| 60 |
+
|
| 61 |
+
# Logs directory
|
| 62 |
+
logs/
|
| 63 |
+
|
| 64 |
+
# Static images (downloaded on-demand instead)
|
| 65 |
+
static/
|
| 66 |
+
|
| 67 |
+
# Data directory (keep spice_images.json for URLs)
|
| 68 |
+
data/raw/
|
| 69 |
+
data/processed/
|
| 70 |
+
data/manual/
|
| 71 |
+
data/spice_list.json
|
| 72 |
+
data/spice_name_mapping.json
|
| 73 |
+
data/spice_images_local.json
|
| 74 |
+
data/health_benefits.json
|
| 75 |
+
data/nccih_herb_mapping.json
|
MCP_INTEGRATION.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCP Integration Guide
|
| 2 |
+
|
| 3 |
+
This guide explains how to connect the Medicinal Cuisine MCP server to various AI clients.
|
| 4 |
+
|
| 5 |
+
## Overview
|
| 6 |
+
|
| 7 |
+
The Medicinal Cuisine AI Advisor exposes its tools via the Model Context Protocol (MCP), allowing AI assistants like Cline and Claude Desktop to query spice information, nutritional data, and health benefits directly.
|
| 8 |
+
|
| 9 |
+
**MCP Endpoint**: `https://mcp-1st-birthday-spice-bae.hf.space/gradio_api/mcp/sse`
|
| 10 |
+
|
| 11 |
+
## Prerequisites
|
| 12 |
+
|
| 13 |
+
- Node.js 18+ installed
|
| 14 |
+
- `npx` available in your PATH
|
| 15 |
+
- The Medicinal Cuisine Space deployed on Hugging Face
|
| 16 |
+
|
| 17 |
+
## Connecting with Cline (VS Code)
|
| 18 |
+
|
| 19 |
+
### Step 1: Locate the Settings File
|
| 20 |
+
|
| 21 |
+
Cline stores MCP server configurations in `cline_mcp_settings.json`. The location varies by OS:
|
| 22 |
+
|
| 23 |
+
- **Windows**: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json`
|
| 24 |
+
- **macOS**: `~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
|
| 25 |
+
- **Linux**: `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
|
| 26 |
+
|
| 27 |
+
### Step 2: Add the Server Configuration
|
| 28 |
+
|
| 29 |
+
Edit `cline_mcp_settings.json` and add:
|
| 30 |
+
|
| 31 |
+
```json
|
| 32 |
+
{
|
| 33 |
+
"mcpServers": {
|
| 34 |
+
"medicinal-cuisine": {
|
| 35 |
+
"command": "npx",
|
| 36 |
+
"args": [
|
| 37 |
+
"-y",
|
| 38 |
+
"mcp-remote",
|
| 39 |
+
"https://mcp-1st-birthday-spice-bae.hf.space/gradio_api/mcp/sse"
|
| 40 |
+
]
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
Note: The `-y` flag auto-confirms npx prompts.
|
| 47 |
+
|
| 48 |
+
### Step 3: Restart Cline
|
| 49 |
+
|
| 50 |
+
Restart VS Code or reload the Cline extension for changes to take effect.
|
| 51 |
+
|
| 52 |
+
## Connecting with Claude Desktop
|
| 53 |
+
|
| 54 |
+
### Step 1: Locate the Settings File
|
| 55 |
+
|
| 56 |
+
Claude Desktop stores configurations in:
|
| 57 |
+
|
| 58 |
+
- **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
|
| 59 |
+
- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
| 60 |
+
|
| 61 |
+
### Step 2: Add the Server Configuration
|
| 62 |
+
|
| 63 |
+
Edit `claude_desktop_config.json` and add:
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{
|
| 67 |
+
"mcpServers": {
|
| 68 |
+
"medicinal-cuisine": {
|
| 69 |
+
"command": "npx",
|
| 70 |
+
"args": [
|
| 71 |
+
"-y",
|
| 72 |
+
"mcp-remote",
|
| 73 |
+
"https://mcp-1st-birthday-spice-bae.hf.space/gradio_api/mcp/sse"
|
| 74 |
+
]
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
Note: The `-y` flag auto-confirms npx prompts.
|
| 81 |
+
|
| 82 |
+
### Step 3: Restart Claude Desktop
|
| 83 |
+
|
| 84 |
+
**Important**: You must completely quit Claude Desktop and reopen it (not just close the window). On macOS, use Cmd+Q or right-click the dock icon and select Quit. On Windows, ensure Claude is fully closed from the system tray.
|
| 85 |
+
|
| 86 |
+
## Available Tools
|
| 87 |
+
|
| 88 |
+
Once connected, the following tools are available:
|
| 89 |
+
|
| 90 |
+
| Tool | Parameters | Description |
|
| 91 |
+
|------|------------|-------------|
|
| 92 |
+
| `get_spice_information` | `spice_name: str` | Get comprehensive spice data with nutrients |
|
| 93 |
+
| `get_nutrient_content` | `spice_name: str, nutrient_name: str` | Get specific nutrient content |
|
| 94 |
+
| `compare_spices` | `spice1: str, spice2: str` | Side-by-side nutritional comparison |
|
| 95 |
+
| `list_available_spices` | None | List all 88+ spices in the database |
|
| 96 |
+
| `get_health_benefits` | `spice_name: str` | Get health benefits from NCCIH |
|
| 97 |
+
| `get_safety_info` | `spice_name: str` | Get side effects and cautions |
|
| 98 |
+
| `find_substitute_spices` | `spice_name: str` | Find nutritionally similar alternatives |
|
| 99 |
+
| `find_spices_for_condition` | `condition: str` | Find spices for a health condition |
|
| 100 |
+
|
| 101 |
+
## Example Queries
|
| 102 |
+
|
| 103 |
+
After connecting, you can ask your AI assistant questions like:
|
| 104 |
+
|
| 105 |
+
- "What are the health benefits of turmeric?"
|
| 106 |
+
- "Compare the nutritional content of cinnamon and ginger"
|
| 107 |
+
- "Find spices that may help with inflammation"
|
| 108 |
+
- "What are the side effects of taking too much garlic?"
|
| 109 |
+
- "List all available spices in the database"
|
| 110 |
+
- "What can I substitute for cumin in a recipe?"
|
| 111 |
+
|
| 112 |
+
## Troubleshooting
|
| 113 |
+
|
| 114 |
+
### Connection Errors
|
| 115 |
+
|
| 116 |
+
1. **Verify the Space is running**: Visit your Hugging Face Space URL directly
|
| 117 |
+
2. **Check the endpoint**: Ensure you're using `/gradio_api/mcp/sse` (not `/gradio_api/mcp/`)
|
| 118 |
+
3. **Install mcp-remote**: Run `npm install -g mcp-remote` if npx fails
|
| 119 |
+
|
| 120 |
+
### Tool Not Found
|
| 121 |
+
|
| 122 |
+
1. Restart your AI client after adding the configuration
|
| 123 |
+
2. Check JSON syntax in the settings file
|
| 124 |
+
3. Verify the Space has finished building
|
| 125 |
+
|
| 126 |
+
### Rate Limiting
|
| 127 |
+
|
| 128 |
+
The server includes guardrails:
|
| 129 |
+
- 10 requests per minute per session
|
| 130 |
+
- 100 requests per day per session
|
| 131 |
+
|
| 132 |
+
If you hit rate limits, wait a moment before retrying.
|
| 133 |
+
|
| 134 |
+
## Security Notes
|
| 135 |
+
|
| 136 |
+
- The MCP server is read-only and cannot modify any data
|
| 137 |
+
- All responses include medical disclaimers
|
| 138 |
+
- No personal data is collected or stored
|
| 139 |
+
- API usage is tracked for cost control only
|
| 140 |
+
|
| 141 |
+
## References
|
| 142 |
+
|
| 143 |
+
- [Model Context Protocol Specification](https://spec.modelcontextprotocol.io/)
|
| 144 |
+
- [Gradio MCP Server Guide](https://www.gradio.app/guides/mcp-server)
|
| 145 |
+
- [mcp-remote Package](https://www.npmjs.com/package/mcp-remote)
|
README.md
CHANGED
|
@@ -8,63 +8,108 @@ sdk_version: 5.50.0
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
#
|
| 14 |
|
| 15 |
-
An AI-powered advisor for spice information, nutritional content, and medicinal properties. Built with Gradio, Neo4j, and
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
## Features
|
| 18 |
|
| 19 |
-
-
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
## Live Demo
|
| 26 |
|
| 27 |
-
- **Web UI**:
|
| 28 |
-
- **MCP Server**:
|
| 29 |
|
| 30 |
## Architecture
|
| 31 |
|
| 32 |
```
|
| 33 |
-
|
| 34 |
-
│
|
| 35 |
-
│
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
│
|
| 41 |
-
│
|
| 42 |
-
│
|
| 43 |
-
│
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
│
|
| 48 |
-
│
|
| 49 |
-
│
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
```
|
| 57 |
|
| 58 |
## Data Sources
|
| 59 |
|
| 60 |
-
All
|
|
|
|
| 61 |
- **USDA FoodData Central** - Public domain nutritional database
|
| 62 |
-
-
|
| 63 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
## Available Spices
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
| 68 |
|
| 69 |
## Local Development
|
| 70 |
|
|
@@ -156,34 +201,44 @@ medicinal-cuisine/
|
|
| 156 |
|
| 157 |
## MCP Server Usage
|
| 158 |
|
| 159 |
-
The application exposes
|
| 160 |
-
|
| 161 |
-
### Available Tools
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
|
| 180 |
-
|
| 181 |
-
mcp_url = "https://your-space.hf.space/gradio_api/mcp/"
|
| 182 |
-
|
| 183 |
-
# Example: List available tools
|
| 184 |
-
response = requests.get(f"{mcp_url}tools")
|
| 185 |
-
print(response.json())
|
| 186 |
-
```
|
| 187 |
|
| 188 |
## Project Structure
|
| 189 |
|
|
@@ -191,31 +246,35 @@ print(response.json())
|
|
| 191 |
medicinal-cuisine/
|
| 192 |
├── app.py # Main Gradio application
|
| 193 |
├── requirements.txt # Python dependencies
|
| 194 |
-
├── .env # Environment variables (not in git)
|
| 195 |
-
├── .env.example # Environment template
|
| 196 |
├── README.md # This file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
│
|
| 198 |
├── data/ # Data storage (not deployed to HF)
|
| 199 |
│ ├── spice_list.json # Seed spice names
|
| 200 |
-
│ ├──
|
| 201 |
-
│ └── raw/
|
| 202 |
-
│ ├── usda/ # Cached API responses
|
| 203 |
-
│ └── metadata.json # API call tracking
|
| 204 |
-
│
|
| 205 |
-
├── data_ingest/ # Data collection scripts (not deployed)
|
| 206 |
-
│ ├── usda_client.py # USDA API client
|
| 207 |
-
│ ├── fetch_all_spices.py # Batch data fetcher
|
| 208 |
-
│ └── ...
|
| 209 |
│
|
| 210 |
├── neo4j_setup/ # Database setup scripts (not deployed)
|
| 211 |
-
│ ├── test_connection.py
|
| 212 |
-
│ ├── create_schema.py
|
| 213 |
-
│
|
|
|
|
|
|
|
| 214 |
│
|
| 215 |
-
└──
|
| 216 |
-
├──
|
| 217 |
-
|
| 218 |
-
└── mcp_tools.py # MCP tool definitions
|
| 219 |
```
|
| 220 |
|
| 221 |
## Disclaimer
|
|
@@ -239,10 +298,20 @@ For issues or questions:
|
|
| 239 |
## Acknowledgments
|
| 240 |
|
| 241 |
- **USDA FoodData Central** for comprehensive nutritional data
|
|
|
|
| 242 |
- **Neo4j Aura** for graph database hosting
|
| 243 |
- **Gradio** for the web UI framework and MCP server capabilities
|
| 244 |
-
- **
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
---
|
| 247 |
|
| 248 |
-
Built
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- mcp-server
|
| 13 |
+
- mcp-in-action
|
| 14 |
+
- building-mcp-track-consumer
|
| 15 |
+
- mcp-in-action-track-consumer
|
| 16 |
---
|
| 17 |
|
| 18 |
+
# Medicinal Cuisine AI Advisor
|
| 19 |
|
| 20 |
+
An AI-powered advisor for spice information, nutritional content, and medicinal properties. Built with Gradio, Neo4j, LlamaIndex, and Claude AI.
|
| 21 |
+
|
| 22 |
+
> **Hackathon Submission**: This project is submitted to [MCP's 1st Birthday Hackathon](https://huggingface.co/MCP-1st-Birthday) (November 14-30, 2025).
|
| 23 |
+
>
|
| 24 |
+
> **Tracks:**
|
| 25 |
+
> - **Track 1: Building MCP** - Consumer MCP Servers (`building-mcp-track-consumer`)
|
| 26 |
+
> - **Track 2: MCP in Action** - Consumer Applications (`mcp-in-action-track-consumer`)
|
| 27 |
|
| 28 |
## Features
|
| 29 |
|
| 30 |
+
- **AI Chat Assistant** - Conversational interface powered by Claude via LlamaIndex AgentWorkflow
|
| 31 |
+
- **Spice Database** - Comprehensive data for 88+ spices with nutritional information
|
| 32 |
+
- **Health Benefits** - Medicinal properties from NCCIH (National Center for Complementary and Integrative Health)
|
| 33 |
+
- **Safety Information** - Side effects, cautions, and drug interactions
|
| 34 |
+
- **Spice Substitutes** - Find alternatives based on nutritional or medicinal similarity
|
| 35 |
+
- **MCP Server Integration** - Model Context Protocol tools at `/gradio_api/mcp/sse`
|
| 36 |
+
- **Guardrails** - Rate limiting, cost controls ($1/day limit), and medical disclaimer enforcement
|
| 37 |
+
- **Data Provenance** - All data sourced from USDA FoodData Central and NCCIH with full attribution
|
| 38 |
|
| 39 |
## Live Demo
|
| 40 |
|
| 41 |
+
- **Web UI**: https://mcp-1st-birthday-spice-bae.hf.space
|
| 42 |
+
- **MCP Server**: https://mcp-1st-birthday-spice-bae.hf.space/gradio_api/mcp/sse
|
| 43 |
|
| 44 |
## Architecture
|
| 45 |
|
| 46 |
```
|
| 47 |
+
┌─────────────────────────────────────────────────────────┐
|
| 48 |
+
│ Gradio Web UI + MCP Server │
|
| 49 |
+
│ (app.py - Port 7860) │
|
| 50 |
+
│ Tabs: AI Chat | Spice Database | Health & Safety │
|
| 51 |
+
└─────────────────────────────────────────────────────────┘
|
| 52 |
+
↓
|
| 53 |
+
┌─────────────────────────────────────────────────────────┐
|
| 54 |
+
│ LlamaIndex AgentWorkflow │
|
| 55 |
+
│ (tools/llama_agent.py - SpiceAgent) │
|
| 56 |
+
│ - Claude AI for natural language understanding │
|
| 57 |
+
│ - 8 function tools for database queries │
|
| 58 |
+
└─────────────────────────────────────────────────────────┘
|
| 59 |
+
↓
|
| 60 |
+
┌─────────────────────────��───────────────────────────────┐
|
| 61 |
+
│ Guardrails │
|
| 62 |
+
│ (tools/guardrails.py) │
|
| 63 |
+
│ - Rate limiting (10 req/min, 100 req/day) │
|
| 64 |
+
│ - Cost control ($1/day limit) │
|
| 65 |
+
│ - Medical disclaimer enforcement │
|
| 66 |
+
│ - Topic filtering (spice-related only) │
|
| 67 |
+
└─────────────────────────────────────────────────────────┘
|
| 68 |
+
↓
|
| 69 |
+
┌─────────────────────────────────────────────────────────┐
|
| 70 |
+
│ MCP Tools (tools/mcp_tools.py) │
|
| 71 |
+
│ - get_spice_information() - get_health_benefits() │
|
| 72 |
+
│ - get_nutrient_content() - get_safety_info() │
|
| 73 |
+
│ - compare_spices() - find_substitutes() │
|
| 74 |
+
│ - list_available_spices() - find_by_condition() │
|
| 75 |
+
└─────────────────────────────────────────────────────────┘
|
| 76 |
+
↓
|
| 77 |
+
┌─────────────────────────────────────────────────────────┐
|
| 78 |
+
│ Neo4j Aura (Spice-Bae Database) │
|
| 79 |
+
│ - 88+ Spice nodes with nutritional data │
|
| 80 |
+
│ - Health benefit relationships (NCCIH) │
|
| 81 |
+
│ - Safety information and cautions │
|
| 82 |
+
└─────────────────────────────────────────────────────────┘
|
| 83 |
+
↑
|
| 84 |
+
┌─────────────────────────────────────────────────────────┐
|
| 85 |
+
│ Data Sources │
|
| 86 |
+
│ - USDA FoodData Central (nutritional data) │
|
| 87 |
+
│ - NCCIH (health benefits, safety info) │
|
| 88 |
+
│ - Wikipedia (spice images with attribution) │
|
| 89 |
+
└─────────────────────────────────────────────────────────┘
|
| 90 |
```
|
| 91 |
|
| 92 |
## Data Sources
|
| 93 |
|
| 94 |
+
All data is sourced from authoritative sources:
|
| 95 |
+
|
| 96 |
- **USDA FoodData Central** - Public domain nutritional database
|
| 97 |
+
- License: Public Domain
|
| 98 |
+
- URL: https://fdc.nal.usda.gov/
|
| 99 |
+
|
| 100 |
+
- **NCCIH (National Center for Complementary and Integrative Health)** - Health benefits and safety information
|
| 101 |
+
- License: Public Domain (U.S. Government)
|
| 102 |
+
- URL: https://www.nccih.nih.gov/
|
| 103 |
+
|
| 104 |
+
- **Wikipedia** - Spice images with proper attribution
|
| 105 |
+
- License: Various (CC BY-SA, Public Domain)
|
| 106 |
+
- Images stored locally with source attribution
|
| 107 |
|
| 108 |
## Available Spices
|
| 109 |
|
| 110 |
+
The database contains 88+ spices including:
|
| 111 |
+
|
| 112 |
+
Allspice, Anise, Asafoetida, Basil, Bay Leaf, Black Cardamom, Black Cumin, Black Pepper, Caraway, Cardamom, Cayenne Pepper, Celery Seed, Chervil, Chili Powder, Chives, Cilantro, Cinnamon, Cloves, Coriander, Cumin, Curry Powder, Dill, Fennel, Fenugreek, Galangal, Garlic, Ginger, Grains of Paradise, Green Cardamom, Horseradish, Juniper Berries, Kaffir Lime, Lavender, Lemon Balm, Lemongrass, Licorice, Lovage, Mace, Marjoram, Mint, Mustard Seed, Nutmeg, Onion Powder, Oregano, Paprika, Parsley, Peppermint, Pink Pepper, Poppy Seed, Rosemary, Saffron, Sage, Savory, Sesame, Shallot, Spearmint, Star Anise, Sumac, Szechuan Pepper, Tamarind, Tarragon, Thyme, Turmeric, Vanilla, Wasabi, White Pepper, and more...
|
| 113 |
|
| 114 |
## Local Development
|
| 115 |
|
|
|
|
| 201 |
|
| 202 |
## MCP Server Usage
|
| 203 |
|
| 204 |
+
The application exposes tools via the Model Context Protocol at `/gradio_api/mcp/sse`:
|
| 205 |
+
|
| 206 |
+
### Available MCP Tools
|
| 207 |
+
|
| 208 |
+
| Tool | Description |
|
| 209 |
+
|------|-------------|
|
| 210 |
+
| `get_spice_information(spice_name)` | Comprehensive spice data with nutrients and image |
|
| 211 |
+
| `get_nutrient_content(spice_name, nutrient_name)` | Specific nutrient content |
|
| 212 |
+
| `compare_spices(spice1, spice2)` | Side-by-side nutritional comparison |
|
| 213 |
+
| `list_available_spices()` | List all 88+ spices |
|
| 214 |
+
| `get_health_benefits(spice_name)` | Health benefits from NCCIH |
|
| 215 |
+
| `get_safety_info(spice_name)` | Side effects, cautions, interactions |
|
| 216 |
+
| `find_substitute_spices(spice_name)` | Nutritionally similar alternatives |
|
| 217 |
+
| `find_spices_for_condition(condition)` | Spices that may help with a condition |
|
| 218 |
+
|
| 219 |
+
### Connecting with Cline or Claude Desktop
|
| 220 |
+
|
| 221 |
+
Add to your MCP settings configuration:
|
| 222 |
+
|
| 223 |
+
```json
|
| 224 |
+
{
|
| 225 |
+
"mcpServers": {
|
| 226 |
+
"medicinal-cuisine": {
|
| 227 |
+
"command": "npx",
|
| 228 |
+
"args": [
|
| 229 |
+
"-y",
|
| 230 |
+
"mcp-remote",
|
| 231 |
+
"https://mcp-1st-birthday-spice-bae.hf.space/gradio_api/mcp/sse"
|
| 232 |
+
]
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
```
|
| 237 |
|
| 238 |
+
- **Cline**: Edit `cline_mcp_settings.json`, then restart VS Code
|
| 239 |
+
- **Claude Desktop**: Edit `claude_desktop_config.json`, then **completely quit and reopen** Claude Desktop
|
| 240 |
|
| 241 |
+
See [MCP_INTEGRATION.md](MCP_INTEGRATION.md) for detailed setup instructions.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
## Project Structure
|
| 244 |
|
|
|
|
| 246 |
medicinal-cuisine/
|
| 247 |
├── app.py # Main Gradio application
|
| 248 |
├── requirements.txt # Python dependencies
|
|
|
|
|
|
|
| 249 |
├── README.md # This file
|
| 250 |
+
├── MCP_INTEGRATION.md # MCP setup guide for Cline/Claude Desktop
|
| 251 |
+
│
|
| 252 |
+
├── tools/ # Core application logic (deployed)
|
| 253 |
+
│ ├── __init__.py # Module exports
|
| 254 |
+
│ ├── neo4j_queries.py # Database queries
|
| 255 |
+
│ ├── mcp_tools.py # MCP tool definitions
|
| 256 |
+
│ ├── llama_agent.py # LlamaIndex agent with Claude
|
| 257 |
+
│ ├── guardrails.py # Safety guardrails
|
| 258 |
+
│ └── image_utils.py # Local image serving
|
| 259 |
+
│
|
| 260 |
+
├── static/ # Static assets (deployed)
|
| 261 |
+
│ └── images/spices/ # Local spice images from Wikipedia
|
| 262 |
│
|
| 263 |
├── data/ # Data storage (not deployed to HF)
|
| 264 |
│ ├── spice_list.json # Seed spice names
|
| 265 |
+
│ ├── spice_images_local.json # Local image mappings
|
| 266 |
+
│ └── raw/ # Cached API responses
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
│
|
| 268 |
├── neo4j_setup/ # Database setup scripts (not deployed)
|
| 269 |
+
│ ├── test_connection.py # Connection tester
|
| 270 |
+
│ ├── create_schema.py # Schema creation
|
| 271 |
+
│ ├── load_data.py # Data loader
|
| 272 |
+
│ ├── fetch_wikipedia_images.py # Image fetcher
|
| 273 |
+
│ └── download_spice_images.py # Image downloader
|
| 274 |
│
|
| 275 |
+
└── data_ingest/ # Data collection scripts (not deployed)
|
| 276 |
+
├── usda_client.py # USDA API client
|
| 277 |
+
└── fetch_all_spices.py # Batch data fetcher
|
|
|
|
| 278 |
```
|
| 279 |
|
| 280 |
## Disclaimer
|
|
|
|
| 298 |
## Acknowledgments
|
| 299 |
|
| 300 |
- **USDA FoodData Central** for comprehensive nutritional data
|
| 301 |
+
- **NCCIH** for health benefits and safety information
|
| 302 |
- **Neo4j Aura** for graph database hosting
|
| 303 |
- **Gradio** for the web UI framework and MCP server capabilities
|
| 304 |
+
- **LlamaIndex** for the agent orchestration framework
|
| 305 |
+
- **Anthropic** for Claude AI and the Model Context Protocol specification
|
| 306 |
+
- **Wikipedia** for spice images with proper attribution
|
| 307 |
+
|
| 308 |
+
## References
|
| 309 |
+
|
| 310 |
+
- [MCP's 1st Birthday Hackathon](https://huggingface.co/MCP-1st-Birthday)
|
| 311 |
+
- [Model Context Protocol Specification](https://spec.modelcontextprotocol.io/)
|
| 312 |
+
- [Gradio MCP Server Documentation](https://www.gradio.app/guides/mcp-server)
|
| 313 |
+
- [LlamaIndex Documentation](https://docs.llamaindex.ai/)
|
| 314 |
|
| 315 |
---
|
| 316 |
|
| 317 |
+
Built for medicinal cuisine enthusiasts
|
app.py
CHANGED
|
@@ -3,335 +3,599 @@
|
|
| 3 |
This application provides:
|
| 4 |
1. Web UI for querying spice information
|
| 5 |
2. MCP server at /gradio_api/mcp/ for AI agent integration
|
|
|
|
| 6 |
|
| 7 |
All data is sourced from USDA FoodData Central with full attribution.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
|
|
|
|
|
|
|
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
import gradio as gr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from tools.mcp_tools import (
|
| 14 |
-
get_spice_information,
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
if not os.getenv("SPACE_ID"):
|
| 26 |
load_dotenv()
|
| 27 |
-
|
| 28 |
-
def create_spice_info_tab():
|
| 29 |
-
"""Create the spice information lookup tab."""
|
| 30 |
-
with gr.Tab("Spice Information"):
|
| 31 |
-
gr.Markdown("### Look up detailed information about a spice")
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
label="Spice Name",
|
| 36 |
-
placeholder="e.g., turmeric, ginger, cinnamon",
|
| 37 |
-
scale=3
|
| 38 |
-
)
|
| 39 |
-
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 40 |
|
| 41 |
-
output = gr.Textbox(
|
| 42 |
-
label="Spice Information",
|
| 43 |
-
lines=15,
|
| 44 |
-
interactive=False
|
| 45 |
-
)
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
| 51 |
|
| 52 |
-
search_btn.click(
|
| 53 |
-
fn=get_spice_information,
|
| 54 |
-
inputs=spice_input,
|
| 55 |
-
outputs=output
|
| 56 |
-
)
|
| 57 |
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
label="Spice Name",
|
| 67 |
-
placeholder="e.g., turmeric",
|
| 68 |
-
scale=2
|
| 69 |
-
)
|
| 70 |
-
nutrient_input = gr.Textbox(
|
| 71 |
-
label="Nutrient Name",
|
| 72 |
-
placeholder="e.g., iron, vitamin C, calcium",
|
| 73 |
-
scale=2
|
| 74 |
-
)
|
| 75 |
-
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 76 |
|
| 77 |
-
|
| 78 |
-
label="Nutrient Information",
|
| 79 |
-
lines=8,
|
| 80 |
-
interactive=False
|
| 81 |
-
)
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
["turmeric", "iron"],
|
| 86 |
-
["ginger root", "vitamin C"],
|
| 87 |
-
["cinnamon", "calcium"]
|
| 88 |
-
],
|
| 89 |
-
inputs=[spice_input, nutrient_input]
|
| 90 |
-
)
|
| 91 |
|
| 92 |
-
|
| 93 |
-
fn=get_nutrient_content,
|
| 94 |
-
inputs=[spice_input, nutrient_input],
|
| 95 |
-
outputs=output
|
| 96 |
-
)
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
"
|
| 101 |
-
with gr.Tab("Compare Spices"):
|
| 102 |
-
gr.Markdown("### Compare nutritional content of two spices")
|
| 103 |
|
| 104 |
-
|
| 105 |
-
spice1_input = gr.Textbox(
|
| 106 |
-
label="Spice 1",
|
| 107 |
-
placeholder="e.g., turmeric",
|
| 108 |
-
scale=2
|
| 109 |
-
)
|
| 110 |
-
spice2_input = gr.Textbox(
|
| 111 |
-
label="Spice 2",
|
| 112 |
-
placeholder="e.g., ginger root",
|
| 113 |
-
scale=2
|
| 114 |
-
)
|
| 115 |
-
compare_btn = gr.Button("Compare", variant="primary", scale=1)
|
| 116 |
|
| 117 |
-
output = gr.Textbox(
|
| 118 |
-
label="Comparison Results",
|
| 119 |
-
lines=20,
|
| 120 |
-
interactive=False
|
| 121 |
-
)
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
],
|
| 129 |
-
inputs=[spice1_input, spice2_input]
|
| 130 |
-
)
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
)
|
|
|
|
|
|
|
| 137 |
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
| 142 |
gr.Markdown(
|
| 143 |
"""
|
| 144 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
Perfect for when you're missing a spice in your recipe!
|
| 148 |
"""
|
| 149 |
)
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
with gr.Row():
|
| 152 |
-
|
| 153 |
-
label="
|
| 154 |
-
placeholder="e.g.,
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
)
|
| 158 |
-
limit_slider = gr.Slider(
|
| 159 |
-
minimum=3,
|
| 160 |
-
maximum=10,
|
| 161 |
-
value=5,
|
| 162 |
-
step=1,
|
| 163 |
-
label="Number of Substitutes",
|
| 164 |
-
scale=1
|
| 165 |
)
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
output = gr.Textbox(
|
| 170 |
-
label="Substitute Suggestions",
|
| 171 |
-
lines=20,
|
| 172 |
-
interactive=False
|
| 173 |
-
)
|
| 174 |
|
| 175 |
gr.Examples(
|
| 176 |
examples=[
|
| 177 |
-
["
|
| 178 |
-
["
|
| 179 |
-
["
|
| 180 |
-
["
|
|
|
|
| 181 |
],
|
| 182 |
-
inputs=
|
| 183 |
)
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
)
|
| 190 |
|
| 191 |
|
| 192 |
-
def
|
| 193 |
-
"""Create the
|
| 194 |
-
with gr.Tab("
|
| 195 |
-
gr.Markdown("###
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
### Learn about health benefits and medicinal properties
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
)
|
| 228 |
-
search_btn = gr.Button("Get Health Info", variant="primary", scale=1)
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
|
| 236 |
-
|
| 237 |
-
examples=[["Garlic"], ["Cinnamon"], ["Fenugreek"], ["Sage"]],
|
| 238 |
-
inputs=spice_input
|
| 239 |
-
)
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
|
| 248 |
-
def
|
| 249 |
-
"""Create the health
|
| 250 |
-
with gr.Tab("
|
| 251 |
gr.Markdown(
|
| 252 |
"""
|
| 253 |
-
###
|
| 254 |
-
|
| 255 |
-
Search traditional uses and scientific research
|
| 256 |
"""
|
| 257 |
)
|
| 258 |
|
| 259 |
-
with gr.
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
)
|
| 265 |
-
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 266 |
-
|
| 267 |
-
output = gr.Textbox(
|
| 268 |
-
label="Matching Spices",
|
| 269 |
-
lines=15,
|
| 270 |
-
interactive=False
|
| 271 |
-
)
|
| 272 |
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
)
|
| 302 |
-
search_btn = gr.Button("Get Safety Info", variant="primary", scale=1)
|
| 303 |
|
| 304 |
-
output = gr.Textbox(
|
| 305 |
-
label="Safety Information",
|
| 306 |
-
lines=18,
|
| 307 |
-
interactive=False
|
| 308 |
-
)
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
)
|
| 314 |
|
| 315 |
-
gr.
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
)
|
| 321 |
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
| 326 |
)
|
| 327 |
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
# Create main application
|
| 330 |
-
with gr.Blocks(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
|
| 332 |
gr.Markdown(
|
| 333 |
"""
|
| 334 |
-
#
|
| 335 |
|
| 336 |
Explore spices, their nutritional content, and health properties.
|
| 337 |
All data sourced from **USDA FoodData Central** with full attribution.
|
|
@@ -340,24 +604,20 @@ with gr.Blocks(title="Medicinal Cuisine AI Advisor") as demo:
|
|
| 340 |
"""
|
| 341 |
)
|
| 342 |
|
| 343 |
-
# Create tabs
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
create_safety_info_tab()
|
| 349 |
-
create_comparison_tab()
|
| 350 |
-
create_substitute_finder_tab()
|
| 351 |
-
create_spice_list_tab()
|
| 352 |
|
| 353 |
gr.Markdown(
|
| 354 |
"""
|
| 355 |
---
|
| 356 |
**Data Sources:** USDA FoodData Central | NCCIH (Public Domain)
|
| 357 |
|
| 358 |
-
**
|
| 359 |
|
| 360 |
-
**
|
| 361 |
"""
|
| 362 |
)
|
| 363 |
|
|
|
|
| 3 |
This application provides:
|
| 4 |
1. Web UI for querying spice information
|
| 5 |
2. MCP server at /gradio_api/mcp/ for AI agent integration
|
| 6 |
+
3. Conversational AI chat interface (LlamaIndex + Claude)
|
| 7 |
|
| 8 |
All data is sourced from USDA FoodData Central with full attribution.
|
| 9 |
"""
|
| 10 |
|
| 11 |
import os
|
| 12 |
+
import traceback
|
| 13 |
+
from functools import wraps
|
| 14 |
+
from typing import List, Tuple, Callable, Any
|
| 15 |
from dotenv import load_dotenv
|
| 16 |
import gradio as gr
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def handle_errors(func: Callable) -> Callable:
|
| 20 |
+
"""Decorator to handle errors gracefully in UI functions.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
func: Function to wrap with error handling.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Wrapped function that catches exceptions.
|
| 27 |
+
"""
|
| 28 |
+
@wraps(func)
|
| 29 |
+
def wrapper(*args, **kwargs) -> Any:
|
| 30 |
+
try:
|
| 31 |
+
return func(*args, **kwargs)
|
| 32 |
+
except ConnectionError:
|
| 33 |
+
return None, "Connection error. Please check your internet connection and try again."
|
| 34 |
+
except TimeoutError:
|
| 35 |
+
return None, "Request timed out. The server may be busy. Please try again."
|
| 36 |
+
except Exception as e:
|
| 37 |
+
error_msg = f"An error occurred: {str(e)}"
|
| 38 |
+
print(f"[ERROR] {func.__name__}: {traceback.format_exc()}")
|
| 39 |
+
if hasattr(func, '__annotations__'):
|
| 40 |
+
return_type = func.__annotations__.get('return', None)
|
| 41 |
+
if return_type and 'Tuple' in str(return_type):
|
| 42 |
+
return None, error_msg
|
| 43 |
+
return error_msg
|
| 44 |
+
return wrapper
|
| 45 |
+
|
| 46 |
+
|
| 47 |
from tools.mcp_tools import (
|
| 48 |
+
get_spice_information as _get_spice_information,
|
| 49 |
+
get_spice_info_with_image as _get_spice_info_with_image,
|
| 50 |
+
get_health_benefits_with_image as _get_health_benefits_with_image,
|
| 51 |
+
get_safety_info_with_image as _get_safety_info_with_image,
|
| 52 |
+
list_available_spices as _list_available_spices,
|
| 53 |
+
get_nutrient_content as _get_nutrient_content,
|
| 54 |
+
compare_spices as _compare_spices,
|
| 55 |
+
find_spice_substitutes as _find_spice_substitutes,
|
| 56 |
+
get_health_benefits_info as _get_health_benefits_info,
|
| 57 |
+
find_spices_for_health_benefit as _find_spices_for_health_benefit,
|
| 58 |
+
get_spice_safety_information as _get_spice_safety_information,
|
| 59 |
+
find_medicinal_substitutes as _find_medicinal_substitutes
|
| 60 |
)
|
| 61 |
+
from tools.llama_agent import SpiceAgent
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# Wrap all functions with error handling
|
| 65 |
+
@handle_errors
|
| 66 |
+
def get_spice_information(spice_name: str) -> str:
|
| 67 |
+
return _get_spice_information(spice_name)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@handle_errors
|
| 71 |
+
def get_spice_info_with_image(spice_name: str) -> Tuple[str, str]:
|
| 72 |
+
return _get_spice_info_with_image(spice_name)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@handle_errors
|
| 76 |
+
def get_health_benefits_with_image(spice_name: str) -> Tuple[str, str]:
|
| 77 |
+
return _get_health_benefits_with_image(spice_name)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@handle_errors
|
| 81 |
+
def get_safety_info_with_image(spice_name: str) -> Tuple[str, str]:
|
| 82 |
+
return _get_safety_info_with_image(spice_name)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@handle_errors
|
| 86 |
+
def list_available_spices() -> str:
|
| 87 |
+
return _list_available_spices()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@handle_errors
|
| 91 |
+
def get_nutrient_content(spice_name: str, nutrient_name: str) -> str:
|
| 92 |
+
return _get_nutrient_content(spice_name, nutrient_name)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@handle_errors
|
| 96 |
+
def compare_spices(spice1: str, spice2: str) -> str:
|
| 97 |
+
return _compare_spices(spice1, spice2)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@handle_errors
|
| 101 |
+
def find_spice_substitutes(spice_name: str, limit: int = 5) -> str:
|
| 102 |
+
return _find_spice_substitutes(spice_name, limit)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@handle_errors
|
| 106 |
+
def get_health_benefits_info(spice_name: str) -> str:
|
| 107 |
+
return _get_health_benefits_info(spice_name)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@handle_errors
|
| 111 |
+
def find_spices_for_health_benefit(benefit: str) -> str:
|
| 112 |
+
return _find_spices_for_health_benefit(benefit)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@handle_errors
|
| 116 |
+
def get_spice_safety_information(spice_name: str) -> str:
|
| 117 |
+
return _get_spice_safety_information(spice_name)
|
| 118 |
+
|
| 119 |
|
| 120 |
if not os.getenv("SPACE_ID"):
|
| 121 |
load_dotenv()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
+
# Initialize LlamaIndex agent (lazy initialization)
|
| 124 |
+
spice_agent = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
def get_agent() -> SpiceAgent:
|
| 128 |
+
"""Get or create the spice agent instance."""
|
| 129 |
+
global spice_agent
|
| 130 |
+
if spice_agent is None:
|
| 131 |
+
spice_agent = SpiceAgent()
|
| 132 |
+
return spice_agent
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
def get_usage_stats() -> str:
|
| 136 |
+
"""Get usage statistics from the guardrails.
|
| 137 |
|
| 138 |
+
Returns:
|
| 139 |
+
Formatted usage statistics string.
|
| 140 |
+
"""
|
| 141 |
+
agent = get_agent()
|
| 142 |
|
| 143 |
+
if not agent.guardrails:
|
| 144 |
+
return "Guardrails are disabled. No usage tracking available."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
usage_guardrail = agent.guardrails.get_guardrail("usage_tracking")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
+
if not usage_guardrail:
|
| 149 |
+
return "Usage tracking guardrail not found."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
+
summary = usage_guardrail.get_daily_summary()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
output = []
|
| 154 |
+
output.append("=== USAGE DASHBOARD ===\n")
|
| 155 |
+
output.append(f"Date: {summary['date']}")
|
| 156 |
+
output.append(f"Total Requests: {summary['total_requests']}")
|
| 157 |
+
output.append(f"\n--- Token Usage ---")
|
| 158 |
+
output.append(f"Input Tokens: {summary['total_input_tokens']:,}")
|
| 159 |
+
output.append(f"Output Tokens: {summary['total_output_tokens']:,}")
|
| 160 |
+
output.append(f"Total Tokens: {summary['total_input_tokens'] + summary['total_output_tokens']:,}")
|
| 161 |
+
output.append(f"\n--- Cost ---")
|
| 162 |
+
output.append(f"Today's Cost: ${summary['total_cost_usd']:.4f}")
|
| 163 |
+
output.append(f"Daily Limit: ${summary['daily_limit_usd']:.2f}")
|
| 164 |
+
output.append(f"Remaining Budget: ${summary['remaining_budget_usd']:.4f}")
|
| 165 |
|
| 166 |
+
pct_used = (summary['total_cost_usd'] / summary['daily_limit_usd']) * 100 if summary['daily_limit_usd'] > 0 else 0
|
| 167 |
+
output.append(f"Budget Used: {pct_used:.1f}%")
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
return "\n".join(output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
def chat_response(
|
| 173 |
+
message: str,
|
| 174 |
+
history: List[Tuple[str, str]]
|
| 175 |
+
) -> Tuple[str, List[Tuple[str, str]]]:
|
| 176 |
+
"""Process chat message and return response.
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
+
Args:
|
| 179 |
+
message: User's input message.
|
| 180 |
+
history: List of (user, assistant) message tuples.
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
Tuple of (empty string for input, updated history).
|
| 184 |
+
"""
|
| 185 |
+
agent = get_agent()
|
| 186 |
+
|
| 187 |
+
if not agent.is_ready():
|
| 188 |
+
response = (
|
| 189 |
+
"The AI chat feature requires an Anthropic API key (ANTHROPIC_API_KEY). "
|
| 190 |
+
"Please use the individual tabs above for spice queries, or set "
|
| 191 |
+
"the ANTHROPIC_API_KEY environment variable to enable conversational AI."
|
| 192 |
)
|
| 193 |
+
else:
|
| 194 |
+
response = agent.chat(message)
|
| 195 |
|
| 196 |
+
history.append((message, response))
|
| 197 |
+
return "", history
|
| 198 |
|
| 199 |
+
|
| 200 |
+
def create_chat_tab():
|
| 201 |
+
"""Create the conversational AI chat tab."""
|
| 202 |
+
with gr.Tab("AI Chat"):
|
| 203 |
gr.Markdown(
|
| 204 |
"""
|
| 205 |
+
### Ask me anything about spices!
|
| 206 |
+
|
| 207 |
+
I can help you with:
|
| 208 |
+
- Spice information and nutritional data
|
| 209 |
+
- Health benefits and traditional uses
|
| 210 |
+
- Finding substitutes for spices
|
| 211 |
+
- Safety information and cautions
|
| 212 |
|
| 213 |
+
**Note:** Requires ANTHROPIC_API_KEY for AI responses. Use other tabs if unavailable.
|
|
|
|
| 214 |
"""
|
| 215 |
)
|
| 216 |
|
| 217 |
+
chatbot = gr.Chatbot(
|
| 218 |
+
label="Conversation",
|
| 219 |
+
height=400,
|
| 220 |
+
type="tuples",
|
| 221 |
+
show_copy_button=True
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
with gr.Row():
|
| 225 |
+
msg_input = gr.Textbox(
|
| 226 |
+
label="Your Question",
|
| 227 |
+
placeholder="e.g., What spices help with inflammation?",
|
| 228 |
+
scale=4,
|
| 229 |
+
show_label=False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
)
|
| 231 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 232 |
|
| 233 |
+
status = gr.Markdown("", elem_classes=["status-ready"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
|
| 235 |
gr.Examples(
|
| 236 |
examples=[
|
| 237 |
+
["What are the health benefits of turmeric?"],
|
| 238 |
+
["Which spices help lower cholesterol?"],
|
| 239 |
+
["What's a good substitute for cinnamon?"],
|
| 240 |
+
["Is garlic safe to take with blood thinners?"],
|
| 241 |
+
["What spices are high in iron?"],
|
| 242 |
],
|
| 243 |
+
inputs=msg_input
|
| 244 |
)
|
| 245 |
|
| 246 |
+
def chat_with_status(message, history):
|
| 247 |
+
"""Chat response with status updates."""
|
| 248 |
+
result = chat_response(message, history)
|
| 249 |
+
return result[0], result[1], ""
|
| 250 |
+
|
| 251 |
+
msg_input.submit(
|
| 252 |
+
fn=lambda: "Thinking...",
|
| 253 |
+
outputs=status
|
| 254 |
+
).then(
|
| 255 |
+
fn=chat_with_status,
|
| 256 |
+
inputs=[msg_input, chatbot],
|
| 257 |
+
outputs=[msg_input, chatbot, status],
|
| 258 |
+
show_progress="minimal"
|
| 259 |
+
)
|
| 260 |
+
send_btn.click(
|
| 261 |
+
fn=lambda: "Thinking...",
|
| 262 |
+
outputs=status
|
| 263 |
+
).then(
|
| 264 |
+
fn=chat_with_status,
|
| 265 |
+
inputs=[msg_input, chatbot],
|
| 266 |
+
outputs=[msg_input, chatbot, status],
|
| 267 |
+
show_progress="minimal"
|
| 268 |
)
|
| 269 |
|
| 270 |
|
| 271 |
+
def create_spice_database_tab():
|
| 272 |
+
"""Create the consolidated spice database tab."""
|
| 273 |
+
with gr.Tab("Spice Database"):
|
| 274 |
+
gr.Markdown("### Explore spice information, nutrients, and comparisons")
|
| 275 |
+
|
| 276 |
+
with gr.Accordion("Spice Lookup", open=True):
|
| 277 |
+
with gr.Row():
|
| 278 |
+
spice_input = gr.Textbox(
|
| 279 |
+
label="Spice Name",
|
| 280 |
+
placeholder="e.g., turmeric, ginger, cinnamon",
|
| 281 |
+
scale=3
|
| 282 |
+
)
|
| 283 |
+
search_btn = gr.Button("Search", variant="primary", scale=1)
|
| 284 |
+
|
| 285 |
+
with gr.Row():
|
| 286 |
+
spice_image = gr.Image(
|
| 287 |
+
label="Spice Image",
|
| 288 |
+
height=200,
|
| 289 |
+
width=200,
|
| 290 |
+
show_label=False,
|
| 291 |
+
scale=1
|
| 292 |
+
)
|
| 293 |
+
info_output = gr.Textbox(
|
| 294 |
+
label="Spice Information",
|
| 295 |
+
lines=10,
|
| 296 |
+
interactive=False,
|
| 297 |
+
scale=3
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
gr.Examples(
|
| 301 |
+
examples=[["turmeric"], ["ginger root"], ["cinnamon"], ["garlic"]],
|
| 302 |
+
inputs=spice_input
|
| 303 |
+
)
|
| 304 |
|
| 305 |
+
search_btn.click(
|
| 306 |
+
fn=get_spice_info_with_image,
|
| 307 |
+
inputs=spice_input,
|
| 308 |
+
outputs=[spice_image, info_output],
|
| 309 |
+
show_progress="minimal"
|
| 310 |
+
)
|
| 311 |
+
spice_input.submit(
|
| 312 |
+
fn=get_spice_info_with_image,
|
| 313 |
+
inputs=spice_input,
|
| 314 |
+
outputs=[spice_image, info_output],
|
| 315 |
+
show_progress="minimal"
|
| 316 |
+
)
|
| 317 |
|
| 318 |
+
with gr.Accordion("Find Substitutes", open=False):
|
| 319 |
+
with gr.Row():
|
| 320 |
+
sub_spice_input = gr.Textbox(
|
| 321 |
+
label="Spice Name",
|
| 322 |
+
placeholder="e.g., cinnamon",
|
| 323 |
+
scale=3
|
| 324 |
+
)
|
| 325 |
+
limit_slider = gr.Slider(
|
| 326 |
+
minimum=3, maximum=10, value=5, step=1,
|
| 327 |
+
label="Results", scale=1
|
| 328 |
+
)
|
| 329 |
+
find_btn = gr.Button("Find", variant="primary", scale=1)
|
| 330 |
+
|
| 331 |
+
sub_output = gr.Textbox(
|
| 332 |
+
label="Substitute Suggestions",
|
| 333 |
+
lines=12,
|
| 334 |
+
interactive=False
|
| 335 |
+
)
|
| 336 |
|
| 337 |
+
find_btn.click(
|
| 338 |
+
fn=find_spice_substitutes,
|
| 339 |
+
inputs=[sub_spice_input, limit_slider],
|
| 340 |
+
outputs=sub_output,
|
| 341 |
+
show_progress="minimal"
|
| 342 |
+
)
|
| 343 |
|
| 344 |
+
with gr.Accordion("Compare Spices", open=False):
|
| 345 |
+
with gr.Row():
|
| 346 |
+
spice1_input = gr.Textbox(label="Spice 1", placeholder="e.g., turmeric", scale=2)
|
| 347 |
+
spice2_input = gr.Textbox(label="Spice 2", placeholder="e.g., ginger", scale=2)
|
| 348 |
+
compare_btn = gr.Button("Compare", variant="primary", scale=1)
|
|
|
|
| 349 |
|
| 350 |
+
compare_output = gr.Textbox(
|
| 351 |
+
label="Comparison Results",
|
| 352 |
+
lines=15,
|
| 353 |
+
interactive=False
|
| 354 |
+
)
|
| 355 |
|
| 356 |
+
compare_btn.click(
|
| 357 |
+
fn=compare_spices,
|
| 358 |
+
inputs=[spice1_input, spice2_input],
|
| 359 |
+
outputs=compare_output,
|
| 360 |
+
show_progress="minimal"
|
| 361 |
)
|
|
|
|
| 362 |
|
| 363 |
+
with gr.Accordion("Nutrient Lookup", open=False):
|
| 364 |
+
with gr.Row():
|
| 365 |
+
nut_spice = gr.Textbox(label="Spice", placeholder="e.g., turmeric", scale=2)
|
| 366 |
+
nut_name = gr.Textbox(label="Nutrient", placeholder="e.g., iron", scale=2)
|
| 367 |
+
nut_btn = gr.Button("Search", variant="primary", scale=1)
|
| 368 |
|
| 369 |
+
nut_output = gr.Textbox(label="Nutrient Information", lines=6, interactive=False)
|
|
|
|
|
|
|
|
|
|
| 370 |
|
| 371 |
+
nut_btn.click(
|
| 372 |
+
fn=get_nutrient_content,
|
| 373 |
+
inputs=[nut_spice, nut_name],
|
| 374 |
+
outputs=nut_output,
|
| 375 |
+
show_progress="minimal"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
with gr.Accordion("All Available Spices", open=False):
|
| 379 |
+
list_btn = gr.Button("Show All Spices", variant="secondary")
|
| 380 |
+
list_output = gr.Textbox(label="Spice List", lines=12, interactive=False)
|
| 381 |
+
|
| 382 |
+
list_btn.click(
|
| 383 |
+
fn=list_available_spices,
|
| 384 |
+
outputs=list_output,
|
| 385 |
+
show_progress="minimal"
|
| 386 |
+
)
|
| 387 |
|
| 388 |
|
| 389 |
+
def create_health_safety_tab():
|
| 390 |
+
"""Create the consolidated health and safety tab."""
|
| 391 |
+
with gr.Tab("Health & Safety"):
|
| 392 |
gr.Markdown(
|
| 393 |
"""
|
| 394 |
+
### Health benefits and safety information
|
| 395 |
+
Data sourced from **NCCIH** (National Center for Complementary and Integrative Health)
|
|
|
|
| 396 |
"""
|
| 397 |
)
|
| 398 |
|
| 399 |
+
with gr.Accordion("Health Benefits Lookup", open=True):
|
| 400 |
+
with gr.Row():
|
| 401 |
+
health_spice = gr.Textbox(
|
| 402 |
+
label="Spice Name",
|
| 403 |
+
placeholder="e.g., Garlic, Cinnamon",
|
| 404 |
+
scale=3
|
| 405 |
+
)
|
| 406 |
+
health_btn = gr.Button("Get Health Info", variant="primary", scale=1)
|
| 407 |
+
|
| 408 |
+
with gr.Row():
|
| 409 |
+
health_image = gr.Image(
|
| 410 |
+
label="Spice Image",
|
| 411 |
+
height=200,
|
| 412 |
+
width=200,
|
| 413 |
+
show_label=False,
|
| 414 |
+
scale=1
|
| 415 |
+
)
|
| 416 |
+
health_output = gr.Textbox(
|
| 417 |
+
label="Health Information",
|
| 418 |
+
lines=12,
|
| 419 |
+
interactive=False,
|
| 420 |
+
scale=3
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
gr.Examples(
|
| 424 |
+
examples=[["Garlic"], ["Cinnamon"], ["Fenugreek"], ["Turmeric"]],
|
| 425 |
+
inputs=health_spice
|
| 426 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
|
| 428 |
+
health_btn.click(
|
| 429 |
+
fn=get_health_benefits_with_image,
|
| 430 |
+
inputs=health_spice,
|
| 431 |
+
outputs=[health_image, health_output],
|
| 432 |
+
show_progress="minimal"
|
| 433 |
+
)
|
| 434 |
+
health_spice.submit(
|
| 435 |
+
fn=get_health_benefits_with_image,
|
| 436 |
+
inputs=health_spice,
|
| 437 |
+
outputs=[health_image, health_output],
|
| 438 |
+
show_progress="minimal"
|
| 439 |
+
)
|
| 440 |
|
| 441 |
+
with gr.Accordion("Search by Health Condition", open=False):
|
| 442 |
+
with gr.Row():
|
| 443 |
+
condition_input = gr.Textbox(
|
| 444 |
+
label="Health Condition",
|
| 445 |
+
placeholder="e.g., diabetes, cholesterol, inflammation",
|
| 446 |
+
scale=3
|
| 447 |
+
)
|
| 448 |
+
condition_btn = gr.Button("Search", variant="primary", scale=1)
|
| 449 |
+
|
| 450 |
+
condition_output = gr.Textbox(
|
| 451 |
+
label="Matching Spices",
|
| 452 |
+
lines=12,
|
| 453 |
+
interactive=False
|
| 454 |
+
)
|
| 455 |
|
| 456 |
+
gr.Examples(
|
| 457 |
+
examples=[["diabetes"], ["cholesterol"], ["inflammation"]],
|
| 458 |
+
inputs=condition_input
|
| 459 |
+
)
|
| 460 |
|
| 461 |
+
condition_btn.click(
|
| 462 |
+
fn=find_spices_for_health_benefit,
|
| 463 |
+
inputs=condition_input,
|
| 464 |
+
outputs=condition_output,
|
| 465 |
+
show_progress="minimal"
|
| 466 |
+
)
|
| 467 |
|
| 468 |
+
with gr.Accordion("Safety Information", open=False):
|
| 469 |
+
with gr.Row():
|
| 470 |
+
safety_spice = gr.Textbox(
|
| 471 |
+
label="Spice Name",
|
| 472 |
+
placeholder="e.g., Garlic, Cinnamon",
|
| 473 |
+
scale=3
|
| 474 |
+
)
|
| 475 |
+
safety_btn = gr.Button("Get Safety Info", variant="primary", scale=1)
|
| 476 |
+
|
| 477 |
+
with gr.Row():
|
| 478 |
+
safety_image = gr.Image(
|
| 479 |
+
label="Spice Image",
|
| 480 |
+
height=200,
|
| 481 |
+
width=200,
|
| 482 |
+
show_label=False,
|
| 483 |
+
scale=1
|
| 484 |
+
)
|
| 485 |
+
safety_output = gr.Textbox(
|
| 486 |
+
label="Safety Information",
|
| 487 |
+
lines=12,
|
| 488 |
+
interactive=False,
|
| 489 |
+
scale=3
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
gr.Markdown(
|
| 493 |
+
"**IMPORTANT:** Always consult healthcare providers before using herbs medicinally."
|
| 494 |
+
)
|
| 495 |
|
| 496 |
+
safety_btn.click(
|
| 497 |
+
fn=get_safety_info_with_image,
|
| 498 |
+
inputs=safety_spice,
|
| 499 |
+
outputs=[safety_image, safety_output],
|
| 500 |
+
show_progress="minimal"
|
| 501 |
+
)
|
| 502 |
+
safety_spice.submit(
|
| 503 |
+
fn=get_safety_info_with_image,
|
| 504 |
+
inputs=safety_spice,
|
| 505 |
+
outputs=[safety_image, safety_output],
|
| 506 |
+
show_progress="minimal"
|
| 507 |
)
|
|
|
|
| 508 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
|
| 510 |
+
def create_usage_dashboard_tab():
|
| 511 |
+
"""Create the usage dashboard tab."""
|
| 512 |
+
with gr.Tab("Usage"):
|
| 513 |
+
gr.Markdown("### API Usage Dashboard")
|
| 514 |
|
| 515 |
+
refresh_btn = gr.Button("Refresh Stats", variant="primary")
|
| 516 |
+
|
| 517 |
+
stats_output = gr.Textbox(
|
| 518 |
+
label="Usage Statistics",
|
| 519 |
+
lines=12,
|
| 520 |
+
interactive=False,
|
| 521 |
+
value="Click 'Refresh Stats' to view current usage."
|
| 522 |
)
|
| 523 |
|
| 524 |
+
gr.Markdown("*Usage tracked for AI Chat. Stats reset daily.*")
|
| 525 |
+
|
| 526 |
+
refresh_btn.click(
|
| 527 |
+
fn=get_usage_stats,
|
| 528 |
+
outputs=stats_output,
|
| 529 |
+
show_progress="minimal"
|
| 530 |
)
|
| 531 |
|
| 532 |
|
| 533 |
+
# Custom CSS for better loading indicators and styling (supports dark mode)
|
| 534 |
+
CUSTOM_CSS = """
|
| 535 |
+
/* Light mode styles */
|
| 536 |
+
.loading-indicator {
|
| 537 |
+
display: flex;
|
| 538 |
+
align-items: center;
|
| 539 |
+
gap: 8px;
|
| 540 |
+
padding: 8px 12px;
|
| 541 |
+
background: linear-gradient(90deg, #f0f7ff 0%, #e8f4fd 100%);
|
| 542 |
+
border-radius: 6px;
|
| 543 |
+
margin: 8px 0;
|
| 544 |
+
}
|
| 545 |
+
.loading-spinner {
|
| 546 |
+
width: 16px;
|
| 547 |
+
height: 16px;
|
| 548 |
+
border: 2px solid #3b82f6;
|
| 549 |
+
border-top-color: transparent;
|
| 550 |
+
border-radius: 50%;
|
| 551 |
+
animation: spin 0.8s linear infinite;
|
| 552 |
+
}
|
| 553 |
+
@keyframes spin {
|
| 554 |
+
to { transform: rotate(360deg); }
|
| 555 |
+
}
|
| 556 |
+
.status-ready { color: #059669; }
|
| 557 |
+
.status-loading { color: #3b82f6; }
|
| 558 |
+
.status-error { color: #dc2626; }
|
| 559 |
+
footer { display: none !important; }
|
| 560 |
+
|
| 561 |
+
/* Dark mode styles */
|
| 562 |
+
@media (prefers-color-scheme: dark) {
|
| 563 |
+
.loading-indicator {
|
| 564 |
+
background: linear-gradient(90deg, #1e3a5f 0%, #1a365d 100%);
|
| 565 |
+
}
|
| 566 |
+
.status-ready { color: #34d399; }
|
| 567 |
+
.status-loading { color: #60a5fa; }
|
| 568 |
+
.status-error { color: #f87171; }
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
/* Gradio dark mode class overrides */
|
| 572 |
+
.dark .loading-indicator {
|
| 573 |
+
background: linear-gradient(90deg, #1e3a5f 0%, #1a365d 100%);
|
| 574 |
+
}
|
| 575 |
+
.dark .status-ready { color: #34d399; }
|
| 576 |
+
.dark .status-loading { color: #60a5fa; }
|
| 577 |
+
.dark .status-error { color: #f87171; }
|
| 578 |
+
|
| 579 |
+
/* Improved image styling */
|
| 580 |
+
.image-container img {
|
| 581 |
+
border-radius: 8px;
|
| 582 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 583 |
+
}
|
| 584 |
+
.dark .image-container img {
|
| 585 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
| 586 |
+
}
|
| 587 |
+
"""
|
| 588 |
+
|
| 589 |
# Create main application
|
| 590 |
+
with gr.Blocks(
|
| 591 |
+
title="Medicinal Cuisine AI Advisor",
|
| 592 |
+
theme=gr.themes.Soft(),
|
| 593 |
+
css=CUSTOM_CSS
|
| 594 |
+
) as demo:
|
| 595 |
|
| 596 |
gr.Markdown(
|
| 597 |
"""
|
| 598 |
+
# Medicinal Cuisine AI Advisor
|
| 599 |
|
| 600 |
Explore spices, their nutritional content, and health properties.
|
| 601 |
All data sourced from **USDA FoodData Central** with full attribution.
|
|
|
|
| 604 |
"""
|
| 605 |
)
|
| 606 |
|
| 607 |
+
# Create consolidated tabs (4 tabs instead of 10)
|
| 608 |
+
create_chat_tab()
|
| 609 |
+
create_spice_database_tab()
|
| 610 |
+
create_health_safety_tab()
|
| 611 |
+
create_usage_dashboard_tab()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
gr.Markdown(
|
| 614 |
"""
|
| 615 |
---
|
| 616 |
**Data Sources:** USDA FoodData Central | NCCIH (Public Domain)
|
| 617 |
|
| 618 |
+
**Images:** Wikipedia/Wikimedia Commons (CC/Public Domain)
|
| 619 |
|
| 620 |
+
**MCP Server:** Available at `/gradio_api/mcp/`
|
| 621 |
"""
|
| 622 |
)
|
| 623 |
|
data/spice_images.json
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default_image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/45/A_small_cup_of_coffee.JPG/640px-A_small_cup_of_coffee.JPG",
|
| 3 |
+
"source_attribution": "Images sourced from Wikipedia/Wikimedia Commons. See individual spice entries for specific licenses.",
|
| 4 |
+
"spice_images": {
|
| 5 |
+
"turmeric": {
|
| 6 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Turmeric_inflorescence.jpg/500px-Turmeric_inflorescence.jpg",
|
| 7 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/a/ab/Turmeric_inflorescence.jpg",
|
| 8 |
+
"source": "https://en.wikipedia.org/wiki/Turmeric",
|
| 9 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 10 |
+
},
|
| 11 |
+
"ginger root": {
|
| 12 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Koeh-146-no_text.jpg/500px-Koeh-146-no_text.jpg",
|
| 13 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/18/Koeh-146-no_text.jpg",
|
| 14 |
+
"source": "https://en.wikipedia.org/wiki/Ginger",
|
| 15 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 16 |
+
},
|
| 17 |
+
"ginger": {
|
| 18 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Koeh-146-no_text.jpg/500px-Koeh-146-no_text.jpg",
|
| 19 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/18/Koeh-146-no_text.jpg",
|
| 20 |
+
"source": "https://en.wikipedia.org/wiki/Ginger",
|
| 21 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 22 |
+
},
|
| 23 |
+
"cinnamon": {
|
| 24 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/de/Cinnamomum_verum_spices.jpg/500px-Cinnamomum_verum_spices.jpg",
|
| 25 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/d/de/Cinnamomum_verum_spices.jpg",
|
| 26 |
+
"source": "https://en.wikipedia.org/wiki/Cinnamon",
|
| 27 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 28 |
+
},
|
| 29 |
+
"garlic": {
|
| 30 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/3/39/Allium_sativum_Woodwill_1793.jpg",
|
| 31 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/3/39/Allium_sativum_Woodwill_1793.jpg",
|
| 32 |
+
"source": "https://en.wikipedia.org/wiki/Garlic",
|
| 33 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 34 |
+
},
|
| 35 |
+
"black pepper": {
|
| 36 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fd/Piper_nigrum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-107.jpg/400px-Piper_nigrum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-107.jpg",
|
| 37 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Piper_nigrum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-107.jpg",
|
| 38 |
+
"source": "https://en.wikipedia.org/wiki/Black_pepper",
|
| 39 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 40 |
+
},
|
| 41 |
+
"cumin": {
|
| 42 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/Cuminum_cyminum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-198.jpg/400px-Cuminum_cyminum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-198.jpg",
|
| 43 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/5/58/Cuminum_cyminum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-198.jpg",
|
| 44 |
+
"source": "https://en.wikipedia.org/wiki/Cumin",
|
| 45 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 46 |
+
},
|
| 47 |
+
"paprika": {
|
| 48 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/8/81/Piment%C3%B3n_Tap_de_Cort%C3%AD_%28cropped%29.jpg",
|
| 49 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/8/81/Piment%C3%B3n_Tap_de_Cort%C3%AD_%28cropped%29.jpg",
|
| 50 |
+
"source": "https://en.wikipedia.org/wiki/Paprika",
|
| 51 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 52 |
+
},
|
| 53 |
+
"cayenne pepper": {
|
| 54 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/d2/Capsicum_annuum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-027.jpg/500px-Capsicum_annuum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-027.jpg",
|
| 55 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/d/d2/Capsicum_annuum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-027.jpg",
|
| 56 |
+
"source": "https://en.wikipedia.org/wiki/Cayenne_pepper",
|
| 57 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 58 |
+
},
|
| 59 |
+
"chili powder": {
|
| 60 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/00/BolivianChilePowder2.JPG/500px-BolivianChilePowder2.JPG",
|
| 61 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/0/00/BolivianChilePowder2.JPG",
|
| 62 |
+
"source": "https://en.wikipedia.org/wiki/Chili_powder",
|
| 63 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 64 |
+
},
|
| 65 |
+
"oregano": {
|
| 66 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/14/Origanum_vulgare_-_harilik_pune.jpg/500px-Origanum_vulgare_-_harilik_pune.jpg",
|
| 67 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/14/Origanum_vulgare_-_harilik_pune.jpg",
|
| 68 |
+
"source": "https://en.wikipedia.org/wiki/Oregano",
|
| 69 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 70 |
+
},
|
| 71 |
+
"basil": {
|
| 72 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Ocimum_basilicum_8zz.jpg/500px-Ocimum_basilicum_8zz.jpg",
|
| 73 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/9/97/Ocimum_basilicum_8zz.jpg",
|
| 74 |
+
"source": "https://en.wikipedia.org/wiki/Basil",
|
| 75 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 76 |
+
},
|
| 77 |
+
"basil, dried": {
|
| 78 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Ocimum_basilicum_8zz.jpg/500px-Ocimum_basilicum_8zz.jpg",
|
| 79 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/9/97/Ocimum_basilicum_8zz.jpg",
|
| 80 |
+
"source": "https://en.wikipedia.org/wiki/Basil",
|
| 81 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 82 |
+
},
|
| 83 |
+
"thyme": {
|
| 84 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ea/Thyme-Bundle.jpg/500px-Thyme-Bundle.jpg",
|
| 85 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/e/ea/Thyme-Bundle.jpg",
|
| 86 |
+
"source": "https://en.wikipedia.org/wiki/Thyme",
|
| 87 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 88 |
+
},
|
| 89 |
+
"rosemary": {
|
| 90 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Rosemary_in_bloom.JPG/500px-Rosemary_in_bloom.JPG",
|
| 91 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/a/a3/Rosemary_in_bloom.JPG",
|
| 92 |
+
"source": "https://en.wikipedia.org/wiki/Rosemary",
|
| 93 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 94 |
+
},
|
| 95 |
+
"sage": {
|
| 96 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5a/Salvia_officinalis0.jpg/500px-Salvia_officinalis0.jpg",
|
| 97 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/5/5a/Salvia_officinalis0.jpg",
|
| 98 |
+
"source": "https://en.wikipedia.org/wiki/Salvia_officinalis",
|
| 99 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 100 |
+
},
|
| 101 |
+
"mint": {
|
| 102 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Mentha_spicata-IMG_6186.jpg/500px-Mentha_spicata-IMG_6186.jpg",
|
| 103 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Mentha_spicata-IMG_6186.jpg",
|
| 104 |
+
"source": "https://en.wikipedia.org/wiki/Mentha",
|
| 105 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 106 |
+
},
|
| 107 |
+
"parsley": {
|
| 108 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e4/Petroselinum.jpg/500px-Petroselinum.jpg",
|
| 109 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/e/e4/Petroselinum.jpg",
|
| 110 |
+
"source": "https://en.wikipedia.org/wiki/Parsley",
|
| 111 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 112 |
+
},
|
| 113 |
+
"cilantro": {
|
| 114 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg/400px-Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
|
| 115 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
|
| 116 |
+
"source": "https://en.wikipedia.org/wiki/Coriander",
|
| 117 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 118 |
+
},
|
| 119 |
+
"coriander": {
|
| 120 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg/400px-Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
|
| 121 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/13/Coriandrum_sativum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-193.jpg",
|
| 122 |
+
"source": "https://en.wikipedia.org/wiki/Coriander",
|
| 123 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 124 |
+
},
|
| 125 |
+
"nutmeg": {
|
| 126 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c8/Biji_Pala_Bubuk.jpg/500px-Biji_Pala_Bubuk.jpg",
|
| 127 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/c/c8/Biji_Pala_Bubuk.jpg",
|
| 128 |
+
"source": "https://en.wikipedia.org/wiki/Nutmeg",
|
| 129 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 130 |
+
},
|
| 131 |
+
"clove": {
|
| 132 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Syzygium_aromaticum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-030.jpg/400px-Syzygium_aromaticum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-030.jpg",
|
| 133 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/4/4b/Syzygium_aromaticum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-030.jpg",
|
| 134 |
+
"source": "https://en.wikipedia.org/wiki/Clove",
|
| 135 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 136 |
+
},
|
| 137 |
+
"cardamom": {
|
| 138 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/66/02017_0119_Kardamom%2C_Winter_in_den_Beskiden.jpg/500px-02017_0119_Kardamom%2C_Winter_in_den_Beskiden.jpg",
|
| 139 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/6/66/02017_0119_Kardamom%2C_Winter_in_den_Beskiden.jpg",
|
| 140 |
+
"source": "https://en.wikipedia.org/wiki/Cardamom",
|
| 141 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 142 |
+
},
|
| 143 |
+
"fennel": {
|
| 144 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c0/Foeniculum_July_2011-1a.jpg/500px-Foeniculum_July_2011-1a.jpg",
|
| 145 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/c/c0/Foeniculum_July_2011-1a.jpg",
|
| 146 |
+
"source": "https://en.wikipedia.org/wiki/Fennel",
|
| 147 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 148 |
+
},
|
| 149 |
+
"mustard": {
|
| 150 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Mustard.png/500px-Mustard.png",
|
| 151 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/3/35/Mustard.png",
|
| 152 |
+
"source": "https://en.wikipedia.org/wiki/Mustard_seed",
|
| 153 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 154 |
+
},
|
| 155 |
+
"saffron": {
|
| 156 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Saffron_-_premium_spice.jpg/500px-Saffron_-_premium_spice.jpg",
|
| 157 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/6/68/Saffron_-_premium_spice.jpg",
|
| 158 |
+
"source": "https://en.wikipedia.org/wiki/Saffron",
|
| 159 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 160 |
+
},
|
| 161 |
+
"vanilla": {
|
| 162 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0f/Vanilla_planifolia_%286998639597%29.jpg/500px-Vanilla_planifolia_%286998639597%29.jpg",
|
| 163 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/0/0f/Vanilla_planifolia_%286998639597%29.jpg",
|
| 164 |
+
"source": "https://en.wikipedia.org/wiki/Vanilla",
|
| 165 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 166 |
+
},
|
| 167 |
+
"bay leaf": {
|
| 168 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a8/Bay_Leaves.JPG/500px-Bay_Leaves.JPG",
|
| 169 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/a/a8/Bay_Leaves.JPG",
|
| 170 |
+
"source": "https://en.wikipedia.org/wiki/Bay_leaf",
|
| 171 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 172 |
+
},
|
| 173 |
+
"dill": {
|
| 174 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Illustration_Anethum_graveolens_clean.jpg/500px-Illustration_Anethum_graveolens_clean.jpg",
|
| 175 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/4/4b/Illustration_Anethum_graveolens_clean.jpg",
|
| 176 |
+
"source": "https://en.wikipedia.org/wiki/Dill",
|
| 177 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 178 |
+
},
|
| 179 |
+
"tarragon": {
|
| 180 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/6/6c/Estragon_1511.jpg",
|
| 181 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/6/6c/Estragon_1511.jpg",
|
| 182 |
+
"source": "https://en.wikipedia.org/wiki/Tarragon",
|
| 183 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 184 |
+
},
|
| 185 |
+
"marjoram": {
|
| 186 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/5c/Origanum_majorana_002.JPG/500px-Origanum_majorana_002.JPG",
|
| 187 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/5/5c/Origanum_majorana_002.JPG",
|
| 188 |
+
"source": "https://en.wikipedia.org/wiki/Marjoram",
|
| 189 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 190 |
+
},
|
| 191 |
+
"allspice": {
|
| 192 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a5/Pimenta_dioica_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-239.jpg/400px-Pimenta_dioica_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-239.jpg",
|
| 193 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/a/a5/Pimenta_dioica_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-239.jpg",
|
| 194 |
+
"source": "https://en.wikipedia.org/wiki/Allspice",
|
| 195 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 196 |
+
},
|
| 197 |
+
"anise": {
|
| 198 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Koehler1887-PimpinellaAnisum.jpg/400px-Koehler1887-PimpinellaAnisum.jpg",
|
| 199 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/3/3b/Koehler1887-PimpinellaAnisum.jpg",
|
| 200 |
+
"source": "https://en.wikipedia.org/wiki/Anise",
|
| 201 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 202 |
+
},
|
| 203 |
+
"star anise": {
|
| 204 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7c/Illicium_verum_1zz.jpg/500px-Illicium_verum_1zz.jpg",
|
| 205 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/7/7c/Illicium_verum_1zz.jpg",
|
| 206 |
+
"source": "https://en.wikipedia.org/wiki/Illicium_verum",
|
| 207 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 208 |
+
},
|
| 209 |
+
"fenugreek": {
|
| 210 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/ce/Illustration_Trigonella_foenum-graecum0_clean.jpg/500px-Illustration_Trigonella_foenum-graecum0_clean.jpg",
|
| 211 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/c/ce/Illustration_Trigonella_foenum-graecum0_clean.jpg",
|
| 212 |
+
"source": "https://en.wikipedia.org/wiki/Fenugreek",
|
| 213 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 214 |
+
},
|
| 215 |
+
"curry powder": {
|
| 216 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Curry_powder_in_the_spice-bazaar_in_Istanbul.jpg/500px-Curry_powder_in_the_spice-bazaar_in_Istanbul.jpg",
|
| 217 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/6/60/Curry_powder_in_the_spice-bazaar_in_Istanbul.jpg",
|
| 218 |
+
"source": "https://en.wikipedia.org/wiki/Curry_powder",
|
| 219 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 220 |
+
},
|
| 221 |
+
"garam masala": {
|
| 222 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/Garammasalaphoto.jpg/500px-Garammasalaphoto.jpg",
|
| 223 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/5/58/Garammasalaphoto.jpg",
|
| 224 |
+
"source": "https://en.wikipedia.org/wiki/Garam_masala",
|
| 225 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 226 |
+
},
|
| 227 |
+
"onion powder": {
|
| 228 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/Onion_Powder%2C_Penzeys_Spices%2C_Arlington_Heights_MA.jpg/500px-Onion_Powder%2C_Penzeys_Spices%2C_Arlington_Heights_MA.jpg",
|
| 229 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/3/36/Onion_Powder%2C_Penzeys_Spices%2C_Arlington_Heights_MA.jpg",
|
| 230 |
+
"source": "https://en.wikipedia.org/wiki/Onion_powder",
|
| 231 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 232 |
+
},
|
| 233 |
+
"garlic powder": {
|
| 234 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Masterfoods_Garlic_Powder.jpg/500px-Masterfoods_Garlic_Powder.jpg",
|
| 235 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Masterfoods_Garlic_Powder.jpg",
|
| 236 |
+
"source": "https://en.wikipedia.org/wiki/Garlic_powder",
|
| 237 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 238 |
+
},
|
| 239 |
+
"celery seed": {
|
| 240 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Celery_1.jpg/500px-Celery_1.jpg",
|
| 241 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/0/0d/Celery_1.jpg",
|
| 242 |
+
"source": "https://en.wikipedia.org/wiki/Celery_seed",
|
| 243 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 244 |
+
},
|
| 245 |
+
"caraway": {
|
| 246 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Carum_carvi_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-172.jpg/400px-Carum_carvi_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-172.jpg",
|
| 247 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/4/42/Carum_carvi_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-172.jpg",
|
| 248 |
+
"source": "https://en.wikipedia.org/wiki/Caraway",
|
| 249 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 250 |
+
},
|
| 251 |
+
"poppy seed": {
|
| 252 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/Poppy_seeds.jpg/500px-Poppy_seeds.jpg",
|
| 253 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/6/69/Poppy_seeds.jpg",
|
| 254 |
+
"source": "https://en.wikipedia.org/wiki/Poppy_seed",
|
| 255 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 256 |
+
},
|
| 257 |
+
"sesame": {
|
| 258 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Sesamum_indicum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-129.jpg/400px-Sesamum_indicum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-129.jpg",
|
| 259 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/7/70/Sesamum_indicum_-_K%C3%B6hler%E2%80%93s_Medizinal-Pflanzen-129.jpg",
|
| 260 |
+
"source": "https://en.wikipedia.org/wiki/Sesame",
|
| 261 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 262 |
+
},
|
| 263 |
+
"juniper": {
|
| 264 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Juniperus_communis_fruits_-_Keila.jpg/500px-Juniperus_communis_fruits_-_Keila.jpg",
|
| 265 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/1/1e/Juniperus_communis_fruits_-_Keila.jpg",
|
| 266 |
+
"source": "https://en.wikipedia.org/wiki/Juniper_berry",
|
| 267 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 268 |
+
},
|
| 269 |
+
"lavender": {
|
| 270 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7e/Single_lavender_flower02.jpg/500px-Single_lavender_flower02.jpg",
|
| 271 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/7/7e/Single_lavender_flower02.jpg",
|
| 272 |
+
"source": "https://en.wikipedia.org/wiki/Lavandula",
|
| 273 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 274 |
+
},
|
| 275 |
+
"chamomile": {
|
| 276 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Kamomillasaunio_%28Matricaria_recutita%29.JPG/500px-Kamomillasaunio_%28Matricaria_recutita%29.JPG",
|
| 277 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/2/26/Kamomillasaunio_%28Matricaria_recutita%29.JPG",
|
| 278 |
+
"source": "https://en.wikipedia.org/wiki/Chamomile",
|
| 279 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 280 |
+
},
|
| 281 |
+
"lemongrass": {
|
| 282 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bd/YosriNov04Pokok_Serai.JPG/500px-YosriNov04Pokok_Serai.JPG",
|
| 283 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/b/bd/YosriNov04Pokok_Serai.JPG",
|
| 284 |
+
"source": "https://en.wikipedia.org/wiki/Cymbopogon",
|
| 285 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 286 |
+
},
|
| 287 |
+
"sumac": {
|
| 288 |
+
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/aa/SumacFruit.JPG/500px-SumacFruit.JPG",
|
| 289 |
+
"full_url": "https://upload.wikimedia.org/wikipedia/commons/a/aa/SumacFruit.JPG",
|
| 290 |
+
"source": "https://en.wikipedia.org/wiki/Sumac",
|
| 291 |
+
"license": "Wikimedia Commons (see source for specific license)"
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
}
|
requirements.txt
CHANGED
|
@@ -1,3 +1,8 @@
|
|
| 1 |
gradio[mcp]==5.50.0
|
| 2 |
requests>=2.31.0
|
| 3 |
python-dotenv>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
gradio[mcp]==5.50.0
|
| 2 |
requests>=2.31.0
|
| 3 |
python-dotenv>=1.0.0
|
| 4 |
+
beautifulsoup4>=4.12.0
|
| 5 |
+
|
| 6 |
+
# LlamaIndex for conversational AI
|
| 7 |
+
llama-index>=0.11.0
|
| 8 |
+
llama-index-llms-anthropic>=0.6.0
|
tools/guardrails.py
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Guardrails module for Medicinal Cuisine AI Agent.
|
| 2 |
+
|
| 3 |
+
This module provides safety guardrails for the conversational AI interface,
|
| 4 |
+
ensuring safe, responsible, and cost-effective usage across different LLM providers.
|
| 5 |
+
|
| 6 |
+
Guardrails implemented:
|
| 7 |
+
1. Input validation (length limits, sanitization)
|
| 8 |
+
2. Rate limiting (per-session request limits)
|
| 9 |
+
3. Medical disclaimer (automatic disclaimer injection)
|
| 10 |
+
4. Topic filtering (block off-topic queries)
|
| 11 |
+
5. Token/cost limits (cap max tokens per request)
|
| 12 |
+
6. Usage tracking (monitor API spend)
|
| 13 |
+
7. Audit logging (log queries for review)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import time
|
| 20 |
+
from abc import ABC, abstractmethod
|
| 21 |
+
from collections import defaultdict
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from enum import Enum
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Dict, List, Optional, Tuple
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class GuardrailResult(Enum):
|
| 30 |
+
"""Result of a guardrail check."""
|
| 31 |
+
PASS = "pass"
|
| 32 |
+
BLOCK = "block"
|
| 33 |
+
WARN = "warn"
|
| 34 |
+
MODIFY = "modify"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class GuardrailResponse:
|
| 39 |
+
"""Response from a guardrail check.
|
| 40 |
+
|
| 41 |
+
Attributes:
|
| 42 |
+
result: The result of the check (pass, block, warn, modify).
|
| 43 |
+
message: Optional message explaining the result.
|
| 44 |
+
modified_input: If result is MODIFY, the modified input to use.
|
| 45 |
+
metadata: Additional metadata about the check.
|
| 46 |
+
"""
|
| 47 |
+
result: GuardrailResult
|
| 48 |
+
message: Optional[str] = None
|
| 49 |
+
modified_input: Optional[str] = None
|
| 50 |
+
metadata: Dict = field(default_factory=dict)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class Guardrail(ABC):
|
| 54 |
+
"""Abstract base class for all guardrails."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, name: str, enabled: bool = True):
|
| 57 |
+
"""Initialize the guardrail.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
name: Name of the guardrail for logging.
|
| 61 |
+
enabled: Whether the guardrail is active.
|
| 62 |
+
"""
|
| 63 |
+
self.name = name
|
| 64 |
+
self.enabled = enabled
|
| 65 |
+
|
| 66 |
+
@abstractmethod
|
| 67 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 68 |
+
"""Check user input before processing.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
user_input: The user's message.
|
| 72 |
+
context: Additional context (session_id, user_id, etc.).
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
GuardrailResponse indicating the result.
|
| 76 |
+
"""
|
| 77 |
+
pass
|
| 78 |
+
|
| 79 |
+
def check_output(self, output: str, context: Dict) -> GuardrailResponse:
|
| 80 |
+
"""Check agent output before returning to user.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
output: The agent's response.
|
| 84 |
+
context: Additional context.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
GuardrailResponse indicating the result.
|
| 88 |
+
"""
|
| 89 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class InputLengthGuardrail(Guardrail):
|
| 93 |
+
"""Guardrail to limit input length and prevent abuse."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
max_length: int = 2000,
|
| 98 |
+
min_length: int = 1,
|
| 99 |
+
enabled: bool = True
|
| 100 |
+
):
|
| 101 |
+
"""Initialize input length guardrail.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
max_length: Maximum allowed input length in characters.
|
| 105 |
+
min_length: Minimum required input length.
|
| 106 |
+
enabled: Whether the guardrail is active.
|
| 107 |
+
"""
|
| 108 |
+
super().__init__(name="input_length", enabled=enabled)
|
| 109 |
+
self.max_length = max_length
|
| 110 |
+
self.min_length = min_length
|
| 111 |
+
|
| 112 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 113 |
+
"""Check if input length is within acceptable bounds."""
|
| 114 |
+
if not self.enabled:
|
| 115 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 116 |
+
|
| 117 |
+
input_length = len(user_input.strip())
|
| 118 |
+
|
| 119 |
+
if input_length < self.min_length:
|
| 120 |
+
return GuardrailResponse(
|
| 121 |
+
result=GuardrailResult.BLOCK,
|
| 122 |
+
message="Please enter a question or message.",
|
| 123 |
+
metadata={"length": input_length, "min": self.min_length}
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
if input_length > self.max_length:
|
| 127 |
+
return GuardrailResponse(
|
| 128 |
+
result=GuardrailResult.BLOCK,
|
| 129 |
+
message=f"Input too long ({input_length} chars). Please limit to {self.max_length} characters.",
|
| 130 |
+
metadata={"length": input_length, "max": self.max_length}
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return GuardrailResponse(
|
| 134 |
+
result=GuardrailResult.PASS,
|
| 135 |
+
metadata={"length": input_length}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class RateLimitGuardrail(Guardrail):
|
| 140 |
+
"""Guardrail to limit request rate per session."""
|
| 141 |
+
|
| 142 |
+
def __init__(
|
| 143 |
+
self,
|
| 144 |
+
max_requests_per_minute: int = 10,
|
| 145 |
+
max_requests_per_hour: int = 100,
|
| 146 |
+
enabled: bool = True
|
| 147 |
+
):
|
| 148 |
+
"""Initialize rate limit guardrail.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
max_requests_per_minute: Max requests allowed per minute.
|
| 152 |
+
max_requests_per_hour: Max requests allowed per hour.
|
| 153 |
+
enabled: Whether the guardrail is active.
|
| 154 |
+
"""
|
| 155 |
+
super().__init__(name="rate_limit", enabled=enabled)
|
| 156 |
+
self.max_requests_per_minute = max_requests_per_minute
|
| 157 |
+
self.max_requests_per_hour = max_requests_per_hour
|
| 158 |
+
self._request_times: Dict[str, List[float]] = defaultdict(list)
|
| 159 |
+
|
| 160 |
+
def _clean_old_requests(self, session_id: str, current_time: float) -> None:
|
| 161 |
+
"""Remove request timestamps older than 1 hour."""
|
| 162 |
+
one_hour_ago = current_time - 3600
|
| 163 |
+
self._request_times[session_id] = [
|
| 164 |
+
t for t in self._request_times[session_id]
|
| 165 |
+
if t > one_hour_ago
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 169 |
+
"""Check if request rate is within limits."""
|
| 170 |
+
if not self.enabled:
|
| 171 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 172 |
+
|
| 173 |
+
session_id = context.get("session_id", "default")
|
| 174 |
+
current_time = time.time()
|
| 175 |
+
|
| 176 |
+
self._clean_old_requests(session_id, current_time)
|
| 177 |
+
|
| 178 |
+
one_minute_ago = current_time - 60
|
| 179 |
+
requests_last_minute = sum(
|
| 180 |
+
1 for t in self._request_times[session_id]
|
| 181 |
+
if t > one_minute_ago
|
| 182 |
+
)
|
| 183 |
+
requests_last_hour = len(self._request_times[session_id])
|
| 184 |
+
|
| 185 |
+
if requests_last_minute >= self.max_requests_per_minute:
|
| 186 |
+
return GuardrailResponse(
|
| 187 |
+
result=GuardrailResult.BLOCK,
|
| 188 |
+
message="Rate limit exceeded. Please wait a moment before sending another message.",
|
| 189 |
+
metadata={
|
| 190 |
+
"requests_last_minute": requests_last_minute,
|
| 191 |
+
"limit_per_minute": self.max_requests_per_minute
|
| 192 |
+
}
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
if requests_last_hour >= self.max_requests_per_hour:
|
| 196 |
+
return GuardrailResponse(
|
| 197 |
+
result=GuardrailResult.BLOCK,
|
| 198 |
+
message="Hourly rate limit exceeded. Please try again later.",
|
| 199 |
+
metadata={
|
| 200 |
+
"requests_last_hour": requests_last_hour,
|
| 201 |
+
"limit_per_hour": self.max_requests_per_hour
|
| 202 |
+
}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
self._request_times[session_id].append(current_time)
|
| 206 |
+
|
| 207 |
+
return GuardrailResponse(
|
| 208 |
+
result=GuardrailResult.PASS,
|
| 209 |
+
metadata={
|
| 210 |
+
"requests_last_minute": requests_last_minute + 1,
|
| 211 |
+
"requests_last_hour": requests_last_hour + 1
|
| 212 |
+
}
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class MedicalDisclaimerGuardrail(Guardrail):
|
| 217 |
+
"""Guardrail to ensure medical disclaimers are included in health-related responses."""
|
| 218 |
+
|
| 219 |
+
HEALTH_KEYWORDS = [
|
| 220 |
+
"health", "benefit", "cure", "treat", "disease", "condition",
|
| 221 |
+
"symptom", "medicine", "medicinal", "therapeutic", "healing",
|
| 222 |
+
"diabetes", "cholesterol", "blood pressure", "inflammation",
|
| 223 |
+
"pain", "infection", "illness", "disorder", "cancer", "heart",
|
| 224 |
+
"liver", "kidney", "stomach", "digestive", "immune", "anxiety",
|
| 225 |
+
"depression", "sleep", "weight", "diet", "pregnant", "pregnancy"
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
DISCLAIMER = (
|
| 229 |
+
"\n\n---\n"
|
| 230 |
+
"**Disclaimer:** This information is for educational purposes only and "
|
| 231 |
+
"should not be considered medical advice. Always consult a qualified "
|
| 232 |
+
"healthcare provider before using herbs or spices for medicinal purposes."
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
def __init__(self, enabled: bool = True):
|
| 236 |
+
"""Initialize medical disclaimer guardrail."""
|
| 237 |
+
super().__init__(name="medical_disclaimer", enabled=enabled)
|
| 238 |
+
self._health_pattern = re.compile(
|
| 239 |
+
r'\b(' + '|'.join(self.HEALTH_KEYWORDS) + r')\b',
|
| 240 |
+
re.IGNORECASE
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
def _is_health_related(self, text: str) -> bool:
|
| 244 |
+
"""Check if text contains health-related keywords."""
|
| 245 |
+
return bool(self._health_pattern.search(text))
|
| 246 |
+
|
| 247 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 248 |
+
"""Mark context if input is health-related."""
|
| 249 |
+
is_health_query = self._is_health_related(user_input)
|
| 250 |
+
return GuardrailResponse(
|
| 251 |
+
result=GuardrailResult.PASS,
|
| 252 |
+
metadata={"is_health_query": is_health_query}
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
def check_output(self, output: str, context: Dict) -> GuardrailResponse:
|
| 256 |
+
"""Add disclaimer to health-related responses."""
|
| 257 |
+
if not self.enabled:
|
| 258 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 259 |
+
|
| 260 |
+
is_health_query = context.get("is_health_query", False)
|
| 261 |
+
is_health_response = self._is_health_related(output)
|
| 262 |
+
|
| 263 |
+
if (is_health_query or is_health_response) and self.DISCLAIMER not in output:
|
| 264 |
+
return GuardrailResponse(
|
| 265 |
+
result=GuardrailResult.MODIFY,
|
| 266 |
+
modified_input=output + self.DISCLAIMER,
|
| 267 |
+
metadata={"disclaimer_added": True}
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
return GuardrailResponse(
|
| 271 |
+
result=GuardrailResult.PASS,
|
| 272 |
+
metadata={"disclaimer_added": False}
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class TopicFilterGuardrail(Guardrail):
|
| 277 |
+
"""Guardrail to filter off-topic queries."""
|
| 278 |
+
|
| 279 |
+
ALLOWED_TOPICS = [
|
| 280 |
+
"spice", "herb", "seasoning", "flavor", "ingredient",
|
| 281 |
+
"nutrient", "vitamin", "mineral", "nutrition", "food",
|
| 282 |
+
"cooking", "recipe", "cuisine", "health", "benefit",
|
| 283 |
+
"substitute", "alternative", "replacement", "similar",
|
| 284 |
+
"side effect", "caution", "safety", "interaction", "dosage"
|
| 285 |
+
]
|
| 286 |
+
|
| 287 |
+
OFF_TOPIC_RESPONSE = (
|
| 288 |
+
"I'm a medicinal cuisine advisor specialized in spices, herbs, and their "
|
| 289 |
+
"health benefits. I can help you with:\n"
|
| 290 |
+
"- Spice and herb information\n"
|
| 291 |
+
"- Nutritional content\n"
|
| 292 |
+
"- Health benefits and traditional uses\n"
|
| 293 |
+
"- Finding substitutes\n"
|
| 294 |
+
"- Safety information\n\n"
|
| 295 |
+
"Please ask a question related to these topics."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
def __init__(self, strict_mode: bool = False, enabled: bool = True):
|
| 299 |
+
"""Initialize topic filter guardrail.
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
strict_mode: If True, block all off-topic queries. If False, just warn.
|
| 303 |
+
enabled: Whether the guardrail is active.
|
| 304 |
+
"""
|
| 305 |
+
super().__init__(name="topic_filter", enabled=enabled)
|
| 306 |
+
self.strict_mode = strict_mode
|
| 307 |
+
self._topic_pattern = re.compile(
|
| 308 |
+
r'\b(' + '|'.join(self.ALLOWED_TOPICS) + r')\b',
|
| 309 |
+
re.IGNORECASE
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
def _is_on_topic(self, text: str) -> bool:
|
| 313 |
+
"""Check if text is related to allowed topics."""
|
| 314 |
+
return bool(self._topic_pattern.search(text))
|
| 315 |
+
|
| 316 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 317 |
+
"""Check if input is on-topic."""
|
| 318 |
+
if not self.enabled:
|
| 319 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 320 |
+
|
| 321 |
+
if self._is_on_topic(user_input):
|
| 322 |
+
return GuardrailResponse(
|
| 323 |
+
result=GuardrailResult.PASS,
|
| 324 |
+
metadata={"on_topic": True}
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
if self.strict_mode:
|
| 328 |
+
return GuardrailResponse(
|
| 329 |
+
result=GuardrailResult.BLOCK,
|
| 330 |
+
message=self.OFF_TOPIC_RESPONSE,
|
| 331 |
+
metadata={"on_topic": False}
|
| 332 |
+
)
|
| 333 |
+
|
| 334 |
+
return GuardrailResponse(
|
| 335 |
+
result=GuardrailResult.WARN,
|
| 336 |
+
message="This query may be off-topic. I specialize in spices and herbs.",
|
| 337 |
+
metadata={"on_topic": False}
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class TokenLimitGuardrail(Guardrail):
|
| 342 |
+
"""Guardrail to limit token usage and control costs."""
|
| 343 |
+
|
| 344 |
+
def __init__(
|
| 345 |
+
self,
|
| 346 |
+
max_input_tokens: int = 500,
|
| 347 |
+
max_output_tokens: int = 1000,
|
| 348 |
+
daily_token_budget: int = 100000,
|
| 349 |
+
enabled: bool = True
|
| 350 |
+
):
|
| 351 |
+
"""Initialize token limit guardrail.
|
| 352 |
+
|
| 353 |
+
Args:
|
| 354 |
+
max_input_tokens: Max tokens allowed in input.
|
| 355 |
+
max_output_tokens: Max tokens allowed in output.
|
| 356 |
+
daily_token_budget: Total tokens allowed per day.
|
| 357 |
+
enabled: Whether the guardrail is active.
|
| 358 |
+
"""
|
| 359 |
+
super().__init__(name="token_limit", enabled=enabled)
|
| 360 |
+
self.max_input_tokens = max_input_tokens
|
| 361 |
+
self.max_output_tokens = max_output_tokens
|
| 362 |
+
self.daily_token_budget = daily_token_budget
|
| 363 |
+
self._daily_usage: Dict[str, int] = {}
|
| 364 |
+
self._last_reset: Optional[str] = None
|
| 365 |
+
|
| 366 |
+
def _estimate_tokens(self, text: str) -> int:
|
| 367 |
+
"""Estimate token count (rough approximation: ~4 chars per token)."""
|
| 368 |
+
return len(text) // 4
|
| 369 |
+
|
| 370 |
+
def _get_today(self) -> str:
|
| 371 |
+
"""Get today's date string."""
|
| 372 |
+
return datetime.now().strftime("%Y-%m-%d")
|
| 373 |
+
|
| 374 |
+
def _reset_daily_if_needed(self) -> None:
|
| 375 |
+
"""Reset daily usage if it's a new day."""
|
| 376 |
+
today = self._get_today()
|
| 377 |
+
if self._last_reset != today:
|
| 378 |
+
self._daily_usage.clear()
|
| 379 |
+
self._last_reset = today
|
| 380 |
+
|
| 381 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 382 |
+
"""Check if input is within token limits."""
|
| 383 |
+
if not self.enabled:
|
| 384 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 385 |
+
|
| 386 |
+
self._reset_daily_if_needed()
|
| 387 |
+
|
| 388 |
+
estimated_tokens = self._estimate_tokens(user_input)
|
| 389 |
+
session_id = context.get("session_id", "default")
|
| 390 |
+
today = self._get_today()
|
| 391 |
+
|
| 392 |
+
if estimated_tokens > self.max_input_tokens:
|
| 393 |
+
return GuardrailResponse(
|
| 394 |
+
result=GuardrailResult.BLOCK,
|
| 395 |
+
message=f"Input too long. Please shorten your message.",
|
| 396 |
+
metadata={
|
| 397 |
+
"estimated_tokens": estimated_tokens,
|
| 398 |
+
"max_tokens": self.max_input_tokens
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
current_daily_usage = self._daily_usage.get(today, 0)
|
| 403 |
+
if current_daily_usage >= self.daily_token_budget:
|
| 404 |
+
return GuardrailResponse(
|
| 405 |
+
result=GuardrailResult.BLOCK,
|
| 406 |
+
message="Daily usage limit reached. Please try again tomorrow.",
|
| 407 |
+
metadata={
|
| 408 |
+
"daily_usage": current_daily_usage,
|
| 409 |
+
"daily_budget": self.daily_token_budget
|
| 410 |
+
}
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
return GuardrailResponse(
|
| 414 |
+
result=GuardrailResult.PASS,
|
| 415 |
+
metadata={
|
| 416 |
+
"estimated_input_tokens": estimated_tokens,
|
| 417 |
+
"daily_usage": current_daily_usage
|
| 418 |
+
}
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
def record_usage(self, input_tokens: int, output_tokens: int) -> None:
|
| 422 |
+
"""Record token usage for tracking.
|
| 423 |
+
|
| 424 |
+
Args:
|
| 425 |
+
input_tokens: Tokens used in input.
|
| 426 |
+
output_tokens: Tokens used in output.
|
| 427 |
+
"""
|
| 428 |
+
self._reset_daily_if_needed()
|
| 429 |
+
today = self._get_today()
|
| 430 |
+
total_tokens = input_tokens + output_tokens
|
| 431 |
+
self._daily_usage[today] = self._daily_usage.get(today, 0) + total_tokens
|
| 432 |
+
|
| 433 |
+
def get_max_output_tokens(self) -> int:
|
| 434 |
+
"""Get the configured max output tokens."""
|
| 435 |
+
return self.max_output_tokens
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
class UsageTrackingGuardrail(Guardrail):
|
| 439 |
+
"""Guardrail to track API usage and costs."""
|
| 440 |
+
|
| 441 |
+
COST_PER_1K_INPUT_TOKENS = {
|
| 442 |
+
"claude-sonnet-4-20250514": 0.003,
|
| 443 |
+
"claude-3-5-haiku-20241022": 0.001,
|
| 444 |
+
"gpt-4": 0.03,
|
| 445 |
+
"gpt-3.5-turbo": 0.0015,
|
| 446 |
+
"default": 0.002
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
COST_PER_1K_OUTPUT_TOKENS = {
|
| 450 |
+
"claude-sonnet-4-20250514": 0.015,
|
| 451 |
+
"claude-3-5-haiku-20241022": 0.005,
|
| 452 |
+
"gpt-4": 0.06,
|
| 453 |
+
"gpt-3.5-turbo": 0.002,
|
| 454 |
+
"default": 0.01
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
def __init__(
|
| 458 |
+
self,
|
| 459 |
+
model_name: str = "default",
|
| 460 |
+
daily_cost_limit: float = 10.0,
|
| 461 |
+
enabled: bool = True
|
| 462 |
+
):
|
| 463 |
+
"""Initialize usage tracking guardrail.
|
| 464 |
+
|
| 465 |
+
Args:
|
| 466 |
+
model_name: Name of the model for cost calculation.
|
| 467 |
+
daily_cost_limit: Maximum daily spend in USD.
|
| 468 |
+
enabled: Whether the guardrail is active.
|
| 469 |
+
"""
|
| 470 |
+
super().__init__(name="usage_tracking", enabled=enabled)
|
| 471 |
+
self.model_name = model_name
|
| 472 |
+
self.daily_cost_limit = daily_cost_limit
|
| 473 |
+
self._usage_log: List[Dict] = []
|
| 474 |
+
self._daily_cost: Dict[str, float] = {}
|
| 475 |
+
|
| 476 |
+
def _get_today(self) -> str:
|
| 477 |
+
"""Get today's date string."""
|
| 478 |
+
return datetime.now().strftime("%Y-%m-%d")
|
| 479 |
+
|
| 480 |
+
def _calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
|
| 481 |
+
"""Calculate cost for token usage."""
|
| 482 |
+
input_rate = self.COST_PER_1K_INPUT_TOKENS.get(
|
| 483 |
+
self.model_name,
|
| 484 |
+
self.COST_PER_1K_INPUT_TOKENS["default"]
|
| 485 |
+
)
|
| 486 |
+
output_rate = self.COST_PER_1K_OUTPUT_TOKENS.get(
|
| 487 |
+
self.model_name,
|
| 488 |
+
self.COST_PER_1K_OUTPUT_TOKENS["default"]
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
input_cost = (input_tokens / 1000) * input_rate
|
| 492 |
+
output_cost = (output_tokens / 1000) * output_rate
|
| 493 |
+
|
| 494 |
+
return input_cost + output_cost
|
| 495 |
+
|
| 496 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 497 |
+
"""Check if daily cost limit is reached."""
|
| 498 |
+
if not self.enabled:
|
| 499 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 500 |
+
|
| 501 |
+
today = self._get_today()
|
| 502 |
+
current_cost = self._daily_cost.get(today, 0.0)
|
| 503 |
+
|
| 504 |
+
if current_cost >= self.daily_cost_limit:
|
| 505 |
+
return GuardrailResponse(
|
| 506 |
+
result=GuardrailResult.BLOCK,
|
| 507 |
+
message="Daily cost limit reached. Please try again tomorrow.",
|
| 508 |
+
metadata={
|
| 509 |
+
"daily_cost": current_cost,
|
| 510 |
+
"daily_limit": self.daily_cost_limit
|
| 511 |
+
}
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
return GuardrailResponse(
|
| 515 |
+
result=GuardrailResult.PASS,
|
| 516 |
+
metadata={"daily_cost": current_cost}
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
def record_usage(
|
| 520 |
+
self,
|
| 521 |
+
input_tokens: int,
|
| 522 |
+
output_tokens: int,
|
| 523 |
+
session_id: str = "default"
|
| 524 |
+
) -> Dict:
|
| 525 |
+
"""Record API usage and calculate cost.
|
| 526 |
+
|
| 527 |
+
Args:
|
| 528 |
+
input_tokens: Number of input tokens.
|
| 529 |
+
output_tokens: Number of output tokens.
|
| 530 |
+
session_id: Session identifier.
|
| 531 |
+
|
| 532 |
+
Returns:
|
| 533 |
+
Dict with usage details.
|
| 534 |
+
"""
|
| 535 |
+
today = self._get_today()
|
| 536 |
+
cost = self._calculate_cost(input_tokens, output_tokens)
|
| 537 |
+
|
| 538 |
+
usage_entry = {
|
| 539 |
+
"timestamp": datetime.now().isoformat(),
|
| 540 |
+
"session_id": session_id,
|
| 541 |
+
"model": self.model_name,
|
| 542 |
+
"input_tokens": input_tokens,
|
| 543 |
+
"output_tokens": output_tokens,
|
| 544 |
+
"cost_usd": cost
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
self._usage_log.append(usage_entry)
|
| 548 |
+
self._daily_cost[today] = self._daily_cost.get(today, 0.0) + cost
|
| 549 |
+
|
| 550 |
+
return usage_entry
|
| 551 |
+
|
| 552 |
+
def get_daily_summary(self) -> Dict:
|
| 553 |
+
"""Get usage summary for today."""
|
| 554 |
+
today = self._get_today()
|
| 555 |
+
today_entries = [
|
| 556 |
+
e for e in self._usage_log
|
| 557 |
+
if e["timestamp"].startswith(today)
|
| 558 |
+
]
|
| 559 |
+
|
| 560 |
+
return {
|
| 561 |
+
"date": today,
|
| 562 |
+
"total_requests": len(today_entries),
|
| 563 |
+
"total_input_tokens": sum(e["input_tokens"] for e in today_entries),
|
| 564 |
+
"total_output_tokens": sum(e["output_tokens"] for e in today_entries),
|
| 565 |
+
"total_cost_usd": self._daily_cost.get(today, 0.0),
|
| 566 |
+
"daily_limit_usd": self.daily_cost_limit,
|
| 567 |
+
"remaining_budget_usd": max(0, self.daily_cost_limit - self._daily_cost.get(today, 0.0))
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
class AuditLogGuardrail(Guardrail):
|
| 572 |
+
"""Guardrail to log all queries for audit and review."""
|
| 573 |
+
|
| 574 |
+
def __init__(
|
| 575 |
+
self,
|
| 576 |
+
log_dir: str = "logs/audit",
|
| 577 |
+
log_to_file: bool = True,
|
| 578 |
+
log_to_console: bool = True,
|
| 579 |
+
enabled: bool = True
|
| 580 |
+
):
|
| 581 |
+
"""Initialize audit log guardrail.
|
| 582 |
+
|
| 583 |
+
Args:
|
| 584 |
+
log_dir: Directory to store log files.
|
| 585 |
+
log_to_file: Whether to write logs to file.
|
| 586 |
+
log_to_console: Whether to print logs to console.
|
| 587 |
+
enabled: Whether the guardrail is active.
|
| 588 |
+
"""
|
| 589 |
+
super().__init__(name="audit_log", enabled=enabled)
|
| 590 |
+
self.log_dir = Path(log_dir)
|
| 591 |
+
self.log_to_file = log_to_file
|
| 592 |
+
self.log_to_console = log_to_console
|
| 593 |
+
self._session_logs: Dict[str, List[Dict]] = defaultdict(list)
|
| 594 |
+
|
| 595 |
+
if log_to_file:
|
| 596 |
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
| 597 |
+
|
| 598 |
+
def _get_log_filename(self) -> Path:
|
| 599 |
+
"""Get today's log filename."""
|
| 600 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 601 |
+
return self.log_dir / f"audit_{today}.jsonl"
|
| 602 |
+
|
| 603 |
+
def check_input(self, user_input: str, context: Dict) -> GuardrailResponse:
|
| 604 |
+
"""Log incoming query."""
|
| 605 |
+
if not self.enabled:
|
| 606 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 607 |
+
|
| 608 |
+
session_id = context.get("session_id", "default")
|
| 609 |
+
|
| 610 |
+
log_entry = {
|
| 611 |
+
"timestamp": datetime.now().isoformat(),
|
| 612 |
+
"type": "input",
|
| 613 |
+
"session_id": session_id,
|
| 614 |
+
"content": user_input,
|
| 615 |
+
"metadata": {k: v for k, v in context.items() if k != "session_id"}
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
self._write_log(log_entry)
|
| 619 |
+
|
| 620 |
+
return GuardrailResponse(
|
| 621 |
+
result=GuardrailResult.PASS,
|
| 622 |
+
metadata={"logged": True}
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
def check_output(self, output: str, context: Dict) -> GuardrailResponse:
|
| 626 |
+
"""Log outgoing response."""
|
| 627 |
+
if not self.enabled:
|
| 628 |
+
return GuardrailResponse(result=GuardrailResult.PASS)
|
| 629 |
+
|
| 630 |
+
session_id = context.get("session_id", "default")
|
| 631 |
+
|
| 632 |
+
log_entry = {
|
| 633 |
+
"timestamp": datetime.now().isoformat(),
|
| 634 |
+
"type": "output",
|
| 635 |
+
"session_id": session_id,
|
| 636 |
+
"content": output[:500] + "..." if len(output) > 500 else output,
|
| 637 |
+
"content_length": len(output),
|
| 638 |
+
"metadata": {k: v for k, v in context.items() if k != "session_id"}
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
self._write_log(log_entry)
|
| 642 |
+
|
| 643 |
+
return GuardrailResponse(
|
| 644 |
+
result=GuardrailResult.PASS,
|
| 645 |
+
metadata={"logged": True}
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
def _write_log(self, entry: Dict) -> None:
|
| 649 |
+
"""Write log entry to file and/or console."""
|
| 650 |
+
if self.log_to_console:
|
| 651 |
+
print(f"[AUDIT] {entry['type'].upper()}: {entry.get('content', '')[:100]}...")
|
| 652 |
+
|
| 653 |
+
if self.log_to_file:
|
| 654 |
+
try:
|
| 655 |
+
with open(self._get_log_filename(), "a", encoding="utf-8") as f:
|
| 656 |
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
| 657 |
+
except Exception as e:
|
| 658 |
+
print(f"[AUDIT] Failed to write log: {e}")
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
class GuardrailManager:
|
| 662 |
+
"""Manager class to orchestrate multiple guardrails."""
|
| 663 |
+
|
| 664 |
+
def __init__(self):
|
| 665 |
+
"""Initialize the guardrail manager."""
|
| 666 |
+
self.guardrails: List[Guardrail] = []
|
| 667 |
+
self._context: Dict = {}
|
| 668 |
+
|
| 669 |
+
def add_guardrail(self, guardrail: Guardrail) -> "GuardrailManager":
|
| 670 |
+
"""Add a guardrail to the manager.
|
| 671 |
+
|
| 672 |
+
Args:
|
| 673 |
+
guardrail: The guardrail to add.
|
| 674 |
+
|
| 675 |
+
Returns:
|
| 676 |
+
Self for method chaining.
|
| 677 |
+
"""
|
| 678 |
+
self.guardrails.append(guardrail)
|
| 679 |
+
return self
|
| 680 |
+
|
| 681 |
+
def check_input(
|
| 682 |
+
self,
|
| 683 |
+
user_input: str,
|
| 684 |
+
session_id: str = "default"
|
| 685 |
+
) -> Tuple[bool, str, Dict]:
|
| 686 |
+
"""Run all input guardrails.
|
| 687 |
+
|
| 688 |
+
Args:
|
| 689 |
+
user_input: The user's message.
|
| 690 |
+
session_id: Session identifier.
|
| 691 |
+
|
| 692 |
+
Returns:
|
| 693 |
+
Tuple of (should_proceed, message, context).
|
| 694 |
+
"""
|
| 695 |
+
context = {"session_id": session_id}
|
| 696 |
+
|
| 697 |
+
for guardrail in self.guardrails:
|
| 698 |
+
if not guardrail.enabled:
|
| 699 |
+
continue
|
| 700 |
+
|
| 701 |
+
response = guardrail.check_input(user_input, context)
|
| 702 |
+
|
| 703 |
+
context.update(response.metadata)
|
| 704 |
+
|
| 705 |
+
if response.result == GuardrailResult.BLOCK:
|
| 706 |
+
print(f"[GUARDRAIL] {guardrail.name} BLOCKED: {response.message}")
|
| 707 |
+
return False, response.message, context
|
| 708 |
+
|
| 709 |
+
if response.result == GuardrailResult.WARN:
|
| 710 |
+
print(f"[GUARDRAIL] {guardrail.name} WARNING: {response.message}")
|
| 711 |
+
|
| 712 |
+
self._context = context
|
| 713 |
+
return True, "", context
|
| 714 |
+
|
| 715 |
+
def check_output(self, output: str) -> str:
|
| 716 |
+
"""Run all output guardrails.
|
| 717 |
+
|
| 718 |
+
Args:
|
| 719 |
+
output: The agent's response.
|
| 720 |
+
|
| 721 |
+
Returns:
|
| 722 |
+
The (possibly modified) output.
|
| 723 |
+
"""
|
| 724 |
+
modified_output = output
|
| 725 |
+
|
| 726 |
+
for guardrail in self.guardrails:
|
| 727 |
+
if not guardrail.enabled:
|
| 728 |
+
continue
|
| 729 |
+
|
| 730 |
+
response = guardrail.check_output(modified_output, self._context)
|
| 731 |
+
|
| 732 |
+
if response.result == GuardrailResult.MODIFY and response.modified_input:
|
| 733 |
+
modified_output = response.modified_input
|
| 734 |
+
print(f"[GUARDRAIL] {guardrail.name} modified output")
|
| 735 |
+
|
| 736 |
+
return modified_output
|
| 737 |
+
|
| 738 |
+
def get_guardrail(self, name: str) -> Optional[Guardrail]:
|
| 739 |
+
"""Get a guardrail by name.
|
| 740 |
+
|
| 741 |
+
Args:
|
| 742 |
+
name: Name of the guardrail.
|
| 743 |
+
|
| 744 |
+
Returns:
|
| 745 |
+
The guardrail or None if not found.
|
| 746 |
+
"""
|
| 747 |
+
for guardrail in self.guardrails:
|
| 748 |
+
if guardrail.name == name:
|
| 749 |
+
return guardrail
|
| 750 |
+
return None
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def create_default_guardrails(
|
| 754 |
+
model_name: str = "claude-sonnet-4-20250514",
|
| 755 |
+
strict_topic_filter: bool = False,
|
| 756 |
+
daily_cost_limit: float = 1.0
|
| 757 |
+
) -> GuardrailManager:
|
| 758 |
+
"""Create a GuardrailManager with default configuration.
|
| 759 |
+
|
| 760 |
+
Args:
|
| 761 |
+
model_name: Name of the LLM model for cost tracking.
|
| 762 |
+
strict_topic_filter: Whether to strictly block off-topic queries.
|
| 763 |
+
daily_cost_limit: Maximum daily spend in USD (default: $1/day).
|
| 764 |
+
|
| 765 |
+
Returns:
|
| 766 |
+
Configured GuardrailManager instance.
|
| 767 |
+
"""
|
| 768 |
+
manager = GuardrailManager()
|
| 769 |
+
|
| 770 |
+
manager.add_guardrail(InputLengthGuardrail(max_length=2000, min_length=1))
|
| 771 |
+
manager.add_guardrail(RateLimitGuardrail(
|
| 772 |
+
max_requests_per_minute=10,
|
| 773 |
+
max_requests_per_hour=100
|
| 774 |
+
))
|
| 775 |
+
manager.add_guardrail(TopicFilterGuardrail(strict_mode=strict_topic_filter))
|
| 776 |
+
manager.add_guardrail(TokenLimitGuardrail(
|
| 777 |
+
max_input_tokens=500,
|
| 778 |
+
max_output_tokens=1000,
|
| 779 |
+
daily_token_budget=100000
|
| 780 |
+
))
|
| 781 |
+
manager.add_guardrail(UsageTrackingGuardrail(
|
| 782 |
+
model_name=model_name,
|
| 783 |
+
daily_cost_limit=daily_cost_limit
|
| 784 |
+
))
|
| 785 |
+
manager.add_guardrail(MedicalDisclaimerGuardrail())
|
| 786 |
+
manager.add_guardrail(AuditLogGuardrail(log_to_file=True, log_to_console=True))
|
| 787 |
+
|
| 788 |
+
return manager
|
tools/image_utils.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image utility functions for fetching spice images from Wikipedia.
|
| 2 |
+
|
| 3 |
+
Images are fetched on-demand from Wikimedia Commons with proper User-Agent
|
| 4 |
+
headers to comply with their API requirements. Downloaded images are cached
|
| 5 |
+
in a temp directory.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import hashlib
|
| 10 |
+
import tempfile
|
| 11 |
+
import requests
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Optional, Tuple
|
| 14 |
+
|
| 15 |
+
DATA_DIR = Path(__file__).parent.parent / "data"
|
| 16 |
+
MAPPINGS_FILE = DATA_DIR / "spice_images.json"
|
| 17 |
+
CACHE_DIR = Path(tempfile.gettempdir()) / "spice_images_cache"
|
| 18 |
+
|
| 19 |
+
HEADERS = {
|
| 20 |
+
"User-Agent": "MedicinalCuisineBot/1.0 (https://huggingface.co/spaces/MCP-1st-Birthday/Spice_Bae; educational project)"
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
_url_cache: dict = {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _load_mappings() -> dict:
|
| 27 |
+
"""Load image URL mappings from JSON file.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
Dict with spice image URLs and metadata.
|
| 31 |
+
"""
|
| 32 |
+
global _url_cache
|
| 33 |
+
|
| 34 |
+
if _url_cache:
|
| 35 |
+
return _url_cache
|
| 36 |
+
|
| 37 |
+
if not MAPPINGS_FILE.exists():
|
| 38 |
+
return {}
|
| 39 |
+
|
| 40 |
+
with open(MAPPINGS_FILE, 'r', encoding='utf-8') as f:
|
| 41 |
+
_url_cache = json.load(f)
|
| 42 |
+
|
| 43 |
+
return _url_cache
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _get_cache_path(url: str) -> Path:
|
| 47 |
+
"""Generate a cache file path for a URL.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
url: Image URL.
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Path to cached file.
|
| 54 |
+
"""
|
| 55 |
+
url_hash = hashlib.md5(url.encode()).hexdigest()
|
| 56 |
+
ext = url.split('.')[-1].split('?')[0][:4]
|
| 57 |
+
if ext not in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
|
| 58 |
+
ext = 'jpg'
|
| 59 |
+
return CACHE_DIR / f"{url_hash}.{ext}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _download_image(url: str) -> Optional[str]:
|
| 63 |
+
"""Download image from URL with proper headers.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
url: Wikimedia image URL.
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Path to downloaded file, or None on failure.
|
| 70 |
+
"""
|
| 71 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
cache_path = _get_cache_path(url)
|
| 73 |
+
|
| 74 |
+
if cache_path.exists():
|
| 75 |
+
return str(cache_path)
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
response = requests.get(url, headers=HEADERS, timeout=10)
|
| 79 |
+
response.raise_for_status()
|
| 80 |
+
|
| 81 |
+
with open(cache_path, 'wb') as f:
|
| 82 |
+
f.write(response.content)
|
| 83 |
+
|
| 84 |
+
return str(cache_path)
|
| 85 |
+
except Exception:
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def get_local_image_path(spice_name: str) -> Optional[str]:
|
| 90 |
+
"""Get image path for a spice (downloads if needed).
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
spice_name: Name of the spice.
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
Path to the image file, or None if not found.
|
| 97 |
+
"""
|
| 98 |
+
mappings = _load_mappings()
|
| 99 |
+
spice_images = mappings.get("spice_images", {})
|
| 100 |
+
|
| 101 |
+
name_lower = spice_name.lower().strip()
|
| 102 |
+
|
| 103 |
+
if name_lower in spice_images:
|
| 104 |
+
url = spice_images[name_lower].get("url")
|
| 105 |
+
if url:
|
| 106 |
+
return _download_image(url)
|
| 107 |
+
|
| 108 |
+
for key, data in spice_images.items():
|
| 109 |
+
if key in name_lower or name_lower in key:
|
| 110 |
+
url = data.get("url")
|
| 111 |
+
if url:
|
| 112 |
+
return _download_image(url)
|
| 113 |
+
|
| 114 |
+
return None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def get_image_source(spice_name: str) -> Optional[str]:
|
| 118 |
+
"""Get Wikipedia source URL for a spice image.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
spice_name: Name of the spice.
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Wikipedia source URL or None.
|
| 125 |
+
"""
|
| 126 |
+
mappings = _load_mappings()
|
| 127 |
+
spice_images = mappings.get("spice_images", {})
|
| 128 |
+
|
| 129 |
+
name_lower = spice_name.lower().strip()
|
| 130 |
+
|
| 131 |
+
if name_lower in spice_images:
|
| 132 |
+
return spice_images[name_lower].get("source")
|
| 133 |
+
|
| 134 |
+
for key, data in spice_images.items():
|
| 135 |
+
if key in name_lower or name_lower in key:
|
| 136 |
+
return data.get("source")
|
| 137 |
+
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def get_spice_image_and_info(
|
| 142 |
+
spice_name: str,
|
| 143 |
+
info_text: str
|
| 144 |
+
) -> Tuple[Optional[str], str]:
|
| 145 |
+
"""Get image path and info text with source attribution.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
spice_name: Name of the spice.
|
| 149 |
+
info_text: Original information text.
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Tuple of (image_path, info_text_with_attribution).
|
| 153 |
+
"""
|
| 154 |
+
image_path = get_local_image_path(spice_name)
|
| 155 |
+
source = get_image_source(spice_name)
|
| 156 |
+
|
| 157 |
+
if source:
|
| 158 |
+
info_text += f"\n\n---\nImage source: {source}"
|
| 159 |
+
|
| 160 |
+
return image_path, info_text
|
tools/llama_agent.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LlamaIndex Agent for Medicinal Cuisine conversational interface.
|
| 2 |
+
|
| 3 |
+
This module wraps the existing spice database tools as LlamaIndex FunctionTools,
|
| 4 |
+
enabling a conversational AI interface powered by Claude.
|
| 5 |
+
|
| 6 |
+
Architecture:
|
| 7 |
+
User Question -> Guardrails -> LlamaIndex AgentWorkflow -> Tool Selection -> Neo4j Query -> Guardrails -> Response
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import os
|
| 12 |
+
from typing import Optional
|
| 13 |
+
from llama_index.core.tools import FunctionTool
|
| 14 |
+
from llama_index.core.agent.workflow import AgentWorkflow
|
| 15 |
+
from llama_index.llms.anthropic import Anthropic
|
| 16 |
+
|
| 17 |
+
from tools.neo4j_queries import SpiceDatabase
|
| 18 |
+
from tools.guardrails import (
|
| 19 |
+
GuardrailManager,
|
| 20 |
+
create_default_guardrails,
|
| 21 |
+
UsageTrackingGuardrail,
|
| 22 |
+
TokenLimitGuardrail,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class SpiceAgent:
|
| 27 |
+
"""Conversational agent for medicinal spice queries.
|
| 28 |
+
|
| 29 |
+
Wraps the SpiceDatabase functions as LlamaIndex tools and uses
|
| 30 |
+
Claude for natural language understanding. Includes comprehensive
|
| 31 |
+
guardrails for safety, cost control, and compliance.
|
| 32 |
+
|
| 33 |
+
Attributes:
|
| 34 |
+
db: SpiceDatabase instance for Neo4j queries
|
| 35 |
+
llm: Anthropic Claude LLM for processing queries
|
| 36 |
+
workflow: LlamaIndex AgentWorkflow for tool orchestration
|
| 37 |
+
guardrails: GuardrailManager for safety checks
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
api_key: Optional[str] = None,
|
| 45 |
+
model: Optional[str] = None,
|
| 46 |
+
enable_guardrails: bool = True,
|
| 47 |
+
daily_cost_limit: float = 1.0,
|
| 48 |
+
strict_topic_filter: bool = False,
|
| 49 |
+
):
|
| 50 |
+
"""Initialize the spice agent.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
api_key: Anthropic API key. If None, reads from ANTHROPIC_API_KEY env var.
|
| 54 |
+
model: Model name to use. Defaults to claude-sonnet-4-20250514.
|
| 55 |
+
enable_guardrails: Whether to enable safety guardrails.
|
| 56 |
+
daily_cost_limit: Maximum daily spend in USD (default: $1/day).
|
| 57 |
+
strict_topic_filter: Whether to strictly block off-topic queries.
|
| 58 |
+
"""
|
| 59 |
+
self.db = SpiceDatabase()
|
| 60 |
+
self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
|
| 61 |
+
self.model = model or self.DEFAULT_MODEL
|
| 62 |
+
self.llm = None
|
| 63 |
+
self.workflow = None
|
| 64 |
+
|
| 65 |
+
if enable_guardrails:
|
| 66 |
+
self.guardrails = create_default_guardrails(
|
| 67 |
+
model_name=self.model,
|
| 68 |
+
strict_topic_filter=strict_topic_filter,
|
| 69 |
+
daily_cost_limit=daily_cost_limit,
|
| 70 |
+
)
|
| 71 |
+
print("[GUARDRAILS] Initialized with default configuration")
|
| 72 |
+
else:
|
| 73 |
+
self.guardrails = None
|
| 74 |
+
print("[GUARDRAILS] Disabled")
|
| 75 |
+
|
| 76 |
+
if self.api_key:
|
| 77 |
+
self._initialize_agent()
|
| 78 |
+
|
| 79 |
+
def _initialize_agent(self) -> None:
|
| 80 |
+
"""Initialize LLM and agent workflow with tools."""
|
| 81 |
+
self.llm = Anthropic(
|
| 82 |
+
model=self.model,
|
| 83 |
+
api_key=self.api_key,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
tools = self._create_tools()
|
| 87 |
+
|
| 88 |
+
self.workflow = AgentWorkflow.from_tools_or_functions(
|
| 89 |
+
tools_or_functions=tools,
|
| 90 |
+
llm=self.llm,
|
| 91 |
+
system_prompt="""You are a helpful medicinal cuisine advisor that helps users learn about spices, their nutritional content, and health benefits.
|
| 92 |
+
|
| 93 |
+
You have access to a database of 88+ spices with:
|
| 94 |
+
- Nutritional data from USDA FoodData Central
|
| 95 |
+
- Health benefits from NCCIH (National Center for Complementary and Integrative Health)
|
| 96 |
+
- Safety information including side effects and cautions
|
| 97 |
+
|
| 98 |
+
IMPORTANT: Always remind users that this information is for educational purposes only and not medical advice.
|
| 99 |
+
|
| 100 |
+
When answering questions:
|
| 101 |
+
1. Use the appropriate tool to query the database
|
| 102 |
+
2. Provide clear, helpful responses
|
| 103 |
+
3. Include source attribution (USDA or NCCIH)
|
| 104 |
+
4. Mention relevant safety information when discussing health benefits
|
| 105 |
+
""",
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
def _create_tools(self) -> list:
|
| 109 |
+
"""Create LlamaIndex FunctionTools from database methods.
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
List of FunctionTool objects for the agent.
|
| 113 |
+
"""
|
| 114 |
+
tools = [
|
| 115 |
+
FunctionTool.from_defaults(
|
| 116 |
+
fn=self._get_spice_info,
|
| 117 |
+
name="get_spice_information",
|
| 118 |
+
description="Get comprehensive information about a spice including nutritional data."
|
| 119 |
+
),
|
| 120 |
+
FunctionTool.from_defaults(
|
| 121 |
+
fn=self._list_spices,
|
| 122 |
+
name="list_available_spices",
|
| 123 |
+
description="List all spices available in the database."
|
| 124 |
+
),
|
| 125 |
+
FunctionTool.from_defaults(
|
| 126 |
+
fn=self._get_nutrient,
|
| 127 |
+
name="get_nutrient_content",
|
| 128 |
+
description="Get specific nutrient content for a spice."
|
| 129 |
+
),
|
| 130 |
+
FunctionTool.from_defaults(
|
| 131 |
+
fn=self._find_substitutes,
|
| 132 |
+
name="find_spice_substitutes",
|
| 133 |
+
description="Find substitute spices based on nutritional similarity."
|
| 134 |
+
),
|
| 135 |
+
FunctionTool.from_defaults(
|
| 136 |
+
fn=self._get_health_benefits,
|
| 137 |
+
name="get_health_benefits",
|
| 138 |
+
description="Get health benefits and medicinal properties of a spice."
|
| 139 |
+
),
|
| 140 |
+
FunctionTool.from_defaults(
|
| 141 |
+
fn=self._find_by_benefit,
|
| 142 |
+
name="find_spices_for_benefit",
|
| 143 |
+
description="Find spices that help with a specific health condition."
|
| 144 |
+
),
|
| 145 |
+
FunctionTool.from_defaults(
|
| 146 |
+
fn=self._get_safety_info,
|
| 147 |
+
name="get_safety_information",
|
| 148 |
+
description="Get safety information including side effects and cautions."
|
| 149 |
+
),
|
| 150 |
+
FunctionTool.from_defaults(
|
| 151 |
+
fn=self._find_medicinal_substitutes,
|
| 152 |
+
name="find_medicinal_substitutes",
|
| 153 |
+
description="Find substitute spices based on shared health benefits."
|
| 154 |
+
),
|
| 155 |
+
]
|
| 156 |
+
return tools
|
| 157 |
+
|
| 158 |
+
def _get_spice_info(self, spice_name: str) -> str:
|
| 159 |
+
"""Get comprehensive spice information."""
|
| 160 |
+
print(f"[TOOL] get_spice_information called with: {spice_name}")
|
| 161 |
+
return self.db.get_spice_info(spice_name)
|
| 162 |
+
|
| 163 |
+
def _list_spices(self) -> str:
|
| 164 |
+
"""List all available spices."""
|
| 165 |
+
print("[TOOL] list_available_spices called")
|
| 166 |
+
return self.db.list_all_spices()
|
| 167 |
+
|
| 168 |
+
def _get_nutrient(self, spice_name: str, nutrient_name: str) -> str:
|
| 169 |
+
"""Get specific nutrient content."""
|
| 170 |
+
print(f"[TOOL] get_nutrient_content called with: {spice_name}, {nutrient_name}")
|
| 171 |
+
return self.db.get_nutrient_content(spice_name, nutrient_name)
|
| 172 |
+
|
| 173 |
+
def _find_substitutes(self, spice_name: str, limit: int = 5) -> str:
|
| 174 |
+
"""Find nutritional substitutes."""
|
| 175 |
+
print(f"[TOOL] find_spice_substitutes called with: {spice_name}, limit={limit}")
|
| 176 |
+
return self.db.find_substitutes(spice_name, limit=limit)
|
| 177 |
+
|
| 178 |
+
def _get_health_benefits(self, spice_name: str) -> str:
|
| 179 |
+
"""Get health benefits information."""
|
| 180 |
+
print(f"[TOOL] get_health_benefits called with: {spice_name}")
|
| 181 |
+
return self.db.get_health_benefits(spice_name)
|
| 182 |
+
|
| 183 |
+
def _find_by_benefit(self, benefit_keyword: str, limit: int = 10) -> str:
|
| 184 |
+
"""Find spices by health benefit."""
|
| 185 |
+
print(f"[TOOL] find_spices_for_benefit called with: {benefit_keyword}, limit={limit}")
|
| 186 |
+
return self.db.find_spices_by_benefit(benefit_keyword, limit=limit)
|
| 187 |
+
|
| 188 |
+
def _get_safety_info(self, spice_name: str) -> str:
|
| 189 |
+
"""Get safety information."""
|
| 190 |
+
print(f"[TOOL] get_safety_information called with: {spice_name}")
|
| 191 |
+
return self.db.get_safety_info(spice_name)
|
| 192 |
+
|
| 193 |
+
def _find_medicinal_substitutes(self, spice_name: str, limit: int = 5) -> str:
|
| 194 |
+
"""Find medicinal substitutes."""
|
| 195 |
+
print(f"[TOOL] find_medicinal_substitutes called with: {spice_name}, limit={limit}")
|
| 196 |
+
return self.db.find_substitutes_by_benefits(spice_name, limit=limit)
|
| 197 |
+
|
| 198 |
+
def chat(self, message: str, session_id: str = "default") -> str:
|
| 199 |
+
"""Process a chat message and return response.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
message: User's question or message.
|
| 203 |
+
session_id: Session identifier for rate limiting and tracking.
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
Agent's response string.
|
| 207 |
+
"""
|
| 208 |
+
if not self.workflow:
|
| 209 |
+
return "Error: Agent not initialized. Please ensure ANTHROPIC_API_KEY is set."
|
| 210 |
+
|
| 211 |
+
if self.guardrails:
|
| 212 |
+
should_proceed, block_message, context = self.guardrails.check_input(
|
| 213 |
+
message, session_id
|
| 214 |
+
)
|
| 215 |
+
if not should_proceed:
|
| 216 |
+
return block_message
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
loop = asyncio.new_event_loop()
|
| 220 |
+
asyncio.set_event_loop(loop)
|
| 221 |
+
try:
|
| 222 |
+
result = loop.run_until_complete(self._async_chat(message))
|
| 223 |
+
|
| 224 |
+
if self.guardrails:
|
| 225 |
+
result = self.guardrails.check_output(result)
|
| 226 |
+
|
| 227 |
+
usage_tracker = self.guardrails.get_guardrail("usage_tracking")
|
| 228 |
+
if usage_tracker and isinstance(usage_tracker, UsageTrackingGuardrail):
|
| 229 |
+
input_tokens = len(message) // 4
|
| 230 |
+
output_tokens = len(result) // 4
|
| 231 |
+
usage_tracker.record_usage(input_tokens, output_tokens, session_id)
|
| 232 |
+
|
| 233 |
+
return result
|
| 234 |
+
finally:
|
| 235 |
+
loop.close()
|
| 236 |
+
except Exception as e:
|
| 237 |
+
return f"Error processing request: {str(e)}"
|
| 238 |
+
|
| 239 |
+
async def _async_chat(self, message: str) -> str:
|
| 240 |
+
"""Async chat handler for the workflow.
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
message: User's question or message.
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Agent's response string.
|
| 247 |
+
"""
|
| 248 |
+
print(f"[AGENT] Processing message: {message}")
|
| 249 |
+
try:
|
| 250 |
+
response = await self.workflow.run(user_msg=message)
|
| 251 |
+
print(f"[AGENT] Raw response type: {type(response)}")
|
| 252 |
+
if response is None:
|
| 253 |
+
return "No response generated. Please try rephrasing your question."
|
| 254 |
+
return str(response)
|
| 255 |
+
except Exception as e:
|
| 256 |
+
print(f"[AGENT] Error in workflow: {type(e).__name__}: {e}")
|
| 257 |
+
import traceback
|
| 258 |
+
traceback.print_exc()
|
| 259 |
+
raise
|
| 260 |
+
|
| 261 |
+
def get_usage_summary(self) -> dict:
|
| 262 |
+
"""Get usage summary for today.
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
Dict with usage statistics or empty dict if guardrails disabled.
|
| 266 |
+
"""
|
| 267 |
+
if not self.guardrails:
|
| 268 |
+
return {}
|
| 269 |
+
|
| 270 |
+
usage_tracker = self.guardrails.get_guardrail("usage_tracking")
|
| 271 |
+
if usage_tracker and isinstance(usage_tracker, UsageTrackingGuardrail):
|
| 272 |
+
return usage_tracker.get_daily_summary()
|
| 273 |
+
return {}
|
| 274 |
+
|
| 275 |
+
def is_ready(self) -> bool:
|
| 276 |
+
"""Check if agent is ready to process queries.
|
| 277 |
+
|
| 278 |
+
Returns:
|
| 279 |
+
True if workflow is initialized, False otherwise.
|
| 280 |
+
"""
|
| 281 |
+
return self.workflow is not None
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def create_agent(api_key: Optional[str] = None) -> SpiceAgent:
|
| 285 |
+
"""Factory function to create a SpiceAgent.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
api_key: Optional Anthropic API key.
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
Configured SpiceAgent instance.
|
| 292 |
+
"""
|
| 293 |
+
return SpiceAgent(api_key=api_key)
|
tools/mcp_tools.py
CHANGED
|
@@ -7,13 +7,54 @@ Spice-Bae Neo4j database.
|
|
| 7 |
All data is sourced from USDA FoodData Central with full attribution.
|
| 8 |
"""
|
| 9 |
|
|
|
|
| 10 |
import gradio as gr
|
| 11 |
from tools.neo4j_queries import SpiceDatabase
|
|
|
|
| 12 |
|
| 13 |
# Initialize database connection
|
| 14 |
db = SpiceDatabase()
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
@gr.mcp.tool()
|
| 18 |
def get_spice_information(spice_name: str) -> str:
|
| 19 |
"""Get comprehensive information about a spice including nutrients and sourcing.
|
|
|
|
| 7 |
All data is sourced from USDA FoodData Central with full attribution.
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
from typing import Optional, Tuple
|
| 11 |
import gradio as gr
|
| 12 |
from tools.neo4j_queries import SpiceDatabase
|
| 13 |
+
from tools.image_utils import get_spice_image_and_info, get_local_image_path
|
| 14 |
|
| 15 |
# Initialize database connection
|
| 16 |
db = SpiceDatabase()
|
| 17 |
|
| 18 |
|
| 19 |
+
def get_spice_info_with_image(spice_name: str) -> Tuple[Optional[str], str]:
|
| 20 |
+
"""Get spice information along with local image path.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
spice_name: Name of the spice to look up.
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
Tuple of (local_image_path, info_text with attribution).
|
| 27 |
+
"""
|
| 28 |
+
info_text = db.get_spice_info(spice_name)
|
| 29 |
+
return get_spice_image_and_info(spice_name, info_text)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def get_health_benefits_with_image(spice_name: str) -> Tuple[Optional[str], str]:
|
| 33 |
+
"""Get health benefits information along with local image path.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
spice_name: Name of the spice to look up.
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Tuple of (local_image_path, health_info_text with attribution).
|
| 40 |
+
"""
|
| 41 |
+
health_text = db.get_health_benefits(spice_name)
|
| 42 |
+
return get_spice_image_and_info(spice_name, health_text)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_safety_info_with_image(spice_name: str) -> Tuple[Optional[str], str]:
|
| 46 |
+
"""Get safety information along with local image path.
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
spice_name: Name of the spice to look up.
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Tuple of (local_image_path, safety_info_text with attribution).
|
| 53 |
+
"""
|
| 54 |
+
safety_text = db.get_safety_info(spice_name)
|
| 55 |
+
return get_spice_image_and_info(spice_name, safety_text)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
@gr.mcp.tool()
|
| 59 |
def get_spice_information(spice_name: str) -> str:
|
| 60 |
"""Get comprehensive information about a spice including nutrients and sourcing.
|
tools/neo4j_queries.py
CHANGED
|
@@ -85,14 +85,16 @@ class SpiceDatabase:
|
|
| 85 |
return f"Spice '{spice_name}' not found in database."
|
| 86 |
|
| 87 |
data = result["data"]["values"][0]
|
| 88 |
-
# Extract properties from Neo4j node object
|
| 89 |
spice_node = data[0] if data else {}
|
| 90 |
spice = spice_node.get('properties', {}) if isinstance(spice_node, dict) else {}
|
| 91 |
nutrients = data[1] if len(data) > 1 else []
|
| 92 |
|
| 93 |
-
# Format output
|
| 94 |
output = []
|
| 95 |
output.append(f"=== {spice.get('name', 'Unknown').upper()} ===\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
output.append(f"Description: {spice.get('description', 'N/A')}")
|
| 97 |
|
| 98 |
if spice.get('scientific_name'):
|
|
@@ -101,7 +103,6 @@ class SpiceDatabase:
|
|
| 101 |
output.append(f"Category: {spice.get('food_category', 'N/A')}")
|
| 102 |
output.append(f"\nKey Nutrients (top 10):")
|
| 103 |
|
| 104 |
-
# Show top 10 nutrients by value
|
| 105 |
nutrient_list = [n for n in nutrients if n.get('value') is not None]
|
| 106 |
nutrient_list.sort(key=lambda x: float(x.get('value', 0) or 0), reverse=True)
|
| 107 |
|
|
@@ -113,6 +114,40 @@ class SpiceDatabase:
|
|
| 113 |
|
| 114 |
return "\n".join(output)
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
def list_all_spices(self) -> str:
|
| 117 |
"""List all available spices in the database.
|
| 118 |
|
|
|
|
| 85 |
return f"Spice '{spice_name}' not found in database."
|
| 86 |
|
| 87 |
data = result["data"]["values"][0]
|
|
|
|
| 88 |
spice_node = data[0] if data else {}
|
| 89 |
spice = spice_node.get('properties', {}) if isinstance(spice_node, dict) else {}
|
| 90 |
nutrients = data[1] if len(data) > 1 else []
|
| 91 |
|
|
|
|
| 92 |
output = []
|
| 93 |
output.append(f"=== {spice.get('name', 'Unknown').upper()} ===\n")
|
| 94 |
+
|
| 95 |
+
if spice.get('image_url'):
|
| 96 |
+
output.append(f"Image: {spice['image_url']}")
|
| 97 |
+
|
| 98 |
output.append(f"Description: {spice.get('description', 'N/A')}")
|
| 99 |
|
| 100 |
if spice.get('scientific_name'):
|
|
|
|
| 103 |
output.append(f"Category: {spice.get('food_category', 'N/A')}")
|
| 104 |
output.append(f"\nKey Nutrients (top 10):")
|
| 105 |
|
|
|
|
| 106 |
nutrient_list = [n for n in nutrients if n.get('value') is not None]
|
| 107 |
nutrient_list.sort(key=lambda x: float(x.get('value', 0) or 0), reverse=True)
|
| 108 |
|
|
|
|
| 114 |
|
| 115 |
return "\n".join(output)
|
| 116 |
|
| 117 |
+
def get_spice_with_image(self, spice_name: str) -> Optional[Dict]:
|
| 118 |
+
"""Get spice information including image URL as a dictionary.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
spice_name: Name of the spice to look up
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
Dict with spice data including image_url and source, or None if not found
|
| 125 |
+
"""
|
| 126 |
+
query = """
|
| 127 |
+
MATCH (s:Spice)
|
| 128 |
+
WHERE toLower(s.name) = toLower($spice_name)
|
| 129 |
+
RETURN s.name as name,
|
| 130 |
+
s.description as description,
|
| 131 |
+
s.image_url as image_url,
|
| 132 |
+
s.image_source as image_source,
|
| 133 |
+
s.food_category as category
|
| 134 |
+
LIMIT 1
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
result = self.run_query(query, {"spice_name": spice_name})
|
| 138 |
+
|
| 139 |
+
if not result or not result.get("data", {}).get("values"):
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
data = result["data"]["values"][0]
|
| 143 |
+
return {
|
| 144 |
+
"name": data[0],
|
| 145 |
+
"description": data[1],
|
| 146 |
+
"image_url": data[2],
|
| 147 |
+
"image_source": data[3],
|
| 148 |
+
"category": data[4]
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
def list_all_spices(self) -> str:
|
| 152 |
"""List all available spices in the database.
|
| 153 |
|