# The AI Assistant

2026-08-08. A port of **edaihelper**, Ernest's own IPS app, onto Convoro —
answering from the Knowledge Base rather than from whatever the model happens
to remember.

🚨 The licence rules in ROADMAP.md do not bite here. edaihelper is his code;
porting his own work to his own platform is unrestricted. Only Invision's
source, copy and APIs stay off the table.

---

## Two credentials, two very different jobs

Ernest has an **Anthropic key** (the model) and a **Voyage key** (embeddings).
They are not interchangeable and they do not belong in the same place:

- **The model call answers a visitor's question.** It happens inside their
  request, and it is the third deliberate exception to the standing rule
  against outbound calls in a request — on the same terms as starting a
  checkout: the call *is* what they asked for.
- **Embedding is the opposite case.** Turning the whole Knowledge Base into
  vectors is bulk work with no visitor waiting on it, and it belongs to the
  task runner. Putting it in a request would be the exact mistake the rule
  exists to prevent.

🚨 **Both keys are admin fields, not config constants.** Asked for directly.
That means they are written to the settings table, never shown back once
saved, and an empty field on the form means *leave it alone* rather than
*clear it* — the same trap the OpenSearch password had.

## Raw HTTP, not the SDK

There is an official Anthropic PHP SDK and normally it would be the right
answer. Convoro cannot use it: the product ships with **no Composer
dependencies at all**, which is the whole reason it installs by uploading a
zip. Adding one for four HTTP verbs would break the thing that makes the
product installable.

So this talks to the API over curl directly, the same shape the PayPal client
already uses, and it never mixes the two styles.

## What the model is told, and what it is not

The assistant answers **from the Knowledge Base**. The retrieved articles go in
the user turn; the instructions go in the system prompt. That split is not
stylistic — it is what lets the system prompt be cached while the articles
change per question.

🚨 **It is told to say when the KB does not cover something.** A support
assistant that confidently invents an answer about your own product is worse
than no assistant, because the person believes it. The instruction to decline
is load-bearing and should not be softened into "try your best".

🚨 **It only ever sees articles the asker may read.** Retrieval goes through
the same searcher seam everything else uses, with the viewer's groups, so a
restricted article cannot be laundered into an answer. This is the same leak
path search has had twice; the assistant is a third mouth on the same data.

## The failure has to be visible

A model call can be slow, rate-limited, refused, or simply unreachable. Every
one of those ends in the visitor being told plainly, never in a hung page:

- Hard connect and read timeouts, and the read timeout is the one that matters.
- **`stop_reason: "refusal"` is checked before the content is read.** A refused
  response has empty or partial content, and code that reads the first block
  unconditionally breaks on it.
- A per-member rate limit, because the cost of this feature is somebody else's
  API bill and a bored member can run it up.
- **Nothing retrieved means no call at all.** With no articles in front of it
  there is nothing to answer *from*, and asking anyway invites exactly the
  confident invention the design is trying to avoid — at full price.

## Signed-in only, and it is about the bill

The Knowledge Base itself is public and stays that way. Asking the assistant
does not.

A rate limit needs somebody to count against, and a guest is nobody. The
alternative — counting by IP address — would mean writing every visitor's
address to a table forever in order to power a counter, which is a worse trade
than asking somebody to sign in.

🚨 For the same reason `assistant_questions` records **who asked and when, and
not what they asked**. The count is the requirement. A permanent table of
members' support questions is not, and it would be exportable and subpoenable
for the rest of the site's life.

## Retrieval: measure before reaching for embeddings

The Voyage key makes semantic retrieval possible, and it is genuinely better
for questions phrased unlike the article that answers them. It is still not
where this starts.

Keyword retrieval over a Knowledge Base written to be retrieved from — one
answer per article, titled as the question somebody would ask — is a strong
baseline, and it is the thing embeddings have to beat. Shipping embeddings
first means never finding out whether they helped.

So: the searcher seam answers today, the embedding path goes behind the same
seam, and the comparison is a measurement rather than an assumption.

**Where that leaves the Voyage key as shipped:** the field exists, it saves,
and it does nothing. The admin screen says so in those words rather than
implying a feature that is not switched on. Accepting the key now costs
nothing and means the setting is already where an operator would look for it;
pretending it changes retrieval would be a lie in the interface.

What *did* change is the Knowledge Base's own `find`: it now matches the
question, summary and body rather than the title alone. Titles-only meant "how
do I change my password" found nothing while an article answering exactly that
sat published — the assistant can only answer from what retrieval finds, so a
weak retriever reads as a stupid assistant.

## What this note does not decide

- **Conversation memory across page loads.** Each question is answered on its
  own today. Threading is a real feature with real cost (every turn re-sends
  the history) and wants its own decision.
- **The assistant posting in topics.** edaihelper does this on IPS. It is a
  bigger question here because a post is content, with an author, that
  moderators act on.
- **Which Voyage model.** Named in a setting rather than hardcoded, because
  the answer changes and an operator should not need a release to follow it.
