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