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
13 changes: 13 additions & 0 deletions docs/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,16 @@
## LibraryVersions

- Button **Refresh Documentation Links** will re-run the task that retrieves the links to external documentation for every version of every Boost library.

## AI Description Settings

The "Auto-Generate Description" buttons on the v3 create-post page call a paid model, so each user gets a fixed number of generations per day. The cap is enforced by the endpoints themselves, not by hiding the button, so calling them directly does not get around it.

**Where**: Wagtail admin -> Settings -> AI Description Settings (`/cms/settings/news/aidescriptionsettings/`). It sits in the CMS beside the posts it governs. Editing it needs the `change_aidescriptionsettings` permission.

- **Daily limit** - generations per user per day, shared across both the body content and the link generator. Must be a positive whole number. A change applies from the next request; no deploy or restart.
- **Usage and history** - shown on the same screen: generations so far today, how many users were refused at the limit, and who last changed the limit, from what value to what. All counts cover the current UTC day and reset at midnight UTC.

**Exemptions**: superusers are always exempt. To exempt anyone else, add them to the `ratelimit_exempt` group at `/admin/auth/group/`. The group carries the `bypass_description_generation_limit` permission, which is what actually lifts the cap, so leave that permission attached. Exempt users' generations are still counted in the usage figures.

**Tuning the number**: every refusal is recorded, so the "users refused" figure shows whether the cap is biting. Rejections are also logged as `description_generation.rate_limited` events, separately from the per-attempt `description_generation.attempt` events.
6 changes: 6 additions & 0 deletions docs/news.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ Users can moderate if:
When an `Entry` is saved without a `summary`, `news/tasks.py` dispatches a Celery task that asks an LLM (via [OpenRouter](https://openrouter.ai), default model `gpt-oss-120b`) to produce a short plain-text summary, then writes it back to the entry. Clearing the `summary` field and saving triggers regeneration.

This is the same OpenRouter integration used by the Boost release-notes "What's New" summary in `versions/tasks.py`. Both share `OPENROUTER_API_KEY` — see [Environment Variables](./env_vars.md).

## AI description generation

`v3-news-generate-description` and `v3-news-generate-link-description` call the summarization model synchronously for the create-post page. Both are capped per user per day; the limit, the day's usage and the change history live in the Wagtail admin under Settings -> AI Description Settings. See [admin.md](admin.md).

The automatic summarization that `PostPage.save()` and `Entry.save()` dispatch to Celery when a post has no summary is a separate path and is **not** capped: it only fires for a live page, so it follows moderation rather than a user button.
27 changes: 27 additions & 0 deletions news/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,30 @@
# Target length for the AI-generated Description. Kept under the 1000-char field
# cap so the model has some leeway and the result fits without truncation.
DESCRIPTION_SUMMARY_MAX_LENGTH = 900 # characters

# Daily cap on AI description generations per user, used as the default for the
# admin-editable `AIDescriptionSettings.daily_limit`.
AI_DESCRIPTION_DAILY_LIMIT_DEFAULT = 20

# Shown to a user who has spent their daily generations. Returned by both
# generation endpoints so the copy can't drift between them and the template.
DESCRIPTION_RATE_LIMIT_MESSAGE = (
"You've used all your description generations for today. The limit resets "
"at midnight UTC. You can write the description yourself in the meantime "
"— your draft is saved."
)

# Shown when someone tries to save a limit below one generation.
DAILY_LIMIT_MIN_MESSAGE = (
"Enter a positive number of generations. To stop generation entirely, "
"remove access to the create-post page instead."
)

# Wagtail log action recording a limit change with its old and new values.
AI_DESCRIPTION_LIMIT_CHANGED_ACTION = "news.ai_description_limit_changed"

# Group whose members skip the daily cap. Membership is managed in the Django
# admin so the exempt set can change without a deploy; the group is seeded with
# `BYPASS_DESCRIPTION_LIMIT_PERMISSION` by a data migration.
RATELIMIT_EXEMPT_GROUP = "ratelimit_exempt"
BYPASS_DESCRIPTION_LIMIT_PERMISSION = "bypass_description_generation_limit"
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Generated by Django 6.0.2 on 2026-08-19 22:16

import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("news", "0015_alter_entry_image"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="AIDescriptionSettings",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"daily_limit",
models.PositiveIntegerField(
default=20,
help_text="Maximum AI description generations per user per day. Resets at midnight UTC. Applies to both the content and link generators. Superusers and members of the 'ratelimit_exempt' group are exempt.",
validators=[django.core.validators.MinValueValidator(1)],
),
),
],
options={
"verbose_name": "AI Description Settings",
},
),
migrations.CreateModel(
name="DescriptionGenerationAttempt",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"input_type",
models.CharField(
choices=[("content", "Content"), ("link", "Link")],
max_length=16,
),
),
(
"input_size",
models.PositiveIntegerField(
help_text="Characters of text sent to the model. For a link this is the extracted article body, not the URL."
),
),
(
"outcome",
models.CharField(
choices=[
("pending", "Pending"),
("success", "Success"),
("rate_limited", "Rate limited"),
("upstream_error", "Upstream error"),
],
max_length=16,
),
),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="description_generation_attempts",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"permissions": [
(
"bypass_description_generation_limit",
"Can bypass the AI description daily limit",
)
],
"indexes": [
models.Index(
fields=["user", "created_at"],
name="news_descri_user_id_d2b0fb_idx",
)
],
},
),
]
47 changes: 47 additions & 0 deletions news/migrations/0017_ratelimit_exempt_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from django.db import migrations

