gabriel / musehub public
musehub_profile.py python
435 lines 13.9 KB
Raw
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