Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 4f29c5d35d 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 15:42:44 +01:00
29 changed files with 69 additions and 585 deletions
-15
View File
@@ -12,9 +12,6 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -23,8 +20,6 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -68,8 +63,6 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -114,8 +107,6 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -160,8 +151,6 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -208,8 +197,6 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -255,8 +242,6 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+2 -26
View File
@@ -180,7 +180,7 @@ jobs:
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
test-backend:
test-back:
runs-on: ubuntu-latest
needs: build-mails
permissions:
@@ -294,33 +294,9 @@ jobs:
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
- name: Run backend tests
- name: Run tests
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
permissions:
-3
View File
@@ -28,7 +28,6 @@ and this project adheres to
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
### Fixed
@@ -36,8 +35,6 @@ and this project adheres to
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
-6
View File
@@ -191,7 +191,6 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -204,11 +203,6 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary service tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
+1 -2
View File
@@ -1,9 +1,8 @@
#!/usr/bin/env bash
# NB: this file is used locally only. In CI, it is overwritten by pytest install
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
-e DJANGO_CONFIGURATION=Test \
app-dev \
pytest "$@"
pytest "$@"
-7
View File
@@ -1,7 +0,0 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+7 -8
View File
@@ -1,14 +1,13 @@
FROM python:3.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libglib2.0-0 \
libgobject-2.0-0 \
&& apt-get upgrade -y openssl libssl3t64 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgobject-2.0-0 \
"openssl=3.5.4-1~deb13u2" \
"libssl3t64=3.5.4-1~deb13u2" \
&& rm -rf /var/lib/apt/lists/*
FROM base AS builder
WORKDIR /builder
+5 -3
View File
@@ -488,7 +488,9 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response({"status": "error"}, status=status_code)
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action(
detail=False,
@@ -755,10 +757,10 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
except InvalidFilepathError:
return drf_response.Response(
+1 -1
View File
@@ -14,7 +14,7 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
@@ -95,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data."}
assert response.json() == {"detail": "Invalid request data: Error message"}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -112,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified."}
assert response.json() == {"detail": "Invalid bucket specified"}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -77,6 +77,7 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -90,7 +91,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error"}
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -115,6 +116,7 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@@ -187,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_HEIGHT
PROCESSING_WIDTH
)
}
+3 -11
View File
@@ -21,17 +21,8 @@ dependencies = [
[project.optional-dependencies]
dev = [
"ruff==0.14.4",
"pytest==9.0.2",
"responses>=0.25.8",
]
[tool.pytest.ini_options]
markers = [
"unit: Test individual components",
"integration: Test the API",
]
testpaths = ["tests"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
@@ -58,10 +49,11 @@ select = [
"T20", # flake8-print
"W", # pycodestyle warning
]
ignore= ["DJ001", "PLR2004"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S", "SLF"]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
+5 -4
View File
@@ -12,6 +12,8 @@ from summary.core.celery_worker import (
)
from summary.core.config import get_settings
settings = get_settings()
class TranscribeSummarizeTaskCreation(BaseModel):
"""Transcription and summarization parameters."""
@@ -32,11 +34,10 @@ class TranscribeSummarizeTaskCreation(BaseModel):
@classmethod
def validate_language(cls, v):
"""Validate 'language' parameter."""
allowed = get_settings().whisperx_allowed_languages
if v is not None and v not in allowed:
if v is not None and v not in settings.whisperx_allowed_languages:
raise ValueError(
f"Language '{v}' is not allowed. "
f"Allowed languages: {', '.join(allowed)}"
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
)
return v
@@ -61,7 +62,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
request.download_link,
request.context_language,
],
queue=get_settings().transcribe_queue,
queue=settings.transcribe_queue,
)
return {"id": task.id, "message": "Task created"}
+2 -3
View File
@@ -12,6 +12,7 @@ from posthog import Posthog
from summary.core.config import get_settings
logger = get_task_logger(__name__)
settings = get_settings()
class AnalyticsException(Exception):
@@ -25,7 +26,6 @@ class Analytics:
def __init__(self):
"""Initialize a client if settings are configure."""
settings = get_settings()
self._client = None
if settings.posthog_api_key and settings.posthog_enabled:
logger.info("Initialize analytics client")
@@ -71,7 +71,6 @@ class MetadataManager:
def __init__(self):
"""Initialize the task tracker with analytics client."""
settings = get_settings()
self._redis = redis.from_url(settings.task_tracker_redis_url)
self._key_prefix = settings.task_tracker_prefix
self._analytics = get_analytics()
@@ -118,7 +117,7 @@ class MetadataManager:
start_time = time.time()
initial_metadata = {
"start_time": start_time,
"asr_model": get_settings().whisperx_asr_model,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"filename": filename,
"email": email,
+2 -2
View File
@@ -140,7 +140,7 @@ def format_transcript(
)
def _format_actions(llm_output: dict) -> str:
def format_actions(llm_output: dict) -> str:
"""Format the actions from the LLM output into a markdown list.
fomat:
@@ -328,7 +328,7 @@ def summarize_transcription(
response_format=FORMAT_NEXT_STEPS,
)
next_steps = _format_actions(json.loads(next_steps))
next_steps = format_actions(json.loads(next_steps))
logger.info("Next steps generated")
-29
View File
@@ -1,6 +1,5 @@
"""Application configuration and settings."""
import os
from functools import lru_cache
from typing import Annotated, List, Literal, Optional, Set
@@ -92,37 +91,9 @@ class Settings(BaseSettings):
task_tracker_prefix: str = "task_metadata:"
class TestSettings(Settings):
"""Settings with safe defaults for testing."""
model_config = SettingsConfigDict(env_file=None)
app_api_token: SecretStr = SecretStr("test-api-token")
aws_storage_bucket_name: str = "test-bucket"
aws_s3_endpoint_url: str = "http://localhost:9000"
aws_s3_access_key_id: str = "test-access-key"
aws_s3_secret_access_key: SecretStr = SecretStr("test-secret-key")
aws_s3_secure_access: bool = False
whisperx_api_key: SecretStr = SecretStr("test-whisperx-key")
whisperx_base_url: str = "http://localhost:8000/v1"
llm_base_url: str = "http://localhost:8001/v1"
llm_api_key: SecretStr = SecretStr("test-llm-key")
llm_model: str = "test-model"
webhook_api_token: SecretStr = SecretStr("test-webhook-token")
webhook_url: str = "http://localhost:8002/webhook"
celery_broker_url: str = "memory://"
celery_result_backend: str = "cache+memory://"
posthog_enabled: bool = False
sentry_is_enabled: bool = False
langfuse_enabled: bool = False
task_tracker_redis_url: str = "redis://localhost:6379/0"
@lru_cache
def get_settings():
"""Load and cache application settings."""
if os.environ.get("SUMMARY_ENV") == "test":
return TestSettings()
return Settings()
+11 -9
View File
@@ -13,6 +13,9 @@ from minio.error import MinioException, S3Error
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -28,24 +31,23 @@ class FileService:
def __init__(self):
"""Initialize FileService with MinIO client and configuration."""
endpoint = (
get_settings()
.aws_s3_endpoint_url.removeprefix("https://")
settings.aws_s3_endpoint_url.removeprefix("https://")
.removeprefix("http://")
.rstrip("/")
)
self._minio_client = Minio(
endpoint,
access_key=get_settings().aws_s3_access_key_id,
secret_key=get_settings().aws_s3_secret_access_key.get_secret_value(),
secure=get_settings().aws_s3_secure_access,
access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access,
)
self._bucket_name = get_settings().aws_storage_bucket_name
self._bucket_name = settings.aws_storage_bucket_name
self._stream_chunk_size = 32 * 1024
self._allowed_extensions = get_settings().recording_allowed_extensions
self._max_duration = get_settings().recording_max_duration
self._allowed_extensions = settings.recording_allowed_extensions
self._max_duration = settings.recording_max_duration
def _download_from_minio(self, remote_object_key) -> Path:
"""Download file from MinIO to local temporary file.
@@ -172,7 +174,7 @@ class FileService:
extension = downloaded_path.suffix.lower()
if extension in get_settings().recording_video_extensions:
if extension in settings.recording_video_extensions:
logger.info("Video file detected, extracting audio...")
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
processed_path = extracted_audio_path
+13 -10
View File
@@ -8,6 +8,9 @@ from langfuse import Langfuse
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -31,28 +34,28 @@ class LLMObservability:
self.session_id = session_id
self.user_id = user_id
if get_settings().langfuse_enabled:
if settings.langfuse_enabled:
def masking_function(data, **kwargs):
if (
user_has_tracing_consent
or get_settings().langfuse_environment != "production"
or settings.langfuse_environment != "production"
):
return data
return "[REDACTED]"
if not get_settings().langfuse_secret_key:
if not settings.langfuse_secret_key:
raise ValueError(
"langfuse_secret_key is not configured. "
"Please set the secret key or disable Langfuse."
)
self._observability_client = Langfuse(
secret_key=get_settings().langfuse_secret_key.get_secret_value(),
public_key=get_settings().langfuse_public_key,
host=get_settings().langfuse_host,
environment=get_settings().langfuse_environment,
secret_key=settings.langfuse_secret_key.get_secret_value(),
public_key=settings.langfuse_public_key,
host=settings.langfuse_host,
environment=settings.langfuse_environment,
mask=masking_function,
)
@@ -69,8 +72,8 @@ class LLMObservability:
to Langfuse for observability when enabled.
"""
base_args = {
"base_url": get_settings().llm_base_url,
"api_key": get_settings().llm_api_key.get_secret_value(),
"base_url": settings.llm_base_url,
"api_key": settings.llm_api_key.get_secret_value(),
}
if not self.is_enabled:
@@ -117,7 +120,7 @@ class LLMService:
"""
try:
params: dict[str, Any] = {
"model": get_settings().llm_model,
"model": settings.llm_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
@@ -6,6 +6,8 @@ from typing import Optional, Tuple
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -21,7 +23,7 @@ class TranscriptFormatter:
def __init__(self, locale: LocaleStrings):
"""Initialize formatter with settings and locale."""
self.hallucination_patterns = get_settings().hallucination_patterns
self.hallucination_patterns = settings.hallucination_patterns
self._locale = locale
def _get_segments(self, transcription):
+8 -8
View File
@@ -9,6 +9,8 @@ from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
@@ -16,9 +18,9 @@ def _create_retry_session():
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=get_settings().webhook_max_retries,
backoff_factor=get_settings().webhook_backoff_factor,
status_forcelist=get_settings().webhook_status_forcelist,
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
@@ -29,9 +31,7 @@ def _post_with_retries(url, data):
"""Send POST request with automatic retries."""
session = _create_retry_session()
session.headers.update(
{
"Authorization": f"Bearer {get_settings().webhook_api_token.get_secret_value()}" # noqa: E501
}
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
)
try:
response = session.post(url, json=data)
@@ -53,10 +53,10 @@ def submit_content(content, title, email, sub):
"sub": sub,
}
logger.debug("Submitting to %s", get_settings().webhook_url)
logger.debug("Submitting to %s", settings.webhook_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(get_settings().webhook_url, data)
response = _post_with_retries(settings.webhook_url, data)
try:
response_data = response.json()
-1
View File
@@ -1 +0,0 @@
"""Tests for the summary service."""
-7
View File
@@ -1,7 +0,0 @@
"""Shared test fixtures and environment setup for the summary service tests."""
import os
# Activate TestSettings (safe defaults for all required env vars)
# before any summary module is imported.
os.environ["SUMMARY_ENV"] = "test"
@@ -1 +0,0 @@
"""Integration tests for the summary service."""
-23
View File
@@ -1,23 +0,0 @@
"""Integration test configuration. Provides shared fixtures."""
import pytest
from fastapi.testclient import TestClient
from summary.core.celery_worker import celery
from summary.main import app
@pytest.fixture()
def client():
"""Provide a FastAPI TestClient for integration tests."""
return TestClient(app)
@pytest.fixture()
def eager_celery():
"""Run Celery tasks synchronously in the same process."""
celery.conf.task_always_eager = True
celery.conf.task_eager_propagates = True
yield
celery.conf.task_always_eager = False
celery.conf.task_eager_propagates = False
@@ -1,21 +0,0 @@
"""Integration tests for the health check endpoints."""
class TestHeartbeat:
"""Tests for the /__heartbeat__ endpoint."""
def test_returns_200(self, client):
"""The heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__heartbeat__")
assert response.status_code == 200
class TestLBHeartbeat:
"""Tests for the /__lbheartbeat__ endpoint."""
def test_returns_200(self, client):
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
@@ -1,130 +0,0 @@
"""Integration test for the transcribe-and-summarize task flow via the API."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import responses
from summary.core.config import get_settings
API_PREFIX = "/api/v1"
AUTH_HEADER = {
"Authorization": f"Bearer {get_settings().app_api_token.get_secret_value()}"
}
WEBHOOK_URL = get_settings().webhook_url
class TestTranscribeSummarizeFlow:
"""End-to-end test: POST /tasks/ triggers transcription and summary via webhook."""
@responses.activate
@patch("summary.core.celery_worker.analytics")
@patch("summary.core.celery_worker.LLMObservability")
@patch("summary.core.celery_worker.LLMService")
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_transcription_and_summary_are_submitted( # noqa: PLR0913
self,
mock_file_service,
mock_openai,
mock_metadata,
mock_llm_cls,
mock_observability_cls,
mock_analytics,
client,
eager_celery,
):
"""Creating a task produces a transcription and summary sent to the webhook."""
# Stub file service
fake_audio = MagicMock()
@contextmanager
def fake_prepare(filename):
yield fake_audio, {"duration": 60.0}
mock_file_service.prepare_audio_file = fake_prepare
# Stub WhisperX transcription
fake_transcription = SimpleNamespace(
segments=[
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
{"speaker": "SPEAKER_01", "text": "Let's discuss the roadmap."},
],
)
mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = fake_transcription
mock_openai.OpenAI.return_value = mock_client
# Stub analytics to enable summary
mock_analytics.is_feature_enabled.return_value = True
# Stub LLM for summarization
mock_llm = MagicMock()
mock_llm_cls.return_value = mock_llm
plan_json = json.dumps({"titles": ["Roadmap"]})
next_steps_json = json.dumps(
{
"actions": [
{
"title": "Draft roadmap",
"assignees": ["Aleb"],
"due_date": "2026-03-04",
}
]
}
)
mock_llm.call.side_effect = [
"### TL;DR\nQuick overview.", # tldr
plan_json, # parts plan
"### Roadmap\nDetails.", # part content
next_steps_json, # next steps
"Cleaned summary.", # cleaning
]
mock_observability_cls.return_value = MagicMock()
# Stub webhook (called twice: transcription + summary)
responses.post(WEBHOOK_URL, json={"id": "doc-1"}, status=200)
responses.post(WEBHOOK_URL, json={"id": "doc-2"}, status=200)
payload = {
"owner_id": "owner-1",
"filename": "recording.webm",
"email": "user@example.com",
"sub": "user-sub-id",
"room": "Visio room",
"recording_date": "2026-03-04",
"recording_time": "09:00",
"language": "en",
"download_link": "https://example.com/rec.webm",
"context_language": "en",
}
response = client.post(
f"{API_PREFIX}/tasks/", json=payload, headers=AUTH_HEADER
)
assert response.status_code == 200
body = response.json()
assert "id" in body
assert body["message"] == "Task created"
# Verify the webhook received the transcription
assert len(responses.calls) >= 1
transcript_payload = json.loads(responses.calls[0].request.body)
assert "SPEAKER_00" in transcript_payload["content"]
assert "Hello everyone." in transcript_payload["content"]
assert "Visio room" in transcript_payload["title"]
assert transcript_payload["email"] == "user@example.com"
assert transcript_payload["sub"] == "user-sub-id"
# Verify the webhook received the summary
assert len(responses.calls) == 2
summary_payload = json.loads(responses.calls[1].request.body)
assert "TL;DR" in summary_payload["content"]
assert "Cleaned summary." in summary_payload["content"]
assert "Draft roadmap" in summary_payload["content"]
-1
View File
@@ -1 +0,0 @@
"""Unit tests for the summary service."""
@@ -1,249 +0,0 @@
"""Tests for the celery_worker module."""
import json
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import responses
from summary.core.celery_worker import (
format_transcript,
summarize_transcription,
transcribe_audio,
)
from summary.core.config import get_settings
from summary.core.file_service import FileServiceException
WEBHOOK_URL = get_settings().webhook_url
# ---------------------------------------------------------------------------
# transcribe_audio
# ---------------------------------------------------------------------------
class TestTranscribeAudio:
"""Tests for the transcribe_audio function."""
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_success(self, mock_file_service, mock_openai, mock_metadata):
"""Transcription succeeds and returns the transcription object."""
fake_audio = MagicMock()
fake_metadata = {"duration": 120.5}
@contextmanager
def fake_prepare(filename):
yield fake_audio, fake_metadata
mock_file_service.prepare_audio_file = fake_prepare
fake_transcription = SimpleNamespace(
segments=[{"speaker": "SPEAKER_00", "text": "Hello"}],
)
mock_client = MagicMock()
mock_client.audio.transcriptions.create.return_value = fake_transcription
mock_openai.OpenAI.return_value = mock_client
result = transcribe_audio("task-1", "recording.ogg", "en")
assert result is fake_transcription
mock_client.audio.transcriptions.create.assert_called_once()
call_kwargs = mock_client.audio.transcriptions.create.call_args
assert call_kwargs.kwargs["language"] == "en"
assert call_kwargs.kwargs["file"] is fake_audio
@patch("summary.core.celery_worker.metadata_manager")
@patch("summary.core.celery_worker.openai")
@patch("summary.core.celery_worker.file_service")
def test_file_service_error_returns_none(
self, mock_file_service, mock_openai, mock_metadata
):
"""Returns None when the file cannot be retrieved."""
@contextmanager
def failing_prepare(filename):
raise FileServiceException("download failed")
yield # NOSONAR - yield required for contextmanager
mock_file_service.prepare_audio_file = failing_prepare
result = transcribe_audio("task-1", "recording.ogg", "en")
assert result is None
mock_openai.OpenAI.return_value.audio.transcriptions.create.assert_not_called()
# ---------------------------------------------------------------------------
# format_transcript
# ---------------------------------------------------------------------------
class TestFormatTranscript:
"""Tests for the format_transcript function."""
def test_with_segments(self):
"""Formats a transcription with segments into content and title."""
transcription = {
"segments": [
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
{"speaker": "SPEAKER_01", "text": "Good morning."},
],
}
content, title = format_transcript(
transcription,
context_language="en",
language="en",
room="Daily standup",
recording_date="2026-03-04",
recording_time="09:00",
download_link="https://example.com/rec.ogg",
)
assert "SPEAKER_00" in content
assert "Hello everyone." in content
assert "SPEAKER_01" in content
assert "Good morning." in content
assert "Daily standup" in title
assert "2026-03-04" in title
assert "09:00" in title
@pytest.mark.parametrize(
"context_language, expected_string",
[
("en", "Download your recording"),
("fr", "Télécharger votre enregistrement"),
("de", "diesem Link folgen"),
("nl", "Download uw opname door"),
],
)
def test_context_language(self, context_language, expected_string):
"""Context language parameter modifies output."""
transcription = {
"segments": [
{"speaker": "SPEAKER_00", "text": "Hello everyone."},
],
}
content, _ = format_transcript(
transcription,
context_language=context_language,
language="en",
room="Daily standup",
recording_date="2026-03-04",
recording_time="09:00",
download_link="https://example.com/rec.ogg",
)
assert expected_string in content
def test_empty_segments(self):
"""Returns empty-transcription message when there are no segments."""
transcription = {"segments": []}
content, title = format_transcript(
transcription,
context_language="en",
language="en",
room=None,
recording_date=None,
recording_time=None,
download_link=None,
)
assert "No audio content" in content or "Transcription" in title
# ---------------------------------------------------------------------------
# summarize_transcription
# ---------------------------------------------------------------------------
class TestSummarizeTranscription:
"""Tests for the summarize_transcription Celery task."""
@responses.activate
@patch("summary.core.celery_worker.LLMService")
@patch("summary.core.celery_worker.LLMObservability")
@patch("summary.core.celery_worker.analytics")
def test_generates_and_submits_summary(
self, mock_analytics, mock_observability_cls, mock_llm_cls
):
"""Assembles TLDR + parts + next steps + cleaning, then submits."""
mock_analytics.is_feature_enabled.return_value = False
# Mock the webhook HTTP endpoint
responses.post(
WEBHOOK_URL,
json={"id": "doc-42"},
status=200,
)
mock_llm = MagicMock()
mock_llm_cls.return_value = mock_llm
plan_json = json.dumps({"titles": ["Topic A", "Topic B"]})
next_steps_json = json.dumps(
{
"actions": [
{
"title": "What's nice about Visio",
"assignees": ["Aleb"],
"due_date": "2026-03-04",
}
]
}
)
# LLM calls in order: tldr, parts (plan), part A, part B, next-steps, cleaning
mock_llm.call.side_effect = [
"### TL;DR\nShort summary.", # tldr
plan_json, # parts plan
"### Topic A\nDetails about A.", # part A
"### Topic B\nDetails about B.", # part B
next_steps_json, # next steps
"Cleaned summary content.", # cleaning
]
mock_observability = MagicMock()
mock_observability_cls.return_value = mock_observability
# Push a fake request context so self.request.id is available
summarize_transcription.push_request(id="summary-task-1")
try:
summarize_transcription.run(
"owner-1",
"Full transcript text",
"user@example.com",
"oidc-sub-123",
"99.999% uptime. Is it reasonable ?",
)
finally:
summarize_transcription.pop_request()
# Verify the webhook was called with the assembled summary
assert len(responses.calls) == 1
webhook_request = responses.calls[0]
submitted_payload = json.loads(webhook_request.request.body)
assert "TL;DR" in submitted_payload["content"]
assert "Cleaned summary content." in submitted_payload["content"]
assert "What's nice about Visio" in submitted_payload["content"]
assert "99.999% uptime. Is it reasonable ?" in submitted_payload["title"]
assert submitted_payload["email"] == "user@example.com"
assert submitted_payload["sub"] == "oidc-sub-123"
# Verify auth header was sent
assert (
webhook_request.request.headers["Authorization"]
== f"Bearer {get_settings().webhook_api_token.get_secret_value()}"
)
# LLM was called for: tldr, plan, part A, part B, next-steps, cleaning
expected_llm_calls = 6
assert mock_llm.call.call_count == expected_llm_calls
mock_observability.flush.assert_called_once()