diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 277b2ab..ef2b38b 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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/.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//.md` Una nota-documento agrupa los attachments de un tipo de documento de la persona. diff --git a/tools/dedup_persons.py b/tools/dedup_persons.py new file mode 100644 index 0000000..8a8fbc1 --- /dev/null +++ b/tools/dedup_persons.py @@ -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\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() diff --git a/tools/extract_entities.py b/tools/extract_entities.py new file mode 100644 index 0000000..2f0db01 --- /dev/null +++ b/tools/extract_entities.py @@ -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 [--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() diff --git a/tools/migrate_external_orgs.py b/tools/migrate_external_orgs.py new file mode 100644 index 0000000..c360ffb --- /dev/null +++ b/tools/migrate_external_orgs.py @@ -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/.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//.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""); 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() diff --git a/tools/normalize_person_frontmatter.py b/tools/normalize_person_frontmatter.py new file mode 100644 index 0000000..f158540 --- /dev/null +++ b/tools/normalize_person_frontmatter.py @@ -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() diff --git a/tools/person_datapoints.py b/tools/person_datapoints.py new file mode 100644 index 0000000..a3c9a38 --- /dev/null +++ b/tools/person_datapoints.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Reporte de datapoints y score de fiabilidad/completitud por persona en osint. + +Para cada ficha personas/.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()