livepilot 1.24.0 → 1.25.0
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/CHANGELOG.md +70 -0
- package/README.md +11 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/explore_tools.py +332 -0
- package/mcp_server/atlas/tools.py +161 -0
- package/mcp_server/composer/framework/atlas_resolver.py +554 -0
- package/mcp_server/composer/framework/knowledge_pack.py +98 -7
- package/mcp_server/composer/full/brief_builder.py +91 -8
- package/mcp_server/tools/_analyzer_engine/sample.py +10 -1
- package/mcp_server/tools/browser.py +102 -19
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""AtlasResolver — v1.25 hybrid knowledge surface.
|
|
2
|
+
|
|
3
|
+
Three layers of corpus access for the agent:
|
|
4
|
+
|
|
5
|
+
Layer A — resolve_anchors(): cohort + role-anchored URIs at brief-build time.
|
|
6
|
+
Wraps existing atlas_pack_aware_compose; cheap
|
|
7
|
+
(~400 tokens in the brief).
|
|
8
|
+
Layer B — resolve_for_role(): per-role ranked candidates with cohort constraint.
|
|
9
|
+
Called by atlas_explore MCP tool during plan design,
|
|
10
|
+
and as a fallback when an anchor doesn't fit.
|
|
11
|
+
Layer C — _score(): corpus-deep ranking signals: tag base, signature
|
|
12
|
+
technique mood overlap, curated .adg presence,
|
|
13
|
+
taste profile, anti-repeat, §1 banned defaults,
|
|
14
|
+
pad-opaque-M4L penalty.
|
|
15
|
+
|
|
16
|
+
Designed to be both invoked from KnowledgePack (brief-build time) AND from the
|
|
17
|
+
three new MCP tools (atlas_explore / atlas_audition / atlas_substitute) at
|
|
18
|
+
plan-design time. Same ranking logic, same candidate shape.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── Constants (memory rules §1, §7 #2) ──────────────────────────────
|
|
31
|
+
|
|
32
|
+
# §1: Banned as defaults for melodic roles unless mood says "analog".
|
|
33
|
+
# Mirrors `_BANNED_DEFAULT_DEVICES` in mcp_server/composer/fast/brief_builder.py
|
|
34
|
+
# so the two paths agree.
|
|
35
|
+
BANNED_DEFAULT_MELODIC: frozenset[str] = frozenset({"Analog", "Poli", "Drift", "Meld"})
|
|
36
|
+
|
|
37
|
+
# §1 pad-specific: opaque to the sound-design critic. Stacks with the banned-
|
|
38
|
+
# default penalty for pad role on Poli/Meld (intentional — both rules apply).
|
|
39
|
+
OPAQUE_M4L_FOR_PAD: frozenset[str] = frozenset({"Poli", "Meld"})
|
|
40
|
+
|
|
41
|
+
# Brief role → atlas tag candidates (matches fast/brief_builder.py _ROLE_TAGS).
|
|
42
|
+
_ROLE_TAGS: dict[str, tuple[str, ...]] = {
|
|
43
|
+
"kick": ("kick", "drum", "808"),
|
|
44
|
+
"snare": ("snare", "clap", "drum"),
|
|
45
|
+
"hat": ("hihat", "hi-hat", "hat", "cymbal"),
|
|
46
|
+
"perc": ("perc", "percussion", "drum"),
|
|
47
|
+
"clap": ("clap", "snare"),
|
|
48
|
+
"bass": ("bass", "sub_bass", "sub"),
|
|
49
|
+
"pad": ("pad", "texture", "atmos", "ambient"),
|
|
50
|
+
"lead": ("lead", "synth_lead", "pluck"),
|
|
51
|
+
"atmos": ("atmos", "ambient", "drone", "texture"),
|
|
52
|
+
"vocal_chop": ("vocal", "vox", "voice"),
|
|
53
|
+
"fx": ("fx", "effect", "transition"),
|
|
54
|
+
"spectral": ("spectral", "stretch", "freeze"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_MELODIC_ROLES: frozenset[str] = frozenset({"bass", "lead", "pad", "atmos", "vocal_chop"})
|
|
58
|
+
|
|
59
|
+
# pack_aware_compose's coarse roles → brief's finer roles.
|
|
60
|
+
# Used in resolve_anchors to spread one cohort pick across multiple fine roles.
|
|
61
|
+
_COARSE_TO_FINE_ROLES: dict[str, tuple[str, ...]] = {
|
|
62
|
+
"rhythmic-driver": ("kick", "snare", "hat"),
|
|
63
|
+
"bass": ("bass",),
|
|
64
|
+
"melodic": ("lead", "vocal_chop"),
|
|
65
|
+
"harmonic-foundation": ("pad",),
|
|
66
|
+
"wash": ("atmos",),
|
|
67
|
+
"fx-bus": ("fx",),
|
|
68
|
+
"spectral-processing": ("spectral",),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Dataclasses ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class AtlasCandidate:
|
|
77
|
+
"""A single device/preset candidate with full reasoning trail."""
|
|
78
|
+
uri: str
|
|
79
|
+
name: str
|
|
80
|
+
source: str = "atlas" # "atlas" | "extension_atlas:packs" | "extension_atlas:m4l-devices" | "browser" | "pack_aware_compose"
|
|
81
|
+
score: float = 0.0
|
|
82
|
+
character_tags: list[str] = field(default_factory=list)
|
|
83
|
+
signature_techniques: list[str] = field(default_factory=list)
|
|
84
|
+
in_pack: Optional[str] = None
|
|
85
|
+
has_curated_adg: bool = False
|
|
86
|
+
reasoning: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class AtlasAnchors:
|
|
91
|
+
"""Brief-level pre-resolution. Carried in KnowledgePack output. ~400 tokens."""
|
|
92
|
+
primary_pack: Optional[str] = None
|
|
93
|
+
pack_cohort: list[str] = field(default_factory=list)
|
|
94
|
+
anchor_producers: list[str] = field(default_factory=list)
|
|
95
|
+
anchor_genres: list[str] = field(default_factory=list)
|
|
96
|
+
primary_aesthetic: str = ""
|
|
97
|
+
vocab_tags: list[str] = field(default_factory=list)
|
|
98
|
+
techniques_in_play: list[str] = field(default_factory=list)
|
|
99
|
+
cohort_uris: dict[str, str] = field(default_factory=dict) # role → URI/preset slug
|
|
100
|
+
reasoning: str = ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── Resolver ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class AtlasResolver:
|
|
107
|
+
"""Layer-aware atlas knowledge resolver for the v1.25 hybrid surface."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
atlas: Any,
|
|
113
|
+
ableton: Any = None,
|
|
114
|
+
taste_profile: Optional[dict] = None,
|
|
115
|
+
recent_uris: Optional[set[str]] = None,
|
|
116
|
+
):
|
|
117
|
+
self._atlas = atlas
|
|
118
|
+
self._ableton = ableton
|
|
119
|
+
self._taste_profile: dict = dict(taste_profile or {})
|
|
120
|
+
self._recent_uris: set[str] = set(recent_uris or set())
|
|
121
|
+
|
|
122
|
+
# ── Layer A — anchors ───────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def resolve_anchors(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
brief_text: str,
|
|
128
|
+
genre: str = "",
|
|
129
|
+
mood: str = "",
|
|
130
|
+
artist_refs: Optional[list[str]] = None,
|
|
131
|
+
) -> AtlasAnchors:
|
|
132
|
+
"""Pre-resolve brief-level anchors via atlas_pack_aware_compose.
|
|
133
|
+
|
|
134
|
+
Wraps the existing v1.23 cohort tool (which already does coherent
|
|
135
|
+
pack/producer cluster selection across artist + genre vocabularies),
|
|
136
|
+
then maps its 7 coarse roles onto the brief's finer 8 roles via
|
|
137
|
+
_COARSE_TO_FINE_ROLES. Best-effort: if pack_aware_compose errors or
|
|
138
|
+
is unavailable, returns an empty AtlasAnchors (anchor_producers,
|
|
139
|
+
cohort_uris all empty) rather than raising.
|
|
140
|
+
"""
|
|
141
|
+
artist_refs = list(artist_refs or [])
|
|
142
|
+
try:
|
|
143
|
+
from ...atlas.pack_aware_compose import pack_aware_compose
|
|
144
|
+
except Exception as exc: # import error — corpus not installed
|
|
145
|
+
logger.debug("resolve_anchors: import pack_aware_compose failed: %s", exc)
|
|
146
|
+
return AtlasAnchors(reasoning=f"pack_aware_compose unavailable ({exc})")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
result = pack_aware_compose(
|
|
150
|
+
aesthetic_brief=brief_text or "",
|
|
151
|
+
target_bpm=None,
|
|
152
|
+
target_scale="",
|
|
153
|
+
track_count=8,
|
|
154
|
+
pack_diversity="coherent",
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
logger.debug("resolve_anchors: pack_aware_compose raised: %s", exc)
|
|
158
|
+
return AtlasAnchors(reasoning=f"pack_aware_compose raised ({exc})")
|
|
159
|
+
|
|
160
|
+
if not isinstance(result, dict) or result.get("error") or result.get("status") == "error":
|
|
161
|
+
err = (result or {}).get("error") if isinstance(result, dict) else "no result"
|
|
162
|
+
return AtlasAnchors(reasoning=f"pack_aware_compose error: {err}")
|
|
163
|
+
|
|
164
|
+
ba = result.get("brief_analysis") or {}
|
|
165
|
+
cohort = list(ba.get("pack_cohort") or result.get("pack_cohort") or [])
|
|
166
|
+
cohort_uris = self._map_pack_aware_roles(result.get("track_proposal") or [])
|
|
167
|
+
techniques = self._extract_techniques_for_packs(cohort)
|
|
168
|
+
|
|
169
|
+
# vocab_tags = anchor producers + genres + mood + primary aesthetic.
|
|
170
|
+
# De-dup while preserving order so the highest-priority tag stays first.
|
|
171
|
+
vocab: list[str] = []
|
|
172
|
+
seen: set[str] = set()
|
|
173
|
+
for v in (
|
|
174
|
+
[ba.get("primary_aesthetic", "") or ""]
|
|
175
|
+
+ list(ba.get("secondary_aesthetics") or [])
|
|
176
|
+
+ artist_refs
|
|
177
|
+
+ ([mood] if mood else [])
|
|
178
|
+
):
|
|
179
|
+
v = (v or "").strip()
|
|
180
|
+
if v and v.lower() not in seen:
|
|
181
|
+
seen.add(v.lower())
|
|
182
|
+
vocab.append(v)
|
|
183
|
+
|
|
184
|
+
return AtlasAnchors(
|
|
185
|
+
primary_pack=cohort[0] if cohort else None,
|
|
186
|
+
pack_cohort=cohort,
|
|
187
|
+
anchor_producers=list(ba.get("anchor_producers") or []),
|
|
188
|
+
anchor_genres=list(ba.get("anchor_genres") or []),
|
|
189
|
+
primary_aesthetic=ba.get("primary_aesthetic") or "",
|
|
190
|
+
vocab_tags=vocab,
|
|
191
|
+
techniques_in_play=techniques,
|
|
192
|
+
cohort_uris=cohort_uris,
|
|
193
|
+
reasoning=self._anchors_reasoning(ba, cohort, mood),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def _map_pack_aware_roles(track_proposal: list[dict]) -> dict[str, str]:
|
|
198
|
+
"""pack_aware_compose's coarse roles → brief's fine roles.
|
|
199
|
+
|
|
200
|
+
Track proposal entries carry `role` (coarse) and `preset` (slug
|
|
201
|
+
"pack/preset-name"). Spread the preset slug across the fine roles
|
|
202
|
+
the coarse role covers — the apply pass resolves the slug to a
|
|
203
|
+
runtime URI via search_browser.
|
|
204
|
+
"""
|
|
205
|
+
cohort_uris: dict[str, str] = {}
|
|
206
|
+
for tp in track_proposal:
|
|
207
|
+
coarse = tp.get("role") or ""
|
|
208
|
+
preset = (tp.get("preset") or "").strip()
|
|
209
|
+
if not preset:
|
|
210
|
+
continue
|
|
211
|
+
for fine in _COARSE_TO_FINE_ROLES.get(coarse, ()):
|
|
212
|
+
cohort_uris.setdefault(fine, preset)
|
|
213
|
+
return cohort_uris
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _extract_techniques_for_packs(cohort: list[str]) -> list[str]:
|
|
217
|
+
"""Pull signature_techniques cross-referenced for cohort packs.
|
|
218
|
+
|
|
219
|
+
v1.25 skeleton — returns []. Wired in v1.25.x once
|
|
220
|
+
device_techniques_index.json reverse-lookup is in place.
|
|
221
|
+
"""
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def _anchors_reasoning(brief_analysis: dict, cohort: list[str], mood: str) -> str:
|
|
226
|
+
bits: list[str] = []
|
|
227
|
+
aesthetic = brief_analysis.get("primary_aesthetic")
|
|
228
|
+
if aesthetic:
|
|
229
|
+
bits.append(f"primary aesthetic '{aesthetic}'")
|
|
230
|
+
if cohort:
|
|
231
|
+
bits.append(f"cohort: {', '.join(cohort[:3])}")
|
|
232
|
+
if mood:
|
|
233
|
+
bits.append(f"mood '{mood}'")
|
|
234
|
+
if not bits:
|
|
235
|
+
return "Default cohort (no aesthetic anchors detected in brief)."
|
|
236
|
+
return "Anchors derived from " + "; ".join(bits) + "."
|
|
237
|
+
|
|
238
|
+
# ── Layer B — per-role candidates ───────────────────────────
|
|
239
|
+
|
|
240
|
+
def resolve_for_role(
|
|
241
|
+
self,
|
|
242
|
+
*,
|
|
243
|
+
role: str,
|
|
244
|
+
genre: str = "",
|
|
245
|
+
mood: str = "",
|
|
246
|
+
artist_refs: Optional[list[str]] = None,
|
|
247
|
+
avoid: Optional[list[str]] = None,
|
|
248
|
+
cohort_constraint: Optional[list[str]] = None,
|
|
249
|
+
excluded_uris: Optional[set[str]] = None,
|
|
250
|
+
n: int = 5,
|
|
251
|
+
) -> list[AtlasCandidate]:
|
|
252
|
+
"""Resolve N ranked candidates for a single role.
|
|
253
|
+
|
|
254
|
+
Uses atlas tag indexes as primary. Deeper sources (extension_atlas,
|
|
255
|
+
search_browser) are deferred to v1.25.x once the surface is proven.
|
|
256
|
+
|
|
257
|
+
Returns list[AtlasCandidate] sorted by score descending, length <= n.
|
|
258
|
+
Empty list when atlas is None or no candidates pass filters.
|
|
259
|
+
"""
|
|
260
|
+
if self._atlas is None:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
artist_refs = list(artist_refs or [])
|
|
264
|
+
avoid_list = list(avoid or [])
|
|
265
|
+
excluded = set(excluded_uris or set())
|
|
266
|
+
cohort_set = set(cohort_constraint or [])
|
|
267
|
+
|
|
268
|
+
role_lower = (role or "").lower()
|
|
269
|
+
tags = _ROLE_TAGS.get(role_lower, (role_lower,))
|
|
270
|
+
|
|
271
|
+
# ── Source 1: factory atlas tag index ───────────────────────
|
|
272
|
+
seen_uris: set[str] = set()
|
|
273
|
+
candidates: list[tuple[dict, str]] = [] # (device, source_label)
|
|
274
|
+
by_tag = getattr(self._atlas, "_by_tag", {}) or {}
|
|
275
|
+
for tag in tags:
|
|
276
|
+
for dev in by_tag.get(str(tag).lower(), []):
|
|
277
|
+
uri = dev.get("uri") or ""
|
|
278
|
+
if not uri or uri in seen_uris or uri in excluded:
|
|
279
|
+
continue
|
|
280
|
+
if cohort_set and dev.get("pack") not in cohort_set:
|
|
281
|
+
continue
|
|
282
|
+
seen_uris.add(uri)
|
|
283
|
+
candidates.append((dev, "atlas"))
|
|
284
|
+
|
|
285
|
+
# ── Source 2: extension_atlas overlays (user corpus + packs + m4l-devices + demos) ──
|
|
286
|
+
# Per user mandate: full-mode resolve_for_role MUST union with the overlay
|
|
287
|
+
# corpus. Producer-curated rack instruments (808 Trap Selector Rack from
|
|
288
|
+
# Trap Drums by Sound Oracle, Harmonic Drone Generator from Drone Lab,
|
|
289
|
+
# user-scanned .amxd library, etc.) live in overlays only, never in the
|
|
290
|
+
# factory tag index. Best-effort: import failure leaves overlay results
|
|
291
|
+
# empty rather than raising.
|
|
292
|
+
overlay_devs = self._gather_from_overlays(
|
|
293
|
+
role=role_lower,
|
|
294
|
+
tags=tags,
|
|
295
|
+
cohort_set=cohort_set,
|
|
296
|
+
excluded=excluded,
|
|
297
|
+
seen_uris=seen_uris,
|
|
298
|
+
)
|
|
299
|
+
for dev in overlay_devs:
|
|
300
|
+
candidates.append((dev, dev.get("_overlay_source") or "extension_atlas"))
|
|
301
|
+
|
|
302
|
+
scored: list[tuple[float, AtlasCandidate]] = []
|
|
303
|
+
for dev, source_label in candidates:
|
|
304
|
+
score, reasoning = self._score(
|
|
305
|
+
dev,
|
|
306
|
+
role=role_lower,
|
|
307
|
+
genre=genre,
|
|
308
|
+
mood=mood,
|
|
309
|
+
artist_refs=artist_refs,
|
|
310
|
+
avoid=avoid_list,
|
|
311
|
+
)
|
|
312
|
+
# +0.15 for demo_project entries — ground-truth role→URI mappings
|
|
313
|
+
# from analyzed Ableton-shipped .als demos. Per user mandate these
|
|
314
|
+
# are the highest-confidence per-role anchors.
|
|
315
|
+
if source_label == "extension_atlas:demo_project":
|
|
316
|
+
score += 0.15
|
|
317
|
+
reasoning = f"{reasoning}; demo_project ground-truth (+0.15)"
|
|
318
|
+
scored.append((score, self._to_candidate(dev, score, reasoning, source=source_label)))
|
|
319
|
+
|
|
320
|
+
scored.sort(key=lambda x: -x[0])
|
|
321
|
+
return [c for _, c in scored[:n]]
|
|
322
|
+
|
|
323
|
+
def _gather_from_overlays(
|
|
324
|
+
self,
|
|
325
|
+
*,
|
|
326
|
+
role: str,
|
|
327
|
+
tags: tuple[str, ...],
|
|
328
|
+
cohort_set: set[str],
|
|
329
|
+
excluded: set[str],
|
|
330
|
+
seen_uris: set[str],
|
|
331
|
+
) -> list[dict]:
|
|
332
|
+
"""Pull role-matching candidates from extension_atlas overlay namespaces.
|
|
333
|
+
|
|
334
|
+
Searches THREE overlay surfaces:
|
|
335
|
+
1. `packs` namespace, entity_type="demo_project" — ground-truth role→URI
|
|
336
|
+
mappings extracted from analyzed factory pack .als demos. Highest
|
|
337
|
+
confidence per-role anchor source. Tagged "extension_atlas:demo_project".
|
|
338
|
+
2. `packs` / `m4l-devices` / `elektron` / `user.*` namespaces, all
|
|
339
|
+
entity_types — surfaces curated rack instruments and user-scanned
|
|
340
|
+
devices not in the factory tag index. Tagged "extension_atlas".
|
|
341
|
+
3. Per-tag substring search per role (e.g., "808" tag for kick/bass)
|
|
342
|
+
so producer-curated instruments with role-bearing tags are surfaced.
|
|
343
|
+
|
|
344
|
+
Returns list[dict] in atlas-candidate-shape (uri, name, character_tags,
|
|
345
|
+
signature_techniques, pack=namespace, _overlay_source).
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
from ...atlas.overlays import get_overlay_index
|
|
349
|
+
except Exception as exc: # overlay backend unavailable
|
|
350
|
+
logger.debug("_gather_from_overlays: overlay import failed: %s", exc)
|
|
351
|
+
return []
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
idx = get_overlay_index()
|
|
355
|
+
except Exception as exc:
|
|
356
|
+
logger.debug("_gather_from_overlays: get_overlay_index failed: %s", exc)
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
results: list[dict] = []
|
|
360
|
+
|
|
361
|
+
# Pass A — demo_project ground truth (limit 10 per tag, dedup downstream).
|
|
362
|
+
for tag in tags:
|
|
363
|
+
try:
|
|
364
|
+
matches = idx.search(
|
|
365
|
+
str(tag).lower(),
|
|
366
|
+
namespace="packs",
|
|
367
|
+
entity_type="demo_project",
|
|
368
|
+
limit=10,
|
|
369
|
+
)
|
|
370
|
+
except Exception:
|
|
371
|
+
continue
|
|
372
|
+
for entry in matches:
|
|
373
|
+
dev = self._overlay_entry_to_device(entry, source="extension_atlas:demo_project")
|
|
374
|
+
if not dev:
|
|
375
|
+
continue
|
|
376
|
+
uri = dev.get("uri") or ""
|
|
377
|
+
if uri and uri in seen_uris or uri in excluded:
|
|
378
|
+
continue
|
|
379
|
+
if cohort_set and dev.get("pack") not in cohort_set:
|
|
380
|
+
continue
|
|
381
|
+
if uri:
|
|
382
|
+
seen_uris.add(uri)
|
|
383
|
+
results.append(dev)
|
|
384
|
+
|
|
385
|
+
# Pass B — full overlay search across all namespaces and entity_types.
|
|
386
|
+
for tag in tags:
|
|
387
|
+
try:
|
|
388
|
+
matches = idx.search(str(tag).lower(), limit=10)
|
|
389
|
+
except Exception:
|
|
390
|
+
continue
|
|
391
|
+
for entry in matches:
|
|
392
|
+
# Skip duplicate hits already covered by Pass A.
|
|
393
|
+
if getattr(entry, "entity_type", "") == "demo_project":
|
|
394
|
+
continue
|
|
395
|
+
dev = self._overlay_entry_to_device(entry, source="extension_atlas")
|
|
396
|
+
if not dev:
|
|
397
|
+
continue
|
|
398
|
+
uri = dev.get("uri") or ""
|
|
399
|
+
if uri and uri in seen_uris or uri in excluded:
|
|
400
|
+
continue
|
|
401
|
+
if cohort_set and dev.get("pack") not in cohort_set:
|
|
402
|
+
continue
|
|
403
|
+
if uri:
|
|
404
|
+
seen_uris.add(uri)
|
|
405
|
+
results.append(dev)
|
|
406
|
+
|
|
407
|
+
return results
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def _overlay_entry_to_device(entry: Any, *, source: str) -> Optional[dict]:
|
|
411
|
+
"""Map an OverlayEntry to the atlas-candidate dict shape.
|
|
412
|
+
|
|
413
|
+
Overlay entries don't carry a load-able URI in the same shape as the
|
|
414
|
+
factory atlas; we synthesize one as `overlay://<namespace>/<entity_id>`
|
|
415
|
+
so the agent can still call extension_atlas_get(namespace, entity_id)
|
|
416
|
+
to resolve the actual loadable resource. The character_tags field
|
|
417
|
+
carries the overlay's tags so _score's mood-overlap heuristic still
|
|
418
|
+
fires.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
namespace = getattr(entry, "namespace", "") or ""
|
|
422
|
+
entity_id = getattr(entry, "entity_id", "") or ""
|
|
423
|
+
if not namespace or not entity_id:
|
|
424
|
+
return None
|
|
425
|
+
return {
|
|
426
|
+
"uri": f"overlay://{namespace}/{entity_id}",
|
|
427
|
+
"name": getattr(entry, "name", "") or entity_id,
|
|
428
|
+
"character_tags": list(getattr(entry, "tags", []) or []),
|
|
429
|
+
"signature_techniques": [],
|
|
430
|
+
"pack": namespace,
|
|
431
|
+
"has_curated_adg": False,
|
|
432
|
+
"_overlay_source": source,
|
|
433
|
+
"_overlay_namespace": namespace,
|
|
434
|
+
"_overlay_entity_id": entity_id,
|
|
435
|
+
}
|
|
436
|
+
except Exception:
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
# ── Layer C — ranking ───────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
def _score(
|
|
442
|
+
self,
|
|
443
|
+
dev: dict,
|
|
444
|
+
*,
|
|
445
|
+
role: str,
|
|
446
|
+
genre: str,
|
|
447
|
+
mood: str,
|
|
448
|
+
artist_refs: list[str],
|
|
449
|
+
avoid: list[str],
|
|
450
|
+
) -> tuple[float, str]:
|
|
451
|
+
"""Compute (score in [0, 1], reasoning string) for one candidate."""
|
|
452
|
+
score = 0.5 # baseline tag-match
|
|
453
|
+
reasons: list[str] = ["tag match"]
|
|
454
|
+
|
|
455
|
+
name = dev.get("name") or ""
|
|
456
|
+
sig_techs = list(dev.get("signature_techniques") or [])
|
|
457
|
+
|
|
458
|
+
mood_lower = (mood or "").lower()
|
|
459
|
+
genre_lower = (genre or "").lower()
|
|
460
|
+
|
|
461
|
+
# +0.20 signature_technique mood overlap (token-level)
|
|
462
|
+
if sig_techs and mood_lower:
|
|
463
|
+
mood_tokens = [tok for tok in mood_lower.split() if len(tok) > 3]
|
|
464
|
+
for tech in sig_techs:
|
|
465
|
+
tech_lower = str(tech).lower()
|
|
466
|
+
if any(tok in tech_lower for tok in mood_tokens):
|
|
467
|
+
score += 0.20
|
|
468
|
+
reasons.append(f"signature_technique '{tech}' matches mood")
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
# +0.10 has curated .adg sidecar
|
|
472
|
+
if self._has_curated_adg(dev):
|
|
473
|
+
score += 0.10
|
|
474
|
+
reasons.append("curated .adg sidecar")
|
|
475
|
+
|
|
476
|
+
# +0.10 recent positive preference
|
|
477
|
+
if name and self._taste_profile.get(name, {}).get("score", 0) > 0:
|
|
478
|
+
score += 0.10
|
|
479
|
+
reasons.append("recent positive preference")
|
|
480
|
+
|
|
481
|
+
# +0.10/+0.05 genre primary/secondary
|
|
482
|
+
if genre_lower:
|
|
483
|
+
primary, secondary = self._device_genres(dev)
|
|
484
|
+
if any(genre_lower in g for g in primary):
|
|
485
|
+
score += 0.10
|
|
486
|
+
reasons.append(f"genre primary '{genre}'")
|
|
487
|
+
elif any(genre_lower in g for g in secondary):
|
|
488
|
+
score += 0.05
|
|
489
|
+
reasons.append(f"genre secondary '{genre}'")
|
|
490
|
+
|
|
491
|
+
# −0.50 §1 banned default for melodic role, mood not "analog"
|
|
492
|
+
if role in _MELODIC_ROLES and name in BANNED_DEFAULT_MELODIC:
|
|
493
|
+
if "analog" not in mood_lower:
|
|
494
|
+
score -= 0.50
|
|
495
|
+
reasons.append(f"§1 banned default '{name}' (mood not 'analog')")
|
|
496
|
+
|
|
497
|
+
# −0.30 pad role with opaque M4L
|
|
498
|
+
if role == "pad" and name in OPAQUE_M4L_FOR_PAD:
|
|
499
|
+
score -= 0.30
|
|
500
|
+
reasons.append(f"opaque M4L pad '{name}' (sound-design critic blind)")
|
|
501
|
+
|
|
502
|
+
# −0.15 anti-repeat (recent_uris)
|
|
503
|
+
uri = dev.get("uri") or ""
|
|
504
|
+
if uri and uri in self._recent_uris:
|
|
505
|
+
score -= 0.15
|
|
506
|
+
reasons.append("recently used (§7 #2 anti-repeat)")
|
|
507
|
+
|
|
508
|
+
# −0.30 caller-supplied avoid-list
|
|
509
|
+
if name in avoid:
|
|
510
|
+
score -= 0.30
|
|
511
|
+
reasons.append("on caller-supplied avoid list")
|
|
512
|
+
|
|
513
|
+
score = max(0.0, min(1.0, score))
|
|
514
|
+
return score, "; ".join(reasons)
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _device_genres(dev: dict) -> tuple[list[str], list[str]]:
|
|
518
|
+
"""Return (primary, secondary) genre lists, lowercased, dup-tolerant."""
|
|
519
|
+
primary: list[str] = []
|
|
520
|
+
secondary: list[str] = []
|
|
521
|
+
for key in ("genre_affinity", "genres"):
|
|
522
|
+
container = dev.get(key) or {}
|
|
523
|
+
if not isinstance(container, dict):
|
|
524
|
+
continue
|
|
525
|
+
for g in container.get("primary", []) or []:
|
|
526
|
+
primary.append(str(g).lower())
|
|
527
|
+
for g in container.get("secondary", []) or []:
|
|
528
|
+
secondary.append(str(g).lower())
|
|
529
|
+
return primary, secondary
|
|
530
|
+
|
|
531
|
+
@staticmethod
|
|
532
|
+
def _has_curated_adg(dev: dict) -> bool:
|
|
533
|
+
"""Heuristic: has curated .adg if explicit flag, .adg URI, or /adg/ path hint."""
|
|
534
|
+
if dev.get("has_curated_adg"):
|
|
535
|
+
return True
|
|
536
|
+
uri = (dev.get("uri") or "").lower()
|
|
537
|
+
if uri.endswith(".adg") or "/adg/" in uri:
|
|
538
|
+
return True
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _to_candidate(dev: dict, score: float, reasoning: str, *, source: str) -> AtlasCandidate:
|
|
543
|
+
char_tags = list(dev.get("character_tags") or dev.get("tags") or [])
|
|
544
|
+
return AtlasCandidate(
|
|
545
|
+
uri=dev.get("uri") or "",
|
|
546
|
+
name=dev.get("name") or "",
|
|
547
|
+
source=source,
|
|
548
|
+
score=score,
|
|
549
|
+
character_tags=char_tags,
|
|
550
|
+
signature_techniques=list(dev.get("signature_techniques") or []),
|
|
551
|
+
in_pack=dev.get("pack"),
|
|
552
|
+
has_curated_adg=AtlasResolver._has_curated_adg(dev),
|
|
553
|
+
reasoning=reasoning,
|
|
554
|
+
)
|