Skip to content

Support Bot #8754

Description

@ywarnier

Support Bot — Rigid Decision-Tree Chatbot

Implementation plan for an optional Chamilo core feature: a support chatbot driven by a
fixed (non-AI) decision tree, reachable from the existing chat dock, with an authoring UI,
a conversation review UI, escalation to the existing Ticket system (v1), and a roadmap for
branching/canvas/live-agent hand-off (v2) and REST + MCP-based authoring for AI agents (v3).

This document describes what to build, not how to write the code. No code in this
repository is changed by this document.

0. Naming and scope conventions used throughout this plan

  • Feature name: Support Bot.
  • Entity/class prefix: SupportBot* (SupportBotTree, SupportBotNode, SupportBotOption,
    SupportBotConversation, SupportBotStep).
  • Lives in CoreBundle (not CourseBundle): the bot is platform/portal-wide, not
    course-scoped — same placement as the Ticket* entities, not like AiTutorConversation
    which is tied to a Course.
  • Settings category: ticket (per requirement — this feature is a sibling of the
    existing ticket settings, since its escalation path is the ticket system).
  • v1 = rigid tree only (fixed node → option → node edges, no conditions). Branching,
    a visual canvas editor, and live-agent handoff are explicitly deferred to v2 (§14).
    Exposing tree authoring as REST + MCP tools for AI agents is deferred to v3 (§15).

1. Feature flag (setting) — must land first

Everything else is built behind this flag. New settings in this codebase are added by
extending SettingsCurrentFixtures.php (§1.2) — that's the authoritative, single place a
setting's name/category/title/comment is defined, and it's what the requirement's "fixture"
reference means. TicketSettingsSchema.php (§1.1) additionally defines the default value and
the form field type for the admin Settings UI. A migration (§1.3) is still needed so existing
installations pick up the new row, since fixtures only run on fresh installs/tests — but that
migration reruns the existing generic fixtures-upsert logic rather than hardcoding the new
setting's name in custom SQL.

1.1 src/CoreBundle/Settings/TicketSettingsSchema.php

Add one boolean setting, following the exact pattern of the existing ticket_* booleans in
this file:

  • buildSettings(): add 'ticket_support_bot_enabled' => 'false' to the setDefaults() array.
  • buildForm(): add ->add('ticket_support_bot_enabled', YesNoType::class).

This is the only setting needed for v1. No JSON/textarea setting is required (unlike
ai_providers) because v1 has nothing to configure beyond on/off — which tree is "active"
is a property of the tree itself (status = published), not a global setting (see §3).

1.2 src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php

In getExistingSettings(), add one entry to the existing 'ticket' => [...] array (the one
starting at line ~1531):

[
    'name' => 'ticket_support_bot_enabled',
    'title' => 'Enable support chatbot',
    'comment' => 'Show a decision-tree chatbot in the chat panel that can guide users to '
        .'an answer or open a support ticket on their behalf.',
],

This entry in SettingsCurrentFixtures.php is the single source of truth for the
setting's name/category/title/comment — this is how every setting in this project is added,
and it's sufficient on its own for fresh installs and PHPUnit/Behat test databases (which are
seeded from fixtures directly).

1.3 Existing installations: settings-upsert migration

Fixtures only run on fresh installs and test databases — an upgrade of an existing site only
runs migrations, it never re-runs DataFixtures. This codebase already has a generic,
reusable mechanism for exactly this gap: Version20260721125301.php (its own description
calls it "a fresh re-run of Version20250926174000's fixtures-upsert logic") reads
SettingsCurrentFixtures::getExistingSettings() and getNewConfigurationSettings() directly
and, for every entry not yet present in the settings table, inserts it (or updates its
title/category/comment if the row already exists but drifted).

Because that logic reads the fixtures file itself rather than hardcoding variable names, no
bespoke INSERT INTO settings ... needs to be written for ticket_support_bot_enabled.
What's needed is: add the entry to SettingsCurrentFixtures.php (§1.2), then add one more
migration
that reruns this same upsert logic — copy Version20260721125301.php's body into
a new class with a fresh timestamp and a description naming this feature (the same way that
file itself was a rerun of the one before it). It will pick up
ticket_support_bot_enabled automatically. This migration is separate from the one that
creates the new tables (§2.3) — schema changes and settings upserts are kept as separate,
single-purpose migrations throughout this codebase's history, and there's no reason to break
that convention here.

1.4 Runtime check

Nowhere in this feature should a new "feature access helper" class be introduced —
AiFeatureAccessHelper exists because AI features have a three-state mode
(true/false/plugin_defined) and per-course configurability. This feature is a plain
on/off platform toggle, so every consumer reads it the same one-line way the existing
ticket_allow_student_add setting is read in TicketListProvider:

'true' === $this->settingsManager->getSetting('ticket.ticket_support_bot_enabled')

A second, independent condition always applies alongside the setting: a published tree
must exist
for the current AccessUrl (or a portal-agnostic one, accessUrl IS NULL
same fallback convention as TicketProject/TicketStatus/TicketPriority). If the setting
is on but no tree is published, the feature stays invisible. This avoids a half-configured
feature appearing with nothing behind it.


2. Data model (v1 — rigid tree)

2.1 Entity-relationship sketch

AccessUrl 1───* SupportBotTree
                    │ 1
                    │
                    * 
              SupportBotNode ──* SupportBotOption ──1 SupportBotNode   (target, goto_node)
                    │                    │
                    │                    └──1 TicketCategory            (escalate_ticket)
                    │
                    * (via SupportBotStep.node)
                    │
