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

Consent-gated forwarding to staff (email + stored ticket)

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.

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 behavior ships registered but not attached to any content type by the default profile. To use it:

  1. A site admin enables the chatbot.configuration behavior on the desired content types in Site Setup → Content Types.

  2. Editors then tick Enable chatbot on individual items of those types.

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 “Enable chatbot” field requires the Manage chatbot properties permission (granted to Manager).

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_no_answer_message

Message when no relevant context is found (offers escalation)

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

Escalation routing

When a visitor consents to forwarding a conversation, a Task ticket with the full transcript is stored in the existing task system — in the task container of the staff member who handles it — and staff are notified:

  • Inside a book: the ticket is owned by and assigned to the book owner (moderator), who is notified through the existing task-notification content rule, exactly like the “ask the book owner” flow.

  • Everywhere else: the ticket is owned by and assigned to the Plone member whose email is configured in chatbot_support_email, who is then emailed directly. Configure this to a real staff member so the ticket lands in their task list. If the email does not match a 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.

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", "path": "/kontakt", "portal_type": "Contact", "score": 0.92}
  ],
  "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.

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. consent must be true.

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: 'visitor@example.com',  // optional
        path: '/plone/section'                  // optional: enables book-owner routing
    })
});
const data = await response.json();
// data.status === 'forwarded' (or 'already_forwarded' on a repeat)

Response:

{
  "@id": "http://localhost:8080/Plone/@chatbot-escalate",
  "status": "forwarded",
  "conversation_id": "ab12cd34ef56ab12cd34ef56ab12cd34"
}