Files
owui-tools/owui-functions/answer_sources.py
T
2026-09-15 15:24:03 -04:00

170 lines
7.6 KiB
Python

"""
title: Answer Sources
author: owuitools
version: 0.2.0
description: Ends a tool-assisted answer with the URLs of the documents the tools actually read, when the answer names none of them.
"""
# WHY THIS EXISTS
#
# Across three graded rounds of forty prompts, the rule "end a tool-assisted
# answer with the URL you drew on" was the one instruction that never landed
# reliably: it lived in the system prompt (ignored), then in a `Sources:`
# trailer on every tool result (lifted into the answer on roughly half of
# the opportunities). The remaining half is the same failure each time - a
# correct answer, read from the right page, that does not say so - and it is
# the single largest bucket of lost points left.
#
# The model has the information; it just does not write it down. So this
# filter writes it down. After the turn, it collects the documents the tools
# report having READ (their `Sources:` trailers - fetched pages, Wikipedia
# articles, RFCs, manual pages, package records) and, if the answer carries
# none of those URLs and no sources line of its own, appends one.
#
# It never adds a search result: a URL that appeared in a `web_search`
# listing was not opened, and attributing an answer to it would be exactly
# the mislabelling the rounds penalise. Only tool outputs that end with a
# `Sources:` line contribute, and only successful reads produce that line.
#
# The line goes into BOTH content and the last message item of output, for
# the reason inline_tool_images.py records: Open WebUI renders from output
# when it is present. Everything fails closed - on any surprise the message
# is returned untouched.
import re
from pydantic import BaseModel, Field
_MARKER = "<!-- answer-sources -->"
_SOURCES_LINE = re.compile(r"^Sources:\s*(.+?)\s*$", re.MULTILINE)
_URL = re.compile(r"https?://[^\s<>\"')\]]+")
_DOI = re.compile(r"\bdoi:\s*(10\.\d{4,9}/\S+)", re.IGNORECASE)
# A line the model wrote itself that already serves as attribution. "Key
# sources:", "Sources used:" and "**References:**" all count; a research
# answer with its own labelled sources block got a second, worse one
# appended under it because the label did not start with the word.
_OWN_SOURCES = re.compile(
r"^\s*(?:[*_#>\-\s]*)(?:[\w-]+\s+){0,2}(?:sources?|references?|citations?|bibliography)"
r"(?:\s+(?:used|consulted|read|cited))?\s*[*_]*\s*[:\-–—]",
re.IGNORECASE | re.MULTILINE,
)
_BODY_DOI = re.compile(r"\b10\.\d{4,9}/[^\s<>\"')\]]+", re.IGNORECASE)
def _normalise(url: str) -> str:
url = url.strip().rstrip(".,;:")
url = re.sub(r"^https?://(www\.)?", "", url, flags=re.IGNORECASE)
return url.rstrip("/").lower()
def _documents_from(text: str) -> list[tuple[str, set[str]]]:
"""One (url, aliases) per `Sources:` line in a tool result, in order.
A trailer reads `Sources: <CITE AS line with a DOI> · <page URL>`. That is
one document, not two: counted as two it filled the cap twice as fast, and
the doi.org form and the page form of the same paper were both listed. The
page URL is what was read and is what gets shown; the DOI is kept as an
alias so a bare doi: in the answer counts as naming it.
"""
docs: list[tuple[str, set[str]]] = []
for match in _SOURCES_LINE.finditer(text or ""):
urls, dois = [], []
for entry in match.group(1).split(" · "):
entry = " ".join(entry.split())
urls += [u.rstrip(".,;:)") for u in _URL.findall(entry)]
dois += [d.group(1).rstrip(".,;:)") for d in _DOI.finditer(entry)]
for u in list(urls):
if "doi.org/" in u.lower():
dois.append(u.split("doi.org/", 1)[1])
page = next((u for u in reversed(urls) if "doi.org/" not in u.lower()), None)
shown = page or (urls[-1] if urls else None) or (f"https://doi.org/{dois[0]}" if dois else None)
if shown is None:
continue
aliases = {_normalise(u) for u in urls} | {d.lower() for d in dois}
docs.append((shown, aliases))
return docs
class Filter:
class Valves(BaseModel):
priority: int = Field(default=10, description="Run after the other outlet filters.")
enabled: bool = Field(default=True, description="Turn the filter off without deleting it.")
max_sources: int = Field(
default=8,
description="Most documents to list, in reading order. A long research turn can "
"read twenty pages; one document per Sources: line, so a paper's DOI and its page "
"URL count once.",
)
label: str = Field(default="Sources", description="Label for the appended line.")
def __init__(self):
self.valves = self.Valves()
async def outlet(self, body: dict, __user__=None, __metadata__=None) -> dict:
if not self.valves.enabled:
return body
try:
messages = body.get("messages") or []
target = None
for message in reversed(messages):
if message.get("role") == "assistant" and message.get("output"):
target = message
break
if target is None:
return body
content = target.get("content") or ""
if not content.strip() or _MARKER in content:
return body
# Documents the tools read this turn, in the order they were read.
entries: dict[str, str] = {}
aliases: set[str] = set()
for item in target.get("output") or []:
if item.get("type") != "function_call_output":
continue
for part in item.get("output") or []:
if part.get("type") not in ("input_text", "output_text", "text"):
continue
for url, names in _documents_from(part.get("text") or ""):
key = _normalise(url)
if key not in entries and not (names & aliases):
entries[key] = url
aliases |= names | {key}
if not entries:
return body
# The answer already attributes itself: it names one of the read
# URLs or DOIs, or carries a sources line of its own. Leave it
# alone - a second sources block under the model's own is
# clutter, and the model's choice of which page to cite is not
# ours to edit.
lowered = content.lower()
if any(key in lowered for key in entries):
return body
if any(d.lower() in aliases for d in _BODY_DOI.findall(content)):
return body
if _OWN_SOURCES.search(content):
return body
urls = list(entries.values())[: max(1, self.valves.max_sources)]
label = (self.valves.label or "Sources").strip()
visible = "\n\n---\n" + f"{label}: " + " · ".join(urls)
target["content"] = content.rstrip() + visible + "\n" + _MARKER
output_items = target.get("output") or []
message_items = [o for o in output_items if o.get("type") == "message"]
if message_items:
last = message_items[-1]
parts = last.get("content") or []
texts = [p for p in parts if p.get("type") == "output_text"]
if texts:
texts[-1]["text"] = (texts[-1].get("text") or "").rstrip() + visible
else:
parts.append({"type": "output_text", "text": visible.lstrip()})
last["content"] = parts
except Exception:
return body
return body