Files
osint/tools/extract_entities.py
T
egutierrez f771c9b883 chore: auto-commit (6 archivos)
- CONVENTIONS.md
- tools/dedup_persons.py
- tools/extract_entities.py
- tools/migrate_external_orgs.py
- tools/normalize_person_frontmatter.py
- tools/person_datapoints.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 00:16:47 +02:00

137 lines
6.1 KiB
Python

#!/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 <Vault> [--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 <Vault> [--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()