(tools) add basic translate tool

This commit is contained in:
natoromano
2026-02-20 13:44:18 +01:00
parent ba712af899
commit bd4b312bba
10 changed files with 558 additions and 6 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(waffle) hide the waffle if not fr theme
- ✨(front) allow pasting an attachment from clipboard
- ✨(array) temporarily adjust array
- ✨(tools) add basic translate tool
### Changed
+23
View File
@@ -0,0 +1,23 @@
"""Build the translation agent."""
import dataclasses
import logging
from django.conf import settings
from .base import BaseAgent
logger = logging.getLogger(__name__)
@dataclasses.dataclass(init=False)
class TranslationAgent(BaseAgent):
"""Create a Pydantic AI translation Agent instance with the configured settings"""
def __init__(self, **kwargs):
"""Initialize the agent with the configured model."""
super().__init__(
model_hrid=settings.LLM_SUMMARIZATION_MODEL_HRID,
output_type=str,
**kwargs,
)
+17
View File
@@ -142,6 +142,7 @@ from chat.mcp_servers import get_mcp_servers
from chat.tools.document_generic_search_rag import add_document_rag_search_tool_from_setting
from chat.tools.document_search_rag import add_document_rag_search_tool
from chat.tools.document_summarize import document_summarize
from chat.tools.document_translate import document_translate
from chat.vercel_ai_sdk.core import events_v4, events_v5
from chat.vercel_ai_sdk.encoder import CURRENT_EVENT_ENCODER_VERSION, EventEncoder
@@ -667,6 +668,16 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"You may add a follow-up question after the summary if needed."
)
@self.conversation_agent.instructions
def translation_system_prompt() -> str:
return (
"When you receive a result from the translation tool, you MUST return it "
"directly to the user without any modification, paraphrasing, or additional "
"summarization. "
"The tool already produces complete translations that should be presented "
"verbatim."
)
# Inform the model (system-level) that documents are attached and available
@self.conversation_agent.instructions
def attached_documents_note() -> str:
@@ -682,6 +693,12 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
"""Wrap the document_summarize tool to provide context and add the tool."""
return await document_summarize(ctx, *args, **kwargs)
@self.conversation_agent.tool(name="translate", retries=2)
@functools.wraps(document_translate)
async def translate(ctx: RunContext, *args, **kwargs) -> ToolReturn:
"""Wrap the document_translate tool to provide context and add the tool."""
return await document_translate(ctx, *args, **kwargs)
async def _handle_input_documents(
self,
input_documents: List[BinaryContent | DocumentUrl],
+29
View File
@@ -15,6 +15,7 @@ import core.urls
import chat.views
import conversations.urls
from chat.agents.summarize import SummarizationAgent
from chat.agents.translate import TranslationAgent
from chat.clients.pydantic_ai import AIAgentService
logger = logging.getLogger(__name__)
@@ -91,6 +92,34 @@ def mock_summarization_agent_fixture():
yield _mock_agent
@pytest.fixture(name="mock_translation_agent")
def mock_translation_agent_fixture():
"""Fixture to mock TranslationAgent with a custom model."""
@contextmanager
def _mock_agent(model):
"""Context manager to mock TranslationAgent with a custom model."""
with ExitStack() as stack:
class TranslationAgentMock(TranslationAgent):
"""Mocked TranslationAgent to override the model."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
logger.info("Overriding TranslationAgent model with %s", model)
self._model = model # pylint: disable=protected-access
stack.enter_context(
patch("chat.agents.translate.TranslationAgent", new=TranslationAgentMock)
)
stack.enter_context(
patch("chat.tools.document_translate.TranslationAgent", new=TranslationAgentMock)
)
yield
yield _mock_agent
PIXEL_PNG = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00"
b"\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfe"
@@ -0,0 +1,311 @@
"""Tests for document_translate functionality."""
import io
from unittest import mock
from django.core.files.storage import default_storage
import pytest
from pydantic_ai import ModelResponse, RunContext, TextPart
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.models.function import FunctionModel
from pydantic_ai.usage import RunUsage
from chat.llm_configuration import LLModel, LLMProvider
from chat.tools.document_translate import document_translate
@pytest.fixture(autouse=True)
def fixture_translation_agent_config(settings):
"""Fixture to set used settings for agent configuration."""
settings.TRANSLATION_MAX_CHARS = 100_000
settings.LLM_CONFIGURATIONS = {
settings.LLM_SUMMARIZATION_MODEL_HRID: LLModel(
hrid="mistral-model",
model_name="mistral-7b-instruct-v0.1",
human_readable_name="Mistral 7B Instruct",
profile=None,
provider=LLMProvider(
hrid="mistral",
kind="mistral",
base_url="https://api.mistral.ai/v1",
api_key="testkey",
),
is_active=True,
system_prompt="direct",
tools=[],
),
}
@pytest.fixture(name="mocked_context")
def fixture_mocked_context():
"""Fixture for a mocked RunContext."""
mock_ctx = mock.Mock(spec=RunContext)
mock_ctx.usage = RunUsage(input_tokens=0, output_tokens=0)
mock_ctx.max_retries = 2
mock_ctx.retries = {}
return mock_ctx
def _mock_attachments_queryset(attachment):
"""Create a mock queryset that chains .filter().order_by().afirst() returning the attachment."""
mock_qs = mock.Mock()
mock_qs.order_by.return_value = mock_qs
mock_qs.afirst = mock.AsyncMock(return_value=attachment)
mock_attachments = mock.Mock()
mock_attachments.filter.return_value = mock_qs
return mock_attachments
def mocked_translation(_messages, _info=None):
"""Mocked translation response."""
return ModelResponse(parts=[TextPart(content="Ceci est une traduction du document.")])
@pytest.mark.asyncio
async def test_document_translate_single_document(mocked_context, mock_translation_agent):
"""Test document_translate with a single document."""
mock_attachment = mock.Mock()
mock_attachment.key = "test_doc.txt"
mock_attachment.file_name = "test_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a test document. " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_full(_message, _info=None):
"""Mocked translation for full flow."""
return ModelResponse(parts=[TextPart(content="Ceci est un document de test.")])
with mock_translation_agent(FunctionModel(mocked_translate_full)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "Ceci est un document de test." in result.return_value
assert result.metadata["sources"] == {"test_doc.txt"}
@pytest.mark.asyncio
async def test_document_translate_uses_last_document(mocked_context, mock_translation_agent):
"""Test document_translate uses the last uploaded document."""
mock_attachment = mock.Mock()
mock_attachment.key = "latest_doc.txt"
mock_attachment.file_name = "latest_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Content of the latest document."
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert result.metadata["sources"] == {"latest_doc.txt"}
# Verify order_by was called with -created_at
mock_conversation.attachments.filter.return_value.order_by.assert_called_once_with(
"-created_at"
)
@pytest.mark.asyncio
async def test_document_translate_with_custom_instructions(mocked_context, mock_translation_agent):
"""Test document_translate with custom instructions."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
captured_prompts = []
def mocked_translate_with_instructions(messages, _info=None):
"""Mocked translation that captures prompt."""
messages_text = messages[0].parts[-1].content
captured_prompts.append(messages_text)
return ModelResponse(parts=[TextPart(content="Traduction formelle")])
with mock_translation_agent(FunctionModel(mocked_translate_with_instructions)):
result = await document_translate(
mocked_context, target_language="French", instructions="Use formal tone"
)
assert result.return_value is not None
assert len(captured_prompts) == 1
assert "Use formal tone" in captured_prompts[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("target_language", [None, ""])
async def test_document_translate_no_target_language(
target_language, mocked_context, mock_translation_agent
):
"""Test document_translate asks the user for language when target_language is not specified."""
mocked_context.deps = mock.Mock()
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language=target_language, instructions=None
)
assert "target language is not specified" in result
@pytest.mark.asyncio
async def test_document_translate_no_text_attachments(mocked_context, mock_translation_agent):
"""Test document_translate returns error message when no text documents found."""
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(None)
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "No text documents found in the conversation" in result
@pytest.mark.asyncio
async def test_document_translate_error_reading_document(mocked_context, mock_translation_agent):
"""Test document_translate handles errors when reading documents."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
with mock.patch.object(default_storage, "open", side_effect=IOError("File read error")):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "An unexpected error occurred during document translation" in result
@pytest.mark.asyncio
async def test_document_translate_error_during_translation(mocked_context, mock_translation_agent):
"""Test document_translate handles ModelRetry during translation."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_translate_error(_messages, _info=None):
"""Mocked translation that raises an error."""
raise ValueError("Translation error")
with mock_translation_agent(FunctionModel(mocked_translate_error)):
with pytest.raises(ModelRetry):
await document_translate(
mocked_context, target_language="French", instructions=None
)
@pytest.mark.asyncio
async def test_document_translate_too_large(settings, mocked_context, mock_translation_agent):
"""Test document_translate rejects documents exceeding max chars."""
settings.TRANSLATION_MAX_CHARS = 100 # Very small limit
mock_attachment = mock.Mock()
mock_attachment.key = "large_doc.txt"
mock_attachment.file_name = "large_doc.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "This is a word. " * 100 # Much larger than 100 chars
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
with mock_translation_agent(FunctionModel(mocked_translation)):
result = await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "too large to translate" in result
@pytest.mark.asyncio
async def test_document_translate_empty_result(mocked_context, mock_translation_agent):
"""Test document_translate raises ModelRetry when translation produces empty result."""
mock_attachment = mock.Mock()
mock_attachment.key = "test.txt"
mock_attachment.file_name = "test.txt"
mock_attachment.content_type = "text/plain"
mock_conversation = mock.Mock()
mock_conversation.attachments = _mock_attachments_queryset(mock_attachment)
file_content = "Test content " * 20
with mock.patch.object(
default_storage, "open", return_value=io.BytesIO(file_content.encode("utf-8"))
):
mocked_context.deps = mock.Mock()
mocked_context.deps.conversation = mock_conversation
def mocked_empty_translation(_messages, _info=None):
"""Mocked translation that returns empty."""
return ModelResponse(parts=[TextPart(content=" ")])
with mock_translation_agent(FunctionModel(mocked_empty_translation)):
with pytest.raises(ModelRetry) as exc_info:
await document_translate(
mocked_context, target_language="French", instructions=None
)
assert "produced an empty result" in str(exc_info.value)
@@ -0,0 +1,139 @@
"""Translation tool used for uploaded documents."""
import logging
from django.conf import settings
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ModelRetry
from pydantic_ai.messages import ToolReturn
from chat.agents.translate import TranslationAgent
from chat.tools.document_summarize import read_document_content
from chat.tools.exceptions import ModelCannotRetry
from chat.tools.utils import last_model_retry_soft_fail
logger = logging.getLogger(__name__)
@last_model_retry_soft_fail
async def document_translate(
ctx: RunContext,
*,
target_language: str | None = None,
instructions: str | None = None,
) -> ToolReturn:
"""
Translate the full content of uploaded documents into the specified target language.
Preserve the original markdown formatting unless the instructions say otherwise.
Return this translation directly to the user WITHOUT any modification
or additional summarization.
The translation is already complete and MUST be presented as-is in the final response.
If target_language isn't specified or unknown, the target language should be asked
to the user.
Instructions are optional but should reflect the user's request.
Examples:
"Translate this doc to English" -> target_language = "English", instructions = ""
"Translate to Spanish, in formal tone" -> target_language = "Spanish",
instructions = "Use formal tone"
"Traduis ce document en français" -> target_language = "French", instructions = ""
"Translate to German, keep technical terms in English" -> target_language = "German",
instructions = "Keep technical terms in English"
"Translate this" -> ask the user: "Which language would you like the document
translated into?"
Args:
target_language (str | None): The language to translate the document into.
If None, ask the user.
instructions (str | None): Optional instructions for the translation style or preferences
"""
try:
if not target_language:
raise ModelCannotRetry(
"The target language is not specified. "
"You must ask the user which language they want the document translated into."
)
instructions_hint = (
f"Follow these instructions: {instructions.strip()}" if instructions else ""
)
translation_agent = TranslationAgent()
# Get the last uploaded text document
last_attachment = await (
ctx.deps.conversation.attachments.filter(
content_type__startswith="text/",
)
.order_by("-created_at")
.afirst()
)
if not last_attachment:
raise ModelCannotRetry(
"No text documents found in the conversation. "
"You must explain this to the user and ask them to provide documents."
)
doc_name, content = await read_document_content(last_attachment)
# Check content size against the configured limit
max_chars = settings.TRANSLATION_MAX_CHARS
if len(content) > max_chars:
raise ModelCannotRetry(
f"The document is too large to translate ({len(content):,} characters, "
f"limit is {max_chars:,}). "
"You must explain this to the user, without providing numerical details. "
"Suggest them to reduce the document size by summarizing it or "
"by splitting it into smaller parts. "
"Also offer them to summarize the document in the target language instead, "
"which can be a good alternative to translation for large documents."
)
logger.info(
"[translate] translating '%s', %s chars, target_language='%s', instructions='%s'",
doc_name,
len(content),
target_language,
instructions_hint,
)
# Translate the document directly
translate_prompt = (
f"You are an agent specializing in text translation. "
f"Translate the following document to {target_language}. "
f"Preserve all markdown formatting exactly as-is. "
f"{instructions_hint}\n\n"
f"'''\n{content}\n'''\n\n"
f"Respond directly with the translated text only, no commentary."
)
logger.debug("[translate] prompt for '%s'=> %s", doc_name, translate_prompt[:100] + "...")
try:
resp = await translation_agent.run(translate_prompt, usage=ctx.usage)
except Exception as exc:
logger.warning("Error during translation of '%s': %s", doc_name, exc, exc_info=True)
raise ModelRetry(f"An error occurred while translating document '{doc_name}'.") from exc
translated_text = (resp.output or "").strip()
if not translated_text:
raise ModelRetry(f"The translation of '{doc_name}' produced an empty result.")
logger.debug("[translate] final translation length: %s chars", len(translated_text))
return ToolReturn(
return_value=translated_text,
metadata={"sources": {doc_name}},
)
except (ModelCannotRetry, ModelRetry):
raise
except Exception as exc:
logger.exception("Unexpected error in document_translate: %s", exc)
raise ModelCannotRetry(
f"An unexpected error occurred during document translation: {type(exc).__name__}. "
"You must explain this to the user and not try to answer based on your knowledge."
) from exc
+7
View File
@@ -883,6 +883,13 @@ USER QUESTION:
environ_prefix=None,
)
# Translation
TRANSLATION_MAX_CHARS = values.PositiveIntegerValue(
default=100_000, # ~100k characters, roughly half a 128k context window
environ_name="TRANSLATION_MAX_CHARS",
environ_prefix=None,
)
# Tavily API
TAVILY_API_KEY = values.Value(
None, # Tavily API key is not set by default
@@ -227,6 +227,21 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
return tool?.toolInvocation;
}, [toolInvocationParts]);
const activeToolInvocationDisplayName = React.useMemo(() => {
if (!activeToolInvocation) {
return '';
}
if (activeToolInvocation.toolName === 'summarize') {
return t('Summarizing...');
}
if (activeToolInvocation.toolName === 'translate') {
return t('Translation in progress...');
}
return t('Search...');
}, [activeToolInvocation, t]);
// Memoize the streaming content split to avoid recreating components in JSX
const { completedBlocks, pending } = React.useMemo(() => {
// When not streaming, everything is completed as a single block array
@@ -361,9 +376,7 @@ const MessageItemComponent: React.FC<MessageItemProps> = ({
>
<Loader />
<Text $variation="600" $size="md">
{activeToolInvocation?.toolName === 'summarize'
? t('Summarizing...')
: t('Search...')}
{activeToolInvocationDisplayName}
</Text>
</Box>
)}
@@ -48,9 +48,13 @@ export const ToolInvocationItem: React.FC<ToolInvocationItemProps> = ({
>
<Loader />
<Text $variation="600" $size="md">
{t('Extracting documents: {{documents}} ...', {
documents: documentIdentifiers.join(', '),
})}
{documentIdentifiers.length === 1
? t('Extracting document: {{documents}} ...', {
documents: documentIdentifiers[0],
})
: t('Extracting documents: {{documents}} ...', {
documents: documentIdentifiers.join(', '),
})}
</Text>
</Box>
);
@@ -54,6 +54,7 @@
"Direct exchange with our team": "Échange direct avec notre équipe",
"Enter your message or a question": "Entrez votre message ou une question",
"Explore other LaSuite apps": "Explorer les autres applications de LaSuite",
"Extracting document: {{documents}} ...": "Extraction du document : {{documents}}...",
"Extracting documents: {{documents}} ...": "Extraction des documents : {{documents}}...",
"Failed to activate account. Please try again.": "Échec de l'activation du compte. Veuillez réessayer.",
"Failed to copy": "Échec de la copie",
@@ -119,6 +120,7 @@
"Start conversation": "Entamer la conversation",
"Stop": "Stop",
"Summarizing...": "Résumé en cours...",
"Translation in progress...": "Traduction en cours...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "L'Assistant est une IA souveraine destinée aux fonctionnaires. Il les aide dans leurs tâches quotidiennes (reformulation, résumé, traduction, recherche d'informations). Vos données restent en France, sur une infrastructure sécurisée et conforme aux normes nationales, et ne sont jamais utilisées à des fins commerciales.",
"The Assistant is in Beta": "L'Assistant est en Bêta",
"The conversation has been deleted.": "La conversation a été supprimée.",
@@ -193,6 +195,7 @@
"Direct exchange with our team": "Directe uitwisseling met ons team",
"Enter your message or a question": "Voer uw bericht of een vraag in",
"Explore other LaSuite apps": "Ontdek andere LaSuite-apps",
"Extracting document: {{documents}} ...": "Document uitpakken: {{documents}}...",
"Extracting documents: {{documents}} ...": "Documenten uitpakken: {{documents}}...",
"Failed to activate account. Please try again.": "Account activeren mislukt. Probeer het opnieuw.",
"Failed to copy": "Kopiëren mislukt",
@@ -258,6 +261,7 @@
"Start conversation": "Begin een gesprek",
"Stop": "Stop",
"Summarizing...": "Samenvatten...",
"Translation in progress...": "Vertaling bezig...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "De assistent is een soevereine AI voor overheidsambtenaren. Het helpt met dagelijkse taken (herformuleren, samenvatten, vertalen, informatiezoeken). Je gegevens blijven in Nederland op een veilige, door de staat compatibele infrastructuur en worden nooit voor commerciële doeleinden gebruikt.",
"The Assistant is in Beta": "De Assistent is in bèta",
"The conversation has been deleted.": "Het gesprek is verwijderd.",
@@ -332,6 +336,7 @@
"Direct exchange with our team": "Прямое общение с нашей командой",
"Enter your message or a question": "Введите сообщение или вопрос",
"Explore other LaSuite apps": "Посмотреть другие приложения LaSuite",
"Extracting document: {{documents}} ...": "Извлечение документа: {{documents}}...",
"Extracting documents: {{documents}} ...": "Извлечение документов: {{documents}}...",
"Failed to activate account. Please try again.": "Не удалось активировать учётную запись. Пожалуйста, попробуйте снова.",
"Failed to copy": "Не удалось скопировать",
@@ -397,6 +402,7 @@
"Start conversation": "Начать беседу",
"Stop": "Остановить",
"Summarizing...": "Обобщение...",
"Translation in progress...": "Перевод выполняется...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помощник — независимый ИИ для государственных служащих. Он помогает в выполнении повседневных задач (перефразирование, резюмирование, перевод, поиск информации). Ваши данные хранятся во Франции на безопасной инфраструктуре, соответствующей государственным требованиям, и никогда не используются в коммерческих целях.",
"The Assistant is in Beta": "Помощник находится на этапе Бета-версии",
"The conversation has been deleted.": "Беседа была удалена.",
@@ -471,6 +477,7 @@
"Direct exchange with our team": "Пряме спілкування з нашою командою",
"Enter your message or a question": "Введіть ваше повідомлення або питання",
"Explore other LaSuite apps": "Ознайомтесь з іншими застосунками LaSuite",
"Extracting document: {{documents}} ...": "Видобування документа: {{documents}}...",
"Extracting documents: {{documents}} ...": "Видобування документів: {{documents}}...",
"Failed to activate account. Please try again.": "Не вдалося активувати обліковий запис. Спробуйте ще раз.",
"Failed to copy": "Не вдалось скопіювати",
@@ -536,6 +543,7 @@
"Start conversation": "Почати розмову",
"Stop": "Зупинити",
"Summarizing...": "Узагальнення...",
"Translation in progress...": "Переклад виконується...",
"The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.": "Помічник — це незалежний штучний інтелект для державних службовців. Він допомагає у виконанні щоденних завдань (перефразування, узагальнення, переклад, пошук інформації). Ваші дані зберігаються у Франції на безпечній інфраструктурі, що відповідає державним вимогам, і ніколи не використовуються в комерційних цілях.",
"The Assistant is in Beta": "Помічник у бета-версії",
"The conversation has been deleted.": "Розмова була видалена.",