# Spelled out rather than imported from `news.constants`: a historical migration
# has to keep seeding the names it shipped with, whatever the constants say
# later.
GROUP_NAME = "ratelimit_exempt"
PERMISSION_CODENAME = "bypass_description_generation_limit"


def create_ratelimit_exempt_group(apps, schema_editor):
"""Seed the exemption group and grant it the bypass permission.

The permission is created explicitly rather than looked up: Django creates
model permissions in a `post_migrate` signal, which fires after the whole
migrate run, so on a fresh database the auto-created row does not exist yet
and the group would end up with no permission at all.
"""
ContentType = apps.get_model("contenttypes", "ContentType")
Permission = apps.get_model("auth", "Permission")
Group = apps.get_model("auth", "Group")

content_type, _ = ContentType.objects.get_or_create(
app_label="news", model="descriptiongenerationattempt"
)
permission, _ = Permission.objects.get_or_create(
codename=PERMISSION_CODENAME,
content_type=content_type,
defaults={"name": "Can bypass the AI description daily limit"},
)
group, _ = Group.objects.get_or_create(name=GROUP_NAME)
group.permissions.add(permission)


class Migration(migrations.Migration):
dependencies = [
("news", "0016_aidescriptionsettings_descriptiongenerationattempt"),
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
]

operations = [
# Reversible, but deliberately not by deleting: the group may predate
# this migration or have been given memberships and unrelated
# permissions since, and a rollback must not take those with it. The
# permission itself goes when the model does.
migrations.RunPython(create_ratelimit_exempt_group, migrations.RunPython.noop),
]
142 changes: 141 additions & 1 deletion news/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from structlog import get_logger
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Case, ExpressionWrapper, FloatField, F, Func, Value, When
from django.db.models.functions import Greatest, Now, Power
Expand All @@ -11,6 +12,9 @@
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.contrib.settings.models import BaseGenericSetting, register_setting
from wagtail.log_actions import log

from core.validators import (
attachment_validator,
Expand All @@ -20,7 +24,15 @@
)

from . import acl
from .constants import CONTENT_SUMMARIZATION_THRESHOLD
from .constants import (
AI_DESCRIPTION_DAILY_LIMIT_DEFAULT,
AI_DESCRIPTION_LIMIT_CHANGED_ACTION,
BYPASS_DESCRIPTION_LIMIT_PERMISSION,
CONTENT_SUMMARIZATION_THRESHOLD,
DAILY_LIMIT_MIN_MESSAGE,
RATELIMIT_EXEMPT_GROUP,
)
from .panels import AIDescriptionUsagePanel
from .tasks import summary_dispatcher
from .tasks import set_thumbnail_for_video_entry

