From 2e45a0db6043cc171e50d932d0be740e3dd1c104 Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Sun, 16 Aug 2026 04:20:11 +0300 Subject: [PATCH 1/5] Add related pages with AI-suggested options --- .github/workflows/ci.yml | 2 +- apps/core/agents.py | 76 +++++ apps/core/migrations/0014_relatedpage.py | 54 ++++ apps/core/models/__init__.py | 2 + apps/core/models/content.py | 21 +- apps/core/models/home.py | 20 ++ apps/core/models/related_pages.py | 14 + .../templates/components/related_pages.html | 16 ++ apps/core/templates/core/content_page.html | 2 + apps/core/templates/core/home_page.html | 2 + apps/core/tests/test_agents.py | 266 ++++++++++++++++++ apps/core/tests/test_related_pages.py | 99 +++++++ apps/core/tests/test_vector_indexes.py | 183 ++++++++++++ apps/core/vector_indexes.py | 85 ++++++ apps/core/wagtail_hooks.py | 8 + .../scss/components/related-pages.scss | 45 +++ apps/frontend/static_src/scss/main.scss | 1 + apps/guide/settings/base.py | 11 + apps/llms_txt/jinja2/llms_txt/page.md.jinja | 10 + docker-compose.yml | 6 +- pyproject.toml | 1 + uv.lock | 11 + 22 files changed, 930 insertions(+), 5 deletions(-) create mode 100644 apps/core/agents.py create mode 100644 apps/core/migrations/0014_relatedpage.py create mode 100644 apps/core/models/related_pages.py create mode 100644 apps/core/templates/components/related_pages.html create mode 100644 apps/core/tests/test_agents.py create mode 100644 apps/core/tests/test_related_pages.py create mode 100644 apps/core/tests/test_vector_indexes.py create mode 100644 apps/core/vector_indexes.py create mode 100644 apps/frontend/static_src/scss/components/related-pages.scss diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49372e2a..865b5f90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: services: postgres: - image: postgres:17 + image: pgvector/pgvector:pg17 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres # pragma: allowlist secret diff --git a/apps/core/agents.py b/apps/core/agents.py new file mode 100644 index 00000000..b6e648ab --- /dev/null +++ b/apps/core/agents.py @@ -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] diff --git a/apps/core/migrations/0014_relatedpage.py b/apps/core/migrations/0014_relatedpage.py new file mode 100644 index 00000000..7714a6e3 --- /dev/null +++ b/apps/core/migrations/0014_relatedpage.py @@ -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, + }, + ), + ] diff --git a/apps/core/models/__init__.py b/apps/core/models/__init__.py index dd0632f7..bf6c3e13 100644 --- a/apps/core/models/__init__.py +++ b/apps/core/models/__init__.py @@ -1,5 +1,6 @@ from .content import ContentPage from .home import HomePage +from .related_pages import RelatedPage from .snippets import FooterContent, FooterItem __all__ = [ @@ -7,4 +8,5 @@ "ContentPage", "FooterContent", "FooterItem", + "RelatedPage", ] diff --git a/apps/core/models/content.py b/apps/core/models/content.py index c804ca44..21bfbdd4 100644 --- a/apps/core/models/content.py +++ b/apps/core/models/content.py @@ -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 @@ -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")] @@ -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] diff --git a/apps/core/models/home.py b/apps/core/models/home.py index 2b43aeeb..43ac51e0 100644 --- a/apps/core/models/home.py +++ b/apps/core/models/home.py @@ -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 @@ -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 = [ @@ -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] diff --git a/apps/core/models/related_pages.py b/apps/core/models/related_pages.py new file mode 100644 index 00000000..c90c310a --- /dev/null +++ b/apps/core/models/related_pages.py @@ -0,0 +1,14 @@ +from django.db import models +from modelcluster.fields import ParentalKey +from wagtail.admin.panels import FieldPanel +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 + ) + panels = [FieldPanel("related_page")] diff --git a/apps/core/templates/components/related_pages.html b/apps/core/templates/components/related_pages.html new file mode 100644 index 00000000..e572ad74 --- /dev/null +++ b/apps/core/templates/components/related_pages.html @@ -0,0 +1,16 @@ +{% load wagtailcore_tags i18n %} + +{% with pages=page.visible_related_pages %} + {% if pages %} + + {% endif %} +{% endwith %} diff --git a/apps/core/templates/core/content_page.html b/apps/core/templates/core/content_page.html index c670305b..3c25e1cf 100644 --- a/apps/core/templates/core/content_page.html +++ b/apps/core/templates/core/content_page.html @@ -42,6 +42,8 @@

{% trans 'In this section' %}

{% blocktrans with last_updated=page.last_published_at|date:"F j, Y" trimmed %}Last updated: {{ last_updated }}{% endblocktrans %}

+ {% include '../components/related_pages.html' %} + {% include '../components/nav_buttons.html' %} {% include '../components/feedback.html' %} {% endblock %} diff --git a/apps/core/templates/core/home_page.html b/apps/core/templates/core/home_page.html index 887f1774..6875e94d 100644 --- a/apps/core/templates/core/home_page.html +++ b/apps/core/templates/core/home_page.html @@ -14,4 +14,6 @@

{{ page.title }}

