Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/advanced-backend-unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Advanced Backend Unit Tests

on:
pull_request:
paths:
- 'backends/advanced/**'
- '.github/workflows/advanced-backend-unit-tests.yml'
- 'Makefile.unittests'
push:
branches:
- dev
- main
paths:
- 'backends/advanced/**'
- '.github/workflows/advanced-backend-unit-tests.yml'
- 'Makefile.unittests'
workflow_dispatch:

permissions:
contents: read

jobs:
advanced-backend-unit-tests:
name: Run advanced backend unit tests
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Mock config files for unit tests
run: |
cat > config.env << 'EOF'
DEPLOYMENT_MODE=docker-compose
DOMAIN=localhost
CONTAINER_REGISTRY=local
SPEAKER_NODE=localhost
INFRASTRUCTURE_NAMESPACE=infrastructure
APPLICATION_NAMESPACE=application
EOF

mkdir -p backends/advanced
cat > backends/advanced/.env << 'EOF'
AUTH_SECRET_KEY=test-auth-secret
ADMIN_PASSWORD=test-admin-password
ADMIN_EMAIL=test-admin@example.com
EOF

- name: Run advanced backend unit tests
run: make -f Makefile.unittests test-unit
27 changes: 27 additions & 0 deletions Makefile.unittests
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
.PHONY: help test-unit robot-test robot-all

# Minimal auth env required during import-time test collection.
UNIT_TEST_ENV := AUTH_SECRET_KEY=test-auth-secret ADMIN_PASSWORD=test-admin-password ADMIN_EMAIL=test-admin@example.com

help:
@echo "Repository-level unit/robot test targets"
@echo " make -f Makefile.unittests test-unit Run advanced backend Python unit tests"
@echo ""
@echo "Robot test passthrough targets (runs via tests/)"
@echo " make -f Makefile.unittests robot-test [CONFIG=deepgram-openai.yml]"
@echo " make -f Makefile.unittests robot-all [CONFIG=deepgram-openai.yml]"

test-unit:
@echo "Running advanced backend Python unit tests..."
@cd backends/advanced && $(UNIT_TEST_ENV) uv run --group test pytest tests/unit tests/test_init_llm_setup.py
@echo "Advanced backend Python unit tests completed"

robot-test:
@echo "Starting/rebuilding Robot test containers from tests/..."
@$(MAKE) -C tests start-rebuild $(if $(CONFIG),CONFIG=$(CONFIG),)
@echo "Running Robot test workflow from tests/..."
@$(MAKE) -C tests test $(if $(CONFIG),CONFIG=$(CONFIG),)

robot-all:
@echo "Running all Robot suites from tests/..."
@$(MAKE) -C tests all $(if $(CONFIG),CONFIG=$(CONFIG),)
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@ cd app
npm start
```

### Unit + Robot Tests
```bash
# Run advanced backend Python unit tests
make -f Makefile.unittests test-unit

# Run Robot test workflow (includes start-rebuild automatically)
make -f Makefile.unittests robot-test

# Run both unit + Robot tests
make -f Makefile.unittests test-unit && make -f Makefile.unittests robot-test

# Optional Robot config override
make -f Makefile.unittests robot-test CONFIG=deepgram-openai.yml
```


### Health Checks
```bash
# Backend health
Expand Down
114 changes: 110 additions & 4 deletions backends/advanced/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,92 @@ def prompt_with_existing_masked(self, prompt_text: str, env_key: str, placeholde
default=default
)

def _get_model_def(self, config: Dict[str, Any], model_name: str) -> Dict[str, Any]:
"""Get a model definition by name from config.yml."""
models = config.get("models", [])
if not isinstance(models, list):
return {}
return next((m for m in models if m.get("name") == model_name), {})

def _infer_embedding_dimensions(self, model_name: str, fallback: int = 1536) -> int:
"""Infer embedding dimensions for common models."""
known_dimensions = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
"nomic-embed-text-v1.5": 768,
"nomic-embed-text:latest": 768,
}
return known_dimensions.get(model_name, fallback)

def _upsert_openai_models(
self,
api_key: str,
base_url: str,
llm_model_name: str,
embedding_model_name: str,
) -> None:
"""Update or create openai-llm/openai-embed in config.yml and set defaults."""
config = self.config_manager.get_full_config()
models = config.get("models", [])
if not isinstance(models, list):
models = []

openai_llm = self._get_model_def(config, "openai-llm")
openai_embed = self._get_model_def(config, "openai-embed")

llm_params = openai_llm.get("model_params", {})
if not isinstance(llm_params, dict):
llm_params = {}
llm_params.setdefault("temperature", 0.2)
llm_params.setdefault("max_tokens", 2000)

embedding_dimensions = openai_embed.get("embedding_dimensions")
if not isinstance(embedding_dimensions, int) or embedding_dimensions <= 0:
embedding_dimensions = self._infer_embedding_dimensions(embedding_model_name)

llm_payload = {
"name": "openai-llm",
"description": "OpenAI/OpenAI-compatible LLM",
"model_type": "llm",
"model_provider": "openai",
"api_family": "openai",
"model_name": llm_model_name,
"model_url": base_url,
"api_key": api_key,
"model_params": llm_params,
"model_output": "json",
}
embed_payload = {
"name": "openai-embed",
"description": "OpenAI/OpenAI-compatible embeddings",
"model_type": "embedding",
"model_provider": "openai",
"api_family": "openai",
"model_name": embedding_model_name,
"model_url": base_url,
"api_key": api_key,
"embedding_dimensions": embedding_dimensions,
"model_output": "vector",
}

