Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ repos:
- types-python-dateutil==2.9.0.20241206
- types-simplejson==3.20.0.20250326
- types-PyYAML==6.0.12.20250402
- types-Markdown==3.10.2.20260518
files: "^kitsune/"
exclude: "/migrations/"
- repo: https://github.com/pre-commit/mirrors-eslint
Expand Down
5 changes: 5 additions & 0 deletions kitsune/celery_beat.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@
"task": "kitsune.wiki.tasks.cleanup_old_anchor_records",
"schedule": crontab(hour="1", minute="0", day_of_week="0"),
},
# Every Sunday at 01:30.
"cleanup_old_translation_records": {
"task": "kitsune.wiki.tasks.cleanup_old_translation_records",
"schedule": crontab(hour="1", minute="30", day_of_week="0"),
},
}

# Periodic tasks only for the prod environment.
Expand Down
3 changes: 3 additions & 0 deletions kitsune/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1401,6 +1401,9 @@ def setup_logger(name, formatter_str, level=logging.DEBUG):
STALE_ANCHOR_RECORD_RETENTION_DAYS = config(
"STALE_ANCHOR_RECORD_RETENTION_DAYS", default=90, cast=int
)
TRANSLATION_RECORD_RETENTION_DAYS = config(
"TRANSLATION_RECORD_RETENTION_DAYS", default=180, cast=int
)

# Threshold for how long inactive users are allowed to remain in groups.
INACTIVE_GROUP_MEMBER_RETENTION_DAYS = config(
Expand Down
86 changes: 85 additions & 1 deletion kitsune/wiki/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import itertools

import markdown
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html_join
from django.utils.safestring import mark_safe

from kitsune.wiki.models import Document, ImportantDate, Locale, PinnedArticleConfig
from kitsune.sumo.sanitize import RESTRICTED_HTML_ATTRIBUTES, RESTRICTED_HTML_TAGS, clean
from kitsune.wiki.models import (
Document,
ImportantDate,
Locale,
PinnedArticleConfig,
RevisionTranslationRecord,
)


class DocumentAdmin(admin.ModelAdmin):
Expand Down Expand Up @@ -140,4 +149,79 @@ def used_by(self, obj):
)


class RevisionTranslationRecordAdmin(admin.ModelAdmin):
"""Read-only view of the LLM explanations recorded for AI/hybrid translations."""

list_display = ("revision", "locale", "method", "trigger", "created")
list_filter = ("locale", "method", "trigger", "created")
search_fields = ("revision__document__title", "revision__document__slug")
fieldsets = (
(None, {"fields": ("revision", "locale", "method", "trigger", "created")}),
(
"LLM Explanations",
{
"fields": (
"content_explanation",
"summary_explanation",
"keywords_explanation",
"title_explanation",
)
},
),
)
readonly_fields = (
"revision",
"locale",
"method",
"trigger",
"created",
"content_explanation",
"summary_explanation",
"keywords_explanation",
"title_explanation",
)

@admin.display(description="Content")
def content_explanation(self, obj):
return self.render_explanation(obj, "content")

@admin.display(description="Summary")
def summary_explanation(self, obj):
return self.render_explanation(obj, "summary")

@admin.display(description="Keywords")
def keywords_explanation(self, obj):
return self.render_explanation(obj, "keywords")

@admin.display(description="Title")
def title_explanation(self, obj):
return self.render_explanation(obj, "title")

@staticmethod
def render_explanation(obj, key):
"""Render a single attribute's explanation, or an em dash if absent."""
text = (obj.explanation or {}).get(key)
if text:
# The LLM often uses markdown in its explanation.
html = markdown.markdown(text, extensions=["fenced_code"])
return mark_safe(
clean(html, tags=RESTRICTED_HTML_TAGS, attributes=RESTRICTED_HTML_ATTRIBUTES)
)

return "—"

def get_queryset(self, request):
return super().get_queryset(request).select_related("revision__document")

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


admin.site.register(PinnedArticleConfig, PinnedArticleConfigAdmin)
admin.site.register(RevisionTranslationRecord, RevisionTranslationRecordAdmin)
138 changes: 138 additions & 0 deletions kitsune/wiki/migrations/0025_revisiontranslationrecord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Generated by Django 5.2.14 on 2026-06-24 17:40

import django.db.models.deletion
import kitsune.sumo.models
import kitsune.sumo.utils
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("wiki", "0024_alter_pinnedarticleconfig_pinned_articles"),
]

