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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:

services:
postgres:
image: postgres:17
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres # pragma: allowlist secret
Expand Down
76 changes: 76 additions & 0 deletions apps/core/agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from django_ai_core.contrib.agents import registry as agent_registry
from django_ai_core.contrib.index import registry as index_registry
from wagtail.admin.admin_url_finder import AdminURLFinder
from wagtail.models import Page
from wagtail_ai.agents.suggested_content import MAX_LIMIT, SuggestedContentAgent

from apps.core.vector_indexes import render_first_paragraph


@agent_registry.register()
class LocalizedSuggestedContentAgent(SuggestedContentAgent):
"""
Suggests related content using the page's own title, search
description, and a short body snippet as the search query (instead of
the raw preview text), and restricts suggestions to the current page's
locale.
"""

slug = "wai_suggested_content"
BODY_SNIPPET_LENGTH = 300

def execute(
self,
vector_index: str,
exclude_pks: list[str],
content: str,
limit: int = 3,
chunk_size: int = 1000,
) -> list:
locale = None
current_page = None
if exclude_pks:
try:
current_page = Page.objects.get(pk=exclude_pks[0])
if current_page.locale:
locale = current_page.locale.language_code
except Page.DoesNotExist:
pass

if current_page is not None:
specific = current_page.specific
query_parts = [specific.title]

description = getattr(specific, "search_description", "") or ""
if description:
query_parts.append(description)

if hasattr(specific, "body"):
body_snippet = render_first_paragraph(specific)
if body_snippet:
query_parts.append(body_snippet[: self.BODY_SNIPPET_LENGTH])

query = ". ".join(query_parts)
else:
query = content[:chunk_size] if content else ""

if not query:
return []

index_cls = index_registry.get(vector_index)
index = index_cls()
finder = AdminURLFinder()

extended_limit = limit + len(exclude_pks)
if extended_limit > MAX_LIMIT:
return []

return [
{
"id": str(page.pk),
"title": page.title,
"editUrl": finder.get_edit_url(page),
}
for page in index.search_sources(query, locale=locale)[:extended_limit]
if str(page.pk) not in exclude_pks
][:limit]
54 changes: 54 additions & 0 deletions apps/core/migrations/0014_relatedpage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Generated by Django 6.1 on 2026-08-15 03:49

import django.db.models.deletion
import modelcluster.fields
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0013_remove_contentpage_table_of_contents"),
migrations.swappable_dependency(settings.WAGTAIL_PAGE_MODEL),
]

operations = [
migrations.CreateModel(
name="RelatedPage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"sort_order",
models.IntegerField(blank=True, editable=False, null=True),
),
(
"related_page",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to=settings.WAGTAIL_PAGE_MODEL,
),
),
(
"source_page",
modelcluster.fields.ParentalKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="related_pages",
to=settings.WAGTAIL_PAGE_MODEL,
),
),
],
options={
"ordering": ["sort_order"],
"abstract": False,
},
),
]
2 changes: 2 additions & 0 deletions apps/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from .content import ContentPage
from .home import HomePage
from .related_pages import RelatedPage
from .snippets import FooterContent, FooterItem

__all__ = [
"HomePage",
"ContentPage",
"FooterContent",
"FooterItem",
"RelatedPage",
]
21 changes: 20 additions & 1 deletion apps/core/models/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
from django.utils.functional import cached_property
from django.utils.html import format_html
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import FieldPanel
from wagtail.api import APIField
from wagtail.fields import StreamField
from wagtail.models import Page
from wagtail.search import index
from wagtail_ai.panels import AITitleFieldPanel
from wagtail_ai.panels import AIMultipleChooserPanel, AITitleFieldPanel

from apps.core.models.feedback import Feedback
from apps.llms_txt.mixins import MarkdownRouteMixin
Expand Down Expand Up @@ -47,6 +48,15 @@ def table_of_contents(self):
content_panels = [
AITitleFieldPanel("title"),
FieldPanel("body"),
AIMultipleChooserPanel(
"related_pages",
chooser_field_name="related_page",
heading=_("Related pages"),
label=_("Page"),
max_num=5,
vector_index="PageIndex",
suggest_limit=2,
),
]

search_fields = Page.search_fields + [index.SearchField("body")]
Expand Down Expand Up @@ -86,3 +96,12 @@ def serve(self, request, *args, **kwargs):
return HttpResponse(json.dumps(data))
else:
return super().serve(request, *args, **kwargs)

@property
def visible_related_pages(self):
related_ids = list(self.related_pages.values_list("related_page_id", flat=True))
if not related_ids:
return []
pages = Page.objects.live().public().filter(id__in=related_ids)
pages_by_id = {p.pk: p for p in pages}
return [pages_by_id[pid] for pid in related_ids if pid in pages_by_id]
20 changes: 20 additions & 0 deletions apps/core/models/home.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.conf import settings
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import FieldPanel
from wagtail.api import APIField
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from wagtail_ai.panels import AIMultipleChooserPanel

from apps.llms_txt.mixins import MarkdownRouteMixin

Expand All @@ -26,6 +28,15 @@ class HomePage(MarkdownRouteMixin, Page):
content_panels = Page.content_panels + [
FieldPanel("introduction"),
FieldPanel("sections"),
AIMultipleChooserPanel(
"related_pages",
chooser_field_name="related_page",
heading=_("Related pages"),
label=_("Page"),
max_num=5,
vector_index="PageIndex",
suggest_limit=2,
),
]

api_fields = [
Expand Down Expand Up @@ -58,3 +69,12 @@ def get_fallback_pages(self, request, path_components):
fallback_pages.append(result.page)

return fallback_pages

@property
def visible_related_pages(self):
related_ids = list(self.related_pages.values_list("related_page_id", flat=True))
if not related_ids:
return []
pages = Page.objects.live().public().filter(id__in=related_ids)
pages_by_id = {p.pk: p for p in pages}
return [pages_by_id[pid] for pid in related_ids if pid in pages_by_id]
12 changes: 12 additions & 0 deletions apps/core/models/related_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.models import Orderable


class RelatedPage(Orderable):
source_page = ParentalKey(
"wagtailcore.Page", related_name="related_pages", on_delete=models.CASCADE
)
related_page = models.ForeignKey(
"wagtailcore.Page", related_name="+", on_delete=models.CASCADE
)
16 changes: 16 additions & 0 deletions apps/core/templates/components/related_pages.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% load wagtailcore_tags i18n %}

{% with pages=page.visible_related_pages %}
{% if pages %}
<div class="related-pages">
<h2 class="related-pages__heading">{% trans "Related pages" %}</h2>
<ul class="related-pages__list">
{% for related in pages %}
<li class="related-pages__item">
<a href="{% pageurl related %}" class="related-pages__link" dir="auto">{{ related.title }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endwith %}
2 changes: 2 additions & 0 deletions apps/core/templates/core/content_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ <h2 class="toc__heading">{% trans 'In this section' %}</h2>
<em>{% blocktrans with last_updated=page.last_published_at|date:"F j, Y" trimmed %}Last updated: {{ last_updated }}{% endblocktrans %}</em>
</p>

{% include '../components/related_pages.html' %}

{% include '../components/nav_buttons.html' %}
{% include '../components/feedback.html' %}
{% endblock %}
2 changes: 2 additions & 0 deletions apps/core/templates/core/home_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ <h1 class="heading heading--display" dir="auto">{{ page.title }}</h1>

{% include_block page.sections %}

{% include '../components/related_pages.html' %}

{% endblock content %}
Loading