cgcardona / muse public
muse_timeline.py python
258 lines 8.3 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Muse Timeline — chronological view of a composition's musical evolution.
2
3 Builds a commit-by-commit timeline from oldest to newest, enriching each
4 commit with music-semantic metadata extracted from associated tags
5 (emotion:*, section:*, track:*). This is the "album liner notes" view of
6 a project's creative arc.
7
8 The service queries:
9 1. ``muse_cli_commits`` — ordered chronologically (oldest-first).
10 2. ``muse_cli_tags`` — joined to extract emotion, section, and track tags
11 per commit.
12
13 Emotion tags (``emotion:melancholic``), section tags (``section:chorus``),
14 and track tags (``track:bass``) are extracted by namespace prefix. When no
15 tags exist the corresponding fields default to ``None`` / empty lists.
16
17 Result types
18 ------------
19 - :class:`MuseTimelineEntry` — a single commit in the timeline.
20 - :class:`MuseTimelineResult` — the full ordered collection + section/emotion
21 summary computed across all entries.
22
23 Callers
24 -------
25 ``maestro.muse_cli.commands.timeline`` is the primary consumer. Future
26 agents may call :func:`build_timeline` directly to derive emotion arcs or
27 section progress maps for generative decisions.
28 """
29 from __future__ import annotations
30
31 import logging
32 from dataclasses import dataclass, field
33 from datetime import datetime
34
35 from sqlalchemy import select
36 from sqlalchemy.ext.asyncio import AsyncSession
37
38 from maestro.muse_cli.models import MuseCliCommit, MuseCliTag
39
40 logger = logging.getLogger(__name__)
41
42 _EMOTION_PREFIX = "emotion:"
43 _SECTION_PREFIX = "section:"
44 _TRACK_PREFIX = "track:"
45
46
47 # ---------------------------------------------------------------------------
48 # Result types
49 # ---------------------------------------------------------------------------
50
51
52 @dataclass(frozen=True)
53 class MuseTimelineEntry:
54 """A single commit in the musical timeline.
55
56 All music-semantic fields are derived from tags attached to the commit.
57 Missing tags produce ``None`` (emotion, section) or empty lists (tracks).
58
59 Fields
60 ------
61 commit_id: Full SHA-256 commit ID.
62 short_id: First 7 characters for display purposes.
63 committed_at: Commit timestamp (UTC).
64 message: Commit message (the human-authored intent label).
65 emotion: First ``emotion:*`` tag value, stripped of the prefix.
66 sections: All ``section:*`` tag values, stripped of the prefix.
67 tracks: All ``track:*`` tag values, stripped of the prefix.
68 activity: Number of tracks modified — used to compute block width.
69 """
70
71 commit_id: str
72 short_id: str
73 committed_at: datetime
74 message: str
75 emotion: str | None
76 sections: tuple[str, ...]
77 tracks: tuple[str, ...]
78 activity: int
79
80
81 @dataclass(frozen=True)
82 class MuseTimelineResult:
83 """Full chronological timeline for a single repository branch.
84
85 ``entries`` is oldest-first. ``emotion_arc`` lists the unique
86 emotion values in chronological order of first appearance.
87 ``section_order`` lists section names in order of first commit.
88
89 Fields
90 ------
91 entries: Ordered timeline entries (oldest → newest).
92 branch: Branch name this timeline was built from.
93 emotion_arc: Ordered sequence of unique emotion labels (oldest first).
94 section_order: Ordered sequence of unique section names (oldest first).
95 total_commits: Total number of commits in the timeline.
96 """
97
98 entries: tuple[MuseTimelineEntry, ...]
99 branch: str
100 emotion_arc: tuple[str, ...]
101 section_order: tuple[str, ...]
102 total_commits: int
103
104
105 # ---------------------------------------------------------------------------
106 # Internal helpers
107 # ---------------------------------------------------------------------------
108
109
110 def _extract_prefix(tag: str, prefix: str) -> str | None:
111 """Return the value after *prefix* if *tag* starts with it, else None."""
112 if tag.startswith(prefix):
113 return tag[len(prefix):]
114 return None
115
116
117 def _group_tags_by_commit(
118 tags: list[MuseCliTag],
119 ) -> dict[str, list[str]]:
120 """Build a mapping of commit_id → list of tag strings."""
121 grouped: dict[str, list[str]] = {}
122 for t in tags:
123 grouped.setdefault(t.commit_id, []).append(t.tag)
124 return grouped
125
126
127 def _make_entry(
128 commit: MuseCliCommit,
129 tag_strings: list[str],
130 ) -> MuseTimelineEntry:
131 """Construct a :class:`MuseTimelineEntry` from a commit row and its tags."""
132 emotions: list[str] = []
133 sections: list[str] = []
134 tracks: list[str] = []
135
136 for tag in tag_strings:
137 emotion_val = _extract_prefix(tag, _EMOTION_PREFIX)
138 if emotion_val is not None:
139 emotions.append(emotion_val)
140 continue
141 section_val = _extract_prefix(tag, _SECTION_PREFIX)
142 if section_val is not None:
143 sections.append(section_val)
144 continue
145 track_val = _extract_prefix(tag, _TRACK_PREFIX)
146 if track_val is not None:
147 tracks.append(track_val)
148
149 return MuseTimelineEntry(
150 commit_id=commit.commit_id,
151 short_id=commit.commit_id[:7],
152 committed_at=commit.committed_at,
153 message=commit.message,
154 emotion=emotions[0] if emotions else None,
155 sections=tuple(sections),
156 tracks=tuple(tracks),
157 activity=len(tracks) if tracks else 1,
158 )
159
160
161 # ---------------------------------------------------------------------------
162 # Public API
163 # ---------------------------------------------------------------------------
164
165
166 async def build_timeline(
167 session: AsyncSession,
168 repo_id: str,
169 branch: str,
170 head_commit_id: str,
171 limit: int = 1000,
172 ) -> MuseTimelineResult:
173 """Build a chronological musical timeline for *branch* in *repo_id*.
174
175 Walks the parent chain from *head_commit_id* (oldest-first after
176 reversal) then queries associated tags in a single batch to avoid N+1
177 round-trips.
178
179 Args:
180 session: Open async SQLAlchemy session.
181 repo_id: Repository scope.
182 branch: Branch name for display in the result.
183 head_commit_id: SHA-256 of the branch HEAD commit.
184 limit: Maximum commits to walk (default 1000).
185
186 Returns:
187 :class:`MuseTimelineResult` sorted oldest-first.
188 """
189 # --- Walk the parent chain newest-first (same pattern as muse log) ---
190 commits_newest_first: list[MuseCliCommit] = []
191 current_id: str | None = head_commit_id
192 while current_id and len(commits_newest_first) < limit:
193 commit = await session.get(MuseCliCommit, current_id)
194 if commit is None:
195 logger.warning("⚠️ Timeline: commit %s not found — chain broken", current_id[:8])
196 break
197 commits_newest_first.append(commit)
198 current_id = commit.parent_commit_id
199
200 # Reverse to oldest-first for timeline display.
201 commits: list[MuseCliCommit] = list(reversed(commits_newest_first))
202
203 if not commits:
204 return MuseTimelineResult(
205 entries=(),
206 branch=branch,
207 emotion_arc=(),
208 section_order=(),
209 total_commits=0,
210 )
211
212 # --- Batch-fetch all tags for the commit set ---
213 commit_ids = [c.commit_id for c in commits]
214 tag_stmt = select(MuseCliTag).where(
215 MuseCliTag.repo_id == repo_id,
216 MuseCliTag.commit_id.in_(commit_ids),
217 )
218 tag_result = await session.execute(tag_stmt)
219 all_tags: list[MuseCliTag] = list(tag_result.scalars().all())
220 tags_by_commit = _group_tags_by_commit(all_tags)
221
222 # --- Build entries ---
223 entries: list[MuseTimelineEntry] = [
224 _make_entry(c, tags_by_commit.get(c.commit_id, []))
225 for c in commits
226 ]
227
228 # --- Derive summaries ---
229 emotion_arc: list[str] = []
230 seen_emotions: set[str] = set()
231 section_order: list[str] = []
232 seen_sections: set[str] = set()
233
234 for entry in entries:
235 if entry.emotion and entry.emotion not in seen_emotions:
236 emotion_arc.append(entry.emotion)
237 seen_emotions.add(entry.emotion)
238 for sec in entry.sections:
239 if sec not in seen_sections:
240 section_order.append(sec)
241 seen_sections.add(sec)
242
243 logger.info(
244 "✅ muse timeline: %d commit(s), %d emotion(s), %d section(s) (repo=%s branch=%s)",
245 len(entries),
246 len(emotion_arc),
247 len(section_order),
248 repo_id[:8],
249 branch,
250 )
251
252 return MuseTimelineResult(
253 entries=tuple(entries),
254 branch=branch,
255 emotion_arc=tuple(emotion_arc),
256 section_order=tuple(section_order),
257 total_commits=len(entries),
258 )