Initial Commit
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user