jofaichow commited on
Commit
12f8092
·
1 Parent(s): 901d32d
.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
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- altair
2
- pandas
3
- streamlit
 
 
 
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
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
 
 
4
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
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="&copy; <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
+ }