operations = [
migrations.CreateModel(
name="RevisionTranslationRecord",
fields=[
(
"revision",
models.OneToOneField(
help_text="The translated revision this record belongs to.",
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="translation_record",
serialize=False,
to="wiki.revision",
),
),
(
"locale",
kitsune.sumo.models.LocaleField(
choices=[
("af", "Afrikaans"),
("ar", "عربي"),
("az", "Azərbaycanca"),
("bg", "Български"),
("bm", "Bamanankan"),
("bn", "বাংলা"),
("bs", "Bosanski"),
("ca", "català"),
("cs", "Čeština"),
("da", "Dansk"),
("de", "Deutsch"),
("ee", "Èʋegbe"),
("el", "Ελληνικά"),
("en-US", "English"),
("es", "Español"),
("et", "eesti keel"),
("eu", "Euskara"),
("fa", "فارسی"),
("fi", "suomi"),
("fr", "Français"),
("fy-NL", "Frysk"),
("ga-IE", "Gaeilge (Éire)"),
("gl", "Galego"),
("gn", "Avañe'ẽ"),
("gu-IN", "ગુજરાતી"),
("ha", "هَرْشَن هَوْسَ"),
("he", "עברית"),
("hi-IN", "हिन्दी (भारत)"),
("hr", "Hrvatski"),
("hu", "Magyar"),
("dsb", "Dolnoserbšćina"),
("hsb", "Hornjoserbsce"),
("id", "Bahasa Indonesia"),
("ig", "Asụsụ Igbo"),
("it", "Italiano"),
("ja", "日本語"),
("ka", "ქართული"),
("km", "ខ្មែរ"),
("kn", "ಕನ್ನಡ"),
("ko", "한국어"),
("ln", "Lingála"),
("lt", "lietuvių kalba"),
("mg", "Malagasy"),
("mk", "Македонски"),
("ml", "മലയാളം"),
("ms", "Bahasa Melayu"),
("ne-NP", "नेपाली"),
("nl", "Nederlands"),
("no", "Norsk"),
("pl", "Polski"),
("pt-BR", "Português (do Brasil)"),
("pt-PT", "Português (Europeu)"),
("ro", "română"),
("ru", "Русский"),
("si", "සිංහල"),
("sk", "slovenčina"),
("sl", "slovenščina"),
("sq", "Shqip"),
("sr", "Српски"),
("sw", "Kiswahili"),
("sv", "Svenska"),
("ta", "தமிழ்"),
("ta-LK", "தமிழ் (இலங்கை)"),
("te", "తెలుగు"),
("th", "ไทย"),
("tn", "Setswana"),
("tr", "Türkçe"),
("uk", "Українська"),
("ur", "اُردو"),
("vi", "Tiếng Việt"),
("wo", "Wolof"),
("xh", "isiXhosa"),
("yo", "èdè Yorùbá"),
("zh-CN", "中文 (简体)"),
("zh-TW", "正體中文 (繁體)"),
("zu", "isiZulu"),
],
db_index=True,
default="en-US",
help_text="The locale of the translated revision.",
max_length=7,
),
),
(
"explanation",
models.JSONField(
default=dict,
encoder=kitsune.sumo.utils.PrettyJSONEncoder,
help_text="Per-attribute LLM explanations (content, summary, keywords, title).",
),
),
(
"trigger",
models.CharField(help_text="What triggered the translation.", max_length=32),
),
(
"method",
models.CharField(help_text="The translation method used.", max_length=10),
),
("created", models.DateTimeField(auto_now_add=True, db_index=True)),
],
options={
"abstract": False,
},
),
]
43 changes: 43 additions & 0 deletions kitsune/wiki/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,49 @@ def __str__(self):
return f"<RevisionAnchorRecord: revision #{self.revision_id}>"


class RevisionTranslationRecord(ModelBase):
"""
Record of an LLM-generated AI or hybrid translation.

Tied to the translated revision that was created. The translated content itself is
stored on the revision, so the explanation is the key piece here. The explanation is
useful for understanding how the LLM is interpreting and executing the translation
prompt, which can then inform prompt adjustments.

Records are removed periodically by a Celery beat task once they are older than
settings.TRANSLATION_RECORD_RETENTION_DAYS.
"""

revision = models.OneToOneField(
Revision,
on_delete=models.CASCADE,
related_name="translation_record",
primary_key=True,
help_text="The translated revision this record belongs to.",
)
locale = LocaleField(
db_index=True,
help_text="The locale of the translated revision.",
)
explanation = models.JSONField(
default=dict,
encoder=PrettyJSONEncoder,
help_text="Per-attribute LLM explanations (content, summary, keywords, title).",
)
trigger = models.CharField(
max_length=32,
help_text="What triggered the translation.",
)
method = models.CharField(
max_length=10,
help_text="The translation method used.",
)
created = models.DateTimeField(auto_now_add=True, db_index=True)

def __str__(self):
return f"Translation record for [{self.locale}] revision #{self.revision_id}"


class HelpfulVote(ModelBase):
"""Helpful or Not Helpful vote on Revision."""

Expand Down
Loading