File size: 21,392 Bytes
b73e252
b7e0be1
b73e252
38cfd9a
 
 
 
 
 
 
 
b73e252
 
 
 
 
 
 
 
38cfd9a
b73e252
38cfd9a
b73e252
 
 
 
 
38cfd9a
 
b73e252
 
 
 
 
 
b7e0be1
b73e252
 
 
 
 
 
 
 
 
 
 
38cfd9a
 
b73e252
 
 
 
 
 
 
 
 
38cfd9a
b7e0be1
 
 
 
38cfd9a
 
 
 
 
b7e0be1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7e0be1
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7e0be1
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b73e252
38cfd9a
b73e252
38cfd9a
 
 
 
b7e0be1
38cfd9a
b73e252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38cfd9a
b73e252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38cfd9a
 
 
 
 
 
b7e0be1
 
419a22a
 
 
 
 
 
b73e252
 
 
 
b7e0be1
b73e252
 
 
 
38cfd9a
 
b7e0be1
b73e252
 
 
38cfd9a
b73e252
 
 
 
 
 
 
 
 
 
 
 
 
38cfd9a
 
 
 
b73e252
 
 
 
38cfd9a
b73e252
 
 
 
 
 
 
 
38cfd9a
 
 
b73e252
 
 
 
38cfd9a
 
 
 
b7e0be1
 
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b73e252
 
38cfd9a
b7e0be1
 
 
38cfd9a
b73e252
 
 
 
 
 
 
 
38cfd9a
b73e252
 
38cfd9a
 
b73e252
38cfd9a
 
 
b7e0be1
38cfd9a
 
 
 
 
 
b73e252
 
 
38cfd9a
b73e252
 
38cfd9a
b73e252
 
 
 
 
 
 
4040dd8
38cfd9a
b73e252
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b73e252
 
 
 
 
38cfd9a
 
 
 
 
 
 
 
b73e252
38cfd9a
b73e252
 
 
 
 
 
 
38cfd9a
b73e252
 
38cfd9a
b73e252
 
 
38cfd9a
b73e252
 
 
 
38cfd9a
b73e252
 
 
 
 
 
 
 
 
 
38cfd9a
b73e252
 
 
b7e0be1
38cfd9a
b73e252
 
 
38cfd9a
 
 
 
 
 
 
 
b73e252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38cfd9a
b73e252
 
 
 
 
38cfd9a
b73e252
 
38cfd9a
b73e252
b7e0be1
b73e252
 
38cfd9a
 
 
b7e0be1
38cfd9a
b73e252
 
38cfd9a
b73e252
 
 
 
38cfd9a
b73e252
 
 
 
 
38cfd9a
b7e0be1
38cfd9a
b73e252
 
38cfd9a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b73e252
 
 
 
b7e0be1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
"""
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 ─────────────────────────────────────────────────────────────────
@app.get("/")
async def root():
    return {"status": "ok", "app": "EVENTUKI API v4.1"}


@app.get("/health")
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",
    }


@app.post("/chat")
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))


@app.delete("/chat/{session_id}")
async def reset(session_id: str):
    web_sessions.pop(session_id, None)
    return {"status": "reset"}


@app.post("/eventos")
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))


@app.get("/eventos")
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]


@app.delete("/eventos/{evento_id}")
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 ───────────────────────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
    init_db()
    logger.info("EVENTUKI API v4.1 lista en http://0.0.0.0:7860")