* stt init

* fix: limits

* fix: errors

* fix: error messages
This commit is contained in:
Alex authored and GitHub committed 2026-03-17 14:27:48 +00:00
1 parent d439e48b39
commit 21996af626
38 files changed
+3096 -188

No files matched your search

+3 -3
View File
@@ -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>
+20 -15
View File
@@ -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)
+2 -8
View File
@@ -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
)
)
+491 -16
View File
@@ -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}"
+29 -17
View File
@@ -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)
+12 -3
View File
@@ -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(
+11 -6
View File
@@ -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()
+21
View File
@@ -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":
+6
View File
@@ -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
+48
View File
@@ -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), {})
+5
View 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 {}
+12
View File
@@ -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:
+27
View File
@@ -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,
)
+1
View File
@@ -0,0 +1 @@
"""Speech-to-text providers."""
+15
View File
@@ -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
+15
View File
@@ -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",
}
+70
View File
@@ -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",
}
+201
View File
@@ -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", "")),
)
+60
View File
@@ -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 {}
+17
View File
@@ -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)
+43
View File
@@ -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
View File
@@ -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,
)
+45 -1
View File
@@ -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.
+3 -2
View File
@@ -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.
+4
View File
@@ -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',
+50
View File
@@ -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,
+2 -2
View File
@@ -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;
+751 -55
View File
@@ -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"
+74
View File
@@ -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(',');
+12 -23
View File
@@ -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;
}
+437
View File
@@ -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"
+71
View File
@@ -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)
+142
View File
@@ -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")
+59
View File
@@ -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"])
+39
View File
@@ -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
+91
View File
@@ -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"