From 8a424acf61f2691678e84bf18275512aef1ebcf8 Mon Sep 17 00:00:00 2001 From: slitty-codes Date: Wed, 1 Apr 2026 05:06:43 +1300 Subject: [PATCH] fix: repair abbreviated return names in phase2 trackContext Gemini frequently abbreviates return track names in trackContext fields (e.g. "Return:Reverb" instead of "Return:Long Reverb"), causing UNKNOWN_TRACK_CONTEXT warnings. Add a deterministic repair pass that fuzzy-matches abbreviated names against declared routingBlueprint returns via case-insensitive and substring matching, and tighten the prompt to prevent the mismatch. Co-Authored-By: Claude Opus 4.6 --- apps/backend/prompts/phase2_system.txt | 3 +- apps/backend/server.py | 84 ++++++++++++++++++++++++ apps/backend/tests/test_server.py | 89 ++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/apps/backend/prompts/phase2_system.txt b/apps/backend/prompts/phase2_system.txt index cf62f211..29d5d449 100644 --- a/apps/backend/prompts/phase2_system.txt +++ b/apps/backend/prompts/phase2_system.txt @@ -27,7 +27,8 @@ LIVE 12 OUTPUT POLICY - trackContext to one of: - an exact trackLayout.name value - Master - - Return: + - Return: where is the EXACT string from routingBlueprint.returns[].name + - CRITICAL: Every Return: trackContext MUST use a name that appears verbatim in your routingBlueprint.returns array. Do not abbreviate, shorten, or paraphrase return names. If you declared a return named "Long Reverb", use Return:Long Reverb — never Return:Reverb. - For return tracks, use the exact format Return: with no space after the colon. - workflowStage to one of: - PROJECT_SETUP diff --git a/apps/backend/server.py b/apps/backend/server.py index 29c495ba..a8c77e9e 100644 --- a/apps/backend/server.py +++ b/apps/backend/server.py @@ -3427,6 +3427,71 @@ def _normalize_track_context_value( ] +def _declared_return_names(data: dict[str, Any]) -> list[str]: + routing_blueprint = _as_record(data.get("routingBlueprint")) + returns = routing_blueprint.get("returns") if routing_blueprint else None + if not isinstance(returns, list): + return [] + names: list[str] = [] + for item in returns: + record = _as_record(item) + if record and _is_str(record.get("name")): + names.append(record["name"]) + return names + + +def _repair_return_track_context( + value: str, + *, + path: str, + declared_returns: list[str], +) -> tuple[str, list[dict[str, Any]]]: + if not isinstance(value, str) or not value.startswith("Return:"): + return value, [] + suffix = value[len("Return:"):] + if not suffix: + return value, [] + exact_set = {f"Return:{name}" for name in declared_returns} + if value in exact_set: + return value, [] + # Case-insensitive exact match + suffix_lower = suffix.lower() + ci_matches = [name for name in declared_returns if suffix_lower == name.lower()] + if len(ci_matches) == 1: + repaired = f"Return:{ci_matches[0]}" + return repaired, [ + _build_phase2_validation_warning( + code="COERCED_TRACK_CONTEXT", + path=path, + message=( + f"Coerced trackContext '{value}' to '{repaired}' by matching " + "against declared routingBlueprint return names." + ), + original_value=value, + coerced_value=repaired, + ) + ] + # Substring match (suffix is a substring of a declared return name) + sub_matches = [ + name for name in declared_returns if suffix_lower in name.lower() + ] + if len(sub_matches) == 1: + repaired = f"Return:{sub_matches[0]}" + return repaired, [ + _build_phase2_validation_warning( + code="COERCED_TRACK_CONTEXT", + path=path, + message=( + f"Coerced trackContext '{value}' to '{repaired}' by matching " + "against declared routingBlueprint return names." + ), + original_value=value, + coerced_value=repaired, + ) + ] + return value, [] + + def _salvage_phase2_array_items( items: Any, *, @@ -3471,6 +3536,7 @@ def _normalize_and_salvage_phase2_result( normalized = dict(data) warnings: list[dict[str, Any]] = [] emptied_required_arrays: set[str] = set() + declared_returns = _declared_return_names(data) recommendations = normalized.get("abletonRecommendations") if isinstance(recommendations, list): @@ -3486,9 +3552,15 @@ def _normalize_and_salvage_phase2_result( normalized_record.get("trackContext"), path=f"abletonRecommendations[{index}].trackContext", ) + track_context, repair_warnings = _repair_return_track_context( + track_context, + path=f"abletonRecommendations[{index}].trackContext", + declared_returns=declared_returns, + ) normalized_item = dict(normalized_record) normalized_item["trackContext"] = track_context warnings.extend(track_context_warnings) + warnings.extend(repair_warnings) normalized_recommendations.append(normalized_item) warnings.extend(item_warnings) normalized["abletonRecommendations"] = normalized_recommendations @@ -3506,9 +3578,15 @@ def _normalize_and_salvage_phase2_result( normalized_item.get("trackContext"), path=f"mixAndMasterChain[{index}].trackContext", ) + track_context, repair_warnings = _repair_return_track_context( + track_context, + path=f"mixAndMasterChain[{index}].trackContext", + declared_returns=declared_returns, + ) normalized_item["trackContext"] = track_context normalized_mix_chain.append(normalized_item) warnings.extend(track_context_warnings) + warnings.extend(repair_warnings) normalized["mixAndMasterChain"] = normalized_mix_chain secret_sauce = _as_record(normalized.get("secretSauce")) @@ -3525,9 +3603,15 @@ def _normalize_and_salvage_phase2_result( normalized_item.get("trackContext"), path=f"secretSauce.workflowSteps[{index}].trackContext", ) + track_context, repair_warnings = _repair_return_track_context( + track_context, + path=f"secretSauce.workflowSteps[{index}].trackContext", + declared_returns=declared_returns, + ) normalized_item["trackContext"] = track_context normalized_steps.append(normalized_item) warnings.extend(track_context_warnings) + warnings.extend(repair_warnings) normalized_secret_sauce = dict(secret_sauce) normalized_secret_sauce["workflowSteps"] = normalized_steps normalized["secretSauce"] = normalized_secret_sauce diff --git a/apps/backend/tests/test_server.py b/apps/backend/tests/test_server.py index 0c30c0ed..44be2451 100644 --- a/apps/backend/tests/test_server.py +++ b/apps/backend/tests/test_server.py @@ -397,6 +397,95 @@ def test_parse_phase2_result_debug_coerces_return_track_context_spacing(self) -> self.assertIn("abletonRecommendations[0].trackContext", warning_paths) self.assertIn("secretSauce.workflowSteps[0].trackContext", warning_paths) + def test_parse_phase2_result_debug_repairs_abbreviated_return_name(self) -> None: + payload = _valid_phase2_result() + payload["routingBlueprint"]["returns"][0]["name"] = "Long Reverb" + payload["mixAndMasterChain"][0]["trackContext"] = "Return:Reverb" + payload["abletonRecommendations"][0]["trackContext"] = "Return:Reverb" + payload["secretSauce"]["workflowSteps"][0]["trackContext"] = "Return:Reverb" + + debug = server._parse_phase2_result_debug(json.dumps(payload)) + + self.assertIsNone(debug["skipReason"]) + self.assertEqual(debug["result"]["mixAndMasterChain"][0]["trackContext"], "Return:Long Reverb") + self.assertEqual(debug["result"]["abletonRecommendations"][0]["trackContext"], "Return:Long Reverb") + self.assertEqual( + debug["result"]["secretSauce"]["workflowSteps"][0]["trackContext"], + "Return:Long Reverb", + ) + coerced = [w for w in debug["validationWarnings"] if w["code"] == "COERCED_TRACK_CONTEXT"] + self.assertEqual(len(coerced), 3) + + def test_parse_phase2_result_debug_repairs_case_insensitive_return_name(self) -> None: + payload = _valid_phase2_result() + payload["routingBlueprint"]["returns"][0]["name"] = "Long Reverb" + payload["mixAndMasterChain"][0]["trackContext"] = "Return:long reverb" + + debug = server._parse_phase2_result_debug(json.dumps(payload)) + + self.assertIsNone(debug["skipReason"]) + self.assertEqual(debug["result"]["mixAndMasterChain"][0]["trackContext"], "Return:Long Reverb") + coerced = [w for w in debug["validationWarnings"] if w["code"] == "COERCED_TRACK_CONTEXT"] + self.assertTrue(len(coerced) >= 1) + + def test_parse_phase2_result_debug_no_repair_when_ambiguous_substring(self) -> None: + payload = _valid_phase2_result() + payload["routingBlueprint"]["returns"] = [ + { + "name": "Long Reverb", + "purpose": "Spatial depth.", + "sendSources": ["Drum Group"], + "deviceFocus": "Hybrid Reverb", + "levelGuidance": "-18 dB send baseline", + }, + { + "name": "Short Reverb", + "purpose": "Tight space.", + "sendSources": ["Drum Group"], + "deviceFocus": "Reverb", + "levelGuidance": "-12 dB send baseline", + }, + ] + payload["mixAndMasterChain"][0]["trackContext"] = "Return:Reverb" + + debug = server._parse_phase2_result_debug(json.dumps(payload)) + + self.assertIsNone(debug["skipReason"]) + # No repair — ambiguous match, value stays as-is + self.assertEqual(debug["result"]["mixAndMasterChain"][0]["trackContext"], "Return:Reverb") + coerced = [w for w in debug["validationWarnings"] if w["code"] == "COERCED_TRACK_CONTEXT"] + # No coercion warning from the repair pass for this field + coerced_mix = [w for w in coerced if "mixAndMasterChain" in w["path"]] + self.assertEqual(len(coerced_mix), 0) + + def test_parse_phase2_result_debug_no_repair_when_exact_match_exists(self) -> None: + payload = _valid_phase2_result() + payload["routingBlueprint"]["returns"][0]["name"] = "Reverb" + payload["mixAndMasterChain"][0]["trackContext"] = "Return:Reverb" + + debug = server._parse_phase2_result_debug(json.dumps(payload)) + + self.assertIsNone(debug["skipReason"]) + self.assertEqual(debug["result"]["mixAndMasterChain"][0]["trackContext"], "Return:Reverb") + coerced = [w for w in debug["validationWarnings"] if w["code"] == "COERCED_TRACK_CONTEXT"] + coerced_mix = [w for w in coerced if "mixAndMasterChain" in w["path"]] + self.assertEqual(len(coerced_mix), 0) + + def test_parse_phase2_result_debug_repairs_after_spacing_normalization(self) -> None: + payload = _valid_phase2_result() + payload["routingBlueprint"]["returns"][0]["name"] = "Long Reverb" + # Space after colon AND abbreviated — both normalizers should chain + payload["mixAndMasterChain"][0]["trackContext"] = "Return: Reverb" + + debug = server._parse_phase2_result_debug(json.dumps(payload)) + + self.assertIsNone(debug["skipReason"]) + self.assertEqual(debug["result"]["mixAndMasterChain"][0]["trackContext"], "Return:Long Reverb") + coerced = [w for w in debug["validationWarnings"] if w["code"] == "COERCED_TRACK_CONTEXT"] + coerced_mix = [w for w in coerced if "mixAndMasterChain" in w["path"]] + # Two coercion warnings: spacing fix + fuzzy repair + self.assertEqual(len(coerced_mix), 2) + def test_parse_phase2_result_drops_single_invalid_ableton_recommendation_item(self) -> None: payload = _valid_phase2_result() payload["abletonRecommendations"].append(