Add voice analytics filters and metrics

This commit is contained in:
2026-04-26 22:00:34 +08:00
parent 3805c18622
commit 55ca0985eb
25 changed files with 710 additions and 39 deletions

View File

@@ -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 运营摘要。"""

View File

@@ -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)

View File

@@ -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"],

View File

@@ -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",

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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,

View File

@@ -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:

View File

@@ -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:

View 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()

View File

@@ -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

View File

@@ -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()