GaetanoParente commited on
Commit
c1b1880
·
1 Parent(s): 2fe50b2

riviste le varie sezioni e i commenti

Browse files
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": [] # Restituisco un array vuoto invece di fallire
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 univoco ma stabile per il nodo di partenza basato sul suo nome.
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

  • SHA256: 2dfc5556b114b807280e5c481620869d02a2ff31942ac6940afa09b72e2fc64c
  • Pointer size: 131 Bytes
  • Size of remote file: 115 kB
docs/validation.png DELETED

Git LFS Details

  • SHA256: bafaa62dbbacfbde6b66596ae45ad777fd762b0d8d23ac9511868d33ae41f36b
  • Pointer size: 131 Bytes
  • Size of remote file: 128 kB
docs/workflow.png DELETED

Git LFS Details

  • SHA256: f89a2b96d85509a12c87f42ebf40496bff7a892d86ed6d6f3ff664307099fbfc
  • Pointer size: 131 Bytes
  • Size of remote file: 511 kB
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
- load_dotenv() # in locale carica il file .env , su HF non trovando il file utilizza i secrets inseriti nella sezione settings.
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
- # --- ESTRATTORE DINAMICO (Schema-RAG) ---
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=os.getenv("GROQ_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
- # Modello Embedding per la selezione dinamica
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
- # Template Specializzato con regole di Graceful Degradation
90
- self.system_template_base = """Sei un Agente Cognitivo per l'estrazione dati (Information Extraction).
 
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 rilevanti e inseriscili nell'array "entities" (anche se non sai come collegarli).
95
- 2. Per creare le "triples", puoi usare ESCLUSIVAMENTE le seguenti Classi (per rdf:type) e Proprietà che sono pertinenti a questo testo:
96
 
97
- CLASSI CONSENTITE (usa come oggetto quando predicate = rdf:type):
98
  {retrieved_classes}
99
 
100
- PROPRIETÀ CONSENTITE (usa come predicate):
101
  {retrieved_properties}
102
 
103
- REGOLE DI GRACEFUL DEGRADATION E ANTI-ALLUCINAZIONE (CRITICO):
104
- - Relazioni (Fallback): Se due entità sono correlate ma nessuna delle proprietà fornite è adatta al contesto esatto, non inventare predicati. Usa il predicato 'skos:related'.
105
- - Classificazione (rdf:type): Se non trovi una Classe specifica esatta tra quelle fornite per tipizzare un'entità, NON FORZARE la classificazione in classi errate. Usa i tipi di salvataggio universali: 'core:Agent' per le persone/popoli, 'core:Concept' per concetti astratti/materiali, 'l0:Location' per i luoghi geografici.
106
- - Entità Orfane: Se sei in forte dubbio su come collegare o classificare un'entità testuale, limitati a inserirla nell'array "entities" come orfana senza creare alcuna tripla. Non inquinare il grafo con dati inesatti.
 
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": "Classe Consentita", "confidence": 0.9}},
114
- {{"subject": "Entità 1", "predicate": "Proprietà Consentita", "object": "Entità 2", "confidence": 0.8}}
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
- # Vettorizziamo le descrizioni semantiche delle classi/proprietà
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=3, top_k_props=4):
 
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
- # Ordiniamo gli indici per similarità
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
- properties.append(f"- {element['id']}: {element['description']}")
 
 
 
 
 
 
 
 
 
 
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 dello schema basato sul testo
156
  retrieved_classes, retrieved_properties = self._retrieve_schema(text_chunk)
157
 
158
- # 2. Iniezione nel prompt
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
- # Pulizia base se il modello chiacchiera prima del JSON
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
- # Normalizzazione output
192
  if isinstance(data, list):
193
  validated_data = KnowledgeGraphExtraction(triples=data, reasoning="Direct list output")
194
  else:
195
- # Filtra campi extra che il modello potrebbe inventare
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
- """Interroga l'indice vettoriale di Neo4j per trovare il nodo più simile."""
 
 
 
 
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
- Interroga l'API di Wikidata per trovare un match diretto (Entity Linking).
34
- Ritorna l'URI di Wikidata (es. wd:Q12345) o None.
 
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 # Prendo solo il best match per la riconciliazione automatica
43
  }
44
  try:
45
- # Timeout breve per non bloccare la pipeline se Wikidata è lento
 
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
- # Raccolgo tutte le entità uniche dal chunk corrente
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
- # Calcolo gli embedding per il batch locale
74
  embeddings = self.embedding_model.embed_documents(unique_chunk_entities)
75
 
76
- # Local Batch Deduplication
 
 
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 = [] # Array di {label, embedding, wikidata_sameAs}
87
 
88
- # Global Database Resolution & Wikidata Linking
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: Neo4j conosce già questa entità (ha già il suo embedding e potenziale URI)
98
  final_canonical = db_canonical_name
99
  print(f" 🔗 Match Globale: '{local_canonical_name}' -> '{db_canonical_name}' (Neo4j)")
100
  else:
101
- # Caso B: È un'entità veramente nuova. Tento l'Entity Linking!
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
- # Mappo le varianti locali al canonico
121
  for item in items:
122
  entity_replacement_map[item["name"]] = final_canonical
123
 
