From 698087b164dfec88327742acf862095871344385 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 26 Mar 2026 08:34:36 +0900 Subject: [PATCH 1/2] feat: implement role-specific harmony, range, overlap, and confidence output Resolves #31 --- patch_model.py | 9 ++++ registered_agents.json | 1 + services/analysis-engine/patch_api.py | 7 +++ .../src/bandscope_analysis/api.py | 1 + .../src/bandscope_analysis/roles/extractor.py | 47 ++++++++++++++++++- .../src/bandscope_analysis/roles/model.py | 1 + services/analysis-engine/tests/test_api.py | 2 +- services/analysis-engine/tests/test_roles.py | 41 +++++++++------- task_agent_mapping.json | 1 + test_enum.py | 11 +++++ test_json.py | 7 +++ uv.lock | 8 ++++ 12 files changed, 115 insertions(+), 21 deletions(-) create mode 100644 patch_model.py create mode 100644 registered_agents.json create mode 100644 services/analysis-engine/patch_api.py create mode 100644 task_agent_mapping.json create mode 100644 test_enum.py create mode 100644 test_json.py create mode 100644 uv.lock diff --git a/patch_model.py b/patch_model.py new file mode 100644 index 00000000..b011b883 --- /dev/null +++ b/patch_model.py @@ -0,0 +1,9 @@ +with open("services/analysis-engine/src/bandscope_analysis/roles/model.py", "r") as f: + content = f.read() + +content = content.replace(" kind: str # CueAnchorKind", " kind: CueAnchorKind") +content = content.replace(" roleType: str # RoleType", " roleType: RoleType") +content = content.replace(" rehearsalPriority: str # RehearsalPriority", " rehearsalPriority: RehearsalPriority") + +with open("services/analysis-engine/src/bandscope_analysis/roles/model.py", "w") as f: + f.write(content) diff --git a/registered_agents.json b/registered_agents.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/registered_agents.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/services/analysis-engine/patch_api.py b/services/analysis-engine/patch_api.py new file mode 100644 index 00000000..c93209c3 --- /dev/null +++ b/services/analysis-engine/patch_api.py @@ -0,0 +1,7 @@ +with open("src/bandscope_analysis/api.py", "r") as f: + content = f.read() + +content = content.replace('from typing import Literal, NotRequired, TypedDict, cast', 'from typing import Any, Literal, NotRequired, TypedDict, cast') + +with open("src/bandscope_analysis/api.py", "w") as f: + f.write(content) diff --git a/services/analysis-engine/src/bandscope_analysis/api.py b/services/analysis-engine/src/bandscope_analysis/api.py index 87fce60c..058d2ce5 100644 --- a/services/analysis-engine/src/bandscope_analysis/api.py +++ b/services/analysis-engine/src/bandscope_analysis/api.py @@ -87,6 +87,7 @@ class RehearsalRolePayload(TypedDict): simplification: str setupNote: str manualOverrides: list[ManualOverridePayload] + overlapWarnings: list[str] class PartGraphNodePayload(TypedDict): diff --git a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py index 34d7b376..bbada8fa 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/extractor.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/extractor.py @@ -73,6 +73,35 @@ def extract( "simplification": "Stay on roots if the chorus entrance gets muddy.", "setupNote": "Keep the attack short so the verse breathes.", "manualOverrides": [], + "overlapWarnings": [ + "Density warning: competing with Keyboard Left Hand in low register." + ], + } + + keys_left_role: RehearsalRole = { + "id": "keys-left", + "name": "Keyboard 1 Left Hand", + "roleType": RoleType.HAND, + "harmony": { + "chord": "C#", + "functionLabel": "Root reinforcement", + "source": "model", + }, + "cue": { + "kind": CueAnchorKind.TRANSITION, + "value": "Lock in with bass pedal.", + }, + "range": {"lowestNote": "C#2", "highestNote": "C#3"}, + "confidence": { + "level": "low", + "source": "model", + "notes": "Muddy frequency range, difficult to clearly separate from bass.", + }, + "rehearsalPriority": RehearsalPriority.MEDIUM, + "simplification": "Omit if bass is covering the lower register.", + "setupNote": "Use a darker patch to avoid clashing with right hand.", + "manualOverrides": [], + "overlapWarnings": ["Density warning: competing with Bass Guitar in low register."], } keys_role: RehearsalRole = { @@ -98,6 +127,7 @@ def extract( "simplification": "Drop top extension if the chorus turnaround feels busy.", "setupNote": "Keep the patch bright enough to stay over the guitars.", "manualOverrides": [], + "overlapWarnings": ["Melodic overlap: top notes conflict with Lead Vocal range."], } vocal_role: RehearsalRole = { @@ -130,6 +160,7 @@ def extract( "source": "user", } ], + "overlapWarnings": ["Melodic overlap: competing with Keyboard 1 Right Hand."], } active_roles = [bass_role] @@ -140,9 +171,15 @@ def extract( ] if i == 0: - active_roles.extend([keys_role, vocal_role]) + active_roles.extend([keys_left_role, keys_role, vocal_role]) part_graph.extend( [ + { + "role_id": "keys-left", + "is_active": True, + "handoff_to": [], + "handoff_from": [], + }, { "role_id": "keys-right", "is_active": True, @@ -158,10 +195,16 @@ def extract( ] ) part_graph[0]["handoff_to"].append("lead-vocal") - part_graph[2]["handoff_from"].append("bass-guitar") + part_graph[3]["handoff_from"].append("bass-guitar") else: part_graph.extend( [ + { + "role_id": "keys-left", + "is_active": False, + "handoff_to": [], + "handoff_from": [], + }, { "role_id": "keys-right", "is_active": False, diff --git a/services/analysis-engine/src/bandscope_analysis/roles/model.py b/services/analysis-engine/src/bandscope_analysis/roles/model.py index 06bac5a0..ea6fc144 100644 --- a/services/analysis-engine/src/bandscope_analysis/roles/model.py +++ b/services/analysis-engine/src/bandscope_analysis/roles/model.py @@ -82,6 +82,7 @@ class RehearsalRole(TypedDict): simplification: str setupNote: str manualOverrides: list[ManualOverride] + overlapWarnings: list[str] class PartGraphNode(TypedDict): diff --git a/services/analysis-engine/tests/test_api.py b/services/analysis-engine/tests/test_api.py index 86ca448f..30211c3b 100644 --- a/services/analysis-engine/tests/test_api.py +++ b/services/analysis-engine/tests/test_api.py @@ -206,7 +206,7 @@ def test_build_demo_rehearsal_song_matches_expected_fixture() -> None: assert song["title"] == "Late Night Set" assert song["sections"][0]["roles"][0]["id"] == "bass-guitar" - assert song["sections"][0]["roles"][2]["manualOverrides"][0]["value"]["source"] == "user" + assert song["sections"][0]["roles"][3]["manualOverrides"][0]["value"]["source"] == "user" def test_run_analysis_job_returns_success_and_failure_envelopes() -> None: diff --git a/services/analysis-engine/tests/test_roles.py b/services/analysis-engine/tests/test_roles.py index a1ada7a0..9aced099 100644 --- a/services/analysis-engine/tests/test_roles.py +++ b/services/analysis-engine/tests/test_roles.py @@ -8,28 +8,28 @@ ) -def test_role_type_enum(): +def test_role_type_enum() -> None: """Verify RoleType enum values match the domain requirements.""" - assert RoleType.INSTRUMENT == "instrument" - assert RoleType.VOCAL == "vocal" - assert RoleType.HAND == "hand" + assert RoleType.INSTRUMENT.value == "instrument" + assert RoleType.VOCAL.value == "vocal" + assert RoleType.HAND.value == "hand" -def test_rehearsal_priority_enum(): +def test_rehearsal_priority_enum() -> None: """Verify RehearsalPriority enum values match.""" - assert RehearsalPriority.LOW == "low" - assert RehearsalPriority.MEDIUM == "medium" - assert RehearsalPriority.HIGH == "high" + assert RehearsalPriority.LOW.value == "low" + assert RehearsalPriority.MEDIUM.value == "medium" + assert RehearsalPriority.HIGH.value == "high" -def test_cue_anchor_kind_enum(): +def test_cue_anchor_kind_enum() -> None: """Verify CueAnchorKind enum values match.""" - assert CueAnchorKind.LYRIC == "lyric" - assert CueAnchorKind.COUNT == "count" - assert CueAnchorKind.TRANSITION == "transition" + assert CueAnchorKind.LYRIC.value == "lyric" + assert CueAnchorKind.COUNT.value == "count" + assert CueAnchorKind.TRANSITION.value == "transition" -def test_role_extractor_basic(): +def test_role_extractor_basic() -> None: """Test that RoleExtractor returns a valid topology structure.""" extractor = RoleExtractor() @@ -44,13 +44,15 @@ def test_role_extractor_basic(): # Check intro section intro_topology = result["topologies"][0] assert intro_topology["section_id"] == "intro" - assert len(intro_topology["active_roles"]) == 3 + assert len(intro_topology["active_roles"]) == 4 roles_by_id = {r["id"]: r for r in intro_topology["active_roles"]} assert "bass-guitar" in roles_by_id assert "lead-vocal" in roles_by_id assert "keys-right" in roles_by_id + assert "keys-left" in roles_by_id assert roles_by_id["lead-vocal"]["roleType"] == "vocal" + assert "Melodic overlap" in roles_by_id["lead-vocal"]["overlapWarnings"][0] intro_graph = intro_topology["part_graph"] graph_by_role = {n["role_id"]: n for n in intro_graph} @@ -66,23 +68,26 @@ def test_role_extractor_basic(): assert verse_topology["active_roles"][0]["id"] == "bass-guitar" assert verse_topology["active_roles"][0]["roleType"] == "instrument" assert verse_topology["active_roles"][0]["rehearsalPriority"] == "high" + assert "Density warning" in verse_topology["active_roles"][0]["overlapWarnings"][0] verse_graph = verse_topology["part_graph"] - assert len(verse_graph) == 3 - assert verse_graph[1]["role_id"] == "keys-right" + assert len(verse_graph) == 4 + assert verse_graph[1]["role_id"] == "keys-left" assert verse_graph[1]["is_active"] is False + assert verse_graph[2]["role_id"] == "keys-right" + assert verse_graph[2]["is_active"] is False assert verse_graph[0]["role_id"] == "bass-guitar" assert verse_graph[0]["handoff_to"] == [] -def test_role_extractor_empty(): +def test_role_extractor_empty() -> None: """Test extractor with empty sections list.""" extractor = RoleExtractor() result = extractor.extract([]) assert result["topologies"] == [] -def test_role_extractor_invalid_section(): +def test_role_extractor_invalid_section() -> None: """Test that RoleExtractor handles non-dict sections gracefully.""" extractor = RoleExtractor() sections = [{"id": "intro"}, "invalid-section-string"] diff --git a/task_agent_mapping.json b/task_agent_mapping.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/task_agent_mapping.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test_enum.py b/test_enum.py new file mode 100644 index 00000000..fe6f685d --- /dev/null +++ b/test_enum.py @@ -0,0 +1,11 @@ +from enum import Enum +from typing import TypedDict + +class RoleType(str, Enum): + INSTRUMENT = "instrument" + +class Role(TypedDict): + roleType: RoleType + +r: Role = {"roleType": RoleType.INSTRUMENT} +r2: Role = {"roleType": RoleType.INSTRUMENT.value} diff --git a/test_json.py b/test_json.py new file mode 100644 index 00000000..a7c9b7e0 --- /dev/null +++ b/test_json.py @@ -0,0 +1,7 @@ +import json +from enum import Enum + +class RoleType(str, Enum): + INSTRUMENT = "instrument" + +print(json.dumps({"roleType": RoleType.INSTRUMENT})) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..ce78d5f5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,8 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "bandscope-workspace" +version = "0.1.0" +source = { virtual = "." } From cdadab88828cec5633c470871bef5ffb0239cc3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 26 Mar 2026 08:35:38 +0900 Subject: [PATCH 2/2] chore: remove scratchpad files --- patch_model.py | 9 --------- registered_agents.json | 1 - services/analysis-engine/patch_api.py | 7 ------- task_agent_mapping.json | 1 - test_enum.py | 11 ----------- test_json.py | 7 ------- 6 files changed, 36 deletions(-) delete mode 100644 patch_model.py delete mode 100644 registered_agents.json delete mode 100644 services/analysis-engine/patch_api.py delete mode 100644 task_agent_mapping.json delete mode 100644 test_enum.py delete mode 100644 test_json.py diff --git a/patch_model.py b/patch_model.py deleted file mode 100644 index b011b883..00000000 --- a/patch_model.py +++ /dev/null @@ -1,9 +0,0 @@ -with open("services/analysis-engine/src/bandscope_analysis/roles/model.py", "r") as f: - content = f.read() - -content = content.replace(" kind: str # CueAnchorKind", " kind: CueAnchorKind") -content = content.replace(" roleType: str # RoleType", " roleType: RoleType") -content = content.replace(" rehearsalPriority: str # RehearsalPriority", " rehearsalPriority: RehearsalPriority") - -with open("services/analysis-engine/src/bandscope_analysis/roles/model.py", "w") as f: - f.write(content) diff --git a/registered_agents.json b/registered_agents.json deleted file mode 100644 index 9e26dfee..00000000 --- a/registered_agents.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/services/analysis-engine/patch_api.py b/services/analysis-engine/patch_api.py deleted file mode 100644 index c93209c3..00000000 --- a/services/analysis-engine/patch_api.py +++ /dev/null @@ -1,7 +0,0 @@ -with open("src/bandscope_analysis/api.py", "r") as f: - content = f.read() - -content = content.replace('from typing import Literal, NotRequired, TypedDict, cast', 'from typing import Any, Literal, NotRequired, TypedDict, cast') - -with open("src/bandscope_analysis/api.py", "w") as f: - f.write(content) diff --git a/task_agent_mapping.json b/task_agent_mapping.json deleted file mode 100644 index 9e26dfee..00000000 --- a/task_agent_mapping.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/test_enum.py b/test_enum.py deleted file mode 100644 index fe6f685d..00000000 --- a/test_enum.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import Enum -from typing import TypedDict - -class RoleType(str, Enum): - INSTRUMENT = "instrument" - -class Role(TypedDict): - roleType: RoleType - -r: Role = {"roleType": RoleType.INSTRUMENT} -r2: Role = {"roleType": RoleType.INSTRUMENT.value} diff --git a/test_json.py b/test_json.py deleted file mode 100644 index a7c9b7e0..00000000 --- a/test_json.py +++ /dev/null @@ -1,7 +0,0 @@ -import json -from enum import Enum - -class RoleType(str, Enum): - INSTRUMENT = "instrument" - -print(json.dumps({"roleType": RoleType.INSTRUMENT}))