feat: initial scaffold of osint_db (DuckDB source-of-truth service)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Paquete server de la app osint_db (service FastAPI + capa DuckDB)."""
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Configuración del service osint_db.
|
||||
|
||||
Valores por defecto del PC de enmanuel; todo es sobreescribible por CLI
|
||||
(--vault, --db, --port) o construyendo un Config a mano en los tests. La
|
||||
configuración DAV (base_url, colecciones, secreto en pass) es la misma que usan
|
||||
los tools del proyecto (projects/osint/tools/sync_dav_to_osint.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Directorio raíz de la app (padre de server/).
|
||||
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
DEFAULT_VAULT = os.path.expanduser("~/Obsidian/osint")
|
||||
DEFAULT_DB = os.path.join(APP_DIR, "data", "osint.duckdb")
|
||||
DEFAULT_PORT = 8771
|
||||
|
||||
# Configuración Xandikos (espejo de projects/osint/tools/sync_dav_to_osint.py).
|
||||
DAV_BASE = "https://dav-eedeb681c4ab89ab8e444ac9.organic-machine.com"
|
||||
DAV_USER = "enmanuel"
|
||||
DAV_CONTACTS_COLLECTION = "/enmanuel/contacts/addressbook/"
|
||||
DAV_CALENDAR_HOME = "/enmanuel/calendars/"
|
||||
PASS_SECRET = "dav/xandikos-enmanuel"
|
||||
|
||||
# Carpetas del vault que mapean a tablas maestras de entidades.
|
||||
# (carpeta del vault, tipo de frontmatter, tabla destino)
|
||||
ENTITY_FOLDERS = (
|
||||
("personas", "persona", "persons"),
|
||||
("organizaciones", "organizacion", "organizations"),
|
||||
("dominios", "dominio", "domains"),
|
||||
("casos", "caso", "cases"),
|
||||
("lugares", "lugar", "places"),
|
||||
)
|
||||
|
||||
# Marker de los bloques sentinel que este service gestiona en notas del vault.
|
||||
SENTINEL_MARKER = "osintdb"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Configuración efectiva del service (inyectable en tests)."""
|
||||
|
||||
vault_dir: str = DEFAULT_VAULT
|
||||
db_path: str = DEFAULT_DB
|
||||
port: int = DEFAULT_PORT
|
||||
host: str = "127.0.0.1"
|
||||
dav_base: str = DAV_BASE
|
||||
dav_user: str = DAV_USER
|
||||
dav_contacts_collection: str = DAV_CONTACTS_COLLECTION
|
||||
dav_calendar_home: str = DAV_CALENDAR_HOME
|
||||
pass_secret: str = PASS_SECRET
|
||||
entity_folders: tuple = field(default=ENTITY_FOLDERS)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Parseo ligero de vCard y de iCalendar para el ingest DAV.
|
||||
|
||||
Lógica propia de la app (glue específico del dominio): el registry baja las
|
||||
colecciones en bruto con dav_get_collection y aquí se extraen los campos
|
||||
mínimos que las tablas maestras contacts y events necesitan. Mismo enfoque de
|
||||
regex con unfold que projects/osint/tools/sync_dav_to_osint.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _unfold(text: str) -> str:
|
||||
"""Deshace el folding de líneas (continuación con espacio o tab)."""
|
||||
return re.sub(r"\r?\n[ \t]", "", text)
|
||||
|
||||
|
||||
def _values(text: str, prop: str) -> list:
|
||||
"""Devuelve todos los valores de una propiedad (TEL, UID, DTSTART, ...).
|
||||
|
||||
Acepta PROP;PARAMS:valor y PROP:valor (con prefijo itemN. opcional) y
|
||||
decodifica los escapes simples (\\n, \\,, \\;, \\\\).
|
||||
"""
|
||||
vals = []
|
||||
for line in text.splitlines():
|
||||
m = re.match(rf"^(?:item\d+\.)?{prop}(?:;[^:]*)?:(.*)$", line, re.IGNORECASE)
|
||||
if m:
|
||||
v = m.group(1).strip()
|
||||
v = (
|
||||
v.replace("\\n", "\n")
|
||||
.replace("\\,", ",")
|
||||
.replace("\\;", ";")
|
||||
.replace("\\\\", "\\")
|
||||
).strip()
|
||||
if v:
|
||||
vals.append(v)
|
||||
return vals
|
||||
|
||||
|
||||
def _prop_line(text: str, prop: str) -> str:
|
||||
"""Devuelve la primera línea completa de una propiedad (con sus params)."""
|
||||
for line in text.splitlines():
|
||||
if re.match(rf"^(?:item\d+\.)?{prop}(?:;|:)", line, re.IGNORECASE):
|
||||
return line
|
||||
return ""
|
||||
|
||||
|
||||
def parse_vcard(vcard_text: str) -> dict:
|
||||
"""Extrae los campos mínimos de un vCard para la tabla contacts.
|
||||
|
||||
Devuelve {uid, fn, tels, emails}. tels y emails son listas deduplicadas
|
||||
preservando el orden.
|
||||
"""
|
||||
txt = _unfold(vcard_text)
|
||||
return {
|
||||
"uid": (_values(txt, "UID") or [""])[0],
|
||||
"fn": (_values(txt, "FN") or [""])[0],
|
||||
"tels": _dedup(_values(txt, "TEL")),
|
||||
"emails": _dedup(_values(txt, "EMAIL")),
|
||||
}
|
||||
|
||||
|
||||
def parse_ical_events(ical_text: str) -> list:
|
||||
"""Extrae los VEVENT de un VCALENDAR para la tabla events.
|
||||
|
||||
Devuelve una lista de dicts {uid, dtstart, dtend, all_day, summary,
|
||||
location, rrule, raw}. dtstart/dtend se normalizan a ISO básico
|
||||
(YYYY-MM-DD o YYYY-MM-DDTHH:MM:SS, conservando la Z de UTC si la trae).
|
||||
all_day es True cuando DTSTART es VALUE=DATE (sin componente de hora).
|
||||
"""
|
||||
events = []
|
||||
txt = _unfold(ical_text)
|
||||
for block in re.findall(
|
||||
r"BEGIN:VEVENT(.*?)END:VEVENT", txt, re.DOTALL | re.IGNORECASE
|
||||
):
|
||||
dtstart_line = _prop_line(block, "DTSTART")
|
||||
dtstart_raw = (_values(block, "DTSTART") or [""])[0]
|
||||
all_day = bool(
|
||||
re.search(r";VALUE=DATE(?:;|:)", dtstart_line, re.IGNORECASE)
|
||||
) or bool(re.fullmatch(r"\d{8}", dtstart_raw))
|
||||
events.append(
|
||||
{
|
||||
"uid": (_values(block, "UID") or [""])[0],
|
||||
"dtstart": _norm_dt(dtstart_raw),
|
||||
"dtend": _norm_dt((_values(block, "DTEND") or [""])[0]),
|
||||
"all_day": all_day,
|
||||
"summary": (_values(block, "SUMMARY") or [""])[0],
|
||||
"location": (_values(block, "LOCATION") or [None])[0],
|
||||
"rrule": (_values(block, "RRULE") or [None])[0],
|
||||
"raw": "BEGIN:VEVENT" + block + "END:VEVENT",
|
||||
}
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _norm_dt(value: str) -> str:
|
||||
"""Normaliza un DATE/DATE-TIME de iCalendar a ISO legible.
|
||||
|
||||
20260115 -> 2026-01-15; 20260115T093000Z -> 2026-01-15T09:30:00Z. Valores
|
||||
ya ISO o vacíos se devuelven tal cual.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", value)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", value)
|
||||
if m:
|
||||
return (
|
||||
f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
f"T{m.group(4)}:{m.group(5)}:{m.group(6)}{m.group(7)}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _dedup(items: list) -> list:
|
||||
"""Deduplica una lista de strings preservando el orden (case-insensitive)."""
|
||||
seen, out = set(), []
|
||||
for it in items:
|
||||
key = str(it).strip().lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
out.append(str(it).strip())
|
||||
return out
|
||||
|
||||
|
||||
def norm_phone(p) -> str:
|
||||
"""Normaliza un teléfono a sus últimos 9 dígitos (número nacional ES)."""
|
||||
if not p:
|
||||
return ""
|
||||
d = re.sub(r"\D", "", str(p))
|
||||
return d[-9:] if len(d) >= 9 else d
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
"""Capa de acceso a la base DuckDB: single-writer + migraciones.
|
||||
|
||||
Single-writer: SOLO este service escribe osint.duckdb. DuckDB bloquea el
|
||||
archivo a un escritor exclusivo, así que la conexión de escritura se abre bajo
|
||||
demanda (migraciones, ingest, rebuild de derivadas), serializada con un lock de
|
||||
proceso, y se cierra inmediatamente al terminar. Así, fuera de una escritura en
|
||||
curso, las lecturas de /api/query pueden abrir su propia conexión read_only via
|
||||
duckdb_query_readonly sin conflicto de lock.
|
||||
|
||||
Migraciones (regla db_migrations adaptada a DuckDB): archivos numerados
|
||||
migrations/NNN_*.sql, aditivos e idempotentes, aplicados en orden al arrancar.
|
||||
La tabla _migrations registra cuáles ya se aplicaron para no repetirlas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
|
||||
import duckdb
|
||||
|
||||
# Lock de proceso: serializa todas las aperturas de la conexión de escritura.
|
||||
_WRITE_LOCK = threading.Lock()
|
||||
|
||||
MIGRATIONS_DIR = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "migrations"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def write_conn(db_path: str):
|
||||
"""Abre la conexión de escritura del service de forma exclusiva.
|
||||
|
||||
Context manager: adquiere el lock de proceso, abre DuckDB en lectura y
|
||||
escritura, cede la conexión y la cierra siempre al salir. Crea el
|
||||
directorio padre del archivo si no existe.
|
||||
"""
|
||||
parent = os.path.dirname(os.path.abspath(db_path))
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with _WRITE_LOCK:
|
||||
conn = duckdb.connect(db_path)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def apply_migrations(db_path: str) -> list:
|
||||
"""Aplica las migraciones pendientes en orden y devuelve las aplicadas.
|
||||
|
||||
Lee migrations/NNN_*.sql ordenados por nombre, salta las que ya están
|
||||
registradas en _migrations y ejecuta el resto dentro de la conexión de
|
||||
escritura. Idempotente: una segunda llamada no aplica nada.
|
||||
"""
|
||||
applied: list = []
|
||||
files = sorted(glob.glob(os.path.join(MIGRATIONS_DIR, "*.sql")))
|
||||
with write_conn(db_path) as conn:
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations ("
|
||||
"name TEXT PRIMARY KEY, applied_at TIMESTAMP DEFAULT now())"
|
||||
)
|
||||
done = {row[0] for row in conn.execute("SELECT name FROM _migrations").fetchall()}
|
||||
for path in files:
|
||||
name = os.path.basename(path)
|
||||
if name in done:
|
||||
continue
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
sql = fh.read()
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
conn.execute(sql)
|
||||
conn.execute("INSERT INTO _migrations (name) VALUES (?)", [name])
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
applied.append(name)
|
||||
return applied
|
||||
|
||||
|
||||
def list_tables(db_path: str) -> list:
|
||||
"""Inventario de tablas para /api/tables.
|
||||
|
||||
Devuelve una lista de dicts {schema, name, kind, row_count, columns} para
|
||||
las tablas de los schemas main y derived (excluye _migrations). kind es
|
||||
'master' para main y 'derived' para derived.
|
||||
"""
|
||||
out: list = []
|
||||
conn = duckdb.connect(db_path, read_only=True)
|
||||
try:
|
||||
tables = conn.execute(
|
||||
"SELECT table_schema, table_name FROM information_schema.tables "
|
||||
"WHERE table_schema IN ('main', 'derived') AND table_name != '_migrations' "
|
||||
"ORDER BY table_schema, table_name"
|
||||
).fetchall()
|
||||
for schema, name in tables:
|
||||
cols = conn.execute(
|
||||
"SELECT column_name, data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position",
|
||||
[schema, name],
|
||||
).fetchall()
|
||||
row_count = conn.execute(
|
||||
f'SELECT COUNT(*) FROM "{schema}"."{name}"'
|
||||
).fetchone()[0]
|
||||
out.append(
|
||||
{
|
||||
"schema": schema,
|
||||
"name": name,
|
||||
"kind": "derived" if schema == "derived" else "master",
|
||||
"row_count": row_count,
|
||||
"columns": [{"name": c, "type": t} for c, t in cols],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return out
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Reconstrucción de las tablas derivadas (schema derived).
|
||||
|
||||
Regla dura: las derivadas contienen SOLO datos computados — ninguna lleva
|
||||
columna que referencie notas (note_path prohibido aquí). Se reconstruyen
|
||||
completas (DROP + CREATE) en cada ingest, así que su contenido siempre refleja
|
||||
el último estado de las maestras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# Nombres de las derivadas que este módulo gestiona (para reportar en ingest).
|
||||
DERIVED_TABLES = ("person_stats", "event_monthly", "contact_link_quality")
|
||||
|
||||
|
||||
def rebuild_derived(conn) -> list:
|
||||
"""Reconstruye todas las tablas derivadas sobre la conexión de escritura.
|
||||
|
||||
Devuelve la lista de nombres cualificados de las tablas reconstruidas.
|
||||
"""
|
||||
_rebuild_person_stats(conn)
|
||||
_rebuild_event_monthly(conn)
|
||||
_rebuild_contact_link_quality(conn)
|
||||
return [f"derived.{name}" for name in DERIVED_TABLES]
|
||||
|
||||
|
||||
def _rebuild_person_stats(conn) -> None:
|
||||
"""Agregados de persons por contexto, pais y tag (sin note_path).
|
||||
|
||||
Una fila por (dimension, valor) con el conteo de personas. Los tags se
|
||||
expanden en Python desde el JSON de la columna tags para no depender de
|
||||
funciones JSON del motor.
|
||||
"""
|
||||
rows = conn.execute("SELECT contexto, pais, tags FROM persons").fetchall()
|
||||
counts: dict = {}
|
||||
for contexto, pais, tags_json in rows:
|
||||
counts[("contexto", contexto or "(sin)")] = (
|
||||
counts.get(("contexto", contexto or "(sin)"), 0) + 1
|
||||
)
|
||||
counts[("pais", pais or "(sin)")] = counts.get(("pais", pais or "(sin)"), 0) + 1
|
||||
try:
|
||||
tags = json.loads(tags_json) if tags_json else []
|
||||
except (TypeError, ValueError):
|
||||
tags = []
|
||||
if not isinstance(tags, list):
|
||||
tags = [tags]
|
||||
for tag in tags:
|
||||
key = ("tag", str(tag))
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
|
||||
conn.execute("DROP TABLE IF EXISTS derived.person_stats")
|
||||
conn.execute(
|
||||
"CREATE TABLE derived.person_stats (dimension TEXT, valor TEXT, n BIGINT)"
|
||||
)
|
||||
payload = [[dim, val, n] for (dim, val), n in sorted(counts.items())]
|
||||
if payload:
|
||||
conn.executemany(
|
||||
"INSERT INTO derived.person_stats VALUES (?, ?, ?)", payload
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_event_monthly(conn) -> None:
|
||||
"""Conteo de eventos por calendario y mes (sin note_path)."""
|
||||
conn.execute("DROP TABLE IF EXISTS derived.event_monthly")
|
||||
conn.execute(
|
||||
"CREATE TABLE derived.event_monthly AS "
|
||||
"SELECT calendar, substr(dtstart, 1, 7) AS month, COUNT(*) AS n "
|
||||
"FROM events WHERE dtstart IS NOT NULL "
|
||||
"GROUP BY calendar, substr(dtstart, 1, 7) ORDER BY calendar, month"
|
||||
)
|
||||
|
||||
|
||||
def _rebuild_contact_link_quality(conn) -> None:
|
||||
"""Calidad del enlace contacts -> persons: solo números, sin paths."""
|
||||
conn.execute("DROP TABLE IF EXISTS derived.contact_link_quality")
|
||||
conn.execute(
|
||||
"CREATE TABLE derived.contact_link_quality AS "
|
||||
"SELECT COUNT(*) AS total, "
|
||||
"COUNT(*) FILTER (WHERE note_path IS NOT NULL) AS linked, "
|
||||
"COUNT(*) FILTER (WHERE note_path IS NULL) AS unlinked "
|
||||
"FROM contacts"
|
||||
)
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Ingests del service osint_db: vault Obsidian y servidor DAV (Xandikos).
|
||||
|
||||
Las tablas maestras se reconstruyen por reemplazo completo (DELETE + INSERT en
|
||||
una transacción): el vault y Xandikos son las fuentes de verdad, así que cada
|
||||
ingest deja la base exactamente como el origen. Tras cada ingest se re-enlazan
|
||||
los contactos con sus fichas y se reconstruyen las tablas derivadas.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import davparse
|
||||
from .config import Config
|
||||
from .db import write_conn
|
||||
from .derived import rebuild_derived
|
||||
from .registry_bridge import (
|
||||
dav_get_collection,
|
||||
dav_list_calendars,
|
||||
list_obsidian_notes,
|
||||
pass_get_secret,
|
||||
read_obsidian_note,
|
||||
)
|
||||
|
||||
def _norm(value):
|
||||
"""Normaliza 'null'/''/None del frontmatter a None real."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str) and value.strip().lower() in ("null", "none", ""):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _as_str(value):
|
||||
"""Convierte un valor de frontmatter a str (o None), sin perder números."""
|
||||
v = _norm(value)
|
||||
return None if v is None else str(v)
|
||||
|
||||
|
||||
def _as_list(value) -> list:
|
||||
"""Convierte un valor de frontmatter a lista (los escalares se envuelven)."""
|
||||
v = _norm(value)
|
||||
if v is None:
|
||||
return []
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
|
||||
def _json(value) -> str:
|
||||
"""Serializa un valor a JSON compacto (sin escapar acentos)."""
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _dav_uid_from_fuente(fuente) -> str | None:
|
||||
"""Extrae el UID de Xandikos cuando fuente es 'Xandikos UID <uid>'."""
|
||||
if not fuente:
|
||||
return None
|
||||
m = re.search(r"Xandikos UID\s+(\S+)", str(fuente))
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def ingest_vault(cfg: Config) -> dict:
|
||||
"""Escanea el vault completo y reconstruye notes + tablas de entidades.
|
||||
|
||||
Devuelve {status:'ok', notes:N, persons:N, organizations:N, domains:N,
|
||||
cases:N, places:N, skipped_unreadable:N, derived_rebuilt:[...]}.
|
||||
"""
|
||||
if not os.path.isdir(cfg.vault_dir):
|
||||
return {"status": "error", "error": f"vault no encontrado: {cfg.vault_dir}"}
|
||||
|
||||
note_rows: list = []
|
||||
entity_rows: dict = {table: [] for _, _, table in cfg.entity_folders}
|
||||
folder_to_table = {folder: table for folder, _, table in cfg.entity_folders}
|
||||
skipped = 0
|
||||
|
||||
for abs_path in list_obsidian_notes(cfg.vault_dir):
|
||||
rel_path = os.path.relpath(abs_path, cfg.vault_dir)
|
||||
base = os.path.splitext(os.path.basename(abs_path))[0]
|
||||
try:
|
||||
note = read_obsidian_note(abs_path)
|
||||
except Exception: # noqa: BLE001 — una nota corrupta no aborta el ingest
|
||||
skipped += 1
|
||||
continue
|
||||
fm = note.get("frontmatter") or {}
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(abs_path), tz=timezone.utc)
|
||||
slug = _as_str(fm.get("slug")) or base
|
||||
note_rows.append(
|
||||
[
|
||||
rel_path,
|
||||
slug,
|
||||
_as_str(fm.get("tipo")),
|
||||
_as_str(fm.get("nombre")) or base,
|
||||
mtime,
|
||||
_json(fm),
|
||||
]
|
||||
)
|
||||
|
||||
# Entidad estructurada: ficha de nivel-1 dentro de una carpeta de
|
||||
# entidades (personas/<slug>.md, no personas/<slug>/<doc>.md) y que
|
||||
# no sea una nota de soporte (prefijo _).
|
||||
top_folder = rel_path.split(os.sep)[0]
|
||||
is_level1 = os.path.basename(os.path.dirname(abs_path)) == top_folder
|
||||
if top_folder in folder_to_table and is_level1 and not base.startswith("_"):
|
||||
table = folder_to_table[top_folder]
|
||||
if table == "persons":
|
||||
entity_rows[table].append(
|
||||
[
|
||||
slug,
|
||||
rel_path,
|
||||
_as_str(fm.get("nombre")) or base,
|
||||
_json(_as_list(fm.get("aliases"))),
|
||||
_as_str(fm.get("sexo")),
|
||||
_as_str(fm.get("fecha_nacimiento")),
|
||||
_as_str(fm.get("dni")),
|
||||
_as_str(fm.get("telefono")),
|
||||
_as_str(fm.get("email")),
|
||||
_as_str(fm.get("direccion")),
|
||||
_as_str(fm.get("pais")),
|
||||
_as_str(fm.get("contexto")),
|
||||
_as_str(fm.get("fuente")),
|
||||
_dav_uid_from_fuente(fm.get("fuente")),
|
||||
_json(_as_list(fm.get("tags"))),
|
||||
mtime,
|
||||
]
|
||||
)
|
||||
else:
|
||||
entity_rows[table].append(
|
||||
[
|
||||
slug,
|
||||
rel_path,
|
||||
_as_str(fm.get("nombre")) or base,
|
||||
_json(_as_list(fm.get("tags"))),
|
||||
_json(fm),
|
||||
mtime,
|
||||
]
|
||||
)
|
||||
|
||||
derived_rebuilt: list = []
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
conn.execute("DELETE FROM notes")
|
||||
if note_rows:
|
||||
conn.executemany(
|
||||
"INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?)", note_rows
|
||||
)
|
||||
conn.execute("DELETE FROM persons")
|
||||
if entity_rows["persons"]:
|
||||
conn.executemany(
|
||||
"INSERT INTO persons VALUES "
|
||||
"(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
_dedup_by_slug(entity_rows["persons"]),
|
||||
)
|
||||
for table in ("organizations", "domains", "cases", "places"):
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
if entity_rows[table]:
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table} VALUES (?, ?, ?, ?, ?, ?)",
|
||||
_dedup_by_slug(entity_rows[table]),
|
||||
)
|
||||
_link_contacts(conn)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
derived_rebuilt = rebuild_derived(conn)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"notes": len(note_rows),
|
||||
"persons": len(_dedup_by_slug(entity_rows["persons"])),
|
||||
"organizations": len(_dedup_by_slug(entity_rows["organizations"])),
|
||||
"domains": len(_dedup_by_slug(entity_rows["domains"])),
|
||||
"cases": len(_dedup_by_slug(entity_rows["cases"])),
|
||||
"places": len(_dedup_by_slug(entity_rows["places"])),
|
||||
"skipped_unreadable": skipped,
|
||||
"derived_rebuilt": derived_rebuilt,
|
||||
}
|
||||
|
||||
|
||||
def _dedup_by_slug(rows: list) -> list:
|
||||
"""Quita filas con slug repetido (gana la primera) para respetar la PK."""
|
||||
seen, out = set(), []
|
||||
for row in rows:
|
||||
if row[0] in seen:
|
||||
continue
|
||||
seen.add(row[0])
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def ingest_dav(cfg: Config) -> dict:
|
||||
"""Baja las colecciones de Xandikos y reconstruye contacts + events.
|
||||
|
||||
Devuelve {status:'ok', contacts:N, events:N, calendars:[...],
|
||||
contacts_linked:N, derived_rebuilt:[...]} o {status:'error', error}.
|
||||
"""
|
||||
secret = pass_get_secret(cfg.pass_secret)
|
||||
if secret.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"pass no devolvió el secreto {cfg.pass_secret!r}: "
|
||||
f"{secret.get('error')}",
|
||||
}
|
||||
pwd = secret["value"] # sensible: nunca logear
|
||||
|
||||
coll = dav_get_collection(
|
||||
cfg.dav_base, cfg.dav_user, pwd, cfg.dav_contacts_collection, "vcard"
|
||||
)
|
||||
if coll.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"CardDAV: {coll.get('error')} (http {coll.get('http_status')})",
|
||||
}
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
contact_rows: list = []
|
||||
seen_uids: set = set()
|
||||
for res in coll.get("resources", []):
|
||||
parsed = davparse.parse_vcard(res.get("data", ""))
|
||||
uid = parsed["uid"] or os.path.splitext(os.path.basename(res["href"]))[0]
|
||||
if uid in seen_uids:
|
||||
continue
|
||||
seen_uids.add(uid)
|
||||
contact_rows.append(
|
||||
[
|
||||
uid,
|
||||
cfg.dav_contacts_collection,
|
||||
res.get("etag"),
|
||||
parsed["fn"] or None,
|
||||
_json(parsed["tels"]),
|
||||
_json(parsed["emails"]),
|
||||
res.get("data", ""),
|
||||
None, # note_path se rellena en el enlace posterior
|
||||
now,
|
||||
]
|
||||
)
|
||||
|
||||
cals = dav_list_calendars(cfg.dav_base, cfg.dav_user, pwd, cfg.dav_calendar_home)
|
||||
if cals.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"CalDAV: {cals.get('error')} (http {cals.get('http_status')})",
|
||||
}
|
||||
|
||||
event_rows: list = []
|
||||
seen_event_uids: set = set()
|
||||
calendar_names: list = []
|
||||
for cal in cals.get("calendars", []):
|
||||
cal_name = cal.get("name") or cal.get("href", "").strip("/").rsplit("/", 1)[-1]
|
||||
calendar_names.append(cal_name)
|
||||
cal_coll = dav_get_collection(
|
||||
cfg.dav_base, cfg.dav_user, pwd, cal["href"], "ical"
|
||||
)
|
||||
if cal_coll.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"CalDAV {cal_name}: {cal_coll.get('error')} "
|
||||
f"(http {cal_coll.get('http_status')})",
|
||||
}
|
||||
for res in cal_coll.get("resources", []):
|
||||
for ev in davparse.parse_ical_events(res.get("data", "")):
|
||||
uid = ev["uid"] or os.path.splitext(os.path.basename(res["href"]))[0]
|
||||
if uid in seen_event_uids:
|
||||
continue
|
||||
seen_event_uids.add(uid)
|
||||
event_rows.append(
|
||||
[
|
||||
uid,
|
||||
cal_name,
|
||||
res.get("etag"),
|
||||
ev["dtstart"] or None,
|
||||
ev["dtend"] or None,
|
||||
ev["all_day"],
|
||||
ev["summary"] or None,
|
||||
ev["location"],
|
||||
ev["rrule"],
|
||||
ev["raw"],
|
||||
now,
|
||||
]
|
||||
)
|
||||
|
||||
with write_conn(cfg.db_path) as conn:
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
conn.execute("DELETE FROM contacts")
|
||||
if contact_rows:
|
||||
conn.executemany(
|
||||
"INSERT INTO contacts VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
contact_rows,
|
||||
)
|
||||
conn.execute("DELETE FROM events")
|
||||
if event_rows:
|
||||
conn.executemany(
|
||||
"INSERT INTO events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
event_rows,
|
||||
)
|
||||
linked = _link_contacts(conn)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
conn.execute("ROLLBACK")
|
||||
raise
|
||||
derived_rebuilt = rebuild_derived(conn)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"contacts": len(contact_rows),
|
||||
"events": len(event_rows),
|
||||
"calendars": calendar_names,
|
||||
"contacts_linked": linked,
|
||||
"derived_rebuilt": derived_rebuilt,
|
||||
}
|
||||
|
||||
|
||||
def _link_contacts(conn) -> int:
|
||||
"""Enlaza contacts.note_path contra las fichas de persons.
|
||||
|
||||
Orden de matching por fiabilidad: UID estilo osint-<slug> (creado por el
|
||||
push del vault), dav_uid registrado en la ficha, teléfono normalizado y
|
||||
por último email. Devuelve el número de contactos enlazados.
|
||||
"""
|
||||
persons = conn.execute(
|
||||
"SELECT slug, note_path, telefono, email, dav_uid FROM persons"
|
||||
).fetchall()
|
||||
by_slug, by_dav_uid, by_phone, by_email = {}, {}, {}, {}
|
||||
for slug, note_path, telefono, email, dav_uid in persons:
|
||||
by_slug[slug] = note_path
|
||||
if dav_uid:
|
||||
by_dav_uid.setdefault(dav_uid, note_path)
|
||||
if telefono:
|
||||
key = davparse.norm_phone(telefono)
|
||||
if key:
|
||||
by_phone.setdefault(key, note_path)
|
||||
if email:
|
||||
by_email.setdefault(str(email).strip().lower(), note_path)
|
||||
|
||||
contacts = conn.execute("SELECT uid, tels, emails FROM contacts").fetchall()
|
||||
linked = 0
|
||||
for uid, tels_json, emails_json in contacts:
|
||||
note_path = None
|
||||
if uid.startswith("osint-") and uid[len("osint-"):] in by_slug:
|
||||
note_path = by_slug[uid[len("osint-"):]]
|
||||
if note_path is None and uid in by_dav_uid:
|
||||
note_path = by_dav_uid[uid]
|
||||
if note_path is None:
|
||||
for tel in json.loads(tels_json or "[]"):
|
||||
hit = by_phone.get(davparse.norm_phone(tel))
|
||||
if hit:
|
||||
note_path = hit
|
||||
break
|
||||
if note_path is None:
|
||||
for em in json.loads(emails_json or "[]"):
|
||||
hit = by_email.get(str(em).strip().lower())
|
||||
if hit:
|
||||
note_path = hit
|
||||
break
|
||||
if note_path is not None:
|
||||
conn.execute(
|
||||
"UPDATE contacts SET note_path = ? WHERE uid = ?", [note_path, uid]
|
||||
)
|
||||
linked += 1
|
||||
return linked
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Service FastAPI osint_db: dueño único de la base DuckDB data/osint.duckdb.
|
||||
|
||||
La base es la fuente de verdad estructurada del ecosistema OSINT: índice del
|
||||
vault de Obsidian (notes + entidades con note_path), maestras DAV importadas de
|
||||
Xandikos (contacts, events) y derivadas computadas (schema derived, sin
|
||||
referencias a notas). Solo este proceso escribe la base; las lecturas de
|
||||
/api/query usan una conexión read_only separada (duckdb_query_readonly).
|
||||
|
||||
Registry-first: el service NO reimplementa parseo de Markdown, protocolo DAV,
|
||||
acceso a pass, ejecución read-only de DuckDB, render de tablas Markdown ni
|
||||
bloques sentinel — importa esas funciones del registry (server/registry_bridge.py)
|
||||
y solo aporta la lógica propia del dominio (mapeo vault→tablas, matching
|
||||
contacto→ficha, derivadas y la API).
|
||||
|
||||
Seguridad: el vault contiene datos personales sensibles, así que el server
|
||||
escucha SOLO en 127.0.0.1 y /api/query es estrictamente de solo lectura.
|
||||
|
||||
Uso:
|
||||
.venv/bin/python server/main.py --vault ~/Obsidian/osint --port 8771
|
||||
|
||||
Contrato (el plugin de Obsidian parsea el body, no el código HTTP: los
|
||||
endpoints de datos responden SIEMPRE 200 con status ok|error en el body):
|
||||
GET /api/health estado + db_path + número de tablas
|
||||
GET /api/tables inventario de tablas (schema, kind, filas, columnas)
|
||||
POST /api/query SELECT arbitrario read-only {sql, params, max_rows}
|
||||
GET /api/queries catálogo de queries con nombre
|
||||
POST /api/query/named ejecuta una query del catálogo {name, max_rows}
|
||||
POST /api/ingest/vault re-escanea el vault y reconstruye maestras+derivadas
|
||||
POST /api/ingest/dav baja Xandikos y reconstruye contacts/events+derivadas
|
||||
POST /api/render/note ejecuta query y la upserta como bloque sentinel en una nota
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Permite ejecutar tanto `python server/main.py` como importar `server.main`.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from pydantic import BaseModel, Field # noqa: E402
|
||||
|
||||
from server.config import SENTINEL_MARKER, Config # noqa: E402
|
||||
from server.db import apply_migrations, list_tables # noqa: E402
|
||||
from server.ingest import ingest_dav, ingest_vault # noqa: E402
|
||||
from server.named_queries import NAMED_QUERIES # noqa: E402
|
||||
from server.registry_bridge import ( # noqa: E402
|
||||
create_obsidian_note,
|
||||
duckdb_query_readonly,
|
||||
read_obsidian_note,
|
||||
render_markdown_table,
|
||||
update_obsidian_note,
|
||||
upsert_sentinel_block,
|
||||
)
|
||||
|
||||
# Tope de filas que un render vuelca en una nota (las notas no son un export).
|
||||
RENDER_MAX_ROWS = 200
|
||||
|
||||
|
||||
class QueryBody(BaseModel):
|
||||
"""Body de POST /api/query."""
|
||||
|
||||
sql: str
|
||||
params: list = Field(default_factory=list)
|
||||
max_rows: int = 500
|
||||
|
||||
|
||||
class NamedQueryBody(BaseModel):
|
||||
"""Body de POST /api/query/named."""
|
||||
|
||||
name: str
|
||||
max_rows: int = 500
|
||||
|
||||
|
||||
class RenderNoteBody(BaseModel):
|
||||
"""Body de POST /api/render/note. Exactamente uno de sql|query."""
|
||||
|
||||
note_path: str
|
||||
block_id: str
|
||||
sql: str | None = None
|
||||
query: str | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
def create_app(cfg: Config) -> FastAPI:
|
||||
"""Construye la app FastAPI con la configuración dada (inyectable en tests)."""
|
||||
app = FastAPI(title="osint_db", docs_url=None, redoc_url=None)
|
||||
|
||||
def run_readonly(sql: str, params: list, max_rows: int) -> dict:
|
||||
"""Ejecuta un SELECT con la conexión read_only del registry, acotado."""
|
||||
max_rows = max(1, min(int(max_rows), 10000))
|
||||
return duckdb_query_readonly(cfg.db_path, sql, params or [], max_rows)
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
try:
|
||||
tables = list_tables(cfg.db_path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "db_path": cfg.db_path, "error": str(e)}
|
||||
return {"status": "ok", "db_path": cfg.db_path, "tables": len(tables)}
|
||||
|
||||
@app.get("/api/tables")
|
||||
def tables() -> dict:
|
||||
try:
|
||||
return {"status": "ok", "tables": list_tables(cfg.db_path)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.post("/api/query")
|
||||
def query(body: QueryBody) -> dict:
|
||||
return run_readonly(body.sql, body.params, body.max_rows)
|
||||
|
||||
@app.get("/api/queries")
|
||||
def queries() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"queries": [
|
||||
{"name": name, "description": q["description"], "sql": q["sql"]}
|
||||
for name, q in NAMED_QUERIES.items()
|
||||
],
|
||||
}
|
||||
|
||||
@app.post("/api/query/named")
|
||||
def query_named(body: NamedQueryBody) -> dict:
|
||||
entry = NAMED_QUERIES.get(body.name)
|
||||
if entry is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"query con nombre desconocida: {body.name!r} "
|
||||
f"(disponibles: {', '.join(sorted(NAMED_QUERIES))})",
|
||||
}
|
||||
return run_readonly(entry["sql"], [], body.max_rows)
|
||||
|
||||
@app.post("/api/ingest/vault")
|
||||
def api_ingest_vault() -> dict:
|
||||
try:
|
||||
return ingest_vault(cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.post("/api/ingest/dav")
|
||||
def api_ingest_dav() -> dict:
|
||||
try:
|
||||
return ingest_dav(cfg)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@app.post("/api/render/note")
|
||||
def render_note(body: RenderNoteBody) -> dict:
|
||||
# Resolver el SQL: o viene literal, o por nombre del catálogo.
|
||||
if bool(body.sql) == bool(body.query):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "indica exactamente uno de los campos 'sql' o 'query' (named)",
|
||||
}
|
||||
if body.query:
|
||||
entry = NAMED_QUERIES.get(body.query)
|
||||
if entry is None:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"query con nombre desconocida: {body.query!r}",
|
||||
}
|
||||
sql = entry["sql"]
|
||||
else:
|
||||
sql = body.sql
|
||||
|
||||
result = run_readonly(sql, [], RENDER_MAX_ROWS)
|
||||
if result.get("status") != "ok":
|
||||
return result
|
||||
|
||||
table_md = render_markdown_table(
|
||||
result["rows"], columns=result["columns"], max_rows=RENDER_MAX_ROWS
|
||||
)
|
||||
content = table_md if table_md else "_(sin filas)_"
|
||||
if body.title:
|
||||
content = f"### {body.title}\n\n{content}"
|
||||
|
||||
# La nota se referencia por path relativo al vault; el path no puede
|
||||
# escapar del vault (mismo criterio anti-traversal que osint_web).
|
||||
rel = body.note_path if body.note_path.endswith(".md") else body.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: {body.note_path!r}"}
|
||||
|
||||
try:
|
||||
if os.path.exists(abs_path):
|
||||
note = read_obsidian_note(abs_path)
|
||||
new_body = upsert_sentinel_block(
|
||||
note.get("body", "") or "",
|
||||
body.block_id,
|
||||
content,
|
||||
marker=SENTINEL_MARKER,
|
||||
)
|
||||
update_obsidian_note(abs_path, body=new_body)
|
||||
else:
|
||||
new_body = upsert_sentinel_block(
|
||||
"", body.block_id, content, marker=SENTINEL_MARKER
|
||||
)
|
||||
create_obsidian_note(
|
||||
cfg.vault_dir,
|
||||
rel,
|
||||
body=new_body,
|
||||
frontmatter={"tipo": "tablero", "tags": ["osintdb"]},
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"note_path": rel,
|
||||
"rows_rendered": result["row_count"],
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Punto de entrada CLI: valida el vault, migra la base y arranca uvicorn."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Service osint_db: DuckDB fuente de verdad del ecosistema OSINT."
|
||||
)
|
||||
parser.add_argument("--vault", default=None, help="directorio del vault Obsidian")
|
||||
parser.add_argument("--db", default=None, help="ruta del archivo osint.duckdb")
|
||||
parser.add_argument("--port", type=int, default=None, help="puerto de escucha")
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="host de escucha (datos sensibles: déjalo en 127.0.0.1)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = Config()
|
||||
if args.vault:
|
||||
cfg.vault_dir = os.path.expanduser(args.vault)
|
||||
if args.db:
|
||||
cfg.db_path = os.path.expanduser(args.db)
|
||||
if args.port is not None:
|
||||
cfg.port = args.port
|
||||
cfg.host = args.host
|
||||
|
||||
if not os.path.isdir(cfg.vault_dir):
|
||||
print(
|
||||
f"ERROR: el vault no existe o no es un directorio: {cfg.vault_dir}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if cfg.host != "127.0.0.1":
|
||||
print(
|
||||
"AVISO: la base contiene datos personales sensibles; escuchar fuera "
|
||||
"de 127.0.0.1 no está soportado.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
applied = apply_migrations(cfg.db_path)
|
||||
if applied:
|
||||
print(f"migraciones aplicadas: {', '.join(applied)}")
|
||||
|
||||
import uvicorn
|
||||
|
||||
app = create_app(cfg)
|
||||
uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="warning")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Catálogo de queries con nombre del service osint_db.
|
||||
|
||||
Cada entrada mapea un nombre estable a {sql, description}. El plugin de
|
||||
Obsidian (y /api/render/note) las invoca por nombre via POST /api/query/named,
|
||||
así el SQL vive en un solo sitio y el cliente no necesita conocer el schema.
|
||||
Todas son SELECT puros: se ejecutan por duckdb_query_readonly.
|
||||
"""
|
||||
|
||||
NAMED_QUERIES = {
|
||||
"personas_por_contexto": {
|
||||
"description": "Personas agrupadas por contexto (círculo de origen), de mayor a menor.",
|
||||
"sql": (
|
||||
"SELECT coalesce(contexto, '(sin)') AS contexto, COUNT(*) AS personas "
|
||||
"FROM persons GROUP BY 1 ORDER BY personas DESC, contexto"
|
||||
),
|
||||
},
|
||||
"personas_recientes": {
|
||||
"description": "Últimas 50 fichas de persona tocadas en el vault (por mtime de la nota).",
|
||||
"sql": (
|
||||
"SELECT slug, nombre, contexto, note_path, updated_at "
|
||||
"FROM persons ORDER BY updated_at DESC LIMIT 50"
|
||||
),
|
||||
},
|
||||
"eventos_proximos": {
|
||||
"description": "Próximos 50 eventos del calendario a partir de hoy, en orden cronológico.",
|
||||
"sql": (
|
||||
"SELECT uid, calendar, dtstart, dtend, all_day, summary, location "
|
||||
"FROM events WHERE dtstart >= strftime(now(), '%Y-%m-%d') "
|
||||
"ORDER BY dtstart LIMIT 50"
|
||||
),
|
||||
},
|
||||
"contactos_sin_nota": {
|
||||
"description": "Contactos CardDAV que no enlazan con ninguna ficha del vault (note_path IS NULL).",
|
||||
"sql": (
|
||||
"SELECT uid, fn, tels, emails FROM contacts "
|
||||
"WHERE note_path IS NULL ORDER BY fn"
|
||||
),
|
||||
},
|
||||
"stats_personas": {
|
||||
"description": "Agregados de personas por dimensión (contexto, país, tag) desde derived.person_stats.",
|
||||
"sql": (
|
||||
"SELECT dimension, valor, n FROM derived.person_stats "
|
||||
"ORDER BY dimension, n DESC, valor"
|
||||
),
|
||||
},
|
||||
"calidad_enlace_contactos": {
|
||||
"description": "Resumen de contactos enlazados a persona vs sin enlazar (derived.contact_link_quality).",
|
||||
"sql": "SELECT total, linked, unlinked FROM derived.contact_link_quality",
|
||||
},
|
||||
"eventos_por_mes": {
|
||||
"description": "Conteo de eventos por calendario y mes (derived.event_monthly).",
|
||||
"sql": (
|
||||
"SELECT calendar, month, n FROM derived.event_monthly "
|
||||
"ORDER BY month DESC, calendar"
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Puente con el fn_registry: localiza python/functions y expone las funciones.
|
||||
|
||||
Registry-first: esta app NO reimplementa el parseo de notas Obsidian, el
|
||||
protocolo DAV, el acceso a pass ni la ejecución read-only de DuckDB. Importa
|
||||
las funciones del registry y las reexporta para el resto de módulos del
|
||||
service. Toda función importada aquí está declarada en uses_functions del
|
||||
app.md.
|
||||
|
||||
La localización de python/functions evita paths hardcodeados de usuario:
|
||||
prueba las variables de entorno FN_REGISTRY_FUNCTIONS y FN_REGISTRY_ROOT,
|
||||
después sube por los directorios padre de este archivo hasta encontrar una
|
||||
raíz que contenga python/functions/obsidian (la app vive en
|
||||
<root>/projects/osint/apps/osint_db/server/), y por último cae al layout
|
||||
estándar del PC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _registry_functions_dir() -> str:
|
||||
"""Devuelve el directorio python/functions del fn_registry."""
|
||||
env_functions = os.environ.get("FN_REGISTRY_FUNCTIONS")
|
||||
if env_functions and os.path.isdir(os.path.join(env_functions, "obsidian")):
|
||||
return env_functions
|
||||
|
||||
candidates: list[str] = []
|
||||
env_root = os.environ.get("FN_REGISTRY_ROOT")
|
||||
if env_root:
|
||||
candidates.append(env_root)
|
||||
current = os.path.dirname(os.path.abspath(__file__))
|
||||
while True:
|
||||
candidates.append(current)
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
candidates.append(os.path.expanduser("~/fn_registry"))
|
||||
for root in candidates:
|
||||
functions_dir = os.path.join(root, "python", "functions")
|
||||
if os.path.isdir(os.path.join(functions_dir, "obsidian")):
|
||||
return functions_dir
|
||||
raise RuntimeError(
|
||||
"no se encontró python/functions/obsidian subiendo desde "
|
||||
f"{os.path.abspath(__file__)}; define FN_REGISTRY_ROOT con la raíz "
|
||||
"del fn_registry"
|
||||
)
|
||||
|
||||
|
||||
_FUNCTIONS_DIR = _registry_functions_dir()
|
||||
if _FUNCTIONS_DIR not in sys.path:
|
||||
sys.path.insert(0, _FUNCTIONS_DIR)
|
||||
|
||||
# Grupo obsidian: CRUD de notas del vault + slugs. El __init__ del paquete
|
||||
# obsidian es ligero (sin dependencias pesadas), así que se importa directo.
|
||||
from obsidian import ( # noqa: E402
|
||||
create_obsidian_note,
|
||||
list_obsidian_notes,
|
||||
read_obsidian_note,
|
||||
slugify_obsidian_name,
|
||||
update_obsidian_note,
|
||||
)
|
||||
|
||||
|
||||
def _load_registry_fn(package: str, module_name: str, attr: str):
|
||||
"""Carga una función del registry por path, sin ejecutar el __init__ del paquete.
|
||||
|
||||
Los __init__ de los paquetes infra y core importan TODAS sus funciones, y
|
||||
algunas arrastran dependencias pesadas (Pillow, etc.) que este service no
|
||||
necesita. Cargamos el archivo concreto con importlib (mismo patrón que
|
||||
osint_web). Sigue siendo registry-first: se usa la función del registry sin
|
||||
reimplementarla, solo se importa de forma quirúrgica.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
file_path = os.path.join(_FUNCTIONS_DIR, package, module_name + ".py")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"{package}_{module_name}", file_path
|
||||
)
|
||||
if spec is None or spec.loader is None: # pragma: no cover - defensivo
|
||||
raise ImportError(f"no se pudo cargar {file_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return getattr(module, attr)
|
||||
|
||||
|
||||
# Grupo dav: lectura bulk de colecciones Xandikos (CardDAV/CalDAV).
|
||||
dav_get_collection = _load_registry_fn("infra", "dav_get_collection", "dav_get_collection")
|
||||
dav_list_calendars = _load_registry_fn("infra", "dav_list_calendars", "dav_list_calendars")
|
||||
dav_collection_ctag = _load_registry_fn("infra", "dav_collection_ctag", "dav_collection_ctag")
|
||||
|
||||
# Secretos via pass (credencial Xandikos, nunca hardcodeada).
|
||||
pass_get_secret = _load_registry_fn("infra", "pass_get_secret", "pass_get_secret")
|
||||
|
||||
# Lectura read-only de DuckDB (la conexión de /api/query, separada del writer).
|
||||
duckdb_query_readonly = _load_registry_fn(
|
||||
"infra", "duckdb_query_readonly", "duckdb_query_readonly"
|
||||
)
|
||||
|
||||
# Render de tablas Markdown + bloques sentinel idempotentes para las notas.
|
||||
render_markdown_table = _load_registry_fn(
|
||||
"core", "render_markdown_table", "render_markdown_table"
|
||||
)
|
||||
upsert_sentinel_block = _load_registry_fn(
|
||||
"core", "upsert_sentinel_block", "upsert_sentinel_block"
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_obsidian_note",
|
||||
"list_obsidian_notes",
|
||||
"read_obsidian_note",
|
||||
"slugify_obsidian_name",
|
||||
"update_obsidian_note",
|
||||
"dav_collection_ctag",
|
||||
"dav_get_collection",
|
||||
"dav_list_calendars",
|
||||
"pass_get_secret",
|
||||
"duckdb_query_readonly",
|
||||
"render_markdown_table",
|
||||
"upsert_sentinel_block",
|
||||
]
|
||||
Reference in New Issue
Block a user