SupportBotTree 1───* SupportBotConversation *───1 User
                          │ 1                    │
                          │                      └── 0..1 Ticket        (resultingTicket)
                          * 
                    SupportBotStep ──0..1 SupportBotOption               (selectedOption)

2.2 Entities

SupportBotTree (table support_bot_tree)

Field Type Notes
id int
title string(255) shown only in admin
description text, nullable admin-only notes
accessUrl AccessUrl, nullable same portal-scoping fallback as TicketProject
status string, enum: draft|published|archived only one published tree per accessUrl (enforced in the admin service, not a DB constraint — same style as other Chamilo enum-like string columns)
version int, default 1 incremented every time a tree is (re)published
entryNode SupportBotNode, nullable (FK) the node shown first; nullable until the first node is created
defaultTicketProject TicketProject, nullable (FK) used when an escalating option doesn't specify its own project
createdBy User (FK)
createdAt / updatedAt datetime

SupportBotNode (table support_bot_node)

Field Type Notes
id int
tree SupportBotTree (FK, cascade delete)
content text message shown to the user; plain text in v1, see §9 XSS note
sortOrder int editor list ordering only, irrelevant to traversal
createdAt / updatedAt datetime

A node with zero SupportBotOption rows is implicitly a dead end and must be flagged
invalid by the admin save validation (§4.3) — every leaf must end via an option whose
actionType is end_conversation or escalate_ticket, never by simply having no options.
This keeps traversal logic in one place (options), not split between "nodes with no options"
and "options with terminal actions".

SupportBotOption (table support_bot_option)

Field Type Notes
id int
node SupportBotNode (FK, cascade delete) the node this button belongs to
label string(255) button text
sortOrder int display order among sibling options
actionType string, enum: goto_node|open_link|escalate_ticket|end_conversation
targetNode SupportBotNode, nullable (FK) required when actionType = goto_node
targetUrl string(500), nullable required when actionType = open_link; validated, see §9 open-redirect note
ticketCategory TicketCategory, nullable (FK) optional override when actionType = escalate_ticket
closingMessage text, nullable optional override text when actionType = end_conversation

SupportBotConversation (table support_bot_conversation)

Field Type Notes
id int
tree SupportBotTree (FK)
treeVersion int snapshot of tree.version at start — so a tree edit mid-conversation doesn't retroactively change what's shown to an in-progress user, and so review (§7) always reflects what the user actually saw
user User (FK) who is chatting
accessUrl AccessUrl, nullable
currentNode SupportBotNode, nullable (FK) null once conversation has ended
outcome string, enum: in_progress|resolved|escalated|abandoned
resultingTicket Ticket, nullable (FK) set only when outcome = escalated
startedAt datetime
endedAt datetime, nullable

SupportBotStep (table support_bot_step)

Field Type Notes
id int
conversation SupportBotConversation (FK, cascade delete)
node SupportBotNode (FK) node that was shown to the user at this step
selectedOption SupportBotOption, nullable (FK) null on the last step of a conversation (nothing chosen yet, or conversation ended/abandoned)
createdAt datetime

SupportBotStep is the append-only transcript log — structurally the same role as
AiTutorMessage, but storing "which node was shown / which option was picked" instead of
free-text role/content pairs, since the tree is rigid and there's nothing else to log.

2.3 Migration

One new file in src/CoreBundle/Migrations/Schema/V200/, in the same style as the existing
migrations there (Schema + $this->addSql(...), up()/down()):

  • Create the 5 tables above with their FKs.
  • Indexes: (tree_id) on node; (node_id) on option; (tree_id, user_id) and
    (access_url_id) on conversation (mirrors idx_ai_tutor_conv_user_course /
    idx_ai_tutor_conv_course); (conversation_id, created_at) on step (mirrors
    idx_ai_tutor_msg_conv_created).

This migration only touches the 5 new tables — it does not insert the
ticket_support_bot_enabled settings row; that's a separate settings-upsert migration
(§1.3).


3. Runtime engine (backend)

New service: src/CoreBundle/Service/SupportBot/SupportBotEngine.php (mirrors
src/CoreBundle/Service/Ticket/TicketWorkflowService.php's role: it is the single place that
enforces conversation rules, called by the runtime controller).

