cgcardona / muse public
muse_context.py python
385 lines 13.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Context service — structured musical state document for AI agent consumption.
2
3 This is the primary read-side interface between Muse VCS and AI music generation
4 agents. ``build_muse_context()`` traverses the commit graph to produce a
5 self-contained ``MuseContextResult`` describing the current musical state of a
6 repository at a given commit (or HEAD).
7
8 When Maestro receives a "generate a new section" request, it calls this service
9 to obtain the full musical context, passes it to the LLM, and the LLM generates
10 music that is harmonically, rhythmically, and structurally coherent with the
11 existing composition.
12
13 Design notes
14 ------------
15 - **Read-only**: this service never writes to the DB.
16 - **Deterministic**: for the same commit_id, the output is always identical.
17 - **Active tracks**: derived from file paths in the snapshot manifest.
18 - **Musical dimensions** (key, tempo, form, harmony): currently None — these
19 require MIDI analysis from the Storpheus service and are not yet integrated.
20 The schema is fully defined so agents can handle None gracefully today and
21 receive populated values once Storpheus integration lands.
22 """
23 from __future__ import annotations
24
25 import json
26 import logging
27 import pathlib
28 from dataclasses import asdict, dataclass, field
29 from typing import Optional
30
31 from sqlalchemy.ext.asyncio import AsyncSession
32
33 from maestro.muse_cli.models import MuseCliCommit, MuseCliSnapshot
34
35 logger = logging.getLogger(__name__)
36
37 # ---------------------------------------------------------------------------
38 # Result types — every public function signature is a contract
39 # ---------------------------------------------------------------------------
40
41
42 @dataclass(frozen=True)
43 class MuseHeadCommitInfo:
44 """Metadata for the commit that the context document was built from."""
45
46 commit_id: str
47 message: str
48 author: str
49 committed_at: str # ISO-8601 UTC
50
51
52 @dataclass(frozen=True)
53 class MuseSectionDetail:
54 """Per-section musical detail surfaced when ``--sections`` is requested.
55
56 ``bars`` is None until MIDI region analysis is integrated.
57 """
58
59 tracks: list[str]
60 bars: Optional[int] = None
61
62
63 @dataclass(frozen=True)
64 class MuseHarmonicProfile:
65 """Harmonic summary — None fields require Storpheus MIDI analysis."""
66
67 chord_progression: Optional[list[str]] = None
68 tension_score: Optional[float] = None
69 harmonic_rhythm: Optional[float] = None
70
71
72 @dataclass(frozen=True)
73 class MuseDynamicProfile:
74 """Dynamic (volume/intensity) summary — None fields require MIDI analysis."""
75
76 avg_velocity: Optional[int] = None
77 dynamic_arc: Optional[str] = None
78 peak_section: Optional[str] = None
79
80
81 @dataclass(frozen=True)
82 class MuseMelodicProfile:
83 """Melodic contour summary — None fields require MIDI analysis."""
84
85 contour: Optional[str] = None
86 range_semitones: Optional[int] = None
87 motifs_detected: Optional[int] = None
88
89
90 @dataclass(frozen=True)
91 class MuseTrackDetail:
92 """Per-track harmonic and dynamic breakdown (``--tracks`` flag)."""
93
94 track_name: str
95 harmonic: MuseHarmonicProfile = field(default_factory=MuseHarmonicProfile)
96 dynamic: MuseDynamicProfile = field(default_factory=MuseDynamicProfile)
97
98
99 @dataclass(frozen=True)
100 class MuseMusicalState:
101 """Full musical state of the project at a given commit.
102
103 ``active_tracks`` is populated from the snapshot manifest file names.
104 All other fields are None until Storpheus MIDI analysis is integrated.
105 Agents should treat None values as unknown and generate accordingly.
106 """
107
108 active_tracks: list[str]
109 key: Optional[str] = None
110 mode: Optional[str] = None
111 tempo_bpm: Optional[int] = None
112 time_signature: Optional[str] = None
113 swing_factor: Optional[float] = None
114 form: Optional[str] = None
115 emotion: Optional[str] = None
116 sections: Optional[dict[str, MuseSectionDetail]] = None
117 tracks: Optional[list[MuseTrackDetail]] = None
118 harmonic_profile: Optional[MuseHarmonicProfile] = None
119 dynamic_profile: Optional[MuseDynamicProfile] = None
120 melodic_profile: Optional[MuseMelodicProfile] = None
121
122
123 @dataclass(frozen=True)
124 class MuseHistoryEntry:
125 """A single ancestor commit in the evolutionary history of the composition.
126
127 Produced for each of the N most-recent ancestors when ``--depth`` > 0.
128 """
129
130 commit_id: str
131 message: str
132 author: str
133 committed_at: str # ISO-8601 UTC
134 active_tracks: list[str]
135 key: Optional[str] = None
136 tempo_bpm: Optional[int] = None
137 emotion: Optional[str] = None
138
139
140 @dataclass(frozen=True)
141 class MuseContextResult:
142 """Complete musical context document for AI agent consumption.
143
144 Returned by ``build_muse_context()``. Self-contained: an agent receiving
145 only this document has everything it needs to generate structurally and
146 stylistically coherent music.
147
148 Use ``to_dict()`` before serialising to JSON or YAML.
149 """
150
151 repo_id: str
152 current_branch: str
153 head_commit: MuseHeadCommitInfo
154 musical_state: MuseMusicalState
155 history: list[MuseHistoryEntry]
156 missing_elements: list[str]
157 suggestions: dict[str, str]
158
159 def to_dict(self) -> dict[str, object]:
160 """Recursively convert to a plain dict suitable for json.dumps / yaml.dump."""
161 raw: dict[str, object] = asdict(self)
162 return raw
163
164
165 # ---------------------------------------------------------------------------
166 # Internal helpers
167 # ---------------------------------------------------------------------------
168
169 _MUSIC_FILE_EXTENSIONS = frozenset(
170 {".mid", ".midi", ".mp3", ".wav", ".aiff", ".aif", ".flac"}
171 )
172
173
174 def _extract_track_names(manifest: dict[str, str]) -> list[str]:
175 """Derive human-readable track names from snapshot manifest file paths.
176
177 Files with recognised music extensions whose stems do not look like raw
178 SHA-256 hashes are treated as track names. The stem is lowercased and
179 de-duplicated.
180
181 Example:
182 ``{"drums.mid": "abc123", "bass.mid": "def456"}`` → ``["bass", "drums"]``
183 """
184 tracks: list[str] = []
185 for path_str in manifest:
186 p = pathlib.PurePosixPath(path_str)
187 if p.suffix.lower() in _MUSIC_FILE_EXTENSIONS:
188 stem = p.stem.lower()
189 # Skip stems that look like raw SHA-256 hashes (64 hex chars)
190 if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem):
191 continue
192 tracks.append(stem)
193 return sorted(set(tracks))
194
195
196 async def _load_commit(session: AsyncSession, commit_id: str) -> MuseCliCommit | None:
197 """Fetch a single MuseCliCommit by primary key."""
198 return await session.get(MuseCliCommit, commit_id)
199
200
201 async def _load_snapshot(
202 session: AsyncSession, snapshot_id: str
203 ) -> MuseCliSnapshot | None:
204 """Fetch a single MuseCliSnapshot by primary key."""
205 return await session.get(MuseCliSnapshot, snapshot_id)
206
207
208 def _read_repo_meta(root: pathlib.Path) -> tuple[str, str]:
209 """Return (repo_id, current_branch) from .muse metadata files.
210
211 Reads ``.muse/repo.json`` and ``.muse/HEAD`` synchronously — these are
212 tiny JSON/text files that do not warrant async I/O.
213 """
214 muse_dir = root / ".muse"
215 repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text())
216 repo_id = repo_data["repo_id"]
217 head_ref = (muse_dir / "HEAD").read_text().strip()
218 branch = head_ref.rsplit("/", 1)[-1] if "/" in head_ref else head_ref
219 return repo_id, branch
220
221
222 def _read_head_commit_id(root: pathlib.Path) -> str | None:
223 """Return the HEAD commit ID from the .muse filesystem layout, or None."""
224 muse_dir = root / ".muse"
225 head_ref = (muse_dir / "HEAD").read_text().strip()
226 ref_path = muse_dir / pathlib.Path(head_ref)
227 if not ref_path.exists():
228 return None
229 cid = ref_path.read_text().strip()
230 return cid or None
231
232
233 async def _build_history(
234 session: AsyncSession,
235 start_commit: MuseCliCommit,
236 depth: int,
237 ) -> list[MuseHistoryEntry]:
238 """Walk the parent chain, returning up to *depth* ancestor entries.
239
240 The *start_commit* (HEAD) is NOT included — it is surfaced separately as
241 ``head_commit`` in the result. Entries are returned newest-first.
242 """
243 entries: list[MuseHistoryEntry] = []
244 current_id: str | None = start_commit.parent_commit_id
245
246 while current_id and len(entries) < depth:
247 commit = await _load_commit(session, current_id)
248 if commit is None:
249 logger.warning("⚠️ History chain broken at %s", current_id[:8])
250 break
251
252 tracks: list[str] = []
253 snapshot = await _load_snapshot(session, commit.snapshot_id)
254 if snapshot is not None and snapshot.manifest:
255 tracks = _extract_track_names(snapshot.manifest)
256
257 entries.append(
258 MuseHistoryEntry(
259 commit_id=commit.commit_id,
260 message=commit.message,
261 author=commit.author,
262 committed_at=commit.committed_at.isoformat(),
263 active_tracks=tracks,
264 )
265 )
266 current_id = commit.parent_commit_id
267
268 return entries
269
270
271 # ---------------------------------------------------------------------------
272 # Public API
273 # ---------------------------------------------------------------------------
274
275
276 async def build_muse_context(
277 session: AsyncSession,
278 *,
279 root: pathlib.Path,
280 commit_id: str | None = None,
281 depth: int = 5,
282 include_sections: bool = False,
283 include_tracks: bool = False,
284 include_history: bool = False,
285 ) -> MuseContextResult:
286 """Build a complete musical context document for AI agent consumption.
287
288 Traverses the commit graph starting from *commit_id* (or HEAD when None)
289 and returns a self-contained ``MuseContextResult``.
290
291 The output is deterministic: for the same ``commit_id`` and flags, the
292 output is always identical, making it safe to cache and reproduce.
293
294 Args:
295 session: Open async DB session. Read-only — no writes performed.
296 root: Repository root (the directory containing ``.muse/``).
297 commit_id: Target commit ID, or None to use HEAD.
298 depth: Number of ancestor commits to include in ``history``.
299 Pass 0 to omit history entirely.
300 include_sections: When True, expand section-level detail in
301 ``musical_state.sections``. Sections are currently
302 stubbed (one "main" section) until MIDI region
303 metadata is integrated.
304 include_tracks: When True, add per-track harmonic/dynamic detail in
305 ``musical_state.tracks``.
306 include_history: Reserved for future use — will annotate each history
307 entry with dimensional deltas once Storpheus MIDI
308 analysis is integrated.
309
310 Returns:
311 MuseContextResult — serialise with ``.to_dict()`` before JSON/YAML output.
312
313 Raises:
314 ValueError: If *commit_id* is provided but not found in the DB.
315 RuntimeError: If the repository has no commits yet and commit_id is None.
316 """
317 repo_id, branch = _read_repo_meta(root)
318
319 # Resolve the target commit
320 if commit_id is None:
321 resolved_id = _read_head_commit_id(root)
322 if not resolved_id:
323 raise RuntimeError(
324 "Repository has no commits yet. Run `muse commit` first."
325 )
326 else:
327 resolved_id = commit_id
328
329 head_commit_row = await _load_commit(session, resolved_id)
330 if head_commit_row is None:
331 raise ValueError(f"Commit {resolved_id!r} not found in DB.")
332
333 # Derive active tracks from the snapshot manifest
334 snapshot = await _load_snapshot(session, head_commit_row.snapshot_id)
335 manifest: dict[str, str] = snapshot.manifest if snapshot is not None else {}
336 active_tracks = _extract_track_names(manifest)
337
338 # Optional sections expansion
339 sections: dict[str, MuseSectionDetail] | None = None
340 if include_sections:
341 sections = {"main": MuseSectionDetail(tracks=active_tracks)}
342
343 # Optional per-track detail
344 track_details: list[MuseTrackDetail] | None = None
345 if include_tracks and active_tracks:
346 track_details = [
347 MuseTrackDetail(
348 track_name=t,
349 harmonic=MuseHarmonicProfile(),
350 dynamic=MuseDynamicProfile(),
351 )
352 for t in active_tracks
353 ]
354
355 musical_state = MuseMusicalState(
356 active_tracks=active_tracks,
357 sections=sections,
358 tracks=track_details,
359 )
360
361 head_commit_info = MuseHeadCommitInfo(
362 commit_id=head_commit_row.commit_id,
363 message=head_commit_row.message,
364 author=head_commit_row.author,
365 committed_at=head_commit_row.committed_at.isoformat(),
366 )
367
368 history = await _build_history(session, head_commit_row, depth=depth)
369
370 logger.info(
371 "✅ Muse context built for commit %s (depth=%d, tracks=%d)",
372 resolved_id[:8],
373 depth,
374 len(active_tracks),
375 )
376
377 return MuseContextResult(
378 repo_id=repo_id,
379 current_branch=branch,
380 head_commit=head_commit_info,
381 musical_state=musical_state,
382 history=history,
383 missing_elements=[],
384 suggestions={},
385 )