ViniciusKhan commited on
Commit
cd53cfa
·
1 Parent(s): 3eea68b

Add Docker Space: FastAPI backend IViagem

Browse files
Files changed (2) hide show
  1. app.py +682 -154
  2. app/main.py +17 -20
app.py CHANGED
@@ -1,86 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
2
  from __future__ import annotations
3
 
4
  import os
5
  import math
6
  import random
 
7
  from functools import lru_cache
8
  from datetime import date, datetime, timedelta
9
  from typing import List, Optional, Literal, Dict, Any, Tuple
10
 
11
  import httpx
12
  from dateutil.parser import isoparse
13
- from fastapi import FastAPI, HTTPException
14
  from fastapi.middleware.cors import CORSMiddleware
 
15
  from pydantic import BaseModel, Field, field_validator
16
 
17
- # ==========================
18
- # Gemini (google-generativeai)
19
- # ==========================
20
- GEMINI_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-exp")
21
- GEMINI_API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
22
 
23
- _genai_model = None
24
- def _init_gemini():
25
- global _genai_model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  if _genai_model is not None:
27
  return _genai_model
28
  try:
29
- import google.generativeai as genai
 
30
  if not GEMINI_API_KEY:
31
  return None
 
 
32
  genai.configure(api_key=GEMINI_API_KEY)
33
- _genai_model = genai.GenerativeModel(GEMINI_MODEL_NAME)
34
  except Exception as e:
 
35
  print(f"[warn] Gemini init falhou: {e}")
36
  _genai_model = None
37
  return _genai_model
38
 
39
- def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: float = 0.7) -> str:
40
- """
41
- Gera texto com Gemini. Se der erro ou não tiver API key, retorna string vazia.
 
 
 
 
42
  """
43
- try:
44
- model = _init_gemini()
45
- if model is None:
46
- return ""
47
- resp = model.generate_content(
48
- prompt,
49
- generation_config={
50
- "temperature": temperature,
51
- "max_output_tokens": max_tokens,
52
- },
 
 
 
53
  )
54
- if hasattr(resp, "text") and resp.text:
55
- return resp.text.strip()
56
- parts = []
57
  try:
58
- for c in resp.candidates or []:
59
- for p in getattr(c, "content", {}).parts or []:
60
- if getattr(p, "text", ""):
61
- parts.append(p.text)
62
- return "\n".join(parts).strip()
 
 
 
 
 
 
 
 
 
 
63
  except Exception:
64
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  except Exception as e:
66
- print(f"[warn] Erro Gemini: {e}")
67
- return ""
 
 
68
 
69
- # ==========================
70
- # Geocodificação (Nominatim/OSM) com cache e fallback
71
- # ==========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  USE_ONLINE_GEOCODING = os.getenv("USE_ONLINE_GEOCODING", "1") != "0"
 
73
  NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
74
 
 
75
  @lru_cache(maxsize=256)
76
- def geocode_city(query: str) -> tuple[float, float, str] | None:
 
 
 
 
 
77
  if not USE_ONLINE_GEOCODING:
78
  return None
79
  q = (query or "").strip()
80
  if not q:
81
  return None
82
  headers = {
83
- "User-Agent": "IViagem/2.3.0 (contato: suporte@ivg.local)",
84
  "Accept-Language": "pt-BR",
85
  }
86
  params = {"q": q, "format": "jsonv2", "limit": 1, "addressdetails": 0}
@@ -100,10 +403,12 @@ def geocode_city(query: str) -> tuple[float, float, str] | None:
100
  except Exception:
101
  return None
102
 