Responsibilities:

  1. Start a conversation — resolve the current AccessUrl's published tree (fallback to
    the portal-agnostic one, same lookup pattern as TicketListProvider::getProjects()); if
    none, throw/return "unavailable". Create a SupportBotConversation row pointing at
    tree.entryNode, outcome = in_progress, and a first SupportBotStep with
    node = entryNode, selectedOption = null.
  2. Advance a conversation — given a conversationId and a optionId:
    • Verify the conversation belongs to the current user (ownership check, §9).
    • Verify the option belongs to conversation.currentNode (defends against a client
      sending an option id that isn't actually offered at the current step — never trust the
      client's idea of "what node it's on").
    • Record a SupportBotStep with selectedOption set.
    • Branch on option.actionType:
      • goto_node: set conversation.currentNode = option.targetNode, append a new step
        for that node with selectedOption = null, return the new node + its options.
      • open_link: return the URL to the caller (client opens it in a new tab); the
        conversation does not advance or end — the same node/options are returned again
        so the user can still pick a different option afterwards.
      • end_conversation: set conversation.outcome = resolved, endedAt = now,
        currentNode = null; return option.closingMessage (or a generic default).
      • escalate_ticket: create a Ticket (see §6), set
        conversation.outcome = escalated, resultingTicket, endedAt = now,
        currentNode = null; return a confirmation payload containing the new ticket's id/url.
  3. Abandon — if a user closes the chat dock or logs out mid-conversation, there is no
    explicit "abandon" call; a scheduled cleanup (existing Chamilo cron mechanism, e.g. a new
    console command run daily) marks any in_progress conversation with
    startedAt < now - 24h as outcome = abandoned. This keeps the review screen (§7) from
    showing stale "in progress" rows forever.

4. Admin authoring

4.1 API surface

Mirrors the Ticket module's split: #[ApiResource] + Provider DTOs for read-heavy list/
detail screens (like TicketList/TicketListProvider, TicketDetail), a plain
#[AsController] for mutations (like TicketAdminController).

Concern Pattern New files
List trees (admin) ApiResource + Provider ApiResource/SupportBot/SupportBotTreeList.php, State/SupportBot/SupportBotTreeListProvider.php
Get one tree with all nodes/options (for the editor) ApiResource + Provider ApiResource/SupportBot/SupportBotTreeDetail.php, State/SupportBot/SupportBotTreeDetailProvider.php
List conversations for a tree (review) ApiResource + Provider ApiResource/SupportBot/SupportBotConversationList.php, State/SupportBot/SupportBotConversationListProvider.php
Get one conversation transcript ApiResource + Provider ApiResource/SupportBot/SupportBotConversationDetail.php, State/SupportBot/SupportBotConversationDetailProvider.php
Create/update/delete/publish/duplicate a tree (whole tree saved as one payload — nodes + options together, since the tree is edited as a unit in the tabular editor, not node-by-node) #[AsController], ROLE_ADMIN Controller/Api/SupportBotAdminController.php, Service/SupportBot/SupportBotAdminService.php
Import (JSON/CSV) / Export (JSON) same controller as above

All admin endpoints: #[IsGranted('ROLE_ADMIN')] at the controller level. This feature has
no "session admin" or delegated-editor concept in v1 — not because it's hard, but because
nothing today asks for partial delegation of tree editing; if that need shows up later it can
be added the same way TicketCategoryRelUser delegates ticket-category responsibility,
without changing anything else in this plan.

4.2 CSRF

Per project convention: SupportBotAdminService::CSRF_TOKEN_ID = 'support_bot_admin'.
SupportBotTreeListProvider and SupportBotTreeDetailProvider each return a csrfToken
field in their JSON payload (exactly like TicketList::$csrfToken); every mutating call in
SupportBotAdminController validates it with
$this->isCsrfTokenValid('support_bot_admin', $token) before touching anything.

4.3 Save/validation rules (enforced server-side in SupportBotAdminService, not just client-side)

  • Every node except leaves must have ≥1 option.
  • Every leaf must have ≥1 option whose actionType is end_conversation or
    escalate_ticket (a leaf silently having zero options is rejected — see §2.2).
  • entryNode must belong to the tree being saved.
  • Every targetNode referenced by a goto_node option must belong to the same tree
    (prevents an admin payload from wiring a node into a foreign tree — see §9 mass-assignment
    note).
  • No orphan nodes: every node except entryNode must be reachable by following at least one
    option chain from entryNode (simple graph traversal in PHP; reject with the list of
    unreachable node refs so the admin can fix them). This is cheap to check because v1 has no
    cycles by construction (every edge points from a node to one created after/alongside it in
    the editor — see §14.1 for what changes once branching allows loops).
  • Publishing sets status = published, increments version, and demotes any other
    published tree on the same accessUrl to archived (only one active tree per portal).

4.4 Vue views

New directory assets/vue/views/supportbot/:

  • SupportBotTreeList.vueBaseTable: title, status badge, version, portal scope, updated
    date. Row actions: edit (secondary-text/pencil), duplicate (secondary-text), publish/
    unpublish toggle, delete (danger-text/delete, behind useConfirmation), "View
    conversations" link. Header actions: "+ Create" (success), "Import" (success, opens a
    BaseDialog file-upload), "Export" per row (primary).
  • SupportBotTreeEdit.vue — the tabular node/option editor (see mockup below). Left: a
    reorderable list of nodes (add/remove/reorder + "set as entry node"). Right: a form for the
    selected node — content (BaseTextArea), and a repeatable list of options, each with
    label (BaseInputText), actionType (BaseSelect), and the matching conditional field
    (targetNode a BaseSelect of the tree's own nodes / targetUrl a BaseInputText /
    ticketCategory a BaseSelect / closingMessage a BaseTextArea). One "Save tree" button
    submits the whole node+option graph in a single request (matches §4.3 — validation needs
    the whole graph at once anyway).
  • SupportBotConversationList.vue / SupportBotConversationDetail.vue — see §7.

Tabular editor mockup (v1 — no canvas, see §13.2 for the v2 alternative):

┌─ Nodes ──────────────┐  ┌─ Node: "n2 – Which device?" ─────────────────────────┐
│ ● n1 Welcome  [entry]│  │ Content:                                             │
│   n2 Which device?   │  │ ┌───────────────────────────────────────────────┐   │
│   n3 Check network   │  │ │ Does the video fail on all devices or just    │   │
│   n4 Clear cache      │  │ │ one?                                          │   │
│   n5 Reset password   │  │ └───────────────────────────────────────────────┘   │
│ [+ Add node]          │  │ Options:                                            │
└───────────────────────┘  │  ┌─────────────────────────────────────────────┐   │
                            │  │ Label: All devices     → Go to node: n3  [x]│   │
                            │  ├─────────────────────────────────────────────┤   │
                            │  │ Label: One device      → Go to node: n4  [x]│   │
                            │  └─────────────────────────────────────────────┘   │
                            │  [+ Add option]                    [Save tree]     │
                            └───────────────────────────────────────────────────┘

