|
import os |
|
import logging |
|
import uvicorn |
|
from fastapi import FastAPI, HTTPException |
|
from pydantic import BaseModel |
|
import rdflib |
|
from rdflib.plugins.sparql.parser import parseQuery |
|
from huggingface_hub import InferenceClient |
|
import re |
|
|
|
|
|
|
|
|
|
logging.basicConfig( |
|
level=logging.DEBUG, |
|
format="%(asctime)s - %(levelname)s - %(message)s", |
|
handlers=[logging.FileHandler("app.log"), logging.StreamHandler()] |
|
) |
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
|
|
|
HF_API_KEY = os.getenv("HF_API_KEY") |
|
if not HF_API_KEY: |
|
|
|
logger.error("HF_API_KEY non impostata.") |
|
raise EnvironmentError("HF_API_KEY non impostata.") |
|
|
|
|
|
HF_MODEL = "meta-llama/Llama-3.3-70B-Instruct" |
|
|
|
|
|
LANG_DETECT_MODEL = "papluca/xlm-roberta-base-language-detection" |
|
|
|
|
|
TRANSLATOR_MODEL_PREFIX = "Helsinki-NLP/opus-mt" |
|
|
|
|
|
|
|
|
|
""" |
|
Qui inizializziamo i client necessari. In questo modo, evitiamo di istanziare |
|
continuamente nuovi oggetti InferenceClient a ogni chiamata delle funzioni. |
|
|
|
- hf_generation_client: per generare query SPARQL e risposte stile "guida museale" |
|
- lang_detect_client: per rilevare la lingua della domanda e della risposta |
|
""" |
|
try: |
|
logger.info("[Startup] Inizializzazione client HF per generazione (modello di LLM).") |
|
hf_generation_client = InferenceClient( |
|
token=HF_API_KEY, |
|
model=HF_MODEL |
|
) |
|
logger.info("[Startup] Inizializzazione client HF per rilevamento lingua.") |
|
lang_detect_client = InferenceClient( |
|
token=HF_API_KEY, |
|
model=LANG_DETECT_MODEL |
|
) |
|
except Exception as ex: |
|
logger.error(f"Errore inizializzazione dei client Hugging Face: {ex}") |
|
raise HTTPException(status_code=500, detail="Impossibile inizializzare i modelli Hugging Face.") |
|
|
|
|
|
|
|
|
|
""" |
|
Carichiamo il file RDF/XML contenente l'ontologia del museo. Questo file è |
|
fondamentale per l'esecuzione di query SPARQL, in quanto definisce le classi, |
|
le proprietà e le istanze presenti nell'ontologia del museo. |
|
""" |
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) |
|
RDF_FILE = os.path.join(BASE_DIR, "Ontologia_corretto-2.rdf") |
|
|
|
ontology_graph = rdflib.Graph() |
|
try: |
|
logger.info(f"Caricamento ontologia da file: {RDF_FILE}") |
|
|
|
ontology_graph.parse(RDF_FILE, format="xml") |
|
logger.info("Ontologia RDF caricata correttamente (formato XML).") |
|
except Exception as e: |
|
logger.error(f"Errore nel caricamento dell'ontologia: {e}") |
|
raise e |
|
|
|
|
|
|
|
app = FastAPI() |
|
|
|
|
|
|
|
class AssistantRequest(BaseModel): |
|
""" |
|
Questo modello Pydantic definisce lo schema della richiesta che |
|
riceverà l'endpoint /assistant. Contiene: |
|
- message: la domanda del visitatore |
|
- max_tokens: max di token per le risposte (di default 512) |
|
- temperature: temperatura di generazione (di default 0.5) |
|
""" |
|
message: str |
|
max_tokens: int = 512 |
|
temperature: float = 0.5 |
|
|
|
|
|
|
|
|
|
|
|
def create_system_prompt_for_sparql(ontology_turtle: str) -> str: |
|
""" |
|
Genera il testo di prompt che istruisce il modello su come costruire |
|
SOLO UNA query SPARQL, in un'unica riga, o in alternativa 'NO_SPARQL' |
|
se la domanda non è pertinente all'ontologia. Il prompt include regole |
|
di formattazione e alcuni esempi di domanda-risposta SPARQL. |
|
|
|
Parametri: |
|
- ontology_turtle: una stringa con l'ontologia in formato Turtle (o simile). |
|
|
|
Ritorna: |
|
- Il testo da usare come "system prompt" per il modello generativo. |
|
""" |
|
prompt = f"""SEI UN GENERATORE DI QUERY SPARQL PER L'ONTOLOGIA DI UN MUSEO. |
|
DEVI GENERARE SOLO UNA QUERY SPARQL (IN UNA SOLA RIGA) SE LA DOMANDA RIGUARDA INFORMAZIONI NELL'ONTOLOGIA. |
|
SE LA DOMANDA NON È ATTINENTE, RISPONDI 'NO_SPARQL'. |
|
|
|
REGOLE SINTATTICHE RIGOROSE: |
|
1) Usare: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> |
|
2) Query in UNA SOLA RIGA (niente a capo), forma: PREFIX progettoMuseo: <...> SELECT ?x WHERE {{ ... }} LIMIT N |
|
3) Attento agli spazi: |
|
- Dopo SELECT: es. SELECT ?autore |
|
- Tra proprietà e variabile: es. progettoMuseo:autoreOpera ?autore . |
|
- Non incollare il '?' a 'progettoMuseo:'. |
|
- Ogni tripla termina con un punto. |
|
4) Se non puoi generare una query valida, rispondi solo 'NO_SPARQL'. |
|
|
|
Esempi di Domande Specifiche e relative Query: |
|
1) Utente: Chi ha creato l'opera 'Afrodite di Milo'? |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?autore WHERE {{ progettoMuseo:AfroditeDiMilo progettoMuseo:autoreOpera ?autore . }} LIMIT 10 |
|
|
|
2) Utente: Quali sono le tecniche utilizzate nelle opere? |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?opera ?tecnica WHERE {{ ?opera progettoMuseo:tecnicaOpera ?tecnica . }} LIMIT 100 |
|
|
|
3) Utente: Quali sono le dimensioni delle opere? |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?opera ?dimensione WHERE {{ ?opera progettoMuseo:dimensioneOpera ?dimensione . }} LIMIT 100 |
|
|
|
4) Utente: Quali opere sono esposte nella stanza Greca? |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?opera WHERE {{ progettoMuseo:StanzaGrecia progettoMuseo:Espone ?opera . }} LIMIT 100 |
|
|
|
5) Utente: Quali sono le proprietà e i tipi delle proprietà nell'ontologia? |
|
Risposta: PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> PREFIX owl: <http://www.w3.org/2002/07/owl#> PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT DISTINCT ?property ?type WHERE {{ ?property rdf:type ?type . FILTER(?type IN (owl:ObjectProperty, owl:DatatypeProperty)) }} |
|
|
|
6) Utente: Recupera tutti i biglietti e i tipi di biglietto. |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?biglietto ?tipoBiglietto WHERE {{ ?biglietto rdf:type progettoMuseo:Biglietto . ?biglietto progettoMuseo:tipoBiglietto ?tipoBiglietto . }} LIMIT 100 |
|
|
|
7) Utente: Recupera tutti i visitatori e i tour a cui partecipano. |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?visitatore ?tour WHERE {{ ?visitatore progettoMuseo:Partecipazione_a_Evento ?tour . }} LIMIT 100 |
|
|
|
8) Utente: Recupera tutte le stanze tematiche e le opere esposte. |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?stanza ?opera WHERE {{ ?stanza rdf:type progettoMuseo:Stanza_Tematica . ?stanza progettoMuseo:Espone ?opera . }} LIMIT 100 |
|
|
|
9) Utente: Recupera tutte le opere con materiale 'Marmo'. |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?opera WHERE {{ ?opera progettoMuseo:materialeOpera "Marmo"@it . }} LIMIT 100 |
|
|
|
10) Utente: Recupera tutti i visitatori con data di nascita dopo il 2000. |
|
Risposta: PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> SELECT ?visitatore WHERE {{ ?visitatore rdf:type progettoMuseo:Visitatore_Individuale . ?visitatore progettoMuseo:dataDiNascitaVisitatore ?data . FILTER(?data > "2000-01-01T00:00:00"^^xsd:dateTime) . }} LIMIT 100 |
|
|
|
ECCO L'ONTOLOGIA (TURTLE) PER CONTESTO: |
|
{ontology_turtle} |
|
FINE ONTOLOGIA. |
|
""" |
|
logger.debug("[create_system_prompt_for_sparql] Prompt generato con ESEMPI e regole SPARQL.") |
|
return prompt |
|
|
|
|
|
def classify_and_translate(question_text: str, model_answer_text: str) -> str: |
|
""" |
|
Classifica la lingua della domanda e della risposta, quindi traduce la risposta |
|
se la lingua è diversa da quella della domanda. L'idea è di restituire una |
|
risposta nella stessa lingua dell'utente. |
|
|
|
Parametri: |
|
- question_text: Testo della domanda dell'utente. |
|
- model_answer_text: Risposta del modello (in qualsiasi lingua). |
|
|
|
Restituisce: |
|
- La risposta tradotta nella lingua della domanda o la risposta originale |
|
se entrambe le lingue coincidono. |
|
|
|
NB: Qui l'oggetto 'lang_detect_client' (per rilevamento lingua) è già |
|
stato inizializzato all'avvio dell'app. Mentre il 'translator_client' |
|
viene creato 'al volo' poiché la direzione di traduzione dipende |
|
dalle due lingue effettive. |
|
""" |
|
|
|
try: |
|
question_lang_result = lang_detect_client.text_classification(text=question_text) |
|
question_lang = question_lang_result[0]['label'] |
|
logger.info(f"[LangDetect] Lingua della domanda: {question_lang}") |
|
except Exception as e: |
|
logger.error(f"Errore nel rilevamento della lingua della domanda: {e}") |
|
question_lang = "en" |
|
|
|
|
|
try: |
|
answer_lang_result = lang_detect_client.text_classification(text=model_answer_text) |
|
answer_lang = answer_lang_result[0]['label'] |
|
logger.info(f"[LangDetect] Lingua della risposta: {answer_lang}") |
|
except Exception as e: |
|
logger.error(f"Errore nel rilevamento della lingua della risposta: {e}") |
|
answer_lang = "it" |
|
|
|
|
|
if question_lang == answer_lang: |
|
logger.info("[Translate] Nessuna traduzione necessaria: stessa lingua.") |
|
return model_answer_text |
|
|
|
|
|
|
|
translator_model = f"{TRANSLATOR_MODEL_PREFIX}-{answer_lang}-{question_lang}" |
|
translator_client = InferenceClient( |
|
token=HF_API_KEY, |
|
model=translator_model |
|
) |
|
|
|
|
|
try: |
|
translation_result = translator_client.translation(text=model_answer_text) |
|
translated_answer = translation_result["translation_text"] |
|
logger.info("[Translate] Risposta tradotta con successo.") |
|
except Exception as e: |
|
logger.error(f"Errore nella traduzione {answer_lang} -> {question_lang}: {e}") |
|
|
|
translated_answer = model_answer_text |
|
|
|
return translated_answer |
|
|
|
|
|
def create_system_prompt_for_guide() -> str: |
|
""" |
|
Genera un testo di prompt che istruisce il modello a rispondere |
|
come "guida museale virtuale", in modo breve (~50 parole), riassumendo |
|
i risultati SPARQL (se presenti) o fornendo comunque una risposta |
|
in base alle conoscenze pregresse. |
|
""" |
|
prompt = ( |
|
"SEI UNA GUIDA MUSEALE VIRTUALE. " |
|
"RISPONDI IN MODO BREVE (~50 PAROLE), SENZA SALUTI O INTRODUZIONI PROLISSE. " |
|
"SE HAI RISULTATI SPARQL, USALI. " |
|
"SE NON HAI RISULTATI O NON HAI UNA QUERY, RISPONDI COMUNQUE CERCANDO DI RIARRANGIARE LE TUE CONOSCENZE." |
|
) |
|
logger.debug("[create_system_prompt_for_guide] Prompt per la risposta guida museale generato.") |
|
return prompt |
|
|
|
|
|
def correct_sparql_syntax_advanced(query: str) -> str: |
|
""" |
|
Applica correzioni sintattiche (euristiche) su una query SPARQL eventualmente |
|
mal formattata, generata dal modello. |
|
Passi: |
|
1. Rimuove newline. |
|
2. Verifica l'esistenza di 'PREFIX progettoMuseo:' e lo aggiunge se mancante. |
|
3. Inserisce spazi dopo SELECT, WHERE (se mancanti). |
|
4. Se c'è 'progettoMuseo:autoreOpera?autore' lo trasforma in 'progettoMuseo:autoreOpera ?autore'. |
|
5. Rimuove spazi multipli. |
|
6. Aggiunge '.' prima di '}' se manca. |
|
7. Aggiunge la clausola WHERE se non presente. |
|
|
|
Parametri: |
|
- query: stringa con la query SPARQL potenzialmente mal formattata. |
|
|
|
Ritorna: |
|
- La query SPARQL corretta se possibile, in singola riga. |
|
""" |
|
original_query = query |
|
logger.debug(f"[correct_sparql_syntax_advanced] Query originaria:\n{original_query}") |
|
|
|
|
|
query = query.replace('\n', ' ').replace('\r', ' ') |
|
|
|
|
|
if 'PREFIX progettoMuseo:' not in query: |
|
logger.debug("[correct_sparql_syntax_advanced] Aggiungo PREFIX progettoMuseo.") |
|
query = ( |
|
"PREFIX progettoMuseo: <http://www.semanticweb.org/lucreziamosca/ontologies/progettoMuseo#> " |
|
+ query |
|
) |
|
|
|
|
|
query = re.sub(r'(SELECT)(\?|\*)', r'\1 \2', query, flags=re.IGNORECASE) |
|
|
|
|
|
query = re.sub(r'(WHERE)\{', r'\1 {', query, flags=re.IGNORECASE) |
|
|
|
|
|
query = re.sub(r'(progettoMuseo:\w+)\?(\w+)', r'\1 ?\2', query) |
|
|
|
|
|
query = re.sub(r'\s+', ' ', query).strip() |
|
|
|
|
|
query = re.sub(r'(\?\w+)\s*\}', r'\1 . }', query) |
|
|
|
|
|
if 'WHERE' not in query.upper(): |
|
query = re.sub(r'(SELECT\s+[^\{]+)\{', r'\1 WHERE {', query, flags=re.IGNORECASE) |
|
|
|
|
|
query = re.sub(r'\s+', ' ', query).strip() |
|
|
|
logger.debug(f"[correct_sparql_syntax_advanced] Query dopo correzioni:\n{query}") |
|
return query |
|
|
|
|
|
def is_sparql_query_valid(query: str) -> bool: |
|
""" |
|
Verifica la validità sintattica di una query SPARQL usando rdflib. |
|
Ritorna True se la query è sintatticamente corretta, False altrimenti. |
|
""" |
|
logger.debug(f"[is_sparql_query_valid] Validazione SPARQL: {query}") |
|
try: |
|
parseQuery(query) |
|
logger.debug("[is_sparql_query_valid] Query SPARQL sintatticamente corretta.") |
|
return True |
|
except Exception as ex: |
|
logger.warning(f"[is_sparql_query_valid] Query non valida: {ex}") |
|
return False |
|
|
|
|
|
|
|
|
|
@app.post("/assistant") |
|
def assistant_endpoint(req: AssistantRequest): |
|
""" |
|
Endpoint che gestisce l'intera pipeline: |
|
1) Genera una query SPARQL dal messaggio dell'utente (prompt dedicato). |
|
2) Verifica la validità della query e, se valida, la esegue sull'ontologia RDF. |
|
3) Crea un "prompt da guida museale" e genera una risposta finale breve (max ~50 parole). |
|
4) Eventualmente, traduce la risposta nella lingua dell'utente. |
|
|
|
Parametri: |
|
- req (AssistantRequest): un oggetto contenente: |
|
- message (str): la domanda dell'utente |
|
- max_tokens (int, opzionale): numero massimo di token per la generazione |
|
- temperature (float, opzionale): temperatura per la generazione |
|
|
|
Ritorna: |
|
- Un JSON con: |
|
{ |
|
"query": <la query SPARQL generata o None>, |
|
"response": <la risposta finale in linguaggio naturale> |
|
} |
|
""" |
|
logger.info("Ricevuta chiamata POST su /assistant") |
|
|
|
|
|
user_message = req.message |
|
max_tokens = req.max_tokens |
|
temperature = req.temperature |
|
|
|
logger.debug(f"Parametri utente: message='{user_message}', max_tokens={max_tokens}, temperature={temperature}") |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
ontology_turtle = ontology_graph.serialize(format="xml") |
|
logger.debug("Ontologia serializzata con successo (XML).") |
|
except Exception as e: |
|
logger.warning(f"Impossibile serializzare l'ontologia in formato XML: {e}") |
|
ontology_turtle = "" |
|
|
|
|
|
system_prompt_sparql = create_system_prompt_for_sparql(ontology_turtle) |
|
|
|
|
|
try: |
|
logger.debug("[assistant_endpoint] Chiamata HF per generare la query SPARQL...") |
|
gen_sparql_output = hf_generation_client.chat.completions.create( |
|
messages=[ |
|
{"role": "system", "content": system_prompt_sparql}, |
|
{"role": "user", "content": user_message} |
|
], |
|
max_tokens=512, |
|
temperature=0.2 |
|
) |
|
possible_query = gen_sparql_output["choices"][0]["message"]["content"].strip() |
|
logger.info(f"[assistant_endpoint] Query generata dal modello: {possible_query}") |
|
except Exception as ex: |
|
logger.error(f"Errore nella generazione della query SPARQL: {ex}") |
|
|
|
possible_query = "NO_SPARQL" |
|
|
|
|
|
if possible_query.upper().startswith("NO_SPARQL"): |
|
generated_query = None |
|
logger.debug("[assistant_endpoint] Modello indica 'NO_SPARQL', quindi nessuna query generata.") |
|
else: |
|
|
|
advanced_corrected = correct_sparql_syntax_advanced(possible_query) |
|
|
|
if is_sparql_query_valid(advanced_corrected): |
|
generated_query = advanced_corrected |
|
logger.debug(f"[assistant_endpoint] Query SPARQL valida dopo correzione avanzata: {generated_query}") |
|
else: |
|
logger.debug("[assistant_endpoint] Query SPARQL non valida. Verrà ignorata.") |
|
generated_query = None |
|
|
|
|
|
|
|
|
|
results = [] |
|
if generated_query: |
|
logger.debug(f"[assistant_endpoint] Esecuzione della query SPARQL:\n{generated_query}") |
|
try: |
|
query_result = ontology_graph.query(generated_query) |
|
results = list(query_result) |
|
logger.info(f"[assistant_endpoint] Query eseguita con successo. Numero risultati = {len(results)}") |
|
except Exception as ex: |
|
logger.error(f"[assistant_endpoint] Errore nell'esecuzione della query: {ex}") |
|
results = [] |
|
|
|
|
|
|
|
|
|
system_prompt_guide = create_system_prompt_for_guide() |
|
|
|
if generated_query and results: |
|
|
|
|
|
results_str = "\n".join( |
|
f"{idx+1}) " + ", ".join(f"{var}={row[var]}" for var in row.labels) |
|
for idx, row in enumerate(results) |
|
) |
|
second_prompt = ( |
|
f"{system_prompt_guide}\n\n" |
|
f"Domanda utente: {user_message}\n" |
|
f"Query generata: {generated_query}\n" |
|
f"Risultati:\n{results_str}\n" |
|
"Rispondi in modo breve (max ~50 parole)." |
|
) |
|
logger.debug("[assistant_endpoint] Prompt di risposta con risultati SPARQL.") |
|
|
|
elif generated_query and not results: |
|
|
|
second_prompt = ( |
|
f"{system_prompt_guide}\n\n" |
|
f"Domanda utente: {user_message}\n" |
|
f"Query generata: {generated_query}\n" |
|
"Nessun risultato dalla query. Prova comunque a rispondere con le tue conoscenze." |
|
) |
|
logger.debug("[assistant_endpoint] Prompt di risposta: query valida ma senza risultati.") |
|
|
|
else: |
|
|
|
second_prompt = ( |
|
f"{system_prompt_guide}\n\n" |
|
f"Domanda utente: {user_message}\n" |
|
"Nessuna query SPARQL generata. Rispondi come puoi, riarrangiando le tue conoscenze." |
|
) |
|
logger.debug("[assistant_endpoint] Prompt di risposta: nessuna query generata.") |
|
|
|
|
|
try: |
|
logger.debug("[assistant_endpoint] Chiamata HF per generare la risposta finale...") |
|
final_output = hf_generation_client.chat.completions.create( |
|
messages=[ |
|
{"role": "system", "content": second_prompt}, |
|
{"role": "user", "content": "Fornisci la risposta finale."} |
|
], |
|
max_tokens=max_tokens, |
|
temperature=temperature |
|
) |
|
final_answer = final_output["choices"][0]["message"]["content"].strip() |
|
logger.info(f"[assistant_endpoint] Risposta finale generata: {final_answer}") |
|
except Exception as ex: |
|
logger.error(f"Errore nella generazione della risposta finale: {ex}") |
|
raise HTTPException(status_code=500, detail="Errore nella generazione della risposta in linguaggio naturale.") |
|
|
|
|
|
|
|
|
|
final_ans = classify_and_translate(user_message, final_answer) |
|
final_ans = final_ans.replace('\\"', "").replace('\"', "") |
|
|
|
|
|
|
|
logger.debug("[assistant_endpoint] Fine elaborazione, restituzione risposta JSON.") |
|
return { |
|
"query": generated_query, |
|
"response": final_ans |
|
} |
|
|
|
|
|
|
|
|
|
@app.get("/") |
|
def home(): |
|
""" |
|
Endpoint di test per verificare se l'applicazione è in esecuzione. |
|
""" |
|
logger.debug("Chiamata GET su '/' - home.") |
|
return { |
|
"message": "Endpoint attivo. Esempio di backend per generare query SPARQL e risposte guida museale." |
|
} |
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
""" |
|
Avvio dell'applicazione FastAPI sulla porta 8000, |
|
utile se eseguito come script principale. |
|
""" |
|
logger.info("Avvio dell'applicazione FastAPI.") |