Skip to content

Commit 7b04b62

Browse files
authored
feat(2397): add AI-generated "What's New" for release notes (#2417)
1 parent 794d081 commit 7b04b62

13 files changed

Lines changed: 1024 additions & 2 deletions

File tree

docs/commands.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
- [`import_commits`](#import_commits)
1313
- [`update_issues`](#update_issues)
1414
- [`import_beta_release`](#import_beta_release)
15+
- [`import_release_notes`](#import_release_notes)
16+
- [`generate_whats_new`](#generate_whats_new)
1517
- [`sync_mailinglist_stats`](#sync_mailinglist_stats)
1618
- [`update_library_version_dependencies`](#update_library_version_dependencies)
1719
- [`release_tasks`](#release_tasks)
@@ -246,6 +248,51 @@ If both the `--release` and the `--library-name` are passed, the command will lo
246248
| `--delete-versions` | bool | If passed, all existing beta Version records will be deleted before the new beta release is imported. |
247249

248250

251+
## `import_release_notes`
252+
253+
**Purpose**: Fetch the rendered release notes for Boost versions and store them in the `RenderedContent` cache (keyed `release_notes_boost-X-XX-X`). Tries the AsciiDoc source on S3 first, falls back to the legacy HTML in the `boostorg/website` GitHub repo. Also fetches the in-progress release notes.
254+
255+
When a release note is freshly stored and the `Version.whats_new` field is empty, this command also queues the AI "What's New" summary task — see [`generate_whats_new`](#generate_whats_new).
256+
257+
**Example**
258+
259+
```bash
260+
./manage.py import_release_notes
261+
```
262+
263+
**Options**
264+
265+
| Options | Format | Description |
266+
|---------|--------|----------------------------------------------------------------------------------------------|
267+
| `--new` | bool | Default: `true`. If `true`, only imports notes for the most recent version. Set to `false` to import for all active versions. |
268+
269+
## `generate_whats_new`
270+
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).
272+
273+
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.
274+
275+
The LLM call is a Celery task; the worker must be running and `OPENROUTER_API_KEY` must be set (see [Environment Variables](./env_vars.md)).
276+
277+
**Example**
278+
279+
```bash
280+
./manage.py generate_whats_new --all-missing
281+
./manage.py generate_whats_new --version boost-1-90-0 --force
282+
./manage.py generate_whats_new --validate --limit 10
283+
```
284+
285+
**Options**
286+
287+
| Options | Format | Description |
288+
|------------------|--------|------------------------------------------------------------------------------------------------------------|
289+
| `--all-missing` | bool | Queue generation for every active version that has stored release notes but no `whats_new` summary yet. |
290+
| `--version` | string | Slug of a single version to (re)generate. Format: `boost-1-90-0`. |
291+
| `--force` | bool | Regenerate even when a summary already exists. The chained save task overwrites `whats_new` and resets `whats_new_approved` to `False`, so regenerated content goes back through admin moderation. |
292+
| `--dry-run` | bool | List the versions that would be queued without queuing them. |
293+
| `--validate` | bool | Run the prompt synchronously against the latest `--limit` versions (that have release notes) and print the LLM output. No DB writes. Use to review prompt changes before sign-off. |
294+
| `--limit` | int | Number of versions to process when `--validate` is set. Default: 10. |
295+
249296
## `sync_mailinglist_stats`
250297

251298
**Purpose**: Build EmailData objects from the hyperkitty email archive database.

docs/env_vars.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,15 @@ This project uses environment variables to configure certain aspects of the appl
8282

8383
### `SLACK_BOT_TOKEN`
8484
- Used to authenticate with the Slack API for pulling data for release reports.
85+
86+
## AI Summarization (OpenRouter)
87+
88+
### `OPENROUTER_API_KEY`
89+
90+
- API key for [OpenRouter](https://openrouter.ai), used by the `openai` SDK to reach the LLM that powers two features:
91+
- News/blogpost/link entry summaries (`news/tasks.py`)
92+
- The Boost release-notes "What's New" draft summary (`versions/tasks.py`)
93+
- Default model is `gpt-oss-120b`. To use a different model (e.g. a Claude model via OpenRouter), change `WHATS_NEW_MODEL` in `versions/tasks.py` and the per-handler model strings in `news/tasks.py`.
94+
- For **local development**, set this in your `.env` file. Note: docker compose only loads `env_file` at container creation, so after adding the variable run `docker compose up -d --force-recreate web celery-worker celery-beat` to pick it up.
95+
- In **deployed environments**, set as a kube secret in `kube/boost/values.yaml` (or the environment-specific yaml file).
96+
- Without this variable set, OpenRouter responds with `401 No cookie auth credentials found` and Celery retries the task up to 3 times before giving up.

docs/news.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,9 @@ Users can moderate if:
3737
- The user posses the `change_entry` permission to the News Entry model
3838
- The user is in a group which posses the `change_entry` permission to the News Entry model
3939
- The user is a Superuser
40+
41+
## AI-generated entry summaries
42+
43+
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.
44+
45+
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).

scripts/seed_whats_new_qa.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""QA helper: seed a Boost Version + release-notes RenderedContent row so
2+
the `generate_whats_new` command has something to summarize.
3+
4+
Run from the project root:
5+
6+
docker compose exec web ./manage.py shell < scripts/seed_whats_new_qa.py
7+
8+
To target a different release, edit NAME / RELEASE_NOTES_URL below.
9+
"""
10+
11+
import requests
12+
13+
from core.models import RenderedContent
14+
from versions.models import Version
15+
16+
NAME = "boost-1.89.0"
17+
RELEASE_NOTES_URL = (
18+
"https://raw.githubusercontent.com/boostorg/website/master/"
19+
"users/history/version_1_89_0.html"
20+
)
21+
22+
version, _ = Version.objects.update_or_create(
23+
name=NAME,
24+
defaults={
25+
"active": True,
26+
"fully_imported": True,
27+
"full_release": True,
28+
"beta": False,
29+
"github_url": f"https://github.com/boostorg/boost/releases/tag/{NAME}",
30+
},
31+
)
32+
33+
response = requests.get(RELEASE_NOTES_URL, timeout=30)
34+
response.raise_for_status()
35+
36+
RenderedContent.objects.update_or_create(
37+
cache_key=version.release_notes_cache_key,
38+
defaults={
39+
"content_type": "text/html",
40+
"content_html": response.text,
41+
"content_original": "",
42+
},
43+
)
44+
45+
print(f"Seeded {version.name} (slug={version.slug}, pk={version.pk})")
46+
print(f"Cache key: {version.release_notes_cache_key}")
47+
print(
48+
f"Next: docker compose exec web ./manage.py generate_whats_new --version={version.slug} --force"
49+
)

versions/admin.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
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

910
from . import models
1011
from .models import Version
12+
from .tasks import dispatch_whats_new
1113

1214

1315
class VersionFileInline(admin.StackedInline):
@@ -27,13 +29,66 @@ class VersionAdmin(admin.ModelAdmin):
2729
"beta",
2830
"fully_imported",
2931
"full_release",
32+
"whats_new_approved",
3033
]
31-
list_filter = ["active", "full_release", "beta"]
34+
list_filter = ["active", "full_release", "beta", "whats_new_approved"]
3235
ordering = ["-release_date", "-name"]
3336
search_fields = ["name", "description"]
3437
date_hierarchy = "release_date"
3538
inlines = [VersionFileInline]
3639
change_list_template = "admin/version_change_list.html"
40+
readonly_fields = ["whats_new_items_display", "whats_new_generated_at"]
41+
fieldsets = (
42+
(
43+
None,
44+
{
45+
"fields": (
46+
"name",
47+
"slug",
48+
"release_date",
49+
"description",
50+
"active",
51+
"github_url",
52+
"beta",
53+
"full_release",
54+
"data",
55+
"fully_imported",
56+
)
57+
},
58+
),
59+
(
60+
"What's New",
61+
{
62+
"fields": (
63+
"whats_new",
64+
"whats_new_items_display",
65+
"whats_new_approved",
66+
"whats_new_generated_at",
67+
),
68+
"description": (
69+
"AI-generated draft summary. Edit `whats_new` (markdown bullets) "
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."
73+
),
74+
},
75+
),
76+
)
77+
actions = ["approve_whats_new", "regenerate_whats_new"]
78+
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+
)
3792

3893
def get_queryset(self, request: HttpRequest) -> QuerySet:
3994
# we want all versions here, including not fully_imported
@@ -56,6 +111,19 @@ def import_new_releases(self, request):
56111
self.message_user(request, msg)
57112
return HttpResponseRedirect("../")
58113

114+
@admin.action(description="Approve What's New (publish)")
115+
def approve_whats_new(self, request, queryset):
116+
updated = queryset.exclude(whats_new="").update(whats_new_approved=True)
117+
self.message_user(request, f"Approved What's New for {updated} version(s).")
118+
119+
@admin.action(description="Regenerate What's New (queue task)")
120+
def regenerate_whats_new(self, request, queryset):
121+
queued = 0
122+
for version in queryset:
123+
dispatch_whats_new(version.pk)
124+
queued += 1
125+
self.message_user(request, f"Queued regeneration for {queued} version(s).")
126+
59127

60128
class ResultInline(admin.StackedInline):
61129
model = models.ReviewResult

0 commit comments

Comments
 (0)