v0.0.1
Browse files- .gitignore +23 -0
- .streamlit/config.toml +22 -0
- README.md +66 -4
- requirements.txt +5 -3
- src/services/recommender.py +1048 -0
- src/streamlit_app.py +512 -38
- src/styles/dark_theme.py +638 -0
- src/utils/prompts.py +30 -0
.gitignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
dist/
|
| 6 |
+
build/
|
| 7 |
+
|
| 8 |
+
# Virtual environment
|
| 9 |
+
.venv/
|
| 10 |
+
venv/
|
| 11 |
+
|
| 12 |
+
# Environment variables — contains API keys
|
| 13 |
+
.env
|
| 14 |
+
|
| 15 |
+
# Jupyter / IDE artifacts
|
| 16 |
+
.ipynb_checkpoints/
|
| 17 |
+
|
| 18 |
+
# Font files (proprietary — use Google Fonts CDN instead)
|
| 19 |
+
static/*.ttf
|
| 20 |
+
|
| 21 |
+
# OS junk
|
| 22 |
+
.DS_Store
|
| 23 |
+
Thumbs.db
|
.streamlit/config.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[server]
|
| 2 |
+
enableStaticServing = true
|
| 3 |
+
port = 12345
|
| 4 |
+
|
| 5 |
+
[theme]
|
| 6 |
+
# ⚡ CYBORG PALETTE — Jet black and electric blue ⚡
|
| 7 |
+
primaryColor = "#2a9fd6"
|
| 8 |
+
backgroundColor = "#060606"
|
| 9 |
+
secondaryBackgroundColor = "#111111"
|
| 10 |
+
textColor = "#dee2e6"
|
| 11 |
+
linkColor = "#2a9fd6"
|
| 12 |
+
borderColor = "#222222"
|
| 13 |
+
showWidgetBorder = true
|
| 14 |
+
baseRadius = "0.375rem"
|
| 15 |
+
font = "sans-serif"
|
| 16 |
+
codeFont = "sans-serif"
|
| 17 |
+
codeBackgroundColor = "#1a1a1a"
|
| 18 |
+
showSidebarBorder = true
|
| 19 |
+
|
| 20 |
+
[theme.sidebar]
|
| 21 |
+
backgroundColor = "#111111"
|
| 22 |
+
secondaryBackgroundColor = "#1a1a1a"
|
README.md
CHANGED
|
@@ -12,9 +12,71 @@ short_description: AI Travel Planner
|
|
| 12 |
license: mit
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
license: mit
|
| 13 |
---
|
| 14 |
|
| 15 |
+
# Roamify
|
| 16 |
|
| 17 |
+
AI-powered travel planner. Pick a city, choose a category, get personalized
|
| 18 |
+
recommendations with photos, a map, and optional translations.
|
| 19 |
|
| 20 |
+
Built with Streamlit, powered by Hermes Agent.
|
| 21 |
+
|
| 22 |
+
## Quick Start
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
# 1. Clone and enter the project
|
| 26 |
+
git clone <repo-url> roamify
|
| 27 |
+
cd roamify
|
| 28 |
+
|
| 29 |
+
# 2. Create a .env file with your API keys
|
| 30 |
+
echo 'OPENAI_API_KEY=your-key-here
|
| 31 |
+
OPENAI_BASE_URL=https://api.openai.com/v1
|
| 32 |
+
LLM_MODEL=gpt-4o-mini
|
| 33 |
+
UNSPLASH_ACCESS_KEY=your-key-here' > .env
|
| 34 |
+
|
| 35 |
+
# 3. Install dependencies
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
+
|
| 38 |
+
# 4. Run the app
|
| 39 |
+
streamlit run src/streamlit_app.py --server.port 12345
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## What You Need
|
| 43 |
+
|
| 44 |
+
- Python 3.11+
|
| 45 |
+
- An OpenAI-compatible API endpoint (OpenAI, Ollama, OpenRouter, etc.)
|
| 46 |
+
- (Optional) An Unsplash API key for image search — images still load from
|
| 47 |
+
Wikipedia/Wikimedia without it
|
| 48 |
+
|
| 49 |
+
## Features
|
| 50 |
+
|
| 51 |
+
- 7 travel categories: Landmark, Culture, Nature, Gems, Photo, Food, Shopping
|
| 52 |
+
- AI-generated recommendations with descriptions and tips
|
| 53 |
+
- Real coordinates from Nominatim (LLM coordinates are never trusted)
|
| 54 |
+
- 5-tier image fallback: Wikipedia → Wikidata → Commons → Local names → Unsplash
|
| 55 |
+
- Leaflet map with spider markers and card↔map hover sync
|
| 56 |
+
- Multi-language translation (Traditional Chinese, Japanese, Korean, French,
|
| 57 |
+
Spanish, German)
|
| 58 |
+
- In-memory caching — repeat searches are fast
|
| 59 |
+
- Dark Cyborg theme with large fonts
|
| 60 |
+
|
| 61 |
+
## Project Structure
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
roamify/
|
| 65 |
+
├── src/
|
| 66 |
+
│ ├── streamlit_app.py # Main Streamlit app
|
| 67 |
+
│ ├── services/
|
| 68 |
+
│ │ └── recommender.py # LLM calls, geocoding, images, caching
|
| 69 |
+
│ ├── styles/
|
| 70 |
+
│ │ └── dark_theme.py # Dark CSS + JS (hover sync, flex panels)
|
| 71 |
+
│ └── utils/
|
| 72 |
+
│ └── prompts.py # Category-specific AI prompt templates
|
| 73 |
+
├── .streamlit/
|
| 74 |
+
│ └── config.toml # Streamlit server and theme config
|
| 75 |
+
├── Dockerfile # HF Spaces deployment
|
| 76 |
+
├── requirements.txt
|
| 77 |
+
└── README.md
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
## License
|
| 81 |
+
|
| 82 |
+
MIT
|
requirements.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.38
|
| 2 |
+
openai>=1.0
|
| 3 |
+
folium>=0.16
|
| 4 |
+
streamlit-folium>=0.18
|
| 5 |
+
python-dotenv>=1.0
|
src/services/recommender.py
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-based recommender service for travel planning."""
|
| 2 |
+
|
| 3 |
+
import concurrent.futures
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
import urllib.request
|
| 11 |
+
import urllib.parse
|
| 12 |
+
import urllib.error
|
| 13 |
+
|
| 14 |
+
from openai import OpenAI
|
| 15 |
+
|
| 16 |
+
from utils.prompts import PROMPT_MAP, CATEGORY_GUIDANCE
|
| 17 |
+
|
| 18 |
+
# Module-level cache for Nominatim geocoding results
|
| 19 |
+
_GEOCODE_CACHE: dict[str, dict | None] = {}
|
| 20 |
+
|
| 21 |
+
# Module-level cache for image enrichment results — keyed by (name, city, country) -> image URL
|
| 22 |
+
# Never cleared, survives "Clear" clicks. Image URLs are stable per attraction.
|
| 23 |
+
_IMAGE_CACHE: dict[tuple[str, str, str], str] = {}
|
| 24 |
+
|
| 25 |
+
# Module-level cache for LLM-generated recommendations — keyed by (city, num, cat_hash) -> items
|
| 26 |
+
# Cleared on explicit user "Clear" click only.
|
| 27 |
+
_LLM_CACHE: dict[tuple[str, int, str], list[dict] | None] = {}
|
| 28 |
+
|
| 29 |
+
# Module-level cache for translations — keyed by (items_hash, second_language) -> translated items
|
| 30 |
+
# Cleared on explicit user "Clear" click only.
|
| 31 |
+
_TRANSLATION_CACHE: dict[tuple[str, str, str], list[dict]] = {}
|
| 32 |
+
|
| 33 |
+
# Stop words used across multiple relevance checks
|
| 34 |
+
_STOP_WORDS = {"the", "a", "an", "of", "in", "on", "at", "and", "or", "de", "la", "le", "el", "di", "del"}
|
| 35 |
+
|
| 36 |
+
# Common attraction type suffixes used in name deduplication
|
| 37 |
+
_ATTRACTION_SUFFIXES = (
|
| 38 |
+
" temple", " shrine", " castle", " palace", " park", " museum",
|
| 39 |
+
" garden", " bridge", " tower", " square", " market", " street",
|
| 40 |
+
" station", " hall", " church", " basilica", " monastery",
|
| 41 |
+
" gallery", " theater", " theatre", " library",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger("roamify")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _http_get_json(url: str, timeout: int = 5, retries: int = 2) -> dict | None:
|
| 48 |
+
"""GET a JSON URL with retry on rate-limit and transient errors."""
|
| 49 |
+
for attempt in range(retries + 1):
|
| 50 |
+
try:
|
| 51 |
+
req = urllib.request.Request(url, headers={"User-Agent": "TravelPlanner/1.0"})
|
| 52 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 53 |
+
return json.loads(resp.read().decode())
|
| 54 |
+
except urllib.error.HTTPError as e:
|
| 55 |
+
if e.code in (429, 502, 503) and attempt < retries:
|
| 56 |
+
time.sleep(1.0 * (attempt + 1)) # backoff: 1s, 2s
|
| 57 |
+
continue
|
| 58 |
+
return None
|
| 59 |
+
except (TimeoutError, OSError, ConnectionError):
|
| 60 |
+
if attempt < retries:
|
| 61 |
+
time.sleep(0.5 * (attempt + 1))
|
| 62 |
+
continue
|
| 63 |
+
return None
|
| 64 |
+
except Exception:
|
| 65 |
+
return None
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _resolve_wiki_title(name: str) -> str:
|
| 70 |
+
"""Resolve an attraction name to the correct Wikipedia article title using search."""
|
| 71 |
+
search_url = "https://en.wikipedia.org/w/api.php?" + urllib.parse.urlencode({
|
| 72 |
+
"action": "query",
|
| 73 |
+
"list": "search",
|
| 74 |
+
"srsearch": name,
|
| 75 |
+
"format": "json",
|
| 76 |
+
"srlimit": 1,
|
| 77 |
+
})
|
| 78 |
+
data = _http_get_json(search_url)
|
| 79 |
+
if data:
|
| 80 |
+
results = data.get("query", {}).get("search", [])
|
| 81 |
+
if results:
|
| 82 |
+
return results[0]["title"]
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _fetch_wiki_image(name: str) -> str:
|
| 87 |
+
"""Tier 1: Resolve article title via search, then fetch thumbnail from Wikipedia.
|
| 88 |
+
Tries REST summary API first, then falls back to action=query pageimages API.
|
| 89 |
+
Prioritizes stripped name over original (parenthetical suffixes confuse search).
|
| 90 |
+
Skips results where the article title doesn't match the attraction name.
|
| 91 |
+
"""
|
| 92 |
+
# Build candidate titles: stripped first (more reliable), then original, then resolved from search
|
| 93 |
+
stripped = re.sub(r"\s*\(.+\)\s*$", "", name).strip()
|
| 94 |
+
candidates = []
|
| 95 |
+
if stripped and stripped != name:
|
| 96 |
+
candidates.append(stripped)
|
| 97 |
+
candidates.append(name)
|
| 98 |
+
# Resolve via search — deduplicate to avoid redundant API calls
|
| 99 |
+
search_names = [stripped] if stripped else []
|
| 100 |
+
if name and (not stripped or name != stripped):
|
| 101 |
+
search_names.append(name)
|
| 102 |
+
for search_name in search_names:
|
| 103 |
+
if search_name:
|
| 104 |
+
resolved = _resolve_wiki_title(search_name)
|
| 105 |
+
if resolved and resolved not in candidates:
|
| 106 |
+
candidates.append(resolved)
|
| 107 |
+
|
| 108 |
+
# Core words from the attraction name for relevance checking
|
| 109 |
+
name_core = set(re.sub(r"[()\-_,]", " ", stripped or name).lower().split())
|
| 110 |
+
name_core = name_core - _STOP_WORDS
|
| 111 |
+
|
| 112 |
+
for title in candidates:
|
| 113 |
+
if not title:
|
| 114 |
+
continue
|
| 115 |
+
# Relevance check: the article title should share at least one significant word with the attraction name
|
| 116 |
+
title_core = set(re.sub(r"[()\-_,]", " ", title).lower().split()) - _STOP_WORDS
|
| 117 |
+
if name_core and title_core and not (name_core & title_core):
|
| 118 |
+
# No exact word overlap — try shared substring of 4+ chars (e.g. "mura" in "Amemura" ↔ "Amerikamura")
|
| 119 |
+
any_shared_substr = any(
|
| 120 |
+
any(w[i:i+4] in tw for i in range(len(w) - 3) if len(w) >= 4)
|
| 121 |
+
for w in name_core
|
| 122 |
+
for tw in title_core
|
| 123 |
+
)
|
| 124 |
+
if not any_shared_substr:
|
| 125 |
+
continue # Article title has no word overlap with attraction name — skip
|
| 126 |
+
# Try REST summary API first
|
| 127 |
+
search_url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{urllib.parse.quote(title)}"
|
| 128 |
+
data = _http_get_json(search_url)
|
| 129 |
+
if data:
|
| 130 |
+
source = data.get("thumbnail", {}).get("source", "")
|
| 131 |
+
if source:
|
| 132 |
+
return source
|
| 133 |
+
# Article exists but has no thumbnail — try pageimages API instead
|
| 134 |
+
img_url = f"https://en.wikipedia.org/w/api.php?{urllib.parse.urlencode({'action': 'query', 'titles': title, 'prop': 'pageimages', 'pithumbsize': 400, 'format': 'json'})}"
|
| 135 |
+
img_data = _http_get_json(img_url)
|
| 136 |
+
if img_data:
|
| 137 |
+
pages = img_data.get("query", {}).get("pages", {})
|
| 138 |
+
for page in pages.values():
|
| 139 |
+
thumb = page.get("thumbnail", {}).get("source", "")
|
| 140 |
+
if thumb:
|
| 141 |
+
return thumb
|
| 142 |
+
return ""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# Tourism-related keywords to disambiguate Wikidata results
|
| 146 |
+
_TOURISM_KEYWORDS = {
|
| 147 |
+
"church", "cathedral", "basilica", "monument", "museum", "palace",
|
| 148 |
+
"castle", "tower", "bridge", "park", "garden", "square", "plaza",
|
| 149 |
+
"temple", "shrine", "mosque", "synagogue", "abbey", "fort", "fortress",
|
| 150 |
+
"arena", "stadium", "theater", "theatre", "gallery", "library",
|
| 151 |
+
"cemetery", "aqueduct", "fountain", "arch", "gate", "wall",
|
| 152 |
+
"district", "neighborhood", "quarter", "area", "market", "island",
|
| 153 |
+
"building", "skyscraper",
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _fetch_wikidata_image(name: str, city: str = "", country: str = "") -> str:
|
| 158 |
+
"""Tier 2: Get image from Wikidata P18 claim → construct full Commons URL.
|
| 159 |
+
Disambiguates by preferring entities whose description contains tourism keywords.
|
| 160 |
+
Tries stripped name, then with city/country context.
|
| 161 |
+
"""
|
| 162 |
+
# Build search queries: original → stripped → with city → with country
|
| 163 |
+
clean = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 164 |
+
queries = [name]
|
| 165 |
+
if clean and clean != name:
|
| 166 |
+
queries.append(clean)
|
| 167 |
+
if city and clean:
|
| 168 |
+
queries.append(f"{clean}, {city}")
|
| 169 |
+
if country and clean and country != city:
|
| 170 |
+
queries.append(f"{clean}, {country}")
|
| 171 |
+
|
| 172 |
+
for query in queries:
|
| 173 |
+
search_url = "https://www.wikidata.org/w/api.php?" + urllib.parse.urlencode({
|
| 174 |
+
"action": "wbsearchentities",
|
| 175 |
+
"search": query,
|
| 176 |
+
"language": "en",
|
| 177 |
+
"format": "json",
|
| 178 |
+
"limit": 5,
|
| 179 |
+
})
|
| 180 |
+
data = _http_get_json(search_url)
|
| 181 |
+
if not data:
|
| 182 |
+
continue
|
| 183 |
+
results = data.get("search", [])
|
| 184 |
+
if not results:
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
# Pick the best candidate: prefer ones with tourism-related descriptions
|
| 188 |
+
best = None
|
| 189 |
+
for r in results[:5]:
|
| 190 |
+
desc = (r.get("description") or "").lower()
|
| 191 |
+
if any(kw in desc for kw in _TOURISM_KEYWORDS):
|
| 192 |
+
best = r
|
| 193 |
+
break
|
| 194 |
+
# If no tourism keyword match, try first result whose label matches stripped name
|
| 195 |
+
if not best:
|
| 196 |
+
for r in results[:5]:
|
| 197 |
+
label = (r.get("label") or "").lower()
|
| 198 |
+
if clean.lower() in label or label in clean.lower():
|
| 199 |
+
best = r
|
| 200 |
+
break
|
| 201 |
+
if not best:
|
| 202 |
+
best = results[0]
|
| 203 |
+
|
| 204 |
+
qid = best["id"]
|
| 205 |
+
|
| 206 |
+
# Fetch P18 (image) claim
|
| 207 |
+
entity_url = "https://www.wikidata.org/w/api.php?" + urllib.parse.urlencode({
|
| 208 |
+
"action": "wbgetclaims",
|
| 209 |
+
"entity": qid,
|
| 210 |
+
"property": "P18",
|
| 211 |
+
"format": "json",
|
| 212 |
+
})
|
| 213 |
+
claims_data = _http_get_json(entity_url)
|
| 214 |
+
if not claims_data:
|
| 215 |
+
continue
|
| 216 |
+
p18 = claims_data.get("claims", {}).get("P18", [])
|
| 217 |
+
if not p18:
|
| 218 |
+
continue
|
| 219 |
+
|
| 220 |
+
# Construct Commons URL from filename using MD5 hash path
|
| 221 |
+
filename = p18[0]["mainsnak"]["datavalue"]["value"]
|
| 222 |
+
safe = filename.replace(" ", "_")
|
| 223 |
+
md5 = hashlib.md5(safe.encode()).hexdigest()
|
| 224 |
+
url = f"https://upload.wikimedia.org/wikipedia/commons/{md5[0]}/{md5[:2]}/{safe}"
|
| 225 |
+
return url
|
| 226 |
+
return ""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _fetch_commons_image(name: str, city: str = "", country: str = "") -> str:
|
| 230 |
+
"""Tier 3: Search Wikimedia Commons for an image file name, return direct URL.
|
| 231 |
+
Tries name, then name+city, then name+country for better disambiguation.
|
| 232 |
+
Skips results whose filename has no word overlap with the attraction name.
|
| 233 |
+
"""
|
| 234 |
+
# Core words from the attraction name for relevance checking
|
| 235 |
+
clean = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 236 |
+
name_core = set(re.sub(r"[()\-_,]", " ", clean or name).lower().split()) - _STOP_WORDS
|
| 237 |
+
|
| 238 |
+
queries = [name]
|
| 239 |
+
if clean and clean != name:
|
| 240 |
+
queries.append(clean)
|
| 241 |
+
if city and clean:
|
| 242 |
+
queries.append(f"{clean}, {city}")
|
| 243 |
+
if country and clean and country != city:
|
| 244 |
+
queries.append(f"{clean}, {country}")
|
| 245 |
+
# Add simplified name variants that used to be in Tier 4
|
| 246 |
+
for suffix in (" Market", " Garden", " Beach", " Park", " Museum", " Square", " Tower", " Bridge", " Temple", " Shrine", " Castle", " Palace", " Street", " Station"):
|
| 247 |
+
if clean.endswith(suffix):
|
| 248 |
+
base = clean[:-len(suffix)].strip()
|
| 249 |
+
if base and base not in queries and base != clean:
|
| 250 |
+
queries.append(base)
|
| 251 |
+
# Try shortened name (first word or two)
|
| 252 |
+
words = clean.split()
|
| 253 |
+
if len(words) > 2:
|
| 254 |
+
two_word = " ".join(words[:2])
|
| 255 |
+
if two_word not in queries:
|
| 256 |
+
queries.append(two_word)
|
| 257 |
+
|
| 258 |
+
for query in queries:
|
| 259 |
+
search_url = "https://commons.wikimedia.org/w/api.php?" + urllib.parse.urlencode({
|
| 260 |
+
"action": "query",
|
| 261 |
+
"list": "search",
|
| 262 |
+
"srsearch": query,
|
| 263 |
+
"srnamespace": "6", # File namespace
|
| 264 |
+
"format": "json",
|
| 265 |
+
"srlimit": 5,
|
| 266 |
+
})
|
| 267 |
+
data = _http_get_json(search_url, timeout=10, retries=1)
|
| 268 |
+
if not data:
|
| 269 |
+
continue
|
| 270 |
+
results = data.get("query", {}).get("search", [])
|
| 271 |
+
# Find an image file (jpg/png/jpeg/webp) with relevance check
|
| 272 |
+
for r in results:
|
| 273 |
+
title = r.get("title", "")
|
| 274 |
+
lower = title.lower()
|
| 275 |
+
if any(lower.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp")):
|
| 276 |
+
# Relevance check: filename should share at least one word with attraction name
|
| 277 |
+
if name_core:
|
| 278 |
+
file_core = set(re.sub(r"[()\-_,.]", " ", lower.replace("file:", "")).split()) - _STOP_WORDS
|
| 279 |
+
if not (name_core & file_core):
|
| 280 |
+
# No exact word overlap — try shared substring of 4+ chars
|
| 281 |
+
any_shared_substr = any(
|
| 282 |
+
any(w[i:i+4] in tw for i in range(len(w) - 3) if len(w) >= 4)
|
| 283 |
+
for w in name_core
|
| 284 |
+
for tw in file_core
|
| 285 |
+
)
|
| 286 |
+
if not any_shared_substr:
|
| 287 |
+
continue # No word overlap — skip irrelevant result
|
| 288 |
+
# Strip "File:" prefix and construct URL
|
| 289 |
+
filename = title.replace("File:", "").strip()
|
| 290 |
+
safe = filename.replace(" ", "_")
|
| 291 |
+
md5 = hashlib.md5(safe.encode()).hexdigest()
|
| 292 |
+
return f"https://upload.wikimedia.org/wikipedia/commons/thumb/{md5[0]}/{md5[:2]}/{safe}/400px-{safe}"
|
| 293 |
+
return ""
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _fetch_local_name_image(name: str, city: str = "", country: str = "") -> str:
|
| 297 |
+
"""Tier 5: Try parenthetical local name from the attraction.
|
| 298 |
+
E.g. 'Awaji Island (Koko-shima)' tries 'Koko-shima' on Commons and Wikidata.
|
| 299 |
+
Also tries '{local_name}, {city}' and '{local_name} {city}'.
|
| 300 |
+
"""
|
| 301 |
+
m = re.search(r"\((.+?)\)", name)
|
| 302 |
+
if not m:
|
| 303 |
+
return ""
|
| 304 |
+
local = m.group(1).strip()
|
| 305 |
+
if not local:
|
| 306 |
+
return ""
|
| 307 |
+
|
| 308 |
+
# Try Commons with local name variants
|
| 309 |
+
queries = [local]
|
| 310 |
+
if city:
|
| 311 |
+
queries.append(f"{local}, {city}")
|
| 312 |
+
if country and country != city:
|
| 313 |
+
queries.append(f"{local}, {country}")
|
| 314 |
+
|
| 315 |
+
for query in queries:
|
| 316 |
+
url = _fetch_commons_image(query)
|
| 317 |
+
if url:
|
| 318 |
+
return url
|
| 319 |
+
|
| 320 |
+
# Try Wikidata with local name
|
| 321 |
+
for query in queries:
|
| 322 |
+
url = _fetch_wikidata_image(query, city=city, country=country)
|
| 323 |
+
if url:
|
| 324 |
+
return url
|
| 325 |
+
|
| 326 |
+
return ""
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _fetch_unsplash_api_image(name: str, city: str = "", country: str = "") -> str:
|
| 330 |
+
"""Tier 6: Search Unsplash for a high-quality landscape photo.
|
| 331 |
+
Only called when all Wikimedia sources fail. Uses orientation=landscape
|
| 332 |
+
to avoid tall/portrait photos. Respects 50 req/hr demo rate limit.
|
| 333 |
+
"""
|
| 334 |
+
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY", "")
|
| 335 |
+
if not unsplash_key:
|
| 336 |
+
return ""
|
| 337 |
+
|
| 338 |
+
# Build search query: name + city for better relevance
|
| 339 |
+
clean = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 340 |
+
query = clean
|
| 341 |
+
if city:
|
| 342 |
+
query = f"{clean} {city}"
|
| 343 |
+
elif country:
|
| 344 |
+
query = f"{clean} {country}"
|
| 345 |
+
|
| 346 |
+
search_url = "https://api.unsplash.com/search/photos?" + urllib.parse.urlencode({
|
| 347 |
+
"query": query,
|
| 348 |
+
"per_page": 3,
|
| 349 |
+
"orientation": "landscape",
|
| 350 |
+
})
|
| 351 |
+
try:
|
| 352 |
+
req = urllib.request.Request(search_url, headers={
|
| 353 |
+
"Authorization": f"Client-ID {unsplash_key}",
|
| 354 |
+
"Accept-Version": "v1",
|
| 355 |
+
})
|
| 356 |
+
with urllib.request.urlopen(req, timeout=8) as resp:
|
| 357 |
+
data = json.loads(resp.read().decode())
|
| 358 |
+
results = data.get("results", [])
|
| 359 |
+
if results:
|
| 360 |
+
# Use small size (400px wide) — perfect for cards
|
| 361 |
+
return results[0]["urls"]["small"]
|
| 362 |
+
except Exception:
|
| 363 |
+
pass
|
| 364 |
+
return ""
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _enrich_one_item(item: dict, city: str = "", country: str = "") -> None:
|
| 368 |
+
"""Look up image for a single item using 5-tier fallback:
|
| 369 |
+
1. Wikipedia REST/pageimages API
|
| 370 |
+
2. Wikidata P18 image claim (with city/country context)
|
| 371 |
+
3. Wikimedia Commons search (with simplified name variants embedded)
|
| 372 |
+
4. Local name from parentheses (e.g. Koko-shima from Awaji Island)
|
| 373 |
+
5. Unsplash search (landscape orientation, last resort)
|
| 374 |
+
|
| 375 |
+
Results are cached in _IMAGE_CACHE to avoid repeat API calls across searches.
|
| 376 |
+
"""
|
| 377 |
+
if item.get("image_url"):
|
| 378 |
+
return
|
| 379 |
+
name = item.get("name", "")
|
| 380 |
+
if not name:
|
| 381 |
+
item["image_url"] = ""
|
| 382 |
+
return
|
| 383 |
+
|
| 384 |
+
# Check image cache first
|
| 385 |
+
cache_key = (name, city or "", country or "")
|
| 386 |
+
cached_url = _IMAGE_CACHE.get(cache_key)
|
| 387 |
+
if cached_url is not None:
|
| 388 |
+
item["image_url"] = cached_url
|
| 389 |
+
return
|
| 390 |
+
|
| 391 |
+
# Tier 1: Wikipedia
|
| 392 |
+
url = _fetch_wiki_image(name)
|
| 393 |
+
if url:
|
| 394 |
+
_IMAGE_CACHE[cache_key] = url
|
| 395 |
+
item["image_url"] = url
|
| 396 |
+
return
|
| 397 |
+
# Tier 2: Wikidata (with city/country for disambiguation)
|
| 398 |
+
url = _fetch_wikidata_image(name, city=city, country=country)
|
| 399 |
+
if url:
|
| 400 |
+
_IMAGE_CACHE[cache_key] = url
|
| 401 |
+
item["image_url"] = url
|
| 402 |
+
return
|
| 403 |
+
# Tier 3: Wikimedia Commons (includes simplified/variant names)
|
| 404 |
+
url = _fetch_commons_image(name, city=city, country=country)
|
| 405 |
+
if url:
|
| 406 |
+
_IMAGE_CACHE[cache_key] = url
|
| 407 |
+
item["image_url"] = url
|
| 408 |
+
return
|
| 409 |
+
# Tier 4: Local name from parentheses
|
| 410 |
+
url = _fetch_local_name_image(name, city=city, country=country)
|
| 411 |
+
if url:
|
| 412 |
+
_IMAGE_CACHE[cache_key] = url
|
| 413 |
+
item["image_url"] = url
|
| 414 |
+
return
|
| 415 |
+
# Tier 5: Unsplash (landscape only, last resort)
|
| 416 |
+
url = _fetch_unsplash_api_image(name, city=city, country=country)
|
| 417 |
+
_IMAGE_CACHE[cache_key] = url
|
| 418 |
+
item["image_url"] = url
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _enrich_with_images(items: list[dict], city: str = "", country: str = "") -> list[dict]:
|
| 422 |
+
"""Add image_url to each item using a 5-tier fallback:
|
| 423 |
+
1. Wikipedia REST API — English page/summary
|
| 424 |
+
2. Wikidata P18 image claim → full Commons URL (MD5 hash path)
|
| 425 |
+
3. Wikimedia Commons search (with simplified/variant names embedded)
|
| 426 |
+
4. Local name from parentheses (e.g. Koko-shima from Awaji Island)
|
| 427 |
+
5. Unsplash search (landscape orientation, last resort)
|
| 428 |
+
All lookups run concurrently via ThreadPoolExecutor (max 6 workers).
|
| 429 |
+
"""
|
| 430 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as pool:
|
| 431 |
+
futures = [pool.submit(_enrich_one_item, item, city=city, country=country) for item in items]
|
| 432 |
+
concurrent.futures.wait(futures)
|
| 433 |
+
return items
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def _haversine_km(lat1, lon1, lat2, lon2):
|
| 437 |
+
"""Return distance in km between two lat/lon pairs."""
|
| 438 |
+
import math
|
| 439 |
+
R = 6371.0
|
| 440 |
+
dlat = math.radians(lat2 - lat1)
|
| 441 |
+
dlon = math.radians(lon2 - lon1)
|
| 442 |
+
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
| 443 |
+
return R * 2 * math.asin(math.sqrt(a))
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _nominatim_search_cached(query: str, timeout: int = 10) -> tuple[dict | None, bool]:
|
| 447 |
+
"""Search Nominatim with caching. Returns (result, was_cached).
|
| 448 |
+
Handles Nominatim's 1-req/s rate limit internally — only sleeps on actual API calls."""
|
| 449 |
+
if query in _GEOCODE_CACHE:
|
| 450 |
+
return _GEOCODE_CACHE[query], True
|
| 451 |
+
url = "https://nominatim.openstreetmap.org/search?" + urllib.parse.urlencode({
|
| 452 |
+
"q": query, "format": "json", "limit": 1, "accept-language": "en",
|
| 453 |
+
})
|
| 454 |
+
data = _http_get_json(url, timeout=timeout, retries=2)
|
| 455 |
+
time.sleep(1.01) # Nominatim rate limit: 1 req/s (only on actual API calls)
|
| 456 |
+
if data and isinstance(data, list) and data:
|
| 457 |
+
_GEOCODE_CACHE[query] = data[0]
|
| 458 |
+
return data[0], False
|
| 459 |
+
_GEOCODE_CACHE[query] = None
|
| 460 |
+
return None, False
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _geocode_city(city: str) -> tuple[float, float, list[float]] | None:
|
| 464 |
+
"""Geocode a city center via Nominatim (cached). Returns (lat, lon, boundingbox) or None."""
|
| 465 |
+
result, _ = _nominatim_search_cached(city)
|
| 466 |
+
if not result:
|
| 467 |
+
return None
|
| 468 |
+
try:
|
| 469 |
+
lat = float(result["lat"])
|
| 470 |
+
lon = float(result["lon"])
|
| 471 |
+
bb = [float(v) for v in result.get("boundingbox", [])]
|
| 472 |
+
if len(bb) == 4:
|
| 473 |
+
return lat, lon, bb
|
| 474 |
+
return lat, lon, []
|
| 475 |
+
except (KeyError, ValueError, IndexError):
|
| 476 |
+
return None
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _verify_coordinates(items: list[dict], city: str) -> list[dict]:
|
| 481 |
+
"""Verify attraction coordinates by forward-geocoding every item via Nominatim.
|
| 482 |
+
The LLM frequently fabricates coordinates — it may put Kiyomizu-dera (Kyoto)
|
| 483 |
+
at fake Tokyo coords, or include Himeji Castle with fake local coords.
|
| 484 |
+
|
| 485 |
+
Strategy: geocode each attraction name + city via Nominatim, then verify the
|
| 486 |
+
result's display_name actually mentions the target city. If not found with
|
| 487 |
+
the city qualifier, try without it — if the real location is in a different
|
| 488 |
+
city, drop the item.
|
| 489 |
+
"""
|
| 490 |
+
# Geocode city center (cached — sleep handled internally)
|
| 491 |
+
city_result = _geocode_city(city)
|
| 492 |
+
if city_result:
|
| 493 |
+
city_center = (city_result[0], city_result[1])
|
| 494 |
+
else:
|
| 495 |
+
city_center = None
|
| 496 |
+
|
| 497 |
+
MAX_CITY_DIST_KM = 15
|
| 498 |
+
verified = []
|
| 499 |
+
|
| 500 |
+
for item in items:
|
| 501 |
+
name = item.get("name", "")
|
| 502 |
+
# Strip parenthetical like "Kiyomizu-dera Temple (Kyoto)" -> "Kiyomizu-dera Temple"
|
| 503 |
+
clean_name = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 504 |
+
if not clean_name:
|
| 505 |
+
verified.append(item)
|
| 506 |
+
continue
|
| 507 |
+
|
| 508 |
+
# Step 1: Try geocode with city qualifier (cached — sleep handled internally)
|
| 509 |
+
query = f"{clean_name}, {city}"
|
| 510 |
+
result1, _ = _nominatim_search_cached(query)
|
| 511 |
+
|
| 512 |
+
n_lat, n_lon, display_name = None, None, ""
|
| 513 |
+
|
| 514 |
+
if result1:
|
| 515 |
+
try:
|
| 516 |
+
n_lat = float(result1["lat"])
|
| 517 |
+
n_lon = float(result1["lon"])
|
| 518 |
+
display_name = (result1.get("display_name", "") or "").lower()
|
| 519 |
+
except (KeyError, ValueError, IndexError):
|
| 520 |
+
pass
|
| 521 |
+
|
| 522 |
+
if n_lat is not None:
|
| 523 |
+
# Check display_name mentions the target city AND the attraction name
|
| 524 |
+
city_lower = city.lower()
|
| 525 |
+
city_words = set(city_lower.split())
|
| 526 |
+
mentions_city = any(w in display_name for w in city_words)
|
| 527 |
+
|
| 528 |
+
# Check display_name actually refers to the attraction, not a shop/restaurant
|
| 529 |
+
clean_lower = clean_name.lower()
|
| 530 |
+
attraction_words = set(re.sub(r"[()\-_,]", " ", clean_lower).split())
|
| 531 |
+
name_in_display = any(w in display_name for w in attraction_words if len(w) > 3)
|
| 532 |
+
|
| 533 |
+
if city_center:
|
| 534 |
+
dist = _haversine_km(city_center[0], city_center[1], n_lat, n_lon)
|
| 535 |
+
if dist <= MAX_CITY_DIST_KM and mentions_city and name_in_display:
|
| 536 |
+
item["latitude"] = n_lat
|
| 537 |
+
item["longitude"] = n_lon
|
| 538 |
+
verified.append(item)
|
| 539 |
+
continue
|
| 540 |
+
elif dist <= MAX_CITY_DIST_KM and not (mentions_city and name_in_display):
|
| 541 |
+
pass # Fall through to unqualified search
|
| 542 |
+
else:
|
| 543 |
+
continue
|
| 544 |
+
else:
|
| 545 |
+
continue
|
| 546 |
+
# else: not found with qualifier — fall through
|
| 547 |
+
|
| 548 |
+
# Step 2: Try geocode WITHOUT city qualifier (cached — sleep handled internally)
|
| 549 |
+
clean_name_no_paren = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 550 |
+
query2 = clean_name_no_paren
|
| 551 |
+
result2, _ = _nominatim_search_cached(query2)
|
| 552 |
+
|
| 553 |
+
n_lat2, n_lon2, display_name2 = None, None, ""
|
| 554 |
+
if result2:
|
| 555 |
+
try:
|
| 556 |
+
n_lat2 = float(result2["lat"])
|
| 557 |
+
n_lon2 = float(result2["lon"])
|
| 558 |
+
display_name2 = (result2.get("display_name", "") or "").lower()
|
| 559 |
+
except (KeyError, ValueError, IndexError):
|
| 560 |
+
pass
|
| 561 |
+
|
| 562 |
+
if n_lat2 is not None and city_center:
|
| 563 |
+
# Check if the unqualified result is in the target city
|
| 564 |
+
city_lower = city.lower()
|
| 565 |
+
city_words = set(city_lower.split())
|
| 566 |
+
mentions_city = any(w in display_name2 for w in city_words)
|
| 567 |
+
|
| 568 |
+
# Also verify the name is in the display
|
| 569 |
+
clean_lower = clean_name.lower()
|
| 570 |
+
attraction_words = set(re.sub(r"[()\-_,]", " ", clean_lower).split())
|
| 571 |
+
name_in_display = any(w in display_name2 for w in attraction_words if len(w) > 3)
|
| 572 |
+
|
| 573 |
+
dist = _haversine_km(city_center[0], city_center[1], n_lat2, n_lon2)
|
| 574 |
+
|
| 575 |
+
if dist <= MAX_CITY_DIST_KM and mentions_city and name_in_display:
|
| 576 |
+
# The attraction is actually in the target city
|
| 577 |
+
item["latitude"] = n_lat2
|
| 578 |
+
item["longitude"] = n_lon2
|
| 579 |
+
verified.append(item)
|
| 580 |
+
continue
|
| 581 |
+
else:
|
| 582 |
+
# The attraction is in a different city — drop it
|
| 583 |
+
continue
|
| 584 |
+
else:
|
| 585 |
+
# No geocoding result at all — keep item with LLM coords as fallback
|
| 586 |
+
try:
|
| 587 |
+
lat = float(item.get("latitude", 0))
|
| 588 |
+
lon = float(item.get("longitude", 0))
|
| 589 |
+
except (ValueError, TypeError):
|
| 590 |
+
lat, lon = 0, 0
|
| 591 |
+
if lat == 0 and lon == 0 or not city_center:
|
| 592 |
+
verified.append(item)
|
| 593 |
+
else:
|
| 594 |
+
dist = _haversine_km(city_center[0], city_center[1], lat, lon)
|
| 595 |
+
if dist <= MAX_CITY_DIST_KM:
|
| 596 |
+
verified.append(item)
|
| 597 |
+
|
| 598 |
+
return verified
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def _get_client() -> OpenAI:
|
| 602 |
+
"""Create an OpenAI client using environment variables."""
|
| 603 |
+
base_url = os.environ.get("OPENAI_BASE_URL", os.environ.get("OPENAI_API_BASE", None))
|
| 604 |
+
api_key = os.environ.get("OPENAI_API_KEY", "sk-dummy")
|
| 605 |
+
default_headers = None
|
| 606 |
+
if "ollama.com" in (base_url or ""):
|
| 607 |
+
default_headers = {"Authorization": f"Bearer {api_key}"}
|
| 608 |
+
api_key = "ollama"
|
| 609 |
+
return OpenAI(api_key=api_key, base_url=base_url, default_headers=default_headers)
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def _get_models() -> list[str]:
|
| 613 |
+
"""Return the ordered list of models to try — primary first, then fallbacks."""
|
| 614 |
+
primary = os.environ.get("LLM_MODEL", os.environ.get("OPENAI_MODEL", "gpt-4o-mini"))
|
| 615 |
+
fallback_str = os.environ.get("LLM_FALLBACK_MODELS", "")
|
| 616 |
+
fallbacks = [m.strip() for m in fallback_str.split(",") if m.strip()]
|
| 617 |
+
return [primary] + fallbacks
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def _parse_json_response(raw: str) -> list[dict] | None:
|
| 621 |
+
"""Robustly extract JSON array from LLM output.
|
| 622 |
+
Returns None if parsing fails entirely (caller should show st.error)."""
|
| 623 |
+
text = raw.strip()
|
| 624 |
+
text = re.sub(r"^```(?:json)?\s*\n?", "", text)
|
| 625 |
+
text = re.sub(r"\n?```\s*$", "", text)
|
| 626 |
+
text = text.strip()
|
| 627 |
+
|
| 628 |
+
try:
|
| 629 |
+
parsed = json.loads(text)
|
| 630 |
+
if isinstance(parsed, list):
|
| 631 |
+
return parsed
|
| 632 |
+
if isinstance(parsed, dict):
|
| 633 |
+
return [parsed]
|
| 634 |
+
except json.JSONDecodeError:
|
| 635 |
+
pass
|
| 636 |
+
|
| 637 |
+
start = text.find("[")
|
| 638 |
+
end = text.rfind("]")
|
| 639 |
+
if start != -1 and end > start:
|
| 640 |
+
candidate = text[start:end + 1]
|
| 641 |
+
try:
|
| 642 |
+
parsed = json.loads(candidate)
|
| 643 |
+
if isinstance(parsed, list):
|
| 644 |
+
return parsed
|
| 645 |
+
except json.JSONDecodeError:
|
| 646 |
+
pass
|
| 647 |
+
# Truncated JSON: try closing the last open object + array
|
| 648 |
+
truncated = text[start:]
|
| 649 |
+
# Remove trailing incomplete value (partial string after last colon)
|
| 650 |
+
truncated = re.sub(r'[,\s]*"[^"]*":\s*"[^"]*$', '', truncated)
|
| 651 |
+
for closing in ['}]}', '}]', '}', ']']:
|
| 652 |
+
attempt = truncated + closing
|
| 653 |
+
try:
|
| 654 |
+
parsed = json.loads(attempt)
|
| 655 |
+
if isinstance(parsed, list) and len(parsed) > 0:
|
| 656 |
+
return parsed
|
| 657 |
+
except json.JSONDecodeError:
|
| 658 |
+
continue
|
| 659 |
+
|
| 660 |
+
pattern = re.compile(r"\[[\s\S]*\](?=\s*$|\s*```)", re.MULTILINE)
|
| 661 |
+
matches = pattern.findall(text)
|
| 662 |
+
for match in reversed(matches):
|
| 663 |
+
try:
|
| 664 |
+
parsed = json.loads(match)
|
| 665 |
+
if isinstance(parsed, list):
|
| 666 |
+
return parsed
|
| 667 |
+
except json.JSONDecodeError:
|
| 668 |
+
continue
|
| 669 |
+
|
| 670 |
+
return None
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def _verify_with_model(items: list[dict], city: str, models: list[str]) -> list[dict]:
|
| 675 |
+
"""Use a fallback model to verify which attractions are actually in the target city.
|
| 676 |
+
The LLM sometimes lists attractions from other cities. Nominatim can catch
|
| 677 |
+
most of these, but this adds a second verification layer.
|
| 678 |
+
Returns only items confirmed to be in the target city."""
|
| 679 |
+
if not items or len(models) < 2:
|
| 680 |
+
return items
|
| 681 |
+
|
| 682 |
+
client = _get_client()
|
| 683 |
+
# Use the third model (not primary or first fallback) for verification
|
| 684 |
+
if len(models) >= 3:
|
| 685 |
+
verifier_model = models[2]
|
| 686 |
+
elif len(models) >= 2:
|
| 687 |
+
verifier_model = models[1]
|
| 688 |
+
else:
|
| 689 |
+
return items
|
| 690 |
+
|
| 691 |
+
names = [item.get("name", "") for item in items]
|
| 692 |
+
names_str = "\n".join(f"{i+1}. {name}" for i, name in enumerate(names))
|
| 693 |
+
|
| 694 |
+
prompt = f"""You are a city geography expert. Determine which of these attractions are actually located IN the city of {city}.
|
| 695 |
+
|
| 696 |
+
For each attraction, answer ONLY "YES" (it is located in {city}) or "NO" (it is in a different city, or is a well-known landmark from elsewhere).
|
| 697 |
+
|
| 698 |
+
Return ONLY a JSON array of indices (1-based) that are YES, like [1, 3, 4]. No other text.
|
| 699 |
+
|
| 700 |
+
Attractions:
|
| 701 |
+
{names_str}"""
|
| 702 |
+
|
| 703 |
+
try:
|
| 704 |
+
response = client.chat.completions.create(
|
| 705 |
+
model=verifier_model,
|
| 706 |
+
messages=[{"role": "user", "content": prompt}],
|
| 707 |
+
temperature=0,
|
| 708 |
+
max_tokens=512,
|
| 709 |
+
)
|
| 710 |
+
raw = response.choices[0].message.content
|
| 711 |
+
if raw and raw.strip():
|
| 712 |
+
# Parse JSON array of indices
|
| 713 |
+
text = re.sub(r"^```(?:json)?\s*\n?", "", raw.strip())
|
| 714 |
+
text = re.sub(r"\n?```\s*$", "", text)
|
| 715 |
+
text = text.strip()
|
| 716 |
+
start = text.find("[")
|
| 717 |
+
end = text.rfind("]")
|
| 718 |
+
if start != -1 and end > start:
|
| 719 |
+
indices = json.loads(text[start:end+1])
|
| 720 |
+
if isinstance(indices, list):
|
| 721 |
+
verified = [items[i-1] for i in indices if 1 <= i <= len(items)]
|
| 722 |
+
if verified:
|
| 723 |
+
return verified
|
| 724 |
+
except Exception:
|
| 725 |
+
pass
|
| 726 |
+
return items
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def _call_model(client, model: str, prompt: str, temperature: float = 0.1) -> list[dict] | None:
|
| 730 |
+
"""Call a single model, parse JSON response, return items or None. Uses generous timeout."""
|
| 731 |
+
for attempt in range(3): # 3 attempts instead of 2
|
| 732 |
+
try:
|
| 733 |
+
response = client.chat.completions.create(
|
| 734 |
+
model=model,
|
| 735 |
+
messages=[{"role": "user", "content": prompt}],
|
| 736 |
+
temperature=temperature,
|
| 737 |
+
max_tokens=3072,
|
| 738 |
+
timeout=60,
|
| 739 |
+
)
|
| 740 |
+
raw = response.choices[0].message.content
|
| 741 |
+
if raw and raw.strip():
|
| 742 |
+
items = _parse_json_response(raw.strip())
|
| 743 |
+
if items is not None:
|
| 744 |
+
return items
|
| 745 |
+
if attempt < 1:
|
| 746 |
+
time.sleep(1)
|
| 747 |
+
continue
|
| 748 |
+
except Exception:
|
| 749 |
+
if attempt < 1:
|
| 750 |
+
time.sleep(1)
|
| 751 |
+
continue
|
| 752 |
+
break
|
| 753 |
+
return None
|
| 754 |
+
|
| 755 |
+
|
| 756 |
+
def get_recommendations(
|
| 757 |
+
tab: str,
|
| 758 |
+
city: str,
|
| 759 |
+
num_attractions: int = 10,
|
| 760 |
+
categories: dict | None = None,
|
| 761 |
+
) -> list[dict] | None:
|
| 762 |
+
"""Call the LLM to get top-N recommendations.
|
| 763 |
+
|
| 764 |
+
Strategy:
|
| 765 |
+
1. Primary model generates request_count + 2 items
|
| 766 |
+
2. Fallback model generates independently (parallel-ish)
|
| 767 |
+
3. Cross-reference: keep items confirmed by BOTH models (matching by name)
|
| 768 |
+
4. If still short of num_attractions, use a third model as verifier
|
| 769 |
+
5. Always geocode via Nominatim to drop wrong-city entries
|
| 770 |
+
"""
|
| 771 |
+
prompt_template = PROMPT_MAP[tab]
|
| 772 |
+
|
| 773 |
+
# Build category prompt from toggle selections
|
| 774 |
+
category_prompt = ""
|
| 775 |
+
if categories:
|
| 776 |
+
enabled = [cat for cat, on in categories.items() if on]
|
| 777 |
+
if enabled:
|
| 778 |
+
lines = [CATEGORY_GUIDANCE[cat].format(city=city) for cat in enabled if cat in CATEGORY_GUIDANCE]
|
| 779 |
+
if lines:
|
| 780 |
+
category_prompt = lines[0]
|
| 781 |
+
|
| 782 |
+
# Ask for n+4 to have enough spares after geocoding filtering (Kyoto is compact, many get dropped)
|
| 783 |
+
request_count = num_attractions + 4
|
| 784 |
+
prompt = prompt_template.format(
|
| 785 |
+
category_prompt=category_prompt,
|
| 786 |
+
num_attractions=request_count,
|
| 787 |
+
)
|
| 788 |
+
# Add instruction to avoid controversial places
|
| 789 |
+
prompt += "\n\nIMPORTANT: Do NOT include any politically controversial attractions, war museums, or memorials that might be offensive to some visitors. Focus on universally enjoyed tourist attractions."
|
| 790 |
+
|
| 791 |
+
client = _get_client()
|
| 792 |
+
models = _get_models()
|
| 793 |
+
|
| 794 |
+
# Step 1: Try primary model
|
| 795 |
+
primary_items = _call_model(client, models[0], prompt)
|
| 796 |
+
if primary_items:
|
| 797 |
+
primary_items = _enrich_with_images(primary_items, city=city)
|
| 798 |
+
primary_items = _verify_coordinates(primary_items, city)
|
| 799 |
+
else:
|
| 800 |
+
primary_items = []
|
| 801 |
+
|
| 802 |
+
# Step 2: Try fallback models if primary gave nothing
|
| 803 |
+
fallback_items = []
|
| 804 |
+
for fb_model in models[1:]:
|
| 805 |
+
if len(fallback_items) > 0:
|
| 806 |
+
break
|
| 807 |
+
fb_items = _call_model(client, fb_model, prompt)
|
| 808 |
+
if fb_items:
|
| 809 |
+
fb_items = _enrich_with_images(fb_items, city=city)
|
| 810 |
+
fb_items = _verify_coordinates(fb_items, city)
|
| 811 |
+
if fb_items:
|
| 812 |
+
fallback_items = fb_items
|
| 813 |
+
|
| 814 |
+
# If still nothing, try all models one more time
|
| 815 |
+
combined = (primary_items or []) + (fallback_items or [])
|
| 816 |
+
if not combined:
|
| 817 |
+
for model in models:
|
| 818 |
+
items = _call_model(client, model, prompt)
|
| 819 |
+
if items:
|
| 820 |
+
combined = _enrich_with_images(items, city=city)
|
| 821 |
+
combined = _verify_coordinates(combined, city)
|
| 822 |
+
if combined:
|
| 823 |
+
break
|
| 824 |
+
if not combined:
|
| 825 |
+
return None
|
| 826 |
+
# Assign retry results to primary_items so dedup works
|
| 827 |
+
primary_items = combined
|
| 828 |
+
fallback_items = []
|
| 829 |
+
|
| 830 |
+
# Step 3: Cross-reference — keep items confirmed by Nominatim in BOTH lists
|
| 831 |
+
def name_key(item):
|
| 832 |
+
"""Normalize name for matching — strips suffixes to catch 'Kiyomizu-dera' vs 'Kiyomizu-dera Temple'."""
|
| 833 |
+
name = item.get("name", "").lower()
|
| 834 |
+
name = re.sub(r"\s*\(.*?\)\s*$", "", name)
|
| 835 |
+
# Strip common suffixes that cause duplicates
|
| 836 |
+
for suffix in _ATTRACTION_SUFFIXES:
|
| 837 |
+
if name.endswith(suffix) and len(name) > len(suffix) + 2:
|
| 838 |
+
name = name[:-len(suffix)].strip()
|
| 839 |
+
name = re.sub(r"[^a-z0-9\s]", "", name)
|
| 840 |
+
return name.strip()
|
| 841 |
+
|
| 842 |
+
# Build a unified list: items in primary, then items in fallback not already in primary
|
| 843 |
+
seen_names = set()
|
| 844 |
+
merged = []
|
| 845 |
+
|
| 846 |
+
for item in primary_items:
|
| 847 |
+
key = name_key(item)
|
| 848 |
+
if key not in seen_names:
|
| 849 |
+
seen_names.add(key)
|
| 850 |
+
merged.append(item)
|
| 851 |
+
|
| 852 |
+
for item in fallback_items:
|
| 853 |
+
key = name_key(item)
|
| 854 |
+
if key not in seen_names:
|
| 855 |
+
seen_names.add(key)
|
| 856 |
+
merged.append(item)
|
| 857 |
+
|
| 858 |
+
# Step 4: Use third model as verifier if merged list > num_attractions
|
| 859 |
+
if len(merged) > request_count and len(models) > 2:
|
| 860 |
+
merged = _verify_with_model(merged, city, models)
|
| 861 |
+
|
| 862 |
+
# Step 5: Filter out controversial places and combined names
|
| 863 |
+
_CONTROVERSIAL_PLACES = {
|
| 864 |
+
"yasukuni",
|
| 865 |
+
"yasukuni shrine",
|
| 866 |
+
}
|
| 867 |
+
merged = [
|
| 868 |
+
item for item in merged
|
| 869 |
+
if not any(
|
| 870 |
+
bad in item.get("name", "").lower()
|
| 871 |
+
for bad in _CONTROVERSIAL_PLACES
|
| 872 |
+
)
|
| 873 |
+
]
|
| 874 |
+
|
| 875 |
+
# Also split any combined names with &, /, or " and " — keep only first place
|
| 876 |
+
for item in merged:
|
| 877 |
+
name = item.get("name", "")
|
| 878 |
+
# Split on common combiners and take the first
|
| 879 |
+
for sep in (" & ", " and ", " / ", "/", " &"):
|
| 880 |
+
if sep in name:
|
| 881 |
+
parts = name.split(sep, 1)
|
| 882 |
+
item["name"] = parts[0].strip()
|
| 883 |
+
break
|
| 884 |
+
|
| 885 |
+
# Strip parenthetical suffixes and trailing qualifiers for the shortest purest name
|
| 886 |
+
for item in merged:
|
| 887 |
+
name = item.get("name", "")
|
| 888 |
+
original = name
|
| 889 |
+
# Remove parenthetical suffixes like "(Mitaka)", "(Asakusa)", "(Kyoto, day-trip)"
|
| 890 |
+
name = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
| 891 |
+
# Remove trailing qualifiers after comma like "Senso-ji, Tokyo" -> "Senso-ji"
|
| 892 |
+
name = re.sub(r",\s*[A-Za-z].*$", "", name).strip()
|
| 893 |
+
# Remove redundant "Temple" from names that already have it (e.g. "Senso-ji, Tokyo (Asakusa)" -> all cleaned)
|
| 894 |
+
# Trim whitespace
|
| 895 |
+
name = name.strip()
|
| 896 |
+
if name:
|
| 897 |
+
item["name"] = name
|
| 898 |
+
|
| 899 |
+
# Step 6: If short by a few items and user wanted 9 or fewer, request extras
|
| 900 |
+
shortfall = num_attractions - len(merged)
|
| 901 |
+
if shortfall > 0 and num_attractions <= 9:
|
| 902 |
+
# Request extra buffer to account for further filtering
|
| 903 |
+
extras_prompt = prompt_template.format(
|
| 904 |
+
category_prompt=category_prompt,
|
| 905 |
+
num_attractions=shortfall + 3,
|
| 906 |
+
)
|
| 907 |
+
# Add same instructions as main prompt
|
| 908 |
+
extras_prompt += "\n\nIMPORTANT: Do NOT include any politically controversial attractions, war museums, or memorials that might be offensive to some visitors. Focus on universally enjoyed tourist attractions."
|
| 909 |
+
# Add instruction to avoid duplicates
|
| 910 |
+
existing_names = {name_key(item) for item in merged}
|
| 911 |
+
extras_prompt += f"\n\nIMPORTANT: Do NOT include any of these already-listed attractions:\n{chr(10).join(f'- {n}' for n in list(existing_names)[:20])}"
|
| 912 |
+
extras_prompt += "\n\nOnly return attractions NOT listed above."
|
| 913 |
+
|
| 914 |
+
# Try the other model for the extras (not the one that generated the main list)
|
| 915 |
+
extras_model = models[2] if len(models) > 2 else (models[1] if len(models) > 1 else models[0])
|
| 916 |
+
extras_items = _call_model(client, extras_model, extras_prompt)
|
| 917 |
+
|
| 918 |
+
# If that failed, try primary model
|
| 919 |
+
if not extras_items and len(models) > 1:
|
| 920 |
+
extras_items = _call_model(client, models[0], extras_prompt)
|
| 921 |
+
|
| 922 |
+
if extras_items:
|
| 923 |
+
extras_items = _enrich_with_images(extras_items, city=city)
|
| 924 |
+
extras_items = _verify_coordinates(extras_items, city)
|
| 925 |
+
for item in extras_items:
|
| 926 |
+
key = name_key(item)
|
| 927 |
+
if key not in seen_names and key:
|
| 928 |
+
seen_names.add(key)
|
| 929 |
+
merged.append(item)
|
| 930 |
+
|
| 931 |
+
# Step 7: Trim to requested count
|
| 932 |
+
items = merged[:num_attractions]
|
| 933 |
+
return items
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def translate_items(items: list[dict], second_language: str, tab: str) -> list[dict]:
|
| 937 |
+
"""Call the LLM to translate recommendation items into a second language."""
|
| 938 |
+
if not second_language or not items:
|
| 939 |
+
return items
|
| 940 |
+
|
| 941 |
+
client = _get_client()
|
| 942 |
+
models = _get_models()
|
| 943 |
+
|
| 944 |
+
# Strip image URLs before translating — they're not needed and bloat the prompt
|
| 945 |
+
items_for_llm = [
|
| 946 |
+
{k: v for k, v in item.items() if k != "image_url"}
|
| 947 |
+
for item in items
|
| 948 |
+
]
|
| 949 |
+
items_json = json.dumps(items_for_llm, ensure_ascii=False, indent=2)
|
| 950 |
+
|
| 951 |
+
sample = items[0] if items else {}
|
| 952 |
+
fields = [k for k in ("name", "short_description", "description", "tip") if k in sample]
|
| 953 |
+
translation_keys = ", ".join(f'"{f}_local": translate the value of "{f}" into {second_language}' for f in fields)
|
| 954 |
+
|
| 955 |
+
prompt = f"""You are a professional translator. Translate the following JSON array of travel recommendations into {second_language}.
|
| 956 |
+
|
| 957 |
+
CRITICAL: If the target language is Traditional Chinese, you MUST use Traditional Chinese characters (繁體字), NOT Simplified Chinese (简体字). Use characters like 的, 們, 國, 會, 後, 發, 時 instead of 的, 们, 国, 会, 后, 发, 时.
|
| 958 |
+
|
| 959 |
+
For each object, add these new keys:
|
| 960 |
+
{translation_keys}
|
| 961 |
+
|
| 962 |
+
Keep all original English keys and values unchanged. Only add the "_local" keys with the {second_language} translations.
|
| 963 |
+
|
| 964 |
+
Input:
|
| 965 |
+
{items_json}
|
| 966 |
+
|
| 967 |
+
Return ONLY the complete JSON array with both English and {second_language} fields. No markdown fences, no extra text."""
|
| 968 |
+
|
| 969 |
+
last_error = None
|
| 970 |
+
for model in models:
|
| 971 |
+
for attempt in range(2):
|
| 972 |
+
try:
|
| 973 |
+
response = client.chat.completions.create(
|
| 974 |
+
model=model,
|
| 975 |
+
messages=[{"role": "user", "content": prompt}],
|
| 976 |
+
temperature=0,
|
| 977 |
+
max_tokens=2048,
|
| 978 |
+
)
|
| 979 |
+
raw = response.choices[0].message.content
|
| 980 |
+
if raw and raw.strip():
|
| 981 |
+
translated = _parse_json_response(raw.strip())
|
| 982 |
+
if isinstance(translated, list):
|
| 983 |
+
if len(translated) != len(items):
|
| 984 |
+
# Length mismatch — skip this model's output
|
| 985 |
+
break
|
| 986 |
+
merged = []
|
| 987 |
+
for orig, trans in zip(items, translated):
|
| 988 |
+
item = dict(orig)
|
| 989 |
+
for k, v in trans.items():
|
| 990 |
+
if k.endswith("_local"):
|
| 991 |
+
item[k] = v
|
| 992 |
+
merged.append(item)
|
| 993 |
+
return merged
|
| 994 |
+
# Parsing failed — retry once
|
| 995 |
+
if attempt < 1:
|
| 996 |
+
time.sleep(1)
|
| 997 |
+
continue
|
| 998 |
+
# Empty or failed — try next model
|
| 999 |
+
break
|
| 1000 |
+
except Exception as e:
|
| 1001 |
+
last_error = e
|
| 1002 |
+
if attempt < 1:
|
| 1003 |
+
time.sleep(1)
|
| 1004 |
+
continue
|
| 1005 |
+
break
|
| 1006 |
+
|
| 1007 |
+
return items
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
# ── Module-level cached wrappers (survive st.cache_data.clear) ──
|
| 1011 |
+
|
| 1012 |
+
def clear_llm_caches() -> None:
|
| 1013 |
+
"""Clear LLM result and translation caches only.
|
| 1014 |
+
Does NOT clear image or geocode caches (those are stable per attraction).
|
| 1015 |
+
Call this when the user clicks Clear in the UI.
|
| 1016 |
+
"""
|
| 1017 |
+
_LLM_CACHE.clear()
|
| 1018 |
+
_TRANSLATION_CACHE.clear()
|
| 1019 |
+
|
| 1020 |
+
|
| 1021 |
+
def get_recommendations_cached(
|
| 1022 |
+
city: str,
|
| 1023 |
+
num_attractions: int = 10,
|
| 1024 |
+
categories: dict | None = None,
|
| 1025 |
+
) -> list[dict] | None:
|
| 1026 |
+
"""Cached version of get_recommendations — avoids repeat LLM calls.
|
| 1027 |
+
Cache key is (city, num_attractions, cat_hash).
|
| 1028 |
+
"""
|
| 1029 |
+
cat_hash = json.dumps(categories or {}, sort_keys=True)
|
| 1030 |
+
key = (city, num_attractions, cat_hash)
|
| 1031 |
+
if key in _LLM_CACHE:
|
| 1032 |
+
return _LLM_CACHE[key]
|
| 1033 |
+
result = get_recommendations(tab="attractions", city=city, num_attractions=num_attractions, categories=categories)
|
| 1034 |
+
_LLM_CACHE[key] = result
|
| 1035 |
+
return result
|
| 1036 |
+
|
| 1037 |
+
|
| 1038 |
+
def translate_items_cached(items: list[dict], items_json: str, second_language: str) -> list[dict]:
|
| 1039 |
+
"""Cached version of translate_items — avoids repeat LLM calls.
|
| 1040 |
+
Cache key uses hash of items_json + language.
|
| 1041 |
+
"""
|
| 1042 |
+
items_hash = hashlib.md5(items_json.encode()).hexdigest()
|
| 1043 |
+
key = (items_hash, second_language)
|
| 1044 |
+
if key in _TRANSLATION_CACHE:
|
| 1045 |
+
return _TRANSLATION_CACHE[key]
|
| 1046 |
+
result = translate_items(items, second_language, "attractions")
|
| 1047 |
+
_TRANSLATION_CACHE[key] = result
|
| 1048 |
+
return result
|
src/streamlit_app.py
CHANGED
|
@@ -1,40 +1,514 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
import streamlit as st
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
| 1 |
+
"""Roam Service — Streamlit App with dark theme and big fonts."""
|
| 2 |
+
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
load_dotenv() # Load .env file
|
| 5 |
+
|
| 6 |
import streamlit as st
|
| 7 |
+
import json
|
| 8 |
+
import folium
|
| 9 |
+
from streamlit_folium import st_folium
|
| 10 |
+
|
| 11 |
+
from styles.dark_theme import apply_dark_theme, EMOJI_MAP
|
| 12 |
+
from services.recommender import get_recommendations_cached, translate_items_cached, clear_llm_caches
|
| 13 |
+
|
| 14 |
+
# ── Popular city suggestions ──
|
| 15 |
+
CITY_SUGGESTIONS = [
|
| 16 |
+
"Abu Dhabi", "Amsterdam", "Antalya", "Athens", "Auckland", "Bali",
|
| 17 |
+
"Bangkok", "Barcelona", "Beijing", "Berlin", "Bogota", "Bordeaux",
|
| 18 |
+
"Boston", "Brisbane", "Bruges", "Brussels", "Budapest", "Buenos Aires",
|
| 19 |
+
"Cairo", "Cancun", "Cape Town", "Cartagena", "Chiang Mai", "Chicago",
|
| 20 |
+
"Copenhagen", "Cusco", "Delhi", "Denver", "Doha", "Dubai", "Dublin",
|
| 21 |
+
"Dubrovnik", "Edinburgh", "Florence", "Fukuoka", "Geneva", "Glasgow",
|
| 22 |
+
"Granada", "Hamburg", "Hanoi", "Helsinki", "Ho Chi Minh City", "Hong Kong",
|
| 23 |
+
"Honolulu", "Hvar", "Innsbruck", "Istanbul", "Jaipur", "Jakarta",
|
| 24 |
+
"Jerusalem", "Johannesburg", "Kathmandu", "Kolkata", "Krakow", "Kuala Lumpur",
|
| 25 |
+
"Kyoto", "Las Vegas", "Lima", "Lisbon", "Liverpool", "London",
|
| 26 |
+
"Los Angeles", "Luxembourg", "Lyon", "Madrid", "Male", "Manchester",
|
| 27 |
+
"Manila", "Marrakech", "Marseille", "Melbourne", "Mexico City", "Miami",
|
| 28 |
+
"Milan", "Monte Carlo", "Montreal", "Moscow", "Munich", "Mumbai",
|
| 29 |
+
"Nairobi", "Naples", "Nashville", "New Delhi", "New Orleans", "New York",
|
| 30 |
+
"Nice", "Osaka", "Oslo", "Paris", "Perth", "Philadelphia", "Phnom Penh",
|
| 31 |
+
"Porto", "Prague", "Queenstown", "Quito", "Reykjavik", "Riga",
|
| 32 |
+
"Rio de Janeiro", "Rome", "Salzburg", "San Diego", "San Francisco",
|
| 33 |
+
"San Sebastian", "Santiago", "Santorini", "Seattle", "Seoul", "Seville",
|
| 34 |
+
"Shanghai", "Siem Reap", "Singapore", "Split", "Stockholm", "Sydney",
|
| 35 |
+
"Taipei", "Tallinn", "Tbilisi", "Tel Aviv", "Tokyo", "Toronto",
|
| 36 |
+
"Ubud", "Valencia", "Vancouver", "Venice", "Vienna", "Vilnius",
|
| 37 |
+
"Warsaw", "Washington", "Zanzibar", "Zurich",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ── Page config ──
|
| 42 |
+
st.set_page_config(
|
| 43 |
+
page_title="Roamify",
|
| 44 |
+
page_icon="✈️",
|
| 45 |
+
layout="wide",
|
| 46 |
+
initial_sidebar_state="collapsed",
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# ── Apply dark theme ──
|
| 50 |
+
apply_dark_theme()
|
| 51 |
+
|
| 52 |
+
# ── Title ──
|
| 53 |
+
st.title("✈️ Roamify")
|
| 54 |
+
st.markdown(
|
| 55 |
+
'<div style="font-size:15px; color:#888; margin-top:-10px; margin-bottom:18px;">Designed by Joe, powered by Hermes Agent · 2026</div>',
|
| 56 |
+
unsafe_allow_html=True,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# ── Category filter (single-select) ──
|
| 60 |
+
CATEGORIES = [
|
| 61 |
+
("Landmark", "🗼"),
|
| 62 |
+
("Culture", "🏛️"),
|
| 63 |
+
("Nature", "🌿"),
|
| 64 |
+
("Gems", "💎"),
|
| 65 |
+
("Photo", "📸"),
|
| 66 |
+
("Food", "🍽️"),
|
| 67 |
+
("Shopping", "🛍️"),
|
| 68 |
+
]
|
| 69 |
+
CATEGORY_LABELS = [f"{emoji} {name}" for name, emoji in CATEGORIES]
|
| 70 |
+
|
| 71 |
+
LANG_OPTIONS = {
|
| 72 |
+
"None (English only)": None,
|
| 73 |
+
"繁體中文 (Traditional Chinese)": "Traditional Chinese",
|
| 74 |
+
"简体中文 (Simplified Chinese)": "Simplified Chinese",
|
| 75 |
+
"日本語 (Japanese)": "Japanese",
|
| 76 |
+
"한국어 (Korean)": "Korean",
|
| 77 |
+
"Français (French)": "French",
|
| 78 |
+
"Español (Spanish)": "Spanish",
|
| 79 |
+
"Deutsch (German)": "German",
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# ── Search form — single row ──
|
| 83 |
+
with st.form("search_form"):
|
| 84 |
+
col_city, col_cat, col_num, col_lang, col_search, col_clear = st.columns([1.5, 3.5, 0.7, 1.0, 0.65, 0.65], gap="medium")
|
| 85 |
+
|
| 86 |
+
with col_city:
|
| 87 |
+
city = st.selectbox("City", CITY_SUGGESTIONS, index=CITY_SUGGESTIONS.index("London"))
|
| 88 |
+
st.markdown(
|
| 89 |
+
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">'
|
| 90 |
+
'<span style="font-size:26px;color:#888;line-height:1;display:inline-block;">⬆</span>'
|
| 91 |
+
'<span style="font-size:16px;color:#888;">First, pick a city.</span>'
|
| 92 |
+
'</div>',
|
| 93 |
+
unsafe_allow_html=True,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
with col_cat:
|
| 97 |
+
selected_category = st.radio(
|
| 98 |
+
"Category",
|
| 99 |
+
options=range(len(CATEGORIES)),
|
| 100 |
+
format_func=lambda i: CATEGORY_LABELS[i],
|
| 101 |
+
horizontal=True,
|
| 102 |
+
index=0,
|
| 103 |
+
)
|
| 104 |
+
st.markdown(
|
| 105 |
+
'<div style="display:flex;align-items:center;gap:8px;margin-top:0px;margin-bottom:14px;">'
|
| 106 |
+
'<span style="font-size:26px;color:#888;line-height:1;display:inline-block;">⬆</span>'
|
| 107 |
+
'<span style="font-size:16px;color:#888;">Next, choose a category.</span>'
|
| 108 |
+
'</div>',
|
| 109 |
+
unsafe_allow_html=True,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
with col_num:
|
| 113 |
+
num_attractions = st.selectbox("Recommendations", [3, 6, 9, 12, 15], index=1)
|
| 114 |
+
|
| 115 |
+
with col_lang:
|
| 116 |
+
selected_lang = st.selectbox("Translation", list(LANG_OPTIONS.keys()), index=0)
|
| 117 |
+
second_language = LANG_OPTIONS[selected_lang]
|
| 118 |
+
|
| 119 |
+
with col_search:
|
| 120 |
+
st.markdown('<div style="font-size:18px;color:#dee2e6;margin-bottom:6px;font-weight:400;">Ready?</div>', unsafe_allow_html=True)
|
| 121 |
+
search = st.form_submit_button("���� Search", use_container_width=True)
|
| 122 |
+
st.markdown(
|
| 123 |
+
'<div style="display:flex;align-items:center;gap:8px;margin-top:0px;margin-bottom:14px;">'
|
| 124 |
+
'<span style="font-size:26px;color:#888;line-height:1;display:inline-block;">⬆</span>'
|
| 125 |
+
'<span style="font-size:16px;color:#888;">Let\'s go!</span>'
|
| 126 |
+
'</div>',
|
| 127 |
+
unsafe_allow_html=True,
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
with col_clear:
|
| 131 |
+
st.markdown('<div style="font-size:18px;color:#dee2e6;margin-bottom:6px;font-weight:400;">Cache</div>', unsafe_allow_html=True)
|
| 132 |
+
clear = st.form_submit_button("🗑️ Clear", use_container_width=True)
|
| 133 |
+
|
| 134 |
+
# ── Track whether Clear was just clicked ──
|
| 135 |
+
if "skip_cache" not in st.session_state:
|
| 136 |
+
st.session_state.skip_cache = False
|
| 137 |
+
|
| 138 |
+
if clear:
|
| 139 |
+
clear_llm_caches() # Only clears LLM + translation caches; keeps image + geocode caches
|
| 140 |
+
# Save last results before cache is gone
|
| 141 |
+
if "last_attractions" not in st.session_state:
|
| 142 |
+
st.session_state["last_attractions"] = None
|
| 143 |
+
st.session_state.skip_cache = True
|
| 144 |
+
st.toast("LLM cache cleared (images & map data kept)", icon="🗑️")
|
| 145 |
+
|
| 146 |
+
# ── Validation ──
|
| 147 |
+
if search:
|
| 148 |
+
# Build categories dict from single-select radio
|
| 149 |
+
categories = {name: (i == selected_category) for i, (name, _) in enumerate(CATEGORIES)}
|
| 150 |
+
if not city.strip():
|
| 151 |
+
st.error("Please enter a city!")
|
| 152 |
+
else:
|
| 153 |
+
st.session_state["do_search"] = True
|
| 154 |
+
st.session_state["search_params"] = {
|
| 155 |
+
"city": city.strip(),
|
| 156 |
+
"num_attractions": num_attractions,
|
| 157 |
+
"second_language": second_language,
|
| 158 |
+
"categories": categories,
|
| 159 |
+
}
|
| 160 |
+
st.session_state.skip_cache = False
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _short_name(text: str, max_len: int = 22) -> str:
|
| 164 |
+
"""Truncate name to fit one line in the card summary."""
|
| 165 |
+
if len(text) <= max_len:
|
| 166 |
+
return text
|
| 167 |
+
return text[:max_len].rstrip() + "…"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _render_cards(items: list[dict], translated: bool = False) -> None:
|
| 171 |
+
"""Render items as a 3-column grid of expandable cards with uniform heights per row."""
|
| 172 |
+
COLS = 3
|
| 173 |
+
|
| 174 |
+
# Build rows of items
|
| 175 |
+
rows_data = []
|
| 176 |
+
for row_start in range(0, len(items), COLS):
|
| 177 |
+
rows_data.append(items[row_start:row_start + COLS])
|
| 178 |
+
|
| 179 |
+
# For each row, compute max lines needed for descriptions
|
| 180 |
+
CHARS_PER_LINE = 30 # estimated chars per line at 16px in 3-col layout
|
| 181 |
+
|
| 182 |
+
for row_idx, row_items in enumerate(rows_data):
|
| 183 |
+
# Find max description length in this row
|
| 184 |
+
descs = []
|
| 185 |
+
for item in row_items:
|
| 186 |
+
d = item.get("description_local" if translated and item.get("description_local") else "description", "")
|
| 187 |
+
descs.append(d)
|
| 188 |
+
|
| 189 |
+
max_desc_lines = max((len(d) + CHARS_PER_LINE - 1) // CHARS_PER_LINE for d in descs) if descs else 1
|
| 190 |
+
|
| 191 |
+
# Render this row
|
| 192 |
+
cols = st.columns(COLS, gap="small")
|
| 193 |
+
for col_idx, item in enumerate(row_items):
|
| 194 |
+
i = row_idx * COLS + col_idx + 1
|
| 195 |
+
name = item.get("name", "Unknown")
|
| 196 |
+
description = item.get("description", "")
|
| 197 |
+
name_local = item.get("name_local", "")
|
| 198 |
+
description_local = item.get("description_local", "")
|
| 199 |
+
|
| 200 |
+
label = f"**{i}. {_short_name(name)}**"
|
| 201 |
+
if translated and name_local:
|
| 202 |
+
label += f" **— {_short_name(name_local)}**"
|
| 203 |
+
|
| 204 |
+
# Compute padding for this card's description
|
| 205 |
+
actual_desc = description_local if translated and description_local else description
|
| 206 |
+
desc_lines = (len(actual_desc) + CHARS_PER_LINE - 1) // CHARS_PER_LINE
|
| 207 |
+
desc_padding = "<br>" * (max_desc_lines - desc_lines)
|
| 208 |
+
|
| 209 |
+
with cols[col_idx]:
|
| 210 |
+
expand_by_default = (len(items) <= 6) or (i <= 3)
|
| 211 |
+
# Hidden marker for card↔map hover sync
|
| 212 |
+
st.markdown(f'<div class="card-pin" data-card-idx="{i}" style="display:none;"></div>', unsafe_allow_html=True)
|
| 213 |
+
with st.expander(label, expanded=expand_by_default):
|
| 214 |
+
image_url = item.get("image_url", "")
|
| 215 |
+
if image_url:
|
| 216 |
+
st.markdown(
|
| 217 |
+
f'<div style="width:100%;aspect-ratio:16/9;overflow:hidden;'
|
| 218 |
+
f'border-radius:8px;background:#1c2333;margin-bottom:12px;">'
|
| 219 |
+
f'<img src="{image_url}" style="width:100%;height:100%;'
|
| 220 |
+
f'object-fit:cover;object-position:center;display:block;" '
|
| 221 |
+
f'loading="lazy" alt="{name}" class="card-img"/>'
|
| 222 |
+
f'</div>',
|
| 223 |
+
unsafe_allow_html=True,
|
| 224 |
+
)
|
| 225 |
+
else:
|
| 226 |
+
st.markdown(
|
| 227 |
+
'<div style="display:flex;align-items:center;justify-content:center;'
|
| 228 |
+
'width:100%;aspect-ratio:16/9;background:#111;border-radius:8px;font-size:48px;'
|
| 229 |
+
'margin-bottom:12px;">'
|
| 230 |
+
'🏛️</div>',
|
| 231 |
+
unsafe_allow_html=True,
|
| 232 |
+
)
|
| 233 |
+
# Description only (tips moved to map popups)
|
| 234 |
+
st.markdown(f'<div class="card-desc">{actual_desc}{desc_padding}</div>', unsafe_allow_html=True)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _build_map(items: list[dict]) -> folium.Map:
|
| 238 |
+
"""Build a folium map with true spider legs: overlapping numbered circles
|
| 239 |
+
fan out radially from their cluster centroid with straight leader lines
|
| 240 |
+
connecting back to small dots at the true locations."""
|
| 241 |
+
|
| 242 |
+
valid_coords = [
|
| 243 |
+
(float(item["latitude"]), float(item["longitude"]))
|
| 244 |
+
for item in items
|
| 245 |
+
if item.get("latitude") is not None and item.get("longitude") is not None
|
| 246 |
+
and str(item.get("latitude", "")).strip() != ""
|
| 247 |
+
and str(item.get("longitude", "")).strip() != ""
|
| 248 |
+
]
|
| 249 |
+
if valid_coords:
|
| 250 |
+
center_lat = sum(c[0] for c in valid_coords) / len(valid_coords)
|
| 251 |
+
center_lon = sum(c[1] for c in valid_coords) / len(valid_coords)
|
| 252 |
+
else:
|
| 253 |
+
center_lat, center_lon = 48.8566, 2.3522
|
| 254 |
+
|
| 255 |
+
m = folium.Map(
|
| 256 |
+
location=[center_lat, center_lon],
|
| 257 |
+
tiles="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
| 258 |
+
attr="© <a href='https://carto.com/'>CARTO</a>",
|
| 259 |
+
name="CartoDB dark",
|
| 260 |
+
zoom_control=False,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Remove Leaflet attribution control entirely
|
| 264 |
+
m.get_root().html.add_child(folium.Element(
|
| 265 |
+
'<style>.leaflet-control-attribution{display:none!important}</style>'
|
| 266 |
+
))
|
| 267 |
+
|
| 268 |
+
marker_coords = []
|
| 269 |
+
|
| 270 |
+
for i, item in enumerate(items, 1):
|
| 271 |
+
try:
|
| 272 |
+
lat = float(item.get("latitude", 0))
|
| 273 |
+
lon = float(item.get("longitude", 0))
|
| 274 |
+
except (ValueError, TypeError):
|
| 275 |
+
continue
|
| 276 |
+
if lat == 0 and lon == 0:
|
| 277 |
+
continue
|
| 278 |
+
|
| 279 |
+
name = item.get("name", "Unknown")
|
| 280 |
+
name_local = item.get("name_local", "")
|
| 281 |
+
tip = item.get("tip_local", "") or item.get("tip", "")
|
| 282 |
+
|
| 283 |
+
# Build popup with Name and Tip — block layout for spacing
|
| 284 |
+
lines = [f"<div style='color:#2a9fd6; font-size:16px; font-weight:bold'>{i}. {name}</div>"]
|
| 285 |
+
if name_local:
|
| 286 |
+
lines.append(f"<div style='color:#aaa; font-size:13px'>{name_local}</div>")
|
| 287 |
+
if tip:
|
| 288 |
+
lines.append(f"<div style='font-size:15px; margin-top:6px'>💡 {tip}</div>")
|
| 289 |
+
popup_html = "".join(lines)
|
| 290 |
+
|
| 291 |
+
marker_coords.append([lat, lon])
|
| 292 |
+
|
| 293 |
+
# Small anchor dot at true position
|
| 294 |
+
folium.CircleMarker(
|
| 295 |
+
location=[lat, lon],
|
| 296 |
+
radius=4,
|
| 297 |
+
color="#2a9fd6",
|
| 298 |
+
fill=True,
|
| 299 |
+
fill_color="#2a9fd6",
|
| 300 |
+
fill_opacity=0.9,
|
| 301 |
+
weight=1,
|
| 302 |
+
).add_to(m)
|
| 303 |
+
|
| 304 |
+
# Numbered circle marker (position updated by JS)
|
| 305 |
+
folium.Marker(
|
| 306 |
+
location=[lat, lon],
|
| 307 |
+
popup=folium.Popup(popup_html, max_width=260, offset=(0, -25)),
|
| 308 |
+
icon=folium.DivIcon(
|
| 309 |
+
html=(
|
| 310 |
+
f'<div class="spider-marker" data-idx="{i}" data-lat="{lat}" data-lng="{lon}" style="'
|
| 311 |
+
f'display:flex;align-items:center;justify-content:center;'
|
| 312 |
+
f'width:36px;height:36px;border-radius:50%;'
|
| 313 |
+
f'background:#2a9fd6;color:#fff;font-size:18px;font-weight:700;'
|
| 314 |
+
f'box-shadow:0 2px 6px rgba(0,0,0,0.5);'
|
| 315 |
+
f'cursor:pointer;">'
|
| 316 |
+
f'{i}</div>'
|
| 317 |
+
),
|
| 318 |
+
icon_size=(36, 36),
|
| 319 |
+
icon_anchor=(18, 18),
|
| 320 |
+
),
|
| 321 |
+
).add_to(m)
|
| 322 |
+
|
| 323 |
+
# Fit map bounds to show all markers with slight padding
|
| 324 |
+
if marker_coords:
|
| 325 |
+
m.fit_bounds(marker_coords, padding=(30, 30))
|
| 326 |
+
|
| 327 |
+
# Spider legs: cluster detection → radial fan-out → leader lines
|
| 328 |
+
spider_js = """<script>
|
| 329 |
+
(function(){
|
| 330 |
+
var MIN_DIST=48, LEG_LENGTH=44, svgEl=null;
|
| 331 |
+
function findMap(){for(var k in window){try{if(window[k] instanceof L.Map)return window[k]}catch(e){}}return null}
|
| 332 |
+
function ensureSvg(m){
|
| 333 |
+
if(svgEl)return svgEl;
|
| 334 |
+
var c=m.getContainer();
|
| 335 |
+
svgEl=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
| 336 |
+
svgEl.style.cssText='position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:450;';
|
| 337 |
+
c.appendChild(svgEl);return svgEl;
|
| 338 |
+
}
|
| 339 |
+
function run(){
|
| 340 |
+
var map=findMap();if(!map)return;
|
| 341 |
+
var svg=ensureSvg(map);
|
| 342 |
+
var els=document.querySelectorAll('.spider-marker');
|
| 343 |
+
if(!els.length)return;
|
| 344 |
+
var pts=[];
|
| 345 |
+
els.forEach(function(el){
|
| 346 |
+
var lat=parseFloat(el.getAttribute('data-lat')),lng=parseFloat(el.getAttribute('data-lng'));
|
| 347 |
+
var cp=map.latLngToContainerPoint([lat,lng]);
|
| 348 |
+
pts.push({el:el,x:cp.x,y:cp.y,ox:cp.x,oy:cp.y,idx:parseInt(el.getAttribute('data-idx'))});
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
// Reset all positions
|
| 352 |
+
pts.forEach(function(p){p.x=p.ox;p.y=p.oy;p.el.style.transform=''});
|
| 353 |
+
|
| 354 |
+
// Find clusters (groups of markers within MIN_DIST of each other)
|
| 355 |
+
var clusters=[], assigned={};
|
| 356 |
+
for(var i=0;i<pts.length;i++){
|
| 357 |
+
if(assigned[i])continue;
|
| 358 |
+
var cluster=[i]; assigned[i]=true;
|
| 359 |
+
for(var j=i+1;j<pts.length;j++){
|
| 360 |
+
if(assigned[j])continue;
|
| 361 |
+
for(var k=0;k<cluster.length;k++){
|
| 362 |
+
var ci=cluster[k];
|
| 363 |
+
var dx=pts[j].x-pts[ci].x, dy=pts[j].y-pts[ci].y;
|
| 364 |
+
if(Math.sqrt(dx*dx+dy*dy)<MIN_DIST){cluster.push(j);assigned[j]=true;break;}
|
| 365 |
+
}
|
| 366 |
+
}
|
| 367 |
+
if(cluster.length>1)clusters.push(cluster);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Clear old lines
|
| 371 |
+
svg.querySelectorAll('line').forEach(function(l){l.remove()});
|
| 372 |
+
|
| 373 |
+
// For each cluster: compute centroid, fan out radially
|
| 374 |
+
clusters.forEach(function(cidxs){
|
| 375 |
+
var cx=0,cy=0;
|
| 376 |
+
cidxs.forEach(function(i){cx+=pts[i].ox;cy+=pts[i].oy;});
|
| 377 |
+
cx/=cidxs.length;cy/=cidxs.length;
|
| 378 |
+
|
| 379 |
+
var n=cidxs.length;
|
| 380 |
+
var startAngle=0;
|
| 381 |
+
|
| 382 |
+
cidxs.forEach(function(i,k){
|
| 383 |
+
var angle=startAngle+(k*2*Math.PI/n);
|
| 384 |
+
var tx=cx+Math.cos(angle)*LEG_LENGTH;
|
| 385 |
+
var ty=cy+Math.sin(angle)*LEG_LENGTH;
|
| 386 |
+
|
| 387 |
+
var ox=tx-pts[i].ox, oy=ty-pts[i].oy;
|
| 388 |
+
pts[i].x=tx;pts[i].y=ty;
|
| 389 |
+
pts[i].el.style.transform='translate('+ox+'px,'+oy+'px)';
|
| 390 |
+
|
| 391 |
+
var line=document.createElementNS('http://www.w3.org/2000/svg','line');
|
| 392 |
+
line.setAttribute('x1',pts[i].ox);line.setAttribute('y1',pts[i].oy);
|
| 393 |
+
line.setAttribute('x2',tx);line.setAttribute('y2',ty);
|
| 394 |
+
line.setAttribute('stroke','#2a9fd6');
|
| 395 |
+
line.setAttribute('stroke-width','1.5');
|
| 396 |
+
line.setAttribute('stroke-opacity','0.7');
|
| 397 |
+
svg.appendChild(line);
|
| 398 |
+
});
|
| 399 |
+
});
|
| 400 |
+
}
|
| 401 |
+
function init(){
|
| 402 |
+
var m=findMap();if(!m){setTimeout(init,200);return}
|
| 403 |
+
m.on('moveend',run);m.on('zoomend',run);setTimeout(run,300);
|
| 404 |
+
}
|
| 405 |
+
if(document.readyState==='complete')init();else window.addEventListener('load',init);
|
| 406 |
+
})();
|
| 407 |
+
</script>"""
|
| 408 |
+
m.get_root().html.add_child(folium.Element(spider_js))
|
| 409 |
+
|
| 410 |
+
return m
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
# ── Results ──
|
| 414 |
+
if st.session_state.get("do_search") and not st.session_state.skip_cache:
|
| 415 |
+
params = st.session_state["search_params"]
|
| 416 |
+
sec_lang = params.get("second_language")
|
| 417 |
+
|
| 418 |
+
try:
|
| 419 |
+
with st.spinner(f"Finding recommendations in {params['city']}..."):
|
| 420 |
+
attractions = get_recommendations_cached(
|
| 421 |
+
city=params["city"],
|
| 422 |
+
num_attractions=params["num_attractions"],
|
| 423 |
+
categories=params.get("categories"),
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
if attractions is None:
|
| 427 |
+
st.error("Failed to get recommendations. The AI response couldn't be parsed. Please try again.")
|
| 428 |
+
st.stop()
|
| 429 |
+
|
| 430 |
+
# Store in session state for survival across Clear clicks
|
| 431 |
+
st.session_state["last_attractions"] = attractions
|
| 432 |
+
|
| 433 |
+
if sec_lang:
|
| 434 |
+
with st.spinner(f"Translating into {sec_lang}..."):
|
| 435 |
+
attractions = translate_items_cached(
|
| 436 |
+
items=attractions,
|
| 437 |
+
items_json=json.dumps(attractions, ensure_ascii=False, sort_keys=True),
|
| 438 |
+
second_language=sec_lang,
|
| 439 |
+
)
|
| 440 |
+
st.session_state["last_attractions"] = attractions
|
| 441 |
+
|
| 442 |
+
except RuntimeError as e:
|
| 443 |
+
st.error(f"⚠️ {e}")
|
| 444 |
+
st.stop()
|
| 445 |
+
except Exception as e:
|
| 446 |
+
st.error(f"Something went wrong: {e}")
|
| 447 |
+
st.stop()
|
| 448 |
+
|
| 449 |
+
# ── Two-column layout: cards (left) | map (right) ──
|
| 450 |
+
left_col, right_col = st.columns([1, 1])
|
| 451 |
+
|
| 452 |
+
with left_col:
|
| 453 |
+
st.subheader(f"{EMOJI_MAP['attractions']} Recommendations")
|
| 454 |
+
with st.container(height=800, border=False):
|
| 455 |
+
_render_cards(attractions, translated=bool(sec_lang))
|
| 456 |
+
|
| 457 |
+
with right_col:
|
| 458 |
+
st.subheader("🗺️ Map")
|
| 459 |
+
st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True)
|
| 460 |
+
m = _build_map(attractions)
|
| 461 |
+
st_folium(m, width="100%", height=800, returned_objects=[])
|
| 462 |
+
|
| 463 |
+
elif st.session_state.get("last_attractions"):
|
| 464 |
+
# After Clear: show cached session state results without re-calling LLM
|
| 465 |
+
attractions = st.session_state["last_attractions"]
|
| 466 |
+
left_col, right_col = st.columns([1, 1])
|
| 467 |
+
|
| 468 |
+
with left_col:
|
| 469 |
+
st.subheader(f"{EMOJI_MAP['attractions']} Recommendations")
|
| 470 |
+
with st.container(height=800, border=False):
|
| 471 |
+
_render_cards(attractions, translated=False)
|
| 472 |
+
|
| 473 |
+
with right_col:
|
| 474 |
+
st.subheader("🗺️ Map")
|
| 475 |
+
st.markdown('<div style="margin-bottom:10px;"></div>', unsafe_allow_html=True)
|
| 476 |
+
m = _build_map(attractions)
|
| 477 |
+
st_folium(m, width="100%", height=800, returned_objects=[])
|
| 478 |
|
| 479 |
+
else:
|
| 480 |
+
# ── Onboarding: hero card panel ──
|
| 481 |
+
import re
|
| 482 |
+
hero_html = """<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;
|
| 483 |
+
min-height:600px;padding:80px 20px 40px;text-align:center;">
|
| 484 |
+
<div style="font-size:120px;margin-bottom:16px;line-height:1;">🧳</div>
|
| 485 |
+
<div style="font-size:42px;font-weight:700;color:#dee2e6;margin-bottom:8px;">
|
| 486 |
+
Where to next?
|
| 487 |
+
</div>
|
| 488 |
+
<div style="font-size:20px;color:#888;max-width:520px;margin-bottom:32px;line-height:1.6;">
|
| 489 |
+
Choose a city, tell us what you love, and get tailored recommendations.
|
| 490 |
+
</div>
|
| 491 |
+
<div style="display:flex;gap:20px;flex-wrap:wrap;justify-content:center;margin-bottom:40px;">
|
| 492 |
+
<div style="background:#1c2333;border-radius:12px;padding:20px 24px;width:160px;border:1px solid #2a2f3a;">
|
| 493 |
+
<div style="font-size:40px;margin-bottom:8px;">🗼</div>
|
| 494 |
+
<div style="font-weight:600;color:#dee2e6;font-size:14px;">Landmarks</div>
|
| 495 |
+
<div style="font-size:12px;color:#666;margin-top:4px;">Colosseum, Taj Mahal, Big Ben</div>
|
| 496 |
+
</div>
|
| 497 |
+
<div style="background:#1c2333;border-radius:12px;padding:20px 24px;width:160px;border:1px solid #2a2f3a;">
|
| 498 |
+
<div style="font-size:40px;margin-bottom:8px;">🏛️</div>
|
| 499 |
+
<div style="font-weight:600;color:#dee2e6;font-size:14px;">Culture</div>
|
| 500 |
+
<div style="font-size:12px;color:#666;margin-top:4px;">Louvre, British Museum, Uffizi</div>
|
| 501 |
+
</div>
|
| 502 |
+
<div style="background:#1c2333;border-radius:12px;padding:20px 24px;width:160px;border:1px solid #2a2f3a;">
|
| 503 |
+
<div style="font-size:40px;margin-bottom:8px;">🍽️</div>
|
| 504 |
+
<div style="font-weight:600;color:#dee2e6;font-size:14px;">Food</div>
|
| 505 |
+
<div style="font-size:12px;color:#666;margin-top:4px;">Pizza, Ramen, In-N-Out Burgers</div>
|
| 506 |
+
</div>
|
| 507 |
+
<div style="background:#1c2333;border-radius:12px;padding:20px 24px;width:160px;border:1px solid #2a2f3a;">
|
| 508 |
+
<div style="font-size:40px;margin-bottom:8px;">🛍️</div>
|
| 509 |
+
<div style="font-weight:600;color:#dee2e6;font-size:14px;">Shopping</div>
|
| 510 |
+
<div style="font-size:12px;color:#666;margin-top:4px;">Harrods, Grand Bazaar, Ginza</div>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
</div>"""
|
| 514 |
+
st.markdown(re.sub(r"\n\s+", "\n", hero_html), unsafe_allow_html=True)
|
src/styles/dark_theme.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dark theme based on Bootswatch Cyborg ('Jet black and electric blue')."""
|
| 2 |
+
|
| 3 |
+
DARK_THEME_CSS = """
|
| 4 |
+
<style>
|
| 5 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
|
| 6 |
+
|
| 7 |
+
/* ── Cyborg palette ── */
|
| 8 |
+
:root {
|
| 9 |
+
--bg-primary: #060606;
|
| 10 |
+
--bg-card: #111111;
|
| 11 |
+
--bg-card-open: #1a1a1a;
|
| 12 |
+
--accent: #2a9fd6;
|
| 13 |
+
--accent-hover: #1a7099;
|
| 14 |
+
--text-primary: #dee2e6;
|
| 15 |
+
--text-muted: #adafae;
|
| 16 |
+
--heading: #ffffff;
|
| 17 |
+
--border: #222222;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/* ── Global ── */
|
| 21 |
+
html, body, [class*="css"] {
|
| 22 |
+
font-family: 'Inter', 'IBMPlexMono', sans-serif;
|
| 23 |
+
font-size: 20px !important;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* ── Main background ── */
|
| 27 |
+
.stApp {
|
| 28 |
+
background-color: var(--bg-primary) !important;
|
| 29 |
+
color: var(--text-primary) !important;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* ── Hide the top header bar ── */
|
| 33 |
+
header[data-testid="stHeader"] {
|
| 34 |
+
display: none !important;
|
| 35 |
+
}
|
| 36 |
+
[data-testid="stToolbar"] {
|
| 37 |
+
display: none !important;
|
| 38 |
+
}
|
| 39 |
+
[data-testid="stAppDeployButton"] {
|
| 40 |
+
display: none !important;
|
| 41 |
+
}
|
| 42 |
+
/* Hide heading anchor links that appear on hover */
|
| 43 |
+
h1 a, h2 a, h3 a, h1 a:link, h2 a:link, h3 a:link,
|
| 44 |
+
h1 a:visited, h2 a:visited, h3 a:visited {
|
| 45 |
+
display: none !important;
|
| 46 |
+
}
|
| 47 |
+
.headerlink {
|
| 48 |
+
display: none !important;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* ── Headings ── */
|
| 52 |
+
h1, h2, h3 {
|
| 53 |
+
color: var(--accent) !important;
|
| 54 |
+
font-weight: 700 !important;
|
| 55 |
+
}
|
| 56 |
+
h1 { font-size: 2.4rem !important; }
|
| 57 |
+
h2 { font-size: 1.8rem !important; }
|
| 58 |
+
h3 { font-size: 1.4rem !important; }
|
| 59 |
+
|
| 60 |
+
/* ── Expander cards ── */
|
| 61 |
+
.stExpander {
|
| 62 |
+
background-color: var(--bg-card) !important;
|
| 63 |
+
border: 1px solid var(--border) !important;
|
| 64 |
+
border-radius: 10px !important;
|
| 65 |
+
margin-bottom: 8px !important;
|
| 66 |
+
}
|
| 67 |
+
.stExpander details:not([open]) {
|
| 68 |
+
min-height: 82px !important;
|
| 69 |
+
}
|
| 70 |
+
.stExpander:hover {
|
| 71 |
+
border-color: var(--accent) !important;
|
| 72 |
+
}
|
| 73 |
+
.stExpander:hover details[open] {
|
| 74 |
+
border-color: var(--accent) !important;
|
| 75 |
+
}
|
| 76 |
+
.stExpander details[open] {
|
| 77 |
+
background-color: var(--bg-card-open) !important;
|
| 78 |
+
border-color: var(--border) !important;
|
| 79 |
+
}
|
| 80 |
+
.stExpander summary {
|
| 81 |
+
font-size: 20px !important;
|
| 82 |
+
color: var(--accent) !important;
|
| 83 |
+
font-weight: 700 !important;
|
| 84 |
+
line-height: 1.5 !important;
|
| 85 |
+
}
|
| 86 |
+
.stExpander summary strong {
|
| 87 |
+
color: var(--accent) !important;
|
| 88 |
+
font-weight: 700 !important;
|
| 89 |
+
font-size: 22px !important;
|
| 90 |
+
display: block !important;
|
| 91 |
+
white-space: nowrap !important;
|
| 92 |
+
overflow: hidden !important;
|
| 93 |
+
text-overflow: ellipsis !important;
|
| 94 |
+
}
|
| 95 |
+
.stExpander summary p {
|
| 96 |
+
font-size: 16px !important;
|
| 97 |
+
color: var(--text-muted) !important;
|
| 98 |
+
}
|
| 99 |
+
.stExpander summary:hover {
|
| 100 |
+
color: var(--accent-hover) !important;
|
| 101 |
+
}
|
| 102 |
+
/* ── Expanded card content ── */
|
| 103 |
+
.stExpander div[data-testid="stExpanderDetails"] {
|
| 104 |
+
border-top-color: var(--border) !important;
|
| 105 |
+
}
|
| 106 |
+
.stExpander div[data-testid="stExpanderDetails"] p,
|
| 107 |
+
.stExpander div[data-testid="stExpanderDetails"] span {
|
| 108 |
+
font-size: 16px !important;
|
| 109 |
+
line-height: 1.5 !important;
|
| 110 |
+
}
|
| 111 |
+
.stExpander div[data-testid="stExpanderDetails"] em {
|
| 112 |
+
font-size: 15px !important;
|
| 113 |
+
}
|
| 114 |
+
/* ── Uniform card heights via fixed desc area ── */
|
| 115 |
+
.card-desc {
|
| 116 |
+
font-size: 16px !important;
|
| 117 |
+
line-height: 1.5 !important;
|
| 118 |
+
min-height: 100px !important;
|
| 119 |
+
display: block !important;
|
| 120 |
+
margin-bottom: 4px !important;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/* ── Input widgets ── */
|
| 124 |
+
.stTextInput > div > div > input,
|
| 125 |
+
.stDateInput input,
|
| 126 |
+
.stNumberInput input,
|
| 127 |
+
.stSelectbox div > div > input {
|
| 128 |
+
font-size: 18px !important;
|
| 129 |
+
background-color: var(--bg-primary) !important;
|
| 130 |
+
color: var(--text-primary) !important;
|
| 131 |
+
border-color: var(--border) !important;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/* ── Button ── */
|
| 135 |
+
.stButton > button,
|
| 136 |
+
button[type="submit"],
|
| 137 |
+
button[kind="secondaryFormSubmit"],
|
| 138 |
+
button[kind="formSubmit"] {
|
| 139 |
+
font-size: 13px !important;
|
| 140 |
+
padding: 10px 16px !important;
|
| 141 |
+
background-color: var(--accent) !important;
|
| 142 |
+
color: #ffffff !important;
|
| 143 |
+
border: none !important;
|
| 144 |
+
border-radius: 8px !important;
|
| 145 |
+
white-space: nowrap !important;
|
| 146 |
+
}
|
| 147 |
+
.stButton > button:hover,
|
| 148 |
+
button[type="submit"]:hover,
|
| 149 |
+
button[kind="secondaryFormSubmit"]:hover,
|
| 150 |
+
button[kind="formSubmit"]:hover {
|
| 151 |
+
background-color: var(--accent-hover) !important;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* ── Spinner ── */
|
| 155 |
+
.stSpinner > div {
|
| 156 |
+
font-size: 18px !important;
|
| 157 |
+
color: var(--accent) !important;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* ── Map: fill full width ── */
|
| 161 |
+
.stFolium {
|
| 162 |
+
width: 100% !important;
|
| 163 |
+
}
|
| 164 |
+
.stFolium > div {
|
| 165 |
+
width: 100% !important;
|
| 166 |
+
}
|
| 167 |
+
.stFolium iframe {
|
| 168 |
+
width: 100% !important;
|
| 169 |
+
}
|
| 170 |
+
/* Push map container down to align with cards */
|
| 171 |
+
.stCustomComponentV1 {
|
| 172 |
+
display: block !important;
|
| 173 |
+
}
|
| 174 |
+
iframe[title="streamlit_folium.st_folium"] {
|
| 175 |
+
display: block !important;
|
| 176 |
+
}
|
| 177 |
+
/* Hide Leaflet attribution label */
|
| 178 |
+
.leaflet-control-attribution {
|
| 179 |
+
display: none !important;
|
| 180 |
+
}
|
| 181 |
+
/* Hide Leaflet zoom controls (+ and -) */
|
| 182 |
+
.leaflet-control-zoom {
|
| 183 |
+
display: none !important;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/* ── Reduce top padding ── */
|
| 187 |
+
.block-container {
|
| 188 |
+
padding-top: 0 !important;
|
| 189 |
+
}
|
| 190 |
+
/* Squeeze title closer to top */
|
| 191 |
+
.main > div:first-child {
|
| 192 |
+
margin-top: -8px !important;
|
| 193 |
+
}
|
| 194 |
+
h1 {
|
| 195 |
+
margin-top: 0 !important;
|
| 196 |
+
padding-top: 0 !important;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/* ── Hide JS-tool iframes ── */
|
| 200 |
+
iframe[title="st.iframe"] {
|
| 201 |
+
display: none !important;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/* ── Hide scrollbars on all panels (keep scroll functionality) ── */
|
| 205 |
+
::-webkit-scrollbar {
|
| 206 |
+
display: none !important;
|
| 207 |
+
width: 0 !important;
|
| 208 |
+
height: 0 !important;
|
| 209 |
+
}
|
| 210 |
+
* {
|
| 211 |
+
scrollbar-width: none !important;
|
| 212 |
+
-ms-overflow-style: none !important;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* ── Hide sidebar completely ── */
|
| 216 |
+
section[data-testid="stSidebar"] {
|
| 217 |
+
display: none !important;
|
| 218 |
+
}
|
| 219 |
+
section[data-testid="stSidebar"] + div {
|
| 220 |
+
margin-left: 0 !important;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
/* ── Flexible panel heights: dynamically set by JS ── */
|
| 226 |
+
/* Fallback height (JS overrides with !important) */
|
| 227 |
+
.stVerticalBlock[data-testid="stVerticalBlock"] > [data-testid="stLayoutWrapper"] > .stVerticalBlock {
|
| 228 |
+
max-height: 800px;
|
| 229 |
+
}
|
| 230 |
+
.stCustomComponentV1 {
|
| 231 |
+
height: 800px;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/* ── Category filter: horizontal radio pills ── */
|
| 235 |
+
.stRadio label[data-baseweb="label"] {
|
| 236 |
+
font-size: 12px !important;
|
| 237 |
+
color: var(--text-muted) !important;
|
| 238 |
+
margin-bottom: 0 !important;
|
| 239 |
+
}
|
| 240 |
+
.stRadio > div[role="radiogroup"] {
|
| 241 |
+
flex-direction: row !important;
|
| 242 |
+
gap: 4px !important;
|
| 243 |
+
flex-wrap: nowrap !important;
|
| 244 |
+
}
|
| 245 |
+
.stRadio > div[role="radiogroup"] > label {
|
| 246 |
+
background-color: var(--bg-card) !important;
|
| 247 |
+
border: 1px solid var(--border) !important;
|
| 248 |
+
border-radius: 20px !important;
|
| 249 |
+
padding: 6px 10px !important;
|
| 250 |
+
min-height: 34px !important;
|
| 251 |
+
line-height: 22px !important;
|
| 252 |
+
color: var(--text-muted) !important;
|
| 253 |
+
font-size: 13px !important;
|
| 254 |
+
font-weight: 500 !important;
|
| 255 |
+
cursor: pointer !important;
|
| 256 |
+
transition: all 0.15s ease !important;
|
| 257 |
+
display: inline-flex !important;
|
| 258 |
+
align-items: center !important;
|
| 259 |
+
white-space: nowrap !important;
|
| 260 |
+
}
|
| 261 |
+
.stRadio > div[role="radiogroup"] > label:hover {
|
| 262 |
+
border-color: var(--accent) !important;
|
| 263 |
+
color: var(--text-primary) !important;
|
| 264 |
+
}
|
| 265 |
+
.stRadio > div[role="radiogroup"] > label[data-baseweb="radio"] {
|
| 266 |
+
justify-content: center !important;
|
| 267 |
+
}
|
| 268 |
+
/* Selected / checked pill */
|
| 269 |
+
.stRadio > div[role="radiogroup"] > label:has(input:checked),
|
| 270 |
+
.stRadio > div[role="radiogroup"] > label[aria-checked="true"] {
|
| 271 |
+
background-color: var(--accent) !important;
|
| 272 |
+
border-color: var(--accent) !important;
|
| 273 |
+
color: #ffffff !important;
|
| 274 |
+
font-weight: 700 !important;
|
| 275 |
+
}
|
| 276 |
+
/* Hide the native radio circle */
|
| 277 |
+
.stRadio > div[role="radiogroup"] > label > div:first-child {
|
| 278 |
+
display: none !important;
|
| 279 |
+
}
|
| 280 |
+
.stRadio > div[role="radiogroup"] > label > div:last-child {
|
| 281 |
+
margin-left: 0 !important;
|
| 282 |
+
padding-left: 0 !important;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
/* ── Compact single-row form ── */
|
| 286 |
+
form[data-testid="stForm"] {
|
| 287 |
+
padding: 0.75rem 1rem !important;
|
| 288 |
+
}
|
| 289 |
+
form[data-testid="stForm"] > div {
|
| 290 |
+
align-items: flex-end !important;
|
| 291 |
+
}
|
| 292 |
+
/* Set explicit height for all form elements to align bottoms */
|
| 293 |
+
form[data-testid="stForm"] [data-testid="stColumn"] {
|
| 294 |
+
height: 70px !important;
|
| 295 |
+
}
|
| 296 |
+
form[data-testid="stForm"] [data-testid="stColumn"] > div {
|
| 297 |
+
display: flex !important;
|
| 298 |
+
flex-direction: column !important;
|
| 299 |
+
justify-content: flex-end !important;
|
| 300 |
+
height: 100% !important;
|
| 301 |
+
}
|
| 302 |
+
/* Make all form inputs fill their column width */
|
| 303 |
+
form[data-testid="stForm"] .stTextInput,
|
| 304 |
+
form[data-testid="stForm"] .stSelectbox,
|
| 305 |
+
form[data-testid="stForm"] .stRadio,
|
| 306 |
+
form[data-testid="stForm"] .stButton {
|
| 307 |
+
width: 100% !important;
|
| 308 |
+
}
|
| 309 |
+
form[data-testid="stForm"] .stTextInput > div,
|
| 310 |
+
form[data-testid="stForm"] .stSelectbox > div,
|
| 311 |
+
form[data-testid="stForm"] .stSelectbox > div > div {
|
| 312 |
+
width: 100% !important;
|
| 313 |
+
}
|
| 314 |
+
/* Compact selectbox — shrink to single-line height */
|
| 315 |
+
form[data-testid="stForm"] .stSelectbox {
|
| 316 |
+
padding-top: 0 !important;
|
| 317 |
+
margin-top: 0 !important;
|
| 318 |
+
}
|
| 319 |
+
form[data-testid="stForm"] .stSelectbox > div > div:first-child {
|
| 320 |
+
padding: 0 8px !important;
|
| 321 |
+
min-height: 38px !important;
|
| 322 |
+
}
|
| 323 |
+
form[data-testid="stForm"] .stSelectbox div[data-baseweb="select"] {
|
| 324 |
+
height: 38px !important;
|
| 325 |
+
}
|
| 326 |
+
form[data-testid="stForm"] .stSelectbox div[data-baseweb="select"] > div {
|
| 327 |
+
min-height: 38px !important;
|
| 328 |
+
padding: 0 8px !important;
|
| 329 |
+
font-size: 18px !important;
|
| 330 |
+
}
|
| 331 |
+
form[data-testid="stForm"] .stSelectbox label {
|
| 332 |
+
font-size: 13px !important;
|
| 333 |
+
margin-bottom: 4px !important;
|
| 334 |
+
}
|
| 335 |
+
/* Compact text input to match */
|
| 336 |
+
form[data-testid="stForm"] .stTextInput > div > div > input {
|
| 337 |
+
min-height: 38px !important;
|
| 338 |
+
padding: 0 8px !important;
|
| 339 |
+
}
|
| 340 |
+
form[data-testid="stForm"] .stTextInput label {
|
| 341 |
+
font-size: 13px !important;
|
| 342 |
+
margin-bottom: 4px !important;
|
| 343 |
+
}
|
| 344 |
+
</style>
|
| 345 |
+
"""
|
| 346 |
+
|
| 347 |
+
CARD_EQUALIZER_JS = """
|
| 348 |
+
<script>
|
| 349 |
+
(function() {
|
| 350 |
+
// st.components renders in an iframe — reach out to the parent document
|
| 351 |
+
const doc = window.parent.document;
|
| 352 |
+
|
| 353 |
+
function equalizeCardDescriptions() {
|
| 354 |
+
const expanders = doc.querySelectorAll('.stExpander details[open]');
|
| 355 |
+
if (!expanders.length) {
|
| 356 |
+
// Cards not yet rendered — retry
|
| 357 |
+
setTimeout(equalizeCardDescriptions, 300);
|
| 358 |
+
return;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
const rows = {};
|
| 362 |
+
expanders.forEach(details => {
|
| 363 |
+
const rect = details.getBoundingClientRect();
|
| 364 |
+
const rowKey = Math.round(rect.top / 20) * 20;
|
| 365 |
+
if (!rows[rowKey]) rows[rowKey] = [];
|
| 366 |
+
rows[rowKey].push(details);
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
Object.values(rows).forEach(rowItems => {
|
| 370 |
+
// Reset all description heights in the row
|
| 371 |
+
rowItems.forEach(details => {
|
| 372 |
+
const pTags = details.querySelectorAll('.stMarkdown p');
|
| 373 |
+
for (const p of pTags) {
|
| 374 |
+
if (!p.textContent.startsWith('💡') && !p.closest('.stMarkdown').querySelector('img')) {
|
| 375 |
+
p.closest('.stMarkdown').style.minHeight = '';
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
// Measure tallest description
|
| 381 |
+
let maxH = 0;
|
| 382 |
+
const descs = [];
|
| 383 |
+
rowItems.forEach(details => {
|
| 384 |
+
const pTags = details.querySelectorAll('.stMarkdown p');
|
| 385 |
+
for (const p of pTags) {
|
| 386 |
+
const parent = p.closest('.stMarkdown');
|
| 387 |
+
if (parent && !p.textContent.startsWith('💡') && !parent.querySelector('img')) {
|
| 388 |
+
const h = parent.getBoundingClientRect().height;
|
| 389 |
+
if (h > maxH) maxH = h;
|
| 390 |
+
descs.push(parent);
|
| 391 |
+
break;
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
// Set all to tallest
|
| 397 |
+
descs.forEach(desc => { desc.style.minHeight = maxH + 'px'; });
|
| 398 |
+
});
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// Start with a delay to let Streamlit render cards
|
| 402 |
+
setTimeout(equalizeCardDescriptions, 500);
|
| 403 |
+
|
| 404 |
+
// Watch for DOM changes in the parent
|
| 405 |
+
new MutationObserver(() => {
|
| 406 |
+
clearTimeout(window._cardEqTimer);
|
| 407 |
+
window._cardEqTimer = setTimeout(equalizeCardDescriptions, 200);
|
| 408 |
+
}).observe(doc.body, { childList: true, subtree: true });
|
| 409 |
+
})();
|
| 410 |
+
</script>
|
| 411 |
+
"""
|
| 412 |
+
|
| 413 |
+
EMOJI_MAP = {
|
| 414 |
+
"attractions": "✨",
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
FLEX_PANELS_JS = """<!DOCTYPE html>
|
| 418 |
+
<html>
|
| 419 |
+
<body>
|
| 420 |
+
<script>
|
| 421 |
+
(function() {
|
| 422 |
+
// We run inside a Streamlit component iframe — target the parent document
|
| 423 |
+
const doc = window.parent.document;
|
| 424 |
+
|
| 425 |
+
function resizePanels() {
|
| 426 |
+
const vh = window.parent.innerHeight;
|
| 427 |
+
if (!vh) return;
|
| 428 |
+
|
| 429 |
+
// Strategy: find the scrollable card container and map iframe,
|
| 430 |
+
// then set their height so they fill the remaining viewport.
|
| 431 |
+
// Use getBoundingClientRect for accurate positioning.
|
| 432 |
+
|
| 433 |
+
const cardContainer = Array.from(
|
| 434 |
+
doc.querySelectorAll('[data-testid="stVerticalBlock"]')
|
| 435 |
+
).find(el => doc.defaultView.getComputedStyle(el).overflowY === 'auto');
|
| 436 |
+
|
| 437 |
+
if (cardContainer) {
|
| 438 |
+
const rect = cardContainer.getBoundingClientRect();
|
| 439 |
+
const panelHeight = Math.max(300, vh - rect.top - 24);
|
| 440 |
+
|
| 441 |
+
cardContainer.style.setProperty('height', panelHeight + 'px', 'important');
|
| 442 |
+
cardContainer.style.setProperty('max-height', panelHeight + 'px', 'important');
|
| 443 |
+
|
| 444 |
+
// Also resize parent LayoutWrapper
|
| 445 |
+
if (cardContainer.parentElement?.getAttribute('data-testid') === 'stLayoutWrapper') {
|
| 446 |
+
cardContainer.parentElement.style.setProperty('height', panelHeight + 'px', 'important');
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// Find the folium iframe container and set its height similarly
|
| 451 |
+
const foliumContainer = doc.querySelector('.stCustomComponentV1');
|
| 452 |
+
if (foliumContainer) {
|
| 453 |
+
const rect = foliumContainer.getBoundingClientRect();
|
| 454 |
+
const mapHeight = Math.max(300, vh - rect.top - 24);
|
| 455 |
+
foliumContainer.style.setProperty('height', mapHeight + 'px', 'important');
|
| 456 |
+
}
|
| 457 |
+
doc.querySelectorAll('.stCustomComponentV1 iframe').forEach(iframe => {
|
| 458 |
+
const rect = iframe.getBoundingClientRect();
|
| 459 |
+
const mapHeight = Math.max(300, vh - rect.top - 24);
|
| 460 |
+
iframe.style.setProperty('height', mapHeight + 'px', 'important');
|
| 461 |
+
});
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
// Run on load (delayed to let Streamlit render)
|
| 465 |
+
setTimeout(resizePanels, 200);
|
| 466 |
+
|
| 467 |
+
// Run on resize
|
| 468 |
+
window.parent.addEventListener('resize', () => {
|
| 469 |
+
clearTimeout(window._panelResizeTimer);
|
| 470 |
+
window._panelResizeTimer = setTimeout(resizePanels, 100);
|
| 471 |
+
});
|
| 472 |
+
|
| 473 |
+
// Watch for DOM changes in parent (Streamlit re-renders)
|
| 474 |
+
new MutationObserver(() => {
|
| 475 |
+
clearTimeout(window._panelResizeTimer);
|
| 476 |
+
window._panelResizeTimer = setTimeout(resizePanels, 300);
|
| 477 |
+
}).observe(doc.body, { childList: true, subtree: true });
|
| 478 |
+
})();
|
| 479 |
+
</script>
|
| 480 |
+
</body>
|
| 481 |
+
</html>
|
| 482 |
+
"""
|
| 483 |
+
|
| 484 |
+
CARD_HOVER_JS = """<!DOCTYPE html>
|
| 485 |
+
<html>
|
| 486 |
+
<body>
|
| 487 |
+
<script>
|
| 488 |
+
(function() {
|
| 489 |
+
const doc = window.parent.document;
|
| 490 |
+
|
| 491 |
+
function getFoliumWin() {
|
| 492 |
+
var iframe = doc.querySelector('.stFolium iframe, iframe[title="streamlit_folium.st_folium"]');
|
| 493 |
+
return iframe ? iframe.contentWindow || iframe.contentWindow : null;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
function getFoliumDoc() {
|
| 497 |
+
var iframe = doc.querySelector('.stFolium iframe, iframe[title="streamlit_folium.st_folium"]');
|
| 498 |
+
return iframe ? iframe.contentDocument || iframe.contentWindow.document : null;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
function findLeafletMap() {
|
| 502 |
+
var win = getFoliumWin();
|
| 503 |
+
if (!win) return null;
|
| 504 |
+
// Leaflet map instances are stored as global variables; find one
|
| 505 |
+
for (var k in win) {
|
| 506 |
+
try {
|
| 507 |
+
if (win[k] && win[k]._container && win[k]._layers) return win[k];
|
| 508 |
+
} catch(e) {}
|
| 509 |
+
}
|
| 510 |
+
return null;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
function highlightMarker(idx) {
|
| 514 |
+
var fdoc = getFoliumDoc();
|
| 515 |
+
if (!fdoc) return;
|
| 516 |
+
var el = fdoc.querySelector('.spider-marker[data-idx="'+idx+'"]');
|
| 517 |
+
if (!el) return;
|
| 518 |
+
el.style.background = '#f59e0b';
|
| 519 |
+
el.style.transform = 'scale(1.35)';
|
| 520 |
+
el.style.boxShadow = '0 0 14px rgba(245,158,11,0.6)';
|
| 521 |
+
el.style.zIndex = '1000';
|
| 522 |
+
// Open popup
|
| 523 |
+
var map = findLeafletMap();
|
| 524 |
+
if (map) {
|
| 525 |
+
map.eachLayer(function(layer) {
|
| 526 |
+
if (layer._icon === el.parentElement && layer._map) {
|
| 527 |
+
layer.openPopup();
|
| 528 |
+
}
|
| 529 |
+
});
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
function unhighlightMarker(idx) {
|
| 534 |
+
var fdoc = getFoliumDoc();
|
| 535 |
+
if (!fdoc) return;
|
| 536 |
+
var el = fdoc.querySelector('.spider-marker[data-idx="'+idx+'"]');
|
| 537 |
+
if (!el) return;
|
| 538 |
+
el.style.background = '#2a9fd6';
|
| 539 |
+
el.style.transform = '';
|
| 540 |
+
el.style.boxShadow = '0 2px 6px rgba(0,0,0,0.5)';
|
| 541 |
+
el.style.zIndex = '';
|
| 542 |
+
// Close popup
|
| 543 |
+
var map = findLeafletMap();
|
| 544 |
+
if (map) {
|
| 545 |
+
map.eachLayer(function(layer) {
|
| 546 |
+
if (layer._icon === el.parentElement && layer._map) {
|
| 547 |
+
layer.closePopup();
|
| 548 |
+
}
|
| 549 |
+
});
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function setupCardHover() {
|
| 554 |
+
var pins = doc.querySelectorAll('.card-pin[data-card-idx]');
|
| 555 |
+
if (!pins.length) { setTimeout(setupCardHover, 300); return; }
|
| 556 |
+
|
| 557 |
+
pins.forEach(function(pin) {
|
| 558 |
+
if (pin._hoverSetup) return;
|
| 559 |
+
pin._hoverSetup = true;
|
| 560 |
+
|
| 561 |
+
var idx = parseInt(pin.getAttribute('data-card-idx'));
|
| 562 |
+
var column = pin.closest('[data-testid="stColumn"]') || pin.parentElement;
|
| 563 |
+
var expander = column ? column.querySelector('.stExpander') : null;
|
| 564 |
+
if (!expander) return;
|
| 565 |
+
|
| 566 |
+
expander.addEventListener('mouseenter', function() {
|
| 567 |
+
highlightMarker(idx);
|
| 568 |
+
});
|
| 569 |
+
expander.addEventListener('mouseleave', function() {
|
| 570 |
+
unhighlightMarker(idx);
|
| 571 |
+
});
|
| 572 |
+
});
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
setTimeout(setupCardHover, 500);
|
| 576 |
+
new MutationObserver(function() {
|
| 577 |
+
clearTimeout(window._hoverObTimer);
|
| 578 |
+
window._hoverObTimer = setTimeout(setupCardHover, 300);
|
| 579 |
+
}).observe(doc.body, { childList: true, subtree: true });
|
| 580 |
+
})();
|
| 581 |
+
</script>
|
| 582 |
+
</body>
|
| 583 |
+
</html>
|
| 584 |
+
"""
|
| 585 |
+
|
| 586 |
+
SMART_IMAGE_POSITION_JS = """<!DOCTYPE html>
|
| 587 |
+
<html>
|
| 588 |
+
<body>
|
| 589 |
+
<script>
|
| 590 |
+
(function() {
|
| 591 |
+
var doc = window.parent.document;
|
| 592 |
+
|
| 593 |
+
function repositionPortraitImages() {
|
| 594 |
+
var imgs = doc.querySelectorAll('.card-img');
|
| 595 |
+
var found = 0;
|
| 596 |
+
imgs.forEach(function(img) {
|
| 597 |
+
// If natural dimensions are available, check immediately
|
| 598 |
+
if (img.naturalHeight > 0 && img.naturalWidth > 0) {
|
| 599 |
+
found++;
|
| 600 |
+
if (img.naturalHeight > img.naturalWidth) {
|
| 601 |
+
// Portrait: show upper third to capture the attraction, not the ground
|
| 602 |
+
img.style.objectPosition = '50% 25%';
|
| 603 |
+
} else {
|
| 604 |
+
img.style.objectPosition = '50% 50%';
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
});
|
| 608 |
+
// Retry if no images have loaded yet
|
| 609 |
+
if (found === 0 && imgs.length > 0) {
|
| 610 |
+
setTimeout(repositionPortraitImages, 300);
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
// Also handle lazy-loaded images — they'll fire 'load' after becoming visible
|
| 615 |
+
doc.addEventListener('load', function(e) {
|
| 616 |
+
if (e.target && e.target.classList && e.target.classList.contains('card-img')) {
|
| 617 |
+
if (e.target.naturalHeight > e.target.naturalWidth) {
|
| 618 |
+
e.target.style.objectPosition = '50% 25%';
|
| 619 |
+
}
|
| 620 |
+
}
|
| 621 |
+
}, true);
|
| 622 |
+
|
| 623 |
+
// Initial run after DOM settles
|
| 624 |
+
setTimeout(repositionPortraitImages, 500);
|
| 625 |
+
})();
|
| 626 |
+
</script>
|
| 627 |
+
</body>
|
| 628 |
+
</html>
|
| 629 |
+
"""
|
| 630 |
+
|
| 631 |
+
def apply_dark_theme():
|
| 632 |
+
"""Inject dark-theme CSS, flexible panel JS, card↔map hover JS, and smart image positioning JS."""
|
| 633 |
+
import streamlit as st
|
| 634 |
+
st.markdown(DARK_THEME_CSS, unsafe_allow_html=True)
|
| 635 |
+
# Use st.iframe to execute JS (st.markdown strips <script> tags)
|
| 636 |
+
st.iframe(FLEX_PANELS_JS, height=1)
|
| 637 |
+
st.iframe(CARD_HOVER_JS, height=1)
|
| 638 |
+
st.iframe(SMART_IMAGE_POSITION_JS, height=1)
|
src/utils/prompts.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt template for the attractions tab."""
|
| 2 |
+
|
| 3 |
+
ATTRACTIONS_PROMPT = """You are a travel expert. List the top {num_attractions} {category_prompt}
|
| 4 |
+
|
| 5 |
+
CRITICAL: Each entry must be ONE SINGLE attraction or place. Do NOT combine multiple places with "&", "and", "/", or commas in the name field. For example, "Meiji Shrine" not "Meiji Shrine & Yoyogi Park".
|
| 6 |
+
|
| 7 |
+
For each entry, provide:
|
| 8 |
+
1. **Name** — the single place name only
|
| 9 |
+
2. **Description** — a short description of why it's worth visiting (between 120 and 125 characters)
|
| 10 |
+
3. **Short description** — a one-liner summary (max 25 characters)
|
| 11 |
+
4. **Tip** — one practical tip for visitors (max 60 characters, e.g., best time to visit, ticket info, how to skip lines)
|
| 12 |
+
5. **Latitude** — the latitude as a number (e.g. 48.8584)
|
| 13 |
+
6. **Longitude** — the longitude as a number (e.g. 2.2945)
|
| 14 |
+
Return the result as a JSON array with {num_attractions} objects, each having keys: "name", "description", "short_description", "tip", "latitude", "longitude".
|
| 15 |
+
Only return valid JSON, no markdown fences or extra text."""
|
| 16 |
+
|
| 17 |
+
PROMPT_MAP = {
|
| 18 |
+
"attractions": ATTRACTIONS_PROMPT,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Maps category toggle names to prompt insertion text
|
| 22 |
+
CATEGORY_GUIDANCE = {
|
| 23 |
+
"Landmark": "famous landmarks in {city} recommended by major travel guides. Focus on iconic buildings, monuments, towers, bridges, castles, palaces, cathedrals, statues, and other man-made structures. Do NOT include parks, gardens, heaths, open spaces, or natural areas.",
|
| 24 |
+
"Culture": "cultural things to do in {city} recommended by major travel guides.",
|
| 25 |
+
"Food": "food and drink areas, restaurants and bars in {city} recommended by major travel guides.",
|
| 26 |
+
"Nature": "nature spots and parks in {city} recommended by major travel guides.",
|
| 27 |
+
"Photo": "scenic photo spots and instagrammable places in {city} recommended by major travel guides.",
|
| 28 |
+
"Shopping": "shopping districts, malls and street markets in {city} recommended by major travel guides.",
|
| 29 |
+
"Gems": "hidden gem neighborhoods and lesser-known spots in {city} recommended by major travel guides.",
|
| 30 |
+
}
|