GaetanoParente commited on
Commit
b70d82f
·
1 Parent(s): 5ca1355

avvio refactoring

Browse files
api.py CHANGED
@@ -11,6 +11,7 @@ from src.extraction.extractor import NeuroSymbolicExtractor
11
  from src.validation.validator import SemanticValidator
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.
@@ -24,8 +25,7 @@ async def lifespan(app: FastAPI):
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)
@@ -33,6 +33,24 @@ async def lifespan(app: FastAPI):
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.
@@ -51,7 +69,26 @@ app = FastAPI(
51
  class DiscoveryRequest(BaseModel):
52
  documentText: str
53
 
54
- @app.post("/api/discover")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def run_discovery(payload: DiscoveryRequest):
56
  start_time = time.time()
57
  raw_text = payload.documentText
@@ -102,56 +139,67 @@ def run_discovery(payload: DiscoveryRequest):
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:
111
- print("\n✅ [SHACL VALIDATION SUCCESS] Tutte le triple ed entità rispettano i vincoli.")
 
 
 
 
 
 
 
 
 
 
 
112
 
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))
123
- pred = getattr(t, 'predicate', t[1] if isinstance(t, tuple) else '')
124
- obj = getattr(t, 'object', t[2] if isinstance(t, tuple) else '')
125
-
126
- if isinstance(t, tuple) and len(t) > 3:
127
- conf = t[3]
128
- else:
129
- conf = getattr(t, 'confidence', 1.0)
130
 
131
- subj_str = str(subj)
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({
139
- "start_node_id": node_id,
140
- "start_node_label": subj_str,
141
- "relationship_type": pred_str,
142
- "end_node_label": obj_str,
143
- "confidence": float(conf)
144
- })
145
-
146
- return {
147
- "status": "success",
148
- "message": "Estrazione semantica completata",
149
- "execution_time_seconds": round(time.time() - start_time, 2),
150
- "chunks_processed": len(chunks),
151
- "triples_extracted": len(graph_data),
152
- "shacl_valid": is_valid,
153
- "graph_data": graph_data
154
- }
 
 
 
155
 
156
  if __name__ == "__main__":
157
  uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True)
 
11
  from src.validation.validator import SemanticValidator
12
  from src.graph.graph_loader import KnowledgeGraphPersister
13
  from src.graph.entity_resolver import EntityResolver
14
+ from pymongo import MongoClient
15
 
16
  # --- GESTORE DEGLI STATI GLOBALI ---
17
  # Usiamo un dizionario globale per tenere in RAM i pesi dei modelli.
 
25
 
26
  ml_models["splitter"] = ActivaSemanticSplitter(model_name="all-MiniLM-L6-v2")
27
 
28
+ ml_models["extractor"] = NeuroSymbolicExtractor(index_path="ontology/domain_index.json")
 
29
 
30
  ml_models["persister"] = KnowledgeGraphPersister()
31
  ml_models["resolver"] = EntityResolver(neo4j_driver=ml_models["persister"].driver, similarity_threshold=0.85)
 
33
 
34
  print("✅ Modelli caricati e pronti a ricevere richieste!")
35
 
36
+ # Setup connessione MongoDB per i log degli scarti
37
+ mongo_ur = os.getenv("MONGO_URI")
38
+ mongo_user = os.getenv("MONGO_USER")
39
+ mongo_pass = os.getenv("MONGO_PASS")
40
+
41
+ if mongo_ur and mongo_user and mongo_pass:
42
+ try:
43
+ client = MongoClient(mongo_ur, username=mongo_user, password=mongo_pass)
44
+ # Creo il database "semantic_discovery" e la collection "rejected_triples"
45
+ ml_models["mongo_db"] = client["semantic_discovery"]["rejected_triples"]
46
+ print("✅ Connesso a MongoDB per lo storage delle allucinazioni LLM.")
47
+ except Exception as e:
48
+ print(f"⚠️ Errore connessione MongoDB: {e}")
49
+ ml_models["mongo_db"] = None
50
+ else:
51
+ print("⚠️ Credenziali MongoDB mancanti. Gli scarti non verranno tracciati.")
52
+ ml_models["mongo_db"] = None
53
+
54
  yield # Qui l'API inizia ad ascoltare le chiamate in ingresso
55
 
56
  # Chiusura pulita delle connessioni. Evita query appese su Neo4j quando killiamo il container.
 
69
  class DiscoveryRequest(BaseModel):
70
  documentText: str
71
 
72
+ class GraphEdge(BaseModel):
73
+ start_node_id: str
74
+ start_node_label: str
75
+ start_node_type: str
76
+ relationship_type: str
77
+ end_node_label: str
78
+ end_node_type: str
79
+ evidence: str
80
+ reasoning: str
81
+
82
+ class DiscoveryResponse(BaseModel):
83
+ status: str
84
+ message: str
85
+ execution_time_seconds: float
86
+ chunks_processed: int
87
+ triples_extracted: int
88
+ shacl_valid: bool
89
+ graph_data: list[GraphEdge]
90
+
91
+ @app.post("/api/discover", response_model=DiscoveryResponse)
92
  def run_discovery(payload: DiscoveryRequest):
93
  start_time = time.time()
94
  raw_text = payload.documentText
 
139
  # --- FASE 2.2: VALIDATION ---
140
  # Prima di salvare nel DB, verifico con SHACL
141
  # se l'LLM ha generato allucinazioni o violato i vincoli dell'ontologia.
142
+ valid_triples, invalid_triples, report = validator.filter_valid_triples(entities_to_save, all_triples)
143
+
144
+ if invalid_triples:
145
+ print(f"\n❌ [SHACL FAILED] Scartate {len(invalid_triples)} triple per violazione di Domain/Range.")
146
+
147
+ # Salvataggio asincrono degli scarti su MongoDB (DLQ)
148
+ if ml_models.get("mongo_db") is not None:
149
+ try:
150
+ # Aggiungo un timestamp per rintracciabilità
151
+ for doc in invalid_triples:
152
+ doc["timestamp"] = time.time()
153
+ ml_models["mongo_db"].insert_many(invalid_triples)
154
+ print("💾 Triple invalide archiviate su MongoDB.")
155
+ except Exception as e:
156
+ print(f"⚠️ Errore scrittura su Mongo: {e}")
157
+
158
+ if len(valid_triples) == len(all_triples) and all_triples:
159
+ print("\n✅ [SHACL SUCCESS] Tutte le triple rispettano rigorosamente l'ontologia.")
160
 
161
  # --- FASE 3: PERSISTENCE (Neo4j) ---
162
  try:
163
+ # Cruciale: passiamo SOLO le valid_triples al database a grafo
164
+ persister.save_entities_and_triples(entities_to_save, valid_triples)
165
  except Exception as e:
166
  print(f"⚠️ Errore salvataggio Neo4j: {e}")
167
 
168
  # Preparazione payload di risposta
169
  graph_data = []
170
+
171
+ for t in valid_triples:
172
+ # Pydantic ci garantisce che i campi esistano
173
+ subj_str = str(t.subject)
174
+ obj_str = str(t.object)
 
 
 
 
175
 
176
+ # Formattazione della relazione (es. "a-loc:isLocatedIn" -> "A_LOC_ISLOCATEDIN")
177
+ # in coerenza con la convenzione Neo4j gestita dal loader
178
+ pred_str = str(t.predicate).replace(":", "_").replace("-", "_").upper()
179
 
180
  # Genero un ID stabile per facilitare il rendering dei nodi lato client
181
  node_id = hashlib.md5(subj_str.encode('utf-8')).hexdigest()
182
 
183
+ graph_data.append(GraphEdge(
184
+ start_node_id=node_id,
185
+ start_node_label=subj_str,
186
+ start_node_type=str(t.subject_type),
187
+ relationship_type=pred_str,
188
+ end_node_label=obj_str,
189
+ end_node_type=str(t.object_type),
190
+ evidence=str(t.evidence),
191
+ reasoning=str(t.reasoning)
192
+ ))
193
+
194
+ return DiscoveryResponse(
195
+ status="success",
196
+ message="Estrazione semantica completata",
197
+ execution_time_seconds=round(time.time() - start_time, 2),
198
+ chunks_processed=len(chunks),
199
+ triples_extracted=len(graph_data),
200
+ shacl_valid=len(invalid_triples) == 0, # True se nessuna tripla è stata scartata
201
+ graph_data=graph_data
202
+ )
203
 
204
  if __name__ == "__main__":
