mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
Co-authored-by: !潤弘資訊熊冠詠 <RT025232@rtc.ruentex.corp> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+76
-24
No files matched your search
@@ -2,12 +2,14 @@ import logging
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import TextToAudioPayload
|
||||
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import (
|
||||
@@ -26,7 +28,6 @@ from controllers.service_api.schema import binary_response, expect_with_user, mu
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
from core.base.tts.audio_mime import SUPPORTED_TTS_AUDIO_MIME_TYPES
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import dump_response
|
||||
from models.model import App, EndUser
|
||||
@@ -98,7 +99,8 @@ class AudioApi(Resource):
|
||||
service_api_ns.models[AudioTranscriptResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.FORM))
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, app_model: App, end_user: EndUser):
|
||||
"""Convert audio to text using speech-to-text.
|
||||
|
||||
Accepts an audio file upload and returns the transcribed text.
|
||||
@@ -109,7 +111,7 @@ class AudioApi(Resource):
|
||||
response = AudioService.transcript_asr(
|
||||
app_model=app_model,
|
||||
file=file,
|
||||
session=db.session(),
|
||||
session=session,
|
||||
end_user=end_user.id,
|
||||
)
|
||||
|
||||
@@ -182,8 +184,9 @@ class TextApi(Resource):
|
||||
# TTS returns provider audio bytes, so the success response is intentionally schema-less.
|
||||
@service_api_ns.response(200, "Text successfully converted to audio")
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
|
||||
@with_session(write=False)
|
||||
@model_validate(TextToAudioPayload)
|
||||
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
|
||||
def post(self, payload: TextToAudioPayload, session: Session, app_model: App, end_user: EndUser):
|
||||
"""Convert text to audio using text-to-speech.
|
||||
|
||||
Converts the provided text to audio using the specified voice.
|
||||
@@ -202,7 +205,7 @@ class TextApi(Resource):
|
||||
)
|
||||
return AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
session=session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
end_user=end_user.external_user_id,
|
||||
|
||||
@@ -2,10 +2,12 @@ import logging
|
||||
|
||||
from flask import request
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
@@ -22,7 +24,6 @@ from controllers.web.error import (
|
||||
)
|
||||
from controllers.web.wraps import WebApiResource
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import dump_response, uuid_value
|
||||
@@ -75,7 +76,8 @@ class AudioApi(WebApiResource):
|
||||
}
|
||||
)
|
||||
@web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, app_model: App, end_user: EndUser):
|
||||
"""Convert audio to text"""
|
||||
file = request.files.get("file")
|
||||
|
||||
@@ -83,7 +85,7 @@ class AudioApi(WebApiResource):
|
||||
response = AudioService.transcript_asr(
|
||||
app_model=app_model,
|
||||
file=file,
|
||||
session=db.session(),
|
||||
session=session,
|
||||
end_user=end_user.external_user_id,
|
||||
)
|
||||
|
||||
@@ -132,8 +134,9 @@ class TextApi(WebApiResource):
|
||||
)
|
||||
# response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized.
|
||||
@web_ns.response(200, "Success")
|
||||
@with_session(write=False)
|
||||
@model_validate(TextToAudioPayload)
|
||||
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
|
||||
def post(self, payload: TextToAudioPayload, session: Session, app_model: App, end_user: EndUser):
|
||||
"""Convert text to audio"""
|
||||
try:
|
||||
message_id = payload.message_id
|
||||
@@ -149,7 +152,7 @@ class TextApi(WebApiResource):
|
||||
)
|
||||
return AudioService.transcript_tts(
|
||||
app_model=app_model,
|
||||
session=db.session(),
|
||||
session=session,
|
||||
text=text,
|
||||
voice=voice,
|
||||
end_user=end_user.external_user_id,
|
||||
|
||||
@@ -210,17 +210,24 @@ class TestAudioServiceMockedBehavior:
|
||||
|
||||
|
||||
class TestAudioApi:
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: {"text": "ok"})
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_transcript_asr(**kwargs: object) -> dict[str, str]:
|
||||
calls.update(kwargs)
|
||||
return {"text": "ok"}
|
||||
|
||||
monkeypatch.setattr(AudioService, "transcript_asr", fake_transcript_asr)
|
||||
api = AudioApi()
|
||||
handler = unwrap(api.post)
|
||||
app_model = _app()
|
||||
end_user = _end_user()
|
||||
|
||||
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
|
||||
response = handler(api, app_model=app_model, end_user=end_user)
|
||||
response = handler(api, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
assert response == {"text": "ok"}
|
||||
assert calls["session"] is sqlite_session
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exc", "expected"),
|
||||
@@ -237,7 +244,14 @@ class TestAudioApi:
|
||||
(InvokeError("invoke"), CompletionRequestError),
|
||||
],
|
||||
)
|
||||
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch, exc, expected) -> None:
|
||||
def test_error_mapping(
|
||||
self,
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_session: Session,
|
||||
exc: Exception,
|
||||
expected: type[Exception],
|
||||
) -> None:
|
||||
monkeypatch.setattr(AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(exc))
|
||||
api = AudioApi()
|
||||
handler = unwrap(api.post)
|
||||
@@ -246,9 +260,9 @@ class TestAudioApi:
|
||||
|
||||
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
|
||||
with pytest.raises(expected):
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
handler(api, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_unhandled_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_unhandled_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
AudioService, "transcript_asr", lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
@@ -259,11 +273,11 @@ class TestAudioApi:
|
||||
|
||||
with app.test_request_context("/audio-to-text", method="POST", data={"file": _file_data()}):
|
||||
with pytest.raises(InternalServerError):
|
||||
handler(api, app_model=app_model, end_user=end_user)
|
||||
handler(api, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
|
||||
class TestTextApi:
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
monkeypatch.setattr(AudioService, "transcript_tts", lambda **_kwargs: {"audio": "ok"})
|
||||
|
||||
api = TextApi()
|
||||
@@ -277,11 +291,13 @@ class TestTextApi:
|
||||
json={"text": "hello", "voice": "v"},
|
||||
):
|
||||
payload = TextToAudioPayload.model_validate(request.get_json() or {})
|
||||
response = handler(api, payload, app_model=app_model, end_user=end_user)
|
||||
response = handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
assert response == {"audio": "ok"}
|
||||
|
||||
def test_success_with_message_ref(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_success_with_message_ref(
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
calls = {}
|
||||
|
||||
def fake_transcript_tts(**kwargs):
|
||||
@@ -301,12 +317,13 @@ class TestTextApi:
|
||||
json={"text": "hello", "message_id": "message-1"},
|
||||
):
|
||||
payload = TextToAudioPayload.model_validate(request.get_json() or {})
|
||||
response = handler(api, payload, app_model=app_model, end_user=end_user)
|
||||
response = handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
|
||||
assert response == {"audio": "ok"}
|
||||
assert calls["message_ref"] == MessageRef(AppRef("tenant-1", "a1"), "message-1", end_user_id="end-user-1")
|
||||
assert calls["session"] is sqlite_session
|
||||
|
||||
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_error_mapping(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
monkeypatch.setattr(
|
||||
AudioService, "transcript_tts", lambda **_kwargs: (_ for _ in ()).throw(QuotaExceededError())
|
||||
)
|
||||
@@ -319,4 +336,4 @@ class TestTextApi:
|
||||
with app.test_request_context("/text-to-audio", method="POST", json={"text": "hello"}):
|
||||
payload = TextToAudioPayload.model_validate(request.get_json() or {})
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
handler(api, payload, app_model=app_model, end_user=end_user)
|
||||
handler(api, payload, sqlite_session, app_model=app_model, end_user=end_user)
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.web.audio import AudioApi, TextApi
|
||||
from controllers.web.audio import AudioApi, TextApi, TextToAudioPayload
|
||||
from controllers.web.error import (
|
||||
AudioTooLargeError,
|
||||
CompletionRequestError,
|
||||
@@ -54,6 +56,13 @@ def _end_user() -> EndUser:
|
||||
return make_end_user(end_user_id="eu-1", app_id="app-1", external_user_id="ext-1", name="Web User")
|
||||
|
||||
|
||||
# The @with_session decorator opens a request-scoped session and injects it as
|
||||
# the first argument after self; these undecorated handlers let tests pass a
|
||||
# session explicitly and assert it is forwarded to AudioService.
|
||||
_audio_post = inspect.unwrap(AudioApi.post)
|
||||
_text_post = inspect.unwrap(TextApi.post)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AudioApi (audio-to-text)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,6 +75,16 @@ class TestAudioApi:
|
||||
result = AudioApi().post(_app_model(), _end_user())
|
||||
|
||||
assert result == {"text": "hello"}
|
||||
assert isinstance(mock_asr.call_args.kwargs["session"], Session)
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", return_value={"text": "hello"})
|
||||
def test_forwards_injected_session(self, mock_asr: MagicMock, app: Flask, sqlite_session: Session) -> None:
|
||||
data = {"file": (BytesIO(b"fake-audio"), "test.mp3")}
|
||||
with app.test_request_context("/audio-to-text", method="POST", data=data, content_type="multipart/form-data"):
|
||||
result = _audio_post(AudioApi(), sqlite_session, _app_model(), _end_user())
|
||||
|
||||
assert result == {"text": "hello"}
|
||||
assert mock_asr.call_args.kwargs["session"] is sqlite_session
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_asr", side_effect=NoAudioUploadedServiceError())
|
||||
def test_no_audio_uploaded(self, mock_asr: MagicMock, app: Flask) -> None:
|
||||
@@ -144,6 +163,16 @@ class TestTextApi:
|
||||
|
||||
assert result == "audio-bytes"
|
||||
mock_tts.assert_called_once()
|
||||
assert isinstance(mock_tts.call_args.kwargs["session"], Session)
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
|
||||
def test_forwards_injected_session(self, mock_tts: MagicMock, app: Flask, sqlite_session: Session) -> None:
|
||||
payload = TextToAudioPayload.model_validate({"text": "hello", "voice": "alloy"})
|
||||
with app.test_request_context("/text-to-audio", method="POST"):
|
||||
result = _text_post(TextApi(), payload, sqlite_session, _app_model(), _end_user())
|
||||
|
||||
assert result == "audio-bytes"
|
||||
assert mock_tts.call_args.kwargs["session"] is sqlite_session
|
||||
|
||||
@patch("controllers.web.audio.AudioService.transcript_tts", return_value="audio-bytes")
|
||||
def test_happy_path_with_message_ref(self, mock_tts: MagicMock, app: Flask) -> None:
|
||||
|
||||
Reference in new issue
Block a user