Commit ·
c1b1880
1
Parent(s): 2fe50b2
riviste le varie sezioni e i commenti
Browse files- api.py +48 -18
- data/ontologie_raw/ARCO/ArCo.owl +0 -0
- data/schemas/ARCO_schema.json +0 -0
- data/schemas/arco_schema.json +0 -42
- docs/graph.png +0 -3
- docs/validation.png +0 -3
- docs/workflow.png +0 -3
- src/extraction/extractor.py +50 -39
- src/graph/entity_resolver.py +34 -22
- src/graph/graph_loader.py +51 -47
- src/ingestion/semantic_splitter.py +33 -19
- src/utils/build_schema.py +89 -32
- src/validation/shapes/schema_constraints.ttl +16 -8
- src/validation/validator.py +30 -16
api.py
CHANGED
|
@@ -4,6 +4,7 @@ import uvicorn
|
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
import hashlib
|
|
|
|
| 7 |
|
| 8 |
from src.ingestion.semantic_splitter import ActivaSemanticSplitter
|
| 9 |
from src.extraction.extractor import NeuroSymbolicExtractor
|
|
@@ -11,27 +12,45 @@ from src.validation.validator import SemanticValidator
|
|
| 11 |
from src.graph.graph_loader import KnowledgeGraphPersister
|
| 12 |
from src.graph.entity_resolver import EntityResolver
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
app = FastAPI(
|
| 15 |
title="Automated Semantic Discovery API",
|
| 16 |
description="Endpoint per l'ingestion testuale e l'estrazione neuro-simbolica",
|
| 17 |
-
version="1.0"
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
-
# Struttura del JSON in ingresso
|
| 21 |
class DiscoveryRequest(BaseModel):
|
| 22 |
documentText: str
|
| 23 |
|
| 24 |
-
# Carico i pesi dei modelli all'avvio del server (Warm-up)
|
| 25 |
-
print("⏳ Inizializzazione modelli (SentenceTransformers e Llama3)...")
|
| 26 |
-
splitter = ActivaSemanticSplitter(model_name="all-MiniLM-L6-v2")
|
| 27 |
-
schema_path = os.path.join("data", "schemas", "ARCO_schema.json")
|
| 28 |
-
extractor = NeuroSymbolicExtractor(model_name="llama3", schema_path=schema_path)
|
| 29 |
-
persister = KnowledgeGraphPersister()
|
| 30 |
-
resolver = EntityResolver(neo4j_driver=persister.driver, similarity_threshold=0.85)
|
| 31 |
-
validator = SemanticValidator()
|
| 32 |
-
print("✅ Modelli caricati e pronti a ricevere richieste!")
|
| 33 |
-
|
| 34 |
-
# Endpoint principale
|
| 35 |
@app.post("/api/discover")
|
| 36 |
def run_discovery(payload: DiscoveryRequest):
|
| 37 |
start_time = time.time()
|
|
@@ -40,10 +59,19 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 40 |
if not raw_text or not raw_text.strip():
|
| 41 |
raise HTTPException(status_code=400, detail="Il testo fornito è vuoto.")
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
# --- FASE 1: INGESTION ---
|
|
|
|
| 44 |
chunks, _, _ = splitter.create_chunks(raw_text, percentile_threshold=90)
|
| 45 |
|
| 46 |
# --- FASE 2: EXTRACTION ---
|
|
|
|
| 47 |
all_triples = []
|
| 48 |
all_entities = []
|
| 49 |
for i, chunk in enumerate(chunks):
|
|
@@ -60,10 +88,11 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 60 |
return {
|
| 61 |
"status": "success",
|
| 62 |
"message": "Nessuna entità trovata.",
|
| 63 |
-
"graph_data": []
|
| 64 |
}
|
| 65 |
|
| 66 |
# --- FASE 2.1: SYMBOLIC RESOLUTION ---
|
|
|
|
| 67 |
entities_to_save = []
|
| 68 |
try:
|
| 69 |
all_entities, all_triples, entities_to_save = resolver.resolve_entities(all_entities, all_triples)
|
|
@@ -71,10 +100,11 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 71 |
print(f"⚠️ Errore nel resolver (skip): {e}")
|
| 72 |
|
| 73 |
# --- FASE 2.2: VALIDATION ---
|
|
|
|
|
|
|
| 74 |
is_valid, report, _ = validator.validate_batch(entities_to_save, all_triples)
|
| 75 |
if not is_valid:
|
| 76 |
print("\n❌ [SHACL VALIDATION FAILED] Rilevate entità o relazioni non conformi all'ontologia:")
|
| 77 |
-
# Il report di pyshacl contiene già l'elenco esatto dei nodi e delle regole violate
|
| 78 |
print(report)
|
| 79 |
print("-" * 60)
|
| 80 |
else:
|
|
@@ -83,10 +113,10 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 83 |
# --- FASE 3: PERSISTENCE (Neo4j) ---
|
| 84 |
try:
|
| 85 |
persister.save_entities_and_triples(entities_to_save, all_triples)
|
| 86 |
-
persister.close()
|
| 87 |
except Exception as e:
|
| 88 |
print(f"⚠️ Errore salvataggio Neo4j: {e}")
|
| 89 |
|
|
|
|
| 90 |
graph_data = []
|
| 91 |
for t in all_triples:
|
| 92 |
subj = getattr(t, 'subject', t[0] if isinstance(t, tuple) else str(t))
|
|
@@ -102,7 +132,7 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 102 |
pred_str = str(pred)
|
| 103 |
obj_str = str(obj)
|
| 104 |
|
| 105 |
-
# Genero un ID
|
| 106 |
node_id = hashlib.md5(subj_str.encode('utf-8')).hexdigest()
|
| 107 |
|
| 108 |
graph_data.append({
|
|
@@ -124,4 +154,4 @@ def run_discovery(payload: DiscoveryRequest):
|
|
| 124 |
}
|
| 125 |
|
| 126 |
if __name__ == "__main__":
|
| 127 |
-
uvicorn.run(app, host="0.0.0.0", port=5000)
|
|
|
|
| 4 |
import os
|
| 5 |
import time
|
| 6 |
import hashlib
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
|
| 9 |
from src.ingestion.semantic_splitter import ActivaSemanticSplitter
|
| 10 |
from src.extraction.extractor import NeuroSymbolicExtractor
|
|
|
|
| 12 |
from src.graph.graph_loader import KnowledgeGraphPersister
|
| 13 |
from src.graph.entity_resolver import EntityResolver
|
| 14 |
|
| 15 |
+
# --- GESTORE DEGLI STATI GLOBALI ---
|
| 16 |
+
# Usiamo un dizionario globale per tenere in RAM i pesi dei modelli.
|
| 17 |
+
ml_models = {}
|
| 18 |
+
|
| 19 |
+
@asynccontextmanager
|
| 20 |
+
async def lifespan(app: FastAPI):
|
| 21 |
+
# Nel mondo FastAPI il lifespan è il modo più pulito per fare il setup.
|
| 22 |
+
# Mi permette di caricare i modelli di embedding e l'LLM all'avvio del worker, una sola volta.
|
| 23 |
+
print("⏳ Inizializzazione modelli (SentenceTransformers e Llama3) nel Lifespan...")
|
| 24 |
+
|
| 25 |
+
ml_models["splitter"] = ActivaSemanticSplitter(model_name="all-MiniLM-L6-v2")
|
| 26 |
+
|
| 27 |
+
schema_path = os.path.join("data", "schemas", "ARCO_schema.json")
|
| 28 |
+
ml_models["extractor"] = NeuroSymbolicExtractor(model_name="llama3", schema_path=schema_path)
|
| 29 |
+
|
| 30 |
+
ml_models["persister"] = KnowledgeGraphPersister()
|
| 31 |
+
ml_models["resolver"] = EntityResolver(neo4j_driver=ml_models["persister"].driver, similarity_threshold=0.85)
|
| 32 |
+
ml_models["validator"] = SemanticValidator()
|
| 33 |
+
|
| 34 |
+
print("✅ Modelli caricati e pronti a ricevere richieste!")
|
| 35 |
+
|
| 36 |
+
yield # Qui l'API inizia ad ascoltare le chiamate in ingresso
|
| 37 |
+
|
| 38 |
+
# Chiusura pulita delle connessioni. Evita query appese su Neo4j quando killiamo il container.
|
| 39 |
+
print("🛑 Spegnimento in corso... chiusura connessioni e pulizia memoria.")
|
| 40 |
+
if "persister" in ml_models and ml_models["persister"]:
|
| 41 |
+
ml_models["persister"].close()
|
| 42 |
+
ml_models.clear()
|
| 43 |
+
|
| 44 |
app = FastAPI(
|
| 45 |
title="Automated Semantic Discovery API",
|
| 46 |
description="Endpoint per l'ingestion testuale e l'estrazione neuro-simbolica",
|
| 47 |
+
version="1.0",
|
| 48 |
+
lifespan=lifespan
|
| 49 |
)
|
| 50 |
|
|
|
|
| 51 |
class DiscoveryRequest(BaseModel):
|
| 52 |
documentText: str
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
@app.post("/api/discover")
|
| 55 |
def run_discovery(payload: DiscoveryRequest):
|
| 56 |
start_time = time.time()
|
|
|
|
| 59 |
if not raw_text or not raw_text.strip():
|
| 60 |
raise HTTPException(status_code=400, detail="Il testo fornito è vuoto.")
|
| 61 |
|
| 62 |
+
# Recupero le istanze
|
| 63 |
+
splitter = ml_models["splitter"]
|
| 64 |
+
extractor = ml_models["extractor"]
|
| 65 |
+
validator = ml_models["validator"]
|
| 66 |
+
resolver = ml_models["resolver"]
|
| 67 |
+
persister = ml_models["persister"]
|
| 68 |
+
|
| 69 |
# --- FASE 1: INGESTION ---
|
| 70 |
+
# Taglio il testo in modo semantico per non sforare la context window dell'LLM
|
| 71 |
chunks, _, _ = splitter.create_chunks(raw_text, percentile_threshold=90)
|
| 72 |
|
| 73 |
# --- FASE 2: EXTRACTION ---
|
| 74 |
+
# Invocazione del motore neuro-simbolico per ogni blocco di testo
|
| 75 |
all_triples = []
|
| 76 |
all_entities = []
|
| 77 |
for i, chunk in enumerate(chunks):
|
|
|
|
| 88 |
return {
|
| 89 |
"status": "success",
|
| 90 |
"message": "Nessuna entità trovata.",
|
| 91 |
+
"graph_data": []
|
| 92 |
}
|
| 93 |
|
| 94 |
# --- FASE 2.1: SYMBOLIC RESOLUTION ---
|
| 95 |
+
# Deduplica in RAM e linking verso Wikidata e Neo4j (Entity Resolution)
|
| 96 |
entities_to_save = []
|
| 97 |
try:
|
| 98 |
all_entities, all_triples, entities_to_save = resolver.resolve_entities(all_entities, all_triples)
|
|
|
|
| 100 |
print(f"⚠️ Errore nel resolver (skip): {e}")
|
| 101 |
|
| 102 |
# --- FASE 2.2: VALIDATION ---
|
| 103 |
+
# Prima di salvare nel DB, verifico con SHACL
|
| 104 |
+
# se l'LLM ha generato allucinazioni o violato i vincoli dell'ontologia.
|
| 105 |
is_valid, report, _ = validator.validate_batch(entities_to_save, all_triples)
|
| 106 |
if not is_valid:
|
| 107 |
print("\n❌ [SHACL VALIDATION FAILED] Rilevate entità o relazioni non conformi all'ontologia:")
|
|
|
|
| 108 |
print(report)
|
| 109 |
print("-" * 60)
|
| 110 |
else:
|
|
|
|
| 113 |
# --- FASE 3: PERSISTENCE (Neo4j) ---
|
| 114 |
try:
|
| 115 |
persister.save_entities_and_triples(entities_to_save, all_triples)
|
|
|
|
| 116 |
except Exception as e:
|
| 117 |
print(f"⚠️ Errore salvataggio Neo4j: {e}")
|
| 118 |
|
| 119 |
+
# Preparazione payload di risposta
|
| 120 |
graph_data = []
|
| 121 |
for t in all_triples:
|
| 122 |
subj = getattr(t, 'subject', t[0] if isinstance(t, tuple) else str(t))
|
|
|
|
| 132 |
pred_str = str(pred)
|
| 133 |
obj_str = str(obj)
|
| 134 |
|
| 135 |
+
# Genero un ID stabile per facilitare il rendering dei nodi lato client
|
| 136 |
node_id = hashlib.md5(subj_str.encode('utf-8')).hexdigest()
|
| 137 |
|
| 138 |
graph_data.append({
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
if __name__ == "__main__":
|
| 157 |
+
uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True)
|
data/ontologie_raw/ARCO/ArCo.owl
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/schemas/ARCO_schema.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
data/schemas/arco_schema.json
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
[
|
| 2 |
-
{
|
| 3 |
-
"id": "arco:CulturalProperty",
|
| 4 |
-
"type": "Class",
|
| 5 |
-
"description": "Qualsiasi bene culturale, materiale o immateriale. Include monumenti, reperti archeologici, statue, dipinti, edifici storici, strade antiche come la Via Appia."
|
| 6 |
-
},
|
| 7 |
-
{
|
| 8 |
-
"id": "cis:CulturalInstituteOrSite",
|
| 9 |
-
"type": "Class",
|
| 10 |
-
"description": "Un istituto o luogo della cultura. Include musei, archivi, biblioteche, parchi archeologici, complessi monumentali."
|
| 11 |
-
},
|
| 12 |
-
{
|
| 13 |
-
"id": "l0:Location",
|
| 14 |
-
"type": "Class",
|
| 15 |
-
"description": "Un'entità geografica o amministrativa. Include città, comuni, regioni, nazioni, fiumi, o aree topografiche."
|
| 16 |
-
},
|
| 17 |
-
{
|
| 18 |
-
"id": "core:Event",
|
| 19 |
-
"type": "Class",
|
| 20 |
-
"description": "Un evento storico, una battaglia, una mostra, una scoperta archeologica o una campagna di scavo."
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
"id": "a-loc:hasCurrentLocation",
|
| 24 |
-
"type": "Property",
|
| 25 |
-
"description": "Collega un bene culturale al luogo fisico o all'istituto (es. un museo) in cui è attualmente conservato o esposto."
|
| 26 |
-
},
|
| 27 |
-
{
|
| 28 |
-
"id": "core:hasPart",
|
| 29 |
-
"type": "Property",
|
| 30 |
-
"description": "Indica che un'entità contiene o è composta da un'altra entità. Utile per indicare che un museo contiene una collezione, o una città contiene un'area."
|
| 31 |
-
},
|
| 32 |
-
{
|
| 33 |
-
"id": "cis:hasSite",
|
| 34 |
-
"type": "Property",
|
| 35 |
-
"description": "Collega un istituto culturale (come un museo) alla sua sede fisica o al comune in cui si trova."
|
| 36 |
-
},
|
| 37 |
-
{
|
| 38 |
-
"id": "ti:atTime",
|
| 39 |
-
"type": "Property",
|
| 40 |
-
"description": "Collega un evento, una scoperta o un reperto alla sua epoca, data o periodo storico."
|
| 41 |
-
}
|
| 42 |
-
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/graph.png
DELETED
Git LFS Details
|
docs/validation.png
DELETED
Git LFS Details
|
docs/workflow.png
DELETED
Git LFS Details
|
src/extraction/extractor.py
CHANGED
|
@@ -12,10 +12,10 @@ from langchain_huggingface import HuggingFaceEmbeddings, ChatHuggingFace, Huggin
|
|
| 12 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
# --- DEFINIZIONE DELLO SCHEMA ---
|
| 19 |
class GraphTriple(BaseModel):
|
| 20 |
subject: str = Field(..., description="Entità sorgente.")
|
| 21 |
predicate: str = Field(..., description="Relazione (es. arco:hasCurrentLocation).")
|
|
@@ -28,13 +28,15 @@ class KnowledgeGraphExtraction(BaseModel):
|
|
| 28 |
entities: List[str] = Field(default_factory=list, description="TUTTE le entità estratte, incluse quelle isolate/orfane.")
|
| 29 |
triples: List[GraphTriple]
|
| 30 |
|
| 31 |
-
|
| 32 |
class NeuroSymbolicExtractor:
|
| 33 |
def __init__(self, model_name="llama3", temperature=0, schema_path=None):
|
| 34 |
|
| 35 |
hf_token = os.getenv("HF_TOKEN")
|
| 36 |
-
groq_api_key=os.getenv("GROQ_API_KEY")
|
| 37 |
|
|
|
|
|
|
|
| 38 |
if hf_token:
|
| 39 |
print("☁️ Rilevato ambiente Cloud (HF Spaces). Utilizzo HuggingFace Inference API.")
|
| 40 |
repo_id = "meta-llama/Meta-Llama-3-8B-Instruct"
|
|
@@ -58,7 +60,7 @@ class NeuroSymbolicExtractor:
|
|
| 58 |
self.llm = ChatGroq(
|
| 59 |
temperature=0,
|
| 60 |
model="llama-3.3-70b-versatile",
|
| 61 |
-
api_key=
|
| 62 |
)
|
| 63 |
except Exception as e:
|
| 64 |
print(f"❌ Errore Groq API {e}")
|
|
@@ -74,67 +76,71 @@ class NeuroSymbolicExtractor:
|
|
| 74 |
except Exception as e:
|
| 75 |
print(f"⚠️ Errore Ollama: {e}")
|
| 76 |
|
| 77 |
-
#
|
| 78 |
print("🧠 Caricamento modello embedding per Dynamic Selection...")
|
| 79 |
self.embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
| 80 |
|
| 81 |
-
# Caricamento vocabolario ontologico
|
| 82 |
self.ontology_elements = []
|
| 83 |
self.ontology_embeddings = None
|
| 84 |
|
|
|
|
| 85 |
if schema_path and os.path.exists(schema_path):
|
| 86 |
print(f"🌟 Indicizzazione vettoriale Ontologia da: {schema_path}")
|
| 87 |
self._index_ontology(schema_path)
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
|
|
|
|
| 91 |
Il tuo compito è analizzare il testo e generare un JSON contenente entità e relazioni.
|
| 92 |
|
| 93 |
REGOLE FONDAMENTALI:
|
| 94 |
-
1. Estrai TUTTI i concetti
|
| 95 |
-
2.
|
| 96 |
|
| 97 |
-
CLASSI CONSENTITE (
|
| 98 |
{retrieved_classes}
|
| 99 |
|
| 100 |
-
PROPRIETÀ CONSENTITE (
|
| 101 |
{retrieved_properties}
|
| 102 |
|
| 103 |
-
REGOLE DI
|
| 104 |
-
-
|
| 105 |
-
-
|
| 106 |
-
-
|
|
|
|
| 107 |
|
| 108 |
Rispondi SOLO ed ESCLUSIVAMENTE con un JSON valido strutturato così:
|
| 109 |
{{
|
| 110 |
"reasoning": "Breve logica delle estrazioni fatte...",
|
| 111 |
-
"entities": ["Entità 1", "Entità orfana"],
|
| 112 |
"triples": [
|
| 113 |
-
{{"subject": "Entità 1", "predicate": "rdf:type", "object": "
|
| 114 |
-
{{"subject": "Entità 1", "predicate": "
|
| 115 |
]
|
| 116 |
}}
|
| 117 |
"""
|
| 118 |
|
| 119 |
def _index_ontology(self, path: str):
|
|
|
|
| 120 |
try:
|
| 121 |
with open(path, 'r', encoding='utf-8') as f:
|
| 122 |
self.ontology_elements = json.load(f)
|
| 123 |
-
|
| 124 |
texts = [el['description'] for el in self.ontology_elements]
|
| 125 |
self.ontology_embeddings = self.embedding_model.embed_documents(texts)
|
| 126 |
print(f"✅ Indicizzati {len(self.ontology_elements)} elementi dell'ontologia.")
|
| 127 |
except Exception as e:
|
| 128 |
print(f"❌ Errore indicizzazione Ontologia: {e}")
|
| 129 |
|
| 130 |
-
def _retrieve_schema(self, query_text: str, top_k_classes=
|
|
|
|
| 131 |
if not self.ontology_elements or self.ontology_embeddings is None:
|
| 132 |
return "Nessuna classe specifica.", "skos:related"
|
| 133 |
|
| 134 |
query_embedding = self.embedding_model.embed_query(query_text)
|
| 135 |
similarities = cosine_similarity([query_embedding], self.ontology_embeddings)[0]
|
| 136 |
|
| 137 |
-
#
|
| 138 |
sorted_indices = np.argsort(similarities)[::-1]
|
| 139 |
|
| 140 |
classes = []
|
|
@@ -145,39 +151,43 @@ class NeuroSymbolicExtractor:
|
|
| 145 |
if element["type"] == "Class" and len(classes) < top_k_classes:
|
| 146 |
classes.append(f"- {element['id']}: {element['description']}")
|
| 147 |
elif element["type"] == "Property" and len(properties) < top_k_props:
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
return "\n".join(classes), "\n".join(properties)
|
| 151 |
|
|
|
|
| 152 |
def extract(self, text_chunk: str, source_id: str = "unknown", max_retries=3) -> KnowledgeGraphExtraction:
|
| 153 |
print(f"🧠 Processing {source_id} (Schema-RAG Mode)...")
|
| 154 |
|
| 155 |
-
# 1. Recupero dinamico
|
| 156 |
retrieved_classes, retrieved_properties = self._retrieve_schema(text_chunk)
|
| 157 |
|
| 158 |
-
# 2.
|
| 159 |
final_sys_text = self.system_template_base.format(
|
| 160 |
retrieved_classes=retrieved_classes,
|
| 161 |
retrieved_properties=retrieved_properties
|
| 162 |
)
|
| 163 |
|
| 164 |
sys_msg = SystemMessage(content=final_sys_text)
|
| 165 |
-
|
| 166 |
-
prompt = ChatPromptTemplate.from_messages([
|
| 167 |
-
sys_msg,
|
| 168 |
-
("human", "{text}")
|
| 169 |
-
])
|
| 170 |
-
|
| 171 |
chain = prompt | self.llm
|
| 172 |
|
| 173 |
for attempt in range(max_retries):
|
| 174 |
try:
|
| 175 |
response = chain.invoke({"text": text_chunk})
|
| 176 |
-
|
| 177 |
-
# Parsing della risposta (diversa tra Ollama e HF)
|
| 178 |
content = response.content
|
| 179 |
|
| 180 |
-
#
|
| 181 |
if "```json" in content:
|
| 182 |
content = content.split("```json")[1].split("```")[0].strip()
|
| 183 |
elif "```" in content:
|
|
@@ -188,11 +198,11 @@ class NeuroSymbolicExtractor:
|
|
| 188 |
|
| 189 |
data = json.loads(content)
|
| 190 |
|
| 191 |
-
#
|
| 192 |
if isinstance(data, list):
|
| 193 |
validated_data = KnowledgeGraphExtraction(triples=data, reasoning="Direct list output")
|
| 194 |
else:
|
| 195 |
-
#
|
| 196 |
triples = [GraphTriple(**t) for t in data.get("triples", [])]
|
| 197 |
validated_data = KnowledgeGraphExtraction(
|
| 198 |
reasoning=data.get("reasoning", "N/A"),
|
|
@@ -208,7 +218,8 @@ class NeuroSymbolicExtractor:
|
|
| 208 |
except (json.JSONDecodeError, ValidationError) as e:
|
| 209 |
print(f"⚠️ Errore Validazione (Tentativo {attempt+1}/{max_retries}): {e}")
|
| 210 |
|
| 211 |
-
# SELF-CORRECTION LOOP
|
|
|
|
| 212 |
prev_content = locals().get('content', 'No content')
|
| 213 |
|
| 214 |
correction_prompt = ChatPromptTemplate.from_messages([
|
|
|
|
| 12 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
|
| 15 |
+
# Carico le variabili d'ambiente. Su HF Spaces non trova il .env ma pesca in automatico dai secrets.
|
| 16 |
+
load_dotenv()
|
| 17 |
|
| 18 |
+
# Modelli Pydantic per blindare l'output dell'LLM.
|
|
|
|
|
|
|
| 19 |
class GraphTriple(BaseModel):
|
| 20 |
subject: str = Field(..., description="Entità sorgente.")
|
| 21 |
predicate: str = Field(..., description="Relazione (es. arco:hasCurrentLocation).")
|
|
|
|
| 28 |
entities: List[str] = Field(default_factory=list, description="TUTTE le entità estratte, incluse quelle isolate/orfane.")
|
| 29 |
triples: List[GraphTriple]
|
| 30 |
|
| 31 |
+
|
| 32 |
class NeuroSymbolicExtractor:
|
| 33 |
def __init__(self, model_name="llama3", temperature=0, schema_path=None):
|
| 34 |
|
| 35 |
hf_token = os.getenv("HF_TOKEN")
|
| 36 |
+
groq_api_key = os.getenv("GROQ_API_KEY")
|
| 37 |
|
| 38 |
+
# Setup del provider LLM a cascata: do priorità ai servizi cloud ad alte performance,
|
| 39 |
+
# se mancano le key faccio un fallback sull'istanza locale di Ollama.
|
| 40 |
if hf_token:
|
| 41 |
print("☁️ Rilevato ambiente Cloud (HF Spaces). Utilizzo HuggingFace Inference API.")
|
| 42 |
repo_id = "meta-llama/Meta-Llama-3-8B-Instruct"
|
|
|
|
| 60 |
self.llm = ChatGroq(
|
| 61 |
temperature=0,
|
| 62 |
model="llama-3.3-70b-versatile",
|
| 63 |
+
api_key=groq_api_key
|
| 64 |
)
|
| 65 |
except Exception as e:
|
| 66 |
print(f"❌ Errore Groq API {e}")
|
|
|
|
| 76 |
except Exception as e:
|
| 77 |
print(f"⚠️ Errore Ollama: {e}")
|
| 78 |
|
| 79 |
+
# Carico il modello leggero per fare l'embedding delle query e matchare l'ontologia al volo
|
| 80 |
print("🧠 Caricamento modello embedding per Dynamic Selection...")
|
| 81 |
self.embedding_model = HuggingFaceEmbeddings(model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
| 82 |
|
|
|
|
| 83 |
self.ontology_elements = []
|
| 84 |
self.ontology_embeddings = None
|
| 85 |
|
| 86 |
+
# Se ho passato il dizionario json generato da ArCo, lo calcolo e lo tengo in RAM
|
| 87 |
if schema_path and os.path.exists(schema_path):
|
| 88 |
print(f"🌟 Indicizzazione vettoriale Ontologia da: {schema_path}")
|
| 89 |
self._index_ontology(schema_path)
|
| 90 |
|
| 91 |
+
# Prompt di sistema: le regole di Graceful Degradation qui sono critiche
|
| 92 |
+
# altrimenti il modello inizia a inventare predicati e inquina il grafo.
|
| 93 |
+
self.system_template_base = """Sei un esperto di Ingegneria della Conoscenza specializzato nell'Ontologia ArCo (Patrimonio Culturale Italiano).
|
| 94 |
Il tuo compito è analizzare il testo e generare un JSON contenente entità e relazioni.
|
| 95 |
|
| 96 |
REGOLE FONDAMENTALI:
|
| 97 |
+
1. Estrai TUTTI i reperti, luoghi, materiali, tecniche, concetti e persone e inseriscili nell'array "entities".
|
| 98 |
+
2. Crea le "triples" usando ESCLUSIVAMENTE le seguenti Classi (per rdf:type) e Proprietà, recuperate dall'ontologia:
|
| 99 |
|
| 100 |
+
CLASSI ARCO CONSENTITE (da usare come oggetto quando predicate = rdf:type):
|
| 101 |
{retrieved_classes}
|
| 102 |
|
| 103 |
+
PROPRIETÀ ARCO CONSENTITE (da usare come predicate):
|
| 104 |
{retrieved_properties}
|
| 105 |
|
| 106 |
+
REGOLE DI CLASSIFICAZIONE E ANTI-ALLUCINAZIONE (CRITICO):
|
| 107 |
+
- rdf:type: Sforzati di usare le classi ArCo specifiche fornite sopra (es. 'arco:HistoricOrArtisticProperty', 'cis:ArchaeologicalSite').
|
| 108 |
+
- Divieto di uso improprio di core:Concept: NON classificare materiali (es. marmo), tecniche costruttive (es. opera laterizia) o dettagli architettonici (es. capitello) come 'core:Concept'. Se non c'è una classe perfetta, classificali come 'arco:ArchaeologicalPropertySurveyType' o lasciali nell'array "entities" senza rdf:type.
|
| 109 |
+
- Usa 'core:Agent' SOLO per persone, famiglie storiche o organizzazioni (es. Antichi Romani, Canova, Imperatore Domiziano).
|
| 110 |
+
- Relazioni: Se due entità sono connesse ma nessuna delle proprietà fornite descrive il legame in modo accurato, usa il predicato generico 'skos:related'.
|
| 111 |
|
| 112 |
Rispondi SOLO ed ESCLUSIVAMENTE con un JSON valido strutturato così:
|
| 113 |
{{
|
| 114 |
"reasoning": "Breve logica delle estrazioni fatte...",
|
| 115 |
+
"entities": ["Entità 1", "Entità orfana", "Marmo"],
|
| 116 |
"triples": [
|
| 117 |
+
{{"subject": "Entità 1", "predicate": "rdf:type", "object": "arco:HistoricOrArtisticProperty", "confidence": 0.9}},
|
| 118 |
+
{{"subject": "Entità 1", "predicate": "a-loc:isLocatedIn", "object": "Entità 2", "confidence": 0.8}}
|
| 119 |
]
|
| 120 |
}}
|
| 121 |
"""
|
| 122 |
|
| 123 |
def _index_ontology(self, path: str):
|
| 124 |
+
"""Vettorizza le descrizioni delle classi per permettere allo Schema-RAG di pescare solo quelle utili."""
|
| 125 |
try:
|
| 126 |
with open(path, 'r', encoding='utf-8') as f:
|
| 127 |
self.ontology_elements = json.load(f)
|
| 128 |
+
|
| 129 |
texts = [el['description'] for el in self.ontology_elements]
|
| 130 |
self.ontology_embeddings = self.embedding_model.embed_documents(texts)
|
| 131 |
print(f"✅ Indicizzati {len(self.ontology_elements)} elementi dell'ontologia.")
|
| 132 |
except Exception as e:
|
| 133 |
print(f"❌ Errore indicizzazione Ontologia: {e}")
|
| 134 |
|
| 135 |
+
def _retrieve_schema(self, query_text: str, top_k_classes=10, top_k_props=8):
|
| 136 |
+
"""Calcola la cosine similarity tra il testo in ingresso e le voci dell'ontologia."""
|
| 137 |
if not self.ontology_elements or self.ontology_embeddings is None:
|
| 138 |
return "Nessuna classe specifica.", "skos:related"
|
| 139 |
|
| 140 |
query_embedding = self.embedding_model.embed_query(query_text)
|
| 141 |
similarities = cosine_similarity([query_embedding], self.ontology_embeddings)[0]
|
| 142 |
|
| 143 |
+
# Ordino per beccare i match migliori
|
| 144 |
sorted_indices = np.argsort(similarities)[::-1]
|
| 145 |
|
| 146 |
classes = []
|
|
|
|
| 151 |
if element["type"] == "Class" and len(classes) < top_k_classes:
|
| 152 |
classes.append(f"- {element['id']}: {element['description']}")
|
| 153 |
elif element["type"] == "Property" and len(properties) < top_k_props:
|
| 154 |
+
|
| 155 |
+
# N.B. Inietto Domain e Range estratti dallo script build_schema
|
| 156 |
+
# per dare all'LLM i paletti relazionali esatti.
|
| 157 |
+
prop_str = f"- {element['id']}: {element['description']}"
|
| 158 |
+
dom = element.get("domain")
|
| 159 |
+
rng = element.get("range")
|
| 160 |
+
|
| 161 |
+
if dom or rng:
|
| 162 |
+
prop_str += f" [VINCOLO -> Soggetto: {dom or 'Qualsiasi'}, Oggetto: {rng or 'Qualsiasi'}]"
|
| 163 |
+
|
| 164 |
+
properties.append(prop_str)
|
| 165 |
|
| 166 |
return "\n".join(classes), "\n".join(properties)
|
| 167 |
|
| 168 |
+
|
| 169 |
def extract(self, text_chunk: str, source_id: str = "unknown", max_retries=3) -> KnowledgeGraphExtraction:
|
| 170 |
print(f"🧠 Processing {source_id} (Schema-RAG Mode)...")
|
| 171 |
|
| 172 |
+
# 1. Recupero dinamico (pesco solo lo schema utile per questo specifico frammento di testo)
|
| 173 |
retrieved_classes, retrieved_properties = self._retrieve_schema(text_chunk)
|
| 174 |
|
| 175 |
+
# 2. Inietto i paletti nel system prompt
|
| 176 |
final_sys_text = self.system_template_base.format(
|
| 177 |
retrieved_classes=retrieved_classes,
|
| 178 |
retrieved_properties=retrieved_properties
|
| 179 |
)
|
| 180 |
|
| 181 |
sys_msg = SystemMessage(content=final_sys_text)
|
| 182 |
+
prompt = ChatPromptTemplate.from_messages([sys_msg, ("human", "{text}")])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
chain = prompt | self.llm
|
| 184 |
|
| 185 |
for attempt in range(max_retries):
|
| 186 |
try:
|
| 187 |
response = chain.invoke({"text": text_chunk})
|
|
|
|
|
|
|
| 188 |
content = response.content
|
| 189 |
|
| 190 |
+
# I LLM a volte ci mettono i backtick markdown anche se chiedi solo JSON puro. Li elimino.
|
| 191 |
if "```json" in content:
|
| 192 |
content = content.split("```json")[1].split("```")[0].strip()
|
| 193 |
elif "```" in content:
|
|
|
|
| 198 |
|
| 199 |
data = json.loads(content)
|
| 200 |
|
| 201 |
+
# Normalizzo l'output per gestire eventuali fluttuazioni della risposta
|
| 202 |
if isinstance(data, list):
|
| 203 |
validated_data = KnowledgeGraphExtraction(triples=data, reasoning="Direct list output")
|
| 204 |
else:
|
| 205 |
+
# Filtro eventuali chiavi fittizie inventate dal modello per rispettare strettamente Pydantic
|
| 206 |
triples = [GraphTriple(**t) for t in data.get("triples", [])]
|
| 207 |
validated_data = KnowledgeGraphExtraction(
|
| 208 |
reasoning=data.get("reasoning", "N/A"),
|
|
|
|
| 218 |
except (json.JSONDecodeError, ValidationError) as e:
|
| 219 |
print(f"⚠️ Errore Validazione (Tentativo {attempt+1}/{max_retries}): {e}")
|
| 220 |
|
| 221 |
+
# SELF-CORRECTION LOOP: Se l'LLM sbagliaa la struttura del JSON,
|
| 222 |
+
# non butto via tutto ma gli rido in pasto l'errore per farglielo correggere.
|
| 223 |
prev_content = locals().get('content', 'No content')
|
| 224 |
|
| 225 |
correction_prompt = ChatPromptTemplate.from_messages([
|
src/graph/entity_resolver.py
CHANGED
|
@@ -6,13 +6,21 @@ from langchain_huggingface import HuggingFaceEmbeddings
|
|
| 6 |
class EntityResolver:
|
| 7 |
def __init__(self, neo4j_driver, model_name="all-MiniLM-L6-v2", similarity_threshold=0.85):
|
| 8 |
print("🧩 Inizializzazione Entity Resolver Ibrido (Vector Search + Wikidata EL)...")
|
|
|
|
|
|
|
| 9 |
self.embedding_model = HuggingFaceEmbeddings(model_name=model_name)
|
|
|
|
|
|
|
| 10 |
self.eps = 1 - similarity_threshold
|
| 11 |
self.similarity_threshold = similarity_threshold
|
| 12 |
self.driver = neo4j_driver
|
| 13 |
|
| 14 |
def _find_canonical_in_db(self, embedding_vector):
|
| 15 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
if not self.driver: return None
|
| 17 |
|
| 18 |
query = """
|
|
@@ -30,8 +38,9 @@ class EntityResolver:
|
|
| 30 |
|
| 31 |
def _link_to_wikidata(self, entity_name):
|
| 32 |
"""
|
| 33 |
-
|
| 34 |
-
|
|
|
|
| 35 |
"""
|
| 36 |
url = "https://www.wikidata.org/w/api.php"
|
| 37 |
params = {
|
|
@@ -39,28 +48,28 @@ class EntityResolver:
|
|
| 39 |
"search": entity_name,
|
| 40 |
"language": "it",
|
| 41 |
"format": "json",
|
| 42 |
-
"limit": 1 #
|
| 43 |
}
|
| 44 |
try:
|
| 45 |
-
#
|
|
|
|
| 46 |
response = requests.get(url, params=params, timeout=3.0)
|
| 47 |
if response.status_code == 200:
|
| 48 |
data = response.json()
|
| 49 |
-
if not data.get("search"):
|
| 50 |
-
print(f" [DEBUG] Wikidata non ha trovato corrispondenze per: '{entity_name}'")
|
| 51 |
-
|
| 52 |
if data.get("search"):
|
| 53 |
best_match = data["search"][0]
|
| 54 |
return f"wd:{best_match['id']}"
|
|
|
|
|
|
|
| 55 |
except Exception as e:
|
| 56 |
-
print(f" ⚠️ Errore lookup Wikidata per '{entity_name}': {e}")
|
| 57 |
return None
|
| 58 |
|
| 59 |
def resolve_entities(self, extracted_entities, triples):
|
| 60 |
if not triples and not extracted_entities:
|
| 61 |
-
return [], []
|
| 62 |
|
| 63 |
-
#
|
| 64 |
chunk_entities = set(extracted_entities)
|
| 65 |
for t in triples:
|
| 66 |
chunk_entities.add(t.subject)
|
|
@@ -68,12 +77,14 @@ class EntityResolver:
|
|
| 68 |
unique_chunk_entities = list(chunk_entities)
|
| 69 |
|
| 70 |
if not unique_chunk_entities:
|
| 71 |
-
return [], triples
|
| 72 |
|
| 73 |
-
#
|
| 74 |
embeddings = self.embedding_model.embed_documents(unique_chunk_entities)
|
| 75 |
|
| 76 |
-
#
|
|
|
|
|
|
|
| 77 |
clustering = DBSCAN(eps=self.eps, min_samples=1, metric='cosine').fit(np.array(embeddings))
|
| 78 |
|
| 79 |
local_cluster_map = {}
|
|
@@ -83,25 +94,25 @@ class EntityResolver:
|
|
| 83 |
local_cluster_map[label].append({"name": entity, "embedding": emb})
|
| 84 |
|
| 85 |
entity_replacement_map = {}
|
| 86 |
-
entities_to_save = [] #
|
| 87 |
|
| 88 |
-
#
|
| 89 |
for label, items in local_cluster_map.items():
|
|
|
|
| 90 |
local_canonical_item = sorted(items, key=lambda x: len(x["name"]), reverse=True)[0]
|
| 91 |
local_canonical_name = local_canonical_item["name"]
|
| 92 |
local_canonical_emb = local_canonical_item["embedding"]
|
| 93 |
|
|
|
|
| 94 |
db_canonical_name = self._find_canonical_in_db(local_canonical_emb)
|
| 95 |
|
| 96 |
if db_canonical_name:
|
| 97 |
-
# Caso A:
|
| 98 |
final_canonical = db_canonical_name
|
| 99 |
print(f" 🔗 Match Globale: '{local_canonical_name}' -> '{db_canonical_name}' (Neo4j)")
|
| 100 |
else:
|
| 101 |
-
# Caso B:
|
| 102 |
final_canonical = local_canonical_name
|
| 103 |
-
|
| 104 |
-
# Chiamata a Wikidata
|
| 105 |
wikidata_uri = self._link_to_wikidata(final_canonical)
|
| 106 |
|
| 107 |
entity_dict = {
|
|
@@ -117,11 +128,12 @@ class EntityResolver:
|
|
| 117 |
|
| 118 |
entities_to_save.append(entity_dict)
|
| 119 |
|
| 120 |
-
#
|
| 121 |
for item in items:
|
| 122 |
entity_replacement_map[item["name"]] = final_canonical
|
| 123 |
|
| 124 |
-
#
|
|
|
|
| 125 |
resolved_triples = []
|
| 126 |
for t in triples:
|
| 127 |
t.subject = entity_replacement_map.get(t.subject, t.subject)
|
|
|
|
| 6 |
class EntityResolver:
|
| 7 |
def __init__(self, neo4j_driver, model_name="all-MiniLM-L6-v2", similarity_threshold=0.85):
|
| 8 |
print("🧩 Inizializzazione Entity Resolver Ibrido (Vector Search + Wikidata EL)...")
|
| 9 |
+
# Uso un modello di embedding ultra-leggero per la risoluzione. Non serve la semantica
|
| 10 |
+
# profonda di un LLM qui, mi basta beccare le stringhe molto simili.
|
| 11 |
self.embedding_model = HuggingFaceEmbeddings(model_name=model_name)
|
| 12 |
+
|
| 13 |
+
# DBSCAN ragiona in termini di distanza (eps), quindi la deduco dalla soglia di similarità (1 - score)
|
| 14 |
self.eps = 1 - similarity_threshold
|
| 15 |
self.similarity_threshold = similarity_threshold
|
| 16 |
self.driver = neo4j_driver
|
| 17 |
|
| 18 |
def _find_canonical_in_db(self, embedding_vector):
|
| 19 |
+
"""
|
| 20 |
+
Interroga l'indice vettoriale nativo di Neo4j.
|
| 21 |
+
Se il nodo esiste già nel grafo globale con un nome leggermente diverso ma
|
| 22 |
+
semanticamente quasi identico, ce lo facciamo restituire per evitare sdoppiamenti.
|
| 23 |
+
"""
|
| 24 |
if not self.driver: return None
|
| 25 |
|
| 26 |
query = """
|
|
|
|
| 38 |
|
| 39 |
def _link_to_wikidata(self, entity_name):
|
| 40 |
"""
|
| 41 |
+
Chiamata REST a Wikidata (Entity Linking).
|
| 42 |
+
Ci serve per ancorare i nodi del nostro grafo a concetti universali (es. wd:Q12345).
|
| 43 |
+
Cruciale per il layer di GraphRAG futuro.
|
| 44 |
"""
|
| 45 |
url = "https://www.wikidata.org/w/api.php"
|
| 46 |
params = {
|
|
|
|
| 48 |
"search": entity_name,
|
| 49 |
"language": "it",
|
| 50 |
"format": "json",
|
| 51 |
+
"limit": 1 # Ci serve solo il top-match per fare riconciliazione a tappeto, niente paginazione.
|
| 52 |
}
|
| 53 |
try:
|
| 54 |
+
# Metto un timeout super restrittivo (3s). Se Wikidata è congestionato,
|
| 55 |
+
# preferisco fallire silenziosamente il linking piuttosto che bloccare tutta l'ingestion della pipeline.
|
| 56 |
response = requests.get(url, params=params, timeout=3.0)
|
| 57 |
if response.status_code == 200:
|
| 58 |
data = response.json()
|
|
|
|
|
|
|
|
|
|
| 59 |
if data.get("search"):
|
| 60 |
best_match = data["search"][0]
|
| 61 |
return f"wd:{best_match['id']}"
|
| 62 |
+
else:
|
| 63 |
+
print(f" [DEBUG] Wikidata non ha trovato corrispondenze per: '{entity_name}'")
|
| 64 |
except Exception as e:
|
| 65 |
+
print(f" ⚠️ Errore lookup Wikidata per '{entity_name}' (ignorato): {e}")
|
| 66 |
return None
|
| 67 |
|
| 68 |
def resolve_entities(self, extracted_entities, triples):
|
| 69 |
if not triples and not extracted_entities:
|
| 70 |
+
return [], [], []
|
| 71 |
|
| 72 |
+
# 1. Raccoglitore: Metto a fattor comune tutte le entità del chunk di testo appena processato
|
| 73 |
chunk_entities = set(extracted_entities)
|
| 74 |
for t in triples:
|
| 75 |
chunk_entities.add(t.subject)
|
|
|
|
| 77 |
unique_chunk_entities = list(chunk_entities)
|
| 78 |
|
| 79 |
if not unique_chunk_entities:
|
| 80 |
+
return [], triples, []
|
| 81 |
|
| 82 |
+
# Embedding massivo di tutte le entità isolate in questo chunk
|
| 83 |
embeddings = self.embedding_model.embed_documents(unique_chunk_entities)
|
| 84 |
|
| 85 |
+
# 2. DEDUPLICA LOCALE IN RAM (DBSCAN)
|
| 86 |
+
# Se nel testo l'LLM ha estratto sia "Canova" che "Antonio Canova",
|
| 87 |
+
# li collassiamo in un solo cluster prima ancora di toccare il database.
|
| 88 |
clustering = DBSCAN(eps=self.eps, min_samples=1, metric='cosine').fit(np.array(embeddings))
|
| 89 |
|
| 90 |
local_cluster_map = {}
|
|
|
|
| 94 |
local_cluster_map[label].append({"name": entity, "embedding": emb})
|
| 95 |
|
| 96 |
entity_replacement_map = {}
|
| 97 |
+
entities_to_save = [] # Struttura per il loader Neo4j: {label, embedding, wikidata_sameAs}
|
| 98 |
|
| 99 |
+
# 3. RISOLUZIONE GLOBALE & ENTITY LINKING
|
| 100 |
for label, items in local_cluster_map.items():
|
| 101 |
+
# Tra le varianti locali, eleggo come canonica provvisoria la stringa più lunga (es. "Tempio di Giove" batte "Tempio")
|
| 102 |
local_canonical_item = sorted(items, key=lambda x: len(x["name"]), reverse=True)[0]
|
| 103 |
local_canonical_name = local_canonical_item["name"]
|
| 104 |
local_canonical_emb = local_canonical_item["embedding"]
|
| 105 |
|
| 106 |
+
# Guardo se il database conosce già qualcosa di molto simile
|
| 107 |
db_canonical_name = self._find_canonical_in_db(local_canonical_emb)
|
| 108 |
|
| 109 |
if db_canonical_name:
|
| 110 |
+
# Caso A: Entità già nota. Faccio override col nome che Neo4j conosce già per evitare biforcazioni.
|
| 111 |
final_canonical = db_canonical_name
|
| 112 |
print(f" 🔗 Match Globale: '{local_canonical_name}' -> '{db_canonical_name}' (Neo4j)")
|
| 113 |
else:
|
| 114 |
+
# Caso B: Entità inedita. Provo a darle una "carta d'identità" agganciandola a Wikidata.
|
| 115 |
final_canonical = local_canonical_name
|
|
|
|
|
|
|
| 116 |
wikidata_uri = self._link_to_wikidata(final_canonical)
|
| 117 |
|
| 118 |
entity_dict = {
|
|
|
|
| 128 |
|
| 129 |
entities_to_save.append(entity_dict)
|
| 130 |
|
| 131 |
+
# Costruisco la mappa di traduzione per tutte le varianti sporche di questo cluster
|
| 132 |
for item in items:
|
| 133 |
entity_replacement_map[item["name"]] = final_canonical
|
| 134 |
|
| 135 |
+
# 4. RISCRITTURA FINALE (Output pulito)
|
| 136 |
+
# Sostituisco i nomi vecchi/sporchi con il canonico definitivo prima di passare il blocco al validatore SHACL
|
| 137 |
resolved_triples = []
|
| 138 |
for t in triples:
|
| 139 |
t.subject = entity_replacement_map.get(t.subject, t.subject)
|
src/graph/graph_loader.py
CHANGED
|
@@ -3,13 +3,12 @@ from collections import defaultdict
|
|
| 3 |
from neo4j import GraphDatabase
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
class KnowledgeGraphPersister:
|
| 9 |
def __init__(self):
|
| 10 |
-
|
| 11 |
-
Inizializza il driver Neo4j e crea i vincoli necessari per le performance.
|
| 12 |
-
"""
|
| 13 |
uri = os.getenv("NEO4J_URI")
|
| 14 |
user = os.getenv("NEO4J_USER")
|
| 15 |
password = os.getenv("NEO4J_PASSWORD")
|
|
@@ -19,7 +18,8 @@ class KnowledgeGraphPersister:
|
|
| 19 |
self.driver.verify_connectivity()
|
| 20 |
print(f"✅ Connesso a Neo4j ({uri}).")
|
| 21 |
|
| 22 |
-
#
|
|
|
|
| 23 |
self._create_constraints()
|
| 24 |
|
| 25 |
except Exception as e:
|
|
@@ -27,16 +27,18 @@ class KnowledgeGraphPersister:
|
|
| 27 |
self.driver = None
|
| 28 |
|
| 29 |
def close(self):
|
|
|
|
| 30 |
if self.driver:
|
| 31 |
self.driver.close()
|
| 32 |
|
| 33 |
def _create_constraints(self):
|
| 34 |
-
"""
|
| 35 |
-
Crea un vincolo di unicità sulla proprietà URI.
|
| 36 |
-
Senza questo, MERGE diventa lentissimo (Full Table Scan).
|
| 37 |
-
"""
|
| 38 |
if not self.driver: return
|
|
|
|
|
|
|
|
|
|
| 39 |
query = "CREATE CONSTRAINT resource_uri_unique IF NOT EXISTS FOR (n:Resource) REQUIRE n.uri IS UNIQUE"
|
|
|
|
|
|
|
| 40 |
query_vector = """
|
| 41 |
CREATE VECTOR INDEX entity_embeddings IF NOT EXISTS
|
| 42 |
FOR (n:Resource) ON (n.embedding)
|
|
@@ -59,40 +61,30 @@ class KnowledgeGraphPersister:
|
|
| 59 |
print(f"⚠️ Warning vector index: {e}")
|
| 60 |
|
| 61 |
def sanitize_name(self, name):
|
| 62 |
-
|
| 63 |
-
Canonicalization base.
|
| 64 |
-
"""
|
| 65 |
if not name: return "Unknown"
|
| 66 |
-
# Rimuove spazi extra e normalizza.
|
| 67 |
return name.strip().replace(" ", "_").replace("'", "").replace('"', "")
|
| 68 |
|
| 69 |
def sanitize_predicate(self, pred):
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
if not pred: return "RELATED_TO"
|
| 74 |
|
| 75 |
-
# Normalizzazione preliminare dei separatori comuni
|
| 76 |
-
# Sostituisco i due punti dei namespace e trattini con underscore
|
| 77 |
pred = pred.replace(":", "_").replace("-", "_").replace(" ", "_")
|
| 78 |
-
|
| 79 |
-
# Rimozione caratteri non sicuri (mantiene solo alfanumerici e underscore)
|
| 80 |
clean = "".join(x for x in pred if x.isalnum() or x == "_")
|
| 81 |
|
| 82 |
-
#
|
| 83 |
return clean.upper() if clean else "RELATED_TO"
|
| 84 |
|
| 85 |
def save_triples(self, triples):
|
| 86 |
-
"""
|
| 87 |
-
Salva le triple usando VERO Batching (UNWIND).
|
| 88 |
-
Raggruppa le triple per predicato per aggirare il limite di parametrizzazione delle relazioni.
|
| 89 |
-
"""
|
| 90 |
if not self.driver or not triples:
|
| 91 |
return
|
| 92 |
|
| 93 |
print(f"💾 Preparazione Batch di {len(triples)} triple...")
|
| 94 |
|
| 95 |
-
#
|
|
|
|
| 96 |
batched_by_pred = defaultdict(list)
|
| 97 |
|
| 98 |
for t in triples:
|
|
@@ -108,7 +100,6 @@ class KnowledgeGraphPersister:
|
|
| 108 |
}
|
| 109 |
batched_by_pred[safe_pred].append(item)
|
| 110 |
|
| 111 |
-
# Esecuzione Transazioni (Una per tipo di relazione)
|
| 112 |
with self.driver.session() as session:
|
| 113 |
for pred, data_list in batched_by_pred.items():
|
| 114 |
try:
|
|
@@ -120,14 +111,13 @@ class KnowledgeGraphPersister:
|
|
| 120 |
print("✅ Salvataggio completato.")
|
| 121 |
|
| 122 |
def save_entities_and_triples(self, entities_to_save, triples):
|
| 123 |
-
"""Salva prima i nodi isolati (con i loro vettori), poi le relazioni."""
|
| 124 |
if not self.driver: return
|
| 125 |
|
| 126 |
-
#
|
|
|
|
| 127 |
if entities_to_save:
|
| 128 |
print(f"💾 Salvataggio di {len(entities_to_save)} nodi singoli con vettori...")
|
| 129 |
|
| 130 |
-
# Aggiungo il campo "uri" calcolandolo dalla label
|
| 131 |
node_batch = []
|
| 132 |
for item in entities_to_save:
|
| 133 |
item["uri"] = self.sanitize_name(item["label"])
|
|
@@ -136,12 +126,13 @@ class KnowledgeGraphPersister:
|
|
| 136 |
with self.driver.session() as session:
|
| 137 |
session.execute_write(self._unwind_write_nodes, node_batch)
|
| 138 |
|
| 139 |
-
# Salvataggio Triple
|
| 140 |
if triples:
|
| 141 |
self.save_triples(triples)
|
| 142 |
|
| 143 |
@staticmethod
|
| 144 |
def _unwind_write_nodes(tx, batch_data):
|
|
|
|
|
|
|
| 145 |
query = (
|
| 146 |
"UNWIND $batch AS row "
|
| 147 |
"MERGE (n:Resource {uri: row.uri}) "
|
|
@@ -154,19 +145,32 @@ class KnowledgeGraphPersister:
|
|
| 154 |
|
| 155 |
@staticmethod
|
| 156 |
def _unwind_write_tx(tx, predicate, batch_data):
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from neo4j import GraphDatabase
|
| 4 |
from dotenv import load_dotenv
|
| 5 |
|
| 6 |
+
# Carico le env vars. Su HF Spaces pesca in automatico dai secrets.
|
| 7 |
+
load_dotenv()
|
| 8 |
|
| 9 |
class KnowledgeGraphPersister:
|
| 10 |
def __init__(self):
|
| 11 |
+
# Setup della connessione a Neo4j.
|
|
|
|
|
|
|
| 12 |
uri = os.getenv("NEO4J_URI")
|
| 13 |
user = os.getenv("NEO4J_USER")
|
| 14 |
password = os.getenv("NEO4J_PASSWORD")
|
|
|
|
| 18 |
self.driver.verify_connectivity()
|
| 19 |
print(f"✅ Connesso a Neo4j ({uri}).")
|
| 20 |
|
| 21 |
+
# Chiamo subito la creazione degli indici. Se partiamo a fare ingestion massiva
|
| 22 |
+
# senza constraint, il DB collassa al primo blocco di MERGE.
|
| 23 |
self._create_constraints()
|
| 24 |
|
| 25 |
except Exception as e:
|
|
|
|
| 27 |
self.driver = None
|
| 28 |
|
| 29 |
def close(self):
|
| 30 |
+
# Chiudo pulito il driver (chiamato nel lifecycle shutdown dell'API)
|
| 31 |
if self.driver:
|
| 32 |
self.driver.close()
|
| 33 |
|
| 34 |
def _create_constraints(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
if not self.driver: return
|
| 36 |
+
|
| 37 |
+
# Senza questo vincolo UNIQUE, l'istruzione MERGE fa un Full Table Scan ogni volta.
|
| 38 |
+
# Fondamentale per mantenere le transazioni < 10ms anche con migliaia di nodi.
|
| 39 |
query = "CREATE CONSTRAINT resource_uri_unique IF NOT EXISTS FOR (n:Resource) REQUIRE n.uri IS UNIQUE"
|
| 40 |
+
|
| 41 |
+
# Indice vettoriale nativo per le ricerche di similarità (dimensionato a 384 per matchare all-MiniLM)
|
| 42 |
query_vector = """
|
| 43 |
CREATE VECTOR INDEX entity_embeddings IF NOT EXISTS
|
| 44 |
FOR (n:Resource) ON (n.embedding)
|
|
|
|
| 61 |
print(f"⚠️ Warning vector index: {e}")
|
| 62 |
|
| 63 |
def sanitize_name(self, name):
|
| 64 |
+
# Canonicalizzazione molto base: sostituisco spazi inutili e tolgo gli apici che spaccano le query Cypher.
|
|
|
|
|
|
|
| 65 |
if not name: return "Unknown"
|
|
|
|
| 66 |
return name.strip().replace(" ", "_").replace("'", "").replace('"', "")
|
| 67 |
|
| 68 |
def sanitize_predicate(self, pred):
|
| 69 |
+
# Cruciale per evitare Cypher Injection. In Cypher NON si può parametrizzare
|
| 70 |
+
# il tipo di relazione in un MERGE (es. non puoi fare -[r:$pred]-). Devo per forza
|
| 71 |
+
# iniettarlo nella stringa della query, quindi lo normalizzo in modo drastico.
|
| 72 |
if not pred: return "RELATED_TO"
|
| 73 |
|
|
|
|
|
|
|
| 74 |
pred = pred.replace(":", "_").replace("-", "_").replace(" ", "_")
|
|
|
|
|
|
|
| 75 |
clean = "".join(x for x in pred if x.isalnum() or x == "_")
|
| 76 |
|
| 77 |
+
# Convenzione Neo4j: le relationships sono sempre in UPPERCASE
|
| 78 |
return clean.upper() if clean else "RELATED_TO"
|
| 79 |
|
| 80 |
def save_triples(self, triples):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if not self.driver or not triples:
|
| 82 |
return
|
| 83 |
|
| 84 |
print(f"💾 Preparazione Batch di {len(triples)} triple...")
|
| 85 |
|
| 86 |
+
# Visto che non posso parametrizzare il predicato nella query Cypher,
|
| 87 |
+
# raggruppo le triple per tipo di relazione e lancio un batch per ognuna.
|
| 88 |
batched_by_pred = defaultdict(list)
|
| 89 |
|
| 90 |
for t in triples:
|
|
|
|
| 100 |
}
|
| 101 |
batched_by_pred[safe_pred].append(item)
|
| 102 |
|
|
|
|
| 103 |
with self.driver.session() as session:
|
| 104 |
for pred, data_list in batched_by_pred.items():
|
| 105 |
try:
|
|
|
|
| 111 |
print("✅ Salvataggio completato.")
|
| 112 |
|
| 113 |
def save_entities_and_triples(self, entities_to_save, triples):
|
|
|
|
| 114 |
if not self.driver: return
|
| 115 |
|
| 116 |
+
# Ingestion a 2 step: prima butto dentro i nodi isolati con tutti i loro payload
|
| 117 |
+
# (embedding vettoriali e link a Wikidata), poi in un secondo momento ci aggancio sopra le relazioni.
|
| 118 |
if entities_to_save:
|
| 119 |
print(f"💾 Salvataggio di {len(entities_to_save)} nodi singoli con vettori...")
|
| 120 |
|
|
|
|
| 121 |
node_batch = []
|
| 122 |
for item in entities_to_save:
|
| 123 |
item["uri"] = self.sanitize_name(item["label"])
|
|
|
|
| 126 |
with self.driver.session() as session:
|
| 127 |
session.execute_write(self._unwind_write_nodes, node_batch)
|
| 128 |
|
|
|
|
| 129 |
if triples:
|
| 130 |
self.save_triples(triples)
|
| 131 |
|
| 132 |
@staticmethod
|
| 133 |
def _unwind_write_nodes(tx, batch_data):
|
| 134 |
+
# L'UNWIND è l'unico modo per fare VERO batching massivo in Neo4j senza distruggere la RAM.
|
| 135 |
+
# Passo un intero array JSON ($batch) e Cypher lo "srotola" inserendo migliaia di nodi al volo.
|
| 136 |
query = (
|
| 137 |
"UNWIND $batch AS row "
|
| 138 |
"MERGE (n:Resource {uri: row.uri}) "
|
|
|
|
| 145 |
|
| 146 |
@staticmethod
|
| 147 |
def _unwind_write_tx(tx, predicate, batch_data):
|
| 148 |
+
# Qui avviene la vera traduzione dal mondo RDF a quello Labeled Property Graph (LPG).
|
| 149 |
+
if predicate in ["RDF_TYPE", "TYPE", "A", "CORE_HASTYPE"]:
|
| 150 |
+
# Se l'LLM ha generato una tripla di classificazione ontologica, NON creo un nodo astratto inutile.
|
| 151 |
+
# Uso APOC per convertire l'oggetto della tripla in una vera Label sul nodo di partenza.
|
| 152 |
+
query = (
|
| 153 |
+
"UNWIND $batch AS row "
|
| 154 |
+
"MERGE (s:Resource {uri: row.subj_uri}) "
|
| 155 |
+
"ON CREATE SET s.label = row.subj_label, s.last_updated = datetime() "
|
| 156 |
+
"WITH s, row "
|
| 157 |
+
"CALL apoc.create.addLabels(s, [replace(row.obj_label, ':', '_')]) YIELD node "
|
| 158 |
+
"RETURN count(node)"
|
| 159 |
+
)
|
| 160 |
+
tx.run(query, batch=batch_data)
|
| 161 |
+
|
| 162 |
+
else:
|
| 163 |
+
# Per tutte le altre relazioni semantiche classiche (es. si_trova_in, ha_autore)
|
| 164 |
+
# eseguo un merge standard tra le due entità.
|
| 165 |
+
query = (
|
| 166 |
+
f"UNWIND $batch AS row "
|
| 167 |
+
f"MERGE (s:Resource {{uri: row.subj_uri}}) "
|
| 168 |
+
f"ON CREATE SET s.label = row.subj_label "
|
| 169 |
+
f"MERGE (o:Resource {{uri: row.obj_uri}}) "
|
| 170 |
+
f"ON CREATE SET o.label = row.obj_label "
|
| 171 |
+
f"MERGE (s)-[r:`{predicate}`]->(o) "
|
| 172 |
+
f"SET r.confidence = row.conf, "
|
| 173 |
+
f" r.source = row.src, "
|
| 174 |
+
f" r.last_updated = datetime()"
|
| 175 |
+
)
|
| 176 |
+
tx.run(query, batch=batch_data)
|
src/ingestion/semantic_splitter.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import numpy as np
|
|
|
|
| 4 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 5 |
from dotenv import load_dotenv
|
| 6 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 7 |
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
class ActivaSemanticSplitter:
|
| 11 |
def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2", batch_size=32):
|
|
@@ -13,6 +15,8 @@ class ActivaSemanticSplitter:
|
|
| 13 |
|
| 14 |
print("🔄 Inizializzazione HuggingFace Embedding Engine...")
|
| 15 |
|
|
|
|
|
|
|
| 16 |
try:
|
| 17 |
self.embedding_model = HuggingFaceEmbeddings(model_name=model_name)
|
| 18 |
print("✅ Modello caricato correttamente.")
|
|
@@ -20,31 +24,31 @@ class ActivaSemanticSplitter:
|
|
| 20 |
print(f"❌ Errore caricamento modello: {e}")
|
| 21 |
raise e
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def _split_sentences(self, text):
|
| 24 |
-
|
| 25 |
-
Divide il testo in frasi gestendo le abbreviazioni custom (es. sec., S.).
|
| 26 |
-
"""
|
| 27 |
text = text.strip()
|
| 28 |
try:
|
| 29 |
-
|
| 30 |
-
#
|
| 31 |
-
try:
|
| 32 |
-
nltk.data.find('tokenizers/punkt')
|
| 33 |
-
nltk.data.find('tokenizers/punkt_tab')
|
| 34 |
-
except LookupError:
|
| 35 |
-
print("⬇️ Download risorse NLTK...")
|
| 36 |
-
nltk.download('punkt', quiet=True)
|
| 37 |
-
nltk.download('punkt_tab', quiet=True)
|
| 38 |
-
|
| 39 |
-
# Invece di usare sent_tokenize() che è una black box, carico l'oggetto.
|
| 40 |
try:
|
| 41 |
tokenizer = nltk.data.load('tokenizers/punkt/italian.pickle')
|
| 42 |
except:
|
| 43 |
-
# Fallback se il
|
| 44 |
from nltk.tokenize.punkt import PunktSentenceTokenizer
|
| 45 |
tokenizer = PunktSentenceTokenizer()
|
| 46 |
|
| 47 |
# --- LISTA ECCEZIONI ABBREVIAZIONI ---
|
|
|
|
|
|
|
| 48 |
custom_abbrevs = ['sec', 's', 'prof', 'dott', 'avv', 'pag', 'fig', 'nr', 'art']
|
| 49 |
for abbr in custom_abbrevs:
|
| 50 |
tokenizer._params.abbrev_types.add(abbr)
|
|
@@ -58,9 +62,13 @@ class ActivaSemanticSplitter:
|
|
| 58 |
print(f"⚠️ Errore NLTK ({e}). Fallback su Regex.")
|
| 59 |
sentences = re.split(r'(?<=[.?!])\s+', text)
|
| 60 |
|
|
|
|
| 61 |
return [s.strip() for s in sentences if len(s.strip()) > 5]
|
| 62 |
|
| 63 |
def combine_sentences(self, sentences, buffer_size=1):
|
|
|
|
|
|
|
|
|
|
| 64 |
combined = []
|
| 65 |
for i in range(len(sentences)):
|
| 66 |
start = max(0, i - buffer_size)
|
|
@@ -70,6 +78,7 @@ class ActivaSemanticSplitter:
|
|
| 70 |
return combined
|
| 71 |
|
| 72 |
def calculate_cosine_distances(self, sentences):
|
|
|
|
| 73 |
embeddings = []
|
| 74 |
total = len(sentences)
|
| 75 |
|
|
@@ -78,10 +87,11 @@ class ActivaSemanticSplitter:
|
|
| 78 |
batch_embeddings = self.embedding_model.embed_documents(batch)
|
| 79 |
embeddings.extend(batch_embeddings)
|
| 80 |
|
|
|
|
| 81 |
distances = []
|
| 82 |
for i in range(len(embeddings) - 1):
|
| 83 |
similarity = cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0]
|
| 84 |
-
#(0 =
|
| 85 |
distance = 1.0 - similarity
|
| 86 |
distances.append(distance)
|
| 87 |
|
|
@@ -96,21 +106,25 @@ class ActivaSemanticSplitter:
|
|
| 96 |
distances, _ = self.calculate_cosine_distances(combined_sentences)
|
| 97 |
|
| 98 |
if not distances:
|
|
|
|
| 99 |
return [text], [], 0
|
| 100 |
|
|
|
|
| 101 |
threshold = np.percentile(distances, percentile_threshold)
|
| 102 |
|
| 103 |
-
#
|
| 104 |
indices_above_thresh = [i for i, x in enumerate(distances) if x > threshold]
|
| 105 |
|
| 106 |
chunks = []
|
| 107 |
start_index = 0
|
| 108 |
breakpoints = indices_above_thresh + [len(single_sentences)]
|
| 109 |
|
|
|
|
|
|
|
| 110 |
for i in breakpoints:
|
| 111 |
end_index = i + 1
|
| 112 |
chunk_text = " ".join(single_sentences[start_index:end_index])
|
| 113 |
-
if len(chunk_text) > 20:
|
| 114 |
chunks.append(chunk_text)
|
| 115 |
start_index = end_index
|
| 116 |
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
import numpy as np
|
| 4 |
+
import nltk
|
| 5 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 8 |
|
| 9 |
+
# Carico l'ambiente. Su HF Spaces andrà a pescare dai secrets, in locale dal .env
|
| 10 |
+
load_dotenv()
|
| 11 |
|
| 12 |
class ActivaSemanticSplitter:
|
| 13 |
def __init__(self, model_name="sentence-transformers/all-MiniLM-L6-v2", batch_size=32):
|
|
|
|
| 15 |
|
| 16 |
print("🔄 Inizializzazione HuggingFace Embedding Engine...")
|
| 17 |
|
| 18 |
+
# Scelto MiniLM-L6: per questo prototipo ci serve un modello veloce e leggero in RAM
|
| 19 |
+
# che non faccia da collo di bottiglia durante l'ingestion massiva di documenti.
|
| 20 |
try:
|
| 21 |
self.embedding_model = HuggingFaceEmbeddings(model_name=model_name)
|
| 22 |
print("✅ Modello caricato correttamente.")
|
|
|
|
| 24 |
print(f"❌ Errore caricamento modello: {e}")
|
| 25 |
raise e
|
| 26 |
|
| 27 |
+
# Check preventivo sui tokenizer.
|
| 28 |
+
try:
|
| 29 |
+
nltk.data.find('tokenizers/punkt')
|
| 30 |
+
nltk.data.find('tokenizers/punkt_tab')
|
| 31 |
+
except LookupError:
|
| 32 |
+
print("⬇️ Download risorse NLTK...")
|
| 33 |
+
nltk.download('punkt', quiet=True)
|
| 34 |
+
nltk.download('punkt_tab', quiet=True)
|
| 35 |
+
|
| 36 |
def _split_sentences(self, text):
|
| 37 |
+
# La pulizia base. Fondamentale per i testi estratti da vecchi OCR o documenti sporchi.
|
|
|
|
|
|
|
| 38 |
text = text.strip()
|
| 39 |
try:
|
| 40 |
+
# Recupero il tokenizer dell'italiano. Evito sent_tokenize() puro perché è una black box
|
| 41 |
+
# e mi serve poter iniettare eccezioni custom per la punteggiatura.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
try:
|
| 43 |
tokenizer = nltk.data.load('tokenizers/punkt/italian.pickle')
|
| 44 |
except:
|
| 45 |
+
# Fallback di sicurezza se il path del pickle salta
|
| 46 |
from nltk.tokenize.punkt import PunktSentenceTokenizer
|
| 47 |
tokenizer = PunktSentenceTokenizer()
|
| 48 |
|
| 49 |
# --- LISTA ECCEZIONI ABBREVIAZIONI ---
|
| 50 |
+
# Evito che il chunker mi spezzi la frase a metà quando incontra "pag." o "art."
|
| 51 |
+
# cosa che distruggerebbe il senso semantico prima ancora di passare all'LLM.
|
| 52 |
custom_abbrevs = ['sec', 's', 'prof', 'dott', 'avv', 'pag', 'fig', 'nr', 'art']
|
| 53 |
for abbr in custom_abbrevs:
|
| 54 |
tokenizer._params.abbrev_types.add(abbr)
|
|
|
|
| 62 |
print(f"⚠️ Errore NLTK ({e}). Fallback su Regex.")
|
| 63 |
sentences = re.split(r'(?<=[.?!])\s+', text)
|
| 64 |
|
| 65 |
+
# Filtro via il rumore di fondo (stringhe troppo corte o spazi rimasti appesi)
|
| 66 |
return [s.strip() for s in sentences if len(s.strip()) > 5]
|
| 67 |
|
| 68 |
def combine_sentences(self, sentences, buffer_size=1):
|
| 69 |
+
# Sliding window per dare contesto: embeddare una frase singola tipo "Di conseguenza." non ha senso vettoriale.
|
| 70 |
+
# Le affianco la frase prima e quella dopo per "spalmare" il significato
|
| 71 |
+
# ed evitare che una frase breve sballi il calcolo del coseno.
|
| 72 |
combined = []
|
| 73 |
for i in range(len(sentences)):
|
| 74 |
start = max(0, i - buffer_size)
|
|
|
|
| 78 |
return combined
|
| 79 |
|
| 80 |
def calculate_cosine_distances(self, sentences):
|
| 81 |
+
# Embeddo tutto in batch. Se arrivano malloppi enormi da estrarre non voglio saturare la memoria.
|
| 82 |
embeddings = []
|
| 83 |
total = len(sentences)
|
| 84 |
|
|
|
|
| 87 |
batch_embeddings = self.embedding_model.embed_documents(batch)
|
| 88 |
embeddings.extend(batch_embeddings)
|
| 89 |
|
| 90 |
+
# Calcolo le distanze sequenziali tra la frase N e la frase N+1
|
| 91 |
distances = []
|
| 92 |
for i in range(len(embeddings) - 1):
|
| 93 |
similarity = cosine_similarity([embeddings[i]], [embeddings[i+1]])[0][0]
|
| 94 |
+
# Inverto la similarità in distanza (0 = concetti identici, 1 = cambio totale di argomento)
|
| 95 |
distance = 1.0 - similarity
|
| 96 |
distances.append(distance)
|
| 97 |
|
|
|
|
| 106 |
distances, _ = self.calculate_cosine_distances(combined_sentences)
|
| 107 |
|
| 108 |
if not distances:
|
| 109 |
+
# Testo troppo breve per essere splittato, lo tengo intero
|
| 110 |
return [text], [], 0
|
| 111 |
|
| 112 |
+
# Calcolo la soglia di taglio dinamicamente in base alle variazioni semantiche del documento stesso.
|
| 113 |
threshold = np.percentile(distances, percentile_threshold)
|
| 114 |
|
| 115 |
+
# Individuo i "punti di rottura" dove l'argomento cambia radicalmente
|
| 116 |
indices_above_thresh = [i for i, x in enumerate(distances) if x > threshold]
|
| 117 |
|
| 118 |
chunks = []
|
| 119 |
start_index = 0
|
| 120 |
breakpoints = indices_above_thresh + [len(single_sentences)]
|
| 121 |
|
| 122 |
+
# Ricostruisco i paragrafi unendo le frasi originali (non quelle col buffer)
|
| 123 |
+
# delimitandole dai punti di rottura che abbiamo appena trovato.
|
| 124 |
for i in breakpoints:
|
| 125 |
end_index = i + 1
|
| 126 |
chunk_text = " ".join(single_sentences[start_index:end_index])
|
| 127 |
+
if len(chunk_text) > 20: # Salto micro-frammenti spazzatura (es. singole parole o punteggiatura)
|
| 128 |
chunks.append(chunk_text)
|
| 129 |
start_index = end_index
|
| 130 |
|
src/utils/build_schema.py
CHANGED
|
@@ -3,11 +3,61 @@ import json
|
|
| 3 |
from pathlib import Path
|
| 4 |
from rdflib import Graph
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
def build_schema_from_ontology(owl_folder_path: str, output_json_path: str):
|
| 7 |
print(f"⏳ Inizializzazione Graph e caricamento file .owl da {owl_folder_path}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
g = Graph()
|
| 9 |
|
| 10 |
-
# 1. Caricamento
|
| 11 |
owl_files = list(Path(owl_folder_path).glob('**/*.owl'))
|
| 12 |
if not owl_files:
|
| 13 |
print("❌ Nessun file .owl trovato nella directory specificata.")
|
|
@@ -15,25 +65,22 @@ def build_schema_from_ontology(owl_folder_path: str, output_json_path: str):
|
|
| 15 |
|
| 16 |
for file_path in owl_files:
|
| 17 |
try:
|
| 18 |
-
# I file .owl standard sono scritti in RDF/XML
|
| 19 |
g.parse(file_path, format="xml")
|
| 20 |
print(f" -> Caricato (XML): {file_path.name}")
|
| 21 |
except Exception as e_xml:
|
| 22 |
-
|
| 23 |
-
g.parse(file_path, format="turtle")
|
| 24 |
-
print(f" -> Caricato (Turtle): {file_path.name}")
|
| 25 |
-
except Exception as e_ttl:
|
| 26 |
-
print(f" ⚠️ Impossibile parsare {file_path.name}. XML err: {e_xml} | TTL err: {e_ttl}")
|
| 27 |
-
|
| 28 |
|
| 29 |
print("✅ Ontologia caricata in memoria. Esecuzione query SPARQL...")
|
| 30 |
|
| 31 |
-
# 2. Query SPARQL
|
|
|
|
|
|
|
|
|
|
| 32 |
sparql_query = """
|
| 33 |
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
| 34 |
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 35 |
|
| 36 |
-
SELECT DISTINCT ?entity ?type ?label ?comment
|
| 37 |
WHERE {
|
| 38 |
{
|
| 39 |
?entity a owl:Class .
|
|
@@ -43,62 +90,77 @@ def build_schema_from_ontology(owl_folder_path: str, output_json_path: str):
|
|
| 43 |
BIND("Property" AS ?type)
|
| 44 |
}
|
| 45 |
|
| 46 |
-
# Recuperiamo le label in italiano (o senza lingua)
|
| 47 |
OPTIONAL {
|
| 48 |
?entity rdfs:label ?label .
|
| 49 |
FILTER(LANGMATCHES(LANG(?label), "it") || LANG(?label) = "")
|
| 50 |
}
|
| 51 |
|
| 52 |
-
# Recuperiamo i commenti/definizioni in italiano (o senza lingua)
|
| 53 |
OPTIONAL {
|
| 54 |
?entity rdfs:comment ?comment .
|
| 55 |
FILTER(LANGMATCHES(LANG(?comment), "it") || LANG(?comment) = "")
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
# Filtriamo per evitare i blank nodes (nodi senza URI)
|
| 59 |
FILTER(isIRI(?entity))
|
| 60 |
}
|
| 61 |
"""
|
| 62 |
|
| 63 |
results = g.query(sparql_query)
|
| 64 |
-
|
| 65 |
schema_elements = {}
|
| 66 |
|
| 67 |
-
# 3.
|
| 68 |
for row in results:
|
| 69 |
entity_uri = row.entity
|
| 70 |
entity_type = str(row.type)
|
| 71 |
label = str(row.label) if row.label else ""
|
| 72 |
comment = str(row.comment) if row.comment else ""
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
|
| 82 |
-
# Costruiamo la descrizione aggregata per l'LLM
|
| 83 |
description_parts = []
|
| 84 |
if label: description_parts.append(label)
|
| 85 |
if comment: description_parts.append(comment)
|
| 86 |
|
| 87 |
final_description = " - ".join(description_parts)
|
| 88 |
|
| 89 |
-
#
|
|
|
|
| 90 |
if not final_description.strip():
|
| 91 |
continue
|
| 92 |
|
| 93 |
-
#
|
| 94 |
if qname not in schema_elements:
|
| 95 |
-
|
| 96 |
"id": qname,
|
| 97 |
"type": entity_type,
|
| 98 |
"description": final_description.strip()
|
| 99 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
-
# 4. Salvataggio
|
| 102 |
output_list = list(schema_elements.values())
|
| 103 |
|
| 104 |
with open(output_json_path, 'w', encoding='utf-8') as f:
|
|
@@ -107,15 +169,10 @@ def build_schema_from_ontology(owl_folder_path: str, output_json_path: str):
|
|
| 107 |
print(f"🎉 Finito! Generato dizionario con {len(output_list)} elementi.")
|
| 108 |
print(f"💾 Salvato in: {output_json_path}")
|
| 109 |
|
| 110 |
-
|
| 111 |
if __name__ == "__main__":
|
| 112 |
-
# Esempio di utilizzo:
|
| 113 |
-
# Assicurati di scaricare i file .ttl di ArCo e metterli in una cartella, ad es. 'data/arco_raw/'
|
| 114 |
NOME_ONTOLOGIA = "ARCO"
|
| 115 |
INPUT_FOLDER = f"data/ontologie_raw/{NOME_ONTOLOGIA}"
|
| 116 |
OUTPUT_FILE = f"data/schemas/{NOME_ONTOLOGIA}_schema.json"
|
| 117 |
|
| 118 |
-
# Crea la directory di output se non esiste
|
| 119 |
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
| 120 |
-
|
| 121 |
build_schema_from_ontology(INPUT_FOLDER, OUTPUT_FILE)
|
|
|
|
| 3 |
from pathlib import Path
|
| 4 |
from rdflib import Graph
|
| 5 |
|
| 6 |
+
# --- MAPPA FORZATA DEI NAMESPACE ARCO E ONTOPIA ---
|
| 7 |
+
# rdflib spesso fa casini con i prefissi di default (generando ID vuoti tipo ':Acquisition').
|
| 8 |
+
# Forziamo la mano con un dizionario hardcoded per avere sempre QName puliti
|
| 9 |
+
# e standardizzati, fondamentali per non confondere l'LLM durante lo Schema-RAG.
|
| 10 |
+
ARCO_NAMESPACES = {
|
| 11 |
+
"https://w3id.org/arco/ontology/arco/": "arco",
|
| 12 |
+
"https://w3id.org/arco/ontology/core/": "core",
|
| 13 |
+
"https://w3id.org/arco/ontology/location/": "a-loc",
|
| 14 |
+
"https://w3id.org/arco/ontology/context-description/": "a-cd",
|
| 15 |
+
"https://w3id.org/arco/ontology/denotative-description/": "a-dd",
|
| 16 |
+
"https://w3id.org/arco/ontology/cultural-event/": "a-ce",
|
| 17 |
+
"http://dati.beniculturali.it/cis/": "cis",
|
| 18 |
+
"https://w3id.org/italia/onto/l0/": "l0",
|
| 19 |
+
"https://w3id.org/italia/onto/CLV/": "clv",
|
| 20 |
+
"https://w3id.org/italia/onto/TI/": "ti",
|
| 21 |
+
"https://w3id.org/italia/onto/RO/": "ro",
|
| 22 |
+
"https://w3id.org/italia/onto/SM/": "sm",
|
| 23 |
+
"http://www.w3.org/2002/07/owl#": "owl"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
def uri_to_qname(uri: str) -> str:
|
| 27 |
+
"""
|
| 28 |
+
Prende un URI chilometrico e lo riduce a un QName compatto (es. arco:CulturalProperty).
|
| 29 |
+
L'LLM impazzirebbe a leggere URL completi nel prompt, sprecando token inutilmente.
|
| 30 |
+
"""
|
| 31 |
+
if not uri:
|
| 32 |
+
return None
|
| 33 |
+
uri_str = str(uri)
|
| 34 |
+
|
| 35 |
+
# Match sulla base dei namespace noti (cerco la radice più lunga)
|
| 36 |
+
best_match = ""
|
| 37 |
+
for ns_uri in ARCO_NAMESPACES.keys():
|
| 38 |
+
if uri_str.startswith(ns_uri) and len(ns_uri) > len(best_match):
|
| 39 |
+
best_match = ns_uri
|
| 40 |
+
|
| 41 |
+
if best_match:
|
| 42 |
+
prefix = ARCO_NAMESPACES[best_match]
|
| 43 |
+
name = uri_str[len(best_match):].lstrip('#')
|
| 44 |
+
return f"{prefix}:{name}"
|
| 45 |
+
|
| 46 |
+
# Fallback drastico se peschiamo qualcosa fuori dai radar: tengo solo l'ultimo pezzetto
|
| 47 |
+
if '#' in uri_str:
|
| 48 |
+
return uri_str.split('#')[-1]
|
| 49 |
+
return uri_str.split('/')[-1]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
def build_schema_from_ontology(owl_folder_path: str, output_json_path: str):
|
| 53 |
print(f"⏳ Inizializzazione Graph e caricamento file .owl da {owl_folder_path}...")
|
| 54 |
+
|
| 55 |
+
# Creo un mega-grafo in memoria. Caricando tutti i file .owl insieme,
|
| 56 |
+
# risolvo automaticamente i cross-reference (es. una proprietà di 'location.owl'
|
| 57 |
+
# che punta a una classe di 'core.owl').
|
| 58 |
g = Graph()
|
| 59 |
|
| 60 |
+
# 1. Caricamento Moduli
|
| 61 |
owl_files = list(Path(owl_folder_path).glob('**/*.owl'))
|
| 62 |
if not owl_files:
|
| 63 |
print("❌ Nessun file .owl trovato nella directory specificata.")
|
|
|
|
| 65 |
|
| 66 |
for file_path in owl_files:
|
| 67 |
try:
|
|
|
|
| 68 |
g.parse(file_path, format="xml")
|
| 69 |
print(f" -> Caricato (XML): {file_path.name}")
|
| 70 |
except Exception as e_xml:
|
| 71 |
+
print(f" ⚠️ Impossibile parsare {file_path.name}. XML err: {e_xml}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
print("✅ Ontologia caricata in memoria. Esecuzione query SPARQL...")
|
| 74 |
|
| 75 |
+
# 2. Query SPARQL
|
| 76 |
+
# Estrazione massiva. Ho rimosso i FILTER(isIRI) su domain e range perché ArCo
|
| 77 |
+
# fa largo uso di Blank Nodes per definire le UNION di classi. Se li filtro,
|
| 78 |
+
# perdo un sacco di vincoli relazionali utili per l'estrattore LLM.
|
| 79 |
sparql_query = """
|
| 80 |
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
| 81 |
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
| 82 |
|
| 83 |
+
SELECT DISTINCT ?entity ?type ?label ?comment ?domain ?range
|
| 84 |
WHERE {
|
| 85 |
{
|
| 86 |
?entity a owl:Class .
|
|
|
|
| 90 |
BIND("Property" AS ?type)
|
| 91 |
}
|
| 92 |
|
|
|
|
| 93 |
OPTIONAL {
|
| 94 |
?entity rdfs:label ?label .
|
| 95 |
FILTER(LANGMATCHES(LANG(?label), "it") || LANG(?label) = "")
|
| 96 |
}
|
| 97 |
|
|
|
|
| 98 |
OPTIONAL {
|
| 99 |
?entity rdfs:comment ?comment .
|
| 100 |
FILTER(LANGMATCHES(LANG(?comment), "it") || LANG(?comment) = "")
|
| 101 |
}
|
| 102 |
+
|
| 103 |
+
OPTIONAL { ?entity rdfs:domain ?domain . }
|
| 104 |
+
OPTIONAL { ?entity rdfs:range ?range . }
|
| 105 |
|
|
|
|
| 106 |
FILTER(isIRI(?entity))
|
| 107 |
}
|
| 108 |
"""
|
| 109 |
|
| 110 |
results = g.query(sparql_query)
|
|
|
|
| 111 |
schema_elements = {}
|
| 112 |
|
| 113 |
+
# 3. Formattazione e Pulizia
|
| 114 |
for row in results:
|
| 115 |
entity_uri = row.entity
|
| 116 |
entity_type = str(row.type)
|
| 117 |
label = str(row.label) if row.label else ""
|
| 118 |
comment = str(row.comment) if row.comment else ""
|
| 119 |
|
| 120 |
+
qname = uri_to_qname(entity_uri)
|
| 121 |
+
|
| 122 |
+
# Gestione Blank Nodes: se il dominio o range non è un URI netto (inizia con http),
|
| 123 |
+
# significa che l'ontologia sta usando una costruzione logica complessa (es. unione di classi).
|
| 124 |
+
# Metto "Mixed/Union" come fallback per avvisare l'LLM che accetta tipi misti.
|
| 125 |
+
domain_str = uri_to_qname(row.domain) if (row.domain and str(row.domain).startswith("http")) else ("Mixed/Union" if row.domain else None)
|
| 126 |
+
range_str = uri_to_qname(row.range) if (row.range and str(row.range).startswith("http")) else ("Mixed/Union" if row.range else None)
|
| 127 |
|
|
|
|
| 128 |
description_parts = []
|
| 129 |
if label: description_parts.append(label)
|
| 130 |
if comment: description_parts.append(comment)
|
| 131 |
|
| 132 |
final_description = " - ".join(description_parts)
|
| 133 |
|
| 134 |
+
# Scarto le voci senza documentazione testuale. Se non hanno un commento,
|
| 135 |
+
# l'LLM non capirebbe mai come usarle e farebbe solo allucinazioni.
|
| 136 |
if not final_description.strip():
|
| 137 |
continue
|
| 138 |
|
| 139 |
+
# Se l'entità non è ancora nel dizionario, la creiamo
|
| 140 |
if qname not in schema_elements:
|
| 141 |
+
element_data = {
|
| 142 |
"id": qname,
|
| 143 |
"type": entity_type,
|
| 144 |
"description": final_description.strip()
|
| 145 |
}
|
| 146 |
+
# Strutturo domain e range come chiavi a se stanti per poterle iniettare facilmente nel prompt
|
| 147 |
+
if entity_type == "Property":
|
| 148 |
+
element_data["domain"] = domain_str
|
| 149 |
+
element_data["range"] = range_str
|
| 150 |
+
|
| 151 |
+
schema_elements[qname] = element_data
|
| 152 |
+
|
| 153 |
+
else:
|
| 154 |
+
# Deduplica intelligente: poiché i file OWL si sovrappongono, potrei leggere la stessa
|
| 155 |
+
# proprietà due volte (una volta vuota, una volta con i vincoli).
|
| 156 |
+
# Se trovo i vincoli al secondo giro, aggiorno il dizionario per non perdere dati preziosi.
|
| 157 |
+
if entity_type == "Property":
|
| 158 |
+
if domain_str and not schema_elements[qname].get("domain"):
|
| 159 |
+
schema_elements[qname]["domain"] = domain_str
|
| 160 |
+
if range_str and not schema_elements[qname].get("range"):
|
| 161 |
+
schema_elements[qname]["range"] = range_str
|
| 162 |
|
| 163 |
+
# 4. Salvataggio su disco
|
| 164 |
output_list = list(schema_elements.values())
|
| 165 |
|
| 166 |
with open(output_json_path, 'w', encoding='utf-8') as f:
|
|
|
|
| 169 |
print(f"🎉 Finito! Generato dizionario con {len(output_list)} elementi.")
|
| 170 |
print(f"💾 Salvato in: {output_json_path}")
|
| 171 |
|
|
|
|
| 172 |
if __name__ == "__main__":
|
|
|
|
|
|
|
| 173 |
NOME_ONTOLOGIA = "ARCO"
|
| 174 |
INPUT_FOLDER = f"data/ontologie_raw/{NOME_ONTOLOGIA}"
|
| 175 |
OUTPUT_FILE = f"data/schemas/{NOME_ONTOLOGIA}_schema.json"
|
| 176 |
|
|
|
|
| 177 |
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
|
|
|
|
| 178 |
build_schema_from_ontology(INPUT_FOLDER, OUTPUT_FILE)
|
src/validation/shapes/schema_constraints.ttl
CHANGED
|
@@ -1,10 +1,12 @@
|
|
| 1 |
@prefix sh: <http://www.w3.org/ns/shacl#> .
|
| 2 |
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
|
| 3 |
-
@prefix ex: <http://
|
| 4 |
@prefix arco: <https://w3id.org/arco/ontology/arco/> .
|
| 5 |
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
| 6 |
|
| 7 |
-
#
|
|
|
|
|
|
|
| 8 |
ex:NodeLabelShape
|
| 9 |
a sh:NodeShape ;
|
| 10 |
sh:targetSubjectsOf skos:prefLabel ;
|
|
@@ -12,25 +14,31 @@ ex:NodeLabelShape
|
|
| 12 |
sh:path skos:prefLabel ;
|
| 13 |
sh:minCount 1 ;
|
| 14 |
sh:nodeKind sh:Literal ;
|
| 15 |
-
sh:message "Errore Topologico:
|
| 16 |
] .
|
| 17 |
|
| 18 |
-
#
|
|
|
|
|
|
|
|
|
|
| 19 |
ex:ObjectPropertyShape
|
| 20 |
a sh:NodeShape ;
|
| 21 |
-
sh:targetSubjectsOf skos:prefLabel ;
|
| 22 |
sh:property [
|
| 23 |
sh:path skos:related ;
|
| 24 |
sh:nodeKind sh:IRI ;
|
| 25 |
-
sh:message "Errore Semantico
|
| 26 |
] .
|
| 27 |
|
| 28 |
-
#
|
|
|
|
|
|
|
|
|
|
| 29 |
ex:TypeShape
|
| 30 |
a sh:NodeShape ;
|
| 31 |
sh:targetSubjectsOf rdf:type ;
|
| 32 |
sh:property [
|
| 33 |
sh:path rdf:type ;
|
| 34 |
sh:nodeKind sh:IRI ;
|
| 35 |
-
sh:message "Errore Ontologico:
|
| 36 |
] .
|
|
|
|
| 1 |
@prefix sh: <http://www.w3.org/ns/shacl#> .
|
| 2 |
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
|
| 3 |
+
@prefix ex: <http://activadigital.it/ontology/> .
|
| 4 |
@prefix arco: <https://w3id.org/arco/ontology/arco/> .
|
| 5 |
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
|
| 6 |
|
| 7 |
+
# --- REGOLA 1: Anti-nodi fantasma ---
|
| 8 |
+
# Il DB a grafo non deve riempirsi di nodi vuoti o corrotti. Se l'LLM decide di estrarre
|
| 9 |
+
# un'entità (soggetto o oggetto che sia), mi deve garantire che abbia una label di testo associata.
|
| 10 |
ex:NodeLabelShape
|
| 11 |
a sh:NodeShape ;
|
| 12 |
sh:targetSubjectsOf skos:prefLabel ;
|
|
|
|
| 14 |
sh:path skos:prefLabel ;
|
| 15 |
sh:minCount 1 ;
|
| 16 |
sh:nodeKind sh:Literal ;
|
| 17 |
+
sh:message "Errore Topologico: Il nodo estratto non ha un nome testuale. Impossibile creare l'entità in Neo4j."
|
| 18 |
] .
|
| 19 |
|
| 20 |
+
# --- REGOLA 2: Protezione Relazioni (No Datatype properties) ---
|
| 21 |
+
# Un classico limite degli LLM in ambito knowledge graph: confondono i nodi con le stringhe.
|
| 22 |
+
# Spesso tentano di fare (Soggetto) -[relazione]-> "Stringa di testo".
|
| 23 |
+
# Qui blindo la cosa: le relazioni semantiche devono SEMPRE puntare a un altro nodo fisico (IRI).
|
| 24 |
ex:ObjectPropertyShape
|
| 25 |
a sh:NodeShape ;
|
| 26 |
+
sh:targetSubjectsOf skos:prefLabel ;
|
| 27 |
sh:property [
|
| 28 |
sh:path skos:related ;
|
| 29 |
sh:nodeKind sh:IRI ;
|
| 30 |
+
sh:message "Errore Semantico: La relazione punta a un testo libero (Literal) invece che a un nodo (IRI)."
|
| 31 |
] .
|
| 32 |
|
| 33 |
+
# --- REGOLA 3: Tipizzazione rigorosa ---
|
| 34 |
+
# Se LLM prova a classificare un'entità usando rdf:type, l'oggetto DEVE essere
|
| 35 |
+
# un URI valido pescato dall'ontologia (es. arco:HistoricOrArtisticProperty).
|
| 36 |
+
# È severamente vietato inventarsi classi testuali tipo rdf:type -> "Monumento Antico".
|
| 37 |
ex:TypeShape
|
| 38 |
a sh:NodeShape ;
|
| 39 |
sh:targetSubjectsOf rdf:type ;
|
| 40 |
sh:property [
|
| 41 |
sh:path rdf:type ;
|
| 42 |
sh:nodeKind sh:IRI ;
|
| 43 |
+
sh:message "Errore Ontologico: L'LLM ha usato una stringa per rdf:type invece di un URI ufficiale di ArCo."
|
| 44 |
] .
|
src/validation/validator.py
CHANGED
|
@@ -5,15 +5,19 @@ from pyshacl import validate
|
|
| 5 |
|
| 6 |
class SemanticValidator:
|
| 7 |
def __init__(self):
|
|
|
|
|
|
|
| 8 |
self.shapes_file = os.path.join(os.path.dirname(__file__), "shapes/schema_constraints.ttl")
|
| 9 |
|
| 10 |
-
#
|
|
|
|
|
|
|
| 11 |
self.namespaces = {
|
| 12 |
"arco": Namespace("https://w3id.org/arco/ontology/arco/"),
|
| 13 |
"core": Namespace("https://w3id.org/arco/ontology/core/"),
|
| 14 |
"a-loc": Namespace("https://w3id.org/arco/ontology/location/"),
|
| 15 |
"cis": Namespace("http://dati.beniculturali.it/cis/"),
|
| 16 |
-
"ex": Namespace("http://
|
| 17 |
}
|
| 18 |
|
| 19 |
if os.path.exists(self.shapes_file):
|
|
@@ -21,69 +25,79 @@ class SemanticValidator:
|
|
| 21 |
self.shacl_graph.parse(self.shapes_file, format="turtle")
|
| 22 |
print("🛡️ SHACL Constraints caricati.")
|
| 23 |
else:
|
| 24 |
-
print("⚠️ File SHACL non trovato. Validazione disabilitata.")
|
| 25 |
self.shacl_graph = None
|
| 26 |
|
| 27 |
def _get_uri(self, text_val):
|
| 28 |
-
|
|
|
|
| 29 |
if ":" in text_val and not text_val.startswith("http"):
|
| 30 |
prefix, name = text_val.split(":", 1)
|
| 31 |
if prefix in self.namespaces:
|
| 32 |
return self.namespaces[prefix][name]
|
| 33 |
|
| 34 |
-
# Se è
|
|
|
|
| 35 |
clean_name = text_val.replace(" ", "_").replace("'", "").replace('"', "")
|
| 36 |
return self.namespaces["ex"][clean_name]
|
| 37 |
|
| 38 |
def _json_to_rdf(self, entities, triples):
|
| 39 |
-
|
|
|
|
| 40 |
g = Graph()
|
| 41 |
-
|
|
|
|
| 42 |
for prefix, ns in self.namespaces.items():
|
| 43 |
g.bind(prefix, ns)
|
| 44 |
g.bind("skos", SKOS)
|
| 45 |
|
| 46 |
-
# 1.
|
| 47 |
if entities:
|
| 48 |
for ent in entities:
|
|
|
|
| 49 |
label = ent["label"] if isinstance(ent, dict) else str(ent)
|
| 50 |
ent_uri = self._get_uri(label)
|
| 51 |
g.add((ent_uri, SKOS.prefLabel, Literal(label, lang="it")))
|
| 52 |
|
| 53 |
-
# 2.
|
| 54 |
if triples:
|
| 55 |
for t in triples:
|
| 56 |
subj_uri = self._get_uri(t.subject)
|
| 57 |
|
| 58 |
-
#
|
|
|
|
| 59 |
g.add((subj_uri, SKOS.prefLabel, Literal(t.subject, lang="it")))
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
obj_uri = self._get_uri(t.object)
|
| 64 |
g.add((subj_uri, RDF.type, obj_uri))
|
| 65 |
else:
|
| 66 |
-
#
|
| 67 |
pred_uri = self._get_uri(t.predicate)
|
| 68 |
obj_uri = self._get_uri(t.object)
|
| 69 |
|
| 70 |
g.add((subj_uri, pred_uri, obj_uri))
|
|
|
|
| 71 |
g.add((obj_uri, SKOS.prefLabel, Literal(t.object, lang="it")))
|
| 72 |
|
| 73 |
return g
|
| 74 |
|
| 75 |
def validate_batch(self, entities, triples):
|
| 76 |
"""
|
| 77 |
-
|
| 78 |
-
Ritorna
|
| 79 |
"""
|
| 80 |
if not self.shacl_graph:
|
| 81 |
return True, "No Constraints", None
|
| 82 |
|
| 83 |
-
#
|
| 84 |
data_graph = self._json_to_rdf(entities, triples)
|
| 85 |
|
| 86 |
print("🔍 Esecuzione Validazione SHACL...")
|
|
|
|
|
|
|
|
|
|
| 87 |
conforms, report_graph, report_text = validate(
|
| 88 |
data_graph,
|
| 89 |
shacl_graph=self.shacl_graph,
|
|
|
|
| 5 |
|
| 6 |
class SemanticValidator:
|
| 7 |
def __init__(self):
|
| 8 |
+
# Carico le regole SHACL.
|
| 9 |
+
# Se l'LLM ha un'allucinazione e inventa relazioni assurde, SHACL lo blocca qui.
|
| 10 |
self.shapes_file = os.path.join(os.path.dirname(__file__), "shapes/schema_constraints.ttl")
|
| 11 |
|
| 12 |
+
# Mappatura dei namespace di ArCo.
|
| 13 |
+
# Il namespace 'ex' ci serve come discarica/fallback per tutte le entità testuali pure
|
| 14 |
+
# (es. "Colosseo", "Monumento") che l'LLM non ha saputo ancorare a un'URI ufficiale.
|
| 15 |
self.namespaces = {
|
| 16 |
"arco": Namespace("https://w3id.org/arco/ontology/arco/"),
|
| 17 |
"core": Namespace("https://w3id.org/arco/ontology/core/"),
|
| 18 |
"a-loc": Namespace("https://w3id.org/arco/ontology/location/"),
|
| 19 |
"cis": Namespace("http://dati.beniculturali.it/cis/"),
|
| 20 |
+
"ex": Namespace("http://activadigital.it/ontology/")
|
| 21 |
}
|
| 22 |
|
| 23 |
if os.path.exists(self.shapes_file):
|
|
|
|
| 25 |
self.shacl_graph.parse(self.shapes_file, format="turtle")
|
| 26 |
print("🛡️ SHACL Constraints caricati.")
|
| 27 |
else:
|
| 28 |
+
print("⚠️ File SHACL non trovato. Validazione disabilitata (pericoloso in prod!).")
|
| 29 |
self.shacl_graph = None
|
| 30 |
|
| 31 |
def _get_uri(self, text_val):
|
| 32 |
+
# L'LLM ci restituisce stringhe come "arco:CulturalProperty" o semplice testo "Statua di bronzo".
|
| 33 |
+
# rdflib ha bisogno di URIRef veri, quindi faccio un po' di parsing per convertirli.
|
| 34 |
if ":" in text_val and not text_val.startswith("http"):
|
| 35 |
prefix, name = text_val.split(":", 1)
|
| 36 |
if prefix in self.namespaces:
|
| 37 |
return self.namespaces[prefix][name]
|
| 38 |
|
| 39 |
+
# Se è testo libero senza namespace, lo ripulisco per evitare che gli spazi
|
| 40 |
+
# rompano l'URI e lo forzo nel nostro namespace custom.
|
| 41 |
clean_name = text_val.replace(" ", "_").replace("'", "").replace('"', "")
|
| 42 |
return self.namespaces["ex"][clean_name]
|
| 43 |
|
| 44 |
def _json_to_rdf(self, entities, triples):
|
| 45 |
+
# Il validatore pyshacl non digerisce i nostri oggetti Pydantic o i JSON nativi.
|
| 46 |
+
# Devo ricostruire un micro-grafo RDF al volo solo per fargli fare il check formale.
|
| 47 |
g = Graph()
|
| 48 |
+
|
| 49 |
+
# Registro i prefissi nel grafo per facilitare l'eventuale debug testuale
|
| 50 |
for prefix, ns in self.namespaces.items():
|
| 51 |
g.bind(prefix, ns)
|
| 52 |
g.bind("skos", SKOS)
|
| 53 |
|
| 54 |
+
# 1. Recupero entità orfane (trovate nel testo ma non agganciate a nessuna tripla)
|
| 55 |
if entities:
|
| 56 |
for ent in entities:
|
| 57 |
+
# Gestisco il tipo di dato a seconda di cosa è uscito dal resolver
|
| 58 |
label = ent["label"] if isinstance(ent, dict) else str(ent)
|
| 59 |
ent_uri = self._get_uri(label)
|
| 60 |
g.add((ent_uri, SKOS.prefLabel, Literal(label, lang="it")))
|
| 61 |
|
| 62 |
+
# 2. Ricostruzione delle Triple relazionali
|
| 63 |
if triples:
|
| 64 |
for t in triples:
|
| 65 |
subj_uri = self._get_uri(t.subject)
|
| 66 |
|
| 67 |
+
# Le nostre regole SHACL (schema_constraints.ttl) esigono tipicamente che i nodi
|
| 68 |
+
# non siano scatole vuote (NodeLabelShape). Ci appiccico sempre la prefLabel in italiano.
|
| 69 |
g.add((subj_uri, SKOS.prefLabel, Literal(t.subject, lang="it")))
|
| 70 |
|
| 71 |
+
# Separo le classificazioni dalle relazioni standard
|
| 72 |
+
if t.predicate.lower() in ["rdf:type", "a", "type", "rdf_type"]:
|
| 73 |
obj_uri = self._get_uri(t.object)
|
| 74 |
g.add((subj_uri, RDF.type, obj_uri))
|
| 75 |
else:
|
| 76 |
+
# Relazione standard (es. a-loc:hasCurrentLocation)
|
| 77 |
pred_uri = self._get_uri(t.predicate)
|
| 78 |
obj_uri = self._get_uri(t.object)
|
| 79 |
|
| 80 |
g.add((subj_uri, pred_uri, obj_uri))
|
| 81 |
+
# Anche il nodo di destinazione deve avere un nome umano
|
| 82 |
g.add((obj_uri, SKOS.prefLabel, Literal(t.object, lang="it")))
|
| 83 |
|
| 84 |
return g
|
| 85 |
|
| 86 |
def validate_batch(self, entities, triples):
|
| 87 |
"""
|
| 88 |
+
Scatena il motore di regole SHACL sia sulle entità isolate che sulle triple.
|
| 89 |
+
Ritorna l'esito, il report testuale degli errori, e il grafo temporaneo.
|
| 90 |
"""
|
| 91 |
if not self.shacl_graph:
|
| 92 |
return True, "No Constraints", None
|
| 93 |
|
| 94 |
+
# Converto la pappa di Pydantic in un vero grafo RDF
|
| 95 |
data_graph = self._json_to_rdf(entities, triples)
|
| 96 |
|
| 97 |
print("🔍 Esecuzione Validazione SHACL...")
|
| 98 |
+
|
| 99 |
+
# Abilito inference='rdfs' così se una regola si applica a una super-classe,
|
| 100 |
+
# pyshacl lo deduce da solo scendendo l'albero gerarchico.
|
| 101 |
conforms, report_graph, report_text = validate(
|
| 102 |
data_graph,
|
| 103 |
shacl_graph=self.shacl_graph,
|