Add voice analytics filters and metrics
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
@@ -120,7 +122,9 @@ async def list_provider_capabilities():
|
||||
@router.get("/providers/analytics", response_model=ProviderAnalyticsResponse)
|
||||
async def get_provider_analytics(
|
||||
days: int | None = Query(default=None, ge=1, le=365),
|
||||
capability: str | None = Query(default=None),
|
||||
capability: Literal["text", "image", "tts", "storybook", "asr"] | None = Query(
|
||||
default=None
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取当前环境跨用户的 Provider 运营摘要。"""
|
||||
|
||||
@@ -116,11 +116,21 @@ async def get_latest_active_voice_session(
|
||||
@router.get("/voice-sessions/analytics", response_model=VoiceSessionAnalyticsResponse)
|
||||
async def get_voice_session_analytics(
|
||||
days: int | None = Query(default=30, ge=1, le=365),
|
||||
provider: str | None = Query(default=None, min_length=1, max_length=64),
|
||||
session_status: (
|
||||
Literal["draft", "active", "waiting_user", "completed", "abandoned"] | None
|
||||
) = Query(default=None),
|
||||
user: User = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get aggregate voice co-creation analytics for the current user."""
|
||||
return await get_voice_session_analytics_service(user.id, db, days=days)
|
||||
return await get_voice_session_analytics_service(
|
||||
user.id,
|
||||
db,
|
||||
days=days,
|
||||
provider=provider,
|
||||
session_status=session_status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/voice-sessions/{session_id}", response_model=VoiceSessionDetailResponse)
|
||||
|
||||
@@ -34,6 +34,14 @@ else:
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
imports=(
|
||||
"app.tasks.achievements",
|
||||
"app.tasks.audio_cache",
|
||||
"app.tasks.generation_maintenance",
|
||||
"app.tasks.generation_workflow",
|
||||
"app.tasks.memory",
|
||||
"app.tasks.push_notifications",
|
||||
),
|
||||
task_track_started=True,
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
|
||||
@@ -73,7 +73,10 @@ class Settings(BaseSettings):
|
||||
)
|
||||
voice_transcription_mode: str = Field(
|
||||
"provider",
|
||||
description="Voice transcription mode: provider or disabled; provider order is controlled by ASR_PROVIDERS",
|
||||
description=(
|
||||
"Voice transcription mode: provider or disabled; provider order is "
|
||||
"controlled by ASR_PROVIDERS"
|
||||
),
|
||||
)
|
||||
voice_transcription_model: str = Field(
|
||||
"gpt-4o-mini-transcribe",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -12,6 +12,10 @@ def _uuid() -> str:
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Provider(Base):
|
||||
"""Model provider registry."""
|
||||
|
||||
@@ -34,9 +38,9 @@ class Provider(Base):
|
||||
nullable=True,
|
||||
) # 存储额外配置(speed, vol, etc)
|
||||
config_ref: Mapped[str] = mapped_column(String(100), nullable=True) # 环境变量 key 名称(回退)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
updated_by: Mapped[str] = mapped_column(String(100), nullable=True)
|
||||
|
||||
@@ -51,7 +55,7 @@ class ProviderMetrics(Base):
|
||||
String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, index=True
|
||||
DateTime(timezone=True), default=_utcnow, index=True
|
||||
)
|
||||
success: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||
latency_ms: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
@@ -82,9 +86,9 @@ class ProviderSecret(Base):
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
encrypted_value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
|
||||
@@ -97,10 +101,10 @@ class CostRecord(Base):
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
provider_id: Mapped[str] = mapped_column(String(36), nullable=True) # 可能是环境变量配置
|
||||
provider_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
capability: Mapped[str] = mapped_column(String(50), nullable=False) # text/image/tts/storybook/asr
|
||||
capability: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
estimated_cost: Mapped[Decimal] = mapped_column(Numeric(10, 6), nullable=False)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, index=True
|
||||
DateTime(timezone=True), default=_utcnow, index=True
|
||||
)
|
||||
|
||||
|
||||
@@ -116,7 +120,7 @@ class UserBudget(Base):
|
||||
Numeric(3, 2), default=Decimal("0.8")
|
||||
) # 80% 时告警
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from app.core.config import settings
|
||||
|
||||
_engine = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
_lock = threading.Lock()
|
||||
_lock = threading.RLock()
|
||||
|
||||
|
||||
def _get_engine():
|
||||
@@ -34,6 +34,25 @@ def _get_session_factory():
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def dispose_engine():
|
||||
"""Dispose the async engine and reset cached DB handles.
|
||||
|
||||
Celery tasks run async code through ``asyncio.run()``, which creates and closes
|
||||
one event loop per task. Asyncpg connections are bound to the loop that created
|
||||
them, so worker tasks must not keep pooled connections across task runs.
|
||||
"""
|
||||
global _engine, _session_factory
|
||||
|
||||
engine = _engine
|
||||
if engine is not None:
|
||||
await engine.dispose()
|
||||
|
||||
with _lock:
|
||||
if _engine is engine:
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Create tables if they do not exist."""
|
||||
from app.db.models import Base # main models
|
||||
|
||||
@@ -77,6 +77,7 @@ class VoiceTurnSummaryResponse(BaseModel):
|
||||
user_transcript: str | None = None
|
||||
transcript_confidence: float | None = None
|
||||
transcription_provider: str | None = None
|
||||
user_audio_duration_ms: int | None = None
|
||||
detected_intent: str
|
||||
intent_confidence: float | None = None
|
||||
understanding_summary: str | None = None
|
||||
@@ -88,6 +89,7 @@ class VoiceTurnSummaryResponse(BaseModel):
|
||||
safety_blocked: bool = False
|
||||
safety_message: str | None = None
|
||||
assistant_text: str | None = None
|
||||
assistant_audio_duration_ms: int | None = None
|
||||
assistant_audio_ready: bool = False
|
||||
assistant_audio_url: str | None = None
|
||||
user_audio_ready: bool = False
|
||||
@@ -149,6 +151,8 @@ class VoiceSessionAnalyticsResponse(BaseModel):
|
||||
"""Aggregated voice co-creation analytics for one user."""
|
||||
|
||||
window_days: int | None = None
|
||||
provider: str | None = None
|
||||
session_status: str | None = None
|
||||
total_sessions: int = 0
|
||||
attention_sessions: int = 0
|
||||
confirmation_attention_sessions: int = 0
|
||||
@@ -164,6 +168,24 @@ class VoiceSessionAnalyticsResponse(BaseModel):
|
||||
tts_failures: int = 0
|
||||
low_confidence_turns: int = 0
|
||||
safety_interventions: int = 0
|
||||
text_fallback_turns: int = 0
|
||||
uploaded_audio_turns: int = 0
|
||||
user_audio_turn_rate: float = 0.0
|
||||
assistant_audio_ready_turns: int = 0
|
||||
assistant_audio_ready_rate: float = 0.0
|
||||
asr_success_rate: float = 0.0
|
||||
tts_success_rate: float = 0.0
|
||||
avg_transcript_confidence: float = 0.0
|
||||
avg_intent_confidence: float = 0.0
|
||||
safety_intervention_rate: float = 0.0
|
||||
failure_event_counts: dict[str, int] = Field(default_factory=dict)
|
||||
total_user_audio_duration_ms: int = 0
|
||||
avg_user_audio_duration_ms: float = 0.0
|
||||
total_assistant_audio_turns: int = 0
|
||||
total_assistant_audio_duration_ms: int = 0
|
||||
avg_assistant_audio_duration_ms: float = 0.0
|
||||
transcription_provider_counts: dict[str, int] = Field(default_factory=dict)
|
||||
confirmation_request_rate: float = 0.0
|
||||
turn_success_rate: float = 0.0
|
||||
finalize_conversion_rate: float = 0.0
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
# Demo adapters
|
||||
from app.services.adapters import demo as _demo_adapters # noqa: F401
|
||||
|
||||
# ASR adapters
|
||||
from app.services.adapters.asr import demo as _asr_demo_adapter # noqa: F401
|
||||
from app.services.adapters.asr import openai as _asr_openai_adapter # noqa: F401
|
||||
from app.services.adapters.base import AdapterConfig, BaseAdapter
|
||||
|
||||
# ASR adapters
|
||||
from app.services.adapters.asr import demo as _asr_demo_adapter # noqa: F401
|
||||
from app.services.adapters.asr import openai as _asr_openai_adapter # noqa: F401
|
||||
|
||||
# Image adapters
|
||||
# Image adapters
|
||||
from app.services.adapters.image import cqtai as _image_cqtai_adapter # noqa: F401
|
||||
from app.services.adapters.registry import AdapterRegistry
|
||||
|
||||
|
||||
@@ -335,6 +335,7 @@ def _turn_to_summary(turn: VoiceTurn) -> VoiceTurnSummaryResponse:
|
||||
user_transcript=turn.user_transcript,
|
||||
transcript_confidence=turn.transcript_confidence,
|
||||
transcription_provider=turn_patch.get("transcription_provider"),
|
||||
user_audio_duration_ms=turn.user_audio_duration_ms,
|
||||
detected_intent=turn.detected_intent,
|
||||
intent_confidence=turn.intent_confidence,
|
||||
understanding_summary=confirmation_state["understanding_summary"],
|
||||
@@ -346,6 +347,7 @@ def _turn_to_summary(turn: VoiceTurn) -> VoiceTurnSummaryResponse:
|
||||
safety_blocked=safety_state["safety_blocked"],
|
||||
safety_message=safety_state["safety_message"],
|
||||
assistant_text=turn.assistant_text,
|
||||
assistant_audio_duration_ms=turn.assistant_audio_duration_ms,
|
||||
assistant_audio_ready=session_audio_exists(turn.assistant_audio_path),
|
||||
assistant_audio_url=_assistant_audio_url(
|
||||
turn.session_id,
|
||||
@@ -1194,10 +1196,14 @@ async def get_voice_session_analytics_service(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
days: int | None = 30,
|
||||
provider: str | None = None,
|
||||
session_status: str | None = None,
|
||||
) -> VoiceSessionAnalyticsResponse:
|
||||
cutoff = None
|
||||
if days is not None:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
provider_filter = (provider or "").strip() or None
|
||||
session_status_filter = (session_status or "").strip() or None
|
||||
|
||||
session_query = select(VoiceSession).where(VoiceSession.user_id == user_id)
|
||||
turn_query = (
|
||||
@@ -1215,10 +1221,30 @@ async def get_voice_session_analytics_service(
|
||||
session_query = session_query.where(VoiceSession.created_at >= cutoff)
|
||||
turn_query = turn_query.where(VoiceTurn.created_at >= cutoff)
|
||||
event_query = event_query.where(VoiceSessionEvent.created_at >= cutoff)
|
||||
if session_status_filter is not None:
|
||||
session_query = session_query.where(VoiceSession.status == session_status_filter)
|
||||
turn_query = turn_query.where(VoiceSession.status == session_status_filter)
|
||||
event_query = event_query.where(VoiceSession.status == session_status_filter)
|
||||
|
||||
sessions = (await db.execute(session_query)).scalars().all()
|
||||
turns = (await db.execute(turn_query)).scalars().all()
|
||||
events = (await db.execute(event_query)).scalars().all()
|
||||
if provider_filter is not None:
|
||||
provider_turn_ids = {
|
||||
turn.id
|
||||
for turn in turns
|
||||
if ((turn.story_patch or {}).get("transcription_provider") or "unknown")
|
||||
== provider_filter
|
||||
}
|
||||
provider_session_ids = {turn.session_id for turn in turns if turn.id in provider_turn_ids}
|
||||
sessions = [session for session in sessions if session.id in provider_session_ids]
|
||||
turns = [turn for turn in turns if turn.id in provider_turn_ids]
|
||||
events = [
|
||||
event
|
||||
for event in events
|
||||
if event.turn_id in provider_turn_ids
|
||||
or (event.turn_id is None and event.session_id in provider_session_ids)
|
||||
]
|
||||
session_summaries = [await _build_session_summary(db, session) for session in sessions]
|
||||
|
||||
total_sessions = len(sessions)
|
||||
@@ -1258,6 +1284,36 @@ async def get_voice_session_analytics_service(
|
||||
safety_interventions = sum(
|
||||
1 for event in events if event.event_type == "safety_intervention_requested"
|
||||
)
|
||||
text_fallback_turns = sum(
|
||||
1 for turn in turns if (turn.story_patch or {}).get("transcription_provider") == "fallback"
|
||||
)
|
||||
uploaded_audio_turns = sum(1 for turn in turns if turn.user_audio_path)
|
||||
assistant_audio_ready_turns = sum(
|
||||
1 for turn in turns if session_audio_exists(turn.assistant_audio_path)
|
||||
)
|
||||
user_audio_durations = [
|
||||
duration for turn in turns if (duration := turn.user_audio_duration_ms) is not None
|
||||
]
|
||||
assistant_audio_durations = [
|
||||
duration for turn in turns if (duration := turn.assistant_audio_duration_ms) is not None
|
||||
]
|
||||
total_user_audio_duration_ms = sum(user_audio_durations)
|
||||
total_assistant_audio_duration_ms = sum(assistant_audio_durations)
|
||||
transcription_provider_counts: dict[str, int] = {}
|
||||
for turn in turns:
|
||||
provider = (turn.story_patch or {}).get("transcription_provider") or "unknown"
|
||||
transcription_provider_counts[provider] = transcription_provider_counts.get(provider, 0) + 1
|
||||
failure_event_counts: dict[str, int] = {}
|
||||
for event in events:
|
||||
if event.status != "failed":
|
||||
continue
|
||||
failure_event_counts[event.event_type] = failure_event_counts.get(event.event_type, 0) + 1
|
||||
transcript_confidences = [
|
||||
confidence for turn in turns if (confidence := turn.transcript_confidence) is not None
|
||||
]
|
||||
intent_confidences = [
|
||||
confidence for turn in turns if (confidence := turn.intent_confidence) is not None
|
||||
]
|
||||
|
||||
turn_success_rate = (
|
||||
round(successful_turns / total_turns, 4) if total_turns else 0.0
|
||||
@@ -1265,9 +1321,27 @@ async def get_voice_session_analytics_service(
|
||||
finalize_conversion_rate = (
|
||||
round(finalized_sessions / total_sessions, 4) if total_sessions else 0.0
|
||||
)
|
||||
confirmation_request_rate = (
|
||||
round(low_confidence_turns / total_turns, 4) if total_turns else 0.0
|
||||
)
|
||||
user_audio_turn_rate = round(uploaded_audio_turns / total_turns, 4) if total_turns else 0.0
|
||||
assistant_audio_ready_rate = (
|
||||
round(assistant_audio_ready_turns / successful_turns, 4) if successful_turns else 0.0
|
||||
)
|
||||
asr_attempts = uploaded_audio_turns + asr_failures
|
||||
asr_success_rate = round(uploaded_audio_turns / asr_attempts, 4) if asr_attempts else 0.0
|
||||
tts_attempts = assistant_audio_ready_turns + tts_failures
|
||||
tts_success_rate = (
|
||||
round(assistant_audio_ready_turns / tts_attempts, 4) if tts_attempts else 0.0
|
||||
)
|
||||
safety_intervention_rate = (
|
||||
round(safety_interventions / total_turns, 4) if total_turns else 0.0
|
||||
)
|
||||
|
||||
return VoiceSessionAnalyticsResponse(
|
||||
window_days=days,
|
||||
provider=provider_filter,
|
||||
session_status=session_status_filter,
|
||||
total_sessions=total_sessions,
|
||||
attention_sessions=attention_sessions,
|
||||
confirmation_attention_sessions=confirmation_attention_sessions,
|
||||
@@ -1283,6 +1357,40 @@ async def get_voice_session_analytics_service(
|
||||
tts_failures=tts_failures,
|
||||
low_confidence_turns=low_confidence_turns,
|
||||
safety_interventions=safety_interventions,
|
||||
text_fallback_turns=text_fallback_turns,
|
||||
uploaded_audio_turns=uploaded_audio_turns,
|
||||
user_audio_turn_rate=user_audio_turn_rate,
|
||||
assistant_audio_ready_turns=assistant_audio_ready_turns,
|
||||
assistant_audio_ready_rate=assistant_audio_ready_rate,
|
||||
asr_success_rate=asr_success_rate,
|
||||
tts_success_rate=tts_success_rate,
|
||||
avg_transcript_confidence=(
|
||||
round(sum(transcript_confidences) / len(transcript_confidences), 4)
|
||||
if transcript_confidences
|
||||
else 0.0
|
||||
),
|
||||
avg_intent_confidence=(
|
||||
round(sum(intent_confidences) / len(intent_confidences), 4)
|
||||
if intent_confidences
|
||||
else 0.0
|
||||
),
|
||||
safety_intervention_rate=safety_intervention_rate,
|
||||
failure_event_counts=failure_event_counts,
|
||||
total_user_audio_duration_ms=total_user_audio_duration_ms,
|
||||
avg_user_audio_duration_ms=(
|
||||
round(total_user_audio_duration_ms / len(user_audio_durations), 2)
|
||||
if user_audio_durations
|
||||
else 0.0
|
||||
),
|
||||
total_assistant_audio_turns=len(assistant_audio_durations),
|
||||
total_assistant_audio_duration_ms=total_assistant_audio_duration_ms,
|
||||
avg_assistant_audio_duration_ms=(
|
||||
round(total_assistant_audio_duration_ms / len(assistant_audio_durations), 2)
|
||||
if assistant_audio_durations
|
||||
else 0.0
|
||||
),
|
||||
transcription_provider_counts=transcription_provider_counts,
|
||||
confirmation_request_rate=confirmation_request_rate,
|
||||
turn_success_rate=turn_success_rate,
|
||||
finalize_conversion_rate=finalize_conversion_rate,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.db.models import Story, StoryUniverse
|
||||
from app.services.achievement_extractor import extract_achievements
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -17,7 +18,7 @@ logger = get_logger(__name__)
|
||||
@celery_app.task
|
||||
def extract_story_achievements(story_id: int, universe_id: str) -> None:
|
||||
"""Extract achievements and update universe."""
|
||||
asyncio.run(_extract_story_achievements(story_id, universe_id))
|
||||
asyncio.run(run_with_disposed_engine(_extract_story_achievements(story_id, universe_id)))
|
||||
|
||||
|
||||
async def _extract_story_achievements(story_id: int, universe_id: str) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.services.story_service import prune_story_audio_cache
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,7 +22,7 @@ def prune_story_audio_cache_task():
|
||||
return await prune_story_audio_cache(session)
|
||||
|
||||
try:
|
||||
result = asyncio.run(_run())
|
||||
result = asyncio.run(run_with_disposed_engine(_run()))
|
||||
logger.info("prune_story_audio_cache_task_completed", **result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.services.generation_jobs import mark_stale_generation_jobs
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -22,7 +23,7 @@ def prune_stale_generation_jobs_task():
|
||||
return await mark_stale_generation_jobs(session)
|
||||
|
||||
try:
|
||||
result = asyncio.run(_run())
|
||||
result = asyncio.run(run_with_disposed_engine(_run()))
|
||||
logger.info("prune_stale_generation_jobs_task_completed", **result)
|
||||
return result
|
||||
except Exception as exc:
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.services.story_service import run_generation_job_service
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -22,7 +23,7 @@ def run_generation_workflow_task(job_id: str):
|
||||
return await run_generation_job_service(job_id, session)
|
||||
|
||||
try:
|
||||
result = asyncio.run(_run())
|
||||
result = asyncio.run(run_with_disposed_engine(_run()))
|
||||
logger.info(
|
||||
"generation_workflow_task_completed",
|
||||
job_id=job_id,
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import asyncio
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.services.memory_service import prune_expired_memories
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.services.memory_service import prune_expired_memories
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -21,7 +22,7 @@ def prune_memories_task():
|
||||
|
||||
try:
|
||||
# Create a new event loop for this task execution
|
||||
count = asyncio.run(_run())
|
||||
count = asyncio.run(run_with_disposed_engine(_run()))
|
||||
logger.info("prune_memories_task_completed", deleted_count=count)
|
||||
return f"Deleted {count} expired memories"
|
||||
except Exception as exc:
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.celery_app import celery_app
|
||||
from app.core.logging import get_logger
|
||||
from app.db.database import _get_session_factory
|
||||
from app.db.models import PushConfig, PushEvent
|
||||
from app.tasks.utils import run_with_disposed_engine
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -22,7 +23,7 @@ TRIGGER_WINDOW_MINUTES = 30
|
||||
@celery_app.task
|
||||
def check_push_notifications() -> None:
|
||||
"""Check push configs and create push events."""
|
||||
asyncio.run(_check_push_notifications())
|
||||
asyncio.run(run_with_disposed_engine(_check_push_notifications()))
|
||||
|
||||
|
||||
def _is_quiet_hours(current: time) -> bool:
|
||||
|
||||
17
backend/app/tasks/utils.py
Normal file
17
backend/app/tasks/utils.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Shared helpers for Celery tasks."""
|
||||
|
||||
from collections.abc import Awaitable
|
||||
from typing import TypeVar
|
||||
|
||||
from app.db.database import dispose_engine
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def run_with_disposed_engine(awaitable: Awaitable[T]) -> T:
|
||||
"""Run async task work and drop DB pools before the event loop closes."""
|
||||
|
||||
try:
|
||||
return await awaitable
|
||||
finally:
|
||||
await dispose_engine()
|
||||
@@ -283,3 +283,6 @@ async def test_admin_provider_analytics_support_days_and_capability_filters(
|
||||
assert data["job_count"] == 1
|
||||
assert data["story_count"] == 1
|
||||
assert data["failure_reasons"] == [{"reason": "timeout", "count": 1}]
|
||||
|
||||
response = await client.get("/admin/providers/analytics?capability=unknown")
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -342,6 +342,7 @@ async def test_voice_session_low_confidence_turn_requests_confirmation(
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
data={"duration_ms": "1200"},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
turn_id = response.json()["turn_id"]
|
||||
@@ -431,6 +432,7 @@ async def test_voice_session_confirmation_accept_continues_original_turn(
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
data={"duration_ms": "1200"},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
@@ -503,6 +505,7 @@ async def test_voice_session_confirmation_switch_to_text_allows_follow_up_turn(
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
data={"duration_ms": "1200"},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
|
||||
@@ -647,6 +650,7 @@ async def test_voice_session_analytics_summarize_failures_and_confirmations(
|
||||
files={
|
||||
"audio_file": ("turn.webm", b"fake-webm-audio", "audio/webm"),
|
||||
},
|
||||
data={"duration_ms": "1200"},
|
||||
)
|
||||
turn_id = response.json()["turn_id"]
|
||||
await client.post(
|
||||
@@ -677,6 +681,46 @@ async def test_voice_session_analytics_summarize_failures_and_confirmations(
|
||||
assert analytics["asr_failures"] >= 1
|
||||
assert analytics["finalized_sessions"] >= 1
|
||||
assert analytics["finalize_conversion_rate"] > 0
|
||||
assert analytics["text_fallback_turns"] >= 1
|
||||
assert analytics["uploaded_audio_turns"] >= 1
|
||||
assert analytics["user_audio_turn_rate"] > 0
|
||||
assert analytics["assistant_audio_ready_turns"] >= 1
|
||||
assert analytics["assistant_audio_ready_rate"] > 0
|
||||
assert analytics["asr_success_rate"] > 0
|
||||
assert analytics["tts_success_rate"] > 0
|
||||
assert analytics["avg_transcript_confidence"] > 0
|
||||
assert analytics["avg_intent_confidence"] > 0
|
||||
assert analytics["failure_event_counts"]["turn_transcription_failed"] >= 1
|
||||
assert analytics["failure_event_counts"]["assistant_audio_failed"] >= 1
|
||||
assert analytics["total_user_audio_duration_ms"] >= 1200
|
||||
assert analytics["avg_user_audio_duration_ms"] >= 1200
|
||||
assert analytics["transcription_provider_counts"]["openai"] >= 1
|
||||
assert analytics["transcription_provider_counts"]["fallback"] >= 1
|
||||
assert analytics["confirmation_request_rate"] > 0
|
||||
|
||||
response = await client.get(
|
||||
"/api/voice-sessions/analytics?days=30&provider=openai"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
provider_analytics = response.json()
|
||||
assert provider_analytics["provider"] == "openai"
|
||||
assert provider_analytics["uploaded_audio_turns"] >= 1
|
||||
assert provider_analytics["text_fallback_turns"] == 0
|
||||
assert set(provider_analytics["transcription_provider_counts"]) == {"openai"}
|
||||
|
||||
response = await client.get(
|
||||
"/api/voice-sessions/analytics?days=30&session_status=completed"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
status_analytics = response.json()
|
||||
assert status_analytics["session_status"] == "completed"
|
||||
assert status_analytics["total_sessions"] >= 1
|
||||
assert status_analytics["finalized_sessions"] >= 1
|
||||
|
||||
response = await client.get(
|
||||
"/api/voice-sessions/analytics?days=30&session_status=unknown"
|
||||
)
|
||||
assert response.status_code == 422
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user