Chatbot¶
Conversational assistant built on the same vector retrieval substrate as RAG, with multi-turn memory and a consent-gated hand-off to staff.
The chatbot is a separate, independently-toggleable feature. It reuses the
shared AI core (wcs.backend.ai: provider client, retrieval, grounding) and the
RAG chunks index, but ships its own REST surface, browser page and
configuration. Enabling the chatbot does not enable the RAG question surface and
vice versa.
How it differs from RAG¶
RAG ( |
Chatbot ( |
|
|---|---|---|
Interaction |
Single question / answer |
Multi-turn conversation with memory |
Follow-ups |
Not supported |
Each turn is condensed into a standalone query before retrieval |
Hand-off |
None |
Forwarding to staff, as a stored ticket or as a |
Toggle |
|
|
Both features generate answers only from retrieved, security-filtered context. The chatbot additionally verifies answerability: after retrieval, a lightweight LLM classification call decides whether the retrieved context actually answers the question. The bot proactively offers to forward the question to staff both when nothing relevant is found and when the retrieved context does not answer it.
Two ways a turn ends up with no answer¶
They are deliberately different, because they fail differently:
No usable context — nothing retrieved, or the best match scored below
chatbot_min_score. The configured no-answer message is returned as-is and no LLM call is made. This is the path an off-topic question should take, and the one the per-content override customises.Context used, answerability check said NEIN — the model’s own reply stands and only
groundedflips, which surfaces the escalation offer. The check is a coarse JA/NEIN second opinion; when it misfires on a question the content can partly answer, the visitor still gets that answer.
The score floor is the lever for the first case. It ships at 0.0 (off) because
the right value depends on the corpus: hybrid BM25 + kNN scores are not
normalised, so read real numbers off the source badges in the chat UI — ask a
question the content covers and one it does not — and set the floor between
them.
Independent toggle¶
RAG_ENABLEDgates the RAG question/admin surface only.CHATBOT_ENABLEDgates the chatbot surface only.The shared retrieval substrate (indexing subscribers, chunk read/write) is active whenever either feature is enabled, so a chatbot-only site indexes and retrieves content normally without the RAG surface being on.
Per-content enablement¶
CHATBOT_ENABLED=true is the global master switch, but it does not turn the
chatbot on everywhere. The chatbot is additionally gated per content object by
an opt-in toggle, mirroring the per-book RAG toggle. A given content is
chatbot-enabled only when the global flag and its per-item toggle are both
on.
The toggle is an “Enable chatbot” checkbox contributed by the
chatbot.configuration behavior. The default profile attaches the behavior to
Book; every other content type is opt-in:
For books, editors tick Enable chatbot on the book itself.
For other types, a site admin first enables the
chatbot.configurationbehavior in Site Setup → Content Types, then editors tick the checkbox on individual items.
The @@chatbot browser page and the @chatbot-ask / @chatbot-escalate REST
endpoints only respond on content whose chatbot is enabled; elsewhere the page
returns 404 and the endpoints refuse. Editing the behavior’s fields requires the
Manage chatbot properties permission (granted to Manager).
Besides the toggle, the behavior carries two optional message overrides — Welcome message and No-answer message. Both are empty by default, and an empty field means “use the global registry value”; a non-empty one wins for that content. This is what makes a book able to greet its readers in its own words without a separate control panel per book.
Configuration¶
Environment variables¶
# Feature flag
CHATBOT_ENABLED=true
The chatbot reuses the shared EMBEDDING_* and LLM_* provider settings and
the shared RAG_TOP_K / ranking configuration documented in RAG.
Plone registry¶
Record |
Description |
|---|---|
|
System prompt for grounded, multi-turn answers |
|
Prompt that rewrites a follow-up into a standalone query |
|
Prompt for the JA/NEIN check that decides whether the retrieved context answers the question (drives the escalation offer) |
|
First message shown when a new conversation starts (empty = none); can be overridden per content by the chatbot behavior |
|
Reply when no usable context was found — nothing retrieved, or nothing above |
|
Retrieval score the best match must reach to count as context (default |
|
Message when a turn fails |
|
Answer temperature (default |
|
Maximum tokens per answer |
|
Conversation turns retained per session |
|
Seconds a session stays alive (sliding expiry) |
|
Seconds a turn result is cached for polling |
|
Chunks retrieved per turn (default |
|
Maximum length of a single user message (anti-abuse input cap) |
|
Temperature for the follow-up condensation call |
|
Maximum tokens for the condensed standalone query |
|
Email of the staff member who receives escalations outside a book context (should match a Plone member) |
|
Fallback identity used to create tickets for anonymous escalations (default |
|
Task action term for escalation tickets (default |
|
Subject line for escalation emails |
|
Confirmation message emailed to the visitor |
|
Escalation mode: |
|
Message placed before the transcript in the |
Welcome message¶
Every new conversation opens with a configurable greeting, rendered by the
@@chatbot page as the first assistant message before the visitor types
anything. It is set in chatbot_welcome_message — a textarea in the 7inOne
Chatbot control panel. Clearing the field turns the greeting off; the chat then
starts empty.
Individual content items override the greeting through the Welcome message
field of the chatbot.configuration behavior — a book, for instance, can open
with a greeting of its own. The per-content value applies whenever it is
non-empty, including when the global record has been cleared. Turning the
greeting off everywhere stays a global decision: an empty per-content field
always means “inherit”, because a cleared field and an untouched one are stored
identically. The same rule governs the per-content No-answer message, which
is resolved when the turn is queued and travels with it into the worker.
The greeting is presentational only: it is never part of the conversation
history sent to the LLM, and no session exists until the first @chatbot-ask
call. Clients that build their own chat UI on the REST API supply their own
greeting.
Answer sources in the browser page¶
Every answer on the @@chatbot page that was written from retrieved content
carries a collapsed Quellen (n) disclosure directly under the bubble.
Expanding it lists the documents the answer came from — linked title, content
type and a short excerpt — so a visitor can always check where a statement came
from. Links open in a new tab, because leaving the page would end the running
conversation.
The disclosure follows sources alone, so it also appears next to an escalation
offer when the answerability check was unsure. Answers with nothing retrieved
show no disclosure at all. See Answer sources.
Escalation routing¶
Escalation runs in one of two modes. Both determine the recipient identically; they differ in what happens with the conversation afterwards.
Recipient¶
Both escalation modes resolve the recipient the same way:
Inside a book: the book owner (moderator) —
moderator_email, falling back tofallback_email.Everywhere else: the globally configured
chatbot_support_email.
If neither resolves, the request is rejected.
Escalation modes¶
Which mode is used is set in chatbot_escalation_mode — a dropdown in the
7inOne Chatbot control panel.
task (default) — the visitor must consent. A Task ticket with the full
transcript is stored in the existing task system, in the task container of the
recipient (/users/<id>/tasks), who is notified through the existing
task-notification content rule, exactly like the “ask the book owner” flow.
Configure chatbot_support_email to a real Plone member so tickets land in
their task list; if the email matches no member, an authenticated visitor’s
ticket goes to their own task container and anonymous visitors are notified by
email only. The visitor always receives a confirmation at the email they
provided. Anonymous visitors are supported. Each conversation yields at most one
ticket.
mailto — nothing is stored and the site sends no mail. The endpoint
returns a ready-to-use mailto: link containing the whole conversation, which
the visitor sends from their own mail client — over secure mail or whatever they
prefer. Choose this mode when the site must not process or store personal data:
since nothing is processed, consent is not required, and the conversation is
never marked as escalated, so the link can be requested again.
The mail body is the configurable intro message
(chatbot_escalation_mailto_intro), a blank line, then one line per message
labelled Nutzer:in: / Chatbot:. Long conversations are truncated from the
oldest message until the encoded URL fits roughly 2000 characters — the limit
legacy mail handlers impose — and the body then starts with
[Ältere Nachrichten wurden gekürzt]. The newest message is never dropped, even
if it exceeds that limit on its own.
The transcript comes from the client: the chat stays on screen far longer than
the stored session lives (chatbot_session_ttl, sliding), so a messages list
sent with the escalation replaces the stored history and the mail always matches
what the visitor sees. It is accepted by shape only — user and assistant
roles, each message clipped to chatbot_max_message_chars, capped at the same
length as a stored session. Clients that send no messages fall back to the
stored history. This applies to mailto mode only; task mode always stores
the server-side transcript.
REST API¶
Send a message (async)¶
By default a turn is processed asynchronously; the client polls for the result.
The first response mints a conversation_id that must be sent back on every
following turn to keep conversation memory.
const response = await fetch('/Plone/@chatbot-ask', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Wie sind die Öffnungszeiten?',
path: '/plone/section' // optional: restrict retrieval to a section
})
});
const data = await response.json();
// data.status === 'pending', data.job_id, data.conversation_id
Pending response:
{
"@id": "http://localhost:8080/Plone/@chatbot-ask",
"status": "pending",
"job_id": "chatbot_turn_ab12…_0",
"conversation_id": "ab12cd34ef56ab12cd34ef56ab12cd34",
"turn_index": 0
}
Poll for the result:
const poll = await fetch('/Plone/@chatbot-ask?job_id=' + data.job_id, {
headers: { 'Accept': 'application/json' }
});
const result = await poll.json();
Completed response:
{
"@id": "http://localhost:8080/Plone/@chatbot-ask",
"status": "completed",
"conversation_id": "ab12cd34ef56ab12cd34ef56ab12cd34",
"turn_index": 0,
"answer": "Die Öffnungszeiten sind …",
"sources": [
{
"title": "Kontakt",
"url": "http://localhost:8080/Plone/kontakt",
"path": "/Plone/kontakt",
"portal_type": "Contact",
"score": 0.92,
"snippet": "Der Schalter ist Mo–Fr von 8 bis 12 Uhr geöffnet …"
}
],
"grounded": true,
"escalation_offer": false
}
answer is plain text. grounded reflects whether the retrieved context
actually answers the question: it is false both when no relevant context was
found and when the found context does not answer the question. When grounded
is false, escalation_offer is true and the bot suggests forwarding the
question to staff.
The configured no-answer message is returned when there was no usable context —
nothing retrieved, or nothing above chatbot_min_score. When context was used
but the answerability check said NEIN, answer is the model’s own wording: that
check is a coarse signal, and letting it suppress answers would discard useful
partial replies whenever it misfires.
Answer sources¶
sources lists the documents the answer was generated from, best match first,
one entry per document. Link to url — it is a real, virtual-host-aware URL,
while path is the raw catalog path the worker recorded at indexing time.
Sources are resolved per request, not at answer time: an entry is omitted when
its content has since been deleted or moved, or when the current user may not
view it, and title always reflects the content object’s current title. An
answer can therefore legitimately come back with fewer sources than documents it
was generated from.
sources is independent of grounded — do not gate the display on it. A turn
that retrieved nothing already returns an empty list, so a non-empty sources
always means those documents fed the answer. grounded can be false while
sources are present: that is the “context was retrieved but the answerability
check said it does not answer the question” case, where the reply was still
written from the listed documents and the visitor benefits most from seeing
them.
Continue the conversation¶
Send the conversation_id from the first response back on every subsequent
turn. The running history is condensed into a standalone query used for
retrieval, so follow-up questions work without repeating context. The answer
itself is then generated from the user’s original question plus the
conversation history.
await fetch('/Plone/@chatbot-ask', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Und am Wochenende?',
conversation_id: 'ab12cd34ef56ab12cd34ef56ab12cd34'
})
});
Logged-in clients may add "sync": true to receive the answer directly instead
of polling.
Forward to staff¶
After the visitor consents, forward the conversation. In task mode consent
must be true; in mailto mode nothing is stored or sent by the site, so
consent may be omitted.
const response = await fetch('/Plone/@chatbot-escalate', {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
conversation_id: 'ab12cd34ef56ab12cd34ef56ab12cd34',
consent: true,
contact_email: '[email protected]', // optional
path: '/plone/section', // optional: enables book-owner routing
messages: [ // optional, mailto mode: the displayed chat
{role: 'user', content: 'Wie sind die Öffnungszeiten?'},
{role: 'assistant', content: 'Dazu habe ich nichts gefunden.'}
]
})
});
const data = await response.json();
// data.status === 'forwarded' (or 'already_forwarded' on a repeat)
Response (task mode):
{
"@id": "http://localhost:8080/Plone/@chatbot-escalate",
"status": "forwarded",
"conversation_id": "ab12cd34ef56ab12cd34ef56ab12cd34"
}
Response (mailto mode):
The client opens the mailto link so the visitor can send the conversation from
their own mail client.
{
"@id": "http://localhost:8080/Plone/@chatbot-escalate",
"status": "mailto",
"conversation_id": "ab12cd34ef56ab12cd34ef56ab12cd34",
"recipient": "[email protected]",
"mailto": "mailto:[email protected]?subject=Neue%20Chatbot-Anfrage&body=…"
}