stapel-translate 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stapel_translate-0.4.0/LICENSE +21 -0
- stapel_translate-0.4.0/PKG-INFO +57 -0
- stapel_translate-0.4.0/README.md +37 -0
- stapel_translate-0.4.0/__init__.py +41 -0
- stapel_translate-0.4.0/actions.py +23 -0
- stapel_translate-0.4.0/admin.py +488 -0
- stapel_translate-0.4.0/apps.py +23 -0
- stapel_translate-0.4.0/autofill.py +109 -0
- stapel_translate-0.4.0/collectors.py +33 -0
- stapel_translate-0.4.0/conf.py +202 -0
- stapel_translate-0.4.0/conftest.py +40 -0
- stapel_translate-0.4.0/dashboard_serializers.py +126 -0
- stapel_translate-0.4.0/dashboard_views.py +1646 -0
- stapel_translate-0.4.0/dto.py +232 -0
- stapel_translate-0.4.0/error_collector.py +144 -0
- stapel_translate-0.4.0/events.py +40 -0
- stapel_translate-0.4.0/figma_serializers.py +51 -0
- stapel_translate-0.4.0/figma_views.py +1031 -0
- stapel_translate-0.4.0/fixtures/builtin/ar.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/de.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/en.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/es.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/fr.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/he.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/hi.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/hr.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/hu.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/it.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/ja.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/ko.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/lb.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/pl.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/pt.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/ru.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/sr.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/tr.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/uk.json +257 -0
- stapel_translate-0.4.0/fixtures/builtin/zh.json +257 -0
- stapel_translate-0.4.0/functions.py +64 -0
- stapel_translate-0.4.0/gdpr.py +85 -0
- stapel_translate-0.4.0/management/__init__.py +0 -0
- stapel_translate-0.4.0/management/commands/__init__.py +0 -0
- stapel_translate-0.4.0/management/commands/autofill_translations.py +76 -0
- stapel_translate-0.4.0/management/commands/collect_translations.py +66 -0
- stapel_translate-0.4.0/management/commands/dump_translations.py +121 -0
- stapel_translate-0.4.0/management/commands/load_builtin_translations.py +118 -0
- stapel_translate-0.4.0/management/commands/translation_backlog.py +83 -0
- stapel_translate-0.4.0/migrations/0001_initial.py +34 -0
- stapel_translate-0.4.0/migrations/0002_add_revision_to_translation.py +23 -0
- stapel_translate-0.4.0/migrations/0003_replace_verified_with_per_language.py +70 -0
- stapel_translate-0.4.0/migrations/0004_add_languages.py +133 -0
- stapel_translate-0.4.0/migrations/0005_add_llm_translated.py +16 -0
- stapel_translate-0.4.0/migrations/0006_authorizedtranslator.py +25 -0
- stapel_translate-0.4.0/migrations/0007_translationentry_comment.py +16 -0
- stapel_translate-0.4.0/migrations/0008_alter_translationentry_source.py +16 -0
- stapel_translate-0.4.0/migrations/0009_translationhistory.py +34 -0
- stapel_translate-0.4.0/migrations/0010_figma_api_key.py +28 -0
- stapel_translate-0.4.0/migrations/0011_remove_serbo_croatian.py +21 -0
- stapel_translate-0.4.0/migrations/0012_add_serbian_croatian.py +33 -0
- stapel_translate-0.4.0/migrations/0013_add_translator_comment_and_refs.py +28 -0
- stapel_translate-0.4.0/migrations/0014_translationentry_order.py +16 -0
- stapel_translate-0.4.0/migrations/0015_authorizedtranslator_allowed_languages_and_more.py +23 -0
- stapel_translate-0.4.0/migrations/0016_translationentry_screenshot.py +16 -0
- stapel_translate-0.4.0/migrations/0017_translationvalue.py +30 -0
- stapel_translate-0.4.0/migrations/0018_copy_language_columns_to_values.py +71 -0
- stapel_translate-0.4.0/migrations/0019_remove_language_columns.py +53 -0
- stapel_translate-0.4.0/migrations/0020_figmaapikey_hashed_keys.py +33 -0
- stapel_translate-0.4.0/migrations/__init__.py +0 -0
- stapel_translate-0.4.0/mixins.py +25 -0
- stapel_translate-0.4.0/models.py +307 -0
- stapel_translate-0.4.0/notification_collector.py +109 -0
- stapel_translate-0.4.0/permissions.py +70 -0
- stapel_translate-0.4.0/providers.py +252 -0
- stapel_translate-0.4.0/py.typed +0 -0
- stapel_translate-0.4.0/pyproject.toml +42 -0
- stapel_translate-0.4.0/schemas/consumes/user.deleted.json +13 -0
- stapel_translate-0.4.0/schemas/emits/translations.changed.json +12 -0
- stapel_translate-0.4.0/schemas/functions/translate.resolve.json +19 -0
- stapel_translate-0.4.0/serializers.py +31 -0
- stapel_translate-0.4.0/setup.cfg +4 -0
- stapel_translate-0.4.0/stapel_translate.egg-info/PKG-INFO +57 -0
- stapel_translate-0.4.0/stapel_translate.egg-info/SOURCES.txt +222 -0
- stapel_translate-0.4.0/stapel_translate.egg-info/dependency_links.txt +1 -0
- stapel_translate-0.4.0/stapel_translate.egg-info/requires.txt +7 -0
- stapel_translate-0.4.0/stapel_translate.egg-info/top_level.txt +1 -0
- stapel_translate-0.4.0/tasks.py +41 -0
- stapel_translate-0.4.0/templates/admin/translations/change_list_with_lang.html +58 -0
- stapel_translate-0.4.0/templates/dashboard/base.html +742 -0
- stapel_translate-0.4.0/templates/dashboard/index.html +390 -0
- stapel_translate-0.4.0/templates/dashboard/language.html +451 -0
- stapel_translate-0.4.0/templates/dashboard/login.html +355 -0
- stapel_translate-0.4.0/templates/dashboard/translation.html +952 -0
- stapel_translate-0.4.0/tests/__init__.py +0 -0
- stapel_translate-0.4.0/tests/conftest.py +11 -0
- stapel_translate-0.4.0/tests/test_autofill.py +181 -0
- stapel_translate-0.4.0/tests/test_backlog_command.py +86 -0
- stapel_translate-0.4.0/tests/test_builtin_fixtures.py +83 -0
- stapel_translate-0.4.0/tests/test_builtin_loader.py +138 -0
- stapel_translate-0.4.0/tests/test_collect_command.py +145 -0
- stapel_translate-0.4.0/tests/test_collectors.py +171 -0
- stapel_translate-0.4.0/tests/test_conf.py +93 -0
- stapel_translate-0.4.0/tests/test_dashboard_api.py +221 -0
- stapel_translate-0.4.0/tests/test_dump_translations.py +141 -0
- stapel_translate-0.4.0/tests/test_events.py +85 -0
- stapel_translate-0.4.0/tests/test_export_import.py +159 -0
- stapel_translate-0.4.0/tests/test_figma_auth.py +145 -0
- stapel_translate-0.4.0/tests/test_llm_help.py +274 -0
- stapel_translate-0.4.0/tests/test_migrations_sanity.py +85 -0
- stapel_translate-0.4.0/tests/test_models.py +162 -0
- stapel_translate-0.4.0/tests/test_providers.py +201 -0
- stapel_translate-0.4.0/tests/test_public_api.py +54 -0
- stapel_translate-0.4.0/tests/test_resolve_function.py +149 -0
- stapel_translate-0.4.0/tests/test_serializer_seams.py +44 -0
- stapel_translate-0.4.0/tests/test_views.py +176 -0
- stapel_translate-0.4.0/urls.py +85 -0
- stapel_translate-0.4.0/utils.py +2 -0
- stapel_translate-0.4.0/views.py +193 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 usestapel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-translate
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: AI-powered content translation Django app for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: django,stapel,translation,i18n,ai
|
|
7
|
+
Classifier: Framework :: Django
|
|
8
|
+
Classifier: Framework :: Django :: 5.2
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: stapel-core<0.4,>=0.3.0
|
|
15
|
+
Provides-Extra: kafka
|
|
16
|
+
Requires-Dist: confluent-kafka>=2.3; extra == "kafka"
|
|
17
|
+
Provides-Extra: all
|
|
18
|
+
Requires-Dist: stapel-translate[kafka]; extra == "all"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# stapel-translate
|
|
22
|
+
|
|
23
|
+
[](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml)
|
|
24
|
+
[](https://codecov.io/gh/usestapel/stapel-translate)
|
|
25
|
+
|
|
26
|
+
> AI-powered content translation — multilingual support, LLM routing (Anthropic/OpenAI)
|
|
27
|
+
|
|
28
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install stapel-translate
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
# settings.py
|
|
40
|
+
INSTALLED_APPS = [
|
|
41
|
+
...
|
|
42
|
+
'stapel_translate',
|
|
43
|
+
]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Bus events
|
|
47
|
+
|
|
48
|
+
### Emits
|
|
49
|
+
| `translations.changed` | [schema](schemas/emits/translations.changed.json) | One or more translation keys were updated for a language. |
|
|
50
|
+
|
|
51
|
+
### Consumes
|
|
52
|
+
| `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
|
|
53
|
+
| `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# stapel-translate
|
|
2
|
+
|
|
3
|
+
[](https://github.com/usestapel/stapel-translate/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/usestapel/stapel-translate)
|
|
5
|
+
|
|
6
|
+
> AI-powered content translation — multilingual support, LLM routing (Anthropic/OpenAI)
|
|
7
|
+
|
|
8
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps for building production-grade platforms.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install stapel-translate
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# settings.py
|
|
20
|
+
INSTALLED_APPS = [
|
|
21
|
+
...
|
|
22
|
+
'stapel_translate',
|
|
23
|
+
]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Bus events
|
|
27
|
+
|
|
28
|
+
### Emits
|
|
29
|
+
| `translations.changed` | [schema](schemas/emits/translations.changed.json) | One or more translation keys were updated for a language. |
|
|
30
|
+
|
|
31
|
+
### Consumes
|
|
32
|
+
| `user.deleted` | [schema](schemas/consumes/user.deleted.json) |
|
|
33
|
+
| `user.deletion_initiated` | [schema](schemas/consumes/user.deletion_initiated.json) |
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT — see [LICENSE](LICENSE)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Stapel Translate — translation management Django app for the Stapel framework.
|
|
2
|
+
|
|
3
|
+
The public API is exported lazily (PEP 562), so importing the package never
|
|
4
|
+
pulls in Django-dependent modules until an attribute is actually accessed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
|
|
9
|
+
# name -> (relative module, attribute)
|
|
10
|
+
_LAZY_EXPORTS = {
|
|
11
|
+
"translate_settings": (".conf", "translate_settings"),
|
|
12
|
+
"SUPPORTED_LANGUAGES": (".conf", "SUPPORTED_LANGUAGES"),
|
|
13
|
+
"LANGUAGE_NAMES": (".conf", "LANGUAGE_NAMES"),
|
|
14
|
+
"get_supported_languages": (".conf", "get_supported_languages"),
|
|
15
|
+
"get_language_names": (".conf", "get_language_names"),
|
|
16
|
+
"get_default_language": (".conf", "get_default_language"),
|
|
17
|
+
"emit_translations_changed": (".events", "emit_translations_changed"),
|
|
18
|
+
"TRANSLATIONS_CHANGED": (".events", "TRANSLATIONS_CHANGED"),
|
|
19
|
+
"get_cache_key": (".utils", "get_cache_key"),
|
|
20
|
+
"register_collector": (".collectors", "register_collector"),
|
|
21
|
+
"autofill_missing": (".autofill", "autofill_missing"),
|
|
22
|
+
"get_llm_provider": (".providers", "get_llm_provider"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
__all__ = sorted(_LAZY_EXPORTS)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def __getattr__(name):
|
|
29
|
+
try:
|
|
30
|
+
module_path, attr = _LAZY_EXPORTS[name]
|
|
31
|
+
except KeyError:
|
|
32
|
+
raise AttributeError(
|
|
33
|
+
f"module {__name__!r} has no attribute {name!r}"
|
|
34
|
+
) from None
|
|
35
|
+
value = getattr(import_module(module_path, __name__), attr)
|
|
36
|
+
globals()[name] = value # cache so __getattr__ runs once per name
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def __dir__():
|
|
41
|
+
return sorted(set(globals()) | set(_LAZY_EXPORTS))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Action subscriptions of the translate module.
|
|
2
|
+
|
|
3
|
+
Handlers must be idempotent: delivery is at-least-once (outbox retries,
|
|
4
|
+
broker redelivery).
|
|
5
|
+
"""
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from stapel_core.comm import on_action
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@on_action("user.deleted")
|
|
14
|
+
def handle_user_deleted(event):
|
|
15
|
+
"""Erase this module's PII when an account deletion is executed."""
|
|
16
|
+
from .gdpr import TranslateGDPRProvider
|
|
17
|
+
|
|
18
|
+
user_id = event.payload.get("user_id")
|
|
19
|
+
if not user_id:
|
|
20
|
+
logger.error("user.deleted event without user_id: %s", event.event_id)
|
|
21
|
+
return
|
|
22
|
+
TranslateGDPRProvider().delete(user_id)
|
|
23
|
+
logger.info("translate data erased for deleted user %s", user_id)
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
import requests as http_requests
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.conf import settings
|
|
7
|
+
from django.contrib import admin, messages
|
|
8
|
+
from django.core.cache import cache
|
|
9
|
+
from django.http import JsonResponse
|
|
10
|
+
from django.shortcuts import redirect
|
|
11
|
+
from django.urls import path
|
|
12
|
+
|
|
13
|
+
from .conf import LANGUAGE_NAMES
|
|
14
|
+
from .models import (
|
|
15
|
+
SUPPORTED_LANGUAGES,
|
|
16
|
+
AuthorizedTranslator,
|
|
17
|
+
FigmaApiKey,
|
|
18
|
+
TranslationEntry,
|
|
19
|
+
TranslationHistory,
|
|
20
|
+
TranslationValue,
|
|
21
|
+
)
|
|
22
|
+
from .providers import agent_payload, get_agent_url
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
LLM_TASK_CACHE_KEY = "llm_translation_task_status"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _translate_entries_background(entry_ids: list):
|
|
30
|
+
"""Background task to translate entries with LLM."""
|
|
31
|
+
api_key = getattr(settings, "SERVICE_API_KEY", None)
|
|
32
|
+
headers = (
|
|
33
|
+
{
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"X-API-KEY": api_key,
|
|
36
|
+
}
|
|
37
|
+
if api_key
|
|
38
|
+
else {"Content-Type": "application/json"}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
total = len(entry_ids)
|
|
42
|
+
translated_count = 0
|
|
43
|
+
error_count = 0
|
|
44
|
+
errors = []
|
|
45
|
+
|
|
46
|
+
# Update status: started
|
|
47
|
+
cache.set(
|
|
48
|
+
LLM_TASK_CACHE_KEY,
|
|
49
|
+
{
|
|
50
|
+
"status": "running",
|
|
51
|
+
"total": total,
|
|
52
|
+
"completed": 0,
|
|
53
|
+
"errors": 0,
|
|
54
|
+
"error_messages": [],
|
|
55
|
+
},
|
|
56
|
+
timeout=3600,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for entry_id in entry_ids:
|
|
60
|
+
try:
|
|
61
|
+
entry = TranslationEntry.objects.get(id=entry_id)
|
|
62
|
+
except TranslationEntry.DoesNotExist:
|
|
63
|
+
error_count += 1
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Build context from existing translations
|
|
67
|
+
existing_translations = []
|
|
68
|
+
missing_langs = []
|
|
69
|
+
|
|
70
|
+
for lang in SUPPORTED_LANGUAGES:
|
|
71
|
+
value = entry.get_value(lang)
|
|
72
|
+
is_verified = entry.get_verified(lang)
|
|
73
|
+
if value:
|
|
74
|
+
status_label = "VERIFIED" if is_verified else "unverified"
|
|
75
|
+
existing_translations.append(f'- {lang} [{status_label}]: "{value}"')
|
|
76
|
+
else:
|
|
77
|
+
missing_langs.append(lang)
|
|
78
|
+
|
|
79
|
+
# Only translate languages that have no value at all
|
|
80
|
+
if not missing_langs:
|
|
81
|
+
translated_count += 1
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
lang_list = "\n".join([f"- {code}" for code in missing_langs])
|
|
85
|
+
json_example = ", ".join([f'"{code}": "..."' for code in missing_langs])
|
|
86
|
+
|
|
87
|
+
existing_section = ""
|
|
88
|
+
if existing_translations:
|
|
89
|
+
existing_section = f"""
|
|
90
|
+
Existing translations (use as reference for style and tone):
|
|
91
|
+
{chr(10).join(existing_translations)}
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
prompt = f"""You are a professional translator for a marketplace application.
|
|
95
|
+
Translate this UI/feature label into the specified languages.
|
|
96
|
+
|
|
97
|
+
Key to translate: "{entry.key}"
|
|
98
|
+
{f"Comment/context: {entry.comment}" if entry.comment else ""}
|
|
99
|
+
{existing_section}
|
|
100
|
+
Languages to translate (provide translations ONLY for these missing languages):
|
|
101
|
+
{lang_list}
|
|
102
|
+
|
|
103
|
+
Rules:
|
|
104
|
+
- Keep translations concise and natural for UI labels
|
|
105
|
+
- Preserve any technical terms or brand names
|
|
106
|
+
- If the key looks like a sentence, translate as a sentence
|
|
107
|
+
- If it's a single word or short phrase, keep it as such
|
|
108
|
+
- For ambiguous terms, prefer marketplace/e-commerce context
|
|
109
|
+
- Match the style and tone of existing translations
|
|
110
|
+
|
|
111
|
+
Return ONLY valid JSON like: {{{json_example}}}"""
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = http_requests.post(
|
|
115
|
+
f"{get_agent_url()}/api/llm/complete",
|
|
116
|
+
json=agent_payload(prompt),
|
|
117
|
+
headers=headers,
|
|
118
|
+
timeout=60,
|
|
119
|
+
)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
data = response.json()
|
|
122
|
+
|
|
123
|
+
if data.get("status") == "ok":
|
|
124
|
+
result = data.get("result", {})
|
|
125
|
+
# Handle string result (try to parse as JSON)
|
|
126
|
+
if isinstance(result, str):
|
|
127
|
+
import json
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
result = json.loads(result)
|
|
131
|
+
except json.JSONDecodeError:
|
|
132
|
+
error_count += 1
|
|
133
|
+
errors.append(f"{entry.key}: LLM returned invalid JSON")
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# Update translations: only fill empty, non-verified fields
|
|
137
|
+
for lang in SUPPORTED_LANGUAGES:
|
|
138
|
+
if lang in result and result[lang]:
|
|
139
|
+
# Skip if already has a value
|
|
140
|
+
if entry.get_value(lang):
|
|
141
|
+
continue
|
|
142
|
+
# Skip if verified
|
|
143
|
+
if entry.get_verified(lang):
|
|
144
|
+
continue
|
|
145
|
+
entry.set_value(lang, result[lang])
|
|
146
|
+
entry.llm_translated = True
|
|
147
|
+
entry.save()
|
|
148
|
+
translated_count += 1
|
|
149
|
+
else:
|
|
150
|
+
error_count += 1
|
|
151
|
+
errors.append(f"{entry.key}: LLM returned non-ok status")
|
|
152
|
+
|
|
153
|
+
except Exception as e:
|
|
154
|
+
error_count += 1
|
|
155
|
+
errors.append(f"{entry.key}: {str(e)}")
|
|
156
|
+
logger.warning(f"Error translating '{entry.key}': {e}")
|
|
157
|
+
|
|
158
|
+
# Update progress in cache
|
|
159
|
+
cache.set(
|
|
160
|
+
LLM_TASK_CACHE_KEY,
|
|
161
|
+
{
|
|
162
|
+
"status": "running",
|
|
163
|
+
"total": total,
|
|
164
|
+
"completed": translated_count + error_count,
|
|
165
|
+
"translated": translated_count,
|
|
166
|
+
"errors": error_count,
|
|
167
|
+
"error_messages": errors[-10:], # Keep last 10 errors
|
|
168
|
+
},
|
|
169
|
+
timeout=3600,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Final status
|
|
173
|
+
cache.set(
|
|
174
|
+
LLM_TASK_CACHE_KEY,
|
|
175
|
+
{
|
|
176
|
+
"status": "completed",
|
|
177
|
+
"total": total,
|
|
178
|
+
"completed": total,
|
|
179
|
+
"translated": translated_count,
|
|
180
|
+
"errors": error_count,
|
|
181
|
+
"error_messages": errors[-10:],
|
|
182
|
+
},
|
|
183
|
+
timeout=3600,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
logger.info(
|
|
187
|
+
f"LLM translation completed: {translated_count} translated, {error_count} errors"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@admin.action(description="Translate selected with LLM (background)")
|
|
192
|
+
def translate_with_llm(modeladmin, request, queryset):
|
|
193
|
+
"""Start background LLM translation for selected entries."""
|
|
194
|
+
# Check if task is already running
|
|
195
|
+
current_status = cache.get(LLM_TASK_CACHE_KEY)
|
|
196
|
+
if current_status and current_status.get("status") == "running":
|
|
197
|
+
modeladmin.message_user(
|
|
198
|
+
request,
|
|
199
|
+
f"Translation already in progress: {current_status.get('completed', 0)}/{current_status.get('total', 0)}. Wait for it to complete.",
|
|
200
|
+
messages.WARNING,
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
entry_ids = list(queryset.values_list("id", flat=True))
|
|
205
|
+
if not entry_ids:
|
|
206
|
+
modeladmin.message_user(request, "No entries selected", messages.WARNING)
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Start background thread
|
|
210
|
+
thread = threading.Thread(
|
|
211
|
+
target=_translate_entries_background, args=(entry_ids,), daemon=True
|
|
212
|
+
)
|
|
213
|
+
thread.start()
|
|
214
|
+
|
|
215
|
+
modeladmin.message_user(
|
|
216
|
+
request,
|
|
217
|
+
f"Started LLM translation for {len(entry_ids)} entries in background. Check status via 'LLM Status' button.",
|
|
218
|
+
messages.SUCCESS,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _language_choices():
|
|
223
|
+
return [
|
|
224
|
+
(code, f"{code} — {LANGUAGE_NAMES.get(code, code)}")
|
|
225
|
+
for code in SUPPORTED_LANGUAGES
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AuthorizedTranslatorForm(forms.ModelForm):
|
|
230
|
+
allowed_languages = forms.MultipleChoiceField(
|
|
231
|
+
choices=_language_choices,
|
|
232
|
+
widget=forms.CheckboxSelectMultiple,
|
|
233
|
+
required=False,
|
|
234
|
+
help_text="Languages this translator can edit. Leave all unchecked = access to all languages.",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
class Meta:
|
|
238
|
+
model = AuthorizedTranslator
|
|
239
|
+
fields = "__all__"
|
|
240
|
+
|
|
241
|
+
def __init__(self, *args, **kwargs):
|
|
242
|
+
super().__init__(*args, **kwargs)
|
|
243
|
+
if self.instance and self.instance.pk and self.instance.allowed_languages:
|
|
244
|
+
self.initial["allowed_languages"] = self.instance.allowed_languages
|
|
245
|
+
|
|
246
|
+
def clean_allowed_languages(self):
|
|
247
|
+
return self.cleaned_data.get("allowed_languages", [])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@admin.register(AuthorizedTranslator)
|
|
251
|
+
class AuthorizedTranslatorAdmin(admin.ModelAdmin):
|
|
252
|
+
form = AuthorizedTranslatorForm
|
|
253
|
+
list_display = [
|
|
254
|
+
"email",
|
|
255
|
+
"name",
|
|
256
|
+
"is_active",
|
|
257
|
+
"allowed_languages_display",
|
|
258
|
+
"created_at",
|
|
259
|
+
]
|
|
260
|
+
list_filter = ["is_active"]
|
|
261
|
+
search_fields = ["email", "name", "notes"]
|
|
262
|
+
readonly_fields = ["created_at"]
|
|
263
|
+
|
|
264
|
+
@admin.display(description="Languages")
|
|
265
|
+
def allowed_languages_display(self, obj):
|
|
266
|
+
if obj.allowed_languages:
|
|
267
|
+
return ", ".join(obj.allowed_languages)
|
|
268
|
+
return "All"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@admin.register(FigmaApiKey)
|
|
272
|
+
class FigmaApiKeyAdmin(admin.ModelAdmin):
|
|
273
|
+
list_display = ["name", "prefix", "is_active", "created_at", "last_used_at"]
|
|
274
|
+
list_filter = ["is_active"]
|
|
275
|
+
search_fields = ["name", "prefix"]
|
|
276
|
+
readonly_fields = ["prefix", "created_at", "last_used_at"]
|
|
277
|
+
fields = ["name", "is_active", "prefix", "created_at", "last_used_at"]
|
|
278
|
+
|
|
279
|
+
def save_model(self, request, obj, form, change):
|
|
280
|
+
super().save_model(request, obj, form, change)
|
|
281
|
+
plaintext = getattr(obj, "plaintext_key", None)
|
|
282
|
+
if plaintext:
|
|
283
|
+
messages.warning(
|
|
284
|
+
request,
|
|
285
|
+
f"Figma API key for '{obj.name}': {plaintext} — "
|
|
286
|
+
"copy it now, it will NOT be shown again.",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@admin.register(TranslationHistory)
|
|
291
|
+
class TranslationHistoryAdmin(admin.ModelAdmin):
|
|
292
|
+
list_display = [
|
|
293
|
+
"created_at",
|
|
294
|
+
"entry_key",
|
|
295
|
+
"language",
|
|
296
|
+
"change_type",
|
|
297
|
+
"author_email",
|
|
298
|
+
"author_name",
|
|
299
|
+
"source",
|
|
300
|
+
"old_value_short",
|
|
301
|
+
"new_value_short",
|
|
302
|
+
]
|
|
303
|
+
list_filter = ["source", "change_type", "language", "author_email"]
|
|
304
|
+
search_fields = [
|
|
305
|
+
"entry__key",
|
|
306
|
+
"author_email",
|
|
307
|
+
"author_name",
|
|
308
|
+
"old_value",
|
|
309
|
+
"new_value",
|
|
310
|
+
]
|
|
311
|
+
readonly_fields = [
|
|
312
|
+
"entry",
|
|
313
|
+
"language",
|
|
314
|
+
"change_type",
|
|
315
|
+
"old_value",
|
|
316
|
+
"new_value",
|
|
317
|
+
"author_email",
|
|
318
|
+
"author_name",
|
|
319
|
+
"source",
|
|
320
|
+
"created_at",
|
|
321
|
+
]
|
|
322
|
+
date_hierarchy = "created_at"
|
|
323
|
+
ordering = ["-created_at"]
|
|
324
|
+
|
|
325
|
+
@admin.display(
|
|
326
|
+
description="Key",
|
|
327
|
+
ordering="entry__key",
|
|
328
|
+
)
|
|
329
|
+
def entry_key(self, obj):
|
|
330
|
+
return obj.entry.key
|
|
331
|
+
|
|
332
|
+
@admin.display(description="Old Value")
|
|
333
|
+
def old_value_short(self, obj):
|
|
334
|
+
if obj.old_value:
|
|
335
|
+
return (
|
|
336
|
+
obj.old_value[:50] + "..." if len(obj.old_value) > 50 else obj.old_value
|
|
337
|
+
)
|
|
338
|
+
return "-"
|
|
339
|
+
|
|
340
|
+
@admin.display(description="New Value")
|
|
341
|
+
def new_value_short(self, obj):
|
|
342
|
+
if obj.new_value:
|
|
343
|
+
return (
|
|
344
|
+
obj.new_value[:50] + "..." if len(obj.new_value) > 50 else obj.new_value
|
|
345
|
+
)
|
|
346
|
+
return "-"
|
|
347
|
+
|
|
348
|
+
def has_add_permission(self, request):
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
def has_change_permission(self, request, obj=None):
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
def has_delete_permission(self, request, obj=None):
|
|
355
|
+
return request.user.is_superuser
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class TranslationValueInline(admin.TabularInline):
|
|
359
|
+
model = TranslationValue
|
|
360
|
+
extra = 0
|
|
361
|
+
fields = ["language", "value", "verified"]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@admin.register(TranslationEntry)
|
|
365
|
+
class TranslationEntryAdmin(admin.ModelAdmin):
|
|
366
|
+
list_display = [
|
|
367
|
+
"key",
|
|
368
|
+
"comment",
|
|
369
|
+
"en_value",
|
|
370
|
+
"lb_value",
|
|
371
|
+
"fr_value",
|
|
372
|
+
"de_value",
|
|
373
|
+
"en_verified",
|
|
374
|
+
"llm_translated",
|
|
375
|
+
"source",
|
|
376
|
+
"revision",
|
|
377
|
+
"deleted",
|
|
378
|
+
]
|
|
379
|
+
list_filter = ["source", "deleted", "llm_translated"]
|
|
380
|
+
search_fields = ["key", "comment", "values__value"]
|
|
381
|
+
change_list_template = "admin/translations/change_list_with_lang.html"
|
|
382
|
+
actions = [translate_with_llm]
|
|
383
|
+
inlines = [TranslationValueInline]
|
|
384
|
+
|
|
385
|
+
def get_queryset(self, request):
|
|
386
|
+
return super().get_queryset(request).prefetch_related("values")
|
|
387
|
+
|
|
388
|
+
@admin.display(description="English")
|
|
389
|
+
def en_value(self, obj):
|
|
390
|
+
return obj.get_value("en")
|
|
391
|
+
|
|
392
|
+
@admin.display(description="Luxembourgish")
|
|
393
|
+
def lb_value(self, obj):
|
|
394
|
+
return obj.get_value("lb")
|
|
395
|
+
|
|
396
|
+
@admin.display(description="French")
|
|
397
|
+
def fr_value(self, obj):
|
|
398
|
+
return obj.get_value("fr")
|
|
399
|
+
|
|
400
|
+
@admin.display(description="German")
|
|
401
|
+
def de_value(self, obj):
|
|
402
|
+
return obj.get_value("de")
|
|
403
|
+
|
|
404
|
+
@admin.display(description="English verified", boolean=True)
|
|
405
|
+
def en_verified(self, obj):
|
|
406
|
+
return obj.get_verified("en")
|
|
407
|
+
|
|
408
|
+
def changelist_view(self, request, extra_context=None):
|
|
409
|
+
lang = cache.get("admin_translation_lang", "en")
|
|
410
|
+
extra_context = extra_context or {}
|
|
411
|
+
extra_context["current_language"] = lang
|
|
412
|
+
extra_context["languages"] = [
|
|
413
|
+
(code, LANGUAGE_NAMES.get(code, code)) for code in SUPPORTED_LANGUAGES
|
|
414
|
+
]
|
|
415
|
+
return super().changelist_view(request, extra_context=extra_context)
|
|
416
|
+
|
|
417
|
+
def get_urls(self):
|
|
418
|
+
urls = super().get_urls()
|
|
419
|
+
custom_urls = [
|
|
420
|
+
path(
|
|
421
|
+
"set_translation_lang/",
|
|
422
|
+
self.admin_site.admin_view(self.set_translation_lang),
|
|
423
|
+
name="set_translation_lang",
|
|
424
|
+
),
|
|
425
|
+
path(
|
|
426
|
+
"llm_status/",
|
|
427
|
+
self.admin_site.admin_view(self.llm_status_view),
|
|
428
|
+
name="llm_translation_status",
|
|
429
|
+
),
|
|
430
|
+
path(
|
|
431
|
+
"collect_keys/",
|
|
432
|
+
self.admin_site.admin_view(self.collect_keys_view),
|
|
433
|
+
name="collect_translation_keys",
|
|
434
|
+
),
|
|
435
|
+
]
|
|
436
|
+
return custom_urls + urls
|
|
437
|
+
|
|
438
|
+
def collect_keys_view(self, request):
|
|
439
|
+
"""Collect error and notification keys from other services."""
|
|
440
|
+
if request.method != "POST":
|
|
441
|
+
return redirect("..")
|
|
442
|
+
if not request.user.is_superuser and not request.user.is_staff:
|
|
443
|
+
messages.error(request, "Permission denied.")
|
|
444
|
+
return redirect("..")
|
|
445
|
+
|
|
446
|
+
from .error_collector import collect_error_keys_from_services
|
|
447
|
+
from .notification_collector import collect_notification_keys
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
err = collect_error_keys_from_services()
|
|
451
|
+
failed = ", ".join(s["name"] for s in err["services_failed"]) or "none"
|
|
452
|
+
messages.success(
|
|
453
|
+
request,
|
|
454
|
+
f"Errors: {err['total_keys']} keys "
|
|
455
|
+
f"({err['created']} created, {err['updated']} updated). "
|
|
456
|
+
f"Failed: {failed}.",
|
|
457
|
+
)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.exception("Error keys collection failed")
|
|
460
|
+
messages.error(request, f"Error keys collection failed: {e}")
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
notif = collect_notification_keys()
|
|
464
|
+
messages.success(
|
|
465
|
+
request,
|
|
466
|
+
f"Notifications: {notif['total_keys']} keys "
|
|
467
|
+
f"({notif['created']} created, {notif['updated']} updated).",
|
|
468
|
+
)
|
|
469
|
+
except Exception as e:
|
|
470
|
+
logger.exception("Notification keys collection failed")
|
|
471
|
+
messages.error(request, f"Notification keys collection failed: {e}")
|
|
472
|
+
|
|
473
|
+
return redirect("..")
|
|
474
|
+
|
|
475
|
+
def llm_status_view(self, request):
|
|
476
|
+
"""Return current LLM translation task status."""
|
|
477
|
+
status = cache.get(LLM_TASK_CACHE_KEY) or {
|
|
478
|
+
"status": "idle",
|
|
479
|
+
"message": "No translation task running",
|
|
480
|
+
}
|
|
481
|
+
return JsonResponse(status)
|
|
482
|
+
|
|
483
|
+
def set_translation_lang(self, request):
|
|
484
|
+
if request.method == "POST":
|
|
485
|
+
lang = request.POST.get("language")
|
|
486
|
+
if lang:
|
|
487
|
+
cache.set("admin_translation_lang", lang, timeout=None)
|
|
488
|
+
return redirect(request.headers.get("referer", "/admin/"))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TranslateConfig(AppConfig):
|
|
5
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
6
|
+
name = "stapel_translate"
|
|
7
|
+
label = 'translate'
|
|
8
|
+
verbose_name = "Stapel Translate"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from stapel_core.gdpr import gdpr_registry
|
|
12
|
+
from .gdpr import TranslateGDPRProvider
|
|
13
|
+
gdpr_registry.register(TranslateGDPRProvider())
|
|
14
|
+
|
|
15
|
+
# Action subscriptions (in-process in a monolith, bus consumer in
|
|
16
|
+
# microservices — same code, transport chosen by STAPEL_COMM).
|
|
17
|
+
from . import actions # noqa: F401
|
|
18
|
+
|
|
19
|
+
# Comm task handlers (translate.autofill).
|
|
20
|
+
from . import tasks # noqa: F401
|
|
21
|
+
|
|
22
|
+
# Comm Function providers (translate.resolve).
|
|
23
|
+
from . import functions # noqa: F401
|