diff --git a/CLAUDE.md b/CLAUDE.md index c8859091..779bb0f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,7 @@ npm run verify # lint + test:unit + build + test:smoke (ful npm run lint # TypeScript type-check only (no ESLint/Prettier) npm test # All Vitest tests (vitest.config.ts include: tests/**/*.test.ts) npm run test:unit # Vitest, restricted to tests/services/ (the unit subset) +npx playwright install chromium # first-time Playwright setup (run once from apps/ui/) npm run test:smoke # Playwright smoke suite (tests/smoke/, default config) npm run test:smoke:live-gemini # Playwright smoke against the real Gemini Files API (tests/smoke/upload-phase2-live-gemini.spec.ts) npm run test:e2e # Playwright full e2e suite (playwright.full.config.ts) @@ -186,7 +187,7 @@ Operational and one-shot scripts live in two places. They are not on the request 1. `dev.sh` — full-stack dev launcher (covered above). Covered by `apps/backend/tests/test_root_dev_script.py`. 2. `test-e2e.sh` / `test-e2e-integration.sh` — e2e harnesses (covered above). Covered by `apps/backend/tests/test_root_e2e_script.py`. 3. `calibrate_confidence.py` — threshold-sweep harness for the pitch / chord / sidechain detectors. Research-only. Its unit test lives in `scripts/tests/test_calibrate_confidence.py`. -4. `build_live12_catalogue.py` — regenerates `data/live12_catalogue.json` (the source-extracted Live 12 device/parameter catalogue) from the upstream `gluon/AbletonLive12_MIDIRemoteScripts` checkout. Static AST extraction; carries no proprietary code. Re-run when the upstream commit shifts or the schema bumps; the published `data/live12_catalogue.schema.json` validates the output. +4. `build_live12_catalogue.py` — regenerates `data/live12_catalogue.json` (the source-extracted Live 12 device/parameter catalogue) from the upstream `gluon/AbletonLive12_MIDIRemoteScripts` checkout. Static AST extraction; carries no proprietary code. Re-run when the upstream commit shifts or the schema bumps; the published `data/live12_catalogue.schema.json` validates the output. Invocation: `scripts/build_live12_catalogue.py --source /path/to/gluon/AbletonLive12_MIDIRemoteScripts --output data/live12_catalogue.json` (or set `SONIC_ANALYZER_LIVE12_SOURCE` env var to omit `--source`). `apps/backend/scripts/`: 1. `bootstrap.sh` — recreate the Python 3.11 venv (covered above). `dev.sh` here is a thin shim that `exec`s the repo-root `scripts/dev.sh` full-stack launcher. `bin/asa` script contracts (install, bootstrap, cleanup) are covered by `apps/backend/tests/test_bootstrap_scripts.py`. @@ -271,7 +272,7 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths 1. **`analyze.py`**: Pure DSP pipeline entry point. Runs as a subprocess invoked by `server.py`. Coordinates the split feature modules below. **Writes JSON to stdout, diagnostics to stderr** — this contract is load-bearing. 2. **`analyze_core.py`, `analyze_audio_io.py`, `analyze_detection.py`, `analyze_estimate.py`, `analyze_rhythm.py`, `analyze_segments.py`, `analyze_structure.py`, `analyze_transcription.py`, `analyze_fast.py`**: Feature modules. Loadouts: BPM/key/LUFS/stereo/spectral balance, rhythm/melody detail, segment boundaries, transcription. `analyze_fast.py` is the streamlined pipeline used by `--fast`. 3. **`server.py`**: FastAPI app and router composition. Routes are organized into `server_phase1.py`, `server_phase2.py`, `server_upload.py`, `server_samples.py`. Handles multipart uploads, invokes `analyze.py` (or worker), normalizes raw output into the `phase1` HTTP contract. -4. **`analysis_runtime.py`**: SQLite-backed run state and stage queue management. Run state in `.runtime/analysis_runs.sqlite3`; artifacts in `.runtime/artifacts/`. +4. **`analysis_runtime.py`**: SQLite-backed run state and stage queue management. Run state in `.runtime/analysis_runs.sqlite3`; artifacts in `.runtime/artifacts/`. New artifact-producing code calls `record_artifact` — do not write `.runtime/` paths directly (see also Tripwire #6 and `artifact_storage.py`). 5. **`worker.py`**: Dedicated worker-process entry point for hosted-style background stage execution. In `local` profile, work runs in-process; in `hosted` profile, this is the worker role. 6. **`runtime_profile.py`**: Switchboard for `local` vs `hosted` profile and `all` vs `api` vs `worker` process roles (env: `SONIC_ANALYZER_RUNTIME_PROFILE`, `SONIC_ANALYZER_PROCESS_ROLE`). 7. **`artifact_storage.py`**: Storage-service boundary. Today writes to local disk; the interface is designed so callers do not assume disk paths forever. @@ -295,7 +296,7 @@ Artifact access goes through `artifact_storage.py` rather than direct disk paths 23. **`recommendations_contract.py` + `schemas/recommendations.v1.schema.json`**: Frozen, versioned Phase 2 recommendation contract (ADR 0003). `project_recommendations` normalizes the three Phase 2 device-card arrays (`abletonRecommendations`, `mixAndMasterChain`, `secretSauce.workflowSteps`) into a flat `{device, parameter, value, unit, range, cited_measurements[]}` envelope (`version: "recommendations.v1"`); `validate_envelope` checks it against the committed JSON Schema via `jsonschema` (the real file, not a hand-rolled mirror — see ADR 0001's drift warning). Derived and additive — it never overrides Phase 1 (invariant #1) and admits ONLY cited cards (`cited_measurements.minItems: 1`); uncited cards stay in the raw arrays where the warn-and-keep catalogue gate flags them. `server.py` attaches the validated envelope to the producer_summary interpretation result as a `recommendations` field (degrades to absent on error). TS mirror: `RecommendationsContract` in [src/types/interpretation.ts](apps/ui/src/types/interpretation.ts). CI gate: `tests/test_recommendations_contract.py` (schema validity + projection-validates + round-trip + freeze). -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, while both 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. +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. 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. @@ -367,11 +368,14 @@ ASA_MSST_ROOT="" # path to the MSST-WebUI checkout (adde ASA_MSST_MODEL="scnet_4stem" # MSST model registry id in separation_backend._MSST_MODEL_REGISTRY. Default `scnet_4stem` (4-stem parity). `bs_roformer_vocals` is research/A-B-only (2-stem, leaves bass/drums empty). Unknown ids fall back to the default. ASA_MSST_MODEL_DIR="" # optional config/checkpoint root (MSST configs/ + pretrain/ layout); defaults to ASA_MSST_ROOT. ASA_MSST_DEVICE="" # optional device for MSST inference (auto|cpu|cuda|mps); defaults to auto. -ASA_PHASE2_PROVIDER="gemini" # Phase 2 interpretation provider: `gemini` (default, product path, unchanged) or `moss` to route the producer_summary interpretation to a self-hosted OpenMOSS MOSS-Audio FastAPI sidecar. Default-off experiment; both providers 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 (the GitHub repo's pyproject points at a non-existent LICENSE), so the sidecar ships no OpenMOSS code and the real-model path is a 501 stub — research-only, not promotable. See apps/backend/phase2_provider.py + docs/PHASE2_PROVIDER.md. +ASA_PHASE2_PROVIDER="gemini" # Phase 2 interpretation provider: `gemini` (default, product path, unchanged), `moss` to route the producer_summary interpretation to a self-hosted OpenMOSS MOSS-Audio FastAPI sidecar, or `claude` to run the local Claude Code CLI headless as a TEXT-ONLY interpreter (no audio sent; grounds on the prompt's embedded Phase 1 JSON; needs no GEMINI_API_KEY — rides the operator's existing Claude Code login). Default-off experiments; all providers flow through the identical parse/citation/catalogue validators. MOSS STEP ONE licence gate: MOSS-Audio *weights* are Apache-2.0 but the *modeling code* has no effective licence (the GitHub repo's pyproject points at a non-existent LICENSE), so the sidecar ships no OpenMOSS code and the real-model path is a 501 stub — research-only, not promotable. See apps/backend/phase2_provider.py + docs/PHASE2_PROVIDER.md. ASA_MOSS_SIDECAR_URL="http://127.0.0.1:8200" # base URL of the MOSS sidecar (apps/backend/moss_sidecar/app.py) when ASA_PHASE2_PROVIDER=moss. ASA_MOSS_SIDECAR_TIMEOUT_SECONDS="180" # per-request timeout for the MOSS sidecar call. ASA_MOSS_MODEL_ID="" # optional MOSS model id forwarded to the sidecar; defaults to the run's model_name. ASA_MOSS_SIDECAR_MODE="mock" # sidecar-side (its own venv): `mock` (default, deterministic schema-valid Phase-1-grounded output) or `model` (a 501 licence-gated stub that executes no OpenMOSS code). +ASA_CLAUDE_CLI="claude" # path to the Claude Code CLI binary used when ASA_PHASE2_PROVIDER=claude; defaults to `claude` on PATH. +ASA_CLAUDE_MODEL="" # optional model override for the claude provider (e.g. `sonnet`); empty = the CLI's default model. The subprocess runs sandboxed: --safe-mode, --tools "", --no-session-persistence, schema enforced via --json-schema. +ASA_CLAUDE_TIMEOUT_SECONDS="600" # per-call timeout for the claude provider subprocess (a full producer_summary prompt on the CLI default model measures ~6 min). ``` Phase 2 is gated by `VITE_ENABLE_PHASE2_GEMINI`. `GEMINI_API_KEY` is backend-only. `SONIC_ANALYZER_ADMIN_KEY` is backend-only and never exposed to clients. @@ -394,7 +398,7 @@ Things that look like normal code changes but silently break the contract. Most 1. **`print(...)` in [analyze.py](apps/backend/analyze.py) without `file=sys.stderr`.** Stdout is the JSON contract. Any stray print corrupts it and the server reports a parse error with no useful trace. The existing code is consistent about this — match the pattern (`print(f"[warn] ...", file=sys.stderr)`). 2. **Calling `analyze.py` as a subprocess without `--yes`.** The CLI prompts for confirmation when stdin is a TTY. Subprocess invocations must pass `--yes` or hang waiting for input. `server.py` already does; new callers must too. 3. **Renaming a field on only one side.** Python emits *camelCase* JSON directly (`bpmConfidence`, not `bpm_confidence`) — there is no conversion layer. A rename in [analyze.py](apps/backend/analyze.py) without a matching update in [src/types.ts](apps/ui/src/types.ts) is undetectable by either type system; the field just disappears from the UI. -4. **Adding a top-level key without updating `EXPECTED_TOP_LEVEL_KEYS`.** [tests/test_analyze.py](apps/backend/tests/test_analyze.py) holds a snapshot of every root key. New fields require updating that set *and* [JSON_SCHEMA.md](apps/backend/JSON_SCHEMA.md). The same test enforces that `--fast` only populates `FAST_MODE_POPULATED_FIELDS`. A change to *measured values* (not just keys) can also trip the golden-snapshot regression gate in [tests/test_phase1_golden.py](apps/backend/tests/test_phase1_golden.py) (fixture `tests/fixtures/golden/phase1_default.json`) — re-baseline it deliberately, never blindly. +4. **Adding a top-level key without updating `EXPECTED_TOP_LEVEL_KEYS`.** [tests/test_analyze.py](apps/backend/tests/test_analyze.py) holds a snapshot of every root key. New fields require updating that set *and* [JSON_SCHEMA.md](apps/backend/JSON_SCHEMA.md). The same test enforces that `--fast` only populates `FAST_MODE_POPULATED_FIELDS`. A change to *measured values* (not just keys) can also trip the golden-snapshot regression gate in [tests/test_phase1_golden.py](apps/backend/tests/test_phase1_golden.py) (fixture `tests/fixtures/golden/phase1_default.json`) — re-baseline it deliberately, never blindly (from `apps/backend/`): `UPDATE_PHASE1_GOLDEN=1 ./venv/bin/python -m unittest tests.test_phase1_golden` 5. **Using `document` or `window` in `tests/services/`.** Vitest runs in `node`, not `jsdom`. Service-layer tests are pure logic; if you need DOM, it's a Playwright test in `tests/smoke/` instead. 6. **Hard-coding `Path(...)` for artifacts in new code.** Artifact access must go through [artifact_storage.py](apps/backend/artifact_storage.py). Direct paths work in `local` profile and break silently in `hosted`. 7. **Editing `apps/ui/.env` and expecting `dev.sh` to honor it.** `dev.sh` reads `apps/ui/.env` but *overrides* `VITE_API_BASE_URL` for the spawned UI process so stale `.env` files don't break the stack. To point the UI at a non-canonical backend, edit `dev.sh` or run the UI directly with the env var on the command line. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 5beed8d3..04f2f9d1 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -80,8 +80,8 @@ python3.11 -m venv venv - Run one test module: ```bash -./venv/bin/python -m unittest tests/test_server.py -./venv/bin/python -m unittest tests/test_analyze.py +./venv/bin/python -m unittest tests.test_server +./venv/bin/python -m unittest tests.test_analyze ``` - Run one test class: diff --git a/apps/backend/phase2_provider.py b/apps/backend/phase2_provider.py index 812d6ca5..0f0bc7d1 100644 --- a/apps/backend/phase2_provider.py +++ b/apps/backend/phase2_provider.py @@ -12,11 +12,16 @@ Selection is env-gated and **default-off**:: - ASA_PHASE2_PROVIDER = "gemini" (default) | "moss" + ASA_PHASE2_PROVIDER = "gemini" (default) | "moss" | "claude" ``resolve_external_phase2_provider()`` returns ``None`` for the Gemini default (meaning: use ``server.py``'s native Gemini path, byte-for-byte unchanged) and a -``MossSidecarProvider`` only when ``moss`` is explicitly selected. The MOSS path +``MossSidecarProvider`` / ``ClaudeCliProvider`` only when ``moss`` / ``claude`` +is explicitly selected. The ``claude`` provider runs the operator's local +Claude Code CLI headless and is **text-only**: it grounds the interpretation in +the prompt's embedded Phase 1 JSON and never receives the audio, so it needs no +``GEMINI_API_KEY`` and no network credits beyond the operator's existing Claude +subscription. The MOSS path applies to the ``producer_summary`` profile only (the recommendation-emitting interpretation); the ``stem_summary`` path stays on Gemini. @@ -39,10 +44,15 @@ DEFAULT_PHASE2_PROVIDER = "gemini" -SUPPORTED_PHASE2_PROVIDERS = ("gemini", "moss") +SUPPORTED_PHASE2_PROVIDERS = ("gemini", "moss", "claude") _DEFAULT_SIDECAR_URL = "http://127.0.0.1:8200" _DEFAULT_SIDECAR_TIMEOUT_SECONDS = 180 + +_DEFAULT_CLAUDE_CLI = "claude" +# A full producer_summary prompt (~240k chars) on the CLI's default model was +# measured at ~6 minutes end-to-end; 300s would kill legitimate calls. +_DEFAULT_CLAUDE_TIMEOUT_SECONDS = 600 # Mirror server.py's INLINE_SIZE_LIMIT intent: above this we do not inline-base64 # the audio into the JSON body. The sidecar/model path is a default-off # experiment, so the simple JSON+base64 transport is acceptable for the small @@ -233,6 +243,213 @@ def generate(self, request: Phase2ProviderRequest) -> Phase2ProviderResponse: ) +_GEMINI_TO_JSON_SCHEMA_TYPES = { + "OBJECT": "object", + "ARRAY": "array", + "STRING": "string", + "NUMBER": "number", + "INTEGER": "integer", + "BOOLEAN": "boolean", +} + + +def _gemini_schema_to_json_schema(node: Any) -> Any: + """Convert the Gemini response-schema dialect to standard JSON Schema. + + The profile ``responseSchema`` constants use Gemini's uppercase type names + (``"OBJECT"``, ``"STRING"``, …) and OpenAPI-style ``nullable``; the Claude + CLI's ``--json-schema`` flag expects standard JSON Schema (lowercase types, + ``["string", "null"]`` unions). Everything else passes through unchanged. + """ + if isinstance(node, list): + return [_gemini_schema_to_json_schema(item) for item in node] + if not isinstance(node, dict): + return node + converted: dict[str, Any] = {} + nullable = bool(node.get("nullable")) + for key, value in node.items(): + if key == "nullable": + continue + if key == "type" and isinstance(value, str): + converted[key] = _GEMINI_TO_JSON_SCHEMA_TYPES.get(value, value.lower()) + else: + converted[key] = _gemini_schema_to_json_schema(value) + if nullable and isinstance(converted.get("type"), str): + converted["type"] = [converted["type"], "null"] + return converted + + +def _strip_code_fences(text: str) -> str: + """Remove a single wrapping ``` / ```json fence pair, if present.""" + stripped = text.strip() + if not stripped.startswith("```"): + return stripped + first_newline = stripped.find("\n") + if first_newline == -1: + return stripped + body = stripped[first_newline + 1 :] + if body.rstrip().endswith("```"): + body = body.rstrip()[: -len("```")] + return body.strip() + + +class ClaudeCliProvider: + """Text-only Phase 2 provider running the local Claude Code CLI headless. + + Unlike the Gemini path (audio + prompt), this provider sends ONLY the + prompt — which already embeds the authoritative Phase 1 JSON and the + Live 12 device catalogue — so the interpretation is grounded purely in + measurements; the request's audio fields are deliberately ignored. This is + by design, not a limitation: PURPOSE.md invariant #1 makes Phase 1 ground + truth, and the provider seam already documents audio as additive. + + The subprocess is sandboxed and deterministic in capability: ``--safe-mode`` + disables the operator's plugins/hooks/MCP servers/CLAUDE.md (a probe showed + a default invocation loads all of them), ``--tools ""`` disables tool use, + and ``--no-session-persistence`` leaves no session behind. The profile's + Gemini-dialect ``response_schema`` is converted to JSON Schema and enforced + via ``--json-schema``; the CLI's validated ``structured_output`` (or the raw + ``result`` text as fallback) is returned to ``server.py``'s shared + parse/citation/catalogue tail — identical handling to Gemini output. + + Auth rides on the operator's existing Claude Code login; no ``GEMINI_API_KEY`` + and no separate API credits are required. Default-off, mirroring MOSS. + """ + + name = "claude" + + def __init__( + self, + *, + cli_path: str = _DEFAULT_CLAUDE_CLI, + timeout_seconds: float = _DEFAULT_CLAUDE_TIMEOUT_SECONDS, + model: str | None = None, + ) -> None: + self.cli_path = cli_path + self.timeout_seconds = timeout_seconds + self.model = model + + @classmethod + def from_env(cls) -> "ClaudeCliProvider": + return cls( + cli_path=os.getenv("ASA_CLAUDE_CLI", "").strip() or _DEFAULT_CLAUDE_CLI, + timeout_seconds=float( + os.getenv("ASA_CLAUDE_TIMEOUT_SECONDS", str(_DEFAULT_CLAUDE_TIMEOUT_SECONDS)) + ), + model=os.getenv("ASA_CLAUDE_MODEL", "").strip() or None, + ) + + def _build_command(self, request: Phase2ProviderRequest) -> list[str]: + command = [ + self.cli_path, + "-p", + "--output-format", + "json", + "--safe-mode", + "--tools", + "", + "--no-session-persistence", + "--json-schema", + json.dumps(_gemini_schema_to_json_schema(request.response_schema)), + ] + if self.model: + command.extend(["--model", self.model]) + return command + + def _extract_result_text(self, stdout: str) -> str | None: + try: + payload = json.loads(stdout) + except ValueError as exc: + snippet = stdout[:200] + raise Phase2ProviderError( + f"Claude CLI returned non-JSON output: {exc}; head: {snippet!r}", + error_code="CLAUDE_CLI_BAD_OUTPUT", + ) from exc + + # --output-format json emits either a single result object or an array + # of events whose last "type":"result" entry carries the outcome. + events = payload if isinstance(payload, list) else [payload] + result_event = None + for event in events: + if isinstance(event, dict) and event.get("type") == "result": + result_event = event + if result_event is None: + raise Phase2ProviderError( + "Claude CLI output contained no result event.", + error_code="CLAUDE_CLI_BAD_OUTPUT", + ) + if result_event.get("is_error"): + raise Phase2ProviderError( + f"Claude CLI reported an error result: {str(result_event.get('result'))[:200]}", + error_code="CLAUDE_CLI_FAILED", + ) + + structured = result_event.get("structured_output") + if isinstance(structured, dict): + return json.dumps(structured) + raw = result_event.get("result") + if isinstance(raw, str) and raw.strip(): + # Fallback when schema enforcement did not produce a structured + # object: hand the fence-stripped text to the shared parse/salvage + # tail, same as a Gemini text response. + return _strip_code_fences(raw) + return None + + def generate(self, request: Phase2ProviderRequest) -> Phase2ProviderResponse: + import subprocess # stdlib; imported lazily to keep module import light + + command = self._build_command(request) + try: + completed = subprocess.run( + command, + input=request.prompt, + capture_output=True, + text=True, + timeout=self.timeout_seconds, + ) + except FileNotFoundError as exc: + raise Phase2ProviderError( + f"Claude CLI binary not found at {self.cli_path!r}. Install Claude Code " + "or set ASA_CLAUDE_CLI to its path.", + error_code="CLAUDE_CLI_UNAVAILABLE", + retryable=False, + ) from exc + except subprocess.TimeoutExpired as exc: + raise Phase2ProviderError( + f"Claude CLI timed out after {self.timeout_seconds:.0f}s.", + error_code="CLAUDE_CLI_TIMEOUT", + ) from exc + + if completed.returncode != 0: + # In --output-format json mode the CLI reports failures as a + # result event on STDOUT (is_error: true) and often leaves stderr + # empty — surface that diagnostic instead of discarding it. + detail = (completed.stderr or "").strip() + try: + payload = json.loads(completed.stdout or "") + events = payload if isinstance(payload, list) else [payload] + for event in events: + if isinstance(event, dict) and event.get("type") == "result": + detail = str(event.get("result") or event.get("subtype") or detail) + except ValueError: + if not detail: + detail = (completed.stdout or "").strip() + raise Phase2ProviderError( + f"Claude CLI exited with code {completed.returncode}: {detail[:300]}", + error_code="CLAUDE_CLI_FAILED", + ) + + text = self._extract_result_text(completed.stdout) + flags = ["text-only"] + if self.model: + flags.append(f"claude-model:{self.model}") + return Phase2ProviderResponse( + text=text, + flags=flags, + message_suffix="Claude CLI interpretation complete (measurement-grounded, no audio).", + ) + + def resolve_phase2_provider_name() -> str: """The configured provider id, defaulting to ``gemini``. @@ -247,8 +464,11 @@ def resolve_external_phase2_provider() -> Phase2Provider | None: """Return a non-Gemini provider, or ``None`` to use server.py's native Gemini. ``None`` is the default — the product Gemini path is left entirely untouched - unless ``ASA_PHASE2_PROVIDER=moss`` is explicitly set. + unless ``ASA_PHASE2_PROVIDER=moss`` or ``=claude`` is explicitly set. """ - if resolve_phase2_provider_name() == "moss": + provider_name = resolve_phase2_provider_name() + if provider_name == "moss": return MossSidecarProvider.from_env() + if provider_name == "claude": + return ClaudeCliProvider.from_env() return None diff --git a/apps/backend/tests/test_phase2_provider.py b/apps/backend/tests/test_phase2_provider.py index 65396c95..2fa12ad8 100644 --- a/apps/backend/tests/test_phase2_provider.py +++ b/apps/backend/tests/test_phase2_provider.py @@ -15,9 +15,11 @@ import phase2_provider_evaluation as evalmod from moss_sidecar.mock_interpreter import build_mock_phase2_result from phase2_provider import ( + ClaudeCliProvider, MossSidecarProvider, Phase2ProviderError, Phase2ProviderRequest, + _gemini_schema_to_json_schema, resolve_external_phase2_provider, resolve_phase2_provider_name, ) @@ -57,6 +59,26 @@ def test_moss_selected(self): self.assertIsInstance(provider, MossSidecarProvider) self.assertEqual(provider.name, "moss") + def test_claude_selected(self): + with mock.patch.dict(os.environ, {"ASA_PHASE2_PROVIDER": "claude"}, clear=True): + self.assertEqual(resolve_phase2_provider_name(), "claude") + provider = resolve_external_phase2_provider() + self.assertIsInstance(provider, ClaudeCliProvider) + self.assertEqual(provider.name, "claude") + + def test_claude_env_config(self): + env = { + "ASA_PHASE2_PROVIDER": "claude", + "ASA_CLAUDE_CLI": "/opt/bin/claude", + "ASA_CLAUDE_MODEL": "sonnet", + "ASA_CLAUDE_TIMEOUT_SECONDS": "120", + } + with mock.patch.dict(os.environ, env, clear=True): + provider = resolve_external_phase2_provider() + self.assertEqual(provider.cli_path, "/opt/bin/claude") + self.assertEqual(provider.model, "sonnet") + self.assertEqual(provider.timeout_seconds, 120.0) + class MockInterpreterContractTests(unittest.TestCase): def test_mock_is_schema_valid(self): @@ -145,6 +167,170 @@ def test_network_error_raises_retryable(self): self.assertTrue(ctx.exception.retryable) +def _claude_cli_stdout(structured=None, raw=None, is_error=False): + """Build a Claude CLI --output-format json stdout: event array ending in a result.""" + return json.dumps([ + {"type": "system", "subtype": "init"}, + { + "type": "result", + "subtype": "success" if not is_error else "error", + "is_error": is_error, + "result": raw, + "structured_output": structured, + }, + ]) + + +def _completed(returncode=0, stdout="", stderr=""): + import subprocess + + return subprocess.CompletedProcess(args=["claude"], returncode=returncode, stdout=stdout, stderr=stderr) + + +class GeminiSchemaConversionTests(unittest.TestCase): + def test_uppercase_types_lowered(self): + gemini = {"type": "OBJECT", "properties": {"name": {"type": "STRING"}}, "required": ["name"]} + converted = _gemini_schema_to_json_schema(gemini) + self.assertEqual(converted["type"], "object") + self.assertEqual(converted["properties"]["name"]["type"], "string") + self.assertEqual(converted["required"], ["name"]) + + def test_nested_arrays_and_items(self): + gemini = {"type": "ARRAY", "items": {"type": "OBJECT", "properties": {"n": {"type": "NUMBER"}}}} + converted = _gemini_schema_to_json_schema(gemini) + self.assertEqual(converted["items"]["properties"]["n"]["type"], "number") + + def test_nullable_becomes_type_union(self): + gemini = {"type": "STRING", "nullable": True} + converted = _gemini_schema_to_json_schema(gemini) + self.assertEqual(converted["type"], ["string", "null"]) + self.assertNotIn("nullable", converted) + + def test_real_phase2_schema_converts_cleanly(self): + from server_phase2 import PHASE2_RESPONSE_SCHEMA + + converted = _gemini_schema_to_json_schema(PHASE2_RESPONSE_SCHEMA) + serialized = json.dumps(converted) + self.assertNotIn('"OBJECT"', serialized) + self.assertNotIn('"STRING"', serialized) + self.assertNotIn('"nullable"', serialized) + + +class ClaudeCliProviderTests(unittest.TestCase): + def _request(self): + return Phase2ProviderRequest( + prompt="PROMPT", + response_schema={"type": "OBJECT", "properties": {}}, + phase1_result=_SAMPLE_PHASE1, + model_name="gemini-2.5-flash", + request_id="req-c1", + source_path="/does/not/matter.flac", + ) + + def test_success_prefers_structured_output(self): + result = build_mock_phase2_result(_SAMPLE_PHASE1) + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout=_claude_cli_stdout(structured=result))) as run: + response = provider.generate(self._request()) + self.assertEqual(json.loads(response.text), result) + # Prompt travels via stdin, not argv. + self.assertEqual(run.call_args.kwargs["input"], "PROMPT") + + def test_command_is_sandboxed_and_schema_enforced(self): + provider = ClaudeCliProvider(model="sonnet") + with mock.patch("subprocess.run", return_value=_completed(stdout=_claude_cli_stdout(structured={}))) as run: + provider.generate(self._request()) + command = run.call_args.args[0] + self.assertIn("--safe-mode", command) + self.assertIn("--no-session-persistence", command) + self.assertIn("--tools", command) + self.assertEqual(command[command.index("--tools") + 1], "") + schema_arg = command[command.index("--json-schema") + 1] + self.assertEqual(json.loads(schema_arg)["type"], "object") + self.assertEqual(command[command.index("--model") + 1], "sonnet") + + def test_model_flag_omitted_by_default(self): + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout=_claude_cli_stdout(structured={}))) as run: + provider.generate(self._request()) + self.assertNotIn("--model", run.call_args.args[0]) + + def test_raw_text_fallback_strips_fences(self): + raw = "```json\n{\"trackCharacter\": \"warm\"}\n```" + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout=_claude_cli_stdout(raw=raw))): + response = provider.generate(self._request()) + self.assertEqual(json.loads(response.text), {"trackCharacter": "warm"}) + + def test_empty_result_yields_skip_text(self): + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout=_claude_cli_stdout())): + response = provider.generate(self._request()) + self.assertIsNone(response.text) + + def test_error_result_raises(self): + provider = ClaudeCliProvider() + with mock.patch( + "subprocess.run", + return_value=_completed(stdout=_claude_cli_stdout(raw="rate limited", is_error=True)), + ): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertEqual(ctx.exception.error_code, "CLAUDE_CLI_FAILED") + + def test_nonzero_exit_raises_retryable(self): + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(returncode=1, stderr="boom")): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertEqual(ctx.exception.error_code, "CLAUDE_CLI_FAILED") + self.assertTrue(ctx.exception.retryable) + + def test_nonzero_exit_surfaces_stdout_error_event(self): + # JSON mode reports failures on stdout with empty stderr — the error + # message must carry the result event's text, not an empty snippet. + provider = ClaudeCliProvider() + stdout = _claude_cli_stdout(raw="API rate limit reached", is_error=True) + with mock.patch("subprocess.run", return_value=_completed(returncode=1, stdout=stdout)): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertIn("API rate limit reached", str(ctx.exception)) + + def test_missing_binary_not_retryable(self): + provider = ClaudeCliProvider(cli_path="/nope/claude") + with mock.patch("subprocess.run", side_effect=FileNotFoundError("no such file")): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertEqual(ctx.exception.error_code, "CLAUDE_CLI_UNAVAILABLE") + self.assertFalse(ctx.exception.retryable) + + def test_timeout_raises_retryable(self): + import subprocess + + provider = ClaudeCliProvider(timeout_seconds=1) + with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=1)): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertEqual(ctx.exception.error_code, "CLAUDE_CLI_TIMEOUT") + self.assertTrue(ctx.exception.retryable) + + def test_non_json_stdout_raises(self): + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout="Welcome to Claude!")): + with self.assertRaises(Phase2ProviderError) as ctx: + provider.generate(self._request()) + self.assertEqual(ctx.exception.error_code, "CLAUDE_CLI_BAD_OUTPUT") + + def test_single_result_object_supported(self): + # Older/newer CLI builds may emit a single result object, not an array. + result = build_mock_phase2_result(_SAMPLE_PHASE1) + single = json.dumps({"type": "result", "is_error": False, "structured_output": result, "result": "done"}) + provider = ClaudeCliProvider() + with mock.patch("subprocess.run", return_value=_completed(stdout=single)): + response = provider.generate(self._request()) + self.assertEqual(json.loads(response.text), result) + + class CitationEvalTests(unittest.TestCase): def _track(self): return evalmod.CorpusTrack(track_id="t1", manifest={}, phase1=_SAMPLE_PHASE1) @@ -257,6 +443,79 @@ def test_gemini_default_does_not_touch_sidecar(self): self.assertEqual(execution["errorCode"], "GEMINI_NOT_CONFIGURED") +class ClaudeBranchIntegrationTests(unittest.TestCase): + """Executes the server.py interpretation path through the claude branch. + + Mirrors ``MossBranchIntegrationTests``: proves the claude provider is + in-path, flows through the SAME shared parse/validate tail as Gemini, and + — critically for the no-Gemini-credits use case — works with NO + ``GEMINI_API_KEY`` set. Self-skips in a lightweight env (``import server`` + needs the full backend venv). + """ + + @classmethod + def setUpClass(cls): + try: + import server # noqa: F401 + except Exception as exc: # pragma: no cover - env-dependent + raise unittest.SkipTest(f"server import unavailable (needs full venv): {exc}") + + def test_claude_provider_flows_through_shared_tail_without_gemini_key(self): + import server + + result = build_mock_phase2_result(_SAMPLE_PHASE1) + stdout = _claude_cli_stdout(structured=result) + profile_config = server._resolve_interpretation_profile_config("producer_summary") + with mock.patch.dict( + os.environ, + {"ASA_PHASE2_PROVIDER": "claude", "GEMINI_API_KEY": ""}, + clear=False, + ), mock.patch("subprocess.run", return_value=_completed(stdout=stdout)) as run: + execution = server._run_interpretation_request_with_profile_config( + source_path=__file__, # audio is ignored by the text-only provider + filename="loop.flac", + file_size_bytes=123, + profile_id="producer_summary", + profile_config=profile_config, + measurement_result=_SAMPLE_PHASE1, + pitch_note_result=None, + grounding_metadata={}, + model_name="gemini-2.5-flash", + request_id="itest-claude-1", + ) + self.assertTrue(execution["ok"], execution) + self.assertIsNotNone(execution["interpretationResult"]) + self.assertTrue(_is_valid_phase2_shape(execution["interpretationResult"])) + timings = (execution.get("diagnostics") or {}).get("timings") or {} + self.assertIn("phase2-provider:claude", timings.get("flagsUsed", [])) + # The CLI received the built prompt on stdin — and that prompt embeds + # the authoritative Phase 1 JSON (the measurement-grounding contract). + prompt_sent = run.call_args.kwargs["input"] + self.assertIn('"bpm": 128.0', prompt_sent) + + def test_gemini_default_does_not_invoke_cli(self): + import server + + with mock.patch.dict( + os.environ, {"ASA_PHASE2_PROVIDER": "gemini", "GEMINI_API_KEY": ""}, clear=False + ), mock.patch("subprocess.run") as run: + execution = server._run_interpretation_request_with_profile_config( + source_path=__file__, + filename="loop.flac", + file_size_bytes=123, + profile_id="producer_summary", + profile_config=server._resolve_interpretation_profile_config("producer_summary"), + measurement_result=_SAMPLE_PHASE1, + pitch_note_result=None, + grounding_metadata={}, + model_name="gemini-2.5-flash", + request_id="itest-gem-2", + ) + run.assert_not_called() + self.assertFalse(execution["ok"]) + self.assertEqual(execution["errorCode"], "GEMINI_NOT_CONFIGURED") + + class ClassifySplitTests(unittest.TestCase): """The DoD's 'offline-good-enough vs Gemini-wins' threshold logic.""" diff --git a/apps/ui/AGENTS.md b/apps/ui/AGENTS.md index f2ea6c56..a865d87d 100644 --- a/apps/ui/AGENTS.md +++ b/apps/ui/AGENTS.md @@ -18,7 +18,7 @@ ## Environment And Setup -- Use Node 20+. +- Use Node 20 (per CI; no `engines` constraint in `package.json`). - Install dependencies: `npm install` - Create a local env file when needed: `cp .env.example .env` - Key env vars: diff --git a/audits/owner-assessment-2026-06-10.md b/audits/owner-assessment-2026-06-10.md new file mode 100644 index 00000000..186563ed --- /dev/null +++ b/audits/owner-assessment-2026-06-10.md @@ -0,0 +1,175 @@ +# Owner Assessment — Is ASA Real? (2026-06-10) + +Written for the project owner, in plain English, after a five-agent read-only audit of both +repos (`asa` and the sibling `~/code/projects/asa-ableton`). The question asked: *"Is this +actually going to do what it says? Is it novel or valuable, or just casual vibe coding? Can +it be honed into an ~80% legitimate analysis of a track and how to recreate it in Ableton?"* + +Audit evidence: workflow run `wf_d81cbf6c-3b1` (5 parallel auditors over the DSP engine, the +Phase 2 guardrail chain, the UI, the sibling repo, and test/CI health), cross-checked against +`GOAL.md`, `apps/backend/NEEDS.md`, and `apps/backend/RECOMMENDATION_VERDICT.md`. + +--- + +## The one-paragraph verdict + +This is not casual vibe code. The measurement half of the product is already legitimate — +real, established audio-analysis engines, correctly used, locked down by a deep test suite. +The advice half is architecturally sound and genuinely novel in one specific way (every +recommendation must cite the measurement that justifies it, checked by three validator +layers), but its *quality* — "do the recommended settings actually recreate the sound?" — is +honestly unproven, and the project itself documents exactly why and what's missing. The gap +between "impressive" and "proven" is one human step: rendering five already-written fixture +specs in Ableton Live 12. + +--- + +## What is verified real (code read, tests run, not taken on faith) + +1. **Phase 1 measurements use the real engines.** BPM, key, loudness (LUFS), true peak, + stereo and spectral balance come from Essentia (the Universitat Pompeu Fabra audio + research library); stem separation is torchaudio's Hybrid Demucs neural network; pitch + extraction is torchcrepe. Formulas spot-checked correct (e.g. true peak converts to dBTP + via the right math; EBU R128 compliance is gated by tests using the official EBU test + signals). +2. **The chain of custody is enforced, not just claimed.** A 531-line system prompt + constrains the AI interpreter; a 57-device / 558-parameter catalogue extracted from + Ableton's own Live 12 scripts checks device and parameter names; citation paths are + verified against the actual measurement payload on both backend and frontend; the + recommendation contract is a frozen, versioned JSON Schema. All of this runs on every + interpretation and is covered by passing tests (20 citation-path tests, 30 contract + tests, verified during this audit). +3. **The UI is a complete product surface.** Upload → analysis → results works end-to-end, + with ~34 feature components and the full pipeline rendered (measurements, spectrograms, + MIDI/Session Musician, recommendation cards, the consistency report, sample audition). +4. **The side program (`asa-ableton`) really works, narrowly.** It converts ASA's + recommendations into `.als` project files that open in Live 12 **without the repair + dialog** (verified eyes-on on Live 12.3.6 and 12.4.1), with 9/9 mapped parameters reading + back at the recommended values. 10 devices are mapped; 16+ common devices are knowingly + unsupported and skip-and-report rather than guess. +5. **Testing and CI are real.** ~1,180 backend test methods across 51 files, ~800 frontend + test assertions, a green CI streak, and the important contracts are regression-gated + (the Phase 1 schema snapshot, a golden measurement fixture, the recommendations.v1 + schema freeze, the cross-language MIME map). Heavy DSP tests skip on Linux CI and are + covered by the macOS nightly instead — a documented, deliberate trade. + +## What is real but heuristic (works, honesty enforced, accuracy unproven) + +1. The **style detectors** (acid / vocal / reverb / supersaw / kick / sidechain / genre) are + genuine signal processing — band energies, decay-slope fitting, envelope correlation — + but their thresholds and confidence weightings are hand-tuned, not validated against a + labeled corpus. They degrade honestly (null + low confidence rather than fake values), + which is the right behavior, but expect misfires on material far from electronic dance + genres. +2. The **validators warn, never block** — a deliberate choice (an earlier auto-rewrite + produced confidently-wrong output). Bad AI output reaches the screen flagged, not + removed. Reasonable design, worth knowing. + +## What is honestly unproven (and the project says so itself) + +1. **Whether the advice recovers the actual settings.** The recommendation-proof campaign + (`GOAL.md`) built the entire scoring machine: five fixture specs with known device + chains, a role/parameter/value-band scorer, a Gemini-vs-deterministic comparison. But the + scored numbers so far come from **synthetic numpy proxy renders**, not Ableton renders. + The provisional result: Gemini 0.227 aggregate vs deterministic 0.000 — Gemini wins + decisively on citation discipline and full-surface coverage, but the + "did-it-recover-the-real-settings" axes await real renders. +2. **A hard, documented limit:** naming the *source instrument* from finished audio is + impossible in principle — a sampled kick and an Operator kick can measure identically + (kick-domain role recall was 0.00 for both Gemini and the deterministic rules). The + advice will always be stronger on *processing* (EQ, compression, saturation, space) than + on "which synth made this." +3. **Per-recommendation verification badges** exist in the UI but render nothing until the + corpus has real renders (all confidence bands are `NONE` — the honest pre-render state). + +## Notable dead code + +`apps/ui/src/data/abletonDevices.ts` — a 309-line deterministic recommendation engine — is +imported nowhere. The product's recommendations come entirely from the AI interpreter. The +campaign docs already flag this (improve-it-or-call-it-a-baseline decision pending). + +## Is it novel / valuable? + +**The novelty is the chain of custody, and it's real.** Spectrum analyzers give numbers +without advice; AI chatbots give advice without numbers; commercial assistants (mastering +services, mix analyzers) don't name exact Ableton Live 12 devices with parameter values tied +to measurements of *your reference track*. ASA's specific combination — deterministic +measurement → cited, catalogue-checked, schema-frozen device recommendations → optional +`.als` export — does not exist as a mainstream product. The market risk isn't "this is +nothing"; it's that the advice layer's quality ceiling is unproven (see above) and the +maintenance surface is large for a single owner. + +## The "80% legitimate" question + +Split it in two: + +1. **Analysis: already there.** The measurement layer is as legitimate as the established + open-source state of the art, because it *is* the established open-source state of the + art, correctly assembled and regression-locked. Tempo, key, loudness, dynamics, stereo, + spectral balance, structure: trustworthy today (key detection on harmonically ambiguous + electronic material is the usual weak spot industry-wide; ASA flags low confidence). +2. **Recreation guidance: plausibly 60–80% useful today on processing-related advice, + unproven on exact values.** Every recommendation is at least grounded in real + measurements and checked against a real device catalogue — the live VTSS run scored + 22/22 recommendations with valid citations. What's missing is the loop that proves the + recommended *values* move a reconstruction toward the reference. That machinery is built + and idle, waiting on the five Ableton renders. + +## Shortest path to proof (in priority order) + +1. **Render the five fixture specs in Live 12** (48 kHz / 24-bit FLAC, checklist in + `apps/backend/tests/fixtures/recommendation_tracks/README.md`). This single session of + Ableton work converts the provisional verdict and the UI badges from proxy to + authoritative. Nothing else on the list substitutes for it. +2. **Use the new no-Gemini interpreter** (`ASA_PHASE2_PROVIDER=claude`, added 2026-06-10 — + see the addendum below and `docs/PHASE2_PROVIDER.md`) for free iteration: it produces + real Phase 2 JSON from measurements alone via the local Claude Code CLI, with no API + credits. +3. **Clear asa-ableton's two gates** — Gate α needs exactly one real Phase 2 JSON (item 2 + now provides this for free); Gate β is a 15-minute eyes-on check of Auto Filter's + Cutoff in Live. Then push the unpushed `feat/track-ii-gate-tooling` branch. +4. Longer-term: validate or retire the weakest detectors against the corpus as it grows, + and decide the fate of the dead deterministic engine. + +--- + +## Addendum: live result — Claude-as-interpreter on a real track (2026-06-10) + +`ASA_PHASE2_PROVIDER=claude` ran end-to-end on `VTSS – Can't Catch Me` (run +`094c2a14`, backend on 8100, **no `GEMINI_API_KEY` set**): real Phase 1 measurement → +the same 238k-character prompt the Gemini path builds → the local Claude Code CLI +(sandboxed: `--safe-mode`, no tools, schema-enforced via `--json-schema`) → the same +parse/citation/catalogue/recommendations.v1 validator tail. + +Scored by ASA's own guardrails, against the documented Gemini bar on this same track +(22/22 recs cited, custody 1.000 — `RECOMMENDATION_VERDICT.md`): + +1. **26/26 device cards cited, zero invented citation paths** (the citation-existence + validator raised nothing). The measurements-only interpreter met the chain-of-custody + bar Gemini set — supporting the design's claim that grounding comes from Phase 1 + JSON, not from listening to the audio. +2. **6 warn-and-keep catalogue annotations** (`RECOMMENDATION_UNVERIFIED` ×5, + `UNKNOWN_PARAMETER` ×1) — Operator/Scale/EQ-Eight parameter-name spellings outside + the curated catalogue. The guardrails surfaced them and kept the cards, exactly as + designed. +3. **recommendations.v1 envelope: 26 schema-valid entries.** Full production surface + covered (kick, acid bass, supersaw lead, drum bus/groove, FX returns, stereo, + master); all seven `sonicElements` populated; `authoritativeMeasurements` echoed + Phase 1 exactly (144.9 BPM, C Minor). +4. Interpretation latency ≈ 6.5 minutes on the CLI's default model (hence the 600 s + provider timeout default). + +**Second track (batch check):** `DJ Metatron Prompt 1.wav` (owner's own production) through +the same path: **30/30 cards cited, zero invented citation paths**, 30-entry envelope, +full-surface coverage, authoritative echo exact (141.1 BPM, D Major, 5/4). 7 warn-and-keep +catalogue annotations with the same fingerprint as VTSS (Operator / Scale / "Band 8 Gain" +spellings) — a consistent, fixable catalogue-coverage theme rather than random noise. +Combined: **56/56 recommendations cited across both real tracks.** + +**Bonus proof:** the same output was fed to the sibling repo's Gate α fidelity harness +(`asa-ableton verify`) — its first-ever real (non-synthetic) input, previously blocked +on Gemini credits. Result: **11/11 applied parameters landed in a structurally valid +`.als`, 0 mismatched; real skip-rate 60.7%** (≈50% excluding cross-container duplicate +cards), driven by the 10-device fragment library (Operator and Echo are the top +coverage gaps). Recorded in `asa-ableton/docs/GATE_ALPHA.md` with the source JSON +committed as a fixture. diff --git a/docs/PHASE2_PROVIDER.md b/docs/PHASE2_PROVIDER.md index 6d53c78b..e78fe848 100644 --- a/docs/PHASE2_PROVIDER.md +++ b/docs/PHASE2_PROVIDER.md @@ -1,4 +1,47 @@ -# Phase 2 Provider Abstraction (Gemini ↔ MOSS) — STEP ONE Licence Gate + Build +# Phase 2 Provider Abstraction (Gemini ↔ MOSS ↔ Claude) — STEP ONE Licence Gate + Build + +> **2026-06-10 addendum — `claude` provider.** A third provider landed: +> `ASA_PHASE2_PROVIDER=claude` routes the interpretation to `ClaudeCliProvider` +> (`apps/backend/phase2_provider.py`), which runs the operator's local +> **Claude Code CLI** headless. Properties: +> 1. **Text-only by design.** The CLI receives ONLY the built prompt (which +> already embeds the authoritative Phase 1 JSON + the Live 12 device +> catalogue) on stdin; the audio file is never sent. This is the +> measurements-grounded advisor mode the provider seam always documented as +> valid ("audio is additive"). +> 2. **Sandboxed subprocess.** `--safe-mode` (no plugins/hooks/MCP/CLAUDE.md — +> a live probe showed a default invocation loads the operator's entire +> extension stack), `--tools ""` (no tool use), `--no-session-persistence`. +> 3. **Schema-enforced output.** The profile's Gemini-dialect `responseSchema` +> is converted to standard JSON Schema (`_gemini_schema_to_json_schema`) and +> enforced via the CLI's `--json-schema`; the validated `structured_output` +> is preferred, with fence-stripped `result` text as fallback. Either way the +> output flows through the SAME shared parse / citation / catalogue / +> `recommendations.v1` tail as Gemini. +> 4. **No Gemini key, no API credits.** Auth rides the operator's existing +> Claude Code login. Useful when Gemini credits are exhausted, and it +> produces real Phase 2 JSON for research gates (e.g. asa-ableton Gate α). +> 5. **Default-off**, like MOSS: `gemini` remains the product default; unknown +> env values still degrade to Gemini. Env: `ASA_CLAUDE_CLI`, +> `ASA_CLAUDE_MODEL`, `ASA_CLAUDE_TIMEOUT_SECONDS`. +> 6. No licence gate applies (no third-party model code is executed; the CLI is +> the operator's own installed tool), but it inherits the same "research / +> operator convenience, not the promoted product path" status until its +> output quality is scored against the recommendation corpus. +> 7. **Provider policy (explicit).** Providers are *selectable and mutually +> exclusive per interpretation*: exactly one of `gemini | moss | claude` +> produces a given result. There is NO automatic fallback between providers +> and NO dual-provider/ensemble review — if such a mode is ever wanted it is +> a separate future design (disagreement handling, latency, Gemini cost). +> Gemini remains implemented, available, and the default; the `claude` route +> exists so the owner can run the complete advice path with no Gemini key +> and no Gemini spend, and is not contingent on out-scoring Gemini. +> Deployment caveat: `claude` rides a *locally logged-in* CLI, so it serves +> the operator's own machine only — it is not a substitute for an API-backed +> provider for other users. +> Tests: `tests/test_phase2_provider.py` (`ClaudeCliProviderTests`, +> `GeminiSchemaConversionTests`, `ClaudeBranchIntegrationTests` — the latter +> proves the no-`GEMINI_API_KEY` path end-to-end through the shared tail). Status: **STEP ONE complete — split verdict (weights clean, code unlicensed). Maintainer chose to build the default-off research experiment (option A). The