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:
+84
-1
@@ -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
|
||||
)
|
||||
|
||||
@@ -393,6 +393,12 @@ def create_app(cfg: Config) -> FastAPI:
|
||||
# last-write-wins por etag (incremental, distinto de ingest_dav).
|
||||
return _guard(lambda: writes.pull_dav(cfg))
|
||||
|
||||
@app.post("/api/org/render-contacts")
|
||||
def org_render_contacts() -> dict:
|
||||
# Materializa en la ficha .md de cada organización la tabla de sus
|
||||
# personas de contacto con teléfono y rol (desde derived.org_contacts).
|
||||
return _guard(lambda: writes.render_all_org_contacts(cfg))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+99
-1
@@ -26,7 +26,7 @@ import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import Config
|
||||
from .config import SENTINEL_MARKER, Config
|
||||
from .registry_bridge import (
|
||||
build_vcard,
|
||||
caldav_put_event,
|
||||
@@ -41,7 +41,10 @@ from .registry_bridge import (
|
||||
duckdb_query_readonly,
|
||||
duckdb_upsert,
|
||||
pass_get_secret,
|
||||
read_obsidian_note,
|
||||
render_markdown_table,
|
||||
update_obsidian_note,
|
||||
upsert_sentinel_block,
|
||||
)
|
||||
|
||||
# Columnas de persons gobernadas por la API estructurada (sin slug, que es la
|
||||
@@ -1295,3 +1298,98 @@ def pull_dav(cfg: Config) -> dict:
|
||||
"deleted": deleted,
|
||||
"unchanged": unchanged,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materialización de los contactos de una organización en su ficha .md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_org_contacts(cfg: Config, slug: str) -> dict:
|
||||
"""Escribe en la ficha de una organización la tabla de sus contactos.
|
||||
|
||||
Lee de ``derived.org_contacts`` las personas relacionadas con la organización
|
||||
(con su rol y teléfono) y vuelca una tabla Markdown dentro de un bloque
|
||||
sentinel ``org-contacts`` en el cuerpo de la nota, sin tocar el resto de la
|
||||
prosa. Idempotente: re-renderizar reescribe solo ese bloque.
|
||||
|
||||
Devuelve {status:'ok', slug, contactos:N, note_path} o {status:'skip'|'error'}.
|
||||
"""
|
||||
slug = (slug or "").strip()
|
||||
if not slug:
|
||||
return {"status": "error", "error": "slug vacío"}
|
||||
|
||||
org = duckdb_query_readonly(
|
||||
cfg.db_path, "SELECT note_path FROM organizations WHERE slug = ?", [slug], 1
|
||||
)
|
||||
if org.get("status") != "ok" or not org.get("rows"):
|
||||
return {"status": "error", "error": f"organización desconocida: {slug!r}"}
|
||||
note_path = org["rows"][0].get("note_path")
|
||||
if not note_path:
|
||||
return {"status": "skip", "slug": slug, "reason": "sin note_path"}
|
||||
|
||||
contacts = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT persona, rol, telefono FROM derived.org_contacts "
|
||||
"WHERE org_slug = ? ORDER BY persona, telefono",
|
||||
[slug],
|
||||
500,
|
||||
)
|
||||
rows = contacts.get("rows") or []
|
||||
if not rows:
|
||||
return {"status": "skip", "slug": slug, "reason": "sin contactos"}
|
||||
|
||||
table_md = render_markdown_table(
|
||||
rows, columns=["persona", "rol", "telefono"], max_rows=500
|
||||
)
|
||||
content = "### Contactos\n\n" + (table_md or "_(sin contactos)_")
|
||||
|
||||
rel = note_path if note_path.endswith(".md") else note_path + ".md"
|
||||
abs_path = os.path.abspath(os.path.join(cfg.vault_dir, rel))
|
||||
vault_real = os.path.realpath(cfg.vault_dir)
|
||||
if not os.path.realpath(abs_path).startswith(vault_real + os.sep):
|
||||
return {"status": "error", "error": f"note_path fuera del vault: {rel!r}"}
|
||||
if not os.path.exists(abs_path):
|
||||
return {"status": "skip", "slug": slug, "reason": "nota inexistente"}
|
||||
|
||||
note = read_obsidian_note(abs_path)
|
||||
new_body = upsert_sentinel_block(
|
||||
note.get("body", "") or "", "org-contacts", content, marker=SENTINEL_MARKER
|
||||
)
|
||||
update_obsidian_note(abs_path, body=new_body)
|
||||
return {
|
||||
"status": "ok",
|
||||
"slug": slug,
|
||||
"contactos": len(rows),
|
||||
"note_path": rel,
|
||||
}
|
||||
|
||||
|
||||
def render_all_org_contacts(cfg: Config) -> dict:
|
||||
"""Materializa la tabla de contactos en TODAS las organizaciones que tengan.
|
||||
|
||||
Recorre las organizaciones con al menos una fila en ``derived.org_contacts``
|
||||
y llama a ``render_org_contacts`` por cada una. Devuelve conteos agregados.
|
||||
"""
|
||||
orgs = duckdb_query_readonly(
|
||||
cfg.db_path,
|
||||
"SELECT DISTINCT org_slug FROM derived.org_contacts ORDER BY org_slug",
|
||||
[],
|
||||
100000,
|
||||
)
|
||||
rendered = skipped = errors = 0
|
||||
for row in orgs.get("rows") or []:
|
||||
res = render_org_contacts(cfg, row["org_slug"])
|
||||
status = res.get("status")
|
||||
if status == "ok":
|
||||
rendered += 1
|
||||
elif status == "skip":
|
||||
skipped += 1
|
||||
else:
|
||||
errors += 1
|
||||
return {
|
||||
"status": "ok",
|
||||
"rendered": rendered,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user