124
- # Riscrittura Output
 
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
- load_dotenv() # in locale carica il file .env , su HF non trovando il file utilizza i secrets inseriti nella sezione settings.
 
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
- # Creazione indici all'avvio (Fondamentale per la velocità dei MERGE)
 
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
- Pulisce il predicato per evitare Cypher Injection.
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
- # Conversione in uppercase (convenzione Neo4j per Relationships)
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
- # Raggruppamento per Predicato
 
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
- # Salvataggio Nodi (anche senza relazioni, includendo l'embedding)
 
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
- Usa UNWIND per inserire migliaia di righe in un colpo solo.
159
- """
160
- query = (
161
- f"UNWIND $batch AS row "
162
- f"MERGE (s:Resource {{uri: row.subj_uri}}) "
163
- f"ON CREATE SET s.label = row.subj_label "
164
- f"MERGE (o:Resource {{uri: row.obj_uri}}) "
165
- f"ON CREATE SET o.label = row.obj_label "
166
- f"MERGE (s)-[r:`{predicate}`]->(o) "
167
- f"SET r.confidence = row.conf, "
168
- f" r.source = row.src, "
169
- f" r.last_updated = datetime()"
170
- )
171
-
172
- tx.run(query, batch=batch_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- load_dotenv() # in locale carica il file .env , su HF non trovando il file utilizza i secrets inseriti nella sezione settings.
 
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
- import nltk
30
- # Controllo che i dati ci siano
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 pickle path non viene risolto automaticamente
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 = identiche, 1 = completamente diverse)
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
- # Un breakpoint avviene quando la distanza supera la soglia
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 di tutti i moduli dell'ontologia
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
- try:
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 per estrarre Classi e ObjectProperties con le loro descrizioni in italiano
 
 
 
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. Elaborazione e formattazione dei risultati
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
- # Trasformiamo l'URI lungo in un prefisso leggibile (es. arco:CulturalProperty)
75
- try:
76
- prefix, namespace, name = g.compute_qname(entity_uri)
77
- qname = f"{prefix}:{name}"
78
- except Exception:
79
- # Fallback se non riesce a calcolare il prefisso
80
- qname = str(entity_uri).split('/')[-1].split('#')[-1]
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
- # Se una classe non ha label commento, la scartiamo per non confondere l'LLM
 
90
  if not final_description.strip():
91
  continue
92
 
93
- # Usiamo un dizionario per evitare duplicati (spesso le ontologie definiscono la stessa classe in più file)
94
  if qname not in schema_elements:
95
- schema_elements[qname] = {
96
  "id": qname,
97
  "type": entity_type,
98
  "description": final_description.strip()
99
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
 
101
- # 4. Salvataggio in JSON
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://activa.ai/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
- # 1. REGOLA BASE: Ogni entità (soggetto o oggetto) deve avere un nome testuale (Label)
 
 
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: Ogni entità nel grafo deve possedere un nome leggibile."
16
  ] .
17
 
18
- # 2. REGOLA RELAZIONALE: Le proprietà non devono puntare a testi (Literal), ma ad altri nodi (IRI)
 
 
 
19
  ex:ObjectPropertyShape
20
  a sh:NodeShape ;
21
- sh:targetSubjectsOf skos:prefLabel ; # Si applica a tutti i nodi
22
  sh:property [
23
  sh:path skos:related ;
24
  sh:nodeKind sh:IRI ;
25
- sh:message "Errore Semantico (skos:related): Le relazioni generiche devono collegare due nodi distinti, non un nodo a un testo."
26
  ] .
27
 
28
- # 3. REGOLA ONTOLOGICA: Se un nodo ha un rdf:type, deve essere un IRI (es. arco:CulturalProperty)
 
 
 
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: La classe assegnata tramite rdf:type deve essere un URI valido dell'ontologia, non una stringa."
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
- # Dizionario dei Namespace ufficiali di ArCo e fallback
 
 
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://activa.ai/ontology/") # Fallback per le entità
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
- """Metodo di supporto per tradurre un testo 'prefisso:nome' in un URIRef reale."""
 
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 è un'entità senza prefisso (es. "Menhir di Canne"), uso il namespace custom
 
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
- """Converte dinamicamente rispettando l'ontologia ArCo."""
 
40
  g = Graph()
41
- # Registriamo i prefissi nel grafo per leggibilità
 
42
  for prefix, ns in self.namespaces.items():
43
  g.bind(prefix, ns)
44
  g.bind("skos", SKOS)
45
 
46
- # 1. Popolamento Entità Isolate (Orfani)
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. Popolamento delle Triple
54
  if triples:
55
  for t in triples:
56
  subj_uri = self._get_uri(t.subject)
57
 
58
- # Assicuriamoci che ogni nodo abbia un nome leggibile
 
59
  g.add((subj_uri, SKOS.prefLabel, Literal(t.subject, lang="it")))
60
 
61
- if t.predicate in ["rdf:type", "a", "type"]:
62
- # Se l'LLM sta classificando il nodo (es. oggetto = arco:CulturalProperty)
63
  obj_uri = self._get_uri(t.object)
64
  g.add((subj_uri, RDF.type, obj_uri))
65
  else:
66
- # Se è una relazione standard (es. a-loc:hasCurrentLocation)
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
- Esegue la validazione SHACL sia sulle entità isolate che sulle triple.
78
- Ritorna (is_valid, report_text, rdf_graph)
79
  """
80
  if not self.shacl_graph:
81
  return True, "No Constraints", None
82
 
83
- # Passiamo entrambe le liste al convertitore
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,