170 lines
7.6 KiB
Python
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
|