Skip to content

Commit 7883d28

Browse files
committed
feat(2397): address PR feedback
1 parent 57252e4 commit 7883d28

9 files changed

Lines changed: 137 additions & 68 deletions

File tree

core/htmlhelper.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import re
22

3-
import bleach
4-
import markdown
53
from bs4 import BeautifulSoup, Comment, Tag
64
from django.http import HttpHeaders
75
from django.template.loader import render_to_string
@@ -754,18 +752,6 @@ def modernize_release_notes(html_content):
754752
return get_body_from_html(content)
755753

756754

757-
def render_whats_new_markdown(text: str) -> str:
758-
"""Render the constrained bullet markdown produced by the What's New
759-
LLM prompt to HTML. Returns an empty string for empty input."""
760-
if not text or not text.strip():
761-
return ""
762-
return bleach.clean(
763-
markdown.markdown(text.strip(), extensions=["extra", "sane_lists"]),
764-
tags=["ul", "ol", "li", "p", "strong", "em", "code", "a"],
765-
attributes={"a": ["href", "title"]},
766-
)
767-
768-
769755
def is_in_no_process_libs(path: str) -> bool:
770756
return any(lib_slug in path for lib_slug in NO_PROCESS_LIBS)
771757

docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ When a release note is freshly stored and the `Version.whats_new` field is empty
268268

269269
## `generate_whats_new`
270270

271-
**Purpose**: Generate the AI-powered "What's New" draft summary for one or more Boost releases. The summary is a short, fixed-rubric bullet list (new libraries, performance, dependencies, security & reliability, developer experience) saved on the `Version` model as `whats_new` / `whats_new_html`. Drafts are not shown on the public site until an admin sets `whats_new_approved=True` (also available as a Django admin action).
271+
**Purpose**: Generate the AI-powered "What's New" draft summary for one or more Boost releases. The summary is a short, fixed-rubric bullet list (new libraries, performance, dependencies, security & reliability, developer experience) saved on the `Version` model as `whats_new` (markdown bullets). The public site parses the bullets into `whats_new_items` and renders them in the release-highlights card. Drafts are not shown on the public site until an admin sets `whats_new_approved=True` (also available as a Django admin action).
272272

273273
This command is opt-in. Auto-generation only runs as a side-effect of `import_release_notes` when a version's `whats_new` is empty. Use this command to backfill historical versions or to regenerate after editing the prompt.
274274

versions/admin.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models.query import QuerySet
44
from django.http import HttpRequest, HttpResponseRedirect
55
from django.urls import path
6+
from django.utils.html import format_html, format_html_join
67

78
from libraries.tasks import import_new_versions_tasks
89

@@ -36,7 +37,7 @@ class VersionAdmin(admin.ModelAdmin):
3637
date_hierarchy = "release_date"
3738
inlines = [VersionFileInline]
3839
change_list_template = "admin/version_change_list.html"
39-
readonly_fields = ["whats_new_html", "whats_new_generated_at"]
40+
readonly_fields = ["whats_new_items_display", "whats_new_generated_at"]
4041
fieldsets = (
4142
(
4243
None,
@@ -60,20 +61,35 @@ class VersionAdmin(admin.ModelAdmin):
6061
{
6162
"fields": (
6263
"whats_new",
63-
"whats_new_html",
64+
"whats_new_items_display",
6465
"whats_new_approved",
6566
"whats_new_generated_at",
6667
),
6768
"description": (
6869
"AI-generated draft summary. Edit `whats_new` (markdown bullets) "
69-
"and re-save to refresh the rendered HTML, or use the "
70-
"'Regenerate What's New' action."
70+
"and re-save to refresh the parsed items shown below, or use the "
71+
"'Regenerate What's New' action. Only bullets matching the "
72+
"`- **Label** — text` pattern are surfaced on the public site."
7173
),
7274
},
7375
),
7476
)
7577
actions = ["approve_whats_new", "regenerate_whats_new"]
7678