def upsert_model(payload: Dict[str, Any]):
for idx, model in enumerate(models):
if model.get("name") == payload["name"]:
models[idx] = {**model, **payload}
return
models.append(payload)

upsert_model(llm_payload)
upsert_model(embed_payload)

config["models"] = models
if "defaults" not in config or not isinstance(config["defaults"], dict):
config["defaults"] = {}
config["defaults"]["llm"] = "openai-llm"
config["defaults"]["embedding"] = "openai-embed"

self.config_manager.save_full_config(config)

def setup_authentication(self):
"""Configure authentication settings"""
Expand Down Expand Up @@ -307,17 +393,29 @@ def setup_llm(self):
self.console.print()

choices = {
"1": "OpenAI (GPT-4, GPT-3.5 - requires API key)",
"1": "OpenAI / OpenAI-compatible (custom base URL, API key, model names)",
"2": "Ollama (local models - runs locally)",
"3": "Skip (no memory extraction)"
}

choice = self.prompt_choice("Which LLM provider will you use?", choices, "1")

if choice == "1":
self.console.print("[blue][INFO][/blue] OpenAI selected")
self.console.print("[blue][INFO][/blue] OpenAI/OpenAI-compatible selected")
self.console.print("Get your API key from: https://platform.openai.com/api-keys")

existing_cfg = self.config_manager.get_full_config()
openai_llm = self._get_model_def(existing_cfg, "openai-llm")
openai_embed = self._get_model_def(existing_cfg, "openai-embed")

default_base_url = openai_llm.get("model_url") or openai_embed.get("model_url") or "https://api.openai.com/v1"
default_llm_model = openai_llm.get("model_name") or "gpt-4o-mini"
default_embedding_model = openai_embed.get("model_name") or "text-embedding-3-small"

base_url = self.prompt_value("OpenAI-compatible base URL", default_base_url)
llm_model_name = self.prompt_value("LLM model name", default_llm_model)
embedding_model_name = self.prompt_value("Embedding model name", default_embedding_model)

# Use the new masked prompt function
api_key = self.prompt_with_existing_masked(
prompt_text="OpenAI API key (leave empty to skip)",
Expand All @@ -329,11 +427,19 @@ def setup_llm(self):

if api_key:
self.config["OPENAI_API_KEY"] = api_key
# Update config.yml to use OpenAI models
self.config_manager.update_config_defaults({"llm": "openai-llm", "embedding": "openai-embed"})
# Update config.yml openai model definitions and defaults
self._upsert_openai_models(
api_key=api_key,
base_url=base_url,
llm_model_name=llm_model_name,
embedding_model_name=embedding_model_name,
)
self.console.print("[green][SUCCESS][/green] OpenAI configured in config.yml")
self.console.print("[blue][INFO][/blue] Set defaults.llm: openai-llm")
self.console.print("[blue][INFO][/blue] Set defaults.embedding: openai-embed")
self.console.print(f"[blue][INFO][/blue] Set openai-llm.model_url: {base_url}")
self.console.print(f"[blue][INFO][/blue] Set openai-llm.model_name: {llm_model_name}")
self.console.print(f"[blue][INFO][/blue] Set openai-embed.model_name: {embedding_model_name}")
else:
self.console.print("[yellow][WARNING][/yellow] No API key provided - memory extraction will not work")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""
Deepgram audio stream worker.

Starts a consumer that reads from audio:stream:deepgram and transcribes audio.
"""

import os

from advanced_omi_backend.services.transcription.streaming_consumer import (
StreamingTranscriptionConsumer,
)
from advanced_omi_backend.workers.base_audio_worker import BaseStreamWorker


class DeepgramStreamWorker(BaseStreamWorker):
"""Deepgram audio stream worker implementation."""

def __init__(self):
super().__init__(service_name="Deepgram audio stream worker")

def validate_config(self):
"""Check that config.yml has Deepgram configured."""
# The registry provider will load configuration from config.yml
api_key = os.getenv("DEEPGRAM_API_KEY")
if not api_key:
self.logger.warning("DEEPGRAM_API_KEY environment variable not set")
self.logger.warning("Ensure config.yml has a default 'stt' model configured for Deepgram")
self.logger.warning("Audio transcription will use alternative providers if configured in config.yml")

def get_consumer(self, redis_client):
"""Create streaming transcription consumer."""
return StreamingTranscriptionConsumer(redis_client=redis_client)


if __name__ == "__main__":
DeepgramStreamWorker.start()
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""
Parakeet audio stream worker.

Starts a consumer that reads from audio:stream:* and transcribes audio using Parakeet.
"""

import os

from advanced_omi_backend.services.transcription.streaming_consumer import (
StreamingTranscriptionConsumer,
)
from advanced_omi_backend.workers.base_audio_worker import BaseStreamWorker


class ParakeetStreamWorker(BaseStreamWorker):
"""Parakeet audio stream worker implementation."""

def __init__(self):
super().__init__(service_name="Parakeet audio stream worker")

def validate_config(self):
"""Check that config.yml has Parakeet configured."""
# The registry provider will load configuration from config.yml
service_url = os.getenv("PARAKEET_ASR_URL")
if not service_url:
self.logger.warning("PARAKEET_ASR_URL environment variable not set")
self.logger.warning("Ensure config.yml has a default 'stt' model configured for Parakeet")
self.logger.warning("Audio transcription will use alternative providers if configured in config.yml")

def get_consumer(self, redis_client):
"""Create streaming transcription consumer."""
return StreamingTranscriptionConsumer(redis_client=redis_client)


if __name__ == "__main__":
ParakeetStreamWorker.start()

Loading
Loading