--- name: build_obsidian_graph kind: function lang: py domain: obsidian version: "1.0.0" purity: impure signature: "def build_obsidian_graph(vault_dir: str, include_dangling: bool = True) -> dict" description: "Construye el grafo agregado (nodos + aristas) de un vault de Obsidian leyendo todas sus notas .md. Cada nota es un nodo tipado (tipo por carpeta o frontmatter, id = slug, label = frontmatter['nombre'] o slug) y cada wikilink [[...]] del cuerpo es una arista dirigida resuelta por slug del ultimo segmento del destino. Los wikilinks rotos se incluyen como nodos fantasma dangling o se descartan segun include_dangling. Compone list_obsidian_notes, read_obsidian_note y slugify_obsidian_name del grupo obsidian. Es la pieza que cierra la frontera 'el grupo obsidian no indexa el grafo agregado'. Base de la vista grafo (sigma.js) de la app osint_web." tags: [obsidian, graph, vault, nodes, edges, wikilinks, sigma, osint] uses_functions: ["list_obsidian_notes_py_obsidian", "read_obsidian_note_py_obsidian", "slugify_obsidian_name_py_obsidian"] uses_types: [] returns: [] returns_optional: false error_type: "error_go_core" imports: ["os", "re"] params: - name: vault_dir desc: "ruta (absoluta o relativa) a la raiz del vault de Obsidian a indexar; se excluyen .obsidian/ y .trash/" - name: include_dangling desc: "si True (por defecto) los wikilinks que no resuelven a ninguna nota generan un nodo fantasma con dangling=True y su arista; si False, esos enlaces rotos se descartan" output: "dict con 'nodes' (lista de {id: slug, tipo: str, label: str, frontmatter: dict}; los fantasma anaden dangling=True y frontmatter vacio) y 'edges' (lista de {source: slug, target: slug, kind: str} deduplicada; kind es relacion/lugar/documento segun la seccion ## donde aparece el wikilink, o 'wikilink' por defecto)" tested: true tests: - "golden grafo del mini-vault con nodos y aristas esperados" - "resuelve wikilink con acentos maria del mar al slug" - "el kind de la arista sale de la seccion del cuerpo" - "dangling marcado con true y excluido con false" - "el tipo cae a la carpeta si falta en frontmatter" - "wikilink sintacticamente roto no tumba el grafo" - "vault inexistente lanza filenotfounderror" test_file_path: "python/functions/obsidian/build_obsidian_graph_test.py" file_path: "python/functions/obsidian/build_obsidian_graph.py" --- ## Ejemplo ```python import sys, os sys.path.insert(0, os.path.join("python", "functions")) from obsidian.build_obsidian_graph import build_obsidian_graph graph = build_obsidian_graph("/home/enmanuel/Obsidian/osint") print(len(graph["nodes"]), "nodos,", len(graph["edges"]), "aristas") # 1199 nodos (984 reales + 215 fantasma), 618 aristas # Un nodo tipado listo para sigma.js: color por tipo, label visible. n = next(x for x in graph["nodes"] if x["tipo"] == "persona") print(n["id"], n["label"], n["tipo"]) # ana-gomez Ana Gómez persona # Aristas con su kind (relacion / lugar / documento / wikilink). for e in graph["edges"][:3]: print(e["source"], "->", e["target"], f"({e['kind']})") ``` Lanzable directo sobre el vault real (imprime conteo de nodos/aristas/dangling): ```bash cd /home/enmanuel/fn_registry PYTHONPATH=python/functions python/.venv/bin/python3 \ python/functions/obsidian/build_obsidian_graph.py /home/enmanuel/Obsidian/osint ``` ## Cuando usarla Cuando necesites el grafo entero de un vault de Obsidian de una sola pasada: para pintar una vista de nodos navegable (sigma.js / graphology), para detectar objetivos OSINT aun sin fichar (nodos `dangling`), o para alimentar el endpoint `/api/graph` de una app que lee el vault sin BD intermedia. Es el agregado que las funciones atomicas del grupo `obsidian` (`list_obsidian_notes`, `read_obsidian_note`) no daban por si solas. ## Gotchas - **Lee todo el vault de disco** (I/O impuro): el grafo refleja el estado de los `.md` en ese instante; vuelve a llamar para refrescar tras editar notas. - **El `id` de un nodo es el slug = nombre de archivo sin `.md`**. Si dos notas en carpetas distintas comparten ese nombre (caso real en el vault osint: `dni.md`, `fotos.md`, `certificado-digital.md` repetidos dentro de cada subcarpeta `personas//`), colapsan al **mismo nodo** y solo la primera en orden alfabetico sobrevive. Por eso el vault con 1022 `.md` produce 984 nodos reales (13 grupos de slugs colisionan). Para evitarlo habria que usar el path relativo como id — se dejo el slug por compatibilidad con la resolucion de wikilinks `[[slug]]`. - **Resolucion de wikilinks por ultimo segmento**: `[[organizaciones/acme-sl|Acme SL]]` resuelve a la nota cuyo slug es `acme-sl`; el alias (`|...`) y el ancla (`#...`) se ignoran. Nombres con acentos/mayusculas (`[[María del Mar]]`) se slugifican con `slugify_obsidian_name` antes de buscar, asi que resuelven igual que `[[maria-del-mar]]`. - **`kind` por seccion es heuristico**: depende del texto del encabezado `## ...` mas cercano por encima del wikilink (`Relaciones`/`Relacionado` -> `relacion`, `Lugares` -> `lugar`, `Documentos` -> `documento`, resto -> `wikilink`). Un wikilink fuera de cualquier seccion conocida es `wikilink`. - **Auto-enlaces ignorados**: si una nota se enlaza a si misma, esa arista no se emite. - **Nodos fantasma** (`dangling: true`) llevan `tipo: "desconocido"` y `frontmatter` vacio; no representan un `.md` en disco. Con `include_dangling=False` no aparecen ni ellos ni sus aristas. - Lanza `FileNotFoundError` si `vault_dir` no existe y `NotADirectoryError` si no es un directorio (heredado de `list_obsidian_notes`). Una nota individual ilegible se omite sin tumbar el grafo.