diff --git a/docs/admin.md b/docs/admin.md index af546e4d8..e5c378b7d 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -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. diff --git a/docs/news.md b/docs/news.md index ae1f811ea..fa27f499f 100644 --- a/docs/news.md +++ b/docs/news.md @@ -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. diff --git a/news/constants.py b/news/constants.py index 2b51cb16c..81c793d55 100644 --- a/news/constants.py +++ b/news/constants.py @@ -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" diff --git a/news/migrations/0016_aidescriptionsettings_descriptiongenerationattempt.py b/news/migrations/0016_aidescriptionsettings_descriptiongenerationattempt.py new file mode 100644 index 000000000..33f60c01a --- /dev/null +++ b/news/migrations/0016_aidescriptionsettings_descriptiongenerationattempt.py @@ -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", + ) + ], + }, + ), + ] diff --git a/news/migrations/0017_ratelimit_exempt_group.py b/news/migrations/0017_ratelimit_exempt_group.py new file mode 100644 index 000000000..a8e6935e0 --- /dev/null +++ b/news/migrations/0017_ratelimit_exempt_group.py @@ -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), + ] diff --git a/news/models.py b/news/models.py index 03af5c76e..5263e6afe 100644 --- a/news/models.py +++ b/news/models.py @@ -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 @@ -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, @@ -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 @@ -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" diff --git a/news/panels.py b/news/panels.py new file mode 100644 index 000000000..193490ad6 --- /dev/null +++ b/news/panels.py @@ -0,0 +1,47 @@ +"""Wagtail admin panels for the news app.""" + +from wagtail.admin.panels import Panel +from wagtail.log_actions import registry as log_registry + +from .constants import AI_DESCRIPTION_LIMIT_CHANGED_ACTION + +# Change entries rendered under the limit. Enough to answer "who moved this and +# when" at a glance without turning the settings screen into a log viewer. +RECENT_CHANGES_DISPLAYED = 5 + + +class AIDescriptionUsagePanel(Panel): + """Read-only panel showing today's generation usage and recent limit changes. + + Wagtail's settings URLs register no history view, so without this the + `wagtail.edit` log entries the edit view writes would be invisible in the + CMS. Rendering usage and history next to the field also satisfies the + ticket's "without leaving that screen" requirement. + """ + + def __init__(self, **kwargs): + kwargs.setdefault("heading", "Usage and history") + super().__init__(**kwargs) + + class BoundPanel(Panel.BoundPanel): + """Renders the usage figures for the current UTC day.""" + + template_name = "news/panels/ai_description_usage.html" + + def get_context_data(self, parent_context=None): + """Adds today's counts and the recent change log to the template.""" + from .services import description_generation_usage_today + + context = super().get_context_data(parent_context) + context["usage"] = description_generation_usage_today() + context["recent_changes"] = self.recent_changes() + return context + + def recent_changes(self): + """Most recent edits to this setting, newest first.""" + if self.instance.pk is None: + return [] + logs = log_registry.get_logs_for_instance(self.instance) + return logs.filter( + action=AI_DESCRIPTION_LIMIT_CHANGED_ACTION + ).select_related("user")[:RECENT_CHANGES_DISPLAYED] diff --git a/news/services.py b/news/services.py index 734cc059d..ec239ab91 100644 --- a/news/services.py +++ b/news/services.py @@ -1,4 +1,16 @@ -from .models import Entry +from datetime import datetime, timezone as dt_timezone + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Count + +from .constants import BYPASS_DESCRIPTION_LIMIT_PERMISSION +from .models import ( + AIDescriptionSettings, + DescriptionGenerationAttempt, + DescriptionGenerationOutcome, + Entry, +) # Display labels for a post's type "chip" (the small category label on post # cards and headers). Maps a raw news_type/tag to a human-facing label; anything @@ -93,3 +105,142 @@ def get_latest_post_cards(limit: int = 3) -> list[dict]: cards = _get_entry_post_cards(limit) + _get_wagtail_post_cards(limit) cards.sort(key=lambda card: card["date"], reverse=True) return cards[:limit] + + +class DescriptionQuotaExceeded(Exception): + """Raised when a user has spent their daily description generations.""" + + def __init__(self, used, limit): + self.used = used + self.limit = limit + super().__init__(f"{used}/{limit} description generations used today") + + +def utc_day_start(): + """Midnight UTC for the current day. + + Computed against UTC explicitly rather than via `timezone.now()` so a future + change to `TIME_ZONE` can't silently move when the limit resets. + """ + return datetime.now(dt_timezone.utc).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + +def counted_attempts_today(): + """Attempts since midnight UTC that consumed a generation. + + Rejections never reached the model, so they don't count against anyone's + quota - they're recorded only so the cap can be tuned from real usage. + """ + return DescriptionGenerationAttempt.objects.filter( + created_at__gte=utc_day_start() + ).exclude(outcome=DescriptionGenerationOutcome.RATE_LIMITED) + + +def is_exempt_from_description_limit(user): + """Whether `user` skips the daily cap. + + Backed by a permission rather than a group name so superusers pass without + a special case and a group rename in the admin can't quietly disable it. + """ + return user.has_perm(f"news.{BYPASS_DESCRIPTION_LIMIT_PERMISSION}") + + +def _record_rejection(user, input_type, input_size): + """Log a refused generation so the cap can be tuned from real usage.""" + DescriptionGenerationAttempt.objects.create( + user=user, + input_type=input_type, + input_size=input_size, + outcome=DescriptionGenerationOutcome.RATE_LIMITED, + ) + + +def ensure_description_generation_quota(request, input_type, input_size=0): + """Refuse an exhausted user before an expensive step, without reserving. + + For callers that have to do real work - an outbound fetch, an extraction - + before they know the input size the reservation needs. Raises + `DescriptionQuotaExceeded` when the user is already out of generations, so + a spent user cannot loop that work for free. + + Advisory only, and not a substitute for + `consume_description_generation_quota`: nothing is reserved here, so two + concurrent requests can both pass this check. The reservation is still what + settles the count. + """ + user = request.user + if is_exempt_from_description_limit(user): + return + + limit = AIDescriptionSettings.load(request_or_site=request).daily_limit + used = counted_attempts_today().filter(user=user).count() + if used >= limit: + _record_rejection(user, input_type, input_size) + raise DescriptionQuotaExceeded(used=used, limit=limit) + + +def consume_description_generation_quota(request, input_type, input_size): + """Reserve one of today's generations for the requesting user. + + Returns the `DescriptionGenerationAttempt` reserved for this call, which the + caller must resolve to a final outcome. Raises `DescriptionQuotaExceeded` + when the user is out of generations. + + The count and the insert share a transaction holding a lock on the user row, + so two concurrent requests from a scripted loop serialize instead of both + reading a stale count. The lock is released before the model call, never + held across it. + """ + user = request.user + limit = AIDescriptionSettings.load(request_or_site=request).daily_limit + used = None + + with transaction.atomic(): + if not is_exempt_from_description_limit(user): + # Result deliberately discarded: this is here to take a row lock, + # so a second concurrent request for the same user waits instead of + # counting the same stale total. + get_user_model().objects.select_for_update().filter(pk=user.pk).first() + used = counted_attempts_today().filter(user=user).count() + + if used is None or used < limit: + return DescriptionGenerationAttempt.objects.create( + user=user, + input_type=input_type, + input_size=input_size, + outcome=DescriptionGenerationOutcome.PENDING, + ) + + # Recorded outside the block above: raising inside it would roll the + # rejection row straight back out again, and the rejection count is what + # the cap gets tuned from. + _record_rejection(user, input_type, input_size) + raise DescriptionQuotaExceeded(used=used, limit=limit) + + +def description_generation_limit_reached(request): + """Whether the requesting user has already spent today's generations. + + Drives the create-post page's exhausted state on load. It is a hint for the + UI only - the endpoints enforce the limit themselves, so a stale or forged + page can't buy an extra generation. + """ + user = request.user + if not user.is_authenticated or is_exempt_from_description_limit(user): + return False + limit = AIDescriptionSettings.load(request_or_site=request).daily_limit + return counted_attempts_today().filter(user=user).count() >= limit + + +def description_generation_usage_today(): + """Today's generation counts, for the Wagtail settings panel.""" + day_start = utc_day_start() + return { + "generations": counted_attempts_today().count(), + "users_at_limit": DescriptionGenerationAttempt.objects.filter( + created_at__gte=day_start, + outcome=DescriptionGenerationOutcome.RATE_LIMITED, + ).aggregate(users=Count("user", distinct=True))["users"], + } diff --git a/news/tests/fixtures.py b/news/tests/fixtures.py index 1abfe1e6e..3baacac28 100644 --- a/news/tests/fixtures.py +++ b/news/tests/fixtures.py @@ -1,13 +1,17 @@ import datetime import io +from importlib import import_module from PIL import Image import pytest from django.db.models import Q from django.contrib.auth.models import Group, Permission +from django.apps import apps as django_apps from django.utils.timezone import now from model_bakery import baker +from news.constants import RATELIMIT_EXEMPT_GROUP + @pytest.fixture def make_entry(db): @@ -137,3 +141,15 @@ def superuser(db, make_user): groups={}, perms=[], ) + + +@pytest.fixture +def ratelimit_exempt_group(db): + """The AI-limit exemption group, seeded as the data migration seeds it. + + The suite runs with `--no-migrations`, so nothing else creates it here. + Calling the migration's own function keeps the two in step. + """ + migration = import_module("news.migrations.0017_ratelimit_exempt_group") + migration.create_ratelimit_exempt_group(django_apps, None) + return Group.objects.get(name=RATELIMIT_EXEMPT_GROUP) diff --git a/news/tests/test_ai_description_settings.py b/news/tests/test_ai_description_settings.py new file mode 100644 index 000000000..0d30aabeb --- /dev/null +++ b/news/tests/test_ai_description_settings.py @@ -0,0 +1,179 @@ +"""The admin-editable AI description limit, its validation, audit and usage.""" + +from datetime import timedelta + +import pytest +from django.urls import reverse +from model_bakery import baker +from wagtail.contrib.settings.views import get_setting_edit_handler +from wagtail.log_actions import registry as log_registry + +from ..constants import AI_DESCRIPTION_LIMIT_CHANGED_ACTION, DAILY_LIMIT_MIN_MESSAGE +from ..models import ( + AIDescriptionSettings, + DescriptionGenerationAttempt, + DescriptionGenerationOutcome, + DescriptionInputType, +) +from ..panels import AIDescriptionUsagePanel +from ..services import description_generation_usage_today, utc_day_start + +pytestmark = pytest.mark.django_db + + +def build_form(data, instance=None, for_user=None): + """The settings form exactly as the Wagtail edit view builds it. + + `AIDescriptionSettingsForm` is a `base_form_class`, so Wagtail composes the + real form class from the panels; instantiating the base directly would miss + the model binding. + """ + instance = instance or AIDescriptionSettings.load() + form_class = get_setting_edit_handler(AIDescriptionSettings).get_form_class() + return form_class(data, instance=instance, for_user=for_user) + + +def make_attempt(user, outcome, **kwargs): + """One attempt row for the current UTC day.""" + return baker.make( + DescriptionGenerationAttempt, + user=user, + input_type=DescriptionInputType.CONTENT, + input_size=10, + outcome=outcome, + **kwargs, + ) + + +class TestValidation: + @pytest.mark.parametrize("value", [0, -1]) + def test_non_positive_limits_are_rejected(self, value): + """A limit of 0 reads as "disabled" but would lock everyone out. + + Both values get the same guidance: the generated form field would + otherwise stop at 0 and refuse -1 in Django's default wording. + """ + form = build_form({"daily_limit": value}) + + assert not form.is_valid() + assert form.errors["daily_limit"] == [DAILY_LIMIT_MIN_MESSAGE] + + def test_a_positive_limit_is_accepted(self): + """The happy path still saves.""" + form = build_form({"daily_limit": 30}) + + assert form.is_valid(), form.errors + assert form.save().daily_limit == 30 + + +class TestAuditTrail: + def test_a_change_records_who_what_and_when(self, superuser): + """Wagtail logs no field values for settings, so the form logs them.""" + instance = AIDescriptionSettings.load() + form = build_form({"daily_limit": 42}, instance=instance, for_user=superuser) + assert form.is_valid(), form.errors + + form.save() + + entry = ( + log_registry.get_logs_for_instance(instance) + .filter(action=AI_DESCRIPTION_LIMIT_CHANGED_ACTION) + .first() + ) + assert entry.user == superuser + assert entry.data["daily_limit"] == {"old": 20, "new": 42} + assert entry.timestamp is not None + + def test_saving_an_unchanged_value_records_nothing(self, superuser): + """Only real changes belong in the trail.""" + instance = AIDescriptionSettings.load() + form = build_form( + {"daily_limit": instance.daily_limit}, + instance=instance, + for_user=superuser, + ) + assert form.is_valid(), form.errors + + form.save() + + assert not ( + log_registry.get_logs_for_instance(instance) + .filter(action=AI_DESCRIPTION_LIMIT_CHANGED_ACTION) + .exists() + ) + + +class TestUsageFigures: + def test_counts_todays_generations_and_users_at_the_cap( + self, regular_user, moderator_user + ): + """The two numbers the admin screen has to show.""" + make_attempt(regular_user, DescriptionGenerationOutcome.SUCCESS) + make_attempt(regular_user, DescriptionGenerationOutcome.UPSTREAM_ERROR) + make_attempt(regular_user, DescriptionGenerationOutcome.RATE_LIMITED) + make_attempt(regular_user, DescriptionGenerationOutcome.RATE_LIMITED) + make_attempt(moderator_user, DescriptionGenerationOutcome.RATE_LIMITED) + + usage = description_generation_usage_today() + + # Rejections never reached the model, so they are not generations. + assert usage["generations"] == 2 + # Rejections collapse per user: regular_user's two count once, plus + # moderator_user. + assert usage["users_at_limit"] == 2 + + def test_yesterdays_rows_are_excluded(self, regular_user): + """Figures cover the current UTC day only.""" + stale = make_attempt(regular_user, DescriptionGenerationOutcome.SUCCESS) + DescriptionGenerationAttempt.objects.filter(pk=stale.pk).update( + created_at=utc_day_start() - timedelta(seconds=1) + ) + + assert description_generation_usage_today()["generations"] == 0 + + +class TestUsagePanel: + def test_the_panel_reports_usage_and_recent_changes(self, superuser): + """Usage and history render on the edit screen itself.""" + instance = AIDescriptionSettings.load() + form = build_form({"daily_limit": 7}, instance=instance, for_user=superuser) + assert form.is_valid(), form.errors + form.save() + make_attempt(superuser, DescriptionGenerationOutcome.SUCCESS) + + panel = AIDescriptionUsagePanel().bind_to_model(AIDescriptionSettings) + bound = panel.get_bound_panel(instance=instance, request=None, form=form) + context = bound.get_context_data() + + assert context["usage"]["generations"] == 1 + assert [e.data["daily_limit"]["new"] for e in context["recent_changes"]] == [7] + + +class TestSettingsScreen: + def test_the_cms_edit_screen_renders_the_limit_and_usage(self, client, superuser): + """Smoke test: a template error in the panel would only show here.""" + make_attempt(superuser, DescriptionGenerationOutcome.SUCCESS) + client.force_login(superuser) + + response = client.get( + reverse("wagtailsettings:edit", args=["news", "aidescriptionsettings"]), + follow=True, + ) + + assert response.status_code == 200 + content = response.content.decode() + assert "daily_limit" in content + assert "generation so far today" in content + + def test_a_user_without_the_permission_cannot_reach_it(self, client, regular_user): + """Admin-only: the screen is permission gated, not just unlinked.""" + client.force_login(regular_user) + + response = client.get( + reverse("wagtailsettings:edit", args=["news", "aidescriptionsettings"]), + follow=True, + ) + + # Bounced to the CMS login rather than shown the setting. + assert response.redirect_chain + assert "daily_limit" not in response.content.decode() diff --git a/news/tests/test_description_generation.py b/news/tests/test_description_generation.py new file mode 100644 index 000000000..9dde0a92e --- /dev/null +++ b/news/tests/test_description_generation.py @@ -0,0 +1,402 @@ +"""Daily cap on the AI description generation endpoints.""" + +from datetime import timedelta + +import pytest +import waffle.testutils +from django.urls import reverse +from django.utils.html import escape +from model_bakery import baker + +from ..constants import ( + BYPASS_DESCRIPTION_LIMIT_PERMISSION, + DESCRIPTION_RATE_LIMIT_MESSAGE, +) +from ..models import ( + AIDescriptionSettings, + DescriptionGenerationAttempt, + DescriptionGenerationOutcome, + DescriptionInputType, +) +from ..services import utc_day_start + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def set_limit(): + """Sets the admin-editable daily limit.""" + + def _set(value): + settings_obj = AIDescriptionSettings.load() + settings_obj.daily_limit = value + settings_obj.save() + return settings_obj + + return _set + + +@pytest.fixture +def spend(regular_user): + """Records `count` generations already spent by a user today.""" + + def _spend(count, user=None, outcome=DescriptionGenerationOutcome.SUCCESS): + return baker.make( + DescriptionGenerationAttempt, + user=user or regular_user, + input_type=DescriptionInputType.CONTENT, + input_size=10, + outcome=outcome, + _quantity=count, + ) + + return _spend + + +@pytest.fixture +def generate(client, monkeypatch): + """POSTs to the content generator with the model call stubbed out.""" + + def _generate(summary="A generated description.", raises=False): + def fake(*args, **kwargs): + if raises: + raise RuntimeError("upstream exploded") + return summary + + monkeypatch.setattr("news.views.generate_summary", fake) + return client.post( + reverse("v3-news-generate-description"), + {"title": "T", "content": "Some post body worth summarizing."}, + ) + + return _generate + + +class TestQuotaEnforcement: + def test_under_the_limit_succeeds_and_records_the_attempt( + self, client, regular_user, set_limit, generate + ): + """A generation below the cap returns the summary and logs a SUCCESS.""" + set_limit(5) + client.force_login(regular_user) + + response = generate() + + assert response.status_code == 200 + assert response.json()["description"] == "A generated description." + attempt = DescriptionGenerationAttempt.objects.get(user=regular_user) + assert attempt.outcome == DescriptionGenerationOutcome.SUCCESS + assert attempt.input_type == DescriptionInputType.CONTENT + assert attempt.input_size > 0 + + def test_at_the_limit_returns_429_with_the_specified_copy( + self, client, regular_user, set_limit, spend, generate + ): + """The user sees the ticket's copy, not a generic error.""" + set_limit(2) + spend(2) + client.force_login(regular_user) + + response = generate() + + assert response.status_code == 429 + body = response.json() + assert body["error"] == DESCRIPTION_RATE_LIMIT_MESSAGE + assert body["rate_limited"] is True + + def test_rejection_is_recorded_separately( + self, client, regular_user, set_limit, spend, generate + ): + """Rejections are logged so the cap can be tuned from real usage.""" + set_limit(1) + spend(1) + client.force_login(regular_user) + + generate() + + assert DescriptionGenerationAttempt.objects.filter( + user=regular_user, outcome=DescriptionGenerationOutcome.RATE_LIMITED + ).exists() + + def test_rejections_do_not_consume_quota( + self, client, regular_user, set_limit, spend, generate + ): + """A refused attempt never reached the model, so it must not count.""" + set_limit(2) + spend(1) + client.force_login(regular_user) + baker.make( + DescriptionGenerationAttempt, + user=regular_user, + input_type=DescriptionInputType.CONTENT, + input_size=1, + outcome=DescriptionGenerationOutcome.RATE_LIMITED, + _quantity=5, + ) + + assert generate().status_code == 200 + + def test_a_pending_attempt_still_consumes_quota( + self, client, regular_user, set_limit, spend, generate + ): + """A request that died mid-flight left its reservation standing.""" + set_limit(1) + spend(1, outcome=DescriptionGenerationOutcome.PENDING) + client.force_login(regular_user) + + assert generate().status_code == 429 + + def test_upstream_failure_still_consumes_quota( + self, client, regular_user, set_limit, generate + ): + """The model call was made and billed, so it counts even when it fails.""" + set_limit(1) + client.force_login(regular_user) + + response = generate(raises=True) + + assert response.status_code == 502 + attempt = DescriptionGenerationAttempt.objects.get(user=regular_user) + assert attempt.outcome == DescriptionGenerationOutcome.UPSTREAM_ERROR + assert generate().status_code == 429 + + def test_yesterdays_attempts_do_not_count( + self, client, regular_user, set_limit, spend, generate + ): + """The count resets at midnight UTC.""" + set_limit(1) + client.force_login(regular_user) + stale = spend(1)[0] + DescriptionGenerationAttempt.objects.filter(pk=stale.pk).update( + created_at=utc_day_start() - timedelta(minutes=1) + ) + + assert generate().status_code == 200 + + def test_limit_change_applies_to_the_next_request( + self, client, regular_user, set_limit, spend, generate + ): + """No deploy or restart: the new value is read per request.""" + set_limit(1) + spend(1) + client.force_login(regular_user) + assert generate().status_code == 429 + + set_limit(5) + + assert generate().status_code == 200 + + def test_the_two_input_types_share_one_limit( + self, client, regular_user, set_limit, spend, generate, monkeypatch + ): + """One cap covers body content and links, not one each.""" + set_limit(2) + spend(2, outcome=DescriptionGenerationOutcome.SUCCESS) + client.force_login(regular_user) + monkeypatch.setattr("news.views.safe_get", _fake_safe_get) + monkeypatch.setattr( + "news.views.extract_article", lambda *a, **kw: ("Title", "Body text") + ) + + response = client.post( + reverse("v3-news-generate-link-description"), + {"url": "https://example.com/post"}, + ) + + assert response.status_code == 429 + assert response.json()["error"] == DESCRIPTION_RATE_LIMIT_MESSAGE + + def test_a_spent_user_never_reaches_the_outbound_fetch( + self, client, regular_user, set_limit, spend, monkeypatch + ): + """Otherwise the endpoint is an unmetered fetcher for anyone logged in.""" + set_limit(1) + spend(1) + client.force_login(regular_user) + fetched = [] + monkeypatch.setattr( + "news.views.safe_get", + lambda *a, **kw: fetched.append(a) or _FakeResponse(), + ) + + response = client.post( + reverse("v3-news-generate-link-description"), + {"url": "https://example.com/post"}, + ) + + assert response.status_code == 429 + assert fetched == [] + # Still recorded, so refusals before the fetch show in the usage figures. + assert ( + DescriptionGenerationAttempt.objects.filter( + user=regular_user, + input_type=DescriptionInputType.LINK, + outcome=DescriptionGenerationOutcome.RATE_LIMITED, + ).count() + == 1 + ) + + +class TestExemptions: + def test_superuser_is_exempt_but_still_logged( + self, client, superuser, set_limit, spend, generate + ): + """Exempt users skip the cap; their spend still shows in the numbers.""" + set_limit(1) + spend(3, user=superuser) + client.force_login(superuser) + + assert generate().status_code == 200 + assert ( + DescriptionGenerationAttempt.objects.filter( + user=superuser, outcome=DescriptionGenerationOutcome.SUCCESS + ).count() + == 4 + ) + + def test_group_member_is_exempt( + self, client, regular_user, set_limit, spend, generate, ratelimit_exempt_group + ): + """Membership of the seeded group lifts the cap.""" + set_limit(1) + spend(3) + regular_user.groups.add(ratelimit_exempt_group) + client.force_login(regular_user) + + assert generate().status_code == 200 + + def test_removing_the_user_from_the_group_restores_the_cap( + self, client, regular_user, set_limit, spend, generate, ratelimit_exempt_group + ): + """Exemption is revocable without a deploy.""" + set_limit(1) + spend(3) + group = ratelimit_exempt_group + regular_user.groups.add(group) + client.force_login(regular_user) + assert generate().status_code == 200 + + regular_user.groups.remove(group) + + assert generate().status_code == 429 + + def test_the_migration_grants_the_permission_to_the_group( + self, ratelimit_exempt_group + ): + """Guards the post_migrate ordering trap in the seeding migration. + + The fixture runs the migration's own function, so this fails if the + permission is ever looked up instead of created explicitly. + """ + assert ratelimit_exempt_group.permissions.filter( + codename=BYPASS_DESCRIPTION_LIMIT_PERMISSION + ).exists() + + +class TestEndpointAccess: + def test_anonymous_is_redirected_to_login(self, client): + """The cap is not the only gate: the endpoint requires a session.""" + response = client.post( + reverse("v3-news-generate-description"), {"content": "x"} + ) + + assert response.status_code == 302 + assert not DescriptionGenerationAttempt.objects.exists() + + def test_get_is_rejected(self, client, regular_user): + """POST only, so a bare browser hit cannot spend a generation.""" + client.force_login(regular_user) + + response = client.get(reverse("v3-news-generate-description")) + + assert response.status_code == 405 + + def test_missing_csrf_token_is_rejected(self, client, regular_user, set_limit): + """A cross-origin script without the token gets nothing.""" + set_limit(5) + client.force_login(regular_user) + csrf_client = client.__class__(enforce_csrf_checks=True) + csrf_client.force_login(regular_user) + + response = csrf_client.post( + reverse("v3-news-generate-description"), {"content": "x"} + ) + + assert response.status_code == 403 + assert not DescriptionGenerationAttempt.objects.exists() + + def test_empty_content_is_rejected_without_spending_quota( + self, client, regular_user, set_limit + ): + """Validation failures never reach the model, so they cost nothing.""" + set_limit(5) + client.force_login(regular_user) + + response = client.post(reverse("v3-news-generate-description"), {"content": ""}) + + assert response.status_code == 400 + assert not DescriptionGenerationAttempt.objects.exists() + + +class _FakeResponse: + """Stands in for a `requests` response in the link-generator path.""" + + text = "
Body text
" + + def raise_for_status(self): + """No-op: the fake fetch always succeeds.""" + + +def _fake_safe_get(*args, **kwargs): + """Replaces the outbound fetch in the link generator.""" + return _FakeResponse() + + +class TestCreatePageState: + """The create page's exhausted state, seeded from the server.""" + + def get_page(self, client, user): + """Renders the v3 create-post page as `user`, with the v3 flag on.""" + user.display_name = "Poster" + user.save() + client.force_login(user) + with waffle.testutils.override_flag("v3", active=True): + return client.get(reverse("v3-news-create")) + + def test_the_button_state_is_seeded_from_the_server( + self, client, regular_user, set_limit + ): + """With generations left, the page renders unrestricted.""" + set_limit(5) + + response = self.get_page(client, regular_user) + + assert response.status_code == 200 + assert response.context["description_generation_limit_reached"] is False + assert "rateLimited: false" in response.content.decode() + + def test_an_exhausted_user_gets_the_limit_message_on_load( + self, client, regular_user, set_limit, spend + ): + """No button, and the specified copy, without waiting for a 429.""" + set_limit(1) + spend(1) + + response = self.get_page(client, regular_user) + + content = response.content.decode() + assert response.context["description_generation_limit_reached"] is True + assert "rateLimited: true" in content + # Django escapes the apostrophe in the copy on the way out. + assert escape(DESCRIPTION_RATE_LIMIT_MESSAGE) in content + + def test_an_exempt_user_is_never_shown_as_limited( + self, client, superuser, set_limit, spend + ): + """Exempt users keep the button however much they have generated.""" + set_limit(1) + spend(5, user=superuser) + + response = self.get_page(client, superuser) + + assert response.context["description_generation_limit_reached"] is False diff --git a/news/views.py b/news/views.py index 0ac66240b..49a9e9030 100644 --- a/news/views.py +++ b/news/views.py @@ -49,6 +49,7 @@ from .constants import ( NEWS_APPROVAL_SALT, MAGIC_LINK_EXPIRATION, + DESCRIPTION_RATE_LIMIT_MESSAGE, DESCRIPTION_SUMMARY_MAX_LENGTH, ) @@ -64,8 +65,23 @@ V3NewsForm, V3VideoForm, ) -from .models import BlogPost, Entry, Link, News, Poll, Video -from .services import news_type_label +from .models import ( + BlogPost, + DescriptionGenerationOutcome, + DescriptionInputType, + Entry, + Link, + News, + Poll, + Video, +) +from .services import ( + DescriptionQuotaExceeded, + consume_description_generation_quota, + description_generation_limit_reached, + ensure_description_generation_quota, + news_type_label, +) from .tasks import generate_summary from .helpers import UnsafeURLError, extract_article, extract_content, safe_get from .notifications import ( @@ -388,9 +404,13 @@ def get(self, request, token, *args, **kwargs): return redirect(entry) -def _v3_create_context(): +def _v3_create_context(request): """Shared context variables needed by the v3 create-post template.""" return { + "description_generation_limit_reached": description_generation_limit_reached( + request + ), + "description_rate_limit_message": DESCRIPTION_RATE_LIMIT_MESSAGE, "post_type_options": [ ("blog", "Blog"), ("news", "News"), @@ -543,7 +563,7 @@ def dispatch(self, request, *args, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context.update(_v3_create_context()) + context.update(_v3_create_context(self.request)) return context def post(self, request, *args, **kwargs): @@ -669,6 +689,56 @@ def post(self, request, *args, **kwargs): return self.render_to_response(context) +def _rate_limited_response(request, input_type, exc, input_size): + """JSON 429 for a user who is out of generations, logged for tuning. + + Rejections get their own log event, separate from the per-attempt one, so + the real-world rejection rate can be read without filtering the successes. + """ + logger.info( + "description_generation.rate_limited", + user_id=request.user.pk, + input_type=input_type, + input_size=input_size, + used=exc.used, + limit=exc.limit, + ) + return JsonResponse( + {"error": DESCRIPTION_RATE_LIMIT_MESSAGE, "rate_limited": True}, + status=429, + ) + + +def _resolve_generation_attempt(attempt, summary): + """Close out a reserved attempt and build the response for it. + + A reserved attempt always resolves, so a failed model call is recorded as + such rather than left pending - and it still consumes the generation, + because the call was made and billed either way. + """ + summary = (summary or "").strip() + attempt.outcome = ( + DescriptionGenerationOutcome.SUCCESS + if summary + else DescriptionGenerationOutcome.UPSTREAM_ERROR + ) + attempt.save(update_fields=["outcome"]) + logger.info( + "description_generation.attempt", + user_id=attempt.user_id, + input_type=attempt.input_type, + input_size=attempt.input_size, + outcome=attempt.outcome, + ) + + if not summary: + return JsonResponse( + {"error": "Could not generate a description. Please try again."}, + status=502, + ) + return JsonResponse({"description": summary}) + + @login_required @require_POST def generate_description(request): @@ -678,8 +748,8 @@ def generate_description(request): Runs the summarization model inline and returns the result as JSON so the browser can drop it into the Description field. - Login-gated since it calls a paid LLM. NOTE: still no rate limiting — add - per-user throttling before relying on auth alone to bound spend. + Login-gated and capped per user per day; see + `consume_description_generation_quota`. """ title = request.POST.get("title", "").strip() content = request.POST.get("content", "").strip() @@ -693,6 +763,15 @@ def generate_description(request): # and only muddies the summary. No-op on content that's already plain text. content = extract_content(content) + try: + attempt = consume_description_generation_quota( + request, DescriptionInputType.CONTENT, len(content) + ) + except DescriptionQuotaExceeded as exc: + return _rate_limited_response( + request, DescriptionInputType.CONTENT, exc, len(content) + ) + try: # Call the plain helper summary = generate_summary( @@ -704,18 +783,9 @@ def generate_description(request): ) except Exception: logger.exception("generate_description: summarization failed") - return JsonResponse( - {"error": "Could not generate a description. Please try again."}, - status=502, - ) + summary = None - if not summary: - return JsonResponse( - {"error": "Could not generate a description. Please try again."}, - status=502, - ) - - return JsonResponse({"description": summary.strip()}) + return _resolve_generation_attempt(attempt, summary) _LINK_FETCH_ERROR = "We couldn't read that link. Please check the URL and try again." @@ -738,13 +808,21 @@ def generate_link_description(request): read that link"). - Summarization failed or returned empty (502, "couldn't generate"). - NOTE: still no rate limiting — add per-user throttling before relying on - auth alone to bound spend. + Login-gated and capped per user per day; see + `consume_description_generation_quota`. """ url = request.POST.get("url", "").strip() if not url: return JsonResponse({"error": _LINK_INVALID_ERROR}, status=400) + # Checked up front so a spent user can't loop the outbound fetch and the + # extraction for free. The reservation below stays authoritative: it needs + # the extracted body length, which isn't known yet here. + try: + ensure_description_generation_quota(request, DescriptionInputType.LINK) + except DescriptionQuotaExceeded as exc: + return _rate_limited_response(request, DescriptionInputType.LINK, exc, 0) + try: resp = safe_get(url, timeout=10) resp.raise_for_status() @@ -761,6 +839,15 @@ def generate_link_description(request): logger.warning("generate_link_description: extraction empty", url=url) return JsonResponse({"error": _LINK_FETCH_ERROR}, status=502) + try: + attempt = consume_description_generation_quota( + request, DescriptionInputType.LINK, len(body) + ) + except DescriptionQuotaExceeded as exc: + return _rate_limited_response( + request, DescriptionInputType.LINK, exc, len(body) + ) + # Feed the extracted body through the same summarizer used by the Blog/News # path — synchronously, with a real timeout so a hung upstream doesn't tie # up a web worker (autoretry_for on the Celery task is a no-op when called @@ -775,18 +862,9 @@ def generate_link_description(request): ) except Exception: logger.exception("generate_link_description: summarization failed", url=url) - return JsonResponse( - {"error": "Could not generate a description. Please try again."}, - status=502, - ) - - if not summary: - return JsonResponse( - {"error": "Could not generate a description. Please try again."}, - status=502, - ) + summary = None - return JsonResponse({"description": summary.strip()}) + return _resolve_generation_attempt(attempt, summary) class EntryApproveView( diff --git a/news/wagtail_hooks.py b/news/wagtail_hooks.py new file mode 100644 index 000000000..d677bd4d5 --- /dev/null +++ b/news/wagtail_hooks.py @@ -0,0 +1,22 @@ +"""Wagtail admin hooks for the news app.""" + +from wagtail import hooks + +from .constants import AI_DESCRIPTION_LIMIT_CHANGED_ACTION + + +@hooks.register("register_log_actions") +def register_ai_description_log_actions(actions): + """Registers the AI description limit change action. + + Wagtail's own `wagtail.edit` entry records no field values, so the settings + form logs this action instead to carry the old and new limit. + """ + + def message(data): + change = data.get("daily_limit", {}) + return f"Daily limit changed from {change.get('old')} to {change.get('new')}" + + actions.register_action( + AI_DESCRIPTION_LIMIT_CHANGED_ACTION, "AI description limit changed", message + ) diff --git a/static/css/v3/create-post-page.css b/static/css/v3/create-post-page.css index 3a03f8e83..b05f1fbcd 100644 --- a/static/css/v3/create-post-page.css +++ b/static/css/v3/create-post-page.css @@ -100,6 +100,12 @@ flex-wrap: wrap; } +/* Cap spent: the note replaces the button and can wrap, so the save indicator + lines up with its first line rather than centring against the whole block. */ +.create-post-page__description-actions--limited { + align-items: baseline; +} + /* Content has no Auto-Generate button, so its save indicator sits alone on the right. */ .create-post-page__content-actions { display: flex; @@ -112,6 +118,7 @@ .create-post-page__save-indicator { display: inline-flex; + flex: 0 0 auto; align-items: center; font-family: var(--font-sans); font-size: var(--font-size-xs); @@ -203,6 +210,28 @@ overflow-y: auto; } +/* Daily-generation limit note. Sits in the actions row where the Auto-Generate + button was, sharing the line with the save indicator. + + `flex-basis: 0` is what keeps the indicator on the same line: flex wrapping + is decided from the basis before any shrinking, so an `auto` basis would let + long copy claim the whole row and bump the indicator below it. The design specifies + one colour for this line in both themes, and --color-text-tertiary remaps to + grey-600 in dark, so the primitive is referenced directly. */ +.create-post-page__limit-note { + flex: 1 1 0; + min-width: 0; + color: var(--color-primary-grey-700); +} + +/* The project defines no global [x-cloak] rule, so an x-show element renders + until Alpine boots. Scoped to the two elements the daily cap toggles, which + would otherwise flash on every page load. */ +.create-post-page__limit-note[x-cloak], +.create-post-page__generate-btn[x-cloak] { + display: none; +} + @media (max-width: 767px) { .create-post-section { margin-top: 32px; diff --git a/templates/news/panels/ai_description_usage.html b/templates/news/panels/ai_description_usage.html new file mode 100644 index 000000000..9a640dbe5 --- /dev/null +++ b/templates/news/panels/ai_description_usage.html @@ -0,0 +1,37 @@ +{% comment %} + AI description usage panel — read-only figures and change history for + `AIDescriptionSettings`, rendered by `news.panels.AIDescriptionUsagePanel`. + + Variables: + self (BoundPanel, required) — the bound panel, supplies `heading` + usage (dict, required) — from `description_generation_usage_today()`, with: + - generations (int, required) — counted generations since midnight UTC + - users_at_limit (int, required) — distinct users refused today + recent_changes (list, optional, default []) — `ModelLogEntry` rows, newest first +{% endcomment %} + diff --git a/templates/news/v3/create.html b/templates/news/v3/create.html index bfa7e4fa4..ee630333c 100644 --- a/templates/news/v3/create.html +++ b/templates/news/v3/create.html @@ -118,9 +118,11 @@{{ description_rate_limit_message }}
+ {% comment %} Calls the generate-description endpoint; disabled while in flight. Hidden once the daily cap is spent - `rateLimited` is seeded from the view context and flipped by a 429 mid-session, but the endpoint enforces the cap either way. Full idle/generating/generated states are a follow-up. {% endcomment %} + {% comment %} Hidden when nothing is written; "Saving" while editing, "Saved" once the draft is written to localStorage (descriptionSaveStatus driven by onDescriptionEdited / restoreDescriptionDraft). {% endcomment %} @@ -163,8 +165,10 @@{{ description_rate_limit_message }}
+ Saving @@ -177,7 +181,7 @@Add a valid link above to auto-generate a description from the page.
+Add a valid link above to auto-generate a description from the page.
{% comment %} Error shown when Auto-Generate fails (invalid URL, fetch failed, or no readable text found). Cleared on next click. {% endcomment %}