feat(org): derived.org_contacts + materializacion de contactos en la ficha de cada organizacion

Deriva del campo relaciones del frontmatter ("[[org-slug]] — rol") los telefonos
de las personas de contacto de cada organizacion (124 orgs, 185 pares) y los
expone en dos sitios: la tabla derived.org_contacts (consultable) y un bloque
sentinel 'org-contacts' en la ficha .md de cada organizacion (tabla persona/rol/
telefono). Nuevo endpoint POST /api/org/render-contacts. Asi, al buscar una
empresa, aparecen todos los telefonos de sus contactos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:03:44 +02:00
parent d7c28c8f55
commit 36c4e06779
3 changed files with 189 additions and 2 deletions
+84 -1
View File
@@ -9,9 +9,18 @@ el último estado de las maestras.
from __future__ import annotations
import json
import re
# Nombres de las derivadas que este módulo gestiona (para reportar en ingest).
DERIVED_TABLES = ("person_stats", "event_monthly", "contact_link_quality")
DERIVED_TABLES = (
"person_stats",
"event_monthly",
"contact_link_quality",
"org_contacts",
)
# Relación persona -> organización en el frontmatter: "[[org-slug]] — rol".
_RELACION_RE = re.compile(r"\[\[([^\]]+)\]\]\s*(?:—|-|)?\s*(.*)")
def rebuild_derived(conn) -> list:
@@ -22,6 +31,7 @@ def rebuild_derived(conn) -> list:
_rebuild_person_stats(conn)
_rebuild_event_monthly(conn)
_rebuild_contact_link_quality(conn)
_rebuild_org_contacts(conn)
return [f"derived.{name}" for name in DERIVED_TABLES]
@@ -81,3 +91,76 @@ def _rebuild_contact_link_quality(conn) -> None:
"COUNT(*) FILTER (WHERE note_path IS NULL) AS unlinked "
"FROM contacts"
)
def _rebuild_org_contacts(conn) -> None:
"""Teléfonos de las personas de contacto de cada organización.
Deriva del campo ``relaciones`` del frontmatter de cada persona (forma
``"[[org-slug]] — rol"``): por cada persona relacionada con una organización
se emite una fila (org_slug, org_nombre, persona, rol, telefono) por cada
teléfono que tenga la persona. Permite que, al buscar una empresa, aparezcan
todos los teléfonos de sus contactos con el nombre de la persona y su rol.
Solo datos computados (regla del módulo: sin note_path). El parseo del JSON
de ``extra_fm`` y de los wikilinks se hace en Python para no depender de las
funciones JSON del motor, igual que ``_rebuild_person_stats`` con los tags.
"""
org_names = {
slug: nombre
for slug, nombre in conn.execute(
"SELECT slug, nombre FROM organizations"
).fetchall()
}
persons = conn.execute(
"SELECT nombre, telefono, telefonos, extra_fm FROM persons"
).fetchall()
rows: list = []
seen: set = set() # (org_slug, persona, telefono) para no duplicar
for nombre, telefono, telefonos_json, extra_fm in persons:
if not extra_fm:
continue
try:
fm = json.loads(extra_fm)
except (TypeError, ValueError):
continue
relaciones = fm.get("relaciones") or []
if not isinstance(relaciones, list):
relaciones = [relaciones]
try:
tels = json.loads(telefonos_json) if telefonos_json else []
except (TypeError, ValueError):
tels = []
if not isinstance(tels, list):
tels = [tels]
if not tels and telefono:
tels = [telefono]
tels = [str(t).strip() for t in tels if str(t).strip()]
if not tels:
continue
for rel in relaciones:
m = _RELACION_RE.match(str(rel))
if not m:
continue
org_slug = m.group(1).strip()
rol = m.group(2).strip() or "contacto"
org_nombre = org_names.get(org_slug, org_slug)
for tel in tels:
key = (org_slug, nombre, tel)
if key in seen:
continue
seen.add(key)
rows.append([org_slug, org_nombre, nombre, rol, tel])
conn.execute("DROP TABLE IF EXISTS derived.org_contacts")
conn.execute(
"CREATE TABLE derived.org_contacts ("
"org_slug TEXT, org_nombre TEXT, persona TEXT, rol TEXT, telefono TEXT)"
)
if rows:
conn.executemany(
"INSERT INTO derived.org_contacts VALUES (?, ?, ?, ?, ?)", rows
)