5. Import / export format

Primary format: JSON. A CSV alternative is offered because non-technical support staff
often draft trees in a spreadsheet first — both import to the exact same validated model
(§4.3 rules apply identically regardless of source format).

5.1 JSON schema

{
  "tree": {
    "title": "Video playback & login help",
    "description": "First-line support tree for common LMS issues",
    "defaultTicketProject": "Support"
  },
  "nodes": [
    {
      "ref": "n1",
      "content": "Hi! What do you need help with?",
      "options": [
        { "label": "A video won't play", "action": "goto_node", "target": "n2" },
        { "label": "I can't log in", "action": "goto_node", "target": "n5" }
      ]
    },
    {
      "ref": "n2",
      "content": "Does the video fail on all devices or just one?",
      "options": [
        { "label": "All devices", "action": "goto_node", "target": "n3" },
        { "label": "Just one device/browser", "action": "goto_node", "target": "n4" }
      ]
    },
    {
      "ref": "n3",
      "content": "Please check your internet connection and try our compatibility guide.",
      "options": [
        { "label": "That fixed it, thanks!", "action": "end_conversation",
          "closingMessage": "Glad we could help!" },
        { "label": "Still not working", "action": "escalate_ticket",
          "ticketCategory": "Technical" }
      ]
    }
  ],
  "entryNode": "n1"
}

ref is an import-time-only string alias (so an option can reference a node defined further
down in the file, before it has a real database id). The importer resolves every target/
entryNode ref to a node id and discards the refs — they are never persisted.

5.2 CSV alternative

One row per option (a node with 2 options produces 2 rows sharing the same node_ref/
node_content):

node_ref node_content option_label option_action option_target_ref option_ticket_category
n1 Hi! What do you need help with? A video won't play goto_node n2
n1 Hi! What do you need help with? I can't log in goto_node n5
n3 Please check your connection... That fixed it, thanks! end_conversation
n3 Please check your connection... Still not working escalate_ticket Technical

5.3 Export

"Export" on SupportBotTreeList.vue downloads the tree in the JSON format above — this
doubles as a backup/versioning mechanism (an admin can keep exported trees in their own git
repo) and as the round-trip format for duplicating a tree across portals.


6. Escalation to a human agent (Ticket integration)

When an option's actionType = escalate_ticket fires (§3, step 2):

  1. Resolve the TicketProject: option.ticketCategory?.project ?? tree.defaultTicketProject;
    if neither resolves, fail loud (admin misconfiguration — this must be caught at
    publish-time by §4.3, not silently swallowed at runtime).
  2. Build the Ticket.message body from the conversation transcript: render every
    SupportBotStep as "{node.content}\n→ {selectedOption.label}", joined in order. This
    gives the human agent the full path the user took without them re-asking the same
    qualifying questions.
  3. Create the Ticket via the existing TicketWorkflowService (do not duplicate ticket
    creation logic) with category = option.ticketCategory, project as resolved above,
    insertUserId = conversation.user, subject = a generated string such as
    "Support Bot: {tree.title}".
  4. Set conversation.resultingTicket, outcome = escalated.
  5. Return to the client a confirmation node-like payload: closing text plus a router-link to
    the new ticket ({ name: 'TicketDetail', params: { id } }), rendered as a BaseButton
    inside the chat bubble.

No new ticket-side code is needed — this is pure composition on top of the existing Ticket
entities/service listed in §0.


