Артыкул апублікаваны на англійскай мове.
A Portfolio Chatbot Built to Say "I Don't Know" Instead of Guessing
How a portfolio chatbot stays honest: a locked honesty dial, five handwritten notes in the prompt instead of RAG, and a fixed refusal line that doubles as a gap detector.
Hand any language model the name of a person it has never heard of and it will describe them anyway, with confidence and detail: a career history, a skill set, even opinions on their strengths. None of it exists. "Hallucination" is a gentle word for that. The model isn't misreading anything. It's building a person out of nothing, fluently and without a flicker of doubt.
For a chatbot on a portfolio site, that's the whole problem. Each invented detail is a sentence a recruiter could read on Monday and repeat in an interview on Thursday, leaving the site's owner to explain that their own website made something up about them. There's no good way to have that conversation.
So the design question flips. Getting a model to talk fluently about someone takes an afternoon. Getting it to say, in a specific and countable way, that it doesn't know something takes much longer, and that's where the interesting work is.
The bot in this case is called PATS. It lives in the corner of the site, answers only from notes written by hand, and when a question falls outside them it gives one fixed sentence and points the visitor to the owner's email. The fixed sentence ended up being the most useful piece of the project. The missing vector database was the second.
No RAG, no vector database: roughly four thousand words of handwritten notes plus one rule, which is that the bot isn't allowed to improvise. The figures below were measured on the live deployment on 13 September 2026.
A dial that doesn't turn
Start with the part a visitor can touch. PATS has three settings: honesty, humor and conciseness. Two work. Humor steps from clinical up to a position labelled max sarcasm, and the tone changes completely. Conciseness runs from a rambling narrative that offers more than you asked down to single-line answers.
Honesty is fixed at 100. It has the same five stops as the other two dials, in the same row and shape, so it looks like a real control and not a caption. The four stops below 100 are greyed out, and where the other rows display their current value, this one reads "locked". Every stop is inert.
In code it's a constant, with a comment explaining the choice:
/**
* Not a parameter. It is displayed as a percentage because that is the joke,
* and it is a `const` because a lowerable honesty setting would contradict
* the one rule the whole site is built on.
*/
export const HONESTY = 100;
It's never sent to the model. It isn't in the request body at all, so even a hand-crafted request can't turn it down.
The idea comes from Interstellar, where a robot's personality is adjustable and the crew does the adjusting. Cooper sets TARS's humor to 75 percent. Honesty sits at 90, because TARS explains that absolute honesty isn't always the most diplomatic or safest way to communicate with emotional beings.
The humor dial stayed. Honesty went to 100, since TARS's reason for withholding that last ten percent doesn't apply here. TARS looks after a crew's morale in deep space, while PATS answers resume questions where little is emotionally loaded. Ten percent less honesty wouldn't buy tact in that setting and would only cost accuracy.
A site arguing that a loud failure beats a plausible wrong answer should have exactly one setting that never moves, and that's honesty. A control that a recruiter can reach for and find welded shut makes the case better than a paragraph claiming it. What follows is the machinery behind the claim.
Why there's no vector database
Everything PATS can state lives in five markdown files: a profile, projects, working style, interests and an FAQ, about four thousand words in all. When the module loads, they're joined into one system prompt, and that whole prompt goes out with every message. There's no retrieval, no embeddings, no vector store, no chunking and no similarity search.
People ask about this first, usually with a raised eyebrow, so here's the reasoning.
A lot of the case for RAG is about cost. Sending a whole corpus with every request is unaffordable, so you retrieve the handful of chunks that look most relevant. Prompt caching has changed that calculation at this size. The complete prompt is 10,870 tokens, and after the first request it's served from cache at around a tenth of the standard input price. The result is under a cent per message, even at the most expensive setting. Against a number like that no retrieval architecture pays off, and every version would have meant another service, another schema and another thing to break.
The second reason is the one that settled it. A retriever that misses does more harm than none at all. If the right chunk doesn't come back, the question still arrives, and the model has to respond with nothing in hand, which is exactly the condition that produces a confident invention. That's the failure the project is meant to prevent, so RAG would have slipped a silent failure mode into a system built to have none. The locked honesty dial and the missing vector database are the same decision, taken twice.
Don't stretch that claim too far, though. It covers about ten thousand tokens of notes that will never outgrow one person's own writing. It isn't a verdict that RAG is dead or that vector databases are pointless. At ten times the size the numbers flip and a retrieval layer earns its place, and the same is true if the bot should answer from real codebases rather than notes about them. Neither condition holds today, and adopting retrieval only because everyone expects it would be the actual blunder.
The loader is deliberately noisy about gaps. If one of the five files is missing or empty, the build breaks, so a bot with a hole in its knowledge never ships for a visitor to stumble on.
The refusal sentence as a measuring tool
Whenever the notes don't contain the answer, PATS closes its reply with a single sentence that never varies by a character:
That's not in my notes. You can ask the owner directly at their email address.
(The real line names the owner and gives the actual address.) The sentence isn't there for politeness. It's an instrument.
A private admin page analyzes the logged conversations and finds the questions PATS couldn't answer by matching that exact string. It doesn't ask a model to classify each reply. It compares strings, in code:
if (row.role !== "assistant" || !row.content.includes(FALLBACK_LINE)) continue;
// The visitor turn immediately before it, in the same session.
for (let j = i - 1; j >= 0; j--) {
if (rows[j].session_id !== row.session_id) break;
if (rows[j].role === "user") {
gaps.push({ question: rows[j].content, at: rows[j].created_at });
break;
}
}
The alternative was a second classification call on every message, labelling each reply answered or unanswered. That would double cost and latency on the hot path to learn what a string comparison already knows, and it would leave the gap list in a form a model could paraphrase away. With a fixed string there's one model call per turn, and gap detection comes free.
Every question PATS can't answer becomes a concrete line on a list of notes still owed. The bot's ignorance is the feature, and the only part of the system that says what to build next.
The price is that the sentence is now frozen. Rewording it would quietly orphan every gap already logged, so a change counts as a spec change, not a copy edit, and is recorded as one.
One subtlety keeps the whole thing working. PATS also declines questions it could answer, such as compensation, immigration specifics and family. Those get a short, warm decline that deliberately doesn't use the refusal sentence. If it did, the gap list would fill with questions nobody intends to answer, and the instrument would stop measuring what it's for. A deliberate boundary isn't a gap.
The talent-scout half
PATS stands for Portfolio Assistant and Talent Scout, and people tend to read the second half backwards. It isn't there to find talent for the visitor. It keeps an eye out for opportunities for the owner.
Drop an email address, a LinkedIn URL or a scheduling link into the chat, or mention that you're hiring, and the owner receives an email. A regex does that, not a model call. Three details in the detector took longer than the feature itself.
First, the recipient is a hardcoded constant and never derived from anything typed into the chat. Whatever a visitor enters, the mail lands in the owner's inbox or nowhere. That's the entire anti-abuse design, and it's far shorter than the alternatives.
Second, the phone number pattern requires a non-digit on both sides. Chats here are full of figures such as 10,870 tokens and 4,325 rows, and a detector that mistakes a token count for a callable number cries wolf.
Third, the widget's own suggested prompts must never set it off. "What is the owner looking for?" is a clickable chip, which rules out hiring-related vocabulary as a trigger on its own. People stop trusting a detector that trips on its own suggestions.
The bug was more interesting than the feature. Sessions got flagged as alerted before the send was tried, in the same column that stored why the alert triggered. If a send then failed, for instance because the mail API key was absent or the daily cap had been hit, the session stayed marked as unemailable for good, even once the cause was fixed. What was wanted is one email per session, yet the check lived on a column written before any email existed. Now it gates on a timestamp that gets set only after a 2xx comes back.
Trying it
PATS sits in the bottom-right corner of the portfolio, behind a chat button on the home page. Ask it something it can't possibly know and watch it decline to guess. The stack is Next.js App Router, TypeScript, Supabase Postgres and zod.