gabriel / musehub public
musehub_profile.py python
438 lines 14.1 KB
Raw
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 1 day 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 agent_model = identity.agent_model
402 if identity.identity_type == "agent":
403 from musehub.services.derived_agent_provisioner import latest_agent_model_id
404 trust_chain = await _build_trust_chain(session, identity)
405 agent_model = await latest_agent_model_id(session, handle) or identity.agent_model
406 if identity.identity_type == "org":
407 org = OrgManifest(
408 members=list(identity.org_members or []),
409 quorum=identity.org_quorum or 1,
410 treasury_address=identity.org_treasury_address,
411 )
412
413 return ProfileManifest(
414 identity_id=identity.identity_id,
415 handle=identity.handle,
416 identity_type=identity.identity_type,
417 display_name=identity.display_name,
418 bio=identity.bio,
419 avatar_url=identity.avatar_url,
420 location=identity.location,
421 website_url=identity.website_url,
422 social_url=identity.social_url,
423 is_verified=identity.is_verified,
424 cc_license=identity.cc_license,
425 pinned_repo_ids=list(identity.pinned_repo_ids or []),
426 repos=repos,
427 created_at=identity.created_at,
428 updated_at=identity.updated_at,
429 activity=activity,
430 attestations=attestations or [],
431 avax_address=identity.avax_address if identity.identity_type == "human" else None,
432 agent_model=agent_model if identity.identity_type == "agent" else None,
433 agent_capabilities=list(identity.agent_capabilities or []) if identity.identity_type == "agent" else [],
434 trust_chain=trust_chain,
435 org=org,
436 mpay_total_sent_nano=mpay_sent_nano,
437 mpay_total_received_nano=mpay_received_nano,
438 )
File History 15 commits
sha256:8a1389ae62d0a688d5e027763249bd8faedfc39ab00857d65da6620d1ab8a689 Merge 'feat/9a-4-f7-overseer-provenance' into 'dev' — propo… Human 1 day ago
sha256:bee12c5cbde2334f98421c6c209d768fa6b8004d6705c9ea798ce6c1651bc11f Merge 'infra/database-phase3-4-cleanup' into 'dev' — propos… Human 2 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 65 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 68 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 71 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 73 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 86 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 90 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 91 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 92 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 92 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 92 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 92 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 104 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 107 days ago