fabiantoh98 commited on
Commit
bc788f6
·
1 Parent(s): e50e98b

Add LlamaIndex agent with guardrails and on-demand image

Browse files
.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
- # 🌿 Medicinal Cuisine AI Advisor
14
 
15
- An AI-powered advisor for spice information, nutritional content, and medicinal properties. Built with Gradio, Neo4j, and the USDA FoodData Central API.
 
 
 
 
 
 
16
 
17
  ## Features
18
 
19
- - 🔍 **Spice Information Lookup** - Detailed nutritional data for 25 common spices
20
- - 🧪 **Nutrient Content Search** - Find specific vitamins, minerals, and compounds
21
- - ⚖️ **Spice Comparison** - Side-by-side nutritional analysis
22
- - 📊 **MCP Server Integration** - Model Context Protocol tools at `/gradio_api/mcp/`
23
- - 📚 **Data Provenance** - All data sourced from USDA FoodData Central with full attribution
 
 
 
24
 
25
  ## Live Demo
26
 
27
- - **Web UI**: [Your Hugging Face Space URL]
28
- - **MCP Server**: [Your Space URL]/gradio_api/mcp/
29
 
30
  ## Architecture
31
 
32
  ```
33
- ┌─────────────────────────────────┐
34
- Gradio Web UI + MCP Server
35
- (app.py - Port 7860)
36
- └─────────────────────────────────┘
37
-
38
- ┌─────────────────────────────────┐
39
- │ MCP Tools (tools/mcp_tools.py)│
40
- - get_spice_information()
41
- - get_nutrient_content()
42
- - compare_spices()
43
- - list_available_spices()
44
- └─────────────────────────────────┘
45
-
46
- ┌─────────────────────────────────┐
47
- Neo4j Aura (Spice-Bae Database)
48
- - 25 Spice nodes
49
- - 2,098 Nutrient relationships
50
- └─────────────────────────────────┘
51
-
52
- ┌─────────────────────────────────┐
53
- │ USDA FoodData Central API │
54
- │ (Cached in data/raw/usda/) │
55
- └─────────────────────────────────┘
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  ```
57
 
58
  ## Data Sources
59
 
60
- All spice and nutritional data is sourced from:
 
61
  - **USDA FoodData Central** - Public domain nutritional database
