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 (@rag-ask)

Chatbot (@chatbot-ask)

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 mailto: link

Toggle

RAG_ENABLED

CHATBOT_ENABLED

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 grounded flips, 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_ENABLED gates the RAG question/admin surface only.

  • CHATBOT_ENABLED gates 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:

  1. For books, editors tick Enable chatbot on the book itself.

  2. For other types, a site admin first enables the chatbot.configuration behavior 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

wcs.backend.chatbot_system_prompt

System prompt for grounded, multi-turn answers

wcs.backend.chatbot_condense_prompt

Prompt that rewrites a follow-up into a standalone query

wcs.backend.chatbot_answerable_prompt

Prompt for the JA/NEIN check that decides whether the retrieved context answers the question (drives the escalation offer)

wcs.backend.chatbot_welcome_message

First message shown when a new conversation starts (empty = none); can be overridden per content by the chatbot behavior

wcs.backend.chatbot_no_answer_message

Reply when no usable context was found — nothing retrieved, or nothing above chatbot_min_score (offers escalation); can be overridden per content by the chatbot behavior

wcs.backend.chatbot_min_score

Retrieval score the best match must reach to count as context (default 0.0 = off)

wcs.backend.chatbot_error_message

Message when a turn fails

wcs.backend.chatbot_llm_temperature

Answer temperature (default 0.2, kept low for grounding)

wcs.backend.chatbot_llm_max_tokens

Maximum tokens per answer

wcs.backend.chatbot_max_turns

Conversation turns retained per session

wcs.backend.chatbot_session_ttl

Seconds a session stays alive (sliding expiry)

wcs.backend.chatbot_turn_result_ttl

Seconds a turn result is cached for polling

wcs.backend.chatbot_top_k

Chunks retrieved per turn (default 5; own knob so history doesn’t crowd the context window)

wcs.backend.chatbot_max_message_chars

Maximum length of a single user message (anti-abuse input cap)

wcs.backend.chatbot_condense_temperature

Temperature for the follow-up condensation call

wcs.backend.chatbot_condense_max_tokens

Maximum tokens for the condensed standalone query

wcs.backend.chatbot_support_email

Email of the staff member who receives escalations outside a book context (should match a Plone member)

wcs.backend.chatbot_system_initiator

Fallback identity used to create tickets for anonymous escalations (default adminuser)

wcs.backend.chatbot_escalation_action

Task action term for escalation tickets (default chatbot-anfrage)

wcs.backend.chatbot_escalation_subject

Subject line for escalation emails

wcs.backend.chatbot_escalation_confirmation

Confirmation message emailed to the visitor

wcs.backend.chatbot_escalation_mode

Escalation mode: task (default) or mailto

wcs.backend.chatbot_escalation_mailto_intro

Message placed before the transcript in the mailto: body

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 to fallback_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=…"
}