Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versionado: una entrada por **entrega académica** del semestre (no SemVer estri

## [Unreleased]

### Added — H4 fase 2: exportador CSV/JSON (RF-11, 2026-05-21)
- Nuevo adapter [`adapters/exportador.py`](core-python/src/sentinel_dispatch/adapters/exportador.py): funciones puras `exportar_a_csv(eventos, path)` y `exportar_a_json(eventos, path)`. CSV con flatten de `payload_*` (e.g. `payload_costo_total`) y encoding `utf-8-sig` (BOM para que Excel español abra correctamente). JSON como array indentado sin BOM. Helper `_aplanar_dict(d, prefijo)` aplana dicts recursivamente; listas (e.g. `ruta`) se serializan como JSON string en una sola celda.
- Nuevo subcomando CLI [`interfaces/cli/export_cmd.py`](core-python/src/sentinel_dispatch/interfaces/cli/export_cmd.py): `sentinel export --formato {csv,json} --in eventos.jsonl --out reporte.{csv,json}`. Enum `FormatoExport(csv|json)` para validación de argumento. Exit 0 en éxito, 2 si `--in` no existe o el JSONL es corrupto.
- Tests: **14 nuevos** verdes en [`test_exportador.py`](core-python/tests/unit/adapters/test_exportador.py) — `TestAplanarDict` (3), `TestExportarCsv` (4 incluyendo unión de columnas para payloads heterogéneos y verificación del BOM), `TestExportarJson` (3), `TestCliExport` (4 end-to-end con archivo válido, corrupto e inexistente). Suite total **249/249** verde; cobertura global **92.66 %**.
- Diseño: el log canónico JSONL fuente (ADR-0007) **no se modifica** por el export — los archivos derivados (CSV/JSON) son artefactos para auditoría externa. RN-03 preservado.

### Changed — H4 fase 2
- `docs/quality/trazabilidad.md`: **RF-11 marcado ✅** apuntando a `adapters/exportador.py` + `interfaces/cli/export_cmd.py`.
- `interfaces/cli/app.py`: registro del subcomando `export` (`app.command("export")(export_cmd.export)`).

### Added — H4 fase 1: log de eventos JSONL append-only (RF-06 / RN-03 / RN-07 / CP-08, 2026-05-21)
- Nuevo port [`ports/repositorio_eventos.py`](core-python/src/sentinel_dispatch/ports/repositorio_eventos.py) con:
- `EventoLog` (Pydantic BaseModel frozen, `extra="forbid"`, validación strict) que representa un evento del log.
Expand Down
143 changes: 143 additions & 0 deletions core-python/src/sentinel_dispatch/adapters/exportador.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Exportador de logs de eventos a CSV/JSON (RF-11).

Convierte una secuencia de :class:`EventoLog` a CSV plano (apto para
LibreOffice/Excel) o JSON array indentado (apto para auditoría humana
y herramientas tabulares como ``jq``).

**Diseño**:

- El archivo fuente sigue siendo el JSONL append-only (ADR-0007); estas
funciones producen **archivos derivados** para consumo externo. Cualquier
edición del export NO afecta el log canónico (RN-03 preservado).
- CSV con flatten del ``payload`` anidado: ``payload.costo.total`` →
columna ``payload_costo_total``. Listas (e.g. ``payload.ruta``) se
serializan a JSON string en una sola celda — las herramientas
tabulares no manejan listas como tipo nativo.
- CSV en encoding ``utf-8-sig`` (BOM) para que Excel español abra el
archivo sin caracteres garbled. JSON sin BOM.
- Sin streaming en v1: el volumen esperado (~30-50 eventos por
simulación, ADR-0007) cabe holgado en memoria. Si crece, refactor
trivial a iterador-a-iterador.
"""

from __future__ import annotations

import csv
import json
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from collections.abc import Iterable, Sequence
from pathlib import Path

from sentinel_dispatch.ports.repositorio_eventos import EventoLog


# ---------------------------------------------------------------------------
# Aplanado de payloads anidados
# ---------------------------------------------------------------------------


def _aplanar_dict(data: dict[str, Any], prefijo: str = "") -> dict[str, Any]:
"""Aplana un dict anidado concatenando keys con ``_``.

Listas se convierten a JSON string en una sola celda. Valores
escalares quedan tal cual. Dicts se recursionan.

Ejemplo:
``{"costo": {"T_viaje": 187.4, "total": 187.4}}`` con
``prefijo="payload"`` →
``{"payload_costo_T_viaje": 187.4, "payload_costo_total": 187.4}``.
"""
resultado: dict[str, Any] = {}
for key, value in data.items():
key_compuesta = f"{prefijo}_{key}" if prefijo else key
if isinstance(value, dict):
resultado.update(_aplanar_dict(value, prefijo=key_compuesta))
elif isinstance(value, list):
resultado[key_compuesta] = json.dumps(value, ensure_ascii=False)
else:
resultado[key_compuesta] = value
return resultado


def _evento_a_fila(evento: EventoLog) -> dict[str, Any]:
"""Convierte un evento a fila plana lista para ``csv.DictWriter``."""
fila: dict[str, Any] = {
"evento_id": evento.evento_id,
"timestamp_iso": evento.timestamp_iso,
"tipo": evento.tipo.value,
"despacho_id": evento.despacho_id,
"incidente_id": evento.incidente_id,
"operador": evento.operador,
}
fila.update(_aplanar_dict(evento.payload, prefijo="payload"))
return fila


# ---------------------------------------------------------------------------
# Exports públicos
# ---------------------------------------------------------------------------


def exportar_a_csv(eventos: Iterable[EventoLog], destino: Path) -> int:
"""Persiste los eventos como CSV plano (utf-8-sig + BOM para Excel).

Las columnas son la **unión** de los campos planos de todos los
eventos: si un evento no tiene una columna que sí aparece en otro,
queda vacía en su fila. Esto soporta payloads heterogéneos sin
truncar información.

Args:
eventos: iterable de :class:`EventoLog`. Se materializa una vez
para hacer la unión de columnas (no streaming).
destino: path absoluto al archivo CSV a crear o sobreescribir.

Returns:
Número de filas de datos escritas (sin contar header).
"""
filas: Sequence[dict[str, Any]] = [_evento_a_fila(e) for e in eventos]

columnas_raiz = [
"evento_id",
"timestamp_iso",
"tipo",
"despacho_id",
"incidente_id",
"operador",
]
columnas_payload: list[str] = []
seen: set[str] = set(columnas_raiz)
for fila in filas:
for key in fila:
if key not in seen:
columnas_payload.append(key)
seen.add(key)
columnas = columnas_raiz + columnas_payload

destino.parent.mkdir(parents=True, exist_ok=True)
with destino.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=columnas, extrasaction="ignore")
writer.writeheader()
for fila in filas:
writer.writerow(fila)
return len(filas)


def exportar_a_json(eventos: Iterable[EventoLog], destino: Path) -> int:
"""Persiste los eventos como un array JSON indentado (utf-8 sin BOM).

El formato es un array (no JSONL): un único array bien formado con N
objetos, más usable para auditoría humana y herramientas como ``jq``.
El log canónico fuente sigue siendo JSONL (ADR-0007); este export es
derivado.

Returns:
Número de objetos escritos en el array.
"""
objetos = [json.loads(e.model_dump_json()) for e in eventos]
destino.parent.mkdir(parents=True, exist_ok=True)
with destino.open("w", encoding="utf-8") as f:
json.dump(objetos, f, ensure_ascii=False, indent=2)
f.write("\n")
return len(objetos)
3 changes: 2 additions & 1 deletion core-python/src/sentinel_dispatch/interfaces/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import typer

from sentinel_dispatch.interfaces.cli import run_dataset_cmd, triaje_cmd
from sentinel_dispatch.interfaces.cli import export_cmd, run_dataset_cmd, triaje_cmd

app = typer.Typer(
name="sentinel",
Expand All @@ -24,6 +24,7 @@

app.add_typer(triaje_cmd.app, name="triaje")
app.command("run-dataset")(run_dataset_cmd.run_dataset)
app.command("export")(export_cmd.export)


if __name__ == "__main__": # pragma: no cover
Expand Down
90 changes: 90 additions & 0 deletions core-python/src/sentinel_dispatch/interfaces/cli/export_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Subcomando ``sentinel export`` — exporta el log JSONL a CSV/JSON (RF-11).

Lee un ``eventos.jsonl`` producido por :class:`JsonlRepositorioEventos`
y produce un archivo derivado para auditoría externa. El log canónico
no se modifica (RN-03 preservado: este subcomando solo lee).

Uso::

sentinel export --formato csv --in data/eventos.jsonl --out reporte.csv
sentinel export --formato json --in data/eventos.jsonl --out reporte.json
"""

from __future__ import annotations

from enum import StrEnum
from pathlib import Path # noqa: TC003 — Typer inspecciona el tipo en runtime.
from typing import Annotated

import typer

from sentinel_dispatch.adapters.exportador import exportar_a_csv, exportar_a_json
from sentinel_dispatch.adapters.repositorio_jsonl import JsonlRepositorioEventos


class FormatoExport(StrEnum):
"""Formatos soportados por :func:`export`."""

CSV = "csv"
JSON = "json"


def export(
formato: Annotated[
FormatoExport,
typer.Option(
"--formato",
help="Formato de salida: 'csv' (utf-8-sig + BOM) o 'json' (array indentado).",
case_sensitive=False,
),
],
entrada_path: Annotated[
Path,
typer.Option(
"--in",
help="Path al archivo eventos.jsonl producido por --log-eventos.",
),
],
salida_path: Annotated[
Path,
typer.Option(
"--out",
help="Path al archivo de salida CSV o JSON (se crea o sobreescribe).",
),
],
) -> None:
"""Exporta el log JSONL a CSV o JSON para auditoría externa (RF-11).

Exit codes:

- **0** si la exportación fue exitosa.
- **2** si ``--in`` no existe o contiene JSONL inválido (propagado
desde el adapter como :exc:`ValidationError` o :exc:`OSError`).
"""
if not entrada_path.exists():
typer.secho(
f"Error: archivo de entrada no encontrado: {entrada_path}",
fg=typer.colors.RED,
err=True,
)
raise typer.Exit(code=2)

try:
repo = JsonlRepositorioEventos(entrada_path)
except Exception as exc: # ValidationError de Pydantic, OSError, etc.
typer.secho(
f"Error: no se pudo leer el log de eventos: {exc}",
fg=typer.colors.RED,
err=True,
)
raise typer.Exit(code=2) from exc

eventos = list(repo.leer_todos())

if formato is FormatoExport.CSV:
n = exportar_a_csv(eventos, salida_path)
else:
n = exportar_a_json(eventos, salida_path)

typer.echo(f"Exportados {n} evento(s) a {salida_path} (formato {formato.value}).")
raise typer.Exit(code=0)
Loading
Loading