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>
This commit is contained in:
2026-06-11 00:16:47 +02:00
parent 186ce83856
commit f771c9b883
6 changed files with 508 additions and 0 deletions
+32
View File
@@ -70,6 +70,38 @@ Body, secciones en este orden (omitir las vacías):
Texto libre de investigación.
```
## 3b. Esquema canónico del frontmatter de persona
Todas las fichas `personas/<slug>.md` comparten el mismo conjunto de campos, en este orden.
Campos sin valor se dejan como `null` (o `[]` para listas) — nunca se omiten, para que el
cálculo de datapoints y el score de completitud sean consistentes.
```yaml
tipo: persona
nombre: "Nombre Apellidos"
slug: nombre-apellidos
aliases: [] # otros nombres por los que aparece
sexo: null # hombre | mujer | null
fecha_nacimiento: null # ISO YYYY-MM-DD | null
dni: null
telefono: null
email: null
direccion: null # texto plano
pais: null
relaciones: [] # ["[[slug]] — parentesco/rol"]
contexto: null # familia | aurgiobsidian | ... (origen/circulo)
fuente: "" # nota/vault de procedencia
tags: [persona, osint]
```
Campos extra heredados (p.ej. `horoscopo`) se conservan al final del frontmatter.
**Datapoints y score de fiabilidad.** Los campos de identidad que cuentan para el score de
completitud son: `sexo, fecha_nacimiento, dni, telefono, email, direccion, pais` (7). El score
de una ficha es `campos_identidad_presentes / 7 * 100`. Una ficha por debajo del 100% tiene
datapoints faltantes. El total de datapoints de una persona suma además sus documentos,
attachments y relaciones. Lo calcula `projects/osint/tools/person_datapoints.py`.
## 4. Nota-documento — `personas/<slug>/<doc-slug>.md`
Una nota-documento agrupa los attachments de un tipo de documento de la persona.
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Deduplica fichas de persona en osint cuando el slug de una es subconjunto estricto de
tokens de otra (p.ej. manuel-torrubia <= simon-manuel-torrubia = misma persona, nombre largo).
Fusiona la corta en la larga (canonica = nombre mas completo): campos no-null, aliases, docs,
attachments y body. Borra la duplicada. Con --apply ejecuta; sin flag solo lista candidatos.
"""
import sys, os, glob, shutil
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from obsidian import read_obsidian_note, create_obsidian_note, delete_obsidian_note
OSINT = "/home/enmanuel/Obsidian/osint"
def load():
out = {}
for fp in glob.glob(f"{OSINT}/personas/*.md"):
s = os.path.splitext(os.path.basename(fp))[0]
if s.startswith("_"):
continue
out[s] = read_obsidian_note(fp)
return out
def main():
apply = "--apply" in sys.argv
fichas = load()
slugs = list(fichas)
pairs = []
for a in slugs:
for b in slugs:
if a == b:
continue
ta, tb = set(a.split("-")), set(b.split("-"))
# a subconjunto estricto de b, con >=2 tokens compartidos (evita nombres de pila sueltos)
if ta < tb and len(ta) >= 2:
pairs.append((a, b))
# quedarse con el superset mas grande por cada corta
best = {}
for a, b in pairs:
if a not in best or len(b) > len(best[a]):
best[a] = b
print(f"candidatos a fusion: {len(best)}")
for a, b in best.items():
print(f" {a} -> {b}")
if not apply or not best:
if not apply:
print("\n(dry-run; usa --apply)")
return
for a, b in best.items():
if a not in fichas or b not in fichas:
continue
fa, fb = fichas[a], fichas[b]
new = dict(fb["frontmatter"])
for k, v in fa["frontmatter"].items():
if v not in (None, "", []) and new.get(k) in (None, "", []):
new[k] = v
al = set(new.get("aliases") or [])
al.add(fa["frontmatter"].get("nombre"))
new["aliases"] = sorted(x for x in al if x)
body = fb["body"].rstrip() + f"\n\n<!-- fusionado desde {a} -->\n" + fa["body"].strip()
# mover docs y attachments de la corta a la canonica
for d in glob.glob(f"{OSINT}/personas/{a}/*"):
os.makedirs(f"{OSINT}/personas/{b}", exist_ok=True)
shutil.move(d, f"{OSINT}/personas/{b}/{os.path.basename(d)}")
for at in glob.glob(f"{OSINT}/attachments/personas/{a}/*"):
os.makedirs(f"{OSINT}/attachments/personas/{b}", exist_ok=True)
shutil.move(at, f"{OSINT}/attachments/personas/{b}/{os.path.basename(at)}")
create_obsidian_note(OSINT, f"personas/{b}", body=body, frontmatter=new, overwrite=True)
delete_obsidian_note(f"{OSINT}/personas/{a}.md")
for empty in (f"{OSINT}/personas/{a}", f"{OSINT}/attachments/personas/{a}"):
if os.path.isdir(empty) and not os.listdir(empty):
os.rmdir(empty)
print(f" fusionado {a} -> {b}")
if __name__ == "__main__":
main()
+136
View File
@@ -0,0 +1,136 @@
#!/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()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""Fichas-indice de organizaciones EXTERNAS (proveedores, bancos, telcos, gov) en osint.
A diferencia de las empresas propias (FenixFood/Biorganic), las externas ya aparecen como
documentos dentro de personas/ y organizaciones/fenixfood-sl/. Este script:
1. Crea una ficha organizaciones/<slug>.md (tipo: organizacion, externa: true) por cada
empresa externa.
2. Cross-referencia (## Relacionado) los documentos ya existentes en osint donde aparece
la empresa, SIN moverlos (siguen bajo su dueño: FenixFood o la persona).
3. Para las empresas con notas sueltas reales en NotasDeObsidian (move_loose=True), mueve
esas notas a organizaciones/<slug>/<doc-slug>.md con sus attachments.
Idempotente (overwrite). Modo MOVER para las sueltas (borra original); backup tar cubre.
"""
import sys, os, re, shutil
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from obsidian import (read_obsidian_note, create_obsidian_note,
slugify_obsidian_name, extract_obsidian_embeds, resolve_obsidian_embed,
list_obsidian_notes)
NOTAS = "/home/enmanuel/Obsidian/NotasDeObsidian"
OSINT = "/home/enmanuel/Obsidian/osint"
STOPWORDS = {"de", "del", "la", "las", "el", "los", "y", "a", "en"}
# (display, slug, regex_match, regex_excluir_falsos_positivos, move_loose)
EXT = [
("Santander", "santander", r"santander", None, False),
("Unicaja", "unicaja", r"unicaja", None, True),
("Abanca", "abanca", r"abanca", None, False),
("BBVA", "bbva", r"\bbbva\b", None, False),
("Cajamar", "cajamar", r"cajamar", None, False),
("Vodafone", "vodafone", r"vodafone", None, True),
("Orange", "orange", r"\borange\b", r"orange\s*pi", False),
("Endesa", "endesa", r"endesa", None, False),
("Ebury", "ebury", r"ebury", None, False),
("OVH", "ovh", r"\bovh\b", None, False),
("Legalitas", "legalitas", r"legalitas", None, False),
("Revolut", "revolut", r"revolut", r"paypal2revolut", False),
("Kiwa", "kiwa", r"kiwa", None, False),
("Martransit", "martransit", r"martransit", None, False),
("Transportes Nieves", "transportes-nieves", r"transportes\s*nieves", None, False),
("AEAT", "aeat", r"\baeat\b|agencia\s+tributaria", None, True),
]
def doc_slug(title, ntok):
parts = [p for p in slugify_obsidian_name(title).split("-")
if p and p not in ntok and p not in STOPWORDS]
return "-".join(parts) or "documento"
def main():
osint_docs = [n for n in list_obsidian_notes(OSINT)
if "/personas/" in n or "/organizaciones/" in n]
notas_rest = [n for n in list_obsidian_notes(NOTAS)
if "/.git/" not in n and "/dist/" not in n]
att_del, docs_del, report = set(), set(), []
for display, slug, rx, excl, move_loose in EXT:
rxc = re.compile(rx, re.I)
exclc = re.compile(excl, re.I) if excl else None
ntok = set(slug.split("-")) | {"documentos", "documento"}
# 1. cross-refs en osint (no mover)
xrefs = []
for n in osint_docs:
t = os.path.basename(n)[:-3]
if rxc.search(t) and not (exclc and exclc.search(t)):
rel = os.path.relpath(n, OSINT)[:-3] # sin .md
dueno = rel.split("/")[1] if rel.startswith(("personas/", "organizaciones/")) else ""
xrefs.append((rel, t, dueno))
# 2. notas sueltas reales (solo si move_loose)
moved = []
if move_loose:
att_rel = f"attachments/organizaciones/{slug}"
att_abs = f"{OSINT}/{att_rel}"
os.makedirs(att_abs, exist_ok=True)
for n in notas_rest:
t = os.path.basename(n)[:-3]
if not rxc.search(t) or (exclc and exclc.search(t)):
continue
if slugify_obsidian_name(t) == slug: # nota homonima = cuerpo de ficha, se trata aparte
continue
dn = read_obsidian_note(n)
ds = doc_slug(t, ntok)
base, k = ds, 2
while os.path.exists(f"{OSINT}/organizaciones/{slug}/{ds}.md"):
ds = f"{base}-{k}"; k += 1
new = []
for i, emb in enumerate(extract_obsidian_embeds(dn["body"]), 1):
ap = resolve_obsidian_embed(NOTAS, emb)
if not ap:
new.append(f"<!-- no encontrado: {emb} -->"); continue
ext = os.path.splitext(ap)[1].lower()
nn = f"{ds}-{i}{ext}"
shutil.copy2(ap, f"{att_abs}/{nn}"); att_del.add(ap)
new.append(f"![[{att_rel}/{nn}]]")
text = re.sub(r'!\[\[[^\]]+\]\]', '', dn["body"]).strip()
parts = [x for x in [text, "\n".join(new)] if x]
create_obsidian_note(OSINT, f"organizaciones/{slug}/{ds}",
body="\n\n".join(parts) if parts else "(sin contenido)",
frontmatter={"tipo": "documento", "entidad": f"[[{slug}]]",
"fuente": "NotasDeObsidian/" + os.path.relpath(n, NOTAS)},
overwrite=True)
moved.append((t, ds)); docs_del.add(n)
# 3. ficha-indice (omitir si no hay nada que referenciar)
if not xrefs and not moved:
continue
bl = [f"Ficha de la organizacion externa {display}. Referencia de todo lo trabajado con ellos.", ""]
if xrefs:
bl += ["## Relacionado", ""]
for rel, t, dueno in xrefs:
bl.append(f"- [[{rel}|{t}]]" + (f" ({dueno})" if dueno else ""))
if moved:
bl += ["", "## Documentos", ""] + [f"- [[organizaciones/{slug}/{ds}|{t}]]" for t, ds in moved]
bl += ["", "## Notas", ""]
create_obsidian_note(OSINT, f"organizaciones/{slug}", body="\n".join(bl),
frontmatter={"tipo": "organizacion", "nombre": display, "slug": slug,
"externa": True, "tags": ["organizacion", "externa", "osint"]},
overwrite=True)
report.append((display, slug, len(xrefs), len(moved)))
for p in docs_del:
try: os.remove(p)
except FileNotFoundError: pass
moved_att = 0
for ap in att_del:
try:
os.remove(ap); moved_att += 1
except FileNotFoundError:
pass
print(f"fichas externas creadas: {len(report)} | sueltas movidas: {len(docs_del)} | attachments: {moved_att}\n")
for d, s, x, m in report:
print(f" {d:20} -> organizaciones/{s} | xref={x} sueltas_movidas={m}")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Normaliza el frontmatter de todas las fichas de persona de osint al esquema canonico
(projects/osint/CONVENTIONS.md seccion 3b). Preserva valores existentes y campos extra; solo
garantiza que todos los campos canonicos esten presentes y en orden, con null/[] por defecto.
No toca el body. Idempotente.
"""
import sys, os, glob
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from obsidian import read_obsidian_note, create_obsidian_note
OSINT = "/home/enmanuel/Obsidian/osint"
CANON = ["tipo", "nombre", "slug", "aliases", "sexo", "fecha_nacimiento", "dni",
"telefono", "email", "direccion", "pais", "relaciones", "contexto", "fuente", "tags"]
LISTS = {"aliases", "relaciones", "tags"}
def main():
n = 0
for fp in sorted(glob.glob(f"{OSINT}/personas/*.md")):
base = os.path.basename(fp)
if base.startswith("_"):
continue
f = read_obsidian_note(fp)
fm = dict(f["frontmatter"])
slug = os.path.splitext(base)[0]
new = {}
for k in CANON:
if k in fm and fm[k] not in (None, "", []):
new[k] = fm[k]
else:
new[k] = [] if k in LISTS else None
# defaults sensatos
new["tipo"] = "persona"
if not new["nombre"]:
new["nombre"] = fm.get("nombre") or slug.replace("-", " ").title()
new["slug"] = slug
if not new["aliases"]:
new["aliases"] = [new["nombre"]]
if not new["tags"]:
new["tags"] = ["persona", "osint"]
if new["fuente"] is None:
new["fuente"] = ""
# conservar campos extra (horoscopo, etc.) al final
for k, v in fm.items():
if k not in new:
new[k] = v
create_obsidian_note(OSINT, f"personas/{slug}", body=f["body"], frontmatter=new, overwrite=True)
n += 1
print(f"fichas de persona normalizadas: {n}")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Reporte de datapoints y score de fiabilidad/completitud por persona en osint.
Para cada ficha personas/<slug>.md calcula:
- score de completitud: campos de identidad presentes / 7 * 100
(sexo, fecha_nacimiento, dni, telefono, email, direccion, pais)
- datapoints totales: campos de identidad presentes + nº documentos + nº attachments + relaciones
- campos faltantes (cuando el score < 100%)
Salida: tabla ordenada por score asc (las menos fiables primero) + totales globales.
Con --json imprime el detalle como JSON. Read-only.
"""
import sys, os, glob, json
sys.path.insert(0, "/home/enmanuel/fn_registry/python/functions")
from obsidian import read_obsidian_note
OSINT = "/home/enmanuel/Obsidian/osint"
IDENT = ["sexo", "fecha_nacimiento", "dni", "telefono", "email", "direccion", "pais"]
def main():
as_json = "--json" in sys.argv
rows = []
tot_dp = 0
for fp in sorted(glob.glob(f"{OSINT}/personas/*.md")):
slug = os.path.splitext(os.path.basename(fp))[0]
if slug.startswith("_"):
continue
fm = read_obsidian_note(fp)["frontmatter"]
present = [k for k in IDENT if fm.get(k) not in (None, "", [])]
missing = [k for k in IDENT if k not in present]
score = round(len(present) / len(IDENT) * 100)
ndocs = len(glob.glob(f"{OSINT}/personas/{slug}/*.md"))
natt = len(glob.glob(f"{OSINT}/attachments/personas/{slug}/*"))
nrel = len(fm.get("relaciones") or [])
dp = len(present) + ndocs + natt + nrel
tot_dp += dp
rows.append({"slug": slug, "score": score, "datapoints": dp,
"ident": len(present), "docs": ndocs, "attachments": natt,
"relaciones": nrel, "faltan": missing})
rows.sort(key=lambda r: (r["score"], -r["datapoints"]))
if as_json:
print(json.dumps({"total_datapoints": tot_dp, "personas": rows}, ensure_ascii=False, indent=2))
return
print(f"PERSONAS: {len(rows)} | datapoints totales: {tot_dp} | "
f"score medio: {round(sum(r['score'] for r in rows)/len(rows))}%\n")
print(f"{'persona':38} {'score':>5} {'dp':>4} {'id':>3} {'doc':>4} {'att':>4} {'rel':>4} faltan")
print("-" * 100)
for r in rows:
flag = "" if r["score"] == 100 else " <-- " + ",".join(r["faltan"])
print(f"{r['slug']:38} {r['score']:>4}% {r['datapoints']:>4} {r['ident']:>3} "
f"{r['docs']:>4} {r['attachments']:>4} {r['relaciones']:>4}{flag}")
bajo = [r for r in rows if r["score"] < 100]
print(f"\nfichas por debajo del 100%: {len(bajo)}/{len(rows)} "
f"({round(len(bajo)/len(rows)*100)}%)")
if __name__ == "__main__":
main()