Expand Down Expand Up @@ -359,3 +371,131 @@ class PollChoice(models.Model):


NEWS_MODELS = [BlogPost, Link, News, Poll, Video]


class DescriptionInputType(models.TextChoices):
"""Which generator produced an attempt: the post body, or a linked page."""

CONTENT = "content", _("Content")
LINK = "link", _("Link")


class DescriptionGenerationOutcome(models.TextChoices):
"""How a generation attempt ended."""

# Reserved before the model call, so a row that never leaves this state is
# a request that died mid-flight. Pending still consumes quota: the call
# was made and billed even if we never saw the answer.
PENDING = "pending", _("Pending")
SUCCESS = "success", _("Success")
RATE_LIMITED = "rate_limited", _("Rate limited")
UPSTREAM_ERROR = "upstream_error", _("Upstream error")


class DescriptionGenerationAttempt(models.Model):
"""One AI description generation attempt, successful or not.

Doubles as the daily counter behind `AIDescriptionSettings.daily_limit`, so
the number an admin sees and the number the limit enforces can never drift
apart. Rows are cheap: the cap keeps them to a couple of dozen per user
per day.
"""

user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="description_generation_attempts",
)
input_type = models.CharField(max_length=16, choices=DescriptionInputType)
input_size = models.PositiveIntegerField(
help_text=(
"Characters of text sent to the model. For a link this is the "
"extracted article body, not the URL."
)
)
outcome = models.CharField(max_length=16, choices=DescriptionGenerationOutcome)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)

class Meta:
indexes = [models.Index(fields=["user", "created_at"])]
permissions = [
(
BYPASS_DESCRIPTION_LIMIT_PERMISSION,
"Can bypass the AI description daily limit",
),
]

def __str__(self):
return f"{self.user_id} {self.input_type} {self.outcome} {self.created_at}"


class AIDescriptionSettingsForm(WagtailAdminModelForm):
"""Validates the limit and records who changed it, from what, to what.

Wagtail's settings edit view logs a bare `wagtail.edit` entry with no field
values, and registers no history UI for settings, so the old -> new pair is
logged here under a dedicated action the usage panel can read back.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Captured before validation binds cleaned data onto `self.instance`,
# which would otherwise overwrite the value we want to report.
self._original_daily_limit = (
self.instance.daily_limit if self.instance.pk else None
)
# `PositiveIntegerField.formfield()` hands the form a minimum of 0 and
# does not carry the model's `MinValueValidator(1)` across, so a
# negative value would be refused in Django's default wording while 0
# got ours. Rebuild the field at the real minimum so every value below
# one reads the same and the widget stops at 1 too.
self.fields["daily_limit"] = self.instance._meta.get_field(
"daily_limit"
).formfield(
min_value=1,
error_messages={"min_value": DAILY_LIMIT_MIN_MESSAGE},
)

def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
if self._original_daily_limit != instance.daily_limit:
log(
instance=instance,
action=AI_DESCRIPTION_LIMIT_CHANGED_ACTION,
user=self.for_user,
data={
"daily_limit": {
"old": self._original_daily_limit,
"new": instance.daily_limit,
}
},
)
return instance


@register_setting
class AIDescriptionSettings(BaseGenericSetting):
"""Admin-managed cap on AI description generation, set in the Wagtail admin.

Lives in the CMS beside the posts it governs. Read per request via
`load(request_or_site=request)`, which caches on the request only, so an
edit applies from the next request with no deploy or restart.
"""

base_form_class = AIDescriptionSettingsForm

daily_limit = models.PositiveIntegerField(
default=AI_DESCRIPTION_DAILY_LIMIT_DEFAULT,
validators=[MinValueValidator(1)],
help_text=(
"Maximum AI description generations per user per day. Resets at "
"midnight UTC. Applies to both the content and link generators. "
"Superusers and members of the "
f"'{RATELIMIT_EXEMPT_GROUP}' group are exempt."
),
)

panels = ["daily_limit", AIDescriptionUsagePanel()]

class Meta:
verbose_name = "AI Description Settings"
Loading
Loading