7. Conversation logging & review

  • SupportBotConversationList.vue (admin, per tree) — BaseTable filterable by outcome,
    date range, and free-text keyword (matched against step content via a LIKE, same style
    as TicketListProvider::applyFilters()'s keyword search). Columns: user, started/ended
    at, outcome badge (Active blue / Resolved green / Escalated gray with a ticket link /
    Abandoned red — reusing the badge convention already in use elsewhere), tree version.
  • SupportBotConversationDetail.vue — read-only chat-transcript rendering of the ordered
    SupportBotStep rows (bot bubble = node.content, "user" bubble = selectedOption.label),
    plus a link to resultingTicket if escalated. No edit capability — this is an audit view,
    not a place to alter history.

8. Vue: chat dock integration (assets/vue/components/chat/DockedChat.vue)

This file already has the exact shape needed, built for the AI Tutor peer
(const AI_PEER_ID = -1). Add a second synthetic peer:

const SUPPORT_BOT_PEER_ID = -2
  • A quick-entry button next to the existing "AI Tutor" one (§ same block as
    tutorCtx.enabled && !contactsHasAiTutor), gated by a new supportBotCtx.enabled flag
    fetched the same way tutorCtx is — except it is not gated by inCourse: the bot is
    platform-wide, so it must be visible from any page, not only inside a course.
  • Message rendering change: today every bubble is either legacy contacts HTML or plain
    text. Add a third bubble kind, "options", rendered as a stack of BaseButton elements (one
    per current SupportBotOption), used only when activePeer.id === SUPPORT_BOT_PEER_ID.
    When this peer is active, the free-text input box is hidden entirely — the tree is rigid,
    there is nothing for the user to type (except in the escalate-confirmation state, which is
    just a button, not text either).
  • Sending: clicking an option button calls the runtime "advance conversation" endpoint
    (§3) with { conversationId, optionId, _token }; the response (next node + its options, or
    a closing/escalation payload) is appended to the transcript exactly like a new incoming
    message today.
  • Opening the bot for the first time calls "start conversation" (§3, step 1) and renders the
    entry node.

9. Security checklist (per CLAUDE.md Rule 13 — OWASP)

Concern Where it applies Mitigation
CSRF Every admin mutation (SupportBotAdminController: save tree, delete, publish, import) and every runtime mutation (SupportBotRuntimeController: start conversation, advance conversation) CsrfTokenManagerInterface::getToken('support_bot_admin' / 'support_bot_chat'), validated via isCsrfTokenValid(); token round-tripped in the JSON payload (there is no HTML <form> here, so the token travels as a JSON field returned by the GET/start call and echoed back on the next POST, same mechanism TicketList::$csrfToken already uses)
Broken access control (admin) All SupportBotAdminController routes, SupportBotTreeList/SupportBotConversationList providers #[IsGranted('ROLE_ADMIN')] at controller level — no session-admin exception needed since v1 has no delegated editors (§13 candidate)
Broken access control (ownership) SupportBotRuntimeController::advanceConversation Verify conversation.user === currentUser before any read/write; return 404 (not 403) on mismatch to avoid confirming the conversation id exists, same reasoning as the "per-user owned ApiResource" pattern in CLAUDE.md
SQL injection SupportBotTreeListProvider/SupportBotConversationListProvider filters and sorting Bound parameters throughout (setParameter); sort field allowlist map, never a raw client string into orderBy() — exact copy of TicketListProvider::applySorting()'s $sortMap pattern
XSS SupportBotNode.content rendering in DockedChat.vue and in the admin transcript view v1 stores/renders plain text only — Vue's {{ }} auto-escapes; no v-html for node content anywhere, unlike the legacy-contacts-HTML branch already in DockedChat.vue which is a separate, pre-existing code path. If rich text is ever wanted, it must be sanitized server-side before storage, not just escaped at render time
Open redirect SupportBotOption.targetUrl (open_link action) Validate on save: must be either a relative Chamilo path or an http(s):// absolute URL against an admin-configured allowlist of domains (reuse whatever mechanism, if any, already validates external links elsewhere — otherwise restrict to relative paths only for v1 and revisit); render as <a target="_blank" rel="noopener"> or open via window.open(), never assign to window.location.href from unsanitized data
Mass parameter manipulation SupportBotAdminController::saveTree (whole-graph payload) Every node/option id present in the incoming payload must be verified to belong to the tree being edited (node.tree_id === tree.id) before any write — an admin editing tree A must not be able to silently attach/detach nodes that belong to tree B; array of node ids cast with array_map('intval', ...)

10. Vue routing & breadcrumbs

No new top-level Symfony route is needed. The only new pages are admin-only (the tree editor
and the conversation review — end users never leave the chat dock to use the bot itself), so
nesting everything under the existing /admin/{vueRouting} catch-all
(IndexController::index(), already registered) avoids adding a new entrypoint route and a
new Breadcrumb.vue whitelist entry — the /admin/* breadcrumb case documented in
Breadcrumb.vue already applies automatically.

Add to assets/vue/router/admin.js's children array:

{
  name: "SupportBotTreeList",
  path: "support-bot",
  meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Support bot" },
  component: () => import("../views/supportbot/SupportBotTreeList.vue"),
},
{
  name: "SupportBotTreeCreate",
  path: "support-bot/create",
  meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Create tree" },
  component: () => import("../views/supportbot/SupportBotTreeEdit.vue"),
},
{
  name: "SupportBotTreeEdit",
  path: "support-bot/:id(\\d+)/edit",
  meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Edit tree" },
  component: () => import("../views/supportbot/SupportBotTreeEdit.vue"),
},
{
  name: "SupportBotConversationList",
  path: "support-bot/:id(\\d+)/conversations",
  meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Conversations" },
  component: () => import("../views/supportbot/SupportBotConversationList.vue"),
},
{
  name: "SupportBotConversationDetail",
  path: "support-bot/:id(\\d+)/conversations/:conversationId(\\d+)",
  meta: { requiresAdmin: true, showBreadcrumb: true, breadcrumb: "Conversation" },
  component: () => import("../views/supportbot/SupportBotConversationDetail.vue"),
},

The generic Settings admin screen already exposes ticket_support_bot_enabled once §1 is
done — no bespoke settings page is needed.


11. Admin menu entry

Add one item to getItemsTracking() (or a more fitting section, e.g. next to the existing
item-ticket-system entry) in
src/CoreBundle/Controller/Admin/IndexBlocksController.php:

$items[] = [
    'class' => 'item-support-bot',
    'url' => '/admin/support-bot',
    'label' => $this->translator->trans('Support bot'),
];

Gate its visibility with the same ticket_support_bot_enabled setting check (follow this
controller's existing pattern for conditionally-shown items) so the menu entry disappears
when the feature is off.


12. Testing plan

12.1 PHPUnit

  • SupportBotEngine: start conversation resolves the right tree (portal-specific over
    portal-agnostic fallback); advancing with a foreign option id is rejected; each
    actionType branch produces the expected conversation/step state; escalation creates a
    real Ticket with the expected category/project and transcript text.
  • SupportBotAdminService: every §4.3 validation rule has a dedicated failing case (missing
    entry node, cross-tree target, unreachable node, leaf without a terminal option); publish
    demotes the previously published tree; import (JSON and CSV) produces an identical model
    to hand-building the same tree via the admin service.
  • Security regression: a non-owner calling "advance conversation" on someone else's
    conversation id gets 404; a non-admin calling any SupportBotAdminController route gets
    403; a request missing/with a wrong CSRF token is rejected on every mutating endpoint.

12.2 Behat (tests/behat/features/supportbot/, mirroring the view directory)

Per the mandatory rule in CLAUDE.md, every interaction needs coverage, and — since this
page is accessible only to admins for authoring/review but to every logged-in role for
using the bot — scenarios must run once per relevant role:

  • manage-trees.feature (admin only): create a tree with a couple of nodes/options,
    edit it, publish it, delete it, import a JSON file, export a tree. Include a scenario that
    a non-admin (student, teacher) cannot reach /admin/support-bot (redirected/denied).
  • use-chatbot.feature, run once as a student and once as a teacher: open the chat
    dock, start the bot, walk a full path to a resolved end, and separately walk a path to
    escalate_ticket and confirm a ticket appears in /tickets for that user.
  • review-conversations.feature (admin only): after a scenario like the above runs, the
    admin can see the conversation in the list, filter it by outcome, and open its transcript.

Every form control in the new Vue views must carry a name attribute per the project rule,
so these steps can use I fill in "name" with "value" / I select "option" from "name".

Each feature file creates and tears down its own tree/settings state, leaving the database as
found (per the project's Behat self-containment rule) — in particular, restoring
ticket_support_bot_enabled to its prior value at the end.


13. Rollout notes

  • Ships in core, not a plugin: the migration always runs, the tables always exist. With the
    setting at its default (false) and no published tree, the feature is completely inert —
    zero visible surface, negligible runtime cost (one settings lookup + one indexed query per
    page that renders DockedChat.vue, cached the same way tutorCtx already is).
  • No BC concerns: nothing existing is touched. Ticket, TicketCategory, TicketProject
    are only ever read by this feature, never modified in shape.
  • Suggested build order (each step independently testable before moving to the next):
    1. Setting fixture entry + schema migration + settings-upsert migration (§1, §2.3) — land
      first so PHPUnit/Behat DBs have the schema and the setting early.
    2. Entities + repositories, no UI yet.
    3. SupportBotEngine + runtime controller, tested via PHPUnit only (no chat UI yet).
    4. Admin CRUD (SupportBotAdminService/SupportBotAdminController) + SupportBotTreeList/
      SupportBotTreeEdit Vue views — an admin can now author and publish a tree end-to-end.
    5. DockedChat.vue integration — a user can now actually talk to the bot.
    6. Import/export.
    7. Conversation review screens.
    8. Behat coverage for all of the above (can be written incrementally alongside each step
      rather than only at the end).

14. V2 roadmap (explicitly out of scope for v1)

These three items were identified while scoping v1 and are real, separately-shippable
features. Listed here so v1's data model choices (§2) don't have to be redesigned later —
each subsection notes what in v1 already accommodates it and what doesn't.

14.1 Branching (conditional tree traversal)

Problem v1 doesn't solve: a fixed button click is the only way to move through the
tree. There's no way to ask the user to type something (an order number, a course name)
and branch on it, and no way to branch on facts Chamilo already knows about the user
(role, enrollment, session membership) without making them click through a redundant
question.

Data model additions:

  • New node type: an input node — asks the user to type/pick a value (text, number, or a
    small enum), stored as a named variable scoped to the conversation.
  • New entity SupportBotVariable (conversation-scoped key/value store): one row per
    (conversation_id, name).
  • SupportBotOption gains a nullable conditionExpression column. Evaluated server-side
    with Symfony's ExpressionLanguage component (sandboxed expression evaluation — never
    eval()), against the conversation's variables plus a small set of built-in facts
    (user.role, user.hasActiveSession, etc., resolved by the engine, not user-supplied).
    Options are evaluated in sortOrder; the first whose condition is true (or that has no
    condition at all, i.e. a "fallback/else" option) wins.
  • This also changes §4.3's "no cycles" assumption: branching by definition allows a node to
    be reachable through more than one path, and — if ever desired — loops (e.g. "ask again if
    the input didn't validate"). The reachability/leaf-validation rules in §4.3 need
    generalizing to a proper graph-reachability check (still cheap, just no longer "every edge
    points forward").

Illustration — v1's fixed edges vs. v2's conditional edges on the same node:

v1 (rigid):                          v2 (branching):
 [n1: "What's your role?"             [n1: input node, stores answer as
       — pick a button —]                   variable `role`]
  ├─ "I'm a teacher" → n2               ├─ if role == "teacher" → n2
  └─ "I'm a student" → n3               ├─ if role == "student" → n3
                                        └─ (no condition = fallback) → n4

14.2 Visual flowchart canvas editor

Problem v1 doesn't solve: the tabular editor (§4.4) is fast to use but doesn't show the
overall shape of a large tree — an admin authoring 40+ nodes may struggle to see at a
glance which branches are getting long or where a loop (once v2.1 allows them) closes.

Approach: add a second, optional front-end over the same data model — no new
entities beyond two presentational columns, positionX/positionY (float), added to
SupportBotNode. A drag-and-drop canvas library (e.g. Vue Flow) renders nodes as boxes and
options as directional connectors; dragging a connector from one node's option handle onto
another node updates that option's targetNode/conditionExpression exactly as the tabular
editor's dropdowns would. Both views stay available — some admins will prefer the tabular
list for fast linear edits, others the canvas for seeing structure; this is purely additive,
not a replacement.

Needed canvas behaviors: pan/zoom, add-node context menu, click-node opens the same
content/options mini-form used in the tabular editor (as a side panel or modal, not
reinvented), and live validation highlighting (e.g. a node with no incoming edge glows red as
"unreachable", a non-leaf with zero outgoing edges glows red as "dead end") reusing the exact
rules from §4.3/§14.1.

Illustration — same tree as the tabular mockup in §4.4, as a canvas would render it:

┌─────────────────┐   "video won't play"    ┌───────────────────────┐
│ n1: Welcome      │────────────────────────▶│ n2: Which device?     │
│                  │                          └───────────┬───────────┘
│                  │    "can't log in"                    │ "all devices"
│                  │────────────┐                          ▼
└──────────────────┘            │              ┌───────────────────────┐
                                 ▼              │ n3: Check connection  │
                     ┌───────────────────────┐  └───────────┬───────────┘
                     │ n5: Reset password    │              │ "still broken"
                     └───────────────────────┘              ▼
                                                  ┌───────────────────────┐
                                                  │ [escalate → Technical] │
                                                  └───────────────────────┘

14.3 Live agent hand-off (real-time human takeover)

Problem v1 doesn't solve: escalation in v1 is always asynchronous — it opens a Ticket
and the conversation ends there. There is no way for the user to be connected, in the same
chat session, to a human agent typing back in real time.

Approach: add a fifth actionType on SupportBotOption: live_handoff. Firing it does
not end the conversation the way escalate_ticket does — instead:

  • conversation.outcome gains a new value, awaiting_agent.
  • A small "available agents" concept is needed: reuse the presence toggle
    DockedChat.vue already shows in its header (userStatus/"Online"/"Offline") — any user
    with ROLE_ADMIN (or a new, narrower ROLE_SUPPORT_AGENT if platforms want to delegate
    this without full admin rights) who is marked online is "available".
  • New admin/agent view, SupportBotLiveQueue.vue — a list of conversations currently
    awaiting_agent, each with a "Claim" button. Claiming is a single conditional UPDATE
    (SET claimedByUserId = :agent WHERE id = :id AND claimedByUserId IS NULL) so two agents
    racing to claim the same request can't both succeed — first write wins, matching
    optimistic-concurrency conventions already used elsewhere in the codebase.
  • Once claimed, no new transport is built: the user's active peer in DockedChat.vue is
    simply reassigned from SUPPORT_BOT_PEER_ID to the claiming agent's real user id, and the
    existing human-to-human messenger flow (the same one ChatController/ChatRepository
    already serve today) takes over untouched — the bot's job was only to route the
    conversation to a person, not to relay messages between them.
  • Fallback: if no agent claims the request within a configurable timeout, the client
    prompts the user to fall back to escalate_ticket instead (v1's async path), so the
    feature degrades gracefully when no one is online rather than leaving the user stuck.

Illustration — hand-off sequence:

 User                Bot engine              Live queue            Agent
  │  clicks "Talk to a human"  │                                    │
  │ ──────────────────────────▶│                                    │
  │                            │ conversation.outcome =              │
  │                            │   awaiting_agent                    │
  │                            │ ───────────────────────────────────▶│ appears in queue
  │                            │                                    │ clicks "Claim"
  │                            │◀───────────────────────────────────│
  │  now chatting with a real person, via the existing messenger    │
  │◀════════════════════════════════════════════════════════════════▶│
  │                            │                                    │
  │   (if nobody claims within timeout)                             │
  │◀── "No agent available — open a ticket instead?" ──│            │

15. V3 roadmap — REST web services + MCP-based tree authoring for AI agents

Goal: let an administrator build/edit a Support Bot tree by directing an AI agent (e.g.
an MCP-compatible client such as Claude) instead of clicking through
SupportBotTreeEdit.vue node by node. This is additive on top of v1/v2 — nothing in the data
model (§2) or SupportBotAdminService validation (§4.3) changes; v3 exposes new transports
in front of the same logic already designed for v1.

15.1 REST web services (granular CRUD)

Why a new surface is needed: v1's admin mutation endpoint (§4.1) is deliberately
whole-tree-at-once — SupportBotAdminController::saveTree takes the entire node/option graph
in a single payload, which fits a human editing in the tabular UI but is a poor shape for a
conversational agent. An agent naturally works one step at a time — "create a tree," "add a
node," "add another option to it," "point that option at the earlier node" — so v3 adds a
parallel, granular REST surface: same tables, same validation service, a different transport
aimed at programmatic/agent-driven authoring rather than the human UI.

Promote SupportBotTree, SupportBotNode, SupportBotOption to first-class
#[ApiResource] entities, in addition to (not instead of) the existing DTO-based
SupportBotTreeList/SupportBotTreeDetail read views and the whole-tree-save controller:

Resource Operations Notes
SupportBotTree GetCollection, Get, Post, Patch, Delete Post creates an empty draft tree; nodes/options are added afterward via their own resources
SupportBotNode GetCollection (filterable by tree), Get, Post, Patch, Delete
SupportBotOption GetCollection (filterable by node), Get, Post, Patch, Delete

Patch, not Put, for updates — verified against API Platform's own upgrade guide/changelog:
API Platform 4 removed Put from the default generated operations entirely (fix: remove PUT from default operations, api-platform/core#6570); Patch (JSON Merge Patch, RFC 7396) is now
the only update operation registered by default. Put still exists but is opt-in, and once
explicitly declared it performs full standards-compliant resource replacement
(standard_put: true is itself now the default for any Put operation that is declared,
reversing API Platform 3's partial-update-via-PUT behavior). This plan uses Patch for all
three resources, both because it matches the framework's new default and because it's the
better fit for how an MCP-driven agent actually edits: adding one option, renaming one node's
content, or repointing one targetNode are all naturally partial updates — sending a full
Put replacement for a one-field change would require the agent to first fetch and resend
every unrelated field, which is unnecessary round-tripping and additional risk of the agent
accidentally clobbering fields it didn't mean to touch. Put is deliberately not added to
any of these three resources unless a concrete need for full-replacement semantics appears.

  • Security: security: "is_granted('ROLE_ADMIN')" on every operation of all three resources
    — no new authorization model, the same gate as the rest of this feature (§9).
  • Every mutating operation routes through SupportBotAdminService via a Processor (the
    same "Processor calls the existing service, doesn't duplicate its rules" pattern already
    used for TicketAdminService), so §4.3's validation stays the single source of truth
    regardless of which transport triggered it.
  • Only the structural, per-object rules from §4.3 are enforced on each individual
    create/update (e.g. "a targetNode must belong to the same tree," "referenced ids must
    exist"). The graph-wide rules — every node reachable from the entry node, every leaf
    terminates — are deliberately not re-checked on every single node/option write, because
    a tree built up one resource at a time legitimately passes through incomplete states (a
    freshly created node has zero options for a moment, by construction). Those rules are
    enforced only when the tree is explicitly published, exposed here as its own operation: a
    publish custom operation on SupportBotTree that returns either "published" or the list
    of validation errors with the specific node/option refs to fix.
  • Bulk JSON/CSV import (§5) is unchanged and remains the fastest way to load a fully-formed
    tree in one call. The granular resources here are for incremental, conversational editing —
    exactly the shape an agent-driven session takes.

15.2 API Platform 4 MCP declaration

Prerequisite, called out explicitly: this requires upgrading api-platform/core from the
project's current 3.0 to the 4.x line, where API Platform's native MCP (Model Context
Protocol) server support lives. That is a project-wide, major-version dependency bump, not
something scoped to the Support Bot feature — every other #[ApiResource] in the codebase
needs its own compatibility pass, so this belongs on its own, separately-reviewed upgrade
ticket. §15.1 (REST) does not depend on that upgrade and is useful standalone; §15.2 (MCP)
only becomes buildable once the project (or at least this feature) is actually on 4.x.

What MCP exposure is expected to look like. The description below is the current
understanding of API Platform 4's newly-introduced MCP support, not a verified API surface —
it must be checked against API Platform 4's actual documentation once the team is on that
version, and treated as provisional until then:

  • API Platform 4 exposes API Resources as MCP tools through a project-level MCP server
    endpoint, opt-in via configuration (e.g. api_platform.yaml: mcp: enabled: true). One MCP
    tool is generated per exposed operation, named from the resource/operation (e.g.
    list_support_bot_trees, get_support_bot_tree, create_support_bot_node,
    publish_support_bot_tree), with the tool's description populated from that operation's
    OpenAPI summary/description. Only the three §15.1 resources should opt in — this is
    not a blanket "expose the whole platform to MCP" change; every other #[ApiResource] in
    the codebase keeps MCP disabled unless a separate, deliberate decision is made for it.
  • Because an MCP tool's description is what an agent uses to decide when to call it, those
    summaries need to be written for that audience specifically: action-oriented, unambiguous
    against any other "create"/"list" tool the platform might expose, and explicit about side
    effects (publish_support_bot_tree's description should say it makes the tree live for
    real users, not just "update tree status").
  • Security carries over unchanged: MCP calls are ordinary authenticated requests to the
    same Symfony security layer, so ROLE_ADMIN and every §9 mitigation apply exactly as they
    do to the REST calls in §15.1 — there is no separate MCP authorization model to design. The
    calling agent authenticates as a real Chamilo admin user via the JWT/API-token mechanism
    this codebase already uses elsewhere for headless, non-browser API access.
  • Recommend a dedicated automation admin account for any agent granted MCP access, rather
    than a shared human admin's personal token, so every tree/node/option created this way stays
    attributable via the existing createdBy/updatedAt fields (§2.2) — a tree an agent built
    should be visibly distinguishable from one a human built in the same list view (§4.4), not
    merely inferable from timing.
  • A validate-without-publishing tool is needed alongside the CRUD tools: expose §4.3's
    graph checks as their own validate_support_bot_tree tool, separate from publish, that
    returns the same errors without flipping status. This is what makes MCP authoring
    actually usable — an agent's natural workflow is create → validate → fix → validate again →
    publish, and without a non-destructive validate step it would only discover a broken tree
    by actually trying to publish it.
  • The bulk import/export endpoints (§5) and the whole-tree-save endpoint (§4.1) should not
    be exposed as MCP tools. A single tool call asking an LLM to produce an entire tree as one
    large structured JSON blob is a worse fit than the granular per-node/per-option tools above
    — long structured output in one call is exactly where LLMs are more error-prone. Granular
    tools plus the validate/publish loop give the agent a much shorter, more correctable unit of
    work per call.

15.3 Illustration — agent-driven authoring session

Admin (via an MCP client, e.g. Claude)        MCP server → REST → SupportBotAdminService
 │ "Create a support tree for password        │
 │  reset issues"                             │
 │ ──────────────────────────────────────────▶│ create_support_bot_tree()
 │                                             │ create_support_bot_node()  ×N
 │                                             │ create_support_bot_option() ×N
 │ "Validate it"                               │
 │ ──────────────────────────────────────────▶│ validate_support_bot_tree()
 │ ◀──────────── "node n4 is unreachable" ─────│
 │ "Fix that, then publish"                    │
 │ ──────────────────────────────────────────▶│ update_support_bot_option()
 │                                             │ publish_support_bot_tree()
 │ ◀───────────────── "Published, v2" ─────────│

Metadata

Metadata

Assignees

No one assigned

    Labels

    AmbitiousFeatures that represent a lot of work and might be pushed further awayFeature request

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions