mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 20:29:07 +00:00
✨(summary) extend v2 routes to support visio usecase
* Update the v2 create transcribe payload to support publishing directly to docs and performing automatically a summary if requested * Note that title computation is to be handled by the caller now as it makes more sense and avoids throwing a bunch of parameters to the endpoint. * All files are send as signed URL now, we don't read directly from s3 anymore, * Docs integration is now explicit in summary settings * To make analytics work properly accross projects, we use the user sub as distinct id, this will require a new posthog project to be deployed to work properly. We create a post hog event at the creation request processing, to make sure feature flags work properly after that. * User email should now be provided to the API, it's not mandatory to avoid a breaking change. * Use a specific user agent for better tracking Finally note that existing helpers don't always make use of the pydantic models, so that's why there are model_dump in some places. To avoid bigger changes.
This commit is contained in:
committed by
aleb_the_flash
parent
27da57aff5
commit
ecf8f0fe3f
@@ -1,15 +1,19 @@
|
|||||||
"""API routes related to application tasks (V2 / tenant friendly)."""
|
"""API routes related to application tasks (V2 / tenant friendly)."""
|
||||||
|
|
||||||
from celery.result import AsyncResult
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
from summary.core.analytics import get_analytics
|
||||||
from summary.core.celery_worker import (
|
from summary.core.celery_worker import (
|
||||||
celery,
|
celery,
|
||||||
process_audio_transcribe_v2_task,
|
process_audio_transcribe_v2_task,
|
||||||
summarize_v2_task,
|
summarize_v2_task,
|
||||||
)
|
)
|
||||||
from summary.core.config import AuthorizedTenant
|
from summary.core.config import AuthorizedTenant, get_settings
|
||||||
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
|
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
||||||
from summary.core.security import verify_tenant_api_key_v2
|
from summary.core.security import verify_tenant_api_key_v2
|
||||||
from summary.core.shared_models import (
|
from summary.core.shared_models import (
|
||||||
SummarizeWebhookFailurePayload,
|
SummarizeWebhookFailurePayload,
|
||||||
@@ -20,17 +24,52 @@ from summary.core.shared_models import (
|
|||||||
TranscribeWebhookSuccessPayload,
|
TranscribeWebhookSuccessPayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router_tasks_v2 = APIRouter()
|
router_tasks_v2 = APIRouter()
|
||||||
|
|
||||||
|
analytics = get_analytics()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/transcribe")
|
@router_tasks_v2.post("/async-jobs/transcribe")
|
||||||
async def create_transcribe_task_v2(
|
async def create_transcribe_task_v2(
|
||||||
request: TranscribeTaskV2Request,
|
request: TranscribeTaskApiRequest,
|
||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a transcription task."""
|
"""Create a transcription task."""
|
||||||
|
if (
|
||||||
|
request.push_to_docs_config is not None
|
||||||
|
and not request_tenant.allowed_push_to_docs
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Push to docs is not allowed for this tenant (%s).", request_tenant.id
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Push to docs is not allowed for this tenant.",
|
||||||
|
)
|
||||||
|
|
||||||
task = process_audio_transcribe_v2_task.apply_async(
|
task = process_audio_transcribe_v2_task.apply_async(
|
||||||
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
args=[
|
||||||
|
{
|
||||||
|
**request.model_dump(),
|
||||||
|
"tenant_id": request_tenant.id,
|
||||||
|
"received_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# We track the request, this also properly initializes the user in the
|
||||||
|
# analytics system, so that later feature flags work properly
|
||||||
|
analytics.capture(
|
||||||
|
settings.posthog_event_request,
|
||||||
|
request.user_sub,
|
||||||
|
properties={
|
||||||
|
"kind": "transcribe",
|
||||||
|
"$set": {
|
||||||
|
"email": request.user_email,
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
@@ -38,14 +77,32 @@ async def create_transcribe_task_v2(
|
|||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/summarize")
|
@router_tasks_v2.post("/async-jobs/summarize")
|
||||||
async def create_summarize_task_v2(
|
async def create_summarize_task_v2(
|
||||||
request: SummarizeTaskV2Request,
|
request: SummarizeTaskApiRequest,
|
||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a summarization task."""
|
"""Create a summarization task."""
|
||||||
task = summarize_v2_task.apply_async(
|
task = summarize_v2_task.apply_async(
|
||||||
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
args=[
|
||||||
|
{
|
||||||
|
**request.model_dump(),
|
||||||
|
"tenant_id": request_tenant.id,
|
||||||
|
"received_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# We track the request, this also properly initializes the user in the
|
||||||
|
# analytics system, so that later feature flags work properly
|
||||||
|
analytics.capture(
|
||||||
|
settings.posthog_event_request,
|
||||||
|
request.user_sub,
|
||||||
|
properties={
|
||||||
|
"kind": "summarize",
|
||||||
|
"$set": {
|
||||||
|
"email": request.user_email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.models import TranscribeTaskJob
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -107,23 +109,24 @@ class MetadataManager:
|
|||||||
"""Check if task_id exists in tasks metadata cache."""
|
"""Check if task_id exists in tasks metadata cache."""
|
||||||
return self._redis.exists(self._get_redis_key(task_id))
|
return self._redis.exists(self._get_redis_key(task_id))
|
||||||
|
|
||||||
def create(self, task_id, task_args):
|
def create(self, task_id: str, task_payload: TranscribeTaskJob):
|
||||||
"""Create initial metadata entry for a new task."""
|
"""Create initial metadata entry for a new task."""
|
||||||
if self._is_disabled or self.has_task_id(task_id):
|
if self._is_disabled or self.has_task_id(task_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
|
|
||||||
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
|
|
||||||
_, filename, _, email, _, received_at, *_ = task_args
|
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
parts = urlsplit(task_payload.cloud_storage_url)
|
||||||
|
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||||
initial_metadata = {
|
initial_metadata = {
|
||||||
"start_time": start_time,
|
"start_time": start_time,
|
||||||
"asr_model": settings.whisperx_asr_model,
|
"asr_model": settings.whisperx_asr_model,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"filename": filename,
|
"filename": clean_url,
|
||||||
"email": email,
|
"sub": task_payload.user_sub,
|
||||||
"queuing_time": round(start_time - received_at, 2),
|
# avoid None in redis, it shouldn't happen anyway in prod
|
||||||
|
"email": task_payload.user_email or "",
|
||||||
|
"tenant_id": task_payload.tenant_id,
|
||||||
|
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
self._save_metadata(task_id, initial_metadata)
|
self._save_metadata(task_id, initial_metadata)
|
||||||
@@ -210,6 +213,6 @@ class MetadataManager:
|
|||||||
self.clear(task_id)
|
self.clear(task_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._analytics.capture(event_name, metadata.get("email"), metadata)
|
self._analytics.capture(event_name, metadata.get("sub"), metadata)
|
||||||
except AnalyticsException:
|
except AnalyticsException:
|
||||||
logger.exception("Failed to capture analytics event")
|
logger.exception("Failed to capture analytics event")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -17,16 +17,15 @@ from requests import exceptions
|
|||||||
|
|
||||||
from summary.core.analytics import MetadataManager, get_analytics
|
from summary.core.analytics import MetadataManager, get_analytics
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.file_service import (
|
from summary.core.docs_service import create_document_in_lasuite_docs
|
||||||
FileService,
|
from summary.core.file_service import FileService, FileServiceException, TranscribeError
|
||||||
FileServiceException,
|
|
||||||
TranscribeError,
|
|
||||||
)
|
|
||||||
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 (
|
from summary.core.models import (
|
||||||
SummarizeTaskV2Payload,
|
PushToDocsBaseConfig,
|
||||||
TranscribeTaskV2Payload,
|
RecordingMetadata,
|
||||||
|
SummarizeTaskJob,
|
||||||
|
TranscribeTaskJob,
|
||||||
)
|
)
|
||||||
from summary.core.prompt import (
|
from summary.core.prompt import (
|
||||||
FORMAT_NEXT_STEPS,
|
FORMAT_NEXT_STEPS,
|
||||||
@@ -85,29 +84,22 @@ file_service = FileService()
|
|||||||
def transcribe_audio(
|
def transcribe_audio(
|
||||||
*,
|
*,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
recording_filename: str | None = None,
|
|
||||||
language: str,
|
language: str,
|
||||||
cloud_storage_url=None,
|
cloud_storage_url: str,
|
||||||
raises: bool = False,
|
raises: bool = False,
|
||||||
):
|
):
|
||||||
"""Transcribe an audio file using WhisperX.
|
"""Transcribe an audio file using WhisperX.
|
||||||
|
|
||||||
Downloads the audio from MinIO or a cloud storage URL, sends it to
|
Downloads the audio from a cloud storage URL, sends it to
|
||||||
WhisperX for transcription, 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(recording_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")
|
||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
try:
|
try:
|
||||||
with file_service.prepare_audio_file(
|
with file_service.prepare_audio_file(
|
||||||
remote_object_key=recording_filename,
|
|
||||||
cloud_storage_url=cloud_storage_url,
|
cloud_storage_url=cloud_storage_url,
|
||||||
) as (audio_file, metadata):
|
) as (audio_file, metadata):
|
||||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||||
@@ -196,11 +188,7 @@ def transcribe_audio(
|
|||||||
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||||
)
|
)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
(
|
("Unexpected error while preparing file %s "),
|
||||||
"Unexpected error while preparing file | filename: %s "
|
|
||||||
"| cloud_storage_url: %s"
|
|
||||||
),
|
|
||||||
recording_filename,
|
|
||||||
redacted_cloud_storage_url,
|
redacted_cloud_storage_url,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -210,44 +198,34 @@ def transcribe_audio(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_speaker_identities_and_apply_to(
|
def resolve_speaker_identities_and_apply_to(
|
||||||
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
|
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
|
||||||
):
|
) -> WhisperXResponse:
|
||||||
"""Assign users to detected speakers and rewrite the transcriptions.
|
"""Assign users to detected speakers and rewrite the transcriptions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
transcription: output of meet-whisperx after transcription and diarization
|
transcription: output of meet-whisperx after transcription and diarization
|
||||||
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
recording_metadata: Metadata of the recording
|
||||||
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
|
||||||
metadata_filename: name of metadata file containing VAD information in S3
|
|
||||||
task_id: current task id, for logging purposes
|
task_id: current task id, for logging purposes
|
||||||
"""
|
"""
|
||||||
recording_start_dt = (
|
|
||||||
datetime.fromisoformat(recording_start_at) if recording_start_at else None
|
|
||||||
)
|
|
||||||
recording_end_dt = (
|
|
||||||
datetime.fromisoformat(recording_end_at) if recording_end_at else None
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"recording_start_dt: %s ; recording_end_dt: %s",
|
"recording_start_dt: %s ; recording_end_dt: %s",
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
recording_metadata.ended_at,
|
||||||
)
|
)
|
||||||
if (recording_start_dt is None) or (recording_end_dt is None):
|
|
||||||
logger.debug("Skipping resolve_speaker_identities")
|
|
||||||
return transcription
|
|
||||||
|
|
||||||
logger.debug("Running resolve_speaker_identities")
|
logger.debug("Running resolve_speaker_identities")
|
||||||
try:
|
try:
|
||||||
metadata = file_service.read_json(metadata_filename)
|
metadata = file_service.read_cloud_storage_json(
|
||||||
|
recording_metadata.cloud_storage_url
|
||||||
|
)
|
||||||
speaker_mapping = resolve_speaker_identities(
|
speaker_mapping = resolve_speaker_identities(
|
||||||
metadata,
|
metadata,
|
||||||
transcription,
|
transcription.model_dump(),
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
recording_metadata.ended_at,
|
||||||
)
|
)
|
||||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||||
return new_transcription
|
return WhisperXResponse.model_validate(new_transcription)
|
||||||
|
|
||||||
except FileServiceException as exc:
|
except FileServiceException as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -272,12 +250,9 @@ def format_transcript(
|
|||||||
transcription,
|
transcription,
|
||||||
context_language: str | None,
|
context_language: str | None,
|
||||||
language: str,
|
language: str,
|
||||||
room: str | None,
|
|
||||||
recording_datetime: str | None,
|
|
||||||
owner_timezone: str | None,
|
|
||||||
download_link: str | None,
|
download_link: str | None,
|
||||||
form_link: str | None,
|
form_link: str | None,
|
||||||
) -> tuple[str, 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
|
||||||
@@ -290,9 +265,6 @@ def format_transcript(
|
|||||||
|
|
||||||
return formatter.format(
|
return formatter.format(
|
||||||
transcription,
|
transcription,
|
||||||
room=room,
|
|
||||||
recording_datetime=recording_datetime,
|
|
||||||
owner_timezone=owner_timezone,
|
|
||||||
download_link=download_link,
|
download_link=download_link,
|
||||||
form_link=form_link,
|
form_link=form_link,
|
||||||
)
|
)
|
||||||
@@ -317,7 +289,7 @@ def format_actions(llm_output: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def summarize_transcription_internals(
|
def summarize_transcription_internals(
|
||||||
*, owner_id: str, transcript: str, session_id: str
|
*, distinct_id: str, transcript: str, session_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a summary from the provided transcription text.
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
@@ -328,11 +300,11 @@ def summarize_transcription_internals(
|
|||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting summarization task | Owner: %s",
|
"Starting summarization task | Owner: %s",
|
||||||
owner_id,
|
distinct_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_has_tracing_consent = analytics.is_feature_enabled(
|
user_has_tracing_consent = analytics.is_feature_enabled(
|
||||||
"summary-tracing-consent", distinct_id=owner_id
|
"summary-tracing-consent", distinct_id=distinct_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
||||||
@@ -343,7 +315,7 @@ def summarize_transcription_internals(
|
|||||||
llm_observability = LLMObservability(
|
llm_observability = LLMObservability(
|
||||||
user_has_tracing_consent=user_has_tracing_consent,
|
user_has_tracing_consent=user_has_tracing_consent,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=owner_id,
|
user_id=distinct_id,
|
||||||
)
|
)
|
||||||
llm_service = LLMService(llm_observability=llm_observability)
|
llm_service = LLMService(llm_observability=llm_observability)
|
||||||
|
|
||||||
@@ -400,6 +372,52 @@ def summarize_transcription_internals(
|
|||||||
##################################################################################
|
##################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
def _should_push_to_docs(
|
||||||
|
payload: TranscribeTaskJob | SummarizeTaskJob,
|
||||||
|
) -> bool:
|
||||||
|
"""Determines if the transcription should be pushed to docs.
|
||||||
|
|
||||||
|
Based on the payload and settings.
|
||||||
|
"""
|
||||||
|
if not payload.push_to_docs_config:
|
||||||
|
reason = "Push to docs is not requested in the payload"
|
||||||
|
elif not settings.is_lasuite_docs_integration_enabled:
|
||||||
|
reason = "Docs integration is disabled"
|
||||||
|
elif not settings.get_authorized_tenant(
|
||||||
|
tenant_id=payload.tenant_id
|
||||||
|
).allowed_push_to_docs:
|
||||||
|
reason = "Tenant is not allowed to push to docs"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Push to docs is not requested: %s", reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _should_auto_create_summary(payload: TranscribeTaskJob) -> bool:
|
||||||
|
"""Determines if the transcription should have an auto-created summary.
|
||||||
|
|
||||||
|
Based on the payload and settings.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
payload.push_to_docs_config is None
|
||||||
|
or not payload.push_to_docs_config.auto_create_summary
|
||||||
|
):
|
||||||
|
reason = "Auto create summary is not requested in the payload"
|
||||||
|
elif not settings.is_summary_enabled:
|
||||||
|
reason = "Summary feature is disabled"
|
||||||
|
elif not analytics.is_feature_enabled(
|
||||||
|
"summary-enabled",
|
||||||
|
distinct_id=payload.user_sub,
|
||||||
|
):
|
||||||
|
reason = "Summary feature flag return false"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Auto create summary is not requested: %s", reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
queue=settings.call_webhook_queue_v2,
|
queue=settings.call_webhook_queue_v2,
|
||||||
@@ -436,7 +454,7 @@ def process_audio_transcribe_v2_task(
|
|||||||
self: Celery task instance (passed on with bind=True)
|
self: Celery task instance (passed on with bind=True)
|
||||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||||
"""
|
"""
|
||||||
payload = TranscribeTaskV2Payload.model_validate(payload)
|
payload = TranscribeTaskJob.model_validate(payload)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Transcribing for object received | Owner: %s",
|
"Transcribing for object received | Owner: %s",
|
||||||
payload.user_sub,
|
payload.user_sub,
|
||||||
@@ -463,6 +481,60 @@ def process_audio_transcribe_v2_task(
|
|||||||
)
|
)
|
||||||
return failure_payload.model_dump()
|
return failure_payload.model_dump()
|
||||||
|
|
||||||
|
# Assign speakers and rewrite transcription/diarization output
|
||||||
|
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
|
||||||
|
try:
|
||||||
|
transcription_res = resolve_speaker_identities_and_apply_to(
|
||||||
|
transcription=transcription_res,
|
||||||
|
recording_metadata=payload.metadata,
|
||||||
|
task_id=job_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
|
||||||
|
|
||||||
|
should_push_to_docs = _should_push_to_docs(payload)
|
||||||
|
# We do it synchronously for now
|
||||||
|
if should_push_to_docs:
|
||||||
|
if payload.push_to_docs_config is None:
|
||||||
|
raise ValueError("Push to docs config is missing")
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
content = format_transcript(
|
||||||
|
transcription_res.model_dump(),
|
||||||
|
payload.context_language,
|
||||||
|
payload.language,
|
||||||
|
payload.push_to_docs_config.download_link,
|
||||||
|
payload.push_to_docs_config.form_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_document_in_lasuite_docs(
|
||||||
|
content=content,
|
||||||
|
title=payload.push_to_docs_config.title,
|
||||||
|
email=payload.push_to_docs_config.user_email,
|
||||||
|
sub=payload.user_sub,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _should_auto_create_summary(payload):
|
||||||
|
locale = get_locale(payload.context_language, payload.language)
|
||||||
|
|
||||||
|
summarize_v2_task.apply_async(
|
||||||
|
args=[
|
||||||
|
SummarizeTaskJob(
|
||||||
|
received_at=datetime.now(timezone.utc),
|
||||||
|
tenant_id=payload.tenant_id,
|
||||||
|
user_sub=payload.user_sub,
|
||||||
|
user_email=payload.user_email,
|
||||||
|
push_to_docs_config=PushToDocsBaseConfig(
|
||||||
|
user_email=payload.push_to_docs_config.user_email,
|
||||||
|
title=locale.summary_title_template.format(
|
||||||
|
title=payload.push_to_docs_config.title
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content=content,
|
||||||
|
).model_dump()
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
file_service.store_transcript(
|
file_service.store_transcript(
|
||||||
transcript=transcription_res,
|
transcript=transcription_res,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -475,9 +547,30 @@ def process_audio_transcribe_v2_task(
|
|||||||
call_webhook_v2_task.apply_async(
|
call_webhook_v2_task.apply_async(
|
||||||
args=[success_payload.model_dump(), payload.tenant_id]
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
)
|
)
|
||||||
|
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||||
|
|
||||||
return success_payload.model_dump()
|
return success_payload.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||||
|
"""Signal handler called before task execution begins."""
|
||||||
|
if args:
|
||||||
|
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution retries."""
|
||||||
|
metadata_manager.retry(request.id)
|
||||||
|
|
||||||
|
|
||||||
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
|
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||||
|
"""Signal handler called when task execution fails permanently."""
|
||||||
|
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||||
|
|
||||||
|
|
||||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
def handle_transcribe_v2_failed(
|
def handle_transcribe_v2_failed(
|
||||||
sender,
|
sender,
|
||||||
@@ -533,15 +626,26 @@ def summarize_v2_task(
|
|||||||
1. Run summary internals
|
1. Run summary internals
|
||||||
2. Sends the final summary via webhook.
|
2. Sends the final summary via webhook.
|
||||||
"""
|
"""
|
||||||
payload = SummarizeTaskV2Payload.model_validate(payload)
|
payload = SummarizeTaskJob.model_validate(payload)
|
||||||
summary = summarize_transcription_internals(
|
summary = summarize_transcription_internals(
|
||||||
owner_id=payload.user_sub,
|
distinct_id=payload.user_sub,
|
||||||
transcript=payload.content,
|
transcript=payload.content,
|
||||||
session_id=self.request.id,
|
session_id=self.request.id,
|
||||||
)
|
)
|
||||||
job_id = self.request.id
|
job_id = self.request.id
|
||||||
file_service.store_summary(summary=summary, job_id=job_id)
|
file_service.store_summary(summary=summary, job_id=job_id)
|
||||||
|
|
||||||
|
if _should_push_to_docs(payload):
|
||||||
|
if payload.push_to_docs_config is None:
|
||||||
|
raise ValueError("Push to docs config is missing")
|
||||||
|
|
||||||
|
create_document_in_lasuite_docs(
|
||||||
|
content=summary,
|
||||||
|
title=payload.push_to_docs_config.title,
|
||||||
|
email=payload.push_to_docs_config.user_email,
|
||||||
|
sub=payload.user_sub,
|
||||||
|
)
|
||||||
|
|
||||||
success_payload = SummarizeWebhookSuccessPayload(
|
success_payload = SummarizeWebhookSuccessPayload(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Application configuration and settings."""
|
"""Application configuration and settings."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from functools import cached_property, lru_cache
|
from functools import cached_property, lru_cache
|
||||||
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
from typing import Annotated, List, Mapping, Optional, Set
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
@@ -34,6 +33,12 @@ class AuthorizedTenant(BaseModel):
|
|||||||
title="Webhook API Key",
|
title="Webhook API Key",
|
||||||
description="The api_key to authenticate the webhook request.",
|
description="The api_key to authenticate the webhook request.",
|
||||||
)
|
)
|
||||||
|
allowed_push_to_docs: bool = Field(
|
||||||
|
title="Allow Push to Docs",
|
||||||
|
description="Whether to allow pushing transcript"
|
||||||
|
" and summaries to docs for this tenant.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -83,7 +88,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# AI-related settings
|
# AI-related settings
|
||||||
whisperx_api_key: SecretStr
|
whisperx_api_key: SecretStr
|
||||||
whisperx_base_url: str = "https://api.openai.com/v1"
|
whisperx_base_url: Url = "https://api.openai.com/v1"
|
||||||
whisperx_asr_model: str = "whisper-1"
|
whisperx_asr_model: str = "whisper-1"
|
||||||
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
||||||
whisperx_default_language: Optional[str] = None
|
whisperx_default_language: Optional[str] = None
|
||||||
@@ -105,10 +110,17 @@ 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
|
||||||
|
app_external_user_agent: str = "summary"
|
||||||
|
|
||||||
# Summary related settings
|
# Summary related settings
|
||||||
is_summary_enabled: bool = True
|
is_summary_enabled: bool = True
|
||||||
transcription_satisfaction_form_base_url: Optional[str] = None
|
|
||||||
|
# Docs service configuration
|
||||||
|
is_lasuite_docs_integration_enabled: bool = False
|
||||||
|
lasuite_docs_base_url: Url = "https://example.com"
|
||||||
|
lasuite_docs_server_to_server_api_key: SecretStr = Field(
|
||||||
|
title="API key for using docs server to server api", default="NO_API_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
sentry_is_enabled: bool = False
|
sentry_is_enabled: bool = False
|
||||||
@@ -120,6 +132,7 @@ class Settings(BaseSettings):
|
|||||||
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
||||||
posthog_event_failure: str = "transcript-failure"
|
posthog_event_failure: str = "transcript-failure"
|
||||||
posthog_event_success: str = "transcript-success"
|
posthog_event_success: str = "transcript-success"
|
||||||
|
posthog_event_request: str = "transcript-request"
|
||||||
|
|
||||||
# Langfuse (LLM Observability)
|
# Langfuse (LLM Observability)
|
||||||
langfuse_enabled: bool = False
|
langfuse_enabled: bool = False
|
||||||
@@ -148,6 +161,25 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("Duplicate application API api_keys are not allowed")
|
raise ValueError("Duplicate application API api_keys are not allowed")
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_docs_config(self):
|
||||||
|
"""Validate docs integration configuration."""
|
||||||
|
if not self.is_lasuite_docs_integration_enabled:
|
||||||
|
return self
|
||||||
|
|
||||||
|
if not self.lasuite_docs_base_url:
|
||||||
|
raise ValueError(
|
||||||
|
"lasuite_docs_base_url is required when docs integration is enabled"
|
||||||
|
)
|
||||||
|
if self.lasuite_docs_server_to_server_api_key.get_secret_value() in {
|
||||||
|
"",
|
||||||
|
"NO_API_KEY",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
"Valid lasuite_docs_server_to_server_api_key is required when"
|
||||||
|
" docs integration is enabled"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Service for delivering content to external destinations."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from requests import Session
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util import Retry
|
||||||
|
|
||||||
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_retry_session(api_key: str | None = None):
|
||||||
|
"""Create an HTTP session configured with retry logic."""
|
||||||
|
session = Session()
|
||||||
|
retries = Retry(
|
||||||
|
total=settings.webhook_max_retries,
|
||||||
|
backoff_factor=settings.webhook_backoff_factor,
|
||||||
|
status_forcelist=settings.webhook_status_forcelist,
|
||||||
|
allowed_methods={"POST"},
|
||||||
|
)
|
||||||
|
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||||
|
if api_key:
|
||||||
|
session.headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": settings.app_external_user_agent,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||||
|
"""Send POST request with automatic retries."""
|
||||||
|
session = _create_retry_session(api_key=api_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.post(url, json=data, timeout=(20, 3 * 60))
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def create_document_in_lasuite_docs(
|
||||||
|
*, content: str, title: str, email: str, sub: str
|
||||||
|
) -> None:
|
||||||
|
"""Call the Docs API to create a document on behalf of the user there.
|
||||||
|
|
||||||
|
Builds the payload, sends it with retries, and logs the outcome.
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"email": email,
|
||||||
|
"sub": sub,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Submitting document to docs endpoint: %s", settings.lasuite_docs_base_url
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Docs payload metadata | title_len=%s content_len=%s has_email=%s has_sub=%s",
|
||||||
|
len(title),
|
||||||
|
len(content),
|
||||||
|
bool(email),
|
||||||
|
bool(sub),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _post_with_retries(
|
||||||
|
url=urljoin(
|
||||||
|
settings.lasuite_docs_base_url, "/api/v1.0/documents/create-for-owner/"
|
||||||
|
),
|
||||||
|
api_key=settings.lasuite_docs_server_to_server_api_key.get_secret_value(),
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = response.json()
|
||||||
|
document_id = response_data.get("id", "N/A")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
document_id = "Unable to parse response"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Delivery success | Document %s submitted (HTTP %s)",
|
||||||
|
document_id,
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
logger.debug("Docs response received (body omitted)")
|
||||||
@@ -30,8 +30,5 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
|||||||
"(externer Link)]({form_link})*\n"
|
"(externer Link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
||||||
document_default_title="Transkription",
|
summary_title_template="Zusammenfassung von {title}",
|
||||||
document_title_template=(
|
|
||||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,5 @@ A few things we recommend you check:
|
|||||||
"(external link)]({form_link})*\n"
|
"(external link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Unable to transcribe text]",
|
hallucination_replacement_text="[Unable to transcribe text]",
|
||||||
document_default_title="Transcription",
|
summary_title_template="Summary of {title}",
|
||||||
document_title_template=(
|
|
||||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,8 +29,5 @@ Quelques points que nous vous conseillons de vérifier :
|
|||||||
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Texte impossible à transcrire]",
|
hallucination_replacement_text="[Texte impossible à transcrire]",
|
||||||
document_default_title="Transcription",
|
summary_title_template="Résumé de {title}",
|
||||||
document_title_template=(
|
|
||||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,8 +30,5 @@ Een paar punten die wij u aanraden te controleren:
|
|||||||
"(externe link)]({form_link})*\n"
|
"(externe link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
||||||
document_default_title="Transcriptie",
|
summary_title_template="Samenvatting van {title}",
|
||||||
document_title_template=(
|
|
||||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ class LocaleStrings:
|
|||||||
download_header_template: str
|
download_header_template: str
|
||||||
form_footer_template: str
|
form_footer_template: str
|
||||||
hallucination_replacement_text: str
|
hallucination_replacement_text: str
|
||||||
document_default_title: str
|
summary_title_template: str
|
||||||
document_title_template: str
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Models for the API & Celery tasks creation."""
|
"""Models for the API & Celery tasks creation."""
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.types import Url
|
from summary.core.types import Url
|
||||||
@@ -12,10 +14,60 @@ class SharedV2TaskCreation(BaseModel):
|
|||||||
"""Model that holds basic information for task creation."""
|
"""Model that holds basic information for task creation."""
|
||||||
|
|
||||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||||
|
user_email: EmailStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="User Email",
|
||||||
|
description="The user's email for analytics purposes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
class RecordingMetadata(BaseModel):
|
||||||
"""Model for creating a transcribe and summarize task (used for API request)."""
|
"""Model for recording metadata."""
|
||||||
|
|
||||||
|
cloud_storage_url: Url = Field(
|
||||||
|
title="Cloud Storage URL",
|
||||||
|
description="The URL of the metadata file for speaker assignement.",
|
||||||
|
)
|
||||||
|
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||||
|
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsBaseConfig(BaseModel):
|
||||||
|
"""Model containing information for pushing transcript and summaries to docs."""
|
||||||
|
|
||||||
|
user_email: EmailStr = Field(
|
||||||
|
title="User Email", description="The user's email, future owner of the docs."
|
||||||
|
)
|
||||||
|
title: str = Field(title="Title", description="The title for the created document.")
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
|
||||||
|
"""Model for push to docs information for transcripts."""
|
||||||
|
|
||||||
|
download_link: Url | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Download Link",
|
||||||
|
description="The link to download the recording.",
|
||||||
|
)
|
||||||
|
form_link: Url | None = Field(
|
||||||
|
default=None,
|
||||||
|
title="Form Link",
|
||||||
|
description="The link to fill out a form for the recording.",
|
||||||
|
)
|
||||||
|
auto_create_summary: bool = Field(
|
||||||
|
title="Auto Create Summary Docs",
|
||||||
|
description="Whether to automatically create a summary "
|
||||||
|
"for the transcription task and push it to docs.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PushToDocsSummaryConfig(PushToDocsBaseConfig):
|
||||||
|
"""Model for push to docs information for summaries."""
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
||||||
|
"""Model for creating a transcribe task (used for API request)."""
|
||||||
|
|
||||||
cloud_storage_url: Url = Field(
|
cloud_storage_url: Url = Field(
|
||||||
title="Cloud storage URL",
|
title="Cloud storage URL",
|
||||||
@@ -27,7 +79,17 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
|||||||
description="The language of the context text.",
|
description="The language of the context text.",
|
||||||
)
|
)
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
title="Language", description="The language of the content to summarize."
|
title="Language", description="The language of the content to transcribe."
|
||||||
|
)
|
||||||
|
metadata: RecordingMetadata | None = Field(
|
||||||
|
title="Metadata",
|
||||||
|
description="The metadata for the transcribe task.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
push_to_docs_config: PushToDocsTranscriptConfig | None = Field(
|
||||||
|
title="Push to Docs info",
|
||||||
|
description="If set, configuration for pushing to docs",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator("language")
|
@field_validator("language")
|
||||||
@@ -42,19 +104,30 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
|
class TranscribeTaskJob(TranscribeTaskApiRequest):
|
||||||
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
|
"""Model for creating a transcribe task (used for actual task creation)."""
|
||||||
|
|
||||||
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
|
received_at: datetime = Field(
|
||||||
|
title="Received At", description="The time the task was received."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SummarizeTaskV2Request(SharedV2TaskCreation):
|
class SummarizeTaskApiRequest(SharedV2TaskCreation):
|
||||||
"""Model for creating a summarize task (used for API request)."""
|
"""Model for creating a summarize task (used for API request)."""
|
||||||
|
|
||||||
content: str = Field(title="Content", description="The content to summarize.")
|
content: str = Field(title="Content", description="The content to summarize.")
|
||||||
|
|
||||||
|
|
||||||
class SummarizeTaskV2Payload(SummarizeTaskV2Request):
|
class SummarizeTaskJob(SummarizeTaskApiRequest):
|
||||||
"""Model for creating a summarize task (used for actual task creation)."""
|
"""Model for creating a summarize task (used for actual task creation)."""
|
||||||
|
|
||||||
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
||||||
|
received_at: datetime = Field(
|
||||||
|
title="Received At", description="The time the task was received."
|
||||||
|
)
|
||||||
|
push_to_docs_config: PushToDocsBaseConfig | None = Field(
|
||||||
|
title="Push to Docs info",
|
||||||
|
description="If set, configuration for pushing to docs",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -167,4 +167,5 @@ __all__ = [
|
|||||||
"SummarizeWebhookPayloads",
|
"SummarizeWebhookPayloads",
|
||||||
"WebhookPayloads",
|
"WebhookPayloads",
|
||||||
"WhisperXResponse",
|
"WhisperXResponse",
|
||||||
|
"webhook_payload_adapter",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Transcript formatting into readable conversation format with speaker labels."""
|
"""Transcript formatting into readable conversation format with speaker labels."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Tuple
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.locales import LocaleStrings
|
from summary.core.locales import LocaleStrings
|
||||||
@@ -38,16 +35,13 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def format( # noqa: PLR0913
|
def format(
|
||||||
self,
|
self,
|
||||||
transcription,
|
transcription,
|
||||||
room: str | None = None,
|
|
||||||
recording_datetime: str | None = None,
|
|
||||||
owner_timezone: str | None = None,
|
|
||||||
download_link: str | None = None,
|
download_link: str | None = None,
|
||||||
form_link: str | None = None,
|
form_link: str | None = None,
|
||||||
) -> Tuple[str, str]:
|
) -> str:
|
||||||
"""Format transcription into the final document and its title."""
|
"""Format transcription into the final document."""
|
||||||
segments = self._get_segments(transcription)
|
segments = self._get_segments(transcription)
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
@@ -59,9 +53,7 @@ class TranscriptFormatter:
|
|||||||
if form_link:
|
if form_link:
|
||||||
content = self._add_footer(content, form_link)
|
content = self._add_footer(content, form_link)
|
||||||
|
|
||||||
title = self._generate_title(room, recording_datetime, owner_timezone)
|
return content
|
||||||
|
|
||||||
return content, title
|
|
||||||
|
|
||||||
def _remove_hallucinations(self, content: str) -> str:
|
def _remove_hallucinations(self, content: str) -> str:
|
||||||
"""Remove hallucination patterns from content."""
|
"""Remove hallucination patterns from content."""
|
||||||
@@ -104,23 +96,3 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
footer = self._locale.form_footer_template.format(form_link=form_link)
|
footer = self._locale.form_footer_template.format(form_link=form_link)
|
||||||
return content + footer
|
return content + footer
|
||||||
|
|
||||||
def _generate_title(
|
|
||||||
self,
|
|
||||||
room: str | None = None,
|
|
||||||
recording_datetime: str | None = None,
|
|
||||||
owner_timezone: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Generate title from context or return default."""
|
|
||||||
if not room or not recording_datetime:
|
|
||||||
return self._locale.document_default_title
|
|
||||||
|
|
||||||
dt = datetime.fromisoformat(recording_datetime)
|
|
||||||
if owner_timezone:
|
|
||||||
dt = dt.astimezone(ZoneInfo(owner_timezone))
|
|
||||||
|
|
||||||
return self._locale.document_title_template.format(
|
|
||||||
room=room,
|
|
||||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
|
||||||
room_recording_time=dt.strftime("%H:%M"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests import Session
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
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 (
|
from summary.core.shared_models import (
|
||||||
@@ -18,83 +15,6 @@ settings = get_settings()
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _create_retry_session(api_key: str | None = None):
|
|
||||||
"""Create an HTTP session configured with retry logic."""
|
|
||||||
session = Session()
|
|
||||||
retries = Retry(
|
|
||||||
total=settings.webhook_max_retries,
|
|
||||||
backoff_factor=settings.webhook_backoff_factor,
|
|
||||||
status_forcelist=settings.webhook_status_forcelist,
|
|
||||||
allowed_methods={"POST"},
|
|
||||||
)
|
|
||||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
|
||||||
if api_key:
|
|
||||||
session.headers.update({"Authorization": f"Bearer {api_key}"})
|
|
||||||
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _post_with_retries(*, url, data, api_key: str | None = None):
|
|
||||||
"""Send POST request with automatic retries."""
|
|
||||||
session = _create_retry_session(api_key=api_key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = session.post(url, json=data)
|
|
||||||
response.raise_for_status()
|
|
||||||
return response
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
|
|
||||||
"""Call webhook with payload a payload and optional token."""
|
|
||||||
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
|
|
||||||
|
|
||||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
|
||||||
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
|
|
||||||
|
|
||||||
response = _post_with_retries(
|
|
||||||
url=tenant.webhook_url,
|
|
||||||
api_key=tenant.webhook_api_key.get_secret_value(),
|
|
||||||
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(
|
def call_webhook_v2(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -114,6 +34,7 @@ def call_webhook_v2(
|
|||||||
json=payload.model_dump(),
|
json=payload.model_dump(),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||||
|
"User-Agent": settings.app_external_user_agent,
|
||||||
},
|
},
|
||||||
timeout=(10, 20),
|
timeout=(10, 20),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Integration tests for the V2 task API endpoints."""
|
"""Integration tests for the V2 task API endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from unittest.mock import ANY, MagicMock, patch
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ class TestTasksV2:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = mock_apply_async.call_args.kwargs["args"]
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
received_at = args[0].pop("received_at")
|
||||||
|
assert isinstance(received_at, datetime)
|
||||||
assert args == [
|
assert args == [
|
||||||
{
|
{
|
||||||
"user_sub": "remote-001",
|
"user_sub": "remote-001",
|
||||||
@@ -38,6 +41,9 @@ class TestTasksV2:
|
|||||||
"language": "en",
|
"language": "en",
|
||||||
"context_language": "fr",
|
"context_language": "fr",
|
||||||
"tenant_id": "test-tenant",
|
"tenant_id": "test-tenant",
|
||||||
|
"user_email": None,
|
||||||
|
"metadata": None,
|
||||||
|
"push_to_docs_config": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -64,11 +70,14 @@ class TestTasksV2:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = mock_apply_async.call_args.kwargs["args"]
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
received_at = args[0].pop("received_at")
|
||||||
|
assert isinstance(received_at, datetime)
|
||||||
assert args == [
|
assert args == [
|
||||||
{
|
{
|
||||||
"user_sub": "remote-002",
|
"user_sub": "remote-002",
|
||||||
"content": "This is a long meeting transcript to summarize.",
|
"content": "This is a long meeting transcript to summarize.",
|
||||||
"tenant_id": "test-tenant",
|
"tenant_id": "test-tenant",
|
||||||
|
"user_email": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user