Upload 3 files
Browse files- app.py +994 -0
- readme.md +311 -0
- requirements.txt +4 -0
app.py
ADDED
|
@@ -0,0 +1,994 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
import requests
|
| 3 |
+
from openai import OpenAI
|
| 4 |
+
import json
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
# Your MCP server URLs
|
| 9 |
+
WEATHER_MCP_URL = "https://emma-ctrl--scotland-weather-mcp-fastapi-app.modal.run/mcp"
|
| 10 |
+
DAYLIGHT_MCP_URL = "https://emma-ctrl--scotland-daylight-mcp-fastapi-app.modal.run/mcp"
|
| 11 |
+
DRIVING_MCP_URL = "https://emma-ctrl--scottish-driving-mcp-fastapi-app.modal.run/mcp"
|
| 12 |
+
|
| 13 |
+
# Initialize Nebius AI Studio client
|
| 14 |
+
client = OpenAI(
|
| 15 |
+
api_key="NEBIUS_API_KEY",
|
| 16 |
+
base_url="https://api.studio.nebius.ai/v1"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def call_mcp_server(server_url, tool_name, arguments):
|
| 20 |
+
"""Call any MCP server with Scottish location validation"""
|
| 21 |
+
|
| 22 |
+
# Define Scottish clarifications dictionary once for all uses
|
| 23 |
+
scottish_clarifications = {
|
| 24 |
+
# Major cities that exist elsewhere
|
| 25 |
+
"aberdeen": "Aberdeen, Scotland, UK",
|
| 26 |
+
"dundee": "Dundee, Scotland, UK",
|
| 27 |
+
"perth": "Perth, Scotland, UK",
|
| 28 |
+
"hamilton": "Hamilton, Scotland, UK",
|
| 29 |
+
"glasgow": "Glasgow, Scotland, UK",
|
| 30 |
+
"edinburgh": "Edinburgh, Scotland, UK",
|
| 31 |
+
"stirling": "Stirling, Scotland, UK",
|
| 32 |
+
"inverness": "Inverness, Scotland, UK",
|
| 33 |
+
"paisley": "Paisley, Scotland, UK",
|
| 34 |
+
"greenock": "Greenock, Scotland, UK",
|
| 35 |
+
"ayr": "Ayr, Scotland, UK",
|
| 36 |
+
"kilmarnock": "Kilmarnock, Scotland, UK",
|
| 37 |
+
"dumfries": "Dumfries, Scotland, UK",
|
| 38 |
+
"falkirk": "Falkirk, Scotland, UK",
|
| 39 |
+
"livingston": "Livingston, Scotland, UK",
|
| 40 |
+
"kirkcaldy": "Kirkcaldy, Scotland, UK",
|
| 41 |
+
"dunfermline": "Dunfermline, Scotland, UK",
|
| 42 |
+
|
| 43 |
+
# Islands (very commonly confused)
|
| 44 |
+
"mull": "Isle of Mull, Scotland, UK",
|
| 45 |
+
"isle of mull": "Isle of Mull, Scotland, UK",
|
| 46 |
+
"skye": "Isle of Skye, Scotland, UK",
|
| 47 |
+
"isle of skye": "Isle of Skye, Scotland, UK",
|
| 48 |
+
"arran": "Isle of Arran, Scotland, UK",
|
| 49 |
+
"isle of arran": "Isle of Arran, Scotland, UK",
|
| 50 |
+
"harris": "Isle of Harris, Scotland, UK",
|
| 51 |
+
"isle of harris": "Isle of Harris, Scotland, UK",
|
| 52 |
+
"lewis": "Isle of Lewis, Scotland, UK",
|
| 53 |
+
"isle of lewis": "Isle of Lewis, Scotland, UK",
|
| 54 |
+
"orkney": "Orkney Islands, Scotland, UK",
|
| 55 |
+
"orkney islands": "Orkney Islands, Scotland, UK",
|
| 56 |
+
"shetland": "Shetland Islands, Scotland, UK",
|
| 57 |
+
"shetland islands": "Shetland Islands, Scotland, UK",
|
| 58 |
+
"islay": "Isle of Islay, Scotland, UK",
|
| 59 |
+
"isle of islay": "Isle of Islay, Scotland, UK",
|
| 60 |
+
"jura": "Isle of Jura, Scotland, UK",
|
| 61 |
+
"isle of jura": "Isle of Jura, Scotland, UK",
|
| 62 |
+
"bute": "Isle of Bute, Scotland, UK",
|
| 63 |
+
"isle of bute": "Isle of Bute, Scotland, UK",
|
| 64 |
+
"iona": "Isle of Iona, Scotland, UK",
|
| 65 |
+
"isle of iona": "Isle of Iona, Scotland, UK",
|
| 66 |
+
"tiree": "Isle of Tiree, Scotland, UK",
|
| 67 |
+
"isle of tiree": "Isle of Tiree, Scotland, UK",
|
| 68 |
+
"coll": "Isle of Coll, Scotland, UK",
|
| 69 |
+
"isle of coll": "Isle of Coll, Scotland, UK",
|
| 70 |
+
"muck": "Isle of Muck, Scotland, UK",
|
| 71 |
+
"eigg": "Isle of Eigg, Scotland, UK",
|
| 72 |
+
"rum": "Isle of Rum, Scotland, UK",
|
| 73 |
+
"rhum": "Isle of Rum, Scotland, UK",
|
| 74 |
+
"canna": "Isle of Canna, Scotland, UK",
|
| 75 |
+
"staffa": "Isle of Staffa, Scotland, UK",
|
| 76 |
+
"ulva": "Isle of Ulva, Scotland, UK",
|
| 77 |
+
|
| 78 |
+
# Highland towns/villages that could be confused
|
| 79 |
+
"fort william": "Fort William, Scotland, UK",
|
| 80 |
+
"aviemore": "Aviemore, Scotland, UK",
|
| 81 |
+
"oban": "Oban, Scotland, UK",
|
| 82 |
+
"pitlochry": "Pitlochry, Scotland, UK",
|
| 83 |
+
"callander": "Callander, Scotland, UK",
|
| 84 |
+
"balloch": "Balloch, Scotland, UK",
|
| 85 |
+
"helensburgh": "Helensburgh, Scotland, UK",
|
| 86 |
+
"mallaig": "Mallaig, Scotland, UK",
|
| 87 |
+
"kyle": "Kyle of Lochalsh, Scotland, UK",
|
| 88 |
+
"kyle of lochalsh": "Kyle of Lochalsh, Scotland, UK",
|
| 89 |
+
"portree": "Portree, Scotland, UK",
|
| 90 |
+
"tobermory": "Tobermory, Scotland, UK",
|
| 91 |
+
"tarbert": "Tarbert, Scotland, UK",
|
| 92 |
+
"campbeltown": "Campbeltown, Scotland, UK",
|
| 93 |
+
"stranraer": "Stranraer, Scotland, UK",
|
| 94 |
+
"thurso": "Thurso, Scotland, UK",
|
| 95 |
+
"wick": "Wick, Scotland, UK",
|
| 96 |
+
"dornoch": "Dornoch, Scotland, UK",
|
| 97 |
+
"golspie": "Golspie, Scotland, UK",
|
| 98 |
+
"brora": "Brora, Scotland, UK",
|
| 99 |
+
"ullapool": "Ullapool, Scotland, UK",
|
| 100 |
+
"gairloch": "Gairloch, Scotland, UK",
|
| 101 |
+
"kinlochewe": "Kinlochewe, Scotland, UK",
|
| 102 |
+
"torridon": "Torridon, Scotland, UK",
|
| 103 |
+
"applecross": "Applecross, Scotland, UK",
|
| 104 |
+
"plockton": "Plockton, Scotland, UK",
|
| 105 |
+
"lochinver": "Lochinver, Scotland, UK",
|
| 106 |
+
"durness": "Durness, Scotland, UK",
|
| 107 |
+
"tongue": "Tongue, Scotland, UK",
|
| 108 |
+
"bettyhill": "Bettyhill, Scotland, UK",
|
| 109 |
+
"john o groats": "John O'Groats, Scotland, UK",
|
| 110 |
+
"john o'groats": "John O'Groats, Scotland, UK",
|
| 111 |
+
|
| 112 |
+
# Border towns that exist elsewhere
|
| 113 |
+
"kelso": "Kelso, Scotland, UK",
|
| 114 |
+
"jedburgh": "Jedburgh, Scotland, UK",
|
| 115 |
+
"hawick": "Hawick, Scotland, UK",
|
| 116 |
+
"galashiels": "Galashiels, Scotland, UK",
|
| 117 |
+
"selkirk": "Selkirk, Scotland, UK",
|
| 118 |
+
"melrose": "Melrose, Scotland, UK",
|
| 119 |
+
"peebles": "Peebles, Scotland, UK",
|
| 120 |
+
"biggar": "Biggar, Scotland, UK",
|
| 121 |
+
"moffat": "Moffat, Scotland, UK",
|
| 122 |
+
"sanquhar": "Sanquhar, Scotland, UK",
|
| 123 |
+
"langholm": "Langholm, Scotland, UK",
|
| 124 |
+
"annan": "Annan, Scotland, UK",
|
| 125 |
+
"gretna": "Gretna, Scotland, UK",
|
| 126 |
+
"gretna green": "Gretna Green, Scotland, UK",
|
| 127 |
+
"lockerbie": "Lockerbie, Scotland, UK",
|
| 128 |
+
|
| 129 |
+
# Eastern Scotland towns
|
| 130 |
+
"st andrews": "St Andrews, Scotland, UK",
|
| 131 |
+
"saint andrews": "St Andrews, Scotland, UK",
|
| 132 |
+
"cupar": "Cupar, Scotland, UK",
|
| 133 |
+
"anstruther": "Anstruther, Scotland, UK",
|
| 134 |
+
"crail": "Crail, Scotland, UK",
|
| 135 |
+
"elie": "Elie, Scotland, UK",
|
| 136 |
+
"pittenweem": "Pittenweem, Scotland, UK",
|
| 137 |
+
"north berwick": "North Berwick, Scotland, UK",
|
| 138 |
+
"dunbar": "Dunbar, Scotland, UK",
|
| 139 |
+
"haddington": "Haddington, Scotland, UK",
|
| 140 |
+
"linlithgow": "Linlithgow, Scotland, UK",
|
| 141 |
+
"bathgate": "Bathgate, Scotland, UK",
|
| 142 |
+
"armadale": "Armadale, Scotland, UK",
|
| 143 |
+
"stonehaven": "Stonehaven, Scotland, UK",
|
| 144 |
+
"montrose": "Montrose, Scotland, UK",
|
| 145 |
+
"arbroath": "Arbroath, Scotland, UK",
|
| 146 |
+
"carnoustie": "Carnoustie, Scotland, UK",
|
| 147 |
+
"forfar": "Forfar, Scotland, UK",
|
| 148 |
+
"brechin": "Brechin, Scotland, UK",
|
| 149 |
+
"kirriemuir": "Kirriemuir, Scotland, UK",
|
| 150 |
+
"blairgowrie": "Blairgowrie, Scotland, UK",
|
| 151 |
+
"crieff": "Crieff, Scotland, UK",
|
| 152 |
+
"aberfeldy": "Aberfeldy, Scotland, UK",
|
| 153 |
+
"dunkeld": "Dunkeld, Scotland, UK",
|
| 154 |
+
"birnam": "Birnam, Scotland, UK",
|
| 155 |
+
|
| 156 |
+
# Western Scotland and Argyll
|
| 157 |
+
"rothesay": "Rothesay, Scotland, UK",
|
| 158 |
+
"dunoon": "Dunoon, Scotland, UK",
|
| 159 |
+
"inveraray": "Inveraray, Scotland, UK",
|
| 160 |
+
"lochgilphead": "Lochgilphead, Scotland, UK",
|
| 161 |
+
"ardrishaig": "Ardrishaig, Scotland, UK",
|
| 162 |
+
"crinan": "Crinan, Scotland, UK",
|
| 163 |
+
"kilmartin": "Kilmartin, Scotland, UK",
|
| 164 |
+
"dalmally": "Dalmally, Scotland, UK",
|
| 165 |
+
"tyndrum": "Tyndrum, Scotland, UK",
|
| 166 |
+
"crianlarich": "Crianlarich, Scotland, UK",
|
| 167 |
+
"killin": "Killin, Scotland, UK",
|
| 168 |
+
"lochearnhead": "Lochearnhead, Scotland, UK",
|
| 169 |
+
"st fillans": "St Fillans, Scotland, UK",
|
| 170 |
+
"comrie": "Comrie, Scotland, UK",
|
| 171 |
+
"auchterarder": "Auchterarder, Scotland, UK",
|
| 172 |
+
"gleneagles": "Gleneagles, Scotland, UK",
|
| 173 |
+
|
| 174 |
+
# Central Scotland
|
| 175 |
+
"bridge of allan": "Bridge of Allan, Scotland, UK",
|
| 176 |
+
"alloa": "Alloa, Scotland, UK",
|
| 177 |
+
"clackmannan": "Clackmannan, Scotland, UK",
|
| 178 |
+
"tillicoultry": "Tillicoultry, Scotland, UK",
|
| 179 |
+
"dollar": "Dollar, Scotland, UK",
|
| 180 |
+
"alva": "Alva, Scotland, UK",
|
| 181 |
+
"menstrie": "Menstrie, Scotland, UK",
|
| 182 |
+
"denny": "Denny, Scotland, UK",
|
| 183 |
+
"bonnybridge": "Bonnybridge, Scotland, UK",
|
| 184 |
+
"larbert": "Larbert, Scotland, UK",
|
| 185 |
+
"stenhousemuir": "Stenhousemuir, Scotland, UK",
|
| 186 |
+
"grangemouth": "Grangemouth, Scotland, UK",
|
| 187 |
+
"bo'ness": "Bo'ness, Scotland, UK",
|
| 188 |
+
"blackness": "Blackness, Scotland, UK",
|
| 189 |
+
"queensferry": "South Queensferry, Scotland, UK",
|
| 190 |
+
"south queensferry": "South Queensferry, Scotland, UK",
|
| 191 |
+
|
| 192 |
+
# Famous landmarks and areas
|
| 193 |
+
"ben nevis": "Ben Nevis, Scotland, UK",
|
| 194 |
+
"ben lomond": "Ben Lomond, Scotland, UK",
|
| 195 |
+
"ben more": "Ben More, Scotland, UK",
|
| 196 |
+
"cairngorms": "Cairngorms, Scotland, UK",
|
| 197 |
+
"glencoe": "Glencoe, Scotland, UK",
|
| 198 |
+
"glen coe": "Glencoe, Scotland, UK",
|
| 199 |
+
"loch lomond": "Loch Lomond, Scotland, UK",
|
| 200 |
+
"loch ness": "Loch Ness, Scotland, UK",
|
| 201 |
+
"loch katrine": "Loch Katrine, Scotland, UK",
|
| 202 |
+
"loch earn": "Loch Earn, Scotland, UK",
|
| 203 |
+
"loch tay": "Loch Tay, Scotland, UK",
|
| 204 |
+
"loch tummel": "Loch Tummel, Scotland, UK",
|
| 205 |
+
"loch rannoch": "Loch Rannoch, Scotland, UK",
|
| 206 |
+
"loch awe": "Loch Awe, Scotland, UK",
|
| 207 |
+
"loch fyne": "Loch Fyne, Scotland, UK",
|
| 208 |
+
"loch long": "Loch Long, Scotland, UK",
|
| 209 |
+
"loch goil": "Loch Goil, Scotland, UK",
|
| 210 |
+
"the trossachs": "The Trossachs, Scotland, UK",
|
| 211 |
+
"trossachs": "The Trossachs, Scotland, UK",
|
| 212 |
+
"queen elizabeth forest park": "Queen Elizabeth Forest Park, Scotland, UK",
|
| 213 |
+
"cairngorms national park": "Cairngorms National Park, Scotland, UK",
|
| 214 |
+
"loch lomond and trossachs national park": "Loch Lomond and Trossachs National Park, Scotland, UK",
|
| 215 |
+
|
| 216 |
+
# Northern Scotland - abbreviated for space
|
| 217 |
+
"elgin": "Elgin, Scotland, UK",
|
| 218 |
+
"forres": "Forres, Scotland, UK",
|
| 219 |
+
"nairn": "Nairn, Scotland, UK",
|
| 220 |
+
"grantown": "Grantown-on-Spey, Scotland, UK",
|
| 221 |
+
"kingussie": "Kingussie, Scotland, UK",
|
| 222 |
+
"newtonmore": "Newtonmore, Scotland, UK",
|
| 223 |
+
"dalwhinnie": "Dalwhinnie, Scotland, UK",
|
| 224 |
+
"carrbridge": "Carrbridge, Scotland, UK",
|
| 225 |
+
"boat of garten": "Boat of Garten, Scotland, UK",
|
| 226 |
+
"tomintoul": "Tomintoul, Scotland, UK",
|
| 227 |
+
"aberlour": "Aberlour, Scotland, UK",
|
| 228 |
+
"dufftown": "Dufftown, Scotland, UK",
|
| 229 |
+
"keith": "Keith, Scotland, UK",
|
| 230 |
+
"huntly": "Huntly, Scotland, UK",
|
| 231 |
+
"inverurie": "Inverurie, Scotland, UK",
|
| 232 |
+
"banff": "Banff, Scotland, UK",
|
| 233 |
+
"fraserburgh": "Fraserburgh, Scotland, UK",
|
| 234 |
+
"peterhead": "Peterhead, Scotland, UK",
|
| 235 |
+
"lairg": "Lairg, Scotland, UK",
|
| 236 |
+
"dornoch": "Dornoch, Scotland, UK",
|
| 237 |
+
"helmsdale": "Helmsdale, Scotland, UK",
|
| 238 |
+
"bettyhill": "Bettyhill, Scotland, UK",
|
| 239 |
+
"tongue": "Tongue, Scotland, UK",
|
| 240 |
+
|
| 241 |
+
# Common abbreviations and variations
|
| 242 |
+
"fort bill": "Fort William, Scotland, UK",
|
| 243 |
+
"the fort": "Fort William, Scotland, UK",
|
| 244 |
+
"malky": "Mallaig, Scotland, UK",
|
| 245 |
+
"toby": "Tobermory, Scotland, UK",
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
# Handle single location field
|
| 249 |
+
if "location" in arguments:
|
| 250 |
+
location = arguments["location"].lower().strip()
|
| 251 |
+
if location in scottish_clarifications:
|
| 252 |
+
arguments["location"] = scottish_clarifications[location]
|
| 253 |
+
elif not any(keyword in location for keyword in ["scotland", "uk", "united kingdom"]):
|
| 254 |
+
arguments["location"] = f"{arguments['location']}, Scotland, UK"
|
| 255 |
+
|
| 256 |
+
# Handle multiple location fields for driving
|
| 257 |
+
for field in ["from_location", "to_location", "start_location"]:
|
| 258 |
+
if field in arguments:
|
| 259 |
+
location = arguments[field].lower().strip()
|
| 260 |
+
if location in scottish_clarifications:
|
| 261 |
+
arguments[field] = scottish_clarifications[location]
|
| 262 |
+
elif not any(keyword in location for keyword in ["scotland", "uk", "united kingdom"]):
|
| 263 |
+
arguments[field] = f"{arguments[field]}, Scotland, UK"
|
| 264 |
+
|
| 265 |
+
# Handle locations array for road trips
|
| 266 |
+
if "locations" in arguments and isinstance(arguments["locations"], list):
|
| 267 |
+
clarified_locations = []
|
| 268 |
+
for location in arguments["locations"]:
|
| 269 |
+
location_lower = location.lower().strip()
|
| 270 |
+
if location_lower in scottish_clarifications:
|
| 271 |
+
clarified_locations.append(scottish_clarifications[location_lower])
|
| 272 |
+
elif not any(keyword in location_lower for keyword in ["scotland", "uk", "united kingdom"]):
|
| 273 |
+
clarified_locations.append(f"{location}, Scotland, UK")
|
| 274 |
+
else:
|
| 275 |
+
clarified_locations.append(location)
|
| 276 |
+
arguments["locations"] = clarified_locations
|
| 277 |
+
|
| 278 |
+
payload = {
|
| 279 |
+
"method": "tools/call",
|
| 280 |
+
"params": {
|
| 281 |
+
"name": tool_name,
|
| 282 |
+
"arguments": arguments
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
try:
|
| 287 |
+
response = requests.post(server_url, json=payload, timeout=30)
|
| 288 |
+
response.raise_for_status()
|
| 289 |
+
return response.json()
|
| 290 |
+
except Exception as e:
|
| 291 |
+
return {"error": f"Failed to get data from {tool_name}: {str(e)}"}
|
| 292 |
+
|
| 293 |
+
def format_response(response, data_type="data"):
|
| 294 |
+
"""Format the response nicely"""
|
| 295 |
+
if "error" in response:
|
| 296 |
+
return f"❌ {response['error']}"
|
| 297 |
+
|
| 298 |
+
if "content" in response and response["content"]:
|
| 299 |
+
return response["content"][0]["text"]
|
| 300 |
+
|
| 301 |
+
return f"❌ No {data_type} data received"
|
| 302 |
+
|
| 303 |
+
# Replace the extract_locations_from_text function with this enhanced version:
|
| 304 |
+
|
| 305 |
+
def extract_locations_from_text(text):
|
| 306 |
+
"""Extract Scottish location names with better journey order detection"""
|
| 307 |
+
scottish_places = [
|
| 308 |
+
"Edinburgh", "Glasgow", "Aberdeen", "Dundee", "Stirling", "Inverness",
|
| 309 |
+
"Fort William", "Aviemore", "Perth", "Paisley", "Greenock", "Dunfermline",
|
| 310 |
+
"Kirkcaldy", "Ayr", "Kilmarnock", "Dumfries", "Oban", "Pitlochry",
|
| 311 |
+
"Callander", "Balloch", "Helensburgh", "Falkirk", "Livingston",
|
| 312 |
+
"Isle of Skye", "Skye", "Isle of Mull", "Mull", "Isle of Arran", "Arran",
|
| 313 |
+
"Isle of Islay", "Islay", "Isle of Jura", "Jura", "Harris", "Lewis",
|
| 314 |
+
"Orkney", "Shetland", "Orkney Islands", "Shetland Islands",
|
| 315 |
+
"Ben Nevis", "Loch Lomond", "Loch Ness", "Cairngorms", "Glencoe",
|
| 316 |
+
"St Andrews", "Melrose", "Jedburgh", "Galashiels", "Hawick",
|
| 317 |
+
"Mallaig", "Kyle of Lochalsh", "Kyle", "Portree", "Tobermory",
|
| 318 |
+
"Tarbert", "Campbeltown", "Stranraer", "Thurso", "Wick",
|
| 319 |
+
"Ullapool", "Durness", "John O'Groats", "Lochinver", "Tongue",
|
| 320 |
+
"North Berwick", "Dunbar", "Stonehaven", "Montrose", "Arbroath",
|
| 321 |
+
"Dunkeld", "Crieff", "Aberfeldy", "Rothesay", "Dunoon", "Inveraray",
|
| 322 |
+
"Tyndrum", "Crianlarich", "Killin", "The Trossachs", "Trossachs"
|
| 323 |
+
]
|
| 324 |
+
|
| 325 |
+
# ENHANCED: Look for journey order keywords
|
| 326 |
+
text_lower = text.lower()
|
| 327 |
+
|
| 328 |
+
# Check for journey order patterns
|
| 329 |
+
journey_patterns = [
|
| 330 |
+
r'start in (.*?) and then (?:go to |visit )(.*?) and then (.*?)(?:\.|$)',
|
| 331 |
+
r'from (.*?) to (.*?) (?:via |through |and then )(.*?)(?:\.|$)',
|
| 332 |
+
r'(.*?) to (.*?) to (.*?)(?:\.|$)',
|
| 333 |
+
r'(.*?) → (.*?) → (.*?)(?:\.|$)',
|
| 334 |
+
r'(.*?) then (.*?) then (.*?)(?:\.|$)'
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
# Try to extract ordered journey
|
| 338 |
+
for pattern in journey_patterns:
|
| 339 |
+
match = re.search(pattern, text_lower)
|
| 340 |
+
if match:
|
| 341 |
+
ordered_locations = []
|
| 342 |
+
for group in match.groups():
|
| 343 |
+
group_clean = group.strip().replace(' and', '').replace(',', '')
|
| 344 |
+
# Find matching Scottish place
|
| 345 |
+
for place in scottish_places:
|
| 346 |
+
if place.lower() in group_clean:
|
| 347 |
+
if place not in ordered_locations:
|
| 348 |
+
ordered_locations.append(place)
|
| 349 |
+
break
|
| 350 |
+
|
| 351 |
+
if len(ordered_locations) >= 2:
|
| 352 |
+
print(f"DEBUG: Found journey order: {ordered_locations}")
|
| 353 |
+
return ordered_locations
|
| 354 |
+
|
| 355 |
+
# Fallback to original method if no journey pattern found
|
| 356 |
+
found_locations = []
|
| 357 |
+
text_upper = text.title()
|
| 358 |
+
|
| 359 |
+
# Sort by length (descending) to match longer names first
|
| 360 |
+
sorted_places = sorted(scottish_places, key=len, reverse=True)
|
| 361 |
+
|
| 362 |
+
for place in sorted_places:
|
| 363 |
+
if place in text_upper and place not in found_locations:
|
| 364 |
+
found_locations.append(place)
|
| 365 |
+
|
| 366 |
+
# Try to reorder based on position in text for common journey words
|
| 367 |
+
if len(found_locations) >= 2:
|
| 368 |
+
journey_indicators = ['start', 'begin', 'first', 'then', 'next', 'finally', 'end']
|
| 369 |
+
|
| 370 |
+
# Look for starting location
|
| 371 |
+
for indicator in ['start in', 'begin in', 'from']:
|
| 372 |
+
if indicator in text_lower:
|
| 373 |
+
for location in found_locations:
|
| 374 |
+
location_pos = text_lower.find(location.lower())
|
| 375 |
+
indicator_pos = text_lower.find(indicator)
|
| 376 |
+
if location_pos > indicator_pos and location_pos - indicator_pos < 50:
|
| 377 |
+
# Move this location to the front
|
| 378 |
+
if location in found_locations:
|
| 379 |
+
found_locations.remove(location)
|
| 380 |
+
found_locations.insert(0, location)
|
| 381 |
+
break
|
| 382 |
+
|
| 383 |
+
print(f"DEBUG: Extracted locations (fallback): {found_locations}")
|
| 384 |
+
return found_locations
|
| 385 |
+
|
| 386 |
+
def extract_date_from_text(text):
|
| 387 |
+
"""Enhanced date extraction"""
|
| 388 |
+
import re
|
| 389 |
+
from datetime import datetime, timedelta
|
| 390 |
+
|
| 391 |
+
# Look for YYYY-MM-DD format
|
| 392 |
+
date_match = re.search(r'\b(\d{4}-\d{2}-\d{2})\b', text)
|
| 393 |
+
if date_match:
|
| 394 |
+
return date_match.group(1)
|
| 395 |
+
|
| 396 |
+
# Handle relative dates
|
| 397 |
+
text_lower = text.lower()
|
| 398 |
+
if 'tomorrow' in text_lower:
|
| 399 |
+
return (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
| 400 |
+
elif 'yesterday' in text_lower:
|
| 401 |
+
return (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
| 402 |
+
elif 'next week' in text_lower:
|
| 403 |
+
return (datetime.now() + timedelta(weeks=1)).strftime('%Y-%m-%d')
|
| 404 |
+
elif 'this weekend' in text_lower:
|
| 405 |
+
days_until_saturday = (5 - datetime.now().weekday()) % 7
|
| 406 |
+
return (datetime.now() + timedelta(days=days_until_saturday)).strftime('%Y-%m-%d')
|
| 407 |
+
|
| 408 |
+
return None # Default to today
|
| 409 |
+
|
| 410 |
+
def should_get_daylight_data(message):
|
| 411 |
+
"""Determine if the user is specifically asking about daylight/sunrise/sunset times"""
|
| 412 |
+
specific_daylight_keywords = [
|
| 413 |
+
'sunrise', 'sunset', 'golden hour', 'photography', 'dawn', 'dusk',
|
| 414 |
+
'sun up', 'sun down', 'light for photos', 'early morning light',
|
| 415 |
+
'evening light', 'when does it get dark', 'when does sun rise',
|
| 416 |
+
'best time for photos', 'photo timing', 'blue hour', 'magic hour'
|
| 417 |
+
]
|
| 418 |
+
|
| 419 |
+
message_lower = message.lower()
|
| 420 |
+
return any(keyword in message_lower for keyword in specific_daylight_keywords)
|
| 421 |
+
|
| 422 |
+
def should_get_weather_data(message):
|
| 423 |
+
"""Determine if the user is asking about weather"""
|
| 424 |
+
weather_keywords = [
|
| 425 |
+
'weather', 'forecast', 'rain', 'sunny', 'temperature', 'wind',
|
| 426 |
+
'cloudy', 'snow', 'storm', 'humid', 'cold', 'warm', 'hot',
|
| 427 |
+
'precipitation', 'degrees', 'celsius', 'fahrenheit', 'conditions',
|
| 428 |
+
'next week', 'this week', 'tomorrow', 'weekend', '7 day', 'weekly',
|
| 429 |
+
'camping', 'hiking', 'outdoor', 'adventure'
|
| 430 |
+
]
|
| 431 |
+
|
| 432 |
+
message_lower = message.lower()
|
| 433 |
+
return any(keyword in message_lower for keyword in weather_keywords)
|
| 434 |
+
|
| 435 |
+
def should_get_driving_data(message):
|
| 436 |
+
"""Determine if user is asking about distances/routes"""
|
| 437 |
+
driving_keywords = [
|
| 438 |
+
'drive', 'driving', 'distance', 'how far', 'route', 'directions',
|
| 439 |
+
'road trip', 'travel time', 'journey', 'miles', 'km', 'kilometers',
|
| 440 |
+
'how long to drive', 'car journey', 'road', 'travel to', 'get to',
|
| 441 |
+
'from', 'to', 'via', 'through', 'stop at', 'visit', 'tour'
|
| 442 |
+
]
|
| 443 |
+
|
| 444 |
+
message_lower = message.lower()
|
| 445 |
+
return any(keyword in message_lower for keyword in driving_keywords)
|
| 446 |
+
|
| 447 |
+
def decode_polyline(polyline_str):
|
| 448 |
+
"""Decode Google polyline string into lat/lon coordinates"""
|
| 449 |
+
try:
|
| 450 |
+
index = 0
|
| 451 |
+
lat = 0
|
| 452 |
+
lng = 0
|
| 453 |
+
coordinates = []
|
| 454 |
+
|
| 455 |
+
while index < len(polyline_str):
|
| 456 |
+
# Decode latitude
|
| 457 |
+
shift = 0
|
| 458 |
+
result = 0
|
| 459 |
+
while True:
|
| 460 |
+
byte = ord(polyline_str[index]) - 63
|
| 461 |
+
index += 1
|
| 462 |
+
result |= (byte & 0x1f) << shift
|
| 463 |
+
shift += 5
|
| 464 |
+
if byte < 0x20:
|
| 465 |
+
break
|
| 466 |
+
|
| 467 |
+
dlat = ~(result >> 1) if result & 1 else result >> 1
|
| 468 |
+
lat += dlat
|
| 469 |
+
|
| 470 |
+
# Decode longitude
|
| 471 |
+
shift = 0
|
| 472 |
+
result = 0
|
| 473 |
+
while True:
|
| 474 |
+
byte = ord(polyline_str[index]) - 63
|
| 475 |
+
index += 1
|
| 476 |
+
result |= (byte & 0x1f) << shift
|
| 477 |
+
shift += 5
|
| 478 |
+
if byte < 0x20:
|
| 479 |
+
break
|
| 480 |
+
|
| 481 |
+
dlng = ~(result >> 1) if result & 1 else result >> 1
|
| 482 |
+
lng += dlng
|
| 483 |
+
|
| 484 |
+
coordinates.append([lat / 1e5, lng / 1e5])
|
| 485 |
+
|
| 486 |
+
return coordinates
|
| 487 |
+
except Exception as e:
|
| 488 |
+
print(f"DEBUG: Polyline decode error: {e}")
|
| 489 |
+
return []
|
| 490 |
+
|
| 491 |
+
def extract_route_geometry_from_mcp(mcp_response, locations):
|
| 492 |
+
"""Extract real driving route coordinates from OpenRouteService API"""
|
| 493 |
+
try:
|
| 494 |
+
if len(locations) >= 2:
|
| 495 |
+
start_lat = float(locations[0][1])
|
| 496 |
+
start_lon = float(locations[0][2])
|
| 497 |
+
end_lat = float(locations[1][1])
|
| 498 |
+
end_lon = float(locations[1][2])
|
| 499 |
+
|
| 500 |
+
# Use your actual API key here
|
| 501 |
+
api_key = "OPEN_ROUTE_SERVICE_API_KEY" # ← Your real key
|
| 502 |
+
|
| 503 |
+
url = "https://api.openrouteservice.org/v2/directions/driving-car"
|
| 504 |
+
headers = {
|
| 505 |
+
'Accept': 'application/json',
|
| 506 |
+
'Authorization': api_key
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
body = {
|
| 510 |
+
"coordinates": [[start_lon, start_lat], [end_lon, end_lat]],
|
| 511 |
+
"format": "geojson",
|
| 512 |
+
"radiuses": [5000, 5000]
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
response = requests.post(url, headers=headers, json=body, timeout=10)
|
| 516 |
+
print(f"DEBUG: Got response status: {response.status_code}")
|
| 517 |
+
|
| 518 |
+
if response.status_code == 200:
|
| 519 |
+
data = response.json()
|
| 520 |
+
|
| 521 |
+
if 'routes' in data and len(data['routes']) > 0:
|
| 522 |
+
route = data['routes'][0]
|
| 523 |
+
|
| 524 |
+
if isinstance(route, dict) and 'geometry' in route:
|
| 525 |
+
geometry = route['geometry']
|
| 526 |
+
|
| 527 |
+
if isinstance(geometry, str):
|
| 528 |
+
# It's an encoded polyline - decode it!
|
| 529 |
+
print(f"DEBUG: Decoding polyline of length: {len(geometry)}")
|
| 530 |
+
decoded_coords = decode_polyline(geometry)
|
| 531 |
+
print(f"DEBUG: SUCCESS! Decoded {len(decoded_coords)} route points")
|
| 532 |
+
return decoded_coords
|
| 533 |
+
else:
|
| 534 |
+
print(f"DEBUG: Geometry is not a string: {type(geometry)}")
|
| 535 |
+
else:
|
| 536 |
+
print(f"DEBUG: Route structure issue: {route}")
|
| 537 |
+
else:
|
| 538 |
+
print(f"DEBUG: No routes in response")
|
| 539 |
+
|
| 540 |
+
except Exception as e:
|
| 541 |
+
print(f"DEBUG: Exception: {e}")
|
| 542 |
+
import traceback
|
| 543 |
+
traceback.print_exc()
|
| 544 |
+
|
| 545 |
+
# Fallback to straight line
|
| 546 |
+
return [[locations[0][1], locations[0][2]], [locations[1][1], locations[1][2]]]
|
| 547 |
+
|
| 548 |
+
def get_scottish_coordinates():
|
| 549 |
+
"""Return coordinates for Scottish locations"""
|
| 550 |
+
return {
|
| 551 |
+
"edinburgh": [55.9533, -3.1883],
|
| 552 |
+
"glasgow": [55.8642, -4.2518],
|
| 553 |
+
"aberdeen": [57.1497, -2.0943],
|
| 554 |
+
"dundee": [56.4620, -2.9707],
|
| 555 |
+
"stirling": [56.1165, -3.9369],
|
| 556 |
+
"inverness": [57.4778, -4.2247],
|
| 557 |
+
"fort william": [56.8198, -5.1052],
|
| 558 |
+
"aviemore": [57.1952, -3.8263],
|
| 559 |
+
"perth": [56.3956, -3.4309],
|
| 560 |
+
"oban": [56.4154, -5.4713],
|
| 561 |
+
"pitlochry": [56.7028, -3.7340],
|
| 562 |
+
"isle of skye": [57.2740, -6.2149],
|
| 563 |
+
"skye": [57.2740, -6.2149],
|
| 564 |
+
"isle of mull": [56.4504, -5.8037],
|
| 565 |
+
"mull": [56.4504, -5.8037],
|
| 566 |
+
"isle of arran": [55.5836, -5.2489],
|
| 567 |
+
"arran": [55.5836, -5.2489],
|
| 568 |
+
"mallaig": [57.0067, -5.8283],
|
| 569 |
+
"portree": [57.4123, -6.1956],
|
| 570 |
+
"tobermory": [56.6229, -6.0679],
|
| 571 |
+
"glencoe": [56.6756, -5.1019],
|
| 572 |
+
"glen coe": [56.6756, -5.1019],
|
| 573 |
+
"ben nevis": [56.7969, -5.0037],
|
| 574 |
+
"st andrews": [56.3398, -2.7967],
|
| 575 |
+
"cairngorms": [57.0833, -3.6667],
|
| 576 |
+
"loch lomond": [56.1000, -4.6000],
|
| 577 |
+
"loch ness": [57.3229, -4.4244],
|
| 578 |
+
"kyle of lochalsh": [57.2785, -5.7127],
|
| 579 |
+
"kyle": [57.2785, -5.7127],
|
| 580 |
+
"thurso": [58.5944, -3.5267],
|
| 581 |
+
"wick": [58.4394, -3.0956],
|
| 582 |
+
"ullapool": [57.8952, -5.1587],
|
| 583 |
+
"durness": [58.5667, -4.7167],
|
| 584 |
+
"cairngorms": [57.0833, -3.6667],
|
| 585 |
+
"cairngorms national park": [57.1952, -3.8263]
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
def create_map_html(locations=[], routes=[], center_lat=56.8, center_lon=-4.2, zoom=6):
|
| 589 |
+
"""Generate interactive map using Folium with real driving routes"""
|
| 590 |
+
try:
|
| 591 |
+
import folium
|
| 592 |
+
|
| 593 |
+
if locations:
|
| 594 |
+
center_lat = locations[0][1]
|
| 595 |
+
center_lon = locations[0][2]
|
| 596 |
+
zoom = 8
|
| 597 |
+
|
| 598 |
+
m = folium.Map(
|
| 599 |
+
location=[center_lat, center_lon],
|
| 600 |
+
zoom_start=zoom,
|
| 601 |
+
tiles='OpenStreetMap'
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
# Add markers
|
| 605 |
+
colors = ['red', 'blue', 'green', 'purple', 'orange']
|
| 606 |
+
for i, (name, lat, lon) in enumerate(locations):
|
| 607 |
+
folium.Marker(
|
| 608 |
+
[lat, lon],
|
| 609 |
+
popup=f"<b>{name}</b>",
|
| 610 |
+
tooltip=name,
|
| 611 |
+
icon=folium.Icon(color=colors[i % len(colors)], icon='info-sign')
|
| 612 |
+
).add_to(m)
|
| 613 |
+
|
| 614 |
+
# Add actual driving route if available
|
| 615 |
+
if routes and len(routes) > 1:
|
| 616 |
+
folium.PolyLine(
|
| 617 |
+
routes,
|
| 618 |
+
color='red',
|
| 619 |
+
weight=4,
|
| 620 |
+
opacity=0.8,
|
| 621 |
+
popup="🚗 Driving Route"
|
| 622 |
+
).add_to(m)
|
| 623 |
+
|
| 624 |
+
# Fit bounds to show entire route
|
| 625 |
+
m.fit_bounds(routes, padding=(20, 20))
|
| 626 |
+
|
| 627 |
+
return m._repr_html_()
|
| 628 |
+
|
| 629 |
+
except ImportError:
|
| 630 |
+
return "<div>Install folium for interactive maps</div>"
|
| 631 |
+
|
| 632 |
+
def extract_locations_and_routes_from_conversation(message, locations_mentioned):
|
| 633 |
+
"""Extract locations and potential routes from current message and conversation context"""
|
| 634 |
+
coords_db = get_scottish_coordinates()
|
| 635 |
+
|
| 636 |
+
# Get coordinates for mentioned locations
|
| 637 |
+
location_coords = []
|
| 638 |
+
for location in locations_mentioned:
|
| 639 |
+
location_key = location.lower().strip()
|
| 640 |
+
if location_key in coords_db:
|
| 641 |
+
lat, lon = coords_db[location_key]
|
| 642 |
+
location_coords.append((location, lat, lon))
|
| 643 |
+
else:
|
| 644 |
+
print(f"DEBUG: Location '{location}' not found in coordinates database")
|
| 645 |
+
|
| 646 |
+
# Detect route patterns
|
| 647 |
+
routes = []
|
| 648 |
+
message_lower = message.lower()
|
| 649 |
+
|
| 650 |
+
route_patterns = ["from", "to", "drive", "route", "road trip", "journey", "travel", "start in", "then go", "then"]
|
| 651 |
+
|
| 652 |
+
if any(pattern in message_lower for pattern in route_patterns) and len(location_coords) >= 2:
|
| 653 |
+
routes.append(location_coords)
|
| 654 |
+
|
| 655 |
+
return location_coords, routes
|
| 656 |
+
|
| 657 |
+
# Replace your intelligent_weather_chat function with this stabilized version
|
| 658 |
+
|
| 659 |
+
def intelligent_weather_chat(message, history):
|
| 660 |
+
"""Comprehensive chat with weather + daylight + driving data - STABILIZED VERSION"""
|
| 661 |
+
try:
|
| 662 |
+
# MAKE SURE THESE VARIABLES ARE INITIALIZED AT THE TOP
|
| 663 |
+
locations = extract_locations_from_text(message)
|
| 664 |
+
date = extract_date_from_text(message)
|
| 665 |
+
route_geometry = []
|
| 666 |
+
location_coords = [] # ← ADD THIS LINE
|
| 667 |
+
|
| 668 |
+
print(f"DEBUG: Extracted locations: {locations}")
|
| 669 |
+
|
| 670 |
+
# Determine what data to fetch based on the user's question
|
| 671 |
+
get_weather = should_get_weather_data(message)
|
| 672 |
+
get_daylight = should_get_daylight_data(message)
|
| 673 |
+
get_driving = should_get_driving_data(message)
|
| 674 |
+
|
| 675 |
+
# Smart defaults based on number of locations
|
| 676 |
+
if locations and not get_weather and not get_daylight and not get_driving:
|
| 677 |
+
if len(locations) >= 2:
|
| 678 |
+
get_weather = True
|
| 679 |
+
get_driving = True
|
| 680 |
+
else:
|
| 681 |
+
get_weather = True
|
| 682 |
+
|
| 683 |
+
weather_data = {}
|
| 684 |
+
daylight_data = {}
|
| 685 |
+
driving_data = {}
|
| 686 |
+
|
| 687 |
+
# Fetch weather data for up to 2 locations (reduced from 3)
|
| 688 |
+
if get_weather:
|
| 689 |
+
for location in locations[:2]:
|
| 690 |
+
current_weather = call_mcp_server(WEATHER_MCP_URL, "get_weather", {"location": location})
|
| 691 |
+
if "content" in current_weather:
|
| 692 |
+
weather_data[location] = format_response(current_weather, "weather")
|
| 693 |
+
|
| 694 |
+
# Fetch daylight data for up to 2 locations
|
| 695 |
+
if get_daylight:
|
| 696 |
+
for location in locations[:2]:
|
| 697 |
+
daylight_args = {"location": location}
|
| 698 |
+
if date:
|
| 699 |
+
daylight_args["date"] = date
|
| 700 |
+
|
| 701 |
+
daylight_times = call_mcp_server(DAYLIGHT_MCP_URL, "get_daylight_times", daylight_args)
|
| 702 |
+
if "content" in daylight_times:
|
| 703 |
+
daylight_data[location] = format_response(daylight_times, "daylight")
|
| 704 |
+
|
| 705 |
+
# Fetch driving data for 2+ locations
|
| 706 |
+
if get_driving and len(locations) >= 2:
|
| 707 |
+
try:
|
| 708 |
+
# GET LOCATION COORDINATES FIRST
|
| 709 |
+
location_coords, _ = extract_locations_and_routes_from_conversation(message, locations)
|
| 710 |
+
print(f"DEBUG: location_coords for route: {location_coords}")
|
| 711 |
+
|
| 712 |
+
if len(locations) == 2:
|
| 713 |
+
print(f"DEBUG: About to call driving MCP for {locations}")
|
| 714 |
+
driving_result = call_mcp_server(
|
| 715 |
+
DRIVING_MCP_URL,
|
| 716 |
+
"get_driving_distance",
|
| 717 |
+
{
|
| 718 |
+
"from_location": locations[0],
|
| 719 |
+
"to_location": locations[1]
|
| 720 |
+
}
|
| 721 |
+
)
|
| 722 |
+
if "content" in driving_result:
|
| 723 |
+
driving_data[f"{locations[0]} → {locations[1]}"] = format_response(driving_result, "driving")
|
| 724 |
+
# Extract route geometry
|
| 725 |
+
route_geometry = extract_route_geometry_from_mcp(driving_result, location_coords)
|
| 726 |
+
print(f"DEBUG: Final route_geometry: {len(route_geometry)} points")
|
| 727 |
+
else:
|
| 728 |
+
print(f"DEBUG: No content in driving result: {driving_result}")
|
| 729 |
+
|
| 730 |
+
elif len(locations) >= 3:
|
| 731 |
+
# ENHANCED: Get wiggly routes for 3+ locations by creating segments
|
| 732 |
+
print(f"DEBUG: Multi-location route with {len(locations)} stops")
|
| 733 |
+
all_route_points = []
|
| 734 |
+
driving_segments = []
|
| 735 |
+
|
| 736 |
+
# Create route segments between consecutive locations
|
| 737 |
+
for i in range(len(locations) - 1):
|
| 738 |
+
from_loc = locations[i]
|
| 739 |
+
to_loc = locations[i + 1]
|
| 740 |
+
|
| 741 |
+
print(f"DEBUG: Getting segment {from_loc} → {to_loc}")
|
| 742 |
+
|
| 743 |
+
driving_result = call_mcp_server(
|
| 744 |
+
DRIVING_MCP_URL,
|
| 745 |
+
"get_driving_distance",
|
| 746 |
+
{
|
| 747 |
+
"from_location": from_loc,
|
| 748 |
+
"to_location": to_loc
|
| 749 |
+
}
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
if "content" in driving_result:
|
| 753 |
+
segment_info = format_response(driving_result, "driving")
|
| 754 |
+
driving_segments.append(f"**{from_loc} → {to_loc}:** {segment_info}")
|
| 755 |
+
|
| 756 |
+
# Get wiggly route for this segment
|
| 757 |
+
if i < len(location_coords) - 1:
|
| 758 |
+
segment_coords = [location_coords[i], location_coords[i + 1]]
|
| 759 |
+
segment_route = extract_route_geometry_from_mcp(driving_result, segment_coords)
|
| 760 |
+
|
| 761 |
+
if len(segment_route) > 2: # We got actual route data
|
| 762 |
+
print(f"DEBUG: Segment {i+1} has {len(segment_route)} route points")
|
| 763 |
+
all_route_points.extend(segment_route)
|
| 764 |
+
else:
|
| 765 |
+
print(f"DEBUG: Segment {i+1} using straight line fallback")
|
| 766 |
+
# Add straight line for this segment
|
| 767 |
+
all_route_points.extend([
|
| 768 |
+
[location_coords[i][1], location_coords[i][2]],
|
| 769 |
+
[location_coords[i+1][1], location_coords[i+1][2]]
|
| 770 |
+
])
|
| 771 |
+
|
| 772 |
+
# Combine all segments into one route
|
| 773 |
+
if all_route_points:
|
| 774 |
+
route_geometry = all_route_points
|
| 775 |
+
print(f"DEBUG: Combined route has {len(route_geometry)} total points")
|
| 776 |
+
|
| 777 |
+
# Combine driving info
|
| 778 |
+
if driving_segments:
|
| 779 |
+
driving_data["Multi-Stop Route"] = "\n\n".join(driving_segments)
|
| 780 |
+
else:
|
| 781 |
+
# Fallback to road trip planner
|
| 782 |
+
driving_result = call_mcp_server(
|
| 783 |
+
DRIVING_MCP_URL,
|
| 784 |
+
"plan_road_trip",
|
| 785 |
+
{"locations": locations[:4]}
|
| 786 |
+
)
|
| 787 |
+
if "content" in driving_result:
|
| 788 |
+
driving_data["Road Trip Plan"] = format_response(driving_result, "driving")
|
| 789 |
+
# Use straight lines as last resort
|
| 790 |
+
if location_coords:
|
| 791 |
+
route_geometry = [[lat, lon] for _, lat, lon in location_coords]
|
| 792 |
+
except Exception as e:
|
| 793 |
+
print(f"Driving data error: {e}")
|
| 794 |
+
route_geometry = []
|
| 795 |
+
|
| 796 |
+
# SIMPLIFIED SYSTEM PROMPT - much shorter to prevent token issues
|
| 797 |
+
system_prompt = """You are a helpful Scottish adventure assistant.
|
| 798 |
+
|
| 799 |
+
Be conversational, practical, and enthusiastic about Scottish adventures.
|
| 800 |
+
|
| 801 |
+
If you have weather data, focus on that first - interpret conditions for their activity and give gear advice.
|
| 802 |
+
If you have daylight data, mention it for photography or camping timing.
|
| 803 |
+
If you have driving data, include route advice and Highland driving tips.
|
| 804 |
+
|
| 805 |
+
Keep responses natural and under 200 words. Focus on practical advice for their Scottish adventure."""
|
| 806 |
+
|
| 807 |
+
# Build MUCH SHORTER context
|
| 808 |
+
context_parts = []
|
| 809 |
+
if weather_data:
|
| 810 |
+
context_parts.append("WEATHER:")
|
| 811 |
+
for location, weather in weather_data.items():
|
| 812 |
+
# Truncate weather data to prevent token overflow
|
| 813 |
+
short_weather = weather[:300] + "..." if len(weather) > 300 else weather
|
| 814 |
+
context_parts.append(f"• {location}: {short_weather}")
|
| 815 |
+
|
| 816 |
+
if daylight_data:
|
| 817 |
+
context_parts.append("\nDAYLIGHT:")
|
| 818 |
+
for location, daylight in daylight_data.items():
|
| 819 |
+
short_daylight = daylight[:200] + "..." if len(daylight) > 200 else daylight
|
| 820 |
+
context_parts.append(f"• {location}: {short_daylight}")
|
| 821 |
+
|
| 822 |
+
if driving_data:
|
| 823 |
+
context_parts.append("\nDRIVING:")
|
| 824 |
+
for route, info in driving_data.items():
|
| 825 |
+
short_driving = info[:300] + "..." if len(info) > 300 else info
|
| 826 |
+
context_parts.append(f"• {route}: {short_driving}")
|
| 827 |
+
|
| 828 |
+
if context_parts:
|
| 829 |
+
comprehensive_context = "\n".join(context_parts)
|
| 830 |
+
user_message = f"""User: "{message}"
|
| 831 |
+
|
| 832 |
+
{comprehensive_context}
|
| 833 |
+
|
| 834 |
+
Give a helpful, natural response under 200 words focusing on their Scottish adventure needs."""
|
| 835 |
+
else:
|
| 836 |
+
user_message = message
|
| 837 |
+
|
| 838 |
+
print(f"DEBUG: Context length: {len(user_message)} chars")
|
| 839 |
+
|
| 840 |
+
# SEVERELY LIMIT conversation history to prevent token overflow
|
| 841 |
+
recent_history = history[-2:] if len(history) > 2 else history
|
| 842 |
+
|
| 843 |
+
messages = [{"role": "system", "content": system_prompt}]
|
| 844 |
+
|
| 845 |
+
for user_msg, bot_msg in recent_history:
|
| 846 |
+
# Truncate long messages
|
| 847 |
+
truncated_user = user_msg[:100] + "..." if len(user_msg) > 100 else user_msg
|
| 848 |
+
truncated_bot = bot_msg[:200] + "..." if len(bot_msg) > 200 else bot_msg
|
| 849 |
+
messages.append({"role": "user", "content": truncated_user})
|
| 850 |
+
messages.append({"role": "assistant", "content": truncated_bot})
|
| 851 |
+
|
| 852 |
+
messages.append({"role": "user", "content": user_message})
|
| 853 |
+
|
| 854 |
+
# STABILIZED AI PARAMETERS
|
| 855 |
+
response = client.chat.completions.create(
|
| 856 |
+
model="deepseek-ai/DeepSeek-V3",
|
| 857 |
+
messages=messages,
|
| 858 |
+
max_tokens=300, # Severely reduced
|
| 859 |
+
temperature=0.1, # Much more conservative
|
| 860 |
+
top_p=0.9, # Add top_p for stability
|
| 861 |
+
frequency_penalty=0.3, # Prevent repetition
|
| 862 |
+
presence_penalty=0.1
|
| 863 |
+
)
|
| 864 |
+
|
| 865 |
+
bot_response = response.choices[0].message.content
|
| 866 |
+
|
| 867 |
+
# RESPONSE VALIDATION - catch broken responses
|
| 868 |
+
if (
|
| 869 |
+
"correct answer" in bot_response.lower() or
|
| 870 |
+
len(bot_response.split()) < 5 or
|
| 871 |
+
len(set(bot_response.split()[-10:])) < 3 or # Detect repetition
|
| 872 |
+
bot_response.count("16°C") > 5 # Detect specific repetition
|
| 873 |
+
):
|
| 874 |
+
print("DEBUG: Detected broken AI response, using fallback")
|
| 875 |
+
|
| 876 |
+
# FALLBACK: Simple data summary
|
| 877 |
+
fallback_parts = []
|
| 878 |
+
if weather_data:
|
| 879 |
+
for location, weather in weather_data.items():
|
| 880 |
+
# Extract key info manually
|
| 881 |
+
lines = weather.split('\n')
|
| 882 |
+
temp_line = next((line for line in lines if '°C' in line), "")
|
| 883 |
+
fallback_parts.append(f"**{location}:** {temp_line}")
|
| 884 |
+
|
| 885 |
+
if driving_data:
|
| 886 |
+
for route, info in driving_data.items():
|
| 887 |
+
lines = info.split('\n')
|
| 888 |
+
distance_line = next((line for line in lines if 'km' in line or 'Distance' in line), "")
|
| 889 |
+
time_line = next((line for line in lines if 'Time' in line or 'hour' in line), "")
|
| 890 |
+
fallback_parts.append(f"**{route}:** {distance_line} {time_line}")
|
| 891 |
+
|
| 892 |
+
if fallback_parts:
|
| 893 |
+
bot_response = "Here's your Scottish adventure info:\n\n" + "\n".join(fallback_parts)
|
| 894 |
+
bot_response += "\n\nFor detailed planning, try asking about specific aspects like weather or routes separately!"
|
| 895 |
+
else:
|
| 896 |
+
bot_response = "I can help you plan your Scottish adventure! Try asking about specific locations like 'weather in Edinburgh' or 'drive from Glasgow to Skye'."
|
| 897 |
+
|
| 898 |
+
print(f"DEBUG: Final response length: {len(bot_response)} chars")
|
| 899 |
+
|
| 900 |
+
# ========== MAP UPDATE LOGIC ==========
|
| 901 |
+
# Extract locations and routes for map
|
| 902 |
+
if not location_coords and locations:
|
| 903 |
+
location_coords, routes = extract_locations_and_routes_from_conversation(message, locations)
|
| 904 |
+
|
| 905 |
+
# Create updated map HTML
|
| 906 |
+
if location_coords:
|
| 907 |
+
updated_map_html = create_map_html(
|
| 908 |
+
locations=location_coords,
|
| 909 |
+
routes=route_geometry,
|
| 910 |
+
center_lat=location_coords[0][1] if location_coords else 56.8,
|
| 911 |
+
center_lon=location_coords[0][2] if location_coords else -4.2,
|
| 912 |
+
zoom=8 if len(location_coords) <= 2 else 7
|
| 913 |
+
)
|
| 914 |
+
else:
|
| 915 |
+
# Default Scotland overview map
|
| 916 |
+
updated_map_html = create_map_html(
|
| 917 |
+
locations=[],
|
| 918 |
+
routes=[],
|
| 919 |
+
center_lat=56.8,
|
| 920 |
+
center_lon=-4.2,
|
| 921 |
+
zoom=6
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
print(f"DEBUG: Map updated with {len(location_coords)} locations")
|
| 925 |
+
|
| 926 |
+
except Exception as e:
|
| 927 |
+
print(f"ERROR: {e}")
|
| 928 |
+
bot_response = "I'm having technical difficulties. Please try a simpler question like 'weather in Edinburgh' or let me know specific Scottish locations you're interested in!"
|
| 929 |
+
# Default map for error case
|
| 930 |
+
updated_map_html = create_map_html()
|
| 931 |
+
location_coords = [] # ← ADD THIS LINE
|
| 932 |
+
|
| 933 |
+
history.append([message, bot_response])
|
| 934 |
+
return history, "", updated_map_html
|
| 935 |
+
|
| 936 |
+
# Create the ultimate Scottish adventure planning interface
|
| 937 |
+
with gr.Blocks(title="🏴 Scotland Adventure Planner", theme=gr.themes.Soft()) as app:
|
| 938 |
+
gr.Markdown("# 🏴 Scotland Adventure Planner")
|
| 939 |
+
gr.Markdown("**Your complete Scottish adventure assistant!** Get weather, driving distances, recommendations.")
|
| 940 |
+
|
| 941 |
+
with gr.Row():
|
| 942 |
+
with gr.Column(scale=3):
|
| 943 |
+
chatbot = gr.Chatbot(height=400, type='tuples')
|
| 944 |
+
msg = gr.Textbox(
|
| 945 |
+
label="Plan your Scottish adventure!",
|
| 946 |
+
placeholder="Try: 'Road trip from Edinburgh to Skye' or 'Photography spots near Fort William'",
|
| 947 |
+
lines=2
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
with gr.Column(scale=2):
|
| 951 |
+
# Simplified map display
|
| 952 |
+
map_display = gr.HTML(
|
| 953 |
+
value="""
|
| 954 |
+
<div style="width: 100%; height: 400px; border: 2px solid #ddd; background: #f5f5f5; display: flex; align-items: center; justify-content: center;">
|
| 955 |
+
<div style="text-align: center;">
|
| 956 |
+
<h3>🗺️ Interactive Map</h3>
|
| 957 |
+
<p>Map will show locations from your conversation</p>
|
| 958 |
+
</div>
|
| 959 |
+
</div>
|
| 960 |
+
""",
|
| 961 |
+
label="📍 Interactive Map"
|
| 962 |
+
)
|
| 963 |
+
|
| 964 |
+
# Compact example buttons
|
| 965 |
+
gr.Markdown("### 🎯 Quick Examples")
|
| 966 |
+
with gr.Row():
|
| 967 |
+
example1 = gr.Button("☀️ Weather Edinburgh", size="sm")
|
| 968 |
+
example2 = gr.Button("🚗 Drive Edinburgh→Skye", size="sm")
|
| 969 |
+
example3 = gr.Button("📸 Golden hour Glencoe", size="sm")
|
| 970 |
+
example4 = gr.Button("🏕️ Camping Cairngorms", size="sm")
|
| 971 |
+
|
| 972 |
+
with gr.Row():
|
| 973 |
+
example5 = gr.Button("🗺️ Road trip Glasgow→Skye", size="sm")
|
| 974 |
+
example6 = gr.Button("🌄 Photography Isle of Mull", size="sm")
|
| 975 |
+
example7 = gr.Button("⛅ Weather + route Perth→Fort William", size="sm")
|
| 976 |
+
example8 = gr.Button("🥾 Hiking weather Ben Nevis", size="sm")
|
| 977 |
+
|
| 978 |
+
# IMPORTANT: Update the submit function to also update the map
|
| 979 |
+
msg.submit(intelligent_weather_chat, [msg, chatbot], [chatbot, msg, map_display])
|
| 980 |
+
|
| 981 |
+
# Button actions
|
| 982 |
+
example1.click(lambda: "What's the weather like in Edinburgh?", outputs=msg)
|
| 983 |
+
example2.click(lambda: "How long to drive from Edinburgh to Skye?", outputs=msg)
|
| 984 |
+
example3.click(lambda: "Golden hour photography times in Glencoe?", outputs=msg)
|
| 985 |
+
example4.click(lambda: "Good camping weather in Cairngorms?", outputs=msg)
|
| 986 |
+
example5.click(lambda: "Road trip from Glasgow to Skye with stops", outputs=msg)
|
| 987 |
+
example6.click(lambda: "Best photography spots on Isle of Mull", outputs=msg)
|
| 988 |
+
example7.click(lambda: "Weather and driving route from Perth to Fort William", outputs=msg)
|
| 989 |
+
example8.click(lambda: "Hiking weather around Ben Nevis area", outputs=msg)
|
| 990 |
+
|
| 991 |
+
gr.Markdown("*Powered by Open-Meteo weather data, Sunrise-Sunset API, OpenRouteService routing, custom MCP servers, and Nebius AI Studio*")
|
| 992 |
+
|
| 993 |
+
if __name__ == "__main__":
|
| 994 |
+
app.launch(share=True)
|
readme.md
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏴 Scotland Adventure Weather Planner
|
| 2 |
+
tags:
|
| 3 |
+
- agent-demo-track
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
https://github.com/user-attachments/assets/5adf9e57-7087-4523-9198-8134f13cff7c
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
A comprehensive Scottish adventure planning app that combines three custom MCP (Model Context Protocol) servers with an intelligent Gradio interface. Get weather forecasts, driving routes, daylight times, and AI-powered recommendations for your Scottish adventures.
|
| 10 |
+
|
| 11 |
+
## 🎯 Features
|
| 12 |
+
|
| 13 |
+
### 🌦️ Weather Intelligence
|
| 14 |
+
- **Real-time weather data** - Current conditions and 7-day forecasts for any Scottish location
|
| 15 |
+
- **Adventure-focused recommendations** - Activity-specific advice for hiking, photography, camping
|
| 16 |
+
- **Geographic disambiguation** - Automatically finds Scottish locations (not US namesakes!)
|
| 17 |
+
- **Weather safety alerts** - Wind warnings, precipitation alerts, visibility conditions
|
| 18 |
+
|
| 19 |
+
### 🌅 Daylight Planning
|
| 20 |
+
- **Sunrise/sunset times** - Perfect for photography and outdoor activity planning
|
| 21 |
+
- **Golden hour calculations** - Optimal lighting times for photographers
|
| 22 |
+
- **Seasonal daylight tracking** - Essential for Highland adventures where daylight varies dramatically
|
| 23 |
+
|
| 24 |
+
### 🚗 Route Planning
|
| 25 |
+
- **Driving distances and times** - Between any Scottish locations
|
| 26 |
+
- **Multi-stop road trip planning** - Optimized routes with Highland driving considerations
|
| 27 |
+
- **Interactive route visualization** - Real driving routes displayed on maps
|
| 28 |
+
- **Scottish driving tips** - Single-track roads, ferry times, fuel stops
|
| 29 |
+
|
| 30 |
+
### 🤖 AI Chat Interface
|
| 31 |
+
- **Natural language queries** - Ask questions like "Road trip from Edinburgh to Skye"
|
| 32 |
+
- **Intelligent data synthesis** - Combines weather, driving, and daylight data
|
| 33 |
+
- **Adventure recommendations** - Personalized suggestions based on conditions
|
| 34 |
+
- **Interactive maps** - Visual route and location display
|
| 35 |
+
|
| 36 |
+
## 🏗️ Architecture
|
| 37 |
+
|
| 38 |
+
### Three Custom MCP Servers
|
| 39 |
+
1. **Weather MCP** (`scotland-weather-mcp`) - Open-Meteo API integration
|
| 40 |
+
2. **Daylight MCP** (`scotland-daylight-mcp`) - Sunrise-Sunset API integration
|
| 41 |
+
3. **Driving MCP** (`scottish-driving-mcp`) - OpenRouteService integration
|
| 42 |
+
|
| 43 |
+
### Gradio Frontend
|
| 44 |
+
- **Multi-functional interface** - Chat, quick examples, interactive maps
|
| 45 |
+
- **Real-time data integration** - Fetches from all three MCP servers
|
| 46 |
+
- **AI-powered responses** - Uses Nebius AI Studio for intelligent synthesis
|
| 47 |
+
|
| 48 |
+
## 🚀 Quick Start
|
| 49 |
+
|
| 50 |
+
### Prerequisites
|
| 51 |
+
```bash
|
| 52 |
+
Python 3.8+
|
| 53 |
+
Modal account (for MCP server deployment)
|
| 54 |
+
OpenRouteService API key (free tier: 2000 requests/day)
|
| 55 |
+
Nebius AI Studio API key
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
### 1. Deploy MCP Servers
|
| 59 |
+
|
| 60 |
+
#### Weather Server
|
| 61 |
+
```bash
|
| 62 |
+
# Clone and setup
|
| 63 |
+
git clone <repository>
|
| 64 |
+
cd scotland-weather-adventure
|
| 65 |
+
|
| 66 |
+
# Deploy weather MCP
|
| 67 |
+
modal deploy weather_server.py
|
| 68 |
+
# Creates: https://your-username--scotland-weather-mcp-fastapi-app.modal.run
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
#### Daylight Server
|
| 72 |
+
```bash
|
| 73 |
+
# Deploy daylight MCP
|
| 74 |
+
modal deploy daylight_server.py
|
| 75 |
+
# Creates: https://your-username--scotland-daylight-mcp-fastapi-app.modal.run
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
#### Driving Server
|
| 79 |
+
```bash
|
| 80 |
+
# Get free API key from: https://openrouteservice.org/dev/#/signup
|
| 81 |
+
modal secret create openrouteservice OPENROUTESERVICE_API_KEY=your_key_here
|
| 82 |
+
|
| 83 |
+
# Deploy driving MCP
|
| 84 |
+
modal deploy driving_server.py
|
| 85 |
+
# Creates: https://your-username--scottish-driving-mcp-fastapi-app.modal.run
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### 2. Setup Gradio Frontend
|
| 89 |
+
```bash
|
| 90 |
+
# Update MCP server URLs in app.py
|
| 91 |
+
WEATHER_MCP_URL = "https://your-username--scotland-weather-mcp-fastapi-app.modal.run/mcp"
|
| 92 |
+
DAYLIGHT_MCP_URL = "https://your-username--scotland-daylight-mcp-fastapi-app.modal.run/mcp"
|
| 93 |
+
DRIVING_MCP_URL = "https://your-username--scottish-driving-mcp-fastapi-app.modal.run/mcp"
|
| 94 |
+
|
| 95 |
+
# Add your Nebius AI Studio API key
|
| 96 |
+
client = OpenAI(api_key="your_nebius_key_here", base_url="https://api.studio.nebius.ai/v1")
|
| 97 |
+
|
| 98 |
+
# Install dependencies and run
|
| 99 |
+
pip install gradio requests openai folium
|
| 100 |
+
python app.py
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
## 📁 Project Structure
|
| 104 |
+
|
| 105 |
+
```
|
| 106 |
+
scotland-weather-adventure/
|
| 107 |
+
├── README.md # This file
|
| 108 |
+
├── app.py # Main Gradio web interface
|
| 109 |
+
├── weather_server.py # Weather MCP server (Modal deployment)
|
| 110 |
+
├── daylight_server.py # Daylight MCP server (Modal deployment)
|
| 111 |
+
├── driving_server.py # Driving MCP server (Modal deployment)
|
| 112 |
+
└── requirements.txt # Dependencies
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
## 🛠️ MCP Server APIs
|
| 116 |
+
|
| 117 |
+
### Weather MCP Tools
|
| 118 |
+
|
| 119 |
+
#### `get_weather`
|
| 120 |
+
Get current weather conditions for any Scottish location.
|
| 121 |
+
```json
|
| 122 |
+
{
|
| 123 |
+
"method": "tools/call",
|
| 124 |
+
"params": {
|
| 125 |
+
"name": "get_weather",
|
| 126 |
+
"arguments": {"location": "Edinburgh"}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
#### `get_forecast`
|
| 132 |
+
Get 1-7 day weather forecast with adventure planning insights.
|
| 133 |
+
```json
|
| 134 |
+
{
|
| 135 |
+
"method": "tools/call",
|
| 136 |
+
"params": {
|
| 137 |
+
"name": "get_forecast",
|
| 138 |
+
"arguments": {"location": "Fort William", "days": 5}
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Daylight MCP Tools
|
| 144 |
+
|
| 145 |
+
#### `get_daylight_times`
|
| 146 |
+
Get sunrise, sunset, and golden hour times for photography planning.
|
| 147 |
+
```json
|
| 148 |
+
{
|
| 149 |
+
"method": "tools/call",
|
| 150 |
+
"params": {
|
| 151 |
+
"name": "get_daylight_times",
|
| 152 |
+
"arguments": {"location": "Glencoe", "date": "2024-07-15"}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
### Driving MCP Tools
|
| 158 |
+
|
| 159 |
+
#### `get_driving_distance`
|
| 160 |
+
Calculate driving distance and time between locations.
|
| 161 |
+
```json
|
| 162 |
+
{
|
| 163 |
+
"method": "tools/call",
|
| 164 |
+
"params": {
|
| 165 |
+
"name": "get_driving_distance",
|
| 166 |
+
"arguments": {
|
| 167 |
+
"from_location": "Edinburgh",
|
| 168 |
+
"to_location": "Isle of Skye"
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
#### `plan_road_trip`
|
| 175 |
+
Plan multi-stop road trips with optimized Scottish routes.
|
| 176 |
+
```json
|
| 177 |
+
{
|
| 178 |
+
"method": "tools/call",
|
| 179 |
+
"params": {
|
| 180 |
+
"name": "plan_road_trip",
|
| 181 |
+
"arguments": {
|
| 182 |
+
"locations": ["Glasgow", "Fort William", "Isle of Skye", "Inverness"]
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
## 🎮 Gradio Interface Features
|
| 189 |
+
|
| 190 |
+
### 💬 Intelligent Chat
|
| 191 |
+
- Natural language adventure planning
|
| 192 |
+
- Combines weather, driving, and daylight data automatically
|
| 193 |
+
- Scottish location recognition and disambiguation
|
| 194 |
+
- Activity-specific recommendations
|
| 195 |
+
|
| 196 |
+
### 📍 Interactive Maps
|
| 197 |
+
- Real driving route visualization using OpenRouteService
|
| 198 |
+
- Multiple location support with markers
|
| 199 |
+
- Route geometry display (not just straight lines!)
|
| 200 |
+
- Automatic map centering and zoom
|
| 201 |
+
|
| 202 |
+
### 🎯 Quick Examples
|
| 203 |
+
Pre-built example queries:
|
| 204 |
+
- "☀️ Weather Edinburgh"
|
| 205 |
+
- "🚗 Drive Edinburgh→Skye"
|
| 206 |
+
- "📸 Golden hour Glencoe"
|
| 207 |
+
- "🗺️ Road trip Glasgow→Skye"
|
| 208 |
+
- And more...
|
| 209 |
+
|
| 210 |
+
## 🌦️ Intelligent Features
|
| 211 |
+
|
| 212 |
+
### Scottish Geographic Intelligence
|
| 213 |
+
The system automatically handles location disambiguation:
|
| 214 |
+
- **"Perth"** → Finds Perth, Scotland (not Australia)
|
| 215 |
+
- **"Hamilton"** → Finds Hamilton, Scotland (not Ontario)
|
| 216 |
+
- **"Arran"** → Finds Isle of Arran, Scotland (not Ireland)
|
| 217 |
+
|
| 218 |
+
### Adventure-Specific Recommendations
|
| 219 |
+
- **Hiking**: Wind warnings, precipitation alerts, visibility
|
| 220 |
+
- **Photography**: Golden hour times, clear sky recommendations
|
| 221 |
+
- **Driving**: Highland road conditions, single-track warnings
|
| 222 |
+
- **Camping**: Daylight hours, weather suitability
|
| 223 |
+
|
| 224 |
+
### Scottish Driving Considerations
|
| 225 |
+
- Single-track Highland roads (allow extra time)
|
| 226 |
+
- Ferry schedules for island destinations
|
| 227 |
+
- Remote area fuel stop planning
|
| 228 |
+
- Highland weather driving safety
|
| 229 |
+
|
| 230 |
+
## 🚢 Deployment Options
|
| 231 |
+
|
| 232 |
+
### MCP Servers (Modal - Recommended)
|
| 233 |
+
```bash
|
| 234 |
+
# All three servers deploy to Modal's serverless platform
|
| 235 |
+
modal deploy weather_server.py
|
| 236 |
+
modal deploy daylight_server.py
|
| 237 |
+
modal deploy driving_server.py
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
### Gradio Frontend
|
| 241 |
+
- **Local Development**: `python app.py`
|
| 242 |
+
- **Gradio Sharing**: Built-in public demo links (`share=True`)
|
| 243 |
+
- **Production**: Deploy to Hugging Face Spaces, Modal, Railway, etc.
|
| 244 |
+
|
| 245 |
+
## 🔧 Configuration
|
| 246 |
+
|
| 247 |
+
### Environment Variables
|
| 248 |
+
```bash
|
| 249 |
+
# Required for driving server
|
| 250 |
+
OPENROUTESERVICE_API_KEY=your_openroute_key
|
| 251 |
+
|
| 252 |
+
# Required for AI chat
|
| 253 |
+
NEBIUS_API_KEY=your_nebius_key
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
### API Keys Needed
|
| 257 |
+
1. **OpenRouteService** (Free: 2000 requests/day) - For driving routes
|
| 258 |
+
2. **Nebius AI Studio** - For intelligent chat responses
|
| 259 |
+
3. **No API keys needed** for weather (Open-Meteo) or daylight (Sunrise-Sunset API)
|
| 260 |
+
|
| 261 |
+
## 🏔️ Example Use Cases
|
| 262 |
+
|
| 263 |
+
### Weekend Trip Planning
|
| 264 |
+
- **Query**: "Should I go to Aviemore or Cairngorms this weekend?"
|
| 265 |
+
- **Response**: Weather comparison, driving times, daylight hours, activity recommendations
|
| 266 |
+
|
| 267 |
+
### Photography Expeditions
|
| 268 |
+
- **Query**: "Golden hour photography spots near Fort William"
|
| 269 |
+
- **Response**: Sunrise/sunset times, weather conditions, recommended locations
|
| 270 |
+
|
| 271 |
+
### Multi-Day Adventures
|
| 272 |
+
- **Query**: "5-day road trip from Edinburgh to Skye with camping"
|
| 273 |
+
- **Response**: Route planning, weather forecast, camping suitability, daily recommendations
|
| 274 |
+
|
| 275 |
+
### Safety Planning
|
| 276 |
+
- **Query**: "Are there wind warnings for climbing in Glencoe?"
|
| 277 |
+
- **Response**: Wind speed alerts, weather safety assessment, alternative suggestions
|
| 278 |
+
|
| 279 |
+
## 🤝 Contributing
|
| 280 |
+
|
| 281 |
+
This project was built for adventure planning and learning! Contributions welcome:
|
| 282 |
+
|
| 283 |
+
1. Fork the repository
|
| 284 |
+
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
| 285 |
+
3. Make your changes
|
| 286 |
+
4. Submit a pull request
|
| 287 |
+
|
| 288 |
+
### Ideas for Enhancement
|
| 289 |
+
- [ ] Add tide times for coastal adventures
|
| 290 |
+
- [ ] Include mountain weather conditions (snow, ice)
|
| 291 |
+
- [ ] Ferry schedule integration
|
| 292 |
+
- [ ] Accommodation booking suggestions
|
| 293 |
+
- [ ] Trail condition reports
|
| 294 |
+
|
| 295 |
+
## 📄 License
|
| 296 |
+
|
| 297 |
+
Open source - built for Scottish adventure enthusiasts and outdoor learning!
|
| 298 |
+
|
| 299 |
+
## 🙏 Credits
|
| 300 |
+
|
| 301 |
+
- **Weather Data**: [Open-Meteo](https://open-meteo.com/) (free weather API)
|
| 302 |
+
- **Daylight Data**: [Sunrise-Sunset.org](https://sunrise-sunset.org/) API
|
| 303 |
+
- **Routing**: [OpenRouteService](https://openrouteservice.org/)
|
| 304 |
+
- **Deployment**: [Modal](https://modal.com/) serverless platform
|
| 305 |
+
- **AI**: [Nebius AI Studio](https://studio.nebius.ai/)
|
| 306 |
+
- **Interface**: [Gradio](https://gradio.app/) web framework
|
| 307 |
+
- **Maps**: [Folium](https://python-visualization.github.io/folium/) Python mapping
|
| 308 |
+
|
| 309 |
+
---
|
| 310 |
+
|
| 311 |
+
*Built with ❤️ for Scottish outdoor enthusiasts and powered by multiple free APIs for maximum accessibility*ß
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.0.0
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
openai>=1.0.0
|
| 4 |
+
folium>=0.14.0
|