diff --git a/docs/configuration.md b/docs/configuration.md index 66470792d..2b2fd3b9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2108,6 +2108,12 @@ used with the OpenAI-compatible backend to target a custom provider or local gat Defaults to None. +### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=`](#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE) {#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE} + +: The language to use for AI suggestions (results may vary by LLM model). If not supplied, defaults to the user's UI language setting or None. + + Defaults to None. + #### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS} : If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc). diff --git a/src-ui/src/app/data/paperless-config.ts b/src-ui/src/app/data/paperless-config.ts index f7b654bf4..1c74d8c08 100644 --- a/src-ui/src/app/data/paperless-config.ts +++ b/src-ui/src/app/data/paperless-config.ts @@ -352,6 +352,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [ config_key: 'PAPERLESS_AI_LLM_ENDPOINT', category: ConfigCategory.AI, }, + { + key: 'llm_output_language', + title: $localize`LLM Output Language`, + type: ConfigOptionType.String, + config_key: 'PAPERLESS_AI_LLM_OUTPUT_LANGUAGE', + category: ConfigCategory.AI, + note: $localize`Language to use for generated AI suggestions. When unset, AI suggestions use the user's display language if explicitly set.`, + }, ] export interface PaperlessConfig extends ObjectWithId { @@ -392,4 +400,5 @@ export interface PaperlessConfig extends ObjectWithId { llm_model: string llm_api_key: string llm_endpoint: string + llm_output_language: string } diff --git a/src/documents/tests/test_api_app_config.py b/src/documents/tests/test_api_app_config.py index e0441f17c..2418236bd 100644 --- a/src/documents/tests/test_api_app_config.py +++ b/src/documents/tests/test_api_app_config.py @@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase): "llm_model": None, "llm_api_key": None, "llm_endpoint": None, + "llm_output_language": None, }, ) diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index 90736849f..a67590b81 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -408,6 +408,45 @@ class TestAISuggestions(DirectoriesMixin, TestCase): "KI Title", ) + @patch("documents.views.get_ai_document_classification") + @override_settings( + AI_ENABLED=True, + LLM_BACKEND="mock_backend", + LLM_OUTPUT_LANGUAGE="fr-fr", + ) + def test_ai_suggestions_configured_language_takes_precedence( + self, + mock_get_ai_classification, + ) -> None: + UiSettings.objects.create(user=self.user, settings={"language": "de-de"}) + mock_get_ai_classification.return_value = { + "title": "Titre IA", + "tags": [], + "correspondents": [], + "document_types": [], + "storage_paths": [], + "dates": [], + } + + self.client.force_login(user=self.user) + response = self.client.get( + f"/api/documents/{self.document.pk}/ai_suggestions/", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_get_ai_classification.assert_called_once_with( + self.document, + self.user, + "fr-fr", + ) + self.assertEqual( + get_llm_suggestion_cache( + self.document.pk, + backend="mock_backend:fr-fr", + ).suggestions["title"], + "Titre IA", + ) + @patch("documents.views.get_ai_document_classification") @override_settings( AI_ENABLED=True, diff --git a/src/documents/views.py b/src/documents/views.py index e92ca2d1d..511429129 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -1469,10 +1469,14 @@ class DocumentViewSet( if not ai_config.ai_enabled: return HttpResponseBadRequest("AI is required for this feature") - output_language = None - if hasattr(request.user, "ui_settings") and isinstance( - request.user.ui_settings.settings, - dict, + output_language = ai_config.llm_output_language + if ( + not output_language + and hasattr(request.user, "ui_settings") + and isinstance( + request.user.ui_settings.settings, + dict, + ) ): output_language = request.user.ui_settings.settings.get("language") or None llm_cache_backend = ( diff --git a/src/paperless/config.py b/src/paperless/config.py index 8c9c7b3ca..40341b92e 100644 --- a/src/paperless/config.py +++ b/src/paperless/config.py @@ -201,6 +201,7 @@ class AIConfig(BaseConfig): llm_model: str = dataclasses.field(init=False) llm_api_key: str = dataclasses.field(init=False) llm_endpoint: str = dataclasses.field(init=False) + llm_output_language: str = dataclasses.field(init=False) llm_allow_internal_endpoints: bool = dataclasses.field(init=False) def __post_init__(self) -> None: @@ -224,6 +225,9 @@ class AIConfig(BaseConfig): self.llm_model = app_config.llm_model or settings.LLM_MODEL self.llm_api_key = app_config.llm_api_key or settings.LLM_API_KEY self.llm_endpoint = app_config.llm_endpoint or settings.LLM_ENDPOINT + self.llm_output_language = ( + app_config.llm_output_language or settings.LLM_OUTPUT_LANGUAGE + ) self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS @property diff --git a/src/paperless/migrations/0012_applicationconfiguration_llm_output_language.py b/src/paperless/migrations/0012_applicationconfiguration_llm_output_language.py new file mode 100644 index 000000000..3dcce37cf --- /dev/null +++ b/src/paperless/migrations/0012_applicationconfiguration_llm_output_language.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2026-06-02 + +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + dependencies = [ + ("paperless", "0011_applicationconfiguration_llm_embedding_chunk_size"), + ] + + operations = [ + migrations.AddField( + model_name="applicationconfiguration", + name="llm_output_language", + field=models.CharField( + blank=True, + max_length=32, + null=True, + verbose_name="Sets the LLM output language", + ), + ), + ] diff --git a/src/paperless/models.py b/src/paperless/models.py index 7c562b811..d246a546f 100644 --- a/src/paperless/models.py +++ b/src/paperless/models.py @@ -359,6 +359,13 @@ class ApplicationConfiguration(AbstractSingletonModel): max_length=256, ) + llm_output_language = models.CharField( + verbose_name=_("Sets the LLM output language"), + blank=True, + null=True, + max_length=32, + ) + class Meta: verbose_name = _("paperless application settings") permissions = [ diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index d1597ab13..58702b726 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -227,6 +227,8 @@ class ApplicationConfigurationSerializer( data["barcode_tag_mapping"] = None if "language" in data and data["language"] == "": data["language"] = None + if "llm_output_language" in data and data["llm_output_language"] == "": + data["llm_output_language"] = None if "llm_api_key" in data and data["llm_api_key"] is not None: if data["llm_api_key"] == "": data["llm_api_key"] = None diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 8e47611c7..062926d38 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -1202,6 +1202,7 @@ LLM_BACKEND = os.getenv("PAPERLESS_AI_LLM_BACKEND") # "ollama" or "openai-like" LLM_MODEL = os.getenv("PAPERLESS_AI_LLM_MODEL") LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY") LLM_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_ENDPOINT") +LLM_OUTPUT_LANGUAGE = os.getenv("PAPERLESS_AI_LLM_OUTPUT_LANGUAGE") LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env( "PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS", "true",