Commit ·
b70d82f
1
Parent(s): 5ca1355
avvio refactoring
Browse files- api.py +88 -40
- app.py +105 -97
- {data/ontologie_raw/ARCO → ontology}/ArCo.owl +0 -0
- {data/ontologie_raw/ARCO → ontology}/arco.owl +0 -0
- {data/ontologie_raw/ARCO → ontology}/context-description.owl +0 -0
- {data/ontologie_raw/ARCO → ontology}/core.owl +0 -0
- {data/ontologie_raw/ARCO → ontology}/location.owl +0 -0
- requirements.txt +5 -3
- src/extraction/extractor.py +187 -201
- src/utils/build_schema.py +172 -131
- src/validation/validator.py +60 -44
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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
print(
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
# --- FASE 3: PERSISTENCE (Neo4j) ---
|
| 114 |
try:
|
| 115 |
-
|
|
|
|
| 116 |
except Exception as e:
|
| 117 |
print(f"⚠️ Errore salvataggio Neo4j: {e}")
|
| 118 |
|
| 119 |
# Preparazione payload di risposta
|
| 120 |
graph_data = []
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 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 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
"
|
| 151 |
-
"
|
| 152 |
-
|
| 153 |
-
|
| 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 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
local_css("assets/style.css")
|
| 31 |
|
| 32 |
-
# --- SESSION STATE MANAGEMENT
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 95 |
|
| 96 |
-
st.sidebar.subheader("Backend AI")
|
| 97 |
-
if
|
| 98 |
-
st.sidebar.success("✅
|
| 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 |
-
|
| 103 |
-
if
|
| 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("**
|
| 131 |
|
| 132 |
tab_gen, tab_val, tab_vis = st.tabs([
|
| 133 |
"⚙️ 1. Pipeline Generativa",
|
| 134 |
-
"🔍 2.
|
| 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.
|
| 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:
|
| 204 |
|
| 205 |
with st.expander("ℹ️ Cosa fa questa fase?"):
|
| 206 |
-
st.write("
|
| 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.
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 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
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
res = extractor.extract(chunk, source_id=chunk_id)
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
st.markdown("⬇️")
|
| 249 |
|
| 250 |
# ==========================
|
| 251 |
-
# FASE 3: RESOLUTION &
|
| 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
|
| 259 |
|
| 260 |
with st.expander("ℹ️ Cosa fa questa fase?"):
|
| 261 |
-
st.write("
|
| 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.
|
| 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("
|
| 277 |
-
with st.spinner("Risoluzione
|
| 278 |
try:
|
| 279 |
raw_data = st.session_state.extraction_data
|
| 280 |
all_entities = raw_data.get("entities", [])
|
| 281 |
-
all_triples =
|
| 282 |
|
| 283 |
resolver = get_resolver()
|
| 284 |
resolver.driver = driver
|
| 285 |
-
all_entities,
|
| 286 |
|
| 287 |
validator = get_validator()
|
| 288 |
-
|
| 289 |
|
| 290 |
-
if
|
| 291 |
-
st.
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
-
persister = KnowledgeGraphPersister()
|
| 298 |
-
persister.
|
| 299 |
-
|
|
|
|
| 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
|
| 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.
|
| 325 |
-
|
|
|
|
| 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-
|
| 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
|
| 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
|
| 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 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
class GraphTriple(BaseModel):
|
| 20 |
-
subject: str
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
)
|
| 48 |
-
|
| 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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
try:
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
temperature=temperature,
|
| 73 |
-
format="json",
|
| 74 |
-
base_url="http://localhost:11434"
|
| 75 |
-
)
|
| 76 |
except Exception as e:
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
|
| 86 |
-
#
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 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 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
{retrieved_classes}
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
-
|
| 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 |
-
#
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
properties = []
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
|
| 170 |
-
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 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 |
-
|
| 197 |
-
|
| 198 |
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
t.source = source_id
|
| 215 |
|
| 216 |
-
|
|
|
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 5 |
|
| 6 |
-
# --- MAPPA
|
| 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:
|
| 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 |
-
|
| 43 |
-
name = uri_str[len(best_match):].lstrip('#')
|
| 44 |
-
return f"{prefix}:{name}"
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
if '#' in uri_str:
|
| 48 |
-
return uri_str.split('#')[-1]
|
| 49 |
return uri_str.split('/')[-1]
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
g = Graph()
|
| 59 |
|
| 60 |
-
# 1.
|
| 61 |
-
owl_files = list(Path(
|
| 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
|
| 70 |
-
except Exception as
|
| 71 |
-
print(f" ⚠️
|
| 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 |
-
|
| 111 |
-
schema_elements = {}
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
|
| 120 |
-
qname = uri_to_qname(
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
#
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
if comment: description_parts.append(comment)
|
| 131 |
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 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 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
print(f"
|
| 170 |
-
print(f"💾 Salvato in: {output_json_path}")
|
| 171 |
|
| 172 |
if __name__ == "__main__":
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
|
| 177 |
-
|
| 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,
|
| 4 |
from pyshacl import validate
|
| 5 |
|
| 6 |
class SemanticValidator:
|
| 7 |
-
def __init__(self):
|
| 8 |
-
|
| 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
|
| 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
|
| 87 |
"""
|
| 88 |
-
|
| 89 |
-
Ritorna
|
| 90 |
"""
|
| 91 |
-
if not self.shacl_graph:
|
| 92 |
-
return
|
| 93 |
|
| 94 |
-
#
|
| 95 |
-
|
| 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 |
-
|
| 103 |
shacl_graph=self.shacl_graph,
|
| 104 |
-
|
| 105 |
-
|
| 106 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|