cgcardona / muse public
emotion_diff.py python
383 lines 11.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse emotion-diff — compare emotion vectors between two commits.
2
3 Answers "how did the emotional character of my composition change?" by comparing
4 two commits' emotion profiles. When explicit ``emotion:*`` tags exist (set via
5 ``muse tag add emotion:<label> <commit>``), those are diffed directly. When tags
6 are absent, the engine infers an emotion vector from available musical metadata
7 (tempo, commit annotation) and reports it alongside an ``[inferred]`` notice.
8
9 Usage
10 -----
11 ::
12
13 # Compare HEAD~1 to HEAD (most common usage)
14 muse emotion-diff
15
16 # Compare specific commits
17 muse emotion-diff a1b2c3d4 f9e8d7c6
18
19 # Scope to keyboard tracks only
20 muse emotion-diff HEAD~1 HEAD --track keys
21
22 # Machine-readable JSON for agent consumption
23 muse emotion-diff HEAD~5 HEAD --json
24
25 Output example (text mode)
26 --------------------------
27 ::
28
29 Emotion diff — a1b2c3d4 → f9e8d7c6
30 Source: explicit_tags
31
32 Commit A (a1b2c3d4): melancholic
33 Commit B (f9e8d7c6): joyful
34
35 Dimension Commit A Commit B Delta
36 ----------- -------- -------- -----
37 energy 0.3000 0.8000 +0.5000
38 valence 0.3000 0.9000 +0.6000
39 tension 0.4000 0.2000 -0.2000
40 darkness 0.6000 0.1000 -0.5000
41
42 Drift: 0.9747 (major)
43 melancholic → joyful (+valence, -darkness) [explicit_tags]
44
45 Output example (JSON mode)
46 --------------------------
47 ::
48
49 {
50 "commit_a": "a1b2c3d4",
51 "commit_b": "f9e8d7c6",
52 "source": "explicit_tags",
53 "label_a": "melancholic",
54 "label_b": "joyful",
55 "vector_a": {"energy": 0.3, "valence": 0.3, "tension": 0.4, "darkness": 0.6},
56 "vector_b": {"energy": 0.8, "valence": 0.9, "tension": 0.2, "darkness": 0.1},
57 "dimensions": [...],
58 "drift": 0.9747,
59 "narrative": "...",
60 "track": null,
61 "section": null
62 }
63
64 Flags
65 -----
66 ``COMMIT_A`` First (baseline) commit ref. Default: HEAD~1.
67 ``COMMIT_B`` Second (target) commit ref. Default: HEAD.
68 ``--track TEXT`` Scope analysis to a specific track (noted; full per-track
69 scoping requires MIDI content — tracked as follow-up).
70 ``--section TEXT`` Scope to a named section (same stub note as --track).
71 ``--json`` Emit structured JSON for agent or tool consumption.
72 """
73 from __future__ import annotations
74
75 import asyncio
76 import json
77 import logging
78 import pathlib
79 from typing import Optional
80
81 import typer
82 from sqlalchemy.ext.asyncio import AsyncSession
83 from typing_extensions import TypedDict
84
85 from maestro.muse_cli._repo import require_repo
86 from maestro.muse_cli.db import open_session
87 from maestro.muse_cli.errors import ExitCode
88 from maestro.services.muse_emotion_diff import (
89 EmotionDiffResult,
90 EmotionVector,
91 compute_emotion_diff,
92 )
93
94 logger = logging.getLogger(__name__)
95
96 app = typer.Typer()
97
98 # ---------------------------------------------------------------------------
99 # JSON serialisation types
100 # ---------------------------------------------------------------------------
101
102
103 class _VectorJson(TypedDict):
104 """JSON representation of an :class:`~maestro.services.muse_emotion_diff.EmotionVector`."""
105
106 energy: float
107 valence: float
108 tension: float
109 darkness: float
110
111
112 class _DimDeltaJson(TypedDict):
113 """JSON representation of a single dimension delta."""
114
115 dimension: str
116 value_a: float
117 value_b: float
118 delta: float
119
120
121 class _EmotionDiffJson(TypedDict):
122 """JSON representation of a full emotion-diff result."""
123
124 commit_a: str
125 commit_b: str
126 source: str
127 label_a: str | None
128 label_b: str | None
129 vector_a: _VectorJson | None
130 vector_b: _VectorJson | None
131 dimensions: list[_DimDeltaJson]
132 drift: float
133 narrative: str
134 track: str | None
135 section: str | None
136
137
138 # ---------------------------------------------------------------------------
139 # Helpers
140 # ---------------------------------------------------------------------------
141
142
143 def _vec_to_json(vec: EmotionVector) -> _VectorJson:
144 return {
145 "energy": vec.energy,
146 "valence": vec.valence,
147 "tension": vec.tension,
148 "darkness": vec.darkness,
149 }
150
151
152 def _resolve_branch(root: pathlib.Path) -> str:
153 """Read the current branch name from ``.muse/HEAD``."""
154 head_file = root / ".muse" / "HEAD"
155 if not head_file.exists():
156 return "main"
157 head_ref = head_file.read_text().strip()
158 return head_ref.rsplit("/", 1)[-1] if "/" in head_ref else head_ref
159
160
161 def _resolve_repo_id(root: pathlib.Path) -> str:
162 """Read repo_id from ``.muse/repo.json``."""
163 repo_json = root / ".muse" / "repo.json"
164 data: dict[str, str] = json.loads(repo_json.read_text())
165 return data["repo_id"]
166
167
168 # ---------------------------------------------------------------------------
169 # Renderers
170 # ---------------------------------------------------------------------------
171
172 _COL_WIDTHS = (11, 9, 9, 8) # dimension, commit_a, commit_b, delta
173
174
175 def render_text(result: EmotionDiffResult) -> None:
176 """Write a human-readable emotion-diff table via :func:`typer.echo`.
177
178 Args:
179 result: The emotion-diff result to render.
180 """
181 typer.echo(f"Emotion diff — {result.commit_a} → {result.commit_b}")
182 typer.echo(f"Source: {result.source}")
183 typer.echo("")
184
185 label_a_str = result.label_a or "(inferred)"
186 label_b_str = result.label_b or "(inferred)"
187 typer.echo(f"Commit A ({result.commit_a}): {label_a_str}")
188 typer.echo(f"Commit B ({result.commit_b}): {label_b_str}")
189
190 if result.track:
191 typer.echo(f"Track filter: {result.track}")
192 typer.echo("⚠️ Per-track emotion scoping not yet implemented — showing full-commit vectors.")
193 if result.section:
194 typer.echo(f"Section filter: {result.section}")
195 typer.echo("⚠️ Section-scoped emotion analysis not yet implemented.")
196
197 typer.echo("")
198
199 if result.vector_a is None or result.vector_b is None:
200 typer.echo("⚠️ One or both commits have no emotion data available.")
201 return
202
203 # Header
204 header = (
205 f"{'Dimension':<{_COL_WIDTHS[0]}} "
206 f"{'Commit A':>{_COL_WIDTHS[1]}} "
207 f"{'Commit B':>{_COL_WIDTHS[2]}} "
208 f"{'Delta':>{_COL_WIDTHS[3]}}"
209 )
210 sep = (
211 f"{'-' * _COL_WIDTHS[0]} "
212 f"{'-' * _COL_WIDTHS[1]} "
213 f"{'-' * _COL_WIDTHS[2]} "
214 f"{'-' * _COL_WIDTHS[3]}"
215 )
216 typer.echo(header)
217 typer.echo(sep)
218
219 for dim in result.dimensions:
220 sign = "+" if dim.delta > 0 else ""
221 typer.echo(
222 f"{dim.dimension:<{_COL_WIDTHS[0]}} "
223 f"{dim.value_a:>{_COL_WIDTHS[1]}.4f} "
224 f"{dim.value_b:>{_COL_WIDTHS[2]}.4f} "
225 f"{sign}{dim.delta:>{_COL_WIDTHS[3] - 1}.4f}"
226 )
227
228 typer.echo("")
229 typer.echo(f"Drift: {result.drift:.4f}")
230 typer.echo(result.narrative)
231
232
233 def render_json(result: EmotionDiffResult) -> None:
234 """Write a machine-readable JSON emotion-diff report via :func:`typer.echo`.
235
236 Args:
237 result: The emotion-diff result to render.
238 """
239 payload: _EmotionDiffJson = {
240 "commit_a": result.commit_a,
241 "commit_b": result.commit_b,
242 "source": result.source,
243 "label_a": result.label_a,
244 "label_b": result.label_b,
245 "vector_a": _vec_to_json(result.vector_a) if result.vector_a else None,
246 "vector_b": _vec_to_json(result.vector_b) if result.vector_b else None,
247 "dimensions": [
248 {
249 "dimension": d.dimension,
250 "value_a": d.value_a,
251 "value_b": d.value_b,
252 "delta": d.delta,
253 }
254 for d in result.dimensions
255 ],
256 "drift": result.drift,
257 "narrative": result.narrative,
258 "track": result.track,
259 "section": result.section,
260 }
261 typer.echo(json.dumps(payload, indent=2))
262
263
264 # ---------------------------------------------------------------------------
265 # Testable async core
266 # ---------------------------------------------------------------------------
267
268
269 async def _emotion_diff_async(
270 *,
271 root: pathlib.Path,
272 session: AsyncSession,
273 commit_a: str,
274 commit_b: str,
275 track: str | None,
276 section: str | None,
277 as_json: bool,
278 ) -> None:
279 """Core emotion-diff logic — fully injectable for tests.
280
281 Reads repository configuration from ``.muse/``, delegates to
282 :func:`~maestro.services.muse_emotion_diff.compute_emotion_diff`, and
283 renders the result in text or JSON format.
284
285 Args:
286 root: Repository root (directory containing ``.muse/``).
287 session: Open async DB session.
288 commit_a: First commit ref (baseline).
289 commit_b: Second commit ref (target).
290 track: Optional track name filter.
291 section: Optional section name filter.
292 as_json: If ``True``, render JSON; otherwise render text table.
293 """
294 branch = _resolve_branch(root)
295 repo_id = _resolve_repo_id(root)
296
297 try:
298 result = await compute_emotion_diff(
299 session,
300 repo_id=repo_id,
301 commit_a=commit_a,
302 commit_b=commit_b,
303 branch=branch,
304 track=track,
305 section=section,
306 )
307 except ValueError as exc:
308 typer.echo(f"❌ {exc}")
309 raise typer.Exit(code=ExitCode.USER_ERROR)
310
311 if as_json:
312 render_json(result)
313 else:
314 render_text(result)
315
316
317 # ---------------------------------------------------------------------------
318 # Typer command
319 # ---------------------------------------------------------------------------
320
321
322 @app.callback(invoke_without_command=True)
323 def emotion_diff(
324 ctx: typer.Context,
325 commit_a: str = typer.Argument(
326 "HEAD~1",
327 help="Baseline commit ref (default: HEAD~1).",
328 metavar="COMMIT_A",
329 ),
330 commit_b: str = typer.Argument(
331 "HEAD",
332 help="Target commit ref (default: HEAD).",
333 metavar="COMMIT_B",
334 ),
335 track: Optional[str] = typer.Option(
336 None,
337 "--track",
338 help="Scope analysis to a specific track (case-insensitive prefix match).",
339 metavar="TEXT",
340 ),
341 section: Optional[str] = typer.Option(
342 None,
343 "--section",
344 help="Scope analysis to a named section/region.",
345 metavar="TEXT",
346 ),
347 as_json: bool = typer.Option(
348 False,
349 "--json/--no-json",
350 help="Emit structured JSON for agent or tool consumption.",
351 ),
352 ) -> None:
353 """Compare emotion vectors between two commits.
354
355 Reads ``emotion:*`` tags on COMMIT_A and COMMIT_B and reports the shift
356 in emotional space. When explicit tags are absent, infers emotion from
357 available musical metadata and notes the inference source.
358
359 Defaults to comparing HEAD~1 against HEAD so that ``muse emotion-diff``
360 shows how the most recent commit changed the emotional character.
361 """
362 root = require_repo()
363
364 async def _run() -> None:
365 async with open_session() as session:
366 await _emotion_diff_async(
367 root=root,
368 session=session,
369 commit_a=commit_a,
370 commit_b=commit_b,
371 track=track,
372 section=section,
373 as_json=as_json,
374 )
375
376 try:
377 asyncio.run(_run())
378 except typer.Exit:
379 raise
380 except Exception as exc:
381 typer.echo(f"❌ muse emotion-diff failed: {exc}")
382 logger.error("❌ muse emotion-diff error: %s", exc, exc_info=True)
383 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)