79+
@admin.display(description="Parsed items (rendered on the site)")
80+
def whats_new_items_display(self, obj: Version) -> str:
81+
items = obj.whats_new_items
82+
if not items:
83+
return "(no parseable bullets — site will not render a What's New card)"
84+
return format_html(
85+
"<ul>{}</ul>",
86+
format_html_join(
87+
"",
88+
"<li><strong>{}</strong> — {}</li>",
89+
((item["title"], item["description"]) for item in items),
90+
),
91+
)
92+
7793
def get_queryset(self, request: HttpRequest) -> QuerySet:
7894
# we want all versions here, including not fully_imported
7995
return Version.objects.with_partials()
@@ -95,25 +111,15 @@ def import_new_releases(self, request):
95111
self.message_user(request, msg)
96112
return HttpResponseRedirect("../")
97113

98-
def save_model(self, request, obj, form, change):
99-
if change and "whats_new" in form.changed_data:
100-
from core.htmlhelper import render_whats_new_markdown
101-
102-
obj.whats_new_html = render_whats_new_markdown(obj.whats_new)
103-
super().save_model(request, obj, form, change)
104-
105114
@admin.action(description="Approve What's New (publish)")
106115
def approve_whats_new(self, request, queryset):
107116
updated = queryset.exclude(whats_new="").update(whats_new_approved=True)
108117
self.message_user(request, f"Approved What's New for {updated} version(s).")
109118

110-
@admin.action(description="Regenerate What's New (clear + queue task)")
119+
@admin.action(description="Regenerate What's New (queue task)")
111120
def regenerate_whats_new(self, request, queryset):
112121
queued = 0
113122
for version in queryset:
114-
Version.objects.filter(pk=version.pk).update(
115-
whats_new="", whats_new_html="", whats_new_approved=False
116-
)
117123
dispatch_whats_new(version.pk)
118124
queued += 1
119125
self.message_user(request, f"Queued regeneration for {queued} version(s).")

