atareao bu gisti düzenledi 3 days ago. Düzenlemeye git
1 file changed, 394 insertions
knowledge_graph.py(dosya oluşturuldu)
| @@ -0,0 +1,394 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | """ | |
| 3 | + | knowledge_graph.py — Visualización de un grafo de conocimiento con NetworkX | |
| 4 | + | ||
| 5 | + | Construye un grafo de conocimiento con las herramientas del stack del podcast, | |
| 6 | + | muestra consultas sobre el grafo, y genera una imagen PNG del mismo. | |
| 7 | + | ||
| 8 | + | Propósito para el episodio 828: | |
| 9 | + | Demostrar visualmente qué es un "grafo de conocimiento" — la base de GraphRAG. | |
| 10 | + | Se ejecuta después de la comparativa RAG vs GraphRAG para mostrar un grafo real. | |
| 11 | + | ||
| 12 | + | Uso: | |
| 13 | + | python knowledge_graph.py # Modo interactivo (consulta + PNG) | |
| 14 | + | python knowledge_graph.py --no-png # Solo consultas, sin guardar imagen | |
| 15 | + | python knowledge_graph.py --png-only # Solo generar PNG | |
| 16 | + | ||
| 17 | + | Requisitos: | |
| 18 | + | pip install networkx matplotlib | |
| 19 | + | """ | |
| 20 | + | ||
| 21 | + | import argparse | |
| 22 | + | import sys | |
| 23 | + | from typing import Dict, List, Tuple | |
| 24 | + | ||
| 25 | + | try: | |
| 26 | + | import networkx as nx | |
| 27 | + | except ImportError: | |
| 28 | + | print("❌ Falta networkx. Instala con: pip install networkx matplotlib") | |
| 29 | + | sys.exit(1) | |
| 30 | + | ||
| 31 | + | try: | |
| 32 | + | from networkx.drawing.nx_pydot import graphviz_layout | |
| 33 | + | except ImportError: | |
| 34 | + | print("❌ Falta pydot. Instala con: pip install pydot") | |
| 35 | + | sys.exit(1) | |
| 36 | + | ||
| 37 | + | try: | |
| 38 | + | import matplotlib.pyplot as plt | |
| 39 | + | except ImportError: | |
| 40 | + | print("❌ Falta matplotlib. Instala con: pip install matplotlib") | |
| 41 | + | sys.exit(1) | |
| 42 | + | ||
| 43 | + | ||
| 44 | + | # ─── Datos del grafo ───────────────────────────────────────────────────────── | |
| 45 | + | ||
| 46 | + | # Nodos: (id, nombre, tipo, descripcion) | |
| 47 | + | ENTIDADES: List[Tuple[int, str, str, str]] = [ | |
| 48 | + | (1, "Traefik", "software", "Reverse proxy para servicios Docker"), | |
| 49 | + | (2, "Docker", "software", "Plataforma de contenedores"), | |
| 50 | + | (3, "Podman", "software", "Alternativa a Docker sin daemon"), | |
| 51 | + | (4, "Ollama", "software", "Ejecución local de modelos LLM"), | |
| 52 | + | (5, "Open WebUI", "software", "Interfaz web para LLMs locales"), | |
| 53 | + | (6, "LightRAG", "software", "GraphRAG ligero con actualización incremental"), | |
| 54 | + | (7, "sqlite-vec", "libreria", "Extensión vectorial para SQLite"), | |
| 55 | + | (8, "Whisper", "software", "Speech-to-text local"), | |
| 56 | + | (9, "Piper TTS", "software", "Text-to-speech local"), | |
| 57 | + | (10, "LLaVA", "modelo", "Modelo de visión local"), | |
| 58 | + | (11, "MCP", "protocolo", "Model Context Protocol"), | |
| 59 | + | (12, "OpenCode", "software", "CLI/TUI para desarrollo asistido con IA"), | |
| 60 | + | (13, "Hermes Agent", "software", "Agente autónomo con skills y MCP"), | |
| 61 | + | (14, "Anacleto", "software", "Motor de orquestación de agentes en Rust"), | |
| 62 | + | (15, "CrewAI", "libreria", "Framework Python para equipos de agentes"), | |
| 63 | + | (16, "systemd", "software", "Init system y gestor de servicios"), | |
| 64 | + | (17, "Quadlets", "software", "Contenedores gestionados por systemd"), | |
| 65 | + | (18, "NetworkX", "libreria", "Biblioteca Python para análisis de grafos"), | |
| 66 | + | ] | |
| 67 | + | ||
| 68 | + | # Aristas: (origen_id, destino_id, relacion) | |
| 69 | + | RELACIONES: List[Tuple[int, int, str]] = [ | |
| 70 | + | # Traefik | |
| 71 | + | (1, 2, "gestiona tráfico de"), | |
| 72 | + | (1, 3, "también funciona con"), | |
| 73 | + | (1, 17, "se despliega con"), | |
| 74 | + | # Docker | |
| 75 | + | (2, 3, "es alternativa de"), | |
| 76 | + | (2, 17, "gestionado por"), | |
| 77 | + | # Ollama | |
| 78 | + | (4, 5, "tiene interfaz en"), | |
| 79 | + | (4, 11, "expone API compatible"), | |
| 80 | + | # Open WebUI | |
| 81 | + | (5, 11, "soporta"), | |
| 82 | + | (5, 6, "integra RAG con"), | |
| 83 | + | # LightRAG | |
| 84 | + | (6, 7, "puede usar"), | |
| 85 | + | (6, 18, "construye grafos con"), | |
| 86 | + | # Agentes | |
| 87 | + | (12, 11, "implementa"), | |
| 88 | + | (13, 11, "implementa"), | |
| 89 | + | (14, 11, "implementa"), | |
| 90 | + | (12, 4, "usa modelos de"), | |
| 91 | + | (13, 4, "usa modelos de"), | |
| 92 | + | (14, 4, "usa modelos de"), | |
| 93 | + | (15, 12, "comparable con"), | |
| 94 | + | # Multimedia | |
| 95 | + | (8, 4, "se integra con"), | |
| 96 | + | (9, 4, "se integra con"), | |
| 97 | + | (10, 4, "se ejecuta vía"), | |
| 98 | + | # Infraestructura | |
| 99 | + | (16, 17, "gestiona"), | |
| 100 | + | (17, 2, "gestiona"), | |
| 101 | + | (17, 3, "gestiona"), | |
| 102 | + | ] | |
| 103 | + | ||
| 104 | + | # Colores por tipo de entidad (para el gráfico) | |
| 105 | + | COLORES_TIPO = { | |
| 106 | + | "software": "#4A90D9", # azul | |
| 107 | + | "libreria": "#50C878", # verde | |
| 108 | + | "modelo": "#E8A838", # naranja | |
| 109 | + | "protocolo": "#9B59B6", # púrpura | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | ||
| 113 | + | # ─── Construcción del grafo ────────────────────────────────────────────────── | |
| 114 | + | ||
| 115 | + | ||
| 116 | + | def construir_grafo() -> nx.DiGraph: | |
| 117 | + | """Construye el grafo dirigido con entidades y relaciones.""" | |
| 118 | + | G = nx.DiGraph() | |
| 119 | + | ||
| 120 | + | # Añadir nodos con atributos | |
| 121 | + | for eid, nombre, tipo, desc in ENTIDADES: | |
| 122 | + | G.add_node(nombre, tipo=tipo, descripcion=desc, id=eid) | |
| 123 | + | ||
| 124 | + | # Añadir aristas con atributos | |
| 125 | + | for origen_id, destino_id, relacion in RELACIONES: | |
| 126 | + | # Buscar nombres por ID | |
| 127 | + | origen_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == origen_id) | |
| 128 | + | destino_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == destino_id) | |
| 129 | + | G.add_edge(origen_nombre, destino_nombre, relacion=relacion) | |
| 130 | + | ||
| 131 | + | return G | |
| 132 | + | ||
| 133 | + | ||
| 134 | + | # ─── Consultas sobre el grafo ──────────────────────────────────────────────── | |
| 135 | + | ||
| 136 | + | ||
| 137 | + | def consultar_relaciones(G: nx.DiGraph, entidad: str) -> None: | |
| 138 | + | """ | |
| 139 | + | Consulta todas las relaciones de una entidad: qué sale de ella y qué llega. | |
| 140 | + | Esta es la consulta equivalente a la del SQL en demo-graphrag.sh. | |
| 141 | + | """ | |
| 142 | + | print(f"\n{'=' * 60}") | |
| 143 | + | print(f" CONSULTA: ¿Qué está relacionado con «{entidad}»?") | |
| 144 | + | print(f"{'=' * 60}\n") | |
| 145 | + | ||
| 146 | + | if entidad not in G: | |
| 147 | + | print(f" ⚠ La entidad «{entidad}» no existe en el grafo.\n") | |
| 148 | + | return | |
| 149 | + | ||
| 150 | + | # Relaciones salientes (entidad → destino) | |
| 151 | + | salientes = list(G.out_edges(entidad, data=True)) | |
| 152 | + | if salientes: | |
| 153 | + | print(f" ▶ {entidad} se relaciona CON:") | |
| 154 | + | for _, destino, data in salientes: | |
| 155 | + | print(f" • {data['relacion']} → {destino}") | |
| 156 | + | else: | |
| 157 | + | print(f" ▶ {entidad} no tiene relaciones salientes.") | |
| 158 | + | ||
| 159 | + | # Relaciones entrantes (origen → entidad) | |
| 160 | + | entrantes = list(G.in_edges(entidad, data=True)) | |
| 161 | + | if entrantes: | |
| 162 | + | print(f"\n ▶ {entidad} es relacionado POR:") | |
| 163 | + | for origen, _, data in entrantes: | |
| 164 | + | print(f" • {origen} → {data['relacion']}") | |
| 165 | + | else: | |
| 166 | + | print(f"\n ▶ {entidad} no tiene relaciones entrantes.") | |
| 167 | + | ||
| 168 | + | print() | |
| 169 | + | ||
| 170 | + | ||
| 171 | + | def consultar_camino(G: nx.DiGraph, origen: str, destino: str) -> None: | |
| 172 | + | """ | |
| 173 | + | Encuentra el camino más corto entre dos entidades en el grafo. | |
| 174 | + | Demuestra que el grafo permite navegación semántica. | |
| 175 | + | """ | |
| 176 | + | print(f"\n{'=' * 60}") | |
| 177 | + | print(f" CONSULTA: Camino de «{origen}» a «{destino}»") | |
| 178 | + | print(f"{'=' * 60}\n") | |
| 179 | + | ||
| 180 | + | if origen not in G: | |
| 181 | + | print(f" ⚠ La entidad «{origen}» no existe.\n") | |
| 182 | + | return | |
| 183 | + | if destino not in G: | |
| 184 | + | print(f" ⚠ La entidad «{destino}» no existe.\n") | |
| 185 | + | return | |
| 186 | + | ||
| 187 | + | try: | |
| 188 | + | camino = nx.shortest_path(G, origen, destino) | |
| 189 | + | print(f" Ruta encontrada ({len(camino) - 1} saltos):") | |
| 190 | + | for i in range(len(camino) - 1): | |
| 191 | + | a, b = camino[i], camino[i + 1] | |
| 192 | + | relacion = G[a][b]["relacion"] | |
| 193 | + | print(f" {a} ──[{relacion}]──→ {b}") | |
| 194 | + | print() | |
| 195 | + | except nx.NetworkXNoPath: | |
| 196 | + | print(f" ⚠ No hay camino entre «{origen}» y «{destino}».\n") | |
| 197 | + | ||
| 198 | + | ||
| 199 | + | def consultar_vecinos_comunes(G: nx.DiGraph, entidad_a: str, entidad_b: str) -> None: | |
| 200 | + | """ | |
| 201 | + | Encuentra vecinos comunes entre dos entidades. | |
| 202 | + | Útil para descubrir conexiones indirectas. | |
| 203 | + | """ | |
| 204 | + | print(f"\n{'=' * 60}") | |
| 205 | + | print(f" CONSULTA: Vecinos comunes de «{entidad_a}» y «{entidad_b}»") | |
| 206 | + | print(f"{'=' * 60}\n") | |
| 207 | + | ||
| 208 | + | if entidad_a not in G or entidad_b not in G: | |
| 209 | + | print(" ⚠ Una de las entidades no existe.\n") | |
| 210 | + | return | |
| 211 | + | ||
| 212 | + | # Vecinos: predecesores + sucesores | |
| 213 | + | vecinos_a = set(G.predecessors(entidad_a)) | set(G.successors(entidad_a)) | |
| 214 | + | vecinos_b = set(G.predecessors(entidad_b)) | set(G.successors(entidad_b)) | |
| 215 | + | comunes = vecinos_a & vecinos_b | |
| 216 | + | ||
| 217 | + | if comunes: | |
| 218 | + | print(f" Entidades conectadas tanto con «{entidad_a}» como con «{entidad_b}»:") | |
| 219 | + | for v in sorted(comunes): | |
| 220 | + | print(f" • {v}") | |
| 221 | + | else: | |
| 222 | + | print(f" No hay vecinos comunes entre «{entidad_a}» y «{entidad_b}».") | |
| 223 | + | print() | |
| 224 | + | ||
| 225 | + | ||
| 226 | + | def mostrar_estadisticas(G: nx.DiGraph) -> None: | |
| 227 | + | """Muestra estadísticas básicas del grafo.""" | |
| 228 | + | print(f"\n{'=' * 60}") | |
| 229 | + | print(f" ESTADÍSTICAS DEL GRAFO DE CONOCIMIENTO") | |
| 230 | + | print(f"{'=' * 60}\n") | |
| 231 | + | print(f" • Nodos (entidades): {G.number_of_nodes()}") | |
| 232 | + | print(f" • Aristas (relaciones): {G.number_of_edges()}") | |
| 233 | + | print(f" • Densidad: {nx.density(G):.4f}") | |
| 234 | + | print(f" • ¿Es conexo?: {'Sí' if nx.is_weakly_connected(G) else 'No'}") | |
| 235 | + | print( | |
| 236 | + | f" • Diámetro: {nx.diameter(G.to_undirected()) if nx.is_weakly_connected(G) else 'N/A'}" | |
| 237 | + | ) | |
| 238 | + | print() | |
| 239 | + | ||
| 240 | + | ||
| 241 | + | # ─── Visualización ─────────────────────────────────────────────────────────── | |
| 242 | + | ||
| 243 | + | ||
| 244 | + | def generar_png(G: nx.DiGraph, archivo: str = "grafo_conocimiento.png") -> str: | |
| 245 | + | """ | |
| 246 | + | Genera una imagen PNG del grafo usando matplotlib. | |
| 247 | + | Los nodos se colorean por tipo y las aristas se etiquetan con la relación. | |
| 248 | + | """ | |
| 249 | + | print(f" Generando visualización: {archivo} ...", end=" ") | |
| 250 | + | ||
| 251 | + | plt.figure(figsize=(16, 12)) | |
| 252 | + | ||
| 253 | + | # Layout: usar Graphviz si está disponible, sino spring_layout | |
| 254 | + | try: | |
| 255 | + | pos = graphviz_layout(G, prog="dot") | |
| 256 | + | except Exception: | |
| 257 | + | pos = nx.spring_layout(G, k=1.5, seed=42, iterations=50) | |
| 258 | + | ||
| 259 | + | # Dibujar nodos por tipo (cada tipo con su color) | |
| 260 | + | for tipo in COLORES_TIPO: | |
| 261 | + | nodos_tipo = [n for n, attr in G.nodes(data=True) if attr["tipo"] == tipo] | |
| 262 | + | nx.draw_networkx_nodes( | |
| 263 | + | G, | |
| 264 | + | pos, | |
| 265 | + | nodelist=nodos_tipo, | |
| 266 | + | node_color=COLORES_TIPO[tipo], | |
| 267 | + | node_size=2500, | |
| 268 | + | node_shape="o", | |
| 269 | + | edgecolors="white", | |
| 270 | + | linewidths=1.5, | |
| 271 | + | alpha=0.95, | |
| 272 | + | ) | |
| 273 | + | ||
| 274 | + | # Dibujar aristas con flechas | |
| 275 | + | nx.draw_networkx_edges( | |
| 276 | + | G, | |
| 277 | + | pos, | |
| 278 | + | edge_color="#888888", | |
| 279 | + | arrows=True, | |
| 280 | + | arrowsize=20, | |
| 281 | + | arrowstyle="->", | |
| 282 | + | width=1.5, | |
| 283 | + | alpha=0.7, | |
| 284 | + | connectionstyle="arc3,rad=0.1", | |
| 285 | + | ) | |
| 286 | + | ||
| 287 | + | # Etiquetas de los nodos | |
| 288 | + | nx.draw_networkx_labels( | |
| 289 | + | G, | |
| 290 | + | pos, | |
| 291 | + | font_size=11, | |
| 292 | + | font_weight="bold", | |
| 293 | + | font_family="sans-serif", | |
| 294 | + | ) | |
| 295 | + | ||
| 296 | + | # Etiquetas de las aristas (relaciones) | |
| 297 | + | edge_labels = {(a, b): data["relacion"] for a, b, data in G.edges(data=True)} | |
| 298 | + | nx.draw_networkx_edge_labels( | |
| 299 | + | G, | |
| 300 | + | pos, | |
| 301 | + | edge_labels=edge_labels, | |
| 302 | + | font_size=8, | |
| 303 | + | font_family="sans-serif", | |
| 304 | + | alpha=0.8, | |
| 305 | + | label_pos=0.5, | |
| 306 | + | ) | |
| 307 | + | ||
| 308 | + | # Leyenda | |
| 309 | + | legend_elements = [] | |
| 310 | + | for tipo, color in COLORES_TIPO.items(): | |
| 311 | + | legend_elements.append( | |
| 312 | + | plt.scatter( | |
| 313 | + | [], [], c=color, s=150, label=tipo, edgecolors="white", linewidths=1 | |
| 314 | + | ) | |
| 315 | + | ) | |
| 316 | + | plt.legend( | |
| 317 | + | handles=legend_elements, | |
| 318 | + | title="Tipo de entidad", | |
| 319 | + | loc="upper right", | |
| 320 | + | fontsize=10, | |
| 321 | + | title_fontsize=12, | |
| 322 | + | ) | |
| 323 | + | ||
| 324 | + | plt.title( | |
| 325 | + | "Grafo de conocimiento — Stack de herramientas del podcast", | |
| 326 | + | fontsize=16, | |
| 327 | + | fontweight="bold", | |
| 328 | + | pad=20, | |
| 329 | + | ) | |
| 330 | + | plt.axis("off") | |
| 331 | + | plt.tight_layout() | |
| 332 | + | plt.savefig(archivo, dpi=150, bbox_inches="tight", facecolor="#FAFAFA") | |
| 333 | + | plt.close() | |
| 334 | + | ||
| 335 | + | print("✅") | |
| 336 | + | return archivo | |
| 337 | + | ||
| 338 | + | ||
| 339 | + | # ─── Main ──────────────────────────────────────────────────────────────────── | |
| 340 | + | ||
| 341 | + | ||
| 342 | + | def main(): | |
| 343 | + | parser = argparse.ArgumentParser( | |
| 344 | + | description="Demo de grafo de conocimiento con NetworkX", | |
| 345 | + | ) | |
| 346 | + | parser.add_argument( | |
| 347 | + | "--no-png", | |
| 348 | + | action="store_true", | |
| 349 | + | help="No generar PNG, solo mostrar consultas", | |
| 350 | + | ) | |
| 351 | + | parser.add_argument( | |
| 352 | + | "--png-only", | |
| 353 | + | action="store_true", | |
| 354 | + | help="Solo generar PNG sin consultas", | |
| 355 | + | ) | |
| 356 | + | args = parser.parse_args() | |
| 357 | + | ||
| 358 | + | print() | |
| 359 | + | print("╔══════════════════════════════════════════════════════════╗") | |
| 360 | + | print("║ GRAFO DE CONOCIMIENTO — Demo para el episodio 828 ║") | |
| 361 | + | print("║ Construido con NetworkX + matplotlib ║") | |
| 362 | + | print("╚══════════════════════════════════════════════════════════╝") | |
| 363 | + | ||
| 364 | + | # Construir el grafo | |
| 365 | + | print("\n Construyendo grafo...") | |
| 366 | + | G = construir_grafo() | |
| 367 | + | print( | |
| 368 | + | f" ✓ Grafo creado: {G.number_of_nodes()} nodos, {G.number_of_edges()} aristas" | |
| 369 | + | ) | |
| 370 | + | ||
| 371 | + | if not args.png_only: | |
| 372 | + | # Mostrar estadísticas | |
| 373 | + | mostrar_estadisticas(G) | |
| 374 | + | ||
| 375 | + | # Consulta principal: ¿Qué está relacionado con Traefik? | |
| 376 | + | consultar_relaciones(G, "Traefik") | |
| 377 | + | ||
| 378 | + | # Consulta: camino de OpenCode a Quadlets | |
| 379 | + | consultar_camino(G, "OpenCode", "Quadlets") | |
| 380 | + | ||
| 381 | + | # Consulta: vecinos comunes de Ollama y Open WebUI | |
| 382 | + | consultar_vecinos_comunes(G, "Ollama", "Open WebUI") | |
| 383 | + | ||
| 384 | + | if not args.no_png: | |
| 385 | + | archivo = generar_png(G) | |
| 386 | + | print(f"\n 📁 Imagen guardada: {archivo}") | |
| 387 | + | print(f" Ábrela para ver el grafo visualmente.") | |
| 388 | + | ||
| 389 | + | print("\n ✅ Demo completada.") | |
| 390 | + | print() | |
| 391 | + | ||
| 392 | + | ||
| 393 | + | if __name__ == "__main__": | |
| 394 | + | main() | |
Daha yeni
Daha eski