Enhancement (beta): add direct LLM language setting (#12906)

This commit is contained in:
shamoon
2026-06-03 08:53:22 -07:00
committed by GitHub
parent 47a6fcfc39
commit 1663ed170c
10 changed files with 100 additions and 4 deletions
+6
View File
@@ -2108,6 +2108,12 @@ used with the OpenAI-compatible backend to target a custom provider or local gat
Defaults to None. Defaults to None.
### [`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE=<str>`](#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=<bool>`](#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS} #### [`PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#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). : If set to false, Paperless blocks AI endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
+9
View File
@@ -352,6 +352,14 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_AI_LLM_ENDPOINT', config_key: 'PAPERLESS_AI_LLM_ENDPOINT',
category: ConfigCategory.AI, 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 { export interface PaperlessConfig extends ObjectWithId {
@@ -392,4 +400,5 @@ export interface PaperlessConfig extends ObjectWithId {
llm_model: string llm_model: string
llm_api_key: string llm_api_key: string
llm_endpoint: string llm_endpoint: string
llm_output_language: string
} }
@@ -81,6 +81,7 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"llm_model": None, "llm_model": None,
"llm_api_key": None, "llm_api_key": None,
"llm_endpoint": None, "llm_endpoint": None,
"llm_output_language": None,
}, },
) )
+39
View File
@@ -408,6 +408,45 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
"KI Title", "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") @patch("documents.views.get_ai_document_classification")
@override_settings( @override_settings(
AI_ENABLED=True, AI_ENABLED=True,
+8 -4
View File
@@ -1469,10 +1469,14 @@ class DocumentViewSet(
if not ai_config.ai_enabled: if not ai_config.ai_enabled:
return HttpResponseBadRequest("AI is required for this feature") return HttpResponseBadRequest("AI is required for this feature")
output_language = None output_language = ai_config.llm_output_language
if hasattr(request.user, "ui_settings") and isinstance( if (
request.user.ui_settings.settings, not output_language
dict, 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 output_language = request.user.ui_settings.settings.get("language") or None
llm_cache_backend = ( llm_cache_backend = (
+4
View File
@@ -201,6 +201,7 @@ class AIConfig(BaseConfig):
llm_model: str = dataclasses.field(init=False) llm_model: str = dataclasses.field(init=False)
llm_api_key: str = dataclasses.field(init=False) llm_api_key: str = dataclasses.field(init=False)
llm_endpoint: 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) llm_allow_internal_endpoints: bool = dataclasses.field(init=False)
def __post_init__(self) -> None: 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_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_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_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 self.llm_allow_internal_endpoints = settings.LLM_ALLOW_INTERNAL_ENDPOINTS
@property @property
@@ -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",
),
),
]
+7
View File
@@ -359,6 +359,13 @@ class ApplicationConfiguration(AbstractSingletonModel):
max_length=256, max_length=256,
) )
llm_output_language = models.CharField(
verbose_name=_("Sets the LLM output language"),
blank=True,
null=True,
max_length=32,
)
class Meta: class Meta:
verbose_name = _("paperless application settings") verbose_name = _("paperless application settings")
permissions = [ permissions = [
+2
View File
@@ -227,6 +227,8 @@ class ApplicationConfigurationSerializer(
data["barcode_tag_mapping"] = None data["barcode_tag_mapping"] = None
if "language" in data and data["language"] == "": if "language" in data and data["language"] == "":
data["language"] = None 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 "llm_api_key" in data and data["llm_api_key"] is not None:
if data["llm_api_key"] == "": if data["llm_api_key"] == "":
data["llm_api_key"] = None data["llm_api_key"] = None
+1
View File
@@ -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_MODEL = os.getenv("PAPERLESS_AI_LLM_MODEL")
LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY") LLM_API_KEY = os.getenv("PAPERLESS_AI_LLM_API_KEY")
LLM_ENDPOINT = os.getenv("PAPERLESS_AI_LLM_ENDPOINT") 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( LLM_ALLOW_INTERNAL_ENDPOINTS = get_bool_from_env(
"PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS", "PAPERLESS_AI_LLM_ALLOW_INTERNAL_ENDPOINTS",
"true", "true",