#!/usr/bin/env python3 """Extrae entidades (personas y organizaciones) mencionadas en los titulos de un vault Obsidian, via ask_llm (NER). Genera fichas-indice en osint que documentan cada entidad y las notas donde aparece (referencia textual; los vaults son separados, no hay wikilink cross-vault). Uso: extract_entities.py [--apply] Sin --apply: solo imprime el preview (no escribe nada). NO mueve ni modifica el vault de origen: solo lee titulos y, con --apply, crea fichas en osint. No sobreescribe fichas de persona/organizacion ya existentes en osint (no pisa datos ricos). """ import sys, os, json, re from collections import defaultdict sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions") from core.ask_llm import ask_llm from obsidian import list_obsidian_notes, slugify_obsidian_name, create_obsidian_note OBS = "/home/enmanuel/Obsidian" OSINT = f"{OBS}/osint" BATCH = 60 def vault_titles(vault): vp = f"{OBS}/{vault}" out = [] for n in list_obsidian_notes(vp): if "/.git/" in n or "/dist/" in n: continue out.append(os.path.basename(n)[:-3]) return sorted(set(out)) def ner_batch(titles, vault): listado = "\n".join(f"{i}. {t}" for i, t in enumerate(titles)) prompt = ( f"Estos son titulos de notas de un vault Obsidian ('{vault}'). Extrae entidades reales:\n" "- personas: nombres propios de individuos reales (colegas, clientes, ponentes, contactos).\n" "- organizaciones: empresas/entidades reales con las que se trabaja (clientes, proveedores, partners, mutuas).\n" "EXCLUYE estrictamente: herramientas y productos de software (BigQuery, Looker, Metabase, " "Navision, Palantir, Dask, ElasticSearch, Excel, Google Cloud, Fivetran, OTRS, TPV, Beats, " "Looker Studio, Visual Studio), conceptos tecnicos, lenguajes, y la propia empresa Aurgi.\n" "Solo incluye una persona si su nombre propio aparece explicito. No inventes.\n" 'Devuelve SOLO JSON: {"personas": {"Nombre Apellido": [indices]}, "organizaciones": {"Nombre": [indices]}}.\n' "Indices 0-based de los titulos donde aparece la entidad.\n\n" + listado ) raw = ask_llm(prompt, model="claude-haiku-4-5-20251001", echo=False) m = re.search(r'\{.*\}', raw, re.S) if not m: return {"personas": {}, "organizaciones": {}} try: return json.loads(m.group(0)) except Exception: return {"personas": {}, "organizaciones": {}} # Nombres que el NER suele clasificar mal como persona pero son organizaciones/sistemas. NOT_PERSON = {"anjana"} def extract(vault): titles = vault_titles(vault) personas, orgs = defaultdict(set), defaultdict(set) for b in range(0, len(titles), BATCH): chunk = titles[b:b + BATCH] res = ner_batch(chunk, vault) for nombre, idxs in (res.get("personas") or {}).items(): for i in idxs: if isinstance(i, int) and 0 <= i < len(chunk): personas[nombre.strip()].add(chunk[i]) for nombre, idxs in (res.get("organizaciones") or {}).items(): for i in idxs: if isinstance(i, int) and 0 <= i < len(chunk): orgs[nombre.strip()].add(chunk[i]) # reclasificar falsos-persona conocidos como organizacion for n in list(personas): if slugify_obsidian_name(n) in NOT_PERSON: orgs[n] |= personas.pop(n) return titles, personas, orgs def make_fichas(vault, personas, orgs): created, skipped = [], [] for nombre, notas in personas.items(): slug = slugify_obsidian_name(nombre) if not slug or len(slug) < 3: continue if os.path.exists(f"{OSINT}/personas/{slug}.md"): skipped.append(("persona", nombre)); continue body = [f"Contacto detectado en el vault {vault} (red profesional).", "", "## Aparece en", ""] + [f"- {t} _({vault})_" for t in sorted(notas)] + ["", "## Notas", ""] create_obsidian_note(OSINT, f"personas/{slug}", body="\n".join(body), frontmatter={"tipo": "persona", "nombre": nombre, "slug": slug, "contexto": vault.lower(), "tags": ["persona", "osint", "contacto"], "fuente": vault}, overwrite=False) created.append(("persona", nombre)) for nombre, notas in orgs.items(): slug = slugify_obsidian_name(nombre) if not slug or len(slug) < 2: continue if os.path.exists(f"{OSINT}/organizaciones/{slug}.md"): skipped.append(("org", nombre)); continue body = [f"Organizacion detectada en el vault {vault} (red profesional).", "", "## Aparece en", ""] + [f"- {t} _({vault})_" for t in sorted(notas)] + ["", "## Notas", ""] create_obsidian_note(OSINT, f"organizaciones/{slug}", body="\n".join(body), frontmatter={"tipo": "organizacion", "nombre": nombre, "slug": slug, "externa": True, "contexto": vault.lower(), "tags": ["organizacion", "externa", "osint"], "fuente": vault}, overwrite=False) created.append(("org", nombre)) return created, skipped def main(): if len(sys.argv) < 2: print("uso: extract_entities.py [--apply]"); return vault = sys.argv[1] apply = "--apply" in sys.argv titles, personas, orgs = extract(vault) print(f"{vault}: {len(titles)} titulos | personas={len(personas)} | organizaciones={len(orgs)}\n") print("PERSONAS:") for n in sorted(personas, key=lambda x: -len(personas[x])): print(f" {n} ({len(personas[n])} notas)") print("\nORGANIZACIONES:") for n in sorted(orgs, key=lambda x: -len(orgs[x])): print(f" {n} ({len(orgs[n])} notas)") if apply: created, skipped = make_fichas(vault, personas, orgs) print(f"\nfichas creadas: {len(created)} | omitidas (ya existian): {len(skipped)}") if skipped: print(" omitidas:", ", ".join(n for _, n in skipped[:15])) if __name__ == "__main__": main()