|
| 1 | +"""The akousmata history view inside oída. |
| 2 | +
|
| 3 | +The shared store has its own app (the akousmata listening navigator, |
| 4 | +github.com/sonicfieldlabs/akousmata); oída embeds the same library view — |
| 5 | +list, filter, detail with lineage and kinship, audio playback — natively in |
| 6 | +its dashboard instead of launching the external app. Card shapes stay |
| 7 | +compatible with the navigator's. Read-only here: oída WRITES memories through |
| 8 | +its listen flow and the germ bridge, and edits belong to the navigator. |
| 9 | +
|
| 10 | +Lazy on the ``akousma`` package like the germ bridge: oída boots without it |
| 11 | +and these routes degrade to 503. |
| 12 | +""" |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +from pathlib import Path |
| 16 | +from typing import Any |
| 17 | + |
| 18 | + |
| 19 | +def _akousma(): |
| 20 | + try: |
| 21 | + import akousma |
| 22 | + except ModuleNotFoundError as exc: # pragma: no cover - environment-dependent |
| 23 | + raise RuntimeError( |
| 24 | + "the 'akousma' package is not installed; " |
| 25 | + "pip install -e <SFL>/earworm/packages/py-akousma" |
| 26 | + ) from exc |
| 27 | + return akousma |
| 28 | + |
| 29 | + |
| 30 | +def summary_line(record: dict[str, Any]) -> str: |
| 31 | + summary = record.get("summary") |
| 32 | + if isinstance(summary, str) and summary.strip(): |
| 33 | + return summary.strip() |
| 34 | + for entry in (record.get("listening") or {}).values(): |
| 35 | + if isinstance(entry, dict): |
| 36 | + text = entry.get("summary") |
| 37 | + if isinstance(text, str) and text.strip(): |
| 38 | + return text.strip() |
| 39 | + payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else entry |
| 40 | + for key in ("caption", "summary", "brief", "main_reading", "notes"): |
| 41 | + value = payload.get(key) if isinstance(payload, dict) else None |
| 42 | + if isinstance(value, str) and value.strip(): |
| 43 | + return value.strip() |
| 44 | + prompt = (record.get("lineage") or {}).get("prompt") |
| 45 | + if isinstance(prompt, str) and prompt.strip(): |
| 46 | + return prompt.strip() |
| 47 | + return ", ".join(str(t) for t in record.get("tags") or []) or "(no summary)" |
| 48 | + |
| 49 | + |
| 50 | +def card(record: dict[str, Any]) -> dict[str, Any]: |
| 51 | + provenance = record.get("provenance") or {} |
| 52 | + audio = record.get("audio") or {} |
| 53 | + lineage = record.get("lineage") or {} |
| 54 | + return { |
| 55 | + "akousma_id": record["akousma_id"], |
| 56 | + "created_at": record.get("created_at"), |
| 57 | + "summary": summary_line(record), |
| 58 | + "tags": list(record.get("tags") or []), |
| 59 | + "originating_app": provenance.get("originating_app"), |
| 60 | + "origin": provenance.get("origin"), |
| 61 | + "source_type": provenance.get("source_type"), |
| 62 | + "duration_seconds": audio.get("duration_seconds"), |
| 63 | + "has_audio": bool(audio.get("uri")), |
| 64 | + "parent_count": len(lineage.get("parent_akousma_ids") or []), |
| 65 | + "relation_count": len(lineage.get("relations") or []), |
| 66 | + } |
| 67 | + |
| 68 | + |
| 69 | +def _resolve_audio(store, record: dict[str, Any]) -> Path | None: |
| 70 | + uri = str((record.get("audio") or {}).get("uri") or "") |
| 71 | + if uri.startswith("akousmata://"): |
| 72 | + path = store.resolve_uri(uri) |
| 73 | + return path if path is not None and path.exists() else None |
| 74 | + if uri.startswith("file://"): |
| 75 | + path = Path(uri[7:]) |
| 76 | + return path if path.exists() else None |
| 77 | + if uri and Path(uri).expanduser().exists(): |
| 78 | + return Path(uri).expanduser() |
| 79 | + return None |
| 80 | + |
| 81 | + |
| 82 | +def _ref(store, akousma_id: str) -> dict[str, Any]: |
| 83 | + record = store.get(akousma_id) |
| 84 | + if record is None: |
| 85 | + return {"akousma_id": akousma_id, "summary": "(missing record)", "missing": True} |
| 86 | + return {"akousma_id": akousma_id, "summary": summary_line(record), "missing": False} |
| 87 | + |
| 88 | + |
| 89 | +def build_akousmata_router(): |
| 90 | + from fastapi import APIRouter, HTTPException |
| 91 | + from fastapi.responses import FileResponse |
| 92 | + |
| 93 | + akousma = _akousma() |
| 94 | + router = APIRouter(prefix="/akousmata", tags=["akousmata"]) |
| 95 | + |
| 96 | + def _store(): |
| 97 | + return akousma.AkousmataStore() |
| 98 | + |
| 99 | + @router.get("/records") |
| 100 | + def list_records( |
| 101 | + app: str | None = None, |
| 102 | + origin: str | None = None, |
| 103 | + tag: str | None = None, |
| 104 | + text: str | None = None, |
| 105 | + limit: int = 100, |
| 106 | + ) -> dict[str, Any]: |
| 107 | + store = _store() |
| 108 | + try: |
| 109 | + try: |
| 110 | + found = store.query(originating_app=app, origin=origin, tag=tag, text=text, limit=max(1, min(limit, 500))) |
| 111 | + except TypeError: # pre-v0.2 store without tag/text filters |
| 112 | + found = store.query(originating_app=app, origin=origin, limit=max(1, min(limit, 500))) |
| 113 | + return {"records": [card(r) for r in found]} |
| 114 | + finally: |
| 115 | + store.close() |
| 116 | + |
| 117 | + @router.get("/tags") |
| 118 | + def tags() -> dict[str, Any]: |
| 119 | + store = _store() |
| 120 | + try: |
| 121 | + return {"tags": store.tags() if hasattr(store, "tags") else []} |
| 122 | + finally: |
| 123 | + store.close() |
| 124 | + |
| 125 | + @router.get("/records/{akousma_id}") |
| 126 | + def detail(akousma_id: str) -> dict[str, Any]: |
| 127 | + store = _store() |
| 128 | + try: |
| 129 | + record = store.get(akousma_id) |
| 130 | + if record is None: |
| 131 | + raise HTTPException(status_code=404, detail=f"akousma not found: {akousma_id}") |
| 132 | + related = store.related(akousma_id) if hasattr(store, "related") else [] |
| 133 | + return { |
| 134 | + "record": record, |
| 135 | + "summary": summary_line(record), |
| 136 | + "parents": [_ref(store, pid) for pid in store.parents(akousma_id)], |
| 137 | + "children": [_ref(store, cid) for cid in store.children(akousma_id)], |
| 138 | + "related": [ |
| 139 | + {**link, "summary": _ref(store, link.get("akousma_id", ""))["summary"]} |
| 140 | + for link in related |
| 141 | + ], |
| 142 | + "audio_available": _resolve_audio(store, record) is not None, |
| 143 | + } |
| 144 | + finally: |
| 145 | + store.close() |
| 146 | + |
| 147 | + @router.get("/audio/{akousma_id}") |
| 148 | + def audio(akousma_id: str): |
| 149 | + store = _store() |
| 150 | + try: |
| 151 | + record = store.get(akousma_id) |
| 152 | + if record is None: |
| 153 | + raise HTTPException(status_code=404, detail=f"akousma not found: {akousma_id}") |
| 154 | + path = _resolve_audio(store, record) |
| 155 | + if path is None: |
| 156 | + raise HTTPException(status_code=404, detail="no resolvable audio for this memory") |
| 157 | + return FileResponse(path) |
| 158 | + finally: |
| 159 | + store.close() |
| 160 | + |
| 161 | + return router |
0 commit comments