Initial commit
This commit is contained in:
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -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"]
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -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.
|
||||||
240
app/main.py
Normal file
240
app/main.py
Normal file
@@ -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)}
|
||||||
134
data/hotspots.json
Normal file
134
data/hotspots.json
Normal file
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.109.0
|
||||||
|
uvicorn[standard]==0.27.0
|
||||||
|
python-multipart==0.0.6
|
||||||
Reference in New Issue
Block a user