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

119 lines
5.0 KiB
Python

"""
title: Quantitative Question Nudge
author: owuitools
version: 0.2.0
description: Raises tool-use salience on measurement-bearing questions that are phrased casually.
"""
# WHY THIS EXISTS
#
# The system prompt already says that reasoning tasks need tools and that
# ruling a cause out is itself a claim. It does not fire on questions that
# are phrased conversationally. Measured case:
#
# "im logging vibration off a motor at 500Hz, motor runs 3600 rpm but
# the biggest fft peak is at 40Hz. is my sensor broken?"
#
# Zero tool calls, and the answer stated "aliasing cannot move a 60 Hz peak
# to 40 Hz - so 40 Hz is a REAL vibration". True of the fundamental and
# wrong as an elimination: the 9th harmonic at 540 Hz folds to exactly
# 40.0 Hz at that sample rate. That sends someone hunting a phantom
# resonance instead of fitting an anti-alias filter. Adding a paragraph to
# the system prompt did not change it - which is this suite's most repeated
# finding: static prompt text loses to the shape of the conversation.
#
# An inlet note is a different lever. It arrives as the most recent thing
# in the conversation rather than as line 40 of a long preamble, so it is
# read as part of the current task. It is invisible to the user: inlet
# rewrites what goes to the model, not what is displayed.
#
# Deliberately narrow. It fires only when a message carries an actual
# measurement - a number with a unit - because that is what distinguishes
# "is my sensor broken" from "is Postgres a good choice". A nudge on every
# message would be one more paragraph the model learns to skim.
#
# WHY IT NAMES THE TOOLS
#
# The first version said "check it with a tool" and left the choice open.
# On a compound-interest question the model duly reached for a tool - and
# reached for web_search, to look up a formula it already knew, then did
# the actual arithmetic in its head and got it wrong. Tool-seeking without
# tool-selection. The note now says which tool does what, and says out loud
# that searching for a formula you know is not the point.
import re
from pydantic import BaseModel, Field
# A number bound to a unit. Bare numbers are excluded on purpose: "give me
# 3 options" is not a measurement. Currency and durations are in because
# "$4,000 at 5% for 10 years" is as quantitative as anything in volts, and
# the arithmetic is exactly where in-head answers go wrong.
_MEASUREMENT = re.compile(
r"[$£€]\s?\d"
r"|\b\d+(?:\.\d+)?\s?"
r"(?:hz|khz|mhz|ghz|rpm|v|kv|mv|a|ma|w|kw|mw|va|kva|ohm|ohms|"
r"db|dbm|s|ms|us|ns|m|mm|cm|km|kg|g|n|nm|pa|kpa|bar|psi|"
r"c|f|k|%|gb|mb|kb|tb|bps|kbps|mbps|gbps|awg|bit|bits|"
r"years?|yrs?|months?|weeks?|days?)\b",
re.I,
)
_NOTE = (
"Reminder for this question: it contains measurements, so it is a technical question "
"however casually it is worded. Before asserting any number, mechanism or standard, check "
"it with a tool: the calculators (ohms_law_power, component_calc, wiring_calc, pcb_calc, "
"subnet_calc) for what they cover, matlab_run for any other arithmetic - compounding, unit "
"conversions, statistics, counting - and web_search then fetch_page for facts and "
"standards, not for formulas you already know. In particular, if you are about to rule a "
"cause OUT, enumerate the variants of that cause and test the ones you have not considered "
"- a correct elimination of the obvious version and a missed elimination of a subtler "
"version look identical in the answer, and send the user to fix the wrong thing."
)
class Filter:
class Valves(BaseModel):
enabled: bool = Field(
default=True,
description="Turn the nudge off without deleting the filter.",
)
min_chars: int = Field(
default=40,
description="Skip very short messages. 'what is 3 dB' needs no reminder.",
)
def __init__(self):
self.valves = self.Valves()
async def inlet(self, body: dict, __user__=None) -> dict:
if not self.valves.enabled:
return body
try:
messages = body.get("messages") or []
if not messages:
return body
last_user = None
for message in reversed(messages):
if message.get("role") == "user":
last_user = message
break
if last_user is None:
return body
text = last_user.get("content")
if not isinstance(text, str) or len(text) < self.valves.min_chars:
return body
if not _MEASUREMENT.search(text):
return body
# Appended AFTER the user's turn, not before it. Inserted
# before, this was read as more preamble and changed nothing;
# the last message in the list is the one that gets weight.
messages.append({"role": "system", "content": _NOTE})
body["messages"] = messages
except Exception:
return body
return body