Commit ·
fe271ee
1
Parent(s): 9fb3deb
termine refactoring v1
Browse files- README.md +50 -64
- app.py +104 -14
- src/extraction/extractor.py +15 -8
- src/graph/entity_resolver.py +56 -6
- src/graph/graph_loader.py +1 -1
README.md
CHANGED
|
@@ -15,47 +15,44 @@ short_description: Neurosymbolic prototype for automatic semantic discovery
|
|
| 15 |

|
| 16 |

|
| 17 |

|
| 18 |
-
<username>/<repository>.git
|
| 129 |
cd prototipo
|
| 130 |
|
|
@@ -135,10 +125,13 @@ source venv/bin/activate # Linux / macOS
|
|
| 135 |
|
| 136 |
# 3. Installa le dipendenze
|
| 137 |
pip install -r requirements.txt
|
|
|
|
|
|
|
|
|
|
| 138 |
```
|
| 139 |
-
## Modalità 1:
|
| 140 |
|
| 141 |
-
Avvia la dashboard per
|
| 142 |
|
| 143 |
```bash
|
| 144 |
streamlit run app.py
|
|
@@ -146,9 +139,9 @@ streamlit run app.py
|
|
| 146 |
|
| 147 |
L'interfaccia sarà disponibile su `http://localhost:8501`.
|
| 148 |
|
| 149 |
-
## Modalità 2:
|
| 150 |
|
| 151 |
-
Avvia il motore in
|
| 152 |
|
| 153 |
```bash
|
| 154 |
python api.py
|
|
@@ -170,13 +163,6 @@ Il sistema produce una risposta JSON strutturata contenente:
|
|
| 170 |
- **Rate Limiting Wikidata**: Le chiamate di Entity Linking dipendono dai tempi di risposta dell'API pubblica di Wikidata; per ingestion intensive è consigliato l'uso di cache locali stratificate.
|
| 171 |
- **Dipendenza da Vocabolari**: L'accuratezza dell'estrazione semantica tramite Schema-RAG fluttua in base alla ricchezza descrittiva del dizionario JSON ontologico fornito in ingresso.
|
| 172 |
|
| 173 |
-
## Possibili estensioni future
|
| 174 |
-
|
| 175 |
-
- Disaccoppiamento architetturale: implementazione di un orchestratore ad alte prestazioni (es. in Golang) per gestire code di messaggistica asincrone e chiamare l'API Python solo per l'inferenza pura.
|
| 176 |
-
- Sviluppo di uno strato GraphRAG.
|
| 177 |
-
- Creazione di una dashboard operativa SPA (es. in Angular) connessa direttamente a Neo4j per la validazione Human-in-the-Loop su larga scala nei processi di BPO.
|
| 178 |
-
- Dockerizzazione multi-container per deploy enterprise in ambienti Kubernetes.
|
| 179 |
-
|
| 180 |
## Riferimenti
|
| 181 |
|
| 182 |
**Automated Semantic Discovery – Generazione Neuro-Simbolica di Ontologie Leggere e Vocabolari Semantici**
|
|
|
|
| 15 |

|
| 16 |

|
| 17 |

|
| 18 |
+

|
| 19 |
+

|
| 20 |
|
| 21 |
+
Questo repository contiene un **prototipo avanzato per la scoperta semantica automatica (Automated Semantic Discovery)**. Il sistema agisce come un microservizio finalizzato alla generazione di **ontologie authoritative** e **Knowledge Graphs** a partire da testo non strutturato.
|
| 22 |
|
| 23 |
+
Nasce come strumento abilitante per scenari aziendali e di BPO, dove l'estrazione massiva di dati deve coniugarsi con il rigore dei vocabolari semantici formali (es. ArCo, OntoPiA, CIDOC-CRM).
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
Il progetto espone una doppia interfaccia:
|
| 26 |
+
1. **API REST (FastAPI):** Ideale per l'integrazione asincrona e l'orchestrazione da parte di backend esterni ad alte prestazioni (es. Go/Java).
|
| 27 |
+
2. **Web UI (Streamlit):** Un'interfaccia interattiva, perfetta per demo, test curati e visualizzazione topologica del grafo.
|
| 28 |
|
| 29 |
+
## Il Paradigma Neuro-Simbolico
|
| 30 |
|
| 31 |
+
Il progetto supera i limiti dei tradizionali sistemi RAG o delle semplici estrazioni LLM implementando una pipeline ibrida:
|
| 32 |
+
- **Neuro (AI Generativa & Vettoriale):** Sfrutta la comprensione del testo dei Large Language Models (tramite Groq/Llama 3) e il clustering semantico basato su embedding spaziali (`sentence-transformers`).
|
| 33 |
+
- **Symbolic (Logica Deterministica):** Applica regole algoritmiche rigide per la validazione ontologica (**SHACL** via `pyshacl`), il Type-Driven Domain Traversal (**TDDT**) e l'Entity Linking formale.
|
|
|
|
| 34 |
|
| 35 |
+
## Workflow Architetturale (Fase 1)
|
| 36 |
|
| 37 |
+
La pipeline elabora i dati in memoria ed è orchestrata in moduli sequenziali indipendenti:
|
| 38 |
|
| 39 |
+
### 1. Semantic Chunking (`semantic_splitter.py`)
|
| 40 |
+
Segmentazione dinamica del testo basata su **cosine similarity** vettoriale. L'algoritmo calcola i percentili di distanza per individuare i reali "punti di rottura" argomentativi, garantendo chunk semanticamente coesi.
|
| 41 |
|
| 42 |
+
### 2. Type-Driven Domain Traversal - TDDT (`extractor.py`)
|
| 43 |
+
Estrazione relazionale a "imbuto" in due passaggi (Pass 1: Macro-Categorizzazione e Specializzazione; Pass 2: Estrazione Relazionale). Il modello linguistico è vincolato tramite *Structured Outputs* (Pydantic JSON Schema) a utilizzare esclusivamente gli URI presenti nel **Domain Index**, azzerando le allucinazioni sui tipi.
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
### 3. Hybrid Entity Resolution (`entity_resolver.py`)
|
| 46 |
+
- Deduplica locale in RAM tramite clustering spaziale (**DBSCAN**).
|
| 47 |
+
- Normalizzazione del "Label Bloat" tramite algoritmi di **Majority Voting** sui tipi ontologici.
|
| 48 |
+
- Risoluzione globale sui **Vector Index nativi di Neo4j**.
|
| 49 |
+
- **Entity Linking** asincrono tramite chiamate REST all'API di **Wikidata** per l'ancoraggio a concetti universali (`owl:sameAs`).
|
| 50 |
|
| 51 |
+
### 4. SHACL Blocking & Validation (`validator.py` & `build_schema.py`)
|
| 52 |
+
Costruzione automatica di vincoli SHACL a partire dal dizionario ontologico. Durante l'estrazione, un reasoner OWL RL convalida la conformità (Domain/Range) delle triple. Le triple invalide vengono deviate su una DLQ (Dead Letter Queue) in MongoDB per non corrompere il grafo principale.
|
| 53 |
|
| 54 |
+
### 5. Graph Persistence (`graph_loader.py`)
|
| 55 |
+
Salvataggio massivo transazionale (`UNWIND` Cypher) su database a grafo **Neo4j**, includendo tracciabilità della fonte (`evidence`, `reasoning`) per garantire la massima *Explainability*.
|
| 56 |
|
| 57 |
## Struttura del repository
|
| 58 |
|
|
|
|
| 62 |
├── assets/
|
| 63 |
│ └── style.css
|
| 64 |
│
|
| 65 |
+
├── ontology/
|
| 66 |
+
│ ├── domain_index.json # Indice gerarchico delle ontologie (JSON)
|
| 67 |
+
│ └── shapes/
|
| 68 |
+
│ └── auto_constraints.ttl # Regole SHACL autogenerate
|
| 69 |
│
|
| 70 |
├── src/
|
| 71 |
│ ├── ingestion/
|
|
|
|
| 73 |
│ ├── extraction/
|
| 74 |
│ │ └── extractor.py
|
| 75 |
│ ├── validation/
|
| 76 |
+
│ │ └── validator.py
|
|
|
|
|
|
|
| 77 |
│ └── graph/
|
| 78 |
│ ├── graph_loader.py
|
| 79 |
│ └── entity_resolver.py
|
| 80 |
│
|
| 81 |
+
├── app.py # Entrypoint Web UI (Streamlit)
|
| 82 |
├── api.py # Entrypoint API REST (FastAPI)
|
| 83 |
+
├── build_schema.py # Script per la generazione di index e shapes SHACL
|
| 84 |
+
├── Dockerfile # Configurazione container
|
| 85 |
+
├── .env.example
|
| 86 |
├── requirements.txt
|
| 87 |
└── README.md
|
| 88 |
```
|
|
|
|
| 90 |
## Tech Stack & Requisiti
|
| 91 |
|
| 92 |
- **Linguaggio**: Python 3.13
|
| 93 |
+
- **Database**: Neo4j (Graph), MongoDB (DLQ)
|
| 94 |
- **Interfacce**: FastAPI, Uvicorn, Streamlit
|
| 95 |
+
- **Core Libraries**:
|
| 96 |
+
- **Neuro** : `langchain`, `langchain-huggingface`, `langchain-groq`, `scikit-learn`
|
| 97 |
+
- **Symbolic** : `neo4j`, `rdflib`, `pyshacl`, `pydantic`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
> Le dipendenze complete sono elencate in `requirements.txt`.
|
| 100 |
|
|
|
|
| 106 |
NEO4J_URI=neo4j+s://<tuo-cluster>.databases.neo4j.io
|
| 107 |
NEO4J_USER=neo4j
|
| 108 |
NEO4J_PASSWORD=la_tua_password
|
| 109 |
+
MONGO_URI=mongodb://localhost:27017/
|
| 110 |
+
GROQ_API_KEY=tua_api_key_groq
|
| 111 |
+
ONTOLOGY_PATH=./ontology
|
| 112 |
```
|
|
|
|
| 113 |
|
| 114 |
## Installazione ed Esecuzione
|
| 115 |
|
| 116 |
```bash
|
| 117 |
+
# 1. Clona il repository
|
| 118 |
git clone [https://github.com/](https://github.com/)<username>/<repository>.git
|
| 119 |
cd prototipo
|
| 120 |
|
|
|
|
| 125 |
|
| 126 |
# 3. Installa le dipendenze
|
| 127 |
pip install -r requirements.txt
|
| 128 |
+
|
| 129 |
+
# 4. Genera gli indici ontologici (una tantum)
|
| 130 |
+
python build_schema.py
|
| 131 |
```
|
| 132 |
+
## Modalità 1: Web UI (Streamlit)
|
| 133 |
|
| 134 |
+
Avvia la dashboard interattiva per visualizzare il grafo e testare l'estrazione:
|
| 135 |
|
| 136 |
```bash
|
| 137 |
streamlit run app.py
|
|
|
|
| 139 |
|
| 140 |
L'interfaccia sarà disponibile su `http://localhost:8501`.
|
| 141 |
|
| 142 |
+
## Modalità 2: API REST Headless
|
| 143 |
|
| 144 |
+
Avvia il motore in ascolto per l'orchestrazione backend:
|
| 145 |
|
| 146 |
```bash
|
| 147 |
python api.py
|
|
|
|
| 163 |
- **Rate Limiting Wikidata**: Le chiamate di Entity Linking dipendono dai tempi di risposta dell'API pubblica di Wikidata; per ingestion intensive è consigliato l'uso di cache locali stratificate.
|
| 164 |
- **Dipendenza da Vocabolari**: L'accuratezza dell'estrazione semantica tramite Schema-RAG fluttua in base alla ricchezza descrittiva del dizionario JSON ontologico fornito in ingresso.
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
## Riferimenti
|
| 167 |
|
| 168 |
**Automated Semantic Discovery – Generazione Neuro-Simbolica di Ontologie Leggere e Vocabolari Semantici**
|
app.py
CHANGED
|
@@ -70,6 +70,24 @@ def get_validator():
|
|
| 70 |
shapes_file="ontology/shapes/auto_constraints.ttl"
|
| 71 |
)
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
# Pre-load dei modelli in memoria
|
| 74 |
_ = get_splitter()
|
| 75 |
_ = get_extractor()
|
|
@@ -353,40 +371,112 @@ with tab_vis:
|
|
| 353 |
if driver:
|
| 354 |
col_ctrl, col_info = st.columns([1, 4])
|
| 355 |
with col_ctrl:
|
| 356 |
-
physics = st.checkbox("Abilita Fisica (Gravità)", value=True)
|
| 357 |
generate_graph = st.button("🔄 Genera / Aggiorna Grafo", type="primary")
|
| 358 |
|
| 359 |
if generate_graph:
|
| 360 |
with st.spinner("Estrazione dati e generazione del grafo interattivo..."):
|
| 361 |
cypher_vis = """
|
| 362 |
-
MATCH (s)
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
|
|
|
|
|
|
|
|
|
| 367 |
"""
|
| 368 |
graph_data = run_query(driver, cypher_vis)
|
| 369 |
|
| 370 |
if graph_data:
|
| 371 |
-
net = Network(height="
|
|
|
|
| 372 |
for item in graph_data:
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
-
net.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
with tempfile.NamedTemporaryFile(delete=False, suffix='.html') as tmp:
|
| 381 |
net.save_graph(tmp.name)
|
| 382 |
with open(tmp.name, 'r', encoding='utf-8') as f:
|
| 383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 384 |
else:
|
| 385 |
st.warning("Il grafo è attualmente vuoto.")
|
| 386 |
st.session_state.graph_html = None
|
| 387 |
|
| 388 |
if st.session_state.graph_html:
|
| 389 |
-
components.html(st.session_state.graph_html, height=
|
| 390 |
else:
|
| 391 |
st.info("👆 Clicca su 'Genera / Aggiorna Grafo' per visualizzare i dati attuali di Neo4j.")
|
| 392 |
|
|
|
|
| 70 |
shapes_file="ontology/shapes/auto_constraints.ttl"
|
| 71 |
)
|
| 72 |
|
| 73 |
+
COLOR_PALETTE = {
|
| 74 |
+
"arco_CulturalProperty": "#FF5733", # Arancio
|
| 75 |
+
"core_Agent": "#33FF57", # Verde
|
| 76 |
+
"l0_Location": "#3357FF", # Blu
|
| 77 |
+
"l0_Object": "#F333FF", # Viola
|
| 78 |
+
"core_EventOrSituation": "#FFD433",# Giallo
|
| 79 |
+
"clv_City": "#33FFF3", # Turchese
|
| 80 |
+
"DEFAULT": "#97C2FC" # Blu standard
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
def get_node_color(labels):
|
| 84 |
+
specific_labels = [l for l in labels if l != 'Resource']
|
| 85 |
+
if not specific_labels:
|
| 86 |
+
return COLOR_PALETTE["DEFAULT"]
|
| 87 |
+
|
| 88 |
+
label = specific_labels[0]
|
| 89 |
+
return COLOR_PALETTE.get(label, COLOR_PALETTE["DEFAULT"])
|
| 90 |
+
|
| 91 |
# Pre-load dei modelli in memoria
|
| 92 |
_ = get_splitter()
|
| 93 |
_ = get_extractor()
|
|
|
|
| 371 |
if driver:
|
| 372 |
col_ctrl, col_info = st.columns([1, 4])
|
| 373 |
with col_ctrl:
|
|
|
|
| 374 |
generate_graph = st.button("🔄 Genera / Aggiorna Grafo", type="primary")
|
| 375 |
|
| 376 |
if generate_graph:
|
| 377 |
with st.spinner("Estrazione dati e generazione del grafo interattivo..."):
|
| 378 |
cypher_vis = """
|
| 379 |
+
MATCH (s:Resource)
|
| 380 |
+
OPTIONAL MATCH (s)-[r]->(o:Resource)
|
| 381 |
+
RETURN
|
| 382 |
+
s.label AS src,
|
| 383 |
+
labels(s) AS src_labels,
|
| 384 |
+
type(r) AS rel,
|
| 385 |
+
o.label AS dst,
|
| 386 |
+
labels(o) AS dst_labels
|
| 387 |
"""
|
| 388 |
graph_data = run_query(driver, cypher_vis)
|
| 389 |
|
| 390 |
if graph_data:
|
| 391 |
+
net = Network(height="800px", width="100%", bgcolor="#222222", font_color="white", notebook=False)
|
| 392 |
+
|
| 393 |
for item in graph_data:
|
| 394 |
+
if item['src']:
|
| 395 |
+
src_label_text = str(item['src'])
|
| 396 |
+
src_color = get_node_color(item['src_labels'])
|
| 397 |
+
net.add_node(src_label_text, label=src_label_text, color=src_color, title=f"Labels: {item['src_labels']}")
|
| 398 |
+
|
| 399 |
+
if item['dst'] and item['rel']:
|
| 400 |
+
dst_label_text = str(item['dst'])
|
| 401 |
+
rel_type = str(item['rel'])
|
| 402 |
+
dst_color = get_node_color(item['dst_labels'])
|
| 403 |
+
|
| 404 |
+
net.add_node(dst_label_text, label=dst_label_text, color=dst_color, title=f"Labels: {item['dst_labels']}")
|
| 405 |
+
|
| 406 |
+
net.add_edge(src_label_text, dst_label_text, title=rel_type)
|
| 407 |
|
| 408 |
+
net.force_atlas_2based(
|
| 409 |
+
gravity=-50,
|
| 410 |
+
central_gravity=0.01,
|
| 411 |
+
spring_length=100,
|
| 412 |
+
spring_strength=0.08,
|
| 413 |
+
damping=0.4
|
| 414 |
+
)
|
| 415 |
+
net.toggle_physics(True)
|
| 416 |
|
| 417 |
with tempfile.NamedTemporaryFile(delete=False, suffix='.html') as tmp:
|
| 418 |
net.save_graph(tmp.name)
|
| 419 |
with open(tmp.name, 'r', encoding='utf-8') as f:
|
| 420 |
+
raw_html = f.read()
|
| 421 |
+
|
| 422 |
+
fullscreen_addon = """
|
| 423 |
+
<style>
|
| 424 |
+
/* Quando l'iframe entra in fullscreen, forziamo il div di Pyvis a coprire l'intero schermo */
|
| 425 |
+
:fullscreen #mynetwork { height: 100vh !important; width: 100vw !important; }
|
| 426 |
+
:-webkit-full-screen #mynetwork { height: 100vh !important; width: 100vw !important; }
|
| 427 |
+
:-moz-full-screen #mynetwork { height: 100vh !important; width: 100vw !important; }
|
| 428 |
+
|
| 429 |
+
#fs-btn {
|
| 430 |
+
position: absolute; top: 15px; right: 15px; z-index: 9999;
|
| 431 |
+
width: 40px; height: 40px;
|
| 432 |
+
background-color: rgba(34, 34, 34, 0.7);
|
| 433 |
+
color: #4facfe; border: 1px solid #4facfe; border-radius: 8px;
|
| 434 |
+
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
| 435 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: all 0.2s ease-in-out;
|
| 436 |
+
}
|
| 437 |
+
#fs-btn:hover { background-color: #4facfe; color: white; }
|
| 438 |
+
</style>
|
| 439 |
+
|
| 440 |
+
<button id="fs-btn" onclick="toggleFullScreen()" title="Schermo Intero">
|
| 441 |
+
<svg id="fs-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 442 |
+
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
|
| 443 |
+
</svg>
|
| 444 |
+
</button>
|
| 445 |
+
|
| 446 |
+
<script>
|
| 447 |
+
const iconExpand = '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>';
|
| 448 |
+
const iconCompress = '<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path>';
|
| 449 |
+
|
| 450 |
+
function toggleFullScreen() {
|
| 451 |
+
if (!document.fullscreenElement) {
|
| 452 |
+
document.documentElement.requestFullscreen().catch(err => console.log(err));
|
| 453 |
+
} else {
|
| 454 |
+
if (document.exitFullscreen) { document.exitFullscreen(); }
|
| 455 |
+
}
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
// Ascoltiamo l'evento fullscreen per cambiare l'icona (Espandi/Riduci) anche se l'utente preme "ESC"
|
| 459 |
+
document.addEventListener('fullscreenchange', (event) => {
|
| 460 |
+
const icon = document.getElementById('fs-icon');
|
| 461 |
+
if (document.fullscreenElement) {
|
| 462 |
+
icon.innerHTML = iconCompress;
|
| 463 |
+
document.getElementById('fs-btn').title = "Riduci Schermo";
|
| 464 |
+
} else {
|
| 465 |
+
icon.innerHTML = iconExpand;
|
| 466 |
+
document.getElementById('fs-btn').title = "Schermo Intero";
|
| 467 |
+
}
|
| 468 |
+
});
|
| 469 |
+
</script>
|
| 470 |
+
</body>
|
| 471 |
+
"""
|
| 472 |
+
|
| 473 |
+
st.session_state.graph_html = raw_html.replace("</body>", fullscreen_addon)
|
| 474 |
else:
|
| 475 |
st.warning("Il grafo è attualmente vuoto.")
|
| 476 |
st.session_state.graph_html = None
|
| 477 |
|
| 478 |
if st.session_state.graph_html:
|
| 479 |
+
components.html(st.session_state.graph_html, height=800, scrolling=True)
|
| 480 |
else:
|
| 481 |
st.info("👆 Clicca su 'Genera / Aggiorna Grafo' per visualizzare i dati attuali di Neo4j.")
|
| 482 |
|
src/extraction/extractor.py
CHANGED
|
@@ -145,12 +145,17 @@ class NeuroSymbolicExtractor:
|
|
| 145 |
# ==========================================
|
| 146 |
roots_text = "\n".join([f"- {uri} — \"{data['label']}: {data['description']}\"" for uri, data in self.root_classes.items()])
|
| 147 |
|
| 148 |
-
sys_l1 = f"""
|
| 149 |
-
Puoi assegnare fino a 2 candidati per entità se sei incerto, ordinandoli per confidenza.
|
| 150 |
|
| 151 |
MACRO-CATEGORIE DISPONIBILI:
|
| 152 |
-
{roots_text}
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
res_l1: MacroClassificationResult = self._execute_with_retry(
|
| 155 |
self.chain_pass1_l1,
|
| 156 |
[SystemMessage(content=sys_l1), HumanMessage(content=text_chunk)]
|
|
@@ -204,7 +209,7 @@ SOTTO-TIPI DISPONIBILI:
|
|
| 204 |
# PASS 2: Estrazione Relazionale
|
| 205 |
# ==========================================
|
| 206 |
# Mappa dei tipi finali
|
| 207 |
-
typed_entities_map = {e.name: e.type for e in res_l2.entities}
|
| 208 |
|
| 209 |
# Recupero deterministico delle proprietà
|
| 210 |
valid_properties = []
|
|
@@ -228,10 +233,12 @@ PROPRIETÀ CONSENTITE (con vincoli domain → range):
|
|
| 228 |
{props_text}
|
| 229 |
- skos:related: Qualsiasi → Qualsiasi (Usa SOLO se nessuna proprietà sopra descrive accuratamente il legame)
|
| 230 |
|
| 231 |
-
REGOLE CRITICHE:
|
| 232 |
1. Usa SOLO le proprietà elencate sopra.
|
| 233 |
-
2.
|
| 234 |
-
3.
|
|
|
|
|
|
|
| 235 |
"""
|
| 236 |
|
| 237 |
final_res: KnowledgeGraphExtraction = self._execute_with_retry(
|
|
|
|
| 145 |
# ==========================================
|
| 146 |
roots_text = "\n".join([f"- {uri} — \"{data['label']}: {data['description']}\"" for uri, data in self.root_classes.items()])
|
| 147 |
|
| 148 |
+
sys_l1 = f"""Sei un estrattore esperto di entità semantiche per il dominio dei Beni Culturali. Il tuo unico compito è individuare le entità rilevanti nel testo e classificarle.
|
|
|
|
| 149 |
|
| 150 |
MACRO-CATEGORIE DISPONIBILI:
|
| 151 |
+
{roots_text}
|
| 152 |
+
|
| 153 |
+
REGOLE DI ESTRAZIONE (TASSATIVE E OBBLIGATORIE):
|
| 154 |
+
1. DIVIETO DI ALLUCINAZIONE URI: Usa ESCLUSIVAMENTE gli URI esatti elencati sopra. È severamente vietato usare etichette inventate come "Person", "Location" o "Group". Se devi categorizzare una persona, usa l'URI corrispondente agli Agenti (es. core:Agent o l0:Agent).
|
| 155 |
+
2. RUMORE EDITORIALE: IGNORA e non estrarre MAI riferimenti alla struttura del libro o alle immagini. È vietato estrarre entità che contengono o sono composte da: "Capitolo", "Sezione", "Tavola", "Fig.", "Figura", "Pagina", "Pag.".
|
| 156 |
+
3. Estrai SOLO veri monumenti storici, luoghi geografici reali, personaggi storici, popoli e concetti architettonici.
|
| 157 |
+
4. Puoi assegnare fino a 2 candidati per entità, ordinandoli per confidenza logica.
|
| 158 |
+
"""
|
| 159 |
res_l1: MacroClassificationResult = self._execute_with_retry(
|
| 160 |
self.chain_pass1_l1,
|
| 161 |
[SystemMessage(content=sys_l1), HumanMessage(content=text_chunk)]
|
|
|
|
| 209 |
# PASS 2: Estrazione Relazionale
|
| 210 |
# ==========================================
|
| 211 |
# Mappa dei tipi finali
|
| 212 |
+
typed_entities_map = {e.name: e.type.strip() for e in res_l2.entities}
|
| 213 |
|
| 214 |
# Recupero deterministico delle proprietà
|
| 215 |
valid_properties = []
|
|
|
|
| 233 |
{props_text}
|
| 234 |
- skos:related: Qualsiasi → Qualsiasi (Usa SOLO se nessuna proprietà sopra descrive accuratamente il legame)
|
| 235 |
|
| 236 |
+
REGOLE CRITICHE E OBBLIGATORIE:
|
| 237 |
1. Usa SOLO le proprietà elencate sopra.
|
| 238 |
+
2. Usa ESCLUSIVAMENTE le entità presenti nella lista "ENTITÀ IDENTIFICATE". È severamente vietato inventare o aggiungere entità non presenti in questo elenco.
|
| 239 |
+
3. I campi 'subject_type' e 'object_type' sono OBBLIGATORI. Devi sempre compilarli copiando esattamente il tipo indicato tra parentesi nella lista delle entità.
|
| 240 |
+
4. Rispetta rigorosamente i vincoli ontologici: il tipo del 'subject' DEVE essere compatibile con il domain, e il tipo dell''object' con il range.
|
| 241 |
+
5. Compila sempre i campi 'evidence' citando esattamente il testo, e 'reasoning' spiegando la scelta logica.
|
| 242 |
"""
|
| 243 |
|
| 244 |
final_res: KnowledgeGraphExtraction = self._execute_with_retry(
|
src/graph/entity_resolver.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import numpy as np
|
| 2 |
import requests
|
|
|
|
| 3 |
from sklearn.cluster import DBSCAN
|
| 4 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 5 |
|
|
@@ -48,21 +49,30 @@ class EntityResolver:
|
|
| 48 |
"search": entity_name,
|
| 49 |
"language": "it",
|
| 50 |
"format": "json",
|
| 51 |
-
"limit": 1
|
| 52 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
try:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
response = requests.get(url, params=params, timeout=3.0)
|
| 57 |
if response.status_code == 200:
|
| 58 |
data = response.json()
|
| 59 |
if data.get("search"):
|
| 60 |
best_match = data["search"][0]
|
| 61 |
return f"wd:{best_match['id']}"
|
| 62 |
else:
|
| 63 |
-
print(f" [DEBUG] Wikidata
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
except Exception as e:
|
| 65 |
print(f" ⚠️ Errore lookup Wikidata per '{entity_name}' (ignorato): {e}")
|
|
|
|
| 66 |
return None
|
| 67 |
|
| 68 |
def resolve_entities(self, extracted_entities, triples):
|
|
@@ -142,4 +152,44 @@ class EntityResolver:
|
|
| 142 |
|
| 143 |
resolved_entities = list(set([entity_replacement_map.get(e, e) for e in extracted_entities]))
|
| 144 |
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
import requests
|
| 3 |
+
from collections import Counter
|
| 4 |
from sklearn.cluster import DBSCAN
|
| 5 |
from langchain_huggingface import HuggingFaceEmbeddings
|
| 6 |
|
|
|
|
| 49 |
"search": entity_name,
|
| 50 |
"language": "it",
|
| 51 |
"format": "json",
|
| 52 |
+
"limit": 1
|
| 53 |
}
|
| 54 |
+
|
| 55 |
+
headers = {
|
| 56 |
+
"User-Agent": "ActivaSemanticDiscoveryBot/1.0 (https://activadigital.it; contact@activadigital.it) python-requests"
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
try:
|
| 60 |
+
response = requests.get(url, params=params, headers=headers, timeout=3.0)
|
| 61 |
+
|
|
|
|
| 62 |
if response.status_code == 200:
|
| 63 |
data = response.json()
|
| 64 |
if data.get("search"):
|
| 65 |
best_match = data["search"][0]
|
| 66 |
return f"wd:{best_match['id']}"
|
| 67 |
else:
|
| 68 |
+
print(f" [DEBUG] Wikidata vuoto per: '{entity_name}'")
|
| 69 |
+
pass
|
| 70 |
+
else:
|
| 71 |
+
print(f" ⚠️ Wikidata ha rifiutato la richiesta. Status: {response.status_code}")
|
| 72 |
+
|
| 73 |
except Exception as e:
|
| 74 |
print(f" ⚠️ Errore lookup Wikidata per '{entity_name}' (ignorato): {e}")
|
| 75 |
+
|
| 76 |
return None
|
| 77 |
|
| 78 |
def resolve_entities(self, extracted_entities, triples):
|
|
|
|
| 152 |
|
| 153 |
resolved_entities = list(set([entity_replacement_map.get(e, e) for e in extracted_entities]))
|
| 154 |
|
| 155 |
+
resolved_triples = self._normalize_types(resolved_triples)
|
| 156 |
+
|
| 157 |
+
return resolved_entities, resolved_triples, entities_to_save
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _normalize_types(self, resolved_triples):
|
| 161 |
+
print("⚖️ Normalizzazione Ontologica: Avvio Majority Voting per i tipi...")
|
| 162 |
+
|
| 163 |
+
# 1. Raccogliamo i voti: contiamo quante volte ogni tipo viene assegnato a un'entità
|
| 164 |
+
type_votes = {}
|
| 165 |
+
|
| 166 |
+
for t in resolved_triples:
|
| 167 |
+
# Conteggio per il subject
|
| 168 |
+
if t.subject not in type_votes:
|
| 169 |
+
type_votes[t.subject] = Counter()
|
| 170 |
+
type_votes[t.subject][t.subject_type] += 1
|
| 171 |
+
|
| 172 |
+
# Conteggio per l'object
|
| 173 |
+
if t.object not in type_votes:
|
| 174 |
+
type_votes[t.object] = Counter()
|
| 175 |
+
type_votes[t.object][t.object_type] += 1
|
| 176 |
+
|
| 177 |
+
# 2. Eleggiamo il vincitore: creiamo una mappa definitiva { "Nome Entità": "Tipo Dominante" }
|
| 178 |
+
canonical_types = {}
|
| 179 |
+
for entity, counter in type_votes.items():
|
| 180 |
+
# most_common(1) restituisce una lista di tuple es. [('cis:CreativeWork', 4)]
|
| 181 |
+
winning_type = counter.most_common(1)[0][0]
|
| 182 |
+
canonical_types[entity] = winning_type
|
| 183 |
+
|
| 184 |
+
# Opzionale: log se c'è stata una correzione
|
| 185 |
+
if len(counter) > 1:
|
| 186 |
+
print(f" -> Normalizzata '{entity}' a {winning_type} (Scartati: {list(counter.keys())})")
|
| 187 |
+
|
| 188 |
+
# 3. Riscriviamo le triple con il tipo vincitore
|
| 189 |
+
final_triples = []
|
| 190 |
+
for t in resolved_triples:
|
| 191 |
+
t.subject_type = canonical_types[t.subject]
|
| 192 |
+
t.object_type = canonical_types[t.object]
|
| 193 |
+
final_triples.append(t)
|
| 194 |
+
|
| 195 |
+
return final_triples
|
src/graph/graph_loader.py
CHANGED
|
@@ -77,7 +77,7 @@ class KnowledgeGraphPersister:
|
|
| 77 |
# Convenzione Neo4j: le relationships sono sempre in UPPERCASE
|
| 78 |
return clean.upper() if clean else "RELATED_TO"
|
| 79 |
|
| 80 |
-
def
|
| 81 |
if not self.driver or not triples:
|
| 82 |
return
|
| 83 |
|
|
|
|
| 77 |
# Convenzione Neo4j: le relationships sono sempre in UPPERCASE
|
| 78 |
return clean.upper() if clean else "RELATED_TO"
|
| 79 |
|
| 80 |
+
def save_triples(self, triples):
|
| 81 |
if not self.driver or not triples:
|
| 82 |
return
|
| 83 |
|