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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user