[feat] plugin: AI summary of search results via local Ollama server

Add an optional, disabled-by-default plugin that shows an AI generated
summary at the top of the result page, generated by a (local) Ollama
server:

- async: the result page is never delayed; a client plugin streams the
  answer (NDJSON over a new /ai_summary endpoint) into a placeholder
  answer with a typing indicator, collapsed behind a More button, with
  an inline follow-up chat
- trigger: first page of general searches only, skipped when an infobox
  or instant answer already answers the query
- grounding (per-user preference): send the top result snippets as
  context, the model answers from them instead of its own knowledge
- configuration: new AI Summary preferences tab (server URL, model,
  grounding) with instance defaults in a new ai_summary: settings
  section; all three preferences can be locked for public instances

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty
2026-07-28 10:47:18 -07:00
co-authored by Claude Fable 5
parent b060c780d0
commit b47fd08cb7
22 changed files with 1227 additions and 3 deletions
+3 -1
View File
@@ -19,6 +19,7 @@ __all__ = [
"EngineResults",
"AnswerSet",
"Answer",
"AiSummary",
"Translations",
"WeatherAnswer",
"Code",
@@ -32,7 +33,7 @@ import typing as t
import abc
from ._base import Result, MainResult, LegacyResult
from .answer import AnswerSet, Answer, Translations, WeatherAnswer
from .answer import AnswerSet, Answer, AiSummary, Translations, WeatherAnswer
from .keyvalue import KeyValue
from .code import Code
from .paper import Paper
@@ -49,6 +50,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
implemented)."""
Answer = Answer
AiSummary = AiSummary
KeyValue = KeyValue
Code = Code
Paper = Paper
+27 -1
View File
@@ -14,6 +14,10 @@ template.
:members:
:show-inheritance:
.. autoclass:: AiSummary
:members:
:show-inheritance:
.. autoclass:: Translations
:members:
:show-inheritance:
@@ -29,7 +33,7 @@ template.
# pylint: disable=too-few-public-methods
__all__ = ["AnswerSet", "Answer", "Translations", "WeatherAnswer"]
__all__ = ["AnswerSet", "Answer", "AiSummary", "Translations", "WeatherAnswer"]
from flask_babel import gettext
import msgspec
@@ -92,6 +96,28 @@ class Answer(BaseAnswer, kw_only=True):
return hash(self.answer)
class AiSummary(BaseAnswer, kw_only=True):
"""Placeholder for an AI generated summary of the search query
(:py:obj:`searx.plugins.ai_summary`). The summary itself is fetched
asynchronously by the client after the result page has been rendered."""
# The answers of an AnswerSet are sorted by their template name; this
# template sorts before the other answer templates, the AI summary is
# therefore rendered first in the answer area.
template: str = "answer/ai_summary.html"
query: str
"""The search query the summary is generated for."""
grounding: bool = False
"""Ground the summary on the search results (user's preference)."""
def __hash__(self):
"""The hash value of field *query* is the hash value of the
:py:obj:`AiSummary` object."""
return hash(self.query)
class Translations(BaseAnswer, kw_only=True):
"""Answer type with a list of translations.