diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index ad82e7de1..155be63d1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -75,6 +75,18 @@ jobs: defaults: run: working-directory: backends/advanced + services: + # Some backend tests hit real local services (vault locks are Redis-backed + # and fail closed; test_leading_silence_trim_db uses Mongo). Match the + # defaults the code assumes: redis://localhost:6379, mongodb://localhost:27018. + redis: + image: redis:7-alpine + ports: + - 6379:6379 + mongo: + image: mongo:8 + ports: + - 27018:27017 steps: - name: Checkout code @@ -90,6 +102,11 @@ jobs: with: version: "latest" + - name: Install system dependencies + # libopus0: native library behind opuslib (services/device_audio.py), + # imported transitively during test collection. + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libopus0 + - name: Install test dependencies run: uv sync --locked --group test diff --git a/.github/workflows/robot-tests.yml b/.github/workflows/robot-tests.yml index 1d3fffa79..9eb226923 100644 --- a/.github/workflows/robot-tests.yml +++ b/.github/workflows/robot-tests.yml @@ -100,6 +100,15 @@ jobs: echo "✓ Logs saved to backends/advanced/logs/" ls -lh logs/ + - name: Upload service logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: robot-service-logs + path: backends/advanced/logs/ + if-no-files-found: warn + retention-days: 14 + - name: Check if test results exist if: always() id: check_results diff --git a/backends/advanced/docker-compose-test.yml b/backends/advanced/docker-compose-test.yml index 6bf75b87b..317a6995a 100644 --- a/backends/advanced/docker-compose-test.yml +++ b/backends/advanced/docker-compose-test.yml @@ -62,6 +62,11 @@ services: - LANGFUSE_BASE_URL=http://langfuse-web-test:3000 - LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key - LANGFUSE_SECRET_KEY=sk-lf-test-secret-key + extra_hosts: + # Mock services (LLM/STT) publish ports on the host and are addressed as + # host.docker.internal. Docker on Linux (CI) does not resolve that name + # without an explicit host-gateway mapping; podman resolves it natively. + - "host.docker.internal:host-gateway" depends_on: mongo-test: condition: service_healthy @@ -383,9 +388,16 @@ services: - LANGFUSE_BASE_URL=http://langfuse-web-test:3000 - LANGFUSE_PUBLIC_KEY=pk-lf-test-public-key - LANGFUSE_SECRET_KEY=sk-lf-test-secret-key + extra_hosts: + # See chronicle-backend-test: required for Docker-on-Linux (CI) to reach + # the mock LLM/STT services published on the host. + - "host.docker.internal:host-gateway" depends_on: - chronicle-backend-test: - condition: service_healthy + # NOTE: no dependency on chronicle-backend-test. The backend's /readiness + # healthcheck requires the worker fleet heartbeat in Redis, so gating the + # workers on backend health deadlocks startup (backend waits for workers, + # workers wait for backend). Matches prod compose: workers only need + # mongo + redis. mongo-test: condition: service_healthy redis-test: diff --git a/backends/advanced/pyproject.toml b/backends/advanced/pyproject.toml index dec87b445..66f519b86 100644 --- a/backends/advanced/pyproject.toml +++ b/backends/advanced/pyproject.toml @@ -104,6 +104,9 @@ filterwarnings = [ "ignore::UserWarning", "ignore::DeprecationWarning", "ignore::PendingDeprecationWarning", + # opuslib 3.x uses `is not 0`, a SyntaxWarning on py3.12 that "error" would + # promote to SyntaxError on first (uncached) import. Third-party; not ours. + "ignore::SyntaxWarning:.*opuslib.*", ] [tool.coverage.run] diff --git a/backends/advanced/src/advanced_omi_backend/config_loader.py b/backends/advanced/src/advanced_omi_backend/config_loader.py index e91f71594..df263d98b 100644 --- a/backends/advanced/src/advanced_omi_backend/config_loader.py +++ b/backends/advanced/src/advanced_omi_backend/config_loader.py @@ -16,6 +16,11 @@ # Global config cache _config_cache: Optional[DictConfig] = None +# Runtime overrides registered by save_config_section(), keyed by section path. +# Re-applied on every load_config() so they survive cache reloads even when +# CONFIG_FILE points away from config.yml (test environments). +_runtime_overrides: dict = {} + def get_config_dir() -> Path: """Get config directory path (single source of truth).""" @@ -117,6 +122,14 @@ def load_config(force_reload: bool = False) -> DictConfig: f"{[m.get('name') for m in extra_defaults]}" ) + # Re-apply runtime overrides saved via save_config_section(). When + # CONFIG_FILE points away from config.yml (test environments), the saved + # values exist only in memory — without this they silently revert on the + # next reload, so a runtime toggle only "took" until some unrelated code + # path refreshed the config. + for section_path, values in _runtime_overrides.items(): + OmegaConf.update(merged, section_path, values, merge=True) + # Cache result _config_cache = merged @@ -182,8 +195,11 @@ def save_config_section(section_path: str, values: dict) -> bool: try: config_path = get_config_dir() / "config.yml" - # Load existing config - existing_config = {} + # Load existing config. Must be a DictConfig even when the file doesn't + # exist yet — OmegaConf.update() raises "Unexpected type" on a plain + # dict, which made the very first runtime settings save fail silently + # on installs without a config.yml. + existing_config = OmegaConf.create({}) if config_path.exists(): existing_config = OmegaConf.load(config_path) @@ -193,13 +209,16 @@ def save_config_section(section_path: str, values: dict) -> bool: # Save back to file OmegaConf.save(existing_config, config_path) - # Reload config from the primary config file (CONFIG_FILE env var) - merged = reload_config() + # Register a runtime override BEFORE reloading: when CONFIG_FILE points + # to a different file than config.yml (test configs), the value we just + # saved is not in the file load_config() reads, so it must be re-applied + # on every load — a one-shot in-memory patch would silently revert on + # the next reload_config() from any code path. + _runtime_overrides[section_path] = values - # Also apply the values to the in-memory cache directly. - # This is needed when CONFIG_FILE points to a different file than config.yml - # (e.g., test configs), so the saved values still take effect at runtime. - OmegaConf.update(merged, section_path, values, merge=True) + # Reload config from the primary config file (CONFIG_FILE env var); + # load_config() re-applies _runtime_overrides on top. + reload_config() logger.info(f"Saved config section '{section_path}' to {config_path}") return True diff --git a/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py b/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py index 3c01891b2..10e36a035 100644 --- a/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py +++ b/backends/advanced/src/advanced_omi_backend/routers/modules/device_input_routes.py @@ -32,7 +32,9 @@ utcnow, ) from advanced_omi_backend.models.user import User -from advanced_omi_backend.services.device_context import request_conversation_context_jobs +from advanced_omi_backend.services.device_context import ( + request_conversation_context_jobs, +) from advanced_omi_backend.services.memory.vault_manager import ConvDocVaultManager router = APIRouter(prefix="/device-input", tags=["device-input"]) @@ -180,6 +182,24 @@ async def ingest_activity( try: await item.insert() accepted += 1 + frame_id = incoming.metadata.get("representative_frame_id") + if ( + frame_id is not None + and "screen_context" in source.capabilities + and any( + incoming.metadata.get(key) + for key in ("app_name", "window_name", "text") + ) + ): + await DeviceInputJob( + user_id=source.user_id, + source_id=source.source_id, + kind="thumbnail", + start_at=incoming.captured_at, + end_at=incoming.ended_at, + purpose="timeline_thumbnail", + payload={"item_id": str(item.id), "frame_id": frame_id}, + ).insert() except DuplicateKeyError: duplicates += 1 existing = await DeviceInputItem.find_one( @@ -307,6 +327,37 @@ async def complete_job( return {"ok": True} +@router.post("/jobs/{job_id}/thumbnail") +async def complete_thumbnail_job( + job_id: str, + file: UploadFile = File(...), + source: CaptureSource = Depends(_device_source), +): + job = await DeviceInputJob.get(job_id) + if job is None or job.source_id != source.source_id or job.kind != "thumbnail": + raise HTTPException(status_code=404, detail="Thumbnail job not found") + item_id = job.payload.get("item_id") + item = await DeviceInputItem.get(item_id) if item_id else None + if item is None or item.source_id != source.source_id: + raise HTTPException(status_code=404, detail="Timeline item not found") + content_type = (file.content_type or "").split(";", 1)[0] + if not content_type.startswith("image/"): + raise HTTPException(status_code=415, detail="Thumbnail must be an image") + data = await file.read(_MAX_IMAGE_BYTES + 1) + if len(data) > _MAX_IMAGE_BYTES: + raise HTTPException(status_code=413, detail="Thumbnail exceeds the media limit") + item.media_data = data + item.media_filename = file.filename or "screenpipe-thumbnail.jpg" + item.media_content_type = content_type + item.content_hash = hashlib.sha256(data).hexdigest() + item.metadata = {**item.metadata, "thumbnail_available": True} + await item.save() + job.status = "complete" + job.completed_at = utcnow() + await job.save() + return {"ok": True, "item_id": str(item.id)} + + @router.get("/sources") async def list_sources(user: User = Depends(current_active_user)): rows = ( @@ -476,6 +527,16 @@ async def _immich_bytes(asset_id: str, endpoint: str) -> tuple[bytes, str]: @router.get("/items/{item_id}/thumbnail") async def context_thumbnail(item_id: str, user: User = Depends(current_active_user)): item = await _owned_item(item_id, user) + if ( + item.media_data + and item.media_content_type + and item.media_content_type.startswith("image/") + ): + return Response( + content=item.media_data, + media_type=item.media_content_type, + headers={"Cache-Control": "private, max-age=3600"}, + ) asset_id = item.metadata.get("asset_id") if item.kind != "immich_memory" or not asset_id: raise HTTPException( @@ -489,6 +550,43 @@ async def context_thumbnail(item_id: str, user: User = Depends(current_active_us ) +@router.post("/items/{item_id}/request-thumbnail") +async def request_item_thumbnail( + item_id: str, user: User = Depends(current_active_user) +): + item = await _owned_item(item_id, user) + if item.kind != "activity": + raise HTTPException(status_code=409, detail="Only activity items have frames") + if item.media_data: + return {"status": "complete"} + frame_id = ( + item.metadata.get("representative_frame_id") + or item.metadata.get("last_frame_id") + or item.metadata.get("first_frame_id") + ) + if frame_id is None: + raise HTTPException(status_code=409, detail="Activity has no source frame") + existing = await DeviceInputJob.find_one( + DeviceInputJob.source_id == item.source_id, + DeviceInputJob.kind == "thumbnail", + {"payload.item_id": item_id}, + {"status": {"$in": ["pending", "claimed"]}}, + ) + if existing: + return {"status": existing.status, "job_id": str(existing.id)} + job = DeviceInputJob( + user_id=item.user_id, + source_id=item.source_id, + kind="thumbnail", + start_at=item.captured_at, + end_at=item.ended_at, + purpose="timeline_thumbnail", + payload={"item_id": item_id, "frame_id": frame_id}, + ) + await job.insert() + return {"status": "pending", "job_id": str(job.id)} + + @router.post("/items/{item_id}/promote") async def promote_context_item(item_id: str, user: User = Depends(current_active_user)): item = await _owned_item(item_id, user) diff --git a/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py b/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py index ab7524058..31416c815 100644 --- a/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py +++ b/backends/advanced/src/advanced_omi_backend/services/audio_stream/session_store.py @@ -18,14 +18,24 @@ ``decode_responses`` setting. """ +import asyncio import json import logging import time +import uuid +from contextlib import asynccontextmanager from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import AsyncIterator, Literal, Optional +from redis.exceptions import WatchError + +from advanced_omi_backend.redis_keys import ( + conversation_create_lock as conversation_create_lock_key, +) +from advanced_omi_backend.redis_keys import conversation_current + logger = logging.getLogger(__name__) # Key templates @@ -391,6 +401,184 @@ async def persist_session(self, session_id: str) -> None: async def delete(self, session_id: str) -> None: await self._redis.delete(self._key(session_id)) + # ------------------------------------------------ conversation assignment + async def set_current_conversation( + self, + session_id: str, + conversation_id: str, + *, + ttl: Optional[int] = 86400, + ) -> None: + """Assign the conversation that receives this session's persisted audio. + + ``ttl=None`` is reserved for an ``always_persist`` placeholder, whose + lifetime is tied to the session rather than a rotation timeout. + """ + key = conversation_current(session_id) + if ttl is None: + await self._redis.set(key, conversation_id) + else: + await self._redis.set(key, conversation_id, ex=ttl) + + async def get_current_conversation_id(self, session_id: str) -> Optional[str]: + """Return the decoded current-conversation assignment, if one exists.""" + return _to_str(await self._redis.get(conversation_current(session_id))) + + async def assign_current_conversation_if_active( + self, + session_id: str, + conversation_id: str, + *, + ttl: Optional[int] = 86400, + ) -> bool: + """Assign only when the session is active and has no current owner. + + The session status check and pointer creation share one Redis transaction, + closing the finalization race between a separate ``get_status`` and ``set``. + """ + session_key = self._key(session_id) + current_key = conversation_current(session_id) + + while True: + async with self._redis.pipeline(transaction=True) as pipe: + try: + await pipe.watch(session_key, current_key) + status = _to_str(await pipe.hget(session_key, "status")) + current_id = _to_str(await pipe.get(current_key)) + if status != SessionStatus.ACTIVE.value or current_id is not None: + await pipe.unwatch() + return False + + pipe.multi() + if ttl is None: + pipe.set(current_key, conversation_id) + else: + pipe.set(current_key, conversation_id, ex=ttl) + await pipe.execute() + return True + except WatchError: + continue + + async def replace_current_conversation_if_active( + self, + session_id: str, + expected_id: str, + replacement_id: str, + *, + ttl: Optional[int] = 86400, + ) -> bool: + """Atomically rotate an active session from one owner to its successor. + + There is no unassigned interval: an audio XADD watching the same pointer + observes either ``expected_id`` or ``replacement_id``. Finalization and a + competing rotation both make the compare-and-swap fail without mutation. + """ + session_key = self._key(session_id) + current_key = conversation_current(session_id) + + while True: + async with self._redis.pipeline(transaction=True) as pipe: + try: + await pipe.watch(session_key, current_key) + status = _to_str(await pipe.hget(session_key, "status")) + current_id = _to_str(await pipe.get(current_key)) + if ( + status != SessionStatus.ACTIVE.value + or current_id != expected_id + ): + await pipe.unwatch() + return False + + pipe.multi() + if ttl is None: + pipe.set(current_key, replacement_id) + else: + pipe.set(current_key, replacement_id, ex=ttl) + await pipe.execute() + return True + except WatchError: + continue + + async def clear_current_conversation( + self, + session_id: str, + *, + expected_id: Optional[str] = None, + ) -> bool: + """Clear the assignment, optionally only when it still has ``expected_id``. + + The compare-and-delete form prevents a late cleanup for conversation A + from deleting a successor assignment to conversation B. + """ + key = conversation_current(session_id) + if expected_id is None: + return bool(await self._redis.delete(key)) + + while True: + async with self._redis.pipeline(transaction=True) as pipe: + try: + await pipe.watch(key) + if _to_str(await pipe.get(key)) != expected_id: + await pipe.unwatch() + return False + pipe.multi() + pipe.delete(key) + result = await pipe.execute() + return bool(result[0]) + except WatchError: + continue + + async def expire_current_conversation(self, session_id: str, ttl: int) -> bool: + """Apply a cleanup TTL only when a conversation assignment exists.""" + return bool(await self._redis.expire(conversation_current(session_id), ttl)) + + @asynccontextmanager + async def conversation_create_lock( + self, + session_id: str, + *, + wait_timeout: float = 5.0, + lease_seconds: int = 30, + poll: float = 0.05, + ): + """Serialize get/create/assign sequences for one streaming session. + + Creation remains available if Redis locking is unhealthy: after + ``wait_timeout`` the caller runs unlocked and receives ``False``. This + avoids deadlocking audio ingestion while making the degraded mode visible + to callers and logs. + """ + key = conversation_create_lock_key(session_id) + token = uuid.uuid4().hex + deadline = time.monotonic() + wait_timeout + acquired = False + + while time.monotonic() < deadline: + acquired = bool( + await self._redis.set(key, token, nx=True, ex=lease_seconds) + ) + if acquired: + break + await asyncio.sleep(poll) + + try: + yield acquired + finally: + if acquired: + while True: + async with self._redis.pipeline(transaction=True) as pipe: + try: + await pipe.watch(key) + if _to_str(await pipe.get(key)) != token: + await pipe.unwatch() + break + pipe.multi() + pipe.delete(key) + await pipe.execute() + break + except WatchError: + continue + # ----------------------------------------------------------- field writes async def set_audio_format(self, session_id: str, audio_format: dict) -> None: await self._redis.hset( diff --git a/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py b/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py index 601f87252..f794b2167 100644 --- a/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py +++ b/backends/advanced/src/advanced_omi_backend/services/device_audio_ingest.py @@ -2,7 +2,7 @@ import asyncio import tempfile -from datetime import timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -21,18 +21,26 @@ _MAX_SESSION = timedelta(minutes=30) +def _as_utc(value: datetime) -> datetime: + """Normalize Mongo's naïve UTC datetimes before ordering or arithmetic.""" + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def group_audio_sessions(items: list[DeviceInputItem]) -> list[list[DeviceInputItem]]: sessions: list[list[DeviceInputItem]] = [] - for item in sorted(items, key=lambda row: row.captured_at): + for item in sorted(items, key=lambda row: _as_utc(row.captured_at)): if not sessions: sessions.append([item]) continue previous = sessions[-1][-1] - previous_end = previous.ended_at or previous.captured_at - session_start = sessions[-1][0].captured_at + previous_end = _as_utc(previous.ended_at or previous.captured_at) + session_start = _as_utc(sessions[-1][0].captured_at) + captured_at = _as_utc(item.captured_at) if ( - item.captured_at - previous_end > _SESSION_GAP - or item.captured_at - session_start >= _MAX_SESSION + captured_at - previous_end > _SESSION_GAP + or captured_at - session_start >= _MAX_SESSION ): sessions.append([item]) else: @@ -41,7 +49,7 @@ def group_audio_sessions(items: list[DeviceInputItem]) -> list[list[DeviceInputI async def _mix_session(items: list[DeviceInputItem], workspace: Path, output: Path) -> None: - start = min(item.captured_at for item in items) + start = min(_as_utc(item.captured_at) for item in items) command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"] valid = [item for item in items if item.media_data] if not valid: @@ -54,7 +62,9 @@ async def _mix_session(items: list[DeviceInputItem], workspace: Path, output: Pa chains = [] labels = [] for index, item in enumerate(valid): - delay_ms = max(0, int((item.captured_at - start).total_seconds() * 1000)) + delay_ms = max( + 0, int((_as_utc(item.captured_at) - start).total_seconds() * 1000) + ) label = f"a{index}" chains.append( f"[{index}:a]aresample=16000,aformat=channel_layouts=mono,adelay={delay_ms}[{label}]" @@ -109,7 +119,9 @@ async def process_device_audio() -> dict[str, Any]: if user is None: continue for session in group_audio_sessions(source_items): - session_end = max((item.ended_at or item.captured_at) for item in session) + session_end = max( + _as_utc(item.ended_at or item.captured_at) for item in session + ) if session_end > utcnow() - _CLOSE_DELAY: continue with tempfile.TemporaryDirectory( @@ -138,7 +150,9 @@ async def process_device_audio() -> dict[str, Any]: Conversation.conversation_id == conversation_id ) if conversation is not None: - conversation.created_at = min(item.captured_at for item in session) + conversation.created_at = min( + _as_utc(item.captured_at) for item in session + ) conversation.external_source_id = f"screenpipe:{source_id}:{session[0].source_item_id}-{session[-1].source_item_id}" conversation.external_source_type = "screenpipe" await conversation.save() diff --git a/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py b/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py index adfb5d433..43fd80dda 100644 --- a/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py +++ b/backends/advanced/src/advanced_omi_backend/services/memory/providers/chronicle.py @@ -14,6 +14,7 @@ """ import logging +import re import time from datetime import datetime, timezone from pathlib import Path @@ -233,14 +234,38 @@ async def _run_agent_with_note_guarantee( "short or low-information transcript, summarize the exact utterance rather " "than leaving either section blank." ) - recovery = await agent_class(user_root, force_fallback=True).run( - transcript, - source_id, - date=trusted_date, - duration_minutes=source_duration_minutes, - title=source_title, - guidance=recovery_guidance, - ) + # The recovery pass is best-effort: it may fail outright (no + # defaults.fallback_llm configured, fallback unreachable, ...). The note + # guarantee must survive that — degrade to an empty recovery result and + # let the source-preserving fallback note below do its job, rather than + # failing the whole memory job with nothing recorded. + try: + recovery = await agent_class(user_root, force_fallback=True).run( + transcript, + source_id, + date=trusted_date, + duration_minutes=source_duration_minutes, + title=source_title, + guidance=recovery_guidance, + ) + except Exception as exc: # noqa: BLE001 - degrade, never lose the note + # Lazy import: circular dependency (agent → memory_agent → llm_client → + # back into providers), same as _agent_class above. + from ..agent.memory_agent import MemoryAgentResult + + memory_logger.error( + "Memory-agent recovery pass failed for %s (%s); falling back to " + "the source-preserving conversation note", + source_id, + exc, + ) + recovery = MemoryAgentResult( + conversation_id=source_id, + rounds=0, + touched=[], + summary="", + errors=[f"recovery pass failed: {exc}"], + ) recovery.rounds += result.rounds recovery.tool_calls += result.tool_calls recovery.touched = list(dict.fromkeys((*result.touched, *recovery.touched))) @@ -440,27 +465,51 @@ async def _record_agent_touches( ), ) - @staticmethod - def _speaker_rename_guidance(transcript_diff: Optional[list]) -> str: + # Diarization placeholders ("Speaker 0", "Unknown Speaker 1") — the only labels a + # conversation-scoped diff may globally rename. A person note under a real name + # aggregates facts from many conversations, so renaming it from one conversation's + # relabel merges the wrong person's whole history (ankush.md -> roshan.md, 2026-07-17). + _PLACEHOLDER_SPEAKER_RE = re.compile( + r"^(unknown\s+)?speaker[\s_]*\d+$", re.IGNORECASE + ) + + @classmethod + def _speaker_rename_guidance(cls, transcript_diff: Optional[list]) -> str: """Turn a speaker diff into an instruction to rename the matching person notes.""" if not transcript_diff: return "" renames: dict[str, str] = {} + relabels: dict[str, str] = {} for ch in transcript_diff: if isinstance(ch, dict) and ch.get("type") == "speaker_change": old, new = ch.get("old_speaker"), ch.get("new_speaker") if old and new and old != new: - renames[old] = new - if not renames: + if cls._PLACEHOLDER_SPEAKER_RE.match(old.strip()): + renames[old] = new + else: + relabels[old] = new + parts: list[str] = [] + if renames: + pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in renames.items()) + parts.append( + f"Placeholder speakers were identified: {pairs}. For each, if a " + "People/.md note exists, call rename_person(old, new) FIRST " + "— it renames the note and rewrites every [[wikilink]] across the vault — " + "then record the conversation and update the renamed person notes. Do not " + "leave notes under placeholder speaker labels." + ) + if relabels: + pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in relabels.items()) + parts.append( + f"Attribution changed between named people IN THIS CONVERSATION ONLY: " + f"{pairs}. Fix the conversation note and move only facts sourced from " + "this conversation between the affected person notes. Do NOT call " + "rename_person for these — both notes describe real people whose facts " + "come from many other conversations." + ) + if not parts: return "" - pairs = "; ".join(f"'{o}' is now '{n}'" for o, n in renames.items()) - return ( - "This is a REPROCESS after speaker re-identification. Speaker labels changed: " - f"{pairs}. For each change, if a People/.md note exists, call " - "rename_person(old, new) FIRST — it renames the note and rewrites every " - "[[wikilink]] across the vault — then record the conversation and update the " - "renamed person notes. Do not leave notes under the old speaker labels." - ) + return "This is a REPROCESS after speaker re-identification. " + " ".join(parts) # ========================================================================= # SEARCH diff --git a/backends/advanced/tests/conftest.py b/backends/advanced/tests/conftest.py new file mode 100644 index 000000000..6abb1cc0a --- /dev/null +++ b/backends/advanced/tests/conftest.py @@ -0,0 +1,18 @@ +"""Shared pytest fixtures and test environment defaults. + +Several backend modules (notably ``advanced_omi_backend.auth``) validate that +required secrets are configured at *import* time. In CI there is no ``.env`` +file, so importing the app during test collection would raise +``ValueError: is not set``. We provide deterministic test defaults here so +collection succeeds without depending on a developer's local ``.env``. + +``setdefault`` is used so a real environment (CI secrets or a local ``.env`` +already exported) always wins over these placeholders. +""" + +import os + +# Import-time required secrets (see advanced_omi_backend.auth). +os.environ.setdefault("AUTH_SECRET_KEY", "test-auth-secret-key") +os.environ.setdefault("ADMIN_PASSWORD", "test-admin-password") +os.environ.setdefault("ADMIN_EMAIL", "admin@example.com") diff --git a/backends/advanced/tests/test_device_audio_ingest.py b/backends/advanced/tests/test_device_audio_ingest.py index 271511e8a..24e4e597c 100644 --- a/backends/advanced/tests/test_device_audio_ingest.py +++ b/backends/advanced/tests/test_device_audio_ingest.py @@ -44,3 +44,17 @@ def test_continuous_capture_is_bounded_into_processing_windows(): rows = [item(str(index), start + timedelta(minutes=index)) for index in range(32)] sessions = group_audio_sessions(rows) assert [len(session) for session in sessions] == [30, 2] + + +def test_mongo_naive_and_aware_timestamps_group_together(): + naive = datetime(2026, 7, 22, 10, 0) + aware = datetime(2026, 7, 22, 10, 0, 30, tzinfo=timezone.utc) + sessions = group_audio_sessions( + [ + item("mongo-naive", naive), + item("api-aware", aware), + ] + ) + assert [[row.source_item_id for row in session] for session in sessions] == [ + ["mongo-naive", "api-aware"] + ] diff --git a/backends/advanced/webui/chronicle-espresso-preset.js b/backends/advanced/webui/chronicle-espresso-preset.js new file mode 100644 index 000000000..9f2456a87 --- /dev/null +++ b/backends/advanced/webui/chronicle-espresso-preset.js @@ -0,0 +1,107 @@ +/** + * Chronicle "Espresso" design-system preset (Tailwind v3). + * + * Source of truth: the Chronicle Design System project (claude.ai/design), + * tokens/*.css. Warm espresso neutrals + a terracotta brand ramp + a + * forest-green status family. Dark is the default surface. + * + * Applied by REMAPPING Tailwind's default color NAMES to the Espresso ramps, + * so the thousands of existing `bg-gray-800 / text-blue-600 / dark:*` utilities + * reskin with no per-component edits. Light->dark ordering is preserved, so + * every hand-paired `x-50 dark:x-900` keeps working. + * + * gray -> espresso neutrals blue -> terracotta (brand accent) + * green -> forest (success/verifier) red -> danger amber/yellow -> warning + * purple-> suggest orange -> clay sky/cyan -> info-blue + * aliases: emerald/teal/lime->forest, zinc/slate/neutral/stone->espresso, + * indigo/violet->purple, pink/rose->red + * + * IMPORTANT: keep this file identical to the copy in + * extras/speaker-recognition/webui/chronicle-espresso-preset.js. + * The two web UIs build in separate Docker contexts, so the preset is + * duplicated rather than shared via a package. + */ + +// warm espresso neutrals (DS gray scale, extended to 950) +const espresso = { + 50: '#f7f3ea', 100: '#f2ece2', 200: '#ddd5c6', 300: '#c9bfae', 400: '#948976', + 500: '#6b5f4f', 600: '#42392f', 700: '#2c251d', 800: '#211b15', 900: '#191410', 950: '#120d0a', +} + +// brand terracotta (DS terracotta ramp; 50/100/200/800/950 interpolated) +const terracotta = { + 50: '#fbeee7', 100: '#f7dccd', 200: '#f0c3ac', 300: '#ecab93', 400: '#e07856', + 500: '#d2694a', 600: '#c2551f', 700: '#a8471f', 800: '#7c351a', 900: '#3a1f14', 950: '#241009', +} + +// forest green — positive / verifier / secondary (part of the palette) +const forest = { + 50: '#eaf3ec', 100: '#d1e7d5', 200: '#a9cfb0', 300: '#8fc79a', 400: '#6f9a5f', + 500: '#4f7d54', 600: '#3f6b47', 700: '#34614a', 800: '#294a39', 900: '#1f3729', 950: '#132218', +} + +// danger red (DS red ramp) +const danger = { + 50: '#fbeae7', 100: '#f7d5cf', 200: '#f0b3a8', 300: '#eda093', 400: '#e8735f', + 500: '#dc4a3a', 600: '#c53a2b', 700: '#a32d20', 800: '#7d2419', 900: '#5a1a12', 950: '#360e09', +} + +// warning amber (DS amber ramp) — shared by both `amber` and `yellow` +const amber = { + 50: '#fcf4e1', 100: '#f8e7bb', 200: '#f2d488', 300: '#f0c674', 400: '#e6ad3f', + 500: '#d99521', 600: '#b8781a', 700: '#925c15', 800: '#6d4513', 900: '#4a2f0f', 950: '#2b1b08', +} + +// warm clay (DS clay/apricot/ochre) — for `orange` +const clay = { + 50: '#fbeee4', 100: '#f6dcc6', 200: '#efc39c', 300: '#e5a86f', 400: '#e59b52', + 500: '#d9822f', 600: '#c26a20', 700: '#9c521c', 800: '#743e18', 900: '#4c2911', 950: '#2c170a', +} + +// suggest purple (DS purple) +const suggest = { + 50: '#f4eef8', 100: '#e7d9ef', 200: '#d4bce2', 300: '#c2a0d4', 400: '#a986c4', + 500: '#9169b0', 600: '#775394', 700: '#5f4278', 800: '#48335c', 900: '#312340', 950: '#1d1526', +} + +// muted info-blue (DS --info-fg family) — keeps a real "info" hue distinct from the brand +const info = { + 50: '#eef3f7', 100: '#d7e3ec', 200: '#b5cbdd', 300: '#8fb0c9', 400: '#6b93b0', + 500: '#4f7896', 600: '#3f6079', 700: '#344e61', 800: '#2a3d4b', 900: '#1f2c37', 950: '#141d24', +} + +/** @type {import('tailwindcss').Config} */ +export default { + theme: { + extend: { + colors: { + // neutrals + gray: espresso, zinc: espresso, slate: espresso, neutral: espresso, stone: espresso, + // brand + blue: terracotta, terracotta, + // status + green: forest, emerald: forest, teal: forest, lime: forest, forest, + red: danger, rose: danger, pink: danger, + amber, yellow: amber, + orange: clay, clay, + purple: suggest, violet: suggest, indigo: suggest, + sky: info, cyan: info, info, + }, + fontFamily: { + sans: ['system-ui', '-apple-system', '"Segoe UI"', 'Roboto', 'Helvetica', 'Arial', 'sans-serif'], + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', '"Liberation Mono"', 'monospace'], + }, + borderRadius: { + sm: '0.25rem', // DS chips (4px) + }, + boxShadow: { + // warm-tinted ambient elevation for the espresso surfaces + sm: '0 1px 2px 0 rgba(20,12,6,.40)', + DEFAULT: '0 2px 4px -1px rgba(20,12,6,.45)', + md: '0 6px 14px -4px rgba(20,12,6,.50)', + lg: '0 12px 28px -6px rgba(20,12,6,.55), 0 4px 10px -4px rgba(20,12,6,.45)', + xl: '0 14px 36px rgba(20,12,6,.60)', + }, + }, + }, +} diff --git a/backends/advanced/webui/src/components/AsrContextSettings.tsx b/backends/advanced/webui/src/components/AsrContextSettings.tsx index 86edb57df..a37855b18 100644 --- a/backends/advanced/webui/src/components/AsrContextSettings.tsx +++ b/backends/advanced/webui/src/components/AsrContextSettings.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Mic, Save, Sparkles, Tag } from 'lucide-react' import { systemApi } from '../services/api' +import { Button, Card, StateBadge, Textarea } from './ui' interface AsrModelInfo { name: string @@ -19,23 +20,19 @@ interface AsrContextData { function HintTypeBadge({ hintType }: { hintType: AsrModelInfo['hint_type'] }) { if (hintType === 'context_prompt') { return ( - + Context prompt (LLM) - + ) } if (hintType === 'keyword_boosting') { return ( - + Keyword boosting (acoustic) - + ) } - return ( - - No recognition hints - - ) + return No recognition hints } function ProviderRow({ label, model, onSaved }: { @@ -88,22 +85,21 @@ function ProviderRow({ label, model, onSaved }: { Context — describe the domain, names, or jargon to help this LLM-based model. It informs recognition but is never transcribed. -