103
- # ==========================
104
- # Geografia / cidades (catálogo local + aliases)
105
- # ==========================
106
- CITY_COORDS: Dict[str, tuple[float, float, str]] = {
 
 
107
  "são paulo": (-23.5505, -46.6333, "São Paulo, SP"),
108
  "rio de janeiro": (-22.9068, -43.1729, "Rio de Janeiro, RJ"),
109
  "manaus": (-3.1190, -60.0217, "Manaus, AM"),
@@ -116,7 +421,7 @@ CITY_COORDS: Dict[str, tuple[float, float, str]] = {
116
  "florianópolis": (-27.5949, -48.5482, "Florianópolis, SC"),
117
  }
118
 
119
- CITY_ALIASES = {
120
  "amazonia": "manaus",
121
  "amazônia": "manaus",
122
  "belem": "belém",
@@ -124,16 +429,27 @@ CITY_ALIASES = {
124
  "sp": "são paulo",
125
  }
126
 
 
127
  def norm(s: str) -> str:
 
128
  return (s or "").strip().lower()
129
 
 
130
  def resolve_city_key(raw: str) -> str:
 
131
  k = norm(raw)
132
  if k in CITY_COORDS:
133
  return k
134
  return CITY_ALIASES.get(k, k)
135
 
136
- def get_coords_and_label(raw: str) -> tuple[float, float, str]:
 
 
 
 
 
 
 
137
  gc = geocode_city(raw)
138
  if gc is not None:
139
  return gc
@@ -144,30 +460,44 @@ def get_coords_and_label(raw: str) -> tuple[float, float, str]:
144
  label = raw.strip() if raw.strip() else "Destino"
145
  return (lat, lon, label)
146
 
 
 
 
 
 
147
  def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
 
148
  (lat1, lon1), (lat2, lon2) = a, b
149
  R = 6371.0
150
  p1, p2 = math.radians(lat1), math.radians(lat2)
151
  dp = math.radians(lat2 - lat1)
152
  dl = math.radians(lon2 - lon1)
153
- x = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
154
- return R * 2 * math.atan2(math.sqrt(x), math.sqrt(1-x))
 
155
 
156
  def daterange(d0: date, d1: date):
 
157
  d = d0
158
  while d <= d1:
159
  yield d
160
  d += timedelta(days=1)
161
 
 
162
  def is_weekend(d: date) -> bool:
 
163
  return d.weekday() >= 5
164
 
165
- # ==========================
166
- # Modelos
167
- # ==========================
 
 
168
  PerfilType = Literal["econômico", "equilibrado", "premium"]
169
 
 
170
  class PlanRequest(BaseModel):
 
171
  cidade_origem: str
172
  destino: str
173
  data_inicio: str
@@ -181,10 +511,13 @@ class PlanRequest(BaseModel):
181
  @field_validator("data_inicio", "data_fim")
182
  @classmethod
183
  def _valida_data(cls, v: str) -> str:
 
184
  _ = isoparse(v).date()
185
  return v
186
 
 
187
  class Leg(BaseModel):
 
188
  modo: Literal["voo", "rodoviário", "fluvial", "misto"]
189
  origem: str
190
  destino: str
@@ -192,14 +525,18 @@ class Leg(BaseModel):
192
  duracao_h: float
193
  preco_estimado: float
194
 
 
195
  class ItemCusto(BaseModel):
 
196
  categoria: str
197
  descricao: str
198
  quantidade: int
199
  preco_unitario: float
200
  custo_total: float
201
 
 
202
  class DiaRoteiro(BaseModel):
 
203
  data: str
204
  manha: str
205
  tarde: str
@@ -207,7 +544,9 @@ class DiaRoteiro(BaseModel):
207
  custo_estimado_dia: float
208
  narrativa: Optional[str] = ""
209
 
 
210
  class PlanResponse(BaseModel):
 
211
  orcamento_estimado_total: float
212
  legs: List[Leg]
213
  custos_itens: List[ItemCusto]
@@ -221,76 +560,83 @@ class PlanResponse(BaseModel):
221
  teto_orcamento_utilizado: Optional[float] = None
222
  sugestoes: Optional[List[str]] = None
223
 
224
- # ==========================
225
- # Parâmetros de custo
226
- # ==========================
227
- PROFILE_FACTORS = {"econômico": 0.8, "equilibrado": 1.0, "premium": 1.6}
228
- BASES = {
 
 
229
  "refeicoes": 120.0,
230
  "atividades": 80.0,
231
- "hospedagem": {
232
- "econômico": 220.0, "equilibrado": 350.0, "premium": 800.0
233
- },
234
  "voo_preco_km": 0.35,
235
  "rod_preco_km": 0.15,
236
  }
237
 
238
- POIS_MANAUS = [
239
- {"nome":"Teatro Amazonas","bairro":"Centro","slot":"tarde","tags":{"cultura"},"custo":60.0,"indoor":True},
240
- {"nome":"Palácio Rio Negro","bairro":"Centro","slot":"manha","tags":{"cultura"},"custo":0.0,"indoor":True},
241
- {"nome":"Museu da Cidade","bairro":"Centro","slot":"manha","tags":{"cultura"},"custo":20.0,"indoor":True},
242
- {"nome":"Mercado Adolpho Lisboa","bairro":"Centro","slot":"manha","tags":{"gastronomia","cultura"},"custo":0.0,"indoor":False},
243
- {"nome":"Café Regional no Centro","bairro":"Centro","slot":"manha","tags":{"gastronomia"},"custo":35.0,"indoor":True},
244
- {"nome":"Encontro das Águas (barco)","bairro":"Marina","slot":"manha","tags":{"natureza"},"custo":220.0,"indoor":False},
245
- {"nome":"MUSA Museu da Amazônia","bairro":"Zona Norte","slot":"tarde","tags":{"natureza","cultura"},"custo":50.0,"indoor":False},
246
- {"nome":"Praia da Ponta Negra (pôr do sol)","bairro":"Ponta Negra","slot":"tarde","tags":{"natureza"},"custo":0.0,"indoor":False},
247
- {"nome":"Anavilhanas (day-trip)","bairro":"Marina","slot":"manha","tags":{"natureza"},"custo":480.0,"indoor":False},
248
- {"nome":"Praia da Lua","bairro":"Zona Oeste","slot":"tarde","tags":{"natureza"},"custo":0.0,"indoor":False},
249
- {"nome":"Jantar – Tacacá/Tambaqui","bairro":"Centro","slot":"noite","tags":{"gastronomia"},"custo":70.0,"indoor":True},
250
- {"nome":"Restaurante na Ponta Negra","bairro":"Ponta Negra","slot":"noite","tags":{"gastronomia"},"custo":95.0,"indoor":True},
251
- {"nome":"Bar com música regional","bairro":"Centro","slot":"noite","tags":{"cultura"},"custo":50.0,"indoor":True},
 
 
252
  ]
253
 
254
- POIS_BELEM = [
255
- {"nome":"Ver-o-Peso","bairro":"Centro","slot":"manha","tags":{"gastronomia","cultura"},"custo":0.0,"indoor":False},
256
- {"nome":"Mangal das Garças","bairro":"Cidade Velha","slot":"tarde","tags":{"natureza"},"custo":20.0,"indoor":False},
257
- {"nome":"Basílica de Nazaré","bairro":"Nazaré","slot":"manha","tags":{"cultura"},"custo":0.0,"indoor":True},
258
- {"nome":"Estação das Docas","bairro":"Campina","slot":"noite","tags":{"gastronomia","cultura"},"custo":90.0,"indoor":True},
259
- {"nome":"Ilha do Combu (day-trip)","bairro":"Ribeirinha","slot":"manha","tags":{"natureza"},"custo":250.0,"indoor":False},
260
  ]
261
 
262
- POIS_RIO = [
263
- {"nome":"Cristo Redentor","bairro":"Cosme Velho","slot":"manha","tags":{"cultura","natureza"},"custo":89.0,"indoor":False},
264
- {"nome":"Pão de Açúcar","bairro":"Urca","slot":"tarde","tags":{"natureza"},"custo":140.0,"indoor":False},
265
- {"nome":"Museu do Amanhã","bairro":"Centro","slot":"tarde","tags":{"cultura","tecnologia"},"custo":30.0,"indoor":True},
266
- {"nome":"Praia de Copacabana","bairro":"Zona Sul","slot":"manha","tags":{"natureza"},"custo":0.0,"indoor":False},
267
- {"nome":"Lapa à noite","bairro":"Lapa","slot":"noite","tags":{"cultura","gastronomia"},"custo":70.0,"indoor":True},
268
  ]
269
 
270
- POIS_SAO_PAULO = [
271
- {"nome":"Avenida Paulista + MASP","bairro":"Paulista","slot":"tarde","tags":{"cultura"},"custo":50.0,"indoor":True},
272
- {"nome":"Beco do Batman","bairro":"Vila Madalena","slot":"manha","tags":{"cultura"},"custo":0.0,"indoor":False},
273
- {"nome":"Mercadão Municipal","bairro":"Centro","slot":"manha","tags":{"gastronomia"},"custo":40.0,"indoor":True},
274
- {"nome":"Ibirapuera","bairro":"Ibirapuera","slot":"tarde","tags":{"natureza"},"custo":0.0,"indoor":False},
275
- {"nome":"Rooftop/Bar (noite)","bairro":"Centro/Zona Sul","slot":"noite","tags":{"gastronomia"},"custo":80.0,"indoor":True},
276
  ]
277
 
278
- POIS_GENERIC = [
279
- {"nome":"Centro histórico / praça principal","bairro":"Centro","slot":"manha","tags":{"cultura"},"custo":0.0,"indoor":False},
280
- {"nome":"Museu/galeria mais bem avaliado","bairro":"Centro","slot":"tarde","tags":{"cultura"},"custo":30.0,"indoor":True},
281
- {"nome":"Parque urbano / mirante","bairro":"Região central","slot":"tarde","tags":{"natureza"},"custo":0.0,"indoor":False},
282
- {"nome":"Mercado público / feira gastronômica","bairro":"Centro","slot":"manha","tags":{"gastronomia"},"custo":35.0,"indoor":True},
283
- {"nome":"Restaurante típico (noite)","bairro":"Centro","slot":"noite","tags":{"gastronomia"},"custo":80.0,"indoor":True},
 
 
284
  ]
285
 
286
- CITY_POIS = {
 
287
  "manaus": POIS_MANAUS,
288
  "belém": POIS_BELEM,
289
  "rio de janeiro": POIS_RIO,
290
  "são paulo": POIS_SAO_PAULO,
291
  }
292
 
 
293
  def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
 
294
  key = resolve_city_key(raw_city)
295
  if key in CITY_POIS:
296
  return CITY_POIS[key]
@@ -299,10 +645,17 @@ def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
299
  return CITY_POIS[alias]
300
  return POIS_GENERIC
301
 
302
- def filter_pois(pois: List[Dict[str, Any]], tags: List[str], slot: str, indoor: Optional[bool] = None) -> List[Dict[str, Any]]:
303
- filtered = []
 
 
 
 
 
 
 
304
  for poi in pois:
305
- if slot != "" and poi["slot"] != slot:
306
  continue
307
  if indoor is not None and poi["indoor"] != indoor:
308
  continue
@@ -311,44 +664,101 @@ def filter_pois(pois: List[Dict[str, Any]], tags: List[str], slot: str, indoor:
311
  filtered.append(poi)
312
  return filtered
313
 
314
- def get_random_poi(pois: List[Dict[str, Any]], exclude: List[str] = []) -> Optional[Dict[str, Any]]:
 
 
 
 
315
  available = [p for p in pois if p["nome"] not in exclude]
316
  if not available:
317
  return None
318
  return random.choice(available)
319
 
320
- # ==========================
321
- # Lógica de planejamento principal
322
- # ==========================
323
- def recompute_plan(req: 'PlanRequest', d0: date, d1: date, perfil: PerfilType,
324
- force_transport: Optional[str] = None, meals_factor: float = 1.0,
325
- cap_paid: Optional[float] = None, budget_mode: bool = False,
326
- allow_daytrips: bool = True):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  total_orcamento = 0.0
328
  legs: List[Leg] = []
329
  custos_itens: List[ItemCusto] = []
330
  roteiro: List[DiaRoteiro] = []
331
 
 
332
  lat_o, lon_o, origem_label = get_coords_and_label(req.cidade_origem)
333
  lat_d, lon_d, destino_label = get_coords_and_label(req.destino)
334
  origem_coords = (lat_o, lon_o)
335
  destino_coords = (lat_d, lon_d)
336
  distancia_total_km = haversine_km(origem_coords, destino_coords)
337
 
 
 
338
  modo_ida = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
339
  preco_ida = distancia_total_km * BASES["voo_preco_km"] if modo_ida == "voo" else distancia_total_km * BASES["rod_preco_km"]
340
  duracao_ida = distancia_total_km / 700 if modo_ida == "voo" else distancia_total_km / 80
341
- legs.append(Leg(modo=modo_ida, origem=origem_label, destino=destino_label, distancia_km=distancia_total_km, duracao_h=duracao_ida, preco_estimado=preco_ida * req.numero_viajantes))
 
 
 
 
 
 
 
 
 
342
  total_orcamento += preco_ida * req.numero_viajantes
343
- custos_itens.append(ItemCusto(categoria="Transporte", descricao=f"{modo_ida} - Ida", quantidade=req.numero_viajantes, preco_unitario=preco_ida, custo_total=preco_ida * req.numero_viajantes))
 
 
 
 
 
 
 
 
344
 
 
345
  if d0 != d1:
346
  modo_volta = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
347
  preco_volta = distancia_total_km * BASES["voo_preco_km"] if modo_volta == "voo" else distancia_total_km * BASES["rod_preco_km"]
348
  duracao_volta = distancia_total_km / 700 if modo_volta == "voo" else distancia_total_km / 80
349
- legs.append(Leg(modo=modo_volta, origem=destino_label, destino=origem_label, distancia_km=distancia_total_km, duracao_h=duracao_volta, preco_estimado=preco_volta * req.numero_viajantes))
 
 
 
 
 
 
 
 
 
350
  total_orcamento += preco_volta * req.numero_viajantes
351
- custos_itens.append(ItemCusto(categoria="Transporte", descricao=f"{modo_volta} - Volta", quantidade=req.numero_viajantes, preco_unitario=preco_volta, custo_total=preco_volta * req.numero_viajantes))
 
 
 
 
 
 
 
 
352
 
353
  num_dias = (d1 - d0).days + 1
354
  fator_perfil = PROFILE_FACTORS[perfil]
@@ -361,46 +771,128 @@ def recompute_plan(req: 'PlanRequest', d0: date, d1: date, perfil: PerfilType,
361
  atividades_tarde: List[str] = []
362
  atividades_noite: List[str] = []
363
 
 
 
364
  if num_dias > 1:
365
  preco_hospedagem_noite = BASES["hospedagem"][perfil]
366
  custo_hospedagem_dia = preco_hospedagem_noite * req.numero_viajantes / num_dias
367
  custo_dia += custo_hospedagem_dia
368
- custos_itens.append(ItemCusto(categoria="Hospedagem", descricao=f"Diária ({perfil})", quantidade=req.numero_viajantes, preco_unitario=preco_hospedagem_noite / num_dias, custo_total=custo_hospedagem_dia))
369
-
 
 
 
 
 
 
 
 
 
 
370
  custo_refeicoes_dia = BASES["refeicoes"] * req.numero_viajantes * fator_perfil * meals_factor
371
  custo_dia += custo_refeicoes_dia
372
- custos_itens.append(ItemCusto(categoria="Alimentação", descricao=f"Refeições ({perfil})", quantidade=req.numero_viajantes, preco_unitario=BASES["refeicoes"] * fator_perfil * meals_factor, custo_total=custo_refeicoes_dia))
 
 
 
 
 
 
 
 
373
 
374
- poi_manha = get_random_poi(filter_pois(pois_destino, req.temas, "manha", indoor=False if not is_weekend(current_date) else None))
 
 
 
 
 
 
 
 
375
  if poi_manha and (not budget_mode or (cap_paid is not None and poi_manha["custo"] <= cap_paid)):
376
  atividades_manha.append(poi_manha["nome"])
377
  custo_dia += poi_manha["custo"] * req.numero_viajantes
378
- custos_itens.append(ItemCusto(categoria="Atividade", descricao=poi_manha["nome"], quantidade=req.numero_viajantes, preco_unitario=poi_manha["custo"], custo_total=poi_manha["custo"] * req.numero_viajantes))
379
-
380
- poi_tarde = get_random_poi(filter_pois(pois_destino, req.temas, "tarde"), exclude=[p["nome"] for p in [poi_manha] if p])
 
 
 
 
 
 
 
 
 
 
 
 
381
  if poi_tarde and (not budget_mode or (cap_paid is not None and poi_tarde["custo"] <= cap_paid)):
382
  atividades_tarde.append(poi_tarde["nome"])
383
  custo_dia += poi_tarde["custo"] * req.numero_viajantes
384
- custos_itens.append(ItemCusto(categoria="Atividade", descricao=poi_tarde["nome"], quantidade=req.numero_viajantes, preco_unitario=poi_tarde["custo"], custo_total=poi_tarde["custo"] * req.numero_viajantes))
385
-
386
- poi_noite = get_random_poi(filter_pois(pois_destino, req.temas, "noite"), exclude=[p["nome"] for p in [poi_manha, poi_tarde] if p])
 
 
 
 
 
 
 
 
 
 
 
 
387
  if poi_noite and (not budget_mode or (cap_paid is not None and poi_noite["custo"] <= cap_paid)):
388
  atividades_noite.append(poi_noite["nome"])
389
  custo_dia += poi_noite["custo"] * req.numero_viajantes
390
- custos_itens.append(ItemCusto(categoria="Atividade", descricao=poi_noite["nome"], quantidade=req.numero_viajantes, preco_unitario=poi_noite["custo"], custo_total=poi_noite["custo"] * req.numero_viajantes))
391
-
392
- roteiro.append(DiaRoteiro(
393
- data=dia_str,
394
- manha=", ".join(atividades_manha) or "Atividade livre",
395
- tarde=", ".join(atividades_tarde) or "Atividade livre",
396
- noite=", ".join(atividades_noite) or "Atividade livre",
397
- custo_estimado_dia=custo_dia
398
- ))
 
 
 
 
 
 
 
 
 
 
399
  total_orcamento += custo_dia
400
 
401
  return total_orcamento, legs, custos_itens, roteiro
402
 
403
- def fit_to_budget(req: PlanRequest, d0: date, d1: date):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  ajustes: List[str] = []
405
  periodo_ajustado: Optional[Dict[str, str]] = None
406
  sugestoes: Optional[List[str]] = None
@@ -413,9 +905,11 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
413
  allow_daytrips = True
414
 
415
  total, legs, custos, roteiro = recompute_plan(req, d0, d1, perfil)
 
416
  if req.teto_orcamento <= 0 or total <= req.teto_orcamento:
417
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
418
 
 
419
  if any(l.modo == "voo" for l in legs):
420
  force_transport = "rodoviário"
421
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport=force_transport)
@@ -425,12 +919,14 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
425
  if total <= req.teto_orcamento:
426
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
427
 
 
428
  if perfil == "premium":
429
  perfil = "equilibrado"
430
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
431
  if total2 < total:
432
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
433
  ajustes.append("Hospedagem ajustada para perfil 'equilibrado'.")
 
434
  if total > req.teto_orcamento and perfil in ("equilibrado", "premium"):
435
  perfil = "econômico"
436
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
@@ -440,31 +936,41 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
440
  if total <= req.teto_orcamento:
441
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
442
 
 
443
  budget_mode = True
444
  cap_paid = 60.0
445
- total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode)
 
 
446
  if total2 < total:
447
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
448
  ajustes.append("Atividades priorizadas para opções gratuitas/baixas (teto por pessoa/dia).")
449
  if total <= req.teto_orcamento:
450
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
451
 
 
452
  allow_daytrips = False
453
- total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips)
 
 
454
  if total2 < total:
455
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
456
- ajustes.append("Day-trips removidas.")
457
  if total <= req.teto_orcamento:
458
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
459
 
 
460
  meals_factor = 0.85
461
- total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport, meals_factor=meals_factor, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips)
 
 
462
  if total2 < total:
