commit 209620a234fb181cdefb62690e7baef94a8c4f76 Author: Fabio Bonetti Date: Tue Jan 27 13:52:44 2026 +0100 Initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb29d3e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Installa dipendenze +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copia codice +COPY app/ ./app/ + +# Crea directory per i dati +RUN mkdir -p /data + +# Variabili d'ambiente +ENV DATA_PATH=/data/hotspots.json +ENV PYTHONUNBUFFERED=1 + +# Esponi porta +EXPOSE 8000 + +# Avvia server +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a838095 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Hotspot API + +Backend FastAPI per riconoscimento e matching hotspot europei. + +## Endpoints + +| Metodo | Endpoint | Descrizione | +|--------|----------|-------------| +| GET | `/` | Info servizio | +| GET | `/health` | Health check | +| GET | `/hotspots` | Lista tutti gli hotspot | +| GET | `/hotspots/{id}` | Singolo hotspot | +| POST | `/match` | Cerca corrispondenza per testo | +| POST | `/reload` | Ricarica database JSON | + +## Esempio chiamata /match + +```bash +curl -X POST "https://api.tuodominio.com/match" \ + -H "Content-Type: application/json" \ + -d '{"text": "Brussels", "language": "en"}' +``` + +Risposta: +```json +{ + "found": true, + "hotspot_id": "1", + "name": "Brussels", + "confidence": 1.0, + "video_ids": [133] +} +``` + +## Deploy su Coolify + +1. Crea un nuovo progetto +2. Add Resource → Docker Image o Git repository +3. Se usi Git, punta al repo con questo codice +4. Configura il volume: `/data` → persistent storage +5. Carica `hotspots.json` in `/data/` +6. Deploy! + +## Aggiornare il database + +1. Carica il nuovo `hotspots.json` via SFTP in `/data/` +2. Chiama `POST /reload` oppure fai redeploy + +## Documentazione API + +Dopo il deploy, visita `/docs` per la documentazione Swagger interattiva. diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..31e844b --- /dev/null +++ b/app/main.py @@ -0,0 +1,240 @@ +from fastapi import FastAPI, UploadFile, File, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from typing import Optional +import json +import os +from pathlib import Path + +app = FastAPI( + title="Hotspot API", + description="API per riconoscimento testo e matching hotspot europei", + version="1.0.0" +) + +# CORS per permettere chiamate dal frontend +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In produzione, specifica i domini + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Path del file JSON (montato come volume in Docker) +DATA_PATH = os.getenv("DATA_PATH", "/data/hotspots.json") + +# Cache del database in memoria +hotspots_db: dict = {} +video_mapping: dict = {} + + +def load_database(): + """Carica il database JSON all'avvio""" + global hotspots_db, video_mapping + + try: + with open(DATA_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Supporta sia formato semplice che complesso + if isinstance(data, dict): + if "hotspots" in data: + hotspots_db = data["hotspots"] + video_mapping = data.get("video_mapping", {}) + else: + # Formato semplice: {id: nome} + hotspots_db = {k: {"id": k, "name": v} for k, v in data.items()} + + print(f"✅ Database caricato: {len(hotspots_db)} hotspots") + except FileNotFoundError: + print(f"⚠️ File {DATA_PATH} non trovato, uso database vuoto") + hotspots_db = {} + except json.JSONDecodeError as e: + print(f"❌ Errore parsing JSON: {e}") + hotspots_db = {} + + +@app.on_event("startup") +async def startup_event(): + load_database() + + +# === MODELS === + +class MatchRequest(BaseModel): + text: str + language: Optional[str] = "en" + + +class MatchResponse(BaseModel): + found: bool + hotspot_id: Optional[str] = None + name: Optional[str] = None + confidence: Optional[float] = None + video_ids: Optional[list] = None + + +class HotspotResponse(BaseModel): + id: str + name: str + video_ids: Optional[list] = None + + +# === UTILITIES === + +def calculate_similarity(str1: str, str2: str) -> float: + """Calcola similarità tra due stringhe (Levenshtein normalizzato)""" + if not str1 or not str2: + return 0.0 + + str1, str2 = str1.lower(), str2.lower() + + if str1 == str2: + return 1.0 + + len1, len2 = len(str1), len(str2) + if len1 == 0 or len2 == 0: + return 0.0 + + # Matrice per Levenshtein + matrix = [[0] * (len2 + 1) for _ in range(len1 + 1)] + + for i in range(len1 + 1): + matrix[i][0] = i + for j in range(len2 + 1): + matrix[0][j] = j + + for i in range(1, len1 + 1): + for j in range(1, len2 + 1): + cost = 0 if str1[i-1] == str2[j-1] else 1 + matrix[i][j] = min( + matrix[i-1][j] + 1, + matrix[i][j-1] + 1, + matrix[i-1][j-1] + cost + ) + + distance = matrix[len1][len2] + max_len = max(len1, len2) + return (max_len - distance) / max_len + + +def clean_text(text: str) -> str: + """Pulisce il testo OCR""" + import re + cleaned = text.lower().strip() + cleaned = re.sub(r'[{}§\[\]()@#$%^&*+=|\\<>?/~`!]', ' ', cleaned) + cleaned = re.sub(r'\s+', ' ', cleaned) + return cleaned.strip() + + +def find_best_match(text: str, threshold: float = 0.65) -> tuple: + """Trova il miglior match nel database""" + cleaned = clean_text(text) + words = cleaned.split() + + best_match = None + best_score = 0.0 + + for hotspot_id, hotspot in hotspots_db.items(): + name = hotspot.get("name", "").lower() if isinstance(hotspot, dict) else str(hotspot).lower() + + # Match esatto + if name == cleaned: + return hotspot_id, name, 1.0 + + # Substring match + if name in cleaned or cleaned in name: + return hotspot_id, name, 0.95 + + # Word match + name_words = name.split() + for word in words: + if len(word) < 2: + continue + for name_word in name_words: + if name_word == word: + return hotspot_id, name, 0.9 + + similarity = calculate_similarity(word, name_word) + if similarity > best_score: + best_score = similarity + best_match = (hotspot_id, name) + + if best_match and best_score >= threshold: + return best_match[0], best_match[1], best_score + + return None, None, 0.0 + + +# === ENDPOINTS === + +@app.get("/") +async def root(): + return { + "service": "Hotspot API", + "version": "1.0.0", + "hotspots_loaded": len(hotspots_db) + } + + +@app.get("/health") +async def health(): + return {"status": "healthy", "database_loaded": len(hotspots_db) > 0} + + +@app.get("/hotspots", response_model=list[HotspotResponse]) +async def get_hotspots(): + """Restituisce tutti gli hotspot""" + result = [] + for hotspot_id, hotspot in hotspots_db.items(): + name = hotspot.get("name", hotspot) if isinstance(hotspot, dict) else str(hotspot) + result.append(HotspotResponse( + id=hotspot_id, + name=name, + video_ids=video_mapping.get(hotspot_id) + )) + return result + + +@app.get("/hotspots/{hotspot_id}", response_model=HotspotResponse) +async def get_hotspot(hotspot_id: str): + """Restituisce un singolo hotspot""" + if hotspot_id not in hotspots_db: + raise HTTPException(status_code=404, detail="Hotspot non trovato") + + hotspot = hotspots_db[hotspot_id] + name = hotspot.get("name", hotspot) if isinstance(hotspot, dict) else str(hotspot) + + return HotspotResponse( + id=hotspot_id, + name=name, + video_ids=video_mapping.get(hotspot_id) + ) + + +@app.post("/match", response_model=MatchResponse) +async def match_text(request: MatchRequest): + """Cerca corrispondenza per il testo fornito""" + if not request.text.strip(): + return MatchResponse(found=False) + + hotspot_id, name, confidence = find_best_match(request.text) + + if hotspot_id: + return MatchResponse( + found=True, + hotspot_id=hotspot_id, + name=name, + confidence=round(confidence, 2), + video_ids=video_mapping.get(hotspot_id) + ) + + return MatchResponse(found=False) + + +@app.post("/reload") +async def reload_database(): + """Ricarica il database dal file JSON""" + load_database() + return {"status": "reloaded", "hotspots_count": len(hotspots_db)} diff --git a/data/hotspots.json b/data/hotspots.json new file mode 100644 index 0000000..adff21e --- /dev/null +++ b/data/hotspots.json @@ -0,0 +1,134 @@ +{ + "hotspots": { + "1": {"name": "Brussels"}, + "2": {"name": "Strasbourg"}, + "3": {"name": "Luxembourg"}, + "4": {"name": "Vienna"}, + "5": {"name": "Frankfurt am Main"}, + "8": {"name": "Parma"}, + "9": {"name": "Warsaw"}, + "10": {"name": "Copenhagen"}, + "12": {"name": "Lisbon"}, + "13": {"name": "Helsinki"}, + "14": {"name": "Cologne"}, + "15": {"name": "Vilnius"}, + "17": {"name": "Munich"}, + "21": {"name": "Heraklion"}, + "27": {"name": "Dublin"}, + "28": {"name": "The Hague"}, + "29": {"name": "Vigo"}, + "30": {"name": "Stockholm"}, + "38": {"name": "L'Aquila"}, + "39": {"name": "Valletta"}, + "40": {"name": "Palermo"}, + "44": {"name": "Paris"}, + "46": {"name": "Kourou"}, + "49": {"name": "Berlin"}, + "51": {"name": "Bolzano"}, + "53": {"name": "Cork"}, + "61": {"name": "Östersund"}, + "62": {"name": "Jukkasjärvi"}, + "67": {"name": "Kuopio"}, + "68": {"name": "Gdańsk"}, + "69": {"name": "Wrocław"}, + "71": {"name": "Poznan"}, + "74": {"name": "Riga"}, + "75": {"name": "Prague"}, + "77": {"name": "Poprad"}, + "78": {"name": "Budapest"}, + "82": {"name": "Bistrita"}, + "83": {"name": "Tulcea"}, + "84": {"name": "Belene"}, + "86": {"name": "Sofia"}, + "89": {"name": "Athens"}, + "90": {"name": "Nicosia"}, + "92": {"name": "Ljubljana"}, + "95": {"name": "Barcelona"}, + "98": {"name": "Dubrovnik"}, + "101": {"name": "North Sea"}, + "102": {"name": "Mediterranean Sea"}, + "108": {"name": "Polar Region"}, + "109": {"name": "Lesbos"}, + "110": {"name": "Madeira"}, + "111": {"name": "Aarhus"}, + "112": {"name": "Vrchlabí"}, + "114": {"name": "Atlantic Ocean"}, + "115": {"name": "Hiiumaa"}, + "116": {"name": "Tallinn"}, + "117": {"name": "Almería"}, + "118": {"name": "Amsterdam"}, + "129": {"name": "Bazoches"}, + "130": {"name": "Rome"}, + "131": {"name": "Sulmona"}, + "132": {"name": "Madrid"}, + "134": {"name": "Bucharest"}, + "137": {"name": "Zagreb"}, + "138": {"name": "Bratislava"} + }, + "video_mapping": { + "1": [133], + "2": [2, 168], + "3": [163], + "4": [154], + "5": [5], + "8": [8], + "9": [157], + "10": [131], + "12": [12], + "13": [13, 129], + "14": [14], + "15": [15], + "17": [17], + "21": [21], + "27": [27], + "28": [28], + "29": [29], + "30": [30], + "38": [38], + "39": [39], + "40": [40], + "44": [44, 128], + "46": [46], + "49": [49], + "51": [51], + "53": [53], + "61": [61], + "62": [62], + "67": [67], + "68": [68], + "69": [69], + "71": [71], + "74": [74], + "75": [75], + "77": [77], + "78": [78], + "82": [82], + "83": [83], + "84": [84], + "86": [86], + "89": [89], + "90": [90], + "92": [92], + "95": [95], + "98": [98], + "101": [101], + "102": [102], + "108": [108], + "109": [109], + "110": [110], + "111": [111], + "112": [112], + "114": [114], + "115": [115], + "116": [116], + "117": [117], + "118": [118], + "129": [129], + "130": [130], + "131": [131], + "132": [132], + "134": [134], + "137": [137], + "138": [138] + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8cd5a25 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6