livepilot 1.9.21 → 1.9.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcpbignore +40 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +47 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +47 -72
- package/bin/livepilot.js +135 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
- package/livepilot/commands/arrange.md +42 -14
- package/livepilot/commands/beat.md +68 -21
- package/livepilot/commands/evaluate.md +23 -13
- package/livepilot/commands/mix.md +35 -11
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -17
- package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +60 -4
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
- package/livepilot/skills/livepilot-core/references/overview.md +4 -4
- package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
- package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
- package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
- package/livepilot/skills/livepilot-release/SKILL.md +15 -15
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
- package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +91 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_constraints/__init__.py +6 -0
- package/mcp_server/creative_constraints/engine.py +277 -0
- package/mcp_server/creative_constraints/models.py +75 -0
- package/mcp_server/creative_constraints/tools.py +341 -0
- package/mcp_server/experiment/__init__.py +6 -0
- package/mcp_server/experiment/engine.py +213 -0
- package/mcp_server/experiment/models.py +120 -0
- package/mcp_server/experiment/tools.py +263 -0
- package/mcp_server/hook_hunter/__init__.py +5 -0
- package/mcp_server/hook_hunter/analyzer.py +342 -0
- package/mcp_server/hook_hunter/models.py +57 -0
- package/mcp_server/hook_hunter/tools.py +586 -0
- package/mcp_server/memory/taste_graph.py +261 -0
- package/mcp_server/memory/tools.py +88 -0
- package/mcp_server/mix_engine/critics.py +2 -2
- package/mcp_server/mix_engine/models.py +1 -1
- package/mcp_server/mix_engine/state_builder.py +2 -2
- package/mcp_server/musical_intelligence/__init__.py +8 -0
- package/mcp_server/musical_intelligence/detectors.py +421 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +221 -0
- package/mcp_server/preview_studio/__init__.py +5 -0
- package/mcp_server/preview_studio/engine.py +280 -0
- package/mcp_server/preview_studio/models.py +73 -0
- package/mcp_server/preview_studio/tools.py +423 -0
- package/mcp_server/runtime/session_kernel.py +96 -0
- package/mcp_server/runtime/tools.py +90 -1
- package/mcp_server/semantic_moves/__init__.py +13 -0
- package/mcp_server/semantic_moves/compiler.py +116 -0
- package/mcp_server/semantic_moves/mix_compilers.py +291 -0
- package/mcp_server/semantic_moves/mix_moves.py +157 -0
- package/mcp_server/semantic_moves/models.py +46 -0
- package/mcp_server/semantic_moves/performance_compilers.py +208 -0
- package/mcp_server/semantic_moves/performance_moves.py +81 -0
- package/mcp_server/semantic_moves/registry.py +32 -0
- package/mcp_server/semantic_moves/resolvers.py +126 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
- package/mcp_server/semantic_moves/tools.py +204 -0
- package/mcp_server/semantic_moves/transition_compilers.py +222 -0
- package/mcp_server/semantic_moves/transition_moves.py +76 -0
- package/mcp_server/server.py +10 -0
- package/mcp_server/session_continuity/__init__.py +6 -0
- package/mcp_server/session_continuity/models.py +86 -0
- package/mcp_server/session_continuity/tools.py +230 -0
- package/mcp_server/session_continuity/tracker.py +235 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +477 -0
- package/mcp_server/song_brain/models.py +132 -0
- package/mcp_server/song_brain/tools.py +294 -0
- package/mcp_server/stuckness_detector/__init__.py +5 -0
- package/mcp_server/stuckness_detector/detector.py +400 -0
- package/mcp_server/stuckness_detector/models.py +66 -0
- package/mcp_server/stuckness_detector/tools.py +195 -0
- package/mcp_server/tools/_conductor.py +104 -6
- package/mcp_server/tools/analyzer.py +1 -1
- package/mcp_server/tools/devices.py +34 -0
- package/mcp_server/wonder_mode/__init__.py +6 -0
- package/mcp_server/wonder_mode/diagnosis.py +84 -0
- package/mcp_server/wonder_mode/engine.py +493 -0
- package/mcp_server/wonder_mode/session.py +114 -0
- package/mcp_server/wonder_mode/tools.py +285 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +4 -1
- package/remote_script/LivePilot/devices.py +29 -0
- package/remote_script/LivePilot/tracks.py +11 -4
- package/scripts/generate_tool_catalog.py +131 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Musical intelligence MCP tools — song-level analysis and critique.
|
|
2
|
+
|
|
3
|
+
4 tools that look beyond parameters into musical meaning:
|
|
4
|
+
detect_repetition_fatigue — is the arrangement getting stale?
|
|
5
|
+
detect_role_conflicts — are tracks fighting for the same space?
|
|
6
|
+
infer_section_purposes — what is each section trying to do?
|
|
7
|
+
score_emotional_arc — does the song have a satisfying arc?
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
from ..server import mcp
|
|
15
|
+
from . import detectors
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_ableton(ctx: Context):
|
|
19
|
+
return ctx.lifespan_context["ableton"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def detect_repetition_fatigue(ctx: Context) -> dict:
|
|
24
|
+
"""Detect repetition fatigue — are patterns overused?
|
|
25
|
+
|
|
26
|
+
Analyzes clip reuse across scenes, motif overuse, and section staleness.
|
|
27
|
+
Returns fatigue level (0=fresh, 1=stale), specific issues, and recommendations.
|
|
28
|
+
|
|
29
|
+
Use this when the track "feels repetitive" or when arrangement
|
|
30
|
+
has been looping without variation.
|
|
31
|
+
"""
|
|
32
|
+
ableton = _get_ableton(ctx)
|
|
33
|
+
|
|
34
|
+
# Get scene matrix for clip reuse analysis
|
|
35
|
+
try:
|
|
36
|
+
matrix = ableton.send_command("get_scene_matrix")
|
|
37
|
+
except Exception:
|
|
38
|
+
matrix = {}
|
|
39
|
+
|
|
40
|
+
scenes = []
|
|
41
|
+
for i, scene_data in enumerate(matrix.get("scenes", [])):
|
|
42
|
+
row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
|
|
43
|
+
scenes.append({
|
|
44
|
+
"name": scene_data.get("name", f"Scene {i}"),
|
|
45
|
+
"clips": row,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# Try to get motif graph for deeper analysis
|
|
49
|
+
motif_graph = None
|
|
50
|
+
try:
|
|
51
|
+
motif_graph = ableton.send_command("get_motif_graph")
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
report = detectors.detect_repetition_fatigue(scenes, motif_graph)
|
|
56
|
+
return report.to_dict()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
def detect_role_conflicts(ctx: Context) -> dict:
|
|
61
|
+
"""Detect role conflicts — are tracks fighting for the same musical space?
|
|
62
|
+
|
|
63
|
+
Checks for: multiple bass tracks, competing leads, overlapping drum layers.
|
|
64
|
+
Also flags missing essential roles (no bass, no drums).
|
|
65
|
+
|
|
66
|
+
Returns conflict list with severity and recommendations.
|
|
67
|
+
"""
|
|
68
|
+
ableton = _get_ableton(ctx)
|
|
69
|
+
session = ableton.send_command("get_session_info")
|
|
70
|
+
tracks = session.get("tracks", [])
|
|
71
|
+
|
|
72
|
+
conflicts = detectors.detect_role_conflicts(tracks)
|
|
73
|
+
return {
|
|
74
|
+
"conflicts": [c.to_dict() for c in conflicts],
|
|
75
|
+
"conflict_count": len(conflicts),
|
|
76
|
+
"track_count": len(tracks),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def infer_section_purposes(ctx: Context) -> dict:
|
|
82
|
+
"""Infer what each section/scene is trying to do musically.
|
|
83
|
+
|
|
84
|
+
Labels each scene as: setup, tension, payoff, contrast, release,
|
|
85
|
+
development, or outro — based on density, position, and energy changes.
|
|
86
|
+
|
|
87
|
+
Use this to understand the song's structure before making arrangement decisions.
|
|
88
|
+
"""
|
|
89
|
+
ableton = _get_ableton(ctx)
|
|
90
|
+
session = ableton.send_command("get_session_info")
|
|
91
|
+
total_tracks = session.get("track_count", 6)
|
|
92
|
+
|
|
93
|
+
# Get scene matrix for density analysis
|
|
94
|
+
try:
|
|
95
|
+
matrix = ableton.send_command("get_scene_matrix")
|
|
96
|
+
except Exception:
|
|
97
|
+
matrix = {}
|
|
98
|
+
|
|
99
|
+
scenes = []
|
|
100
|
+
for i, scene_data in enumerate(matrix.get("scenes", [])):
|
|
101
|
+
row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
|
|
102
|
+
scenes.append({
|
|
103
|
+
"name": scene_data.get("name", f"Scene {i}"),
|
|
104
|
+
"clips": row,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
purposes = detectors.infer_section_purposes(scenes, total_tracks)
|
|
108
|
+
return {
|
|
109
|
+
"sections": [p.to_dict() for p in purposes],
|
|
110
|
+
"section_count": len(purposes),
|
|
111
|
+
"purpose_summary": {p.purpose: sum(1 for s in purposes if s.purpose == p.purpose)
|
|
112
|
+
for p in purposes},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@mcp.tool()
|
|
117
|
+
def score_emotional_arc(ctx: Context) -> dict:
|
|
118
|
+
"""Score the emotional arc of the arrangement.
|
|
119
|
+
|
|
120
|
+
Measures: arc clarity (build→climax→resolve), contrast between sections,
|
|
121
|
+
payoff strength (does the climax feel earned?), and resolution (does it end well?).
|
|
122
|
+
|
|
123
|
+
Returns an overall score (0-1) and specific issues with recommendations.
|
|
124
|
+
"""
|
|
125
|
+
ableton = _get_ableton(ctx)
|
|
126
|
+
session = ableton.send_command("get_session_info")
|
|
127
|
+
total_tracks = session.get("track_count", 6)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
matrix = ableton.send_command("get_scene_matrix")
|
|
131
|
+
except Exception:
|
|
132
|
+
matrix = {}
|
|
133
|
+
|
|
134
|
+
scenes = []
|
|
135
|
+
for i, scene_data in enumerate(matrix.get("scenes", [])):
|
|
136
|
+
row = matrix.get("matrix", [[]])[i] if i < len(matrix.get("matrix", [])) else []
|
|
137
|
+
scenes.append({
|
|
138
|
+
"name": scene_data.get("name", f"Scene {i}"),
|
|
139
|
+
"clips": row,
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
purposes = detectors.infer_section_purposes(scenes, total_tracks)
|
|
143
|
+
arc = detectors.score_emotional_arc(purposes)
|
|
144
|
+
return arc.to_dict()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Phrase Evaluation ────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@mcp.tool()
|
|
151
|
+
def analyze_phrase_arc(
|
|
152
|
+
ctx: Context,
|
|
153
|
+
file_path: str,
|
|
154
|
+
target: str = "loop",
|
|
155
|
+
) -> dict:
|
|
156
|
+
"""Analyze a captured audio phrase for musical quality.
|
|
157
|
+
|
|
158
|
+
Evaluates: arc clarity, contrast, fatigue risk, payoff strength,
|
|
159
|
+
identity strength, and translation risk.
|
|
160
|
+
|
|
161
|
+
file_path: path to a captured audio file (from capture_audio)
|
|
162
|
+
target: what the phrase is ("loop", "drop", "chorus", "transition", "intro", "outro")
|
|
163
|
+
|
|
164
|
+
Requires capture_audio + analyze_loudness + analyze_spectrum_offline first.
|
|
165
|
+
"""
|
|
166
|
+
from . import phrase_critic
|
|
167
|
+
|
|
168
|
+
ableton = _get_ableton(ctx)
|
|
169
|
+
|
|
170
|
+
# Run offline analysis on the file
|
|
171
|
+
loudness_data = None
|
|
172
|
+
spectrum_data = None
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
loudness_data = ableton.send_command("analyze_loudness_offline", {
|
|
176
|
+
"file_path": file_path, "detail": "full",
|
|
177
|
+
})
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
spectrum_data = ableton.send_command("analyze_spectrum_offline_internal", {
|
|
183
|
+
"file_path": file_path,
|
|
184
|
+
})
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
critique = phrase_critic.analyze_phrase(loudness_data, spectrum_data, target)
|
|
189
|
+
critique.render_id = file_path.split("/")[-1] if "/" in file_path else file_path
|
|
190
|
+
return critique.to_dict()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@mcp.tool()
|
|
194
|
+
def compare_phrase_renders(
|
|
195
|
+
ctx: Context,
|
|
196
|
+
file_paths: list,
|
|
197
|
+
target: str = "loop",
|
|
198
|
+
) -> dict:
|
|
199
|
+
"""Compare multiple audio captures and rank by musical quality.
|
|
200
|
+
|
|
201
|
+
file_paths: list of paths to captured audio files
|
|
202
|
+
target: what the phrases are ("loop", "drop", "chorus", etc.)
|
|
203
|
+
|
|
204
|
+
Returns ranked list with scores and notes for each.
|
|
205
|
+
"""
|
|
206
|
+
from . import phrase_critic
|
|
207
|
+
|
|
208
|
+
critiques = []
|
|
209
|
+
for path in file_paths:
|
|
210
|
+
# Try to get cached analysis or run fresh
|
|
211
|
+
critique = phrase_critic.analyze_phrase(target=target)
|
|
212
|
+
critique.render_id = path.split("/")[-1] if isinstance(path, str) and "/" in path else str(path)
|
|
213
|
+
critiques.append(critique)
|
|
214
|
+
|
|
215
|
+
ranking = phrase_critic.compare_phrases(critiques)
|
|
216
|
+
return {
|
|
217
|
+
"ranking": ranking,
|
|
218
|
+
"count": len(ranking),
|
|
219
|
+
"target": target,
|
|
220
|
+
"best": ranking[0] if ranking else None,
|
|
221
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Preview Studio engine — pure computation, zero I/O.
|
|
2
|
+
|
|
3
|
+
Creates, compares, and ranks preview variants using the creative triptych
|
|
4
|
+
pattern (safe / strong / unexpected).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .models import PreviewSet, PreviewVariant
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── In-memory store ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
_preview_sets: dict[str, PreviewSet] = {}
|
|
20
|
+
_MAX_PREVIEW_SETS = 20
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_preview_set(set_id: str) -> Optional[PreviewSet]:
|
|
24
|
+
return _preview_sets.get(set_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def store_preview_set(ps: PreviewSet) -> None:
|
|
28
|
+
_preview_sets[ps.set_id] = ps
|
|
29
|
+
# Evict oldest sets if over limit
|
|
30
|
+
while len(_preview_sets) > _MAX_PREVIEW_SETS:
|
|
31
|
+
oldest_key = next(iter(_preview_sets))
|
|
32
|
+
del _preview_sets[oldest_key]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Creation ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_preview_set(
|
|
39
|
+
request_text: str,
|
|
40
|
+
kernel_id: str,
|
|
41
|
+
strategy: str = "creative_triptych",
|
|
42
|
+
available_moves: Optional[list[dict]] = None,
|
|
43
|
+
song_brain: Optional[dict] = None,
|
|
44
|
+
taste_graph: Optional[dict] = None,
|
|
45
|
+
) -> PreviewSet:
|
|
46
|
+
"""Create a preview set with variant slots.
|
|
47
|
+
|
|
48
|
+
For creative_triptych, generates 3 variants: safe, strong, unexpected.
|
|
49
|
+
Each variant gets a move_id from available_moves ranked by novelty.
|
|
50
|
+
"""
|
|
51
|
+
set_id = _compute_set_id(request_text, kernel_id)
|
|
52
|
+
now = int(time.time() * 1000)
|
|
53
|
+
|
|
54
|
+
moves = available_moves or []
|
|
55
|
+
song_brain = song_brain or {}
|
|
56
|
+
taste_graph = taste_graph or {}
|
|
57
|
+
|
|
58
|
+
if strategy == "creative_triptych":
|
|
59
|
+
variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
|
|
60
|
+
elif strategy == "binary":
|
|
61
|
+
variants = _build_binary(request_text, moves, song_brain, set_id, now)
|
|
62
|
+
else:
|
|
63
|
+
variants = _build_triptych(request_text, moves, song_brain, taste_graph, set_id, now)
|
|
64
|
+
|
|
65
|
+
ps = PreviewSet(
|
|
66
|
+
set_id=set_id,
|
|
67
|
+
request_text=request_text,
|
|
68
|
+
strategy=strategy,
|
|
69
|
+
source_kernel_id=kernel_id,
|
|
70
|
+
variants=variants,
|
|
71
|
+
created_at_ms=now,
|
|
72
|
+
)
|
|
73
|
+
store_preview_set(ps)
|
|
74
|
+
return ps
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_triptych(
|
|
78
|
+
request_text: str,
|
|
79
|
+
moves: list[dict],
|
|
80
|
+
song_brain: dict,
|
|
81
|
+
taste_graph: dict,
|
|
82
|
+
set_id: str,
|
|
83
|
+
now: int,
|
|
84
|
+
) -> list[PreviewVariant]:
|
|
85
|
+
"""Build safe / strong / unexpected variants."""
|
|
86
|
+
identity = song_brain.get("identity_core", "")
|
|
87
|
+
sacred = [e.get("description", "") for e in song_brain.get("sacred_elements", [])]
|
|
88
|
+
sacred_text = ", ".join(sacred[:3]) if sacred else "core elements"
|
|
89
|
+
|
|
90
|
+
profiles = [
|
|
91
|
+
{
|
|
92
|
+
"label": "safe",
|
|
93
|
+
"novelty": 0.2,
|
|
94
|
+
"intent": f"Close to current identity, minimal risk. {request_text}",
|
|
95
|
+
"identity_effect": "preserves",
|
|
96
|
+
"what_preserved": f"Preserves {sacred_text}",
|
|
97
|
+
"why_it_matters": "Low risk — good when identity is fragile",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"label": "strong",
|
|
101
|
+
"novelty": 0.5,
|
|
102
|
+
"intent": f"Musically assertive approach. {request_text}",
|
|
103
|
+
"identity_effect": "evolves",
|
|
104
|
+
"what_preserved": f"Maintains {sacred_text} while pushing forward",
|
|
105
|
+
"why_it_matters": "Best balance of impact and safety",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"label": "unexpected",
|
|
109
|
+
"novelty": 0.8,
|
|
110
|
+
"intent": f"Surprising but taste-filtered. {request_text}",
|
|
111
|
+
"identity_effect": "contrasts",
|
|
112
|
+
"what_preserved": f"Respects {sacred_text} but reframes context",
|
|
113
|
+
"why_it_matters": "High novelty — may unlock a new direction",
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
variants = []
|
|
118
|
+
for i, profile in enumerate(profiles):
|
|
119
|
+
# Pick a move if available
|
|
120
|
+
move_id = ""
|
|
121
|
+
compiled_plan = None
|
|
122
|
+
if moves and i < len(moves):
|
|
123
|
+
move_id = moves[i].get("move_id", "")
|
|
124
|
+
compiled_plan = moves[i].get("compile_plan")
|
|
125
|
+
|
|
126
|
+
variants.append(PreviewVariant(
|
|
127
|
+
variant_id=f"{set_id}_{profile['label']}",
|
|
128
|
+
label=profile["label"],
|
|
129
|
+
intent=profile["intent"],
|
|
130
|
+
novelty_level=profile["novelty"],
|
|
131
|
+
identity_effect=profile["identity_effect"],
|
|
132
|
+
what_preserved=profile["what_preserved"],
|
|
133
|
+
why_it_matters=profile["why_it_matters"],
|
|
134
|
+
move_id=move_id,
|
|
135
|
+
compiled_plan=compiled_plan,
|
|
136
|
+
taste_fit=_estimate_taste_fit(profile["novelty"], taste_graph),
|
|
137
|
+
created_at_ms=now,
|
|
138
|
+
))
|
|
139
|
+
|
|
140
|
+
return variants
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_binary(
|
|
144
|
+
request_text: str,
|
|
145
|
+
moves: list[dict],
|
|
146
|
+
song_brain: dict,
|
|
147
|
+
set_id: str,
|
|
148
|
+
now: int,
|
|
149
|
+
) -> list[PreviewVariant]:
|
|
150
|
+
"""Build simple A/B comparison."""
|
|
151
|
+
return [
|
|
152
|
+
PreviewVariant(
|
|
153
|
+
variant_id=f"{set_id}_a",
|
|
154
|
+
label="option_a",
|
|
155
|
+
intent=f"Primary approach: {request_text}",
|
|
156
|
+
novelty_level=0.3,
|
|
157
|
+
identity_effect="preserves",
|
|
158
|
+
move_id=moves[0].get("move_id", "") if moves else "",
|
|
159
|
+
created_at_ms=now,
|
|
160
|
+
),
|
|
161
|
+
PreviewVariant(
|
|
162
|
+
variant_id=f"{set_id}_b",
|
|
163
|
+
label="option_b",
|
|
164
|
+
intent=f"Alternative approach: {request_text}",
|
|
165
|
+
novelty_level=0.6,
|
|
166
|
+
identity_effect="evolves",
|
|
167
|
+
move_id=moves[1].get("move_id", "") if len(moves) > 1 else "",
|
|
168
|
+
created_at_ms=now,
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Comparison ────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def compare_variants(
|
|
177
|
+
preview_set: PreviewSet,
|
|
178
|
+
criteria: Optional[dict] = None,
|
|
179
|
+
) -> dict:
|
|
180
|
+
"""Compare variants within a preview set and rank them."""
|
|
181
|
+
criteria = criteria or {}
|
|
182
|
+
weight_taste = criteria.get("taste_weight", 0.3)
|
|
183
|
+
weight_novelty = criteria.get("novelty_weight", 0.2)
|
|
184
|
+
weight_identity = criteria.get("identity_weight", 0.5)
|
|
185
|
+
|
|
186
|
+
rankings = []
|
|
187
|
+
for v in preview_set.variants:
|
|
188
|
+
# Score components
|
|
189
|
+
taste_score = v.taste_fit
|
|
190
|
+
novelty_score = 1.0 - abs(v.novelty_level - 0.5) * 2 # bell curve around 0.5
|
|
191
|
+
identity_score = _identity_effect_score(v.identity_effect)
|
|
192
|
+
|
|
193
|
+
composite = (
|
|
194
|
+
taste_score * weight_taste
|
|
195
|
+
+ novelty_score * weight_novelty
|
|
196
|
+
+ identity_score * weight_identity
|
|
197
|
+
)
|
|
198
|
+
v.score = round(composite, 3)
|
|
199
|
+
|
|
200
|
+
rankings.append({
|
|
201
|
+
"variant_id": v.variant_id,
|
|
202
|
+
"label": v.label,
|
|
203
|
+
"score": v.score,
|
|
204
|
+
"taste_fit": v.taste_fit,
|
|
205
|
+
"novelty_level": v.novelty_level,
|
|
206
|
+
"identity_effect": v.identity_effect,
|
|
207
|
+
"summary": v.intent,
|
|
208
|
+
"what_preserved": v.what_preserved,
|
|
209
|
+
"why_it_matters": v.why_it_matters,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
rankings.sort(key=lambda r: r["score"], reverse=True)
|
|
213
|
+
|
|
214
|
+
comparison = {
|
|
215
|
+
"rankings": rankings,
|
|
216
|
+
"recommended": rankings[0]["variant_id"] if rankings else "",
|
|
217
|
+
"criteria_used": {
|
|
218
|
+
"taste_weight": weight_taste,
|
|
219
|
+
"novelty_weight": weight_novelty,
|
|
220
|
+
"identity_weight": weight_identity,
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
preview_set.comparison = comparison
|
|
225
|
+
preview_set.status = "compared"
|
|
226
|
+
return comparison
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def commit_variant(preview_set: PreviewSet, variant_id: str) -> Optional[PreviewVariant]:
|
|
230
|
+
"""Mark a variant as committed and discard others."""
|
|
231
|
+
chosen = None
|
|
232
|
+
for v in preview_set.variants:
|
|
233
|
+
if v.variant_id == variant_id:
|
|
234
|
+
v.status = "committed"
|
|
235
|
+
chosen = v
|
|
236
|
+
else:
|
|
237
|
+
v.status = "discarded"
|
|
238
|
+
|
|
239
|
+
if chosen:
|
|
240
|
+
preview_set.committed_variant_id = variant_id
|
|
241
|
+
preview_set.status = "committed"
|
|
242
|
+
|
|
243
|
+
return chosen
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def discard_set(set_id: str) -> bool:
|
|
247
|
+
"""Discard an entire preview set."""
|
|
248
|
+
ps = _preview_sets.pop(set_id, None)
|
|
249
|
+
if ps:
|
|
250
|
+
ps.status = "discarded"
|
|
251
|
+
for v in ps.variants:
|
|
252
|
+
v.status = "discarded"
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Helpers ───────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _compute_set_id(request_text: str, kernel_id: str) -> str:
|
|
261
|
+
seed = json.dumps({"request": request_text, "kernel": kernel_id}, sort_keys=True)
|
|
262
|
+
return "ps_" + hashlib.sha256(seed.encode()).hexdigest()[:10]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _estimate_taste_fit(novelty: float, taste_graph: dict) -> float:
|
|
266
|
+
"""Estimate how well a novelty level fits user taste."""
|
|
267
|
+
boldness = taste_graph.get("transition_boldness", 0.5)
|
|
268
|
+
# Users who like boldness prefer higher novelty
|
|
269
|
+
fit = 1.0 - abs(novelty - boldness) * 0.5
|
|
270
|
+
return round(max(0.0, min(1.0, fit)), 3)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _identity_effect_score(effect: str) -> float:
|
|
274
|
+
"""Score identity effects — preserves is safest."""
|
|
275
|
+
return {
|
|
276
|
+
"preserves": 0.9,
|
|
277
|
+
"evolves": 0.7,
|
|
278
|
+
"contrasts": 0.4,
|
|
279
|
+
"resets": 0.2,
|
|
280
|
+
}.get(effect, 0.5)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Preview Studio data models — pure dataclasses, zero I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class PreviewVariant:
|
|
12
|
+
"""One creative option in a preview set."""
|
|
13
|
+
|
|
14
|
+
variant_id: str = ""
|
|
15
|
+
label: str = "" # "safe", "strong", "unexpected"
|
|
16
|
+
intent: str = "" # what this variant is trying to achieve
|
|
17
|
+
novelty_level: float = 0.0 # 0=conservative, 1=radical
|
|
18
|
+
songbrain_delta: str = "" # what changed vs identity
|
|
19
|
+
taste_fit: float = 0.5 # 0-1 how well it matches user taste
|
|
20
|
+
render_ref: str = "" # reference to cached render
|
|
21
|
+
summary: str = "" # one-line musical explanation
|
|
22
|
+
|
|
23
|
+
# What changed, why it matters, what it preserves
|
|
24
|
+
what_changed: str = ""
|
|
25
|
+
why_it_matters: str = ""
|
|
26
|
+
what_preserved: str = ""
|
|
27
|
+
|
|
28
|
+
# Move / plan data
|
|
29
|
+
move_id: str = ""
|
|
30
|
+
compiled_plan: Optional[dict] = None
|
|
31
|
+
|
|
32
|
+
# Scoring
|
|
33
|
+
score: float = 0.0
|
|
34
|
+
identity_effect: str = "preserves" # preserves, evolves, contrasts, resets
|
|
35
|
+
|
|
36
|
+
# State
|
|
37
|
+
status: str = "pending" # pending, rendered, committed, discarded
|
|
38
|
+
created_at_ms: int = 0
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
d = asdict(self)
|
|
42
|
+
# Remove None compiled_plan for cleaner output
|
|
43
|
+
if d.get("compiled_plan") is None:
|
|
44
|
+
d.pop("compiled_plan", None)
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PreviewSet:
|
|
50
|
+
"""A set of variants tied to one user request."""
|
|
51
|
+
|
|
52
|
+
set_id: str = ""
|
|
53
|
+
request_text: str = ""
|
|
54
|
+
strategy: str = "creative_triptych" # creative_triptych, binary, custom
|
|
55
|
+
source_kernel_id: str = ""
|
|
56
|
+
variants: list[PreviewVariant] = field(default_factory=list)
|
|
57
|
+
comparison: Optional[dict] = None
|
|
58
|
+
committed_variant_id: str = ""
|
|
59
|
+
status: str = "pending" # pending, compared, committed, discarded
|
|
60
|
+
created_at_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"set_id": self.set_id,
|
|
65
|
+
"request_text": self.request_text,
|
|
66
|
+
"strategy": self.strategy,
|
|
67
|
+
"source_kernel_id": self.source_kernel_id,
|
|
68
|
+
"variants": [v.to_dict() for v in self.variants],
|
|
69
|
+
"comparison": self.comparison,
|
|
70
|
+
"committed_variant_id": self.committed_variant_id,
|
|
71
|
+
"status": self.status,
|
|
72
|
+
"variant_count": len(self.variants),
|
|
73
|
+
}
|