embed786 commited on
Commit
6380b21
·
verified ·
1 Parent(s): be7a131

Upload 9 files

Browse files
planmate/__init__.py ADDED
File without changes
planmate/attractions.py CHANGED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # planmate/attractions.py
2
+
3
+ import requests
4
+ from typing import List, Dict
5
+ from .config import OPENTRIPMAP_BASE, get_opentripmap_key
6
+
7
+ def _get(path: str, params: Dict):
8
+ url = f"{OPENTRIPMAP_BASE}{path}"
9
+ params = dict(params or {})
10
+ params["apikey"] = get_opentripmap_key()
11
+ r = requests.get(url, params=params, timeout=30)
12
+ try:
13
+ r.raise_for_status()
14
+ except requests.HTTPError as e:
15
+ # make API errors easier to debug in Streamlit
16
+ snippet = (r.text or "")[:300]
17
+ raise RuntimeError(f"OpenTripMap error {r.status_code}: {snippet}") from e
18
+ return r.json()
19
+
20
+ def get_poi_radius(lat: float, lon: float, radius=7000, kinds="interesting_places", limit=50) -> List[Dict]:
21
+ return _get(
22
+ "/places/radius",
23
+ {
24
+ "lat": lat,
25
+ "lon": lon,
26
+ "radius": radius,
27
+ "kinds": kinds,
28
+ "format": "json",
29
+ "limit": limit,
30
+ "rate": 2,
31
+ },
32
+ )
33
+
34
+ def get_details(xid: str) -> Dict:
35
+ return _get(f"/places/xid/{xid}", {})
36
+
37
+ def enrich_pois(pois: List[Dict], fetch_details=False) -> List[Dict]:
38
+ out = []
39
+ for p in pois:
40
+ item = {
41
+ "name": p.get("name") or "(Unnamed)",
42
+ "dist": p.get("dist"),
43
+ "rate": p.get("rate"),
44
+ "kinds": p.get("kinds", ""),
45
+ "xid": p.get("xid"),
46
+ "point": p.get("point", {}),
47
+ }
48
+ if fetch_details and item["xid"]:
49
+ try:
50
+ det = get_details(item["xid"])
51
+ item["wikipedia"] = det.get("wikipedia")
52
+ item["url"] = det.get("url")
53
+ item["address"] = det.get("address", {})
54
+ item["otm"] = det.get("otm")
55
+ except Exception:
56
+ pass
57
+ out.append(item)
58
+ return out
59
+
60
+ def get_attractions_and_stays(lat: float, lon: float, radius=7000, limit=40):
61
+ # Attractions remain unchanged
62
+ attractions_kinds = "interesting_places,cultural,historic,museums,architecture"
63
+
64
+ # IMPORTANT: Use taxonomy accepted by OpenTripMap.
65
+ # "accomodations" (sic) is the umbrella category; for hotels use "other_hotels", not "hotels".
66
+ # See OTM catalog: Accomodations (accomodations), Hotels (other_hotels), Hostels (hostels).
67
+ # https://dev.opentripmap.org/catalog
68
+ stays_kinds = (
69
+ "accomodations,other_hotels,hostels,apartments,guest_houses,"
70
+ "resorts,motels,villas_and_chalet,alpine_hut"
71
+ )
72
+
73
+ attractions = get_poi_radius(lat, lon, radius, attractions_kinds, limit)
74
+ stays = get_poi_radius(lat, lon, radius, stays_kinds, limit=30)
75
+
76
+ return {
77
+ "attractions": enrich_pois(attractions, fetch_details=False),
78
+ "stays": enrich_pois(stays, fetch_details=False),
79
+ }
planmate/config.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ # Load .env if present (local dev convenience)
5
+ load_dotenv()
6
+
7
+ APP_TITLE = "PlanMate"
8
+ APP_TAGLINE = "AI Power smart trip planner"
9
+
10
+ THEME = {
11
+ "bg": "#fcfcfc",
12
+ "text": "#383838",
13
+ "label": "#153d15",
14
+ "border": "#153d15",
15
+ }
16
+
17
+ CURRENCY = "PKR"
18
+ UNITS = "metric"
19
+ LANGUAGE = "en"
20
+
21
+ # API Base URLs
22
+ AMADEUS_BASE = "https://test.api.amadeus.com"
23
+ OPENWEATHER_BASE = "https://api.openweathermap.org"
24
+ OPENTRIPMAP_BASE = "https://api.opentripmap.com/0.1/en"
25
+
26
+ def get_env(key: str) -> str:
27
+ val = os.getenv(key)
28
+ if not val:
29
+ raise RuntimeError(f"Missing required environment variable: {key}")
30
+ return val
31
+
32
+ def get_gemini_key():
33
+ return get_env("GEMINI_API_KEY")
34
+
35
+ def get_amadeus_credentials():
36
+ return get_env("AMADEUS_CLIENT_ID"), get_env("AMADEUS_CLIENT_SECRET")
37
+
38
+ def get_openweather_key():
39
+ return get_env("OPENWEATHER_API_KEY")
40
+
41
+ def get_opentripmap_key():
42
+ return get_env("OPENTRIPMAP_API_KEY")
planmate/flights.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Tuple
2
+ from .clients.amadeus_client import AmadeusClient
3
+ from .utils import format_price
4
+
5
+
6
+ def resolve_city_to_iata(city_name: str) -> Tuple[str, str]:
7
+ """Resolve a human city name to an IATA city code using Amadeus.
8
+ Returns (iata_code, display_label). Raises RuntimeError if not found.
9
+ """
10
+ client = AmadeusClient()
11
+ res = client.locations(city_name, subtypes="CITY")
12
+ data = res.get("data", [])
13
+ if not data:
14
+ # try airports as fallback and take the cityName + iataCode of airport
15
+ res = client.locations(city_name, subtypes="AIRPORT")
16
+ data = res.get("data", [])
17
+ if not data:
18
+ raise RuntimeError(f"Could not resolve city to IATA: {city_name}")
19
+
20
+ # Prefer exact name match then highest relevance (first)
21
+ city_lower = city_name.strip().lower()
22
+ chosen = None
23
+ for d in data:
24
+ name = (d.get("name") or "").lower()
25
+ address_city = (d.get("address", {}).get("cityName") or "").lower()
26
+ if name == city_lower or address_city == city_lower:
27
+ chosen = d
28
+ break
29
+ if not chosen:
30
+ chosen = data[0]
31
+
32
+ code = chosen.get("iataCode")
33
+ label = f"{chosen.get('name')} ({code})"
34
+ if not code:
35
+ raise RuntimeError(f"No IATA code found for: {city_name}")
36
+ return code, label
37
+
38
+
39
+ def search_airports(keyword: str) -> List[Dict]:
40
+ client = AmadeusClient()
41
+ res = client.locations(keyword)
42
+ items = []
43
+ for d in res.get("data", []):
44
+ items.append(
45
+ {
46
+ "name": d.get("name"),
47
+ "iataCode": d.get("iataCode"),
48
+ "subType": d.get("subType"),
49
+ "address": d.get("address", {}).get("cityName")
50
+ or d.get("address", {}).get("countryName"),
51
+ }
52
+ )
53
+ return items
54
+
55
+
56
+ def search_flights(
57
+ origin_code: str,
58
+ dest_code: str,
59
+ depart: str,
60
+ ret: str,
61
+ adults=1,
62
+ currency="PKR",
63
+ non_stop: bool = False,
64
+ ) -> Dict:
65
+ client = AmadeusClient()
66
+ res = client.flight_offers(
67
+ origin_code, dest_code, depart, ret, adults, currency, non_stop
68
+ )
69
+ dicts = res.get("dictionaries", {})
70
+ carriers = dicts.get("carriers", {})
71
+ result = []
72
+ for offer in res.get("data", []):
73
+ price = offer.get("price", {}).get("total")
74
+ itineraries = offer.get("itineraries", [])
75
+ legs = []
76
+ for it in itineraries:
77
+ segs = []
78
+ for s in it.get("segments", []):
79
+ carrier = s.get("carrierCode")
80
+ segs.append(
81
+ {
82
+ "from": s.get("departure", {}).get("iataCode"),
83
+ "to": s.get("arrival", {}).get("iataCode"),
84
+ "dep": s.get("departure", {}).get("at"),
85
+ "arr": s.get("arrival", {}).get("at"),
86
+ "carrier": carriers.get(carrier, carrier),
87
+ "number": s.get("number"),
88
+ "duration": it.get("duration", ""),
89
+ }
90
+ )
91
+ legs.append(segs)
92
+ result.append(
93
+ {
94
+ "price": price,
95
+ "price_label": format_price(price, currency),
96
+ "legs": legs,
97
+ "oneWay": (ret is None or ret == ""),
98
+ }
99
+ )
100
+ return {"flights": result, "carriers": carriers}
planmate/itinerary.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict
2
+ from .llm import generate_itinerary
3
+
4
+
5
+ def build_itinerary(
6
+ city: str,
7
+ start_date: str,
8
+ days: int,
9
+ selected_attractions: List[Dict],
10
+ selected_stay: Dict | None,
11
+ weather_summary: str,
12
+ ) -> str:
13
+ return generate_itinerary(
14
+ city, start_date, days, selected_attractions, selected_stay, weather_summary
15
+ )
planmate/llm.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import List, Dict, Optional, Tuple
3
+ import google.generativeai as genai
4
+ from .config import get_gemini_key
5
+ from .utils import is_iata_code
6
+
7
+ # Initialize once
8
+ _gen_inited = False
9
+
10
+ def _ensure_init():
11
+ global _gen_inited
12
+ if not _gen_inited:
13
+ genai.configure(api_key=get_gemini_key())
14
+ _gen_inited = True
15
+
16
+
17
+ def _model():
18
+ _ensure_init()
19
+ return genai.GenerativeModel(
20
+ model_name="gemini-2.0-flash-lite",
21
+ generation_config={
22
+ "temperature": 0.2,
23
+ "top_p": 0.9,
24
+ "top_k": 40,
25
+ "max_output_tokens": 600,
26
+ },
27
+ )
28
+
29
+
30
+ def resolve_city_to_iata_ai(city: str, country_hint: Optional[str] = None) -> Tuple[str, str, str]:
31
+ """Use the LLM to resolve a human-readable city to an IATA code.
32
+ Returns (code, canonical_name, kind) where kind is 'CITY' or 'AIRPORT'.
33
+ Raises RuntimeError on failure.
34
+ """
35
+ m = _model()
36
+ prompt = f"""
37
+ You convert a city name into a 3-letter IATA code for flight search.
38
+ Rules:
39
+ - If the metro area has multiple major airports with an aggregate IATA city code, return the CITY code (e.g., New York→NYC, London→LON, Paris→PAR, Tokyo→TYO).
40
+ - If the city typically uses a single primary commercial airport, return that AIRPORT code (e.g., Dubai→DXB, Doha→DOH, Lahore→LHE, Karachi→KHI, Istanbul→IST).
41
+ - Return empty code "" if no suitable airport exists.
42
+ - The code MUST be exactly 3 uppercase letters A–Z.
43
+ - Respond with ONLY a compact JSON object, no additional text, using this schema:
44
+ {{"code":"XXX","name":"Canonical City or Airport Name","kind":"CITY|AIRPORT","alternates":["AAA","BBB"]}}
45
+
46
+ Input:
47
+ - city: {city}
48
+ - country_hint: {country_hint or 'unknown'}
49
+ """
50
+ resp = m.generate_content(prompt)
51
+ text = (resp.text or "").strip()
52
+
53
+ # Extract JSON block
54
+ mjson = None
55
+ if "{" in text:
56
+ try:
57
+ start = text.find("{")
58
+ end = text.rfind("}") + 1
59
+ mjson = json.loads(text[start:end])
60
+ except Exception:
61
+ pass
62
+ if not mjson or not isinstance(mjson, dict):
63
+ raise RuntimeError("AI could not produce a valid JSON mapping for the city.")
64
+
65
+ code = str(mjson.get("code", "")).strip().upper()
66
+ name = str(mjson.get("name", city)).strip()
67
+ kind = str(mjson.get("kind", "")).strip().upper()
68
+
69
+ if not is_iata_code(code):
70
+ raise RuntimeError(f"AI returned an invalid IATA code: {code}")
71
+ if kind not in ("CITY", "AIRPORT"):
72
+ kind = "AIRPORT"
73
+ return code, name, kind
74
+
75
+
76
+ def rank_accommodations(accommodations: List[Dict], prefs: str = "") -> List[Dict]:
77
+ """Add an 'llm_score' to each accommodation and sort by it."""
78
+ if not accommodations:
79
+ return []
80
+ m = _model()
81
+ lines = "\n".join(
82
+ [
83
+ f"- {a.get('name','(no name)')} | rate:{a.get('rate')} | dist:{int(a.get('dist',0))}m | kinds:{a.get('kinds','')}"
84
+ for a in accommodations[:50]
85
+ ]
86
+ )
87
+ prompt = f"""Rank the following accommodations for a tourist trip. Prefer central, well-rated options.
88
+ User preferences: {prefs or 'not specified'}.
89
+ Return a JSON array of objects with 'name' and an integer 'score' 1-100.
90
+
91
+ Items:
92
+ {lines}
93
+ """
94
+ resp = m.generate_content(prompt)
95
+ try:
96
+ text = resp.text or ""
97
+ if "```json" in text:
98
+ data = json.loads(text.split("```json")[-1].split("```")[0])
99
+ else:
100
+ data = json.loads(text)
101
+ except Exception:
102
+ data = []
103
+ score_map = {d.get("name", ""): int(d.get("score", 50)) for d in data if isinstance(d, dict)}
104
+ for a in accommodations:
105
+ a["llm_score"] = score_map.get(a.get("name", ""), 50)
106
+ return sorted(accommodations, key=lambda x: x.get("llm_score", 50), reverse=True)
107
+
108
+
109
+ def generate_itinerary(
110
+ city: str,
111
+ start_date: str,
112
+ days: int,
113
+ selected_attractions: List[Dict],
114
+ selected_stay: Dict | None,
115
+ weather_summary: str,
116
+ ) -> str:
117
+ m = _model()
118
+ attractions_text = "\n".join([f"- {a.get('name')} ({a.get('kinds','')})" for a in selected_attractions])
119
+ hotel_text = selected_stay.get("name") if selected_stay else "TBD"
120
+ prompt = f"""
121
+ Create a practical, day-by-day itinerary for a trip.
122
+
123
+ City: {city}
124
+ Start Date: {start_date}
125
+ Days: {days}
126
+ Hotel: {hotel_text}
127
+ Weather (summary): {weather_summary}
128
+
129
+ Attractions to consider:
130
+ {attractions_text}
131
+
132
+ Constraints: group nearby sights, account for weather, add meal/time suggestions, include commute notes.
133
+ Return Markdown with sections Day 1, Day 2, ..., and a brief daily plan (morning/afternoon/evening).
134
+ """
135
+ resp = m.generate_content(prompt)
136
+ return resp.text
planmate/utils.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime, timedelta
2
+ from typing import List
3
+ import pytz
4
+ import re
5
+
6
+ def clamp_days(n: int, min_v=1, max_v=30):
7
+ return max(min_v, min(max_v, n))
8
+
9
+
10
+ def to_iso(d: date) -> str:
11
+ return d.strftime("%Y-%m-%d")
12
+
13
+
14
+ def parse_date_str(s: str) -> date:
15
+ return datetime.strptime(s, "%Y-%m-%d").date()
16
+
17
+
18
+ def compute_return_date(start: date, days: int) -> date:
19
+ days = clamp_days(days)
20
+ return start + timedelta(days=days)
21
+
22
+
23
+ def format_price(amount: str, currency: str = "PKR") -> str:
24
+ try:
25
+ f = float(amount)
26
+ return f"{currency} {f:,.0f}"
27
+ except Exception:
28
+ return f"{currency} {amount}"
29
+
30
+
31
+ def day_list(start: date, days: int) -> List[date]:
32
+ days = clamp_days(days)
33
+ return [start + timedelta(days=i) for i in range(days)]
34
+
35
+
36
+ def as_local(dt: datetime, tz="Asia/Karachi") -> datetime:
37
+ return dt.astimezone(pytz.timezone(tz))
38
+
39
+ def is_iata_code(s: str) -> bool:
40
+ return bool(re.fullmatch(r"[A-Z]{3}", s or ""))
planmate/validation.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict
2
+ from .clients.amadeus_client import AmadeusClient
3
+ from .weather import geocode_city
4
+ from .config import MAX_AIRPORT_KM
5
+
6
+
7
+ def nearest_airports_for_city(city: str, max_km: int = MAX_AIRPORT_KM) -> Dict:
8
+ """
9
+ Returns nearest airports to the given city (within max_km).
10
+ Output: {"location": {...}, "airports": [airport_dicts_with__distance_km], "raw": raw_list}
11
+ Raises on geocoding or network errors.
12
+ """
13
+ loc = geocode_city(city) # {name, lat, lon, country}
14
+ client = AmadeusClient()
15
+ res = client.airports_nearby(lat=loc["lat"], lon=loc["lon"])
16
+
17
+ filtered = []
18
+ for a in res.get("data", []):
19
+ dist = a.get("distance", {})
20
+ val = dist.get("value")
21
+ unit = (dist.get("unit") or "").upper()
22
+ if val is None:
23
+ continue
24
+ km = float(val) if unit == "KM" else float(val) * 1.60934
25
+ a["_distance_km"] = km
26
+ if km <= max_km:
27
+ filtered.append(a)
28
+
29
+ return {"location": loc, "airports": filtered, "raw": res.get("data", [])}
30
+
31
+
32
+ def assert_city_has_airport(city: str, max_km: int = MAX_AIRPORT_KM) -> Dict:
33
+ """Validates a city has at least one airport within max_km. Raises RuntimeError otherwise."""
34
+ data = nearest_airports_for_city(city, max_km)
35
+ if len(data["airports"]) == 0:
36
+ raise RuntimeError(f"No airport found within {max_km} km of '{city}'. Please try a nearby larger city.")
37
+ return data
planmate/weather.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from datetime import datetime
3
+ from typing import Dict
4
+ from .config import OPENWEATHER_BASE, get_openweather_key, UNITS
5
+ from .utils import day_list
6
+
7
+
8
+ def geocode_city(city: str) -> Dict:
9
+ url = f"{OPENWEATHER_BASE}/geo/1.0/direct"
10
+ r = requests.get(
11
+ url,
12
+ params={"q": city, "limit": 1, "appid": get_openweather_key()},
13
+ timeout=20,
14
+ )
15
+ r.raise_for_status()
16
+ arr = r.json()
17
+ if not arr:
18
+ raise RuntimeError(f"City not found: {city}")
19
+ d = arr[0]
20
+ return {
21
+ "name": d.get("name"),
22
+ "lat": d.get("lat"),
23
+ "lon": d.get("lon"),
24
+ "country": d.get("country"),
25
+ }
26
+
27
+
28
+ def forecast_5day(lat: float, lon: float, units=UNITS) -> Dict:
29
+ # 5 day / 3-hour forecast
30
+ url = f"{OPENWEATHER_BASE}/data/2.5/forecast"
31
+ r = requests.get(
32
+ url,
33
+ params={"lat": lat, "lon": lon, "appid": get_openweather_key(), "units": units},
34
+ timeout=25,
35
+ )
36
+ r.raise_for_status()
37
+ return r.json()
38
+
39
+
40
+ def summarize_forecast_for_range(city: str, start_date, days: int) -> Dict:
41
+ loc = geocode_city(city)
42
+ fc = forecast_5day(loc["lat"], loc["lon"])
43
+ daily = {}
44
+ for item in fc.get("list", []):
45
+ dt_txt = item.get("dt_txt") # 'YYYY-MM-DD HH:MM:SS'
46
+ dt = datetime.strptime(dt_txt, "%Y-%m-%d %H:%M:%S")
47
+ dkey = dt.date().isoformat()
48
+ temp = item.get("main", {}).get("temp")
49
+ weather = item.get("weather", [{}])[0].get("description", "")
50
+ wind = item.get("wind", {}).get("speed", 0)
51
+ if dkey not in daily:
52
+ daily[dkey] = {"temps": [], "desc": {}, "wind": []}
53
+ daily[dkey]["temps"].append(temp)
54
+ daily[dkey]["wind"].append(wind)
55
+ daily[dkey]["desc"][weather] = daily[dkey]["desc"].get(weather, 0) + 1
56
+
57
+ dates = [d.isoformat() for d in day_list(start_date, days)]
58
+ rows = []
59
+ for dkey in dates:
60
+ if dkey in daily:
61
+ temps = daily[dkey]["temps"]
62
+ avg = sum(temps) / len(temps) if temps else None
63
+ wavg = (
64
+ sum(daily[dkey]["wind"]) / len(daily[dkey]["wind"]) if daily[dkey]["wind"] else 0
65
+ )
66
+ desc = max(daily[dkey]["desc"], key=daily[dkey]["desc"].get)
67
+ rows.append(
68
+ {
69
+ "date": dkey,
70
+ "temp_avg": round(avg, 1) if avg is not None else None,
71
+ "wind": round(wavg, 1),
72
+ "desc": desc,
73
+ }
74
+ )
75
+ else:
76
+ rows.append(
77
+ {
78
+ "date": dkey,
79
+ "temp_avg": None,
80
+ "wind": None,
81
+ "desc": "No forecast (beyond 5 days)",
82
+ }
83
+ )
84
+ summary_text = "\n".join(
85
+ [
86
+ f"{r['date']}: {r['desc']}, avg {r['temp_avg']}°C, wind {r['wind']} m/s"
87
+ for r in rows
88
+ ]
89
+ )
90
+ return {"location": loc, "daily": rows, "summary_text": summary_text}