mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 12:49:34 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 945d9c3278 | |||
| 264f267ac3 | |||
| 4bf3ba4c48 | |||
| 4fdc2eee11 | |||
| 19c2a378e7 | |||
| 5a70604f01 |
@@ -304,7 +304,8 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
env:
|
||||||
APP_API_TOKEN: "test-api-token"
|
V1_TENANT_ID: 'test-tenant'
|
||||||
|
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||||
@@ -316,8 +317,6 @@ jobs:
|
|||||||
LLM_BASE_URL: "https://configure-your-url.com"
|
LLM_BASE_URL: "https://configure-your-url.com"
|
||||||
LLM_API_KEY: "test-llm-secret"
|
LLM_API_KEY: "test-llm-secret"
|
||||||
LLM_MODEL: "test-llm-model"
|
LLM_MODEL: "test-llm-model"
|
||||||
WEBHOOK_API_TOKEN: "test-webhook-secret"
|
|
||||||
WEBHOOK_URL: "https://configure-your-url.com"
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🔒️(helm) Add pod and container securityContext #1197
|
||||||
|
- ✨(summary) add routes v2 for async STT and summary tasks #1171
|
||||||
|
- ✅(backend) add unit tests for JwtTokenService #1232
|
||||||
|
|
||||||
## [1.13.0] - 2026-03-31
|
## [1.13.0] - 2026-03-31
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ k8s_resource('minio-bucket', resource_deps=['minio'])
|
|||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||||
|
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||||
|
|||||||
@@ -0,0 +1,541 @@
|
|||||||
|
"""
|
||||||
|
Tests for JWT token service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0212,W0621
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from core.services.jwt_token import (
|
||||||
|
JwtTokenService,
|
||||||
|
TokenDecodeError,
|
||||||
|
TokenExpiredError,
|
||||||
|
TokenInvalidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Fixtures --
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def jwt_service():
|
||||||
|
"""Create a JWT token service for testing."""
|
||||||
|
return JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="test-issuer",
|
||||||
|
audience="test-audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_user():
|
||||||
|
"""Create a mock user with a string ID."""
|
||||||
|
user = mock.Mock()
|
||||||
|
user.id = "test-user-id"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# -- __init__ / Configuration --
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_secret_key():
|
||||||
|
"""Missing secret key should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_secret_key():
|
||||||
|
"""None secret key should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key=None,
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_algorithm():
|
||||||
|
"""Missing algorithm should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_algorithm():
|
||||||
|
"""None algorithm should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm=None,
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_missing_token_type():
|
||||||
|
"""Missing token type should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_token_type():
|
||||||
|
"""None token type should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_none_expiration_seconds():
|
||||||
|
"""None expiration seconds should raise ImproperlyConfigured."""
|
||||||
|
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
|
||||||
|
JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=None,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_zero_expiration_seconds_is_accepted():
|
||||||
|
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=0,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
assert service._expiration_seconds == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_stores_config_correctly():
|
||||||
|
"""All config values should be stored correctly on the instance."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="my-issuer",
|
||||||
|
audience="my-audience",
|
||||||
|
expiration_seconds=1800,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
assert service._key == "test-secret-padded-to-32-bytes!!"
|
||||||
|
assert service._algorithm == "HS256"
|
||||||
|
assert service._issuer == "my-issuer"
|
||||||
|
assert service._audience == "my-audience"
|
||||||
|
assert service._expiration_seconds == 1800
|
||||||
|
assert service._token_type == "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / Return shape --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
|
||||||
|
"""Response always contains access_token, token_type, and expires_in."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
assert "access_token" in result
|
||||||
|
assert "token_type" in result
|
||||||
|
assert "expires_in" in result
|
||||||
|
assert result["token_type"] == "Bearer"
|
||||||
|
assert result["expires_in"] == 3600
|
||||||
|
assert isinstance(result["access_token"], str)
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
|
||||||
|
"""scope key should be present in response when scope is provided."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read write")
|
||||||
|
|
||||||
|
assert result["scope"] == "read write"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
|
||||||
|
"""scope key should be absent from response when scope is empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||||
|
|
||||||
|
assert "scope" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
|
||||||
|
"""scope key should be absent from response when scope is None."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope=None)
|
||||||
|
|
||||||
|
assert "scope" not in result
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / Payload correctness --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
|
||||||
|
"""Payload should always contain iat, exp, and user_id."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iat"] == 1673784000
|
||||||
|
assert payload["exp"] == 1673787600
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
|
||||||
|
"""exp should equal iat + expiration_seconds exactly."""
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=900,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["exp"] - payload["iat"] == 900
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
|
||||||
|
"""iss should be present in payload when issuer is non-empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iss"] == "test-issuer"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
|
||||||
|
"""aud should be present in payload when audience is non-empty."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["aud"] == "test-audience"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_absent_when_empty(mock_user):
|
||||||
|
"""iss should be absent from payload when issuer is empty string."""
|
||||||
|
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="",
|
||||||
|
audience="",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "iss" not in payload
|
||||||
|
assert "aud" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_iss_absent_when_none(mock_user):
|
||||||
|
"""iss should be absent from payload when issuer is None."""
|
||||||
|
|
||||||
|
service = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer=None,
|
||||||
|
audience=None,
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service.generate_jwt(mock_user, scope="read")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "iss" not in payload
|
||||||
|
assert "aud" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
|
||||||
|
"""scope should be absent from payload when not provided."""
|
||||||
|
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="")
|
||||||
|
payload = pyjwt.decode(
|
||||||
|
result["access_token"],
|
||||||
|
"test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
issuer="test-issuer",
|
||||||
|
audience="test-audience",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "scope" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / extra_payload handling --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
|
||||||
|
"""extra_payload=None should not crash and produce a valid token."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""Non-colliding extra_payload keys should appear in decoded token."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user,
|
||||||
|
scope="read",
|
||||||
|
extra_payload={"client_id": "my-app", "delegated": True},
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
assert payload["delegated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
|
||||||
|
"""iat in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["iat"] == 1673784000
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
|
||||||
|
"""exp in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"exp": 9999999999}
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["exp"] == 1673787600
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""user_id in extra_payload should be overwritten by the service."""
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"user_id": "hacked"}
|
||||||
|
)
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
|
||||||
|
"""generate_jwt should not mutate the original extra_payload dict."""
|
||||||
|
extra = {"client_id": "my-app"}
|
||||||
|
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
|
||||||
|
|
||||||
|
assert extra == {"client_id": "my-app"}
|
||||||
|
|
||||||
|
|
||||||
|
# -- generate_jwt / user.id casting --
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
|
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
|
||||||
|
"""user.id as UUID should be cast to str in payload."""
|
||||||
|
user = mock.Mock()
|
||||||
|
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
|
||||||
|
result = jwt_service.generate_jwt(user, scope="read")
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
|
||||||
|
|
||||||
|
|
||||||
|
# -- decode_jwt / Happy path --
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_roundtrip(jwt_service, mock_user):
|
||||||
|
"""Valid token should decode to correct payload."""
|
||||||
|
with freeze_time("2023-01-15 12:00:00"):
|
||||||
|
result = jwt_service.generate_jwt(
|
||||||
|
mock_user, scope="read", extra_payload={"client_id": "my-app"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with freeze_time("2023-01-15 12:30:00"):
|
||||||
|
payload = jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
assert payload["user_id"] == "test-user-id"
|
||||||
|
assert payload["scope"] == "read"
|
||||||
|
assert payload["client_id"] == "my-app"
|
||||||
|
assert payload["iss"] == "test-issuer"
|
||||||
|
assert payload["aud"] == "test-audience"
|
||||||
|
|
||||||
|
|
||||||
|
# -- decode_jwt / Error mapping --
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
|
||||||
|
"""Expired token should raise TokenExpiredError."""
|
||||||
|
with freeze_time("2023-01-15 12:00:00"):
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with freeze_time("2099-01-01 00:00:00"):
|
||||||
|
with pytest.raises(TokenExpiredError):
|
||||||
|
jwt_service.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
|
||||||
|
"""Token with wrong issuer should raise TokenInvalidError."""
|
||||||
|
service_a = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer-a",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_b = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer-b",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_a.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
service_b.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
|
||||||
|
"""Token with wrong audience should raise TokenInvalidError."""
|
||||||
|
service_a = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience-a",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_b = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience-b",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_a.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenInvalidError):
|
||||||
|
service_b.decode_jwt(result["access_token"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_tampered_signature_raises_token_decode_error(
|
||||||
|
jwt_service, mock_user
|
||||||
|
):
|
||||||
|
"""Token with tampered signature should raise TokenDecodeError."""
|
||||||
|
result = jwt_service.generate_jwt(mock_user, scope="read")
|
||||||
|
header, payload, _ = result["access_token"].split(".")
|
||||||
|
tampered_token = f"{header}.{payload}.invalidsignature"
|
||||||
|
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt(tampered_token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
|
||||||
|
"""Garbage string should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt("this.is.not.a.valid.token")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
|
||||||
|
"""Empty string should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt("")
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
|
||||||
|
"""None should raise TokenDecodeError."""
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
jwt_service.decode_jwt(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
|
||||||
|
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
|
||||||
|
service_hs256 = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="HS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
service_rs256 = JwtTokenService(
|
||||||
|
secret_key="test-secret-padded-to-32-bytes!!",
|
||||||
|
algorithm="RS256",
|
||||||
|
issuer="issuer",
|
||||||
|
audience="audience",
|
||||||
|
expiration_seconds=3600,
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
result = service_hs256.generate_jwt(mock_user, scope="read")
|
||||||
|
|
||||||
|
with pytest.raises(TokenDecodeError):
|
||||||
|
service_rs256.decode_jwt(result["access_token"])
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:20-alpine AS frontend-deps
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
COPY ./src/frontend/package.json ./package.json
|
COPY ./src/frontend/package.json ./package.json
|
||||||
@@ -8,7 +10,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY .dockerignore ./.dockerignore
|
COPY .dockerignore ./.dockerignore
|
||||||
COPY ./src/frontend/ .
|
COPY --chown=node:node ./src/frontend/ .
|
||||||
|
|
||||||
### ---- Front-end builder image ----
|
### ---- Front-end builder image ----
|
||||||
FROM frontend-deps AS meet
|
FROM frontend-deps AS meet
|
||||||
@@ -17,6 +19,8 @@ WORKDIR /home/frontend
|
|||||||
|
|
||||||
FROM frontend-deps AS meet-dev
|
FROM frontend-deps AS meet-dev
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
WORKDIR /home/frontend
|
WORKDIR /home/frontend
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.podSecurityContext: &podSecurityContext
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
|
fsGroupChangePolicy: "OnRootMismatch"
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
|
||||||
|
.securityContext: &securityContext
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
|
|
||||||
|
backend:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
|
|
||||||
|
summary:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
|
|
||||||
|
celeryTranscribe:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
|
|
||||||
|
celerySummarize:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
|
|
||||||
|
agents:
|
||||||
|
podSecurityContext: *podSecurityContext
|
||||||
|
securityContext: *securityContext
|
||||||
@@ -28,7 +28,6 @@ livekit:
|
|||||||
urls:
|
urls:
|
||||||
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
|
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
type: nginx
|
type: nginx
|
||||||
annotations:
|
annotations:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ _summaryEnvVars: &summaryEnvVars
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: secret-dev
|
name: secret-dev
|
||||||
key: LLM_API_KEY
|
key: LLM_API_KEY
|
||||||
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
|
LLM_MODEL: Qwen/Qwen3-Coder-30B-A3B-Instruct
|
||||||
WEBHOOK_API_TOKEN: password
|
WEBHOOK_API_TOKEN: password
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
||||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ livekit:
|
|||||||
urls:
|
urls:
|
||||||
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
|
- https://meet.127.0.0.1.nip.io/api/v1.0/rooms/webhooks-livekit/
|
||||||
|
|
||||||
|
|
||||||
loadBalancer:
|
loadBalancer:
|
||||||
type: nginx
|
type: nginx
|
||||||
annotations:
|
annotations:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ _summaryEnvVars: &summaryEnvVars
|
|||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
AWS_S3_SECURE_ACCESS: False
|
AWS_S3_SECURE_ACCESS: False
|
||||||
|
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/", "webhook_api_key": "token_summary"}]'
|
||||||
WHISPERX_API_KEY:
|
WHISPERX_API_KEY:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: secret-dev
|
name: secret-dev
|
||||||
@@ -24,13 +25,14 @@ _summaryEnvVars: &summaryEnvVars
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: secret-dev
|
name: secret-dev
|
||||||
key: LLM_API_KEY
|
key: LLM_API_KEY
|
||||||
LLM_MODEL: meta-llama/Llama-3.1-8B-Instruct
|
LLM_MODEL: Qwen/Qwen3-Coder-30B-A3B-Instruct
|
||||||
WEBHOOK_API_TOKEN: password
|
WEBHOOK_API_TOKEN: password
|
||||||
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
WEBHOOK_URL: https://www.mock-impress.com/webhook/
|
||||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||||
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||||
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
|
TASK_TRACKER_REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
|
|
||||||
|
|
||||||
_summaryImage: &summaryImage
|
_summaryImage: &summaryImage
|
||||||
repository: localhost:5001/meet-summary
|
repository: localhost:5001/meet-summary
|
||||||
pullPolicy: Always
|
pullPolicy: Always
|
||||||
@@ -124,7 +126,6 @@ backend:
|
|||||||
CELERY_ENABLED: True
|
CELERY_ENABLED: True
|
||||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||||
|
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
command:
|
command:
|
||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
@@ -215,7 +216,7 @@ celeryTranscribe:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "transcribe-queue"
|
- "transcribe-queue,transcribe-queue-v2"
|
||||||
|
|
||||||
celerySummarize:
|
celerySummarize:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
@@ -230,7 +231,27 @@ celerySummarize:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "summarize-queue"
|
- "summarize-queue,summarize-queue-v2"
|
||||||
|
|
||||||
|
celerySummaryBackend:
|
||||||
|
replicas: 1
|
||||||
|
envVars:
|
||||||
|
<<: *summaryEnvVars
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: localhost:5001/meet-summary
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
command:
|
||||||
|
- "celery"
|
||||||
|
- "-A"
|
||||||
|
- "summary.core.celery_worker"
|
||||||
|
- "worker"
|
||||||
|
- "--pool=solo"
|
||||||
|
- "--loglevel=info"
|
||||||
|
- "-Q"
|
||||||
|
- "call-webhook-queue-v2"
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ releases:
|
|||||||
missingFileHandler: Warn
|
missingFileHandler: Warn
|
||||||
chart: ./meet
|
chart: ./meet
|
||||||
values:
|
values:
|
||||||
|
- env.d/common.yaml.gotmpl
|
||||||
- env.d/{{ .Environment.Name }}/values.meet.yaml.gotmpl
|
- env.d/{{ .Environment.Name }}/values.meet.yaml.gotmpl
|
||||||
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
- env.d/{{ .Environment.Name }}/values.secrets.yaml
|
||||||
secrets:
|
secrets:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
type: application
|
type: application
|
||||||
name: meet
|
name: meet
|
||||||
version: 0.0.17
|
version: 0.0.18
|
||||||
|
|||||||
@@ -202,6 +202,16 @@ Requires top level scope
|
|||||||
{{ include "meet.fullname" . }}-celery-summarize
|
{{ include "meet.fullname" . }}-celery-summarize
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Full name for the Celery Summary Backend
|
||||||
|
|
||||||
|
Requires top level scope
|
||||||
|
*/}}
|
||||||
|
{{- define "meet.celerySummaryBackend.fullname" -}}
|
||||||
|
{{ include "meet.fullname" . }}-celery-summary-backend
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Full name for the agents
|
Full name for the agents
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.agents.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.agents.shareProcessNamespace }}
|
||||||
|
{{- with .Values.agents.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.agents.sidecars }}
|
{{- with .Values.agents.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
||||||
|
{{- with .Values.backend.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.backend.sidecars }}
|
{{- with .Values.backend.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
||||||
|
{{- with .Values.backend.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.backend.sidecars }}
|
{{- with .Values.backend.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
||||||
|
{{- with .Values.backend.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.backend.sidecars }}
|
{{- with .Values.backend.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.celeryBackend.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.celeryBackend.shareProcessNamespace }}
|
||||||
|
{{- with .Values.celeryBackend.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.celeryBackend.sidecars }}
|
{{- with .Values.celeryBackend.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.celerySummarize.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.celerySummarize.shareProcessNamespace }}
|
||||||
|
{{- with .Values.celerySummarize.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.celerySummarize.sidecars }}
|
{{- with .Values.celerySummarize.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
{{- $envVars := include "meet.common.env" (list . .Values.celerySummaryBackend) -}}
|
||||||
|
{{- $fullName := include "meet.celerySummaryBackend.fullname" . -}}
|
||||||
|
{{- $component := "celery-summary-backend" -}}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
annotations:
|
||||||
|
{{- with .Values.celerySummaryBackend.dpAnnotations }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
labels:
|
||||||
|
{{- include "meet.common.labels" (list . $component) | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.celerySummaryBackend.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
{{- with .Values.celerySummaryBackend.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "meet.common.selectorLabels" (list . $component) | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
{{- if $.Values.image.credentials }}
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
|
{{- end }}
|
||||||
|
shareProcessNamespace: {{ .Values.celerySummaryBackend.shareProcessNamespace }}
|
||||||
|
containers:
|
||||||
|
{{- with .Values.celerySummaryBackend.sidecars }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
image: "{{ (.Values.celerySummaryBackend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.celerySummaryBackend.image | default dict).tag | default .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ (.Values.celerySummaryBackend.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
|
||||||
|
{{- with .Values.celerySummaryBackend.command }}
|
||||||
|
command:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.args }}
|
||||||
|
args:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
env:
|
||||||
|
{{- if $envVars }}
|
||||||
|
{{- $envVars | indent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.securityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.celerySummaryBackend.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- if .Values.celerySummaryBackend.probes.liveness }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.liveness (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.celerySummaryBackend.probes.readiness }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.readiness (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.celerySummaryBackend.probes.startup }}
|
||||||
|
startupProbe:
|
||||||
|
{{- include "meet.probes.abstract" (merge .Values.celerySummaryBackend.probes.startup (dict "targetPort" .Values.celerySummaryBackend.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
volumeMounts:
|
||||||
|
{{- range $index, $value := .Values.mountFiles }}
|
||||||
|
- name: "files-{{ $index }}"
|
||||||
|
mountPath: {{ $value.path }}
|
||||||
|
subPath: content
|
||||||
|
{{- end }}
|
||||||
|
{{- range $name, $volume := .Values.celerySummaryBackend.persistence }}
|
||||||
|
- name: "{{ $name }}"
|
||||||
|
mountPath: "{{ $volume.mountPath }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.celerySummaryBackend.extraVolumeMounts }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
mountPath: {{ .mountPath }}
|
||||||
|
subPath: {{ .subPath | default "" }}
|
||||||
|
readOnly: {{ .readOnly }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.celerySummaryBackend.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
{{- range $index, $value := .Values.mountFiles }}
|
||||||
|
- name: "files-{{ $index }}"
|
||||||
|
configMap:
|
||||||
|
name: "{{ include "meet.fullname" $ }}-files-{{ $index }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- range $name, $volume := .Values.celerySummaryBackend.persistence }}
|
||||||
|
- name: "{{ $name }}"
|
||||||
|
{{- if eq $volume.type "emptyDir" }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- else }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{ $fullName }}-{{ $name }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.celerySummaryBackend.extraVolumes }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
{{- if .existingClaim }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ .existingClaim }}
|
||||||
|
{{- else if .hostPath }}
|
||||||
|
hostPath:
|
||||||
|
{{ toYaml .hostPath | nindent 12 }}
|
||||||
|
{{- else if .csi }}
|
||||||
|
csi:
|
||||||
|
{{- toYaml .csi | nindent 12 }}
|
||||||
|
{{- else if .configMap }}
|
||||||
|
configMap:
|
||||||
|
{{- toYaml .configMap | nindent 12 }}
|
||||||
|
{{- else if .emptyDir }}
|
||||||
|
emptyDir:
|
||||||
|
{{- toYaml .emptyDir | nindent 12 }}
|
||||||
|
{{- else }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
|
{{ if .Values.celerySummaryBackend.pdb.enabled }}
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
spec:
|
||||||
|
maxUnavailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "meet.common.selectorLabels" (list . $component) | nindent 6 }}
|
||||||
|
{{ end }}
|
||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.celeryTranscribe.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.celeryTranscribe.shareProcessNamespace }}
|
||||||
|
{{- with .Values.celeryTranscribe.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.celeryTranscribe.sidecars }}
|
{{- with .Values.celeryTranscribe.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.frontend.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.frontend.shareProcessNamespace }}
|
||||||
|
{{- with .Values.frontend.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.frontend.sidecars }}
|
{{- with .Values.frontend.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ spec:
|
|||||||
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
- name: {{ include "meet.secret.dockerconfigjson.name" (dict "fullname" (include "meet.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
shareProcessNamespace: {{ .Values.summary.shareProcessNamespace }}
|
shareProcessNamespace: {{ .Values.summary.shareProcessNamespace }}
|
||||||
|
{{- with .Values.summary.podSecurityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
{{- with .Values.summary.sidecars }}
|
{{- with .Values.summary.sidecars }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
+140
-9
@@ -216,9 +216,12 @@ backend:
|
|||||||
ttlSecondsAfterFinished: 30
|
ttlSecondsAfterFinished: 30
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
|
|
||||||
## @param backend.securityContext Configure backend Pod security context
|
## @param backend.securityContext Configure backend Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param backend.podSecurityContext Configure backend Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param backend.envVars Configure backend container environment variables
|
## @param backend.envVars Configure backend container environment variables
|
||||||
## @extra backend.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra backend.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -359,9 +362,12 @@ frontend:
|
|||||||
## @param frontend.sidecars Add sidecars containers to frontend deployment
|
## @param frontend.sidecars Add sidecars containers to frontend deployment
|
||||||
sidecars: []
|
sidecars: []
|
||||||
|
|
||||||
## @param frontend.securityContext Configure frontend Pod security context
|
## @param frontend.securityContext Configure frontend Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param frontend.podSecurityContext Configure frontend Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param frontend.envVars Configure frontend container environment variables
|
## @param frontend.envVars Configure frontend container environment variables
|
||||||
## @extra frontend.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra frontend.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -533,9 +539,12 @@ summary:
|
|||||||
## @param summary.migrateJobAnnotations Annotations for the migrate job
|
## @param summary.migrateJobAnnotations Annotations for the migrate job
|
||||||
migrateJobAnnotations: {}
|
migrateJobAnnotations: {}
|
||||||
|
|
||||||
## @param summary.securityContext Configure summary Pod security context
|
## @param summary.securityContext Configure summary Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param summary.podSecurityContext Configure summary Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param summary.envVars Configure summary container environment variables
|
## @param summary.envVars Configure summary container environment variables
|
||||||
## @extra summary.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra summary.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra summary.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra summary.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -646,9 +655,12 @@ celeryBackend:
|
|||||||
## @param celeryBackend.migrateJobAnnotations Annotations for the migrate job
|
## @param celeryBackend.migrateJobAnnotations Annotations for the migrate job
|
||||||
migrateJobAnnotations: {}
|
migrateJobAnnotations: {}
|
||||||
|
|
||||||
## @param celeryBackend.securityContext Configure celeryBackend Pod security context
|
## @param celeryBackend.securityContext Configure celeryBackend Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param celeryBackend.podSecurityContext Configure celeryBackend Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param celeryBackend.envVars Configure celeryBackend container environment variables
|
## @param celeryBackend.envVars Configure celeryBackend container environment variables
|
||||||
## @extra celeryBackend.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra celeryBackend.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra celeryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra celeryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -738,7 +750,7 @@ celeryTranscribe:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "transcribe-queue"
|
- "transcribe-queue,transcribe-queue-v2"
|
||||||
|
|
||||||
## @param celeryTranscribe.args Override the celeryTranscribe container args
|
## @param celeryTranscribe.args Override the celeryTranscribe container args
|
||||||
args: []
|
args: []
|
||||||
@@ -755,9 +767,12 @@ celeryTranscribe:
|
|||||||
## @param celeryTranscribe.migrateJobAnnotations Annotations for the migrate job
|
## @param celeryTranscribe.migrateJobAnnotations Annotations for the migrate job
|
||||||
migrateJobAnnotations: {}
|
migrateJobAnnotations: {}
|
||||||
|
|
||||||
## @param celeryTranscribe.securityContext Configure celeryTranscribe Pod security context
|
## @param celeryTranscribe.securityContext Configure celeryTranscribe Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param celeryTranscribe.podSecurityContext Configure celeryTranscribe Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param celeryTranscribe.envVars Configure celeryTranscribe container environment variables
|
## @param celeryTranscribe.envVars Configure celeryTranscribe container environment variables
|
||||||
## @extra celeryTranscribe.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra celeryTranscribe.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra celeryTranscribe.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra celeryTranscribe.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -847,7 +862,7 @@ celerySummarize:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "summarize-queue"
|
- "summarize-queue,summarize-queue-v2"
|
||||||
|
|
||||||
## @param celerySummarize.args Override the celerySummarize container args
|
## @param celerySummarize.args Override the celerySummarize container args
|
||||||
args: []
|
args: []
|
||||||
@@ -864,9 +879,12 @@ celerySummarize:
|
|||||||
## @param celerySummarize.migrateJobAnnotations Annotations for the migrate job
|
## @param celerySummarize.migrateJobAnnotations Annotations for the migrate job
|
||||||
migrateJobAnnotations: {}
|
migrateJobAnnotations: {}
|
||||||
|
|
||||||
## @param celerySummarize.securityContext Configure celerySummarize Pod security context
|
## @param celerySummarize.securityContext Configure celerySummarize Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param celerySummarize.podSecurityContext Configure celerySummarize Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param celerySummarize.envVars Configure celerySummarize container environment variables
|
## @param celerySummarize.envVars Configure celerySummarize container environment variables
|
||||||
## @extra celerySummarize.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra celerySummarize.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra celerySummarize.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra celerySummarize.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
@@ -933,6 +951,116 @@ celerySummarize:
|
|||||||
pdb:
|
pdb:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
## @section celerySummaryBackend
|
||||||
|
|
||||||
|
celerySummaryBackend:
|
||||||
|
## @param celerySummaryBackend.image.repository Repository to use to pull meet's celerySummaryBackend container image
|
||||||
|
## @param celerySummaryBackend.image.tag meet's celerySummaryBackend container tag
|
||||||
|
## @param celerySummaryBackend.image.pullPolicy celerySummaryBackend container image pull policy
|
||||||
|
image:
|
||||||
|
repository: lasuite/meet-summary
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.dpAnnotations Annotations to add to the celerySummaryBackend Deployment
|
||||||
|
dpAnnotations: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.command Override the celerySummaryBackend container command
|
||||||
|
command:
|
||||||
|
- "celery"
|
||||||
|
- "-A"
|
||||||
|
- "summary.core.celery_worker"
|
||||||
|
- "worker"
|
||||||
|
- "--pool=solo"
|
||||||
|
- "--loglevel=info"
|
||||||
|
- "-Q"
|
||||||
|
- "call-webhook-queue-v2"
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.args Override the celerySummaryBackend container args
|
||||||
|
args: []
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.replicas Amount of celerySummaryBackend replicas
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.shareProcessNamespace Enable share process namespace between containers
|
||||||
|
shareProcessNamespace: false
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.sidecars Add sidecars containers to celerySummaryBackend deployment
|
||||||
|
sidecars: []
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.migrateJobAnnotations Annotations for the migrate job
|
||||||
|
migrateJobAnnotations: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.securityContext Configure celerySummaryBackend Pod security context
|
||||||
|
securityContext: null
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.envVars Configure celerySummaryBackend container environment variables
|
||||||
|
## @extra celerySummaryBackend.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
|
## @extra celerySummaryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
|
## @extra celerySummaryBackend.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
|
||||||
|
## @extra celerySummaryBackend.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
|
||||||
|
## @extra celerySummaryBackend.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
|
||||||
|
## @skip celerySummaryBackend.envVars
|
||||||
|
envVars:
|
||||||
|
<<: *commonEnvVars
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.podAnnotations Annotations to add to the celerySummaryBackend Pod
|
||||||
|
podAnnotations: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.service.type celerySummaryBackend Service type
|
||||||
|
## @param celerySummaryBackend.service.port celerySummaryBackend Service listening port
|
||||||
|
## @param celerySummaryBackend.service.targetPort celerySummaryBackend container listening port
|
||||||
|
## @param celerySummaryBackend.service.annotations Annotations to add to the celerySummaryBackend Service
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
targetPort: 8000
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.probes Configure celerySummaryBackend probes
|
||||||
|
## @param celerySummaryBackend.probes.liveness.path [nullable] Configure path for celerySummaryBackend HTTP liveness probe
|
||||||
|
## @param celerySummaryBackend.probes.liveness.targetPort [nullable] Configure port for celerySummaryBackend HTTP liveness probe
|
||||||
|
## @param celerySummaryBackend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend liveness probe
|
||||||
|
## @param celerySummaryBackend.probes.liveness.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend liveness probe
|
||||||
|
## @param celerySummaryBackend.probes.startup.path [nullable] Configure path for celerySummaryBackend HTTP startup probe
|
||||||
|
## @param celerySummaryBackend.probes.startup.targetPort [nullable] Configure port for celerySummaryBackend HTTP startup probe
|
||||||
|
## @param celerySummaryBackend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend startup probe
|
||||||
|
## @param celerySummaryBackend.probes.startup.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend startup probe
|
||||||
|
## @param celerySummaryBackend.probes.readiness.path [nullable] Configure path for celerySummaryBackend HTTP readiness probe
|
||||||
|
## @param celerySummaryBackend.probes.readiness.targetPort [nullable] Configure port for celerySummaryBackend HTTP readiness probe
|
||||||
|
## @param celerySummaryBackend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for celerySummaryBackend readiness probe
|
||||||
|
## @param celerySummaryBackend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for celerySummaryBackend readiness probe
|
||||||
|
probes: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.resources Resource requirements for the celerySummaryBackend container
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.nodeSelector Node selector for the celerySummaryBackend Pod
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.tolerations Tolerations for the celerySummaryBackend Pod
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.affinity Affinity for the celerySummaryBackend Pod
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.persistence Additional volumes to create and mount on the celerySummaryBackend. Used for debugging purposes
|
||||||
|
## @extra celerySummaryBackend.persistence.volume-name.size Size of the additional volume
|
||||||
|
## @extra celerySummaryBackend.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
|
||||||
|
## @extra celerySummaryBackend.persistence.volume-name.mountPath Path where the volume should be mounted to
|
||||||
|
persistence: {}
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.extraVolumeMounts Additional volumes to mount on the celerySummaryBackend.
|
||||||
|
extraVolumeMounts: []
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.extraVolumes Additional volumes to mount on the celerySummaryBackend.
|
||||||
|
extraVolumes: []
|
||||||
|
|
||||||
|
## @param celerySummaryBackend.pdb.enabled Enable pdb on celerySummaryBackend
|
||||||
|
pdb:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
|
||||||
## @section agents
|
## @section agents
|
||||||
|
|
||||||
agents:
|
agents:
|
||||||
@@ -963,9 +1091,12 @@ agents:
|
|||||||
## @param agents.sidecars Add sidecars containers to agents deployment
|
## @param agents.sidecars Add sidecars containers to agents deployment
|
||||||
sidecars: []
|
sidecars: []
|
||||||
|
|
||||||
## @param agents.securityContext Configure agents Pod security context
|
## @param agents.securityContext Configure agents Container security context
|
||||||
securityContext: null
|
securityContext: null
|
||||||
|
|
||||||
|
## @param agents.podSecurityContext Configure agents Pod security context
|
||||||
|
podSecurityContext: null
|
||||||
|
|
||||||
## @param agents.envVars Configure agents container environment variables
|
## @param agents.envVars Configure agents container environment variables
|
||||||
## @extra agents.envVars.BY_VALUE Example environment variable by setting value directly
|
## @extra agents.envVars.BY_VALUE Example environment variable by setting value directly
|
||||||
## @extra agents.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
## @extra agents.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from summary.api.route import tasks
|
from summary.api.route import tasks, tasks_v2
|
||||||
from summary.core.security import verify_token
|
from summary.core.security import verify_tenant_api_key
|
||||||
|
|
||||||
api_router = APIRouter(dependencies=[Depends(verify_token)])
|
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||||
api_router.include_router(tasks.router, tags=["tasks"])
|
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
|
||||||
|
|
||||||
|
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||||
|
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ class TranscribeSummarizeTaskCreation(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tasks")
|
router_tasks_v1 = APIRouter(prefix="/tasks")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/")
|
@router_tasks_v1.post("/")
|
||||||
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
||||||
"""Create a transcription and summarization task."""
|
"""Create a transcription and summarization task."""
|
||||||
task = process_audio_transcribe_summarize_v2.apply_async(
|
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||||
@@ -68,7 +68,7 @@ async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreat
|
|||||||
return {"id": task.id, "message": "Task created"}
|
return {"id": task.id, "message": "Task created"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}")
|
@router_tasks_v1.get("/{task_id}")
|
||||||
async def get_task_status(task_id: str):
|
async def get_task_status(task_id: str):
|
||||||
"""Check task status by ID."""
|
"""Check task status by ID."""
|
||||||
task = AsyncResult(task_id)
|
task = AsyncResult(task_id)
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""API routes related to application tasks (V2 / tenant friendly)."""
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
|
from summary.core.celery_worker import (
|
||||||
|
celery,
|
||||||
|
process_audio_transcribe_v2_task,
|
||||||
|
summarize_v2_task,
|
||||||
|
)
|
||||||
|
from summary.core.config import AuthorizedTenant
|
||||||
|
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
|
||||||
|
from summary.core.security import verify_tenant_api_key_v2
|
||||||
|
from summary.core.shared_models import (
|
||||||
|
SummarizeWebhookFailurePayload,
|
||||||
|
SummarizeWebhookPendingPayload,
|
||||||
|
SummarizeWebhookSuccessPayload,
|
||||||
|
TranscribeWebhookFailurePayload,
|
||||||
|
TranscribeWebhookPendingPayload,
|
||||||
|
TranscribeWebhookSuccessPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
router_tasks_v2 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v2.post("/async-jobs/transcribe")
|
||||||
|
async def create_transcribe_task_v2(
|
||||||
|
request: TranscribeTaskV2Request,
|
||||||
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
|
):
|
||||||
|
"""Create a transcription task."""
|
||||||
|
task = process_audio_transcribe_v2_task.apply_async(
|
||||||
|
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v2.post("/async-jobs/summarize")
|
||||||
|
async def create_summarize_task_v2(
|
||||||
|
request: SummarizeTaskV2Request,
|
||||||
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
|
):
|
||||||
|
"""Create a summarization task."""
|
||||||
|
task = summarize_v2_task.apply_async(
|
||||||
|
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
||||||
|
)
|
||||||
|
|
||||||
|
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v2.get("/async-jobs/transcribe/{job_id}")
|
||||||
|
async def get_transcribe_job_status(
|
||||||
|
job_id: str,
|
||||||
|
request: Request,
|
||||||
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
|
):
|
||||||
|
"""Check transcription task status by ID."""
|
||||||
|
# We have to look directly in Redis to check if the task exists
|
||||||
|
redis_client = celery.backend.client
|
||||||
|
key = f"celery-task-meta-{job_id}"
|
||||||
|
if not redis_client.exists(key):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
task = AsyncResult(job_id, app=celery)
|
||||||
|
task_tenant_id = task.args[0]["tenant_id"]
|
||||||
|
if task_tenant_id != request_tenant.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
if task.status == "SUCCESS":
|
||||||
|
result = task.result
|
||||||
|
return TranscribeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||||
|
|
||||||
|
if task.status == "FAILURE":
|
||||||
|
return TranscribeWebhookFailurePayload(
|
||||||
|
job_id=job_id, error_code="unknown_error"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
return TranscribeWebhookPendingPayload(job_id=job_id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v2.get("/async-jobs/summarize/{job_id}")
|
||||||
|
async def get_summarize_job_status(
|
||||||
|
job_id: str,
|
||||||
|
request: Request,
|
||||||
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
|
):
|
||||||
|
"""Check summarize task status by ID."""
|
||||||
|
# We have to look directly in Redis to check if the task exists
|
||||||
|
redis_client = celery.backend.client
|
||||||
|
key = f"celery-task-meta-{job_id}"
|
||||||
|
if not redis_client.exists(key):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
task = AsyncResult(job_id, app=celery)
|
||||||
|
task_tenant_id = task.args[0]["tenant_id"]
|
||||||
|
if task_tenant_id != request_tenant.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
if task.status == "SUCCESS":
|
||||||
|
result = task.result
|
||||||
|
return SummarizeWebhookSuccessPayload.model_validate(result).model_dump()
|
||||||
|
|
||||||
|
if task.status == "FAILURE":
|
||||||
|
return SummarizeWebhookFailurePayload(
|
||||||
|
job_id=job_id, error_code="unknown_error"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
return SummarizeWebhookPendingPayload(job_id=job_id).model_dump()
|
||||||
@@ -17,6 +17,10 @@ from summary.core.config import get_settings
|
|||||||
from summary.core.file_service import FileService, FileServiceException
|
from summary.core.file_service import FileService, FileServiceException
|
||||||
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
||||||
from summary.core.locales import get_locale
|
from summary.core.locales import get_locale
|
||||||
|
from summary.core.models import (
|
||||||
|
SummarizeTaskV2Payload,
|
||||||
|
TranscribeTaskV2Payload,
|
||||||
|
)
|
||||||
from summary.core.prompt import (
|
from summary.core.prompt import (
|
||||||
FORMAT_NEXT_STEPS,
|
FORMAT_NEXT_STEPS,
|
||||||
FORMAT_PLAN,
|
FORMAT_PLAN,
|
||||||
@@ -27,8 +31,19 @@ from summary.core.prompt import (
|
|||||||
PROMPT_SYSTEM_TLDR,
|
PROMPT_SYSTEM_TLDR,
|
||||||
PROMPT_USER_PART,
|
PROMPT_USER_PART,
|
||||||
)
|
)
|
||||||
|
from summary.core.shared_models import (
|
||||||
|
SummarizeWebhookFailurePayload,
|
||||||
|
SummarizeWebhookSuccessPayload,
|
||||||
|
TranscribeWebhookFailurePayload,
|
||||||
|
TranscribeWebhookSuccessPayload,
|
||||||
|
WhisperXResponse,
|
||||||
|
webhook_payload_adapter,
|
||||||
|
)
|
||||||
from summary.core.transcript_formatter import TranscriptFormatter
|
from summary.core.transcript_formatter import TranscriptFormatter
|
||||||
from summary.core.webhook_service import submit_content
|
from summary.core.webhook_service import (
|
||||||
|
call_webhook_v2,
|
||||||
|
submit_content,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
analytics = get_analytics()
|
analytics = get_analytics()
|
||||||
@@ -43,6 +58,9 @@ celery = Celery(
|
|||||||
broker=settings.celery_broker_url,
|
broker=settings.celery_broker_url,
|
||||||
backend=settings.celery_result_backend,
|
backend=settings.celery_result_backend,
|
||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
|
# To store the tasks args too in results and make the
|
||||||
|
# V2 API work
|
||||||
|
result_extended=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
celery.config_from_object("summary.core.celery_config")
|
celery.config_from_object("summary.core.celery_config")
|
||||||
@@ -58,14 +76,26 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
|
|||||||
file_service = FileService()
|
file_service = FileService()
|
||||||
|
|
||||||
|
|
||||||
def transcribe_audio(task_id, filename, language):
|
def transcribe_audio(
|
||||||
|
*,
|
||||||
|
task_id: str,
|
||||||
|
filename: str | None = None,
|
||||||
|
language: str,
|
||||||
|
cloud_storage_url=None,
|
||||||
|
raises: bool = False,
|
||||||
|
):
|
||||||
"""Transcribe an audio file using WhisperX.
|
"""Transcribe an audio file using WhisperX.
|
||||||
|
|
||||||
Downloads the audio from MinIO, sends it to WhisperX for transcription,
|
Downloads the audio from MinIO or a cloud storage URL, sends it to
|
||||||
and tracks metadata throughout the process.
|
WhisperX for transcription, and tracks metadata throughout the process.
|
||||||
|
|
||||||
Returns the transcription object, or None if the file could not be retrieved.
|
Returns the transcription object, or None if the file could not be retrieved.
|
||||||
"""
|
"""
|
||||||
|
if bool(filename) == bool(cloud_storage_url):
|
||||||
|
raise ValueError(
|
||||||
|
"Either filename or cloud_storage_url must be provided, but not both."
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Initiating WhisperX client")
|
logger.info("Initiating WhisperX client")
|
||||||
whisperx_client = openai.OpenAI(
|
whisperx_client = openai.OpenAI(
|
||||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||||
@@ -75,7 +105,10 @@ def transcribe_audio(task_id, filename, language):
|
|||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
try:
|
try:
|
||||||
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
|
with file_service.prepare_audio_file(
|
||||||
|
remote_object_key=filename,
|
||||||
|
cloud_storage_url=cloud_storage_url,
|
||||||
|
) as (audio_file, metadata):
|
||||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||||
|
|
||||||
if language is None:
|
if language is None:
|
||||||
@@ -104,8 +137,21 @@ def transcribe_audio(task_id, filename, language):
|
|||||||
logger.info("Transcription received in %.2f seconds.", transcription_time)
|
logger.info("Transcription received in %.2f seconds.", transcription_time)
|
||||||
logger.debug("Transcription: \n %s", transcription)
|
logger.debug("Transcription: \n %s", transcription)
|
||||||
|
|
||||||
except FileServiceException:
|
except FileServiceException as e:
|
||||||
logger.exception("Unexpected error for filename: %s", filename)
|
# For v2 pipeline we want failures not silent errors like this
|
||||||
|
if raises:
|
||||||
|
raise e
|
||||||
|
redacted_cloud_storage_url = (
|
||||||
|
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||||
|
)
|
||||||
|
logger.exception(
|
||||||
|
(
|
||||||
|
"Unexpected error while preparing file | filename: %s "
|
||||||
|
"| cloud_storage_url: %s"
|
||||||
|
),
|
||||||
|
filename,
|
||||||
|
redacted_cloud_storage_url,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
metadata_manager.track_transcription_metadata(task_id, transcription)
|
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||||
@@ -114,13 +160,13 @@ def transcribe_audio(task_id, filename, language):
|
|||||||
|
|
||||||
def format_transcript(
|
def format_transcript(
|
||||||
transcription,
|
transcription,
|
||||||
context_language,
|
context_language: str | None,
|
||||||
language,
|
language: str,
|
||||||
room,
|
room: str | None,
|
||||||
recording_date,
|
recording_date: str | None,
|
||||||
recording_time,
|
recording_time: str | None,
|
||||||
download_link,
|
download_link: str | None,
|
||||||
):
|
) -> tuple[str, str]:
|
||||||
"""Format a transcription into readable content with a title.
|
"""Format a transcription into readable content with a title.
|
||||||
|
|
||||||
Resolves the locale from context_language / language, then uses
|
Resolves the locale from context_language / language, then uses
|
||||||
@@ -208,7 +254,9 @@ def process_audio_transcribe_summarize_v2(
|
|||||||
|
|
||||||
task_id = self.request.id
|
task_id = self.request.id
|
||||||
|
|
||||||
transcription = transcribe_audio(task_id, filename, language)
|
transcription = transcribe_audio(
|
||||||
|
task_id=task_id, filename=filename, language=language
|
||||||
|
)
|
||||||
if transcription is None:
|
if transcription is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -258,23 +306,15 @@ def task_failure_handler(task_id, exception=None, **kwargs):
|
|||||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
def summarize_transcription_internals(
|
||||||
bind=True,
|
*, owner_id: str, transcript: str, session_id: str
|
||||||
autoretry_for=[LLMException, Exception],
|
) -> str:
|
||||||
max_retries=settings.celery_max_retries,
|
|
||||||
queue=settings.summarize_queue,
|
|
||||||
)
|
|
||||||
def summarize_transcription(
|
|
||||||
self, owner_id: str, transcript: str, email: str, sub: str, title: str
|
|
||||||
):
|
|
||||||
"""Generate a summary from the provided transcription text.
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
This Celery task performs the following operations:
|
|
||||||
1. Uses an LLM to generate a TL;DR summary of the transcription.
|
1. Uses an LLM to generate a TL;DR summary of the transcription.
|
||||||
2. Breaks the transcription into parts and summarizes each part.
|
2. Breaks the transcription into parts and summarizes each part.
|
||||||
3. Cleans up the combined summary
|
3. Cleans up the combined summary
|
||||||
4. Generates next steps.
|
4. Generates next steps.
|
||||||
5. Sends the final summary via webhook.
|
|
||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting summarization task | Owner: %s",
|
"Starting summarization task | Owner: %s",
|
||||||
@@ -292,7 +332,7 @@ def summarize_transcription(
|
|||||||
# privacy controls in observability traces.
|
# privacy controls in observability traces.
|
||||||
llm_observability = LLMObservability(
|
llm_observability = LLMObservability(
|
||||||
user_has_tracing_consent=user_has_tracing_consent,
|
user_has_tracing_consent=user_has_tracing_consent,
|
||||||
session_id=self.request.id,
|
session_id=session_id,
|
||||||
user_id=owner_id,
|
user_id=owner_id,
|
||||||
)
|
)
|
||||||
llm_service = LLMService(llm_observability=llm_observability)
|
llm_service = LLMService(llm_observability=llm_observability)
|
||||||
@@ -338,9 +378,199 @@ def summarize_transcription(
|
|||||||
logger.info("Summary cleaned")
|
logger.info("Summary cleaned")
|
||||||
|
|
||||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||||
|
|
||||||
|
llm_observability.flush()
|
||||||
|
logger.debug("LLM observability flushed")
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=[LLMException, Exception],
|
||||||
|
max_retries=settings.celery_max_retries,
|
||||||
|
queue=settings.summarize_queue,
|
||||||
|
)
|
||||||
|
def summarize_transcription(
|
||||||
|
self, owner_id: str, transcript: str, email: str, sub: str, title: str
|
||||||
|
):
|
||||||
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
|
This Celery task performs the following operations:
|
||||||
|
1. Run summary internals
|
||||||
|
2. Sends the final summary via webhook.
|
||||||
|
"""
|
||||||
|
summary = summarize_transcription_internals(
|
||||||
|
owner_id=owner_id, transcript=transcript, session_id=self.request.id
|
||||||
|
)
|
||||||
summary_title = settings.summary_title_template.format(title=title)
|
summary_title = settings.summary_title_template.format(title=title)
|
||||||
|
|
||||||
submit_content(summary, summary_title, email, sub)
|
submit_content(summary, summary_title, email, sub)
|
||||||
|
|
||||||
llm_observability.flush()
|
|
||||||
logger.debug("LLM observability flushed")
|
##################################################################################
|
||||||
|
# Tasks v2
|
||||||
|
##################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
max_retries=3,
|
||||||
|
queue=settings.call_webhook_queue_v2,
|
||||||
|
autoretry_for=[exceptions.HTTPError],
|
||||||
|
)
|
||||||
|
def call_webhook_v2_task(
|
||||||
|
payload: dict,
|
||||||
|
tenant_id: str,
|
||||||
|
):
|
||||||
|
"""Calls a webhook asynchrously (retry handled by celery)."""
|
||||||
|
call_webhook_v2(
|
||||||
|
payload=webhook_payload_adapter.validate_python(payload), tenant_id=tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=[exceptions.HTTPError],
|
||||||
|
max_retries=settings.celery_max_retries,
|
||||||
|
queue=settings.transcribe_queue_v2,
|
||||||
|
)
|
||||||
|
def process_audio_transcribe_v2_task(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
):
|
||||||
|
"""Process an audio file by transcribing it.
|
||||||
|
|
||||||
|
This Celery task orchestrates:
|
||||||
|
1. Audio transcription via WhisperX
|
||||||
|
2. Store transcript result on S3
|
||||||
|
3. Webhook submission
|
||||||
|
|
||||||
|
Args:
|
||||||
|
self: Celery task instance (passed on with bind=True)
|
||||||
|
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||||
|
"""
|
||||||
|
payload = TranscribeTaskV2Payload.model_validate(payload)
|
||||||
|
logger.info(
|
||||||
|
"Transcribing for object received | Owner: %s",
|
||||||
|
payload.user_sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
job_id = self.request.id
|
||||||
|
|
||||||
|
transcription_res = WhisperXResponse(
|
||||||
|
**transcribe_audio( # type: ignore
|
||||||
|
task_id=job_id,
|
||||||
|
cloud_storage_url=payload.cloud_storage_url,
|
||||||
|
language=payload.language,
|
||||||
|
raises=True,
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
|
||||||
|
file_service.store_transcript(
|
||||||
|
transcript=transcription_res,
|
||||||
|
job_id=job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
success_payload = TranscribeWebhookSuccessPayload(
|
||||||
|
job_id=job_id,
|
||||||
|
transcription_data_url=file_service.get_transcript_signed_url(job_id),
|
||||||
|
)
|
||||||
|
call_webhook_v2_task.apply_async(
|
||||||
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
|
)
|
||||||
|
return success_payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def handle_transcribe_v2_failed(
|
||||||
|
sender,
|
||||||
|
task_id=None,
|
||||||
|
exception=None,
|
||||||
|
args=None,
|
||||||
|
kwargs=None,
|
||||||
|
traceback=None,
|
||||||
|
einfo=None,
|
||||||
|
**kw,
|
||||||
|
):
|
||||||
|
"""Handle the failure of transcribe_v2_task.
|
||||||
|
|
||||||
|
This function is triggered when the transcribe_v2_task fails.
|
||||||
|
It sends a webhook failure payload to notify the client of the failure.
|
||||||
|
"""
|
||||||
|
task = sender
|
||||||
|
# If retries are exhausted:
|
||||||
|
if task.request.retries >= task.max_retries:
|
||||||
|
call_webhook_v2_task.apply_async(
|
||||||
|
args=[
|
||||||
|
TranscribeWebhookFailurePayload(
|
||||||
|
job_id=task.id,
|
||||||
|
error_code="unknown_error",
|
||||||
|
).model_dump(),
|
||||||
|
args[0]["tenant_id"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=[LLMException, Exception],
|
||||||
|
max_retries=settings.celery_max_retries,
|
||||||
|
queue=settings.summarize_queue_v2,
|
||||||
|
)
|
||||||
|
def summarize_v2_task(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
):
|
||||||
|
"""Generate a summary from the provided content.
|
||||||
|
|
||||||
|
This Celery task performs the following operations:
|
||||||
|
1. Run summary internals
|
||||||
|
2. Sends the final summary via webhook.
|
||||||
|
"""
|
||||||
|
payload = SummarizeTaskV2Payload.model_validate(payload)
|
||||||
|
summary = summarize_transcription_internals(
|
||||||
|
owner_id=payload.user_sub,
|
||||||
|
transcript=payload.content,
|
||||||
|
session_id=self.request.id,
|
||||||
|
)
|
||||||
|
job_id = self.request.id
|
||||||
|
file_service.store_summary(summary=summary, job_id=job_id)
|
||||||
|
|
||||||
|
success_payload = SummarizeWebhookSuccessPayload(
|
||||||
|
job_id=job_id,
|
||||||
|
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||||
|
)
|
||||||
|
call_webhook_v2_task.apply_async(
|
||||||
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
|
)
|
||||||
|
return success_payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||||
|
def handle_summarize_v2_failed(
|
||||||
|
sender,
|
||||||
|
task_id=None,
|
||||||
|
exception=None,
|
||||||
|
args=None,
|
||||||
|
kwargs=None,
|
||||||
|
traceback=None,
|
||||||
|
einfo=None,
|
||||||
|
**kw,
|
||||||
|
):
|
||||||
|
"""Handle the failure of summarize_v2_task.
|
||||||
|
|
||||||
|
This function is triggered when the summarize_v2_task fails.
|
||||||
|
It sends a webhook failure payload to notify the client of the failure.
|
||||||
|
"""
|
||||||
|
task = sender
|
||||||
|
# If retries are exhausted:
|
||||||
|
if task.request.retries >= task.max_retries:
|
||||||
|
call_webhook_v2_task.apply_async(
|
||||||
|
args=[
|
||||||
|
SummarizeWebhookFailurePayload(
|
||||||
|
job_id=task.id,
|
||||||
|
error_code="unknown_error",
|
||||||
|
).model_dump(),
|
||||||
|
args[0]["tenant_id"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,21 +1,58 @@
|
|||||||
"""Application configuration and settings."""
|
"""Application configuration and settings."""
|
||||||
|
|
||||||
from functools import lru_cache
|
import logging
|
||||||
from typing import Annotated, List, Literal, Optional, Set
|
import os
|
||||||
|
from functools import cached_property, lru_cache
|
||||||
|
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic import SecretStr
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
Field,
|
||||||
|
SecretStr,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from summary.core.types import Url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizedTenant(BaseModel):
|
||||||
|
"""Authorized tenant configuration."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
id: str = Field(title="Tenant Id", description="A unique ID for the tenant.")
|
||||||
|
api_key: SecretStr = Field(title="API key for using the summary API", min_length=8)
|
||||||
|
webhook_url: Url = Field(
|
||||||
|
title="Webhook URL", description="The URL to send the summary to."
|
||||||
|
)
|
||||||
|
webhook_api_key: SecretStr = Field(
|
||||||
|
title="Webhook API Key",
|
||||||
|
description="The api_key to authenticate the webhook request.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Configuration settings loaded from environment variables and .env file."""
|
"""Configuration settings loaded from environment variables and .env file."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env")
|
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
||||||
|
|
||||||
app_name: str = "app"
|
app_name: str = "summary"
|
||||||
app_api_v1_str: str = "/api/v1"
|
app_api_v1_str: str = "/api/v1"
|
||||||
app_api_token: SecretStr
|
app_api_v2_str: str = "/api/v2"
|
||||||
|
|
||||||
|
# Authorized Tenants
|
||||||
|
# Using env variables to store authorized tenants for now
|
||||||
|
# to avoid any other external dependency (DB)
|
||||||
|
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
|
||||||
|
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
|
||||||
|
|
||||||
# Audio recordings
|
# Audio recordings
|
||||||
recording_max_duration: Optional[int] = None
|
recording_max_duration: Optional[int] = None
|
||||||
@@ -29,6 +66,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
transcribe_queue: str = "transcribe-queue"
|
transcribe_queue: str = "transcribe-queue"
|
||||||
summarize_queue: str = "summarize-queue"
|
summarize_queue: str = "summarize-queue"
|
||||||
|
# v2 tasks
|
||||||
|
transcribe_queue_v2: str = "transcribe-queue-v2"
|
||||||
|
summarize_queue_v2: str = "summarize-queue-v2"
|
||||||
|
call_webhook_queue_v2: str = "call-webhook-queue-v2"
|
||||||
|
|
||||||
# Minio settings
|
# Minio settings
|
||||||
aws_storage_bucket_name: str
|
aws_storage_bucket_name: str
|
||||||
@@ -36,6 +77,8 @@ class Settings(BaseSettings):
|
|||||||
aws_s3_access_key_id: str
|
aws_s3_access_key_id: str
|
||||||
aws_s3_secret_access_key: SecretStr
|
aws_s3_secret_access_key: SecretStr
|
||||||
aws_s3_secure_access: bool = True
|
aws_s3_secure_access: bool = True
|
||||||
|
aws_transcript_path: str = "transcripts"
|
||||||
|
aws_summary_path: str = "summaries"
|
||||||
|
|
||||||
# AI-related settings
|
# AI-related settings
|
||||||
whisperx_api_key: SecretStr
|
whisperx_api_key: SecretStr
|
||||||
@@ -56,8 +99,6 @@ class Settings(BaseSettings):
|
|||||||
webhook_max_retries: int = 2
|
webhook_max_retries: int = 2
|
||||||
webhook_status_forcelist: List[int] = [502, 503, 504]
|
webhook_status_forcelist: List[int] = [502, 503, 504]
|
||||||
webhook_backoff_factor: float = 0.1
|
webhook_backoff_factor: float = 0.1
|
||||||
webhook_api_token: SecretStr
|
|
||||||
webhook_url: str
|
|
||||||
|
|
||||||
# Locale
|
# Locale
|
||||||
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
||||||
@@ -90,6 +131,92 @@ class Settings(BaseSettings):
|
|||||||
task_tracker_redis_url: str = "redis://redis/0"
|
task_tracker_redis_url: str = "redis://redis/0"
|
||||||
task_tracker_prefix: str = "task_metadata:"
|
task_tracker_prefix: str = "task_metadata:"
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def legacy_default_tenant_config(cls, data: Any) -> Any:
|
||||||
|
"""Migrate the legacy default tenant configuration."""
|
||||||
|
if isinstance(data, dict):
|
||||||
|
api_key = os.getenv("APP_API_TOKEN")
|
||||||
|
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
|
||||||
|
webhook_url = os.getenv("WEBHOOK_URL")
|
||||||
|
if api_key and webhook_api_key and webhook_url:
|
||||||
|
logger.warning(
|
||||||
|
"Deprecated legacy app configuration detected, "
|
||||||
|
"please use only the new 'authorized_tenants' field instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
authorized_tenants = list(data.get("authorized_tenants", []))
|
||||||
|
authorized_tenants.append(
|
||||||
|
AuthorizedTenant(
|
||||||
|
id=V1_DEFAULT_TENANT_ID,
|
||||||
|
api_key=SecretStr(api_key),
|
||||||
|
webhook_url=webhook_url,
|
||||||
|
webhook_api_key=SecretStr(webhook_api_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
data["authorized_tenants"] = tuple(authorized_tenants)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_authorized_tenants(self):
|
||||||
|
"""Validate authorized tenants configuration."""
|
||||||
|
if len(self.authorized_tenants) == 0:
|
||||||
|
raise ValueError("No authorized tenants configured")
|
||||||
|
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
|
||||||
|
|
||||||
|
if len(tenant_ids) != len(self.authorized_tenants):
|
||||||
|
raise ValueError("Duplicate tenant ids are not allowed")
|
||||||
|
|
||||||
|
api_keys = {
|
||||||
|
tenant.api_key.get_secret_value() for tenant in self.authorized_tenants
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(api_keys) != len(self.authorized_tenants):
|
||||||
|
raise ValueError("Duplicate application API api_keys are not allowed")
|
||||||
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_default_v1_tenant(self):
|
||||||
|
"""Validate default v1 tenant configuration."""
|
||||||
|
if not any(
|
||||||
|
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
|
||||||
|
):
|
||||||
|
raise ValueError("v1 tenant is not configured in authorized tenants")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
||||||
|
"""Return a frozenset of authorized tenant API api_keys."""
|
||||||
|
return frozenset(
|
||||||
|
app.api_key.get_secret_value() for app in self.authorized_tenants
|
||||||
|
)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def authorized_tenant_by_id(self) -> Mapping[str, AuthorizedTenant]:
|
||||||
|
"""Return a dict of authorized tenants by ID."""
|
||||||
|
return {app.id: app for app in self.authorized_tenants}
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def authorized_tenant_by_api_key(self) -> Mapping[str, AuthorizedTenant]:
|
||||||
|
"""Return the authorized tenant for a given API api_key."""
|
||||||
|
return {app.api_key.get_secret_value(): app for app in self.authorized_tenants}
|
||||||
|
|
||||||
|
def get_authorized_tenant(
|
||||||
|
self, *, tenant_id: str | None = None, api_key: str | None = None
|
||||||
|
) -> AuthorizedTenant:
|
||||||
|
"""Return the authorized tenant for a given API api_key or id."""
|
||||||
|
if tenant_id is None and api_key is None:
|
||||||
|
raise ValueError("Either tenant_id or api_key must be provided")
|
||||||
|
if tenant_id is not None and api_key is not None:
|
||||||
|
raise ValueError("Only one of tenant_id or api_key can be provided")
|
||||||
|
|
||||||
|
if tenant_id is not None:
|
||||||
|
return self.authorized_tenant_by_id[tenant_id]
|
||||||
|
else:
|
||||||
|
return self.authorized_tenant_by_api_key[api_key]
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings():
|
def get_settings():
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
"""File service to encapsulate files' manipulations."""
|
"""File service to encapsulate files' manipulations."""
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import mutagen
|
import mutagen
|
||||||
|
import requests
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import MinioException, S3Error
|
from minio.error import MinioException, S3Error
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.shared_models import WhisperXResponse
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +101,58 @@ class FileService:
|
|||||||
if response:
|
if response:
|
||||||
response.close()
|
response.close()
|
||||||
|
|
||||||
|
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||||
|
"""Download file from a cloud storage URL to local temporary file."""
|
||||||
|
logger.info(
|
||||||
|
"Download recording from URL | cloud_storage_url: %s",
|
||||||
|
cloud_storage_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not cloud_storage_url:
|
||||||
|
logger.warning("Invalid cloud_storage_url '%s'", cloud_storage_url)
|
||||||
|
raise ValueError("Invalid cloud_storage_url")
|
||||||
|
|
||||||
|
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
|
||||||
|
if extension not in self._allowed_extensions:
|
||||||
|
logger.warning(
|
||||||
|
"Invalid file extension '%s' from cloud_storage_url", extension
|
||||||
|
)
|
||||||
|
raise ValueError(f"Invalid file extension '{extension}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with requests.get(
|
||||||
|
cloud_storage_url,
|
||||||
|
stream=True,
|
||||||
|
timeout=(10, 300),
|
||||||
|
# verify=False,
|
||||||
|
) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
suffix=extension,
|
||||||
|
delete=False,
|
||||||
|
prefix="cloud_storage_download_",
|
||||||
|
) as tmp:
|
||||||
|
for chunk in response.iter_content(
|
||||||
|
chunk_size=self._stream_chunk_size
|
||||||
|
):
|
||||||
|
if chunk:
|
||||||
|
tmp.write(chunk)
|
||||||
|
|
||||||
|
tmp.flush()
|
||||||
|
local_path = Path(tmp.name)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Recording successfully downloaded from cloud_storage_url"
|
||||||
|
)
|
||||||
|
logger.debug("Recording local file path: %s", local_path)
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise FileServiceException(
|
||||||
|
"Unexpected error while downloading object from cloud_storage_url."
|
||||||
|
) from e
|
||||||
|
|
||||||
def _validate_duration(self, local_path: Path) -> float:
|
def _validate_duration(self, local_path: Path) -> float:
|
||||||
"""Validate audio file duration against configured maximum."""
|
"""Validate audio file duration against configured maximum."""
|
||||||
file_metadata = mutagen.File(local_path).info
|
file_metadata = mutagen.File(local_path).info
|
||||||
@@ -157,19 +213,37 @@ class FileService:
|
|||||||
raise RuntimeError("Failed to extract audio.") from e
|
raise RuntimeError("Failed to extract audio.") from e
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def prepare_audio_file(self, remote_object_key: str):
|
def prepare_audio_file(
|
||||||
|
self,
|
||||||
|
remote_object_key: str | None = None,
|
||||||
|
cloud_storage_url: str | None = None,
|
||||||
|
):
|
||||||
"""Download and prepare audio file for processing.
|
"""Download and prepare audio file for processing.
|
||||||
|
|
||||||
Downloads file from MinIO, validates duration, and yields an open
|
Downloads file from MinIO or an external cloud URL, validates duration,
|
||||||
file handle with metadata. Automatically cleans up temporary files
|
and yields an open file handle with metadata. Automatically cleans up
|
||||||
when the context exits.
|
temporary files when the context exits.
|
||||||
"""
|
"""
|
||||||
downloaded_path = None
|
downloaded_path = None
|
||||||
processed_path = None
|
processed_path = None
|
||||||
file_handle = None
|
file_handle = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloaded_path = self._download_from_minio(remote_object_key)
|
if bool(remote_object_key) == bool(cloud_storage_url):
|
||||||
|
raise ValueError(
|
||||||
|
(
|
||||||
|
"Exactly one of 'remote_object_key' or "
|
||||||
|
"'cloud_storage_url' must be provided."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cloud_storage_url:
|
||||||
|
downloaded_path = self._download_from_cloud_storage_url(
|
||||||
|
cloud_storage_url
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
downloaded_path = self._download_from_minio(remote_object_key)
|
||||||
|
|
||||||
duration = self._validate_duration(downloaded_path)
|
duration = self._validate_duration(downloaded_path)
|
||||||
|
|
||||||
extension = downloaded_path.suffix.lower()
|
extension = downloaded_path.suffix.lower()
|
||||||
@@ -199,3 +273,47 @@ class FileService:
|
|||||||
logger.debug("Temporary file removed: %s", path)
|
logger.debug("Temporary file removed: %s", path)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||||
|
|
||||||
|
def store_transcript(self, *, transcript: WhisperXResponse, job_id: str) -> None:
|
||||||
|
"""Store transcript in MinIO."""
|
||||||
|
logger.info("Storing transcript for job id %s", job_id)
|
||||||
|
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||||
|
logger.debug("Transcript path: %s", transcript_path)
|
||||||
|
data = transcript.model_dump_json().encode()
|
||||||
|
self._minio_client.put_object(
|
||||||
|
self._bucket_name,
|
||||||
|
transcript_path,
|
||||||
|
io.BytesIO(data),
|
||||||
|
length=len(data),
|
||||||
|
)
|
||||||
|
logger.info("Transcript stored successfully for job id %s", job_id)
|
||||||
|
|
||||||
|
def get_transcript_signed_url(self, job_id: str) -> str:
|
||||||
|
"""Get signed URL for transcript file."""
|
||||||
|
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||||
|
logger.debug("Transcript path: %s", transcript_path)
|
||||||
|
return self._minio_client.presigned_get_object(
|
||||||
|
self._bucket_name, transcript_path, expires=timedelta(hours=24)
|
||||||
|
)
|
||||||
|
|
||||||
|
def store_summary(self, *, summary: str, job_id: str) -> None:
|
||||||
|
"""Store summary in MinIO."""
|
||||||
|
logger.info("Storing summary for job id %s", job_id)
|
||||||
|
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||||
|
logger.debug("Summary path: %s", summary_path)
|
||||||
|
data = summary.encode()
|
||||||
|
self._minio_client.put_object(
|
||||||
|
self._bucket_name,
|
||||||
|
summary_path,
|
||||||
|
io.BytesIO(data),
|
||||||
|
length=len(data),
|
||||||
|
)
|
||||||
|
logger.info("Summary stored successfully for job id %s", job_id)
|
||||||
|
|
||||||
|
def get_summary_signed_url(self, job_id: str) -> str:
|
||||||
|
"""Get signed URL for summary file."""
|
||||||
|
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||||
|
logger.debug("Summary path: %s", summary_path)
|
||||||
|
return self._minio_client.presigned_get_object(
|
||||||
|
self._bucket_name, summary_path, expires=timedelta(hours=24)
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Models for the API & Celery tasks creation."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.types import Url
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SharedV2TaskCreation(BaseModel):
|
||||||
|
"""Model that holds basic information for task creation."""
|
||||||
|
|
||||||
|
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||||
|
"""Model for creating a transcribe and summarize task (used for API request)."""
|
||||||
|
|
||||||
|
cloud_storage_url: Url = Field(
|
||||||
|
title="Cloud storage URL",
|
||||||
|
description="The URL of the audio file to transcribe.",
|
||||||
|
)
|
||||||
|
context_language: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Context Language",
|
||||||
|
description="The language of the context text.",
|
||||||
|
)
|
||||||
|
language: str = Field(
|
||||||
|
title="Language", description="The language of the content to summarize."
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def validate_language(cls, v):
|
||||||
|
"""Validate 'language' parameter."""
|
||||||
|
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(settings.whisperx_allowed_languages)}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
|
||||||
|
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
|
||||||
|
|
||||||
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
|
|
||||||
|
|
||||||
|
class SummarizeTaskV2Request(SharedV2TaskCreation):
|
||||||
|
"""Model for creating a summarize task (used for API request)."""
|
||||||
|
|
||||||
|
content: str = Field(title="Content", description="The content to summarize.")
|
||||||
|
|
||||||
|
|
||||||
|
class SummarizeTaskV2Payload(SummarizeTaskV2Request):
|
||||||
|
"""Model for creating a summarize task (used for actual task creation)."""
|
||||||
|
|
||||||
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
@@ -8,12 +8,23 @@ from .config import SettingsDeps
|
|||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
def verify_token(
|
def verify_tenant_api_key(
|
||||||
settings: SettingsDeps,
|
settings: SettingsDeps,
|
||||||
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
|
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
|
||||||
):
|
):
|
||||||
"""Verify the bearer token from the Authorization header."""
|
"""Verify the bearer api_key from the Authorization header."""
|
||||||
token = credentials.credentials
|
api_key = credentials.credentials
|
||||||
if token != settings.app_api_token.get_secret_value():
|
if api_key not in settings.authorized_tenant_api_keys:
|
||||||
raise HTTPException(status_code=401, detail="Invalid token")
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||||
return token
|
return api_key
|
||||||
|
|
||||||
|
|
||||||
|
def verify_tenant_api_key_v2(
|
||||||
|
settings: SettingsDeps,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Security(security), # noqa: B008
|
||||||
|
):
|
||||||
|
"""Verify the bearer api_key from the Authorization header."""
|
||||||
|
api_key = credentials.credentials
|
||||||
|
if api_key not in settings.authorized_tenant_api_keys:
|
||||||
|
raise HTTPException(status_code=403, detail="Unauthorized")
|
||||||
|
return settings.get_authorized_tenant(api_key=api_key)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Transcribe / summary Shared / Webhook models."""
|
||||||
|
|
||||||
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, TypeAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class WordSegment(BaseModel):
|
||||||
|
"""Word segment model for transcription tasks."""
|
||||||
|
|
||||||
|
word: str = Field(title="Word")
|
||||||
|
start: float = Field(title="Start Time", description="Start time in seconds.")
|
||||||
|
end: float = Field(title="End Time", description="End time in seconds.")
|
||||||
|
score: float | None = Field(
|
||||||
|
title="Confidence Score", description="Confidence score for the word segment."
|
||||||
|
)
|
||||||
|
speaker: str | None = Field(
|
||||||
|
title="Speaker", description="Speaker identifier for the word segment."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Segment(BaseModel):
|
||||||
|
"""Segment model for transcription tasks."""
|
||||||
|
|
||||||
|
start: float = Field(title="Start Time", description="Start time in seconds.")
|
||||||
|
end: float = Field(title="End Time", description="End time in seconds.")
|
||||||
|
text: str = Field(
|
||||||
|
title="Segment Text", description="Transcribed text for the segment."
|
||||||
|
)
|
||||||
|
words: tuple[WordSegment, ...] | None = Field(
|
||||||
|
title="Word Segments", description="List of word segments within the segment."
|
||||||
|
)
|
||||||
|
speaker: str | None = Field(
|
||||||
|
title="Speaker", description="Speaker identifier for the segment."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WhisperXResponse(BaseModel):
|
||||||
|
"""Model for WhisperX response."""
|
||||||
|
|
||||||
|
segments: tuple[Segment, ...] = Field(
|
||||||
|
title="Segments", description="List of transcribed segments."
|
||||||
|
)
|
||||||
|
word_segments: tuple[WordSegment, ...] = Field(
|
||||||
|
title="Word Segments", description="List of word segments."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseWebhook(BaseModel):
|
||||||
|
"""Base webhook payload."""
|
||||||
|
|
||||||
|
job_id: str = Field(
|
||||||
|
title="Job ID",
|
||||||
|
description="The ID of the job document in the receiver system.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeWebhookSuccessPayload(BaseWebhook):
|
||||||
|
"""Payload for a successful transcription webhook."""
|
||||||
|
|
||||||
|
type: Literal["transcript"] = Field(default="transcript")
|
||||||
|
status: Literal["success"] = Field(default="success")
|
||||||
|
transcription_data_url: str = Field(
|
||||||
|
title="Transcript", description="URL to the raw transcription data."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeWebhookPendingPayload(BaseWebhook):
|
||||||
|
"""Payload for a pending transcription webhook-like response."""
|
||||||
|
|
||||||
|
type: Literal["transcript"] = Field(default="transcript")
|
||||||
|
status: Literal["pending"] = Field(default="pending")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeWebhookFailurePayload(BaseWebhook):
|
||||||
|
"""Payload for a failed transcription webhook."""
|
||||||
|
|
||||||
|
type: Literal["transcript"] = Field(default="transcript")
|
||||||
|
status: Literal["failure"] = Field(default="failure")
|
||||||
|
error_code: Literal["unknown_error"] = Field(
|
||||||
|
title="Error code", description="The error code."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TranscribeWebhookPayloads = Annotated[
|
||||||
|
Union[
|
||||||
|
TranscribeWebhookSuccessPayload,
|
||||||
|
TranscribeWebhookPendingPayload,
|
||||||
|
TranscribeWebhookFailurePayload,
|
||||||
|
],
|
||||||
|
Field(discriminator="status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SummarizeWebhookSuccessPayload(BaseWebhook):
|
||||||
|
"""Payload for a successful summarization webhook."""
|
||||||
|
|
||||||
|
type: Literal["summary"] = Field(default="summary")
|
||||||
|
status: Literal["success"] = Field(default="success")
|
||||||
|
summary_data_url: str = Field(
|
||||||
|
title="Summary", description="URL to the raw summary data."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SummarizeWebhookPendingPayload(BaseWebhook):
|
||||||
|
"""Payload for a pending summarization webhook-like response."""
|
||||||
|
|
||||||
|
type: Literal["summary"] = Field(default="summary")
|
||||||
|
status: Literal["pending"] = Field(default="pending")
|
||||||
|
|
||||||
|
|
||||||
|
class SummarizeWebhookFailurePayload(BaseWebhook):
|
||||||
|
"""Payload for a failed summarization webhook."""
|
||||||
|
|
||||||
|
type: Literal["summary"] = Field(default="summary")
|
||||||
|
status: Literal["failure"] = Field(default="failure")
|
||||||
|
error_code: Literal["unknown_error"] = Field(
|
||||||
|
title="Error code", description="The error code."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SummarizeWebhookPayloads = Annotated[
|
||||||
|
Union[
|
||||||
|
SummarizeWebhookSuccessPayload,
|
||||||
|
SummarizeWebhookPendingPayload,
|
||||||
|
SummarizeWebhookFailurePayload,
|
||||||
|
],
|
||||||
|
Field(discriminator="status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
WebhookPayloads = Annotated[
|
||||||
|
Union[TranscribeWebhookPayloads, SummarizeWebhookPayloads],
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
webhook_payload_adapter = TypeAdapter(WebhookPayloads)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TranscribeWebhookSuccessPayload",
|
||||||
|
"TranscribeWebhookPendingPayload",
|
||||||
|
"TranscribeWebhookFailurePayload",
|
||||||
|
"SummarizeWebhookSuccessPayload",
|
||||||
|
"SummarizeWebhookPendingPayload",
|
||||||
|
"SummarizeWebhookFailurePayload",
|
||||||
|
"TranscribeWebhookPayloads",
|
||||||
|
"SummarizeWebhookPayloads",
|
||||||
|
"WebhookPayloads",
|
||||||
|
"WhisperXResponse",
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Basic types for the summary service."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from pydantic import BeforeValidator, HttpUrl, TypeAdapter
|
||||||
|
|
||||||
|
http_url_adapter = TypeAdapter(HttpUrl)
|
||||||
|
Url = Annotated[
|
||||||
|
str, BeforeValidator(lambda value: str(http_url_adapter.validate_python(value)))
|
||||||
|
]
|
||||||
@@ -3,18 +3,22 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from requests.adapters import HTTPAdapter
|
from requests.adapters import HTTPAdapter
|
||||||
from urllib3.util import Retry
|
from urllib3.util import Retry
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.shared_models import (
|
||||||
|
WebhookPayloads,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _create_retry_session():
|
def _create_retry_session(api_key: str | None = None):
|
||||||
"""Create an HTTP session configured with retry logic."""
|
"""Create an HTTP session configured with retry logic."""
|
||||||
session = Session()
|
session = Session()
|
||||||
retries = Retry(
|
retries = Retry(
|
||||||
@@ -24,15 +28,16 @@ def _create_retry_session():
|
|||||||
allowed_methods={"POST"},
|
allowed_methods={"POST"},
|
||||||
)
|
)
|
||||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||||
|
if api_key:
|
||||||
|
session.headers.update({"Authorization": f"Bearer {api_key}"})
|
||||||
|
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
def _post_with_retries(url, data):
|
def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||||
"""Send POST request with automatic retries."""
|
"""Send POST request with automatic retries."""
|
||||||
session = _create_retry_session()
|
session = _create_retry_session(api_key=api_key)
|
||||||
session.headers.update(
|
|
||||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
response = session.post(url, json=data)
|
response = session.post(url, json=data)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
@@ -41,22 +46,78 @@ def _post_with_retries(url, data):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def submit_content(content, title, email, sub):
|
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
|
||||||
"""Submit content to the configured webhook destination.
|
"""Call webhook with payload a payload and optional token."""
|
||||||
|
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
|
||||||
|
|
||||||
Builds the payload, sends it with retries, and logs the outcome.
|
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||||
"""
|
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
|
||||||
data = {
|
|
||||||
"title": title,
|
|
||||||
"content": content,
|
|
||||||
"email": email,
|
|
||||||
"sub": sub,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug("Submitting to %s", settings.webhook_url)
|
response = _post_with_retries(
|
||||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
url=tenant.webhook_url,
|
||||||
|
api_key=tenant.webhook_api_key.get_secret_value(),
|
||||||
response = _post_with_retries(settings.webhook_url, data)
|
data=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
document_id = response_data.get("id", "N/A")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
document_id = "Unable to parse response"
|
||||||
|
response_data = response.text
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Delivery success | Document %s submitted (HTTP %s)",
|
||||||
|
document_id,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
logger.debug("Full response: %s", response_data)
|
||||||
|
|
||||||
|
|
||||||
|
def submit_content(content: str, title: str, email: str, sub: str) -> None:
|
||||||
|
"""Submit content to the configured webhook destination.
|
||||||
|
|
||||||
|
Builds the payload, sends it with retries, and logs the outcome.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
Deprecated: Use call_webhook_v2 directly instead.
|
||||||
|
|
||||||
|
Deprecated:
|
||||||
|
This will route content to the v1 default tenant
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"email": email,
|
||||||
|
"sub": sub,
|
||||||
|
}
|
||||||
|
|
||||||
|
call_webhook_v1(payload=data, tenant_id=settings.v1_tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def call_webhook_v2(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
payload: WebhookPayloads,
|
||||||
|
) -> None:
|
||||||
|
"""Call webhook with a payload to a specific tenant.
|
||||||
|
|
||||||
|
Request is performed without retry, retry should be handled at the task level.
|
||||||
|
"""
|
||||||
|
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
|
||||||
|
|
||||||
|
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||||
|
logger.debug("Request payload: %s", payload.model_dump_json(indent=2))
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
tenant.webhook_url,
|
||||||
|
json=payload.model_dump(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||||
|
},
|
||||||
|
timeout=(10, 20),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_data = response.json()
|
response_data = response.json()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import sentry_sdk
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from summary.api import health
|
from summary.api import health
|
||||||
from summary.api.main import api_router
|
from summary.api.main import api_router_v1, api_router_v2
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -17,5 +17,6 @@ app = FastAPI(
|
|||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.app_api_v1_str)
|
app.include_router(api_router_v1, prefix=settings.app_api_v1_str)
|
||||||
|
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class TestHeartbeat:
|
|||||||
"""Tests for the /__heartbeat__ endpoint."""
|
"""Tests for the /__heartbeat__ endpoint."""
|
||||||
|
|
||||||
def test_returns_200(self, client):
|
def test_returns_200(self, client):
|
||||||
"""The heartbeat endpoint responds with 200 OK without a token."""
|
"""The heartbeat endpoint responds with 200 OK without an api_key."""
|
||||||
response = client.get("/__heartbeat__")
|
response = client.get("/__heartbeat__")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -15,7 +15,7 @@ class TestLBHeartbeat:
|
|||||||
"""Tests for the /__lbheartbeat__ endpoint."""
|
"""Tests for the /__lbheartbeat__ endpoint."""
|
||||||
|
|
||||||
def test_returns_200(self, client):
|
def test_returns_200(self, client):
|
||||||
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
|
"""The LB heartbeat endpoint responds with 200 OK without an api_key."""
|
||||||
response = client.get("/__lbheartbeat__")
|
response = client.get("/__lbheartbeat__")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
|
|
||||||
class TestTasks:
|
class TestTasks:
|
||||||
"""Tests for the /tasks endpoint."""
|
"""Tests for the /v1/tasks endpoint."""
|
||||||
|
|
||||||
@patch(
|
@patch(
|
||||||
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
|
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Integration tests for the V2 task API endpoints."""
|
||||||
|
|
||||||
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestTasksV2:
|
||||||
|
"""Tests for the /v2/async-jobs-jobs endpoints."""
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks_v2.process_audio_transcribe_v2_task.apply_async",
|
||||||
|
return_value=MagicMock(id="transcribe-task-id-abc"),
|
||||||
|
)
|
||||||
|
def test_create_transcribe_task_v2_returns_task_id(self, mock_apply_async, client):
|
||||||
|
"""POST /async-jobs/transcribe creates a task and injects tenant_id."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v2/async-jobs/transcribe",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json={
|
||||||
|
"user_sub": "remote-001",
|
||||||
|
"cloud_storage_url": "https://example.com/audio.mp3",
|
||||||
|
"language": "en",
|
||||||
|
"context_language": "fr",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"job_id": "transcribe-task-id-abc",
|
||||||
|
"type": "transcript",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
assert args == [
|
||||||
|
{
|
||||||
|
"user_sub": "remote-001",
|
||||||
|
"cloud_storage_url": "https://example.com/audio.mp3",
|
||||||
|
"language": "en",
|
||||||
|
"context_language": "fr",
|
||||||
|
"tenant_id": "test-tenant",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks_v2.summarize_v2_task.apply_async",
|
||||||
|
return_value=MagicMock(id="summarize-task-id-abc"),
|
||||||
|
)
|
||||||
|
def test_create_summarize_task_v2_returns_task_id(self, mock_apply_async, client):
|
||||||
|
"""POST /async-jobs/summarize creates a task and injects tenant_id."""
|
||||||
|
response = client.post(
|
||||||
|
"/api/v2/async-jobs/summarize",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json={
|
||||||
|
"user_sub": "remote-002",
|
||||||
|
"content": "This is a long meeting transcript to summarize.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"job_id": "summarize-task-id-abc",
|
||||||
|
"type": "summary",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
assert args == [
|
||||||
|
{
|
||||||
|
"user_sub": "remote-002",
|
||||||
|
"content": "This is a long meeting transcript to summarize.",
|
||||||
|
"tenant_id": "test-tenant",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
@patch("summary.api.route.tasks_v2.celery")
|
||||||
|
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||||
|
def test_get_transcribe_task_status_returns_status_for_same_tenant(
|
||||||
|
self, mock_async_result, mock_celery, client
|
||||||
|
):
|
||||||
|
"""GET /async-jobs/transcribe/{id} returns status when tenant matches."""
|
||||||
|
mock_celery.backend.client.exists.return_value = True
|
||||||
|
mock_async_result.return_value = MagicMock(
|
||||||
|
status="PENDING",
|
||||||
|
args=[{"tenant_id": "test-tenant"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v2/async-jobs/transcribe/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"job_id": "task-id-abc",
|
||||||
|
"type": "transcript",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_async_result.assert_called_once_with("task-id-abc", app=ANY)
|
||||||
|
|
||||||
|
@patch("summary.api.route.tasks_v2.celery")
|
||||||
|
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||||
|
def test_get_summarize_task_status_returns_status_for_same_tenant(
|
||||||
|
self, mock_async_result, mock_celery, client
|
||||||
|
):
|
||||||
|
"""GET /async-jobs/summarize/{id} returns status when tenant matches."""
|
||||||
|
mock_celery.backend.client.exists.return_value = True
|
||||||
|
mock_async_result.return_value = MagicMock(
|
||||||
|
status="SUCCESS",
|
||||||
|
args=[{"tenant_id": "test-tenant"}],
|
||||||
|
result={
|
||||||
|
"job_id": "task-id-abc",
|
||||||
|
"summary_data_url": "https://example.com/summary.json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v2/async-jobs/summarize/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"job_id": "task-id-abc",
|
||||||
|
"type": "summary",
|
||||||
|
"status": "success",
|
||||||
|
"summary_data_url": "https://example.com/summary.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_async_result.assert_called_once_with("task-id-abc", app=ANY)
|
||||||
|
|
||||||
|
@patch("summary.api.route.tasks_v2.celery")
|
||||||
|
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||||
|
def test_get_task_status_returns_404_when_job_does_not_exist(
|
||||||
|
self, mock_async_result, mock_celery, client
|
||||||
|
):
|
||||||
|
"""GET /async-jobs/.../{id} returns 404 when task key is not in Redis."""
|
||||||
|
mock_celery.backend.client.exists.return_value = False
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v2/async-jobs/transcribe/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {"detail": "Not found"}
|
||||||
|
mock_async_result.assert_not_called()
|
||||||
|
|
||||||
|
@patch("summary.api.route.tasks_v2.celery")
|
||||||
|
@patch("summary.api.route.tasks_v2.AsyncResult")
|
||||||
|
def test_get_task_status_returns_403_for_other_tenant(
|
||||||
|
self, mock_async_result, mock_celery, client
|
||||||
|
):
|
||||||
|
"""GET /async-jobs/.../{id} returns 403 when task belongs to another tenant."""
|
||||||
|
mock_celery.backend.client.exists.return_value = True
|
||||||
|
mock_async_result.return_value = MagicMock(
|
||||||
|
status="PENDING",
|
||||||
|
args=[{"tenant_id": "another-tenant"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/api/v2/async-jobs/summarize/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"detail": "Forbidden"}
|
||||||
@@ -4,14 +4,22 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from summary.core.config import Settings, get_settings
|
from summary.core.config import AuthorizedTenant, Settings, get_settings
|
||||||
from summary.main import app
|
from summary.main import app
|
||||||
|
|
||||||
|
|
||||||
def get_settings_override():
|
def get_settings_override():
|
||||||
"""Return settings for tests."""
|
"""Return settings for tests."""
|
||||||
return Settings(
|
return Settings(
|
||||||
app_api_token=SecretStr("test-api-token"),
|
v1_tenant_id="test-tenant",
|
||||||
|
authorized_tenants=(
|
||||||
|
AuthorizedTenant(
|
||||||
|
webhook_url="https://example.com/webhook",
|
||||||
|
id="test-tenant",
|
||||||
|
api_key=SecretStr("test-api-token"),
|
||||||
|
webhook_api_key=SecretStr("test-webhook-api-key"),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user