diff --git a/CLAUDE.md b/CLAUDE.md index 4160d3f7..bfac519d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -209,7 +209,7 @@ Operational and one-shot scripts live in two places. They are not on the request 2. `packages/loudness-spectro-wasm/` — browser-first WebAssembly DSP (ITU-R BS.1770-5 / EBU R128 loudness, A-weighted spectrum, and spectral-reassignment spectrogram). Rust lifted from [openmeters](https://github.com/httpsworldview/openmeters) and compiled via `wasm-bindgen`, GPL-3.0-or-later. **Phase 1: standalone, not yet imported by `apps/ui` or `apps/backend`.** Has its own Cargo workspace, `npm`/`cargo` build, and EBU/ebur128/pyloudnorm validation layers — see [`packages/loudness-spectro-wasm/README.md`](packages/loudness-spectro-wasm/README.md). When integration lands, the canonical Phase 1 LUFS contract still comes from the Essentia path until this is wired in and proven at parity. (The reassigned spectrogram on the product path today comes from librosa via the `reassigned` spectral-enhancement endpoint, not this package.) 3. `audits/` — dated, automated audit reports (e.g. `nightly-2026-05-19.md`, `phase2-recommendation-surface-2026-05-24.md`, `full-review-2026-05-30.md`). Past-tense paper trail; not imported by either app. (Older one-shot advisory deliverables like the phase 1 audit have been archived under `docs/history/phase1-audit/`.) 4. `incorporations/` — planning docs for incorporating upstream projects (e.g. `forking-plans-2026-05-14.md`, `beat-this-measurement-gate-2026-05-20.md`, `msst-separation-licence-gate-2026-06-05.md`). Planning notes only; not code. -5. `docs/` — long-form rationale and architectural records. Key contents: `ARCHITECTURE_STRATEGY.md` (three-layer design rationale), `SETUP.md` (first-run setup), `LAYER2_EVALUATION.md` and `POLYPHONIC_TRANSCRIPTION_SPIKE.md` (Layer 2 research), `SAMPLE_GENERATION.md` (Phase 3 design), `PHASE2_PROVIDER.md` (Phase 2 provider selection — Gemini vs MOSS sidecar, licence status, provider contract), `adr/` (Architecture Decision Records — `0001-phase1-json-schema-v1.md` declares the v1 schema stability contract; `0002-phase1-loudness-units-v2.md` bumped `truePeak` to dBTP and `bpmConfidence` to normalized 0–1, schema now at `phase1.v2`; `0003-recommendations-contract-v1.md` freezes the Phase 2 `recommendations` contract (`recommendations.v1`) — a normalized, schema-validated, citation-gated projection of the device cards), and `history/` (completed plans, one-shot audits, and deliverables — past-tense). Read the relevant doc *before* structural changes; do not treat them as living API docs. +5. `docs/` — long-form rationale and architectural records. Key contents: `ARCHITECTURE_STRATEGY.md` (three-layer design rationale), `SETUP.md` (first-run setup), `LAYER2_EVALUATION.md` and `POLYPHONIC_TRANSCRIPTION_SPIKE.md` (Layer 2 research), `SAMPLE_GENERATION.md` (Phase 3 design), `PHASE2_PROVIDER.md` (Phase 2 provider selection — Gemini vs MOSS sidecar, licence status, provider contract), `ASA_ABLETON_BOUNDARY.md` (the file-coupled contract between ASA and the sibling `asa-ableton` `.als` generator — the `phase2-export.v1` handoff envelope), `adr/` (Architecture Decision Records — `0001-phase1-json-schema-v1.md` declares the v1 schema stability contract; `0002-phase1-loudness-units-v2.md` bumped `truePeak` to dBTP and `bpmConfidence` to normalized 0–1, schema now at `phase1.v2`; `0003-recommendations-contract-v1.md` freezes the Phase 2 `recommendations` contract (`recommendations.v1`) — a normalized, schema-validated, citation-gated projection of the device cards), and `history/` (completed plans, one-shot audits, and deliverables — past-tense). Read the relevant doc *before* structural changes; do not treat them as living API docs. 6. `tests/ground_truth/` — labeled-corpus fixtures consumed by `scripts/calibrate_confidence.py` (see `tests/ground_truth/README.md`). Not a test suite — each app owns its own (`apps/backend/tests/`, `apps/ui/tests/`). ### Three-Layer Model @@ -298,6 +298,8 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths 24. **`phase2_provider.py` + `moss_sidecar/`**: Selectable Phase 2 interpretation provider (default-off experiment). `ASA_PHASE2_PROVIDER=moss` routes the producer_summary interpretation to a self-hosted OpenMOSS MOSS-Audio FastAPI sidecar instead of Gemini; `ASA_PHASE2_PROVIDER=claude` routes it to `ClaudeCliProvider` — a text-only interpreter that runs the local Claude Code CLI headless (sandboxed: `--safe-mode`, `--tools ""`, `--no-session-persistence`, response schema enforced via `--json-schema`), grounds purely on the prompt's embedded Phase 1 JSON, and needs no `GEMINI_API_KEY`. All paths flow through the identical parse/citation/catalogue validators. STEP ONE licence gate: MOSS-Audio *weights* are Apache-2.0 but the *modeling code* has no effective licence, so the sidecar ships no OpenMOSS code and the real-model path is a 501 stub — research-only, not promotable. `moss_sidecar/app.py` is the FastAPI sidecar; `moss_sidecar/mock_interpreter.py` generates deterministic schema-valid Phase-1-grounded output for the default `ASA_MOSS_SIDECAR_MODE=mock` path. See `docs/PHASE2_PROVIDER.md` for the full design rationale and licence analysis. +25. **`phase2_export.py`**: Versioned Phase 2 handoff envelope (`phase2-export.v1`) for downstream consumers — the sibling `asa-ableton` repo (turns ASA recommendations into an openable Live 12 `.als` starter set) and `scripts/evaluate_recommendations.py --phase2`. Backs `GET /api/analysis-runs/{run_id}/export/phase2`: the stored `producer_summary` interpretation result verbatim (including the frozen `recommendations.v1` projection), the authoritative Phase 1 payload its citations resolve against, the full `validationWarnings` trail, and provenance — one curl, one self-contained file, no snapshot surgery. Same thin lookup-and-serve pattern as `csv_export.py`; key set frozen by `tests/test_phase2_export.py`. Cross-repo contract documented in `docs/ASA_ABLETON_BOUNDARY.md`. + The subprocess isolation means `analyze.py` works as a standalone CLI. Check `apps/backend/JSON_SCHEMA.md` before adding new analyzer output fields. Check `apps/backend/ARCHITECTURE.md` for the full HTTP flow and contract details. **Phase 2 (`POST /api/phase2`, legacy compat):** Uploads audio to Gemini inline if ≤100 MiB, or via the Gemini Files API if larger. Phase 1 JSON is appended to the system prompt from `prompts/phase2_system.txt`. Also relevant: `prompts/stem_summary_system.txt` and `prompts/live12_device_catalog.json` (the *prompt-injected* device catalogue — distinct from the runtime-validation `data/live12_catalogue.json` consumed by `phase2_catalogue_gates.py`). Backend defense-in-depth: `server_phase2.py`'s `_validate_phase2_citation_paths` mirrors the frontend citation-existence check and emits `validationWarnings` when a recommendation cites a Phase 1 path that doesn't exist — it flags invented citations rather than failing, since Phase 1 stays authoritative (invariant #1). `phase2_catalogue_gates.apply_live12_catalogue_gates` runs immediately after for source-catalogue checks (see backend file #20 above). Additional regression gates: `tests/test_phase2_grammar_fix.py` locks in the gerund-fix post-process in `server_phase2.py`; `tests/test_phase2_prompt_catalog.py` pins prompt examples against the Live 12 catalogue. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 04f2f9d1..1f52af49 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -118,6 +118,7 @@ python3.11 -m venv venv - `url_ingest.py`: SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs`. Fetches a public `http`/`https` audio file and streams the bytes through the same downstream pipeline as a multipart upload, enforcing the shared 100 MiB cap. - `audio_mime.py`: Canonical, host-independent filename→MIME map for ingested audio (`canonical_audio_mime`). Mirrors `apps/ui/src/services/audioFile.ts` so a `.flac` resolves to `audio/flac` on every OS (stdlib `mimetypes` is host-dependent). Imported directly by `server_phase2.py` and `url_ingest.py`, and reached by `server.py` via `server_phase2._get_audio_mime_type` — keep the two maps in sync. - `csv_export.py`: CSV exporters for Phase 1 time-series fields; backs `GET /api/analysis-runs/{run_id}/export/csv/{field_path}`. +- `phase2_export.py`: Versioned `phase2-export.v1` handoff envelope for downstream consumers (the sibling `asa-ableton` `.als` generator; `scripts/evaluate_recommendations.py --phase2`); backs `GET /api/analysis-runs/{run_id}/export/phase2`. Same thin lookup-and-serve pattern as `csv_export.py`. Cross-repo contract documented in `docs/ASA_ABLETON_BOUNDARY.md`. - `stage_status.py`: Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field carried on every stage in the run snapshot. - `sample_generation.py`, `sample_theory.py`, `sample_synthesis.py`, `sample_drums.py`: Phase 3 audition-sample generation — PyTheory plan, FluidSynth/sine-additive render, NumPy drum one-shots, citation manifest. On-demand only. - `dsp_bandbank.py`, `dsp_utils.py`: Shared DSP primitives — `BatchedBandpass` Butterworth bank and cross-module utilities. diff --git a/apps/backend/ARCHITECTURE.md b/apps/backend/ARCHITECTURE.md index 1a5ed608..2d1d3c1d 100644 --- a/apps/backend/ARCHITECTURE.md +++ b/apps/backend/ARCHITECTURE.md @@ -17,6 +17,7 @@ | `url_ingest.py` | SSRF-guarded URL-mode ingestion for `POST /api/analysis-runs`. Fetches a public `http`/`https` audio file and streams the bytes through the same downstream pipeline as a multipart upload, enforcing the shared 100 MiB cap. | | `audio_mime.py` | Canonical, host-independent filename→MIME resolution for ingested audio (`canonical_audio_mime`). Mirrors the frontend map in `apps/ui/src/services/audioFile.ts` so a `.flac` resolves to `audio/flac` on every OS — stdlib `mimetypes` is host-dependent (`audio/x-flac` on macOS vs `audio/flac` on Linux), which failed the test gate and could mislabel a FLAC handed to Gemini. Imported directly by `server_phase2.py` and `url_ingest.py`, and reached by `server.py` via `server_phase2._get_audio_mime_type`. | | `csv_export.py` | CSV exporters for Phase 1 time-series fields, keyed by dotted JSON path. Backs `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` and keeps the route handler a thin lookup-and-serve. | +| `phase2_export.py` | Versioned Phase 2 handoff envelope (`phase2-export.v1`) for downstream consumers — the sibling `asa-ableton` `.als` generator and `scripts/evaluate_recommendations.py --phase2`. Backs `GET /api/analysis-runs/{run_id}/export/phase2`; same thin lookup-and-serve pattern as `csv_export.py`. See [`docs/ASA_ABLETON_BOUNDARY.md`](../../docs/ASA_ABLETON_BOUNDARY.md). | | `transcription_pianoroll.py` | Renders the pitch-note translation stage's `transcriptionDetail` as a velocity-encoded `(pitch, time)` uint8 matrix via `symusic`. Backs `GET /api/analysis-runs/{run_id}/transcription/pianoroll`. Derived view — Phase 1 stays authoritative; the response cites the Phase 1 `bpm` and `timeSignature` so chain of custody (PURPOSE invariant #2) is preserved. | | `stage_status.py` | Collapses the eight internal stage statuses into the additive client-facing `publicStatus` field carried on every stage in the run snapshot. | | `server_samples.py` + `sample_generation.py` / `sample_theory.py` / `sample_synthesis.py` / `sample_drums.py` | Phase 3 audition-sample generation. PyTheory musical plan, FluidSynth (sine-additive fallback) audio render, NumPy drum one-shots, and a citation manifest tying every clip back to a Phase 1 field. On-demand only — not part of the staged-execution queue. See [`docs/SAMPLE_GENERATION.md`](../../docs/SAMPLE_GENERATION.md). | @@ -93,6 +94,7 @@ Custom routes: - `GET /api/analysis-runs/{run_id}/artifacts` and `…/artifacts/{artifact_id}` - `GET /api/analysis-runs/{run_id}/source-audio` — re-serves the original ingested audio for the run. Owner-only (no admin bypass). Saves a round-trip vs looking up the source artifact id first. - `GET /api/analysis-runs/{run_id}/export/csv/{field_path}` — CSV export of a Phase 1 time-series field. See [`docs/adr/0001-phase1-json-schema-v1.md`](../../docs/adr/0001-phase1-json-schema-v1.md) and the registry in [`csv_export.py`](csv_export.py). +- `GET /api/analysis-runs/{run_id}/export/phase2` — single-file `phase2-export.v1` handoff envelope: the stored `producer_summary` interpretation result verbatim (including the frozen `recommendations.v1` projection), the authoritative Phase 1 payload its citations resolve against, the full `validationWarnings` trail, and provenance. The cross-repo contract for the sibling `asa-ableton` `.als` generator and the input format `scripts/evaluate_recommendations.py --phase2` accepts. Status codes: 200 (attachment), 404 (`RUN_NOT_FOUND`/`PHASE2_EXPORT_NOT_AVAILABLE`). Implementation in [`phase2_export.py`](phase2_export.py); contract doc at [`docs/ASA_ABLETON_BOUNDARY.md`](../../docs/ASA_ABLETON_BOUNDARY.md). - `GET /api/analysis-runs/{run_id}/transcription/pianoroll` — velocity-encoded pianoroll matrix derived from the pitch-note translation stage's `transcriptionDetail`. Query params: `mode` (`frame`|`onset`, default `frame`), `pitchLow` (default 21), `pitchHigh` (default 109, exclusive), `tpq` (default 4). Response cites the Phase 1 `bpm` and `timeSignature` so every cell traces back to a measurement. Status codes: 200 (payload), 400 (`INVALID_MODE`/`INVALID_PITCH_RANGE`/`INVALID_TPQ`), 404 (`RUN_NOT_FOUND`/`TRANSCRIPTION_NOT_REQUESTED`/`TRANSCRIPTION_NOT_AVAILABLE`), 409 (`MEASUREMENT_NOT_COMPLETED`/`TRANSCRIPTION_NOT_COMPLETED`). Implementation in [`transcription_pianoroll.py`](transcription_pianoroll.py). - `POST /api/analysis-runs/{run_id}/spectral-enhancements/{kind}` — on-demand spectral artifacts. `kind` is one of `cqt`, `hpss`, `onset`, `chroma_interactive`, or `reassigned` (sharper transient/frequency localization via `librosa.reassigned_spectrogram`). - `POST /api/analysis-runs/{run_id}/pitch-note-translations` diff --git a/apps/backend/phase2_export.py b/apps/backend/phase2_export.py new file mode 100644 index 00000000..1177b4c7 --- /dev/null +++ b/apps/backend/phase2_export.py @@ -0,0 +1,121 @@ +"""Phase 2 handoff export — versioned envelope for downstream consumers. + +Backs ``GET /api/analysis-runs/{run_id}/export/phase2`` and keeps the route +handler a thin lookup-and-serve (same pattern as ``csv_export.py``). + +ASA's Phase 2 interpretation result is the input to downstream tooling that +lives *outside* this repo — concretely the sibling ``asa-ableton`` project +(turns the device cards into an openable Live 12 ``.als`` starter set) and the +in-repo recommendation-proof harness (``scripts/evaluate_recommendations.py +--phase2``). Until now that JSON had no export path: it exists only embedded in +the full run snapshot, with the validation warnings living in a different +subtree (attempt ``diagnostics``) and the grounding provenance in a third. +Consumers hand-extracted it, which is exactly the kind of unversioned, fragile +handoff ADR 0001 warns about. + +This module freezes a single-file envelope (``phase2-export.v1``): + + - ``phase2`` — the stored ``producer_summary`` interpretation result, + verbatim, exactly as the validator tail left it (including the additive + ``recommendations`` field — the frozen ``recommendations.v1`` contract, + ADR 0003). + - ``phase1`` — the authoritative measurement payload the interpretation was + grounded on, included so a consumer can independently verify that every + cited ``phase1Fields`` path resolves (PURPOSE.md invariant #2) without a + second request. + - ``validationWarnings`` — the full warn-and-keep trail (citation-existence, + semantic, catalogue-gate) from the attempt diagnostics, so a non-browser + consumer sees the same chain-of-custody flags the UI renders. + - ``provenance`` — the stored attempt provenance (interpretation schema + version, profile, model, prompt version, grounding ids), verbatim. + +Derived and read-only: the export projects stored run state and never +re-estimates or mutates anything (invariant #1). Only the ``producer_summary`` +profile is exportable — it is the profile that carries device cards; +``stem_summary`` has no downstream device consumer. + +See ``docs/ASA_ABLETON_BOUNDARY.md`` for the cross-repo contract this backs. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Mapping + +EXPORT_SCHEMA_VERSION = "phase2-export.v1" + +# The device-card profile. stem_summary results carry no device cards and are +# deliberately not exportable through this envelope. +EXPORTABLE_PROFILE_ID = "producer_summary" + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _producer_summary_attempt(snapshot: Mapping[str, Any]) -> Mapping[str, Any] | None: + """The completed producer_summary attempt from a run snapshot, or ``None``. + + Reads the per-profile map rather than the stage-level ``result`` because + the stage's preferred attempt can be a different profile (e.g. + ``stem_summary`` finishing first). + """ + stages = snapshot.get("stages") + if not isinstance(stages, Mapping): + return None + interpretation = stages.get("interpretation") + if not isinstance(interpretation, Mapping): + return None + profiles = interpretation.get("profiles") + if not isinstance(profiles, Mapping): + return None + attempt = profiles.get(EXPORTABLE_PROFILE_ID) + if not isinstance(attempt, Mapping): + return None + if attempt.get("status") != "completed": + return None + if not isinstance(attempt.get("result"), Mapping): + # A completed attempt can carry a null result (skip path) — there is + # nothing to hand off in that case. + return None + return attempt + + +def _validation_warnings(attempt: Mapping[str, Any]) -> list[dict[str, Any]]: + diagnostics = attempt.get("diagnostics") + if not isinstance(diagnostics, Mapping): + return [] + warnings = diagnostics.get("validationWarnings") + if not isinstance(warnings, list): + return [] + return [warning for warning in warnings if isinstance(warning, dict)] + + +def build_phase2_export( + snapshot: Mapping[str, Any], + *, + exported_at: str | None = None, +) -> dict[str, Any] | None: + """Project a run snapshot into a ``phase2-export.v1`` envelope. + + Returns ``None`` when the run has no completed ``producer_summary`` + interpretation result to hand off (not requested, still running, failed, + or completed-with-skip). Deterministic and side-effect-free. + """ + attempt = _producer_summary_attempt(snapshot) + if attempt is None: + return None + measurement = snapshot.get("stages", {}).get("measurement", {}) + measurement_result = ( + measurement.get("result") if isinstance(measurement, Mapping) else None + ) + provenance = attempt.get("provenance") + return { + "schemaVersion": EXPORT_SCHEMA_VERSION, + "runId": snapshot.get("runId"), + "exportedAt": exported_at if exported_at is not None else _utc_now_iso(), + "provenance": provenance if isinstance(provenance, Mapping) else None, + "validationWarnings": _validation_warnings(attempt), + "phase1": measurement_result if isinstance(measurement_result, Mapping) else None, + "phase2": attempt["result"], + } diff --git a/apps/backend/recommendation_evaluation.py b/apps/backend/recommendation_evaluation.py index 1a643525..3eb56940 100644 --- a/apps/backend/recommendation_evaluation.py +++ b/apps/backend/recommendation_evaluation.py @@ -576,6 +576,27 @@ def infer_domain(track_context: str | None, category: str | None) -> str: return UNKNOWN_DOMAIN +def coerce_phase2_payload(raw: Mapping[str, Any]) -> Mapping[str, Any]: + """Accept either a bare ``Phase2Result`` or a ``phase2-export.v1`` envelope. + + The backend's ``GET /api/analysis-runs/{run_id}/export/phase2`` route + (``phase2_export.py``) wraps the interpretation result in a versioned + handoff envelope ``{schemaVersion: "phase2-export.v1", ..., phase2: {...}}`` + so one downloaded file feeds both this harness and the sibling + ``asa-ableton`` repo. Unwrap it here so ``--phase2`` (and a fixture-dir + ``phase2.json``) can be that file directly; a bare result passes through + unchanged. + """ + schema_version = raw.get("schemaVersion") + if ( + isinstance(schema_version, str) + and schema_version.startswith("phase2-export.") + and isinstance(raw.get("phase2"), Mapping) + ): + return raw["phase2"] + return raw + + def normalize_phase2(phase2: Mapping[str, Any]) -> list[NormalizedRec]: """Adapter: ``Phase2Result`` JSON -> normalized recs. diff --git a/apps/backend/scripts/evaluate_recommendations.py b/apps/backend/scripts/evaluate_recommendations.py index a535c868..de09bd24 100644 --- a/apps/backend/scripts/evaluate_recommendations.py +++ b/apps/backend/scripts/evaluate_recommendations.py @@ -15,8 +15,11 @@ * ``baseline`` — trivial no-op source (empty rec set). Runs now, no deps. The floor any real source must clear. * ``gemini`` — score a stored ``Phase2Result`` JSON (``--phase2`` or a - sibling ``phase2.json`` in the fixture dir). Producing it - live needs GEMINI_API_KEY + rendered audio (needs-fixture). + sibling ``phase2.json`` in the fixture dir). Either a bare + result or a ``phase2-export.v1`` envelope downloaded from + ``GET /api/analysis-runs/{run_id}/export/phase2`` works. + Producing it live needs GEMINI_API_KEY + rendered audio + (needs-fixture). * ``deterministic`` — score the ``abletonDevices.ts`` path. Wiring the node bridge that emits normalized recs from a real Phase 1 fingerprint is a documented follow-on (NEEDS.md); until @@ -86,7 +89,9 @@ def _resolve_recs( candidate = sibling if sibling.exists() else None if candidate is None or not candidate.exists(): return None, "no Phase2Result JSON (pass --phase2 or drop phase2.json in the fixture dir)" - phase2 = json.loads(candidate.read_text(encoding="utf-8")) + phase2 = rev.coerce_phase2_payload( + json.loads(candidate.read_text(encoding="utf-8")) + ) return rev.normalize_phase2(phase2), f"Phase2Result from {candidate.name}" if source == "deterministic": diff --git a/apps/backend/server.py b/apps/backend/server.py index 7c224621..ba6c7683 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -49,6 +49,7 @@ ) from utils.cleanup import cleanup_artifacts import csv_export +import phase2_export import transcription_pianoroll import upload_limits import url_ingest @@ -3132,6 +3133,66 @@ async def export_run_field_as_csv( ) +@app.get("/api/analysis-runs/{run_id}/export/phase2") +async def export_run_phase2( + run_id: str, + x_asa_user_id: str | None = Header(None), + x_asa_user_email: str | None = Header(None), +) -> JSONResponse: + """Export the run's Phase 2 handoff envelope (``phase2-export.v1``). + + A single self-contained JSON file for downstream consumers — the + sibling ``asa-ableton`` ``.als`` generator and the recommendation-proof + harness (``scripts/evaluate_recommendations.py --phase2``). Carries the + stored ``producer_summary`` interpretation result verbatim (including + the frozen ``recommendations.v1`` envelope), the authoritative Phase 1 + measurement payload its citations resolve against, the full + warn-and-keep ``validationWarnings`` trail, and the stored provenance. + See :mod:`phase2_export` and ``docs/ASA_ABLETON_BOUNDARY.md``. + + Returns: + - 200 ``application/json`` with an attachment Content-Disposition. + - 404 ``RUN_NOT_FOUND`` if the run does not exist or is not owned + by the requesting user. + - 404 ``PHASE2_EXPORT_NOT_AVAILABLE`` if the run has no completed + ``producer_summary`` interpretation result to hand off. + """ + user_context = _resolve_route_user_context(x_asa_user_id, x_asa_user_email) + if isinstance(user_context, JSONResponse): + return user_context + runtime = get_analysis_runtime() + try: + snapshot = runtime.get_run(run_id, owner_user_id=user_context.user_id) + except (KeyError, PermissionError): + return _run_not_found_response(run_id) + + envelope = phase2_export.build_phase2_export(snapshot) + if envelope is None: + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "PHASE2_EXPORT_NOT_AVAILABLE", + "message": ( + f"Run '{run_id}' has no completed producer_summary " + f"interpretation result to export. Interpretation " + f"may be off, still running, failed, or skipped." + ), + "retryable": False, + } + }, + ) + + return JSONResponse( + content=envelope, + headers={ + "Content-Disposition": ( + f'attachment; filename="{run_id}_phase2_export.json"' + ), + }, + ) + + @app.get("/api/analysis-runs/{run_id}/transcription/pianoroll") async def get_transcription_pianoroll( run_id: str, diff --git a/apps/backend/tests/test_phase2_export.py b/apps/backend/tests/test_phase2_export.py new file mode 100644 index 00000000..404272c0 --- /dev/null +++ b/apps/backend/tests/test_phase2_export.py @@ -0,0 +1,201 @@ +"""Tests for phase2_export.py — the phase2-export.v1 handoff envelope. + +Pure-builder tests only; the HTTP shell (status codes, ownership, headers) +is covered by Phase2ExportRouteTests in tests/test_server.py. +""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import phase2_export # noqa: E402 + + +def _snapshot( + *, + interpretation_status: str = "completed", + result: dict | None = None, + diagnostics: dict | None = None, + provenance: dict | None = None, + profile_id: str = "producer_summary", + measurement_result: dict | None = None, +) -> dict: + """A minimal run snapshot shaped like AnalysisRuntime.get_run output.""" + return { + "runId": "run-123", + "stages": { + "measurement": { + "status": "completed", + "result": measurement_result, + }, + "interpretation": { + "status": interpretation_status, + "result": result, + "profiles": { + profile_id: { + "attemptId": "attempt-1", + "status": interpretation_status, + "result": result, + "provenance": provenance, + "diagnostics": diagnostics, + "error": None, + } + }, + }, + }, + } + + +PHASE2_RESULT = { + "trackCharacter": "Driving techno", + "abletonRecommendations": [ + { + "device": "Glue Compressor", + "parameter": "Threshold", + "value": "-12 dB", + "phase1Fields": ["lufsIntegrated"], + } + ], + "recommendations": { + "version": "recommendations.v1", + "recommendations": [ + { + "device": "Glue Compressor", + "parameter": "Threshold", + "value": -12.0, + "unit": "dB", + "range": [-15.0, -9.0], + "cited_measurements": ["lufsIntegrated"], + } + ], + }, +} + +MEASUREMENT_RESULT = {"bpm": 130.0, "lufsIntegrated": -9.2} + +PROVENANCE = { + "schemaVersion": "interpretation.v2", + "profileId": "producer_summary", + "modelName": "gemini-2.5-flash", + "groundedMeasurementRunId": "run-123", +} + +DIAGNOSTICS = { + "requestId": "req-1", + "validationWarnings": [ + { + "code": "RECOMMENDATION_UNVERIFIED", + "path": "abletonRecommendations[0]", + "message": "parameter_unknown", + } + ], +} + + +class BuildPhase2ExportTests(unittest.TestCase): + def test_complete_run_exports_full_envelope(self) -> None: + envelope = phase2_export.build_phase2_export( + _snapshot( + result=PHASE2_RESULT, + diagnostics=DIAGNOSTICS, + provenance=PROVENANCE, + measurement_result=MEASUREMENT_RESULT, + ), + exported_at="2026-06-10T00:00:00+00:00", + ) + self.assertIsNotNone(envelope) + self.assertEqual(envelope["schemaVersion"], "phase2-export.v1") + self.assertEqual(envelope["runId"], "run-123") + self.assertEqual(envelope["exportedAt"], "2026-06-10T00:00:00+00:00") + # phase2 is the stored interpretation result verbatim — including the + # frozen recommendations.v1 envelope the validator tail attached. + self.assertEqual(envelope["phase2"], PHASE2_RESULT) + self.assertEqual( + envelope["phase2"]["recommendations"]["version"], "recommendations.v1" + ) + # phase1 is the authoritative measurement payload, so a consumer can + # resolve cited phase1Fields paths without a second request. + self.assertEqual(envelope["phase1"], MEASUREMENT_RESULT) + self.assertEqual(envelope["provenance"], PROVENANCE) + self.assertEqual( + envelope["validationWarnings"], + DIAGNOSTICS["validationWarnings"], + ) + + def test_envelope_key_set_is_frozen(self) -> None: + """The v1 key set is a published contract — additions need a version bump.""" + envelope = phase2_export.build_phase2_export( + _snapshot(result=PHASE2_RESULT, measurement_result=MEASUREMENT_RESULT) + ) + self.assertEqual( + set(envelope.keys()), + { + "schemaVersion", + "runId", + "exportedAt", + "provenance", + "validationWarnings", + "phase1", + "phase2", + }, + ) + + def test_exported_at_defaults_to_now(self) -> None: + envelope = phase2_export.build_phase2_export( + _snapshot(result=PHASE2_RESULT) + ) + self.assertIsInstance(envelope["exportedAt"], str) + self.assertIn("T", envelope["exportedAt"]) + + def test_missing_diagnostics_yields_empty_warning_list(self) -> None: + envelope = phase2_export.build_phase2_export( + _snapshot(result=PHASE2_RESULT, diagnostics=None) + ) + self.assertEqual(envelope["validationWarnings"], []) + + def test_non_dict_warning_entries_are_dropped(self) -> None: + envelope = phase2_export.build_phase2_export( + _snapshot( + result=PHASE2_RESULT, + diagnostics={"validationWarnings": ["stray-string", {"code": "X"}]}, + ) + ) + self.assertEqual(envelope["validationWarnings"], [{"code": "X"}]) + + def test_no_interpretation_stage_returns_none(self) -> None: + snapshot = {"runId": "run-123", "stages": {"measurement": {"result": {}}}} + self.assertIsNone(phase2_export.build_phase2_export(snapshot)) + + def test_incomplete_interpretation_returns_none(self) -> None: + self.assertIsNone( + phase2_export.build_phase2_export( + _snapshot(interpretation_status="running", result=None) + ) + ) + + def test_completed_with_null_result_returns_none(self) -> None: + """The skip path completes an attempt with a null result — not exportable.""" + self.assertIsNone( + phase2_export.build_phase2_export(_snapshot(result=None)) + ) + + def test_stem_summary_only_run_returns_none(self) -> None: + """Only producer_summary carries device cards; stem_summary is not a handoff.""" + self.assertIsNone( + phase2_export.build_phase2_export( + _snapshot(result={"stems": []}, profile_id="stem_summary") + ) + ) + + def test_missing_measurement_result_degrades_to_null_phase1(self) -> None: + envelope = phase2_export.build_phase2_export( + _snapshot(result=PHASE2_RESULT, measurement_result=None) + ) + self.assertIsNotNone(envelope) + self.assertIsNone(envelope["phase1"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/backend/tests/test_recommendation_evaluation.py b/apps/backend/tests/test_recommendation_evaluation.py index 90c39c7c..d1c3fcc3 100644 --- a/apps/backend/tests/test_recommendation_evaluation.py +++ b/apps/backend/tests/test_recommendation_evaluation.py @@ -363,6 +363,25 @@ def test_normalize_phase2(self): self.assertEqual(recs[0].citations, ("kickDetail.fundamentalHz",)) self.assertEqual(recs[1].domain, "master") # mix chain defaults to master + def test_coerce_phase2_payload_unwraps_export_envelope(self): + """A phase2-export.v1 file (GET .../export/phase2) feeds --phase2 directly.""" + bare = {"abletonRecommendations": [{"device": "Operator"}]} + envelope = { + "schemaVersion": "phase2-export.v1", + "runId": "run-123", + "phase1": {"bpm": 130.0}, + "phase2": bare, + } + self.assertEqual(rev.coerce_phase2_payload(envelope), bare) + + def test_coerce_phase2_payload_passes_bare_result_through(self): + bare = {"abletonRecommendations": [], "schemaVersion": None} + self.assertEqual(rev.coerce_phase2_payload(bare), bare) + # A result that happens to carry an unrelated schemaVersion string is + # NOT unwrapped — only the phase2-export.* envelope is. + other = {"schemaVersion": "interpretation.v2", "phase2": {"x": 1}} + self.assertEqual(rev.coerce_phase2_payload(other), other) + class AuthoredFixtureCatalogTests(unittest.TestCase): """Regression: every committed example spec is catalog-valid.""" diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index 1e3c1a75..94512617 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -5241,6 +5241,122 @@ def test_unknown_run_returns_404(self) -> None: self.assertEqual(payload["error"]["code"], "RUN_NOT_FOUND") +class Phase2ExportRouteTests(unittest.TestCase): + """Route tests for GET /api/analysis-runs/{run_id}/export/phase2. + + The envelope projection logic is covered by tests/test_phase2_export.py. + These tests verify only the HTTP shell: status codes, error envelopes, + headers, and that the route pulls the stored interpretation attempt out + of the run snapshot. + """ + + PHASE2_RESULT = { + "trackCharacter": "Driving techno", + "abletonRecommendations": [ + { + "device": "Glue Compressor", + "parameter": "Threshold", + "value": "-12 dB", + "phase1Fields": ["lufsIntegrated"], + } + ], + } + + def _decode_json_response(self, response) -> dict: + return json.loads(response.body.decode("utf-8")) + + def _make_run(self, runtime, *, with_interpretation: bool) -> str: + created = runtime.create_run( + filename="track.mp3", + content=b"fake-audio", + mime_type="audio/mpeg", + pitch_note_mode="off", + pitch_note_backend="auto", + interpretation_mode="sync" if with_interpretation else "off", + interpretation_profile="producer_summary", + interpretation_model=None, + ) + run_id = created["runId"] + runtime.complete_measurement( + run_id, + payload={"bpm": 130.0, "lufsIntegrated": -9.2}, + provenance={"schemaVersion": "measurement.v1", "engineVersion": "analyze.py"}, + diagnostics={"backendDurationMs": 1000}, + ) + if with_interpretation: + attempt_id = runtime.create_interpretation_attempt( + run_id, + profile_id="producer_summary", + model_name="gemini-2.5-flash", + ) + runtime.complete_interpretation_attempt( + attempt_id, + result=self.PHASE2_RESULT, + provenance={ + "schemaVersion": "interpretation.v2", + "profileId": "producer_summary", + "modelName": "gemini-2.5-flash", + }, + diagnostics={ + "validationWarnings": [ + {"code": "RECOMMENDATION_UNVERIFIED", "path": "x"} + ] + }, + ) + return run_id + + def test_returns_envelope_with_attachment_disposition(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_phase2_export_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + run_id = self._make_run(runtime, with_interpretation=True) + + with patch.object(server, "get_analysis_runtime", return_value=runtime): + response = asyncio.run(server.export_run_phase2(run_id)) + + self.assertEqual(response.status_code, 200) + payload = self._decode_json_response(response) + self.assertEqual(payload["schemaVersion"], "phase2-export.v1") + self.assertEqual(payload["runId"], run_id) + self.assertEqual(payload["phase2"], self.PHASE2_RESULT) + self.assertEqual(payload["phase1"]["bpm"], 130.0) + self.assertEqual( + payload["validationWarnings"], + [{"code": "RECOMMENDATION_UNVERIFIED", "path": "x"}], + ) + self.assertEqual(payload["provenance"]["profileId"], "producer_summary") + disposition = response.headers.get("content-disposition", "") + self.assertIn(f"{run_id}_phase2_export.json", disposition) + + def test_run_without_interpretation_returns_404(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_phase2_export_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + run_id = self._make_run(runtime, with_interpretation=False) + + with patch.object(server, "get_analysis_runtime", return_value=runtime): + response = asyncio.run(server.export_run_phase2(run_id)) + + self.assertEqual(response.status_code, 404) + payload = self._decode_json_response(response) + self.assertEqual(payload["error"]["code"], "PHASE2_EXPORT_NOT_AVAILABLE") + self.assertFalse(payload["error"]["retryable"]) + + def test_unknown_run_returns_404(self) -> None: + from analysis_runtime import AnalysisRuntime + + with tempfile.TemporaryDirectory(prefix="asa_phase2_export_") as temp_dir: + runtime = AnalysisRuntime(Path(temp_dir) / "runtime") + with patch.object(server, "get_analysis_runtime", return_value=runtime): + response = asyncio.run(server.export_run_phase2("does-not-exist")) + + self.assertEqual(response.status_code, 404) + payload = self._decode_json_response(response) + self.assertEqual(payload["error"]["code"], "RUN_NOT_FOUND") + + class TranscriptionPianorollRouteTests(unittest.TestCase): """Route tests for GET /api/analysis-runs/{run_id}/transcription/pianoroll. diff --git a/docs/ASA_ABLETON_BOUNDARY.md b/docs/ASA_ABLETON_BOUNDARY.md new file mode 100644 index 00000000..5de96d42 --- /dev/null +++ b/docs/ASA_ABLETON_BOUNDARY.md @@ -0,0 +1,113 @@ +# The ASA ↔ asa-ableton Boundary + +**Status:** living contract doc. Backed by `apps/backend/phase2_export.py`, +`GET /api/analysis-runs/{run_id}/export/phase2`, and the envelope-freeze test in +`apps/backend/tests/test_phase2_export.py`. + +## The two repos + +- **ASA** (this repo, `slittycode/ableton-sonic-analyzer`) — measures a track + deterministically (Phase 1) and produces AI device recommendations grounded in + those measurements (Phase 2). Its product output, for downstream tooling, is a + `producer_summary` interpretation result: device cards across + `abletonRecommendations`, `mixAndMasterChain`, and `secretSauce.workflowSteps`, + plus the frozen `recommendations.v1` projection (ADR 0003) attached as a + `recommendations` field. +- **asa-ableton** (sibling repo, `slittycode/asa-ableton`) — stdlib-only Python + that turns ASA's Phase 2 recommendations into an openable Live 12 `.als` + starter set. It is a *consumer*: it never talks to ASA's server at runtime and + shares no code with ASA. The boundary is a file. + +The two repos are deliberately **file-coupled, not code-coupled**. asa-ableton's +fidelity gates (Gate α in its `docs/GATE_ALPHA.md`) run on a saved ASA +interpretation JSON committed as a fixture on its side. The same file shape also +feeds ASA's own recommendation-proof harness +(`apps/backend/scripts/evaluate_recommendations.py --phase2`, GOAL.md). + +## The handoff artifact: `phase2-export.v1` + +One HTTP call produces the complete, self-describing handoff file: + +```bash +curl http://127.0.0.1:8100/api/analysis-runs//export/phase2 \ + -o .phase2-export.json +``` + +(Local profile needs no auth headers; hosted mode resolves ownership from the +usual `X-ASA-User-Id` header.) + +Envelope shape (key set frozen by `test_envelope_key_set_is_frozen` — additions +require a version bump): + +```json +{ + "schemaVersion": "phase2-export.v1", + "runId": "…", + "exportedAt": "ISO-8601", + "provenance": { "schemaVersion": "interpretation.v2", "profileId": "producer_summary", "modelName": "…", "promptVersion": "…", "groundedMeasurementRunId": "…", "…": "…" }, + "validationWarnings": [ { "code": "RECOMMENDATION_UNVERIFIED", "path": "…", "message": "…" } ], + "phase1": { "…full authoritative Phase 1 measurement payload…" }, + "phase2": { "…producer_summary interpretation result, verbatim…" } +} +``` + +Field semantics: + +1. **`phase2`** — the stored interpretation result exactly as ASA's validator + tail left it, including the additive `recommendations` field (the frozen + `recommendations.v1` contract). Nothing is rewritten or filtered at export + time. +2. **`phase1`** — the authoritative measurement payload the interpretation was + grounded on. Included so a consumer can independently resolve every cited + `phase1Fields` path (PURPOSE.md invariant #2) without a second request. + Phase 1 values always win over anything Phase 2 says (invariant #1). +3. **`validationWarnings`** — the full warn-and-keep trail (citation-existence, + semantic, and Live 12 catalogue-gate warnings). ASA never drops or rewrites + a flagged card; consumers decide what to do with flagged entries. +4. **`provenance`** — interpretation schema version, profile, model, prompt + version, and grounding IDs, verbatim from the run. + +Returns 404 `PHASE2_EXPORT_NOT_AVAILABLE` until a `producer_summary` +interpretation has completed with a result. Only `producer_summary` is +exportable — `stem_summary` carries no device cards. + +## Guidance for device-applying consumers (asa-ableton) + +- **Prefer `phase2.recommendations.recommendations`** (the `recommendations.v1` + entries) when you need normalized `{device, parameter, value, unit, range, + cited_measurements[]}` records: values are parsed to numbers where possible + and every entry is citation-gated. Fall back to the three raw card arrays for + prose context (`reason`, `trackContext`, ordering). +- **Dedupe before counting.** The raw arrays — and therefore the flat + `recommendations.v1` projection — can legitimately repeat a + `(device, parameter, value)` tuple across containers (a mix-chain card and a + workflow step may name the same move). asa-ableton's Gate α already observed + this inflating its skip-rate accounting (~60.7% raw vs ~50% deduped). + Deduplication is intentionally a consumer concern: ASA's contract is + warn-and-keep, never drop. +- **Treat flagged cards as flagged, not invalid.** A + `RECOMMENDATION_UNVERIFIED` warning means the source-extracted catalogue + could not confirm a device/parameter spelling — the card may still be a + correct UI-vocabulary spelling (see `UI_PARAMETER_ALIASES` in + `apps/backend/live12_catalogue.py`). + +## Relationship to other export surfaces + +- **UI "Export JSON"** (`track-analysis.json` from `AnalysisResults.tsx`) is a + looser, browser-side superset for humans: `{phase1, phase2, exportedAt}`, + without `provenance` or `validationWarnings`, and without a schema version. + The backend route is the canonical machine handoff; converging the UI export + onto `phase2-export.v1` is a known follow-up. +- **`evaluate_recommendations.py --phase2`** accepts either a bare + `Phase2Result` or a `phase2-export.v1` file + (`recommendation_evaluation.coerce_phase2_payload`), so the downloaded export + drops straight into a fixture dir as `phase2.json` for the GOAL.md campaign. + +## Stability promise + +`phase2-export.v1` follows the same policy as `recommendations.v1` (ADR 0003): +the key set above is frozen; new top-level fields require bumping the +`schemaVersion`. The *interior* of `phase1` and `phase2` is governed by their +own contracts (`phase1.v2` per ADR 0002, `interpretation.v2`/`recommendations.v1` +for Phase 2) and evolves with them — the envelope passes them through verbatim +and adds no transformation of its own.