diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f5871..85794f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/core-python/src/sentinel_dispatch/adapters/exportador.py b/core-python/src/sentinel_dispatch/adapters/exportador.py new file mode 100644 index 0000000..d47391a --- /dev/null +++ b/core-python/src/sentinel_dispatch/adapters/exportador.py @@ -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) diff --git a/core-python/src/sentinel_dispatch/interfaces/cli/app.py b/core-python/src/sentinel_dispatch/interfaces/cli/app.py index 17416ea..10dc516 100644 --- a/core-python/src/sentinel_dispatch/interfaces/cli/app.py +++ b/core-python/src/sentinel_dispatch/interfaces/cli/app.py @@ -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", @@ -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 diff --git a/core-python/src/sentinel_dispatch/interfaces/cli/export_cmd.py b/core-python/src/sentinel_dispatch/interfaces/cli/export_cmd.py new file mode 100644 index 0000000..a9dd26d --- /dev/null +++ b/core-python/src/sentinel_dispatch/interfaces/cli/export_cmd.py @@ -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) diff --git a/core-python/tests/unit/adapters/test_exportador.py b/core-python/tests/unit/adapters/test_exportador.py new file mode 100644 index 0000000..8bbb93c --- /dev/null +++ b/core-python/tests/unit/adapters/test_exportador.py @@ -0,0 +1,219 @@ +"""UT del exportador CSV/JSON (RF-11).""" + +from __future__ import annotations + +import csv +import json +from typing import TYPE_CHECKING, Any + +from typer.testing import CliRunner + +from sentinel_dispatch.adapters.exportador import ( + _aplanar_dict, + exportar_a_csv, + exportar_a_json, +) +from sentinel_dispatch.adapters.repositorio_jsonl import JsonlRepositorioEventos +from sentinel_dispatch.interfaces.cli import app +from sentinel_dispatch.ports.repositorio_eventos import EventoLog, TipoEvento + +if TYPE_CHECKING: + from pathlib import Path + + +runner = CliRunner() + + +def _evento( + evento_id: str = "EVT-20260521T120000-0001", + *, + payload: dict[str, Any] | None = None, +) -> EventoLog: + return EventoLog( + evento_id=evento_id, + timestamp_iso="2026-05-21T12:00:00.000Z", + tipo=TipoEvento.DESPACHO_CREADO, + despacho_id="SD-20260521-0001", + incidente_id="I-01", + payload=payload + or { + "motivo": "optimo", + "costo": {"T_viaje": 187.42, "penalizacion": 0.0, "total": 187.42}, + "unidad_seleccionada": {"id": "U02"}, + "ruta": ["123456", "234567"], + }, + ) + + +# --------------------------------------------------------------------------- +# _aplanar_dict +# --------------------------------------------------------------------------- + + +class TestAplanarDict: + def test_dict_anidado_se_concatena_con_underscore(self) -> None: + data = {"costo": {"T_viaje": 1.0, "total": 1.0}} + plano = _aplanar_dict(data, prefijo="payload") + assert plano == {"payload_costo_T_viaje": 1.0, "payload_costo_total": 1.0} + + def test_lista_se_serializa_a_json_string(self) -> None: + data = {"ruta": ["123", "456"]} + plano = _aplanar_dict(data, prefijo="p") + assert plano["p_ruta"] == '["123", "456"]' + + def test_sin_prefijo_concatena_solo_keys(self) -> None: + data = {"a": {"b": 1}} + plano = _aplanar_dict(data) + assert plano == {"a_b": 1} + + +# --------------------------------------------------------------------------- +# CSV +# --------------------------------------------------------------------------- + + +class TestExportarCsv: + def test_normal_3_eventos_3_filas_con_header(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.csv" + n = exportar_a_csv([_evento(f"EVT-{i:04d}") for i in range(3)], destino) + assert n == 3 + # Lectura con utf-8-sig (consume BOM) + with destino.open(encoding="utf-8-sig", newline="") as f: + filas = list(csv.DictReader(f)) + assert len(filas) == 3 + assert filas[0]["evento_id"] == "EVT-0000" + assert filas[0]["payload_motivo"] == "optimo" + assert filas[0]["payload_costo_total"] == "187.42" + + def test_borde_lista_vacia_solo_header_minimal(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.csv" + n = exportar_a_csv([], destino) + assert n == 0 + contenido = destino.read_text(encoding="utf-8-sig") + # Solo el header de las columnas raíz (sin payload_*). + assert contenido.strip() == "evento_id,timestamp_iso,tipo,despacho_id,incidente_id,operador" + + def test_payloads_heterogeneos_union_de_columnas(self, tmp_path: Path) -> None: + """Si un evento tiene una columna que otro no, ambas existen y la + celda faltante queda vacía.""" + destino = tmp_path / "reporte.csv" + n = exportar_a_csv( + [ + _evento("EVT-A", payload={"motivo": "optimo", "eta_segundos": 100}), + _evento("EVT-B", payload={"motivo": "saturacion"}), + ], + destino, + ) + assert n == 2 + with destino.open(encoding="utf-8-sig", newline="") as f: + filas = list(csv.DictReader(f)) + assert filas[0]["payload_eta_segundos"] == "100" + assert filas[1]["payload_eta_segundos"] == "" + + def test_utf8_sig_emite_bom_para_excel(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.csv" + exportar_a_csv([_evento()], destino) + # Primeros 3 bytes deben ser BOM utf-8 (EF BB BF). + assert destino.read_bytes()[:3] == b"\xef\xbb\xbf" + + +# --------------------------------------------------------------------------- +# JSON +# --------------------------------------------------------------------------- + + +class TestExportarJson: + def test_normal_3_eventos_produce_array_de_3_objetos(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.json" + n = exportar_a_json([_evento(f"EVT-{i:04d}") for i in range(3)], destino) + assert n == 3 + data = json.loads(destino.read_text(encoding="utf-8")) + assert isinstance(data, list) + assert len(data) == 3 + assert data[0]["evento_id"] == "EVT-0000" + assert data[0]["payload"]["motivo"] == "optimo" + + def test_borde_lista_vacia_produce_array_vacio(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.json" + n = exportar_a_json([], destino) + assert n == 0 + contenido = destino.read_text(encoding="utf-8") + assert json.loads(contenido) == [] + + def test_json_sin_bom(self, tmp_path: Path) -> None: + destino = tmp_path / "reporte.json" + exportar_a_json([_evento()], destino) + assert destino.read_bytes()[:3] != b"\xef\xbb\xbf" + + +# --------------------------------------------------------------------------- +# CLI: sentinel export +# --------------------------------------------------------------------------- + + +class TestCliExport: + def test_cli_export_csv_end_to_end(self, tmp_path: Path) -> None: + """Smoke: escribe un JSONL, invoca `sentinel export --formato csv`, parsea.""" + eventos_path = tmp_path / "eventos.jsonl" + repo = JsonlRepositorioEventos(eventos_path) + repo.append(_evento("EVT-X-0001")) + repo.append(_evento("EVT-X-0002")) + + out_csv = tmp_path / "out.csv" + result = runner.invoke( + app, + ["export", "--formato", "csv", "--in", str(eventos_path), "--out", str(out_csv)], + ) + assert result.exit_code == 0, result.output + assert out_csv.exists() + with out_csv.open(encoding="utf-8-sig", newline="") as f: + filas = list(csv.DictReader(f)) + assert len(filas) == 2 + + def test_cli_export_json_end_to_end(self, tmp_path: Path) -> None: + eventos_path = tmp_path / "eventos.jsonl" + repo = JsonlRepositorioEventos(eventos_path) + repo.append(_evento("EVT-Y-0001")) + + out_json = tmp_path / "out.json" + result = runner.invoke( + app, + ["export", "--formato", "json", "--in", str(eventos_path), "--out", str(out_json)], + ) + assert result.exit_code == 0 + data = json.loads(out_json.read_text(encoding="utf-8")) + assert isinstance(data, list) + assert data[0]["evento_id"] == "EVT-Y-0001" + + def test_cli_export_archivo_inexistente_exit_2(self, tmp_path: Path) -> None: + result = runner.invoke( + app, + [ + "export", + "--formato", + "csv", + "--in", + str(tmp_path / "no-existe.jsonl"), + "--out", + str(tmp_path / "out.csv"), + ], + ) + assert result.exit_code == 2 + assert "no encontrado" in result.stderr + + def test_cli_export_jsonl_corrupto_exit_2(self, tmp_path: Path) -> None: + eventos_path = tmp_path / "corrupto.jsonl" + eventos_path.write_text('{"evento_id": "X", "campo_invalido": 1}\n', encoding="utf-8") + result = runner.invoke( + app, + [ + "export", + "--formato", + "csv", + "--in", + str(eventos_path), + "--out", + str(tmp_path / "out.csv"), + ], + ) + assert result.exit_code == 2 diff --git a/docs/quality/trazabilidad.md b/docs/quality/trazabilidad.md index d113f90..47493f1 100644 --- a/docs/quality/trazabilidad.md +++ b/docs/quality/trazabilidad.md @@ -39,7 +39,7 @@ La matriz cubre los **doce Requisitos Funcionales** (RF-01..RF-12), las **diez R | **RF-08** Re-despacho automático con confirmación humana | `domain/dispatch/` → [`redespacho.py`](../../core-python/src/sentinel_dispatch/domain/dispatch/redespacho.py) | `evaluar_redespacho(unidad_actual, incidente_actual, incidente_nuevo, progreso_pct, flota, tiempos_viaje)` → `PropuestaRedespacho(procede, razon, unidad_de_reemplazo, ...)` | [CP-06](../SRS.md#213-casos-de-prueba) progreso 40% · [CP-07](../SRS.md#213-casos-de-prueba) progreso 60% | Tres condiciones RN-06 evaluadas en orden: criticidad creciente → progreso ≤ 50% → cobertura alternativa. Veredicto humanlegible en `razon`. Nunca ejecuta — solo propone | ✅ H3 fase 2 | | **RF-09** Panel de unidades en tiempo real | `interfaces/api` + UI HTMX (F5 diferido) | _Endpoint `/unidades/estado` (pendiente)_ | Verificación funcional durante FTR-02 | Estado actualizado refleja transiciones Disponible↔EnRuta↔EnEscena↔Taller con coordenadas | ⛔ post-H5 (ADR-0004 deferred) | | **RF-10** Detección de saturación y candidatas a re-dirección | `application/` → [`saturacion.py`](../../core-python/src/sentinel_dispatch/application/saturacion.py) | `detectar_saturacion(flota, progreso_por_unidad)` → `EstadoSaturacion(saturada, candidatas_redireccion)`; candidatas EnRuta ordenadas por `(progreso_pct asc, unidad.id lex asc)`; default conservador `progreso=0.0` para EnRuta sin progreso provisto | [CP-10](../SRS.md#213-casos-de-prueba) flota saturada | Sistema reporta saturación cuando ninguna unidad está en `DISPONIBLE`; lista candidatas EnRuta para re-dirección manual del operador | ✅ H3 fase 3 | -| **RF-11** Exportación de logs CSV/JSON | `adapters/exportador.py` | `exportar(logs, formato={csv,json})` (pendiente) | _Test funcional (pendiente, post-H4)_ | Logs exportados conservan campos del esquema y son legibles por herramientas estándar | 🟡 H4 | +| **RF-11** Exportación de logs CSV/JSON | `adapters/exportador.py` + `interfaces/cli/export_cmd.py` | `exportar_a_csv(eventos, path)` y `exportar_a_json(eventos, path)`; CLI `sentinel export --formato {csv,json} --in EVENTOS.jsonl --out PATH`. CSV con flatten de `payload_*` y encoding `utf-8-sig` (BOM Excel); JSON como array indentado sin BOM | `test_exportador.py` (14 UT — flatten, CSV/JSON Normal/Borde, end-to-end CLI con archivo inexistente o corrupto) | Logs derivados con union de columnas para payloads heterogéneos; el log canónico JSONL no se modifica (RN-03 preservado, este subcomando solo lee) | ✅ | | **RF-12** Modo simulación sobre flota ficticia | `application/` → `simulacion.py` | `simular(flota_ficticia, incidente)` (pendiente) | _Test funcional (pendiente)_ | Cálculo completo se ejecuta sin afectar el estado operativo real; resultado claramente marcado como simulación | 🟡 H4 | ## 3. Reglas de Negocio