205
  uvicorn.run("api:app", host="0.0.0.0", port=5000, reload=True)
app.py CHANGED
@@ -1,7 +1,9 @@
1
  import streamlit as st
2
  import os
 
3
  import tempfile
4
  import pandas as pd
 
5
  from neo4j import GraphDatabase
6
  from pyvis.network import Network
7
  import streamlit.components.v1 as components
@@ -24,12 +26,13 @@ st.set_page_config(
24
  )
25
 
26
  def local_css(file_name):
27
- with open(file_name, "r") as f:
28
- st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
 
29
 
30
  local_css("assets/style.css")
31
 
32
- # --- SESSION STATE MANAGEMENT (In-Memory per HF Spaces) ---
33
  if 'pipeline_stage' not in st.session_state:
34
  st.session_state.pipeline_stage = 0
35
  if 'document_text' not in st.session_state:
@@ -54,8 +57,7 @@ def get_splitter():
54
 
55
  @st.cache_resource
56
  def get_extractor():
57
- schema_path = os.path.join("data", "schemas", "ARCO_schema.json")
58
- return NeuroSymbolicExtractor(model_name="llama3", schema_path=schema_path)
59
 
60
  @st.cache_resource(show_spinner="🧩 Inizializzazione Entity Resolver...")
61
  def get_resolver():
@@ -63,9 +65,12 @@ def get_resolver():
63
 
64
  @st.cache_resource
65
  def get_validator():
66
- return SemanticValidator()
 
 
 
67
 
68
- #carico subito i vari oggetti così da evitare rallentamenti nelle varie fasi della pipeline
69
  _ = get_splitter()
70
  _ = get_extractor()
71
  _ = get_validator()
@@ -91,23 +96,21 @@ st.sidebar.title("⚙️ Configurazione")
91
  env_uri = os.getenv("NEO4J_URI", "")
92
  env_user = os.getenv("NEO4J_USER", "neo4j")
93
  env_password = os.getenv("NEO4J_PASSWORD", "")
94
- env_hf_token = os.getenv("HF_TOKEN", "")
95
 
96
- st.sidebar.subheader("Backend AI")
97
- if env_hf_token:
98
- st.sidebar.success("✅ HF Token: Configurato da Secrets")
99
- hf_token_input = st.sidebar.text_input("Sovrascrivi Token (Opzionale)", type="password", key="hf_token_override")
100
- if hf_token_input: os.environ["HF_TOKEN"] = hf_token_input
101
  else:
102
- hf_token_input = st.sidebar.text_input("Inserisci HF Token", type="password")
103
- if hf_token_input: os.environ["HF_TOKEN"] = hf_token_input
104
 
105
  st.sidebar.subheader("Knowledge Graph")
106
- uri = st.sidebar.text_input("URI", value=env_uri)
107
- user = st.sidebar.text_input("User", value=env_user)
108
 
109
- pwd_placeholder = "✅ Configurato (Lascia vuoto)" if env_password else "Inserisci Password"
110
- password_input = st.sidebar.text_input("Password", type="password", placeholder=pwd_placeholder)
111
  password = password_input if password_input else env_password
112
 
113
  driver = None
@@ -119,7 +122,7 @@ if uri and password:
119
  os.environ["NEO4J_USER"] = user
120
  os.environ["NEO4J_PASSWORD"] = password
121
  else:
122
- st.sidebar.error("🔴 Errore connessione")
123
 
124
  st.sidebar.divider()
125
  if st.sidebar.button("🔄 Reset Pipeline", on_click=reset_pipeline):
@@ -127,11 +130,11 @@ if st.sidebar.button("🔄 Reset Pipeline", on_click=reset_pipeline):
127
 
128
  # --- MAIN HEADER ---
129
  st.title("🧠 Automated Semantic Discovery Prototype")
130
- st.markdown("**Endpoint per l'ingestion testuale e l'estrazione neuro-simbolica**")
131
 
132
  tab_gen, tab_val, tab_vis = st.tabs([
133
  "⚙️ 1. Pipeline Generativa",
134
- "🔍 2. Validazione (HITL)",
135
  "🕸️ 3. Esplorazione Grafo"
136
  ])
137
 
@@ -163,27 +166,17 @@ with tab_gen:
163
  st.markdown(f"### {'✅' if st.session_state.pipeline_stage >= 1 else '1️⃣'} Fase 1: Semantic Chunking")
164
 
165
  with st.expander("ℹ️ Cosa fa questa fase?"):
166
- st.write("Segmenta il testo in frammenti coerenti analizzando la similarità semantica vettoriale tra le frasi. " \
167
- "A differenza di un taglio rigido per numero di parole, questo approccio garantisce che i concetti non vengano interrotti bruscamente, " \
168
- "ottimizzando il contesto per l'LLM.")
169
 
170
  if st.session_state.pipeline_stage >= 1:
171
  chunks = st.session_state.chunks
172
- st.markdown(f"""
173
- <div class="success-box">
174
- <b>Chunking completato!</b> Generati {len(chunks)} frammenti semantici.
175
- </div>
176
- """, unsafe_allow_html=True)
177
- with st.expander("Vedi dettagli frammenti"):
178
- st.json(chunks)
179
  else:
180
  if st.button("Avvia Semantic Splitter", type="primary"):
181
  with st.spinner("Creazione chunks in corso..."):
182
  try:
183
  splitter = get_splitter()
184
  chunks, _, _ = splitter.create_chunks(input_text, percentile_threshold=90)
185
-
186
- # Salvataggio in-memory
187
  st.session_state.chunks = chunks
188
  st.session_state.pipeline_stage = 1
189
  st.rerun()
@@ -193,110 +186,120 @@ with tab_gen:
193
  st.markdown("⬇️")
194
 
195
  # ==========================
196
- # FASE 2: EXTRACTION
197
  # ==========================
198
  is_step_b_unlocked = st.session_state.pipeline_stage >= 1
199
 
200
  with st.container():
201
  color = "white" if is_step_b_unlocked else "gray"
202
  icon = "✅" if st.session_state.pipeline_stage >= 2 else ("2️⃣" if is_step_b_unlocked else "🔒")
203
- st.markdown(f"<h3 style='color:{color}'>{icon} Fase 2: Neuro-Symbolic Extraction</h3>", unsafe_allow_html=True)
204
 
205
  with st.expander("ℹ️ Cosa fa questa fase?"):
206
- st.write("Invia i frammenti al Large Language Model (es. Llama 3) per estrarre dinamicamente entità e relazioni. " \
207
- "L'approccio Neuro-Simbolico forza l'output del modello a rispettare una struttura dati rigorosa (JSON tipizzato) prima di procedere.")
208
 
209
  if not is_step_b_unlocked:
210
  st.caption("Completa la Fase 1 per sbloccare l'estrazione.")
211
  elif st.session_state.pipeline_stage >= 2:
212
  data = st.session_state.extraction_data
213
- st.markdown(f"""
214
- <div class="success-box">
215
- <b>Estrazione completata!</b> Identificate {len(data['entities'])} entità e {len(data['triples'])} triple.
216
- </div>
217
- """, unsafe_allow_html=True)
218
- with st.expander("Vedi dati estratti"):
219
- st.write("Entità Trovate:", data['entities'])
220
- st.dataframe(pd.DataFrame(data['triples']), hide_index=True)
221
  else:
222
- if st.button("Avvia Estrazione Ontologica", type="primary"):
223
- with st.spinner("Invocazione modello sui frammenti..."):
224
- try:
225
- chunks = st.session_state.chunks
226
- extractor = get_extractor()
227
- all_triples = []
228
- all_entities = []
229
- prog_bar = st.progress(0)
230
-
231
- for i, chunk in enumerate(chunks):
232
- chunk_id = f"st_req_chunk_{i+1}"
233
- res = extractor.extract(chunk, source_id=chunk_id)
234
 
235
- if res:
236
- if res.triples: all_triples.extend([t.model_dump() for t in res.triples])
237
- if res.entities: all_entities.extend(res.entities)
238
 
239
- prog_bar.progress((i+1)/len(chunks))
240
-
241
- # Salvataggio in-memory
242
- st.session_state.extraction_data = {"entities": all_entities, "triples": all_triples}
243
- st.session_state.pipeline_stage = 2
244
- st.rerun()
245
- except Exception as e:
246
- st.error(f"Errore: {e}")
 
 
 
 
 
247
 
248
  st.markdown("⬇️")
249
 
