gabriel / musehub public
ui_avatars.py python
116 lines 3.8 KB
Raw
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 27 days ago
1 """Spectral Sigil avatar route.
2
3 GET /avatars/handle/{handle}.svg — any handle; derives ID from sha256(handle)
4 GET /avatars/{algo}/{hex}.svg — registered identity by content-addressed ID
5
6 The handle route MUST be registered first — FastAPI matches in declaration order,
7 and {algo} would otherwise greedily capture "handle" before the specific route fires.
8
9 Supported algos: sha256
10 Content-addressed: same identity_id → same SVG → immutable cache forever.
11 """
12
13 import hashlib
14 import re
15
16 from fastapi import APIRouter, HTTPException
17 from fastapi.responses import Response
18 from sqlalchemy import select
19
20 from musehub.db import database as _db
21 from musehub.db.musehub_identity_models import MusehubIdentity
22 from musehub.services.spectral_sigil import generate_sigil
23 from muse.core.types import long_id
24
25 router = APIRouter(tags=["avatars"])
26
27 _SUPPORTED_ALGOS = frozenset({"sha256"})
28 _HEX_RE: dict[str, re.Pattern[str]] = {
29 "sha256": re.compile(r"^[0-9a-f]{64}$"),
30 }
31
32 _CACHE_HEADERS = {
33 "Cache-Control": "public, max-age=31536000, immutable",
34 "Vary": "Accept-Encoding",
35 }
36
37 async def _fetch_identity(identity_id: str) -> MusehubIdentity | None:
38 """Return the MusehubIdentity row for the canonical *identity_id* (algo:hex), or None."""
39 async with _db.AsyncSessionLocal() as session:
40 result = await session.execute(
41 select(MusehubIdentity).where(
42 MusehubIdentity.identity_id == identity_id,
43 MusehubIdentity.deleted_at.is_(None),
44 )
45 )
46 return result.scalar_one_or_none()
47
48
49 @router.get("/avatars/handle/{handle}.svg")
50 async def get_sigil_by_handle(handle: str) -> Response:
51 """Generate a sigil for any handle, registered or not.
52
53 Derives a stable identity_id from sha256(handle) so the same handle always
54 produces the same sigil. If the handle is a registered identity, uses the
55 real identity_id and identity_type; otherwise falls back to "agent" type.
56 """
57 if not handle or len(handle) > 128:
58 raise HTTPException(status_code=400, detail="invalid handle")
59
60 async with _db.AsyncSessionLocal() as session:
61 result = await session.execute(
62 select(MusehubIdentity).where(
63 MusehubIdentity.handle == handle,
64 MusehubIdentity.deleted_at.is_(None),
65 )
66 )
67 identity = result.scalar_one_or_none()
68
69 if identity is not None:
70 hex_id = identity.identity_id.split(":", 1)[-1]
71 identity_type = identity.identity_type
72 else:
73 hex_id = hashlib.sha256(handle.encode()).hexdigest()
74 identity_type = "agent"
75
76 svg_bytes = generate_sigil(
77 identity_id=hex_id,
78 handle=handle,
79 identity_type=identity_type,
80 domain_counts={},
81 )
82
83 return Response(
84 content=svg_bytes,
85 media_type="image/svg+xml",
86 headers={"Cache-Control": "public, max-age=86400", "Vary": "Accept-Encoding"},
87 )
88
89
90 @router.get("/avatars/{algo}/{hex}.svg")
91 async def get_sigil(algo: str, hex: str) -> Response:
92 if algo not in _SUPPORTED_ALGOS:
93 raise HTTPException(status_code=400, detail=f"unsupported algorithm '{algo}' — supported: sha256")
94
95 pattern = _HEX_RE.get(algo)
96 if pattern is None or not pattern.match(hex):
97 raise HTTPException(status_code=400, detail=f"invalid hex digest for {algo} — expected 64 hex chars")
98
99 canonical_id = long_id(hex, algo)
100
101 identity = await _fetch_identity(canonical_id)
102 if identity is None:
103 raise HTTPException(status_code=404, detail="identity not found")
104
105 svg_bytes = generate_sigil(
106 identity_id=hex,
107 handle=identity.handle,
108 identity_type=identity.identity_type,
109 domain_counts={},
110 )
111
112 return Response(
113 content=svg_bytes,
114 media_type="image/svg+xml",
115 headers=_CACHE_HEADERS,
116 )
File History 1 commit
sha256:da49a05fd62cda46a7d73ec53a8d0adc5835d2070f6f0c51b12233a673c2e109 docs(mwp-1): mark Phase 5 complete, all acceptance criteria… Sonnet 4.6 27 days ago