62
- - **License**: Public Domain
63
- - **Attribution**: USDA FoodData Central (https://fdc.nal.usda.gov/)
 
 
 
 
 
 
 
 
64
 
65
  ## Available Spices
66
 
67
- Black Pepper, Cayenne Pepper, Cinnamon, Coriander, Cumin, Fennel, Fenugreek, Garlic, Mustard Seed, Nutmeg, Oregano, Paprika, Saffron, Sage, Allspice, Anise, Basil, Cloves, Ginger, Rosemary, Thyme, Turmeric, Vanilla
 
 
68
 
69
  ## Local Development
70
 
@@ -156,34 +201,44 @@ medicinal-cuisine/
156
 
157
  ## MCP Server Usage
158
 
159
- The application exposes 4 tools via the Model Context Protocol:
160
-
161
- ### Available Tools
162
-
163
- 1. **get_spice_information(spice_name: str)**
164
- - Returns comprehensive spice data with nutrients
165
-
166
- 2. **get_nutrient_content(spice_name: str, nutrient_name: str)**
167
- - Returns specific nutrient content
168
-
169
- 3. **compare_spices(spice1: str, spice2: str)**
170
- - Returns side-by-side nutritional comparison
171
-
172
- 4. **list_available_spices()**
173
- - Returns list of all 25 spices
174
-
175
- ### Connecting to MCP Server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
 
177
- ```python
178
- import requests
179
 
180
- # MCP endpoint
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
- │ ├── spice_name_mapping.json # USDA search term mappings
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 # Connection tester
212
- │ ├── create_schema.py # Schema creation
213
- └── load_data.py # Data loader
 
 
214
 
215
- └── tools/ # Core application logic
216
- ├── __init__.py # Module exports
217
- ├── neo4j_queries.py # Database queries
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
- - **Anthropic** for the Model Context Protocol specification
 
 
 
 
 
 
 
 
 
245
 
246
  ---
247
 
248
- Built with ❤️ for medicinal cuisine enthusiasts
 
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
- list_available_spices,
16
- get_nutrient_content,
17
- compare_spices,
18
- find_spice_substitutes,
19
- get_health_benefits_info,
20
- find_spices_for_health_benefit,
21
- get_spice_safety_information,
22
- find_medicinal_substitutes
 
 
 
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
- with gr.Row():
34
- spice_input = gr.Textbox(
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
- gr.Examples(
48
- examples=[["turmeric"], ["ginger root"], ["cinnamon"], ["garlic"]],
49
- inputs=spice_input
50
- )
 
 
51
 
52
- search_btn.click(
53
- fn=get_spice_information,
54
- inputs=spice_input,
55
- outputs=output
56
- )
57
 
 
 
58
 
59
- def create_nutrient_lookup_tab():
60
- """Create the nutrient content lookup tab."""
61
- with gr.Tab("Nutrient Content"):
62
- gr.Markdown("### Find specific nutrient content in a spice")
63
 
64
- with gr.Row():
65
- spice_input = gr.Textbox(
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
- output = gr.Textbox(
78
- label="Nutrient Information",
79
- lines=8,
80
- interactive=False
81
- )
82
 
83
- gr.Examples(
84
- examples=[
85
- ["turmeric", "iron"],
86
- ["ginger root", "vitamin C"],
87
- ["cinnamon", "calcium"]
88
- ],
89
- inputs=[spice_input, nutrient_input]
90
- )
91
 
92
- search_btn.click(
93
- fn=get_nutrient_content,
94
- inputs=[spice_input, nutrient_input],
95
- outputs=output
96
- )
97
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
- def create_comparison_tab():
100
- """Create the spice comparison tab."""
101
- with gr.Tab("Compare Spices"):
102
- gr.Markdown("### Compare nutritional content of two spices")
103
 
104
- with gr.Row():
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
- gr.Examples(
124
- examples=[
125
- ["turmeric", "ginger root"],
126
- ["cinnamon", "nutmeg"],
127
- ["garlic", "ginger root"]
128
- ],
129
- inputs=[spice1_input, spice2_input]
130
- )
131
 
132
- compare_btn.click(
133
- fn=compare_spices,
134
- inputs=[spice1_input, spice2_input],
135
- outputs=output
 
 
 
 
 
 
 
 
 
 
136
  )
 
 
137
 
 
 
138
 
139
- def create_substitute_finder_tab():
140
- """Create the spice substitute finder tab."""
141
- with gr.Tab("Find Substitutes"):
 
142
  gr.Markdown(
143
  """
144
- ### Find substitute spices based on nutritional similarity
 
 
 
 
 
 
145
 
146
- Uses dynamic cosine similarity on nutrient profiles from USDA data.
147
- Perfect for when you're missing a spice in your recipe!
148
  """
149
  )
150
 
 
 
 
 
 
 
 
151
  with gr.Row():
152
- spice_input = gr.Textbox(
153
- label="Spice Name",
154
- placeholder="e.g., turmeric, cinnamon, cumin",
155
- value="cinnamon",
156
- scale=3
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
- find_btn = gr.Button("Find Substitutes", variant="primary")
168
-
169
- output = gr.Textbox(
170
- label="Substitute Suggestions",
171
- lines=20,
172
- interactive=False
173
- )
174
 
175
  gr.Examples(
176
  examples=[
177
- ["cinnamon", 5],
178
- ["turmeric", 5],
179
- ["Black Pepper", 3],
180
- ["basil, dried", 5]
 
181
  ],
182
- inputs=[spice_input, limit_slider]
183
  )
184
 
185
- find_btn.click(
186
- fn=find_spice_substitutes,
187
- inputs=[spice_input, limit_slider],
188
- outputs=output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  )
190
 
191
 
192
- def create_spice_list_tab():
193
- """Create the available spices list tab."""
194
- with gr.Tab("Available Spices"):
195
- gr.Markdown("### View all spices in the database")
196
-
197
- list_btn = gr.Button("Show All Spices", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
 
199
- output = gr.Textbox(
200
- label="Spice List",
201
- lines=15,
202
- interactive=False
203
- )
 
 
 
 
 
 
 
204
 
205
- list_btn.click(
206
- fn=list_available_spices,
207
- outputs=output
208
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
 
 
 
 
 
 
210
 
211
- def create_health_benefits_tab():
212
- """Create the health benefits information tab."""
213
- with gr.Tab("Health Benefits"):
214
- gr.Markdown(
215
- """
216
- ### Learn about health benefits and medicinal properties
217
 
218
- Data sourced from **NCCIH (National Center for Complementary and Integrative Health)**
219
- """
220
- )
 
 
221
 
222
- with gr.Row():
223
- spice_input = gr.Textbox(
224
- label="Spice Name",
225
- placeholder="e.g., Garlic, Cinnamon, Sage",
226
- scale=3
227
  )
228
- search_btn = gr.Button("Get Health Info", variant="primary", scale=1)
229
 
230
- output = gr.Textbox(
231
- label="Health Information",
232
- lines=20,
233
- interactive=False
234
- )
235
 
236
- gr.Examples(
237
- examples=[["Garlic"], ["Cinnamon"], ["Fenugreek"], ["Sage"]],
238
- inputs=spice_input
239
- )
240
 
241
- search_btn.click(
242
- fn=get_health_benefits_info,
243
- inputs=spice_input,
244
- outputs=output
245
- )
 
 
 
 
 
 
 
 
 
 
 
246
 
247
 
248
- def create_health_search_tab():
249
- """Create the health benefit search tab."""
250
- with gr.Tab("Search by Health Benefit"):
251
  gr.Markdown(
252
  """
253
- ### Find spices for specific health conditions
254
-
255
- Search traditional uses and scientific research
256
  """
257
  )
258
 
259
- with gr.Row():
260
- benefit_input = gr.Textbox(
261
- label="Health Benefit or Condition",
262
- placeholder="e.g., diabetes, cholesterol, inflammation",
263
- scale=3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- gr.Examples(
274
- examples=[["diabetes"], ["cholesterol"], ["blood pressure"], ["inflammation"]],
275
- inputs=benefit_input
276
- )
 
 
 
 
 
 
 
 
277
 
278
- search_btn.click(
279
- fn=find_spices_for_health_benefit,
280
- inputs=benefit_input,
281
- outputs=output
282
- )
 
 
 
 
 
 
 
 
 
283
 
 
 
 
 
284
 
285
- def create_safety_info_tab():
286
- """Create the safety information tab."""
287
- with gr.Tab("Safety Information"):
288
- gr.Markdown(
289
- """
290
- ### Check side effects and safety cautions
291
 
292
- Important safety information for medicinal use
293
- """
294
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- with gr.Row():
297
- spice_input = gr.Textbox(
298
- label="Spice Name",
299
- placeholder="e.g., Garlic, Cinnamon",
300
- scale=3
 
 
 
 
 
 
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
- gr.Examples(
311
- examples=[["Garlic"], ["Cinnamon"], ["Fenugreek"]],
312
- inputs=spice_input
313
- )
314
 
315
- gr.Markdown(
316
- """
317
- **IMPORTANT:** This information is for educational purposes only.
318
- Always consult healthcare providers before using herbs medicinally.
319
- """
 
 
320
  )
321
 
322
- search_btn.click(
323
- fn=get_spice_safety_information,
324
- inputs=spice_input,
325
- outputs=output
 
 
326
  )
327
 
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  # Create main application
330
- with gr.Blocks(title="Medicinal Cuisine AI Advisor") as demo:
 
 
 
 
331
 
332
  gr.Markdown(
333
  """
334
- # 🌿 Medicinal Cuisine AI Advisor
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
- create_spice_info_tab()
345
- create_nutrient_lookup_tab()
346
- create_health_benefits_tab()
347
- create_health_search_tab()
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
- **MCP Server:** Available at `/gradio_api/mcp/`
359
 
360
- **25 spices** | **2,098 nutrient relationships** | **NEW: Health Benefits & Safety Info!**
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