250
  # ==========================
251
- # FASE 3: RESOLUTION & PERSISTENCE
252
  # ==========================
253
  is_step_c_unlocked = st.session_state.pipeline_stage >= 2
254
 
255
  with st.container():
256
  color = "white" if is_step_c_unlocked else "gray"
257
  icon = "✅" if st.session_state.pipeline_stage >= 3 else ("3️⃣" if is_step_c_unlocked else "🔒")
258
- st.markdown(f"<h3 style='color:{color}'>{icon} Fase 3: Resolution, Validation & Graph Population</h3>", unsafe_allow_html=True)
259
 
260
  with st.expander("ℹ️ Cosa fa questa fase?"):
261
- st.write("Unisce ed elimina i duplicati delle entità (Entity Resolution) sfruttando i Vector Index di Neo4j e chiamate esterne. " \
262
- "Successivamente, applica regole deterministiche (SHACL) per validare le triple estratte e le salva permanentemente nel database a grafo.")
263
 
264
  if not is_step_c_unlocked:
265
  st.caption("Completa la Fase 2 per procedere.")
266
  elif st.session_state.pipeline_stage >= 3:
267
- st.markdown("""
268
- <div class="success-box">
269
- <b>Grafo Aggiornato!</b> I dati sono stati validati e caricati su Neo4j.
270
- </div>
271
- """, unsafe_allow_html=True)
272
  else:
273
  if not driver:
274
  st.error("⚠️ Connettiti a Neo4j (nella sidebar) per procedere.")
275
  else:
276
- if st.button("Genera e Valida Knowledge Graph", type="primary"):
277
- with st.spinner("Risoluzione entità, validazione SHACL e scrittura..."):
278
  try:
279
  raw_data = st.session_state.extraction_data
280
  all_entities = raw_data.get("entities", [])
281
- all_triples = [GraphTriple(**t) for t in raw_data.get("triples", [])]
282
 
283
  resolver = get_resolver()
284
  resolver.driver = driver
285
- all_entities, all_triples, entities_to_save = resolver.resolve_entities(all_entities, all_triples)
286
 
287
  validator = get_validator()
288
- is_valid, report, _ = validator.validate_batch(entities_to_save, all_triples)
289
 