463
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
464
  ajustes.append("Alimentação otimizada (~15% mais em conta).")
465
  if total <= req.teto_orcamento:
466
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
467
 
 
468
  cur_d1 = d1
469
  while total > req.teto_orcamento and (cur_d1 - d0).days + 1 > 2:
470
  cur_d1 = cur_d1 - timedelta(days=1)
@@ -475,7 +981,10 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
475
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
476
  periodo_ajustado = {"data_inicio": d0.isoformat(), "data_fim": cur_d1.isoformat()}
477
  ajustes.append("Viagem encurtada ao final para adequar ao orçamento.")
 
 
478
 
 
479
  if total > req.teto_orcamento:
480
  sugestoes = [
481
  "Considere reduzir o número de viajantes ou dividir quartos.",
@@ -485,30 +994,43 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
485
 
486
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
487
 
 
488
  def risk_tag(raw_dest: str) -> str:
 
489
  k = resolve_city_key(raw_dest)
490
  if k in ("manaus", "belém"):
491
  return "Médio (chuvas tropicais / calor úmido)"
492
  return "Baixo"
493
 
494
- APP_VERSION = "2.3.0"
495
- app = FastAPI(title="IViagem Backend (Smart + Budget + Geocode + Gemini)", version=APP_VERSION)
496
- app.add_middleware(
497
- CORSMiddleware, allow_origins=["*"], allow_credentials=True,
498
- allow_methods=["*"], allow_headers=["*"],
499
- )
500
 
501
  @app.get("/health")
502
- def health():
 
503
  return {"status": "ok", "ts": datetime.utcnow().isoformat()}
504
 
 
505
  @app.get("/info")
506
- def info():
507
- return {"name": "IViagem Planner (smart + budget + geocode + gemini)",
508
- "version": APP_VERSION, "endpoints": ["/health", "/info", "/plan"]}
 
 
 
 
 
 
509
 
510
  @app.post("/plan", response_model=PlanResponse)
511
- def plan(req: PlanRequest):
 
 
 
 
 
512
  try:
513
  d0 = isoparse(req.data_inicio).date()
514
  d1 = isoparse(req.data_fim).date()
@@ -518,16 +1040,15 @@ def plan(req: PlanRequest):
518
  raise HTTPException(status_code=400, detail="data_fim não pode ser anterior a data_inicio.")
519
 
520
  total, parts, ajustes, periodo_ajustado, sugestoes = fit_to_budget(req, d0, d1)
521
-
522
  legs = parts["legs"]
523
  custos_itens = parts["custos"]
524
  roteiro = parts["roteiro"]
525
 
526
- tempo_voo_total = None
527
  if any(l.modo == "voo" for l in legs):
528
  tempo_voo_total = f"{round(sum(l.duracao_h for l in legs), 1)} h"
529
 
530
- economia_vs_base = None
531
  if req.teto_orcamento and req.teto_orcamento > 0:
532
  economia_vs_base = round(req.teto_orcamento - total, 2)
533
 
@@ -535,7 +1056,9 @@ def plan(req: PlanRequest):
535
  risco = risk_tag(req.destino)
536
 
537
  periodo_txt = ""
538
- if periodo_ajustado and (periodo_ajustado['data_inicio'] != req.data_inicio or periodo_ajustado['data_fim'] != req.data_fim):
 
 
539
  periodo_txt = f" Período ajustado: {periodo_ajustado['data_inicio']} a {periodo_ajustado['data_fim']}."
540
 
541
  temas_txt = ", ".join(req.temas)
@@ -553,6 +1076,7 @@ def plan(req: PlanRequest):
553
  f" Período solicitado: {req.data_inicio} a {req.data_fim}.{periodo_txt} Moeda: {req.moeda}."
554
  )
555
 
 
556
  for dia in roteiro:
