musehub_credits.py
python
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