musehub_profile.py
python
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago
| 1 | """MuseHub profile persistence adapter. |
| 2 | |
| 3 | Single point of DB access for user-profile entities (``musehub_identities``). |
| 4 | Aggregates cross-repo data (public repos, contribution graph, session credits) |
| 5 | so that route handlers stay thin. |
| 6 | """ |
| 7 | |
| 8 | import logging |
| 9 | from collections import defaultdict |
| 10 | from datetime import datetime, timedelta, timezone |
| 11 | |
| 12 | from sqlalchemy import distinct, desc, func, select, text |
| 13 | from sqlalchemy.ext.asyncio import AsyncSession |
| 14 | |
| 15 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 16 | from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubRepo |
| 17 | from musehub.types.json_types import IntDict |
| 18 | from musehub.models.musehub import ( |
| 19 | ActivityDomain, |
| 20 | AttestationBadge, |
| 21 | OrgManifest, |
| 22 | ProfileManifest, |
| 23 | ProfileResponse, |
| 24 | ProfileRepoSummary, |
| 25 | ProfileUpdateRequest, |
| 26 | TrustChainEntry, |
| 27 | ) |
| 28 | |
| 29 | logger = logging.getLogger(__name__) |
| 30 | |
| 31 | _MAX_PINNED = 6 |
| 32 | _CONTRIBUTION_WEEKS = 52 |
| 33 | |
| 34 | |
| 35 | def _utc_today() -> datetime: |
| 36 | return datetime.now(tz=timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) |
| 37 | |
| 38 | |
| 39 | def _to_profile_response( |
| 40 | identity: MusehubIdentity, |
| 41 | repos: list[ProfileRepoSummary], |
| 42 | session_credits: int, |
| 43 | ) -> ProfileResponse: |
| 44 | return ProfileResponse( |
| 45 | user_id=identity.identity_id, |
| 46 | username=identity.handle, |
| 47 | display_name=identity.display_name, |
| 48 | bio=identity.bio, |
| 49 | avatar_url=identity.avatar_url, |
| 50 | location=identity.location, |
| 51 | website_url=identity.website_url, |
| 52 | social_url=identity.social_url, |
| 53 | is_verified=identity.is_verified, |
| 54 | cc_license=identity.cc_license, |
| 55 | pinned_repo_ids=list(identity.pinned_repo_ids or []), |
| 56 | repos=repos, |
| 57 | session_credits=session_credits, |
| 58 | created_at=identity.created_at, |
| 59 | updated_at=identity.updated_at, |
| 60 | ) |
| 61 | |
| 62 | |
| 63 | async def create_profile( |
| 64 | session: AsyncSession, |
| 65 | user_id: str, |
| 66 | username: str, |
| 67 | bio: str | None = None, |
| 68 | avatar_url: str | None = None, |
| 69 | ) -> MusehubIdentity: |
| 70 | """Create a new MusehubIdentity for a human user.""" |
| 71 | identity = MusehubIdentity( |
| 72 | identity_id=user_id, |
| 73 | handle=username, |
| 74 | identity_type="human", |
| 75 | bio=bio, |
| 76 | avatar_url=avatar_url, |
| 77 | ) |
| 78 | session.add(identity) |
| 79 | await session.flush() |
| 80 | return identity |
| 81 | |
| 82 | |
| 83 | async def get_profile_by_username( |
| 84 | session: AsyncSession, username: str |
| 85 | ) -> MusehubIdentity | None: |
| 86 | result = await session.execute( |
| 87 | select(MusehubIdentity).where( |
| 88 | MusehubIdentity.handle == username, |
| 89 | MusehubIdentity.deleted_at.is_(None), |
| 90 | ) |
| 91 | ) |
| 92 | return result.scalar_one_or_none() |
| 93 | |
| 94 | |
| 95 | async def get_profile_by_user_id( |
| 96 | session: AsyncSession, user_id: str |
| 97 | ) -> MusehubIdentity | None: |
| 98 | result = await session.execute( |
| 99 | select(MusehubIdentity).where(MusehubIdentity.identity_id == user_id) |
| 100 | ) |
| 101 | return result.scalar_one_or_none() |
| 102 | |
| 103 | |
| 104 | async def update_profile( |
| 105 | session: AsyncSession, |
| 106 | identity: MusehubIdentity, |
| 107 | patch: ProfileUpdateRequest, |
| 108 | ) -> MusehubIdentity: |
| 109 | if patch.display_name is not None: |
| 110 | identity.display_name = patch.display_name |
| 111 | if patch.bio is not None: |
| 112 | identity.bio = patch.bio |
| 113 | if patch.avatar_url is not None: |
| 114 | identity.avatar_url = patch.avatar_url |
| 115 | if patch.location is not None: |
| 116 | identity.location = patch.location |
| 117 | if patch.website_url is not None: |
| 118 | identity.website_url = patch.website_url |
| 119 | if patch.social_url is not None: |
| 120 | identity.social_url = patch.social_url |
| 121 | if patch.pinned_repo_ids is not None: |
| 122 | identity.pinned_repo_ids = patch.pinned_repo_ids[:_MAX_PINNED] |
| 123 | identity.updated_at = datetime.now(tz=timezone.utc) |
| 124 | session.add(identity) |
| 125 | await session.flush() |
| 126 | return identity |
| 127 | |
| 128 | |
| 129 | async def get_public_repos( |
| 130 | session: AsyncSession, handle: str |
| 131 | ) -> list[ProfileRepoSummary]: |
| 132 | repo_rows_result = await session.execute( |
| 133 | select(MusehubRepo) |
| 134 | .where( |
| 135 | MusehubRepo.owner == handle, |
| 136 | MusehubRepo.visibility == "public", |
| 137 | ) |
| 138 | .order_by(desc(MusehubRepo.created_at)) |
| 139 | ) |
| 140 | repo_rows = list(repo_rows_result.scalars()) |
| 141 | |
| 142 | if not repo_rows: |
| 143 | return [] |
| 144 | |
| 145 | repo_ids = [r.repo_id for r in repo_rows] |
| 146 | latest_result = await session.execute( |
| 147 | select( |
| 148 | MusehubCommitRef.repo_id, |
| 149 | func.max(MusehubCommit.timestamp).label("last_activity"), |
| 150 | ) |
| 151 | .join(MusehubCommit, MusehubCommitRef.commit_id == MusehubCommit.commit_id) |
| 152 | .where(MusehubCommitRef.repo_id.in_(repo_ids)) |
| 153 | .group_by(MusehubCommitRef.repo_id) |
| 154 | ) |
| 155 | last_activity = {row.repo_id: row.last_activity for row in latest_result} |
| 156 | |
| 157 | return [ |
| 158 | ProfileRepoSummary( |
| 159 | repo_id=r.repo_id, |
| 160 | name=r.name, |
| 161 | owner=r.owner, |
| 162 | slug=r.slug, |
| 163 | visibility=r.visibility, |
| 164 | domain=r.domain_id or "code", |
| 165 | last_activity_at=last_activity.get(r.repo_id), |
| 166 | created_at=r.created_at, |
| 167 | ) |
| 168 | for r in repo_rows |
| 169 | ] |
| 170 | |
| 171 | |
| 172 | async def get_session_credits(session: AsyncSession, handle: str) -> int: |
| 173 | repo_result = await session.execute( |
| 174 | select(MusehubRepo.repo_id).where( |
| 175 | MusehubRepo.owner == handle, |
| 176 | ) |
| 177 | ) |
| 178 | repo_ids = [row[0] for row in repo_result] |
| 179 | |
| 180 | if not repo_ids: |
| 181 | return 0 |
| 182 | |
| 183 | result = await session.execute( |
| 184 | select(func.count(MusehubCommitRef.commit_id)).where( |
| 185 | MusehubCommitRef.repo_id.in_(repo_ids) |
| 186 | ) |
| 187 | ) |
| 188 | count = result.scalar() |
| 189 | return int(count) if count is not None else 0 |
| 190 | |
| 191 | |
| 192 | async def get_full_profile( |
| 193 | session: AsyncSession, username: str |
| 194 | ) -> ProfileResponse | None: |
| 195 | identity = await get_profile_by_username(session, username) |
| 196 | if identity is None: |
| 197 | return None |
| 198 | |
| 199 | repos, session_credits = ( |
| 200 | await get_public_repos(session, identity.handle), |
| 201 | await get_session_credits(session, identity.handle), |
| 202 | ) |
| 203 | |
| 204 | return _to_profile_response(identity, repos, session_credits) |
| 205 | |
| 206 | |
| 207 | # --------------------------------------------------------------------------- |
| 208 | # Multi-domain activity canvas (52 weeks × 7 days per domain) |
| 209 | # --------------------------------------------------------------------------- |
| 210 | |
| 211 | _GRID_DAYS = 364 # 52 × 7 |
| 212 | |
| 213 | # Internal system domains that are not meaningful user-facing activity. |
| 214 | _CANVAS_EXCLUDED_DOMAINS: frozenset[str] = frozenset({"identity", "social"}) |
| 215 | |
| 216 | # Valid domain slugs are short alphanumeric slugs. Anything containing ":" is a |
| 217 | # raw cryptographic identifier (e.g. sha256:<hex>) that leaked into domain_id — |
| 218 | # treat those as "code" (the default code-domain) rather than a new domain label. |
| 219 | def _normalise_domain_slug(raw: str | None) -> str: |
| 220 | slug = raw or "code" |
| 221 | if ":" in slug: |
| 222 | return "code" |
| 223 | return slug |
| 224 | |
| 225 | |
| 226 | def _empty_grid() -> list[int]: |
| 227 | return [0] * _GRID_DAYS |
| 228 | |
| 229 | |
| 230 | def _date_to_grid_index(today: datetime, target_date: datetime) -> int | None: |
| 231 | """Return the grid index (0 = oldest Monday) for a given date, or None if out of range.""" |
| 232 | delta = (today.date() - target_date.date()).days |
| 233 | if delta < 0 or delta >= _GRID_DAYS: |
| 234 | return None |
| 235 | return _GRID_DAYS - 1 - delta |
| 236 | |
| 237 | |
| 238 | async def _build_domain_commit_grid( |
| 239 | session: AsyncSession, |
| 240 | handle: str, |
| 241 | today: datetime, |
| 242 | cutoff: datetime, |
| 243 | domain_id: str, |
| 244 | ) -> list[int]: |
| 245 | """Count commits to repos owned by handle with the given domain slug. |
| 246 | |
| 247 | "code" matches both repos with domain_id="code" and repos with domain_id=NULL |
| 248 | (legacy rows created before domain tracking was added). |
| 249 | """ |
| 250 | if domain_id == "code": |
| 251 | # "code" is the default domain. Match explicit "code" rows, NULL (legacy), |
| 252 | # and any domain_id that is a raw cryptographic value (contains ":") — those |
| 253 | # are mis-classified repos that should have been tagged "code". |
| 254 | domain_filter = ( |
| 255 | (MusehubRepo.domain_id == "code") |
| 256 | | MusehubRepo.domain_id.is_(None) |
| 257 | | MusehubRepo.domain_id.like("%:%") |
| 258 | ) |
| 259 | else: |
| 260 | domain_filter = MusehubRepo.domain_id == domain_id |
| 261 | |
| 262 | repo_result = await session.execute( |
| 263 | select(MusehubRepo.repo_id).where( |
| 264 | MusehubRepo.owner == handle, |
| 265 | domain_filter, |
| 266 | ) |
| 267 | ) |
| 268 | repo_ids = [row[0] for row in repo_result] |
| 269 | if not repo_ids: |
| 270 | return _empty_grid() |
| 271 | |
| 272 | rows = await session.execute( |
| 273 | select( |
| 274 | func.date(MusehubCommit.timestamp).label("day"), |
| 275 | func.count(MusehubCommit.commit_id).label("cnt"), |
| 276 | ) |
| 277 | .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id) |
| 278 | .where( |
| 279 | MusehubCommitRef.repo_id.in_(repo_ids), |
| 280 | MusehubCommit.timestamp >= cutoff, |
| 281 | ) |
| 282 | .group_by(func.date(MusehubCommit.timestamp)) |
| 283 | ) |
| 284 | grid = _empty_grid() |
| 285 | for row in rows: |
| 286 | idx = _date_to_grid_index(today, datetime.fromisoformat(str(row.day))) |
| 287 | if idx is not None: |
| 288 | grid[idx] = int(row.cnt) |
| 289 | return grid |
| 290 | |
| 291 | |
| 292 | |
| 293 | def _grid_to_domain(domain: str, grid: list[int]) -> ActivityDomain: |
| 294 | peak = max(grid) if grid else 0 |
| 295 | total = sum(grid) |
| 296 | return ActivityDomain(domain=domain, grid=grid, peak=peak, total=total) |
| 297 | |
| 298 | |
| 299 | async def build_activity_canvas( |
| 300 | session: AsyncSession, handle: str |
| 301 | ) -> list[ActivityDomain]: |
| 302 | """Build the activity canvas for a handle. |
| 303 | |
| 304 | domain_id in musehub_repos is a plain string label ("code", "midi", "mist", …) |
| 305 | or NULL for legacy repos. NULL is treated as "code". Internal domains |
| 306 | (identity, social) are excluded. Only domains with at least one commit |
| 307 | in the trailing 52 weeks are included. |
| 308 | """ |
| 309 | today = _utc_today() |
| 310 | cutoff = today - timedelta(weeks=_CONTRIBUTION_WEEKS) |
| 311 | |
| 312 | # Collect all distinct domain slugs the user has repos under. |
| 313 | # NULL domain_id is normalised to "code" here and in _build_domain_commit_grid. |
| 314 | result = await session.execute( |
| 315 | select(MusehubRepo.domain_id) |
| 316 | .where(MusehubRepo.owner == handle) |
| 317 | .distinct() |
| 318 | ) |
| 319 | raw_slugs: set[str] = { |
| 320 | _normalise_domain_slug(row[0]) for row in result |
| 321 | if _normalise_domain_slug(row[0]) not in _CANVAS_EXCLUDED_DOMAINS |
| 322 | } |
| 323 | |
| 324 | domains: list[ActivityDomain] = [] |
| 325 | for slug in sorted(raw_slugs): |
| 326 | grid = await _build_domain_commit_grid(session, handle, today, cutoff, slug) |
| 327 | entry = _grid_to_domain(slug, grid) |
| 328 | if entry.total > 0: |
| 329 | domains.append(entry) |
| 330 | |
| 331 | return domains |
| 332 | |
| 333 | |
| 334 | # --------------------------------------------------------------------------- |
| 335 | # Trust chain — agent lineage back to spawning human |
| 336 | # --------------------------------------------------------------------------- |
| 337 | |
| 338 | async def _build_trust_chain( |
| 339 | session: AsyncSession, identity: MusehubIdentity |
| 340 | ) -> list[TrustChainEntry]: |
| 341 | """Walk the spawned_by chain from agent identity up to the root human.""" |
| 342 | chain: list[TrustChainEntry] = [ |
| 343 | TrustChainEntry( |
| 344 | handle=identity.handle, |
| 345 | identity_type=identity.identity_type, |
| 346 | spawned_by=identity.spawned_by, |
| 347 | ) |
| 348 | ] |
| 349 | current_handle = identity.spawned_by |
| 350 | seen: set[str] = {identity.handle} |
| 351 | |
| 352 | while current_handle and current_handle not in seen: |
| 353 | seen.add(current_handle) |
| 354 | result = await session.execute( |
| 355 | select(MusehubIdentity).where( |
| 356 | MusehubIdentity.handle == current_handle, |
| 357 | MusehubIdentity.deleted_at.is_(None), |
| 358 | ) |
| 359 | ) |
| 360 | parent = result.scalar_one_or_none() |
| 361 | if parent is None: |
| 362 | break |
| 363 | chain.append( |
| 364 | TrustChainEntry( |
| 365 | handle=parent.handle, |
| 366 | identity_type=parent.identity_type, |
| 367 | spawned_by=parent.spawned_by, |
| 368 | ) |
| 369 | ) |
| 370 | current_handle = parent.spawned_by |
| 371 | |
| 372 | return chain |
| 373 | |
| 374 | |
| 375 | # --------------------------------------------------------------------------- |
| 376 | # Unified profile manifest |
| 377 | # --------------------------------------------------------------------------- |
| 378 | |
| 379 | async def build_profile_manifest( |
| 380 | session: AsyncSession, |
| 381 | handle: str, |
| 382 | attestations: list[AttestationBadge] | None = None, |
| 383 | mpay_sent_nano: int = 0, |
| 384 | mpay_received_nano: int = 0, |
| 385 | ) -> ProfileManifest | None: |
| 386 | """Build the unified archetype-aware profile manifest. |
| 387 | |
| 388 | ``attestations`` and MPay totals are supplied by the caller so this |
| 389 | function doesn't need to import the attestation/mpay services directly. |
| 390 | """ |
| 391 | identity = await get_profile_by_username(session, handle) |
| 392 | if identity is None: |
| 393 | return None |
| 394 | |
| 395 | repos = await get_public_repos(session, handle) |
| 396 | activity = await build_activity_canvas(session, handle) |
| 397 | |
| 398 | trust_chain: list[TrustChainEntry] = [] |
| 399 | org: OrgManifest | None = None |
| 400 | |
| 401 | if identity.identity_type == "agent": |
| 402 | trust_chain = await _build_trust_chain(session, identity) |
| 403 | elif identity.identity_type == "org": |
| 404 | org = OrgManifest( |
| 405 | members=list(identity.org_members or []), |
| 406 | quorum=identity.org_quorum or 1, |
| 407 | treasury_address=identity.org_treasury_address, |
| 408 | ) |
| 409 | |
| 410 | return ProfileManifest( |
| 411 | identity_id=identity.identity_id, |
| 412 | handle=identity.handle, |
| 413 | identity_type=identity.identity_type, |
| 414 | display_name=identity.display_name, |
| 415 | bio=identity.bio, |
| 416 | avatar_url=identity.avatar_url, |
| 417 | location=identity.location, |
| 418 | website_url=identity.website_url, |
| 419 | social_url=identity.social_url, |
| 420 | is_verified=identity.is_verified, |
| 421 | cc_license=identity.cc_license, |
| 422 | pinned_repo_ids=list(identity.pinned_repo_ids or []), |
| 423 | repos=repos, |
| 424 | created_at=identity.created_at, |
| 425 | updated_at=identity.updated_at, |
| 426 | activity=activity, |
| 427 | attestations=attestations or [], |
| 428 | avax_address=identity.avax_address if identity.identity_type == "human" else None, |
| 429 | agent_model=identity.agent_model if identity.identity_type == "agent" else None, |
| 430 | agent_capabilities=list(identity.agent_capabilities or []) if identity.identity_type == "agent" else [], |
| 431 | trust_chain=trust_chain, |
| 432 | org=org, |
| 433 | mpay_total_sent_nano=mpay_sent_nano, |
| 434 | mpay_total_received_nano=mpay_received_nano, |
| 435 | ) |
File History
1 commit
sha256:3c58668648c7323bb9f5c6881cfe6a3f14fc93fcb73b537d253732952a5bf8bf
chore: bump version to 0.2.0rc12
Sonnet 4.6
patch
8 days ago