557
  dia_prompt = (
558
  f"Crie uma narrativa curta para o dia {dia.data} em {destino_label}. "
@@ -563,6 +1087,8 @@ def plan(req: PlanRequest):
563
  narrativa = generate_text_with_llm(dia_prompt, max_tokens=150, temperature=0.9)
564
  dia.narrativa = narrativa or ""
565
 
 
 
566
  if sugestoes is None:
567
  sugestao_prompt = (
568
  f"Com base no planejamento {req.cidade_origem} → {destino_label} ({req.data_inicio} a {req.data_fim}), "
@@ -594,11 +1120,13 @@ def plan(req: PlanRequest):
594
  sugestoes=sugestoes or None,
595
  )
596
 
 
597
  @app.get("/geocode")
598
- def geocode(q: str):
 
599
  gc = geocode_city(q)
600
  if gc is None:
601
  lat, lon, label = get_coords_and_label(q)
602
  return {"online": None, "fallback": {"lat": lat, "lon": lon, "label": label}}
603
  lat, lon, label = gc
604
- return {"online": {"lat": lat, "lon": lon, "label": label}}
 
1
+ """
2
+ IViagem Backend (Smart + Budget + Geocode + Gemini)
3
+ ===================================================
4
+
5
+ This module defines a FastAPI application providing endpoints for generating travel
6
+ plans with cost estimates, activity suggestions, and budget adjustments. It
7
+ integrates with Google's Gemini models via the ``google-generativeai``
8
+ package. Where possible it attempts to select an appropriate model and
9
+ generate text using the native SDK; when that fails (e.g. due to 404 errors
10
+ for certain model/API combinations) it gracefully falls back to the REST API
11
+ using both ``v1`` and ``v1beta`` endpoints.
12
+
13
+ The code is heavily commented to aid maintenance and clarity. Environment
14
+ variables control model selection and API keys. See the README or the
15
+ ``requirements.txt`` file for further details.
16
+ """
17
 
18
  from __future__ import annotations
19
 
20
  import os
21
  import math
22
  import random
23
+ import logging
24
  from functools import lru_cache
25
  from datetime import date, datetime, timedelta
26
  from typing import List, Optional, Literal, Dict, Any, Tuple
27
 
28
  import httpx
29
  from dateutil.parser import isoparse
30
+ from fastapi import FastAPI, HTTPException, Response
31
  from fastapi.middleware.cors import CORSMiddleware
32
+ from fastapi.responses import HTMLResponse
33
  from pydantic import BaseModel, Field, field_validator
34
 
35
+ # =============================================================================
36
+ # App & Configuration
37
+ # =============================================================================
 
 
38
 
39
+ # Semantic version of this backend. Bump when behaviour changes.
40
+ APP_VERSION = "2.3.1"
41
+
42
+ # Configure the logger to integrate with uvicorn.
43
+ log = logging.getLogger("uvicorn.error")
44
+
45
+ # Initialise FastAPI app with a descriptive title and version.
46
+ app = FastAPI(
47
+ title="IViagem Backend (Smart + Budget + Geocode + Gemini)",
48
+ version=APP_VERSION,
49
+ )
50
+
51
+ # Add CORS support. In production you should restrict ``allow_origins``
52
+ # to the domains of your frontend application to prevent unwanted access.
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=["*"],
56
+ allow_credentials=True,
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+ # =============================================================================
62
+ # Gemini (google-generativeai) integration
63
+ # =============================================================================
64
+
65
+ # Default Gemini model name. ``gemini-flash-latest`` automatically aliases to
66
+ # the most recent flash model supported by the API. You can override this via
67
+ # the GEMINI_MODEL environment variable.
68
+ GEMINI_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-flash-latest")
69
+
70
+ # API key used to authenticate with Google's generative language API. You can
71
+ # supply either ``GOOGLE_API_KEY`` or ``GEMINI_API_KEY``. Keys must start
72
+ # with ``AIza`` for the REST API to accept them.
73
+ GEMINI_API_KEY = (os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
74
+
75
+ # Module‑level caches to hold the instantiated model and the effective
76
+ # model name chosen based on API capabilities.
77
+ _genai_model: Optional[Any] = None
78
+ _effective_model: Optional[str] = None
79
+
80
+
81
+ def _probe_gemini_key(api_key: str) -> tuple[bool, str]:
82
+ """Validate the API key by querying the models endpoint.
83
+
84
+ This helper does not log the key itself. It returns a tuple ``(ok,
85
+ message)`` where ``ok`` indicates whether the key could list models
86
+ successfully and ``message`` provides diagnostic information.
87
+ """
88
+ if not api_key:
89
+ return False, "GOOGLE_API_KEY/GEMINI_API_KEY ausente."
90
+ if not api_key.startswith("AIza"):
91
+ return False, "Formato inesperado (não começa com AIza)."
92
+ url = f"https://generativelanguage.googleapis.com/v1/models?key={api_key}"
93
+ try:
94
+ r = httpx.get(url, timeout=10)
95
+ if r.status_code == 200:
96
+ return True, "OK"
97
+ return False, f"{r.status_code} {r.text[:300]}"
98
+ except Exception as e:
99
+ return False, f"Falha HTTP: {e!r}"
100
+
101
+
102
+ def _list_models_v1(api_key: str) -> list[dict]:
103
+ """List models available via the REST v1 endpoint.
104
+
105
+ Returns an empty list on failure or if no models could be retrieved.
106
+ """
107
+ try:
108
+ r = httpx.get(
109
+ f"https://generativelanguage.googleapis.com/v1/models?key={api_key}",
110
+ timeout=10,
111
+ )
112
+ if r.status_code == 200:
113
+ return r.json().get("models", [])
114
+ except Exception:
115
+ pass
116
+ return []
117
+
118
+
119
+ def _pick_supported_model(api_key: str, preferred: str) -> str:
120
+ """Select a model that supports ``generateContent``.
121
+
122
+ If the preferred model is found in the listing returned by the REST API it
123
+ is used. Otherwise the first model supporting ``generateContent`` is
124
+ returned. As a last resort it returns ``preferred`` unchanged.
125
+ """
126
+ models = _list_models_v1(api_key)
127
+ names = [m.get("name", "") for m in models]
128
+ if preferred in names:
129
+ return preferred
130
+ for m in models:
131
+ methods = (
132
+ m.get("supportedGenerationMethods")
133
+ or m.get("supported_generation_methods")
134
+ or []
135
+ )
136
+ if "generateContent" in methods:
137
+ return m["name"]
138
+ # Fallback: return preferred even if unknown; the REST API may still work.
139
+ return preferred
140
+
141
+
142
+ @app.on_event("startup")
143
+ def _startup_check() -> None:
144
+ """Log the status of the Gemini key on startup.
145
+
146
+ The startup hook runs when FastAPI starts. It checks the API key and
147
+ writes a diagnostic message to the uvicorn logger.
148
+ """
149
+ key = GEMINI_API_KEY or ""
150
+ ok, msg = _probe_gemini_key(key)
151
+ if ok:
152
+ log.info("[startup] Gemini OK (models endpoint respondeu 200).")
153
+ else:
154
+ log.warning("[startup] Gemini inválido: %s", msg)
155
+
156
+
157
+ def _init_gemini() -> Optional[Any]:
158
+ """Initialise the Gemini client once, choosing a supported model if possible.
159
+
160
+ Attempts to import ``google.generativeai`` and configure it with the API key.
161
+ If the module import or configuration fails, it logs a warning and
162
+ returns ``None``. The effective model name is stored in the module
163
+ variable ``_effective_model``.
164
+ """
165
+ global _genai_model, _effective_model
166
  if _genai_model is not None:
167
  return _genai_model
168
  try:
169
+ import google.generativeai as genai # type: ignore
170
+ # If no API key is provided, do not attempt to configure the client.
171
  if not GEMINI_API_KEY:
172
  return None
173
+ # Choose a supported model based on the listing of available models.
174
+ _effective_model = _pick_supported_model(GEMINI_API_KEY, GEMINI_MODEL_NAME)
175
  genai.configure(api_key=GEMINI_API_KEY)
176
+ _genai_model = genai.GenerativeModel(_effective_model)
177
  except Exception as e:
178
+ # Fall back to REST only; suppress import/config errors for clarity.
179
  print(f"[warn] Gemini init falhou: {e}")
180
  _genai_model = None
181
  return _genai_model
182
 
183
+
184
+ def _rest_generate_content(prompt: str, max_tokens: int, temperature: float) -> str:
185
+ """Fallback generator using the REST API.
186
+
187
+ This helper tries the ``v1`` endpoint first; if it receives a 404 it
188
+ automatically retries with ``v1beta``. It returns the first non-empty
189
+ response it can extract or an empty string on failure.
190
  """
191
+ model = _effective_model or GEMINI_MODEL_NAME
192
+ if not GEMINI_API_KEY:
193
+ return ""
194
+ # Prepare the payload in the format expected by the REST API.
195
+ payload = {
196
+ "contents": [{"role": "user", "parts": [{"text": prompt}]}],
197
+ "generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
198
+ }
199
+ # Try both API versions: first v1, then v1beta if necessary.
200
+ for api_ver in ("v1", "v1beta"):
201
+ url = (
202
+ f"https://generativelanguage.googleapis.com/"
203
+ f"{api_ver}/models/{model}:generateContent?key={GEMINI_API_KEY}"
204
  )
 
 
 
205
  try:
206
+ resp = httpx.post(url, json=payload, timeout=25)
207
+ except Exception as exc:
208
+ print(f"[warn] REST {api_ver} generateContent exceção: {exc}")
209
+ continue
210
+ if resp.status_code == 404 and api_ver == "v1":
211
+ # Some models (e.g. gemini-1.5-*) require the v1beta endpoint.
212
+ continue
213
+ if resp.status_code != 200:
214
+ print(
215
+ f"[warn] REST {api_ver} generateContent falhou: {resp.status_code} "
216
+ f"{resp.text[:200]}"
217
+ )
218
+ continue
219
+ try:
220
+ data = resp.json()
221
  except Exception:
222
+ continue
223
+ # Extract text from the returned candidates/parts structure.
224
+ for cand in data.get("candidates", []) or []:
225
+ parts = cand.get("content", {}).get("parts", []) or []
226
+ texts = [p.get("text", "") for p in parts if p.get("text")]
227
+ if texts:
228
+ return "\n".join(texts).strip()
229
+ return ""
230
+
231
+
232
+ def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: float = 0.7) -> str:
233
+ """Generate text using the Gemini SDK with safe extraction and REST fallback.
234
+
235
+ The SDK sometimes raises ``Invalid operation`` when the quick accessor
236
+ ``response.text`` is used and the response has no valid parts due to safety
237
+ filtering. To avoid this, we never access ``resp.text`` directly; we
238
+ iterate over the returned candidates and extract the ``text`` from each
239
+ part manually. If the SDK fails or yields no parts, we fall back to the
240
+ REST API.
241
+ """
242
+ try:
243
+ model = _init_gemini()
244
+ if model is not None:
245
+ resp = model.generate_content(
246
+ prompt,
247
+ generation_config={
248
+ "temperature": temperature,
249
+ "max_output_tokens": max_tokens,
250
+ },
251
+ )
252
+ # Always extract text by iterating through candidates and parts. Do
253
+ # not call ``resp.text`` as it may raise when no valid parts exist.
254
+ parts_out: List[str] = []
255
+ for cand in getattr(resp, "candidates", []) or []:
256
+ content = getattr(cand, "content", {}) or {}
257
+ for part in getattr(content, "parts", []) or []:
258
+ txt = getattr(part, "text", "")
259
+ if txt:
260
+ parts_out.append(txt)
261
+ if parts_out:
262
+ return "\n".join(parts_out).strip()
263
  except Exception as e:
264
+ # Log SDK failures and proceed to REST fallback.
265
+ print(f"[warn] Erro Gemini (SDK): {e}")
266
+ # Use REST fallback if SDK generation fails or returns no text.
267
+ return _rest_generate_content(prompt, max_tokens, temperature)
268
 
269
+
270
+ # =============================================================================
271
+ # HTTP utilitários e página raiz
272
+ # =============================================================================
273
+
274
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
275
+ def home() -> str:
276
+ """Serve uma página HTML simples na raiz da API.
277
+
278
+ A query string ``?logs=container`` é ignorada para compatibilidade com
279
+ a interface do Hugging Face Spaces.
280
+ """
281
+ return f"""
282
+ <!doctype html>
283
+ <html lang="pt-BR">
284
+ <head>
285
+ <meta charset="utf-8" />
286
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
287
+ <title>IViagem API</title>
288
+ <style>
289
+ body {{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; padding: 24px; line-height:1.6; }}
290
+ code {{ background:#f4f4f5; padding:2px 6px; border-radius:6px; }}
291
+ a {{ color:#0ea5e9; text-decoration:none; }}
292
+ .tag {{ display:inline-block; padding:2px 6px; border-radius:999px; background:#ecfeff; color:#0e7490; font-weight:600; font-size:12px; }}
293
+ </style>
294
+ </head>
295
+ <body>
296
+ <h1>IViagem Backend</h1>
297
+ <p class="tag">v{APP_VERSION}</p>
298
+ <p>API ativa. Endpoints úteis:</p>
299
+ <ul>
300
+ <li><a href="/info">/info</a></li>
301
+ <li><a href="/health">/health</a></li>
302
+ <li><a href="/docs">/docs</a> (Swagger)</li>
303
+ <li><a href="/redoc">/redoc</a></li>
304
+ </ul>
305
+ <p>Exemplo <code>POST /plan</code> está em <a href="/docs">/docs</a>.</p>
306
+ </body>
307
+ </html>
308
+ """
309
+
310
+
311
+ @app.get("/favicon.ico", include_in_schema=False)
312
+ def favicon() -> Response:
313
+ """Return an empty 204 response for the favicon request."""
314
+ return Response(status_code=204)
315
+
316
+
317
+ @app.get("/debug/gemini")
318
+ def debug_gemini() -> Dict[str, Any]:
319
+ """Provide diagnostic information about the Gemini configuration.
320
+
321
+ Returns whether the key probes successfully, a masked key preview, and
322
+ the first few models returned by the models endpoint. Useful for
323
+ debugging misconfiguration.
324
+ """
325
+ ok, msg = _probe_gemini_key(GEMINI_API_KEY or "")
326
+ out: Dict[str, Any] = {
327
+ "ok": ok,
328
+ "probe": msg,
329
+ "effective_model": _effective_model or GEMINI_MODEL_NAME,
330
+ }
331
+ if ok:
332
+ try:
333
+ r = httpx.get(
334
+ f"https://generativelanguage.googleapis.com/v1/models?key={GEMINI_API_KEY}",
335
+ timeout=10,
336
+ )
337
+ j = r.json()
338
+ out["first_models"] = [m["name"] for m in j.get("models", [])[:5]]
339
+ except Exception as e:
340
+ out["models_error"] = repr(e)
341
+ return out
342
+
343
+
344
+ @app.get("/debug/env")
345
+ def debug_env() -> Dict[str, Any]:
346
+ """Expose basic information about the configured API key.
347
+
348
+ The key itself is masked to avoid leaking secrets. This endpoint can
349
+ help verify that the key is loaded and correctly formatted.
350
+ """
351
+ k = GEMINI_API_KEY or ""
352
+ masked = (k[:4] + "*" * max(0, len(k) - 8) + k[-4:]) if k else ""
353
+ return {
354
+ "has_key": bool(k),
355
+ "prefix_is_AIZa": k.startswith("AIza"),
356
+ "length": len(k),
357
+ "masked_sample": masked,
358
+ "effective_model": _effective_model or GEMINI_MODEL_NAME,
359
+ }
360
+
361
+
362
+ # =============================================================================
363
+ # Geocoding (Nominatim/OSM) with cache and fallback
364
+ # =============================================================================
365
+
366
+ # Toggle online geocoding via environment variable. Set ``USE_ONLINE_GEOCODING=0``
367
+ # to disable network calls and rely solely on built‑in city coordinates.
368
  USE_ONLINE_GEOCODING = os.getenv("USE_ONLINE_GEOCODING", "1") != "0"
369
+
370
  NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
371
 
372
+
373
  @lru_cache(maxsize=256)
374
+ def geocode_city(query: str) -> Tuple[float, float, str] | None:
375
+ """Look up a city name via OpenStreetMap's Nominatim service.
376
+
377
+ Returns a tuple ``(lat, lon, display_name)`` or ``None`` if not found or
378
+ if online geocoding is disabled. Results are cached for efficiency.
379
+ """
380
  if not USE_ONLINE_GEOCODING:
381
  return None
382
  q = (query or "").strip()
383
  if not q:
384
  return None
385
  headers = {
386
+ "User-Agent": f"IViagem/{APP_VERSION} (contato: suporte@ivg.local)",
387
  "Accept-Language": "pt-BR",
388
  }
389
  params = {"q": q, "format": "jsonv2", "limit": 1, "addressdetails": 0}
 
403
  except Exception:
404
  return None
405
 
406
+
407
+ # =============================================================================
408
+ # Geography / city catalogue and aliases
409
+ # =============================================================================
410
+
411
+ CITY_COORDS: Dict[str, Tuple[float, float, str]] = {
412
  "são paulo": (-23.5505, -46.6333, "São Paulo, SP"),
413
  "rio de janeiro": (-22.9068, -43.1729, "Rio de Janeiro, RJ"),
414
  "manaus": (-3.1190, -60.0217, "Manaus, AM"),
 
421
  "florianópolis": (-27.5949, -48.5482, "Florianópolis, SC"),
422
  }
423
 
424
+ CITY_ALIASES: Dict[str, str] = {
425
  "amazonia": "manaus",
426
  "amazônia": "manaus",
427
  "belem": "belém",
 
429
  "sp": "são paulo",
430
  }
431
 
432
+
433
  def norm(s: str) -> str:
434
+ """Normalise a string to lower case and strip whitespace."""
435
  return (s or "").strip().lower()
436
 
437
+
438
  def resolve_city_key(raw: str) -> str:
439
+ """Map a raw city string to a canonical key using aliases."""
440
  k = norm(raw)
441
  if k in CITY_COORDS:
442
  return k
443
  return CITY_ALIASES.get(k, k)
444
 
445
+
446
+ def get_coords_and_label(raw: str) -> Tuple[float, float, str]:
447
+ """Retrieve latitude, longitude and display label for a city.
448
+
449
+ Uses online geocoding if enabled; otherwise falls back to a predefined
450
+ catalogue or defaults to São Paulo when the city is unknown. The
451
+ original string is used as a label when no other information is available.
452
+ """
453
  gc = geocode_city(raw)
454
  if gc is not None:
455
  return gc
 
460
  label = raw.strip() if raw.strip() else "Destino"
461
  return (lat, lon, label)
462
 
463
+
464
+ # =============================================================================
465
+ # Utility functions
466
+ # =============================================================================
467
+
468
  def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
469
+ """Compute the great-circle distance between two coordinates in km."""
470
  (lat1, lon1), (lat2, lon2) = a, b
471
  R = 6371.0
472
  p1, p2 = math.radians(lat1), math.radians(lat2)
473
  dp = math.radians(lat2 - lat1)
474
  dl = math.radians(lon2 - lon1)
475
+ x = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
476
+ return R * 2 * math.atan2(math.sqrt(x), math.sqrt(1 - x))
477
+
478
 
479
  def daterange(d0: date, d1: date):
480
+ """Yield dates between two endpoints inclusive."""
481
  d = d0
482
  while d <= d1:
483
  yield d
484
  d += timedelta(days=1)
485
 
486
+
487
  def is_weekend(d: date) -> bool:
488
+ """Return True if the given date is a weekend (Saturday or Sunday)."""
489
  return d.weekday() >= 5
490
 
491
+
492
+ # =============================================================================
493
+ # Pydantic models
494
+ # =============================================================================
495
+
496
  PerfilType = Literal["econômico", "equilibrado", "premium"]
497
 
498
+
499
  class PlanRequest(BaseModel):
500
+ """Schema for the /plan request body."""
501
  cidade_origem: str
502
  destino: str
503
  data_inicio: str
 
511
  @field_validator("data_inicio", "data_fim")
512
  @classmethod
513
  def _valida_data(cls, v: str) -> str:
514
+ # Validate ISO date strings; raise if invalid.
515
  _ = isoparse(v).date()
516
  return v
517
 
518
+
519
  class Leg(BaseModel):
520
+ """Represents a transport leg (e.g. flight or road trip)."""
521
  modo: Literal["voo", "rodoviário", "fluvial", "misto"]
522
  origem: str
523
  destino: str
 
525
  duracao_h: float
526
  preco_estimado: float
527
 
528
+
529
  class ItemCusto(BaseModel):
530
+ """Represents an itemised cost component."""
531
  categoria: str
532
  descricao: str
533
  quantidade: int
534
  preco_unitario: float
535
  custo_total: float
536
 
537
+
538
  class DiaRoteiro(BaseModel):
539
+ """Represents one day's itinerary with activities and costs."""
540
  data: str
541
  manha: str
542
  tarde: str
 
544
  custo_estimado_dia: float
545
  narrativa: Optional[str] = ""
546
 
547
+
548
  class PlanResponse(BaseModel):
549
+ """Schema for the response returned by /plan."""
550
  orcamento_estimado_total: float
551
  legs: List[Leg]
552
  custos_itens: List[ItemCusto]
 
560
  teto_orcamento_utilizado: Optional[float] = None
561
  sugestoes: Optional[List[str]] = None
562
 
563
+
564
+ # =============================================================================
565
+ # Cost parameters and points of interest (POIs)
566
+ # =============================================================================
567
+
568
+ PROFILE_FACTORS: Dict[str, float] = {"econômico": 0.8, "equilibrado": 1.0, "premium": 1.6}
569
+ BASES: Dict[str, Any] = {
570
  "refeicoes": 120.0,
571
  "atividades": 80.0,
572
+ "hospedagem": {"econômico": 220.0, "equilibrado": 350.0, "premium": 800.0},
 
 
573
  "voo_preco_km": 0.35,
574
  "rod_preco_km": 0.15,
575
  }
576
 
577
+ # Define points of interest for specific cities. Each entry includes a name,
578
+ # neighbourhood, timeslot, tags, cost, and whether it is indoors.
579
+ POIS_MANAUS: List[Dict[str, Any]] = [
580
+ {"nome": "Teatro Amazonas", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 60.0, "indoor": True},
581
+ {"nome": "Palácio Rio Negro", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
582
+ {"nome": "Museu da Cidade", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 20.0, "indoor": True},
583
+ {"nome": "Mercado Adolpho Lisboa", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia", "cultura"}, "custo": 0.0, "indoor": False},
584
+ {"nome": "Café Regional no Centro", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 35.0, "indoor": True},
585
+ {"nome": "Encontro das Águas (barco)", "bairro": "Marina", "slot": "manha", "tags": {"natureza"}, "custo": 220.0, "indoor": False},
586
+ {"nome": "MUSA – Museu da Amazônia", "bairro": "Zona Norte", "slot": "tarde", "tags": {"natureza", "cultura"}, "custo": 50.0, "indoor": False},
587
+ {"nome": "Praia da Ponta Negra (pôr do sol)", "bairro": "Ponta Negra", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
588
+ {"nome": "Anavilhanas (day-trip)", "bairro": "Marina", "slot": "manha", "tags": {"natureza"}, "custo": 480.0, "indoor": False},
589
+ {"nome": "Praia da Lua", "bairro": "Zona Oeste", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
590
+ {"nome": "Jantar Tacacá/Tambaqui", "bairro": "Centro", "slot": "noite", "tags": {"gastronomia"}, "custo": 70.0, "indoor": True},
591
+ {"nome": "Restaurante na Ponta Negra", "bairro": "Ponta Negra", "slot": "noite", "tags": {"gastronomia"}, "custo": 95.0, "indoor": True},
592
+ {"nome": "Bar com música regional", "bairro": "Centro", "slot": "noite", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
593
  ]
594
 
595
+ POIS_BELEM: List[Dict[str, Any]] = [
596
+ {"nome": "Ver-o-Peso", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia", "cultura"}, "custo": 0.0, "indoor": False},
597
+ {"nome": "Mangal das Garças", "bairro": "Cidade Velha", "slot": "tarde", "tags": {"natureza"}, "custo": 20.0, "indoor": False},
598
+ {"nome": "Basílica de Nazaré", "bairro": "Nazaré", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
599
+ {"nome": "Estação das Docas", "bairro": "Campina", "slot": "noite", "tags": {"gastronomia", "cultura"}, "custo": 90.0, "indoor": True},
600
+ {"nome": "Ilha do Combu (day-trip)", "bairro": "Ribeirinha", "slot": "manha", "tags": {"natureza"}, "custo": 250.0, "indoor": False},
601
  ]
602
 
603
+ POIS_RIO: List[Dict[str, Any]] = [
604
+ {"nome": "Cristo Redentor", "bairro": "Cosme Velho", "slot": "manha", "tags": {"cultura", "natureza"}, "custo": 89.0, "indoor": False},
605
+ {"nome": "Pão de Açúcar", "bairro": "Urca", "slot": "tarde", "tags": {"natureza"}, "custo": 140.0, "indoor": False},
606
+ {"nome": "Museu do Amanhã", "bairro": "Centro", "slot": "tarde", "tags": {"cultura", "tecnologia"}, "custo": 30.0, "indoor": True},
607
+ {"nome": "Praia de Copacabana", "bairro": "Zona Sul", "slot": "manha", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
608
+ {"nome": "Lapa à noite", "bairro": "Lapa", "slot": "noite", "tags": {"cultura", "gastronomia"}, "custo": 70.0, "indoor": True},
609
  ]
610
 
611
+ POIS_SAO_PAULO: List[Dict[str, Any]] = [
612
+ {"nome": "Avenida Paulista + MASP", "bairro": "Paulista", "slot": "tarde", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
613
+ {"nome": "Beco do Batman", "bairro": "Vila Madalena", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
614
+ {"nome": "Mercadão Municipal", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 40.0, "indoor": True},
615
+ {"nome": "Ibirapuera", "bairro": "Ibirapuera", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
616
+ {"nome": "Rooftop/Bar (noite)", "bairro": "Centro/Zona Sul", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
617
  ]
618
 
619
+ # Generic POIs used when a city has no specific listing. These provide a
620
+ # reasonable default selection of activities across different times of day.
621
+ POIS_GENERIC: List[Dict[str, Any]] = [
622
+ {"nome": "Centro histórico / praça principal", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
623
+ {"nome": "Museu/galeria mais bem avaliado", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 30.0, "indoor": True},
624
+ {"nome": "Parque urbano / mirante", "bairro": "Região central", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
625
+ {"nome": "Mercado público / feira gastronômica", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 35.0, "indoor": True},
626
+ {"nome": "Restaurante típico (noite)", "bairro": "Centro", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
627
  ]
628
 
629
+ # Map city keys to their specific POI lists.
630
+ CITY_POIS: Dict[str, List[Dict[str, Any]]] = {
631
  "manaus": POIS_MANAUS,
632
  "belém": POIS_BELEM,
633
  "rio de janeiro": POIS_RIO,
634
  "são paulo": POIS_SAO_PAULO,
635
  }
636
 
637
+
638
  def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
639
+ """Return a list of POIs for the given city or a generic list if none are defined."""
640
  key = resolve_city_key(raw_city)
641
  if key in CITY_POIS:
642
  return CITY_POIS[key]
 
645
  return CITY_POIS[alias]
646
  return POIS_GENERIC
647
 
648
+
649
+ def filter_pois(
650
+ pois: List[Dict[str, Any]],
651
+ tags: List[str],
652
+ slot: str,
653
+ indoor: Optional[bool] = None,
654
+ ) -> List[Dict[str, Any]]:
655
+ """Filter POIs by tags, time slot and indoor/outdoor criteria."""
656
+ filtered: List[Dict[str, Any]] = []
657
  for poi in pois:
658
+ if slot and poi["slot"] != slot:
659
  continue
660
  if indoor is not None and poi["indoor"] != indoor:
661
  continue
 
664
  filtered.append(poi)
665
  return filtered
666
 
667
+
668
+ def get_random_poi(pois: List[Dict[str, Any]], exclude: List[str] | None = None) -> Optional[Dict[str, Any]]:
669
+ """Select a random POI from the list, excluding any by name."""
670
+ if exclude is None:
671
+ exclude = []
672
  available = [p for p in pois if p["nome"] not in exclude]
673
  if not available:
674
  return None
675
  return random.choice(available)
676
 
677
+
678
+ # =============================================================================
679
+ # Planning logic
680
+ # =============================================================================
681
+
682
+ def recompute_plan(
683
+ req: PlanRequest,
684
+ d0: date,
685
+ d1: date,
686
+ perfil: PerfilType,
687
+ force_transport: Optional[str] = None,
688
+ meals_factor: float = 1.0,
689
+ cap_paid: Optional[float] = None,
690
+ budget_mode: bool = False,
691
+ allow_daytrips: bool = True,
692
+ ) -> Tuple[float, List[Leg], List[ItemCusto], List[DiaRoteiro]]:
693
+ """Compute a detailed travel plan for the given input parameters.
694
+
695
+ Returns the total estimated budget, the list of transport legs, itemised
696
+ costs, and daily itinerary. This function does not apply any budget
697
+ adjustments; see ``fit_to_budget`` for that.
698
+ """
699
  total_orcamento = 0.0
700
  legs: List[Leg] = []
701
  custos_itens: List[ItemCusto] = []
702
  roteiro: List[DiaRoteiro] = []
703
 
704
+ # Determine coordinates and labels for origin and destination.
705
  lat_o, lon_o, origem_label = get_coords_and_label(req.cidade_origem)
706
  lat_d, lon_d, destino_label = get_coords_and_label(req.destino)
707
  origem_coords = (lat_o, lon_o)
708
  destino_coords = (lat_d, lon_d)
709
  distancia_total_km = haversine_km(origem_coords, destino_coords)
710
 
711
+ # Compute the first leg (outbound). Choose flight for long distances (>500 km)
712
+ # unless a specific transport mode is forced.
713
  modo_ida = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
714
  preco_ida = distancia_total_km * BASES["voo_preco_km"] if modo_ida == "voo" else distancia_total_km * BASES["rod_preco_km"]
715
  duracao_ida = distancia_total_km / 700 if modo_ida == "voo" else distancia_total_km / 80
716
+ legs.append(
717
+ Leg(
718
+ modo=modo_ida,
719
+ origem=origem_label,
720
+ destino=destino_label,
721
+ distancia_km=distancia_total_km,
722
+ duracao_h=duracao_ida,
723
+ preco_estimado=preco_ida * req.numero_viajantes,
724
+ )
725
+ )
726
  total_orcamento += preco_ida * req.numero_viajantes
727
+ custos_itens.append(
728
+ ItemCusto(
729
+ categoria="Transporte",
730
+ descricao=f"{modo_ida} - Ida",
731
+ quantidade=req.numero_viajantes,
732
+ preco_unitario=preco_ida,
733
+ custo_total=preco_ida * req.numero_viajantes,
734
+ )
735
+ )
736
 
737
+ # Return leg (if travel spans at least one night). Distances and modes mirror the outbound.
738
  if d0 != d1:
739
  modo_volta = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
740
  preco_volta = distancia_total_km * BASES["voo_preco_km"] if modo_volta == "voo" else distancia_total_km * BASES["rod_preco_km"]
741
  duracao_volta = distancia_total_km / 700 if modo_volta == "voo" else distancia_total_km / 80
742
+ legs.append(
743
+ Leg(
744
+ modo=modo_volta,
745
+ origem=destino_label,
746
+ destino=origem_label,
747
+ distancia_km=distancia_total_km,
748
+ duracao_h=duracao_volta,
749
+ preco_estimado=preco_volta * req.numero_viajantes,
750
+ )
751
+ )
752
  total_orcamento += preco_volta * req.numero_viajantes
753
+ custos_itens.append(
754
+ ItemCusto(
755
+ categoria="Transporte",
756
+ descricao=f"{modo_volta} - Volta",
757
+ quantidade=req.numero_viajantes,
758
+ preco_unitario=preco_volta,
759
+ custo_total=preco_volta * req.numero_viajantes,
760
+ )
761
+ )
762
 
763
  num_dias = (d1 - d0).days + 1
764
  fator_perfil = PROFILE_FACTORS[perfil]
 
771
  atividades_tarde: List[str] = []
772
  atividades_noite: List[str] = []
773
 
774
+ # Allocate lodging cost across each day; divide by number of days to
775
+ # spread the cost evenly when multiple nights.
776
  if num_dias > 1:
777
  preco_hospedagem_noite = BASES["hospedagem"][perfil]
778
  custo_hospedagem_dia = preco_hospedagem_noite * req.numero_viajantes / num_dias
779
  custo_dia += custo_hospedagem_dia
780
+ custos_itens.append(
781
+ ItemCusto(
782
+ categoria="Hospedagem",
783
+ descricao=f"Diária ({perfil})",
784
+ quantidade=req.numero_viajantes,
785
+ preco_unitario=preco_hospedagem_noite / num_dias,
786
+ custo_total=custo_hospedagem_dia,
787
+ )
788
+ )
789
+
790
+ # Meal cost: based on profile factor, number of travellers and optional
791
+ # meals_factor to reduce costs when adjusting to budgets.
792
  custo_refeicoes_dia = BASES["refeicoes"] * req.numero_viajantes * fator_perfil * meals_factor
793
  custo_dia += custo_refeicoes_dia
794
+ custos_itens.append(
795
+ ItemCusto(
796
+ categoria="Alimentação",
797
+ descricao=f"Refeições ({perfil})",
798
+ quantidade=req.numero_viajantes,
799
+ preco_unitario=BASES["refeicoes"] * fator_perfil * meals_factor,
800
+ custo_total=custo_refeicoes_dia,
801
+ )
802
+ )
803
 
804
+ # Select morning activity: avoid paid indoor activities on weekdays if not weekend and not budget mode.
805
+ poi_manha = get_random_poi(
806
+ filter_pois(
807
+ pois_destino,
808
+ req.temas,
809
+ "manha",
810
+ indoor=False if not is_weekend(current_date) else None,
811
+ )
812
+ )
813
  if poi_manha and (not budget_mode or (cap_paid is not None and poi_manha["custo"] <= cap_paid)):
814
  atividades_manha.append(poi_manha["nome"])
815
  custo_dia += poi_manha["custo"] * req.numero_viajantes
816
+ custos_itens.append(
817
+ ItemCusto(
818
+ categoria="Atividade",
819
+ descricao=poi_manha["nome"],
820
+ quantidade=req.numero_viajantes,
821
+ preco_unitario=poi_manha["custo"],
822
+ custo_total=poi_manha["custo"] * req.numero_viajantes,
823
+ )
824
+ )
825
+
826
+ # Afternoon activity: ensure not to repeat morning activity.
827
+ poi_tarde = get_random_poi(
828
+ filter_pois(pois_destino, req.temas, "tarde"),
829
+ exclude=[p["nome"] for p in [poi_manha] if p],
830
+ )
831
  if poi_tarde and (not budget_mode or (cap_paid is not None and poi_tarde["custo"] <= cap_paid)):
832
  atividades_tarde.append(poi_tarde["nome"])
833
  custo_dia += poi_tarde["custo"] * req.numero_viajantes
834
+ custos_itens.append(
835
+ ItemCusto(
836
+ categoria="Atividade",
837
+ descricao=poi_tarde["nome"],
838
+ quantidade=req.numero_viajantes,
839
+ preco_unitario=poi_tarde["custo"],
840
+ custo_total=poi_tarde["custo"] * req.numero_viajantes,
841
+ )
842
+ )
843
+
844
+ # Evening activity: avoid repeating earlier activities.
845
+ poi_noite = get_random_poi(
846
+ filter_pois(pois_destino, req.temas, "noite"),
847
+ exclude=[p["nome"] for p in [poi_manha, poi_tarde] if p],
848
+ )
849
  if poi_noite and (not budget_mode or (cap_paid is not None and poi_noite["custo"] <= cap_paid)):
850
  atividades_noite.append(poi_noite["nome"])
851
  custo_dia += poi_noite["custo"] * req.numero_viajantes
852
+ custos_itens.append(
853
+ ItemCusto(
854
+ categoria="Atividade",
855
+ descricao=poi_noite["nome"],
856
+ quantidade=req.numero_viajantes,
857
+ preco_unitario=poi_noite["custo"],
858
+ custo_total=poi_noite["custo"] * req.numero_viajantes,
859
+ )
860
+ )
861
+
862
+ roteiro.append(
863
+ DiaRoteiro(
864
+ data=dia_str,
865
+ manha=", ".join(atividades_manha) or "Atividade livre",
866
+ tarde=", ".join(atividades_tarde) or "Atividade livre",
867
+ noite=", ".join(atividades_noite) or "Atividade livre",
868
+ custo_estimado_dia=custo_dia,
869
+ )
870
+ )
871
  total_orcamento += custo_dia
872
 
873
  return total_orcamento, legs, custos_itens, roteiro
874
 
875
+
876
+ def fit_to_budget(
877
+ req: PlanRequest,
878
+ d0: date,
879
+ d1: date,
880
+ ) -> Tuple[
881
+ float,
882
+ Dict[str, Any],
883
+ List[str],
884
+ Optional[Dict[str, str]],
885
+ Optional[List[str]],
886
+ ]:
887
+ """Adjust the travel plan to fit within the provided budget.
888
+
889
+ This function repeatedly recalculates the plan with different cost
890
+ reductions (transport mode, accommodation profile, free activities, meal
891
+ reductions, shorter duration) until the total is below the budget or no
892
+ further adjustments reduce the cost. It returns the total cost, a
893
+ dictionary with legs/costs/route, a list of applied adjustments, an
894
+ optional adjusted period, and optional suggestions for the user.
895
+ """
896
  ajustes: List[str] = []
897
  periodo_ajustado: Optional[Dict[str, str]] = None
898
  sugestoes: Optional[List[str]] = None
 
905
  allow_daytrips = True
906
 
907
  total, legs, custos, roteiro = recompute_plan(req, d0, d1, perfil)
908
+ # If no budget constraint or already within budget, return immediately.
909
  if req.teto_orcamento <= 0 or total <= req.teto_orcamento:
910
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
911
 
912
+ # 1. Try switching from flights to road if flights are present.
913
  if any(l.modo == "voo" for l in legs):
914
  force_transport = "rodoviário"
915
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport=force_transport)
 
919
  if total <= req.teto_orcamento:
920
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
921
 
922
+ # 2. Downgrade accommodation profile if premium.
923
  if perfil == "premium":
924
  perfil = "equilibrado"
925
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
926
  if total2 < total:
927
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
928
  ajustes.append("Hospedagem ajustada para perfil 'equilibrado'.")
929
+ # 3. Further downgrade to econômico if still over budget.
930
  if total > req.teto_orcamento and perfil in ("equilibrado", "premium"):
931
  perfil = "econômico"
932
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
 
936
  if total <= req.teto_orcamento:
937
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
938
 
939
+ # 4. Prioritise free or low‑cost activities.
940
  budget_mode = True
941
  cap_paid = 60.0
942
+ total2, legs2, custos2, roteiro2 = recompute_plan(
943
+ req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode
944
+ )
945
  if total2 < total:
946
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
947
  ajustes.append("Atividades priorizadas para opções gratuitas/baixas (teto por pessoa/dia).")
948
  if total <= req.teto_orcamento:
949
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
950
 
951
+ # 5. Remove day‑trips (long excursions) if still above budget.
952
  allow_daytrips = False
953
+ total2, legs2, custos2, roteiro2 = recompute_plan(
954
+ req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
955
+ )
956
  if total2 < total:
957
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
958
+ ajustes.append("Daytrips removidas.")
959
  if total <= req.teto_orcamento:
960
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
961
 
962
+ # 6. Reduce meal cost by 15%.
963
  meals_factor = 0.85
964
+ total2, legs2, custos2, roteiro2 = recompute_plan(
965
+ req, d0, d1, perfil, force_transport, meals_factor=meals_factor, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
966
+ )
967
  if total2 < total:
968
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
969
  ajustes.append("Alimentação otimizada (~15% mais em conta).")
970
  if total <= req.teto_orcamento:
971
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
972
 
973
+ # 7. Shorten the trip by reducing days at the end.
974
  cur_d1 = d1
975
  while total > req.teto_orcamento and (cur_d1 - d0).days + 1 > 2:
976
  cur_d1 = cur_d1 - timedelta(days=1)
 
981
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
982
  periodo_ajustado = {"data_inicio": d0.isoformat(), "data_fim": cur_d1.isoformat()}
983
  ajustes.append("Viagem encurtada ao final para adequar ao orçamento.")
984
+ if total <= req.teto_orcamento:
985
+ break
986
 
987
+ # If still above budget, provide suggestions.
988
  if total > req.teto_orcamento:
989
  sugestoes = [
990
  "Considere reduzir o número de viajantes ou dividir quartos.",
 
994
 
995
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
996
 
997
+
998
  def risk_tag(raw_dest: str) -> str:
999
+ """Return a simple climate risk tag for a destination."""
1000
  k = resolve_city_key(raw_dest)
1001
  if k in ("manaus", "belém"):
1002
  return "Médio (chuvas tropicais / calor úmido)"
1003
  return "Baixo"
1004
 
1005
+
1006
+ # =============================================================================
1007
+ # Endpoints
1008
+ # =============================================================================
 
 
1009
 
1010
  @app.get("/health")
1011
+ def health() -> Dict[str, str]:
1012
+ """Health check endpoint; returns current UTC timestamp."""
1013
  return {"status": "ok", "ts": datetime.utcnow().isoformat()}
1014
 
1015
+
1016
  @app.get("/info")
1017
+ def info() -> Dict[str, Any]:
1018
+ """Return basic information about the API and its endpoints."""
1019
+ return {
1020
+ "name": "IViagem Planner (smart + budget + geocode + gemini)",
1021
+ "version": APP_VERSION,
1022
+ "effective_model": _effective_model or GEMINI_MODEL_NAME,
1023
+ "endpoints": ["/health", "/info", "/plan", "/debug/gemini", "/debug/env", "/geocode"],
1024
+ }
1025
+
1026
 
1027
  @app.post("/plan", response_model=PlanResponse)
1028
+ def plan(req: PlanRequest) -> PlanResponse:
1029
+ """Compute and return a detailed travel plan based on the request.
1030
+
1031
+ Validates input dates, adjusts the plan to respect the provided budget,
1032
+ computes risk tags and generates narrative observations using the LLM.
1033
+ """
1034
  try:
1035
  d0 = isoparse(req.data_inicio).date()
1036
  d1 = isoparse(req.data_fim).date()
 
1040
  raise HTTPException(status_code=400, detail="data_fim não pode ser anterior a data_inicio.")
1041
 
1042
  total, parts, ajustes, periodo_ajustado, sugestoes = fit_to_budget(req, d0, d1)
 
1043
  legs = parts["legs"]
1044
  custos_itens = parts["custos"]
1045
  roteiro = parts["roteiro"]
1046
 
1047
+ tempo_voo_total: Optional[str] = None
1048
  if any(l.modo == "voo" for l in legs):
1049
  tempo_voo_total = f"{round(sum(l.duracao_h for l in legs), 1)} h"
1050
 
1051
+ economia_vs_base: Optional[float] = None
1052
  if req.teto_orcamento and req.teto_orcamento > 0:
1053
  economia_vs_base = round(req.teto_orcamento - total, 2)
1054
 
 
1056
  risco = risk_tag(req.destino)
1057
 
1058
  periodo_txt = ""
1059
+ if periodo_ajustado and (
1060
+ periodo_ajustado["data_inicio"] != req.data_inicio or periodo_ajustado["data_fim"] != req.data_fim
1061
+ ):
1062
  periodo_txt = f" Período ajustado: {periodo_ajustado['data_inicio']} a {periodo_ajustado['data_fim']}."
1063
 
1064
  temas_txt = ", ".join(req.temas)
 
1076
  f" Período solicitado: {req.data_inicio} a {req.data_fim}.{periodo_txt} Moeda: {req.moeda}."
1077
  )
1078
 
1079
+ # Generate a narrative for each day using the LLM.
1080
  for dia in roteiro:
1081
  dia_prompt = (
1082
  f"Crie uma narrativa curta para o dia {dia.data} em {destino_label}. "
 
1087
  narrativa = generate_text_with_llm(dia_prompt, max_tokens=150, temperature=0.9)
1088
  dia.narrativa = narrativa or ""
1089
 
1090
+ # Suggestion generation: if no suggestions were generated during budget fitting,
1091
+ # use the LLM to propose tips; otherwise return the existing suggestions.
1092
  if sugestoes is None:
1093
  sugestao_prompt = (
1094
  f"Com base no planejamento {req.cidade_origem} → {destino_label} ({req.data_inicio} a {req.data_fim}), "
 
1120
  sugestoes=sugestoes or None,
1121
  )
1122
 
1123
+
1124
  @app.get("/geocode")
1125
+ def geocode(q: str) -> Dict[str, Any]:
1126
+ """Geocode a location name, returning online and fallback coordinates."""
1127
  gc = geocode_city(q)
1128
  if gc is None:
1129
  lat, lon, label = get_coords_and_label(q)
1130
  return {"online": None, "fallback": {"lat": lat, "lon": lon, "label": label}}
1131
  lat, lon, label = gc
1132
+ return {"online": {"lat": lat, "lon": lon, "label": label}}
app/main.py CHANGED
@@ -230,19 +230,18 @@ def _rest_generate_content(prompt: str, max_tokens: int, temperature: float) ->
230
 
231
 
232
  def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: float = 0.7) -> str:
233
- """Generate text using Gemini with a fallback to the REST API.
234
-
235
- Tries to call the SDK's ``generate_content`` method; if that fails or
236
- produces no text, it falls back to ``_rest_generate_content``. Returns
237
- an empty string if all attempts fail.
 
 
 
238
  """
239
  try:
240
  model = _init_gemini()
241
  if model is not None:
242
- # Use the SDK to generate content. Note: older versions of the
243
- # SDK return an object where ``text`` may be empty if the content
244
- # was blocked or truncated; in that case we iterate over
245
- # candidates/parts.
246
  resp = model.generate_content(
247
  prompt,
248
  generation_config={
@@ -250,21 +249,19 @@ def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: floa
250
  "max_output_tokens": max_tokens,
251
  },
252
  )
253
- # ``text`` is a quick accessor for the full text when available.
254
- if hasattr(resp, "text") and resp.text:
255
- return resp.text.strip()
256
- # Fallback: iterate candidates/parts manually.
257
  parts_out: List[str] = []
258
- for c in getattr(resp, "candidates", []) or []:
259
- content = getattr(c, "content", {}) or {}
260
- for p in getattr(content, "parts", []) or []:
261
- text = getattr(p, "text", "")
262
- if text:
263
- parts_out.append(text)
264
  if parts_out:
265
  return "\n".join(parts_out).strip()
266
  except Exception as e:
267
- # In case of any exception with the SDK, log and fall back.
268
  print(f"[warn] Erro Gemini (SDK): {e}")
269
  # Use REST fallback if SDK generation fails or returns no text.
270
  return _rest_generate_content(prompt, max_tokens, temperature)
 
230
 
231
 
232
  def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: float = 0.7) -> str:
233
+ """Generate text using the Gemini SDK with safe extraction and REST fallback.
234
+
235
+ The SDK sometimes raises ``Invalid operation`` when the quick accessor
236
+ ``response.text`` is used and the response has no valid parts due to safety
237
+ filtering. To avoid this, we never access ``resp.text`` directly; we
238
+ iterate over the returned candidates and extract the ``text`` from each
239
+ part manually. If the SDK fails or yields no parts, we fall back to the
240
+ REST API.
241
  """
242
  try:
243
  model = _init_gemini()
244
  if model is not None:
 
 
 
 
245
  resp = model.generate_content(
246
  prompt,
247
  generation_config={
 
249
  "max_output_tokens": max_tokens,
250
  },
251
  )
252
+ # Always extract text by iterating through candidates and parts. Do
253
+ # not call ``resp.text`` as it may raise when no valid parts exist.
 
 
254
  parts_out: List[str] = []
255
+ for cand in getattr(resp, "candidates", []) or []:
256
+ content = getattr(cand, "content", {}) or {}
257
+ for part in getattr(content, "parts", []) or []:
258
+ txt = getattr(part, "text", "")
259
+ if txt:
260
+ parts_out.append(txt)
261
  if parts_out:
262
  return "\n".join(parts_out).strip()
263
  except Exception as e:
264
+ # Log SDK failures and proceed to REST fallback.
265
  print(f"[warn] Erro Gemini (SDK): {e}")
266
  # Use REST fallback if SDK generation fails or returns no text.
267
  return _rest_generate_content(prompt, max_tokens, temperature)