emotion_diff.py
python
| 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) |