Spaces:
Sleeping
Sleeping
Download app.py from natisol87/eventuki-api: direct link, hf CLI and curl.
- Browser
- Download file 21.4 kB
-
https://huggingface.co/spaces/natisol87/eventuki-api/resolve/main/app.py
- Command line
-
hf download hf://spaces/natisol87/eventuki-api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/natisol87/eventuki-api/resolve/main/app.py
21.4 kB
| """ | |
| EVENTUKI β Backend API v4.1 | |
| FastAPI + Groq (Llama 3.1) + Serper (Google Search) + SQLite | |
| Persistencia de la DB en Hugging Face Dataset (sobrevive reinicios del Space). | |
| Variables de entorno requeridas en HF Space β Settings β Repository secrets: | |
| GROQ_API_KEY β tu clave de Groq | |
| SERPER_API_KEY β tu clave de Serper | |
| HF_TOKEN β token de HF con permiso write (Settings β Access Tokens) | |
| HF_DATASET_REPO β nombre del repo dataset, ej: "natisol87/eventuki-db" | |
| (lo crea automΓ‘ticamente si no existe) | |
| """ | |
| import asyncio | |
| import datetime | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import shutil | |
| import sqlite3 | |
| import threading | |
| import aiohttp | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from groq import AsyncGroq | |
| from huggingface_hub import HfApi, hf_hub_download | |
| from huggingface_hub.utils import EntryNotFoundError, RepositoryNotFoundError | |
| from pydantic import BaseModel | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s β %(levelname)s β %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # ββ App βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="EVENTUKI API", version="4.1") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Groq ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| groq_client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY", "")) | |
| MODEL = "llama-3.1-8b-instant" | |
| MODEL_BK = "llama3-8b-8192" | |
| SITES_HINT = ( | |
| "ticketek livepass passline enigmatickets venti " | |
| "eventbrite cckonex movistararena teatrocolon " | |
| "indiehoy feverup festivalesba allaccess paseshow" | |
| ) | |
| _RE = re.compile(r"<<<EVENTOS>>>(.*?)<<<FIN_EVENTOS>>>", re.DOTALL) | |
| # ββ ConfiguraciΓ³n HF Dataset ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DB_PATH = "eventuki.db" | |
| DB_FILENAME = "eventuki.db" | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "") | |
| hf_api = HfApi(token=HF_TOKEN) | |
| _upload_lock = threading.Lock() | |
| # ββ NormalizaciΓ³n de fechas βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MESES_ES = { | |
| "enero": 1, "febrero": 2, "marzo": 3, "abril": 4, | |
| "mayo": 5, "junio": 6, "julio": 7, "agosto": 8, | |
| "septiembre": 9, "octubre": 10, "noviembre": 11, "diciembre": 12, | |
| } | |
| def normalizar_fecha(fecha_str: str) -> str: | |
| """ | |
| Acepta cualquiera de estos formatos y devuelve YYYY-MM-DD: | |
| - 2026-06-14 (HTML date input, ISO) | |
| - 14/06/2026 (dd/mm/yyyy) | |
| - 06/14/2026 (mm/dd/yyyy β lo que muestra el front con locale en-US) | |
| - 14 de junio 2026 (texto en espaΓ±ol, como lo devuelve el LLM) | |
| - 14 de junio de 2026 | |
| Devuelve la cadena original si no puede parsear (nunca rompe). | |
| """ | |
| if not fecha_str: | |
| return fecha_str | |
| s = fecha_str.strip() | |
| # YYYY-MM-DD (viene del input type="date" de HTML β el formato real del value) | |
| try: | |
| return datetime.datetime.strptime(s, "%Y-%m-%d").strftime("%Y-%m-%d") | |
| except ValueError: | |
| pass | |
| # dd/mm/yyyy | |
| try: | |
| partes = s.split("/") | |
| if len(partes) == 3 and int(partes[0]) > 12: | |
| return datetime.datetime.strptime(s, "%d/%m/%Y").strftime("%Y-%m-%d") | |
| except (ValueError, IndexError): | |
| pass | |
| # mm/dd/yyyy o dd/mm/yyyy ambiguo (primer nΓΊmero β€ 12) | |
| try: | |
| return datetime.datetime.strptime(s, "%m/%d/%Y").strftime("%Y-%m-%d") | |
| except ValueError: | |
| pass | |
| # dd/mm/yyyy como fallback del bloque anterior | |
| try: | |
| return datetime.datetime.strptime(s, "%d/%m/%Y").strftime("%Y-%m-%d") | |
| except ValueError: | |
| pass | |
| # "14 de junio 2026" o "14 de junio de 2026" | |
| s_lower = s.lower().replace(" de ", " ") | |
| partes = s_lower.split() | |
| if len(partes) >= 3: | |
| try: | |
| dia = int(partes[0]) | |
| mes = MESES_ES.get(partes[1]) | |
| anio = int(partes[2]) | |
| if mes: | |
| return datetime.datetime(anio, mes, dia).strftime("%Y-%m-%d") | |
| except (ValueError, IndexError): | |
| pass | |
| logger.warning("No se pudo normalizar la fecha: %s β se guarda tal cual", fecha_str) | |
| return fecha_str | |
| # ββ Persistencia: cargar DB desde HF Dataset βββββββββββββββββββββββββββββββββ | |
| def load_db_from_hub() -> bool: | |
| """ | |
| Descarga eventuki.db desde el HF Dataset al filesystem local. | |
| Retorna True si se descargΓ³, False si no existΓa (primera vez). | |
| """ | |
| if not HF_TOKEN or not HF_DATASET_REPO: | |
| logger.warning("HF_TOKEN o HF_DATASET_REPO no configurados. Usando DB solo local.") | |
| return False | |
| try: | |
| local_path = hf_hub_download( | |
| repo_id=HF_DATASET_REPO, | |
| filename=DB_FILENAME, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| local_dir=".", | |
| force_download=True, | |
| ) | |
| if local_path != DB_PATH: | |
| shutil.copy(local_path, DB_PATH) | |
| logger.info("DB descargada desde HF Dataset: %s", HF_DATASET_REPO) | |
| return True | |
| except EntryNotFoundError: | |
| logger.info("DB no encontrada en HF Dataset (primera vez). Se crearΓ‘ una nueva.") | |
| return False | |
| except RepositoryNotFoundError: | |
| logger.info("Dataset repo '%s' no existe. Se crearΓ‘.", HF_DATASET_REPO) | |
| try: | |
| hf_api.create_repo( | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| private=True, | |
| exist_ok=True, | |
| ) | |
| logger.info("Dataset repo creado: %s", HF_DATASET_REPO) | |
| except Exception as e: | |
| logger.error("No se pudo crear el dataset repo: %s", e) | |
| return False | |
| except Exception as e: | |
| logger.error("Error al descargar DB desde HF: %s", e) | |
| return False | |
| def save_db_to_hub(): | |
| """ | |
| Sube eventuki.db al HF Dataset en un thread separado | |
| para no bloquear la respuesta HTTP. | |
| """ | |
| if not HF_TOKEN or not HF_DATASET_REPO: | |
| return | |
| def _upload(): | |
| with _upload_lock: | |
| try: | |
| hf_api.upload_file( | |
| path_or_fileobj=DB_PATH, | |
| path_in_repo=DB_FILENAME, | |
| repo_id=HF_DATASET_REPO, | |
| repo_type="dataset", | |
| token=HF_TOKEN, | |
| commit_message=f"Update DB β {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", | |
| ) | |
| logger.info("DB guardada en HF Dataset.") | |
| except Exception as e: | |
| logger.error("Error al subir DB a HF: %s", e) | |
| threading.Thread(target=_upload, daemon=True).start() | |
| # ββ SQLite ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def init_db(): | |
| """ | |
| 1. Intenta descargar la DB existente desde HF Dataset. | |
| 2. Si no existe, crea una nueva con el schema. | |
| """ | |
| load_db_from_hub() | |
| with sqlite3.connect(DB_PATH) as c: | |
| c.execute(""" | |
| CREATE TABLE IF NOT EXISTS eventos_usuarios ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| nombre TEXT NOT NULL, | |
| categoria TEXT, | |
| fecha TEXT, | |
| hora TEXT, | |
| precio TEXT, | |
| lugar TEXT, | |
| descripcion TEXT, | |
| url_entradas TEXT, | |
| creado_en TEXT DEFAULT (datetime('now','localtime')) | |
| ) | |
| """) | |
| c.commit() | |
| logger.info("DB lista") | |
| # ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_system_prompt(): | |
| hoy = datetime.date.today() | |
| hoy_str = hoy.strftime("%d de %B de %Y") | |
| semana_str = (hoy + datetime.timedelta(days=7)).strftime("%d de %B de %Y") | |
| p = "Sos EVENTUKI, el asistente de eventos para Buenos Aires.\n" | |
| p += "Slogan: Si la ciudad no duerme, su agenda tampoco.\n" | |
| p += "Hablas en espanol rioplatense. Sos entusiasta y porteno.\n\n" | |
| p += "HOY ES: " + hoy_str + "\nLIMITE SEMANA: " + semana_str + "\n\n" | |
| p += "COMO LEER RESULTADOS:\n" | |
| p += "TITULO = nombre del sitio web. IGNORALO.\n" | |
| p += "DESCRIPCION = datos reales del evento.\n" | |
| p += "URL = link de entradas.\n" | |
| p += "Siempre extraer nombre del evento de DESCRIPCION.\n\n" | |
| p += "FILTRO DE FECHAS: aplicar segun lo pedido. No incluir fechas pasadas.\n\n" | |
| p += "TIPOS DE EVENTOS:\n" | |
| p += "- EVENTO LOCAL (Usuario): cargados por la comunidad portena. " | |
| p += "SIEMPRE incluirlos aunque no tengan URL de venta. Son reales y verificados. " | |
| p += "Usar \"fuente\": \"local\" en el JSON.\n" | |
| p += "- Resultados de Google: solo incluir si tienen URL real que empiece con http. " | |
| p += "Usar \"fuente\": \"google\" en el JSON.\n\n" | |
| p += "Las fechas en la DB estan en formato YYYY-MM-DD. " | |
| p += "Mostrarlas al usuario como '14 de junio de 2026'.\n\n" | |
| p += "CIERRE DE RESPUESTA:\n" | |
| p += "Jamas termines con preguntas del tipo 'ΒΏnecesitas algo mas?', " | |
| p += "'ΒΏte puedo ayudar con otra cosa?', 'ΒΏqueres que busque mas eventos?' " | |
| p += "o cualquier variante. Tampoco ofrezcas continuar la conversacion. " | |
| p += "Simplemente presenta los eventos y punto. " | |
| p += "Si no encontraste nada, decilo directo y listo.\n\n" | |
| p += "FORMATO RESPUESTA β texto breve, luego AL FINAL:\n\n" | |
| p += "<<<EVENTOS>>>\n[\n {\n" | |
| p += ' "nombre": "nombre extraido de DESCRIPCION",\n' | |
| p += ' "categoria": "Recital",\n' | |
| p += ' "fecha": "14 de junio de 2026",\n' | |
| p += ' "hora": "19:00 o A confirmar",\n' | |
| p += ' "lugar": "Movistar Arena",\n' | |
| p += ' "precio": "desde $85.000 o A confirmar",\n' | |
| p += ' "descripcion": "max 100 chars",\n' | |
| p += ' "url_entradas": "URL o vacio si evento local sin venta online",\n' | |
| p += ' "direccion": "o A confirmar",\n' | |
| p += ' "fuente": "local o google"\n' | |
| p += " }\n]\n<<<FIN_EVENTOS>>>" | |
| return p | |
| # ββ Serper search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def search_events(query: str) -> str: | |
| hoy_str = datetime.date.today().strftime("%B %Y") | |
| queries = [ | |
| query + " " + hoy_str + " entradas comprar " + SITES_HINT, | |
| query + " Buenos Aires " + hoy_str + " precio fecha horario", | |
| ] | |
| results, seen = [], set() | |
| async with aiohttp.ClientSession() as session: | |
| for q in queries: | |
| try: | |
| async with session.post( | |
| "https://google.serper.dev/search", | |
| headers={ | |
| "X-API-KEY": os.environ.get("SERPER_API_KEY", ""), | |
| "Content-Type": "application/json", | |
| }, | |
| json={"q": q, "gl": "ar", "hl": "es", "num": 8}, | |
| ) as r: | |
| data = await r.json() | |
| for item in data.get("organic", []): | |
| url = item.get("link", "") | |
| if url and url not in seen: | |
| seen.add(url) | |
| results.append(item) | |
| except Exception as e: | |
| logger.warning("Serper: %s", e) | |
| if not results: | |
| return "SIN_RESULTADOS" | |
| lines = [ | |
| "TITULO: " + r.get("title", "") | |
| + "\nDESCRIPCION: " + r.get("snippet", "") | |
| + "\nURL: " + r.get("link", "") | |
| for r in results | |
| ] | |
| return "\n---\n".join(lines) | |
| # ββ BΓΊsqueda local en DB ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def search_local_events() -> str: | |
| """ | |
| Devuelve todos los eventos de la DB sin filtrar por query. | |
| La DB es pequeΓ±a; el LLM selecciona los relevantes segΓΊn | |
| el mensaje del usuario y las fechas. | |
| """ | |
| try: | |
| with sqlite3.connect(DB_PATH) as c: | |
| c.row_factory = sqlite3.Row | |
| rows = c.execute( | |
| "SELECT * FROM eventos_usuarios ORDER BY fecha ASC LIMIT 30" | |
| ).fetchall() | |
| if not rows: | |
| return "" | |
| lines = [ | |
| f"EVENTO LOCAL (Usuario): {r['nombre']}\n" | |
| f"CATEGORIA: {r['categoria']}\n" | |
| f"FECHA: {r['fecha']} {r['hora']}\n" | |
| f"LUGAR: {r['lugar']}\n" | |
| f"PRECIO: {r['precio']}\n" | |
| f"DESCRIPCION: {r['descripcion']}\n" | |
| f"URL: {r['url_entradas'] or 'No disponible'}" | |
| for r in rows | |
| ] | |
| return "\n---\n".join(lines) | |
| except Exception as e: | |
| logger.warning("Error DB Search: %s", e) | |
| return "" | |
| # ββ Parse events ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def parse_events(raw: str, seen: set): | |
| """ | |
| Distingue eventos locales de Google: | |
| - Locales (fuente=local): se incluyen aunque no tengan URL. | |
| - Google (fuente=google): se descartan si no tienen URL http vΓ‘lida. | |
| """ | |
| m = _RE.search(raw) | |
| if not m: | |
| return raw.strip(), [] | |
| clean = _RE.sub("", raw).strip() | |
| try: | |
| all_evs = json.loads(m.group(1).strip()) | |
| except json.JSONDecodeError: | |
| return clean, [] | |
| unique = [] | |
| for ev in all_evs: | |
| for f in ["nombre", "fecha", "hora", "lugar", "precio", | |
| "descripcion", "url_entradas", "direccion"]: | |
| if f in ev and isinstance(ev[f], str): | |
| ev[f] = ev[f].strip().replace("\r", "").replace("\n", "") | |
| url = ev.get("url_entradas", "") | |
| fuente = ev.get("fuente", "google") | |
| if fuente != "local": | |
| if not url or not url.startswith("http") or "example" in url.lower(): | |
| continue | |
| key = ev.get("nombre", "") + "|" + ev.get("fecha", "") | |
| if key not in seen: | |
| seen.add(key) | |
| unique.append(ev) | |
| return clean, unique | |
| # ββ Groq call βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def call_groq(messages: list, model: str) -> str: | |
| r = await groq_client.chat.completions.create( | |
| model=model, messages=messages, max_tokens=1500, temperature=0.2, | |
| ) | |
| return r.choices[0].message.content or "" | |
| # ββ AI pipeline βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def get_events(history: list, seen_events: set): | |
| last = history[-1]["content"] if history else "" | |
| google_task = asyncio.create_task(search_events(last)) | |
| local_task = asyncio.create_task(search_local_events()) | |
| google_results, local_results = await asyncio.gather(google_task, local_task) | |
| if local_results and google_results != "SIN_RESULTADOS": | |
| results = ( | |
| "=== EVENTOS CARGADOS POR USUARIOS (PRIORIDAD) ===\n" | |
| + local_results | |
| + "\n\n=== RESULTADOS DE GOOGLE ===\n" | |
| + google_results | |
| ) | |
| elif local_results: | |
| results = "=== EVENTOS CARGADOS POR USUARIOS ===\n" + local_results | |
| elif google_results != "SIN_RESULTADOS": | |
| results = google_results | |
| else: | |
| return "No encontrΓ© eventos verificados. ProbΓ‘ con otra fecha o categorΓa.", [] | |
| messages = [{"role": "system", "content": build_system_prompt()}] | |
| for msg in history[:-1]: | |
| messages.append({"role": msg["role"], "content": msg["content"]}) | |
| messages.append({ | |
| "role": "user", | |
| "content": ( | |
| last | |
| + "\n\n=== DATOS ENCONTRADOS ===\n" | |
| + results | |
| + "\n=== FIN ===\n\n" | |
| + "Incluir TODOS los EVENTO LOCAL aunque no tengan URL (usar fuente:local). " | |
| + "Para eventos de Google, solo incluir si tienen URL real." | |
| ), | |
| }) | |
| try: | |
| text = await call_groq(messages, MODEL) | |
| except Exception as e: | |
| if "429" in str(e) or "model" in str(e).lower(): | |
| text = await call_groq(messages, MODEL_BK) | |
| else: | |
| raise | |
| return parse_events(text, seen_events) | |
| # ββ Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| web_sessions: dict = {} | |
| class ChatRequest(BaseModel): | |
| session_id: str | |
| message: str | |
| class EventRequest(BaseModel): | |
| nombre: str | |
| categoria: str | |
| fecha: str | |
| hora: str | |
| lugar: str | |
| precio: str | |
| descripcion: str | |
| url_entradas: str = "" | |
| # ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def root(): | |
| return {"status": "ok", "app": "EVENTUKI API v4.1"} | |
| async def health(): | |
| hf_ok = bool(HF_TOKEN and HF_DATASET_REPO) | |
| return { | |
| "status": "healthy", | |
| "time": datetime.datetime.now().isoformat(), | |
| "persistencia_hf": hf_ok, | |
| "dataset_repo": HF_DATASET_REPO or "no configurado", | |
| } | |
| async def chat(req: ChatRequest): | |
| sid = req.session_id | |
| if sid not in web_sessions: | |
| web_sessions[sid] = {"history": [], "seen_events": set()} | |
| session = web_sessions[sid] | |
| session["history"].append({"role": "user", "content": req.message}) | |
| try: | |
| text, events = await get_events(session["history"], session["seen_events"]) | |
| session["history"].append({"role": "assistant", "content": text}) | |
| return {"text": text, "events": events} | |
| except Exception as e: | |
| logger.error("Error /chat: %s", e) | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def reset(session_id: str): | |
| web_sessions.pop(session_id, None) | |
| return {"status": "reset"} | |
| async def add_event(ev: EventRequest): | |
| """Guarda un evento en la DB local y lo persiste en HF Dataset.""" | |
| try: | |
| fecha_normalizada = normalizar_fecha(ev.fecha) # β normalizaciΓ³n aquΓ | |
| with sqlite3.connect(DB_PATH) as c: | |
| c.execute( | |
| "INSERT INTO eventos_usuarios " | |
| "(nombre,categoria,fecha,hora,lugar,precio,descripcion,url_entradas) " | |
| "VALUES (?,?,?,?,?,?,?,?)", | |
| (ev.nombre, ev.categoria, fecha_normalizada, ev.hora, | |
| ev.lugar, ev.precio, ev.descripcion, ev.url_entradas), | |
| ) | |
| c.commit() | |
| save_db_to_hub() | |
| return {"status": "ok", "mensaje": "Evento guardado correctamente"} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def list_events(): | |
| """Lista los eventos subidos por usuarios.""" | |
| with sqlite3.connect(DB_PATH) as c: | |
| c.row_factory = sqlite3.Row | |
| rows = c.execute( | |
| "SELECT * FROM eventos_usuarios ORDER BY fecha ASC" | |
| ).fetchall() | |
| return [dict(r) for r in rows] | |
| async def delete_event(evento_id: int): | |
| """Elimina un evento por ID y sincroniza con HF Dataset.""" | |
| with sqlite3.connect(DB_PATH) as c: | |
| deleted = c.execute( | |
| "DELETE FROM eventos_usuarios WHERE id = ?", (evento_id,) | |
| ).rowcount | |
| c.commit() | |
| if deleted == 0: | |
| raise HTTPException(status_code=404, detail="Evento no encontrado") | |
| save_db_to_hub() | |
| return {"status": "ok", "mensaje": f"Evento {evento_id} eliminado"} | |
| # ββ Startup βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def startup(): | |
| init_db() | |
| logger.info("EVENTUKI API v4.1 lista en http://0.0.0.0:7860") |