[AZ-959] replay_api: POST /replay (video,csv) + /static/example-csv

Extend the AZ-701 replay_api POST /replay endpoint so AZ-897 (now
in ../ui repo) can drive the AZ-894 CSV-replay path. The endpoint
keeps full back-compat for tlog clients and adds:

- (video, tlog) OR (video, csv) multipart with strict XOR enforced
  at the API boundary (AC-2 / AC-3 → 400 multipart_missing_field)
- validate_csv_kind: rejects malformed CSV schema at boundary by
  scanning the header line for AZ-896 required tokens; messages
  point at csv_replay_format.md (AC-4)
- ReplayInputs DTO: tlog_path / csv_path are now Path | None with
  XOR re-enforced in __post_init__ for internal callers
- JobStorage reserves both input.tlog and input.csv paths; handler
  writes exactly one
- SubprocessReplayRunner.run dispatches --imu vs --tlog argv (AC-1)
- _maybe_render_report dispatches load_csv_ground_truth vs
  load_tlog_ground_truth; CsvGpsFix and TlogGpsFix have
  field-compatible shapes for the GroundTruthRow adapter (AC-6)
- GET /static/example-csv serves the AZ-896 reference CSV; honours
  REPLAY_API_EXAMPLE_CSV_PATH env, falls back to source-checkout
  layout, returns 503 with example_csv_unavailable when neither
  resolves to a readable file. No auth required (AC-5)

Tests: 27/27 unit tests green:
- 18 pre-existing tlog-path tests unchanged (AC-7)
- 9 new tests covering ACs 1-6 + validate_csv_kind isolation

Deferred (NOT silently fixed; reported to user as end-of-turn
notes for scope discipline):

- gps-denied-render-map only consumes binary tlog truth today, so
  CSV-path jobs return map_html_url=None. Extending render-map to
  dispatch on truth-file extension is AZ-700 follow-up territory.
- ReportContext.tlog_path field is now overloaded as the
  "ground-truth source path"; the rendered report still labels
  the line "Tlog: <csv_path>" which is cosmetically misleading
  for CSV runs. Field rename + label fix is AZ-699 follow-up.

Bookkeeping: AZ-959 spec moved todo/ → done/, dep-table preamble
fifth bump documents what landed + what's deferred, state.md
records batch 5 complete and what comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-29 12:45:25 +03:00
parent 05fcacffa3
commit 1d18e25cf4
8 changed files with 476 additions and 17 deletions
@@ -18,12 +18,14 @@ from gps_denied_onboard.replay_api.errors import (
)
__all__ = [
"MIN_CSV_PROBE_BYTES",
"MIN_TLOG_PROBE_BYTES",
"MIN_VIDEO_PROBE_BYTES",
"auth_required",
"expected_bearer_token",
"extract_bearer_token",
"validate_calibration_kind",
"validate_csv_kind",
"validate_tlog_kind",
"validate_upload_size",
"validate_video_kind",
@@ -48,6 +50,26 @@ _MP4_FTYP_MARKER: bytes = b"ftyp"
MIN_VIDEO_PROBE_BYTES: int = 12
# CSV header line for the AZ-896 replay format is ~410 chars; probe
# generously so we can read the full header regardless of OS line
# endings or operator whitespace. The validator only checks the
# headline column tokens; the parser in ``csv_ground_truth`` does
# the strict per-row validation downstream.
MIN_CSV_PROBE_BYTES: int = 512
_CSV_REQUIRED_HEADER_TOKENS: tuple[str, ...] = (
"timestamp(ms)",
"Time",
"SCALED_IMU2.xacc",
"SCALED_IMU2.xgyro",
"GLOBAL_POSITION_INT.lat",
"GLOBAL_POSITION_INT.lon",
)
_CSV_FORMAT_DOC_PATH: str = (
"_docs/02_document/contracts/replay/csv_replay_format.md"
)
def validate_tlog_kind(probe_bytes: bytes) -> None:
"""Reject anything that doesn't open with a MAVLink magic byte.
@@ -90,6 +112,34 @@ def validate_video_kind(probe_bytes: bytes) -> None:
)
def validate_csv_kind(probe_bytes: bytes) -> None:
"""Reject anything that doesn't open with the AZ-896 CSV header.
The strict per-row schema lives in ``csv_ground_truth.py``; this
boundary check just confirms the first line looks like the AZ-896
header so we fail fast at the API before the subprocess hands the
error back through an opaque non-zero exit code.
"""
if len(probe_bytes) < 1:
raise UnsupportedFileKindError("csv upload is empty")
header_end = probe_bytes.find(b"\n")
header_bytes = probe_bytes if header_end < 0 else probe_bytes[:header_end]
try:
header = header_bytes.decode("utf-8").strip()
except UnicodeDecodeError as exc:
raise UnsupportedFileKindError(
"csv header is not valid UTF-8 (see "
f"{_CSV_FORMAT_DOC_PATH})"
) from exc
columns = {col.strip() for col in header.split(",")}
missing = [token for token in _CSV_REQUIRED_HEADER_TOKENS if token not in columns]
if missing:
raise UnsupportedFileKindError(
"csv header is missing required columns "
f"{missing} (see {_CSV_FORMAT_DOC_PATH})"
)
def validate_calibration_kind(probe_bytes: bytes) -> None:
"""Light JSON-shape check; the renderer is the strict validator."""
if not probe_bytes: