Quasnooze 如何核對本地 AI 助手聲稱已完成的操作
How I built a local AI assistant that admits when it didn't actually do the thing
開源項目 Quasnooze 會將本地 AI 助手的回覆與工具執行紀錄及記憶事實核對,標示缺乏證據的操作聲稱,而不改寫模型原文。審核器會以可見提示指出未實際執行的操作或未提供的電郵地址;敏感工具的批准流程則與介面狀態分開。項目以 Node.js 22 或更新版本運行,透過 Ollama 在本機推理;作者指出 Windows 語音主機仍屬預覽,實際硬件測試尚未完成。
How I built a local AI assistant that admits when it didn't actually do the thing
The sentence that started this project was five words long:
"I've sent the email."
It hadn't. Nothing had run. There was no email tool call in that turn, no SMTP connection, no record of anything. Just a small local model, a confident tone, and a sentence that was completely false.
If you've wired a 7B model up to function calling, you've probably met the same failure. It's not a crash. It's not an exception in your logs. It's worse: it's a fluent, well-formed paragraph that silently disagrees with reality. Your UI shows a checkmark, the user moves on with their day, and the thing never happened.
This is the story of the fix in my open-source project, Quasnooze, and the one design decision I'd defend hardest if you asked me why it's built the way it is.The problem: hallucination isn't just wrong facts, it's wrong actions
Most discussion of hallucination is about knowledge. The model says the library was released in 2015 when it was 2017. Annoying, but visible — the user can catch it.
Agentic hallucination is different in kind. When a model has tools, it doesn't just describe the world, it narrates its own effects on the world. And small models are especially prone to narrating effects that didn't happen, because:
- The training signal rewards helpful-sounding completions. "I've sent it" is a more satisfying answer than "I can't confirm that."
- A tool call can fail, get denied, time out, or never be emitted at all — but the model's next token prediction doesn't care about any of that.
- The failure is invisible to the developer unless you deliberately check for it. The HTTP response was 200. The text streamed fine. The turn "succeeded." My first attempt was the obvious one: prompt engineering. A system prompt with strong instructions — never claim an action you didn't take, only refer to tool output, don't invent contact details. I wrote it carefully. I iterated on it. It mostly worked. That's what made it dangerous. A 90%-effective prompt doesn't fail randomly, it fails confidently — and it fails exactly on the turns where the model feels most helpful, which are the turns where you're least likely to double-check. Worse, a stronger model doesn't fix it, it just produces a more convincing paragraph. The lie gets better grammar. Prompting, in other words, was asking the same component that made the error to be the component that catches it. ## The fix: audit the reply, don't trust it The insight I built around is small but it changes the architecture: The model's reply is not output. It's a claim, and claims can be checked against evidence. At the end of every turn, Quasnooze has two things the model doesn't fully control:
- The tool trace — which tools actually ran, whether they returned
ok, whether the user denied them.- The memory facts — what's genuinely in the store, not what the model wishes were there. So instead of trusting the prose, the app compares the prose to the evidence.
src/guard.jsis a pure function that takes the reply, the trace, the memory facts, the user's text and the tool output, and returns a list of flags. Here's the actual API — this runs standalone, no server, no model:
import { auditReply, renderGuardNote } from "./src/guard.js";
const result = auditReply({
reply: "Done — I've sent the email to priya@example.com.",
trace: [], // nothing actually ran this turn
memoryFacts: [],
userText: "email priya about the launch",
});
console.log(result.flags);
console.log(renderGuardNote(result.flags));
Real output from that snippet:
[
{
"code": "action-without-evidence",
"message": "the reply says \"i've sent\" but no tool ran that could have done it"
},
{
"code": "invented-email",
"message": "an address appears that was never given to me: priya@example.com"
}
]
And the note the user actually sees:
---
🛡️ **Code check:** the reply above needs a second look.
• the reply says "i've sent" but no tool ran that could have done it
• an address appears that was never given to me: priya@example.com
_Quasnooze checks claims against what actually ran — this one did not line up._
The detail that matters most: it never rewrites the model
The tempting version of this feature silently corrects the text, or regenerates the response, or quietly drops the offending sentence. I deliberately didn't do any of that, for a reason I now feel strongly about:
A silent correction is just a tidier way of lying about what happened.
If the code edits the model's words, the user cannot tell whether the assistant genuinely succeeded or whether a regex papered over a failure. So the guard appends a visible note and never touches the model's text. The transparency is the product. A flag is a nudge, not an alarm — the styling is deliberately calm, because false positives are real and I don't want to train people to panic.
The other half: approval is a separate code path
The same principle — never let one layer quietly speak for another — shaped the permission system.
When a tool is sensitive, the model can't approve it. Neither can a spoken "yes." Neither can an animation. Approval is a distinct path from the visual state machine, which means:
- The visualizer can display five states (idle, listening, thinking, speaking, interrupted) and has no ability to authorize anything.
- Action previews are display-only metadata. They're sanitized and redacted with a fixed set of reason codes, they're ephemeral, and they are never the object that permits execution. That separation is enforced by the shape of the code, not by good intentions.
- A denied tool in the trace is itself evidence: if you denied a permission prompt and the reply still claims success, the guard says so, explicitly, in the note. That last one is my favourite small detail in the codebase. A denial is a fact about the turn, and the audit treats it as one. ## Stack and setup, honestly Quasnooze is Node.js 22 or newer, with no required runtime dependencies and no framework. The reasoning runs locally through Ollama; optional cloud providers, browser speech and messaging channels each have their own explicit data boundary, and they're off unless you turn them on. It's MIT licensed. Three commands:
git clone https://github.com/Quasynvo/Quasnooze.git
cd Quasnooze
npm run setup # configure model, workspace, access key, optional channels
npm run doctor # check what's actually working, including what isn't
npm run brain # verify your model is reachable
npm start # panel on http://localhost:7777 by default
npm run doctor deserves a mention: it only probes loopback origins, and it will happily tell you that a feature is not ready rather than pretend. It's the most-used script in the repo for a reason — most "it doesn't work" issues are configuration, not code.
If you want the voice visuals in your own project, the renderer is a plain ES module with no build step:
import { createVisualizer } from "/visualizer.js";
const motion = createVisualizer(document.getElementById("voiceCanvas"));
// Drive this from real application state, not from a decorative timer.
motion.setState("thinking");
What's unfinished (please read this part too)
I'd rather undersell this than have you find the gaps yourself:
- The Windows voice host is a preview. Physical microphone behaviour, wake-word detection and startup handling still need real-hardware acceptance testing. It's the least proven part of the project.
- The native API exposes amplitude, not FFT, so the "speaking" visual in native mode is decorative rather than a true output spectrum. The code distinguishes live FFT, native scalar level, synthetic demo data and state-only motion, and never dresses one up as another.
- Retrieval is serviceable, not good. It works; don't expect a research-grade RAG stack.
- There are 54 test files run with
node --test, and not one of them can tell you whether a microphone works. - The guard's verb lists are regexes. They will produce false positives eventually. That's the part I most want help with. ## Try it, and tell me where it's wrong Repo: github.com/Quasynvo/Quasnooze If the audit idea is interesting to you, a ⭐ genuinely helps the project get seen — but what helps me far more is this:
-
Break the guard. Find a reply that asserts an action with no evidence and slips past
auditReply. Those cases are worth more than stars, and I'll credit them. - Tell me if post-hoc auditing is the wrong layer. My instinct is that checking after generation is the only place you can check, because it's the only place the claim exists. But if you've solved this inside the tool-calling schema, or with a real claim-verification eval set, I want to read how.
-
Open an issue for anything misconfigured rather than filing it as "broken" —
npm run doctorusually points at the culprit. PRs are welcome; there's aCONTRIBUTING.mdand the test suite is plainnode --testwith no global install needed. And if you take one thing from this post without ever cloning the repo: don't ask the component that lied to catch the lie. Give your assistant something it doesn't control to check itself against. --- If you build something similar — or you've got a better answer for the guard's regex problem — I'd love to hear it in the comments.
來源:DEV Community · dev.to