feat: initial scaffold of osint_db (DuckDB source-of-truth service)

This commit is contained in:
agent
2026-06-13 00:02:41 +02:00
commit 2716edd5a0
15 changed files with 2422 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Paquete server de la app osint_db (service FastAPI + capa DuckDB)."""
+55
View File
@@ -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)
+133
View File
@@ -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
View File
@@ -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
+83
View File
@@ -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"
)
+364
View File
@@ -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
View File
@@ -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())
+57
View File
@@ -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"
),
},
}
+123
View File
@@ -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",
]