{% include_block page.sections %} + {% include '../components/related_pages.html' %} + {% endblock content %} diff --git a/apps/core/tests/test_agents.py b/apps/core/tests/test_agents.py new file mode 100644 index 00000000..9f536553 --- /dev/null +++ b/apps/core/tests/test_agents.py @@ -0,0 +1,266 @@ +import json +from unittest import mock + +from django.test import TestCase + +from apps.core.agents import LocalizedSuggestedContentAgent +from apps.core.factories import ContentPageFactory, HomePageFactory + + +class FakeQuerySet(list): + """A list that supports .filter() (for locale) like a queryish queryset.""" + + def filter(self, **kwargs): + return self + + +class FakeIndex: + """Minimal stand-in for PageIndex, with a scripted search_sources.""" + + def __init__(self, pages): + self._pages = pages + + def search_sources(self, query, *, locale=None, **kwargs): + return FakeQuerySet(self._pages) + + +class TestLocalizedSuggestedContentAgent(TestCase): + def setUp(self): + self.home = HomePageFactory() + self.agent = LocalizedSuggestedContentAgent() + + def _make_page(self, **kwargs): + kwargs.setdefault("parent", self.home) + return ContentPageFactory(**kwargs) + + def _run_with_fake_index(self, current_page, pages, **kwargs): + fake_index = FakeIndex(pages) + + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + return self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(current_page.pk)], + content="ignored preview text", + **kwargs, + ) + + def test_builds_query_from_title_and_search_description(self): + page = self._make_page( + title="Manage snippets", + search_description="Reusable content elements.", + ) + captured_queries = [] + + class RecordingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + captured_queries.append(query) + return FakeQuerySet([]) + + fake_index = RecordingIndex([]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk)], + content="ignored", + ) + + self.assertEqual(len(captured_queries), 1) + query = captured_queries[0] + self.assertIn("Manage snippets", query) + self.assertIn("Reusable content elements.", query) + + def test_includes_body_snippet_in_query(self): + page = self._make_page( + title="Manage snippets", + body=json.dumps( + [{"type": "text", "value": "

Snippets let you reuse content.

"}] + ), + ) + captured_queries = [] + + class RecordingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + captured_queries.append(query) + return FakeQuerySet([]) + + fake_index = RecordingIndex([]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk)], + content="ignored", + ) + + self.assertIn("Snippets let you reuse content.", captured_queries[0]) + + def test_body_snippet_is_truncated_to_max_length(self): + long_text = "A" * 1000 + page = self._make_page( + title="Long page", + body=json.dumps([{"type": "text", "value": f"

{long_text}

"}]), + ) + captured_queries = [] + + class RecordingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + captured_queries.append(query) + return FakeQuerySet([]) + + fake_index = RecordingIndex([]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk)], + content="ignored", + ) + + query = captured_queries[0] + snippet_in_query = query.split("Long page. ")[-1] + self.assertLessEqual( + len(snippet_in_query), self.agent.BODY_SNIPPET_LENGTH + len(". ") + ) + + def test_falls_back_to_content_when_current_page_not_found(self): + captured_queries = [] + + class RecordingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + captured_queries.append(query) + return FakeQuerySet([]) + + fake_index = RecordingIndex([]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + result = self.agent.execute( + vector_index="PageIndex", + exclude_pks=["999999"], + content="Fallback preview content", + chunk_size=100, + ) + + self.assertEqual(captured_queries[0], "Fallback preview content") + self.assertEqual(result, []) + + def test_returns_empty_list_when_no_query_and_no_content(self): + result = self.agent.execute( + vector_index="PageIndex", + exclude_pks=["999999"], + content="", + ) + self.assertEqual(result, []) + + def test_excludes_pages_in_exclude_pks(self): + page = self._make_page(title="Source page") + already_related = self._make_page(title="Already added") + + fake_index = FakeIndex([already_related]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + result = self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk), str(already_related.pk)], + content="ignored", + ) + + self.assertEqual(result, []) + + def test_respects_limit(self): + page = self._make_page(title="Source page") + related_pages = [self._make_page(title=f"Match {i}") for i in range(5)] + + result = self._run_with_fake_index(page, related_pages, limit=2) + + self.assertEqual(len(result), 2) + + def test_preserves_order(self): + page = self._make_page(title="Source page") + first = self._make_page(title="First match") + second = self._make_page(title="Second match") + + result = self._run_with_fake_index(page, [first, second]) + + self.assertEqual([r["title"] for r in result], ["First match", "Second match"]) + + def test_derives_locale_from_current_page_and_filters_by_it(self): + page = self._make_page(title="Source page") + filter_calls = [] + + class SpyingQuerySet(FakeQuerySet): + def filter(self, **kwargs): + filter_calls.append(kwargs) + return self + + class SpyingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + qs = SpyingQuerySet(self._pages) + if locale: + qs = qs.filter(locale=locale) + return qs + + fake_index = SpyingIndex([]) + with mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ): + self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk)], + content="ignored", + ) + + self.assertEqual(filter_calls, [{"locale": page.locale.language_code}]) + + def test_does_not_filter_by_locale_when_current_page_has_none(self): + page = self._make_page(title="Source page") + filter_calls = [] + + class SpyingQuerySet(FakeQuerySet): + def filter(self, **kwargs): + filter_calls.append(kwargs) + return self + + class SpyingIndex(FakeIndex): + def search_sources(self, query, *, locale=None, **kwargs): + qs = SpyingQuerySet(self._pages) + if locale: + qs = qs.filter(locale=locale) + return qs + + fake_index = SpyingIndex([]) + fake_page = mock.Mock(pk=page.pk, locale=None, specific=mock.Mock()) + fake_page.specific.title = "Source page" + fake_page.specific.search_description = "" + del fake_page.specific.body + + with ( + mock.patch( + "apps.core.agents.index_registry.get", return_value=lambda: fake_index + ), + mock.patch("apps.core.agents.Page.objects.get", return_value=fake_page), + ): + self.agent.execute( + vector_index="PageIndex", + exclude_pks=[str(page.pk)], + content="ignored", + ) + + self.assertEqual(filter_calls, []) + + def test_returns_edit_url_for_each_suggestion(self): + page = self._make_page(title="Source page") + related = self._make_page(title="Related page") + + result = self._run_with_fake_index(page, [related]) + + self.assertEqual(len(result), 1) + self.assertIn("editUrl", result[0]) + self.assertTrue(result[0]["editUrl"]) diff --git a/apps/core/tests/test_related_pages.py b/apps/core/tests/test_related_pages.py new file mode 100644 index 00000000..e07e512e --- /dev/null +++ b/apps/core/tests/test_related_pages.py @@ -0,0 +1,99 @@ +from django.test import TestCase +from wagtail.models import PageViewRestriction + +from apps.core.factories import ContentPageFactory, HomePageFactory +from apps.core.models import RelatedPage + + +class TestRelatedPages(TestCase): + def setUp(self): + self.home = HomePageFactory() + self.source = ContentPageFactory(parent=self.home, title="Getting started") + self.related_1 = ContentPageFactory(parent=self.home, title="How-to guides") + self.related_2 = ContentPageFactory(parent=self.home, title="Concepts") + + RelatedPage.objects.create( + source_page=self.source, related_page=self.related_2, sort_order=0 + ) + RelatedPage.objects.create( + source_page=self.source, related_page=self.related_1, sort_order=1 + ) + + def _get_related_pages_html(self, response): + content = response.content.decode() + start = content.index('class="related-pages"') + end = content.index("", start) + return content[start:end] + + def test_related_pages_render_in_curated_order(self): + response = self.client.get(self.source.url) + related_html = self._get_related_pages_html(response) + + concepts_index = related_html.index(self.related_2.title) + how_to_index = related_html.index(self.related_1.title) + self.assertLess(concepts_index, how_to_index) + + def test_related_pages_link_to_correct_urls(self): + response = self.client.get(self.source.url) + related_html = self._get_related_pages_html(response) + + self.assertIn(f'href="{self.related_1.url}"', related_html) + self.assertIn(f'href="{self.related_2.url}"', related_html) + + def test_no_related_pages_section_when_empty(self): + other_page = ContentPageFactory(parent=self.home, title="Lonely page") + response = self.client.get(other_page.url) + content = response.content.decode() + + self.assertNotIn("related-pages", content) + + def test_draft_related_pages_are_excluded(self): + draft_page = ContentPageFactory( + parent=self.home, title="Draft page", live=False + ) + RelatedPage.objects.create( + source_page=self.source, related_page=draft_page, sort_order=2 + ) + + response = self.client.get(self.source.url) + content = response.content.decode() + + self.assertNotIn(draft_page.title, content) + + def test_private_related_pages_are_excluded(self): + private_page = ContentPageFactory(parent=self.home, title="Private page") + PageViewRestriction.objects.create( + page=private_page, + restriction_type=PageViewRestriction.LOGIN, + ) + RelatedPage.objects.create( + source_page=self.source, related_page=private_page, sort_order=2 + ) + + response = self.client.get(self.source.url) + content = response.content.decode() + + self.assertNotIn(private_page.title, content) + + def test_related_pages_appear_in_markdown_export(self): + markdown = self.source.to_markdown() + + self.assertIn("## Related pages", markdown) + self.assertIn(f"[{self.related_1.title}]({self.related_1.full_url})", markdown) + self.assertIn(f"[{self.related_2.title}]({self.related_2.full_url})", markdown) + + def test_no_related_pages_heading_in_markdown_when_empty(self): + other_page = ContentPageFactory(parent=self.home, title="Lonely page") + markdown = other_page.to_markdown() + + self.assertNotIn("## Related pages", markdown) + + def test_home_page_related_pages_render(self): + RelatedPage.objects.create( + source_page=self.home, related_page=self.related_1, sort_order=0 + ) + + response = self.client.get(self.home.url) + related_html = self._get_related_pages_html(response) + + self.assertIn(self.related_1.title, related_html) diff --git a/apps/core/tests/test_vector_indexes.py b/apps/core/tests/test_vector_indexes.py new file mode 100644 index 00000000..5de315e7 --- /dev/null +++ b/apps/core/tests/test_vector_indexes.py @@ -0,0 +1,183 @@ +import json +from unittest import mock + +from django.test import TestCase + +from apps.core.factories import ContentPageFactory, HomePageFactory +from apps.core.vector_indexes import PageIndex, PageSource, render_first_paragraph + + +class TestRenderFirstParagraph(TestCase): + def _page_with_body(self, blocks): + return ContentPageFactory(body=json.dumps(blocks)) + + def test_returns_first_text_block(self): + page = self._page_with_body( + [{"type": "text", "value": "

First paragraph.

"}] + ) + self.assertEqual(render_first_paragraph(page), "First paragraph.") + + def test_skips_alert_block_and_returns_first_text_block(self): + page = self._page_with_body( + [ + { + "type": "alert", + "value": { + "alert_type": "Note", + "alert_body": "

An alert callout.

", + }, + }, + {"type": "text", "value": "

The real content.

"}, + ] + ) + self.assertEqual(render_first_paragraph(page), "The real content.") + + def test_returns_text_annotated_block_content(self): + page = self._page_with_body( + [ + { + "type": "text_annotated", + "value": {"content": "

Annotated paragraph.

"}, + } + ] + ) + self.assertEqual(render_first_paragraph(page), "Annotated paragraph.") + + def test_returns_empty_string_when_no_text_blocks(self): + page = self._page_with_body( + [ + { + "type": "alert", + "value": { + "alert_type": "Note", + "alert_body": "

Only an alert here.

", + }, + } + ] + ) + self.assertEqual(render_first_paragraph(page), "") + + def test_returns_empty_string_for_empty_body(self): + page = self._page_with_body([]) + self.assertEqual(render_first_paragraph(page), "") + + +class TestPageSource(TestCase): + def setUp(self): + self.home = HomePageFactory() + + def _make_source(self): + # Avoid instantiating CoreEmbeddingTransformer (which requires a + # configured LLM service) by building a PageSource directly. + from apps.core.models.content import ContentPage + + return PageSource(queryset=ContentPage.objects.live().public()) + + def test_get_content_includes_title_and_body_text(self): + page = ContentPageFactory( + parent=self.home, + title="Managing snippets", + body=json.dumps([{"type": "text", "value": "

Snippet content.

"}]), + ) + source = self._make_source() + content = source.get_content(page) + + self.assertIn("Managing snippets", content) + self.assertIn("Snippet content.", content) + + def test_get_content_includes_search_description(self): + page = ContentPageFactory( + parent=self.home, + title="Managing snippets", + search_description="A short summary of the page.", + ) + source = self._make_source() + content = source.get_content(page) + + self.assertIn("A short summary of the page.", content) + + def test_get_content_includes_seo_title_when_different(self): + page = ContentPageFactory( + parent=self.home, + title="Managing snippets", + seo_title="Snippets - Full SEO Title", + ) + source = self._make_source() + content = source.get_content(page) + + self.assertIn("Snippets - Full SEO Title", content) + + def test_get_content_omits_seo_title_when_same_as_title(self): + page = ContentPageFactory(parent=self.home, title="Managing snippets") + page.seo_title = page.title + source = self._make_source() + content = source.get_content(page) + + # Title should only appear once (not duplicated as seo_title). + self.assertEqual(content.count("Managing snippets"), 1) + + def test_get_content_strips_html_tags(self): + page = ContentPageFactory( + parent=self.home, + body=json.dumps( + [{"type": "text", "value": "

Text with bold.

"}] + ), + ) + source = self._make_source() + content = source.get_content(page) + + self.assertNotIn("

", content) + self.assertNotIn("", content) + self.assertIn("Text with bold.", content) + + def test_uses_paragraph_chunk_transformer(self): + from django_ai_core.contrib.index.chunking import ParagraphChunkTransformer + + source = self._make_source() + self.assertIsInstance(source.chunk_transformer, ParagraphChunkTransformer) + + def test_get_metadata_includes_locale(self): + page = ContentPageFactory(parent=self.home, title="Managing snippets") + source = self._make_source() + metadata = source.get_metadata(page) + + self.assertEqual(metadata["locale"], page.locale.language_code) + + +class TestPageIndex(TestCase): + def test_search_sources_applies_locale_filter(self): + with mock.patch( + "apps.core.vector_indexes.get_llm_service" + ) as mock_get_llm_service: + mock_get_llm_service.return_value = mock.Mock() + index = PageIndex() + + fake_queryset = mock.Mock() + fake_queryset.filter.return_value = "filtered-queryset" + + with mock.patch( + "django_ai_core.contrib.index.base.VectorIndex.search_sources", + return_value=fake_queryset, + ): + result = index.search_sources("some query", locale="en") + + fake_queryset.filter.assert_called_once_with(locale="en") + self.assertEqual(result, "filtered-queryset") + + def test_search_sources_skips_filter_when_no_locale(self): + with mock.patch( + "apps.core.vector_indexes.get_llm_service" + ) as mock_get_llm_service: + mock_get_llm_service.return_value = mock.Mock() + index = PageIndex() + + fake_queryset = mock.Mock() + + with mock.patch( + "django_ai_core.contrib.index.base.VectorIndex.search_sources", + return_value=fake_queryset, + ): + result = index.search_sources("some query") + + fake_queryset.filter.assert_not_called() + self.assertEqual(result, fake_queryset) diff --git a/apps/core/vector_indexes.py b/apps/core/vector_indexes.py new file mode 100644 index 00000000..b6c24044 --- /dev/null +++ b/apps/core/vector_indexes.py @@ -0,0 +1,85 @@ +from bs4 import BeautifulSoup +from django.template import Context, Template +from django_ai_core.contrib.index import VectorIndex, registry +from django_ai_core.contrib.index.chunking import ParagraphChunkTransformer +from django_ai_core.contrib.index.embedding import CoreEmbeddingTransformer +from django_ai_core.contrib.index.source import ModelSource +from django_ai_core.contrib.index.storage.pgvector import PgVectorProvider +from wagtail_ai.agents.base import get_llm_service + +from apps.core.models.content import ContentPage + + +def render_body_text(obj) -> str: + """Render a page's StreamField body to plain text (HTML tags stripped).""" + template = Template("{% load wagtailcore_tags %}{% include_block body %}") + html = template.render(Context({"body": obj.body})) + return BeautifulSoup(html, "lxml").get_text() + + +def render_first_paragraph(obj) -> str: + """ + Render the first substantive text block of a page's StreamField body, + skipping non-text blocks such as "alert" callouts, which tend to + restate the search description rather than add distinct content. + """ + for block in obj.body: + if block.block_type == "text": + html = str(block.value) + elif block.block_type == "text_annotated": + html = str(block.value["content"]) + else: + continue + text = BeautifulSoup(html, "lxml").get_text().strip() + if text: + return text + return "" + + +class PageSource(ModelSource): + """ModelSource that renders StreamField body to plain text for embeddings.""" + + def __init__(self, queryset=None, **kwargs): + super().__init__(queryset=queryset, **kwargs) + self.chunk_transformer = ParagraphChunkTransformer( + min_chunk_size=100, + max_chunk_size=1500, + ) + + def get_content(self, obj): + text = render_body_text(obj) + parts = [obj.title] + if obj.search_description: + parts.append(obj.search_description) + if obj.seo_title and obj.seo_title != obj.title: + parts.append(obj.seo_title) + parts.append(text) + return "\n\n".join(parts) + + def get_metadata(self, obj): + metadata = super().get_metadata(obj) + if obj.locale: + metadata["locale"] = obj.locale.language_code + return metadata + + +@registry.register() +class PageIndex(VectorIndex): + storage_provider = PgVectorProvider() + + def __init__(self): + self.sources = [ + PageSource( + queryset=ContentPage.objects.live().public(), + ) + ] + self.embedding_transformer = CoreEmbeddingTransformer( + get_llm_service("embedding") + ) + super().__init__() + + def search_sources(self, query, *, locale=None, **kwargs): + qs = super().search_sources(query, **kwargs) + if locale: + qs = qs.filter(locale=locale) + return qs diff --git a/apps/core/wagtail_hooks.py b/apps/core/wagtail_hooks.py index 49801f0c..64a7ba51 100644 --- a/apps/core/wagtail_hooks.py +++ b/apps/core/wagtail_hooks.py @@ -9,10 +9,18 @@ from wagtail.models import Page from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet +from wagtail_ai.agents.suggested_content import SuggestedContentAgent # noqa: F401 from wagtail_ai.panels import AIDescriptionFieldPanel +from apps.core import vector_indexes # noqa: F401 +from apps.core.agents import LocalizedSuggestedContentAgent # noqa: F401 from apps.core.models.feedback import Feedback +# Note: the `wagtail_ai.agents.suggested_content` import above must stay +# before the `apps.core.agents` import. It triggers registration of the +# default SuggestedContentAgent, so our LocalizedSuggestedContentAgent +# (registered with the same slug) is registered last and takes priority. + STYLE_GUIDE_URL = "https://github.com/wagtail/guide/blob/main/docs/style-guide.md" CONTRIBUTING_URL = "https://github.com/wagtail/guide/blob/main/CONTRIBUTING.md" diff --git a/apps/frontend/static_src/scss/components/related-pages.scss b/apps/frontend/static_src/scss/components/related-pages.scss new file mode 100644 index 00000000..167541a7 --- /dev/null +++ b/apps/frontend/static_src/scss/components/related-pages.scss @@ -0,0 +1,45 @@ +.related-pages { + margin-bottom: ($gutter * 3); + + &__heading { + @include fs(m); + font-weight: 700; + margin-bottom: ($gutter * 1.5); + } + + &__list { + list-style-type: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: 1fr; + gap: $gutter; + + @include media-query(medium) { + grid-template-columns: repeat(2, 1fr); + } + } + + &__item { + margin: 0; + } + + &__link { + display: block; + padding: ($gutter * 0.75) ($gutter * 1.25); + border-radius: $border-radius--lg; + background: light-dark($color--light-grey, $color--off-black); + text-decoration: none; + transition: background $transition, transform $transition; + border-inline-start: 3px solid light-dark($color--teal, $color--light-teal); + + @include fs(xs); + font-weight: 700; + color: light-dark($color--blue, $color--white); + + &:hover { + background: light-dark($color--extra-light-teal, $color--grey); + transform: translateY(-2px); + } + } +} diff --git a/apps/frontend/static_src/scss/main.scss b/apps/frontend/static_src/scss/main.scss index d9aa4964..4ffb0fa3 100644 --- a/apps/frontend/static_src/scss/main.scss +++ b/apps/frontend/static_src/scss/main.scss @@ -35,6 +35,7 @@ @import './components/link-block'; @import './components/nav-buttons'; @import './components/navigation'; +@import './components/related-pages'; @import './components/page-tools'; @import './components/primary-nav'; @import './components/rich-text'; diff --git a/apps/guide/settings/base.py b/apps/guide/settings/base.py index b6ef5eb1..66f8189a 100644 --- a/apps/guide/settings/base.py +++ b/apps/guide/settings/base.py @@ -58,6 +58,9 @@ "wagtail", "modelcluster", "taggit", + "django_ai_core", + "django_ai_core.contrib.index", + "django_ai_core.contrib.index.storage.pgvector", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -672,6 +675,14 @@ "api_key": os.environ.get("WAGTAIL_AI_VISION_API_KEY"), "api_base": os.environ.get("WAGTAIL_AI_VISION_API_BASE"), }, + "embedding": { + "provider": "openai", + "model": os.environ.get( + "WAGTAIL_AI_EMBEDDING_MODEL", "openai/text-embedding-3-large" + ), + "api_key": os.environ.get("KILO_API_KEY"), + "api_base": "https://api.kilo.ai/api/gateway", + }, }, "IMAGE_DESCRIPTION_PROVIDER": "vision", # Legacy configuration still in use for rich text integration. diff --git a/apps/llms_txt/jinja2/llms_txt/page.md.jinja b/apps/llms_txt/jinja2/llms_txt/page.md.jinja index 65625e3d..c9e08745 100644 --- a/apps/llms_txt/jinja2/llms_txt/page.md.jinja +++ b/apps/llms_txt/jinja2/llms_txt/page.md.jinja @@ -27,3 +27,13 @@ Page URL: {{ page.full_url }} {% endfor %} {%- endif -%} + +{%- if page.visible_related_pages %} + +## Related pages + +{% for related in page.visible_related_pages -%} +- [{{ related.title }}]({{ related.full_url }}) +{% endfor -%} +{%- endif %} + diff --git a/docker-compose.yml b/docker-compose.yml index 4668ebaf..d01de717 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,9 +26,9 @@ services: db: condition: service_healthy db: - image: postgres:17 - expose: - - 5432 + image: pgvector/pgvector:pg17 + ports: + - "5432:5432" environment: # Location of DB data folder PGDATA: /var/lib/postgresql/data/pgdata diff --git a/pyproject.toml b/pyproject.toml index 09e43f8f..ae3afd31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "draftjs-exporter>=7.1.0,<8.0", "jinja2>=3.1.6,<4.0.0", "wagtail-ai>=3.1.1,<4.0.0", + "pgvector>=0.3.6,<0.6.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 5e33c5b5..5b6b9b0c 100644 --- a/uv.lock +++ b/uv.lock @@ -642,6 +642,7 @@ dependencies = [ { name = "draftjs-exporter" }, { name = "jinja2" }, { name = "lxml" }, + { name = "pgvector" }, { name = "psycopg" }, { name = "sentry-sdk", extra = ["django"] }, { name = "wagtail" }, @@ -675,6 +676,7 @@ requires-dist = [ { name = "draftjs-exporter", specifier = ">=7.1.0,<8.0" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, { name = "lxml", specifier = ">=4.9,<7" }, + { name = "pgvector", specifier = ">=0.3.6,<0.6.0" }, { name = "psycopg", specifier = ">=3.2.10,<3.3" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.45.0,<3.0.0" }, { name = "wagtail", specifier = "==8.0rc1" }, @@ -1046,6 +1048,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pgvector" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, +] + [[package]] name = "pillow" version = "12.3.0" From f55ed6d645d6865bbdf0ccc750417b41d238796e Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Sun, 16 Aug 2026 04:37:12 +0300 Subject: [PATCH 2/5] Remove unused panels definition from RelatedPage --- apps/core/models/related_pages.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/core/models/related_pages.py b/apps/core/models/related_pages.py index c90c310a..a485abe7 100644 --- a/apps/core/models/related_pages.py +++ b/apps/core/models/related_pages.py @@ -1,6 +1,5 @@ from django.db import models from modelcluster.fields import ParentalKey -from wagtail.admin.panels import FieldPanel from wagtail.models import Orderable @@ -11,4 +10,3 @@ class RelatedPage(Orderable): related_page = models.ForeignKey( "wagtailcore.Page", related_name="+", on_delete=models.CASCADE ) - panels = [FieldPanel("related_page")] From 7d1e3c663445ab67dceca7cf6b7264205bdb9126 Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Sun, 16 Aug 2026 04:52:32 +0300 Subject: [PATCH 3/5] Fix Prettier formatting on SCSS and docker-compose --- apps/frontend/static_src/scss/components/related-pages.scss | 3 ++- docker-compose.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/frontend/static_src/scss/components/related-pages.scss b/apps/frontend/static_src/scss/components/related-pages.scss index 167541a7..a41ad9e1 100644 --- a/apps/frontend/static_src/scss/components/related-pages.scss +++ b/apps/frontend/static_src/scss/components/related-pages.scss @@ -31,7 +31,8 @@ background: light-dark($color--light-grey, $color--off-black); text-decoration: none; transition: background $transition, transform $transition; - border-inline-start: 3px solid light-dark($color--teal, $color--light-teal); + border-inline-start: 3px solid + light-dark($color--teal, $color--light-teal); @include fs(xs); font-weight: 700; diff --git a/docker-compose.yml b/docker-compose.yml index d01de717..a1eaf10e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: db: image: pgvector/pgvector:pg17 ports: - - "5432:5432" + - '5432:5432' environment: # Location of DB data folder PGDATA: /var/lib/postgresql/data/pgdata From d1d5170663d0956105d249a5d4fab9612b633c7d Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Sun, 16 Aug 2026 05:09:53 +0300 Subject: [PATCH 4/5] Clean up tests and fix embedding provider config --- apps/core/tests/test_agents.py | 151 ++++--------------------- apps/core/tests/test_related_pages.py | 65 ++++------- apps/core/tests/test_vector_indexes.py | 103 ++--------------- apps/core/wagtail_hooks.py | 11 +- apps/guide/settings/base.py | 6 +- 5 files changed, 60 insertions(+), 276 deletions(-) diff --git a/apps/core/tests/test_agents.py b/apps/core/tests/test_agents.py index 9f536553..800f76f1 100644 --- a/apps/core/tests/test_agents.py +++ b/apps/core/tests/test_agents.py @@ -33,9 +33,9 @@ def _make_page(self, **kwargs): kwargs.setdefault("parent", self.home) return ContentPageFactory(**kwargs) - def _run_with_fake_index(self, current_page, pages, **kwargs): + def _run(self, current_page, pages, **kwargs): + """Run the agent with a fake index returning the given pages.""" fake_index = FakeIndex(pages) - with mock.patch( "apps.core.agents.index_registry.get", return_value=lambda: fake_index ): @@ -46,99 +46,51 @@ def _run_with_fake_index(self, current_page, pages, **kwargs): **kwargs, ) - def test_builds_query_from_title_and_search_description(self): - page = self._make_page( - title="Manage snippets", - search_description="Reusable content elements.", - ) - captured_queries = [] + def _run_and_capture_query(self, page): + """Run the agent with a recording index that captures the search query.""" + captured = [] class RecordingIndex(FakeIndex): def search_sources(self, query, *, locale=None, **kwargs): - captured_queries.append(query) + captured.append(query) return FakeQuerySet([]) - fake_index = RecordingIndex([]) with mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index + "apps.core.agents.index_registry.get", + return_value=lambda: RecordingIndex([]), ): self.agent.execute( vector_index="PageIndex", exclude_pks=[str(page.pk)], content="ignored", ) + return captured[0] - self.assertEqual(len(captured_queries), 1) - query = captured_queries[0] - self.assertIn("Manage snippets", query) - self.assertIn("Reusable content elements.", query) - - def test_includes_body_snippet_in_query(self): + def test_builds_query_from_title_description_and_body_snippet(self): page = self._make_page( title="Manage snippets", + search_description="Reusable content elements.", body=json.dumps( [{"type": "text", "value": "

Snippets let you reuse content.

"}] ), ) - captured_queries = [] + query = self._run_and_capture_query(page) - class RecordingIndex(FakeIndex): - def search_sources(self, query, *, locale=None, **kwargs): - captured_queries.append(query) - return FakeQuerySet([]) - - fake_index = RecordingIndex([]) - with mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index - ): - self.agent.execute( - vector_index="PageIndex", - exclude_pks=[str(page.pk)], - content="ignored", - ) - - self.assertIn("Snippets let you reuse content.", captured_queries[0]) - - def test_body_snippet_is_truncated_to_max_length(self): - long_text = "A" * 1000 - page = self._make_page( - title="Long page", - body=json.dumps([{"type": "text", "value": f"

{long_text}

"}]), - ) - captured_queries = [] - - class RecordingIndex(FakeIndex): - def search_sources(self, query, *, locale=None, **kwargs): - captured_queries.append(query) - return FakeQuerySet([]) - - fake_index = RecordingIndex([]) - with mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index - ): - self.agent.execute( - vector_index="PageIndex", - exclude_pks=[str(page.pk)], - content="ignored", - ) - - query = captured_queries[0] - snippet_in_query = query.split("Long page. ")[-1] - self.assertLessEqual( - len(snippet_in_query), self.agent.BODY_SNIPPET_LENGTH + len(". ") - ) + self.assertIn("Manage snippets", query) + self.assertIn("Reusable content elements.", query) + self.assertIn("Snippets let you reuse content.", query) def test_falls_back_to_content_when_current_page_not_found(self): - captured_queries = [] + captured = [] class RecordingIndex(FakeIndex): def search_sources(self, query, *, locale=None, **kwargs): - captured_queries.append(query) + captured.append(query) return FakeQuerySet([]) - fake_index = RecordingIndex([]) with mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index + "apps.core.agents.index_registry.get", + return_value=lambda: RecordingIndex([]), ): result = self.agent.execute( vector_index="PageIndex", @@ -147,15 +99,7 @@ def search_sources(self, query, *, locale=None, **kwargs): chunk_size=100, ) - self.assertEqual(captured_queries[0], "Fallback preview content") - self.assertEqual(result, []) - - def test_returns_empty_list_when_no_query_and_no_content(self): - result = self.agent.execute( - vector_index="PageIndex", - exclude_pks=["999999"], - content="", - ) + self.assertEqual(captured[0], "Fallback preview content") self.assertEqual(result, []) def test_excludes_pages_in_exclude_pks(self): @@ -176,9 +120,9 @@ def test_excludes_pages_in_exclude_pks(self): def test_respects_limit(self): page = self._make_page(title="Source page") - related_pages = [self._make_page(title=f"Match {i}") for i in range(5)] + related = [self._make_page(title=f"Match {i}") for i in range(5)] - result = self._run_with_fake_index(page, related_pages, limit=2) + result = self._run(page, related, limit=2) self.assertEqual(len(result), 2) @@ -187,7 +131,7 @@ def test_preserves_order(self): first = self._make_page(title="First match") second = self._make_page(title="Second match") - result = self._run_with_fake_index(page, [first, second]) + result = self._run(page, [first, second]) self.assertEqual([r["title"] for r in result], ["First match", "Second match"]) @@ -207,9 +151,8 @@ def search_sources(self, query, *, locale=None, **kwargs): qs = qs.filter(locale=locale) return qs - fake_index = SpyingIndex([]) with mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index + "apps.core.agents.index_registry.get", return_value=lambda: SpyingIndex([]) ): self.agent.execute( vector_index="PageIndex", @@ -218,49 +161,3 @@ def search_sources(self, query, *, locale=None, **kwargs): ) self.assertEqual(filter_calls, [{"locale": page.locale.language_code}]) - - def test_does_not_filter_by_locale_when_current_page_has_none(self): - page = self._make_page(title="Source page") - filter_calls = [] - - class SpyingQuerySet(FakeQuerySet): - def filter(self, **kwargs): - filter_calls.append(kwargs) - return self - - class SpyingIndex(FakeIndex): - def search_sources(self, query, *, locale=None, **kwargs): - qs = SpyingQuerySet(self._pages) - if locale: - qs = qs.filter(locale=locale) - return qs - - fake_index = SpyingIndex([]) - fake_page = mock.Mock(pk=page.pk, locale=None, specific=mock.Mock()) - fake_page.specific.title = "Source page" - fake_page.specific.search_description = "" - del fake_page.specific.body - - with ( - mock.patch( - "apps.core.agents.index_registry.get", return_value=lambda: fake_index - ), - mock.patch("apps.core.agents.Page.objects.get", return_value=fake_page), - ): - self.agent.execute( - vector_index="PageIndex", - exclude_pks=[str(page.pk)], - content="ignored", - ) - - self.assertEqual(filter_calls, []) - - def test_returns_edit_url_for_each_suggestion(self): - page = self._make_page(title="Source page") - related = self._make_page(title="Related page") - - result = self._run_with_fake_index(page, [related]) - - self.assertEqual(len(result), 1) - self.assertIn("editUrl", result[0]) - self.assertTrue(result[0]["editUrl"]) diff --git a/apps/core/tests/test_related_pages.py b/apps/core/tests/test_related_pages.py index e07e512e..76e948fd 100644 --- a/apps/core/tests/test_related_pages.py +++ b/apps/core/tests/test_related_pages.py @@ -25,75 +25,50 @@ def _get_related_pages_html(self, response): end = content.index("", start) return content[start:end] - def test_related_pages_render_in_curated_order(self): - response = self.client.get(self.source.url) - related_html = self._get_related_pages_html(response) + def test_related_pages_render_in_curated_order_with_correct_urls(self): + html = self._get_related_pages_html(self.client.get(self.source.url)) - concepts_index = related_html.index(self.related_2.title) - how_to_index = related_html.index(self.related_1.title) - self.assertLess(concepts_index, how_to_index) - - def test_related_pages_link_to_correct_urls(self): - response = self.client.get(self.source.url) - related_html = self._get_related_pages_html(response) - - self.assertIn(f'href="{self.related_1.url}"', related_html) - self.assertIn(f'href="{self.related_2.url}"', related_html) + self.assertLess( + html.index(self.related_2.title), html.index(self.related_1.title) + ) + self.assertIn(f'href="{self.related_1.url}"', html) + self.assertIn(f'href="{self.related_2.url}"', html) def test_no_related_pages_section_when_empty(self): other_page = ContentPageFactory(parent=self.home, title="Lonely page") - response = self.client.get(other_page.url) - content = response.content.decode() + content = self.client.get(other_page.url).content.decode() self.assertNotIn("related-pages", content) - def test_draft_related_pages_are_excluded(self): - draft_page = ContentPageFactory( - parent=self.home, title="Draft page", live=False + def test_draft_and_private_pages_are_excluded(self): + draft = ContentPageFactory(parent=self.home, title="Draft", live=False) + private = ContentPageFactory(parent=self.home, title="Private") + PageViewRestriction.objects.create( + page=private, restriction_type=PageViewRestriction.LOGIN ) RelatedPage.objects.create( - source_page=self.source, related_page=draft_page, sort_order=2 - ) - - response = self.client.get(self.source.url) - content = response.content.decode() - - self.assertNotIn(draft_page.title, content) - - def test_private_related_pages_are_excluded(self): - private_page = ContentPageFactory(parent=self.home, title="Private page") - PageViewRestriction.objects.create( - page=private_page, - restriction_type=PageViewRestriction.LOGIN, + source_page=self.source, related_page=draft, sort_order=2 ) RelatedPage.objects.create( - source_page=self.source, related_page=private_page, sort_order=2 + source_page=self.source, related_page=private, sort_order=3 ) - response = self.client.get(self.source.url) - content = response.content.decode() + content = self.client.get(self.source.url).content.decode() - self.assertNotIn(private_page.title, content) + self.assertNotIn("Draft", content) + self.assertNotIn("Private", content) def test_related_pages_appear_in_markdown_export(self): markdown = self.source.to_markdown() self.assertIn("## Related pages", markdown) self.assertIn(f"[{self.related_1.title}]({self.related_1.full_url})", markdown) - self.assertIn(f"[{self.related_2.title}]({self.related_2.full_url})", markdown) - - def test_no_related_pages_heading_in_markdown_when_empty(self): - other_page = ContentPageFactory(parent=self.home, title="Lonely page") - markdown = other_page.to_markdown() - - self.assertNotIn("## Related pages", markdown) def test_home_page_related_pages_render(self): RelatedPage.objects.create( source_page=self.home, related_page=self.related_1, sort_order=0 ) - response = self.client.get(self.home.url) - related_html = self._get_related_pages_html(response) + html = self._get_related_pages_html(self.client.get(self.home.url)) - self.assertIn(self.related_1.title, related_html) + self.assertIn(self.related_1.title, html) diff --git a/apps/core/tests/test_vector_indexes.py b/apps/core/tests/test_vector_indexes.py index 5de315e7..711e1ec5 100644 --- a/apps/core/tests/test_vector_indexes.py +++ b/apps/core/tests/test_vector_indexes.py @@ -11,13 +11,7 @@ class TestRenderFirstParagraph(TestCase): def _page_with_body(self, blocks): return ContentPageFactory(body=json.dumps(blocks)) - def test_returns_first_text_block(self): - page = self._page_with_body( - [{"type": "text", "value": "

First paragraph.

"}] - ) - self.assertEqual(render_first_paragraph(page), "First paragraph.") - - def test_skips_alert_block_and_returns_first_text_block(self): + def test_skips_alert_and_returns_first_text_block(self): page = self._page_with_body( [ { @@ -43,79 +37,29 @@ def test_returns_text_annotated_block_content(self): ) self.assertEqual(render_first_paragraph(page), "Annotated paragraph.") - def test_returns_empty_string_when_no_text_blocks(self): - page = self._page_with_body( - [ - { - "type": "alert", - "value": { - "alert_type": "Note", - "alert_body": "

Only an alert here.

", - }, - } - ] - ) - self.assertEqual(render_first_paragraph(page), "") - - def test_returns_empty_string_for_empty_body(self): - page = self._page_with_body([]) - self.assertEqual(render_first_paragraph(page), "") - class TestPageSource(TestCase): def setUp(self): self.home = HomePageFactory() def _make_source(self): - # Avoid instantiating CoreEmbeddingTransformer (which requires a - # configured LLM service) by building a PageSource directly. from apps.core.models.content import ContentPage return PageSource(queryset=ContentPage.objects.live().public()) - def test_get_content_includes_title_and_body_text(self): + def test_get_content_includes_title_description_and_body(self): page = ContentPageFactory( parent=self.home, title="Managing snippets", + search_description="A short summary.", body=json.dumps([{"type": "text", "value": "

Snippet content.

"}]), ) - source = self._make_source() - content = source.get_content(page) + content = self._make_source().get_content(page) self.assertIn("Managing snippets", content) + self.assertIn("A short summary.", content) self.assertIn("Snippet content.", content) - def test_get_content_includes_search_description(self): - page = ContentPageFactory( - parent=self.home, - title="Managing snippets", - search_description="A short summary of the page.", - ) - source = self._make_source() - content = source.get_content(page) - - self.assertIn("A short summary of the page.", content) - - def test_get_content_includes_seo_title_when_different(self): - page = ContentPageFactory( - parent=self.home, - title="Managing snippets", - seo_title="Snippets - Full SEO Title", - ) - source = self._make_source() - content = source.get_content(page) - - self.assertIn("Snippets - Full SEO Title", content) - - def test_get_content_omits_seo_title_when_same_as_title(self): - page = ContentPageFactory(parent=self.home, title="Managing snippets") - page.seo_title = page.title - source = self._make_source() - content = source.get_content(page) - - # Title should only appear once (not duplicated as seo_title). - self.assertEqual(content.count("Managing snippets"), 1) - def test_get_content_strips_html_tags(self): page = ContentPageFactory( parent=self.home, @@ -123,23 +67,14 @@ def test_get_content_strips_html_tags(self): [{"type": "text", "value": "

Text with bold.

"}] ), ) - source = self._make_source() - content = source.get_content(page) + content = self._make_source().get_content(page) self.assertNotIn("

", content) - self.assertNotIn("", content) self.assertIn("Text with bold.", content) - def test_uses_paragraph_chunk_transformer(self): - from django_ai_core.contrib.index.chunking import ParagraphChunkTransformer - - source = self._make_source() - self.assertIsInstance(source.chunk_transformer, ParagraphChunkTransformer) - def test_get_metadata_includes_locale(self): page = ContentPageFactory(parent=self.home, title="Managing snippets") - source = self._make_source() - metadata = source.get_metadata(page) + metadata = self._make_source().get_metadata(page) self.assertEqual(metadata["locale"], page.locale.language_code) @@ -153,31 +88,13 @@ def test_search_sources_applies_locale_filter(self): index = PageIndex() fake_queryset = mock.Mock() - fake_queryset.filter.return_value = "filtered-queryset" + fake_queryset.filter.return_value = "filtered" with mock.patch( "django_ai_core.contrib.index.base.VectorIndex.search_sources", return_value=fake_queryset, ): - result = index.search_sources("some query", locale="en") + result = index.search_sources("query", locale="en") fake_queryset.filter.assert_called_once_with(locale="en") - self.assertEqual(result, "filtered-queryset") - - def test_search_sources_skips_filter_when_no_locale(self): - with mock.patch( - "apps.core.vector_indexes.get_llm_service" - ) as mock_get_llm_service: - mock_get_llm_service.return_value = mock.Mock() - index = PageIndex() - - fake_queryset = mock.Mock() - - with mock.patch( - "django_ai_core.contrib.index.base.VectorIndex.search_sources", - return_value=fake_queryset, - ): - result = index.search_sources("some query") - - fake_queryset.filter.assert_not_called() - self.assertEqual(result, fake_queryset) + self.assertEqual(result, "filtered") diff --git a/apps/core/wagtail_hooks.py b/apps/core/wagtail_hooks.py index 64a7ba51..12a25f55 100644 --- a/apps/core/wagtail_hooks.py +++ b/apps/core/wagtail_hooks.py @@ -9,18 +9,13 @@ from wagtail.models import Page from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet -from wagtail_ai.agents.suggested_content import SuggestedContentAgent # noqa: F401 +from wagtail_ai.agents.suggested_content import SuggestedContentAgent from wagtail_ai.panels import AIDescriptionFieldPanel -from apps.core import vector_indexes # noqa: F401 -from apps.core.agents import LocalizedSuggestedContentAgent # noqa: F401 +from apps.core import vector_indexes +from apps.core.agents import LocalizedSuggestedContentAgent from apps.core.models.feedback import Feedback -# Note: the `wagtail_ai.agents.suggested_content` import above must stay -# before the `apps.core.agents` import. It triggers registration of the -# default SuggestedContentAgent, so our LocalizedSuggestedContentAgent -# (registered with the same slug) is registered last and takes priority. - STYLE_GUIDE_URL = "https://github.com/wagtail/guide/blob/main/docs/style-guide.md" CONTRIBUTING_URL = "https://github.com/wagtail/guide/blob/main/CONTRIBUTING.md" diff --git a/apps/guide/settings/base.py b/apps/guide/settings/base.py index 66f8189a..ad8a1009 100644 --- a/apps/guide/settings/base.py +++ b/apps/guide/settings/base.py @@ -676,12 +676,12 @@ "api_base": os.environ.get("WAGTAIL_AI_VISION_API_BASE"), }, "embedding": { - "provider": "openai", + "provider": os.environ.get("WAGTAIL_AI_EMBEDDING_PROVIDER", "openai"), "model": os.environ.get( "WAGTAIL_AI_EMBEDDING_MODEL", "openai/text-embedding-3-large" ), - "api_key": os.environ.get("KILO_API_KEY"), - "api_base": "https://api.kilo.ai/api/gateway", + "api_key": os.environ.get("WAGTAIL_AI_EMBEDDING_API_KEY"), + "api_base": os.environ.get("WAGTAIL_AI_EMBEDDING_API_BASE"), }, }, "IMAGE_DESCRIPTION_PROVIDER": "vision", From b0127ac575d69b89c6a138566d9d1cf62d964a48 Mon Sep 17 00:00:00 2001 From: Raghad Dahi Date: Sun, 16 Aug 2026 05:18:40 +0300 Subject: [PATCH 5/5] Restore noqa comments for side-effect imports --- apps/core/wagtail_hooks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/core/wagtail_hooks.py b/apps/core/wagtail_hooks.py index 12a25f55..9ddb1c68 100644 --- a/apps/core/wagtail_hooks.py +++ b/apps/core/wagtail_hooks.py @@ -9,11 +9,11 @@ from wagtail.models import Page from wagtail.snippets.models import register_snippet from wagtail.snippets.views.snippets import SnippetViewSet -from wagtail_ai.agents.suggested_content import SuggestedContentAgent +from wagtail_ai.agents.suggested_content import SuggestedContentAgent # noqa: F401 from wagtail_ai.panels import AIDescriptionFieldPanel -from apps.core import vector_indexes -from apps.core.agents import LocalizedSuggestedContentAgent +from apps.core import vector_indexes # noqa: F401 +from apps.core.agents import LocalizedSuggestedContentAgent # noqa: F401 from apps.core.models.feedback import Feedback STYLE_GUIDE_URL = "https://github.com/wagtail/guide/blob/main/docs/style-guide.md"