versions/management/commands/generate_whats_new.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
"--all-missing",
1616
is_flag=True,
1717
default=False,
18-
help="Queue generation for every active version that has no summary yet.",
18+
help=(
19+
"Queue generation for every active version that has stored release "
20+
"notes in the Rendered Content page, but no summary yet. Versions "
21+
"without release notes are skipped."
22+
),
1923
)
2024
@click.option(
2125
"--version",
@@ -66,7 +70,7 @@ def command(
6670
if not all_missing and not version_slug:
6771
raise click.UsageError("Pass --all-missing, --version <slug>, or --validate.")
6872

69-
versions = _select_versions(all_missing, version_slug, force)
73+
versions = _select_versions(version_slug, force)
7074
if not versions:
7175
click.secho("No versions matched.", fg="yellow")
7276
return
@@ -79,19 +83,15 @@ def command(
7983
)
8084
continue
8185
click.secho(f"queueing whats_new for {version.name}", fg="green")
82-
if force:
83-
Version.objects.filter(pk=version.pk).update(
84-
whats_new="", whats_new_html="", whats_new_approved=False
85-
)
8686
dispatch_whats_new(version.pk)
8787

8888

89-
def _select_versions(all_missing: bool, version_slug: str | None, force: bool):
89+
def _select_versions(version_slug: str | None, force: bool):
9090
qs = Version.objects.active().exclude(name__in=["master", "develop"])
9191
if version_slug:
9292
qs = qs.filter(slug=version_slug)
93-
elif all_missing:
94-
qs = qs.filter(whats_new="") if not force else qs
93+
if not force:
94+
qs = qs.filter(whats_new="")
9595

9696
rendered_keys = set(
9797
RenderedContent.objects.filter(

versions/migrations/0027_version_whats_new.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,4 @@ class Migration(migrations.Migration):
3232
name="whats_new_generated_at",
3333
field=models.DateTimeField(blank=True, null=True),
3434
),
35-
migrations.AddField(
36-
model_name="version",
37-
name="whats_new_html",
38-
field=models.TextField(blank=True, default=""),
39-
),
4035
]

versions/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ class Version(models.Model):
5151
"Clear to regenerate on next release-notes import."
5252
),
5353
)
54-
whats_new_html = models.TextField(blank=True, default="")
5554
whats_new_approved = models.BooleanField(
5655
default=False,
5756
help_text="Public site only renders the summary when this is True.",
@@ -234,10 +233,12 @@ def whats_new_items(self):
234233
dicts for the v3 release-highlights card.
235234
236235
Accepts the Markdown unordered-list bullets the LLM is instructed
237-
to emit; a leading ``-`` or ``*`` marker is required:
236+
to emit; a leading ``-`` or ``*`` marker is required, followed by a
237+
bold category label (``**Label**``):
238238
- `- **New libraries** — sentence`
239239
- `* **New libraries:** sentence`
240-
Trailing `:` inside the label is stripped.
240+
Trailing `:` inside the label is stripped. Bullets missing the bold
241+
label are silently skipped and will not appear on the public site.
241242
"""
242243
if not self.whats_new:
243244
return []

versions/tasks.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,10 @@ def skip_tag(name, new=False):
626626

627627

628628
WHATS_NEW_MODEL = "gpt-oss-120b"
629+
# Guardrail to keep the LLM call comfortably under the model's context window
630+
# (gpt-oss-120b is ~131K tokens; ~100K chars leaves headroom for the system
631+
# prompt + output). Inputs above this are truncated.
632+
WHATS_NEW_MAX_INPUT_CHARS = 100_000
629633

630634
WHATS_NEW_SYSTEM_PROMPT = dedent(
631635
"""
@@ -701,6 +705,15 @@ def generate_whats_new(self, version_pk: int) -> str | None:
701705
return None
702706

703707
release_note_text = _release_note_text(rendered_content)
708+
if len(release_note_text) > WHATS_NEW_MAX_INPUT_CHARS:
709+
logger.warning(
710+
"generate_whats_new_truncating_input",
711+
version_pk=version_pk,
712+
original_chars=len(release_note_text),
713+
max_chars=WHATS_NEW_MAX_INPUT_CHARS,
714+
)
715+
release_note_text = release_note_text[:WHATS_NEW_MAX_INPUT_CHARS]
716+
704717
logger.info(
705718
"generate_whats_new_dispatching",
706719
version_pk=version_pk,
@@ -713,9 +726,15 @@ def generate_whats_new(self, version_pk: int) -> str | None:
713726
{"role": "user", "content": release_note_text},
714727
]
715728
client = OpenAI(base_url=OPENROUTER_URL, api_key=OPENROUTER_API_KEY)
716-
response = client.chat.completions.create(model=WHATS_NEW_MODEL, messages=messages)
717729
try:
730+
response = client.chat.completions.create(
731+
model=WHATS_NEW_MODEL, messages=messages
732+
)
718733
content = response.choices[0].message.content
734+
except OpenAIError:
735+
# Let autoretry_for=(OpenAIError,) on the task handle retries.
736+
logger.exception("generate_whats_new_openai_error", version_pk=version_pk)
737+
raise
719738
except (AttributeError, IndexError) as e:
720739
logger.error("generate_whats_new_response_error", error=str(e))
721740
return None
@@ -735,15 +754,12 @@ def save_whats_new(markdown_text: str | None, version_pk: int):
735754
Resets ``whats_new_approved`` to False so regenerated content goes back
736755
through admin moderation before it becomes visible on the public site.
737756
"""
738-
from core.htmlhelper import render_whats_new_markdown
739-
740757
if not markdown_text:
741758
logger.info("save_whats_new_empty_skip", version_pk=version_pk)
742759
return
743760

744761
Version.objects.filter(pk=version_pk).update(
745762
whats_new=markdown_text,
746-
whats_new_html=render_whats_new_markdown(markdown_text),
747763
whats_new_generated_at=timezone.now(),
748764
whats_new_approved=False,
749765
)

versions/tests/test_ai_tasks.py

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@
22

33
import pytest
44
from model_bakery import baker
5+
from openai import APIError
56

67
from core.models import RenderedContent
7-
from versions.tasks import generate_whats_new, save_whats_new
8+
from versions.tasks import (
9+
WHATS_NEW_MAX_INPUT_CHARS,
10+
generate_whats_new,
11+
save_whats_new,
12+
)
813
from versions.models import Version
914

1015

@@ -47,7 +52,7 @@ def test_generate_whats_new_populates_field(version):
4752

4853
version.refresh_from_db()
4954
assert version.whats_new == SAMPLE_OUTPUT
50-
assert "<ul>" in version.whats_new_html
55+
assert len(version.whats_new_items) == 3
5156
assert version.whats_new_generated_at is not None
5257
# Drafts must not auto-publish.
5358
assert version.whats_new_approved is False
@@ -79,22 +84,9 @@ def test_save_whats_new_skips_empty(version):
7984

8085
version.refresh_from_db()
8186
assert version.whats_new == ""
82-
assert version.whats_new_html == ""
8387
assert version.whats_new_generated_at is None
8488

8589

86-
@pytest.mark.django_db
87-
def test_save_whats_new_sanitizes_html(version):
88-
save_whats_new.run(
89-
"- ok bullet\n- <script>alert(1)</script> sneaky\n",
90-
version.pk,
91-
)
92-
93-
version.refresh_from_db()
94-
assert "<script>" not in version.whats_new_html
95-
assert "alert(1)" in version.whats_new_html # text survives, tag does not
96-
97-
9890
@pytest.mark.django_db
9991
def test_save_whats_new_resets_approval(version):
10092
Version.objects.filter(pk=version.pk).update(whats_new_approved=True)
@@ -104,6 +96,47 @@ def test_save_whats_new_resets_approval(version):
10496
assert version.whats_new_approved is False
10597

10698

99+
@pytest.mark.django_db
100+
def test_generate_whats_new_truncates_long_input(version):
101+
long_text = "x" * (WHATS_NEW_MAX_INPUT_CHARS + 50_000)
102+
baker.make(
103+
RenderedContent,
104+
cache_key=version.release_notes_cache_key,
105+
content_type="text/asciidoc",
106+
content_original=long_text,
107+
)
108+
109+
with patch("versions.tasks.OpenAI") as mock_openai:
110+
client = mock_openai.return_value
111+
client.chat.completions.create.return_value = _mock_openai_response(
112+
SAMPLE_OUTPUT
113+
)
114+
generate_whats_new.run(version.pk)
115+
116+
sent_messages = client.chat.completions.create.call_args.kwargs["messages"]
117+
user_content = next(m["content"] for m in sent_messages if m["role"] == "user")
118+
assert len(user_content) == WHATS_NEW_MAX_INPUT_CHARS
119+
120+
121+
@pytest.mark.django_db
122+
def test_generate_whats_new_propagates_openai_error(version):
123+
baker.make(
124+
RenderedContent,
125+
cache_key=version.release_notes_cache_key,
126+
content_type="text/asciidoc",
127+
content_original="release notes",
128+
)
129+
130+
with patch("versions.tasks.OpenAI") as mock_openai:
131+
client = mock_openai.return_value
132+
# APIError is a concrete OpenAIError subclass.
133+
client.chat.completions.create.side_effect = APIError(
134+
message="boom", request=MagicMock(), body=None
135+
)
136+
with pytest.raises(APIError):
137+
generate_whats_new.run(version.pk)
138+
139+
107140
@pytest.mark.django_db
108141
def test_whats_new_items_parses_bullets(version):
109142
Version.objects.filter(pk=version.pk).update(whats_new=SAMPLE_OUTPUT)

versions/tests/test_commands.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,41 @@ def test_generate_whats_new_force_includes_populated(
7474
queued_pks = {call.args[0] for call in mock_dispatch.call_args_list}
7575
assert queued_pks == {version_with_notes.pk, version_with_notes_and_summary.pk}
7676

77-
# --force clears the existing summary so the chained save task replaces it.
77+
# --force only controls which versions are queued; the existing summary
78+
# is left intact until the chained save task lands its replacement.
7879
version_with_notes_and_summary.refresh_from_db()
79-
assert version_with_notes_and_summary.whats_new == ""
80+
assert version_with_notes_and_summary.whats_new != ""
81+
82+
83+
@pytest.mark.django_db
84+
def test_generate_whats_new_version_skips_populated_without_force(
85+
version_with_notes_and_summary,
86+
):
87+
with patch(
88+
"versions.management.commands.generate_whats_new.dispatch_whats_new"
89+
) as mock_dispatch:
90+
call_command(
91+
"generate_whats_new", "--version", version_with_notes_and_summary.slug
92+
)
93+
94+
mock_dispatch.assert_not_called()
95+
96+
97+
@pytest.mark.django_db
98+
def test_generate_whats_new_version_with_force_overrides_populated(
99+
version_with_notes_and_summary,
100+
):
101+
with patch(
102+
"versions.management.commands.generate_whats_new.dispatch_whats_new"
103+
) as mock_dispatch:
104+
call_command(
105+
"generate_whats_new",
106+
"--version",
107+
version_with_notes_and_summary.slug,
108+
"--force",
109+
)
110+
111+
mock_dispatch.assert_called_once_with(version_with_notes_and_summary.pk)
80112

81113

82114
@pytest.mark.django_db

0 commit comments

Comments
 (0)