mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 14:15:52 +00:00
stt init (#2306)
* stt init * fix: limits * fix: errors * fix: error messages
This commit is contained in:
38 files changed
+3096
-188
No files matched your search
@@ -7,7 +7,7 @@
|
||||
</p>
|
||||
|
||||
<p align="left">
|
||||
<strong><a href="https://www.docsgpt.cloud/">DocsGPT</a></strong> is an open-source AI platform for building intelligent agents and assistants. Features Agent Builder, deep research tools, document analysis (PDF, Office, web content), Multi-model support (choose your provider or run locally), and rich API connectivity for agents with actionable tools and integrations. Deploy anywhere with complete privacy control.
|
||||
<strong><a href="https://www.docsgpt.cloud/">DocsGPT</a></strong> is an open-source AI platform for building intelligent agents and assistants. Features Agent Builder, deep research tools, document analysis (PDF, Office, web content, and audio), Multi-model support (choose your provider or run locally), and rich API connectivity for agents with actionable tools and integrations. Deploy anywhere with complete privacy control.
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
@@ -35,7 +35,8 @@
|
||||
<strong>Key Features:</strong>
|
||||
</h3>
|
||||
<ul align="left">
|
||||
<li><strong>🗂️ Wide Format Support:</strong> Reads PDF, DOCX, CSV, XLSX, EPUB, MD, RST, HTML, MDX, JSON, PPTX, and images.</li>
|
||||
<li><strong>🗂️ Wide Format Support:</strong> Reads PDF, DOCX, CSV, XLSX, EPUB, MD, RST, HTML, MDX, JSON, PPTX, images, and audio files such as MP3, WAV, M4A, OGG, and WebM.</li>
|
||||
<li><strong>🎙️ Speech Workflows:</strong> Record voice input into chat, transcribe audio on the backend, and ingest meeting recordings or voice notes as searchable knowledge.</li>
|
||||
<li><strong>🌐 Web & Data Integration:</strong> Ingests from URLs, sitemaps, Reddit, GitHub and web crawlers.</li>
|
||||
<li><strong>✅ Reliable Answers:</strong> Get accurate, hallucination-free responses with source citations viewable in a clean UI.</li>
|
||||
<li><strong>🔑 Streamlined API Keys:</strong> Generate keys linked to your settings, documents, and models, simplifying chatbot and integration setup.</li>
|
||||
@@ -158,4 +159,3 @@ The source code license is [MIT](https://opensource.org/license/mit/), as descri
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,7 @@ Provides virtual folder organization for agents (Google Drive-like structure).
|
||||
|
||||
import datetime
|
||||
from bson.objectid import ObjectId
|
||||
from flask import jsonify, make_response, request
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource, fields
|
||||
|
||||
from application.api import api
|
||||
@@ -19,6 +19,11 @@ agents_folders_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _folder_error_response(message: str, err: Exception):
|
||||
current_app.logger.error(f"{message}: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/")
|
||||
class AgentFolders(Resource):
|
||||
@api.doc(description="Get all folders for the user")
|
||||
@@ -40,8 +45,8 @@ class AgentFolders(Resource):
|
||||
for f in folders
|
||||
]
|
||||
return make_response(jsonify({"folders": result}), 200)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to fetch folders", err)
|
||||
|
||||
@api.doc(description="Create a new folder")
|
||||
@api.expect(
|
||||
@@ -82,8 +87,8 @@ class AgentFolders(Resource):
|
||||
jsonify({"id": str(result.inserted_id), "name": data["name"], "parent_id": parent_id}),
|
||||
201,
|
||||
)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to create folder", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/<string:folder_id>")
|
||||
@@ -117,8 +122,8 @@ class AgentFolder(Resource):
|
||||
}),
|
||||
200,
|
||||
)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to fetch folder", err)
|
||||
|
||||
@api.doc(description="Update a folder")
|
||||
def put(self, folder_id):
|
||||
@@ -145,8 +150,8 @@ class AgentFolder(Resource):
|
||||
if result.matched_count == 0:
|
||||
return make_response(jsonify({"success": False, "message": "Folder not found"}), 404)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to update folder", err)
|
||||
|
||||
@api.doc(description="Delete a folder")
|
||||
def delete(self, folder_id):
|
||||
@@ -165,8 +170,8 @@ class AgentFolder(Resource):
|
||||
if result.deleted_count == 0:
|
||||
return make_response(jsonify({"success": False, "message": "Folder not found"}), 404)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to delete folder", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/move_agent")
|
||||
@@ -211,8 +216,8 @@ class MoveAgentToFolder(Resource):
|
||||
)
|
||||
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agent", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/bulk_move")
|
||||
@@ -257,5 +262,5 @@ class BulkMoveAgents(Resource):
|
||||
{"$unset": {"folder_id": ""}},
|
||||
)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as e:
|
||||
return make_response(jsonify({"success": False, "message": str(e)}), 400)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agents", err)
|
||||
@@ -740,13 +740,7 @@ class UpdateAgent(Resource):
|
||||
request, existing_agent.get("image", ""), user, storage
|
||||
)
|
||||
if error:
|
||||
current_app.logger.error(
|
||||
f"Image upload error for agent {agent_id}: {error}"
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": f"Image upload failed: {error}"}),
|
||||
400,
|
||||
)
|
||||
return error
|
||||
update_fields = {}
|
||||
allowed_fields = [
|
||||
"name",
|
||||
@@ -1412,4 +1406,4 @@ class RemoveSharedAgent(Resource):
|
||||
current_app.logger.error(f"Error removing shared agent: {err}")
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Server error"}), 500
|
||||
)
|
||||
)
|
||||
@@ -1,15 +1,36 @@
|
||||
"""File attachments and media routes."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from bson.objectid import ObjectId
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
|
||||
from application.api import api
|
||||
from application.api.user.base import agents_collection, storage
|
||||
from application.api.user.tasks import store_attachment
|
||||
from application.cache import get_redis_instance
|
||||
from application.core.settings import settings
|
||||
from application.stt.constants import (
|
||||
SUPPORTED_AUDIO_EXTENSIONS,
|
||||
SUPPORTED_AUDIO_MIME_TYPES,
|
||||
)
|
||||
from application.stt.upload_limits import (
|
||||
AudioFileTooLargeError,
|
||||
build_stt_file_size_limit_message,
|
||||
enforce_audio_file_size_limit,
|
||||
is_audio_filename,
|
||||
)
|
||||
from application.stt.live_session import (
|
||||
apply_live_stt_hypothesis,
|
||||
create_live_stt_session,
|
||||
delete_live_stt_session,
|
||||
finalize_live_stt_session,
|
||||
get_live_stt_transcript_text,
|
||||
load_live_stt_session,
|
||||
save_live_stt_session,
|
||||
)
|
||||
from application.stt.stt_creator import STTCreator
|
||||
from application.tts.tts_creator import TTSCreator
|
||||
from application.utils import safe_filename
|
||||
|
||||
@@ -19,6 +40,74 @@ attachments_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_authenticated_user():
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
api_key = request.form.get("api_key") or request.args.get("api_key")
|
||||
|
||||
if decoded_token:
|
||||
return safe_filename(decoded_token.get("sub"))
|
||||
|
||||
if api_key:
|
||||
from application.api.user.base import agents_collection
|
||||
|
||||
agent = agents_collection.find_one({"key": api_key})
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid API key"}), 401
|
||||
)
|
||||
return safe_filename(agent.get("user"))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_uploaded_file_size(file) -> int:
|
||||
try:
|
||||
current_position = file.stream.tell()
|
||||
file.stream.seek(0, os.SEEK_END)
|
||||
size_bytes = file.stream.tell()
|
||||
file.stream.seek(current_position)
|
||||
return size_bytes
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _is_supported_audio_mimetype(mimetype: str) -> bool:
|
||||
if not mimetype:
|
||||
return True
|
||||
normalized = mimetype.split(";")[0].strip().lower()
|
||||
return normalized.startswith("audio/") or normalized in SUPPORTED_AUDIO_MIME_TYPES
|
||||
|
||||
|
||||
def _enforce_uploaded_audio_size_limit(file, filename: str) -> None:
|
||||
if not is_audio_filename(filename):
|
||||
return
|
||||
size_bytes = _get_uploaded_file_size(file)
|
||||
if size_bytes:
|
||||
enforce_audio_file_size_limit(size_bytes)
|
||||
|
||||
|
||||
def _get_store_attachment_user_error(exc: Exception) -> str:
|
||||
if isinstance(exc, AudioFileTooLargeError):
|
||||
return build_stt_file_size_limit_message()
|
||||
return "Failed to process file"
|
||||
|
||||
|
||||
def _require_live_stt_redis():
|
||||
redis_client = get_redis_instance()
|
||||
if redis_client:
|
||||
return redis_client
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Live transcription is unavailable"}),
|
||||
503,
|
||||
)
|
||||
|
||||
|
||||
def _parse_bool_form_value(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@attachments_ns.route("/store_attachment")
|
||||
class StoreAttachment(Resource):
|
||||
@api.expect(
|
||||
@@ -36,8 +125,9 @@ class StoreAttachment(Resource):
|
||||
description="Stores one or multiple attachments without vectorization or training. Supports user or API key authentication."
|
||||
)
|
||||
def post(self):
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
api_key = request.form.get("api_key") or request.args.get("api_key")
|
||||
auth_user = _resolve_authenticated_user()
|
||||
if hasattr(auth_user, "status_code"):
|
||||
return auth_user
|
||||
|
||||
files = request.files.getlist("file")
|
||||
if not files:
|
||||
@@ -51,22 +141,16 @@ class StoreAttachment(Resource):
|
||||
400,
|
||||
)
|
||||
|
||||
user = None
|
||||
if decoded_token:
|
||||
user = safe_filename(decoded_token.get("sub"))
|
||||
elif api_key:
|
||||
agent = agents_collection.find_one({"key": api_key})
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid API key"}), 401
|
||||
)
|
||||
user = safe_filename(agent.get("user"))
|
||||
else:
|
||||
user = auth_user
|
||||
if not user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}), 401
|
||||
)
|
||||
|
||||
try:
|
||||
from application.api.user.tasks import store_attachment
|
||||
from application.api.user.base import storage
|
||||
|
||||
tasks = []
|
||||
errors = []
|
||||
original_file_count = len(files)
|
||||
@@ -75,6 +159,7 @@ class StoreAttachment(Resource):
|
||||
try:
|
||||
attachment_id = ObjectId()
|
||||
original_filename = safe_filename(os.path.basename(file.filename))
|
||||
_enforce_uploaded_audio_size_limit(file, original_filename)
|
||||
relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}"
|
||||
|
||||
metadata = storage.save_file(file, relative_path)
|
||||
@@ -90,15 +175,31 @@ class StoreAttachment(Resource):
|
||||
"task_id": task.id,
|
||||
"filename": original_filename,
|
||||
"attachment_id": str(attachment_id),
|
||||
"upload_index": idx,
|
||||
})
|
||||
except Exception as file_err:
|
||||
current_app.logger.error(f"Error processing file {idx} ({file.filename}): {file_err}", exc_info=True)
|
||||
errors.append({
|
||||
"upload_index": idx,
|
||||
"filename": file.filename,
|
||||
"error": str(file_err)
|
||||
"error": _get_store_attachment_user_error(file_err),
|
||||
})
|
||||
|
||||
if not tasks:
|
||||
if errors and all(
|
||||
error.get("error") == build_stt_file_size_limit_message()
|
||||
for error in errors
|
||||
):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
"errors": errors,
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"status": "error", "message": "No valid files to upload"}),
|
||||
400,
|
||||
@@ -135,11 +236,385 @@ class StoreAttachment(Resource):
|
||||
return make_response(jsonify({"success": False, "error": "Failed to store attachment"}), 400)
|
||||
|
||||
|
||||
@attachments_ns.route("/stt")
|
||||
class SpeechToText(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"SpeechToTextModel",
|
||||
{
|
||||
"file": fields.Raw(required=True, description="Audio file"),
|
||||
"language": fields.String(
|
||||
required=False, description="Optional transcription language hint"
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@api.doc(description="Transcribe an uploaded audio file")
|
||||
def post(self):
|
||||
auth_user = _resolve_authenticated_user()
|
||||
if hasattr(auth_user, "status_code"):
|
||||
return auth_user
|
||||
if not auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
|
||||
file = request.files.get("file")
|
||||
if not file or file.filename == "":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing file"}),
|
||||
400,
|
||||
)
|
||||
|
||||
filename = safe_filename(os.path.basename(file.filename))
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unsupported audio format"}),
|
||||
400,
|
||||
)
|
||||
|
||||
if not _is_supported_audio_mimetype(file.mimetype or ""):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unsupported audio MIME type"}),
|
||||
400,
|
||||
)
|
||||
|
||||
try:
|
||||
_enforce_uploaded_audio_size_limit(file, filename)
|
||||
except AudioFileTooLargeError:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||
file.save(temp_file.name)
|
||||
temp_path = Path(temp_file.name)
|
||||
|
||||
stt_instance = STTCreator.create_stt(settings.STT_PROVIDER)
|
||||
transcript = stt_instance.transcribe(
|
||||
temp_path,
|
||||
language=request.form.get("language") or settings.STT_LANGUAGE,
|
||||
timestamps=settings.STT_ENABLE_TIMESTAMPS,
|
||||
diarize=settings.STT_ENABLE_DIARIZATION,
|
||||
)
|
||||
return make_response(jsonify({"success": True, **transcript}), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error transcribing audio: {err}", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Failed to transcribe audio"}),
|
||||
400,
|
||||
)
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
@attachments_ns.route("/stt/live/start")
|
||||
class LiveSpeechToTextStart(Resource):
|
||||
@api.doc(description="Start a live speech-to-text session")
|
||||
def post(self):
|
||||
auth_user = _resolve_authenticated_user()
|
||||
if hasattr(auth_user, "status_code"):
|
||||
return auth_user
|
||||
if not auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
|
||||
redis_client = _require_live_stt_redis()
|
||||
if hasattr(redis_client, "status_code"):
|
||||
return redis_client
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
session_state = create_live_stt_session(
|
||||
user=auth_user,
|
||||
language=payload.get("language") or settings.STT_LANGUAGE,
|
||||
)
|
||||
save_live_stt_session(redis_client, session_state)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"session_id": session_state["session_id"],
|
||||
"language": session_state.get("language"),
|
||||
"committed_text": "",
|
||||
"mutable_text": "",
|
||||
"previous_hypothesis": "",
|
||||
"latest_hypothesis": "",
|
||||
"finalized_text": "",
|
||||
"pending_text": "",
|
||||
"transcript_text": "",
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@attachments_ns.route("/stt/live/chunk")
|
||||
class LiveSpeechToTextChunk(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"LiveSpeechToTextChunkModel",
|
||||
{
|
||||
"session_id": fields.String(
|
||||
required=True, description="Live transcription session ID"
|
||||
),
|
||||
"chunk_index": fields.Integer(
|
||||
required=True, description="Sequential chunk index"
|
||||
),
|
||||
"is_silence": fields.Boolean(
|
||||
required=False,
|
||||
description="Whether the latest capture window was mostly silence",
|
||||
),
|
||||
"file": fields.Raw(required=True, description="Audio chunk"),
|
||||
},
|
||||
)
|
||||
)
|
||||
@api.doc(description="Transcribe a chunk for a live speech-to-text session")
|
||||
def post(self):
|
||||
auth_user = _resolve_authenticated_user()
|
||||
if hasattr(auth_user, "status_code"):
|
||||
return auth_user
|
||||
if not auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
|
||||
redis_client = _require_live_stt_redis()
|
||||
if hasattr(redis_client, "status_code"):
|
||||
return redis_client
|
||||
|
||||
session_id = request.form.get("session_id", "").strip()
|
||||
if not session_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing session_id"}),
|
||||
400,
|
||||
)
|
||||
|
||||
session_state = load_live_stt_session(redis_client, session_id)
|
||||
if not session_state:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Live transcription session not found",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
if safe_filename(str(session_state.get("user", ""))) != auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}),
|
||||
403,
|
||||
)
|
||||
|
||||
chunk_index_raw = request.form.get("chunk_index", "").strip()
|
||||
if chunk_index_raw == "":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing chunk_index"}),
|
||||
400,
|
||||
)
|
||||
|
||||
try:
|
||||
chunk_index = int(chunk_index_raw)
|
||||
except ValueError:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Invalid chunk_index"}),
|
||||
400,
|
||||
)
|
||||
is_silence = _parse_bool_form_value(request.form.get("is_silence"))
|
||||
|
||||
file = request.files.get("file")
|
||||
if not file or file.filename == "":
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing file"}),
|
||||
400,
|
||||
)
|
||||
|
||||
filename = safe_filename(os.path.basename(file.filename))
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in SUPPORTED_AUDIO_EXTENSIONS:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unsupported audio format"}),
|
||||
400,
|
||||
)
|
||||
|
||||
if not _is_supported_audio_mimetype(file.mimetype or ""):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Unsupported audio MIME type"}),
|
||||
400,
|
||||
)
|
||||
|
||||
try:
|
||||
_enforce_uploaded_audio_size_limit(file, filename)
|
||||
except AudioFileTooLargeError:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
|
||||
file.save(temp_file.name)
|
||||
temp_path = Path(temp_file.name)
|
||||
|
||||
session_language = session_state.get("language") or settings.STT_LANGUAGE
|
||||
stt_instance = STTCreator.create_stt(settings.STT_PROVIDER)
|
||||
transcript = stt_instance.transcribe(
|
||||
temp_path,
|
||||
language=session_language,
|
||||
timestamps=False,
|
||||
diarize=False,
|
||||
)
|
||||
if not session_state.get("language") and transcript.get("language"):
|
||||
session_state["language"] = transcript["language"]
|
||||
|
||||
try:
|
||||
apply_live_stt_hypothesis(
|
||||
session_state,
|
||||
str(transcript.get("text", "")),
|
||||
chunk_index,
|
||||
is_silence=is_silence,
|
||||
)
|
||||
except ValueError:
|
||||
current_app.logger.warning(
|
||||
"Invalid live transcription chunk",
|
||||
exc_info=True,
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Invalid live transcription chunk",
|
||||
}
|
||||
),
|
||||
409,
|
||||
)
|
||||
save_live_stt_session(redis_client, session_state)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"chunk_index": chunk_index,
|
||||
"chunk_text": transcript.get("text", ""),
|
||||
"is_silence": is_silence,
|
||||
"language": session_state.get("language"),
|
||||
"committed_text": session_state.get("committed_text", ""),
|
||||
"mutable_text": session_state.get("mutable_text", ""),
|
||||
"previous_hypothesis": session_state.get(
|
||||
"previous_hypothesis", ""
|
||||
),
|
||||
"latest_hypothesis": session_state.get(
|
||||
"latest_hypothesis", ""
|
||||
),
|
||||
"finalized_text": session_state.get("committed_text", ""),
|
||||
"pending_text": session_state.get("mutable_text", ""),
|
||||
"transcript_text": get_live_stt_transcript_text(session_state),
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error transcribing live audio chunk: {err}", exc_info=True
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Failed to transcribe audio"}),
|
||||
400,
|
||||
)
|
||||
finally:
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
|
||||
@attachments_ns.route("/stt/live/finish")
|
||||
class LiveSpeechToTextFinish(Resource):
|
||||
@api.doc(description="Finish a live speech-to-text session")
|
||||
def post(self):
|
||||
auth_user = _resolve_authenticated_user()
|
||||
if hasattr(auth_user, "status_code"):
|
||||
return auth_user
|
||||
if not auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Authentication required"}),
|
||||
401,
|
||||
)
|
||||
|
||||
redis_client = _require_live_stt_redis()
|
||||
if hasattr(redis_client, "status_code"):
|
||||
return redis_client
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
session_id = str(payload.get("session_id", "")).strip()
|
||||
if not session_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing session_id"}),
|
||||
400,
|
||||
)
|
||||
|
||||
session_state = load_live_stt_session(redis_client, session_id)
|
||||
if not session_state:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Live transcription session not found",
|
||||
}
|
||||
),
|
||||
404,
|
||||
)
|
||||
|
||||
if safe_filename(str(session_state.get("user", ""))) != auth_user:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Forbidden"}),
|
||||
403,
|
||||
)
|
||||
|
||||
final_text = finalize_live_stt_session(session_state)
|
||||
delete_live_stt_session(redis_client, session_id)
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"language": session_state.get("language"),
|
||||
"text": final_text,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
@attachments_ns.route("/images/<path:image_path>")
|
||||
class ServeImage(Resource):
|
||||
@api.doc(description="Serve an image from storage")
|
||||
def get(self, image_path):
|
||||
try:
|
||||
from application.api.user.base import storage
|
||||
|
||||
file_obj = storage.get_file(image_path)
|
||||
extension = image_path.split(".")[-1].lower()
|
||||
content_type = f"image/{extension}"
|
||||
|
||||
@@ -14,7 +14,14 @@ from application.api.user.base import sources_collection
|
||||
from application.api.user.tasks import ingest, ingest_connector_task, ingest_remote
|
||||
from application.core.settings import settings
|
||||
from application.parser.connectors.connector_creator import ConnectorCreator
|
||||
from application.parser.file.constants import SUPPORTED_SOURCE_EXTENSIONS
|
||||
from application.storage.storage_creator import StorageCreator
|
||||
from application.stt.upload_limits import (
|
||||
AudioFileTooLargeError,
|
||||
build_stt_file_size_limit_message,
|
||||
enforce_audio_file_size_limit,
|
||||
is_audio_filename,
|
||||
)
|
||||
from application.utils import check_required_fields, safe_filename
|
||||
|
||||
|
||||
@@ -23,6 +30,12 @@ sources_upload_ns = Namespace(
|
||||
)
|
||||
|
||||
|
||||
def _enforce_audio_path_size_limit(file_path: str, filename: str) -> None:
|
||||
if not is_audio_filename(filename):
|
||||
return
|
||||
enforce_audio_file_size_limit(os.path.getsize(file_path))
|
||||
|
||||
|
||||
@sources_upload_ns.route("/upload")
|
||||
class UploadFile(Resource):
|
||||
@api.expect(
|
||||
@@ -78,6 +91,7 @@ class UploadFile(Resource):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_file_path = os.path.join(temp_dir, safe_file)
|
||||
file.save(temp_file_path)
|
||||
_enforce_audio_path_size_limit(temp_file_path, safe_file)
|
||||
|
||||
# Only extract actual .zip files, not Office formats (.docx, .xlsx, .pptx)
|
||||
# which are technically zip archives but should be processed as-is
|
||||
@@ -102,6 +116,10 @@ class UploadFile(Resource):
|
||||
os.path.join(root, extracted_file), temp_dir
|
||||
)
|
||||
storage_path = f"{base_path}/{rel_path}"
|
||||
_enforce_audio_path_size_limit(
|
||||
os.path.join(root, extracted_file),
|
||||
extracted_file,
|
||||
)
|
||||
|
||||
with open(
|
||||
os.path.join(root, extracted_file), "rb"
|
||||
@@ -124,29 +142,23 @@ class UploadFile(Resource):
|
||||
storage.save_file(f, file_path)
|
||||
task = ingest.delay(
|
||||
settings.UPLOAD_FOLDER,
|
||||
[
|
||||
".rst",
|
||||
".md",
|
||||
".pdf",
|
||||
".txt",
|
||||
".docx",
|
||||
".csv",
|
||||
".epub",
|
||||
".html",
|
||||
".mdx",
|
||||
".json",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
],
|
||||
list(SUPPORTED_SOURCE_EXTENSIONS),
|
||||
job_name,
|
||||
user,
|
||||
file_path=base_path,
|
||||
filename=dir_name,
|
||||
file_name_map=file_name_map,
|
||||
)
|
||||
except AudioFileTooLargeError:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error uploading file: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
@@ -5,7 +5,14 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from bson.errors import InvalidId
|
||||
from bson.objectid import ObjectId
|
||||
from flask import jsonify, make_response, request, Response
|
||||
from flask import (
|
||||
Response,
|
||||
current_app,
|
||||
has_app_context,
|
||||
jsonify,
|
||||
make_response,
|
||||
request,
|
||||
)
|
||||
from pymongo.collection import Collection
|
||||
|
||||
|
||||
@@ -319,8 +326,10 @@ def safe_db_operation(
|
||||
try:
|
||||
result = operation()
|
||||
return result, None
|
||||
except Exception as e:
|
||||
return None, error_response(f"{error_message}: {str(e)}")
|
||||
except Exception as err:
|
||||
if has_app_context():
|
||||
current_app.logger.error(f"{error_message}: {err}", exc_info=True)
|
||||
return None, error_response(error_message)
|
||||
|
||||
|
||||
def validate_enum(
|
||||
|
||||
@@ -30,6 +30,11 @@ from application.api.user.utils import (
|
||||
workflows_ns = Namespace("workflows", path="/api")
|
||||
|
||||
|
||||
def _workflow_error_response(message: str, err: Exception):
|
||||
current_app.logger.error(f"{message}: {err}", exc_info=True)
|
||||
return error_response(message)
|
||||
|
||||
|
||||
def serialize_workflow(w: Dict) -> Dict:
|
||||
"""Serialize workflow document to API response format."""
|
||||
return {
|
||||
@@ -396,11 +401,11 @@ class WorkflowList(Resource):
|
||||
try:
|
||||
create_workflow_nodes(workflow_id, nodes_data, 1)
|
||||
create_workflow_edges(workflow_id, edges_data, 1)
|
||||
except Exception as e:
|
||||
except Exception as err:
|
||||
workflow_nodes_collection.delete_many({"workflow_id": workflow_id})
|
||||
workflow_edges_collection.delete_many({"workflow_id": workflow_id})
|
||||
workflows_collection.delete_one({"_id": result.inserted_id})
|
||||
return error_response(f"Failed to create workflow structure: {str(e)}")
|
||||
return _workflow_error_response("Failed to create workflow structure", err)
|
||||
|
||||
return success_response({"id": workflow_id}, 201)
|
||||
|
||||
@@ -470,14 +475,14 @@ class WorkflowDetail(Resource):
|
||||
try:
|
||||
create_workflow_nodes(workflow_id, nodes_data, next_graph_version)
|
||||
create_workflow_edges(workflow_id, edges_data, next_graph_version)
|
||||
except Exception as e:
|
||||
except Exception as err:
|
||||
workflow_nodes_collection.delete_many(
|
||||
{"workflow_id": workflow_id, "graph_version": next_graph_version}
|
||||
)
|
||||
workflow_edges_collection.delete_many(
|
||||
{"workflow_id": workflow_id, "graph_version": next_graph_version}
|
||||
)
|
||||
return error_response(f"Failed to update workflow structure: {str(e)}")
|
||||
return _workflow_error_response("Failed to update workflow structure", err)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
_, error = safe_db_operation(
|
||||
@@ -535,7 +540,7 @@ class WorkflowDetail(Resource):
|
||||
workflow_nodes_collection.delete_many({"workflow_id": workflow_id})
|
||||
workflow_edges_collection.delete_many({"workflow_id": workflow_id})
|
||||
workflows_collection.delete_one({"_id": workflow["_id"], "user": user_id})
|
||||
except Exception as e:
|
||||
return error_response(f"Failed to delete workflow: {str(e)}")
|
||||
except Exception as err:
|
||||
return _workflow_error_response("Failed to delete workflow", err)
|
||||
|
||||
return success_response()
|
||||
@@ -19,6 +19,10 @@ from application.api.user.routes import user # noqa: E402
|
||||
from application.api.connector.routes import connector # noqa: E402
|
||||
from application.celery_init import celery # noqa: E402
|
||||
from application.core.settings import settings # noqa: E402
|
||||
from application.stt.upload_limits import ( # noqa: E402
|
||||
build_stt_file_size_limit_message,
|
||||
should_reject_stt_request,
|
||||
)
|
||||
|
||||
|
||||
if platform.system() == "Windows":
|
||||
@@ -88,6 +92,23 @@ def generate_token():
|
||||
return jsonify({"error": "Token generation not allowed in current auth mode"}), 400
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_stt_request_size_limits():
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
if should_reject_stt_request(request.path, request.content_length):
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": build_stt_file_size_limit_message(),
|
||||
}
|
||||
),
|
||||
413,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@app.before_request
|
||||
def authenticate_request():
|
||||
if request.method == "OPTIONS":
|
||||
|
||||
@@ -156,6 +156,12 @@ class Settings(BaseSettings):
|
||||
|
||||
TTS_PROVIDER: str = "google_tts" # google_tts or elevenlabs
|
||||
ELEVENLABS_API_KEY: Optional[str] = None
|
||||
STT_PROVIDER: str = "openai" # openai or faster_whisper
|
||||
OPENAI_STT_MODEL: str = "gpt-4o-mini-transcribe"
|
||||
STT_LANGUAGE: Optional[str] = None
|
||||
STT_MAX_FILE_SIZE_MB: int = 25
|
||||
STT_ENABLE_TIMESTAMPS: bool = False
|
||||
STT_ENABLE_DIARIZATION: bool = False
|
||||
|
||||
# Tool pre-fetch settings
|
||||
ENABLE_TOOL_PREFETCH: bool = True
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Union
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.parser.file.base_parser import BaseParser
|
||||
from application.stt.stt_creator import STTCreator
|
||||
from application.stt.upload_limits import enforce_audio_file_size_limit
|
||||
|
||||
|
||||
class AudioParser(BaseParser):
|
||||
def __init__(self, parser_config=None):
|
||||
super().__init__(parser_config=parser_config)
|
||||
self._transcript_metadata: Dict[str, Dict] = {}
|
||||
|
||||
def _init_parser(self) -> Dict:
|
||||
return {}
|
||||
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, list[str]]:
|
||||
_ = errors
|
||||
try:
|
||||
enforce_audio_file_size_limit(file.stat().st_size)
|
||||
except OSError:
|
||||
pass
|
||||
stt = STTCreator.create_stt(settings.STT_PROVIDER)
|
||||
result = stt.transcribe(
|
||||
file,
|
||||
language=settings.STT_LANGUAGE,
|
||||
timestamps=settings.STT_ENABLE_TIMESTAMPS,
|
||||
diarize=settings.STT_ENABLE_DIARIZATION,
|
||||
)
|
||||
|
||||
transcript_metadata = {
|
||||
"transcript_duration_s": result.get("duration_s"),
|
||||
"transcript_language": result.get("language"),
|
||||
"transcript_provider": result.get("provider"),
|
||||
}
|
||||
if result.get("segments"):
|
||||
transcript_metadata["transcript_segments"] = result["segments"]
|
||||
|
||||
self._transcript_metadata[str(file)] = {
|
||||
key: value
|
||||
for key, value in transcript_metadata.items()
|
||||
if value not in (None, [], {})
|
||||
}
|
||||
return result.get("text", "")
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
return self._transcript_metadata.get(str(file), {})
|
||||
@@ -36,3 +36,8 @@ class BaseParser:
|
||||
@abstractmethod
|
||||
def parse_file(self, file: Path, errors: str = "ignore") -> Union[str, List[str]]:
|
||||
"""Parse file."""
|
||||
|
||||
def get_file_metadata(self, file: Path) -> Dict:
|
||||
"""Return parser-specific metadata for the most recently parsed file."""
|
||||
_ = file
|
||||
return {}
|
||||
@@ -14,11 +14,17 @@ from application.parser.file.tabular_parser import PandasCSVParser, ExcelParser
|
||||
from application.parser.file.json_parser import JSONParser
|
||||
from application.parser.file.pptx_parser import PPTXParser
|
||||
from application.parser.file.image_parser import ImageParser
|
||||
from application.parser.file.audio_parser import AudioParser
|
||||
from application.parser.schema.base import Document
|
||||
from application.stt.constants import SUPPORTED_AUDIO_EXTENSIONS
|
||||
from application.utils import num_tokens_from_string
|
||||
from application.core.settings import settings
|
||||
|
||||
|
||||
def _build_audio_parser_mapping() -> Dict[str, BaseParser]:
|
||||
return {extension: AudioParser() for extension in SUPPORTED_AUDIO_EXTENSIONS}
|
||||
|
||||
|
||||
def get_default_file_extractor(
|
||||
ocr_enabled: Optional[bool] = None,
|
||||
) -> Dict[str, BaseParser]:
|
||||
@@ -70,6 +76,7 @@ def get_default_file_extractor(
|
||||
".webp": DoclingImageParser(ocr_enabled=ocr_enabled) if ocr_enabled else ImageParser(),
|
||||
# Media/subtitles
|
||||
".vtt": DoclingVTTParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
# Specialized XML formats
|
||||
".xml": DoclingXMLParser(),
|
||||
# Formats docling doesn't support - use standard parsers
|
||||
@@ -96,6 +103,7 @@ def get_default_file_extractor(
|
||||
".png": ImageParser(),
|
||||
".jpg": ImageParser(),
|
||||
".jpeg": ImageParser(),
|
||||
**_build_audio_parser_mapping(),
|
||||
}
|
||||
|
||||
|
||||
@@ -221,11 +229,13 @@ class SimpleDirectoryReader(BaseReader):
|
||||
|
||||
for input_file in self.input_files:
|
||||
suffix_lower = input_file.suffix.lower()
|
||||
parser_metadata = {}
|
||||
if suffix_lower in self.file_extractor:
|
||||
parser = self.file_extractor[suffix_lower]
|
||||
if not parser.parser_config_set:
|
||||
parser.init_parser()
|
||||
data = parser.parse_file(input_file, errors=self.errors)
|
||||
parser_metadata = parser.get_file_metadata(input_file)
|
||||
else:
|
||||
# do standard read
|
||||
with open(input_file, "r", errors=self.errors) as f:
|
||||
@@ -244,6 +254,8 @@ class SimpleDirectoryReader(BaseReader):
|
||||
'title': input_file.name,
|
||||
'token_count': file_tokens,
|
||||
}
|
||||
if parser_metadata:
|
||||
base_metadata.update(parser_metadata)
|
||||
|
||||
if hasattr(self, 'input_dir'):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Shared file-extension constants for parsing and ingestion flows."""
|
||||
|
||||
from application.stt.constants import SUPPORTED_AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS = (
|
||||
".rst",
|
||||
".md",
|
||||
".pdf",
|
||||
".txt",
|
||||
".docx",
|
||||
".csv",
|
||||
".epub",
|
||||
".html",
|
||||
".mdx",
|
||||
".json",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
)
|
||||
|
||||
SUPPORTED_SOURCE_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg")
|
||||
|
||||
SUPPORTED_SOURCE_EXTENSIONS = (
|
||||
*SUPPORTED_SOURCE_DOCUMENT_EXTENSIONS,
|
||||
*SUPPORTED_SOURCE_IMAGE_EXTENSIONS,
|
||||
*SUPPORTED_AUDIO_EXTENSIONS,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Speech-to-text providers."""
|
||||
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class BaseSTT(ABC):
|
||||
@abstractmethod
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: Optional[str] = None,
|
||||
timestamps: bool = False,
|
||||
diarize: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
SUPPORTED_AUDIO_EXTENSIONS = (".wav", ".mp3", ".m4a", ".ogg", ".webm")
|
||||
|
||||
SUPPORTED_AUDIO_MIME_TYPES = {
|
||||
"application/ogg",
|
||||
"audio/aac",
|
||||
"audio/mp3",
|
||||
"audio/mp4",
|
||||
"audio/mpeg",
|
||||
"audio/ogg",
|
||||
"audio/wav",
|
||||
"audio/webm",
|
||||
"audio/x-m4a",
|
||||
"audio/x-wav",
|
||||
"video/webm",
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from application.stt.base import BaseSTT
|
||||
|
||||
|
||||
class FasterWhisperSTT(BaseSTT):
|
||||
def __init__(
|
||||
self,
|
||||
model_size: str = "base",
|
||||
device: str = "auto",
|
||||
compute_type: str = "int8",
|
||||
):
|
||||
self.model_size = model_size
|
||||
self.device = device
|
||||
self.compute_type = compute_type
|
||||
self._model = None
|
||||
|
||||
def _get_model(self):
|
||||
if self._model is None:
|
||||
try:
|
||||
from faster_whisper import WhisperModel
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"faster-whisper is required to use the faster_whisper STT provider."
|
||||
) from exc
|
||||
|
||||
self._model = WhisperModel(
|
||||
self.model_size,
|
||||
device=self.device,
|
||||
compute_type=self.compute_type,
|
||||
)
|
||||
return self._model
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: Optional[str] = None,
|
||||
timestamps: bool = False,
|
||||
diarize: bool = False,
|
||||
) -> Dict[str, object]:
|
||||
_ = diarize
|
||||
model = self._get_model()
|
||||
segments_iter, info = model.transcribe(
|
||||
str(file_path),
|
||||
language=language,
|
||||
word_timestamps=timestamps,
|
||||
)
|
||||
|
||||
segments = []
|
||||
text_parts = []
|
||||
for segment in segments_iter:
|
||||
segment_text = getattr(segment, "text", "").strip()
|
||||
if segment_text:
|
||||
text_parts.append(segment_text)
|
||||
segments.append(
|
||||
{
|
||||
"start": getattr(segment, "start", None),
|
||||
"end": getattr(segment, "end", None),
|
||||
"text": segment_text,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"text": " ".join(text_parts).strip(),
|
||||
"language": getattr(info, "language", language),
|
||||
"duration_s": getattr(info, "duration", None),
|
||||
"segments": segments if timestamps else [],
|
||||
"provider": "faster_whisper",
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
LIVE_STT_SESSION_PREFIX = "stt_live_session:"
|
||||
LIVE_STT_SESSION_TTL_SECONDS = 15 * 60
|
||||
LIVE_STT_MUTABLE_TAIL_WORDS = 8
|
||||
LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS = 2
|
||||
LIVE_STT_MIN_COMMITTED_OVERLAP_WORDS = 2
|
||||
|
||||
|
||||
def normalize_transcript_text(text: str) -> str:
|
||||
return " ".join((text or "").split()).strip()
|
||||
|
||||
|
||||
def join_transcript_parts(*parts: str) -> str:
|
||||
return " ".join(part for part in map(normalize_transcript_text, parts) if part)
|
||||
|
||||
|
||||
def _normalize_word(word: str) -> str:
|
||||
normalized = re.sub(r"[^\w]+", "", word.casefold(), flags=re.UNICODE)
|
||||
return normalized or word.casefold()
|
||||
|
||||
|
||||
def _split_words(text: str) -> list[str]:
|
||||
normalized = normalize_transcript_text(text)
|
||||
return normalized.split() if normalized else []
|
||||
|
||||
|
||||
def _common_prefix_length(left_words: list[str], right_words: list[str]) -> int:
|
||||
max_index = min(len(left_words), len(right_words))
|
||||
prefix_length = 0
|
||||
for index in range(max_index):
|
||||
if _normalize_word(left_words[index]) != _normalize_word(right_words[index]):
|
||||
break
|
||||
prefix_length += 1
|
||||
return prefix_length
|
||||
|
||||
|
||||
def _find_suffix_prefix_overlap(
|
||||
left_words: list[str], right_words: list[str], min_overlap: int
|
||||
) -> int:
|
||||
max_overlap = min(len(left_words), len(right_words))
|
||||
if max_overlap < min_overlap:
|
||||
return 0
|
||||
|
||||
left_keys = [_normalize_word(word) for word in left_words]
|
||||
right_keys = [_normalize_word(word) for word in right_words]
|
||||
|
||||
for overlap_size in range(max_overlap, min_overlap - 1, -1):
|
||||
if left_keys[-overlap_size:] == right_keys[:overlap_size]:
|
||||
return overlap_size
|
||||
return 0
|
||||
|
||||
|
||||
def strip_committed_prefix(committed_text: str, hypothesis_text: str) -> str:
|
||||
committed_words = _split_words(committed_text)
|
||||
hypothesis_words = _split_words(hypothesis_text)
|
||||
if not committed_words or not hypothesis_words:
|
||||
return normalize_transcript_text(hypothesis_text)
|
||||
|
||||
full_prefix_length = _common_prefix_length(committed_words, hypothesis_words)
|
||||
if full_prefix_length == len(committed_words):
|
||||
return " ".join(hypothesis_words[full_prefix_length:])
|
||||
|
||||
overlap_size = _find_suffix_prefix_overlap(
|
||||
committed_words,
|
||||
hypothesis_words,
|
||||
LIVE_STT_MIN_COMMITTED_OVERLAP_WORDS,
|
||||
)
|
||||
if overlap_size:
|
||||
return " ".join(hypothesis_words[overlap_size:])
|
||||
return " ".join(hypothesis_words)
|
||||
|
||||
|
||||
def _calculate_commit_count(
|
||||
previous_hypothesis: str, current_hypothesis: str, is_silence: bool
|
||||
) -> int:
|
||||
previous_words = _split_words(previous_hypothesis)
|
||||
current_words = _split_words(current_hypothesis)
|
||||
if not current_words:
|
||||
return 0
|
||||
|
||||
if not previous_words:
|
||||
if is_silence:
|
||||
return max(0, len(current_words) - LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS)
|
||||
return 0
|
||||
|
||||
stable_prefix_length = _common_prefix_length(previous_words, current_words)
|
||||
if not stable_prefix_length:
|
||||
return 0
|
||||
|
||||
mutable_tail_words = (
|
||||
LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS
|
||||
if is_silence
|
||||
else LIVE_STT_MUTABLE_TAIL_WORDS
|
||||
)
|
||||
max_committable_by_tail = max(0, len(current_words) - mutable_tail_words)
|
||||
return min(stable_prefix_length, max_committable_by_tail)
|
||||
|
||||
|
||||
def create_live_stt_session(
|
||||
user: str, language: Optional[str] = None
|
||||
) -> Dict[str, object]:
|
||||
return {
|
||||
"session_id": str(uuid.uuid4()),
|
||||
"user": user,
|
||||
"language": language,
|
||||
"committed_text": "",
|
||||
"mutable_text": "",
|
||||
"previous_hypothesis": "",
|
||||
"latest_hypothesis": "",
|
||||
"last_chunk_index": -1,
|
||||
}
|
||||
|
||||
|
||||
def get_live_stt_session_key(session_id: str) -> str:
|
||||
return f"{LIVE_STT_SESSION_PREFIX}{session_id}"
|
||||
|
||||
|
||||
def save_live_stt_session(redis_client, session_state: Dict[str, object]) -> None:
|
||||
redis_client.setex(
|
||||
get_live_stt_session_key(str(session_state["session_id"])),
|
||||
LIVE_STT_SESSION_TTL_SECONDS,
|
||||
json.dumps(session_state),
|
||||
)
|
||||
|
||||
|
||||
def load_live_stt_session(redis_client, session_id: str) -> Optional[Dict[str, object]]:
|
||||
raw_session = redis_client.get(get_live_stt_session_key(session_id))
|
||||
if not raw_session:
|
||||
return None
|
||||
if isinstance(raw_session, bytes):
|
||||
raw_session = raw_session.decode("utf-8")
|
||||
return json.loads(raw_session)
|
||||
|
||||
|
||||
def delete_live_stt_session(redis_client, session_id: str) -> None:
|
||||
redis_client.delete(get_live_stt_session_key(session_id))
|
||||
|
||||
|
||||
def apply_live_stt_hypothesis(
|
||||
session_state: Dict[str, object],
|
||||
hypothesis_text: str,
|
||||
chunk_index: int,
|
||||
is_silence: bool = False,
|
||||
) -> Dict[str, object]:
|
||||
last_chunk_index = int(session_state.get("last_chunk_index", -1))
|
||||
if chunk_index < 0:
|
||||
raise ValueError("chunk_index must be non-negative")
|
||||
if chunk_index < last_chunk_index:
|
||||
raise ValueError("chunk_index is older than the last processed chunk")
|
||||
if chunk_index == last_chunk_index:
|
||||
return session_state
|
||||
|
||||
committed_text = normalize_transcript_text(str(session_state.get("committed_text", "")))
|
||||
previous_hypothesis = normalize_transcript_text(
|
||||
str(session_state.get("latest_hypothesis", ""))
|
||||
)
|
||||
current_hypothesis = strip_committed_prefix(committed_text, hypothesis_text)
|
||||
|
||||
if not current_hypothesis and is_silence and previous_hypothesis:
|
||||
committed_text = join_transcript_parts(committed_text, previous_hypothesis)
|
||||
previous_hypothesis = ""
|
||||
|
||||
commit_count = _calculate_commit_count(
|
||||
previous_hypothesis,
|
||||
current_hypothesis,
|
||||
is_silence=is_silence,
|
||||
)
|
||||
current_words = _split_words(current_hypothesis)
|
||||
|
||||
if commit_count:
|
||||
committed_text = join_transcript_parts(
|
||||
committed_text,
|
||||
" ".join(current_words[:commit_count]),
|
||||
)
|
||||
current_hypothesis = " ".join(current_words[commit_count:])
|
||||
|
||||
session_state["committed_text"] = committed_text
|
||||
session_state["mutable_text"] = normalize_transcript_text(current_hypothesis)
|
||||
session_state["previous_hypothesis"] = previous_hypothesis
|
||||
session_state["latest_hypothesis"] = normalize_transcript_text(current_hypothesis)
|
||||
session_state["last_chunk_index"] = chunk_index
|
||||
return session_state
|
||||
|
||||
|
||||
def get_live_stt_transcript_text(session_state: Dict[str, object]) -> str:
|
||||
return join_transcript_parts(
|
||||
str(session_state.get("committed_text", "")),
|
||||
str(session_state.get("mutable_text", "")),
|
||||
)
|
||||
|
||||
|
||||
def finalize_live_stt_session(session_state: Dict[str, object]) -> str:
|
||||
return join_transcript_parts(
|
||||
str(session_state.get("committed_text", "")),
|
||||
str(session_state.get("latest_hypothesis", "")),
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.stt.base import BaseSTT
|
||||
|
||||
|
||||
class OpenAISTT(BaseSTT):
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
self.api_key = api_key or settings.OPENAI_API_KEY or settings.API_KEY
|
||||
self.base_url = base_url or settings.OPENAI_BASE_URL or "https://api.openai.com/v1"
|
||||
self.model = model or settings.OPENAI_STT_MODEL
|
||||
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
file_path: Path,
|
||||
language: Optional[str] = None,
|
||||
timestamps: bool = False,
|
||||
diarize: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
_ = diarize
|
||||
request: Dict[str, Any] = {
|
||||
"file": file_path,
|
||||
"model": self.model,
|
||||
"response_format": "verbose_json",
|
||||
}
|
||||
if language:
|
||||
request["language"] = language
|
||||
if timestamps:
|
||||
request["timestamp_granularities"] = ["segment"]
|
||||
|
||||
with open(file_path, "rb") as audio_file:
|
||||
request["file"] = audio_file
|
||||
response = self.client.audio.transcriptions.create(**request)
|
||||
response_dict = self._to_dict(response)
|
||||
segments = response_dict.get("segments") or []
|
||||
|
||||
return {
|
||||
"text": response_dict.get("text", ""),
|
||||
"language": response_dict.get("language") or language,
|
||||
"duration_s": response_dict.get("duration"),
|
||||
"segments": [self._to_dict(segment) for segment in segments],
|
||||
"provider": "openai",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _to_dict(value: Any) -> Dict[str, Any]:
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump()
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
@@ -0,0 +1,17 @@
|
||||
from application.stt.base import BaseSTT
|
||||
from application.stt.faster_whisper_stt import FasterWhisperSTT
|
||||
from application.stt.openai_stt import OpenAISTT
|
||||
|
||||
|
||||
class STTCreator:
|
||||
stt_providers = {
|
||||
"openai": OpenAISTT,
|
||||
"faster_whisper": FasterWhisperSTT,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create_stt(cls, stt_type, *args, **kwargs) -> BaseSTT:
|
||||
stt_class = cls.stt_providers.get(stt_type.lower())
|
||||
if not stt_class:
|
||||
raise ValueError(f"No stt class found for type {stt_type}")
|
||||
return stt_class(*args, **kwargs)
|
||||
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
from application.core.settings import settings
|
||||
from application.stt.constants import SUPPORTED_AUDIO_EXTENSIONS
|
||||
from application.utils import safe_filename
|
||||
|
||||
|
||||
STT_REQUEST_SIZE_OVERHEAD_BYTES = 1024 * 1024
|
||||
STT_SIZE_LIMITED_PATHS = frozenset(("/api/stt", "/api/stt/live/chunk"))
|
||||
|
||||
|
||||
class AudioFileTooLargeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def get_stt_max_file_size_bytes() -> int:
|
||||
return max(0, settings.STT_MAX_FILE_SIZE_MB) * 1024 * 1024
|
||||
|
||||
|
||||
def build_stt_file_size_limit_message() -> str:
|
||||
return f"Audio file exceeds {settings.STT_MAX_FILE_SIZE_MB}MB limit"
|
||||
|
||||
|
||||
def is_audio_filename(filename: str | Path | None) -> bool:
|
||||
if not filename:
|
||||
return False
|
||||
safe_name = safe_filename(Path(str(filename)).name)
|
||||
return Path(safe_name).suffix.lower() in SUPPORTED_AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
def enforce_audio_file_size_limit(size_bytes: int) -> None:
|
||||
max_size_bytes = get_stt_max_file_size_bytes()
|
||||
if max_size_bytes and size_bytes > max_size_bytes:
|
||||
raise AudioFileTooLargeError(build_stt_file_size_limit_message())
|
||||
|
||||
|
||||
def should_reject_stt_request(path: str, content_length: int | None) -> bool:
|
||||
if path not in STT_SIZE_LIMITED_PATHS or content_length is None:
|
||||
return False
|
||||
max_request_size_bytes = (
|
||||
get_stt_max_file_size_bytes() + STT_REQUEST_SIZE_OVERHEAD_BYTES
|
||||
)
|
||||
return content_length > max_request_size_bytes
|
||||
+14
-37
@@ -26,6 +26,7 @@ from application.parser.chunking import Chunker
|
||||
from application.parser.connectors.connector_creator import ConnectorCreator
|
||||
from application.parser.embedding_pipeline import embed_and_store_documents
|
||||
from application.parser.file.bulk import SimpleDirectoryReader, get_default_file_extractor
|
||||
from application.parser.file.constants import SUPPORTED_SOURCE_EXTENSIONS
|
||||
from application.parser.remote.remote_creator import RemoteCreator
|
||||
from application.parser.schema.base import Document
|
||||
from application.retriever.retriever_creator import RetrieverCreator
|
||||
@@ -646,23 +647,7 @@ def reingest_source_worker(self, source_id, user):
|
||||
reader = SimpleDirectoryReader(
|
||||
input_dir=temp_dir,
|
||||
recursive=True,
|
||||
required_exts=[
|
||||
".rst",
|
||||
".md",
|
||||
".pdf",
|
||||
".txt",
|
||||
".docx",
|
||||
".csv",
|
||||
".epub",
|
||||
".html",
|
||||
".mdx",
|
||||
".json",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
],
|
||||
required_exts=list(SUPPORTED_SOURCE_EXTENSIONS),
|
||||
exclude_hidden=True,
|
||||
file_metadata=metadata_from_filename,
|
||||
)
|
||||
@@ -1140,17 +1125,25 @@ def attachment_worker(self, file_info, user):
|
||||
file_extractor = get_default_file_extractor(
|
||||
ocr_enabled=settings.DOCLING_OCR_ATTACHMENTS_ENABLED
|
||||
)
|
||||
content = storage.process_file(
|
||||
attachment_document = storage.process_file(
|
||||
relative_path,
|
||||
lambda local_path, **kwargs: SimpleDirectoryReader(
|
||||
input_files=[local_path],
|
||||
exclude_hidden=True,
|
||||
errors="ignore",
|
||||
file_extractor=file_extractor,
|
||||
file_metadata=metadata_from_filename,
|
||||
)
|
||||
.load_data()[0]
|
||||
.text,
|
||||
.load_data()[0],
|
||||
)
|
||||
content = attachment_document.text
|
||||
parser_metadata = {
|
||||
key: value
|
||||
for key, value in (attachment_document.extra_info or {}).items()
|
||||
if key.startswith("transcript_")
|
||||
}
|
||||
if parser_metadata:
|
||||
metadata = {**metadata, **parser_metadata}
|
||||
|
||||
token_count = num_tokens_from_string(content)
|
||||
if token_count > 100000:
|
||||
@@ -1333,23 +1326,7 @@ def ingest_connector(
|
||||
reader = SimpleDirectoryReader(
|
||||
input_dir=temp_dir,
|
||||
recursive=True,
|
||||
required_exts=[
|
||||
".rst",
|
||||
".md",
|
||||
".pdf",
|
||||
".txt",
|
||||
".docx",
|
||||
".csv",
|
||||
".epub",
|
||||
".html",
|
||||
".mdx",
|
||||
".json",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
],
|
||||
required_exts=list(SUPPORTED_SOURCE_EXTENSIONS),
|
||||
exclude_hidden=True,
|
||||
file_metadata=metadata_from_filename,
|
||||
)
|
||||
|
||||
@@ -65,6 +65,8 @@ Here are some of the most fundamental settings you'll likely want to configure:
|
||||
|
||||
- **`OPENAI_BASE_URL`**: Specifically used when `LLM_PROVIDER` is set to `openai` but you are connecting to a local inference engine (like Ollama, Llama.cpp, etc.) that exposes an OpenAI-compatible API. This setting tells DocsGPT where to find your local LLM server.
|
||||
|
||||
- **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline.
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
Let's look at some concrete examples of how to configure these settings in your `.env` file.
|
||||
@@ -95,6 +97,49 @@ EMBEDDINGS_NAME=huggingface_sentence-transformers/all-mpnet-base-v2 # You can al
|
||||
|
||||
In this case, even though you are using Ollama locally, `LLM_PROVIDER` is set to `openai` because Ollama (and many other local inference engines) are designed to be API-compatible with OpenAI. `OPENAI_BASE_URL` points DocsGPT to the local Ollama server.
|
||||
|
||||
## Speech-to-Text Settings
|
||||
|
||||
DocsGPT can transcribe audio in two places:
|
||||
|
||||
- Voice input in the chat.
|
||||
- Audio file ingestion. Uploaded `.wav`, `.mp3`, `.m4a`, `.ogg`, and `.webm` files are transcribed first and then passed through the normal parser, chunking, embedding, and indexing pipeline.
|
||||
|
||||
For an end-to-end walkthrough, see the [Speech and Audio Guide](/Guides/speech-and-audio).
|
||||
|
||||
| Setting | Purpose | Typical values |
|
||||
| --- | --- | --- |
|
||||
| `STT_PROVIDER` | Speech-to-text backend provider. | `openai`, `faster_whisper` |
|
||||
| `OPENAI_STT_MODEL` | OpenAI transcription model used when `STT_PROVIDER=openai`. | `gpt-4o-mini-transcribe` |
|
||||
| `STT_LANGUAGE` | Optional language hint passed to the provider. Leave unset for auto-detection when supported. | `en`, `es`, unset |
|
||||
| `STT_MAX_FILE_SIZE_MB` | Maximum file size accepted by the synchronous `/api/stt` endpoint. | `25` |
|
||||
| `STT_ENABLE_TIMESTAMPS` | Include timestamp segments in the normalized transcript response and stored parser metadata. | `true`, `false` |
|
||||
| `STT_ENABLE_DIARIZATION` | Reserved provider option for speaker diarization. Some providers may ignore it. | `true`, `false` |
|
||||
|
||||
### Example: OpenAI Speech-to-Text
|
||||
|
||||
```env
|
||||
STT_PROVIDER=openai
|
||||
OPENAI_API_KEY=YOUR_OPENAI_API_KEY
|
||||
OPENAI_STT_MODEL=gpt-4o-mini-transcribe
|
||||
STT_LANGUAGE=
|
||||
STT_MAX_FILE_SIZE_MB=25
|
||||
STT_ENABLE_TIMESTAMPS=false
|
||||
STT_ENABLE_DIARIZATION=false
|
||||
```
|
||||
|
||||
If you already use `API_KEY` for OpenAI, DocsGPT can reuse that key for transcription. Set `OPENAI_API_KEY` only when you want a dedicated key.
|
||||
|
||||
### Example: Local `faster_whisper`
|
||||
|
||||
```env
|
||||
STT_PROVIDER=faster_whisper
|
||||
STT_LANGUAGE=en
|
||||
STT_ENABLE_TIMESTAMPS=true
|
||||
STT_ENABLE_DIARIZATION=false
|
||||
```
|
||||
|
||||
`faster_whisper` is an optional backend dependency. Install it in the Python environment used by the DocsGPT API and worker before selecting this provider.
|
||||
|
||||
## Authentication Settings
|
||||
|
||||
DocsGPT includes a JWT (JSON Web Token) based authentication feature for managing sessions or securing local deployments while allowing access.
|
||||
@@ -180,4 +225,3 @@ These are just the basic settings to get you started. The `settings.py` file con
|
||||
|
||||
For a complete list of available settings and their descriptions, refer to the `settings.py` file in `application/core`. Remember to restart your Docker containers after making changes to your `.env` file or `settings.py` for the changes to take effect.
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export const allGuides = {
|
||||
|
||||
# **DocsGPT 🦖**
|
||||
|
||||
DocsGPT is an open-source genAI tool that helps users get reliable answers from any knowledge source, while avoiding hallucinations. It enables quick and reliable information retrieval, with tooling and agentic system capability built in.
|
||||
DocsGPT is an open-source genAI tool that helps users get reliable answers from any knowledge source, while avoiding hallucinations. It enables quick and reliable information retrieval, with tooling and agentic system capability built in, including speech-to-text workflows for chat and audio knowledge ingestion.
|
||||
|
||||
|
||||
<video controls width={1920} height={1080} muted autoPlay loop playsInline>
|
||||
@@ -71,7 +71,8 @@ DocsGPT is an open-source genAI tool that helps users get reliable answers from
|
||||
Try it yourself: [https://www.docsgpt.cloud/](https://www.docsgpt.cloud/)
|
||||
|
||||
### Features:
|
||||
- **🗂️ Wide Format Support:** Reads PDF, DOCX, CSV, XLSX, EPUB, MD, RST, HTML, MDX, JSON, PPTX, and images.
|
||||
- **🗂️ Wide Format Support:** Reads PDF, DOCX, CSV, XLSX, EPUB, MD, RST, HTML, MDX, JSON, PPTX, images, and audio files such as MP3, WAV, M4A, OGG, and WebM.
|
||||
- **🎙️ Speech Workflows:** Record voice input into chat, transcribe on the backend, and index uploaded audio files as searchable source material.
|
||||
- **🌐 Web & Data Integration:** Ingests from URLs, sitemaps, Reddit, GitHub and web crawlers.
|
||||
- **✅ Reliable Answers:** Get accurate, hallucination-free responses with source citations viewable in a clean UI.
|
||||
- **🔑 Streamlined API Keys:** Generate keys linked to your settings, documents, and models, simplifying chatbot and integration setup.
|
||||
|
||||
@@ -58,6 +58,10 @@ const endpoints = {
|
||||
`/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`,
|
||||
UPDATE_CHUNK: '/api/update_chunk',
|
||||
STORE_ATTACHMENT: '/api/store_attachment',
|
||||
STT: '/api/stt',
|
||||
LIVE_STT_START: '/api/stt/live/start',
|
||||
LIVE_STT_CHUNK: '/api/stt/live/chunk',
|
||||
LIVE_STT_FINISH: '/api/stt/live/finish',
|
||||
DIRECTORY_STRUCTURE: (docId: string) =>
|
||||
`/api/directory_structure?id=${docId}`,
|
||||
MANAGE_SOURCE_FILES: '/api/manage_source_files',
|
||||
|
||||
@@ -91,6 +91,56 @@ const userService = {
|
||||
formData.append('file', file);
|
||||
return apiClient.postFormData(endpoints.USER.PARSE_SPEC, formData, token);
|
||||
},
|
||||
transcribeAudio: (
|
||||
file: File,
|
||||
token: string | null,
|
||||
language?: string,
|
||||
): Promise<Response> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
return apiClient.postFormData(endpoints.USER.STT, formData, token);
|
||||
},
|
||||
startLiveTranscription: (
|
||||
token: string | null,
|
||||
language?: string,
|
||||
): Promise<Response> =>
|
||||
apiClient.post(
|
||||
endpoints.USER.LIVE_STT_START,
|
||||
language ? { language } : {},
|
||||
token,
|
||||
),
|
||||
transcribeLiveAudioChunk: (
|
||||
sessionId: string,
|
||||
chunkIndex: number,
|
||||
file: File,
|
||||
token: string | null,
|
||||
isSilence?: boolean,
|
||||
): Promise<Response> => {
|
||||
const formData = new FormData();
|
||||
formData.append('session_id', sessionId);
|
||||
formData.append('chunk_index', String(chunkIndex));
|
||||
if (typeof isSilence === 'boolean') {
|
||||
formData.append('is_silence', String(isSilence));
|
||||
}
|
||||
formData.append('file', file);
|
||||
return apiClient.postFormData(
|
||||
endpoints.USER.LIVE_STT_CHUNK,
|
||||
formData,
|
||||
token,
|
||||
);
|
||||
},
|
||||
finishLiveTranscription: (
|
||||
sessionId: string,
|
||||
token: string | null,
|
||||
): Promise<Response> =>
|
||||
apiClient.post(
|
||||
endpoints.USER.LIVE_STT_FINISH,
|
||||
{ session_id: sessionId },
|
||||
token,
|
||||
),
|
||||
getDocumentChunks: (
|
||||
docId: string,
|
||||
page: number,
|
||||
|
||||
@@ -13,6 +13,7 @@ import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
import EyeView from '../assets/eye-view.svg';
|
||||
import Trash from '../assets/red-trash.svg';
|
||||
import { SOURCE_FILE_TREE_ACCEPT_ATTR } from '../constants/fileUpload';
|
||||
import { useOutsideAlerter, useLoaderState } from '../hooks';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import {
|
||||
@@ -413,8 +414,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept =
|
||||
'.rst,.md,.pdf,.txt,.docx,.csv,.epub,.html,.mdx,.json,.xlsx,.pptx,.png,.jpg,.jpeg';
|
||||
fileInput.accept = SOURCE_FILE_TREE_ACCEPT_ATTR;
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const fileList = (event.target as HTMLInputElement).files;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { LoaderCircle, Mic, Square } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
reorderAttachments,
|
||||
} from '../upload/uploadSlice';
|
||||
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
import {
|
||||
selectSelectedDocs,
|
||||
selectToken,
|
||||
@@ -32,10 +33,152 @@ import { getOS, isTouchDevice } from '../utils/browserUtils';
|
||||
import SourcesPopup from './SourcesPopup';
|
||||
import ToolsPopup from './ToolsPopup';
|
||||
import { handleAbort } from '../conversation/conversationSlice';
|
||||
import {
|
||||
FILE_UPLOAD_ACCEPT,
|
||||
FILE_UPLOAD_ACCEPT_ATTR,
|
||||
} from '../constants/fileUpload';
|
||||
|
||||
const generateId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
|
||||
type RecordingState = 'idle' | 'recording' | 'transcribing' | 'error';
|
||||
|
||||
const LIVE_TRANSCRIPTION_TIMESLICE_MS = 1000;
|
||||
const LIVE_CAPTURE_SAMPLE_RATE = 16000;
|
||||
const LIVE_CAPTURE_MAX_BUFFER_SECONDS = 20;
|
||||
const LIVE_SILENCE_RMS_THRESHOLD = 0.015;
|
||||
const ENABLE_VOICE_INPUT = import.meta.env.VITE_ENABLE_VOICE_INPUT === 'true';
|
||||
|
||||
type AudioContextWindow = Window &
|
||||
typeof globalThis & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
type LiveAudioSnapshot = {
|
||||
blob: Blob;
|
||||
chunkIndex: number;
|
||||
isSilence: boolean;
|
||||
};
|
||||
|
||||
const getAudioContextConstructor = (): typeof AudioContext | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioWindow = window as AudioContextWindow;
|
||||
return audioWindow.AudioContext || audioWindow.webkitAudioContext || null;
|
||||
};
|
||||
|
||||
const downsampleFloat32Buffer = (
|
||||
source: Float32Array,
|
||||
inputSampleRate: number,
|
||||
outputSampleRate: number,
|
||||
): Float32Array => {
|
||||
if (
|
||||
!source.length ||
|
||||
inputSampleRate <= 0 ||
|
||||
outputSampleRate <= 0 ||
|
||||
inputSampleRate === outputSampleRate
|
||||
) {
|
||||
return source;
|
||||
}
|
||||
|
||||
if (outputSampleRate > inputSampleRate) {
|
||||
return source;
|
||||
}
|
||||
|
||||
const ratio = inputSampleRate / outputSampleRate;
|
||||
const outputLength = Math.max(1, Math.round(source.length / ratio));
|
||||
const output = new Float32Array(outputLength);
|
||||
|
||||
let outputOffset = 0;
|
||||
let inputOffset = 0;
|
||||
while (outputOffset < output.length) {
|
||||
const nextInputOffset = Math.min(
|
||||
source.length,
|
||||
Math.round((outputOffset + 1) * ratio),
|
||||
);
|
||||
let accumulator = 0;
|
||||
let count = 0;
|
||||
for (let index = inputOffset; index < nextInputOffset; index += 1) {
|
||||
accumulator += source[index];
|
||||
count += 1;
|
||||
}
|
||||
output[outputOffset] =
|
||||
count > 0 ? accumulator / count : source[inputOffset];
|
||||
outputOffset += 1;
|
||||
inputOffset = nextInputOffset;
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
const concatenateFloat32Chunks = (
|
||||
chunks: Float32Array[],
|
||||
totalLength: number,
|
||||
): Float32Array => {
|
||||
const output = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
chunks.forEach((chunk) => {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
const encodeWavFromFloat32 = (
|
||||
samples: Float32Array,
|
||||
sampleRate: number,
|
||||
): Blob => {
|
||||
const bytesPerSample = 2;
|
||||
const blockAlign = bytesPerSample;
|
||||
const buffer = new ArrayBuffer(44 + samples.length * bytesPerSample);
|
||||
const view = new DataView(buffer);
|
||||
let offset = 0;
|
||||
|
||||
const writeString = (value: string) => {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
offset += value.length;
|
||||
};
|
||||
|
||||
writeString('RIFF');
|
||||
view.setUint32(offset, 36 + samples.length * bytesPerSample, true);
|
||||
offset += 4;
|
||||
writeString('WAVE');
|
||||
writeString('fmt ');
|
||||
view.setUint32(offset, 16, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, 1, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, 1, true);
|
||||
offset += 2;
|
||||
view.setUint32(offset, sampleRate, true);
|
||||
offset += 4;
|
||||
view.setUint32(offset, sampleRate * blockAlign, true);
|
||||
offset += 4;
|
||||
view.setUint16(offset, blockAlign, true);
|
||||
offset += 2;
|
||||
view.setUint16(offset, 16, true);
|
||||
offset += 2;
|
||||
writeString('data');
|
||||
view.setUint32(offset, samples.length * bytesPerSample, true);
|
||||
offset += 4;
|
||||
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const clamped = Math.max(-1, Math.min(1, samples[index]));
|
||||
view.setInt16(
|
||||
offset,
|
||||
clamped < 0 ? clamped * 0x8000 : clamped * 0x7fff,
|
||||
true,
|
||||
);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
};
|
||||
|
||||
type MessageInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
loading: boolean;
|
||||
@@ -61,16 +204,82 @@ export default function MessageInput({
|
||||
const [uploadModalState, setUploadModalState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [handleDragActive, setHandleDragActive] = useState<boolean>(false);
|
||||
const [recordingState, setRecordingState] = useState<RecordingState>('idle');
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
|
||||
const selectedDocs = useSelector(selectSelectedDocs);
|
||||
const token = useSelector(selectToken);
|
||||
const attachments = useSelector(selectAttachments);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const audioSourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const audioProcessorNodeRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const audioSilenceGainRef = useRef<GainNode | null>(null);
|
||||
const snapshotIntervalRef = useRef<number | null>(null);
|
||||
const pcmChunksRef = useRef<Float32Array[]>([]);
|
||||
const totalBufferedSamplesRef = useRef(0);
|
||||
const totalCapturedSamplesRef = useRef(0);
|
||||
const lastSnapshotCapturedSamplesRef = useRef(0);
|
||||
const recentWindowRmsRef = useRef({ sumSquares: 0, sampleCount: 0 });
|
||||
const liveSessionIdRef = useRef<string | null>(null);
|
||||
const livePendingSnapshotRef = useRef<LiveAudioSnapshot | null>(null);
|
||||
const liveChunkIndexRef = useRef(0);
|
||||
const liveUploadInFlightRef = useRef(false);
|
||||
const liveStopRequestedRef = useRef(false);
|
||||
const voiceBaseValueRef = useRef('');
|
||||
const liveTranscriptRef = useRef('');
|
||||
|
||||
const browserOS = getOS();
|
||||
const isTouch = isTouchDevice();
|
||||
|
||||
const stopMediaStream = () => {
|
||||
mediaStreamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
mediaStreamRef.current = null;
|
||||
};
|
||||
|
||||
const stopAudioProcessing = () => {
|
||||
if (snapshotIntervalRef.current !== null) {
|
||||
window.clearInterval(snapshotIntervalRef.current);
|
||||
snapshotIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (audioProcessorNodeRef.current) {
|
||||
audioProcessorNodeRef.current.onaudioprocess = null;
|
||||
audioProcessorNodeRef.current.disconnect();
|
||||
audioProcessorNodeRef.current = null;
|
||||
}
|
||||
if (audioSourceNodeRef.current) {
|
||||
audioSourceNodeRef.current.disconnect();
|
||||
audioSourceNodeRef.current = null;
|
||||
}
|
||||
if (audioSilenceGainRef.current) {
|
||||
audioSilenceGainRef.current.disconnect();
|
||||
audioSilenceGainRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close().catch(() => undefined);
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
stopMediaStream();
|
||||
};
|
||||
|
||||
const resetLiveTranscriptionState = () => {
|
||||
pcmChunksRef.current = [];
|
||||
totalBufferedSamplesRef.current = 0;
|
||||
totalCapturedSamplesRef.current = 0;
|
||||
lastSnapshotCapturedSamplesRef.current = 0;
|
||||
recentWindowRmsRef.current = { sumSquares: 0, sampleCount: 0 };
|
||||
liveSessionIdRef.current = null;
|
||||
livePendingSnapshotRef.current = null;
|
||||
liveChunkIndexRef.current = 0;
|
||||
liveUploadInFlightRef.current = false;
|
||||
liveStopRequestedRef.current = false;
|
||||
voiceBaseValueRef.current = '';
|
||||
liveTranscriptRef.current = '';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
@@ -90,6 +299,13 @@ export default function MessageInput({
|
||||
};
|
||||
}, [browserOS]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAudioProcessing();
|
||||
resetLiveTranscriptionState();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (!files || files.length === 0) return;
|
||||
@@ -143,42 +359,114 @@ export default function MessageInput({
|
||||
filename?: string;
|
||||
attachment_id?: string;
|
||||
path?: string;
|
||||
upload_index?: number;
|
||||
}>;
|
||||
const errors = Array.isArray(response?.errors)
|
||||
? (response.errors as Array<{
|
||||
filename?: string;
|
||||
error?: string;
|
||||
upload_index?: number;
|
||||
}>)
|
||||
: [];
|
||||
const hasIndexedResults =
|
||||
tasks.some((task) => typeof task.upload_index === 'number') ||
|
||||
errors.some(
|
||||
(errorItem) => typeof errorItem.upload_index === 'number',
|
||||
);
|
||||
|
||||
tasks.forEach((t, idx) => {
|
||||
const uiId = indexToUiId[idx];
|
||||
if (!uiId) return;
|
||||
if (t?.task_id) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: {
|
||||
taskId: t.task_id,
|
||||
status: 'processing',
|
||||
progress: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (hasIndexedResults) {
|
||||
const tasksByIndex = new Map<
|
||||
number,
|
||||
(typeof tasks)[number]
|
||||
>();
|
||||
const failedIndices = new Set<number>();
|
||||
|
||||
if (tasks.length < files.length) {
|
||||
for (let i = tasks.length; i < files.length; i++) {
|
||||
const uiId = indexToUiId[i];
|
||||
if (uiId) {
|
||||
tasks.forEach((task, taskOrderIndex) => {
|
||||
const uploadIndex =
|
||||
typeof task.upload_index === 'number'
|
||||
? task.upload_index
|
||||
: taskOrderIndex;
|
||||
tasksByIndex.set(uploadIndex, task);
|
||||
});
|
||||
|
||||
errors.forEach((errorItem) => {
|
||||
if (typeof errorItem.upload_index === 'number') {
|
||||
failedIndices.add(errorItem.upload_index);
|
||||
}
|
||||
});
|
||||
|
||||
files.forEach((_, index) => {
|
||||
const uiId = indexToUiId[index];
|
||||
if (!uiId) return;
|
||||
|
||||
const task = tasksByIndex.get(index);
|
||||
if (task?.task_id) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: {
|
||||
taskId: task.task_id,
|
||||
status: 'processing',
|
||||
progress: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (failedIndices.has(index)) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
tasks.forEach((t, idx) => {
|
||||
const uiId = indexToUiId[idx];
|
||||
if (!uiId) return;
|
||||
if (t?.task_id) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: {
|
||||
taskId: t.task_id,
|
||||
status: 'processing',
|
||||
progress: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (tasks.length < files.length) {
|
||||
for (let i = tasks.length; i < files.length; i++) {
|
||||
const uiId = indexToUiId[i];
|
||||
if (uiId) {
|
||||
dispatch(
|
||||
updateAttachment({
|
||||
id: uiId,
|
||||
updates: { status: 'failed' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,27 +709,7 @@ export default function MessageInput({
|
||||
setHandleDragActive(false);
|
||||
},
|
||||
maxSize: 25000000,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/x-rst': ['.rst'],
|
||||
'text/x-markdown': ['.md'],
|
||||
'application/zip': ['.zip'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
['.docx'],
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv'],
|
||||
'text/html': ['.html'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
|
||||
'.xlsx',
|
||||
],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
['.pptx'],
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpeg'],
|
||||
'image/jpg': ['.jpg'],
|
||||
},
|
||||
accept: FILE_UPLOAD_ACCEPT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -514,6 +782,359 @@ export default function MessageInput({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const buildVoiceDraftValue = (baseText: string, transcript: string) => {
|
||||
const normalizedBaseText = baseText ?? '';
|
||||
const normalizedTranscript = transcript.trim();
|
||||
|
||||
if (!normalizedTranscript) {
|
||||
return normalizedBaseText;
|
||||
}
|
||||
|
||||
return normalizedBaseText.trim()
|
||||
? `${normalizedBaseText}${
|
||||
normalizedBaseText.endsWith('\n') ? '' : '\n'
|
||||
}${normalizedTranscript}`
|
||||
: normalizedTranscript;
|
||||
};
|
||||
|
||||
const applyLiveTranscript = (transcript: string) => {
|
||||
const normalizedTranscript = transcript.trim();
|
||||
liveTranscriptRef.current = normalizedTranscript;
|
||||
setValue(
|
||||
buildVoiceDraftValue(voiceBaseValueRef.current, normalizedTranscript),
|
||||
);
|
||||
setTimeout(() => {
|
||||
handleInput();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const trimLivePcmBuffer = () => {
|
||||
const maxBufferedSamples =
|
||||
LIVE_CAPTURE_SAMPLE_RATE * LIVE_CAPTURE_MAX_BUFFER_SECONDS;
|
||||
|
||||
while (
|
||||
totalBufferedSamplesRef.current > maxBufferedSamples &&
|
||||
pcmChunksRef.current.length > 1
|
||||
) {
|
||||
const removedChunk = pcmChunksRef.current.shift();
|
||||
if (!removedChunk) {
|
||||
break;
|
||||
}
|
||||
totalBufferedSamplesRef.current -= removedChunk.length;
|
||||
}
|
||||
|
||||
if (
|
||||
totalBufferedSamplesRef.current > maxBufferedSamples &&
|
||||
pcmChunksRef.current.length === 1
|
||||
) {
|
||||
const onlyChunk = pcmChunksRef.current[0];
|
||||
if (!onlyChunk || onlyChunk.length <= maxBufferedSamples) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedChunk = onlyChunk.slice(
|
||||
onlyChunk.length - maxBufferedSamples,
|
||||
);
|
||||
pcmChunksRef.current = [trimmedChunk];
|
||||
totalBufferedSamplesRef.current = trimmedChunk.length;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupLiveSession = async () => {
|
||||
const sessionId = liveSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
liveSessionIdRef.current = null;
|
||||
try {
|
||||
await userService.finishLiveTranscription(sessionId, token);
|
||||
} catch {
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
};
|
||||
|
||||
const failLiveTranscription = async (message: string) => {
|
||||
console.error('Live audio transcription failed', message);
|
||||
stopAudioProcessing();
|
||||
await cleanupLiveSession();
|
||||
resetLiveTranscriptionState();
|
||||
setRecordingState('error');
|
||||
setVoiceError(message);
|
||||
};
|
||||
|
||||
const finalizeLiveTranscription = async () => {
|
||||
const sessionId = liveSessionIdRef.current;
|
||||
if (!sessionId) {
|
||||
resetLiveTranscriptionState();
|
||||
setRecordingState('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await userService.finishLiveTranscription(
|
||||
sessionId,
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(
|
||||
data?.message || 'Failed to finalize live transcription.',
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.text === 'string') {
|
||||
applyLiveTranscript(data.text);
|
||||
}
|
||||
|
||||
setRecordingState('idle');
|
||||
if (autoFocus) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Finalizing live audio transcription failed', error);
|
||||
setRecordingState('error');
|
||||
setVoiceError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to finalize live transcription.',
|
||||
);
|
||||
} finally {
|
||||
resetLiveTranscriptionState();
|
||||
}
|
||||
};
|
||||
|
||||
const maybeFinalizeLiveTranscription = async () => {
|
||||
if (
|
||||
!liveStopRequestedRef.current ||
|
||||
liveUploadInFlightRef.current ||
|
||||
livePendingSnapshotRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await finalizeLiveTranscription();
|
||||
};
|
||||
|
||||
const processPendingLiveSnapshot = async () => {
|
||||
if (liveUploadInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSnapshot = livePendingSnapshotRef.current;
|
||||
const sessionId = liveSessionIdRef.current;
|
||||
if (!nextSnapshot || !sessionId) {
|
||||
await maybeFinalizeLiveTranscription();
|
||||
return;
|
||||
}
|
||||
|
||||
livePendingSnapshotRef.current = null;
|
||||
liveUploadInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const file = new File(
|
||||
[nextSnapshot.blob],
|
||||
`voice-live-${nextSnapshot.chunkIndex}.wav`,
|
||||
{
|
||||
type: 'audio/wav',
|
||||
},
|
||||
);
|
||||
const response = await userService.transcribeLiveAudioChunk(
|
||||
sessionId,
|
||||
nextSnapshot.chunkIndex,
|
||||
file,
|
||||
token,
|
||||
nextSnapshot.isSilence,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(data?.message || 'Failed to transcribe audio.');
|
||||
}
|
||||
|
||||
if (typeof data.transcript_text === 'string') {
|
||||
applyLiveTranscript(data.transcript_text);
|
||||
}
|
||||
} catch (error) {
|
||||
await failLiveTranscription(
|
||||
error instanceof Error ? error.message : 'Failed to transcribe audio.',
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
liveUploadInFlightRef.current = false;
|
||||
}
|
||||
|
||||
if (livePendingSnapshotRef.current) {
|
||||
void processPendingLiveSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
void maybeFinalizeLiveTranscription();
|
||||
};
|
||||
|
||||
const queueCurrentLiveSnapshot = (forceSilence = false) => {
|
||||
if (
|
||||
totalCapturedSamplesRef.current === lastSnapshotCapturedSamplesRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pcmChunksRef.current.length || totalBufferedSamplesRef.current <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pcmSnapshot = concatenateFloat32Chunks(
|
||||
pcmChunksRef.current,
|
||||
totalBufferedSamplesRef.current,
|
||||
);
|
||||
if (!pcmSnapshot.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sumSquares, sampleCount } = recentWindowRmsRef.current;
|
||||
const averageRms =
|
||||
sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0;
|
||||
const isSilence = forceSilence || averageRms < LIVE_SILENCE_RMS_THRESHOLD;
|
||||
|
||||
recentWindowRmsRef.current = { sumSquares: 0, sampleCount: 0 };
|
||||
lastSnapshotCapturedSamplesRef.current = totalCapturedSamplesRef.current;
|
||||
livePendingSnapshotRef.current = {
|
||||
blob: encodeWavFromFloat32(pcmSnapshot, LIVE_CAPTURE_SAMPLE_RATE),
|
||||
chunkIndex: liveChunkIndexRef.current,
|
||||
isSilence,
|
||||
};
|
||||
liveChunkIndexRef.current += 1;
|
||||
void processPendingLiveSnapshot();
|
||||
};
|
||||
|
||||
const handleVoiceInput = async () => {
|
||||
if (recordingState === 'transcribing') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (recordingState === 'recording') {
|
||||
setRecordingState('transcribing');
|
||||
liveStopRequestedRef.current = true;
|
||||
stopAudioProcessing();
|
||||
queueCurrentLiveSnapshot();
|
||||
void maybeFinalizeLiveTranscription();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setRecordingState('error');
|
||||
setVoiceError('Voice input is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
const AudioContextConstructor = getAudioContextConstructor();
|
||||
if (!AudioContextConstructor) {
|
||||
setRecordingState('error');
|
||||
setVoiceError('Voice input is not supported in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
try {
|
||||
setVoiceError(null);
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
|
||||
const liveStartResponse = await userService.startLiveTranscription(token);
|
||||
const liveStartData = await liveStartResponse.json();
|
||||
if (!liveStartResponse.ok || !liveStartData?.success) {
|
||||
throw new Error(
|
||||
liveStartData?.message || 'Failed to start live transcription.',
|
||||
);
|
||||
}
|
||||
|
||||
const audioContext = new AudioContextConstructor();
|
||||
await audioContext.resume().catch(() => undefined);
|
||||
const sourceNode = audioContext.createMediaStreamSource(stream);
|
||||
const processorNode = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
const silenceGain = audioContext.createGain();
|
||||
silenceGain.gain.value = 0;
|
||||
|
||||
pcmChunksRef.current = [];
|
||||
totalBufferedSamplesRef.current = 0;
|
||||
totalCapturedSamplesRef.current = 0;
|
||||
lastSnapshotCapturedSamplesRef.current = 0;
|
||||
recentWindowRmsRef.current = { sumSquares: 0, sampleCount: 0 };
|
||||
liveSessionIdRef.current = liveStartData.session_id;
|
||||
livePendingSnapshotRef.current = null;
|
||||
liveChunkIndexRef.current = 0;
|
||||
liveUploadInFlightRef.current = false;
|
||||
liveStopRequestedRef.current = false;
|
||||
voiceBaseValueRef.current = value;
|
||||
liveTranscriptRef.current = '';
|
||||
applyLiveTranscript('');
|
||||
|
||||
processorNode.onaudioprocess = (event: AudioProcessingEvent) => {
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
if (!inputData.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capturedChunk = new Float32Array(inputData.length);
|
||||
capturedChunk.set(inputData);
|
||||
|
||||
const downsampledChunk = downsampleFloat32Buffer(
|
||||
capturedChunk,
|
||||
audioContext.sampleRate,
|
||||
LIVE_CAPTURE_SAMPLE_RATE,
|
||||
);
|
||||
if (!downsampledChunk.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
pcmChunksRef.current.push(downsampledChunk);
|
||||
totalBufferedSamplesRef.current += downsampledChunk.length;
|
||||
totalCapturedSamplesRef.current += downsampledChunk.length;
|
||||
|
||||
let sumSquares = 0;
|
||||
for (let index = 0; index < downsampledChunk.length; index += 1) {
|
||||
const sample = downsampledChunk[index];
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
|
||||
recentWindowRmsRef.current.sumSquares += sumSquares;
|
||||
recentWindowRmsRef.current.sampleCount += downsampledChunk.length;
|
||||
trimLivePcmBuffer();
|
||||
};
|
||||
|
||||
sourceNode.connect(processorNode);
|
||||
processorNode.connect(silenceGain);
|
||||
silenceGain.connect(audioContext.destination);
|
||||
|
||||
mediaStreamRef.current = stream;
|
||||
audioContextRef.current = audioContext;
|
||||
audioSourceNodeRef.current = sourceNode;
|
||||
audioProcessorNodeRef.current = processorNode;
|
||||
audioSilenceGainRef.current = silenceGain;
|
||||
snapshotIntervalRef.current = window.setInterval(() => {
|
||||
if (!liveStopRequestedRef.current) {
|
||||
queueCurrentLiveSnapshot();
|
||||
}
|
||||
}, LIVE_TRANSCRIPTION_TIMESLICE_MS);
|
||||
|
||||
setRecordingState('recording');
|
||||
} catch (error) {
|
||||
console.error('Microphone access failed', error);
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
stopAudioProcessing();
|
||||
await cleanupLiveSession();
|
||||
resetLiveTranscriptionState();
|
||||
setRecordingState('error');
|
||||
setVoiceError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Microphone access was denied.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -565,12 +1186,18 @@ export default function MessageInput({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePostDocumentSelect = (doc: any) => {
|
||||
console.log('Selected document:', doc);
|
||||
const handlePostDocumentSelect = (_docs: Doc[] | null) => {
|
||||
// SourcesPopup updates Redux selection directly; this preserves the prop contract.
|
||||
void _docs;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (value.trim() && !loading) {
|
||||
if (
|
||||
value.trim() &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
recordingState !== 'transcribing'
|
||||
) {
|
||||
onSubmit(value);
|
||||
setValue('');
|
||||
// Refocus input after submission if autoFocus is enabled
|
||||
@@ -598,7 +1225,7 @@ export default function MessageInput({
|
||||
try {
|
||||
e.dataTransfer.setData('text/plain', id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
@@ -621,6 +1248,19 @@ export default function MessageInput({
|
||||
setDraggingId(null);
|
||||
};
|
||||
|
||||
const voiceButtonLabel =
|
||||
recordingState === 'recording'
|
||||
? 'Stop recording'
|
||||
: recordingState === 'transcribing'
|
||||
? 'Transcribing audio'
|
||||
: 'Voice input';
|
||||
const voiceButtonText =
|
||||
recordingState === 'recording'
|
||||
? 'Stop'
|
||||
: recordingState === 'transcribing'
|
||||
? 'Transcribing'
|
||||
: 'Voice';
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className="flex w-full flex-col">
|
||||
{/* react-dropzone input (for drag/drop) */}
|
||||
@@ -718,6 +1358,12 @@ export default function MessageInput({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{voiceError && (
|
||||
<div className="px-2 pb-1 text-xs text-[#B42318] sm:px-3">
|
||||
{voiceError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full">
|
||||
<label htmlFor="message-input" className="sr-only">
|
||||
{t('inputPlaceholder')}
|
||||
@@ -727,6 +1373,10 @@ export default function MessageInput({
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
readOnly={
|
||||
recordingState === 'recording' ||
|
||||
recordingState === 'transcribing'
|
||||
}
|
||||
tabIndex={1}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
className="inputbox-style no-scrollbar bg-lotion dark:text-bright-gray dark:placeholder:text-bright-gray/50 w-full overflow-x-hidden overflow-y-auto rounded-t-[23px] px-2 text-base leading-tight whitespace-pre-wrap opacity-100 placeholder:text-gray-500 focus:outline-hidden sm:px-3 dark:bg-transparent"
|
||||
@@ -786,6 +1436,43 @@ export default function MessageInput({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{ENABLE_VOICE_INPUT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleVoiceInput();
|
||||
}}
|
||||
aria-label={voiceButtonLabel}
|
||||
title={voiceButtonLabel}
|
||||
disabled={loading || recordingState === 'transcribing'}
|
||||
className={`xs:px-3 xs:py-1.5 dark:border-purple-taupe flex items-center rounded-[32px] border px-2 py-1 transition-colors ${
|
||||
recordingState === 'recording'
|
||||
? 'border-[#B42318] bg-[#FEE4E2] text-[#B42318] dark:bg-[#4A2323]'
|
||||
: 'border-[#AAAAAA] hover:bg-gray-100 dark:hover:bg-[#2C2E3C]'
|
||||
} ${
|
||||
loading || recordingState === 'transcribing'
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{recordingState === 'transcribing' ? (
|
||||
<LoaderCircle className="mr-1 h-3.5 w-3.5 animate-spin sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : recordingState === 'recording' ? (
|
||||
<Square className="mr-1 h-3.5 w-3.5 fill-current sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<Mic className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
<span
|
||||
className={`xs:text-[12px] dark:text-bright-gray text-[10px] font-medium sm:text-[14px] ${
|
||||
recordingState === 'recording'
|
||||
? 'text-[#B42318]'
|
||||
: 'text-[#5D5D5D]'
|
||||
}`}
|
||||
>
|
||||
{voiceButtonText}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<label className="xs:px-3 xs:py-1.5 dark:border-purple-taupe flex cursor-pointer items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:hover:bg-[#2C2E3C]">
|
||||
<img
|
||||
src={ClipIcon}
|
||||
@@ -799,6 +1486,7 @@ export default function MessageInput({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept={FILE_UPLOAD_ACCEPT_ATTR}
|
||||
onChange={handleFileAttachment}
|
||||
/>
|
||||
</label>
|
||||
@@ -819,11 +1507,19 @@ export default function MessageInput({
|
||||
onClick={handleSubmit}
|
||||
aria-label={t('send')}
|
||||
className={`ml-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors duration-300 ease-in-out sm:h-9 sm:w-9 ${
|
||||
value.trim() && !loading
|
||||
value.trim() &&
|
||||
!loading &&
|
||||
recordingState !== 'recording' &&
|
||||
recordingState !== 'transcribing'
|
||||
? 'bg-purple-30 text-white'
|
||||
: 'bg-[#EDEDED] text-[#959595] dark:bg-[#37383D] dark:text-[#77787D]'
|
||||
}`}
|
||||
disabled={!value.trim() || loading}
|
||||
disabled={
|
||||
!value.trim() ||
|
||||
loading ||
|
||||
recordingState === 'recording' ||
|
||||
recordingState === 'transcribing'
|
||||
}
|
||||
>
|
||||
<SendArrowIcon
|
||||
className="mx-auto my-auto block h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
export const AUDIO_FILE_ACCEPT: Record<string, string[]> = {
|
||||
'audio/mpeg': ['.mp3'],
|
||||
'audio/mp4': ['.m4a'],
|
||||
'audio/ogg': ['.ogg'],
|
||||
'audio/wav': ['.wav'],
|
||||
'audio/webm': ['.webm'],
|
||||
'video/webm': ['.webm'],
|
||||
};
|
||||
|
||||
export const FILE_UPLOAD_ACCEPT: Record<string, string[]> = {
|
||||
'application/pdf': ['.pdf'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/x-rst': ['.rst'],
|
||||
'text/x-markdown': ['.md'],
|
||||
'application/zip': ['.zip'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [
|
||||
'.docx',
|
||||
],
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv'],
|
||||
'text/html': ['.html'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
|
||||
'.xlsx',
|
||||
],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': [
|
||||
'.pptx',
|
||||
],
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpeg'],
|
||||
'image/jpg': ['.jpg'],
|
||||
...AUDIO_FILE_ACCEPT,
|
||||
};
|
||||
|
||||
export const FILE_UPLOAD_ACCEPT_ATTR = [
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.rst',
|
||||
'.md',
|
||||
'.zip',
|
||||
'.docx',
|
||||
'.json',
|
||||
'.csv',
|
||||
'.html',
|
||||
'.epub',
|
||||
'.xlsx',
|
||||
'.pptx',
|
||||
'.png',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.wav',
|
||||
'.mp3',
|
||||
'.m4a',
|
||||
'.ogg',
|
||||
'.webm',
|
||||
].join(',');
|
||||
|
||||
export const SOURCE_FILE_TREE_ACCEPT_ATTR = [
|
||||
'.rst',
|
||||
'.md',
|
||||
'.pdf',
|
||||
'.txt',
|
||||
'.docx',
|
||||
'.csv',
|
||||
'.epub',
|
||||
'.html',
|
||||
'.mdx',
|
||||
'.json',
|
||||
'.xlsx',
|
||||
'.pptx',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
].join(',');
|
||||
@@ -32,6 +32,7 @@ import { FormField, IngestorConfig, IngestorType } from './types/ingestor';
|
||||
|
||||
import { FilePicker } from '../components/FilePicker';
|
||||
import GoogleDrivePicker from '../components/GoogleDrivePicker';
|
||||
import { FILE_UPLOAD_ACCEPT } from '../constants/fileUpload';
|
||||
|
||||
import ChevronRight from '../assets/chevron-right.svg';
|
||||
|
||||
@@ -561,7 +562,11 @@ function Upload({
|
||||
files.forEach((file) => {
|
||||
formData.append('file', file);
|
||||
});
|
||||
} else if (hasRemoteFilePicker || hasGoogleDrivePicker || hasSharePointPicker) {
|
||||
} else if (
|
||||
hasRemoteFilePicker ||
|
||||
hasGoogleDrivePicker ||
|
||||
hasSharePointPicker
|
||||
) {
|
||||
const sessionToken = getSessionToken(ingestor.type as string);
|
||||
configData = {
|
||||
provider: ingestor.type as string,
|
||||
@@ -692,27 +697,7 @@ function Upload({
|
||||
onDragOver: doNothing,
|
||||
onDragLeave: doNothing,
|
||||
maxSize: 25000000,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/x-rst': ['.rst'],
|
||||
'text/x-markdown': ['.md'],
|
||||
'application/zip': ['.zip'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
['.docx'],
|
||||
'application/json': ['.json'],
|
||||
'text/csv': ['.csv'],
|
||||
'text/html': ['.html'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [
|
||||
'.xlsx',
|
||||
],
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation':
|
||||
['.pptx'],
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpeg'],
|
||||
'image/jpg': ['.jpg'],
|
||||
},
|
||||
accept: FILE_UPLOAD_ACCEPT,
|
||||
});
|
||||
|
||||
const isUploadDisabled = (): boolean => {
|
||||
@@ -745,7 +730,11 @@ function Upload({
|
||||
if (files.length === 0) {
|
||||
return true;
|
||||
}
|
||||
} else if (hasRemoteFilePicker || hasGoogleDrivePicker || hasSharePointPicker) {
|
||||
} else if (
|
||||
hasRemoteFilePicker ||
|
||||
hasGoogleDrivePicker ||
|
||||
hasSharePointPicker
|
||||
) {
|
||||
if (selectedFiles.length === 0 && selectedFolders.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask, request
|
||||
|
||||
|
||||
def _get_response_status(response):
|
||||
if isinstance(response, tuple):
|
||||
return response[1]
|
||||
return response.status_code
|
||||
|
||||
|
||||
def _get_response_json(response):
|
||||
if isinstance(response, tuple):
|
||||
return response[0].json
|
||||
return response.json
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.values = {}
|
||||
|
||||
def get(self, key):
|
||||
return self.values.get(key)
|
||||
|
||||
def setex(self, key, ttl, value):
|
||||
_ = ttl
|
||||
self.values[key] = value
|
||||
|
||||
def delete(self, key):
|
||||
self.values.pop(key, None)
|
||||
|
||||
|
||||
class TestStoreAttachmentEndpoint:
|
||||
@patch("application.api.user.tasks.store_attachment.delay")
|
||||
def test_store_attachment_preserves_upload_indexes_for_partial_failures(
|
||||
self, mock_store_attachment, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import StoreAttachment
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_storage = MagicMock()
|
||||
mock_store_attachment.side_effect = [
|
||||
SimpleNamespace(id="task-alpha"),
|
||||
SimpleNamespace(id="task-gamma"),
|
||||
]
|
||||
|
||||
def save_file(file, path):
|
||||
_ = path
|
||||
if file.filename == "beta.txt":
|
||||
raise ValueError("Failed to save file")
|
||||
return {"storage_type": "local"}
|
||||
|
||||
mock_storage.save_file.side_effect = save_file
|
||||
|
||||
with patch("application.api.user.base.storage", mock_storage):
|
||||
with app.test_request_context(
|
||||
"/api/store_attachment",
|
||||
method="POST",
|
||||
data={
|
||||
"file": [
|
||||
(io.BytesIO(b"alpha"), "alpha.txt"),
|
||||
(io.BytesIO(b"beta"), "beta.txt"),
|
||||
(io.BytesIO(b"gamma"), "gamma.txt"),
|
||||
]
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = StoreAttachment()
|
||||
response = resource.post()
|
||||
payload = _get_response_json(response)
|
||||
|
||||
assert _get_response_status(response) == 200
|
||||
assert [task["upload_index"] for task in payload["tasks"]] == [0, 2]
|
||||
assert payload["errors"][0]["upload_index"] == 1
|
||||
assert payload["errors"][0]["error"] == "Failed to process file"
|
||||
|
||||
@patch("application.api.user.tasks.store_attachment.delay")
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
def test_store_attachment_rejects_oversized_audio_files(
|
||||
self, mock_limit_settings, mock_store_attachment, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import StoreAttachment
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_limit_settings.STT_MAX_FILE_SIZE_MB = 1
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/store_attachment",
|
||||
method="POST",
|
||||
data={
|
||||
"file": (
|
||||
io.BytesIO(b"x" * (2 * 1024 * 1024)),
|
||||
"meeting.wav",
|
||||
)
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = StoreAttachment()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 413
|
||||
assert "exceeds" in _get_response_json(response)["message"]
|
||||
mock_store_attachment.assert_not_called()
|
||||
|
||||
|
||||
class TestSpeechToTextEndpoint:
|
||||
def test_stt_returns_400_when_file_is_missing(self, flask_app, mock_mongo_db):
|
||||
from application.api.user.attachments.routes import SpeechToText
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context("/api/stt", method="POST", data={}):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = SpeechToText()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 400
|
||||
assert _get_response_json(response)["message"] == "Missing file"
|
||||
|
||||
def test_stt_returns_401_when_authentication_is_missing(
|
||||
self, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import SpeechToText
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt",
|
||||
method="POST",
|
||||
data={"file": (io.BytesIO(b"audio-bytes"), "clip.wav")},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = None
|
||||
|
||||
resource = SpeechToText()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 401
|
||||
assert _get_response_json(response)["message"] == "Authentication required"
|
||||
|
||||
@patch("application.api.user.attachments.routes.STTCreator.create_stt")
|
||||
def test_stt_transcribes_audio_for_authenticated_user(
|
||||
self, mock_create_stt, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import SpeechToText
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_stt = MagicMock()
|
||||
mock_stt.transcribe.return_value = {
|
||||
"text": "hello from audio",
|
||||
"language": "en",
|
||||
"duration_s": 1.2,
|
||||
"segments": [],
|
||||
"provider": "openai",
|
||||
}
|
||||
mock_create_stt.return_value = mock_stt
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt",
|
||||
method="POST",
|
||||
data={
|
||||
"file": (io.BytesIO(b"audio-bytes"), "clip.wav"),
|
||||
"language": "en",
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = SpeechToText()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 200
|
||||
assert _get_response_json(response) == {
|
||||
"success": True,
|
||||
"text": "hello from audio",
|
||||
"language": "en",
|
||||
"duration_s": 1.2,
|
||||
"segments": [],
|
||||
"provider": "openai",
|
||||
}
|
||||
mock_create_stt.assert_called_once()
|
||||
mock_stt.transcribe.assert_called_once()
|
||||
|
||||
def test_stt_rejects_unsupported_extension(self, flask_app, mock_mongo_db):
|
||||
from application.api.user.attachments.routes import SpeechToText
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt",
|
||||
method="POST",
|
||||
data={"file": (io.BytesIO(b"audio-bytes"), "clip.exe")},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = SpeechToText()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 400
|
||||
assert "Unsupported audio format" in _get_response_json(response)["message"]
|
||||
|
||||
|
||||
class TestLiveSpeechToTextEndpoint:
|
||||
@patch("application.api.user.attachments.routes.get_redis_instance")
|
||||
def test_live_stt_start_creates_session(
|
||||
self, mock_get_redis, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import LiveSpeechToTextStart
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_get_redis.return_value = FakeRedis()
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/start",
|
||||
method="POST",
|
||||
json={"language": "ru"},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = LiveSpeechToTextStart()
|
||||
response = resource.post()
|
||||
payload = _get_response_json(response)
|
||||
|
||||
assert _get_response_status(response) == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["language"] == "ru"
|
||||
assert payload["session_id"]
|
||||
assert payload["transcript_text"] == ""
|
||||
|
||||
@patch("application.api.user.attachments.routes.STTCreator.create_stt")
|
||||
@patch("application.api.user.attachments.routes.get_redis_instance")
|
||||
def test_live_stt_chunk_reconciles_transcript_progressively(
|
||||
self, mock_get_redis, mock_create_stt, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import (
|
||||
LiveSpeechToTextChunk,
|
||||
LiveSpeechToTextFinish,
|
||||
LiveSpeechToTextStart,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
fake_redis = FakeRedis()
|
||||
mock_get_redis.return_value = fake_redis
|
||||
|
||||
start_resource = LiveSpeechToTextStart()
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/start",
|
||||
method="POST",
|
||||
json={"language": "ru"},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
start_response = start_resource.post()
|
||||
session_id = _get_response_json(start_response)["session_id"]
|
||||
|
||||
mock_stt = MagicMock()
|
||||
mock_stt.transcribe.side_effect = [
|
||||
{
|
||||
"text": "hello this is a longer test phrase for transcript stabilization today now",
|
||||
"language": "ru",
|
||||
"duration_s": 1.0,
|
||||
"segments": [],
|
||||
"provider": "openai",
|
||||
},
|
||||
{
|
||||
"text": "hello this is a longer test phrase for transcript stabilization today now again later",
|
||||
"language": "ru",
|
||||
"duration_s": 1.0,
|
||||
"segments": [],
|
||||
"provider": "openai",
|
||||
},
|
||||
]
|
||||
mock_create_stt.return_value = mock_stt
|
||||
|
||||
chunk_resource = LiveSpeechToTextChunk()
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/chunk",
|
||||
method="POST",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_index": "0",
|
||||
"file": (io.BytesIO(b"chunk-0"), "chunk-0.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
chunk_response = chunk_resource.post()
|
||||
chunk_payload = _get_response_json(chunk_response)
|
||||
|
||||
assert _get_response_status(chunk_response) == 200
|
||||
assert (
|
||||
chunk_payload["transcript_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now"
|
||||
)
|
||||
assert chunk_payload["committed_text"] == ""
|
||||
assert (
|
||||
chunk_payload["mutable_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now"
|
||||
)
|
||||
assert chunk_payload["finalized_text"] == ""
|
||||
assert (
|
||||
chunk_payload["pending_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now"
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/chunk",
|
||||
method="POST",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_index": "1",
|
||||
"is_silence": "true",
|
||||
"file": (io.BytesIO(b"chunk-1"), "chunk-1.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
chunk_response = chunk_resource.post()
|
||||
chunk_payload = _get_response_json(chunk_response)
|
||||
|
||||
assert _get_response_status(chunk_response) == 200
|
||||
assert (
|
||||
chunk_payload["transcript_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now again later"
|
||||
)
|
||||
assert (
|
||||
chunk_payload["committed_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now"
|
||||
)
|
||||
assert chunk_payload["mutable_text"] == "again later"
|
||||
assert chunk_payload["finalized_text"] == chunk_payload["committed_text"]
|
||||
assert chunk_payload["pending_text"] == "again later"
|
||||
assert chunk_payload["is_silence"] is True
|
||||
|
||||
finish_resource = LiveSpeechToTextFinish()
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/finish",
|
||||
method="POST",
|
||||
json={"session_id": session_id},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
finish_response = finish_resource.post()
|
||||
finish_payload = _get_response_json(finish_response)
|
||||
|
||||
assert _get_response_status(finish_response) == 200
|
||||
assert (
|
||||
finish_payload["text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization today now again later"
|
||||
)
|
||||
|
||||
@patch("application.api.user.attachments.routes.get_redis_instance")
|
||||
def test_live_stt_chunk_rejects_missing_session(
|
||||
self, mock_get_redis, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import LiveSpeechToTextChunk
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_get_redis.return_value = FakeRedis()
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/chunk",
|
||||
method="POST",
|
||||
data={
|
||||
"session_id": "missing-session",
|
||||
"chunk_index": "0",
|
||||
"file": (io.BytesIO(b"chunk-0"), "chunk-0.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
resource = LiveSpeechToTextChunk()
|
||||
response = resource.post()
|
||||
|
||||
assert _get_response_status(response) == 404
|
||||
assert _get_response_json(response)["message"] == "Live transcription session not found"
|
||||
|
||||
@patch("application.api.user.attachments.routes.STTCreator.create_stt")
|
||||
@patch("application.api.user.attachments.routes.get_redis_instance")
|
||||
def test_live_stt_chunk_hides_internal_value_errors(
|
||||
self, mock_get_redis, mock_create_stt, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.attachments.routes import (
|
||||
LiveSpeechToTextChunk,
|
||||
LiveSpeechToTextStart,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
fake_redis = FakeRedis()
|
||||
mock_get_redis.return_value = fake_redis
|
||||
|
||||
start_resource = LiveSpeechToTextStart()
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/start",
|
||||
method="POST",
|
||||
json={"language": "ru"},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
start_response = start_resource.post()
|
||||
session_id = _get_response_json(start_response)["session_id"]
|
||||
|
||||
mock_stt = MagicMock()
|
||||
mock_stt.transcribe.return_value = {
|
||||
"text": "hello there",
|
||||
"language": "ru",
|
||||
"duration_s": 1.0,
|
||||
"segments": [],
|
||||
"provider": "openai",
|
||||
}
|
||||
mock_create_stt.return_value = mock_stt
|
||||
|
||||
chunk_resource = LiveSpeechToTextChunk()
|
||||
with app.test_request_context(
|
||||
"/api/stt/live/chunk",
|
||||
method="POST",
|
||||
data={
|
||||
"session_id": session_id,
|
||||
"chunk_index": "-1",
|
||||
"file": (io.BytesIO(b"chunk-neg"), "chunk-neg.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
response = chunk_resource.post()
|
||||
|
||||
assert _get_response_status(response) == 409
|
||||
assert (
|
||||
_get_response_json(response)["message"]
|
||||
== "Invalid live transcription chunk"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask, request
|
||||
from application.parser.file.constants import SUPPORTED_SOURCE_EXTENSIONS
|
||||
|
||||
|
||||
def test_upload_route_passes_audio_extensions_to_ingest(flask_app, mock_mongo_db):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_storage = MagicMock()
|
||||
mock_task = SimpleNamespace(id="task-123")
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/upload",
|
||||
method="POST",
|
||||
data={
|
||||
"user": "test_user",
|
||||
"name": "Meeting Notes",
|
||||
"file": (io.BytesIO(b"audio-bytes"), "meeting.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
with patch(
|
||||
"application.api.user.sources.upload.StorageCreator.get_storage",
|
||||
return_value=mock_storage,
|
||||
), patch(
|
||||
"application.api.user.sources.upload.ingest.delay",
|
||||
return_value=mock_task,
|
||||
) as mock_delay:
|
||||
resource = UploadFile()
|
||||
response = resource.post()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json["success"] is True
|
||||
|
||||
formats = mock_delay.call_args.args[1]
|
||||
assert formats == list(SUPPORTED_SOURCE_EXTENSIONS)
|
||||
|
||||
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
def test_upload_route_rejects_oversized_audio(
|
||||
mock_limit_settings, flask_app, mock_mongo_db
|
||||
):
|
||||
from application.api.user.sources.upload import UploadFile
|
||||
|
||||
app = Flask(__name__)
|
||||
mock_limit_settings.STT_MAX_FILE_SIZE_MB = 1
|
||||
|
||||
with app.test_request_context(
|
||||
"/api/upload",
|
||||
method="POST",
|
||||
data={
|
||||
"user": "test_user",
|
||||
"name": "Meeting Notes",
|
||||
"file": (io.BytesIO(b"x" * (2 * 1024 * 1024)), "meeting.wav"),
|
||||
},
|
||||
content_type="multipart/form-data",
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
|
||||
with patch("application.api.user.sources.upload.StorageCreator.get_storage"):
|
||||
resource = UploadFile()
|
||||
response = resource.post()
|
||||
|
||||
assert response.status_code == 413
|
||||
assert "exceeds" in response.json["message"]
|
||||
@@ -0,0 +1,122 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from bson import ObjectId
|
||||
from flask import Flask, jsonify, make_response, request
|
||||
|
||||
|
||||
def test_safe_db_operation_hides_exception_details():
|
||||
from application.api.user.utils import safe_db_operation
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
def failing_operation():
|
||||
raise RuntimeError("database credentials leaked")
|
||||
|
||||
with app.app_context():
|
||||
_, error = safe_db_operation(
|
||||
failing_operation,
|
||||
"Failed to create workflow",
|
||||
)
|
||||
|
||||
assert error.status_code == 400
|
||||
assert error.json["message"] == "Failed to create workflow"
|
||||
assert "credentials" not in error.json["message"]
|
||||
|
||||
|
||||
def test_agent_folders_hides_exception_details():
|
||||
from application.api.user.agents.folders import AgentFolders
|
||||
|
||||
app = Flask(__name__)
|
||||
failing_collection = Mock()
|
||||
failing_collection.find.side_effect = RuntimeError("folder backend secret")
|
||||
|
||||
with patch(
|
||||
"application.api.user.agents.folders.agent_folders_collection",
|
||||
failing_collection,
|
||||
):
|
||||
with app.test_request_context("/api/agents/folders/", method="GET"):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
response = AgentFolders().get()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json["message"] == "Failed to fetch folders"
|
||||
assert "secret" not in response.json["message"]
|
||||
|
||||
|
||||
def test_workflow_create_hides_structure_exception_details():
|
||||
from application.api.user.workflows.routes import WorkflowList
|
||||
|
||||
app = Flask(__name__)
|
||||
insert_result = SimpleNamespace(inserted_id=ObjectId())
|
||||
|
||||
with patch(
|
||||
"application.api.user.workflows.routes.safe_db_operation",
|
||||
return_value=(insert_result, None),
|
||||
), patch(
|
||||
"application.api.user.workflows.routes.create_workflow_nodes",
|
||||
side_effect=RuntimeError("storage bucket credentials leaked"),
|
||||
), patch(
|
||||
"application.api.user.workflows.routes.workflow_nodes_collection"
|
||||
) as mock_nodes, patch(
|
||||
"application.api.user.workflows.routes.workflow_edges_collection"
|
||||
) as mock_edges, patch(
|
||||
"application.api.user.workflows.routes.workflows_collection"
|
||||
) as mock_workflows:
|
||||
with app.test_request_context(
|
||||
"/api/workflows",
|
||||
method="POST",
|
||||
json={
|
||||
"name": "Workflow",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start"},
|
||||
{"id": "end", "type": "end"},
|
||||
],
|
||||
"edges": [{"id": "edge-1", "source": "start", "target": "end"}],
|
||||
},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
response = WorkflowList().post()
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json["message"] == "Failed to create workflow structure"
|
||||
assert "credentials" not in response.json["message"]
|
||||
mock_nodes.delete_many.assert_called_once_with(
|
||||
{"workflow_id": str(insert_result.inserted_id)}
|
||||
)
|
||||
mock_edges.delete_many.assert_called_once_with(
|
||||
{"workflow_id": str(insert_result.inserted_id)}
|
||||
)
|
||||
mock_workflows.delete_one.assert_called_once_with(
|
||||
{"_id": insert_result.inserted_id}
|
||||
)
|
||||
|
||||
|
||||
def test_update_agent_reuses_sanitized_image_upload_error():
|
||||
from application.api.user.agents.routes import UpdateAgent
|
||||
|
||||
app = Flask(__name__)
|
||||
agent_id = str(ObjectId())
|
||||
|
||||
with app.test_request_context(
|
||||
f"/api/agents/update_agent/{agent_id}",
|
||||
method="PUT",
|
||||
json={},
|
||||
):
|
||||
request.decoded_token = {"sub": "test_user"}
|
||||
sanitized_error = make_response(
|
||||
jsonify({"success": False, "message": "Image upload failed"}),
|
||||
400,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"application.api.user.agents.routes.agents_collection.find_one",
|
||||
return_value={"_id": ObjectId(agent_id), "user": "test_user"},
|
||||
), patch(
|
||||
"application.api.user.agents.routes.handle_image_upload",
|
||||
return_value=(None, sanitized_error),
|
||||
):
|
||||
response = UpdateAgent().put(agent_id)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json["message"] == "Image upload failed"
|
||||
@@ -0,0 +1,71 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from application.parser.file.audio_parser import AudioParser
|
||||
from application.parser.file.bulk import get_default_file_extractor
|
||||
from application.stt.upload_limits import AudioFileTooLargeError
|
||||
|
||||
|
||||
def test_audio_init_parser():
|
||||
parser = AudioParser()
|
||||
assert isinstance(parser._init_parser(), dict)
|
||||
assert not parser.parser_config_set
|
||||
parser.init_parser()
|
||||
assert parser.parser_config_set
|
||||
|
||||
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
@patch("application.parser.file.audio_parser.STTCreator.create_stt")
|
||||
@patch("application.parser.file.audio_parser.settings")
|
||||
def test_audio_parser_transcribes_file(
|
||||
mock_settings, mock_create_stt, mock_limit_settings, tmp_path
|
||||
):
|
||||
mock_settings.STT_PROVIDER = "openai"
|
||||
mock_settings.STT_LANGUAGE = "en"
|
||||
mock_settings.STT_ENABLE_TIMESTAMPS = False
|
||||
mock_settings.STT_ENABLE_DIARIZATION = False
|
||||
mock_limit_settings.STT_MAX_FILE_SIZE_MB = 25
|
||||
|
||||
mock_stt = MagicMock()
|
||||
mock_stt.transcribe.return_value = {"text": "Transcript from audio"}
|
||||
mock_create_stt.return_value = mock_stt
|
||||
audio_file = tmp_path / "meeting.wav"
|
||||
audio_file.write_bytes(b"audio-bytes")
|
||||
|
||||
parser = AudioParser()
|
||||
result = parser.parse_file(audio_file)
|
||||
|
||||
assert result == "Transcript from audio"
|
||||
mock_create_stt.assert_called_once_with("openai")
|
||||
mock_stt.transcribe.assert_called_once_with(
|
||||
audio_file,
|
||||
language="en",
|
||||
timestamps=False,
|
||||
diarize=False,
|
||||
)
|
||||
|
||||
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
def test_audio_parser_rejects_oversized_files(mock_limit_settings, tmp_path):
|
||||
mock_limit_settings.STT_MAX_FILE_SIZE_MB = 1
|
||||
|
||||
audio_file = tmp_path / "meeting.wav"
|
||||
audio_file.write_bytes(b"x" * (2 * 1024 * 1024))
|
||||
|
||||
parser = AudioParser()
|
||||
|
||||
try:
|
||||
parser.parse_file(audio_file)
|
||||
except AudioFileTooLargeError as exc:
|
||||
assert "exceeds" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected oversized audio file to be rejected")
|
||||
|
||||
|
||||
def test_default_file_extractor_supports_audio_extensions():
|
||||
extractor = get_default_file_extractor()
|
||||
|
||||
assert isinstance(extractor[".wav"], AudioParser)
|
||||
assert isinstance(extractor[".mp3"], AudioParser)
|
||||
assert isinstance(extractor[".m4a"], AudioParser)
|
||||
assert isinstance(extractor[".ogg"], AudioParser)
|
||||
assert isinstance(extractor[".webm"], AudioParser)
|
||||
@@ -0,0 +1,142 @@
|
||||
from application.stt.live_session import (
|
||||
apply_live_stt_hypothesis,
|
||||
finalize_live_stt_session,
|
||||
get_live_stt_transcript_text,
|
||||
strip_committed_prefix,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_committed_prefix_removes_full_prefix_match():
|
||||
assert (
|
||||
strip_committed_prefix(
|
||||
"hello this is committed",
|
||||
"hello this is committed and this stays mutable",
|
||||
)
|
||||
== "and this stays mutable"
|
||||
)
|
||||
|
||||
|
||||
def test_strip_committed_prefix_removes_committed_suffix_overlap():
|
||||
assert (
|
||||
strip_committed_prefix(
|
||||
"one two three four five",
|
||||
"four five six seven eight",
|
||||
)
|
||||
== "six seven eight"
|
||||
)
|
||||
|
||||
|
||||
def test_apply_live_stt_hypothesis_keeps_initial_hypothesis_mutable():
|
||||
session_state = {
|
||||
"session_id": "session-1",
|
||||
"user": "test-user",
|
||||
"language": "ru",
|
||||
"committed_text": "",
|
||||
"mutable_text": "",
|
||||
"previous_hypothesis": "",
|
||||
"latest_hypothesis": "",
|
||||
"last_chunk_index": -1,
|
||||
}
|
||||
|
||||
apply_live_stt_hypothesis(
|
||||
session_state,
|
||||
"hello this is a longer test phrase for transcript stabilization",
|
||||
0,
|
||||
)
|
||||
|
||||
assert session_state["committed_text"] == ""
|
||||
assert (
|
||||
session_state["mutable_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization"
|
||||
)
|
||||
|
||||
|
||||
def test_apply_live_stt_hypothesis_commits_stable_prefix_beyond_mutable_tail():
|
||||
session_state = {
|
||||
"session_id": "session-1",
|
||||
"user": "test-user",
|
||||
"language": "ru",
|
||||
"committed_text": "",
|
||||
"mutable_text": "",
|
||||
"previous_hypothesis": "",
|
||||
"latest_hypothesis": "",
|
||||
"last_chunk_index": -1,
|
||||
}
|
||||
|
||||
first_hypothesis = (
|
||||
"hello this is a longer test phrase for transcript stabilization today now"
|
||||
)
|
||||
second_hypothesis = (
|
||||
"hello this is a longer test phrase for transcript stabilization today now again later"
|
||||
)
|
||||
|
||||
apply_live_stt_hypothesis(session_state, first_hypothesis, 0)
|
||||
apply_live_stt_hypothesis(session_state, second_hypothesis, 1)
|
||||
|
||||
assert session_state["committed_text"] == "hello this is a longer test"
|
||||
assert (
|
||||
session_state["mutable_text"]
|
||||
== "phrase for transcript stabilization today now again later"
|
||||
)
|
||||
assert (
|
||||
get_live_stt_transcript_text(session_state)
|
||||
== "hello this is a longer test phrase for transcript stabilization today now again later"
|
||||
)
|
||||
|
||||
|
||||
def test_apply_live_stt_hypothesis_commits_more_aggressively_on_silence():
|
||||
session_state = {
|
||||
"session_id": "session-1",
|
||||
"user": "test-user",
|
||||
"language": "ru",
|
||||
"committed_text": "",
|
||||
"mutable_text": "",
|
||||
"previous_hypothesis": "",
|
||||
"latest_hypothesis": "",
|
||||
"last_chunk_index": -1,
|
||||
}
|
||||
|
||||
hypothesis = "hello this is a longer test phrase for transcript stabilization today now"
|
||||
apply_live_stt_hypothesis(session_state, hypothesis, 0)
|
||||
apply_live_stt_hypothesis(session_state, hypothesis, 1, is_silence=True)
|
||||
|
||||
assert (
|
||||
session_state["committed_text"]
|
||||
== "hello this is a longer test phrase for transcript stabilization"
|
||||
)
|
||||
assert session_state["mutable_text"] == "today now"
|
||||
|
||||
|
||||
def test_finalize_live_stt_session_returns_committed_and_mutable_text():
|
||||
session_state = {
|
||||
"session_id": "session-1",
|
||||
"user": "test-user",
|
||||
"language": "ru",
|
||||
"committed_text": "hello this is",
|
||||
"mutable_text": "a live transcript",
|
||||
"previous_hypothesis": "a live transcript",
|
||||
"latest_hypothesis": "a live transcript",
|
||||
"last_chunk_index": 1,
|
||||
}
|
||||
|
||||
assert finalize_live_stt_session(session_state) == "hello this is a live transcript"
|
||||
|
||||
|
||||
def test_apply_live_stt_hypothesis_rejects_older_chunks():
|
||||
session_state = {
|
||||
"session_id": "session-1",
|
||||
"user": "test-user",
|
||||
"language": "ru",
|
||||
"committed_text": "",
|
||||
"mutable_text": "hello there",
|
||||
"previous_hypothesis": "hello there",
|
||||
"latest_hypothesis": "hello there",
|
||||
"last_chunk_index": 1,
|
||||
}
|
||||
|
||||
try:
|
||||
apply_live_stt_hypothesis(session_state, "hello there again", 0)
|
||||
except ValueError as exc:
|
||||
assert "older" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected older chunk to raise ValueError")
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from application.stt.stt_creator import STTCreator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stt_creator():
|
||||
return STTCreator()
|
||||
|
||||
|
||||
def test_create_openai_stt(stt_creator):
|
||||
with patch.dict(STTCreator.stt_providers, {"openai": MagicMock()}):
|
||||
mock_openai_stt = STTCreator.stt_providers["openai"]
|
||||
instance = MagicMock()
|
||||
mock_openai_stt.return_value = instance
|
||||
|
||||
result = stt_creator.create_stt("openai", "arg1", language="en")
|
||||
|
||||
mock_openai_stt.assert_called_once_with("arg1", language="en")
|
||||
assert result == instance
|
||||
|
||||
|
||||
def test_create_faster_whisper_stt(stt_creator):
|
||||
with patch.dict(STTCreator.stt_providers, {"faster_whisper": MagicMock()}):
|
||||
mock_faster_whisper_stt = STTCreator.stt_providers["faster_whisper"]
|
||||
instance = MagicMock()
|
||||
mock_faster_whisper_stt.return_value = instance
|
||||
|
||||
result = stt_creator.create_stt("faster_whisper", model_size="base")
|
||||
|
||||
mock_faster_whisper_stt.assert_called_once_with(model_size="base")
|
||||
assert result == instance
|
||||
|
||||
|
||||
def test_invalid_stt_type(stt_creator):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
stt_creator.create_stt("unknown_stt")
|
||||
assert "No stt class found" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_stt_type_case_insensitivity(stt_creator):
|
||||
with patch.dict(STTCreator.stt_providers, {"openai": MagicMock()}):
|
||||
mock_openai_stt = STTCreator.stt_providers["openai"]
|
||||
instance = MagicMock()
|
||||
mock_openai_stt.return_value = instance
|
||||
|
||||
result = stt_creator.create_stt("OpEnAi")
|
||||
|
||||
mock_openai_stt.assert_called_once_with()
|
||||
assert result == instance
|
||||
|
||||
|
||||
def test_stt_providers_integrity(stt_creator):
|
||||
providers = stt_creator.stt_providers
|
||||
assert "openai" in providers
|
||||
assert "faster_whisper" in providers
|
||||
assert callable(providers["openai"])
|
||||
assert callable(providers["faster_whisper"])
|
||||
@@ -0,0 +1,39 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from application.stt.upload_limits import (
|
||||
build_stt_file_size_limit_message,
|
||||
enforce_audio_file_size_limit,
|
||||
is_audio_filename,
|
||||
should_reject_stt_request,
|
||||
)
|
||||
|
||||
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
def test_should_reject_stt_request_when_content_length_exceeds_limit(mock_settings):
|
||||
mock_settings.STT_MAX_FILE_SIZE_MB = 1
|
||||
|
||||
assert (
|
||||
should_reject_stt_request(
|
||||
"/api/stt",
|
||||
(2 * 1024 * 1024) + 1,
|
||||
)
|
||||
is True
|
||||
)
|
||||
assert should_reject_stt_request("/api/upload", (2 * 1024 * 1024) + 1) is False
|
||||
|
||||
|
||||
@patch("application.stt.upload_limits.settings")
|
||||
def test_enforce_audio_file_size_limit_uses_configured_message(mock_settings):
|
||||
mock_settings.STT_MAX_FILE_SIZE_MB = 1
|
||||
|
||||
try:
|
||||
enforce_audio_file_size_limit(2 * 1024 * 1024)
|
||||
except ValueError as exc:
|
||||
assert str(exc) == build_stt_file_size_limit_message()
|
||||
else:
|
||||
raise AssertionError("Expected oversized audio file to raise")
|
||||
|
||||
|
||||
def test_is_audio_filename_handles_supported_extensions():
|
||||
assert is_audio_filename("meeting.wav") is True
|
||||
assert is_audio_filename("meeting.txt") is False
|
||||
@@ -0,0 +1,91 @@
|
||||
from bson import ObjectId
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from application.parser.schema.base import Document
|
||||
|
||||
|
||||
def test_attachment_worker_persists_transcript_metadata(mock_mongo_db):
|
||||
from application.worker import attachment_worker
|
||||
|
||||
task = MagicMock()
|
||||
mock_storage = MagicMock()
|
||||
mock_storage.process_file.return_value = Document(
|
||||
text="transcribed meeting notes",
|
||||
extra_info={
|
||||
"transcript_language": "en",
|
||||
"transcript_duration_s": 12.5,
|
||||
"transcript_provider": "openai",
|
||||
},
|
||||
)
|
||||
file_info = {
|
||||
"filename": "meeting.wav",
|
||||
"attachment_id": "507f1f77bcf86cd799439011",
|
||||
"path": "inputs/test_user/attachments/507f1f77bcf86cd799439011/meeting.wav",
|
||||
"metadata": {"storage_type": "local"},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"application.worker.StorageCreator.get_storage",
|
||||
return_value=mock_storage,
|
||||
), patch("application.worker.num_tokens_from_string", return_value=10):
|
||||
result = attachment_worker(task, file_info, "test_user")
|
||||
|
||||
stored_attachment = mock_mongo_db["docsgpt"]["attachments"].find_one(
|
||||
{"_id": ObjectId(result["attachment_id"])}
|
||||
)
|
||||
|
||||
assert stored_attachment is not None
|
||||
assert stored_attachment["metadata"]["storage_type"] == "local"
|
||||
assert stored_attachment["metadata"]["transcript_language"] == "en"
|
||||
assert stored_attachment["metadata"]["transcript_duration_s"] == 12.5
|
||||
assert stored_attachment["metadata"]["transcript_provider"] == "openai"
|
||||
|
||||
|
||||
def test_attachment_worker_preserves_reader_metadata_for_audio(
|
||||
mock_mongo_db, tmp_path
|
||||
):
|
||||
from application.worker import attachment_worker
|
||||
|
||||
task = MagicMock()
|
||||
mock_storage = MagicMock()
|
||||
fake_audio_file = tmp_path / "meeting.wav"
|
||||
fake_audio_file.write_bytes(b"audio-bytes")
|
||||
|
||||
fake_parser = MagicMock()
|
||||
fake_parser.parser_config_set = True
|
||||
fake_parser.parse_file.return_value = "transcribed meeting notes"
|
||||
fake_parser.get_file_metadata.return_value = {
|
||||
"transcript_language": "en",
|
||||
"transcript_duration_s": 12.5,
|
||||
"transcript_provider": "openai",
|
||||
}
|
||||
|
||||
def process_file(path, processor_func, **kwargs):
|
||||
_ = path, kwargs
|
||||
return processor_func(local_path=str(fake_audio_file))
|
||||
|
||||
mock_storage.process_file.side_effect = process_file
|
||||
file_info = {
|
||||
"filename": "meeting.wav",
|
||||
"attachment_id": "507f1f77bcf86cd799439012",
|
||||
"path": "inputs/test_user/attachments/507f1f77bcf86cd799439012/meeting.wav",
|
||||
"metadata": {"storage_type": "local"},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"application.worker.StorageCreator.get_storage",
|
||||
return_value=mock_storage,
|
||||
), patch(
|
||||
"application.worker.get_default_file_extractor",
|
||||
return_value={".wav": fake_parser},
|
||||
), patch("application.worker.num_tokens_from_string", return_value=10):
|
||||
result = attachment_worker(task, file_info, "test_user")
|
||||
|
||||
stored_attachment = mock_mongo_db["docsgpt"]["attachments"].find_one(
|
||||
{"_id": ObjectId(result["attachment_id"])}
|
||||
)
|
||||
|
||||
assert stored_attachment is not None
|
||||
assert stored_attachment["metadata"]["transcript_language"] == "en"
|
||||
assert stored_attachment["metadata"]["transcript_duration_s"] == 12.5
|
||||
assert stored_attachment["metadata"]["transcript_provider"] == "openai"
|
||||
Reference in new issue
Block a user