gabriel / musehub public
musehub_credits.py python
154 lines 5.7 KB
Raw
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago
1 """Credits aggregation service for MuseHub repos.
2
3 Aggregates contributor information from commit history — think dynamic album
4 liner notes that update as the composition evolves. Every pushed commit
5 contributes an author name, a timestamp, and a message whose keywords are
6 used to infer contribution types (composer, arranger, producer, etc.).
7
8 Design decisions:
9 - Pure DB read — no mutations, no side effects.
10 - Contribution types are inferred from commit message keywords, not stored
11 explicitly, so they evolve as musicians describe their work more richly.
12 - Sort options mirror what a label credit page would offer: by contribution
13 count (most prolific first), by recency (most recently active first), and
14 alphabetical (predictable scanning order).
15 """
16
17 import logging
18 from collections import defaultdict
19 from datetime import datetime
20
21 from sqlalchemy import select
22 from sqlalchemy.ext.asyncio import AsyncSession
23
24 from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef
25 from musehub.models.musehub import ContributorCredits, CreditsResponse
26 from musehub.types.json_types import IntDict
27
28 type AuthorDateMap = dict[str, datetime]
29
30 type RoleKeywordsDict = dict[str, list[str]]
31 type RoleSetsDict = dict[str, set[str]]
32
33 logger = logging.getLogger(__name__)
34
35 # ---------------------------------------------------------------------------
36 # Role inference keyword map
37 # Keys are contribution-type labels; values are substrings to search for in
38 # the lower-cased commit message. Order matters: first match wins per token.
39 # ---------------------------------------------------------------------------
40
41 _ROLE_KEYWORDS: RoleKeywordsDict = {
42 "composer": ["compos", "wrote", "writing", "melody", "theme", "motif"],
43 "arranger": ["arrang", "orchestrat", "voicing", "reharmoni"],
44 "producer": ["produc", "session", "master", "mix session", "track layout"],
45 "performer": ["perform", "record", "played", "guitar", "piano", "bass", "drum"],
46 "mixer": ["mix", "blend", "balance", "eq ", "equaliz", "compressor"],
47 "editor": ["edit", "cut", "splice", "trim", "clip"],
48 "lyricist": ["lyric", "word", "verse", "chorus", "hook", "lyric"],
49 "sound designer": ["synth", "sound design", "patch", "preset", "timbre"],
50 }
51
52
53 def _infer_roles(message: str) -> list[str]:
54 """Return contribution type labels detected from a commit message.
55
56 Uses a simple keyword scan — sufficient for MVP. If no keywords match,
57 falls back to ``["contributor"]`` so every commit always carries a role.
58 """
59 lower = message.lower()
60 found: list[str] = []
61 for role, keywords in _ROLE_KEYWORDS.items():
62 if any(kw in lower for kw in keywords):
63 found.append(role)
64 return found if found else ["contributor"]
65
66
67 def _sort_contributors(
68 contributors: list[ContributorCredits], sort: str
69 ) -> list[ContributorCredits]:
70 """Apply the requested sort order to the contributor list.
71
72 Supported values:
73 - ``"count"`` — most prolific contributor first (default)
74 - ``"recency"`` — most recently active contributor first
75 - ``"alpha"`` — alphabetical by author name
76 """
77 if sort == "recency":
78 return sorted(contributors, key=lambda c: c.last_active, reverse=True)
79 if sort == "alpha":
80 return sorted(contributors, key=lambda c: c.author.lower())
81 # Default: sort by session count descending, then alpha for ties
82 return sorted(contributors, key=lambda c: (-c.session_count, c.author.lower()))
83
84
85 async def aggregate_credits(
86 session: AsyncSession,
87 repo_id: str,
88 *,
89 sort: str = "count",
90 ) -> CreditsResponse:
91 """Aggregate contributors across all commits in a repo.
92
93 Reads every commit for the repo (no limit — credits need completeness,
94 not pagination). Groups by author string, counts sessions, infers roles
95 from commit messages, and records activity timestamps.
96
97 Args:
98 session: Active async DB session.
99 repo_id: Target repo ID.
100 sort: Sort order for the contributor list — ``"count"`` (default),
101 ``"recency"``, or ``"alpha"``.
102
103 Returns:
104 ``CreditsResponse`` with a complete contributor list and echoed sort.
105 """
106 stmt = (
107 select(MusehubCommit)
108 .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id)
109 .where(MusehubCommitRef.repo_id == repo_id)
110 .order_by(MusehubCommit.timestamp)
111 )
112 rows = (await session.execute(stmt)).scalars().all()
113
114 # Per-author accumulators
115 counts: IntDict = defaultdict(int)
116 roles_sets: RoleSetsDict = defaultdict(set)
117 first_active: AuthorDateMap = {}
118 last_active: AuthorDateMap = {}
119
120 for row in rows:
121 author = row.author
122 counts[author] += 1
123 for role in _infer_roles(row.message):
124 roles_sets[author].add(role)
125 ts = row.timestamp
126 if author not in first_active or ts < first_active[author]:
127 first_active[author] = ts
128 if author not in last_active or ts > last_active[author]:
129 last_active[author] = ts
130
131 contributors = [
132 ContributorCredits(
133 author=author,
134 session_count=counts[author],
135 contribution_types=sorted(roles_sets[author]),
136 first_active=first_active[author],
137 last_active=last_active[author],
138 )
139 for author in counts
140 ]
141
142 sorted_contributors = _sort_contributors(contributors, sort)
143 logger.debug(
144 "✅ Credits aggregated for repo %s: %d contributor(s), sort=%s",
145 repo_id,
146 len(sorted_contributors),
147 sort,
148 )
149 return CreditsResponse(
150 repo_id=repo_id,
151 contributors=sorted_contributors,
152 sort=sort,
153 total_contributors=len(sorted_contributors),
154 )
File History 1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 25 days ago