290
- if not is_valid:
291
- st.markdown(f"""
292
- <div class="warning-box">
293
- <b>Attenzione:</b> La validazione SHACL ha rilevato violazioni. Guarda il log console per i dettagli.
294
- </div>
295
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
 
297
- persister = KnowledgeGraphPersister()
298
- persister.save_entities_and_triples(entities_to_save, all_triples)
299
- persister.close()
 
300
 
301
  st.session_state.pipeline_stage = 3
302
  st.rerun()
@@ -304,7 +307,7 @@ with tab_gen:
304
  st.error(f"Errore critico: {e}")
305
 
306
  # ==============================================================================
307
- # TAB 2 & 3: VALIDAZIONE E VISUALIZZAZIONE
308
  # ==============================================================================
309
  with tab_val:
310
  st.header("Curation & Feedback Loop")
@@ -320,9 +323,10 @@ with tab_val:
320
  RETURN elementId(r) as id,
321
  COALESCE(s.label, s.name, head(labels(s))) as Soggetto,
322
  type(r) as Predicato,
323
- COALESCE(o.label, o.name, head(labels(o))) as Oggetto,
324
- COALESCE(r.confidence, 1.0) as Confidenza
325
- ORDER BY Confidenza ASC
 
326
  """
327
  triples_data = run_query(driver, cypher_val)
328
 
@@ -330,10 +334,13 @@ with tab_val:
330
  df = pd.DataFrame(triples_data)
331
  st.dataframe(df.drop(columns=["id"]), width='stretch', hide_index=True)
332
  else:
333
- st.info("Grafo vuoto.")
334
  else:
335
  st.warning("Database non connesso.")
336
 
 
 
 
337
  with tab_vis:
338
  st.header("Esplorazione Topologica")
339
  if driver:
@@ -349,6 +356,7 @@ with tab_vis:
349
  RETURN COALESCE(s.label, s.name, head(labels(s))) as src,
350
  type(r) as rel,
351
  COALESCE(o.label, o.name, head(labels(o))) as dst
 
352
  """
353
  graph_data = run_query(driver, cypher_vis)
354
 
 
1
  import streamlit as st
2
  import os
3
+ import time
4
  import tempfile
5
  import pandas as pd
6
+ from pymongo import MongoClient
7
  from neo4j import GraphDatabase
8
  from pyvis.network import Network
9
  import streamlit.components.v1 as components
 
26
  )
27
 
28
  def local_css(file_name):
29
+ if os.path.exists(file_name):
30
+ with open(file_name, "r") as f:
31
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
32
 
33
  local_css("assets/style.css")
34
 
35
+ # --- SESSION STATE MANAGEMENT ---
36
  if 'pipeline_stage' not in st.session_state:
37
  st.session_state.pipeline_stage = 0
38
  if 'document_text' not in st.session_state:
 
57
 
58
  @st.cache_resource
59
  def get_extractor():
60
+ return NeuroSymbolicExtractor(index_path="ontology/domain_index.json")
 
61
 
62
  @st.cache_resource(show_spinner="🧩 Inizializzazione Entity Resolver...")
63
  def get_resolver():
 
65
 
66
  @st.cache_resource
67
  def get_validator():
68
+ return SemanticValidator(
69
+ ontology_dir="ontology",
70
+ shapes_file="ontology/shapes/auto_constraints.ttl"
71
+ )
72
 
73
+ # Pre-load dei modelli in memoria
74
  _ = get_splitter()
75
  _ = get_extractor()
76
  _ = get_validator()
 
96
  env_uri = os.getenv("NEO4J_URI", "")
97
  env_user = os.getenv("NEO4J_USER", "neo4j")
98
  env_password = os.getenv("NEO4J_PASSWORD", "")
99
+ env_google_key = os.getenv("GOOGLE_API_KEY", "")
100
 
101
+ st.sidebar.subheader("Backend AI (TDDT)")
102
+ if env_google_key:
103
+ st.sidebar.success("✅ Google API Key: Configurata")
 
 
104
  else:
105
+ google_key_input = st.sidebar.text_input("Inserisci GOOGLE_API_KEY", type="password")
106
+ if google_key_input: os.environ["GOOGLE_API_KEY"] = google_key_input
107
 
108
  st.sidebar.subheader("Knowledge Graph")
109
+ uri = st.sidebar.text_input("URI Neo4j", value=env_uri)
110
+ user = st.sidebar.text_input("User Neo4j", value=env_user)
111
 
112
+ pwd_placeholder = "✅ Configurato (Lascia vuoto)" if env_password else "Inserisci Password Neo4j"
113
+ password_input = st.sidebar.text_input("Password Neo4j", type="password", placeholder=pwd_placeholder)
114
  password = password_input if password_input else env_password
115
 
116
  driver = None
 
122
  os.environ["NEO4J_USER"] = user
123
  os.environ["NEO4J_PASSWORD"] = password
124
  else:
125
+ st.sidebar.error("🔴 Errore connessione Neo4j")
126
 
127
  st.sidebar.divider()
128
  if st.sidebar.button("🔄 Reset Pipeline", on_click=reset_pipeline):
 
130
 
131
  # --- MAIN HEADER ---
132
  st.title("🧠 Automated Semantic Discovery Prototype")
133
+ st.markdown("**Type-Driven Domain Traversal (TDDT) & OWL RL Validation**")
134
 
135
  tab_gen, tab_val, tab_vis = st.tabs([
136
  "⚙️ 1. Pipeline Generativa",
137
+ "🔍 2. Dati e DLQ",
138
  "🕸️ 3. Esplorazione Grafo"
139
  ])
140
 
 
166
  st.markdown(f"### {'✅' if st.session_state.pipeline_stage >= 1 else '1️⃣'} Fase 1: Semantic Chunking")
167
 
168
  with st.expander("ℹ️ Cosa fa questa fase?"):
169
+ st.write("Segmenta il testo in frammenti coerenti analizzando la similarità semantica vettoriale tra le frasi.")
 
 
170
 
171
  if st.session_state.pipeline_stage >= 1:
172
  chunks = st.session_state.chunks
173
+ st.success(f"Chunking completato! Generati {len(chunks)} frammenti semantici.")
 
 
 
 
 
 
174
  else:
175
  if st.button("Avvia Semantic Splitter", type="primary"):
176
  with st.spinner("Creazione chunks in corso..."):
177
  try:
178
  splitter = get_splitter()
179
  chunks, _, _ = splitter.create_chunks(input_text, percentile_threshold=90)
 
 
180
  st.session_state.chunks = chunks
181
  st.session_state.pipeline_stage = 1
182
  st.rerun()
 
186
  st.markdown("⬇️")
187
 
188
  # ==========================
189
+ # FASE 2: EXTRACTION (TDDT)
190
  # ==========================
191
  is_step_b_unlocked = st.session_state.pipeline_stage >= 1
192
 
193
  with st.container():
194
  color = "white" if is_step_b_unlocked else "gray"
195
  icon = "✅" if st.session_state.pipeline_stage >= 2 else ("2️⃣" if is_step_b_unlocked else "🔒")
196
+ st.markdown(f"<h3 style='color:{color}'>{icon} Fase 2: TDDT Extraction (Gemini)</h3>", unsafe_allow_html=True)
197
 
198
  with st.expander("ℹ️ Cosa fa questa fase?"):
199
+ st.write("Esegue l'estrazione gerarchica in due passaggi: prima classifica le entità usando le root dell'ontologia, poi estrae le relazioni passando all'LLM solo le proprietà ammesse (Domain Index).")
 
200
 
201
  if not is_step_b_unlocked:
202
  st.caption("Completa la Fase 1 per sbloccare l'estrazione.")
203
  elif st.session_state.pipeline_stage >= 2:
204
  data = st.session_state.extraction_data
205
+ st.success(f"Estrazione TDDT completata! Identificate {len(data['entities'])} entità e {len(data['triples'])} triple.")
206
+ with st.expander("Vedi dati estratti (Pre-Validazione)"):
207
+ st.write("Entità Inferite:", [e.model_dump() for e in data['entities']])
208
+ if data['triples']:
209
+ st.dataframe(pd.DataFrame([t.model_dump() for t in data['triples']]), hide_index=True)
 
 
 
210
  else:
211
+ if st.button("Avvia Estrazione TDDT", type="primary"):
212
+ if not os.getenv("GOOGLE_API_KEY"):
213
+ st.error("⚠️ GOOGLE_API_KEY mancante. Inseriscila nella sidebar.")
214
+ else:
215
+ with st.spinner("Classificazione ed estrazione gerarchica in corso..."):
216
+ try:
217
+ chunks = st.session_state.chunks
218
+ extractor = get_extractor()
219
+ all_triples = []
220
+ all_entities = []
221
+ prog_bar = st.progress(0)
 
222
 
223
+ for i, chunk in enumerate(chunks):
224
+ chunk_id = f"st_req_chunk_{i+1}"
225
+ res = extractor.extract(chunk, source_id=chunk_id)
226
 
227
+ if res:
228
+ if res.triples: all_triples.extend(res.triples)
229
+
230
+ prog_bar.progress((i+1)/len(chunks))
231
+
232
+ # Estraggo le entità univoche dalle triple per il Resolver
233
+ unique_entities = list(set([t.subject for t in all_triples] + [t.object for t in all_triples]))
234
+
235
+ st.session_state.extraction_data = {"entities": unique_entities, "triples": all_triples}
236
+ st.session_state.pipeline_stage = 2
237
+ st.rerun()
238
+ except Exception as e:
239
+ st.error(f"Errore: {e}")
240
 
241
  st.markdown("⬇️")
242
 
243
  # ==========================
244
+ # FASE 3: RESOLUTION & VALIDATION (BLOCCANTE)
245
  # ==========================
246
  is_step_c_unlocked = st.session_state.pipeline_stage >= 2
247
 
248
  with st.container():
249
  color = "white" if is_step_c_unlocked else "gray"
250
  icon = "✅" if st.session_state.pipeline_stage >= 3 else ("3️⃣" if is_step_c_unlocked else "🔒")
251
+ st.markdown(f"<h3 style='color:{color}'>{icon} Fase 3: Resolution & SHACL Blocking</h3>", unsafe_allow_html=True)
252
 
253
  with st.expander("ℹ️ Cosa fa questa fase?"):
254
+ st.write("Risolve le entità (Entity Linking) e applica la validazione OWL RL. Le triple non conformi vengono scartate e salvate nella Dead Letter Queue (MongoDB), mentre quelle valide popolano Neo4j.")
 
255
 
256
  if not is_step_c_unlocked:
257
  st.caption("Completa la Fase 2 per procedere.")
258
  elif st.session_state.pipeline_stage >= 3:
259
+ st.success("Grafo Aggiornato! Le triple conformi sono su Neo4j, gli scarti su Mongo (se configurato).")
 
 
 
 
260
  else:
261
  if not driver:
262
  st.error("⚠️ Connettiti a Neo4j (nella sidebar) per procedere.")
263
  else:
264
+ if st.button("Valida e Scrivi su Grafo", type="primary"):
265
+ with st.spinner("Risoluzione, validazione logica e persistenza..."):
266
  try:
267
  raw_data = st.session_state.extraction_data
268
  all_entities = raw_data.get("entities", [])
269
+ all_triples = raw_data.get("triples", [])
270
 
271
  resolver = get_resolver()
272
  resolver.driver = driver
273
+ all_entities, resolved_triples, entities_to_save = resolver.resolve_entities(all_entities, all_triples)
274
 
275
  validator = get_validator()
276
+ valid_triples, invalid_triples, report = validator.filter_valid_triples(entities_to_save, resolved_triples)
277
 
278
+ if invalid_triples:
279
+ st.warning(f"Rilevate {len(invalid_triples)} violazioni ontologiche. Scartate dalla persistenza.")
280
+
281
+ # Salvataggio in DLQ (MongoDB)
282
+ mongo_ur = os.getenv("MONGO_UR")
283
+ mongo_user = os.getenv("MONGO_USER")
284
+ mongo_pass = os.getenv("MONGO_PASS")
285
+
286
+ if mongo_ur:
287
+ try:
288
+ client = MongoClient(mongo_ur, username=mongo_user, password=mongo_pass)
289
+ db = client["semantic_discovery"]["rejected_triples"]
290
+ docs = []
291
+ for doc in invalid_triples:
292
+ doc["timestamp"] = time.time()
293
+ docs.append(doc)
294
+ db.insert_many(docs)
295
+ st.info("💾 Scarti archiviati correttamente su MongoDB.")
296
+ except Exception as e:
297
+ st.error(f"Errore scrittura DLQ: {e}")
298
 
299
+ persister = KnowledgeGraphPersister()
300
+ persister.driver = driver # Inietto il driver testato
301
+ # Salviamo SOLO le valide
302
+ persister.save_entities_and_triples(entities_to_save, valid_triples)
303
 
304
  st.session_state.pipeline_stage = 3
305
  st.rerun()
 
307
  st.error(f"Errore critico: {e}")
308
 
309
  # ==============================================================================
310
+ # TAB 2: VALIDAZIONE E DLQ (Aggiornato per 1.4)
311
  # ==============================================================================
312
  with tab_val:
313
  st.header("Curation & Feedback Loop")
 
323
  RETURN elementId(r) as id,
324
  COALESCE(s.label, s.name, head(labels(s))) as Soggetto,
325
  type(r) as Predicato,
326
+ COALESCE(o.label, o.name, head(labels(o))) as Oggetto,
327
+ COALESCE(r.evidence, 'N/A') as Evidenza,
328
+ COALESCE(r.reasoning, 'N/A') as Ragionamento
329
+ LIMIT 100
330
  """
331
  triples_data = run_query(driver, cypher_val)
332
 
 
334
  df = pd.DataFrame(triples_data)
335
  st.dataframe(df.drop(columns=["id"]), width='stretch', hide_index=True)
336
  else:
337
+ st.info("Grafo vuoto o relazioni senza nuovi attributi.")
338
  else:
339
  st.warning("Database non connesso.")
340
 
341
+ # ==============================================================================
342
+ # TAB 3: ESPLORAZIONE GRAFO
343
+ # ==============================================================================
344
  with tab_vis:
345
  st.header("Esplorazione Topologica")
346
  if driver:
 
356
  RETURN COALESCE(s.label, s.name, head(labels(s))) as src,
357
  type(r) as rel,
358
  COALESCE(o.label, o.name, head(labels(o))) as dst
359
+ LIMIT 300
360
  """
361
  graph_data = run_query(driver, cypher_vis)
362
 
{data/ontologie_raw/ARCO → ontology}/ArCo.owl RENAMED
File without changes
{data/ontologie_raw/ARCO → ontology}/arco.owl RENAMED
File without changes
{data/ontologie_raw/ARCO → ontology}/context-description.owl RENAMED
File without changes
{data/ontologie_raw/ARCO → ontology}/core.owl RENAMED
File without changes
{data/ontologie_raw/ARCO → ontology}/location.owl RENAMED
File without changes
requirements.txt CHANGED
@@ -1,9 +1,8 @@
1
  # --- Core Framework & Orchestration ---
2
  langchain>=0.3.0
3
  langchain-community>=0.3.0
4
- langchain-ollama>=0.2.0
5
- langchain-huggingface>=0.1.0
6
- langchain-groq
7
  langchain-core
8
  huggingface_hub
9
 
@@ -22,6 +21,9 @@ spacy
22
  neo4j>=5.0.0
23
  rdflib
24
 
 
 
 
25
  # --- Web & API ---
26
  fastapi
27
  uvicorn
 
1
  # --- Core Framework & Orchestration ---
2
  langchain>=0.3.0
3
  langchain-community>=0.3.0
4
+ langchain-google-genai>=2.0.0 # Per Gemini 2.0 Flash (TDDT)
5
+ langchain-huggingface>=0.1.0 # Mantenuto per il Semantic Splitter
 
6
  langchain-core
7
  huggingface_hub
8
 
 
21
  neo4j>=5.0.0
22
  rdflib
23
 
24
+ # --- Storage & DLQ ---
25
+ pymongo
26
+
27
  # --- Web & API ---
28
  fastapi
29
  uvicorn
src/extraction/extractor.py CHANGED
@@ -1,238 +1,224 @@
1
- import json
2
  import os
3
- import numpy as np
4
- from typing import List, Optional
5
  from pydantic import BaseModel, Field, ValidationError
6
  from langchain_core.prompts import ChatPromptTemplate
7
  from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
8
- from langchain_groq import ChatGroq
9
-
10
- from langchain_ollama import ChatOllama
11
- from langchain_huggingface import HuggingFaceEmbeddings, ChatHuggingFace, HuggingFaceEndpoint
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).")
22
- object: str = Field(..., description="Entità target.")
23
- confidence: float = Field(..., description="Confidenza (0.0 - 1.0).")
24
- source: Optional[str] = Field(None)
 
 
 
25
 
26
  class KnowledgeGraphExtraction(BaseModel):
27
- reasoning: Optional[str] = Field(None, description="Breve ragionamento logico.")
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 groq_api_key:
41
- print("☁️ Rilevato ambiente Groq Cloud!")
42
- try:
43
- self.llm = ChatGroq(
44
- temperature=0,
45
- model="llama-3.3-70b-versatile",
46
- api_key=groq_api_key
47
- )
48
- except Exception as e:
49
- print(f"❌ Errore Groq API {e}")
50
- elif hf_token:
51
- print("☁️ Rilevato ambiente Cloud (HF Spaces). Utilizzo HuggingFace Inference API.")
52
- repo_id = "meta-llama/Meta-Llama-3-8B-Instruct"
53
-
54
- try:
55
- endpoint = HuggingFaceEndpoint(
56
- repo_id=repo_id,
57
- task="text-generation",
58
- max_new_tokens=1024,
59
- temperature=0.1,
60
- huggingfacehub_api_token=hf_token
61
- )
62
- self.llm = ChatHuggingFace(llm=endpoint)
63
- print(f"✅ Connesso a {repo_id} via API.")
64
- except Exception as e:
65
- print(f"❌ Errore connessione HF API: {e}. Fallback su CPU locale (sconsigliato).")
66
- raise e
67
  else:
68
- print(f"🏠 Ambiente Locale rilevato. Inizializzazione Ollama: {model_name}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  try:
70
- self.llm = ChatOllama(
71
- model=model_name,
72
- temperature=temperature,
73
- format="json",
74
- base_url="http://localhost:11434"
75
- )
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 = []
147
- properties = []
148
 
149
- for idx in sorted_indices:
150
- element = self.ontology_elements[idx]
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:
194
- content = content.split("```")[1].split("```")[0].strip()
195
 
196
- if not content:
197
- raise ValueError("Il modello ha restituito una stringa vuota o un formato non parsabile.")
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"),
209
- entities=data.get("entities", []),
210
- triples=triples
211
- )
212
-
213
- for t in validated_data.triples:
214
- t.source = source_id
215
 
216
- return validated_data
 
217
 
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([
226
- sys_msg,
227
- HumanMessage(content=text_chunk),
228
- AIMessage(content=prev_content),
229
- HumanMessage(content=f"Errore nel JSON precedente: {e}. Correggi e restituisci SOLO JSON valido senza markdown.")
230
- ])
231
-
232
- chain = correction_prompt | self.llm
 
 
 
 
 
 
 
 
 
 
233
 
234
- except Exception as e:
235
- print(f"❌ Errore critico: {e}")
236
- break
237
-
238
  return KnowledgeGraphExtraction(triples=[])
 
 
1
  import os
2
+ import json
3
+ from typing import List, Optional, Dict, Any
4
  from pydantic import BaseModel, Field, ValidationError
5
  from langchain_core.prompts import ChatPromptTemplate
6
  from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
7
+ from langchain_google_genai import ChatGoogleGenerativeAI
 
 
 
 
8
  from dotenv import load_dotenv
9
 
10
+ load_dotenv()
11
+
12
+ # --- MODELLI PYDANTIC (Contratti Formali) ---
13
+
14
+ # PASS 1 - Livello 1
15
+ class MacroCategoryCandidate(BaseModel):
16
+ category: str = Field(description="URI della macro-categoria (es. arco:CulturalProperty)")
17
+ reasoning: str = Field(description="Perché questa macro-categoria è appropriata per l'entità")
18
+
19
+ class EntityMacroClassification(BaseModel):
20
+ name: str = Field(description="Nome dell'entità come appare nel testo")
21
+ candidates: List[MacroCategoryCandidate] = Field(
22
+ description="1-2 macro-categorie candidate, ordinate per preferenza (la prima è la più probabile)",
23
+ min_length=1,
24
+ max_length=2
25
+ )
26
 
27
+ class MacroClassificationResult(BaseModel):
28
+ """Output del Livello 1"""
29
+ entities: List[EntityMacroClassification]
30
+
31
+ # PASS 1 - Livello 2
32
+ class TypedEntity(BaseModel):
33
+ name: str = Field(description="Nome dell'entità come appare nel testo")
34
+ type: str = Field(description="URI del tipo ontologico finale (es. arco:ArchaeologicalProperty)")
35
+
36
+ class TypeInferenceResult(BaseModel):
37
+ """Output del Livello 2"""
38
+ entities: List[TypedEntity]
39
+
40
+ # PASS 2 - Extraction
41
  class GraphTriple(BaseModel):
42
+ subject: str
43
+ subject_type: str = Field(description="Tipo ontologico del soggetto (da Pass 1)")
44
+ predicate: str
45
+ object: str
46
+ object_type: str = Field(description="Tipo ontologico dell'oggetto (da Pass 1)")
47
+ evidence: str = Field(description="Span testuale esatto dal chunk da cui la relazione è estratta")
48
+ reasoning: str = Field(description="Perché questo predicato è stato scelto per questa coppia di entità")
49
+ source: Optional[str] = Field(None) # Mantenuto per compatibilità con il batching Neo4j a valle
50
 
51
  class KnowledgeGraphExtraction(BaseModel):
 
 
52
  triples: List[GraphTriple]
53
 
54
 
55
  class NeuroSymbolicExtractor:
56
+ def __init__(self, index_path="../../ontology/schemas/domain_index.json"):
57
+ print("🧠 Inizializzazione TDDT Extractor (Type-Driven Domain Traversal)...")
58
 
59
+ google_api_key = os.getenv("GOOGLE_API_KEY")
60
+ if not google_api_key:
61
+ raise ValueError("❌ GOOGLE_API_KEY mancante. Richiesta per Gemini 2.0 Flash.")
62
+
63
+ # Inizializzo l'LLM primario. Temperatura 0 per massimizzare il determinismo.
64
+ self.llm = ChatGoogleGenerativeAI(
65
+ model="gemini-2.0-flash",
66
+ temperature=0,
67
+ api_key=google_api_key
68
+ )
69
 
70
+ # Inizializzo le chain con structured output
71
+ self.chain_pass1_l1 = self.llm.with_structured_output(MacroClassificationResult)
72
+ self.chain_pass1_l2 = self.llm.with_structured_output(TypeInferenceResult)
73
+ self.chain_pass2 = self.llm.with_structured_output(KnowledgeGraphExtraction)
74
+
75
+ # Caricamento del Domain Index in RAM
76
+ self.domain_index = {"classes": {}, "properties_by_domain": {}}
77
+ if os.path.exists(index_path):
78
+ with open(index_path, 'r', encoding='utf-8') as f:
79
+ self.domain_index = json.load(f)
80
+ print(f"✅ Domain Index caricato: {len(self.domain_index['classes'])} classi disponibili.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  else:
82
+ print(f"⚠️ Attenzione: Domain Index non trovato al percorso {index_path}")
83
+
84
+ self.root_classes = self._extract_root_classes()
85
+
86
+ def _extract_root_classes(self) -> Dict[str, Any]:
87
+ """Estrae il primo livello ontologico per la macro-categorizzazione."""
88
+ roots = {}
89
+ for uri, data in self.domain_index["classes"].items():
90
+ # Consideriamo root le classi senza padri o figlie dirette di owl:Thing / l0:Entity
91
+ if not data["parents"] or "owl:Thing" in data["parents"] or "l0:Entity" in data["parents"]:
92
+ roots[uri] = data
93
+ return roots
94
+
95
+ def _get_subclasses(self, parent_uris: List[str]) -> Dict[str, Any]:
96
+ """Recupera tutte le sottoclassi dirette (e se stesse) dai rami indicati."""
97
+ subclasses = {}
98
+ for uri, data in self.domain_index["classes"].items():
99
+ if uri in parent_uris or any(p in parent_uris for p in data["parents"]):
100
+ subclasses[uri] = data
101
+ return subclasses
102
+
103
+ def _execute_with_retry(self, chain, prompt_messages, max_retries=3):
104
+ """Self-correction loop unificato."""
105
+ for attempt in range(max_retries):
106
  try:
107
+ result = chain.invoke(prompt_messages)
108
+ return result
 
 
 
 
109
  except Exception as e:
110
+ print(f"⚠️ Errore (Tentativo {attempt+1}/{max_retries}): {e}")
111
+ if attempt == max_retries - 1:
112
+ print("❌ Fallimento critico del task LLM.")
113
+ return None
114
+ return None
115
+
116
+ def extract(self, text_chunk: str, source_id: str = "unknown") -> KnowledgeGraphExtraction:
117
+ print(f"\n🧩 Processing {source_id} (TDDT Mode)...")
118
 
119
+ # ==========================================
120
+ # PASS 1 - LIVELLO 1: Macro-Categorizzazione
121
+ # ==========================================
122
+ roots_text = "\n".join([f"- {uri} — \"{data['label']}: {data['description']}\"" for uri, data in self.root_classes.items()])
 
 
 
 
 
123
 
124
+ sys_l1 = f"""Identifica le entità principali nel testo e assegna a ciascuna la macro-categoria più appropriata.
125
+ Puoi assegnare fino a 2 candidati per entità se sei incerto, ordinandoli per confidenza.
126
+
127
+ MACRO-CATEGORIE DISPONIBILI:
128
+ {roots_text}"""
 
129
 
130
+ res_l1: MacroClassificationResult = self._execute_with_retry(
131
+ self.chain_pass1_l1,
132
+ [SystemMessage(content=sys_l1), HumanMessage(content=text_chunk)]
133
+ )
134
 
135
+ if not res_l1 or not res_l1.entities:
136
+ print(" -> Nessuna entità trovata al Livello 1.")
137
+ return KnowledgeGraphExtraction(triples=[])
138
+
139
+ # ==========================================
140
+ # PASS 1 - LIVELLO 2: Specializzazione
141
+ # ==========================================
142
+ # Raccogliamo tutti i rami candidati da esplorare
143
+ candidate_uris = set()
144
+ for ent in res_l1.entities:
145
+ for cand in ent.candidates:
146
+ candidate_uris.add(cand.category)
147
 
148
+ subclasses = self._get_subclasses(list(candidate_uris))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
+ # Raggruppo le sottoclassi per visualizzarle ordinate nel prompt
151
+ subs_text_blocks = []
152
+ for parent in candidate_uris:
153
+ subs_text_blocks.append(f"\n[{parent} →]")
154
+ children = {k: v for k, v in subclasses.items() if parent in v["parents"] or k == parent}
155
+ for uri, data in children.items():
156
+ subs_text_blocks.append(f"- {uri} — \"{data['label']}: {data['description']}\"")
157
+
158
+ subs_text = "\n".join(subs_text_blocks)
159
 
160
+ ent_text = "\n".join([f"- '{e.name}': " + ", ".join([f"{c.category}" for c in e.candidates]) for e in res_l1.entities])
 
161
 
162
+ sys_l2 = f"""Per ciascuna entità identificata, scegli il sotto-tipo più specifico tra quelli elencati.
163
+ Se non c'è un sotto-tipo rilevante per un'entità, conferma la sua macro-categoria.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ ENTITÀ IDENTIFICATE (con macro-categorie candidate):
166
+ {ent_text}
167
 
168
+ SOTTO-TIPI DISPONIBILI:
169
+ {subs_text}"""
170
 
171
+ res_l2: TypeInferenceResult = self._execute_with_retry(
172
+ self.chain_pass1_l2,
173
+ [SystemMessage(content=sys_l2), HumanMessage(content=text_chunk)]
 
 
 
 
174
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
+ if not res_l2 or not res_l2.entities:
177
+ return KnowledgeGraphExtraction(triples=[])
178
 
179
+ # ==========================================
180
+ # PASS 2: Estrazione Relazionale
181
+ # ==========================================
182
+ # Mappa dei tipi finali
183
+ typed_entities_map = {e.name: e.type for e in res_l2.entities}
184
+
185
+ # Recupero deterministico delle proprietà
186
+ valid_properties = []
187
+ seen_props = set()
188
+ for ent_type in typed_entities_map.values():
189
+ props = self.domain_index["properties_by_domain"].get(ent_type, [])
190
+ for p in props:
191
+ if p["id"] not in seen_props:
192
+ valid_properties.append(f"- {p['id']}: {p['inherited_from']} → {p['range']} (Label: {p['label']})")
193
+ seen_props.add(p["id"])
 
194
 
195
+ props_text = "\n".join(valid_properties) if valid_properties else "- (Nessuna proprietà specifica trovata. Usa skos:related)"
196
+ ent_final_text = "\n".join([f"- {name} ({uri_type})" for name, uri_type in typed_entities_map.items()])
197
 
198
+ sys_ext = f"""Estrai le relazioni semantiche tra le entità presenti nel testo.
199
+
200
+ ENTITÀ IDENTIFICATE (con il loro tipo):
201
+ {ent_final_text}
202
+
203
+ PROPRIETÀ CONSENTITE (con vincoli domain → range):
204
+ {props_text}
205
+ - skos:related: Qualsiasi → Qualsiasi (Usa SOLO se nessuna proprietà sopra descrive accuratamente il legame)
206
+
207
+ REGOLE CRITICHE:
208
+ 1. Usa SOLO le proprietà elencate sopra.
209
+ 2. Rispetta rigorosamente i vincoli ontologici: il tipo del 'subject' DEVE essere compatibile con il domain, e il tipo dell''object' con il range.
210
+ 3. Compila sempre i campi 'evidence' citando esattamente il testo, e 'reasoning' spiegando la scelta logica.
211
+ """
212
+
213
+ final_res: KnowledgeGraphExtraction = self._execute_with_retry(
214
+ self.chain_pass2,
215
+ [SystemMessage(content=sys_ext), HumanMessage(content=text_chunk)]
216
+ )
217
+
218
+ if final_res and final_res.triples:
219
+ # Propago il source_id prima di inviare l'output
220
+ for t in final_res.triples:
221
+ t.source = source_id
222
+ return final_res
223
 
 
 
 
 
224
  return KnowledgeGraphExtraction(triples=[])
src/utils/build_schema.py CHANGED
@@ -1,12 +1,10 @@
1
  import os
2
  import json
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",
@@ -14,165 +12,208 @@ ARCO_NAMESPACES = {
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.")
64
- return
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 .
87
- BIND("Class" AS ?type)
88
- } UNION {
89
- ?entity a owl:ObjectProperty .
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:
167
- json.dump(output_list, f, ensure_ascii=False, indent=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
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)
 
1
  import os
2
  import json
3
  from pathlib import Path
4
+ from collections import defaultdict
5
+ from rdflib import Graph, URIRef, BNode, RDF, RDFS, OWL, Namespace
6
 
7
+ # --- MAPPA DEI NAMESPACE (Estesa con CIDOC-CRM) ---
 
 
 
8
  ARCO_NAMESPACES = {
9
  "https://w3id.org/arco/ontology/arco/": "arco",
10
  "https://w3id.org/arco/ontology/core/": "core",
 
12
  "https://w3id.org/arco/ontology/context-description/": "a-cd",
13
  "https://w3id.org/arco/ontology/denotative-description/": "a-dd",
14
  "https://w3id.org/arco/ontology/cultural-event/": "a-ce",
15
+ "https://w3id.org/arco/ontology/catalogue/": "a-cat",
16
  "http://dati.beniculturali.it/cis/": "cis",
17
  "https://w3id.org/italia/onto/l0/": "l0",
18
  "https://w3id.org/italia/onto/CLV/": "clv",
19
  "https://w3id.org/italia/onto/TI/": "ti",
20
  "https://w3id.org/italia/onto/RO/": "ro",
21
  "https://w3id.org/italia/onto/SM/": "sm",
22
+ "https://w3id.org/italia/onto/MU/": "mu",
23
+ "http://www.cidoc-crm.org/cidoc-crm/": "crm", # Aggiunto CIDOC-CRM
24
  "http://www.w3.org/2002/07/owl#": "owl"
25
  }
26
 
27
+ def uri_to_qname(uri: URIRef) -> str:
28
+ if not uri or isinstance(uri, BNode):
 
 
 
 
29
  return None
30
  uri_str = str(uri)
 
 
31
  best_match = ""
32
  for ns_uri in ARCO_NAMESPACES.keys():
33
  if uri_str.startswith(ns_uri) and len(ns_uri) > len(best_match):
34
  best_match = ns_uri
 
35
  if best_match:
36
+ return f"{ARCO_NAMESPACES[best_match]}:{uri_str[len(best_match):].lstrip('#')}"
 
 
37
 
38
+ if '#' in uri_str: return uri_str.split('#')[-1]
 
 
39
  return uri_str.split('/')[-1]
40
 
41
+ def get_union_classes(g: Graph, bnode: BNode):
42
+ """Estrae le classi da un costrutto owl:unionOf (usato spesso in ArCo per domini/range multipli)."""
43
+ union_list = g.value(bnode, OWL.unionOf)
44
+ classes = []
45
+ if union_list:
46
+ # Naviga la lista RDF
47
+ current = union_list
48
+ while current and current != RDF.nil:
49
+ item = g.value(current, RDF.first)
50
+ if isinstance(item, URIRef):
51
+ classes.append(uri_to_qname(item))
52
+ current = g.value(current, RDF.rest)
53
+ return [c for c in classes if c]
54
+
55
+ def build_domain_index_and_shacl(ontology_dir: str, output_json: str, output_shacl: str):
56
+ print(f"⏳ Inizializzazione Graph e caricamento da {ontology_dir}...")
57
  g = Graph()
58
 
59
+ # 1. Carica tutti i file .owl
60
+ owl_files = list(Path(ontology_dir).glob('**/*.owl'))
 
 
 
 
61
  for file_path in owl_files:
62
  try:
63
  g.parse(file_path, format="xml")
64
+ print(f" -> Caricato: {file_path.name}")
65
+ except Exception as e:
66
+ print(f" ⚠️ Errore parsing {file_path.name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ print("✅ Ontologie caricate in memoria. Compilazione indici in corso...")
 
69
 
70
+ classes_dict = {}
71
+ properties_list = []
72
+
73
+ # 2. Estrazione Classi e Gerarchia
74
+ for s in g.subjects(RDF.type, OWL.Class):
75
+ if isinstance(s, BNode): continue
76
 
77
+ qname = uri_to_qname(s)
78
+ label = g.value(s, RDFS.label)
79
+ comment = g.value(s, RDFS.comment)
80
+
81
+ # Filtro lingua: preferisco italiano, altrimenti inglese (per CIDOC-CRM)
82
+ label_str = str(label) if label else qname
83
+ for lang_label in g.objects(s, RDFS.label):
84
+ if lang_label.language == 'it': label_str = str(lang_label)
85
+
86
+ desc_str = str(comment) if comment else ""
87
+ for lang_comment in g.objects(s, RDFS.comment):
88
+ if lang_comment.language == 'it': desc_str = str(lang_comment)
89
 
90
+ # Trova parent diretti
91
+ parents = [uri_to_qname(p) for p in g.objects(s, RDFS.subClassOf) if isinstance(p, URIRef)]
 
92
 
93
+ classes_dict[qname] = {
94
+ "label": label_str,
95
+ "description": desc_str,
96
+ "parents": parents,
97
+ "namespace": qname.split(":")[0] if ":" in qname else "unknown"
98
+ }
99
+
100
+ # 3. Estrazione Proprietà
101
+ for prop_type in [OWL.ObjectProperty, OWL.DatatypeProperty]:
102
+ for s in g.subjects(RDF.type, prop_type):
103
+ if isinstance(s, BNode): continue
104
+
105
+ qname = uri_to_qname(s)
106
+ label = g.value(s, RDFS.label)
107
+ label_str = str(label) if label else qname
108
+
109
+ # Dominio
110
+ domain_node = g.value(s, RDFS.domain)
111
+ domains = []
112
+ if isinstance(domain_node, URIRef):
113
+ domains.append(uri_to_qname(domain_node))
114
+ elif isinstance(domain_node, BNode):
115
+ domains.extend(get_union_classes(g, domain_node))
116
+
117
+ # Range
118
+ range_node = g.value(s, RDFS.range)
119
+ ranges = []
120
+ if isinstance(range_node, URIRef):
121
+ ranges.append(uri_to_qname(range_node))
122
+ elif isinstance(range_node, BNode):
123
+ ranges.extend(get_union_classes(g, range_node))
124
+
125
+ properties_list.append({
126
+ "id": qname,
127
+ "label": label_str,
128
+ "domains": domains,
129
+ "ranges": ranges
130
+ })
131
 
132
+ # 4. Calcolo Ereditarietà Transitiva per il Domain Index
133
+ properties_by_domain = defaultdict(list)
134
+
135
+ # Mappo prima le proprietà ai domini espliciti
136
+ for prop in properties_list:
137
+ for d in prop["domains"]:
138
+ properties_by_domain[d].append({
139
+ "id": prop["id"],
140
+ "label": prop["label"],
141
+ "range": prop["ranges"][0] if prop["ranges"] else "Mixed/Union",
142
+ "inherited_from": d
143
+ })
144
+
145
+ # Funzione ricorsiva per raccogliere proprietà dai parent
146
+ def get_inherited_properties(class_qname, visited=None):
147
+ if visited is None: visited = set()
148
+ if class_qname in visited: return []
149
+ visited.add(class_qname)
150
+
151
+ props = list(properties_by_domain.get(class_qname, []))
152
+ for parent in classes_dict.get(class_qname, {}).get("parents", []):
153
+ inherited = get_inherited_properties(parent, visited)
154
+ for p in inherited:
155
+ # Evito duplicati
156
+ if not any(existing["id"] == p["id"] for existing in props):
157
+ props.append(p)
158
+ return props
159
+
160
+ final_properties_by_domain = {}
161
+ for cls in classes_dict.keys():
162
+ all_props = get_inherited_properties(cls)
163
+ if all_props:
164
+ final_properties_by_domain[cls] = all_props
165
+
166
+ # 5. Generazione Text Embeddings Dictionary
167
+ class_embeddings_texts = {
168
+ k: f"{v['label']} - {v['description']}" for k, v in classes_dict.items() if v['description']
169
+ }
170
 
171
+ # 6. Salvataggio domain_index.json
172
+ domain_index = {
173
+ "classes": classes_dict,
174
+ "properties_by_domain": final_properties_by_domain,
175
+ "class_embeddings_texts": class_embeddings_texts
176
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
+ os.makedirs(os.path.dirname(output_json), exist_ok=True)
179
+ with open(output_json, 'w', encoding='utf-8') as f:
180
+ json.dump(domain_index, f, ensure_ascii=False, indent=2)
181
+ print(f"💾 Salvato Indice di Dominio in: {output_json}")
182
+
183
+ # 7. Generazione auto_constraints.ttl per SHACL
184
+ os.makedirs(os.path.dirname(output_shacl), exist_ok=True)
185
+ with open(output_shacl, 'w', encoding='utf-8') as f:
186
+ f.write("@prefix sh: <http://www.w3.org/ns/shacl#> .\n")
187
+ f.write("@prefix ex: <http://activadigital.it/ontology/> .\n")
188
+ for ns_uri, prefix in ARCO_NAMESPACES.items():
189
+ f.write(f"@prefix {prefix}: <{ns_uri}> .\n")
190
+ f.write("\n")
191
+
192
+ shape_count = 0
193
+ for prop in properties_list:
194
+ safe_id = prop["id"].replace(":", "_").replace("-", "_")
195
+
196
+ # Domain Shape (solo se domain esplicito singolo per non creare conflitti con le Union)
197
+ if len(prop["domains"]) == 1:
198
+ dom = prop["domains"][0]
199
+ f.write(f"ex:{safe_id}_DomainShape a sh:NodeShape ;\n")
200
+ f.write(f" sh:targetSubjectsOf {prop['id']} ;\n")
201
+ f.write(f" sh:class {dom} .\n\n")
202
+ shape_count += 1
203
+
204
+ # Range Shape (solo se range esplicito singolo)
205
+ if len(prop["ranges"]) == 1 and "http" not in prop["ranges"][0]: # Evito XSD datatypes complessi
206
+ rng = prop["ranges"][0]
207
+ f.write(f"ex:{safe_id}_RangeShape a sh:NodeShape ;\n")
208
+ f.write(f" sh:targetObjectsOf {prop['id']} ;\n")
209
+ f.write(f" sh:class {rng} .\n\n")
210
+ shape_count += 1
211
 
212
+ print(f"🛡️ Generato SHACL auto_constraints.ttl con {shape_count} regole rigorose in: {output_shacl}")
 
213
 
214
  if __name__ == "__main__":
215
+ ONTOLOGY_FOLDER = "../../ontology/"
216
+ OUTPUT_JSON = "../../ontology/schemas/domain_index.json"
217
+ OUTPUT_SHACL = "../../ontology/schemas/auto_constraints.ttl"
218
 
219
+ build_domain_index_and_shacl(ONTOLOGY_FOLDER, OUTPUT_JSON, OUTPUT_SHACL)
 
src/validation/validator.py CHANGED
@@ -1,108 +1,124 @@
1
  import os
 
2
  from rdflib import Graph, Literal, RDF, URIRef, Namespace
3
- from rdflib.namespace import SKOS, XSD
4
  from pyshacl import validate
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):
24
  self.shacl_graph = Graph()
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,
104
- inference='rdfs',
105
- serialize_report_graph=True
106
  )
 
 
 
 
 
107
 
108
- return conforms, report_text, data_graph
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
+ from pathlib import Path
3
  from rdflib import Graph, Literal, RDF, URIRef, Namespace
4
+ from rdflib.namespace import SKOS, OWL
5
  from pyshacl import validate
6
 
7
  class SemanticValidator:
8
+ def __init__(self, ontology_dir="../../ontology", shapes_file="../../ontology/shapes/auto_constraints.ttl"):
9
+ self.shapes_file = shapes_file
 
 
10
 
11
+ # Mappatura namespace
 
 
12
  self.namespaces = {
13
  "arco": Namespace("https://w3id.org/arco/ontology/arco/"),
14
  "core": Namespace("https://w3id.org/arco/ontology/core/"),
15
  "a-loc": Namespace("https://w3id.org/arco/ontology/location/"),
16
+ "a-cd": Namespace("https://w3id.org/arco/ontology/context-description/"),
17
  "cis": Namespace("http://dati.beniculturali.it/cis/"),
18
+ "crm": Namespace("http://www.cidoc-crm.org/cidoc-crm/"),
19
  "ex": Namespace("http://activadigital.it/ontology/")
20
  }
21
 
22
+ print("🛡️ Inizializzazione Semantic Validator (OWL RL)...")
23
+
24
+ # Caricamento massivo dell'Ontologia in memoria per il Reasoner
25
+ self.ont_graph = Graph()
26
+
27
+ arco_path = Path(ontology_dir) / "arco"
28
+ if arco_path.exists():
29
+ for owl_file in arco_path.glob("*.owl"):
30
+ self.ont_graph.parse(str(owl_file), format="xml")
31
+
32
+ cidoc_path = Path(ontology_dir) / "cidoc-crm" / "cidoc-crm.owl"
33
+ if cidoc_path.exists():
34
+ self.ont_graph.parse(str(cidoc_path), format="xml")
35
+
36
+ print(f"✅ Ontologia completa caricata nel reasoner ({len(self.ont_graph)} triple).")
37
+
38
  if os.path.exists(self.shapes_file):
39
  self.shacl_graph = Graph()
40
  self.shacl_graph.parse(self.shapes_file, format="turtle")
41
+ print("🛡️ SHACL Auto-Constraints caricati.")
42
  else:
43
  print("⚠️ File SHACL non trovato. Validazione disabilitata (pericoloso in prod!).")
44
  self.shacl_graph = None
45
 
46
  def _get_uri(self, text_val):
 
 
47
  if ":" in text_val and not text_val.startswith("http"):
48
  prefix, name = text_val.split(":", 1)
49
  if prefix in self.namespaces:
50
  return self.namespaces[prefix][name]
51
 
 
 
52
  clean_name = text_val.replace(" ", "_").replace("'", "").replace('"', "")
53
  return self.namespaces["ex"][clean_name]
54
 
55
  def _json_to_rdf(self, entities, triples):
 
 
56
  g = Graph()
 
 
57
  for prefix, ns in self.namespaces.items():
58
  g.bind(prefix, ns)
59
  g.bind("skos", SKOS)
60
 
 
61
  if entities:
62
  for ent in entities:
 
63
  label = ent["label"] if isinstance(ent, dict) else str(ent)
64
  ent_uri = self._get_uri(label)
65
  g.add((ent_uri, SKOS.prefLabel, Literal(label, lang="it")))
66
 
 
67
  if triples:
68
  for t in triples:
69
  subj_uri = self._get_uri(t.subject)
 
 
 
70
  g.add((subj_uri, SKOS.prefLabel, Literal(t.subject, lang="it")))
71
 
 
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
  pred_uri = self._get_uri(t.predicate)
77
  obj_uri = self._get_uri(t.object)
 
78
  g.add((subj_uri, pred_uri, obj_uri))
 
79
  g.add((obj_uri, SKOS.prefLabel, Literal(t.object, lang="it")))
 
80
  return g
81
 
82
+ def filter_valid_triples(self, entities, triples):
83
  """
84
+ Esegue la validazione bloccante (OWL RL).
85
+ Ritorna le triple valide da salvare su Neo4j e quelle invalide da buttare su Mongo.
86
  """
87
+ if not self.shacl_graph or not triples:
88
+ return triples, [], "No Validation"
89
 
90
+ # 1. Testiamo l'intero batch in un colpo solo per massima velocità
91
+ batch_graph = self._json_to_rdf(entities, triples)
 
 
 
 
 
92
  conforms, report_graph, report_text = validate(
93
+ batch_graph,
94
  shacl_graph=self.shacl_graph,
95
+ ont_graph=self.ont_graph,
96
+ inference='owlrl'
97
  )
98
+
99
+ if conforms:
100
+ return triples, [], "All valid"
101
+
102
+ print("⚠️ Rilevate violazioni SHACL nel blocco. Isolamento colpevoli...")
103
 
104
+ # 2. Se fallisce, isoliamo chirurgicamente le triple non conformi
105
+ valid_triples = []
106
+ invalid_triples = []
107
+
108
+ for t in triples:
109
+ single_graph = self._json_to_rdf(entities, [t])
110
+ t_conforms, _, t_report = validate(
111
+ single_graph,
112
+ shacl_graph=self.shacl_graph,
113
+ ont_graph=self.ont_graph,
114
+ inference='owlrl'
115
+ )
116
+ if t_conforms:
117
+ valid_triples.append(t)
118
+ else:
119
+ invalid_triples.append({
120
+ "triple": t.model_dump() if hasattr(t, 'model_dump') else t,
121
+ "violation_report": t_report
122
+ })
123
+
124
+ return valid_triples, invalid_triples, report_text