knowledge_graph.py
· 13 KiB · Python
Ham
#!/usr/bin/env python3
"""
knowledge_graph.py — Visualización de un grafo de conocimiento con NetworkX
Construye un grafo de conocimiento con las herramientas del stack del podcast,
muestra consultas sobre el grafo, y genera una imagen PNG del mismo.
Propósito para el episodio 828:
Demostrar visualmente qué es un "grafo de conocimiento" — la base de GraphRAG.
Se ejecuta después de la comparativa RAG vs GraphRAG para mostrar un grafo real.
Uso:
python knowledge_graph.py # Modo interactivo (consulta + PNG)
python knowledge_graph.py --no-png # Solo consultas, sin guardar imagen
python knowledge_graph.py --png-only # Solo generar PNG
Requisitos:
pip install networkx matplotlib
"""
import argparse
import sys
from typing import Dict, List, Tuple
try:
import networkx as nx
except ImportError:
print("❌ Falta networkx. Instala con: pip install networkx matplotlib")
sys.exit(1)
try:
from networkx.drawing.nx_pydot import graphviz_layout
except ImportError:
print("❌ Falta pydot. Instala con: pip install pydot")
sys.exit(1)
try:
import matplotlib.pyplot as plt
except ImportError:
print("❌ Falta matplotlib. Instala con: pip install matplotlib")
sys.exit(1)
# ─── Datos del grafo ─────────────────────────────────────────────────────────
# Nodos: (id, nombre, tipo, descripcion)
ENTIDADES: List[Tuple[int, str, str, str]] = [
(1, "Traefik", "software", "Reverse proxy para servicios Docker"),
(2, "Docker", "software", "Plataforma de contenedores"),
(3, "Podman", "software", "Alternativa a Docker sin daemon"),
(4, "Ollama", "software", "Ejecución local de modelos LLM"),
(5, "Open WebUI", "software", "Interfaz web para LLMs locales"),
(6, "LightRAG", "software", "GraphRAG ligero con actualización incremental"),
(7, "sqlite-vec", "libreria", "Extensión vectorial para SQLite"),
(8, "Whisper", "software", "Speech-to-text local"),
(9, "Piper TTS", "software", "Text-to-speech local"),
(10, "LLaVA", "modelo", "Modelo de visión local"),
(11, "MCP", "protocolo", "Model Context Protocol"),
(12, "OpenCode", "software", "CLI/TUI para desarrollo asistido con IA"),
(13, "Hermes Agent", "software", "Agente autónomo con skills y MCP"),
(14, "Anacleto", "software", "Motor de orquestación de agentes en Rust"),
(15, "CrewAI", "libreria", "Framework Python para equipos de agentes"),
(16, "systemd", "software", "Init system y gestor de servicios"),
(17, "Quadlets", "software", "Contenedores gestionados por systemd"),
(18, "NetworkX", "libreria", "Biblioteca Python para análisis de grafos"),
]
# Aristas: (origen_id, destino_id, relacion)
RELACIONES: List[Tuple[int, int, str]] = [
# Traefik
(1, 2, "gestiona tráfico de"),
(1, 3, "también funciona con"),
(1, 17, "se despliega con"),
# Docker
(2, 3, "es alternativa de"),
(2, 17, "gestionado por"),
# Ollama
(4, 5, "tiene interfaz en"),
(4, 11, "expone API compatible"),
# Open WebUI
(5, 11, "soporta"),
(5, 6, "integra RAG con"),
# LightRAG
(6, 7, "puede usar"),
(6, 18, "construye grafos con"),
# Agentes
(12, 11, "implementa"),
(13, 11, "implementa"),
(14, 11, "implementa"),
(12, 4, "usa modelos de"),
(13, 4, "usa modelos de"),
(14, 4, "usa modelos de"),
(15, 12, "comparable con"),
# Multimedia
(8, 4, "se integra con"),
(9, 4, "se integra con"),
(10, 4, "se ejecuta vía"),
# Infraestructura
(16, 17, "gestiona"),
(17, 2, "gestiona"),
(17, 3, "gestiona"),
]
# Colores por tipo de entidad (para el gráfico)
COLORES_TIPO = {
"software": "#4A90D9", # azul
"libreria": "#50C878", # verde
"modelo": "#E8A838", # naranja
"protocolo": "#9B59B6", # púrpura
}
# ─── Construcción del grafo ──────────────────────────────────────────────────
def construir_grafo() -> nx.DiGraph:
"""Construye el grafo dirigido con entidades y relaciones."""
G = nx.DiGraph()
# Añadir nodos con atributos
for eid, nombre, tipo, desc in ENTIDADES:
G.add_node(nombre, tipo=tipo, descripcion=desc, id=eid)
# Añadir aristas con atributos
for origen_id, destino_id, relacion in RELACIONES:
# Buscar nombres por ID
origen_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == origen_id)
destino_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == destino_id)
G.add_edge(origen_nombre, destino_nombre, relacion=relacion)
return G
# ─── Consultas sobre el grafo ────────────────────────────────────────────────
def consultar_relaciones(G: nx.DiGraph, entidad: str) -> None:
"""
Consulta todas las relaciones de una entidad: qué sale de ella y qué llega.
Esta es la consulta equivalente a la del SQL en demo-graphrag.sh.
"""
print(f"\n{'=' * 60}")
print(f" CONSULTA: ¿Qué está relacionado con «{entidad}»?")
print(f"{'=' * 60}\n")
if entidad not in G:
print(f" ⚠ La entidad «{entidad}» no existe en el grafo.\n")
return
# Relaciones salientes (entidad → destino)
salientes = list(G.out_edges(entidad, data=True))
if salientes:
print(f" ▶ {entidad} se relaciona CON:")
for _, destino, data in salientes:
print(f" • {data['relacion']} → {destino}")
else:
print(f" ▶ {entidad} no tiene relaciones salientes.")
# Relaciones entrantes (origen → entidad)
entrantes = list(G.in_edges(entidad, data=True))
if entrantes:
print(f"\n ▶ {entidad} es relacionado POR:")
for origen, _, data in entrantes:
print(f" • {origen} → {data['relacion']}")
else:
print(f"\n ▶ {entidad} no tiene relaciones entrantes.")
print()
def consultar_camino(G: nx.DiGraph, origen: str, destino: str) -> None:
"""
Encuentra el camino más corto entre dos entidades en el grafo.
Demuestra que el grafo permite navegación semántica.
"""
print(f"\n{'=' * 60}")
print(f" CONSULTA: Camino de «{origen}» a «{destino}»")
print(f"{'=' * 60}\n")
if origen not in G:
print(f" ⚠ La entidad «{origen}» no existe.\n")
return
if destino not in G:
print(f" ⚠ La entidad «{destino}» no existe.\n")
return
try:
camino = nx.shortest_path(G, origen, destino)
print(f" Ruta encontrada ({len(camino) - 1} saltos):")
for i in range(len(camino) - 1):
a, b = camino[i], camino[i + 1]
relacion = G[a][b]["relacion"]
print(f" {a} ──[{relacion}]──→ {b}")
print()
except nx.NetworkXNoPath:
print(f" ⚠ No hay camino entre «{origen}» y «{destino}».\n")
def consultar_vecinos_comunes(G: nx.DiGraph, entidad_a: str, entidad_b: str) -> None:
"""
Encuentra vecinos comunes entre dos entidades.
Útil para descubrir conexiones indirectas.
"""
print(f"\n{'=' * 60}")
print(f" CONSULTA: Vecinos comunes de «{entidad_a}» y «{entidad_b}»")
print(f"{'=' * 60}\n")
if entidad_a not in G or entidad_b not in G:
print(" ⚠ Una de las entidades no existe.\n")
return
# Vecinos: predecesores + sucesores
vecinos_a = set(G.predecessors(entidad_a)) | set(G.successors(entidad_a))
vecinos_b = set(G.predecessors(entidad_b)) | set(G.successors(entidad_b))
comunes = vecinos_a & vecinos_b
if comunes:
print(f" Entidades conectadas tanto con «{entidad_a}» como con «{entidad_b}»:")
for v in sorted(comunes):
print(f" • {v}")
else:
print(f" No hay vecinos comunes entre «{entidad_a}» y «{entidad_b}».")
print()
def mostrar_estadisticas(G: nx.DiGraph) -> None:
"""Muestra estadísticas básicas del grafo."""
print(f"\n{'=' * 60}")
print(f" ESTADÍSTICAS DEL GRAFO DE CONOCIMIENTO")
print(f"{'=' * 60}\n")
print(f" • Nodos (entidades): {G.number_of_nodes()}")
print(f" • Aristas (relaciones): {G.number_of_edges()}")
print(f" • Densidad: {nx.density(G):.4f}")
print(f" • ¿Es conexo?: {'Sí' if nx.is_weakly_connected(G) else 'No'}")
print(
f" • Diámetro: {nx.diameter(G.to_undirected()) if nx.is_weakly_connected(G) else 'N/A'}"
)
print()
# ─── Visualización ───────────────────────────────────────────────────────────
def generar_png(G: nx.DiGraph, archivo: str = "grafo_conocimiento.png") -> str:
"""
Genera una imagen PNG del grafo usando matplotlib.
Los nodos se colorean por tipo y las aristas se etiquetan con la relación.
"""
print(f" Generando visualización: {archivo} ...", end=" ")
plt.figure(figsize=(16, 12))
# Layout: usar Graphviz si está disponible, sino spring_layout
try:
pos = graphviz_layout(G, prog="dot")
except Exception:
pos = nx.spring_layout(G, k=1.5, seed=42, iterations=50)
# Dibujar nodos por tipo (cada tipo con su color)
for tipo in COLORES_TIPO:
nodos_tipo = [n for n, attr in G.nodes(data=True) if attr["tipo"] == tipo]
nx.draw_networkx_nodes(
G,
pos,
nodelist=nodos_tipo,
node_color=COLORES_TIPO[tipo],
node_size=2500,
node_shape="o",
edgecolors="white",
linewidths=1.5,
alpha=0.95,
)
# Dibujar aristas con flechas
nx.draw_networkx_edges(
G,
pos,
edge_color="#888888",
arrows=True,
arrowsize=20,
arrowstyle="->",
width=1.5,
alpha=0.7,
connectionstyle="arc3,rad=0.1",
)
# Etiquetas de los nodos
nx.draw_networkx_labels(
G,
pos,
font_size=11,
font_weight="bold",
font_family="sans-serif",
)
# Etiquetas de las aristas (relaciones)
edge_labels = {(a, b): data["relacion"] for a, b, data in G.edges(data=True)}
nx.draw_networkx_edge_labels(
G,
pos,
edge_labels=edge_labels,
font_size=8,
font_family="sans-serif",
alpha=0.8,
label_pos=0.5,
)
# Leyenda
legend_elements = []
for tipo, color in COLORES_TIPO.items():
legend_elements.append(
plt.scatter(
[], [], c=color, s=150, label=tipo, edgecolors="white", linewidths=1
)
)
plt.legend(
handles=legend_elements,
title="Tipo de entidad",
loc="upper right",
fontsize=10,
title_fontsize=12,
)
plt.title(
"Grafo de conocimiento — Stack de herramientas del podcast",
fontsize=16,
fontweight="bold",
pad=20,
)
plt.axis("off")
plt.tight_layout()
plt.savefig(archivo, dpi=150, bbox_inches="tight", facecolor="#FAFAFA")
plt.close()
print("✅")
return archivo
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Demo de grafo de conocimiento con NetworkX",
)
parser.add_argument(
"--no-png",
action="store_true",
help="No generar PNG, solo mostrar consultas",
)
parser.add_argument(
"--png-only",
action="store_true",
help="Solo generar PNG sin consultas",
)
args = parser.parse_args()
print()
print("╔══════════════════════════════════════════════════════════╗")
print("║ GRAFO DE CONOCIMIENTO — Demo para el episodio 828 ║")
print("║ Construido con NetworkX + matplotlib ║")
print("╚══════════════════════════════════════════════════════════╝")
# Construir el grafo
print("\n Construyendo grafo...")
G = construir_grafo()
print(
f" ✓ Grafo creado: {G.number_of_nodes()} nodos, {G.number_of_edges()} aristas"
)
if not args.png_only:
# Mostrar estadísticas
mostrar_estadisticas(G)
# Consulta principal: ¿Qué está relacionado con Traefik?
consultar_relaciones(G, "Traefik")
# Consulta: camino de OpenCode a Quadlets
consultar_camino(G, "OpenCode", "Quadlets")
# Consulta: vecinos comunes de Ollama y Open WebUI
consultar_vecinos_comunes(G, "Ollama", "Open WebUI")
if not args.no_png:
archivo = generar_png(G)
print(f"\n 📁 Imagen guardada: {archivo}")
print(f" Ábrela para ver el grafo visualmente.")
print("\n ✅ Demo completada.")
print()
if __name__ == "__main__":
main()
| 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() |
| 395 |