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

Add Docker Space: FastAPI backend IViagem

Browse files
Files changed (2) hide show
  1. app/main.py +337 -101
  2. requirements.txt +1 -1
app/main.py CHANGED
@@ -1,3 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import os
@@ -15,37 +32,59 @@ from fastapi.middleware.cors import CORSMiddleware
15
  from fastapi.responses import HTMLResponse
16
  from pydantic import BaseModel, Field, field_validator
17
 
18
- # ==========================
19
- # App & Config
20
- # ==========================
21
- APP_VERSION = "2.3.0"
 
 
 
 
22
  log = logging.getLogger("uvicorn.error")
23
 
 
24
  app = FastAPI(
25
  title="IViagem Backend (Smart + Budget + Geocode + Gemini)",
26
  version=APP_VERSION,
27
  )
28
 
 
 
29
  app.add_middleware(
30
  CORSMiddleware,
31
- allow_origins=["*"], # ajuste depois para as URLs do seu front
32
  allow_credentials=True,
33
  allow_methods=["*"],
34
  allow_headers=["*"],
35
  )
36
 
37
- # ==========================
38
- # Gemini (google-generativeai)
39
- # ==========================
40
- GEMINI_MODEL_NAME = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
 
 
 
 
 
 
 
 
41
  GEMINI_API_KEY = (os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
42
 
43
- _genai_model = None
 
 
44
  _effective_model: Optional[str] = None
45
 
46
 
47
  def _probe_gemini_key(api_key: str) -> tuple[bool, str]:
48
- """Valida a key chamando /v1/models. Não expõe a chave nos logs."""
 
 
 
 
 
49
  if not api_key:
50
  return False, "GOOGLE_API_KEY/GEMINI_API_KEY ausente."
51
  if not api_key.startswith("AIza"):
@@ -61,7 +100,10 @@ def _probe_gemini_key(api_key: str) -> tuple[bool, str]:
61
 
62
 
63
  def _list_models_v1(api_key: str) -> list[dict]:
64
- """Lista modelos disponíveis via REST v1."""
 
 
 
65
  try:
66
  r = httpx.get(
67
  f"https://generativelanguage.googleapis.com/v1/models?key={api_key}",
@@ -75,9 +117,11 @@ def _list_models_v1(api_key: str) -> list[dict]:
75
 
76
 
77
  def _pick_supported_model(api_key: str, preferred: str) -> str:
78
- """
79
- Escolhe um modelo disponível que suporte generateContent.
80
- Se o preferred existir, usa-o; senão, pega o primeiro que tenha generateContent.
 
 
81
  """
82
  models = _list_models_v1(api_key)
83
  names = [m.get("name", "") for m in models]
@@ -91,12 +135,17 @@ def _pick_supported_model(api_key: str, preferred: str) -> str:
91
  )
92
  if "generateContent" in methods:
93
  return m["name"]
94
- # fallback: devolve preferred mesmo assim (o REST pode funcionar em alguns ambientes)
95
  return preferred
96
 
97
 
98
  @app.on_event("startup")
99
- def _startup_check():
 
 
 
 
 
100
  key = GEMINI_API_KEY or ""
101
  ok, msg = _probe_gemini_key(key)
102
  if ok:
@@ -105,61 +154,95 @@ def _startup_check():
105
  log.warning("[startup] Gemini inválido: %s", msg)
106
 
107
 
108
- def _init_gemini():
109
- """Inicializa o cliente do Gemini uma única vez, com escolha de modelo suportado."""
 
 
 
 
 
 
110
  global _genai_model, _effective_model
111
  if _genai_model is not None:
112
  return _genai_model
113
  try:
114
- import google.generativeai as genai
 
115
  if not GEMINI_API_KEY:
116
  return None
 
117
  _effective_model = _pick_supported_model(GEMINI_API_KEY, GEMINI_MODEL_NAME)
118
  genai.configure(api_key=GEMINI_API_KEY)
119
  _genai_model = genai.GenerativeModel(_effective_model)
120
  except Exception as e:
 
121
  print(f"[warn] Gemini init falhou: {e}")
122
  _genai_model = None
123
  return _genai_model
124
 
125
 
126
- def _rest_generate_content_v1(prompt: str, max_tokens: int, temperature: float) -> str:
127
- """
128
- Fallback REST v1 para contornar ambientes em que o SDK usa v1beta
129
- e retorna 404 para alguns modelos.
 
 
130
  """
131
  model = _effective_model or GEMINI_MODEL_NAME
132
  if not GEMINI_API_KEY:
133
  return ""
134
- url = f"https://generativelanguage.googleapis.com/v1/models/{model}:generateContent?key={GEMINI_API_KEY}"
135
  payload = {
136
  "contents": [{"role": "user", "parts": [{"text": prompt}]}],
137
  "generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
138
  }
139
- try:
140
- r = httpx.post(url, json=payload, timeout=25)
141
- if r.status_code != 200:
142
- print(f"[warn] REST v1 generateContent falhou: {r.status_code} {r.text[:200]}")
143
- return ""
144
- data = r.json()
145
- for cand in data.get("candidates", []):
146
- parts = cand.get("content", {}).get("parts", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  texts = [p.get("text", "") for p in parts if p.get("text")]
148
  if texts:
149
  return "\n".join(texts).strip()
150
- except Exception as e:
151
- print(f"[warn] REST v1 generateContent exceção: {e}")
152
  return ""
153
 
154
 
155
  def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: float = 0.7) -> str:
156
- """
157
- Gera texto com Gemini. Tenta SDK; se falhar, usa REST v1 como fallback.
158
- Se ainda assim falhar, retorna string vazia.
 
 
159
  """
160
  try:
161
  model = _init_gemini()
162
  if model is not None:
 
 
 
 
163
  resp = model.generate_content(
164
  prompt,
165
  generation_config={
@@ -167,29 +250,37 @@ def generate_text_with_llm(prompt: str, max_tokens: int = 500, temperature: floa
167
  "max_output_tokens": max_tokens,
168
  },
169
  )
 
170
  if hasattr(resp, "text") and resp.text:
171
  return resp.text.strip()
172
- # fallback para extrair texto de candidates/parts
173
  parts_out: List[str] = []
174
  for c in getattr(resp, "candidates", []) or []:
175
- for p in getattr(getattr(c, "content", {}), "parts", []) or []:
176
- if getattr(p, "text", ""):
177
- parts_out.append(p.text)
 
 
178
  if parts_out:
179
  return "\n".join(parts_out).strip()
180
  except Exception as e:
 
181
  print(f"[warn] Erro Gemini (SDK): {e}")
 
 
182
 
183
- # fallback REST v1
184
- return _rest_generate_content_v1(prompt, max_tokens, temperature)
185
 
 
 
 
186
 
187
- # ==========================
188
- # Home & utilitários HTTP
189
- # ==========================
190
  @app.get("/", response_class=HTMLResponse, include_in_schema=False)
191
- def home():
192
- # aceita / e /?logs=container (query string não muda o match da rota)
 
 
 
 
193
  return f"""
194
  <!doctype html>
195
  <html lang="pt-BR">
@@ -221,18 +312,31 @@ def home():
221
 
222
 
223
  @app.get("/favicon.ico", include_in_schema=False)
224
- def favicon():
 
225
  return Response(status_code=204)
226
 
227
 
228
- # (opcional) endpoint de debug da key
229
  @app.get("/debug/gemini")
230
- def debug_gemini():
 
 
 
 
 
 
231
  ok, msg = _probe_gemini_key(GEMINI_API_KEY or "")
232
- out: Dict[str, Any] = {"ok": ok, "probe": msg, "effective_model": _effective_model or GEMINI_MODEL_NAME}
 
 
 
 
233
  if ok:
234
  try:
235
- r = httpx.get(f"https://generativelanguage.googleapis.com/v1/models?key={GEMINI_API_KEY}", timeout=10)
 
 
 
236
  j = r.json()
237
  out["first_models"] = [m["name"] for m in j.get("models", [])[:5]]
238
  except Exception as e:
@@ -241,7 +345,12 @@ def debug_gemini():
241
 
242
 
243
  @app.get("/debug/env")
244
- def debug_env():
 
 
 
 
 
245
  k = GEMINI_API_KEY or ""
246
  masked = (k[:4] + "*" * max(0, len(k) - 8) + k[-4:]) if k else ""
247
  return {
@@ -253,15 +362,24 @@ def debug_env():
253
  }
254
 
255
 
256
- # ==========================
257
- # Geocodificação (Nominatim/OSM) com cache e fallback
258
- # ==========================
 
 
 
259
  USE_ONLINE_GEOCODING = os.getenv("USE_ONLINE_GEOCODING", "1") != "0"
 
260
  NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
261
 
262
 
263
  @lru_cache(maxsize=256)
264
- def geocode_city(query: str) -> tuple[float, float, str] | None:
 
 
 
 
 
265
  if not USE_ONLINE_GEOCODING:
266
  return None
267
  q = (query or "").strip()
@@ -289,10 +407,11 @@ def geocode_city(query: str) -> tuple[float, float, str] | None:
289
  return None
290
 
291
 
292
- # ==========================
293
- # Geografia / cidades (catálogo local + aliases)
294
- # ==========================
295
- CITY_COORDS: Dict[str, tuple[float, float, str]] = {
 
296
  "são paulo": (-23.5505, -46.6333, "São Paulo, SP"),
297
  "rio de janeiro": (-22.9068, -43.1729, "Rio de Janeiro, RJ"),
298
  "manaus": (-3.1190, -60.0217, "Manaus, AM"),
@@ -304,7 +423,8 @@ CITY_COORDS: Dict[str, tuple[float, float, str]] = {
304
  "porto alegre": (-30.0346, -51.2177, "Porto Alegre, RS"),
305
  "florianópolis": (-27.5949, -48.5482, "Florianópolis, SC"),
306
  }
307
- CITY_ALIASES = {
 
308
  "amazonia": "manaus",
309
  "amazônia": "manaus",
310
  "belem": "belém",
@@ -314,17 +434,25 @@ CITY_ALIASES = {
314
 
315
 
316
  def norm(s: str) -> str:
 
317
  return (s or "").strip().lower()
318
 
319
 
320
  def resolve_city_key(raw: str) -> str:
 
321
  k = norm(raw)
322
  if k in CITY_COORDS:
323
  return k
324
  return CITY_ALIASES.get(k, k)
325
 
326
 
327
- def get_coords_and_label(raw: str) -> tuple[float, float, str]:
 
 
 
 
 
 
328
  gc = geocode_city(raw)
329
  if gc is not None:
330
  return gc
@@ -336,7 +464,12 @@ def get_coords_and_label(raw: str) -> tuple[float, float, str]:
336
  return (lat, lon, label)
337
 
338
 
 
 
 
 
339
  def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
 
340
  (lat1, lon1), (lat2, lon2) = a, b
341
  R = 6371.0
342
  p1, p2 = math.radians(lat1), math.radians(lat2)
@@ -347,6 +480,7 @@ def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
347
 
348
 
349
  def daterange(d0: date, d1: date):
 
350
  d = d0
351
  while d <= d1:
352
  yield d
@@ -354,16 +488,19 @@ def daterange(d0: date, d1: date):
354
 
355
 
356
  def is_weekend(d: date) -> bool:
 
357
  return d.weekday() >= 5
358
 
359
 
360
- # ==========================
361
- # Modelos (Pydantic)
362
- # ==========================
 
363
  PerfilType = Literal["econômico", "equilibrado", "premium"]
364
 
365
 
366
  class PlanRequest(BaseModel):
 
367
  cidade_origem: str
368
  destino: str
369
  data_inicio: str
@@ -377,11 +514,13 @@ class PlanRequest(BaseModel):
377
  @field_validator("data_inicio", "data_fim")
378
  @classmethod
379
  def _valida_data(cls, v: str) -> str:
 
380
  _ = isoparse(v).date()
381
  return v
382
 
383
 
384
  class Leg(BaseModel):
 
385
  modo: Literal["voo", "rodoviário", "fluvial", "misto"]
386
  origem: str
387
  destino: str
@@ -391,6 +530,7 @@ class Leg(BaseModel):
391
 
392
 
393
  class ItemCusto(BaseModel):
 
394
  categoria: str
395
  descricao: str
396
  quantidade: int
@@ -399,6 +539,7 @@ class ItemCusto(BaseModel):
399
 
400
 
401
  class DiaRoteiro(BaseModel):
 
402
  data: str
403
  manha: str
404
  tarde: str
@@ -408,6 +549,7 @@ class DiaRoteiro(BaseModel):
408
 
409
 
410
  class PlanResponse(BaseModel):
 
411
  orcamento_estimado_total: float
412
  legs: List[Leg]
413
  custos_itens: List[ItemCusto]
@@ -422,11 +564,12 @@ class PlanResponse(BaseModel):
422
  sugestoes: Optional[List[str]] = None
423
 
424
 
425
- # ==========================
426
- # Parâmetros de custo
427
- # ==========================
428
- PROFILE_FACTORS = {"econômico": 0.8, "equilibrado": 1.0, "premium": 1.6}
429
- BASES = {
 
430
  "refeicoes": 120.0,
431
  "atividades": 80.0,
432
  "hospedagem": {"econômico": 220.0, "equilibrado": 350.0, "premium": 800.0},
@@ -434,7 +577,9 @@ BASES = {
434
  "rod_preco_km": 0.15,
435
  }
436
 
437
- POIS_MANAUS = [
 
 
438
  {"nome": "Teatro Amazonas", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 60.0, "indoor": True},
439
  {"nome": "Palácio Rio Negro", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
440
  {"nome": "Museu da Cidade", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 20.0, "indoor": True},
@@ -449,38 +594,52 @@ POIS_MANAUS = [
449
  {"nome": "Restaurante na Ponta Negra", "bairro": "Ponta Negra", "slot": "noite", "tags": {"gastronomia"}, "custo": 95.0, "indoor": True},
450
  {"nome": "Bar com música regional", "bairro": "Centro", "slot": "noite", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
451
  ]
452
- POIS_BELEM = [
 
453
  {"nome": "Ver-o-Peso", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia", "cultura"}, "custo": 0.0, "indoor": False},
454
  {"nome": "Mangal das Garças", "bairro": "Cidade Velha", "slot": "tarde", "tags": {"natureza"}, "custo": 20.0, "indoor": False},
455
  {"nome": "Basílica de Nazaré", "bairro": "Nazaré", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
456
  {"nome": "Estação das Docas", "bairro": "Campina", "slot": "noite", "tags": {"gastronomia", "cultura"}, "custo": 90.0, "indoor": True},
457
  {"nome": "Ilha do Combu (day-trip)", "bairro": "Ribeirinha", "slot": "manha", "tags": {"natureza"}, "custo": 250.0, "indoor": False},
458
  ]
459
- POIS_RIO = [
 
460
  {"nome": "Cristo Redentor", "bairro": "Cosme Velho", "slot": "manha", "tags": {"cultura", "natureza"}, "custo": 89.0, "indoor": False},
461
  {"nome": "Pão de Açúcar", "bairro": "Urca", "slot": "tarde", "tags": {"natureza"}, "custo": 140.0, "indoor": False},
462
  {"nome": "Museu do Amanhã", "bairro": "Centro", "slot": "tarde", "tags": {"cultura", "tecnologia"}, "custo": 30.0, "indoor": True},
463
  {"nome": "Praia de Copacabana", "bairro": "Zona Sul", "slot": "manha", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
464
  {"nome": "Lapa à noite", "bairro": "Lapa", "slot": "noite", "tags": {"cultura", "gastronomia"}, "custo": 70.0, "indoor": True},
465
  ]
466
- POIS_SAO_PAULO = [
 
467
  {"nome": "Avenida Paulista + MASP", "bairro": "Paulista", "slot": "tarde", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
468
  {"nome": "Beco do Batman", "bairro": "Vila Madalena", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
469
  {"nome": "Mercadão Municipal", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 40.0, "indoor": True},
470
  {"nome": "Ibirapuera", "bairro": "Ibirapuera", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
471
  {"nome": "Rooftop/Bar (noite)", "bairro": "Centro/Zona Sul", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
472
  ]
473
- POIS_GENERIC = [
 
 
 
474
  {"nome": "Centro histórico / praça principal", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
475
  {"nome": "Museu/galeria mais bem avaliado", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 30.0, "indoor": True},
476
  {"nome": "Parque urbano / mirante", "bairro": "Região central", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
477
  {"nome": "Mercado público / feira gastronômica", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 35.0, "indoor": True},
478
  {"nome": "Restaurante típico (noite)", "bairro": "Centro", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
479
  ]
480
- CITY_POIS = {"manaus": POIS_MANAUS, "belém": POIS_BELEM, "rio de janeiro": POIS_RIO, "são paulo": POIS_SAO_PAULO}
 
 
 
 
 
 
 
481
 
482
 
483
  def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
 
484
  key = resolve_city_key(raw_city)
485
  if key in CITY_POIS:
486
  return CITY_POIS[key]
@@ -490,10 +649,16 @@ def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
490
  return POIS_GENERIC
491
 
492
 
493
- def filter_pois(pois: List[Dict[str, Any]], tags: List[str], slot: str, indoor: Optional[bool] = None) -> List[Dict[str, Any]]:
494
- filtered = []
 
 
 
 
 
 
495
  for poi in pois:
496
- if slot != "" and poi["slot"] != slot:
497
  continue
498
  if indoor is not None and poi["indoor"] != indoor:
499
  continue
@@ -503,18 +668,22 @@ def filter_pois(pois: List[Dict[str, Any]], tags: List[str], slot: str, indoor:
503
  return filtered
504
 
505
 
506
- def get_random_poi(pois: List[Dict[str, Any]], exclude: List[str] = []) -> Optional[Dict[str, Any]]:
 
 
 
507
  available = [p for p in pois if p["nome"] not in exclude]
508
  if not available:
509
  return None
510
  return random.choice(available)
511
 
512
 
513
- # ==========================
514
- # Lógica de planejamento
515
- # ==========================
 
516
  def recompute_plan(
517
- req: "PlanRequest",
518
  d0: date,
519
  d1: date,
520
  perfil: PerfilType,
@@ -523,18 +692,27 @@ def recompute_plan(
523
  cap_paid: Optional[float] = None,
524
  budget_mode: bool = False,
525
  allow_daytrips: bool = True,
526
- ):
 
 
 
 
 
 
527
  total_orcamento = 0.0
528
  legs: List[Leg] = []
529
  custos_itens: List[ItemCusto] = []
530
  roteiro: List[DiaRoteiro] = []
531
 
 
532
  lat_o, lon_o, origem_label = get_coords_and_label(req.cidade_origem)
533
  lat_d, lon_d, destino_label = get_coords_and_label(req.destino)
534
  origem_coords = (lat_o, lon_o)
535
  destino_coords = (lat_d, lon_d)
536
  distancia_total_km = haversine_km(origem_coords, destino_coords)
537
 
 
 
538
  modo_ida = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
539
  preco_ida = distancia_total_km * BASES["voo_preco_km"] if modo_ida == "voo" else distancia_total_km * BASES["rod_preco_km"]
540
  duracao_ida = distancia_total_km / 700 if modo_ida == "voo" else distancia_total_km / 80
@@ -559,6 +737,7 @@ def recompute_plan(
559
  )
560
  )
561
 
 
562
  if d0 != d1:
563
  modo_volta = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
564
  preco_volta = distancia_total_km * BASES["voo_preco_km"] if modo_volta == "voo" else distancia_total_km * BASES["rod_preco_km"]
@@ -595,6 +774,8 @@ def recompute_plan(
595
  atividades_tarde: List[str] = []
596
  atividades_noite: List[str] = []
597
 
 
 
598
  if num_dias > 1:
599
  preco_hospedagem_noite = BASES["hospedagem"][perfil]
600
  custo_hospedagem_dia = preco_hospedagem_noite * req.numero_viajantes / num_dias
@@ -609,6 +790,8 @@ def recompute_plan(
609
  )
610
  )
611
 
 
 
612
  custo_refeicoes_dia = BASES["refeicoes"] * req.numero_viajantes * fator_perfil * meals_factor
613
  custo_dia += custo_refeicoes_dia
614
  custos_itens.append(
@@ -621,8 +804,14 @@ def recompute_plan(
621
  )
622
  )
623
 
 
624
  poi_manha = get_random_poi(
625
- filter_pois(pois_destino, req.temas, "manha", indoor=False if not is_weekend(current_date) else None)
 
 
 
 
 
626
  )
627
  if poi_manha and (not budget_mode or (cap_paid is not None and poi_manha["custo"] <= cap_paid)):
628
  atividades_manha.append(poi_manha["nome"])
@@ -637,6 +826,7 @@ def recompute_plan(
637
  )
638
  )
639
 
 
640
  poi_tarde = get_random_poi(
641
  filter_pois(pois_destino, req.temas, "tarde"),
642
  exclude=[p["nome"] for p in [poi_manha] if p],
@@ -654,6 +844,7 @@ def recompute_plan(
654
  )
655
  )
656
 
 
657
  poi_noite = get_random_poi(
658
  filter_pois(pois_destino, req.temas, "noite"),
659
  exclude=[p["nome"] for p in [poi_manha, poi_tarde] if p],
@@ -685,7 +876,26 @@ def recompute_plan(
685
  return total_orcamento, legs, custos_itens, roteiro
686
 
687
 
688
- def fit_to_budget(req: PlanRequest, d0: date, d1: date):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  ajustes: List[str] = []
690
  periodo_ajustado: Optional[Dict[str, str]] = None
691
  sugestoes: Optional[List[str]] = None
@@ -698,9 +908,11 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
698
  allow_daytrips = True
699
 
700
  total, legs, custos, roteiro = recompute_plan(req, d0, d1, perfil)
 
701
  if req.teto_orcamento <= 0 or total <= req.teto_orcamento:
702
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
703
 
 
704
  if any(l.modo == "voo" for l in legs):
705
  force_transport = "rodoviário"
706
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport=force_transport)
@@ -710,12 +922,14 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
710
  if total <= req.teto_orcamento:
711
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
712
 
 
713
  if perfil == "premium":
714
  perfil = "equilibrado"
715
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
716
  if total2 < total:
717
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
718
  ajustes.append("Hospedagem ajustada para perfil 'equilibrado'.")
 
719
  if total > req.teto_orcamento and perfil in ("equilibrado", "premium"):
720
  perfil = "econômico"
721
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
@@ -725,6 +939,7 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
725
  if total <= req.teto_orcamento:
726
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
727
 
 
728
  budget_mode = True
729
  cap_paid = 60.0
730
  total2, legs2, custos2, roteiro2 = recompute_plan(
@@ -736,16 +951,18 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
736
  if total <= req.teto_orcamento:
737
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
738
 
 
739
  allow_daytrips = False
740
  total2, legs2, custos2, roteiro2 = recompute_plan(
741
  req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
742
  )
743
  if total2 < total:
744
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
745
- ajustes.append("Day-trips removidas.")
746
  if total <= req.teto_orcamento:
747
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
748
 
 
749
  meals_factor = 0.85
750
  total2, legs2, custos2, roteiro2 = recompute_plan(
751
  req, d0, d1, perfil, force_transport, meals_factor=meals_factor, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
@@ -756,6 +973,7 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
756
  if total <= req.teto_orcamento:
757
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
758
 
 
759
  cur_d1 = d1
760
  while total > req.teto_orcamento and (cur_d1 - d0).days + 1 > 2:
761
  cur_d1 = cur_d1 - timedelta(days=1)
@@ -766,7 +984,10 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
766
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
767
  periodo_ajustado = {"data_inicio": d0.isoformat(), "data_fim": cur_d1.isoformat()}
768
  ajustes.append("Viagem encurtada ao final para adequar ao orçamento.")
 
 
769
 
 
770
  if total > req.teto_orcamento:
771
  sugestoes = [
772
  "Considere reduzir o número de viajantes ou dividir quartos.",
@@ -778,22 +999,26 @@ def fit_to_budget(req: PlanRequest, d0: date, d1: date):
778
 
779
 
780
  def risk_tag(raw_dest: str) -> str:
 
781
  k = resolve_city_key(raw_dest)
782
  if k in ("manaus", "belém"):
783
  return "Médio (chuvas tropicais / calor úmido)"
784
  return "Baixo"
785
 
786
 
787
- # ==========================
788
  # Endpoints
789
- # ==========================
 
790
  @app.get("/health")
791
- def health():
 
792
  return {"status": "ok", "ts": datetime.utcnow().isoformat()}
793
 
794
 
795
  @app.get("/info")
796
- def info():
 
797
  return {
798
  "name": "IViagem Planner (smart + budget + geocode + gemini)",
799
  "version": APP_VERSION,
@@ -803,7 +1028,12 @@ def info():
803
 
804
 
805
  @app.post("/plan", response_model=PlanResponse)
806
- def plan(req: PlanRequest):
 
 
 
 
 
807
  try:
808
  d0 = isoparse(req.data_inicio).date()
809
  d1 = isoparse(req.data_fim).date()
@@ -817,11 +1047,11 @@ def plan(req: PlanRequest):
817
  custos_itens = parts["custos"]
818
  roteiro = parts["roteiro"]
819
 
820
- tempo_voo_total = None
821
  if any(l.modo == "voo" for l in legs):
822
  tempo_voo_total = f"{round(sum(l.duracao_h for l in legs), 1)} h"
823
 
824
- economia_vs_base = None
825
  if req.teto_orcamento and req.teto_orcamento > 0:
826
  economia_vs_base = round(req.teto_orcamento - total, 2)
827
 
@@ -829,7 +1059,9 @@ def plan(req: PlanRequest):
829
  risco = risk_tag(req.destino)
830
 
831
  periodo_txt = ""
832
- if periodo_ajustado and (periodo_ajustado["data_inicio"] != req.data_inicio or periodo_ajustado["data_fim"] != req.data_fim):
 
 
833
  periodo_txt = f" Período ajustado: {periodo_ajustado['data_inicio']} a {periodo_ajustado['data_fim']}."
834
 
835
  temas_txt = ", ".join(req.temas)
@@ -847,6 +1079,7 @@ def plan(req: PlanRequest):
847
  f" Período solicitado: {req.data_inicio} a {req.data_fim}.{periodo_txt} Moeda: {req.moeda}."
848
  )
849
 
 
850
  for dia in roteiro:
851
  dia_prompt = (
852
  f"Crie uma narrativa curta para o dia {dia.data} em {destino_label}. "
@@ -857,6 +1090,8 @@ def plan(req: PlanRequest):
857
  narrativa = generate_text_with_llm(dia_prompt, max_tokens=150, temperature=0.9)
858
  dia.narrativa = narrativa or ""
859
 
 
 
860
  if sugestoes is None:
861
  sugestao_prompt = (
862
  f"Com base no planejamento {req.cidade_origem} → {destino_label} ({req.data_inicio} a {req.data_fim}), "
@@ -890,10 +1125,11 @@ def plan(req: PlanRequest):
890
 
891
 
892
  @app.get("/geocode")
893
- def geocode(q: str):
 
894
  gc = geocode_city(q)
895
  if gc is None:
896
  lat, lon, label = get_coords_and_label(q)
897
  return {"online": None, "fallback": {"lat": lat, "lon": lon, "label": label}}
898
  lat, lon, label = gc
899
- 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
 
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"):
 
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}",
 
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]
 
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:
 
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 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
  "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)
271
 
 
 
272
 
273
+ # =============================================================================
274
+ # HTTP utilitários e página raiz
275
+ # =============================================================================
276
 
 
 
 
277
  @app.get("/", response_class=HTMLResponse, include_in_schema=False)
278
+ def home() -> str:
279
+ """Serve uma página HTML simples na raiz da API.
280
+
281
+ A query string ``?logs=container`` é ignorada para compatibilidade com
282
+ a interface do Hugging Face Spaces.
283
+ """
284
  return f"""
285
  <!doctype html>
286
  <html lang="pt-BR">
 
312
 
313
 
314
  @app.get("/favicon.ico", include_in_schema=False)
315
+ def favicon() -> Response:
316
+ """Return an empty 204 response for the favicon request."""
317
  return Response(status_code=204)
318
 
319
 
 
320
  @app.get("/debug/gemini")
321
+ def debug_gemini() -> Dict[str, Any]:
322
+ """Provide diagnostic information about the Gemini configuration.
323
+
324
+ Returns whether the key probes successfully, a masked key preview, and
325
+ the first few models returned by the models endpoint. Useful for
326
+ debugging misconfiguration.
327
+ """
328
  ok, msg = _probe_gemini_key(GEMINI_API_KEY or "")
329
+ out: Dict[str, Any] = {
330
+ "ok": ok,
331
+ "probe": msg,
332
+ "effective_model": _effective_model or GEMINI_MODEL_NAME,
333
+ }
334
  if ok:
335
  try:
336
+ r = httpx.get(
337
+ f"https://generativelanguage.googleapis.com/v1/models?key={GEMINI_API_KEY}",
338
+ timeout=10,
339
+ )
340
  j = r.json()
341
  out["first_models"] = [m["name"] for m in j.get("models", [])[:5]]
342
  except Exception as e:
 
345
 
346
 
347
  @app.get("/debug/env")
348
+ def debug_env() -> Dict[str, Any]:
349
+ """Expose basic information about the configured API key.
350
+
351
+ The key itself is masked to avoid leaking secrets. This endpoint can
352
+ help verify that the key is loaded and correctly formatted.
353
+ """
354
  k = GEMINI_API_KEY or ""
355
  masked = (k[:4] + "*" * max(0, len(k) - 8) + k[-4:]) if k else ""
356
  return {
 
362
  }
363
 
364
 
365
+ # =============================================================================
366
+ # Geocoding (Nominatim/OSM) with cache and fallback
367
+ # =============================================================================
368
+
369
+ # Toggle online geocoding via environment variable. Set ``USE_ONLINE_GEOCODING=0``
370
+ # to disable network calls and rely solely on built‑in city coordinates.
371
  USE_ONLINE_GEOCODING = os.getenv("USE_ONLINE_GEOCODING", "1") != "0"
372
+
373
  NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
374
 
375
 
376
  @lru_cache(maxsize=256)
377
+ def geocode_city(query: str) -> Tuple[float, float, str] | None:
378
+ """Look up a city name via OpenStreetMap's Nominatim service.
379
+
380
+ Returns a tuple ``(lat, lon, display_name)`` or ``None`` if not found or
381
+ if online geocoding is disabled. Results are cached for efficiency.
382
+ """
383
  if not USE_ONLINE_GEOCODING:
384
  return None
385
  q = (query or "").strip()
 
407
  return None
408
 
409
 
410
+ # =============================================================================
411
+ # Geography / city catalogue and aliases
412
+ # =============================================================================
413
+
414
+ CITY_COORDS: Dict[str, Tuple[float, float, str]] = {
415
  "são paulo": (-23.5505, -46.6333, "São Paulo, SP"),
416
  "rio de janeiro": (-22.9068, -43.1729, "Rio de Janeiro, RJ"),
417
  "manaus": (-3.1190, -60.0217, "Manaus, AM"),
 
423
  "porto alegre": (-30.0346, -51.2177, "Porto Alegre, RS"),
424
  "florianópolis": (-27.5949, -48.5482, "Florianópolis, SC"),
425
  }
426
+
427
+ CITY_ALIASES: Dict[str, str] = {
428
  "amazonia": "manaus",
429
  "amazônia": "manaus",
430
  "belem": "belém",
 
434
 
435
 
436
  def norm(s: str) -> str:
437
+ """Normalise a string to lower case and strip whitespace."""
438
  return (s or "").strip().lower()
439
 
440
 
441
  def resolve_city_key(raw: str) -> str:
442
+ """Map a raw city string to a canonical key using aliases."""
443
  k = norm(raw)
444
  if k in CITY_COORDS:
445
  return k
446
  return CITY_ALIASES.get(k, k)
447
 
448
 
449
+ def get_coords_and_label(raw: str) -> Tuple[float, float, str]:
450
+ """Retrieve latitude, longitude and display label for a city.
451
+
452
+ Uses online geocoding if enabled; otherwise falls back to a predefined
453
+ catalogue or defaults to São Paulo when the city is unknown. The
454
+ original string is used as a label when no other information is available.
455
+ """
456
  gc = geocode_city(raw)
457
  if gc is not None:
458
  return gc
 
464
  return (lat, lon, label)
465
 
466
 
467
+ # =============================================================================
468
+ # Utility functions
469
+ # =============================================================================
470
+
471
  def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
472
+ """Compute the great-circle distance between two coordinates in km."""
473
  (lat1, lon1), (lat2, lon2) = a, b
474
  R = 6371.0
475
  p1, p2 = math.radians(lat1), math.radians(lat2)
 
480
 
481
 
482
  def daterange(d0: date, d1: date):
483
+ """Yield dates between two endpoints inclusive."""
484
  d = d0
485
  while d <= d1:
486
  yield d
 
488
 
489
 
490
  def is_weekend(d: date) -> bool:
491
+ """Return True if the given date is a weekend (Saturday or Sunday)."""
492
  return d.weekday() >= 5
493
 
494
 
495
+ # =============================================================================
496
+ # Pydantic models
497
+ # =============================================================================
498
+
499
  PerfilType = Literal["econômico", "equilibrado", "premium"]
500
 
501
 
502
  class PlanRequest(BaseModel):
503
+ """Schema for the /plan request body."""
504
  cidade_origem: str
505
  destino: str
506
  data_inicio: str
 
514
  @field_validator("data_inicio", "data_fim")
515
  @classmethod
516
  def _valida_data(cls, v: str) -> str:
517
+ # Validate ISO date strings; raise if invalid.
518
  _ = isoparse(v).date()
519
  return v
520
 
521
 
522
  class Leg(BaseModel):
523
+ """Represents a transport leg (e.g. flight or road trip)."""
524
  modo: Literal["voo", "rodoviário", "fluvial", "misto"]
525
  origem: str
526
  destino: str
 
530
 
531
 
532
  class ItemCusto(BaseModel):
533
+ """Represents an itemised cost component."""
534
  categoria: str
535
  descricao: str
536
  quantidade: int
 
539
 
540
 
541
  class DiaRoteiro(BaseModel):
542
+ """Represents one day's itinerary with activities and costs."""
543
  data: str
544
  manha: str
545
  tarde: str
 
549
 
550
 
551
  class PlanResponse(BaseModel):
552
+ """Schema for the response returned by /plan."""
553
  orcamento_estimado_total: float
554
  legs: List[Leg]
555
  custos_itens: List[ItemCusto]
 
564
  sugestoes: Optional[List[str]] = None
565
 
566
 
567
+ # =============================================================================
568
+ # Cost parameters and points of interest (POIs)
569
+ # =============================================================================
570
+
571
+ PROFILE_FACTORS: Dict[str, float] = {"econômico": 0.8, "equilibrado": 1.0, "premium": 1.6}
572
+ BASES: Dict[str, Any] = {
573
  "refeicoes": 120.0,
574
  "atividades": 80.0,
575
  "hospedagem": {"econômico": 220.0, "equilibrado": 350.0, "premium": 800.0},
 
577
  "rod_preco_km": 0.15,
578
  }
579
 
580
+ # Define points of interest for specific cities. Each entry includes a name,
581
+ # neighbourhood, timeslot, tags, cost, and whether it is indoors.
582
+ POIS_MANAUS: List[Dict[str, Any]] = [
583
  {"nome": "Teatro Amazonas", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 60.0, "indoor": True},
584
  {"nome": "Palácio Rio Negro", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
585
  {"nome": "Museu da Cidade", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 20.0, "indoor": True},
 
594
  {"nome": "Restaurante na Ponta Negra", "bairro": "Ponta Negra", "slot": "noite", "tags": {"gastronomia"}, "custo": 95.0, "indoor": True},
595
  {"nome": "Bar com música regional", "bairro": "Centro", "slot": "noite", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
596
  ]
597
+
598
+ POIS_BELEM: List[Dict[str, Any]] = [
599
  {"nome": "Ver-o-Peso", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia", "cultura"}, "custo": 0.0, "indoor": False},
600
  {"nome": "Mangal das Garças", "bairro": "Cidade Velha", "slot": "tarde", "tags": {"natureza"}, "custo": 20.0, "indoor": False},
601
  {"nome": "Basílica de Nazaré", "bairro": "Nazaré", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": True},
602
  {"nome": "Estação das Docas", "bairro": "Campina", "slot": "noite", "tags": {"gastronomia", "cultura"}, "custo": 90.0, "indoor": True},
603
  {"nome": "Ilha do Combu (day-trip)", "bairro": "Ribeirinha", "slot": "manha", "tags": {"natureza"}, "custo": 250.0, "indoor": False},
604
  ]
605
+
606
+ POIS_RIO: List[Dict[str, Any]] = [
607
  {"nome": "Cristo Redentor", "bairro": "Cosme Velho", "slot": "manha", "tags": {"cultura", "natureza"}, "custo": 89.0, "indoor": False},
608
  {"nome": "Pão de Açúcar", "bairro": "Urca", "slot": "tarde", "tags": {"natureza"}, "custo": 140.0, "indoor": False},
609
  {"nome": "Museu do Amanhã", "bairro": "Centro", "slot": "tarde", "tags": {"cultura", "tecnologia"}, "custo": 30.0, "indoor": True},
610
  {"nome": "Praia de Copacabana", "bairro": "Zona Sul", "slot": "manha", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
611
  {"nome": "Lapa à noite", "bairro": "Lapa", "slot": "noite", "tags": {"cultura", "gastronomia"}, "custo": 70.0, "indoor": True},
612
  ]
613
+
614
+ POIS_SAO_PAULO: List[Dict[str, Any]] = [
615
  {"nome": "Avenida Paulista + MASP", "bairro": "Paulista", "slot": "tarde", "tags": {"cultura"}, "custo": 50.0, "indoor": True},
616
  {"nome": "Beco do Batman", "bairro": "Vila Madalena", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
617
  {"nome": "Mercadão Municipal", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 40.0, "indoor": True},
618
  {"nome": "Ibirapuera", "bairro": "Ibirapuera", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
619
  {"nome": "Rooftop/Bar (noite)", "bairro": "Centro/Zona Sul", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
620
  ]
621
+
622
+ # Generic POIs used when a city has no specific listing. These provide a
623
+ # reasonable default selection of activities across different times of day.
624
+ POIS_GENERIC: List[Dict[str, Any]] = [
625
  {"nome": "Centro histórico / praça principal", "bairro": "Centro", "slot": "manha", "tags": {"cultura"}, "custo": 0.0, "indoor": False},
626
  {"nome": "Museu/galeria mais bem avaliado", "bairro": "Centro", "slot": "tarde", "tags": {"cultura"}, "custo": 30.0, "indoor": True},
627
  {"nome": "Parque urbano / mirante", "bairro": "Região central", "slot": "tarde", "tags": {"natureza"}, "custo": 0.0, "indoor": False},
628
  {"nome": "Mercado público / feira gastronômica", "bairro": "Centro", "slot": "manha", "tags": {"gastronomia"}, "custo": 35.0, "indoor": True},
629
  {"nome": "Restaurante típico (noite)", "bairro": "Centro", "slot": "noite", "tags": {"gastronomia"}, "custo": 80.0, "indoor": True},
630
  ]
631
+
632
+ # Map city keys to their specific POI lists.
633
+ CITY_POIS: Dict[str, List[Dict[str, Any]]] = {
634
+ "manaus": POIS_MANAUS,
635
+ "belém": POIS_BELEM,
636
+ "rio de janeiro": POIS_RIO,
637
+ "são paulo": POIS_SAO_PAULO,
638
+ }
639
 
640
 
641
  def get_pois_for_city(raw_city: str) -> List[Dict[str, Any]]:
642
+ """Return a list of POIs for the given city or a generic list if none are defined."""
643
  key = resolve_city_key(raw_city)
644
  if key in CITY_POIS:
645
  return CITY_POIS[key]
 
649
  return POIS_GENERIC
650
 
651
 
652
+ def filter_pois(
653
+ pois: List[Dict[str, Any]],
654
+ tags: List[str],
655
+ slot: str,
656
+ indoor: Optional[bool] = None,
657
+ ) -> List[Dict[str, Any]]:
658
+ """Filter POIs by tags, time slot and indoor/outdoor criteria."""
659
+ filtered: List[Dict[str, Any]] = []
660
  for poi in pois:
661
+ if slot and poi["slot"] != slot:
662
  continue
663
  if indoor is not None and poi["indoor"] != indoor:
664
  continue
 
668
  return filtered
669
 
670
 
671
+ def get_random_poi(pois: List[Dict[str, Any]], exclude: List[str] | None = None) -> Optional[Dict[str, Any]]:
672
+ """Select a random POI from the list, excluding any by name."""
673
+ if exclude is None:
674
+ exclude = []
675
  available = [p for p in pois if p["nome"] not in exclude]
676
  if not available:
677
  return None
678
  return random.choice(available)
679
 
680
 
681
+ # =============================================================================
682
+ # Planning logic
683
+ # =============================================================================
684
+
685
  def recompute_plan(
686
+ req: PlanRequest,
687
  d0: date,
688
  d1: date,
689
  perfil: PerfilType,
 
692
  cap_paid: Optional[float] = None,
693
  budget_mode: bool = False,
694
  allow_daytrips: bool = True,
695
+ ) -> Tuple[float, List[Leg], List[ItemCusto], List[DiaRoteiro]]:
696
+ """Compute a detailed travel plan for the given input parameters.
697
+
698
+ Returns the total estimated budget, the list of transport legs, itemised
699
+ costs, and daily itinerary. This function does not apply any budget
700
+ adjustments; see ``fit_to_budget`` for that.
701
+ """
702
  total_orcamento = 0.0
703
  legs: List[Leg] = []
704
  custos_itens: List[ItemCusto] = []
705
  roteiro: List[DiaRoteiro] = []
706
 
707
+ # Determine coordinates and labels for origin and destination.
708
  lat_o, lon_o, origem_label = get_coords_and_label(req.cidade_origem)
709
  lat_d, lon_d, destino_label = get_coords_and_label(req.destino)
710
  origem_coords = (lat_o, lon_o)
711
  destino_coords = (lat_d, lon_d)
712
  distancia_total_km = haversine_km(origem_coords, destino_coords)
713
 
714
+ # Compute the first leg (outbound). Choose flight for long distances (>500 km)
715
+ # unless a specific transport mode is forced.
716
  modo_ida = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
717
  preco_ida = distancia_total_km * BASES["voo_preco_km"] if modo_ida == "voo" else distancia_total_km * BASES["rod_preco_km"]
718
  duracao_ida = distancia_total_km / 700 if modo_ida == "voo" else distancia_total_km / 80
 
737
  )
738
  )
739
 
740
+ # Return leg (if travel spans at least one night). Distances and modes mirror the outbound.
741
  if d0 != d1:
742
  modo_volta = force_transport if force_transport else ("voo" if distancia_total_km > 500 else "rodoviário")
743
  preco_volta = distancia_total_km * BASES["voo_preco_km"] if modo_volta == "voo" else distancia_total_km * BASES["rod_preco_km"]
 
774
  atividades_tarde: List[str] = []
775
  atividades_noite: List[str] = []
776
 
777
+ # Allocate lodging cost across each day; divide by number of days to
778
+ # spread the cost evenly when multiple nights.
779
  if num_dias > 1:
780
  preco_hospedagem_noite = BASES["hospedagem"][perfil]
781
  custo_hospedagem_dia = preco_hospedagem_noite * req.numero_viajantes / num_dias
 
790
  )
791
  )
792
 
793
+ # Meal cost: based on profile factor, number of travellers and optional
794
+ # meals_factor to reduce costs when adjusting to budgets.
795
  custo_refeicoes_dia = BASES["refeicoes"] * req.numero_viajantes * fator_perfil * meals_factor
796
  custo_dia += custo_refeicoes_dia
797
  custos_itens.append(
 
804
  )
805
  )
806
 
807
+ # Select morning activity: avoid paid indoor activities on weekdays if not weekend and not budget mode.
808
  poi_manha = get_random_poi(
809
+ filter_pois(
810
+ pois_destino,
811
+ req.temas,
812
+ "manha",
813
+ indoor=False if not is_weekend(current_date) else None,
814
+ )
815
  )
816
  if poi_manha and (not budget_mode or (cap_paid is not None and poi_manha["custo"] <= cap_paid)):
817
  atividades_manha.append(poi_manha["nome"])
 
826
  )
827
  )
828
 
829
+ # Afternoon activity: ensure not to repeat morning activity.
830
  poi_tarde = get_random_poi(
831
  filter_pois(pois_destino, req.temas, "tarde"),
832
  exclude=[p["nome"] for p in [poi_manha] if p],
 
844
  )
845
  )
846
 
847
+ # Evening activity: avoid repeating earlier activities.
848
  poi_noite = get_random_poi(
849
  filter_pois(pois_destino, req.temas, "noite"),
850
  exclude=[p["nome"] for p in [poi_manha, poi_tarde] if p],
 
876
  return total_orcamento, legs, custos_itens, roteiro
877
 
878
 
879
+ def fit_to_budget(
880
+ req: PlanRequest,
881
+ d0: date,
882
+ d1: date,
883
+ ) -> Tuple[
884
+ float,
885
+ Dict[str, Any],
886
+ List[str],
887
+ Optional[Dict[str, str]],
888
+ Optional[List[str]],
889
+ ]:
890
+ """Adjust the travel plan to fit within the provided budget.
891
+
892
+ This function repeatedly recalculates the plan with different cost
893
+ reductions (transport mode, accommodation profile, free activities, meal
894
+ reductions, shorter duration) until the total is below the budget or no
895
+ further adjustments reduce the cost. It returns the total cost, a
896
+ dictionary with legs/costs/route, a list of applied adjustments, an
897
+ optional adjusted period, and optional suggestions for the user.
898
+ """
899
  ajustes: List[str] = []
900
  periodo_ajustado: Optional[Dict[str, str]] = None
901
  sugestoes: Optional[List[str]] = None
 
908
  allow_daytrips = True
909
 
910
  total, legs, custos, roteiro = recompute_plan(req, d0, d1, perfil)
911
+ # If no budget constraint or already within budget, return immediately.
912
  if req.teto_orcamento <= 0 or total <= req.teto_orcamento:
913
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
914
 
915
+ # 1. Try switching from flights to road if flights are present.
916
  if any(l.modo == "voo" for l in legs):
917
  force_transport = "rodoviário"
918
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport=force_transport)
 
922
  if total <= req.teto_orcamento:
923
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
924
 
925
+ # 2. Downgrade accommodation profile if premium.
926
  if perfil == "premium":
927
  perfil = "equilibrado"
928
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
929
  if total2 < total:
930
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
931
  ajustes.append("Hospedagem ajustada para perfil 'equilibrado'.")
932
+ # 3. Further downgrade to econômico if still over budget.
933
  if total > req.teto_orcamento and perfil in ("equilibrado", "premium"):
934
  perfil = "econômico"
935
  total2, legs2, custos2, roteiro2 = recompute_plan(req, d0, d1, perfil, force_transport)
 
939
  if total <= req.teto_orcamento:
940
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
941
 
942
+ # 4. Prioritise free or low‑cost activities.
943
  budget_mode = True
944
  cap_paid = 60.0
945
  total2, legs2, custos2, roteiro2 = recompute_plan(
 
951
  if total <= req.teto_orcamento:
952
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
953
 
954
+ # 5. Remove day‑trips (long excursions) if still above budget.
955
  allow_daytrips = False
956
  total2, legs2, custos2, roteiro2 = recompute_plan(
957
  req, d0, d1, perfil, force_transport, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
958
  )
959
  if total2 < total:
960
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
961
+ ajustes.append("Daytrips removidas.")
962
  if total <= req.teto_orcamento:
963
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
964
 
965
+ # 6. Reduce meal cost by 15%.
966
  meals_factor = 0.85
967
  total2, legs2, custos2, roteiro2 = recompute_plan(
968
  req, d0, d1, perfil, force_transport, meals_factor=meals_factor, cap_paid=cap_paid, budget_mode=budget_mode, allow_daytrips=allow_daytrips
 
973
  if total <= req.teto_orcamento:
974
  return total, {"legs": legs, "custos": custos, "roteiro": roteiro}, ajustes, periodo_ajustado, sugestoes
975
 
976
+ # 7. Shorten the trip by reducing days at the end.
977
  cur_d1 = d1
978
  while total > req.teto_orcamento and (cur_d1 - d0).days + 1 > 2:
979
  cur_d1 = cur_d1 - timedelta(days=1)
 
984
  total, legs, custos, roteiro = total2, legs2, custos2, roteiro2
985
  periodo_ajustado = {"data_inicio": d0.isoformat(), "data_fim": cur_d1.isoformat()}
986
  ajustes.append("Viagem encurtada ao final para adequar ao orçamento.")
987
+ if total <= req.teto_orcamento:
988
+ break
989
 
990
+ # If still above budget, provide suggestions.
991
  if total > req.teto_orcamento:
992
  sugestoes = [
993
  "Considere reduzir o número de viajantes ou dividir quartos.",
 
999
 
1000
 
1001
  def risk_tag(raw_dest: str) -> str:
1002
+ """Return a simple climate risk tag for a destination."""
1003
  k = resolve_city_key(raw_dest)
1004
  if k in ("manaus", "belém"):
1005
  return "Médio (chuvas tropicais / calor úmido)"
1006
  return "Baixo"
1007
 
1008
 
1009
+ # =============================================================================
1010
  # Endpoints
1011
+ # =============================================================================
1012
+
1013
  @app.get("/health")
1014
+ def health() -> Dict[str, str]:
1015
+ """Health check endpoint; returns current UTC timestamp."""
1016
  return {"status": "ok", "ts": datetime.utcnow().isoformat()}
1017
 
1018
 
1019
  @app.get("/info")
1020
+ def info() -> Dict[str, Any]:
1021
+ """Return basic information about the API and its endpoints."""
1022
  return {
1023
  "name": "IViagem Planner (smart + budget + geocode + gemini)",
1024
  "version": APP_VERSION,
 
1028
 
1029
 
1030
  @app.post("/plan", response_model=PlanResponse)
1031
+ def plan(req: PlanRequest) -> PlanResponse:
1032
+ """Compute and return a detailed travel plan based on the request.
1033
+
1034
+ Validates input dates, adjusts the plan to respect the provided budget,
1035
+ computes risk tags and generates narrative observations using the LLM.
1036
+ """
1037
  try:
1038
  d0 = isoparse(req.data_inicio).date()
1039
  d1 = isoparse(req.data_fim).date()
 
1047
  custos_itens = parts["custos"]
1048
  roteiro = parts["roteiro"]
1049
 
1050
+ tempo_voo_total: Optional[str] = None
1051
  if any(l.modo == "voo" for l in legs):
1052
  tempo_voo_total = f"{round(sum(l.duracao_h for l in legs), 1)} h"
1053
 
1054
+ economia_vs_base: Optional[float] = None
1055
  if req.teto_orcamento and req.teto_orcamento > 0:
1056
  economia_vs_base = round(req.teto_orcamento - total, 2)
1057
 
 
1059
  risco = risk_tag(req.destino)
1060
 
1061
  periodo_txt = ""
1062
+ if periodo_ajustado and (
1063
+ periodo_ajustado["data_inicio"] != req.data_inicio or periodo_ajustado["data_fim"] != req.data_fim
1064
+ ):
1065
  periodo_txt = f" Período ajustado: {periodo_ajustado['data_inicio']} a {periodo_ajustado['data_fim']}."
1066
 
1067
  temas_txt = ", ".join(req.temas)
 
1079
  f" Período solicitado: {req.data_inicio} a {req.data_fim}.{periodo_txt} Moeda: {req.moeda}."
1080
  )
1081
 
1082
+ # Generate a narrative for each day using the LLM.
1083
  for dia in roteiro:
1084
  dia_prompt = (
1085
  f"Crie uma narrativa curta para o dia {dia.data} em {destino_label}. "
 
1090
  narrativa = generate_text_with_llm(dia_prompt, max_tokens=150, temperature=0.9)
1091
  dia.narrativa = narrativa or ""
1092
 
1093
+ # Suggestion generation: if no suggestions were generated during budget fitting,
1094
+ # use the LLM to propose tips; otherwise return the existing suggestions.
1095
  if sugestoes is None:
1096
  sugestao_prompt = (
1097
  f"Com base no planejamento {req.cidade_origem} → {destino_label} ({req.data_inicio} a {req.data_fim}), "
 
1125
 
1126
 
1127
  @app.get("/geocode")
1128
+ def geocode(q: str) -> Dict[str, Any]:
1129
+ """Geocode a location name, returning online and fallback coordinates."""
1130
  gc = geocode_city(q)
1131
  if gc is None:
1132
  lat, lon, label = get_coords_and_label(q)
1133
  return {"online": None, "fallback": {"lat": lat, "lon": lon, "label": label}}
1134
  lat, lon, label = gc
1135
+ return {"online": {"lat": lat, "lon": lon, "label": label}}
requirements.txt CHANGED
@@ -3,4 +3,4 @@ uvicorn[standard]==0.30.6
3
  httpx==0.27.2
4
  python-dateutil==2.9.0.post0
5
  pydantic==2.9.2
6
- google-generativeai==0.7.2
 
3
  httpx==0.27.2
4
  python-dateutil==2.9.0.post0
5
  pydantic==2.9.2
6
+ google-generativeai==0.7.2