diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py
new file mode 100644
index 000000000..b49ec41b1
--- /dev/null
+++ b/backend/app/alembic/versions/088_add_chatbot_session.py
@@ -0,0 +1,307 @@
+"""add chatbot, flow version, session and message tables
+
+Revision ID: 088
+Revises: 087
+Create Date: 2026-10-20 00:00:00.000000
+"""
+
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision = "088"
+down_revision = "087"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "chatbot",
+ sa.Column(
+ "id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Chatbot identifier used in API paths",
+ ),
+ sa.Column(
+ "name",
+ sqlmodel.sql.sqltypes.AutoString(length=255),
+ nullable=False,
+ comment="Display name of the chatbot shown in the UI",
+ ),
+ sa.Column(
+ "organization_id",
+ sa.Integer(),
+ nullable=False,
+ comment="Organization that owns the chatbot",
+ ),
+ sa.Column(
+ "project_id",
+ sa.Integer(),
+ nullable=False,
+ comment="Project that owns the chatbot",
+ ),
+ sa.Column(
+ "inserted_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the chatbot was created",
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the chatbot was last updated",
+ ),
+ sa.Column(
+ "deleted_at",
+ sa.DateTime(),
+ nullable=True,
+ comment="Soft-delete timestamp; the row is kept because sessions reference it",
+ ),
+ sa.ForeignKeyConstraint(
+ ["organization_id"], ["organization.id"], ondelete="CASCADE"
+ ),
+ sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ op.f("ix_chatbot_organization_id"),
+ "chatbot",
+ ["organization_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_chatbot_project_id"), "chatbot", ["project_id"], unique=False
+ )
+
+ op.create_table(
+ "chatbot_flow_version",
+ sa.Column(
+ "id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Flow version identifier pinned by sessions",
+ ),
+ sa.Column(
+ "chatbot_id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Chatbot this flow version belongs to",
+ ),
+ sa.Column(
+ "version",
+ sa.Integer(),
+ nullable=False,
+ comment="Sequential version number per chatbot, starting at 1",
+ ),
+ sa.Column(
+ "nodes",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=False,
+ comment="React Flow nodes as saved by the UI: [{id, type (input/output/coordinator/onboarding/rag), position {x, y}, data}]; agent node data carries config_id, config_version, prompt",
+ ),
+ sa.Column(
+ "edges",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=False,
+ server_default=sa.text("'[]'::jsonb"),
+ comment="React Flow edges as saved by the UI: [{id, source, target, sourceHandle, targetHandle, data}]",
+ ),
+ sa.Column(
+ "is_live",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("false"),
+ comment="True for the single published version new sessions run on; live versions are immutable, the highest non-live version is the draft",
+ ),
+ sa.Column(
+ "inserted_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the version was created",
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the draft was last saved or the version was published/unpublished",
+ ),
+ sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint(
+ "chatbot_id",
+ "version",
+ name="uq_chatbot_flow_version_chatbot_version",
+ ),
+ )
+ op.create_index(
+ op.f("ix_chatbot_flow_version_chatbot_id"),
+ "chatbot_flow_version",
+ ["chatbot_id"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_chatbot_flow_version_live_chatbot",
+ "chatbot_flow_version",
+ ["chatbot_id"],
+ unique=True,
+ postgresql_where=sa.text("is_live"),
+ )
+
+ op.create_table(
+ "chatbot_session",
+ sa.Column(
+ "id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Session identifier returned to the caller",
+ ),
+ sa.Column(
+ "chatbot_id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Chatbot this session runs; tenant scope comes from the chatbot",
+ ),
+ sa.Column(
+ "flow_version_id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Live flow version pinned at creation, so publishing never changes a running conversation",
+ ),
+ sa.Column(
+ "contact_ref",
+ sqlmodel.sql.sqltypes.AutoString(length=500),
+ nullable=False,
+ comment="Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi",
+ ),
+ sa.Column(
+ "active_node_id",
+ sqlmodel.sql.sqltypes.AutoString(length=255),
+ nullable=False,
+ comment="React Flow node id inside the pinned flow version that handles the next turn",
+ ),
+ sa.Column(
+ "status",
+ sa.Enum(
+ "active",
+ "inactive",
+ name="chatbotsessionstatusenum",
+ ),
+ nullable=False,
+ comment="Session in use: active, or inactive once superseded by a newer session for the same chatbot + contact_ref or the chatbot was deleted",
+ ),
+ sa.Column(
+ "completion_status",
+ sa.Enum(
+ "in_progress",
+ "complete",
+ "abandoned",
+ name="chatbotcompletionstatusenum",
+ ),
+ nullable=False,
+ server_default="in_progress",
+ comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when deactivated while in_progress",
+ ),
+ sa.Column(
+ "collected_fields",
+ postgresql.JSON(astext_type=sa.Text()),
+ nullable=False,
+ comment="Flat map of field name -> value gathered from the user so far",
+ ),
+ sa.Column(
+ "inserted_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the session was created",
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the session was last updated",
+ ),
+ sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="RESTRICT"),
+ sa.ForeignKeyConstraint(
+ ["flow_version_id"], ["chatbot_flow_version.id"], ondelete="RESTRICT"
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ op.f("ix_chatbot_session_flow_version_id"),
+ "chatbot_session",
+ ["flow_version_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_chatbot_session_contact_ref"),
+ "chatbot_session",
+ ["contact_ref"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_chatbot_session_chatbot_contact",
+ "chatbot_session",
+ ["chatbot_id", "contact_ref"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_chatbot_session_active_chatbot_contact",
+ "chatbot_session",
+ ["chatbot_id", "contact_ref"],
+ unique=True,
+ postgresql_where=sa.text("status = 'active'"),
+ )
+
+ op.create_table(
+ "chatbot_session_message",
+ sa.Column("id", sa.Uuid(), nullable=False, comment="Unique identifier"),
+ sa.Column(
+ "session_id",
+ sa.Uuid(),
+ nullable=False,
+ comment="Reference to the owning chatbot session",
+ ),
+ sa.Column(
+ "role",
+ sa.Enum("system", "user", "assistant", name="chatbotmessageroleenum"),
+ nullable=False,
+ comment="Speaker: user or assistant (system is reserved; the prompt lives on the flow node)",
+ ),
+ sa.Column(
+ "content",
+ sqlmodel.sql.sqltypes.AutoString(),
+ nullable=False,
+ comment="Message text",
+ ),
+ sa.Column(
+ "node_id",
+ sqlmodel.sql.sqltypes.AutoString(length=255),
+ nullable=True,
+ comment="React Flow node id of the pinned flow that was active for this turn",
+ ),
+ sa.Column(
+ "inserted_at",
+ sa.DateTime(),
+ nullable=False,
+ comment="Timestamp when the message was stored; defines transcript order",
+ ),
+ sa.ForeignKeyConstraint(
+ ["session_id"], ["chatbot_session.id"], ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ op.f("ix_chatbot_session_message_session_id"),
+ "chatbot_session_message",
+ ["session_id"],
+ unique=False,
+ )
+
+
+def downgrade():
+ raise NotImplementedError(
+ "not reversible: dropping chatbot tables discards chatbot flows and stored conversations"
+ )
diff --git a/backend/app/alembic/versions/089_add_chatbot_feature_flag.py b/backend/app/alembic/versions/089_add_chatbot_feature_flag.py
new file mode 100644
index 000000000..68529d795
--- /dev/null
+++ b/backend/app/alembic/versions/089_add_chatbot_feature_flag.py
@@ -0,0 +1,22 @@
+"""add CHATBOT feature flag key
+
+Revision ID: 089
+Revises: 088
+Create Date: 2026-10-23 00:00:00.000000
+"""
+
+from alembic import op
+
+revision = "089"
+down_revision = "088"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ with op.get_context().autocommit_block():
+ op.execute("ALTER TYPE featureflagkey ADD VALUE IF NOT EXISTS 'CHATBOT'")
+
+
+def downgrade():
+ pass
diff --git a/backend/app/api/docs/chatbot/create.md b/backend/app/api/docs/chatbot/create.md
new file mode 100644
index 000000000..fcc16ea5d
--- /dev/null
+++ b/backend/app/api/docs/chatbot/create.md
@@ -0,0 +1,3 @@
+Create a chatbot in the caller's project and return it (`201`).
+
+Only `name` is required (1–255 characters; display only). A new chatbot has no flow: its first `PUT /chatbots/{chatbot_id}/flow` goes live at once, after which sessions can start. Later saves are drafts that go live with `POST /chatbots/{chatbot_id}/flow/publish`.
diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md
new file mode 100644
index 000000000..90a640e80
--- /dev/null
+++ b/backend/app/api/docs/chatbot/create_session.md
@@ -0,0 +1,5 @@
+Start a conversation between a chatbot and an end user and return `{session_id, chatbot_id, flow_version, active_node_id}` (`201`).
+
+The session is pinned to the chatbot's live flow version and starts on its entry node (the agent node the `input` node leads to, or the flow's only agent node). Every turn runs that pinned version, so publishing a newer flow applies to new sessions only. `contact_ref` is the caller's own identifier for the end user and is stored as-is.
+
+A contact has at most one active session per chatbot. Creating a session marks the contact's previous `active` sessions of this chatbot as `inactive`, in the same transaction; those still `in_progress` become `abandoned`, while `complete` ones stay `complete`. Sessions of other chatbots or other contacts are untouched. Returns 404 if the chatbot doesn't exist in this project or was deleted, 409 if the chatbot has no published flow (publish the flow first) or another session for the same contact was created concurrently (retry).
diff --git a/backend/app/api/docs/chatbot/delete.md b/backend/app/api/docs/chatbot/delete.md
new file mode 100644
index 000000000..0111864c5
--- /dev/null
+++ b/backend/app/api/docs/chatbot/delete.md
@@ -0,0 +1,3 @@
+Soft-delete a chatbot; returns `{"message": "Chatbot deleted successfully"}`. It disappears from list/get and its flow can no longer be edited, published, or used for new sessions.
+
+In the same transaction every `active` session of the chatbot becomes `inactive` (those still `in_progress` become `abandoned`; `complete` ones stay `complete`), so further messages to them return 409. Stored sessions and transcripts are kept. Returns 404 if the chatbot doesn't exist in this project or was already deleted.
diff --git a/backend/app/api/docs/chatbot/get.md b/backend/app/api/docs/chatbot/get.md
new file mode 100644
index 000000000..462be00a9
--- /dev/null
+++ b/backend/app/api/docs/chatbot/get.md
@@ -0,0 +1,3 @@
+Get one chatbot of the caller's project by `chatbot_id`.
+
+Returns 404 if the chatbot doesn't exist in this project or was deleted. The chatbot's flow is fetched separately with `GET /chatbots/{chatbot_id}/flow`.
diff --git a/backend/app/api/docs/chatbot/get_flow.md b/backend/app/api/docs/chatbot/get_flow.md
new file mode 100644
index 000000000..04b998d42
--- /dev/null
+++ b/backend/app/api/docs/chatbot/get_flow.md
@@ -0,0 +1,5 @@
+Get a version of the chatbot's React Flow graph: `{id, chatbot_id, version, is_live, nodes, edges, inserted_at, updated_at}`.
+
+With `?version=N` that version is returned. Without it, the draft is returned if one exists (the highest version when it is not live), otherwise the live version. `nodes` and `edges` come back exactly as saved, with React Flow keys (`sourceHandle`, `targetHandle`, and UI-only keys such as `measured`). Agent nodes of a published version carry the pinned `data.config_version`.
+
+Returns 404 if the chatbot doesn't exist in this project, has no flow yet, or has no such version.
diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md
new file mode 100644
index 000000000..20591ec3e
--- /dev/null
+++ b/backend/app/api/docs/chatbot/get_latest_session.md
@@ -0,0 +1,5 @@
+Get the most recently created chatbot session for `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_id`.
+
+`contact_ref` is required and matched exactly. Omit `chatbot_id` to get the contact's newest session across the project's chatbots — the response's `chatbot_id` and `chatbot_name` say which one. Sessions of deleted chatbots are not returned.
+
+The response carries the pinned `flow_version`, the `active_node_id` and that node's `prompt`, the session `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created or the chatbot was deleted), its `completion_status` (`in_progress` until every field the prompt asks for is collected, then `complete`; `abandoned` if it was deactivated while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when no matching session exists in this project.
diff --git a/backend/app/api/docs/chatbot/list.md b/backend/app/api/docs/chatbot/list.md
new file mode 100644
index 000000000..b035041f2
--- /dev/null
+++ b/backend/app/api/docs/chatbot/list.md
@@ -0,0 +1,3 @@
+List the caller's project's chatbots, most recently updated first. Deleted chatbots are excluded.
+
+Paginate with `skip` (default 0) and `limit` (1–100, default 100).
diff --git a/backend/app/api/docs/chatbot/publish_flow.md b/backend/app/api/docs/chatbot/publish_flow.md
new file mode 100644
index 000000000..29079d486
--- /dev/null
+++ b/backend/app/api/docs/chatbot/publish_flow.md
@@ -0,0 +1,5 @@
+Publish the chatbot's draft so new sessions run on it. Returns the now-live version.
+
+The draft is validated strictly: node ids are unique; at most one `input` and one `output` node; every edge's `source` and `target` is a node in the flow; every agent node has `data.config_id` and a non-empty `data.prompt`, and its config resolves to an Anthropic text config in this project with a model set (and a valid thinking budget). The project must have Anthropic credentials configured, since agent nodes call Anthropic with the project's own key. Only `onboarding` agent nodes can run for now — `coordinator` and `rag` nodes are rejected as not supported yet. The flow needs an entry node: with no edges it must contain exactly one agent node; otherwise the `input` node must have exactly one outgoing edge, leading to an agent node. A node without `data.config_version` gets the config's latest version written into the stored node, so later config edits don't change the published flow.
+
+The previous live version (if any) stops being live in the same transaction. Sessions already running stay pinned to the version they started on. Returns 404 for an unknown chatbot, 409 if there is no draft to publish (save the flow first) or on a concurrent change, and 422 listing every validation problem.
diff --git a/backend/app/api/docs/chatbot/save_flow.md b/backend/app/api/docs/chatbot/save_flow.md
new file mode 100644
index 000000000..d940014cb
--- /dev/null
+++ b/backend/app/api/docs/chatbot/save_flow.md
@@ -0,0 +1,7 @@
+Save the chatbot's flow: `{nodes, edges}` in React Flow shape. Returns the saved version.
+
+The **first** save of a chatbot creates version 1 and makes it live immediately, so the chatbot can run without a separate publish. Because it goes live, it gets the same strict validation as publish (see the publish endpoint) and its missing `config_version`s are pinned to the latest; an invalid first flow returns 422 and nothing is saved.
+
+Every later save is a draft that needs a publish to go live: if the highest version is not live it is overwritten, otherwise a new version `max + 1` is created with `is_live: false`. Live versions are never modified, so editing never affects running conversations.
+
+Draft saves check only the shape, so half-built flows save: each node needs `id` (1–255 chars), `type` (`input`, `output`, `coordinator`, `onboarding`, `rag`), `position {x, y}` and an object `data`; agent nodes (`coordinator`, `onboarding`, `rag`) may carry `data.config_id` (UUID), `data.config_version` (≥ 1) and `data.prompt`. Each edge needs `id`, `source`, `target`; `sourceHandle`, `targetHandle` and `data` (e.g. `intent`, `description`) are optional. Extra keys are stored and returned as sent. Returns 404 for an unknown chatbot, 409 if the flow was changed concurrently (reload and retry), and 422 for an invalid first save.
diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md
new file mode 100644
index 000000000..e6d1034d0
--- /dev/null
+++ b/backend/app/api/docs/chatbot/send_session_message.md
@@ -0,0 +1,7 @@
+Queue one turn of a chatbot session; the bot reply is delivered asynchronously to `webhook_url`.
+
+The user `message` is stored immediately (tagged with the session's active node) and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "chatbot_id", "node_id", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model, config or flow failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead.
+
+The turn runs the session's active node of its pinned flow version: the node's `data.prompt` is the system prompt and its `data.config_id` / `data.config_version` supply the model. Each turn re-reads the conversation against that prompt and saves every field the user has given into `collected_fields`, a flat `{field_name: value}` map with snake_case keys (values are only ever taken from what the user said; a correction overwrites the old value). `completion_status` is `complete` once every required field is collected and `in_progress` otherwise; it is computed from a structured extraction, not from the reply text. A `complete` session still accepts messages. `status` (`active`/`inactive`) is separate and says whether the session is in use: once a newer session is created for the same contact and chatbot, or the chatbot is deleted, this one is `inactive` and rejects new messages with 409. A turn that was already queued when that happened is not run; Kaapi POSTs the failure payload with error `[KAAPI] Chatbot session was superseded by a newer session; start a new session.` instead.
+
+On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project, 409 if the session is inactive, and 422 for an invalid `webhook_url` or a missing `message` on a started session.
diff --git a/backend/app/api/docs/chatbot/update.md b/backend/app/api/docs/chatbot/update.md
new file mode 100644
index 000000000..6b8642a35
--- /dev/null
+++ b/backend/app/api/docs/chatbot/update.md
@@ -0,0 +1,3 @@
+Rename a chatbot. Only `name` can change; omitting it leaves the chatbot unchanged.
+
+Returns 404 if the chatbot doesn't exist in this project or was deleted.
diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py
index 317f05d28..9c9adf40e 100644
--- a/backend/app/api/docs/openapi_config.py
+++ b/backend/app/api/docs/openapi_config.py
@@ -8,9 +8,6 @@
from app.api.docs.guardrails_schemas import GUARDRAILS_COMPONENT_SCHEMAS
from app.api.openapi_docs import inject_component_schemas, register_component_schemas
-# Schemas for routes that document a contract they do not validate (the
-# guardrails proxy). Registered at import so they are in `components` before
-# any `$ref` to them is resolved.
register_component_schemas(GUARDRAILS_COMPONENT_SCHEMAS)
# Tag metadata for organizing endpoints in documentation
@@ -35,6 +32,10 @@
"name": "LLM",
"description": "Large Language Model inference and interaction endpoints",
},
+ {
+ "name": "Chatbot",
+ "description": "Chatbots, their versioned React Flow graphs, and conversation sessions",
+ },
{
"name": "Guardrails",
"description": (
@@ -104,6 +105,7 @@
"Collections",
"Config Management",
"LLM",
+ "Chatbot",
"Guardrails",
"Evaluation",
"Fine Tuning",
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index a796c58b6..c181ddf2c 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -5,6 +5,7 @@
api_keys,
assistants,
auth,
+ chatbot,
collection_job,
collections,
config,
@@ -47,6 +48,7 @@
api_router.include_router(assessment_api_routes.router)
api_router.include_router(assistants.router)
api_router.include_router(auth.router)
+api_router.include_router(chatbot.router)
api_router.include_router(collection_job.router)
api_router.include_router(collections.router)
api_router.include_router(config.router)
diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py
new file mode 100644
index 000000000..47f1c4ff3
--- /dev/null
+++ b/backend/app/api/routes/chatbot/__init__.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Depends
+
+from app.api.permissions import require_feature
+from app.api.routes.chatbot.chatbot import router as chatbot_router
+from app.api.routes.chatbot.flow import router as flow_router
+from app.api.routes.chatbot.session import router as session_router
+from app.core.feature_flags import FeatureFlag
+
+CHATBOTS_PREFIX = "/chatbots"
+
+router = APIRouter(
+ tags=["Chatbot"],
+ dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))],
+)
+router.include_router(session_router, prefix=CHATBOTS_PREFIX)
+router.include_router(chatbot_router, prefix=CHATBOTS_PREFIX)
+router.include_router(flow_router, prefix=CHATBOTS_PREFIX)
diff --git a/backend/app/api/routes/chatbot/chatbot.py b/backend/app/api/routes/chatbot/chatbot.py
new file mode 100644
index 000000000..5727748c8
--- /dev/null
+++ b/backend/app/api/routes/chatbot/chatbot.py
@@ -0,0 +1,131 @@
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Path, Query
+
+from app.api.deps import AuthContextDep, SessionDep
+from app.api.permissions import Permission, require_permission
+from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception
+from app.crud.chatbot import (
+ create_chatbot,
+ delete_chatbot,
+ list_chatbots,
+ update_chatbot,
+)
+from app.models import Chatbot, ChatbotCreate, ChatbotPublic, ChatbotUpdate, Message
+from app.services.chatbot.flow import get_chatbot_or_raise
+from app.utils import APIResponse, load_description
+
+CHATBOT_DELETED_MESSAGE = "Chatbot deleted successfully"
+
+router = APIRouter()
+
+
+def _get_chatbot_or_404(
+ session: SessionDep, auth_context: AuthContextDep, chatbot_id: UUID
+) -> Chatbot:
+ try:
+ return get_chatbot_or_raise(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ )
+ except CHATBOT_SERVICE_ERRORS as e:
+ raise to_http_exception(e) from e
+
+
+@router.post(
+ "",
+ status_code=201,
+ description=load_description("chatbot/create.md"),
+ response_model=APIResponse[ChatbotPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def create_chatbot_route(
+ request: ChatbotCreate,
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[ChatbotPublic]:
+ chatbot = create_chatbot(
+ session,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ name=request.name,
+ )
+ return APIResponse.success_response(ChatbotPublic.model_validate(chatbot))
+
+
+@router.get(
+ "",
+ description=load_description("chatbot/list.md"),
+ response_model=APIResponse[list[ChatbotPublic]],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def list_chatbots_route(
+ session: SessionDep,
+ auth_context: AuthContextDep,
+ skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0,
+ limit: Annotated[
+ int, Query(ge=1, le=100, description="Maximum records to return")
+ ] = 100,
+) -> APIResponse[list[ChatbotPublic]]:
+ chatbots = list_chatbots(
+ session,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ skip=skip,
+ limit=limit,
+ )
+ chatbots_public: list[ChatbotPublic] = []
+ for chatbot in chatbots:
+ chatbots_public.append(ChatbotPublic.model_validate(chatbot))
+ return APIResponse.success_response(chatbots_public)
+
+
+@router.get(
+ "/{chatbot_id}",
+ description=load_description("chatbot/get.md"),
+ response_model=APIResponse[ChatbotPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def get_chatbot_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[ChatbotPublic]:
+ chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id)
+ return APIResponse.success_response(ChatbotPublic.model_validate(chatbot))
+
+
+@router.patch(
+ "/{chatbot_id}",
+ description=load_description("chatbot/update.md"),
+ response_model=APIResponse[ChatbotPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def update_chatbot_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ request: ChatbotUpdate,
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[ChatbotPublic]:
+ chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id)
+ chatbot = update_chatbot(session, chatbot=chatbot, chatbot_update=request)
+ return APIResponse.success_response(ChatbotPublic.model_validate(chatbot))
+
+
+@router.delete(
+ "/{chatbot_id}",
+ description=load_description("chatbot/delete.md"),
+ response_model=APIResponse[Message],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def delete_chatbot_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[Message]:
+ chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id)
+ delete_chatbot(session, chatbot=chatbot)
+ return APIResponse.success_response(Message(message=CHATBOT_DELETED_MESSAGE))
diff --git a/backend/app/api/routes/chatbot/errors.py b/backend/app/api/routes/chatbot/errors.py
new file mode 100644
index 000000000..467bdbad5
--- /dev/null
+++ b/backend/app/api/routes/chatbot/errors.py
@@ -0,0 +1,28 @@
+from fastapi import HTTPException
+
+from app.services.chatbot.exceptions import (
+ ChatbotFlowConflictError,
+ ChatbotFlowNotFoundError,
+ ChatbotFlowValidationError,
+ ChatbotNotFoundError,
+ ChatbotSessionConflictError,
+)
+
+_STATUS_BY_CHATBOT_ERROR: dict[type[Exception], int] = {
+ ChatbotNotFoundError: 404,
+ ChatbotFlowNotFoundError: 404,
+ ChatbotFlowConflictError: 409,
+ ChatbotSessionConflictError: 409,
+ ChatbotFlowValidationError: 422,
+}
+CHATBOT_SERVICE_ERRORS: tuple[type[Exception], ...] = tuple(_STATUS_BY_CHATBOT_ERROR)
+
+
+def to_http_exception(error: Exception) -> HTTPException:
+ """Map a chatbot service error to the HTTP status every chatbot route uses for it."""
+ for error_type, status_code in _STATUS_BY_CHATBOT_ERROR.items():
+ if isinstance(error, error_type):
+ return HTTPException(status_code=status_code, detail=str(error))
+ raise TypeError(
+ f"[to_http_exception] Unmapped chatbot error: {type(error).__name__}"
+ )
diff --git a/backend/app/api/routes/chatbot/flow.py b/backend/app/api/routes/chatbot/flow.py
new file mode 100644
index 000000000..5dafaf715
--- /dev/null
+++ b/backend/app/api/routes/chatbot/flow.py
@@ -0,0 +1,97 @@
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Path, Query
+
+from app.api.deps import AuthContextDep, SessionDep
+from app.api.permissions import Permission, require_permission
+from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception
+from app.models import ChatbotFlowUpdate, ChatbotFlowVersionPublic
+from app.services.chatbot.flow import (
+ build_chatbot_flow_version_public,
+ get_chatbot_flow,
+ publish_chatbot_flow,
+ save_chatbot_flow_draft,
+)
+from app.utils import APIResponse, load_description
+
+router = APIRouter()
+
+
+@router.get(
+ "/{chatbot_id}/flow",
+ description=load_description("chatbot/get_flow.md"),
+ response_model=APIResponse[ChatbotFlowVersionPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def get_chatbot_flow_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ session: SessionDep,
+ auth_context: AuthContextDep,
+ version: Annotated[
+ int | None,
+ Query(
+ ge=1,
+ description="Flow version to fetch; omit to get the draft if one exists, else the live version",
+ ),
+ ] = None,
+) -> APIResponse[ChatbotFlowVersionPublic]:
+ try:
+ flow_version = get_chatbot_flow(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ version=version,
+ )
+ except CHATBOT_SERVICE_ERRORS as e:
+ raise to_http_exception(e) from e
+ return APIResponse.success_response(build_chatbot_flow_version_public(flow_version))
+
+
+@router.put(
+ "/{chatbot_id}/flow",
+ description=load_description("chatbot/save_flow.md"),
+ response_model=APIResponse[ChatbotFlowVersionPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def save_chatbot_flow_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ request: ChatbotFlowUpdate,
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[ChatbotFlowVersionPublic]:
+ try:
+ flow_version = save_chatbot_flow_draft(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ flow=request,
+ )
+ except CHATBOT_SERVICE_ERRORS as e:
+ raise to_http_exception(e) from e
+ return APIResponse.success_response(build_chatbot_flow_version_public(flow_version))
+
+
+@router.post(
+ "/{chatbot_id}/flow/publish",
+ description=load_description("chatbot/publish_flow.md"),
+ response_model=APIResponse[ChatbotFlowVersionPublic],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def publish_chatbot_flow_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[ChatbotFlowVersionPublic]:
+ try:
+ flow_version = publish_chatbot_flow(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ )
+ except CHATBOT_SERVICE_ERRORS as e:
+ raise to_http_exception(e) from e
+ return APIResponse.success_response(build_chatbot_flow_version_public(flow_version))
diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
new file mode 100644
index 000000000..713e561f1
--- /dev/null
+++ b/backend/app/api/routes/chatbot/session.py
@@ -0,0 +1,167 @@
+from typing import Annotated
+from uuid import UUID
+
+from asgi_correlation_id import correlation_id
+from fastapi import APIRouter, Depends, HTTPException, Path, Query
+
+from app.api.deps import AuthContextDep, SessionDep
+from app.api.permissions import Permission, require_permission
+from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception
+from app.celery.utils import start_chatbot_session_message
+from app.crud.chatbot import (
+ create_chatbot_session_message,
+ get_chatbot_session,
+ list_chatbot_session_messages,
+)
+from app.models import (
+ CONTACT_REF_MAX_LENGTH,
+ ChatbotMessageRoleEnum,
+ ChatbotSessionStatusEnum,
+ ChatbotSessionSummary,
+ CreateChatbotSessionRequest,
+ CreateChatbotSessionResponse,
+ SendChatbotSessionMessageRequest,
+ SendChatbotSessionMessageResponse,
+)
+from app.services.chatbot.session import (
+ get_latest_chatbot_session_summary,
+ start_chatbot_session,
+)
+from app.utils import APIResponse, load_description, validate_callback_url
+
+DEFAULT_TRACE_ID = "N/A"
+
+router = APIRouter()
+
+
+@router.get(
+ "/sessions/latest",
+ description=load_description("chatbot/get_latest_session.md"),
+ response_model=APIResponse[ChatbotSessionSummary],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def read_latest_chatbot_session(
+ contact_ref: Annotated[
+ str,
+ Query(
+ min_length=1,
+ max_length=CONTACT_REF_MAX_LENGTH,
+ description="Caller-side identifier of the end user whose latest session to fetch",
+ ),
+ ],
+ session: SessionDep,
+ auth_context: AuthContextDep,
+ chatbot_id: Annotated[
+ UUID | None,
+ Query(
+ description="Chatbot whose latest session to fetch; omit to get the contact's newest session across the project's chatbots",
+ ),
+ ] = None,
+) -> APIResponse[ChatbotSessionSummary]:
+ summary = get_latest_chatbot_session_summary(
+ session=session,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ contact_ref=contact_ref,
+ chatbot_id=chatbot_id,
+ )
+ if summary is None:
+ raise HTTPException(
+ status_code=404,
+ detail="No chatbot session found for this contact_ref"
+ + (" and chatbot_id" if chatbot_id is not None else ""),
+ )
+ return APIResponse.success_response(summary)
+
+
+@router.post(
+ "/sessions/{session_id}/message",
+ status_code=202,
+ description=load_description("chatbot/send_session_message.md"),
+ response_model=APIResponse[SendChatbotSessionMessageResponse],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def send_chatbot_session_message(
+ session_id: Annotated[UUID, Path(description="Chatbot session identifier")],
+ request: SendChatbotSessionMessageRequest,
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[SendChatbotSessionMessageResponse]:
+ organization_id = auth_context.organization_.id
+ project_id = auth_context.project_.id
+
+ try:
+ validate_callback_url(request.webhook_url)
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=f"Invalid webhook_url: {e}")
+
+ chatbot_session = get_chatbot_session(
+ session,
+ session_id=session_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ )
+ if chatbot_session is None:
+ raise HTTPException(status_code=404, detail="Chatbot session not found")
+ if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE:
+ raise HTTPException(
+ status_code=409,
+ detail="Chatbot session is inactive; start a new session",
+ )
+
+ if request.message is None:
+ if list_chatbot_session_messages(session, session_id=session_id):
+ raise HTTPException(
+ status_code=422,
+ detail="`message` is required once the session has started; omit it only on the first call.",
+ )
+ else:
+ create_chatbot_session_message(
+ session,
+ session_id=session_id,
+ role=ChatbotMessageRoleEnum.USER,
+ content=request.message,
+ node_id=chatbot_session.active_node_id,
+ )
+
+ start_chatbot_session_message(
+ session_id=str(session_id),
+ organization_id=organization_id,
+ project_id=project_id,
+ webhook_url=request.webhook_url,
+ trace_id=correlation_id.get() or DEFAULT_TRACE_ID,
+ )
+ return APIResponse.success_response(SendChatbotSessionMessageResponse())
+
+
+@router.post(
+ "/{chatbot_id}/sessions",
+ status_code=201,
+ description=load_description("chatbot/create_session.md"),
+ response_model=APIResponse[CreateChatbotSessionResponse],
+ dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
+)
+def create_chatbot_session_route(
+ chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")],
+ request: CreateChatbotSessionRequest,
+ session: SessionDep,
+ auth_context: AuthContextDep,
+) -> APIResponse[CreateChatbotSessionResponse]:
+ try:
+ chatbot_session, flow_version = start_chatbot_session(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=auth_context.organization_.id,
+ project_id=auth_context.project_.id,
+ contact_ref=request.contact_ref,
+ )
+ except CHATBOT_SERVICE_ERRORS as e:
+ raise to_http_exception(e) from e
+ return APIResponse.success_response(
+ CreateChatbotSessionResponse(
+ session_id=chatbot_session.id,
+ chatbot_id=chatbot_session.chatbot_id,
+ flow_version=flow_version.version,
+ active_node_id=chatbot_session.active_node_id,
+ )
+ )
diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py
index e8cf6bacb..b7c7de8f8 100644
--- a/backend/app/celery/tasks/job_execution.py
+++ b/backend/app/celery/tasks/job_execution.py
@@ -4,7 +4,7 @@
per-task `priority`:
9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job,
- run_guardrails_job)
+ run_guardrails_job, run_chatbot_session_message)
6 Prompt improvement + iteration graph (run_prompt_improvement,
run_evaluation_iteration_graph_step)
2 Everything else (doctransform, collections, STT/TTS evaluation, assessment)
@@ -24,6 +24,7 @@
import logging
from collections.abc import Callable
from typing import TYPE_CHECKING, TypeVar
+from uuid import UUID
from asgi_correlation_id import correlation_id
from celery import Task, current_task
@@ -576,3 +577,29 @@ def send_eval_completion_callback(
)
return execute_eval_completion_callback(evaluation_id=evaluation_id)
+
+
+@celery_app.task(bind=True, queue="default", priority=9)
+@gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_chatbot_session_message")
+def run_chatbot_session_message(
+ self: Task,
+ session_id: str,
+ organization_id: int,
+ project_id: int,
+ webhook_url: str,
+ trace_id: str,
+) -> None:
+ """Idempotency: the service skips the turn if the transcript already ends
+ on an assistant reply, so a redelivery doesn't re-bill or double-reply."""
+ from app.services.chatbot.session import process_chatbot_session_message
+
+ _set_trace(trace_id)
+ return _run_with_otel_parent(
+ self,
+ lambda: process_chatbot_session_message(
+ session_id=UUID(session_id),
+ organization_id=organization_id,
+ project_id=project_id,
+ webhook_url=webhook_url,
+ ),
+ )
diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py
index 3bd205d22..a7b31f9e4 100644
--- a/backend/app/celery/utils.py
+++ b/backend/app/celery/utils.py
@@ -337,6 +337,26 @@ def start_evaluation_iteration_round(
return task_id
+def start_chatbot_session_message(
+ session_id: str,
+ organization_id: int,
+ project_id: int,
+ webhook_url: str,
+ trace_id: str = "N/A",
+) -> str:
+ from app.celery.tasks.job_execution import run_chatbot_session_message
+
+ task_id = _enqueue_with_trace_context(
+ run_chatbot_session_message,
+ session_id=session_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ webhook_url=webhook_url,
+ trace_id=trace_id,
+ )
+ return task_id
+
+
def get_task_status(task_id: str) -> Dict[str, Any]:
result = AsyncResult(task_id)
return {
diff --git a/backend/app/core/feature_flags/constants.py b/backend/app/core/feature_flags/constants.py
index 0ecf470e4..7538f814b 100644
--- a/backend/app/core/feature_flags/constants.py
+++ b/backend/app/core/feature_flags/constants.py
@@ -3,3 +3,4 @@
class FeatureFlag(StrEnum):
ASSESSMENT = "ASSESSMENT"
+ CHATBOT = "CHATBOT"
diff --git a/backend/app/core/util.py b/backend/app/core/util.py
index 25c06d909..c7470edae 100644
--- a/backend/app/core/util.py
+++ b/backend/app/core/util.py
@@ -4,6 +4,7 @@
from fastapi import HTTPException
from openai import OpenAI
+from sqlalchemy.exc import IntegrityError
logger = logging.getLogger(__name__)
@@ -12,6 +13,14 @@ def now():
return datetime.now(timezone.utc).replace(tzinfo=None)
+def is_constraint_violation(error: IntegrityError, constraint_name: str) -> bool:
+ """True only when *error* was raised by the named constraint or index."""
+ violated = getattr(getattr(error.orig, "diag", None), "constraint_name", None)
+ if violated:
+ return violated == constraint_name
+ return constraint_name in str(error.orig or error)
+
+
def raise_from_unknown(error: Exception, status_code=500):
logger.warning(
'Unexpected exception "{}": {}'.format(
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
new file mode 100644
index 000000000..7ab7e93b2
--- /dev/null
+++ b/backend/app/crud/chatbot.py
@@ -0,0 +1,417 @@
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from sqlalchemy import ColumnElement, update
+from sqlalchemy.exc import IntegrityError
+from sqlmodel import Session, col, select
+
+from app.core.util import is_constraint_violation, now
+from app.models import (
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
+ CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT,
+ LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX,
+ Chatbot,
+ ChatbotCompletionStatusEnum,
+ ChatbotFlowVersion,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
+ ChatbotUpdate,
+)
+from app.services.chatbot.exceptions import (
+ CONCURRENT_FLOW_CONFLICT_MESSAGE,
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotFlowConflictError,
+ ChatbotSessionConflictError,
+)
+
+
+def create_chatbot(
+ session: Session, *, organization_id: int, project_id: int, name: str
+) -> Chatbot:
+ timestamp = now()
+ chatbot = Chatbot(
+ organization_id=organization_id,
+ project_id=project_id,
+ name=name,
+ inserted_at=timestamp,
+ updated_at=timestamp,
+ )
+ session.add(chatbot)
+ session.commit()
+ session.refresh(chatbot)
+ return chatbot
+
+
+def get_chatbot(
+ session: Session,
+ *,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+ for_update: bool = False,
+) -> Chatbot | None:
+ """A non-deleted chatbot in the project; `for_update` row-locks it to serialize flow writes."""
+ statement = select(Chatbot).where(
+ Chatbot.id == chatbot_id,
+ Chatbot.organization_id == organization_id,
+ Chatbot.project_id == project_id,
+ col(Chatbot.deleted_at).is_(None),
+ )
+ if for_update:
+ statement = statement.with_for_update()
+ return session.exec(statement).first()
+
+
+def list_chatbots(
+ session: Session,
+ *,
+ organization_id: int,
+ project_id: int,
+ skip: int,
+ limit: int,
+) -> list[Chatbot]:
+ statement = (
+ select(Chatbot)
+ .where(
+ Chatbot.organization_id == organization_id,
+ Chatbot.project_id == project_id,
+ col(Chatbot.deleted_at).is_(None),
+ )
+ .order_by(col(Chatbot.updated_at).desc())
+ .offset(skip)
+ .limit(limit)
+ )
+ return list(session.exec(statement).all())
+
+
+def update_chatbot(
+ session: Session, *, chatbot: Chatbot, chatbot_update: ChatbotUpdate
+) -> Chatbot:
+ update_data = chatbot_update.model_dump(exclude_unset=True, exclude_none=True)
+ update_data["updated_at"] = now()
+ chatbot.sqlmodel_update(update_data)
+ session.add(chatbot)
+ session.commit()
+ session.refresh(chatbot)
+ return chatbot
+
+
+def delete_chatbot(session: Session, *, chatbot: Chatbot) -> Chatbot:
+ """Soft-delete the chatbot and deactivate its active sessions in the same transaction."""
+ timestamp = now()
+ _deactivate_active_chatbot_sessions(
+ session, chatbot_id=chatbot.id, contact_ref=None, timestamp=timestamp
+ )
+ chatbot.sqlmodel_update({"deleted_at": timestamp, "updated_at": timestamp})
+ session.add(chatbot)
+ session.commit()
+ session.refresh(chatbot)
+ return chatbot
+
+
+def get_latest_chatbot_flow_version(
+ session: Session, *, chatbot_id: UUID
+) -> ChatbotFlowVersion | None:
+ """The highest version: the draft when it isn't live, otherwise the live one."""
+ statement = (
+ select(ChatbotFlowVersion)
+ .where(ChatbotFlowVersion.chatbot_id == chatbot_id)
+ .order_by(col(ChatbotFlowVersion.version).desc())
+ .limit(1)
+ )
+ return session.exec(statement).first()
+
+
+def get_live_chatbot_flow_version(
+ session: Session, *, chatbot_id: UUID
+) -> ChatbotFlowVersion | None:
+ statement = select(ChatbotFlowVersion).where(
+ ChatbotFlowVersion.chatbot_id == chatbot_id,
+ col(ChatbotFlowVersion.is_live).is_(True),
+ )
+ return session.exec(statement).first()
+
+
+def get_chatbot_flow_version(
+ session: Session, *, chatbot_id: UUID, version: int
+) -> ChatbotFlowVersion | None:
+ statement = select(ChatbotFlowVersion).where(
+ ChatbotFlowVersion.chatbot_id == chatbot_id,
+ ChatbotFlowVersion.version == version,
+ )
+ return session.exec(statement).first()
+
+
+def get_chatbot_flow_version_by_id(
+ session: Session, *, flow_version_id: UUID
+) -> ChatbotFlowVersion | None:
+ return session.get(ChatbotFlowVersion, flow_version_id)
+
+
+def create_chatbot_flow_version(
+ session: Session,
+ *,
+ chatbot_id: UUID,
+ version: int,
+ nodes: list[dict[str, Any]],
+ edges: list[dict[str, Any]],
+ is_live: bool = False,
+) -> ChatbotFlowVersion:
+ timestamp = now()
+ flow_version = ChatbotFlowVersion(
+ chatbot_id=chatbot_id,
+ version=version,
+ nodes=nodes,
+ edges=edges,
+ is_live=is_live,
+ inserted_at=timestamp,
+ updated_at=timestamp,
+ )
+ session.add(flow_version)
+ try:
+ session.commit()
+ except IntegrityError as e:
+ session.rollback()
+ if not is_constraint_violation(e, CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT):
+ raise
+ raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e
+ session.refresh(flow_version)
+ return flow_version
+
+
+def update_chatbot_flow_version_graph(
+ session: Session,
+ *,
+ flow_version: ChatbotFlowVersion,
+ nodes: list[dict[str, Any]],
+ edges: list[dict[str, Any]],
+) -> ChatbotFlowVersion:
+ flow_version.sqlmodel_update({"nodes": nodes, "edges": edges, "updated_at": now()})
+ session.add(flow_version)
+ session.commit()
+ session.refresh(flow_version)
+ return flow_version
+
+
+def publish_chatbot_flow_version(
+ session: Session,
+ *,
+ flow_version: ChatbotFlowVersion,
+ nodes: list[dict[str, Any]],
+) -> ChatbotFlowVersion:
+ """Make `flow_version` the live one (storing its pinned `nodes`) in one transaction."""
+ timestamp = now()
+ # Must hit the DB before the draft is marked live, or the partial unique
+ # index on (chatbot_id) WHERE is_live sees two live rows.
+ unset_previous_live = (
+ update(ChatbotFlowVersion)
+ .where(
+ col(ChatbotFlowVersion.chatbot_id) == flow_version.chatbot_id,
+ col(ChatbotFlowVersion.is_live).is_(True),
+ )
+ .values(is_live=False, updated_at=timestamp)
+ )
+ session.exec(unset_previous_live)
+
+ flow_version.sqlmodel_update(
+ {"nodes": nodes, "is_live": True, "updated_at": timestamp}
+ )
+ session.add(flow_version)
+ try:
+ session.commit()
+ except IntegrityError as e:
+ session.rollback()
+ if not is_constraint_violation(e, LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX):
+ raise
+ raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e
+ session.refresh(flow_version)
+ return flow_version
+
+
+def _deactivate_active_chatbot_sessions(
+ session: Session,
+ *,
+ chatbot_id: UUID,
+ contact_ref: str | None,
+ timestamp: datetime,
+) -> int:
+ """Mark active sessions of the chatbot (one contact's, or all when `contact_ref` is None) inactive; does not commit."""
+ scope: list[ColumnElement[bool]] = [
+ col(ChatbotSession.chatbot_id) == chatbot_id,
+ col(ChatbotSession.status) == ChatbotSessionStatusEnum.ACTIVE,
+ ]
+ if contact_ref is not None:
+ scope.append(col(ChatbotSession.contact_ref) == contact_ref)
+
+ # Runs first: once the second statement flips status, these rows no longer match.
+ abandon_in_progress = (
+ update(ChatbotSession)
+ .where(
+ *scope,
+ col(ChatbotSession.completion_status)
+ == ChatbotCompletionStatusEnum.IN_PROGRESS,
+ )
+ .values(
+ status=ChatbotSessionStatusEnum.INACTIVE,
+ completion_status=ChatbotCompletionStatusEnum.ABANDONED,
+ updated_at=timestamp,
+ )
+ )
+ abandoned_count = session.exec(abandon_in_progress).rowcount
+
+ deactivate_remaining = (
+ update(ChatbotSession)
+ .where(*scope)
+ .values(status=ChatbotSessionStatusEnum.INACTIVE, updated_at=timestamp)
+ )
+ deactivated_count = session.exec(deactivate_remaining).rowcount
+
+ return abandoned_count + deactivated_count
+
+
+def create_chatbot_session(
+ session: Session,
+ *,
+ chatbot_id: UUID,
+ flow_version_id: UUID,
+ contact_ref: str,
+ active_node_id: str,
+) -> ChatbotSession:
+ """Insert a new active session, superseding the contact's active ones for the same chatbot."""
+ timestamp = now()
+ _deactivate_active_chatbot_sessions(
+ session,
+ chatbot_id=chatbot_id,
+ contact_ref=contact_ref,
+ timestamp=timestamp,
+ )
+ chatbot_session = ChatbotSession(
+ chatbot_id=chatbot_id,
+ flow_version_id=flow_version_id,
+ contact_ref=contact_ref,
+ active_node_id=active_node_id,
+ inserted_at=timestamp,
+ updated_at=timestamp,
+ )
+ session.add(chatbot_session)
+ try:
+ session.commit()
+ except IntegrityError as e:
+ session.rollback()
+ if not is_constraint_violation(e, ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX):
+ raise
+ raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e
+ session.refresh(chatbot_session)
+ return chatbot_session
+
+
+def get_chatbot_session(
+ session: Session,
+ *,
+ session_id: UUID,
+ organization_id: int,
+ project_id: int,
+) -> ChatbotSession | None:
+ """Scoped through the owning chatbot; a deleted chatbot's sessions still resolve (they are inactive)."""
+ statement = (
+ select(ChatbotSession)
+ .join(Chatbot, col(Chatbot.id) == col(ChatbotSession.chatbot_id))
+ .where(
+ ChatbotSession.id == session_id,
+ Chatbot.organization_id == organization_id,
+ Chatbot.project_id == project_id,
+ )
+ )
+ return session.exec(statement).first()
+
+
+def get_latest_chatbot_session(
+ session: Session,
+ *,
+ organization_id: int,
+ project_id: int,
+ contact_ref: str,
+ chatbot_id: UUID | None = None,
+) -> tuple[ChatbotSession, Chatbot, ChatbotFlowVersion] | None:
+ """Newest session of the contact across the project's non-deleted chatbots, or one chatbot's."""
+ statement = (
+ select(ChatbotSession, Chatbot, ChatbotFlowVersion)
+ .join(Chatbot, col(Chatbot.id) == col(ChatbotSession.chatbot_id))
+ .join(
+ ChatbotFlowVersion,
+ col(ChatbotFlowVersion.id) == col(ChatbotSession.flow_version_id),
+ )
+ .where(
+ ChatbotSession.contact_ref == contact_ref,
+ Chatbot.organization_id == organization_id,
+ Chatbot.project_id == project_id,
+ col(Chatbot.deleted_at).is_(None),
+ )
+ )
+ if chatbot_id is not None:
+ statement = statement.where(ChatbotSession.chatbot_id == chatbot_id)
+ statement = statement.order_by(col(ChatbotSession.inserted_at).desc()).limit(1)
+ row = session.exec(statement).first()
+ if row is None:
+ return None
+ chatbot_session, chatbot, flow_version = row
+ return chatbot_session, chatbot, flow_version
+
+
+def update_chatbot_session_progress(
+ session: Session,
+ *,
+ session_id: UUID,
+ collected_fields: dict[str, Any],
+ completion_status: ChatbotCompletionStatusEnum,
+) -> ChatbotSession | None:
+ chatbot_session = session.get(ChatbotSession, session_id)
+ if chatbot_session is None:
+ return None
+
+ chatbot_session.sqlmodel_update(
+ {
+ "collected_fields": collected_fields,
+ "completion_status": completion_status,
+ "updated_at": now(),
+ }
+ )
+ session.add(chatbot_session)
+ session.commit()
+ session.refresh(chatbot_session)
+ return chatbot_session
+
+
+def create_chatbot_session_message(
+ session: Session,
+ *,
+ session_id: UUID,
+ role: ChatbotMessageRoleEnum,
+ content: str,
+ node_id: str | None,
+) -> ChatbotSessionMessage:
+ message = ChatbotSessionMessage(
+ session_id=session_id,
+ role=role,
+ content=content,
+ node_id=node_id,
+ inserted_at=now(),
+ )
+ session.add(message)
+ session.commit()
+ session.refresh(message)
+ return message
+
+
+def list_chatbot_session_messages(
+ session: Session, *, session_id: UUID
+) -> list[ChatbotSessionMessage]:
+ statement = (
+ select(ChatbotSessionMessage)
+ .where(ChatbotSessionMessage.session_id == session_id)
+ .order_by(col(ChatbotSessionMessage.inserted_at).asc())
+ )
+ return list(session.exec(statement).all())
diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py
index 313a4e27c..85be0ed64 100644
--- a/backend/app/crud/config/version.py
+++ b/backend/app/crud/config/version.py
@@ -213,6 +213,11 @@ def read_one(self, version_number: int) -> ConfigVersion | None:
)
return self.session.exec(statement).one_or_none()
+ def read_latest(self) -> ConfigVersion | None:
+ """Read the highest non-deleted version of the configuration."""
+ self._config_exists_or_raise(self.config_id)
+ return self._get_latest_version()
+
def read_all(self, skip: int = 0, limit: int = 100) -> list[ConfigVersionItems]:
"""
Read all versions for a specific configuration with pagination.
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 8a1559573..bea3419eb 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -35,6 +35,37 @@
BatchJobType,
BatchJobUpdate,
)
+from .chatbot import (
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
+ AGENT_NODE_TYPES,
+ CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT,
+ CHATBOT_NAME_MAX_LENGTH,
+ CONTACT_REF_MAX_LENGTH,
+ FLOW_NODE_ID_MAX_LENGTH,
+ LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX,
+ Chatbot,
+ ChatbotAgentNodeData,
+ ChatbotCompletionStatusEnum,
+ ChatbotCreate,
+ ChatbotFlowEdge,
+ ChatbotFlowNode,
+ ChatbotFlowNodeTypeEnum,
+ ChatbotFlowPosition,
+ ChatbotFlowUpdate,
+ ChatbotFlowVersion,
+ ChatbotFlowVersionPublic,
+ ChatbotMessageRoleEnum,
+ ChatbotPublic,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
+ ChatbotSessionSummary,
+ ChatbotUpdate,
+ CreateChatbotSessionRequest,
+ CreateChatbotSessionResponse,
+ SendChatbotSessionMessageRequest,
+ SendChatbotSessionMessageResponse,
+)
from .collection import (
Collection,
CollectionIDPublic,
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
new file mode 100644
index 000000000..4c1259646
--- /dev/null
+++ b/backend/app/models/chatbot.py
@@ -0,0 +1,466 @@
+from datetime import datetime
+from enum import StrEnum
+from typing import Any
+from uuid import UUID, uuid4
+
+from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
+from pydantic import Field as PydanticField
+from sqlalchemy import JSON, Column, Index, UniqueConstraint, text
+from sqlalchemy.dialects.postgresql import ENUM, JSONB
+from sqlmodel import Field, SQLModel
+
+from app.core.util import now
+
+
+class ChatbotMessageRoleEnum(StrEnum):
+ SYSTEM = "system"
+ USER = "user"
+ ASSISTANT = "assistant"
+
+
+class ChatbotSessionStatusEnum(StrEnum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+
+
+class ChatbotCompletionStatusEnum(StrEnum):
+ IN_PROGRESS = "in_progress"
+ COMPLETE = "complete"
+ ABANDONED = "abandoned"
+
+
+class ChatbotFlowNodeTypeEnum(StrEnum):
+ INPUT = "input"
+ OUTPUT = "output"
+ COORDINATOR = "coordinator"
+ ONBOARDING = "onboarding"
+ RAG = "rag"
+
+
+AGENT_NODE_TYPES: frozenset[ChatbotFlowNodeTypeEnum] = frozenset(
+ {
+ ChatbotFlowNodeTypeEnum.COORDINATOR,
+ ChatbotFlowNodeTypeEnum.ONBOARDING,
+ ChatbotFlowNodeTypeEnum.RAG,
+ }
+)
+
+_SESSION_STATUS_PG_ENUM = ENUM(
+ ChatbotSessionStatusEnum,
+ name="chatbotsessionstatusenum",
+ values_callable=lambda enum_cls: [member.value for member in enum_cls],
+ create_type=False,
+)
+_COMPLETION_STATUS_PG_ENUM = ENUM(
+ ChatbotCompletionStatusEnum,
+ name="chatbotcompletionstatusenum",
+ values_callable=lambda enum_cls: [member.value for member in enum_cls],
+ create_type=False,
+)
+_MESSAGE_ROLE_PG_ENUM = ENUM(
+ ChatbotMessageRoleEnum,
+ name="chatbotmessageroleenum",
+ values_callable=lambda enum_cls: [member.value for member in enum_cls],
+ create_type=False,
+)
+
+CONTACT_REF_MAX_LENGTH = 500
+CHATBOT_NAME_MAX_LENGTH = 255
+FLOW_NODE_ID_MAX_LENGTH = 255
+ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX = "uq_chatbot_session_active_chatbot_contact"
+LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX = "uq_chatbot_flow_version_live_chatbot"
+CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT = "uq_chatbot_flow_version_chatbot_version"
+
+
+class ChatbotFlowPosition(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+ x: float
+ y: float
+
+
+class ChatbotAgentNodeData(BaseModel):
+ """`data` of a coordinator/onboarding/rag node; UI-only keys (labels) pass through."""
+
+ model_config = ConfigDict(extra="allow")
+
+ config_id: UUID | None = None
+ config_version: int | None = PydanticField(default=None, ge=1)
+ prompt: str | None = None
+
+
+class ChatbotFlowNode(BaseModel):
+ """React Flow node, stored as the UI sent it (extra keys such as `measured` round-trip)."""
+
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
+
+ id: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH)
+ type: ChatbotFlowNodeTypeEnum
+ position: ChatbotFlowPosition
+ data: dict[str, Any] = PydanticField(default_factory=dict)
+
+ def agent_data(self) -> ChatbotAgentNodeData:
+ return ChatbotAgentNodeData.model_validate(self.data)
+
+ @model_validator(mode="after")
+ def _validate_agent_data(self) -> "ChatbotFlowNode":
+ if self.type not in AGENT_NODE_TYPES:
+ return self
+ try:
+ self.agent_data()
+ except ValidationError as e:
+ raise ValueError(f"Invalid data for node '{self.id}': {e}") from e
+ return self
+
+
+class ChatbotFlowEdge(BaseModel):
+ """React Flow edge; `data.intent` / `data.description` are reserved for coordinator routing."""
+
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
+
+ id: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH)
+ source: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH)
+ target: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH)
+ source_handle: str | None = PydanticField(default=None, alias="sourceHandle")
+ target_handle: str | None = PydanticField(default=None, alias="targetHandle")
+ data: dict[str, Any] = PydanticField(default_factory=dict)
+
+
+class ChatbotBase(SQLModel):
+ name: str = Field(
+ min_length=1,
+ max_length=CHATBOT_NAME_MAX_LENGTH,
+ sa_column_kwargs={"comment": "Display name of the chatbot shown in the UI"},
+ )
+
+
+class ChatbotCreate(ChatbotBase):
+ pass
+
+
+class ChatbotUpdate(SQLModel):
+ name: str | None = Field(
+ default=None, min_length=1, max_length=CHATBOT_NAME_MAX_LENGTH
+ )
+
+
+class Chatbot(ChatbotBase, table=True):
+ __tablename__ = "chatbot"
+
+ id: UUID = Field(
+ default_factory=uuid4,
+ primary_key=True,
+ sa_column_kwargs={"comment": "Chatbot identifier used in API paths"},
+ )
+ organization_id: int = Field(
+ foreign_key="organization.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Organization that owns the chatbot"},
+ )
+ project_id: int = Field(
+ foreign_key="project.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Project that owns the chatbot"},
+ )
+ inserted_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={"comment": "Timestamp when the chatbot was created"},
+ )
+ updated_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "Timestamp when the chatbot was last updated",
+ "onupdate": now,
+ },
+ )
+ deleted_at: datetime | None = Field(
+ default=None,
+ nullable=True,
+ sa_column_kwargs={
+ "comment": "Soft-delete timestamp; the row is kept because sessions reference it"
+ },
+ )
+
+
+class ChatbotPublic(ChatbotBase):
+ id: UUID
+ organization_id: int
+ project_id: int
+ inserted_at: datetime
+ updated_at: datetime
+
+
+class ChatbotFlowVersion(SQLModel, table=True):
+ __tablename__ = "chatbot_flow_version"
+ __table_args__ = (
+ UniqueConstraint(
+ "chatbot_id", "version", name=CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT
+ ),
+ Index(
+ LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX,
+ "chatbot_id",
+ unique=True,
+ postgresql_where=text("is_live"),
+ ),
+ )
+
+ id: UUID = Field(
+ default_factory=uuid4,
+ primary_key=True,
+ sa_column_kwargs={"comment": "Flow version identifier pinned by sessions"},
+ )
+ chatbot_id: UUID = Field(
+ foreign_key="chatbot.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Chatbot this flow version belongs to"},
+ )
+ version: int = Field(
+ ge=1,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "Sequential version number per chatbot, starting at 1"
+ },
+ )
+ nodes: list[dict[str, Any]] = Field(
+ sa_column=Column(
+ JSONB,
+ nullable=False,
+ comment="React Flow nodes as saved by the UI: [{id, type (input/output/coordinator/onboarding/rag), position {x, y}, data}]; agent node data carries config_id, config_version, prompt",
+ ),
+ )
+ edges: list[dict[str, Any]] = Field(
+ default_factory=list,
+ sa_column=Column(
+ JSONB,
+ nullable=False,
+ server_default=text("'[]'::jsonb"),
+ comment="React Flow edges as saved by the UI: [{id, source, target, sourceHandle, targetHandle, data}]",
+ ),
+ )
+ is_live: bool = Field(
+ default=False,
+ nullable=False,
+ sa_column_kwargs={
+ "server_default": text("false"),
+ "comment": "True for the single published version new sessions run on; live versions are immutable, the highest non-live version is the draft",
+ },
+ )
+ inserted_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={"comment": "Timestamp when the version was created"},
+ )
+ updated_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "Timestamp when the draft was last saved or the version was published/unpublished",
+ "onupdate": now,
+ },
+ )
+
+
+class ChatbotFlowUpdate(SQLModel):
+ nodes: list[ChatbotFlowNode] = Field(
+ description="React Flow nodes; only their shape is checked on save."
+ )
+ edges: list[ChatbotFlowEdge] = Field(
+ default_factory=list, description="React Flow edges between `nodes`."
+ )
+
+
+class ChatbotFlowVersionPublic(SQLModel):
+ id: UUID
+ chatbot_id: UUID
+ version: int
+ is_live: bool
+ nodes: list[ChatbotFlowNode]
+ edges: list[ChatbotFlowEdge]
+ inserted_at: datetime
+ updated_at: datetime
+
+
+class ChatbotSession(SQLModel, table=True):
+ __tablename__ = "chatbot_session"
+ __table_args__ = (
+ # Also serves FK lookups on chatbot_id via its prefix, so chatbot_id has no own index.
+ Index("ix_chatbot_session_chatbot_contact", "chatbot_id", "contact_ref"),
+ Index(
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
+ "chatbot_id",
+ "contact_ref",
+ unique=True,
+ postgresql_where=text("status = 'active'"),
+ ),
+ )
+
+ id: UUID = Field(
+ default_factory=uuid4,
+ primary_key=True,
+ sa_column_kwargs={"comment": "Session identifier returned to the caller"},
+ )
+ chatbot_id: UUID = Field(
+ foreign_key="chatbot.id",
+ nullable=False,
+ ondelete="RESTRICT",
+ sa_column_kwargs={
+ "comment": "Chatbot this session runs; tenant scope comes from the chatbot"
+ },
+ )
+ flow_version_id: UUID = Field(
+ foreign_key="chatbot_flow_version.id",
+ nullable=False,
+ index=True,
+ ondelete="RESTRICT",
+ sa_column_kwargs={
+ "comment": "Live flow version pinned at creation, so publishing never changes a running conversation"
+ },
+ )
+ contact_ref: str = Field(
+ max_length=CONTACT_REF_MAX_LENGTH,
+ index=True,
+ sa_column_kwargs={
+ "comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi"
+ },
+ )
+ active_node_id: str = Field(
+ max_length=FLOW_NODE_ID_MAX_LENGTH,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "React Flow node id inside the pinned flow version that handles the next turn"
+ },
+ )
+ status: ChatbotSessionStatusEnum = Field(
+ default=ChatbotSessionStatusEnum.ACTIVE,
+ sa_column=Column(
+ _SESSION_STATUS_PG_ENUM,
+ nullable=False,
+ comment="Session in use: active, or inactive once superseded by a newer session for the same chatbot + contact_ref or the chatbot was deleted",
+ ),
+ )
+ completion_status: ChatbotCompletionStatusEnum = Field(
+ default=ChatbotCompletionStatusEnum.IN_PROGRESS,
+ sa_column=Column(
+ _COMPLETION_STATUS_PG_ENUM,
+ nullable=False,
+ server_default=ChatbotCompletionStatusEnum.IN_PROGRESS.value,
+ comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when deactivated while in_progress",
+ ),
+ )
+ collected_fields: dict[str, Any] = Field(
+ default_factory=dict,
+ sa_column=Column(
+ JSON,
+ nullable=False,
+ comment="Flat map of field name -> value gathered from the user so far",
+ ),
+ )
+ inserted_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={"comment": "Timestamp when the session was created"},
+ )
+ updated_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "Timestamp when the session was last updated",
+ "onupdate": now,
+ },
+ )
+
+
+class ChatbotSessionMessage(SQLModel, table=True):
+ __tablename__ = "chatbot_session_message"
+
+ id: UUID = Field(
+ default_factory=uuid4,
+ primary_key=True,
+ sa_column_kwargs={"comment": "Unique identifier"},
+ )
+ session_id: UUID = Field(
+ foreign_key="chatbot_session.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Reference to the owning chatbot session"},
+ )
+ role: ChatbotMessageRoleEnum = Field(
+ sa_column=Column(
+ _MESSAGE_ROLE_PG_ENUM,
+ nullable=False,
+ comment="Speaker: user or assistant (system is reserved; the prompt lives on the flow node)",
+ ),
+ )
+ content: str = Field(
+ sa_column_kwargs={"comment": "Message text"},
+ )
+ node_id: str | None = Field(
+ default=None,
+ max_length=FLOW_NODE_ID_MAX_LENGTH,
+ nullable=True,
+ sa_column_kwargs={
+ "comment": "React Flow node id of the pinned flow that was active for this turn"
+ },
+ )
+ inserted_at: datetime = Field(
+ default_factory=now,
+ nullable=False,
+ sa_column_kwargs={
+ "comment": "Timestamp when the message was stored; defines transcript order"
+ },
+ )
+
+
+class CreateChatbotSessionRequest(SQLModel):
+ contact_ref: str = Field(
+ min_length=1,
+ max_length=CONTACT_REF_MAX_LENGTH,
+ description="Caller-side identifier of the end user this conversation is with.",
+ )
+
+
+class CreateChatbotSessionResponse(SQLModel):
+ session_id: UUID
+ chatbot_id: UUID
+ flow_version: int
+ active_node_id: str
+
+
+class ChatbotSessionSummary(SQLModel):
+ session_id: UUID
+ chatbot_id: UUID
+ chatbot_name: str
+ flow_version: int
+ active_node_id: str
+ prompt: str | None
+ contact_ref: str
+ status: ChatbotSessionStatusEnum
+ completion_status: ChatbotCompletionStatusEnum
+ collected_fields: dict[str, Any]
+ inserted_at: datetime
+ updated_at: datetime
+
+
+class SendChatbotSessionMessageRequest(SQLModel):
+ message: str | None = Field(
+ default=None,
+ min_length=1,
+ description="The new user message; omit only on the first call to get the opening greeting.",
+ )
+ webhook_url: str = Field(
+ min_length=1,
+ description="HTTPS URL the bot reply is POSTed to once the turn completes.",
+ )
+
+
+class SendChatbotSessionMessageResponse(SQLModel):
+ status: str = "queued"
diff --git a/backend/app/services/chatbot/agents/__init__.py b/backend/app/services/chatbot/agents/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/services/chatbot/agents/onboarding/__init__.py b/backend/app/services/chatbot/agents/onboarding/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py
new file mode 100644
index 000000000..4c8f32ded
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/graph.py
@@ -0,0 +1,232 @@
+"""Onboarding turn: extract the flow's fields, then reply -- two LangGraph nodes over a LangChain chat model."""
+
+import asyncio
+import json
+from typing import Any
+
+import anthropic
+from langchain_core.exceptions import OutputParserException
+from langchain_core.language_models import LanguageModelInput
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.messages import BaseMessage, SystemMessage
+from langchain_core.runnables import Runnable
+from langgraph.graph import END, START, StateGraph
+from langgraph.graph.state import CompiledStateGraph
+from pydantic import BaseModel, ValidationError
+
+from app.services.chatbot.agents.onboarding.prompts import (
+ EXTRACTION_FLOW_PROMPT_TEMPLATE,
+ EXTRACTION_INSTRUCTION,
+ EXTRACTION_METHOD,
+ EXTRACTION_PRIOR_FIELDS_TEMPLATE,
+ PROGRESS_COLLECTED_TEMPLATE,
+ PROGRESS_COMPLETE_NOTE,
+ PROGRESS_MISSING_TEMPLATE,
+ PROGRESS_NOTE_PREFIX,
+)
+from app.services.chatbot.agents.onboarding.types import (
+ FieldExtraction,
+ OnboardingState,
+)
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.history import (
+ extract_final_reply,
+ has_user_turn,
+ insert_after_leading_system,
+ summarize_if_needed,
+ to_langchain_messages,
+)
+from app.services.chatbot.types import ChatbotMessageDict, ChatbotTurnResult
+from app.services.chatbot.utils import ChatbotModelSettings, get_chat_model
+
+ExtractionModel = Runnable[LanguageModelInput, dict | BaseModel]
+
+EXTRACT_FIELDS_NODE = "extract_fields_node"
+CHATBOT_NODE = "chatbot_node"
+
+
+def _format_fields(fields: dict[str, Any]) -> str:
+ return json.dumps(fields, ensure_ascii=False, default=str)
+
+
+def _build_extraction_messages(
+ *,
+ flow_prompt: str,
+ prior_fields: dict[str, Any],
+ conversation_view: list[BaseMessage],
+) -> list[BaseMessage]:
+ instruction = (
+ EXTRACTION_INSTRUCTION
+ + EXTRACTION_FLOW_PROMPT_TEMPLATE.format(flow_prompt=flow_prompt)
+ + EXTRACTION_PRIOR_FIELDS_TEMPLATE.format(
+ prior_fields=_format_fields(prior_fields)
+ )
+ )
+ # The view's own first message is the flow prompt, already embedded above.
+ return [SystemMessage(content=instruction), *conversation_view[1:]]
+
+
+def _build_progress_note(state: OnboardingState) -> SystemMessage | None:
+ if "is_complete" not in state:
+ return None
+
+ note = ""
+ collected = state["collected_fields"]
+ if collected:
+ note += PROGRESS_COLLECTED_TEMPLATE.format(collected=_format_fields(collected))
+ missing = state.get("missing_fields", [])
+ if state["is_complete"]:
+ note += PROGRESS_COMPLETE_NOTE
+ elif missing:
+ note += PROGRESS_MISSING_TEMPLATE.format(missing=", ".join(missing))
+ if not note:
+ return None
+ return SystemMessage(content=f"{PROGRESS_NOTE_PREFIX}{note.strip()}")
+
+
+def _invoke_extraction(
+ extraction_model: ExtractionModel,
+ messages: list[BaseMessage],
+) -> FieldExtraction:
+ try:
+ extraction = extraction_model.invoke(messages)
+ except (OutputParserException, ValidationError) as e:
+ error_message = (
+ "[ANTHROPIC] Field extraction returned malformed structured output — "
+ f"retry the turn. If persistent, contact Kaapi. (code: {type(e).__name__})"
+ )
+ raise ChatbotProviderError(error_message) from e
+
+ if not isinstance(extraction, FieldExtraction):
+ error_message = (
+ "[ANTHROPIC] Field extraction returned an unexpected output shape — retry "
+ "the turn. If persistent, contact Kaapi. (code: UnexpectedOutputType)"
+ )
+ raise ChatbotProviderError(error_message)
+ return extraction
+
+
+def build_onboarding_graph(
+ chat_model: BaseChatModel,
+ extraction_model: ExtractionModel,
+ *,
+ flow_prompt: str,
+ history_token_limit: int,
+) -> CompiledStateGraph:
+ """extract_fields_node -> chatbot_node; both models are fixed for the graph's lifetime."""
+
+ def extract_fields_node(state: OnboardingState) -> dict[str, Any]:
+ if not has_user_turn(state["messages"]):
+ return {}
+
+ conversation_view = summarize_if_needed(
+ chat_model, state["messages"], history_token_limit=history_token_limit
+ )
+ prior_fields = state["collected_fields"]
+ extraction = _invoke_extraction(
+ extraction_model,
+ _build_extraction_messages(
+ flow_prompt=flow_prompt,
+ prior_fields=prior_fields,
+ conversation_view=conversation_view,
+ ),
+ )
+ extracted = {field.name: field.value for field in extraction.collected_fields}
+ merged_fields = {**prior_fields, **extracted}
+ is_complete = extraction.is_complete and not extraction.missing_fields
+ return {
+ "context_messages": conversation_view,
+ "collected_fields": merged_fields,
+ "missing_fields": extraction.missing_fields,
+ "is_complete": is_complete,
+ }
+
+ def chatbot_node(state: OnboardingState) -> dict[str, list[BaseMessage]]:
+ messages = state.get("context_messages")
+ if messages is None:
+ messages = summarize_if_needed(
+ chat_model, state["messages"], history_token_limit=history_token_limit
+ )
+ progress_note = _build_progress_note(state)
+ if progress_note is not None:
+ messages = insert_after_leading_system(messages, progress_note)
+ response = chat_model.invoke(messages)
+ return {"messages": [response]}
+
+ graph = StateGraph(OnboardingState)
+ graph.add_node(EXTRACT_FIELDS_NODE, extract_fields_node)
+ graph.add_node(CHATBOT_NODE, chatbot_node)
+ graph.add_edge(START, EXTRACT_FIELDS_NODE)
+ graph.add_edge(EXTRACT_FIELDS_NODE, CHATBOT_NODE)
+ graph.add_edge(CHATBOT_NODE, END)
+ return graph.compile()
+
+
+def _get_extraction_model(chat_model: BaseChatModel) -> ExtractionModel:
+ return chat_model.with_structured_output(FieldExtraction, method=EXTRACTION_METHOD)
+
+
+def _to_provider_error(error: Exception, *, model: str) -> ChatbotProviderError:
+ if isinstance(error, anthropic.APITimeoutError):
+ return ChatbotProviderError(
+ "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
+ "(code: APITimeoutError)"
+ )
+ if isinstance(error, anthropic.APIConnectionError):
+ return ChatbotProviderError(
+ "[KAAPI] Network/DNS issue reaching provider — check connectivity. "
+ "If persistent, contact Kaapi. (code: APIConnectionError)"
+ )
+ if isinstance(error, anthropic.APIStatusError):
+ return ChatbotProviderError(
+ f"[ANTHROPIC] Chat model call failed: {error.message} "
+ f"(code: {error.status_code}, request_id: {error.request_id})"
+ )
+ return ChatbotProviderError(
+ f"[KAAPI] Unsupported model configuration for '{model}': {error} "
+ "— update the config's model, thinking, or effort. (code: ValueError)"
+ )
+
+
+async def run_chatbot_turn(
+ *,
+ system_prompt: str,
+ history: list[ChatbotMessageDict],
+ user_message: str | None,
+ model_settings: ChatbotModelSettings,
+ collected_fields: dict[str, Any] | None = None,
+) -> ChatbotTurnResult:
+ """Run one stateless turn: update the collected fields, then reply."""
+ if user_message is None and history:
+ raise ChatbotInputError(
+ "[KAAPI] `message` is required once `history` is non-empty; omit it only "
+ "on the first call to get the opening greeting."
+ )
+
+ messages = to_langchain_messages(
+ system_prompt=system_prompt, history=history, user_message=user_message
+ )
+ prior_fields = dict(collected_fields) if collected_fields else {}
+
+ initial_state: OnboardingState = {
+ "messages": messages,
+ "collected_fields": prior_fields,
+ }
+ try:
+ chat_model = get_chat_model(model_settings)
+ graph = build_onboarding_graph(
+ chat_model,
+ _get_extraction_model(chat_model),
+ flow_prompt=system_prompt,
+ history_token_limit=model_settings.history_token_limit,
+ )
+ # ChatAnthropic.invoke is sync; keep it off the event loop.
+ result = await asyncio.to_thread(graph.invoke, initial_state)
+ except (anthropic.APIConnectionError, anthropic.APIStatusError, ValueError) as e:
+ raise _to_provider_error(e, model=model_settings.model) from e
+
+ return ChatbotTurnResult(
+ reply=extract_final_reply(result["messages"]),
+ collected_fields=result["collected_fields"],
+ is_complete=result.get("is_complete", False),
+ )
diff --git a/backend/app/services/chatbot/agents/onboarding/prompts.py b/backend/app/services/chatbot/agents/onboarding/prompts.py
new file mode 100644
index 000000000..b483cbe1d
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/prompts.py
@@ -0,0 +1,29 @@
+EXTRACTION_METHOD = "json_schema"
+
+EXTRACTION_INSTRUCTION = (
+ "You extract data from a chatbot conversation. You do not talk to the user.\n"
+ "The admin flow prompt below defines the goal, the fields to collect, and the rules.\n"
+ "1. Identify every field the flow prompt asks to collect; name each in snake_case. "
+ "Treat every field as required unless the flow prompt explicitly marks it optional.\n"
+ "2. collected_fields: every field whose value the user has clearly provided in the "
+ "conversation, including those in the previously collected map. Reuse the exact "
+ "keys of the previously collected map; change a value only if the user corrected it.\n"
+ "3. A value counts as collected only once every rule the flow prompt sets for that "
+ "field is met. For example, if the flow prompt says to confirm or verify a value, "
+ "include it only after the user has confirmed it; until then leave it out of "
+ "collected_fields and keep the field in missing_fields.\n"
+ "4. Never invent, guess, or infer a value the user did not give.\n"
+ "5. missing_fields: the required fields not yet collected under these rules.\n"
+ "6. is_complete: true only when no required field is missing."
+)
+EXTRACTION_FLOW_PROMPT_TEMPLATE = "\n\n\n{flow_prompt}\n"
+EXTRACTION_PRIOR_FIELDS_TEMPLATE = (
+ "\n\n\n{prior_fields}\n"
+)
+
+PROGRESS_NOTE_PREFIX = "[Field collection progress] "
+PROGRESS_COLLECTED_TEMPLATE = (
+ "Already collected (do not ask for these again): {collected}. "
+)
+PROGRESS_MISSING_TEMPLATE = "Still missing: {missing}. Ask only for the missing fields."
+PROGRESS_COMPLETE_NOTE = "Every required field is collected; confirm and wrap up as the flow prompt describes."
diff --git a/backend/app/services/chatbot/agents/onboarding/types.py b/backend/app/services/chatbot/agents/onboarding/types.py
new file mode 100644
index 000000000..850e4d958
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/types.py
@@ -0,0 +1,34 @@
+from typing import Annotated, Any, NotRequired, TypedDict
+
+from langchain_core.messages import BaseMessage
+from langgraph.graph.message import add_messages
+from pydantic import BaseModel, Field
+
+
+class OnboardingState(TypedDict):
+ messages: Annotated[list[BaseMessage], add_messages]
+ context_messages: NotRequired[list[BaseMessage]]
+ collected_fields: dict[str, Any]
+ missing_fields: NotRequired[list[str]]
+ is_complete: NotRequired[bool]
+
+
+class ExtractedField(BaseModel):
+ name: str = Field(description="snake_case field name.")
+ value: str = Field(description="Value the user clearly provided; never guessed.")
+
+
+class FieldExtraction(BaseModel):
+ """Fields the admin's flow prompt asks for, as answered so far by the user."""
+
+ collected_fields: list[ExtractedField] = Field(
+ default_factory=list,
+ description="Every field the user has clearly provided, one entry per field.",
+ )
+ missing_fields: list[str] = Field(
+ default_factory=list,
+ description="snake_case names of required fields the user has not answered yet.",
+ )
+ is_complete: bool = Field(
+ description="True only when no required field is missing.",
+ )
diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py
new file mode 100644
index 000000000..30b4e74ab
--- /dev/null
+++ b/backend/app/services/chatbot/config.py
@@ -0,0 +1,162 @@
+from dataclasses import replace
+from typing import Any, get_args
+from uuid import UUID
+
+from fastapi import HTTPException
+from pydantic import ValidationError
+from sqlmodel import Session
+
+from app.crud.config import ConfigVersionCrud
+from app.crud.credentials import get_provider_credential
+from app.models import ConfigVersion
+from app.models.llm.constants import Provider
+from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig
+from app.services.chatbot.exceptions import ChatbotInputError
+from app.services.chatbot.utils import (
+ DEFAULT_HISTORY_TOKEN_LIMIT,
+ DEFAULT_MAX_OUTPUT_TOKENS,
+ AnthropicEffort,
+ ChatbotModelSettings,
+ default_thinking,
+)
+
+_ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort)
+THINKING_TYPE_ENABLED = "enabled"
+CREDENTIAL_API_KEY = "api_key"
+ANTHROPIC_CREDENTIALS_MISSING_MESSAGE = (
+ "[KAAPI] Anthropic credentials are not configured for this project; add an "
+ "Anthropic API key in the project's credentials."
+)
+
+
+def get_anthropic_api_key(
+ *, session: Session, organization_id: int, project_id: int
+) -> str:
+ """The project's Anthropic API key; the chatbot never falls back to a platform key."""
+ credentials = get_provider_credential(
+ session=session,
+ org_id=organization_id,
+ project_id=project_id,
+ provider=Provider.ANTHROPIC.value,
+ )
+ if not isinstance(credentials, dict) or not credentials.get(CREDENTIAL_API_KEY):
+ raise ChatbotInputError(ANTHROPIC_CREDENTIALS_MISSING_MESSAGE)
+ return credentials[CREDENTIAL_API_KEY]
+
+
+def get_chatbot_config_version(
+ *, session: Session, project_id: int, config_id: UUID, config_version: int | None
+) -> ConfigVersion:
+ """The requested version, or the latest live one when `config_version` is None."""
+ version_crud = ConfigVersionCrud(
+ session=session, config_id=config_id, project_id=project_id
+ )
+ if config_version is not None:
+ return version_crud.exists_or_raise(version_number=config_version)
+
+ latest = version_crud.read_latest()
+ if latest is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"No version found for config '{config_id}'",
+ )
+ return latest
+
+
+def _to_anthropic_effort(effort: str | None) -> AnthropicEffort | None:
+ if effort is None:
+ return None
+ for level in _ANTHROPIC_EFFORT_LEVELS:
+ if level == effort:
+ return level
+ raise ChatbotInputError(
+ f"[KAAPI] Config param 'effort' value '{effort}' is not an Anthropic effort "
+ f"level ({', '.join(_ANTHROPIC_EFFORT_LEVELS)}); update the config."
+ )
+
+
+def _validate_thinking_budget(thinking: dict[str, Any], *, max_tokens: int) -> None:
+ if thinking.get("type") != THINKING_TYPE_ENABLED:
+ return
+ budget = thinking.get("budget_tokens")
+ if not isinstance(budget, int) or budget >= max_tokens:
+ raise ChatbotInputError(
+ f"[KAAPI] Config thinking.budget_tokens ({budget}) must be an integer below "
+ f"the chatbot's max output tokens ({max_tokens}); lower the budget or use "
+ "thinking type 'adaptive'."
+ )
+
+
+def resolve_chatbot_model_settings(
+ *,
+ session: Session,
+ organization_id: int,
+ project_id: int,
+ config_id: UUID,
+ config_version: int | None,
+ max_tokens: int | None,
+ history_token_limit: int | None,
+) -> ChatbotModelSettings:
+ """Turn a saved config (+ request overrides) and the project's Anthropic key into chat-model settings."""
+ version_row = get_chatbot_config_version(
+ session=session,
+ project_id=project_id,
+ config_id=config_id,
+ config_version=config_version,
+ )
+ model_settings = build_chatbot_model_settings(
+ version_row, max_tokens=max_tokens, history_token_limit=history_token_limit
+ )
+ api_key = get_anthropic_api_key(
+ session=session, organization_id=organization_id, project_id=project_id
+ )
+ return replace(model_settings, api_key=api_key)
+
+
+def build_chatbot_model_settings(
+ version_row: ConfigVersion,
+ *,
+ max_tokens: int | None,
+ history_token_limit: int | None,
+) -> ChatbotModelSettings:
+ try:
+ blob = ConfigBlob.model_validate(version_row.config_blob)
+ except ValidationError as e:
+ raise ChatbotInputError(
+ f"[KAAPI] Stored configuration blob is invalid: {e}"
+ ) from e
+
+ completion = blob.completion
+ if (
+ not isinstance(completion, KaapiTextCompletionConfig)
+ or completion.provider != Provider.ANTHROPIC
+ ):
+ raise ChatbotInputError(
+ "[KAAPI] Chatbot only supports configs with completion provider "
+ f"'{Provider.ANTHROPIC.value}' and type 'text'; got provider "
+ f"'{completion.provider}', type '{completion.type}'."
+ )
+
+ params = completion.params
+ if not params.model:
+ raise ChatbotInputError(
+ "[KAAPI] Config is missing completion.params.model; set a model on the config."
+ )
+
+ thinking = params.thinking if params.thinking is not None else default_thinking()
+ resolved_max_tokens = (
+ max_tokens if max_tokens is not None else DEFAULT_MAX_OUTPUT_TOKENS
+ )
+ _validate_thinking_budget(thinking, max_tokens=resolved_max_tokens)
+ model_settings = ChatbotModelSettings(
+ model=params.model,
+ max_tokens=resolved_max_tokens,
+ reasoning_effort=_to_anthropic_effort(params.effort),
+ thinking=thinking,
+ history_token_limit=(
+ history_token_limit
+ if history_token_limit is not None
+ else DEFAULT_HISTORY_TOKEN_LIMIT
+ ),
+ )
+ return model_settings
diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py
new file mode 100644
index 000000000..1d71e0194
--- /dev/null
+++ b/backend/app/services/chatbot/exceptions.py
@@ -0,0 +1,34 @@
+CONCURRENT_SESSION_CONFLICT_MESSAGE = (
+ "A concurrent session was just created for this contact and chatbot; retry"
+)
+CONCURRENT_FLOW_CONFLICT_MESSAGE = (
+ "The chatbot flow was changed concurrently; reload the flow and retry"
+)
+
+
+class ChatbotInputError(ValueError):
+ """The turn request can't produce a valid model call (caller's fault)."""
+
+
+class ChatbotProviderError(Exception):
+ """The chat-model provider failed or was unreachable."""
+
+
+class ChatbotSessionConflictError(Exception):
+ """Another active session for the same contact + chatbot was committed concurrently."""
+
+
+class ChatbotNotFoundError(Exception):
+ """The chatbot doesn't exist in the caller's project or was deleted."""
+
+
+class ChatbotFlowNotFoundError(Exception):
+ """The chatbot has no flow version (or not the requested one)."""
+
+
+class ChatbotFlowConflictError(Exception):
+ """The flow isn't in the state the operation needs (no draft, nothing live, or a concurrent write)."""
+
+
+class ChatbotFlowValidationError(ValueError):
+ """The flow fails publish-time validation (caller's fault)."""
diff --git a/backend/app/services/chatbot/flow.py b/backend/app/services/chatbot/flow.py
new file mode 100644
index 000000000..b5f78bac9
--- /dev/null
+++ b/backend/app/services/chatbot/flow.py
@@ -0,0 +1,435 @@
+from typing import Any, NoReturn
+from uuid import UUID
+
+from fastapi import HTTPException
+from sqlmodel import Session
+
+from app.core.util import now
+
+from app.crud.chatbot import (
+ create_chatbot_flow_version,
+ get_chatbot,
+ get_chatbot_flow_version,
+ get_latest_chatbot_flow_version,
+ publish_chatbot_flow_version,
+ update_chatbot_flow_version_graph,
+)
+from app.models import (
+ AGENT_NODE_TYPES,
+ Chatbot,
+ ChatbotFlowEdge,
+ ChatbotFlowNode,
+ ChatbotFlowNodeTypeEnum,
+ ChatbotFlowUpdate,
+ ChatbotFlowVersion,
+ ChatbotFlowVersionPublic,
+)
+from app.services.chatbot.config import (
+ build_chatbot_model_settings,
+ get_anthropic_api_key,
+ get_chatbot_config_version,
+)
+from app.services.chatbot.exceptions import (
+ ChatbotFlowConflictError,
+ ChatbotFlowNotFoundError,
+ ChatbotFlowValidationError,
+ ChatbotInputError,
+ ChatbotNotFoundError,
+)
+
+KAAPI_ERROR_TAG = "[KAAPI]"
+FIRST_FLOW_VERSION = 1
+MAX_INPUT_NODES = 1
+MAX_OUTPUT_NODES = 1
+# The runtime only executes onboarding agents on this branch; coordinator/rag are stored but not publishable.
+SUPPORTED_AGENT_NODE_TYPES: frozenset[ChatbotFlowNodeTypeEnum] = frozenset(
+ {ChatbotFlowNodeTypeEnum.ONBOARDING}
+)
+_SUPPORTED_AGENT_NODE_TYPES_LABEL = ", ".join(
+ sorted(node_type.value for node_type in SUPPORTED_AGENT_NODE_TYPES)
+)
+CHATBOT_NOT_FOUND_MESSAGE = "Chatbot not found"
+NO_DRAFT_TO_PUBLISH_MESSAGE = (
+ "[KAAPI] Chatbot has no draft flow to publish; save the flow first."
+)
+
+
+def dump_flow_nodes(nodes: list[ChatbotFlowNode]) -> list[dict[str, Any]]:
+ """Serialize with React Flow (camelCase) keys so the stored JSON matches what the UI sent."""
+ dumped: list[dict[str, Any]] = []
+ for node in nodes:
+ dumped.append(node.model_dump(mode="json", by_alias=True))
+ return dumped
+
+
+def dump_flow_edges(edges: list[ChatbotFlowEdge]) -> list[dict[str, Any]]:
+ dumped: list[dict[str, Any]] = []
+ for edge in edges:
+ dumped.append(edge.model_dump(mode="json", by_alias=True))
+ return dumped
+
+
+def parse_flow_nodes(raw_nodes: list[dict[str, Any]]) -> list[ChatbotFlowNode]:
+ nodes: list[ChatbotFlowNode] = []
+ for raw_node in raw_nodes:
+ nodes.append(ChatbotFlowNode.model_validate(raw_node))
+ return nodes
+
+
+def parse_flow_edges(raw_edges: list[dict[str, Any]]) -> list[ChatbotFlowEdge]:
+ edges: list[ChatbotFlowEdge] = []
+ for raw_edge in raw_edges:
+ edges.append(ChatbotFlowEdge.model_validate(raw_edge))
+ return edges
+
+
+def find_flow_node(
+ nodes: list[ChatbotFlowNode], node_id: str
+) -> ChatbotFlowNode | None:
+ for node in nodes:
+ if node.id == node_id:
+ return node
+ return None
+
+
+def build_chatbot_flow_version_public(
+ flow_version: ChatbotFlowVersion,
+) -> ChatbotFlowVersionPublic:
+ return ChatbotFlowVersionPublic(
+ id=flow_version.id,
+ chatbot_id=flow_version.chatbot_id,
+ version=flow_version.version,
+ is_live=flow_version.is_live,
+ nodes=parse_flow_nodes(flow_version.nodes),
+ edges=parse_flow_edges(flow_version.edges),
+ inserted_at=flow_version.inserted_at,
+ updated_at=flow_version.updated_at,
+ )
+
+
+def _raise_flow_validation_error(function_name: str, problems: list[str]) -> NoReturn:
+ error_message = f"{KAAPI_ERROR_TAG} Chatbot flow is invalid: {'; '.join(problems)}."
+ raise ChatbotFlowValidationError(error_message)
+
+
+def resolve_entry_node(
+ *, nodes: list[ChatbotFlowNode], edges: list[ChatbotFlowEdge]
+) -> ChatbotFlowNode:
+ """The agent node a new session starts on.
+
+ Without edges the flow must be a single agent node; otherwise the entry is
+ the target of the one edge leaving the input node.
+ """
+ if not edges:
+ agent_nodes = [node for node in nodes if node.type in AGENT_NODE_TYPES]
+ if len(agent_nodes) != 1:
+ _raise_flow_validation_error(
+ "resolve_entry_node",
+ [
+ "a flow without edges must contain exactly one agent node "
+ f"(found {len(agent_nodes)})"
+ ],
+ )
+ return agent_nodes[0]
+
+ input_nodes = [node for node in nodes if node.type == ChatbotFlowNodeTypeEnum.INPUT]
+ if len(input_nodes) != 1:
+ _raise_flow_validation_error(
+ "resolve_entry_node",
+ [
+ "a flow with edges must contain exactly one input node "
+ f"(found {len(input_nodes)})"
+ ],
+ )
+ input_node_id = input_nodes[0].id
+
+ entry_edges = [edge for edge in edges if edge.source == input_node_id]
+ if len(entry_edges) != 1:
+ _raise_flow_validation_error(
+ "resolve_entry_node",
+ [
+ f"input node '{input_node_id}' must have exactly one outgoing edge "
+ f"(found {len(entry_edges)})"
+ ],
+ )
+
+ entry_node = find_flow_node(nodes, entry_edges[0].target)
+ if entry_node is None or entry_node.type not in AGENT_NODE_TYPES:
+ _raise_flow_validation_error(
+ "resolve_entry_node",
+ [
+ f"the input node's edge must lead to an agent node "
+ f"(target '{entry_edges[0].target}')"
+ ],
+ )
+ return entry_node
+
+
+def _list_structural_problems(
+ *, nodes: list[ChatbotFlowNode], edges: list[ChatbotFlowEdge]
+) -> list[str]:
+ problems: list[str] = []
+
+ seen_node_ids: set[str] = set()
+ duplicate_node_ids: set[str] = set()
+ input_count = 0
+ output_count = 0
+ for node in nodes:
+ if node.id in seen_node_ids:
+ duplicate_node_ids.add(node.id)
+ seen_node_ids.add(node.id)
+ if node.type == ChatbotFlowNodeTypeEnum.INPUT:
+ input_count += 1
+ if node.type == ChatbotFlowNodeTypeEnum.OUTPUT:
+ output_count += 1
+
+ if duplicate_node_ids:
+ problems.append(f"duplicate node ids: {', '.join(sorted(duplicate_node_ids))}")
+ if input_count > MAX_INPUT_NODES:
+ problems.append(
+ f"at most {MAX_INPUT_NODES} input node allowed (found {input_count})"
+ )
+ if output_count > MAX_OUTPUT_NODES:
+ problems.append(
+ f"at most {MAX_OUTPUT_NODES} output node allowed (found {output_count})"
+ )
+
+ for edge in edges:
+ if edge.source not in seen_node_ids:
+ problems.append(
+ f"edge '{edge.id}' source '{edge.source}' is not a node in the flow"
+ )
+ if edge.target not in seen_node_ids:
+ problems.append(
+ f"edge '{edge.id}' target '{edge.target}' is not a node in the flow"
+ )
+ return problems
+
+
+def _pin_agent_node_config(
+ *, session: Session, project_id: int, node: ChatbotFlowNode
+) -> tuple[ChatbotFlowNode, list[str]]:
+ """Check the agent node is runnable; returns the node with its config_version pinned, plus any problems."""
+ if node.type not in SUPPORTED_AGENT_NODE_TYPES:
+ return node, [
+ f"node '{node.id}' has type '{node.type.value}', which is not supported yet "
+ f"(supported agent types: {_SUPPORTED_AGENT_NODE_TYPES_LABEL})"
+ ]
+
+ node_data = node.agent_data()
+ problems: list[str] = []
+ if node_data.prompt is None or not node_data.prompt.strip():
+ problems.append(f"node '{node.id}' is missing data.prompt")
+ if node_data.config_id is None:
+ problems.append(f"node '{node.id}' is missing data.config_id")
+ return node, problems
+
+ try:
+ version_row = get_chatbot_config_version(
+ session=session,
+ project_id=project_id,
+ config_id=node_data.config_id,
+ config_version=node_data.config_version,
+ )
+ except HTTPException as e:
+ problems.append(f"node '{node.id}': {e.detail}")
+ return node, problems
+
+ try:
+ build_chatbot_model_settings(
+ version_row, max_tokens=None, history_token_limit=None
+ )
+ except ChatbotInputError as e:
+ reason = str(e).removeprefix(KAAPI_ERROR_TAG).strip()
+ problems.append(f"node '{node.id}': {reason}")
+ return node, problems
+
+ # Pinned at publish so later config edits never change a published flow.
+ pinned_data = {**node.data, "config_version": version_row.version}
+ return node.model_copy(update={"data": pinned_data}), problems
+
+
+def validate_flow_for_publish(
+ *,
+ session: Session,
+ organization_id: int,
+ project_id: int,
+ nodes: list[ChatbotFlowNode],
+ edges: list[ChatbotFlowEdge],
+) -> list[ChatbotFlowNode]:
+ """Strict publish-time checks; returns the nodes with every agent config_version pinned."""
+ problems = _list_structural_problems(nodes=nodes, edges=edges)
+
+ # Agent nodes call Anthropic with the project's own key, so a live flow needs one.
+ if any(node.type in AGENT_NODE_TYPES for node in nodes):
+ try:
+ get_anthropic_api_key(
+ session=session, organization_id=organization_id, project_id=project_id
+ )
+ except ChatbotInputError as e:
+ problems.append(str(e).removeprefix(KAAPI_ERROR_TAG).strip())
+
+ pinned_nodes: list[ChatbotFlowNode] = []
+ for node in nodes:
+ if node.type not in AGENT_NODE_TYPES:
+ pinned_nodes.append(node)
+ continue
+ pinned_node, node_problems = _pin_agent_node_config(
+ session=session, project_id=project_id, node=node
+ )
+ pinned_nodes.append(pinned_node)
+ problems.extend(node_problems)
+
+ if problems:
+ _raise_flow_validation_error("validate_flow_for_publish", problems)
+
+ resolve_entry_node(nodes=pinned_nodes, edges=edges)
+ return pinned_nodes
+
+
+def get_chatbot_or_raise(
+ *,
+ session: Session,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+ for_update: bool = False,
+) -> Chatbot:
+ """The non-deleted chatbot in the project, else ChatbotNotFoundError; `for_update` row-locks it."""
+ chatbot = get_chatbot(
+ session,
+ chatbot_id=chatbot_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ for_update=for_update,
+ )
+ if chatbot is None:
+ raise ChatbotNotFoundError(CHATBOT_NOT_FOUND_MESSAGE)
+ return chatbot
+
+
+def _mark_chatbot_edited(session: Session, chatbot: Chatbot) -> None:
+ chatbot.updated_at = now()
+ session.add(chatbot)
+
+
+def get_chatbot_flow(
+ *,
+ session: Session,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+ version: int | None,
+) -> ChatbotFlowVersion:
+ """The requested version, else the draft if one exists, else the live version."""
+ get_chatbot_or_raise(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ for_update=False,
+ )
+ if version is not None:
+ flow_version = get_chatbot_flow_version(
+ session, chatbot_id=chatbot_id, version=version
+ )
+ if flow_version is None:
+ raise ChatbotFlowNotFoundError(
+ f"Flow version {version} not found for this chatbot"
+ )
+ return flow_version
+
+ # The highest version is always the draft when one exists, otherwise the live one.
+ flow_version = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id)
+ if flow_version is None:
+ raise ChatbotFlowNotFoundError("Chatbot has no flow yet; save a flow first")
+ return flow_version
+
+
+def save_chatbot_flow_draft(
+ *,
+ session: Session,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+ flow: ChatbotFlowUpdate,
+) -> ChatbotFlowVersion:
+ """Save the flow: the first version goes live at once; later saves overwrite the
+ draft, or start a new draft when the highest version is live (live versions are immutable).
+ """
+ # The row lock serializes draft saves and publishes of the same chatbot.
+ chatbot = get_chatbot_or_raise(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ for_update=True,
+ )
+ nodes = dump_flow_nodes(flow.nodes)
+ edges = dump_flow_edges(flow.edges)
+
+ latest = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id)
+ if latest is None:
+ # A new chatbot has nothing to run until its first flow exists, so that save
+ # goes live directly; it is validated like a publish so live is always runnable.
+ pinned_nodes = validate_flow_for_publish(
+ session=session,
+ organization_id=organization_id,
+ project_id=project_id,
+ nodes=flow.nodes,
+ edges=flow.edges,
+ )
+ _mark_chatbot_edited(session, chatbot)
+ return create_chatbot_flow_version(
+ session,
+ chatbot_id=chatbot_id,
+ version=FIRST_FLOW_VERSION,
+ nodes=dump_flow_nodes(pinned_nodes),
+ edges=edges,
+ is_live=True,
+ )
+
+ _mark_chatbot_edited(session, chatbot)
+ if not latest.is_live:
+ return update_chatbot_flow_version_graph(
+ session, flow_version=latest, nodes=nodes, edges=edges
+ )
+
+ return create_chatbot_flow_version(
+ session,
+ chatbot_id=chatbot_id,
+ version=latest.version + 1,
+ nodes=nodes,
+ edges=edges,
+ )
+
+
+def publish_chatbot_flow(
+ *,
+ session: Session,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+) -> ChatbotFlowVersion:
+ chatbot = get_chatbot_or_raise(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ for_update=True,
+ )
+ draft = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id)
+ if draft is None or draft.is_live:
+ raise ChatbotFlowConflictError(NO_DRAFT_TO_PUBLISH_MESSAGE)
+
+ pinned_nodes = validate_flow_for_publish(
+ session=session,
+ organization_id=organization_id,
+ project_id=project_id,
+ nodes=parse_flow_nodes(draft.nodes),
+ edges=parse_flow_edges(draft.edges),
+ )
+ _mark_chatbot_edited(session, chatbot)
+ return publish_chatbot_flow_version(
+ session, flow_version=draft, nodes=dump_flow_nodes(pinned_nodes)
+ )
diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py
new file mode 100644
index 000000000..2c1d94595
--- /dev/null
+++ b/backend/app/services/chatbot/history.py
@@ -0,0 +1,126 @@
+"""Conversation-history plumbing shared by every chatbot agent graph."""
+
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
+
+from app.models.chatbot import ChatbotMessageRoleEnum
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.types import ChatbotMessageDict
+from app.services.chatbot.utils import FLOW_KEEP_RECENT_MESSAGES
+
+SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] "
+SUMMARIZE_INSTRUCTION = (
+ "Summarize the conversation so far in 2-3 sentences. Keep every fact the "
+ "user has already given -- this note replaces the original messages."
+)
+SUMMARIZE_TRANSCRIPT_TEMPLATE = "\n{rendered}\n"
+TRANSCRIPT_USER_LABEL = "User"
+TRANSCRIPT_ASSISTANT_LABEL = "Assistant"
+
+KICKOFF_MESSAGE_ID = "chatbot-kickoff"
+KICKOFF_MESSAGE_CONTENT = "Begin the conversation."
+
+
+def summarize_if_needed(
+ chat_model: BaseChatModel,
+ messages: list[BaseMessage],
+ *,
+ history_token_limit: int,
+) -> list[BaseMessage]:
+ """Trim the view sent to the model once history crosses the token budget."""
+ if len(messages) <= FLOW_KEEP_RECENT_MESSAGES + 1: # +1 for the system message
+ return messages
+ if chat_model.get_num_tokens_from_messages(messages) <= history_token_limit:
+ return messages
+
+ system_message, *rest = messages
+ to_summarize, recent = (
+ rest[:-FLOW_KEEP_RECENT_MESSAGES],
+ rest[-FLOW_KEEP_RECENT_MESSAGES:],
+ )
+ while recent and to_summarize and not isinstance(recent[0], HumanMessage):
+ recent.insert(0, to_summarize.pop())
+ if not to_summarize:
+ return messages
+
+ rendered = _render_transcript(to_summarize)
+ summary = chat_model.invoke(
+ [
+ SystemMessage(content=SUMMARIZE_INSTRUCTION),
+ HumanMessage(
+ content=SUMMARIZE_TRANSCRIPT_TEMPLATE.format(rendered=rendered)
+ ),
+ ]
+ )
+ summary_note = SystemMessage(content=f"{SUMMARY_NOTE_PREFIX}{summary.text}")
+ return [system_message, summary_note, *recent]
+
+
+def _render_transcript(messages: list[BaseMessage]) -> str:
+ lines: list[str] = []
+ for message in messages:
+ if message.id == KICKOFF_MESSAGE_ID:
+ continue
+ speaker = (
+ TRANSCRIPT_USER_LABEL
+ if isinstance(message, HumanMessage)
+ else TRANSCRIPT_ASSISTANT_LABEL
+ )
+ lines.append(f"{speaker}: {message.text}")
+ return "\n".join(lines)
+
+
+def has_user_turn(messages: list[BaseMessage]) -> bool:
+ for message in messages:
+ if isinstance(message, HumanMessage) and message.id != KICKOFF_MESSAGE_ID:
+ return True
+ return False
+
+
+def insert_after_leading_system(
+ messages: list[BaseMessage], note: SystemMessage
+) -> list[BaseMessage]:
+ # Adjacent system messages merge into Anthropic's top-level `system`; a
+ # trailing one is rejected as "non-consecutive" on pre-Opus-5 models.
+ insert_at = 0
+ while insert_at < len(messages) and isinstance(messages[insert_at], SystemMessage):
+ insert_at += 1
+ return [*messages[:insert_at], note, *messages[insert_at:]]
+
+
+def to_langchain_messages(
+ *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None
+) -> list[BaseMessage]:
+ conversation: list[BaseMessage] = []
+ for entry in history:
+ role = entry["role"]
+ content = entry["content"]
+ if role == ChatbotMessageRoleEnum.USER:
+ conversation.append(HumanMessage(content=content))
+ elif role == ChatbotMessageRoleEnum.ASSISTANT:
+ conversation.append(AIMessage(content=content))
+ elif role != ChatbotMessageRoleEnum.SYSTEM:
+ raise ChatbotInputError(f"Unsupported message role: {role}")
+
+ if user_message is not None:
+ conversation.append(HumanMessage(content=user_message))
+
+ if not conversation or not isinstance(conversation[0], HumanMessage):
+ kickoff = HumanMessage(content=KICKOFF_MESSAGE_CONTENT, id=KICKOFF_MESSAGE_ID)
+ conversation.insert(0, kickoff)
+
+ return [SystemMessage(content=system_prompt), *conversation]
+
+
+def extract_final_reply(messages: list[BaseMessage]) -> str:
+ """Text of the turn's last message, which every agent graph ends on."""
+ reply = ""
+ if messages and isinstance(messages[-1], AIMessage):
+ reply = messages[-1].text
+ if not reply.strip():
+ error_message = (
+ "[ANTHROPIC] Chat model returned no reply text — retry the turn. "
+ "If persistent, contact Kaapi. (code: EmptyReply)"
+ )
+ raise ChatbotProviderError(error_message)
+ return reply
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
new file mode 100644
index 000000000..3f7da127c
--- /dev/null
+++ b/backend/app/services/chatbot/session.py
@@ -0,0 +1,329 @@
+import asyncio
+from typing import NamedTuple
+from uuid import UUID
+
+from fastapi import HTTPException
+from sqlmodel import Session
+
+from app.core.db import engine
+from app.crud.chatbot import (
+ create_chatbot_session,
+ create_chatbot_session_message,
+ get_chatbot_flow_version_by_id,
+ get_chatbot_session,
+ get_latest_chatbot_session,
+ get_live_chatbot_flow_version,
+ list_chatbot_session_messages,
+ update_chatbot_session_progress,
+)
+from app.models import (
+ AGENT_NODE_TYPES,
+ ChatbotFlowNodeTypeEnum,
+ ChatbotFlowVersion,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
+ ChatbotSessionSummary,
+)
+from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn
+from app.services.chatbot.config import resolve_chatbot_model_settings
+from app.services.chatbot.exceptions import (
+ ChatbotFlowConflictError,
+ ChatbotInputError,
+ ChatbotProviderError,
+)
+from app.services.chatbot.flow import (
+ find_flow_node,
+ get_chatbot_or_raise,
+ parse_flow_edges,
+ parse_flow_nodes,
+ resolve_entry_node,
+)
+from app.services.chatbot.types import ChatbotMessageDict
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.utils import APIResponse, get_webhook_secret, send_callback
+
+SUPERSEDED_SESSION_ERROR = (
+ "[KAAPI] Chatbot session was superseded by a newer session; start a new session."
+)
+NO_LIVE_FLOW_MESSAGE = "[KAAPI] Chatbot has no published flow; publish the flow first."
+
+
+class _TurnNode(NamedTuple):
+ node_id: str
+ prompt: str
+ config_id: UUID
+ config_version: int | None
+
+
+def start_chatbot_session(
+ *,
+ session: Session,
+ chatbot_id: UUID,
+ organization_id: int,
+ project_id: int,
+ contact_ref: str,
+) -> tuple[ChatbotSession, ChatbotFlowVersion]:
+ """Create a session pinned to the live flow version and its entry node."""
+ get_chatbot_or_raise(
+ session=session,
+ chatbot_id=chatbot_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ )
+
+ live_version = get_live_chatbot_flow_version(session, chatbot_id=chatbot_id)
+ if live_version is None:
+ raise ChatbotFlowConflictError(NO_LIVE_FLOW_MESSAGE)
+
+ entry_node = resolve_entry_node(
+ nodes=parse_flow_nodes(live_version.nodes),
+ edges=parse_flow_edges(live_version.edges),
+ )
+ chatbot_session = create_chatbot_session(
+ session,
+ chatbot_id=chatbot_id,
+ flow_version_id=live_version.id,
+ contact_ref=contact_ref,
+ active_node_id=entry_node.id,
+ )
+ return chatbot_session, live_version
+
+
+def get_latest_chatbot_session_summary(
+ *,
+ session: Session,
+ organization_id: int,
+ project_id: int,
+ contact_ref: str,
+ chatbot_id: UUID | None,
+) -> ChatbotSessionSummary | None:
+ row = get_latest_chatbot_session(
+ session,
+ organization_id=organization_id,
+ project_id=project_id,
+ contact_ref=contact_ref,
+ chatbot_id=chatbot_id,
+ )
+ if row is None:
+ return None
+ chatbot_session, chatbot, flow_version = row
+
+ prompt: str | None = None
+ active_node = find_flow_node(
+ parse_flow_nodes(flow_version.nodes), chatbot_session.active_node_id
+ )
+ if active_node is not None and active_node.type in AGENT_NODE_TYPES:
+ prompt = active_node.agent_data().prompt
+
+ return ChatbotSessionSummary(
+ session_id=chatbot_session.id,
+ chatbot_id=chatbot.id,
+ chatbot_name=chatbot.name,
+ flow_version=flow_version.version,
+ active_node_id=chatbot_session.active_node_id,
+ prompt=prompt,
+ contact_ref=chatbot_session.contact_ref,
+ status=chatbot_session.status,
+ completion_status=chatbot_session.completion_status,
+ collected_fields=chatbot_session.collected_fields,
+ inserted_at=chatbot_session.inserted_at,
+ updated_at=chatbot_session.updated_at,
+ )
+
+
+def _resolve_turn_node(
+ *, flow_version: ChatbotFlowVersion | None, active_node_id: str
+) -> _TurnNode:
+ """The runnable onboarding node the session is on; ChatbotInputError when it can't run."""
+ active_node = None
+ if flow_version is not None:
+ active_node = find_flow_node(
+ parse_flow_nodes(flow_version.nodes), active_node_id
+ )
+ if active_node is None:
+ error_message = (
+ f"[KAAPI] Active node '{active_node_id}' is not in the session's flow "
+ "version; start a new session."
+ )
+ raise ChatbotInputError(error_message)
+
+ if active_node.type != ChatbotFlowNodeTypeEnum.ONBOARDING:
+ error_message = (
+ f"[KAAPI] Active node '{active_node_id}' has type '{active_node.type.value}', "
+ f"which the chatbot runtime does not support yet; only "
+ f"'{ChatbotFlowNodeTypeEnum.ONBOARDING.value}' nodes can run."
+ )
+ raise ChatbotInputError(error_message)
+
+ node_data = active_node.agent_data()
+ if node_data.prompt is None or node_data.config_id is None:
+ error_message = (
+ f"[KAAPI] Active node '{active_node_id}' is missing data.prompt or "
+ "data.config_id; republish the flow and start a new session."
+ )
+ raise ChatbotInputError(error_message)
+
+ return _TurnNode(
+ node_id=active_node.id,
+ prompt=node_data.prompt,
+ config_id=node_data.config_id,
+ config_version=node_data.config_version,
+ )
+
+
+def _split_pending_turn(
+ stored_messages: list[ChatbotSessionMessage],
+) -> tuple[list[ChatbotMessageDict], str | None]:
+ """Split the stored transcript into (prior history, pending user message)."""
+ history: list[ChatbotMessageDict] = []
+ for stored in stored_messages:
+ # The system prompt lives on the flow node and is passed separately.
+ if stored.role == ChatbotMessageRoleEnum.SYSTEM:
+ continue
+ history.append({"role": stored.role.value, "content": stored.content})
+
+ if not history:
+ return history, None
+ if history[-1]["role"] != ChatbotMessageRoleEnum.USER:
+ # No pending user turn; run_chatbot_turn rejects this with ChatbotInputError.
+ return history, None
+ pending = history.pop()
+ return history, pending["content"]
+
+
+def _send_failure_webhook(
+ *,
+ session_id: UUID,
+ webhook_url: str,
+ webhook_secret: str | None,
+ error: str,
+) -> None:
+ envelope = APIResponse.failure_response(
+ error=error, data={"session_id": str(session_id)}
+ )
+ send_callback(webhook_url, envelope.model_dump(exclude_none=True), webhook_secret)
+ return
+
+
+def process_chatbot_session_message(
+ *,
+ session_id: UUID,
+ organization_id: int,
+ project_id: int,
+ webhook_url: str,
+) -> None:
+ """Run the pending chatbot turn for a session and POST the reply to `webhook_url`."""
+ webhook_secret = get_webhook_secret(project_id, organization_id)
+
+ # The DB session is closed before the LLM call so a slow provider doesn't
+ # pin a pooled connection for the whole turn.
+ with Session(engine) as session:
+ chatbot_session = get_chatbot_session(
+ session,
+ session_id=session_id,
+ organization_id=organization_id,
+ project_id=project_id,
+ )
+ if chatbot_session is None:
+ return
+ # The turn may have been queued before a newer session (or a chatbot delete) deactivated this one.
+ if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE:
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=SUPERSEDED_SESSION_ERROR,
+ )
+ return
+
+ stored_messages = list_chatbot_session_messages(session, session_id=session_id)
+ if (
+ stored_messages
+ and stored_messages[-1].role == ChatbotMessageRoleEnum.ASSISTANT
+ ):
+ return
+
+ chatbot_id = chatbot_session.chatbot_id
+ session_status = chatbot_session.status.value
+ collected_fields = dict(chatbot_session.collected_fields)
+ flow_version = get_chatbot_flow_version_by_id(
+ session, flow_version_id=chatbot_session.flow_version_id
+ )
+
+ try:
+ turn_node = _resolve_turn_node(
+ flow_version=flow_version,
+ active_node_id=chatbot_session.active_node_id,
+ )
+ model_settings: ChatbotModelSettings = resolve_chatbot_model_settings(
+ session=session,
+ organization_id=organization_id,
+ project_id=project_id,
+ config_id=turn_node.config_id,
+ config_version=turn_node.config_version,
+ max_tokens=None,
+ history_token_limit=None,
+ )
+ except (HTTPException, ChatbotInputError) as e:
+ error = str(e.detail) if isinstance(e, HTTPException) else str(e)
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=error,
+ )
+ return
+
+ history, user_message = _split_pending_turn(stored_messages)
+
+ try:
+ turn_result = asyncio.run(
+ run_chatbot_turn(
+ system_prompt=turn_node.prompt,
+ history=history,
+ user_message=user_message,
+ model_settings=model_settings,
+ collected_fields=collected_fields,
+ )
+ )
+ except (ChatbotInputError, ChatbotProviderError) as e:
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=str(e),
+ )
+ return
+
+ reply = turn_result.reply
+ completion_status = turn_result.completion_status
+ with Session(engine) as session:
+ create_chatbot_session_message(
+ session,
+ session_id=session_id,
+ role=ChatbotMessageRoleEnum.ASSISTANT,
+ content=reply,
+ node_id=turn_node.node_id,
+ )
+ update_chatbot_session_progress(
+ session,
+ session_id=session_id,
+ collected_fields=turn_result.collected_fields,
+ completion_status=completion_status,
+ )
+
+ envelope = APIResponse.success_response(
+ data={
+ "session_id": str(session_id),
+ "chatbot_id": str(chatbot_id),
+ "node_id": turn_node.node_id,
+ "reply": reply,
+ "status": session_status,
+ "completion_status": completion_status.value,
+ "collected_fields": turn_result.collected_fields,
+ }
+ )
+ send_callback(webhook_url, envelope.model_dump(exclude_none=True), webhook_secret)
+ return
diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py
new file mode 100644
index 000000000..bcc08759e
--- /dev/null
+++ b/backend/app/services/chatbot/types.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Any
+
+from app.models.chatbot import ChatbotCompletionStatusEnum
+
+ChatbotMessageDict = dict[str, str]
+
+
+@dataclass(frozen=True)
+class ChatbotTurnResult:
+ reply: str
+ collected_fields: dict[str, Any]
+ is_complete: bool
+
+ @property
+ def completion_status(self) -> ChatbotCompletionStatusEnum:
+ if self.is_complete:
+ return ChatbotCompletionStatusEnum.COMPLETE
+ return ChatbotCompletionStatusEnum.IN_PROGRESS
diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py
new file mode 100644
index 000000000..764f261ca
--- /dev/null
+++ b/backend/app/services/chatbot/utils.py
@@ -0,0 +1,48 @@
+"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node."""
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+from langchain_anthropic import ChatAnthropic
+
+AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
+
+DEFAULT_MAX_OUTPUT_TOKENS = 1024
+DEFAULT_THINKING: dict[str, Any] = {"type": "adaptive"}
+
+DEFAULT_HISTORY_TOKEN_LIMIT = 4000
+FLOW_KEEP_RECENT_MESSAGES = 6
+
+
+def default_thinking() -> dict[str, Any]:
+ return dict(DEFAULT_THINKING)
+
+
+@dataclass(frozen=True)
+class ChatbotModelSettings:
+ model: str
+ max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
+ reasoning_effort: AnthropicEffort | None = None
+ thinking: dict[str, Any] = field(default_factory=default_thinking)
+ history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT
+ api_key: str = field(default="", repr=False, compare=False)
+
+
+def get_chat_model(model_settings: ChatbotModelSettings) -> ChatAnthropic:
+ """LangChain's provider abstraction, so swapping providers later is a
+ config change here, not a rewrite of the node.
+ """
+ if not model_settings.api_key:
+ raise RuntimeError(
+ "[get_chat_model] model_settings has no api_key; build it with "
+ "resolve_chatbot_model_settings so the project's Anthropic credentials are loaded"
+ )
+ kwargs: dict[str, Any] = {
+ "model": model_settings.model,
+ "max_tokens": model_settings.max_tokens,
+ "thinking": model_settings.thinking,
+ "anthropic_api_key": model_settings.api_key,
+ }
+ if model_settings.reasoning_effort is not None:
+ kwargs["reasoning_effort"] = model_settings.reasoning_effort
+ return ChatAnthropic(**kwargs)
diff --git a/backend/app/tests/api/routes/chatbot/__init__.py b/backend/app/tests/api/routes/chatbot/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/tests/api/routes/chatbot/conftest.py b/backend/app/tests/api/routes/chatbot/conftest.py
new file mode 100644
index 000000000..31f3d5ba9
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/conftest.py
@@ -0,0 +1,61 @@
+import pytest
+from sqlmodel import Session
+
+from app.core.feature_flags import FeatureFlag
+from app.crud.feature_flag import (
+ create_feature_flag,
+ get_feature_flag,
+ update_feature_flag,
+)
+from app.models import Chatbot, Config
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import (
+ create_test_anthropic_config,
+ create_test_chatbot,
+ create_test_live_chatbot,
+)
+
+
+def set_chatbot_flag(db: Session, auth: TestAuthContext, *, enabled: bool) -> None:
+ scope = {
+ "session": db,
+ "key": FeatureFlag.CHATBOT.value,
+ "organization_id": auth.organization_id,
+ "project_id": auth.project_id,
+ }
+ if get_feature_flag(**scope) is None:
+ create_feature_flag(**scope, enabled=enabled)
+ else:
+ update_feature_flag(**scope, enabled=enabled)
+
+
+@pytest.fixture(autouse=True)
+def chatbot_feature_enabled(db: Session, user_api_key: TestAuthContext) -> None:
+ set_chatbot_flag(db, user_api_key, enabled=True)
+
+
+@pytest.fixture
+def config(db: Session, user_api_key: TestAuthContext) -> Config:
+ return create_test_anthropic_config(db, project_id=user_api_key.project_id)
+
+
+@pytest.fixture
+def chatbot(db: Session, user_api_key: TestAuthContext) -> Chatbot:
+ """A chatbot in the caller's project with no flow saved yet."""
+ return create_test_chatbot(db, project=user_api_key.project)
+
+
+@pytest.fixture
+def live_chatbot(db: Session, config: Config) -> Chatbot:
+ """A chatbot in the caller's project whose live flow is one onboarding node."""
+ return create_test_live_chatbot(db, config=config)[0]
+
+
+@pytest.fixture
+def foreign_chatbot(db: Session) -> Chatbot:
+ return create_test_chatbot(db)
+
+
+@pytest.fixture
+def foreign_live_chatbot(db: Session) -> Chatbot:
+ return create_test_live_chatbot(db, config=create_test_anthropic_config(db))[0]
diff --git a/backend/app/tests/api/routes/chatbot/test_chatbot.py b/backend/app/tests/api/routes/chatbot/test_chatbot.py
new file mode 100644
index 000000000..4d9261445
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_chatbot.py
@@ -0,0 +1,256 @@
+from uuid import UUID
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.models import (
+ Chatbot,
+ ChatbotCompletionStatusEnum,
+ ChatbotSession,
+ ChatbotSessionStatusEnum,
+)
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import (
+ CHATBOTS_URL,
+ LATEST_SESSION_URL,
+ UNKNOWN_CHATBOT_ID,
+ chatbot_url,
+ create_test_chatbot,
+ create_test_chatbot_session,
+)
+
+
+class TestCreateChatbot:
+ def test_creates_chatbot_in_callers_project(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ user_api_key: TestAuthContext,
+ ) -> None:
+ resp = client.post(
+ CHATBOTS_URL, headers=user_api_key_header, json={"name": "Onboarding"}
+ )
+
+ assert resp.status_code == 201
+ body = resp.json()["data"]
+ assert body["name"] == "Onboarding"
+ assert body["project_id"] == user_api_key.project_id
+ assert body["organization_id"] == user_api_key.organization_id
+ stored = db.get(Chatbot, UUID(body["id"]))
+ assert stored is not None
+ assert stored.name == "Onboarding"
+ assert stored.project_id == user_api_key.project_id
+
+ @pytest.mark.parametrize(
+ "payload",
+ [{}, {"name": ""}, {"name": "x" * 256}],
+ ids=["missing", "empty", "too-long"],
+ )
+ def test_invalid_name_returns_422(
+ self,
+ client: TestClient,
+ user_api_key_header: dict[str, str],
+ payload: dict[str, str],
+ ) -> None:
+ resp = client.post(CHATBOTS_URL, headers=user_api_key_header, json=payload)
+
+ assert resp.status_code == 422
+
+
+class TestListChatbots:
+ def test_lists_only_callers_non_deleted_chatbots(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ user_api_key: TestAuthContext,
+ chatbot: Chatbot,
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ deleted = create_test_chatbot(db, project=user_api_key.project)
+ assert (
+ client.delete(
+ chatbot_url(deleted.id), headers=user_api_key_header
+ ).status_code
+ == 200
+ )
+
+ resp = client.get(CHATBOTS_URL, headers=user_api_key_header)
+
+ assert resp.status_code == 200
+ ids = {item["id"] for item in resp.json()["data"]}
+ assert str(chatbot.id) in ids
+ assert str(foreign_chatbot.id) not in ids
+ assert str(deleted.id) not in ids
+
+ def test_skip_and_limit_page_newest_first(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ user_api_key: TestAuthContext,
+ ) -> None:
+ # The seeded project may already own chatbots, so page through a known head.
+ create_test_chatbot(db, project=user_api_key.project)
+ middle = create_test_chatbot(db, project=user_api_key.project)
+ newest = create_test_chatbot(db, project=user_api_key.project)
+
+ first = client.get(
+ CHATBOTS_URL, headers=user_api_key_header, params={"limit": 1}
+ )
+ second = client.get(
+ CHATBOTS_URL, headers=user_api_key_header, params={"skip": 1, "limit": 1}
+ )
+
+ assert [c["id"] for c in first.json()["data"]] == [str(newest.id)]
+ assert [c["id"] for c in second.json()["data"]] == [str(middle.id)]
+
+
+class TestGetChatbot:
+ def test_returns_chatbot(
+ self, client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot
+ ) -> None:
+ resp = client.get(chatbot_url(chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["id"] == str(chatbot.id)
+ assert body["name"] == chatbot.name
+
+ def test_unknown_returns_404(
+ self, client: TestClient, user_api_key_header: dict[str, str]
+ ) -> None:
+ resp = client.get(chatbot_url(UNKNOWN_CHATBOT_ID), headers=user_api_key_header)
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Chatbot not found"
+
+ def test_other_projects_chatbot_returns_404(
+ self,
+ client: TestClient,
+ user_api_key_header: dict[str, str],
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ resp = client.get(chatbot_url(foreign_chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 404
+
+
+class TestUpdateChatbot:
+ def test_renames_chatbot(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ chatbot: Chatbot,
+ ) -> None:
+ resp = client.patch(
+ chatbot_url(chatbot.id),
+ headers=user_api_key_header,
+ json={"name": "Renamed"},
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["name"] == "Renamed"
+ db.expire_all()
+ stored = db.get(Chatbot, chatbot.id)
+ assert stored is not None
+ assert stored.name == "Renamed"
+
+ def test_other_projects_chatbot_returns_404_and_is_unchanged(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ original_name = foreign_chatbot.name
+
+ resp = client.patch(
+ chatbot_url(foreign_chatbot.id),
+ headers=user_api_key_header,
+ json={"name": "Hijack"},
+ )
+
+ assert resp.status_code == 404
+ db.expire_all()
+ stored = db.get(Chatbot, foreign_chatbot.id)
+ assert stored is not None
+ assert stored.name == original_name
+
+
+class TestDeleteChatbot:
+ def test_soft_deletes_and_hides_chatbot(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ chatbot: Chatbot,
+ ) -> None:
+ resp = client.delete(chatbot_url(chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 200
+ assert resp.json()["data"] == {"message": "Chatbot deleted successfully"}
+ db.expire_all()
+ stored = db.get(Chatbot, chatbot.id)
+ assert stored is not None
+ assert stored.deleted_at is not None
+ assert (
+ client.get(chatbot_url(chatbot.id), headers=user_api_key_header).status_code
+ == 404
+ )
+ assert (
+ client.patch(
+ chatbot_url(chatbot.id), headers=user_api_key_header, json={"name": "x"}
+ ).status_code
+ == 404
+ )
+ assert (
+ client.delete(
+ chatbot_url(chatbot.id), headers=user_api_key_header
+ ).status_code
+ == 404
+ )
+
+ def test_deactivates_active_sessions_and_hides_them_from_latest(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ live_chatbot: Chatbot,
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=live_chatbot)
+
+ resp = client.delete(chatbot_url(live_chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 200
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ assert stored.status == ChatbotSessionStatusEnum.INACTIVE
+ assert stored.completion_status == ChatbotCompletionStatusEnum.ABANDONED
+ latest = client.get(
+ LATEST_SESSION_URL,
+ headers=user_api_key_header,
+ params={"contact_ref": chatbot_session.contact_ref},
+ )
+ assert latest.status_code == 404
+
+ def test_other_projects_chatbot_returns_404_and_is_kept(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ resp = client.delete(
+ chatbot_url(foreign_chatbot.id), headers=user_api_key_header
+ )
+
+ assert resp.status_code == 404
+ db.expire_all()
+ stored = db.get(Chatbot, foreign_chatbot.id)
+ assert stored is not None
+ assert stored.deleted_at is None
diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
new file mode 100644
index 000000000..a75c823e5
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
@@ -0,0 +1,62 @@
+from uuid import UUID
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.tests.api.routes.chatbot.conftest import set_chatbot_flag
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import CHATBOTS_URL, UNKNOWN_CHATBOT_ID
+
+UNKNOWN_SESSION_ID = UUID("00000000-0000-0000-0000-000000000001")
+
+
+@pytest.mark.parametrize(
+ ("method", "path", "kwargs"),
+ [
+ ("post", "", {"json": {"name": "bot"}}),
+ ("get", "", {}),
+ ("get", f"/{UNKNOWN_CHATBOT_ID}", {}),
+ ("patch", f"/{UNKNOWN_CHATBOT_ID}", {"json": {"name": "bot"}}),
+ ("delete", f"/{UNKNOWN_CHATBOT_ID}", {}),
+ ("get", f"/{UNKNOWN_CHATBOT_ID}/flow", {}),
+ ("put", f"/{UNKNOWN_CHATBOT_ID}/flow", {"json": {"nodes": []}}),
+ ("post", f"/{UNKNOWN_CHATBOT_ID}/flow/publish", {}),
+ ("post", f"/{UNKNOWN_CHATBOT_ID}/sessions", {"json": {"contact_ref": "c"}}),
+ ("get", "/sessions/latest", {"params": {"contact_ref": "c"}}),
+ ("post", f"/sessions/{UNKNOWN_SESSION_ID}/message", {"json": {}}),
+ ],
+ ids=[
+ "create_chatbot",
+ "list_chatbots",
+ "get_chatbot",
+ "update_chatbot",
+ "delete_chatbot",
+ "get_flow",
+ "save_flow",
+ "publish_flow",
+ "create_session",
+ "get_latest_session",
+ "send_message",
+ ],
+)
+def test_chatbot_routes_return_403_when_flag_disabled(
+ client: TestClient,
+ db: Session,
+ user_api_key: TestAuthContext,
+ user_api_key_header: dict[str, str],
+ method: str,
+ path: str,
+ kwargs: dict,
+) -> None:
+ set_chatbot_flag(db, user_api_key, enabled=False)
+
+ resp = client.request(
+ method,
+ f"{CHATBOTS_URL}{path}",
+ headers=user_api_key_header,
+ **kwargs,
+ )
+
+ assert resp.status_code == 403
+ assert "CHATBOT" in resp.json()["error"]
diff --git a/backend/app/tests/api/routes/chatbot/test_flow.py b/backend/app/tests/api/routes/chatbot/test_flow.py
new file mode 100644
index 000000000..06c10b9f8
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_flow.py
@@ -0,0 +1,972 @@
+from collections.abc import Callable
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+import pytest
+from fastapi.testclient import TestClient
+from httpx import Response
+from sqlmodel import Session
+
+from app.models import Chatbot, Config
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import (
+ CHATBOTS_URL,
+ ONBOARDING_NODE_ID,
+ UNKNOWN_CHATBOT_ID,
+ add_test_config_version,
+ build_flow_edge,
+ build_flow_node,
+ build_onboarding_node,
+ create_test_anthropic_config,
+ create_test_chatbot,
+ create_test_flow_version,
+ flow_url,
+ list_stored_flow_versions,
+ publish_url,
+ random_contact_ref,
+ sessions_url,
+)
+from app.tests.utils.test_data import create_test_config
+
+INVALID_FLOW_PREFIX = "[KAAPI] Chatbot flow is invalid: "
+NO_DRAFT_MESSAGE = "[KAAPI] Chatbot has no draft flow to publish; save the flow first."
+MISSING_CREDENTIALS = "Anthropic credentials are not configured for this project"
+INPUT_NODE_ID = "input-1"
+OUTPUT_NODE_ID = "output-1"
+
+
+@pytest.fixture
+def save(
+ client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot
+) -> Callable[..., Response]:
+ def _save(
+ nodes: list[dict[str, Any]], edges: list[dict[str, Any]] | None = None
+ ) -> Response:
+ return client.put(
+ flow_url(chatbot.id),
+ headers=user_api_key_header,
+ json={"nodes": nodes, "edges": edges or []},
+ )
+
+ return _save
+
+
+@pytest.fixture
+def publish(
+ client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot
+) -> Callable[[], Response]:
+ return lambda: client.post(publish_url(chatbot.id), headers=user_api_key_header)
+
+
+@pytest.fixture
+def uncredentialed_config(db: Session, user_api_key: TestAuthContext) -> Config:
+ """An Anthropic config in the caller's project, which has no Anthropic credentials."""
+ return create_test_anthropic_config(
+ db, project_id=user_api_key.project_id, with_anthropic_credentials=False
+ )
+
+
+@pytest.fixture
+def live_v1(config: Config, save: Callable[..., Response]) -> dict[str, Any]:
+ """A valid first save, which goes live as v1; later saves are drafts."""
+ resp = save([build_onboarding_node(config_id=config.id)])
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert (body["version"], body["is_live"]) == (1, True)
+ return body
+
+
+class TestSaveChatbotFlow:
+ def test_first_save_creates_live_version_1_with_pinned_config_version(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ ) -> None:
+ add_test_config_version(db, config)
+
+ resp = save(
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_onboarding_node(config_id=config.id, config_version=None),
+ ],
+ [build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID)],
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["version"] == 1
+ assert body["is_live"] is True
+ assert body["chatbot_id"] == str(chatbot.id)
+ assert body["nodes"][1]["data"]["config_version"] == 2
+ [stored] = list_stored_flow_versions(db, chatbot)
+ assert (stored.version, stored.is_live) == (1, True)
+ assert stored.nodes[1]["data"]["config_version"] == 2
+ assert stored.nodes[1]["data"]["label"] == "Beneficiary Info Extractor"
+ assert "config_version" not in stored.nodes[0]["data"]
+
+ def test_invalid_first_save_returns_422_and_stores_nothing(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ ) -> None:
+ resp = save([build_onboarding_node(config_id=config.id, prompt=None)])
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error
+ assert list_stored_flow_versions(db, chatbot) == []
+ assert (
+ client.get(flow_url(chatbot.id), headers=user_api_key_header).status_code
+ == 404
+ )
+
+ def test_session_can_start_right_after_first_save(
+ self,
+ client: TestClient,
+ user_api_key_header: dict[str, str],
+ chatbot: Chatbot,
+ live_v1: dict[str, Any],
+ ) -> None:
+ resp = client.post(
+ sessions_url(chatbot.id),
+ headers=user_api_key_header,
+ json={"contact_ref": random_contact_ref()},
+ )
+
+ assert resp.status_code == 201
+ body = resp.json()["data"]
+ assert body["flow_version"] == 1
+ assert body["active_node_id"] == ONBOARDING_NODE_ID
+
+ def test_save_overwrites_existing_draft_in_place(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ draft = save([build_onboarding_node(config_id=config.id, prompt="Draft.")])
+ draft_body = draft.json()["data"]
+ new_nodes = [build_onboarding_node(config_id=config.id, prompt="Ask grade.")]
+
+ resp = save(new_nodes)
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["id"] == draft_body["id"]
+ assert resp.json()["data"]["version"] == 2
+ assert resp.json()["data"]["is_live"] is False
+ live, stored_draft = list_stored_flow_versions(db, chatbot)
+ assert str(live.id) == live_v1["id"]
+ assert live.is_live is True
+ assert stored_draft.nodes == new_nodes
+
+ def test_save_after_publish_starts_new_draft_and_keeps_live_version(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ ) -> None:
+ live_nodes = [build_onboarding_node(config_id=config.id)]
+ create_test_flow_version(db, chatbot=chatbot, nodes=live_nodes, is_live=True)
+ draft_nodes = [build_onboarding_node(config_id=config.id, prompt="Ask grade.")]
+
+ resp = save(draft_nodes)
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["version"] == 2
+ assert resp.json()["data"]["is_live"] is False
+ live, draft = list_stored_flow_versions(db, chatbot)
+ assert (live.version, live.is_live, live.nodes) == (1, True, live_nodes)
+ assert (draft.version, draft.is_live, draft.nodes) == (2, False, draft_nodes)
+
+ def test_react_flow_keys_and_extra_ui_keys_round_trip(
+ self,
+ client: TestClient,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ onboarding = build_onboarding_node(config_id=config.id)
+ onboarding["measured"] = {"width": 240, "height": 80}
+ onboarding["selected"] = True
+ onboarding["position"] = {"x": 12.5, "y": -3, "snapped": False}
+ edge = {
+ **build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID),
+ "sourceHandle": "out",
+ "targetHandle": "in",
+ "animated": True,
+ "data": {"intent": "greet"},
+ }
+
+ save([build_flow_node(INPUT_NODE_ID, "input"), onboarding], [edge])
+ resp = client.get(flow_url(chatbot.id), headers=user_api_key_header)
+
+ body = resp.json()["data"]
+ assert (body["version"], body["is_live"]) == (2, False)
+ returned_onboarding = body["nodes"][1]
+ assert returned_onboarding["measured"] == {"width": 240, "height": 80}
+ assert returned_onboarding["selected"] is True
+ assert returned_onboarding["position"] == {
+ "x": 12.5,
+ "y": -3,
+ "snapped": False,
+ }
+ assert returned_onboarding["data"]["label"] == "Beneficiary Info Extractor"
+ assert body["edges"] == [
+ {
+ "id": "e1",
+ "source": INPUT_NODE_ID,
+ "target": ONBOARDING_NODE_ID,
+ "sourceHandle": "out",
+ "targetHandle": "in",
+ "animated": True,
+ "data": {"intent": "greet"},
+ }
+ ]
+
+ @pytest.mark.parametrize(
+ "bad_node",
+ [
+ {"id": "n1", "type": "bogus", "position": {"x": 0, "y": 0}},
+ {"id": "", "type": "input", "position": {"x": 0, "y": 0}},
+ {"id": "n1", "type": "input"},
+ {
+ "id": "n1",
+ "type": "onboarding",
+ "position": {"x": 0, "y": 0},
+ "data": {"config_version": 0},
+ },
+ ],
+ ids=["unknown-type", "empty-id", "missing-position", "config-version-zero"],
+ )
+ def test_malformed_node_returns_422_and_saves_nothing(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ bad_node: dict[str, Any],
+ ) -> None:
+ resp = save([bad_node])
+
+ assert resp.status_code == 422
+ assert list_stored_flow_versions(db, chatbot) == []
+
+ def test_draft_save_does_not_validate_publish_rules(
+ self, save: Callable[..., Response], live_v1: dict[str, Any]
+ ) -> None:
+ coordinator = build_flow_node("router", "coordinator", prompt="Route")
+
+ resp = save([coordinator, build_flow_node(INPUT_NODE_ID, "input")])
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["is_live"] is False
+
+ def test_unknown_chatbot_returns_404(
+ self, client: TestClient, user_api_key_header: dict[str, str]
+ ) -> None:
+ resp = client.put(
+ flow_url(UNKNOWN_CHATBOT_ID),
+ headers=user_api_key_header,
+ json={"nodes": []},
+ )
+
+ assert resp.status_code == 404
+
+ def test_other_projects_chatbot_returns_404_and_saves_nothing(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ resp = client.put(
+ flow_url(foreign_chatbot.id),
+ headers=user_api_key_header,
+ json={"nodes": [build_flow_node(INPUT_NODE_ID, "input")]},
+ )
+
+ assert resp.status_code == 404
+ assert list_stored_flow_versions(db, foreign_chatbot) == []
+
+
+class TestGetChatbotFlow:
+ def test_no_versions_returns_404(
+ self, client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot
+ ) -> None:
+ resp = client.get(flow_url(chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Chatbot has no flow yet; save a flow first"
+
+ def test_default_returns_live_when_no_draft(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ ) -> None:
+ live = create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[build_onboarding_node(config_id=config.id)],
+ is_live=True,
+ )
+
+ resp = client.get(flow_url(chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["id"] == str(live.id)
+ assert resp.json()["data"]["is_live"] is True
+
+ def test_default_returns_draft_over_live(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ ) -> None:
+ nodes = [build_onboarding_node(config_id=config.id)]
+ create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True)
+ draft = create_test_flow_version(db, chatbot=chatbot, nodes=nodes)
+
+ resp = client.get(flow_url(chatbot.id), headers=user_api_key_header)
+
+ assert resp.json()["data"]["id"] == str(draft.id)
+ assert resp.json()["data"]["version"] == 2
+ assert resp.json()["data"]["is_live"] is False
+
+ def test_version_param_returns_that_version(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ ) -> None:
+ nodes = [build_onboarding_node(config_id=config.id)]
+ first = create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True)
+ create_test_flow_version(db, chatbot=chatbot, nodes=nodes)
+
+ resp = client.get(
+ flow_url(chatbot.id), headers=user_api_key_header, params={"version": 1}
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["id"] == str(first.id)
+
+ def test_unknown_version_returns_404(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ config: Config,
+ chatbot: Chatbot,
+ ) -> None:
+ create_test_flow_version(
+ db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)]
+ )
+
+ resp = client.get(
+ flow_url(chatbot.id), headers=user_api_key_header, params={"version": 7}
+ )
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Flow version 7 not found for this chatbot"
+
+ def test_other_projects_chatbot_returns_404(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ foreign_chatbot: Chatbot,
+ ) -> None:
+ create_test_flow_version(
+ db, chatbot=foreign_chatbot, nodes=[build_flow_node(INPUT_NODE_ID, "input")]
+ )
+
+ resp = client.get(flow_url(foreign_chatbot.id), headers=user_api_key_header)
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Chatbot not found"
+
+
+class TestPublishChatbotFlow:
+ def test_publishes_draft_and_unsets_previous_live(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ ) -> None:
+ create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[build_onboarding_node(config_id=config.id)],
+ is_live=True,
+ )
+ save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")])
+
+ resp = publish()
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["version"] == 2
+ assert resp.json()["data"]["is_live"] is True
+ previous, published = list_stored_flow_versions(db, chatbot)
+ assert previous.is_live is False
+ assert published.is_live is True
+
+ def test_missing_config_version_is_pinned_to_latest_and_stored(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ add_test_config_version(db, config)
+ save(
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_onboarding_node(config_id=config.id, config_version=None),
+ ],
+ [build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID)],
+ )
+
+ resp = publish()
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["version"] == 2
+ assert resp.json()["data"]["nodes"][1]["data"]["config_version"] == 2
+ _, stored = list_stored_flow_versions(db, chatbot)
+ assert stored.is_live is True
+ assert stored.nodes[1]["data"]["config_version"] == 2
+ assert stored.nodes[1]["data"]["label"] == "Beneficiary Info Extractor"
+ assert "config_version" not in stored.nodes[0]["data"]
+
+ def test_explicit_config_version_is_kept(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ add_test_config_version(db, config)
+ save([build_onboarding_node(config_id=config.id, config_version=1)])
+
+ resp = publish()
+
+ assert resp.status_code == 200
+ _, stored = list_stored_flow_versions(db, chatbot)
+ assert (stored.version, stored.is_live) == (2, True)
+ assert stored.nodes[0]["data"]["config_version"] == 1
+
+ def test_no_versions_returns_409(self, publish: Callable[[], Response]) -> None:
+ resp = publish()
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == NO_DRAFT_MESSAGE
+
+ def test_publish_right_after_first_save_returns_409(
+ self, publish: Callable[[], Response], live_v1: dict[str, Any]
+ ) -> None:
+ resp = publish()
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == NO_DRAFT_MESSAGE
+
+ def test_latest_already_live_returns_409(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ publish: Callable[[], Response],
+ ) -> None:
+ create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[build_onboarding_node(config_id=config.id)],
+ is_live=True,
+ )
+
+ resp = publish()
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == NO_DRAFT_MESSAGE
+
+ def test_unknown_chatbot_returns_404(
+ self, client: TestClient, user_api_key_header: dict[str, str]
+ ) -> None:
+ resp = client.post(publish_url(UNKNOWN_CHATBOT_ID), headers=user_api_key_header)
+
+ assert resp.status_code == 404
+
+
+# Each case builds (nodes, edges) from the project's valid Anthropic config id.
+FlowBuilder = Callable[[UUID], tuple[list[dict[str, Any]], list[dict[str, Any]]]]
+
+INVALID_FLOW_CASES: dict[str, tuple[FlowBuilder, str]] = {
+ "duplicate-node-ids": (
+ lambda cid: (
+ [
+ build_onboarding_node(config_id=cid),
+ build_onboarding_node(config_id=cid),
+ ],
+ [],
+ ),
+ f"duplicate node ids: {ONBOARDING_NODE_ID}",
+ ),
+ "two-input-nodes": (
+ lambda cid: (
+ [
+ build_flow_node("in-a", "input"),
+ build_flow_node("in-b", "input"),
+ build_onboarding_node(config_id=cid),
+ ],
+ [],
+ ),
+ "at most 1 input node allowed (found 2)",
+ ),
+ "two-output-nodes": (
+ lambda cid: (
+ [
+ build_flow_node("out-a", "output"),
+ build_flow_node("out-b", "output"),
+ build_onboarding_node(config_id=cid),
+ ],
+ [],
+ ),
+ "at most 1 output node allowed (found 2)",
+ ),
+ "edge-to-unknown-node": (
+ lambda cid: (
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_onboarding_node(config_id=cid),
+ ],
+ [build_flow_edge("e1", INPUT_NODE_ID, "ghost")],
+ ),
+ "edge 'e1' target 'ghost' is not a node in the flow",
+ ),
+ "edge-from-unknown-node": (
+ lambda cid: (
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_onboarding_node(config_id=cid),
+ ],
+ [
+ build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID),
+ build_flow_edge("e2", "ghost", ONBOARDING_NODE_ID),
+ ],
+ ),
+ "edge 'e2' source 'ghost' is not a node in the flow",
+ ),
+ "coordinator-node": (
+ lambda cid: (
+ [build_onboarding_node(config_id=cid, node_type="coordinator")],
+ [],
+ ),
+ f"node '{ONBOARDING_NODE_ID}' has type 'coordinator', which is not supported yet",
+ ),
+ "rag-node": (
+ lambda cid: ([build_onboarding_node(config_id=cid, node_type="rag")], []),
+ f"node '{ONBOARDING_NODE_ID}' has type 'rag', which is not supported yet",
+ ),
+ "missing-config-id": (
+ lambda cid: ([build_onboarding_node(config_id=None)], []),
+ f"node '{ONBOARDING_NODE_ID}' is missing data.config_id",
+ ),
+ "missing-prompt": (
+ lambda cid: ([build_onboarding_node(config_id=cid, prompt=None)], []),
+ f"node '{ONBOARDING_NODE_ID}' is missing data.prompt",
+ ),
+ "blank-prompt": (
+ lambda cid: ([build_onboarding_node(config_id=cid, prompt=" ")], []),
+ f"node '{ONBOARDING_NODE_ID}' is missing data.prompt",
+ ),
+ "unknown-config-version": (
+ lambda cid: ([build_onboarding_node(config_id=cid, config_version=99)], []),
+ f"node '{ONBOARDING_NODE_ID}': Version with number '99' not found",
+ ),
+ "no-edges-two-agents": (
+ lambda cid: (
+ [
+ build_onboarding_node(config_id=cid),
+ build_onboarding_node(config_id=cid, node_id="second"),
+ ],
+ [],
+ ),
+ "a flow without edges must contain exactly one agent node (found 2)",
+ ),
+ "no-edges-no-agent": (
+ lambda cid: ([build_flow_node(INPUT_NODE_ID, "input")], []),
+ "a flow without edges must contain exactly one agent node (found 0)",
+ ),
+ "edges-without-input": (
+ lambda cid: (
+ [
+ build_onboarding_node(config_id=cid),
+ build_flow_node(OUTPUT_NODE_ID, "output"),
+ ],
+ [build_flow_edge("e1", ONBOARDING_NODE_ID, OUTPUT_NODE_ID)],
+ ),
+ "a flow with edges must contain exactly one input node (found 0)",
+ ),
+ "input-with-two-outgoing-edges": (
+ lambda cid: (
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_onboarding_node(config_id=cid),
+ build_onboarding_node(config_id=cid, node_id="second"),
+ ],
+ [
+ build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID),
+ build_flow_edge("e2", INPUT_NODE_ID, "second"),
+ ],
+ ),
+ f"input node '{INPUT_NODE_ID}' must have exactly one outgoing edge (found 2)",
+ ),
+ "input-edge-to-output": (
+ lambda cid: (
+ [
+ build_flow_node(INPUT_NODE_ID, "input"),
+ build_flow_node(OUTPUT_NODE_ID, "output"),
+ build_onboarding_node(config_id=cid),
+ ],
+ [
+ build_flow_edge("e1", INPUT_NODE_ID, OUTPUT_NODE_ID),
+ build_flow_edge("e2", ONBOARDING_NODE_ID, OUTPUT_NODE_ID),
+ ],
+ ),
+ f"the input node's edge must lead to an agent node (target '{OUTPUT_NODE_ID}')",
+ ),
+}
+
+
+# A representative subset: the first save shares publish's validator, so the full
+# matrix is covered by the publish tests below.
+FIRST_SAVE_INVALID_CASES = [
+ "coordinator-node",
+ "duplicate-node-ids",
+ "input-edge-to-output",
+ "no-edges-no-agent",
+ "unknown-config-version",
+]
+
+
+class TestFirstSaveValidation:
+ @pytest.mark.parametrize(
+ "case", FIRST_SAVE_INVALID_CASES, ids=FIRST_SAVE_INVALID_CASES
+ )
+ def test_invalid_first_save_returns_422_and_stores_nothing(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ case: str,
+ ) -> None:
+ build_flow, expected_problem = INVALID_FLOW_CASES[case]
+ nodes, edges = build_flow(config.id)
+
+ resp = save(nodes, edges)
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert expected_problem in error
+ assert list_stored_flow_versions(db, chatbot) == []
+
+
+class TestPublishChatbotFlowValidation:
+ @pytest.mark.parametrize(
+ "case", sorted(INVALID_FLOW_CASES), ids=sorted(INVALID_FLOW_CASES)
+ )
+ def test_invalid_flow_returns_422_and_stays_draft(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ case: str,
+ ) -> None:
+ build_flow, expected_problem = INVALID_FLOW_CASES[case]
+ nodes, edges = build_flow(config.id)
+ assert save(nodes, edges).status_code == 200
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert expected_problem in error
+ live, draft = list_stored_flow_versions(db, chatbot)
+ assert (live.version, live.is_live) == (1, True)
+ assert (draft.version, draft.is_live) == (2, False)
+
+ def test_config_from_another_project_returns_422(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ foreign_config = create_test_anthropic_config(db)
+ save([build_onboarding_node(config_id=foreign_config.id)])
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert f"node '{ONBOARDING_NODE_ID}': " in error
+ assert str(foreign_config.id) in error
+ _, draft = list_stored_flow_versions(db, chatbot)
+ assert draft.is_live is False
+
+ def test_non_anthropic_config_returns_422(
+ self,
+ db: Session,
+ user_api_key: TestAuthContext,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ openai_config = create_test_config(
+ db, project_id=user_api_key.project_id, use_kaapi_schema=True
+ )
+ save([build_onboarding_node(config_id=openai_config.id)])
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert f"node '{ONBOARDING_NODE_ID}': Chatbot only supports configs" in error
+ _, draft = list_stored_flow_versions(db, chatbot)
+ assert draft.is_live is False
+
+ def test_all_problems_are_reported_together(
+ self,
+ config: Config,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ save(
+ [
+ build_flow_node("in-a", "input"),
+ build_flow_node("in-b", "input"),
+ build_onboarding_node(
+ config_id=config.id, node_id="router", node_type="coordinator"
+ ),
+ build_onboarding_node(config_id=config.id, prompt=None),
+ ]
+ )
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert "at most 1 input node allowed (found 2)" in error
+ assert "node 'router' has type 'coordinator'" in error
+ assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error
+ assert error.count(INVALID_FLOW_PREFIX) == 1
+
+
+class TestMissingAnthropicCredentials:
+ def test_first_save_returns_422_and_stores_nothing(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ uncredentialed_config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ ) -> None:
+ resp = save([build_onboarding_node(config_id=uncredentialed_config.id)])
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert MISSING_CREDENTIALS in error
+ assert error.count("[KAAPI]") == 1
+ assert list_stored_flow_versions(db, chatbot) == []
+ assert (
+ client.get(flow_url(chatbot.id), headers=user_api_key_header).status_code
+ == 404
+ )
+
+ def test_publish_returns_422_and_stays_draft(
+ self,
+ db: Session,
+ uncredentialed_config: Config,
+ chatbot: Chatbot,
+ publish: Callable[[], Response],
+ ) -> None:
+ # Stored directly: the credentials were removed after the flow went live.
+ nodes = [build_onboarding_node(config_id=uncredentialed_config.id)]
+ create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True)
+ create_test_flow_version(db, chatbot=chatbot, nodes=nodes)
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ error = resp.json()["error"]
+ assert error.startswith(INVALID_FLOW_PREFIX)
+ assert MISSING_CREDENTIALS in error
+ live, draft = list_stored_flow_versions(db, chatbot)
+ assert (live.version, live.is_live) == (1, True)
+ assert (draft.version, draft.is_live) == (2, False)
+
+
+def _stored_updated_at(db: Session, chatbot: Chatbot) -> datetime:
+ db.expire_all()
+ stored = db.get(Chatbot, chatbot.id)
+ assert stored is not None
+ return stored.updated_at
+
+
+def _newest_chatbot_id(client: TestClient, headers: dict[str, str]) -> str:
+ resp = client.get(CHATBOTS_URL, headers=headers, params={"limit": 1})
+ assert resp.status_code == 200
+ return resp.json()["data"][0]["id"]
+
+
+SaveBranchSetup = Callable[[Session, Chatbot, Config, Callable[..., Response]], None]
+
+
+def _no_flow_yet(
+ db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response]
+) -> None:
+ pass
+
+
+def _live_and_draft(
+ db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response]
+) -> None:
+ assert save([build_onboarding_node(config_id=config.id)]).status_code == 200
+ assert (
+ save([build_onboarding_node(config_id=config.id, prompt="Draft.")]).status_code
+ == 200
+ )
+
+
+def _live_only(
+ db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response]
+) -> None:
+ create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[build_onboarding_node(config_id=config.id)],
+ is_live=True,
+ )
+
+
+SAVE_BRANCH_SETUPS: dict[str, SaveBranchSetup] = {
+ "first-live-save": _no_flow_yet,
+ "draft-overwrite": _live_and_draft,
+ "new-draft-after-live": _live_only,
+}
+
+
+class TestFlowEditsBumpChatbotUpdatedAt:
+ @pytest.mark.parametrize(
+ "branch", sorted(SAVE_BRANCH_SETUPS), ids=sorted(SAVE_BRANCH_SETUPS)
+ )
+ def test_save_moves_chatbot_to_top_of_list(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ user_api_key: TestAuthContext,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ branch: str,
+ ) -> None:
+ SAVE_BRANCH_SETUPS[branch](db, chatbot, config, save)
+ newer = create_test_chatbot(db, project=user_api_key.project)
+ assert _newest_chatbot_id(client, user_api_key_header) == str(newer.id)
+ before = _stored_updated_at(db, chatbot)
+
+ resp = save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")])
+
+ assert resp.status_code == 200
+ assert _stored_updated_at(db, chatbot) > before
+ assert _newest_chatbot_id(client, user_api_key_header) == str(chatbot.id)
+
+ def test_publish_moves_chatbot_to_top_of_list(
+ self,
+ client: TestClient,
+ db: Session,
+ user_api_key_header: dict[str, str],
+ user_api_key: TestAuthContext,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")])
+ newer = create_test_chatbot(db, project=user_api_key.project)
+ assert _newest_chatbot_id(client, user_api_key_header) == str(newer.id)
+ before = _stored_updated_at(db, chatbot)
+
+ resp = publish()
+
+ assert resp.status_code == 200
+ assert _stored_updated_at(db, chatbot) > before
+ assert _newest_chatbot_id(client, user_api_key_header) == str(chatbot.id)
+
+ def test_rejected_first_save_leaves_updated_at_unchanged(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ ) -> None:
+ before = _stored_updated_at(db, chatbot)
+
+ resp = save([build_onboarding_node(config_id=config.id, prompt=None)])
+
+ assert resp.status_code == 422
+ assert _stored_updated_at(db, chatbot) == before
+
+ def test_rejected_publish_leaves_updated_at_unchanged(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ save: Callable[..., Response],
+ publish: Callable[[], Response],
+ live_v1: dict[str, Any],
+ ) -> None:
+ save([build_onboarding_node(config_id=config.id, prompt=None)])
+ before = _stored_updated_at(db, chatbot)
+
+ resp = publish()
+
+ assert resp.status_code == 422
+ assert _stored_updated_at(db, chatbot) == before
diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py
new file mode 100644
index 000000000..94e0b8448
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -0,0 +1,609 @@
+import socket
+from collections.abc import Callable
+from typing import Any
+from uuid import UUID, uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from httpx import Response
+from sqlmodel import Session
+
+from app.api.routes.chatbot import session as session_routes
+from app.crud.chatbot import (
+ delete_chatbot,
+ list_chatbot_session_messages,
+ update_chatbot_session_progress,
+)
+from app.models import (
+ Chatbot,
+ ChatbotCompletionStatusEnum,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionStatusEnum,
+ Config,
+)
+from app.services.chatbot import session as session_service
+from app.services.chatbot.exceptions import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
+)
+from app.services.chatbot.session import NO_LIVE_FLOW_MESSAGE
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import (
+ DEFAULT_SYSTEM_PROMPT,
+ LATEST_SESSION_URL,
+ ONBOARDING_NODE_ID,
+ UNKNOWN_CHATBOT_ID,
+ WEBHOOK_URL,
+ add_test_chatbot_messages,
+ build_flow_edge,
+ build_flow_node,
+ build_onboarding_node,
+ create_test_chatbot_session,
+ create_test_flow_version,
+ create_test_live_chatbot,
+ list_stored_sessions_for_contact,
+ message_url,
+ random_contact_ref,
+ sessions_url,
+)
+
+PUBLIC_IP = "93.184.216.34"
+
+
+def _supersede(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession:
+ chatbot = db.get(Chatbot, chatbot_session.chatbot_id)
+ assert chatbot is not None
+ return create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=chatbot_session.contact_ref
+ )
+
+
+@pytest.fixture
+def chatbot_session(db: Session, live_chatbot: Chatbot) -> ChatbotSession:
+ return create_test_chatbot_session(db, chatbot=live_chatbot)
+
+
+@pytest.fixture
+def create_session(
+ client: TestClient, user_api_key_header: dict[str, str]
+) -> Callable[[UUID, dict[str, Any]], Response]:
+ return lambda chatbot_id, payload: client.post(
+ sessions_url(chatbot_id), headers=user_api_key_header, json=payload
+ )
+
+
+@pytest.fixture
+def get_latest(
+ client: TestClient, user_api_key_header: dict[str, str]
+) -> Callable[..., Response]:
+ def _get_latest(contact_ref: str, chatbot_id: UUID | None = None) -> Response:
+ params = {"contact_ref": contact_ref}
+ if chatbot_id is not None:
+ params["chatbot_id"] = str(chatbot_id)
+ return client.get(
+ LATEST_SESSION_URL, headers=user_api_key_header, params=params
+ )
+
+ return _get_latest
+
+
+@pytest.fixture
+def send_message(
+ client: TestClient, user_api_key_header: dict[str, str]
+) -> Callable[..., Response]:
+ def _send_message(session_id: UUID, **body: str) -> Response:
+ return client.post(
+ message_url(session_id),
+ headers=user_api_key_header,
+ json={"webhook_url": WEBHOOK_URL, **body},
+ )
+
+ return _send_message
+
+
+@pytest.fixture
+def enqueued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
+ calls: list[dict[str, Any]] = []
+ # The Celery broker is the external boundary; patched where the route imported it.
+ monkeypatch.setattr(
+ session_routes,
+ "start_chatbot_session_message",
+ lambda **kwargs: calls.append(kwargs) or "task-id",
+ )
+ # validate_callback_url resolves DNS; pin it to a public address.
+ monkeypatch.setattr(
+ socket,
+ "getaddrinfo",
+ lambda *_args, **_kwargs: [
+ (socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 443))
+ ],
+ )
+ return calls
+
+
+class TestCreateChatbotSession:
+ def test_creates_session_pinned_to_live_flow_and_entry_node(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ ) -> None:
+ live = create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[
+ build_flow_node("start", "input"),
+ build_onboarding_node(config_id=config.id),
+ ],
+ edges=[build_flow_edge("e1", "start", ONBOARDING_NODE_ID)],
+ is_live=True,
+ )
+ # A newer unpublished draft must not be picked up.
+ create_test_flow_version(
+ db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)]
+ )
+ contact_ref = random_contact_ref()
+
+ resp = create_session(chatbot.id, {"contact_ref": contact_ref})
+
+ assert resp.status_code == 201
+ body = resp.json()["data"]
+ assert body["chatbot_id"] == str(chatbot.id)
+ assert body["flow_version"] == 1
+ assert body["active_node_id"] == ONBOARDING_NODE_ID
+ [stored] = list_stored_sessions_for_contact(db, contact_ref)
+ assert str(stored.id) == body["session_id"]
+ assert stored.flow_version_id == live.id
+ assert stored.active_node_id == ONBOARDING_NODE_ID
+ assert stored.status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+
+ def test_new_session_for_same_contact_supersedes_previous(
+ self,
+ db: Session,
+ live_chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ get_latest: Callable[..., Response],
+ ) -> None:
+ contact_ref = random_contact_ref()
+ payload = {"contact_ref": contact_ref}
+ first = create_session(live_chatbot.id, payload)
+
+ resp = create_session(live_chatbot.id, payload)
+
+ assert resp.status_code == 201
+ first_id = first.json()["data"]["session_id"]
+ second_id = resp.json()["data"]["session_id"]
+ assert second_id != first_id
+ latest = get_latest(contact_ref, live_chatbot.id).json()["data"]
+ assert latest["session_id"] == second_id
+ statuses = {
+ str(s.id): (s.status, s.completion_status)
+ for s in list_stored_sessions_for_contact(db, contact_ref)
+ }
+ assert statuses == {
+ first_id: (
+ ChatbotSessionStatusEnum.INACTIVE,
+ ChatbotCompletionStatusEnum.ABANDONED,
+ ),
+ second_id: (
+ ChatbotSessionStatusEnum.ACTIVE,
+ ChatbotCompletionStatusEnum.IN_PROGRESS,
+ ),
+ }
+
+ def test_chatbot_without_live_flow_returns_409(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ ) -> None:
+ create_test_flow_version(
+ db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)]
+ )
+ contact_ref = random_contact_ref()
+
+ resp = create_session(chatbot.id, {"contact_ref": contact_ref})
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == NO_LIVE_FLOW_MESSAGE
+ assert list_stored_sessions_for_contact(db, contact_ref) == []
+
+ def test_unknown_chatbot_returns_404(
+ self, create_session: Callable[[UUID, dict[str, Any]], Response]
+ ) -> None:
+ resp = create_session(UNKNOWN_CHATBOT_ID, {"contact_ref": "contact-1"})
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "Chatbot not found"
+
+ def test_other_projects_chatbot_returns_404(
+ self,
+ db: Session,
+ foreign_live_chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ ) -> None:
+ contact_ref = random_contact_ref()
+
+ resp = create_session(foreign_live_chatbot.id, {"contact_ref": contact_ref})
+
+ assert resp.status_code == 404
+ assert list_stored_sessions_for_contact(db, contact_ref) == []
+
+ def test_deleted_chatbot_returns_404(
+ self,
+ db: Session,
+ live_chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ ) -> None:
+ delete_chatbot(db, chatbot=live_chatbot)
+
+ resp = create_session(live_chatbot.id, {"contact_ref": "c-1"})
+
+ assert resp.status_code == 404
+
+ @pytest.mark.parametrize(
+ "payload",
+ [{}, {"contact_ref": ""}, {"contact_ref": "x" * 501}],
+ ids=["missing", "empty", "too-long"],
+ )
+ def test_invalid_contact_ref_returns_422(
+ self,
+ live_chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ payload: dict[str, str],
+ ) -> None:
+ resp = create_session(live_chatbot.id, payload)
+
+ assert resp.status_code == 422
+
+ def test_concurrent_create_conflict_returns_409(
+ self,
+ live_chatbot: Chatbot,
+ create_session: Callable[[UUID, dict[str, Any]], Response],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ # The real unique-index race is covered in the CRUD tests; there the
+ # rollback would also discard this fixture's transaction mid-request.
+ def lose_race(*_args: Any, **_kwargs: Any) -> ChatbotSession:
+ raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE)
+
+ monkeypatch.setattr(session_service, "create_chatbot_session", lose_race)
+
+ resp = create_session(live_chatbot.id, {"contact_ref": random_contact_ref()})
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == CONCURRENT_SESSION_CONFLICT_MESSAGE
+
+
+class TestGetLatestChatbotSession:
+ def test_returns_contacts_newest_session_with_chatbot_and_node_details(
+ self,
+ db: Session,
+ config: Config,
+ foreign_live_chatbot: Chatbot,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ chatbot, _ = create_test_live_chatbot(
+ db,
+ config=config,
+ name="Onboarding Bot",
+ prompt="Collect the student's name and grade.",
+ )
+ contact_ref = random_contact_ref()
+ create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref)
+ newer = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=contact_ref
+ )
+ create_test_chatbot_session(db, chatbot=chatbot)
+ create_test_chatbot_session(
+ db, chatbot=foreign_live_chatbot, contact_ref=contact_ref
+ )
+
+ resp = get_latest(contact_ref, chatbot.id)
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["session_id"] == str(newer.id)
+ assert body["chatbot_id"] == str(chatbot.id)
+ assert body["chatbot_name"] == "Onboarding Bot"
+ assert body["flow_version"] == 1
+ assert body["active_node_id"] == ONBOARDING_NODE_ID
+ assert body["prompt"] == "Collect the student's name and grade."
+ assert body["contact_ref"] == contact_ref
+ assert body["status"] == "active"
+ assert body["completion_status"] == "in_progress"
+ assert body["collected_fields"] == {}
+
+ def test_reports_stored_field_progress(
+ self,
+ db: Session,
+ chatbot_session: ChatbotSession,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha", "city": "Pune"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+
+ resp = get_latest(chatbot_session.contact_ref, chatbot_session.chatbot_id)
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["session_id"] == str(chatbot_session.id)
+ assert body["completion_status"] == "complete"
+ assert body["collected_fields"] == {"name": "Asha", "city": "Pune"}
+
+ def test_prompt_comes_from_the_sessions_pinned_flow(
+ self,
+ db: Session,
+ config: Config,
+ live_chatbot: Chatbot,
+ chatbot_session: ChatbotSession,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ create_test_flow_version(
+ db,
+ chatbot=live_chatbot,
+ nodes=[
+ build_onboarding_node(config_id=config.id, prompt="A newer prompt.")
+ ],
+ is_live=True,
+ )
+
+ resp = get_latest(chatbot_session.contact_ref)
+
+ body = resp.json()["data"]
+ assert body["flow_version"] == 1
+ assert body["prompt"] == DEFAULT_SYSTEM_PROMPT
+
+ def test_prompt_is_null_when_active_node_is_not_in_flow(
+ self,
+ db: Session,
+ live_chatbot: Chatbot,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(
+ db, chatbot=live_chatbot, active_node_id="ghost"
+ )
+
+ resp = get_latest(chatbot_session.contact_ref)
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["active_node_id"] == "ghost"
+ assert resp.json()["data"]["prompt"] is None
+
+ def test_omitted_chatbot_id_returns_newest_session_across_chatbots(
+ self,
+ db: Session,
+ config: Config,
+ chatbot_session: ChatbotSession,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ other_chatbot, _ = create_test_live_chatbot(db, config=config)
+ newest = create_test_chatbot_session(
+ db, chatbot=other_chatbot, contact_ref=chatbot_session.contact_ref
+ )
+
+ resp = get_latest(chatbot_session.contact_ref)
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["session_id"] == str(newest.id)
+ assert body["chatbot_id"] == str(other_chatbot.id)
+
+ def test_deleted_chatbots_sessions_are_excluded(
+ self,
+ db: Session,
+ config: Config,
+ chatbot_session: ChatbotSession,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ deleted_chatbot, _ = create_test_live_chatbot(db, config=config)
+ create_test_chatbot_session(
+ db, chatbot=deleted_chatbot, contact_ref=chatbot_session.contact_ref
+ )
+ delete_chatbot(db, chatbot=deleted_chatbot)
+
+ across = get_latest(chatbot_session.contact_ref)
+ scoped = get_latest(chatbot_session.contact_ref, deleted_chatbot.id)
+
+ assert across.json()["data"]["session_id"] == str(chatbot_session.id)
+ assert scoped.status_code == 404
+
+ def test_unknown_contact_returns_404(
+ self, live_chatbot: Chatbot, get_latest: Callable[..., Response]
+ ) -> None:
+ resp = get_latest(random_contact_ref(), live_chatbot.id)
+
+ assert resp.status_code == 404
+ assert (
+ resp.json()["error"]
+ == "No chatbot session found for this contact_ref and chatbot_id"
+ )
+
+ def test_omitted_chatbot_id_unknown_contact_returns_404(
+ self, get_latest: Callable[..., Response]
+ ) -> None:
+ resp = get_latest(random_contact_ref())
+
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "No chatbot session found for this contact_ref"
+
+ def test_other_projects_session_returns_404(
+ self,
+ db: Session,
+ foreign_live_chatbot: Chatbot,
+ get_latest: Callable[..., Response],
+ ) -> None:
+ foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot)
+
+ resp = get_latest(foreign_session.contact_ref)
+
+ assert resp.status_code == 404
+
+ def test_missing_contact_ref_returns_422(
+ self, client: TestClient, user_api_key_header: dict[str, str]
+ ) -> None:
+ resp = client.get(LATEST_SESSION_URL, headers=user_api_key_header)
+
+ assert resp.status_code == 422
+
+
+class TestSendChatbotSessionMessage:
+ def test_stores_user_message_and_queues_turn(
+ self,
+ db: Session,
+ user_api_key: TestAuthContext,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = send_message(chatbot_session.id, message="Asha")
+
+ assert resp.status_code == 202
+ assert resp.json()["data"] == {"status": "queued"}
+ stored = list_chatbot_session_messages(db, session_id=chatbot_session.id)
+ assert [(m.role, m.content, m.node_id) for m in stored] == [
+ (ChatbotMessageRoleEnum.USER, "Asha", ONBOARDING_NODE_ID)
+ ]
+ [job] = enqueued
+ assert job["session_id"] == str(chatbot_session.id)
+ assert job["organization_id"] == user_api_key.organization_id
+ assert job["project_id"] == user_api_key.project_id
+ assert job["webhook_url"] == WEBHOOK_URL
+
+ def test_omitted_message_on_first_call_queues_greeting(
+ self,
+ db: Session,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = send_message(chatbot_session.id)
+
+ assert resp.status_code == 202
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+ assert [job["session_id"] for job in enqueued] == [str(chatbot_session.id)]
+
+ def test_omitted_message_after_session_started_returns_422(
+ self,
+ db: Session,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [(ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?")],
+ )
+
+ resp = send_message(chatbot_session.id)
+
+ assert resp.status_code == 422
+ assert "`message` is required" in resp.json()["error"]
+ assert enqueued == []
+
+ def test_unknown_session_returns_404(
+ self,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = send_message(uuid4(), message="Asha")
+
+ assert resp.status_code == 404
+ assert enqueued == []
+
+ def test_session_in_other_project_returns_404(
+ self,
+ db: Session,
+ foreign_live_chatbot: Chatbot,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot)
+
+ resp = send_message(foreign_session.id, message="Asha")
+
+ assert resp.status_code == 404
+ assert list_chatbot_session_messages(db, session_id=foreign_session.id) == []
+ assert enqueued == []
+
+ def test_non_https_webhook_url_returns_422(
+ self,
+ db: Session,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = send_message(
+ chatbot_session.id,
+ message="Asha",
+ webhook_url="http://hooks.example.com/chatbot",
+ )
+
+ assert resp.status_code == 422
+ assert "Invalid webhook_url" in resp.json()["error"]
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+ assert enqueued == []
+
+ @pytest.mark.parametrize(
+ "payload",
+ [{"message": "Asha"}, {}],
+ ids=["with-message", "message-omitted"],
+ )
+ def test_inactive_session_returns_409_and_stores_nothing(
+ self,
+ db: Session,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ payload: dict[str, str],
+ ) -> None:
+ _supersede(db, chatbot_session)
+
+ resp = send_message(chatbot_session.id, **payload)
+
+ assert resp.status_code == 409
+ assert (
+ resp.json()["error"] == "Chatbot session is inactive; start a new session"
+ )
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+ assert enqueued == []
+
+ def test_inactive_session_in_other_project_returns_404(
+ self,
+ db: Session,
+ foreign_live_chatbot: Chatbot,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot)
+ _supersede(db, foreign_session)
+
+ resp = send_message(foreign_session.id, message="Asha")
+
+ assert resp.status_code == 404
+ assert enqueued == []
+
+ def test_session_of_deleted_chatbot_returns_409(
+ self,
+ db: Session,
+ live_chatbot: Chatbot,
+ chatbot_session: ChatbotSession,
+ send_message: Callable[..., Response],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ delete_chatbot(db, chatbot=live_chatbot)
+
+ resp = send_message(chatbot_session.id, message="Asha")
+
+ assert resp.status_code == 409
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+ assert enqueued == []
diff --git a/backend/app/tests/celery/test_job_execution.py b/backend/app/tests/celery/test_job_execution.py
index c18eb32e8..74484e3f6 100644
--- a/backend/app/tests/celery/test_job_execution.py
+++ b/backend/app/tests/celery/test_job_execution.py
@@ -1,4 +1,5 @@
from unittest.mock import patch
+from uuid import uuid4
import pytest
@@ -56,3 +57,32 @@ def test_no_reenqueue_when_stage_not_requeued(self):
apply_async = self._run(requeue=False)
apply_async.assert_not_called()
+
+
+class TestRunChatbotSessionMessage:
+ def test_hands_service_a_uuid_session_id(self):
+ session_id = uuid4()
+ received: list[dict] = []
+ with (
+ patch.object(job_execution, "_set_trace", lambda trace_id: None),
+ patch(
+ "app.services.chatbot.session.process_chatbot_session_message",
+ lambda **kwargs: received.append(kwargs),
+ ),
+ ):
+ job_execution.run_chatbot_session_message(
+ session_id=str(session_id),
+ organization_id=2,
+ project_id=3,
+ webhook_url="https://hooks.example.com/chatbot",
+ trace_id="trace-1",
+ )
+
+ assert received == [
+ {
+ "session_id": session_id,
+ "organization_id": 2,
+ "project_id": 3,
+ "webhook_url": "https://hooks.example.com/chatbot",
+ }
+ ]
diff --git a/backend/app/tests/celery/test_utils.py b/backend/app/tests/celery/test_utils.py
new file mode 100644
index 000000000..314b593e2
--- /dev/null
+++ b/backend/app/tests/celery/test_utils.py
@@ -0,0 +1,36 @@
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import patch
+
+from app.celery.tasks.job_execution import run_chatbot_session_message
+from app.celery.utils import start_chatbot_session_message
+
+
+class TestStartChatbotSessionMessage:
+ def test_publishes_turn_and_returns_task_id(self):
+ published: list[dict[str, Any]] = []
+
+ def fake_apply_async(**kwargs: Any) -> SimpleNamespace:
+ published.append(kwargs)
+ return SimpleNamespace(id="task-123")
+
+ # apply_async is the broker boundary.
+ with patch.object(run_chatbot_session_message, "apply_async", fake_apply_async):
+ task_id = start_chatbot_session_message(
+ session_id="session-1",
+ organization_id=2,
+ project_id=3,
+ webhook_url="https://hooks.example.com/chatbot",
+ trace_id="trace-1",
+ )
+
+ assert task_id == "task-123"
+ [message] = published
+ assert message["kwargs"] == {
+ "session_id": "session-1",
+ "organization_id": 2,
+ "project_id": 3,
+ "webhook_url": "https://hooks.example.com/chatbot",
+ "trace_id": "trace-1",
+ }
+ assert "otel" in message["headers"]
diff --git a/backend/app/tests/core/test_util.py b/backend/app/tests/core/test_util.py
new file mode 100644
index 000000000..6e2da7cbd
--- /dev/null
+++ b/backend/app/tests/core/test_util.py
@@ -0,0 +1,37 @@
+from types import SimpleNamespace
+
+import pytest
+from sqlalchemy.exc import IntegrityError
+
+from app.core.util import is_constraint_violation
+
+CONSTRAINT = "uq_example_active"
+
+
+class TestIsConstraintViolation:
+ # psycopg always exposes diag.constraint_name; the message fallback guards
+ # drivers/wrappers that don't, so it's exercised with stand-in errors.
+ @pytest.mark.parametrize(
+ ("orig", "expected"),
+ [
+ (SimpleNamespace(diag=SimpleNamespace(constraint_name=CONSTRAINT)), True),
+ (
+ SimpleNamespace(diag=SimpleNamespace(constraint_name="other_fkey")),
+ False,
+ ),
+ (
+ Exception(
+ f'duplicate key value violates unique constraint "{CONSTRAINT}"'
+ ),
+ True,
+ ),
+ (Exception("violates foreign key constraint"), False),
+ ],
+ ids=["diag-match", "diag-other", "message-match", "message-other"],
+ )
+ def test_matches_only_the_named_constraint(
+ self, orig: object, expected: bool
+ ) -> None:
+ error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type]
+
+ assert is_constraint_violation(error, CONSTRAINT) is expected
diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py
index ed122b721..2610544af 100644
--- a/backend/app/tests/crud/config/test_version.py
+++ b/backend/app/tests/crud/config/test_version.py
@@ -650,3 +650,57 @@ def test_does_not_mutate_shared_params_dict(self) -> None:
# prior_blob must NOT have been mutated through the shared reference
assert prior_blob["completion"]["params"]["temperature"] == 0.7
assert shared_params["temperature"] == 0.7
+
+
+class TestReadLatest:
+ def test_returns_highest_version(self, db: Session) -> None:
+ config = create_test_config(db)
+ create_test_version(db, config_id=config.id, project_id=config.project_id)
+ create_test_version(db, config_id=config.id, project_id=config.project_id)
+ version_crud = ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ )
+
+ latest = version_crud.read_latest()
+
+ assert latest is not None
+ assert latest.version == 3
+
+ def test_skips_soft_deleted_versions(self, db: Session) -> None:
+ config = create_test_config(db)
+ create_test_version(db, config_id=config.id, project_id=config.project_id)
+ version_crud = ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ )
+ version_crud.delete_or_raise(2)
+
+ latest = version_crud.read_latest()
+
+ assert latest is not None
+ assert latest.version == 1
+
+ def test_returns_none_when_every_version_deleted(self, db: Session) -> None:
+ config = create_test_config(db)
+ version_crud = ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ )
+ version_crud.delete_or_raise(1)
+
+ assert version_crud.read_latest() is None
+
+ def test_config_in_other_project_raises_404(self, db: Session) -> None:
+ config = create_test_config(db)
+ other_project = create_test_project(db)
+ # An explicit tag skips the constructor's lookup, so the scope check
+ # inside read_latest is the one that has to reject the foreign config.
+ version_crud = ConfigVersionCrud(
+ session=db,
+ project_id=other_project.id,
+ config_id=config.id,
+ tag=ConfigTag.DEFAULT,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ version_crud.read_latest()
+
+ assert exc_info.value.status_code == 404
diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py
new file mode 100644
index 000000000..fef300e2b
--- /dev/null
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -0,0 +1,761 @@
+from collections.abc import Iterator
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from sqlalchemy import event, insert
+from sqlalchemy.exc import IntegrityError
+from sqlmodel import Session
+
+from app.core.db import engine
+from app.crud.chatbot import (
+ create_chatbot,
+ create_chatbot_flow_version,
+ create_chatbot_session,
+ create_chatbot_session_message,
+ delete_chatbot,
+ get_chatbot,
+ get_chatbot_session,
+ get_latest_chatbot_flow_version,
+ get_latest_chatbot_session,
+ get_live_chatbot_flow_version,
+ list_chatbot_session_messages,
+ list_chatbots,
+ publish_chatbot_flow_version,
+ update_chatbot,
+ update_chatbot_session_progress,
+)
+from app.models import (
+ Chatbot,
+ ChatbotCompletionStatusEnum,
+ ChatbotFlowVersion,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionStatusEnum,
+ ChatbotUpdate,
+ Config,
+ Project,
+)
+from app.services.chatbot.exceptions import (
+ CONCURRENT_FLOW_CONFLICT_MESSAGE,
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotFlowConflictError,
+ ChatbotSessionConflictError,
+)
+from app.tests.utils.chatbot import (
+ ONBOARDING_NODE_ID,
+ add_test_chatbot_messages,
+ build_onboarding_node,
+ create_test_chatbot,
+ create_test_chatbot_session,
+ create_test_flow_version,
+ create_test_anthropic_config,
+ create_test_live_chatbot,
+ list_stored_flow_versions,
+ list_stored_sessions_for_contact,
+ random_contact_ref,
+)
+from app.tests.utils.test_data import create_test_project
+
+
+@pytest.fixture
+def config(db: Session) -> Config:
+ return create_test_anthropic_config(db)
+
+
+@pytest.fixture
+def project(db: Session, config: Config) -> Project:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ return project
+
+
+@pytest.fixture
+def live_chatbot_and_flow(
+ db: Session, config: Config
+) -> tuple[Chatbot, ChatbotFlowVersion]:
+ return create_test_live_chatbot(db, config=config)
+
+
+@pytest.fixture
+def chatbot(live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion]) -> Chatbot:
+ return live_chatbot_and_flow[0]
+
+
+def _reload(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession:
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ return stored
+
+
+def _scope(project: Project) -> dict[str, int]:
+ return {"organization_id": project.organization_id, "project_id": project.id}
+
+
+class TestChatbotCrud:
+ def test_create_persists_name_and_scope(
+ self, db: Session, project: Project
+ ) -> None:
+ created = create_chatbot(db, **_scope(project), name="Onboarding Bot")
+
+ db.expire_all()
+ stored = db.get(Chatbot, created.id)
+ assert stored is not None
+ assert stored.name == "Onboarding Bot"
+ assert stored.project_id == project.id
+ assert stored.organization_id == project.organization_id
+ assert stored.deleted_at is None
+
+ def test_get_is_scoped_to_project(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ other_project = create_test_project(db, organization_id=project.organization_id)
+
+ assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(project)) is not None
+ assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(other_project)) is None
+
+ def test_get_hides_deleted_chatbot(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ delete_chatbot(db, chatbot=chatbot)
+
+ assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(project)) is None
+
+ def test_list_returns_projects_live_chatbots_most_recently_updated_first(
+ self, db: Session, project: Project
+ ) -> None:
+ older = create_test_chatbot(db, project=project)
+ newer = create_test_chatbot(db, project=project)
+ deleted = create_test_chatbot(db, project=project)
+ delete_chatbot(db, chatbot=deleted)
+ create_test_chatbot(db)
+ update_chatbot(db, chatbot=older, chatbot_update=ChatbotUpdate(name="renamed"))
+
+ listed = list_chatbots(db, **_scope(project), skip=0, limit=100)
+
+ assert [c.id for c in listed] == [older.id, newer.id]
+
+ def test_list_paginates(self, db: Session, project: Project) -> None:
+ create_test_chatbot(db, project=project)
+ second = create_test_chatbot(db, project=project)
+ create_test_chatbot(db, project=project)
+
+ listed = list_chatbots(db, **_scope(project), skip=1, limit=1)
+
+ assert [c.id for c in listed] == [second.id]
+
+ def test_update_changes_name_and_bumps_updated_at(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ before = chatbot.updated_at
+
+ update_chatbot(db, chatbot=chatbot, chatbot_update=ChatbotUpdate(name="New"))
+
+ db.expire_all()
+ stored = db.get(Chatbot, chatbot.id)
+ assert stored is not None
+ assert stored.name == "New"
+ assert stored.updated_at > before
+
+ def test_update_with_no_name_keeps_name(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ original_name = chatbot.name
+
+ update_chatbot(db, chatbot=chatbot, chatbot_update=ChatbotUpdate())
+
+ db.expire_all()
+ stored = db.get(Chatbot, chatbot.id)
+ assert stored is not None
+ assert stored.name == original_name
+
+ def test_delete_soft_deletes_and_deactivates_only_its_active_sessions(
+ self, db: Session, config: Config, chatbot: Chatbot
+ ) -> None:
+ in_progress = create_test_chatbot_session(db, chatbot=chatbot)
+ complete = create_test_chatbot_session(db, chatbot=chatbot)
+ update_chatbot_session_progress(
+ db,
+ session_id=complete.id,
+ collected_fields={"name": "Asha"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+ other_chatbot, _ = create_test_live_chatbot(db, config=config)
+ untouched = create_test_chatbot_session(
+ db, chatbot=other_chatbot, contact_ref=in_progress.contact_ref
+ )
+
+ delete_chatbot(db, chatbot=chatbot)
+
+ db.expire_all()
+ stored_chatbot = db.get(Chatbot, chatbot.id)
+ assert stored_chatbot is not None
+ assert stored_chatbot.deleted_at is not None
+ stored_in_progress = _reload(db, in_progress)
+ assert stored_in_progress.status == ChatbotSessionStatusEnum.INACTIVE
+ assert (
+ stored_in_progress.completion_status
+ == ChatbotCompletionStatusEnum.ABANDONED
+ )
+ stored_complete = _reload(db, complete)
+ assert stored_complete.status == ChatbotSessionStatusEnum.INACTIVE
+ assert stored_complete.completion_status == ChatbotCompletionStatusEnum.COMPLETE
+ assert _reload(db, untouched).status == ChatbotSessionStatusEnum.ACTIVE
+
+
+class TestChatbotFlowVersionCrud:
+ def test_latest_is_highest_version_and_live_is_the_published_one(
+ self,
+ db: Session,
+ config: Config,
+ live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion],
+ ) -> None:
+ chatbot, live = live_chatbot_and_flow
+ draft = create_test_flow_version(
+ db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)]
+ )
+
+ latest = get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id)
+ current_live = get_live_chatbot_flow_version(db, chatbot_id=chatbot.id)
+
+ assert latest is not None and latest.id == draft.id
+ assert latest.version == 2
+ assert current_live is not None and current_live.id == live.id
+
+ def test_no_versions_returns_none(self, db: Session, project: Project) -> None:
+ chatbot = create_test_chatbot(db, project=project)
+
+ assert get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id) is None
+ assert get_live_chatbot_flow_version(db, chatbot_id=chatbot.id) is None
+
+ def test_publish_unsets_previous_live_and_stores_given_nodes(
+ self,
+ db: Session,
+ config: Config,
+ live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion],
+ ) -> None:
+ chatbot, previous_live = live_chatbot_and_flow
+ draft = create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[build_onboarding_node(config_id=config.id, config_version=None)],
+ )
+ pinned_nodes = [build_onboarding_node(config_id=config.id, config_version=1)]
+
+ publish_chatbot_flow_version(db, flow_version=draft, nodes=pinned_nodes)
+
+ db.expire_all()
+ stored_previous = db.get(ChatbotFlowVersion, previous_live.id)
+ stored_draft = db.get(ChatbotFlowVersion, draft.id)
+ assert stored_previous is not None and stored_previous.is_live is False
+ assert stored_draft is not None and stored_draft.is_live is True
+ assert stored_draft.nodes == pinned_nodes
+
+
+class TestCreateChatbotSession:
+ def test_persists_session_as_active_with_no_collected_fields(
+ self, db: Session, live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion]
+ ) -> None:
+ chatbot, flow_version = live_chatbot_and_flow
+ contact_ref = random_contact_ref()
+
+ created = create_chatbot_session(
+ db,
+ chatbot_id=chatbot.id,
+ flow_version_id=flow_version.id,
+ contact_ref=contact_ref,
+ active_node_id=ONBOARDING_NODE_ID,
+ )
+
+ stored = _reload(db, created)
+ assert stored.chatbot_id == chatbot.id
+ assert stored.flow_version_id == flow_version.id
+ assert stored.contact_ref == contact_ref
+ assert stored.active_node_id == ONBOARDING_NODE_ID
+ assert stored.status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored.collected_fields == {}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+ assert stored.inserted_at == stored.updated_at
+
+
+class TestGetChatbotSession:
+ def test_returns_session_in_scope(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+
+ found = get_chatbot_session(
+ db, session_id=chatbot_session.id, **_scope(project)
+ )
+
+ assert found is not None
+ assert found.id == chatbot_session.id
+
+ def test_other_project_cannot_see_session(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ other_project = create_test_project(db, organization_id=project.organization_id)
+
+ found = get_chatbot_session(
+ db, session_id=chatbot_session.id, **_scope(other_project)
+ )
+
+ assert found is None
+
+ def test_other_organization_cannot_see_session(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ other_project = create_test_project(db)
+
+ found = get_chatbot_session(
+ db,
+ session_id=chatbot_session.id,
+ organization_id=other_project.organization_id,
+ project_id=project.id,
+ )
+
+ assert found is None
+
+ def test_deleted_chatbots_session_still_resolves(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ delete_chatbot(db, chatbot=chatbot)
+
+ found = get_chatbot_session(
+ db, session_id=chatbot_session.id, **_scope(project)
+ )
+
+ assert found is not None
+ assert found.status == ChatbotSessionStatusEnum.INACTIVE
+
+ def test_unknown_id_returns_none(self, db: Session, project: Project) -> None:
+ assert get_chatbot_session(db, session_id=uuid4(), **_scope(project)) is None
+
+
+class TestChatbotSessionMessages:
+ def test_create_message_persists_role_content_and_node(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+
+ message = create_chatbot_session_message(
+ db,
+ session_id=chatbot_session.id,
+ role=ChatbotMessageRoleEnum.USER,
+ content="Asha",
+ node_id=ONBOARDING_NODE_ID,
+ )
+
+ stored = list_chatbot_session_messages(db, session_id=chatbot_session.id)
+ assert [(m.id, m.role, m.content, m.node_id) for m in stored] == [
+ (message.id, ChatbotMessageRoleEnum.USER, "Asha", ONBOARDING_NODE_ID)
+ ]
+
+ def test_list_returns_oldest_first_and_only_this_session(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ other_session = create_test_chatbot_session(db, chatbot=chatbot)
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?"),
+ (ChatbotMessageRoleEnum.USER, "Asha"),
+ (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"),
+ ],
+ )
+ add_test_chatbot_messages(
+ db, other_session, [(ChatbotMessageRoleEnum.USER, "unrelated")]
+ )
+
+ stored = list_chatbot_session_messages(db, session_id=chatbot_session.id)
+
+ assert [m.content for m in stored] == [
+ "Hi! What's your name?",
+ "Asha",
+ "Which city?",
+ ]
+
+ def test_list_empty_session_returns_empty(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+
+
+class TestGetLatestChatbotSession:
+ def test_returns_newest_with_its_chatbot_and_pinned_flow(
+ self,
+ db: Session,
+ config: Config,
+ live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion],
+ project: Project,
+ ) -> None:
+ chatbot, flow_version = live_chatbot_and_flow
+ contact_ref = random_contact_ref()
+ create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref)
+ newest = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=contact_ref
+ )
+ create_test_chatbot_session(db, chatbot=chatbot)
+ foreign_chatbot, _ = create_test_live_chatbot(
+ db, config=create_test_anthropic_config(db)
+ )
+ create_test_chatbot_session(
+ db, chatbot=foreign_chatbot, contact_ref=contact_ref
+ )
+
+ row = get_latest_chatbot_session(
+ db, **_scope(project), contact_ref=contact_ref, chatbot_id=chatbot.id
+ )
+
+ assert row is not None
+ found_session, found_chatbot, found_flow = row
+ assert found_session.id == newest.id
+ assert found_chatbot.id == chatbot.id
+ assert found_flow.id == flow_version.id
+
+ def test_unknown_contact_returns_none(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ create_test_chatbot_session(db, chatbot=chatbot)
+
+ assert (
+ get_latest_chatbot_session(
+ db,
+ **_scope(project),
+ contact_ref=random_contact_ref(),
+ chatbot_id=chatbot.id,
+ )
+ is None
+ )
+
+ def test_without_chatbot_id_returns_newest_across_chatbots(
+ self, db: Session, config: Config, chatbot: Chatbot, project: Project
+ ) -> None:
+ older = create_test_chatbot_session(db, chatbot=chatbot)
+ other_chatbot, _ = create_test_live_chatbot(db, config=config)
+ newest = create_test_chatbot_session(
+ db, chatbot=other_chatbot, contact_ref=older.contact_ref
+ )
+
+ row = get_latest_chatbot_session(
+ db, **_scope(project), contact_ref=older.contact_ref
+ )
+
+ assert row is not None
+ assert row[0].id == newest.id
+ assert row[1].id == other_chatbot.id
+
+ def test_ignores_newer_session_of_another_chatbot(
+ self, db: Session, config: Config, chatbot: Chatbot, project: Project
+ ) -> None:
+ wanted = create_test_chatbot_session(db, chatbot=chatbot)
+ other_chatbot, _ = create_test_live_chatbot(db, config=config)
+ create_test_chatbot_session(
+ db, chatbot=other_chatbot, contact_ref=wanted.contact_ref
+ )
+
+ row = get_latest_chatbot_session(
+ db,
+ **_scope(project),
+ contact_ref=wanted.contact_ref,
+ chatbot_id=chatbot.id,
+ )
+
+ assert row is not None
+ assert row[0].id == wanted.id
+
+ def test_skips_sessions_of_deleted_chatbots(
+ self, db: Session, config: Config, chatbot: Chatbot, project: Project
+ ) -> None:
+ kept = create_test_chatbot_session(db, chatbot=chatbot)
+ deleted_chatbot, _ = create_test_live_chatbot(db, config=config)
+ create_test_chatbot_session(
+ db, chatbot=deleted_chatbot, contact_ref=kept.contact_ref
+ )
+ delete_chatbot(db, chatbot=deleted_chatbot)
+
+ row = get_latest_chatbot_session(
+ db, **_scope(project), contact_ref=kept.contact_ref
+ )
+ scoped_row = get_latest_chatbot_session(
+ db,
+ **_scope(project),
+ contact_ref=kept.contact_ref,
+ chatbot_id=deleted_chatbot.id,
+ )
+
+ assert row is not None
+ assert row[0].id == kept.id
+ assert scoped_row is None
+
+ def test_returns_newest_even_when_inactive(
+ self, db: Session, chatbot: Chatbot, project: Project
+ ) -> None:
+ older = create_test_chatbot_session(db, chatbot=chatbot)
+ newest = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=older.contact_ref
+ )
+ # No API path leaves the newest row inactive; set it directly so a
+ # status filter would surface the older row instead.
+ newest.status = ChatbotSessionStatusEnum.INACTIVE
+ db.add(newest)
+ db.commit()
+
+ row = get_latest_chatbot_session(
+ db,
+ **_scope(project),
+ contact_ref=older.contact_ref,
+ chatbot_id=chatbot.id,
+ )
+
+ assert row is not None
+ assert row[0].id == newest.id
+ assert row[0].status == ChatbotSessionStatusEnum.INACTIVE
+
+
+class TestUpdateChatbotSessionProgress:
+ def test_persists_fields_and_status_and_bumps_updated_at(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ inserted_at = chatbot_session.inserted_at
+
+ returned = update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha", "city": "Pune"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+
+ assert returned is not None
+ stored = _reload(db, chatbot_session)
+ assert stored.collected_fields == {"name": "Asha", "city": "Pune"}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.COMPLETE
+ assert stored.updated_at > inserted_at
+ assert stored.inserted_at == inserted_at
+
+ def test_replaces_rather_than_merges_fields(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha", "city": "Pune"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha"},
+ completion_status=ChatbotCompletionStatusEnum.IN_PROGRESS,
+ )
+
+ stored = _reload(db, chatbot_session)
+ assert stored.collected_fields == {"name": "Asha"}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+
+ def test_unknown_id_returns_none(self, db: Session) -> None:
+ assert (
+ update_chatbot_session_progress(
+ db,
+ session_id=uuid4(),
+ collected_fields={"name": "Asha"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+ is None
+ )
+
+
+class TestCreateChatbotSessionSupersedes:
+ def test_in_progress_session_becomes_inactive_and_abandoned(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ older = create_test_chatbot_session(db, chatbot=chatbot)
+ older_updated_at = older.updated_at
+
+ newer = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=older.contact_ref
+ )
+
+ superseded = _reload(db, older)
+ assert superseded.status == ChatbotSessionStatusEnum.INACTIVE
+ assert superseded.completion_status == ChatbotCompletionStatusEnum.ABANDONED
+ assert superseded.updated_at > older_updated_at
+ assert _reload(db, newer).status == ChatbotSessionStatusEnum.ACTIVE
+
+ def test_complete_session_becomes_inactive_but_stays_complete(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ older = create_test_chatbot_session(db, chatbot=chatbot)
+ update_chatbot_session_progress(
+ db,
+ session_id=older.id,
+ collected_fields={"name": "Asha"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+ completed_at = _reload(db, older).updated_at
+
+ create_test_chatbot_session(db, chatbot=chatbot, contact_ref=older.contact_ref)
+
+ superseded = _reload(db, older)
+ assert superseded.status == ChatbotSessionStatusEnum.INACTIVE
+ assert superseded.completion_status == ChatbotCompletionStatusEnum.COMPLETE
+ assert superseded.collected_fields == {"name": "Asha"}
+ assert superseded.updated_at > completed_at
+
+ def test_other_chatbot_and_contact_sessions_stay_active(
+ self, db: Session, config: Config, chatbot: Chatbot
+ ) -> None:
+ contact_ref = random_contact_ref()
+ other_chatbot, _ = create_test_live_chatbot(db, config=config)
+ same_contact_other_chatbot = create_test_chatbot_session(
+ db, chatbot=other_chatbot, contact_ref=contact_ref
+ )
+ same_chatbot_other_contact = create_test_chatbot_session(db, chatbot=chatbot)
+ untouched = [same_contact_other_chatbot, same_chatbot_other_contact]
+ updated_ats = [s.updated_at for s in untouched]
+
+ create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref)
+
+ for chatbot_session, updated_at in zip(untouched, updated_ats, strict=True):
+ stored = _reload(db, chatbot_session)
+ assert stored.status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+ assert stored.updated_at == updated_at
+
+ def test_already_inactive_session_is_left_unmodified(
+ self, db: Session, chatbot: Chatbot
+ ) -> None:
+ first = create_test_chatbot_session(db, chatbot=chatbot)
+ second = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=first.contact_ref
+ )
+ first_after_supersede = _reload(db, first).updated_at
+
+ third = create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=first.contact_ref
+ )
+
+ stored_first = _reload(db, first)
+ assert stored_first.status == ChatbotSessionStatusEnum.INACTIVE
+ assert stored_first.completion_status == ChatbotCompletionStatusEnum.ABANDONED
+ assert stored_first.updated_at == first_after_supersede
+ assert _reload(db, second).status == ChatbotSessionStatusEnum.INACTIVE
+ assert _reload(db, third).status == ChatbotSessionStatusEnum.ACTIVE
+
+
+@pytest.fixture
+def savepoint_db() -> Iterator[Session]:
+ # The crud functions roll back on IntegrityError. Under the shared `db`
+ # fixture that rollback discards the whole test transaction (rows created
+ # earlier included), so atomicity is unobservable. Joining via savepoints makes
+ # its commit/rollback behave as in production while still undoing everything here.
+ connection = engine.connect()
+ transaction = connection.begin()
+ session = Session(bind=connection, join_transaction_mode="create_savepoint")
+ try:
+ yield session
+ finally:
+ session.close()
+ transaction.rollback()
+ connection.close()
+
+
+class TestCreateChatbotSessionFailures:
+ def test_fk_failure_propagates_and_keeps_prior_session_active(
+ self, savepoint_db: Session
+ ) -> None:
+ config = create_test_anthropic_config(savepoint_db)
+ chatbot, _ = create_test_live_chatbot(savepoint_db, config=config)
+ prior = create_test_chatbot_session(savepoint_db, chatbot=chatbot)
+
+ with pytest.raises(IntegrityError):
+ create_chatbot_session(
+ savepoint_db,
+ chatbot_id=chatbot.id,
+ flow_version_id=uuid4(),
+ contact_ref=prior.contact_ref,
+ active_node_id=ONBOARDING_NODE_ID,
+ )
+
+ stored = _reload(savepoint_db, prior)
+ assert stored.status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+
+ def test_active_index_violation_raises_conflict_and_rolls_back_supersede(
+ self, savepoint_db: Session
+ ) -> None:
+ config = create_test_anthropic_config(savepoint_db)
+ chatbot, flow_version = create_test_live_chatbot(savepoint_db, config=config)
+ prior = create_test_chatbot_session(savepoint_db, chatbot=chatbot)
+ racing_row = {
+ "id": uuid4(),
+ "chatbot_id": chatbot.id,
+ "flow_version_id": flow_version.id,
+ "contact_ref": prior.contact_ref,
+ "active_node_id": ONBOARDING_NODE_ID,
+ "status": ChatbotSessionStatusEnum.ACTIVE,
+ "completion_status": ChatbotCompletionStatusEnum.IN_PROGRESS,
+ "collected_fields": {},
+ "inserted_at": prior.inserted_at,
+ "updated_at": prior.inserted_at,
+ }
+
+ # Simulates a racing request committing its active row after this call's
+ # supersede UPDATE ran but before its INSERT is flushed.
+ raced: list[bool] = []
+
+ def insert_racing_row(session: Session, *_args: Any) -> None:
+ if raced:
+ return
+ raced.append(True)
+ session.connection().execute(insert(ChatbotSession).values(**racing_row))
+
+ event.listen(savepoint_db, "before_flush", insert_racing_row)
+ try:
+ with pytest.raises(ChatbotSessionConflictError) as exc_info:
+ create_chatbot_session(
+ savepoint_db,
+ chatbot_id=chatbot.id,
+ flow_version_id=flow_version.id,
+ contact_ref=prior.contact_ref,
+ active_node_id=ONBOARDING_NODE_ID,
+ )
+ finally:
+ event.remove(savepoint_db, "before_flush", insert_racing_row)
+
+ assert str(exc_info.value) == CONCURRENT_SESSION_CONFLICT_MESSAGE
+ stored = list_stored_sessions_for_contact(savepoint_db, prior.contact_ref)
+ assert [s.id for s in stored] == [prior.id]
+ assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored[0].completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+
+
+class TestCreateChatbotFlowVersionFailures:
+ def test_duplicate_version_raises_flow_conflict(
+ self, savepoint_db: Session
+ ) -> None:
+ project = create_test_project(savepoint_db)
+ chatbot = create_test_chatbot(savepoint_db, project=project)
+ create_chatbot_flow_version(
+ savepoint_db, chatbot_id=chatbot.id, version=1, nodes=[], edges=[]
+ )
+
+ with pytest.raises(ChatbotFlowConflictError) as exc_info:
+ create_chatbot_flow_version(
+ savepoint_db, chatbot_id=chatbot.id, version=1, nodes=[], edges=[]
+ )
+
+ assert str(exc_info.value) == CONCURRENT_FLOW_CONFLICT_MESSAGE
+ versions = list_stored_flow_versions(savepoint_db, chatbot)
+ assert [v.version for v in versions] == [1]
diff --git a/backend/app/tests/services/chatbot/__init__.py b/backend/app/tests/services/chatbot/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/tests/services/chatbot/agents/__init__.py b/backend/app/tests/services/chatbot/agents/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/tests/services/chatbot/agents/onboarding/__init__.py b/backend/app/tests/services/chatbot/agents/onboarding/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
new file mode 100644
index 000000000..220cd9d8b
--- /dev/null
+++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
@@ -0,0 +1,616 @@
+import anthropic
+import httpx
+import pytest
+from langchain_core.exceptions import OutputParserException
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.models import ChatbotCompletionStatusEnum
+from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn
+from app.services.chatbot.agents.onboarding.prompts import EXTRACTION_INSTRUCTION
+from app.services.chatbot.agents.onboarding.types import FieldExtraction
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.history import extract_final_reply
+from app.services.chatbot.types import ChatbotMessageDict, ChatbotTurnResult
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.chatbot import (
+ ANTHROPIC_REQUEST,
+ DEFAULT_SYSTEM_PROMPT as SYSTEM_PROMPT,
+ ScriptedChatModel,
+ extracted_fields,
+ patch_chat_model,
+)
+
+DEFAULT_HISTORY_TOKEN_LIMIT = 4000
+SUMMARY_TEXT = "User is Asha from Pune."
+PROGRESS_PREFIX = "[Field collection progress] "
+COLLECTED_PREFIX = "Already collected (do not ask for these again): "
+
+
+def _alternating_history(turns: int) -> list[dict[str, str]]:
+ history: list[dict[str, str]] = []
+ for i in range(turns):
+ history.append({"role": "user", "content": f"user {i}"})
+ history.append({"role": "assistant", "content": f"bot {i}"})
+ return history
+
+
+async def _run_turn(
+ *,
+ history: list[ChatbotMessageDict],
+ user_message: str | None,
+ history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT,
+ collected_fields: dict[str, str] | None = None,
+) -> ChatbotTurnResult:
+ return await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=history,
+ user_message=user_message,
+ model_settings=ChatbotModelSettings(
+ model="claude-sonnet-4-6", history_token_limit=history_token_limit
+ ),
+ collected_fields=collected_fields,
+ )
+
+
+class TestRunChatbotTurn:
+ @pytest.mark.asyncio
+ async def test_first_call_returns_greeting_without_kickoff(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
+ )
+
+ result = await _run_turn(history=[], user_message=None)
+
+ assert result.reply == "Hi! What's your name?"
+ # Anthropic rejects a conversation that doesn't open with a user turn.
+ sent = chat_model.calls[0]
+ assert isinstance(sent[0], SystemMessage)
+ assert isinstance(sent[1], HumanMessage)
+ assert sent[1].content == "Begin the conversation."
+
+ @pytest.mark.asyncio
+ async def test_follow_up_turn_appends_user_message_and_reply(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Nice to meet you, Asha."])
+ )
+ history = [
+ {"role": "system", "content": "stale prompt from the previous call"},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ ]
+
+ result = await _run_turn(history=history, user_message="Asha")
+
+ assert result.reply == "Nice to meet you, Asha."
+ sent_contents = [m.content for m in chat_model.calls[0]]
+ assert "stale prompt from the previous call" not in sent_contents
+
+ @pytest.mark.asyncio
+ async def test_empty_reply_becomes_provider_error(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ patch_chat_model(monkeypatch, ScriptedChatModel(replies=[" "]))
+
+ with pytest.raises(ChatbotProviderError, match="EmptyReply"):
+ await _run_turn(history=[], user_message=None)
+
+ def test_reply_must_come_from_a_final_ai_message(self) -> None:
+ with pytest.raises(ChatbotProviderError, match="EmptyReply"):
+ extract_final_reply([HumanMessage(content="Asha")])
+
+ @pytest.mark.asyncio
+ async def test_missing_message_with_history_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(monkeypatch, ScriptedChatModel())
+
+ with pytest.raises(ChatbotInputError, match="`message` is required"):
+ await _run_turn(
+ history=[{"role": "assistant", "content": "Hi!"}], user_message=None
+ )
+ assert chat_model.calls == []
+
+ @pytest.mark.asyncio
+ async def test_unsupported_role_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(monkeypatch, ScriptedChatModel())
+
+ with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"):
+ await _run_turn(
+ history=[{"role": "tool", "content": "{}"}], user_message="hello"
+ )
+ assert chat_model.calls == []
+
+
+class TestProviderErrorMapping:
+ @pytest.mark.parametrize(
+ ("error", "expected_message"),
+ [
+ (
+ anthropic.APITimeoutError(request=ANTHROPIC_REQUEST),
+ "(code: APITimeoutError)",
+ ),
+ (
+ anthropic.APIConnectionError(request=ANTHROPIC_REQUEST),
+ "(code: APIConnectionError)",
+ ),
+ (
+ ValueError("thinking.type=disabled is not supported"),
+ "[KAAPI] Unsupported model configuration for 'claude-sonnet-4-6': "
+ "thinking.type=disabled is not supported",
+ ),
+ (
+ anthropic.APIStatusError(
+ "Overloaded",
+ response=httpx.Response(
+ 529,
+ request=ANTHROPIC_REQUEST,
+ headers={"request-id": "req_123"},
+ ),
+ body=None,
+ ),
+ "[ANTHROPIC] Chat model call failed: Overloaded (code: 529, request_id: req_123)",
+ ),
+ ],
+ ids=["timeout", "connection", "value_error", "status"],
+ )
+ @pytest.mark.asyncio
+ async def test_anthropic_errors_become_provider_errors(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ error: anthropic.APIError,
+ expected_message: str,
+ ) -> None:
+ patch_chat_model(monkeypatch, ScriptedChatModel(error=error))
+
+ with pytest.raises(ChatbotProviderError) as exc_info:
+ await _run_turn(history=[], user_message="hello")
+
+ assert expected_message in str(exc_info.value)
+ assert exc_info.value.__cause__ is error
+
+
+class TestHistorySummarization:
+ @pytest.mark.asyncio
+ async def test_long_history_over_budget_is_summarized(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(replies=[SUMMARY_TEXT, "Got it."], token_count=5000),
+ )
+ history = _alternating_history(turns=4)
+
+ result = await _run_turn(
+ history=history, user_message="user 4", history_token_limit=4000
+ )
+
+ summarize_call, reply_call = chat_model.calls
+ # 9 conversation messages, keep-recent=6 lands on an assistant turn, so the
+ # window widens back to the preceding user turn: [user 0, bot 0] get summarized,
+ # sent as one user message so the request never ends on an assistant turn.
+ instruction, transcript_message = summarize_call
+ assert isinstance(instruction, SystemMessage)
+ assert isinstance(transcript_message, HumanMessage)
+ assert transcript_message.content == (
+ "\nUser: user 0\nAssistant: bot 0\n"
+ )
+ assert [m.content for m in reply_call] == [
+ SYSTEM_PROMPT,
+ f"[Earlier conversation summary] {SUMMARY_TEXT}",
+ "user 1",
+ "bot 1",
+ "user 2",
+ "bot 2",
+ "user 3",
+ "bot 3",
+ "user 4",
+ ]
+ # The reply is the turn's answer, not the summary text.
+ assert result.reply == "Got it."
+
+ @pytest.mark.asyncio
+ async def test_long_history_within_budget_is_sent_whole(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=100)
+ )
+
+ await _run_turn(
+ history=_alternating_history(turns=4),
+ user_message="user 4",
+ history_token_limit=4000,
+ )
+
+ assert len(chat_model.calls) == 1
+ assert len(chat_model.calls[0]) == 10
+
+ @pytest.mark.asyncio
+ async def test_short_history_never_summarized(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ )
+
+ await _run_turn(
+ history=_alternating_history(turns=3)[:5],
+ user_message="more",
+ history_token_limit=10,
+ )
+
+ assert len(chat_model.calls) == 1
+ assert len(chat_model.calls[0]) == 7
+
+ @pytest.mark.asyncio
+ async def test_no_user_turn_to_split_on_skips_summary(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ )
+ # Only the injected kickoff precedes the recent window, so widening the
+ # window to start on a user turn consumes everything there was to summarize.
+ assistant_only = [
+ {"role": "assistant", "content": f"bot {i}"} for i in range(7)
+ ]
+
+ await _run_turn(
+ history=assistant_only, user_message="hello", history_token_limit=10
+ )
+
+ assert len(chat_model.calls) == 1
+ assert len(chat_model.calls[0]) == 10
+
+ @pytest.mark.asyncio
+ async def test_long_history_summary_is_shared_by_extraction_and_reply(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=[SUMMARY_TEXT, "Got it."],
+ token_count=5000,
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields({"name": "Asha"}),
+ missing_fields=["city"],
+ is_complete=False,
+ )
+ ],
+ ),
+ )
+
+ await _run_turn(
+ history=_alternating_history(turns=4),
+ user_message="user 4",
+ history_token_limit=4000,
+ )
+
+ # Exactly one summarize call: the extraction node's view is reused for the reply.
+ summarize_call, reply_call = chat_model.calls
+ assert summarize_call[-1].content == (
+ "\nUser: user 0\nAssistant: bot 0\n"
+ )
+ summary_note = f"[Earlier conversation summary] {SUMMARY_TEXT}"
+ recent = ["user 1", "bot 1", "user 2", "bot 2", "user 3", "bot 3", "user 4"]
+ [extraction_call] = chat_model.extraction_calls
+ assert [m.content for m in extraction_call[1:]] == [summary_note, *recent]
+ # The progress note joins the leading system block, after the summary note.
+ assert [m.content for m in reply_call] == [
+ SYSTEM_PROMPT,
+ summary_note,
+ f'{PROGRESS_PREFIX}{COLLECTED_PREFIX}{{"name": "Asha"}}. '
+ "Still missing: city. Ask only for the missing fields.",
+ *recent,
+ ]
+ assert all(isinstance(m, SystemMessage) for m in reply_call[:3])
+
+
+class TestFieldExtraction:
+ @pytest.mark.asyncio
+ async def test_greeting_turn_skips_extraction(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Hi! What's your name?"],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields({"name": "invented"}),
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ ),
+ )
+
+ result = await _run_turn(
+ history=[], user_message=None, collected_fields={"name": "Asha"}
+ )
+
+ assert chat_model.extraction_calls == []
+ assert result.collected_fields == {"name": "Asha"}
+ assert result.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+ [reply_call] = chat_model.calls
+ assert [m.content for m in reply_call] == [
+ SYSTEM_PROMPT,
+ "Begin the conversation.",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_extraction_uses_native_json_schema_output(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Which city?"])
+ )
+
+ await _run_turn(history=[], user_message="I'm Asha")
+
+ # Forced tool use is rejected by Anthropic when thinking is on.
+ assert chat_model.structured_output_methods == ["json_schema"]
+
+ @pytest.mark.asyncio
+ async def test_extraction_sees_flow_prompt_prior_fields_and_conversation(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Which city?"])
+ )
+
+ await _run_turn(
+ history=[{"role": "assistant", "content": "Hi! What's your name?"}],
+ user_message="I'm Asha",
+ collected_fields={"age": "30"},
+ )
+
+ [extraction_call] = chat_model.extraction_calls
+ instruction, *conversation = extraction_call
+ assert isinstance(instruction, SystemMessage)
+ assert instruction.text.startswith(EXTRACTION_INSTRUCTION)
+ assert f"\n{SYSTEM_PROMPT}\n" in instruction.text
+ assert (
+ '\n{"age": "30"}\n'
+ in instruction.text
+ )
+ # The flow prompt is embedded in the instruction, not repeated as a message.
+ assert [m.content for m in conversation] == [
+ "Begin the conversation.",
+ "Hi! What's your name?",
+ "I'm Asha",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_extracted_fields_merge_over_prior_with_corrections_winning(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Noted."],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields(
+ {"city": "Mumbai", "age": "30"}
+ ),
+ missing_fields=[],
+ is_complete=False,
+ )
+ ],
+ ),
+ )
+
+ result = await _run_turn(
+ history=[{"role": "assistant", "content": "Anything to change?"}],
+ user_message="Actually I live in Mumbai now, and I'm 30",
+ collected_fields={"name": "Asha", "city": "Pune"},
+ )
+
+ assert result.collected_fields == {
+ "name": "Asha",
+ "city": "Mumbai",
+ "age": "30",
+ }
+
+ @pytest.mark.parametrize(
+ ("is_complete", "missing_fields", "expected_status"),
+ [
+ (True, [], ChatbotCompletionStatusEnum.COMPLETE),
+ (True, ["city"], ChatbotCompletionStatusEnum.IN_PROGRESS),
+ (False, [], ChatbotCompletionStatusEnum.IN_PROGRESS),
+ ],
+ ids=["complete", "complete_but_lists_missing", "not_complete"],
+ )
+ @pytest.mark.asyncio
+ async def test_completion_requires_flag_and_no_missing_fields(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ is_complete: bool,
+ missing_fields: list[str],
+ expected_status: ChatbotCompletionStatusEnum,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Thanks!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields({"name": "Asha"}),
+ missing_fields=missing_fields,
+ is_complete=is_complete,
+ )
+ ],
+ ),
+ )
+
+ result = await _run_turn(history=[], user_message="I'm Asha")
+
+ assert result.completion_status == expected_status
+
+
+class TestProgressNote:
+ @pytest.mark.parametrize(
+ ("extraction", "expected_note"),
+ [
+ (
+ FieldExtraction(
+ collected_fields=extracted_fields({"name": "Asha"}),
+ missing_fields=["city"],
+ is_complete=False,
+ ),
+ f'{COLLECTED_PREFIX}{{"name": "Asha"}}. '
+ "Still missing: city. Ask only for the missing fields.",
+ ),
+ (
+ FieldExtraction(
+ collected_fields=extracted_fields({}),
+ missing_fields=["name", "city"],
+ is_complete=False,
+ ),
+ "Still missing: name, city. Ask only for the missing fields.",
+ ),
+ (
+ FieldExtraction(
+ collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}),
+ missing_fields=[],
+ is_complete=True,
+ ),
+ f'{COLLECTED_PREFIX}{{"name": "Asha", "city": "Pune"}}. '
+ "Every required field is collected; confirm and wrap up as the "
+ "flow prompt describes.",
+ ),
+ ],
+ ids=["collected_and_missing", "missing_only", "complete"],
+ )
+ @pytest.mark.asyncio
+ async def test_note_follows_system_prompt_in_reply_call(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ extraction: FieldExtraction,
+ expected_note: str,
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Reply."], extractions=[extraction])
+ )
+
+ await _run_turn(
+ history=[{"role": "assistant", "content": "Hi! What's your name?"}],
+ user_message="I'm Asha from Pune",
+ )
+
+ [reply_call] = chat_model.calls
+ assert [type(m) for m in reply_call[:2]] == [SystemMessage, SystemMessage]
+ assert [m.content for m in reply_call] == [
+ SYSTEM_PROMPT,
+ f"{PROGRESS_PREFIX}{expected_note}",
+ "Begin the conversation.",
+ "Hi! What's your name?",
+ "I'm Asha from Pune",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_no_note_when_nothing_collected_missing_or_complete(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Reply."],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields({}),
+ missing_fields=[],
+ is_complete=False,
+ )
+ ],
+ ),
+ )
+
+ await _run_turn(history=[], user_message="hello")
+
+ [reply_call] = chat_model.calls
+ assert [m.content for m in reply_call] == [SYSTEM_PROMPT, "hello"]
+
+ @pytest.mark.asyncio
+ async def test_prior_fields_survive_into_note_when_extraction_adds_none(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Which city?"])
+ )
+
+ await _run_turn(
+ history=[{"role": "assistant", "content": "Which city?"}],
+ user_message="hmm",
+ collected_fields={"name": "Asha"},
+ )
+
+ [reply_call] = chat_model.calls
+ assert reply_call[1].content == (
+ f'{PROGRESS_PREFIX}{COLLECTED_PREFIX}{{"name": "Asha"}}.'
+ )
+
+
+class TestFieldExtractionSchema:
+ def test_collected_fields_is_a_list_of_name_value_pairs(self) -> None:
+ # Anthropic's json_schema mode forces additionalProperties=false on every
+ # object, so a free-form dict here would only ever come back empty.
+ schema = FieldExtraction.model_json_schema()
+ collected = schema["properties"]["collected_fields"]
+ item = schema["$defs"][collected["items"]["$ref"].split("/")[-1]]
+
+ assert collected["type"] == "array"
+ assert set(item["properties"]) == {"name", "value"}
+ assert set(item["required"]) == {"name", "value"}
+
+
+class TestExtractionErrors:
+ @pytest.mark.parametrize(
+ ("extraction_outcome", "expected_message"),
+ [
+ (
+ OutputParserException("not valid JSON"),
+ "[ANTHROPIC] Field extraction returned malformed structured output",
+ ),
+ (
+ {"collected_fields": {}, "missing_fields": [], "is_complete": True},
+ "[ANTHROPIC] Field extraction returned an unexpected output shape",
+ ),
+ (
+ ValueError("effort is not supported for this model"),
+ "[KAAPI] Unsupported model configuration for 'claude-sonnet-4-6': "
+ "effort is not supported for this model",
+ ),
+ (
+ anthropic.APITimeoutError(request=ANTHROPIC_REQUEST),
+ "(code: APITimeoutError)",
+ ),
+ ],
+ ids=["output_parser", "wrong_type", "value_error", "anthropic_timeout"],
+ )
+ @pytest.mark.asyncio
+ async def test_extraction_failure_becomes_provider_error_before_reply(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ extraction_outcome: object,
+ expected_message: str,
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["should never be used"], extractions=[extraction_outcome]
+ ),
+ )
+
+ with pytest.raises(ChatbotProviderError) as exc_info:
+ await _run_turn(history=[], user_message="I'm Asha")
+
+ assert expected_message in str(exc_info.value)
+ assert chat_model.calls == []
diff --git a/backend/app/tests/services/chatbot/conftest.py b/backend/app/tests/services/chatbot/conftest.py
new file mode 100644
index 000000000..a2ec328dd
--- /dev/null
+++ b/backend/app/tests/services/chatbot/conftest.py
@@ -0,0 +1,10 @@
+import pytest
+from sqlmodel import Session
+
+from app.models import Config
+from app.tests.utils.chatbot import create_test_anthropic_config
+
+
+@pytest.fixture
+def config(db: Session) -> Config:
+ return create_test_anthropic_config(db)
diff --git a/backend/app/tests/services/chatbot/test_config.py b/backend/app/tests/services/chatbot/test_config.py
new file mode 100644
index 000000000..d02fb4fc4
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_config.py
@@ -0,0 +1,257 @@
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from sqlmodel import Session
+
+from app.crud.config import ConfigVersionCrud
+from app.models import Config, Project
+from app.services.chatbot.config import (
+ get_anthropic_api_key,
+ resolve_chatbot_model_settings,
+)
+from app.services.chatbot.exceptions import ChatbotInputError
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.chatbot import (
+ add_test_config_version,
+ create_test_anthropic_config,
+ create_test_anthropic_credential,
+)
+from app.tests.utils.test_data import create_test_config, create_test_project
+
+MISSING_CREDENTIALS = "Anthropic credentials are not configured for this project"
+
+
+def _resolve(
+ db: Session,
+ config: Config,
+ *,
+ config_version: int | None = None,
+ max_tokens: int | None = None,
+ history_token_limit: int | None = None,
+) -> ChatbotModelSettings:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ return resolve_chatbot_model_settings(
+ session=db,
+ organization_id=project.organization_id,
+ project_id=config.project_id,
+ config_id=config.id,
+ config_version=config_version,
+ max_tokens=max_tokens,
+ history_token_limit=history_token_limit,
+ )
+
+
+def _overwrite_latest_blob(db: Session, config: Config, blob: dict[str, Any]) -> None:
+ # Bypasses ConfigVersionCrud validation to simulate a legacy/corrupt stored row.
+ version = ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ ).read_latest()
+ assert version is not None
+ version.config_blob = blob
+ db.add(version)
+ db.commit()
+
+
+def _project_api_key(db: Session, project: Project) -> str:
+ return get_anthropic_api_key(
+ session=db, organization_id=project.organization_id, project_id=project.id
+ )
+
+
+class TestGetAnthropicApiKey:
+ def test_returns_the_projects_stored_key(self, db: Session) -> None:
+ project = create_test_project(db)
+ create_test_anthropic_credential(
+ db, project_id=project.id, api_key="sk-ant-project-key"
+ )
+
+ assert _project_api_key(db, project) == "sk-ant-project-key"
+
+ def test_project_without_anthropic_credentials_raises(self, db: Session) -> None:
+ # A credential in another project of the same org must not leak across.
+ project = create_test_project(db)
+ sibling = create_test_project(db, organization_id=project.organization_id)
+ create_test_anthropic_credential(db, project_id=sibling.id)
+
+ with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS):
+ _project_api_key(db, project)
+
+ def test_empty_stored_key_raises(self, db: Session) -> None:
+ project = create_test_project(db)
+ create_test_anthropic_credential(db, project_id=project.id, api_key="")
+
+ with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS):
+ _project_api_key(db, project)
+
+
+class TestResolveChatbotModelSettings:
+ def test_latest_version_with_server_defaults(self, db: Session) -> None:
+ config = create_test_anthropic_config(db, model="claude-sonnet-4-6")
+ add_test_config_version(
+ db,
+ config,
+ model="claude-opus-5-5",
+ effort="high",
+ thinking={"type": "adaptive", "display": "summarized"},
+ )
+
+ model_settings = _resolve(db, config)
+
+ assert model_settings == ChatbotModelSettings(
+ model="claude-opus-5-5",
+ max_tokens=1024,
+ reasoning_effort="high",
+ thinking={"type": "adaptive", "display": "summarized"},
+ history_token_limit=4000,
+ )
+
+ def test_settings_carry_the_projects_anthropic_key(self, db: Session) -> None:
+ config = create_test_anthropic_config(db, with_anthropic_credentials=False)
+ create_test_anthropic_credential(
+ db, project_id=config.project_id, api_key="sk-ant-project-key"
+ )
+
+ model_settings = _resolve(db, config)
+
+ assert model_settings.api_key == "sk-ant-project-key"
+
+ def test_project_without_anthropic_credentials_raises(self, db: Session) -> None:
+ config = create_test_anthropic_config(db, with_anthropic_credentials=False)
+
+ with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS):
+ _resolve(db, config)
+
+ def test_enabled_thinking_with_budget_below_max_tokens_is_accepted(
+ self, db: Session
+ ) -> None:
+ thinking = {"type": "enabled", "budget_tokens": 2048}
+ config = create_test_anthropic_config(db, thinking=thinking)
+
+ model_settings = _resolve(db, config, max_tokens=4096)
+
+ assert model_settings.thinking == thinking
+ assert model_settings.max_tokens == 4096
+
+ @pytest.mark.parametrize(
+ "thinking",
+ [
+ {"type": "enabled", "budget_tokens": 2048},
+ {"type": "enabled", "budget_tokens": 1024},
+ {"type": "enabled"},
+ ],
+ ids=["budget-above-max", "budget-equal-max", "budget-missing"],
+ )
+ def test_enabled_thinking_budget_not_below_max_tokens_rejected(
+ self, db: Session, thinking: dict[str, Any]
+ ) -> None:
+ config = create_test_anthropic_config(db, thinking=thinking)
+
+ with pytest.raises(ChatbotInputError, match="budget_tokens"):
+ _resolve(db, config)
+
+ def test_explicit_version_and_request_overrides(self, db: Session) -> None:
+ config = create_test_anthropic_config(db, model="claude-sonnet-4-6")
+ add_test_config_version(db, config)
+
+ model_settings = _resolve(
+ db, config, config_version=1, max_tokens=256, history_token_limit=900
+ )
+
+ assert model_settings.model == "claude-sonnet-4-6"
+ assert model_settings.max_tokens == 256
+ assert model_settings.history_token_limit == 900
+ assert model_settings.reasoning_effort is None
+ assert model_settings.thinking == {"type": "adaptive"}
+
+ def test_unknown_version_raises_404(self, db: Session) -> None:
+ config = create_test_anthropic_config(db)
+
+ with pytest.raises(HTTPException) as exc_info:
+ _resolve(db, config, config_version=99)
+
+ assert exc_info.value.status_code == 404
+
+ def test_config_from_other_project_raises_404(self, db: Session) -> None:
+ config = create_test_anthropic_config(db)
+ other_project = create_test_project(db)
+
+ with pytest.raises(HTTPException) as exc_info:
+ resolve_chatbot_model_settings(
+ session=db,
+ organization_id=other_project.organization_id,
+ project_id=other_project.id,
+ config_id=config.id,
+ config_version=None,
+ max_tokens=None,
+ history_token_limit=None,
+ )
+
+ assert exc_info.value.status_code == 404
+
+ def test_unknown_config_raises_404(self, db: Session) -> None:
+ project = create_test_project(db)
+
+ with pytest.raises(HTTPException) as exc_info:
+ resolve_chatbot_model_settings(
+ session=db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ config_id=uuid4(),
+ config_version=None,
+ max_tokens=None,
+ history_token_limit=None,
+ )
+
+ assert exc_info.value.status_code == 404
+
+ def test_no_live_versions_raises_404(self, db: Session) -> None:
+ config = create_test_anthropic_config(db)
+ ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ ).delete_or_raise(1)
+
+ with pytest.raises(HTTPException) as exc_info:
+ _resolve(db, config)
+
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == f"No version found for config '{config.id}'"
+
+ def test_non_anthropic_provider_rejected(self, db: Session) -> None:
+ config = create_test_config(db, use_kaapi_schema=True)
+
+ with pytest.raises(ChatbotInputError, match="only supports configs"):
+ _resolve(db, config)
+
+ def test_native_completion_config_rejected(self, db: Session) -> None:
+ config = create_test_config(db)
+
+ with pytest.raises(ChatbotInputError, match="only supports configs"):
+ _resolve(db, config)
+
+ def test_invalid_stored_blob_rejected(self, db: Session) -> None:
+ config = create_test_anthropic_config(db)
+ _overwrite_latest_blob(db, config, {"completion": {"provider": "anthropic"}})
+
+ with pytest.raises(ChatbotInputError, match="blob is invalid"):
+ _resolve(db, config)
+
+ def test_missing_model_rejected(self, db: Session) -> None:
+ config = create_test_anthropic_config(db)
+ _overwrite_latest_blob(
+ db,
+ config,
+ {"completion": {"provider": "anthropic", "type": "text", "params": {}}},
+ )
+
+ with pytest.raises(ChatbotInputError, match="missing completion.params.model"):
+ _resolve(db, config)
+
+ @pytest.mark.parametrize("effort", ["none", "minimal"])
+ def test_non_anthropic_effort_rejected(self, db: Session, effort: str) -> None:
+ config = create_test_anthropic_config(db, effort=effort)
+
+ with pytest.raises(ChatbotInputError, match=f"'{effort}' is not an Anthropic"):
+ _resolve(db, config)
diff --git a/backend/app/tests/services/chatbot/test_flow.py b/backend/app/tests/services/chatbot/test_flow.py
new file mode 100644
index 000000000..1bce0facb
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_flow.py
@@ -0,0 +1,285 @@
+from typing import Any
+from uuid import UUID
+
+import pytest
+from sqlmodel import Session
+
+from app.models import ChatbotFlowEdge, ChatbotFlowNode, Config, Project
+from app.services.chatbot.exceptions import ChatbotFlowValidationError
+from app.services.chatbot.flow import resolve_entry_node, validate_flow_for_publish
+from app.tests.utils.chatbot import (
+ ONBOARDING_NODE_ID,
+ add_test_config_version,
+ build_flow_edge,
+ build_flow_node,
+ build_onboarding_node,
+ create_test_anthropic_config,
+)
+
+CONFIG_ID = UUID("00000000-0000-0000-0000-00000000c0f1")
+
+
+def _node(node_id: str, node_type: str, **data: Any) -> ChatbotFlowNode:
+ return ChatbotFlowNode.model_validate(build_flow_node(node_id, node_type, **data))
+
+
+def _onboarding(
+ node_id: str = ONBOARDING_NODE_ID, config_id: UUID = CONFIG_ID, **overrides: Any
+) -> ChatbotFlowNode:
+ return ChatbotFlowNode.model_validate(
+ build_onboarding_node(config_id=config_id, node_id=node_id, **overrides)
+ )
+
+
+def _organization_id(db: Session, config: Config) -> int:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ return project.organization_id
+
+
+def _edge(edge_id: str, source: str, target: str) -> ChatbotFlowEdge:
+ return ChatbotFlowEdge.model_validate(build_flow_edge(edge_id, source, target))
+
+
+class TestResolveEntryNode:
+ def test_no_edges_returns_the_single_agent_node(self) -> None:
+ nodes = [_node("in", "input"), _onboarding(), _node("out", "output")]
+
+ entry = resolve_entry_node(nodes=nodes, edges=[])
+
+ assert entry.id == ONBOARDING_NODE_ID
+
+ def test_with_edges_returns_target_of_the_input_nodes_edge(self) -> None:
+ nodes = [
+ _node("in", "input"),
+ _onboarding("first"),
+ _onboarding("second"),
+ _node("out", "output"),
+ ]
+ edges = [
+ _edge("e1", "in", "second"),
+ _edge("e2", "second", "first"),
+ _edge("e3", "first", "out"),
+ ]
+
+ entry = resolve_entry_node(nodes=nodes, edges=edges)
+
+ assert entry.id == "second"
+
+ def test_input_edge_may_lead_to_any_agent_type(self) -> None:
+ nodes = [_node("in", "input"), _node("router", "coordinator")]
+
+ entry = resolve_entry_node(nodes=nodes, edges=[_edge("e1", "in", "router")])
+
+ assert entry.id == "router"
+
+ @pytest.mark.parametrize(
+ ("nodes", "edges", "expected_problem"),
+ [
+ (
+ [_node("in", "input")],
+ [],
+ "a flow without edges must contain exactly one agent node (found 0)",
+ ),
+ (
+ [_onboarding("a"), _onboarding("b")],
+ [],
+ "a flow without edges must contain exactly one agent node (found 2)",
+ ),
+ (
+ [_onboarding("a"), _node("out", "output")],
+ [_edge("e1", "a", "out")],
+ "a flow with edges must contain exactly one input node (found 0)",
+ ),
+ (
+ [_node("in-a", "input"), _node("in-b", "input"), _onboarding("a")],
+ [_edge("e1", "in-a", "a")],
+ "a flow with edges must contain exactly one input node (found 2)",
+ ),
+ (
+ [_node("in", "input"), _onboarding("a"), _node("out", "output")],
+ [_edge("e1", "a", "out")],
+ "input node 'in' must have exactly one outgoing edge (found 0)",
+ ),
+ (
+ [_node("in", "input"), _onboarding("a"), _onboarding("b")],
+ [_edge("e1", "in", "a"), _edge("e2", "in", "b")],
+ "input node 'in' must have exactly one outgoing edge (found 2)",
+ ),
+ (
+ [_node("in", "input"), _node("out", "output")],
+ [_edge("e1", "in", "out")],
+ "the input node's edge must lead to an agent node (target 'out')",
+ ),
+ (
+ [_node("in", "input"), _onboarding("a")],
+ [_edge("e1", "in", "ghost")],
+ "the input node's edge must lead to an agent node (target 'ghost')",
+ ),
+ ],
+ ids=[
+ "no-edges-no-agent",
+ "no-edges-two-agents",
+ "edges-without-input",
+ "edges-with-two-inputs",
+ "input-without-outgoing-edge",
+ "input-with-two-outgoing-edges",
+ "input-edge-to-output",
+ "input-edge-to-missing-node",
+ ],
+ )
+ def test_unresolvable_entry_raises(
+ self,
+ nodes: list[ChatbotFlowNode],
+ edges: list[ChatbotFlowEdge],
+ expected_problem: str,
+ ) -> None:
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ resolve_entry_node(nodes=nodes, edges=edges)
+
+ assert str(exc_info.value) == (
+ f"[KAAPI] Chatbot flow is invalid: {expected_problem}."
+ )
+
+
+class TestValidateFlowForPublish:
+ def test_returns_nodes_with_missing_config_version_pinned_to_latest(
+ self, db: Session, config: Config
+ ) -> None:
+ add_test_config_version(db, config)
+ input_node = _node("in", "input", label="Start")
+ draft_node = _onboarding(config_id=config.id, config_version=None)
+
+ pinned = validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, config),
+ project_id=config.project_id,
+ nodes=[input_node, draft_node],
+ edges=[_edge("e1", "in", ONBOARDING_NODE_ID)],
+ )
+
+ assert pinned[0] == input_node
+ assert pinned[1].data == {
+ "label": "Beneficiary Info Extractor",
+ "config_id": str(config.id),
+ "prompt": "Collect the user's name and city.",
+ "config_version": 2,
+ }
+ assert "config_version" not in draft_node.data
+
+ def test_explicit_config_version_is_kept(self, db: Session, config: Config) -> None:
+ add_test_config_version(db, config)
+
+ [pinned] = validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, config),
+ project_id=config.project_id,
+ nodes=[_onboarding(config_id=config.id, config_version=1)],
+ edges=[],
+ )
+
+ assert pinned.data["config_version"] == 1
+
+ def test_problems_are_joined_into_one_error(
+ self, db: Session, config: Config
+ ) -> None:
+ nodes = [
+ _onboarding(config_id=config.id),
+ _onboarding(config_id=config.id),
+ _node("out-a", "output"),
+ _node("out-b", "output"),
+ ]
+
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, config),
+ project_id=config.project_id,
+ nodes=nodes,
+ edges=[],
+ )
+
+ assert str(exc_info.value) == (
+ "[KAAPI] Chatbot flow is invalid: "
+ f"duplicate node ids: {ONBOARDING_NODE_ID}; "
+ "at most 1 output node allowed (found 2)."
+ )
+
+ def test_config_error_is_reported_per_node_without_nested_kaapi_tag(
+ self, db: Session
+ ) -> None:
+ bad_thinking_config = create_test_anthropic_config(
+ db, thinking={"type": "enabled", "budget_tokens": 999_999}
+ )
+
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, bad_thinking_config),
+ project_id=bad_thinking_config.project_id,
+ nodes=[_onboarding(config_id=bad_thinking_config.id)],
+ edges=[],
+ )
+
+ message = str(exc_info.value)
+ assert f"node '{ONBOARDING_NODE_ID}': Config thinking.budget_tokens" in message
+ assert message.count("[KAAPI]") == 1
+
+ def test_config_of_another_project_is_rejected(
+ self, db: Session, config: Config
+ ) -> None:
+ other_project_config = create_test_anthropic_config(db)
+
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, config),
+ project_id=config.project_id,
+ nodes=[_onboarding(config_id=other_project_config.id)],
+ edges=[],
+ )
+
+ assert f"node '{ONBOARDING_NODE_ID}': " in str(exc_info.value)
+
+ def test_structurally_valid_flow_without_entry_raises(
+ self, db: Session, config: Config
+ ) -> None:
+ nodes = [_onboarding("a", config.id), _onboarding("b", config.id)]
+
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, config),
+ project_id=config.project_id,
+ nodes=nodes,
+ edges=[],
+ )
+
+ assert "exactly one agent node (found 2)" in str(exc_info.value)
+
+ def test_missing_anthropic_credentials_joins_the_other_problems(
+ self, db: Session
+ ) -> None:
+ uncredentialed = create_test_anthropic_config(
+ db, with_anthropic_credentials=False
+ )
+ nodes = [
+ _onboarding(config_id=uncredentialed.id),
+ _node("out-a", "output"),
+ _node("out-b", "output"),
+ ]
+
+ with pytest.raises(ChatbotFlowValidationError) as exc_info:
+ validate_flow_for_publish(
+ session=db,
+ organization_id=_organization_id(db, uncredentialed),
+ project_id=uncredentialed.project_id,
+ nodes=nodes,
+ edges=[],
+ )
+
+ message = str(exc_info.value)
+ assert message.startswith("[KAAPI] Chatbot flow is invalid: ")
+ assert "at most 1 output node allowed (found 2)" in message
+ assert "Anthropic credentials are not configured for this project" in message
+ assert message.count("[KAAPI]") == 1
diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py
new file mode 100644
index 000000000..295a8a8fc
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_session.py
@@ -0,0 +1,756 @@
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from typing import Any
+from uuid import uuid4
+
+import anthropic
+import pytest
+from sqlmodel import Session
+
+from app.crud.chatbot import (
+ list_chatbot_session_messages,
+ update_chatbot_session_progress,
+)
+from app.crud.config import ConfigVersionCrud
+from app.models import (
+ Chatbot,
+ ChatbotCompletionStatusEnum,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ Config,
+ Project,
+)
+from app.services.chatbot import session as session_service
+from app.services.chatbot.agents.onboarding import graph as onboarding_graph
+from app.services.chatbot.agents.onboarding.types import FieldExtraction
+from app.services.chatbot.session import (
+ SUPERSEDED_SESSION_ERROR,
+ process_chatbot_session_message,
+)
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.chatbot import (
+ ANTHROPIC_REQUEST,
+ DEFAULT_SYSTEM_PROMPT,
+ ONBOARDING_NODE_ID,
+ WEBHOOK_URL,
+ ScriptedChatModel,
+ add_test_chatbot_messages,
+ add_test_config_version,
+ build_onboarding_node,
+ create_test_anthropic_config,
+ create_test_chatbot,
+ create_test_chatbot_session,
+ create_test_flow_version,
+ create_test_live_chatbot,
+ extracted_fields,
+ patch_chat_model,
+)
+from app.tests.utils.test_data import create_test_config
+
+ChatTurns = list[tuple[ChatbotMessageRoleEnum, str]]
+
+
+class _NonClosingSession:
+ """The worker opens `Session(engine)`, which can't see this test's
+ uncommitted rows; hand it the transactional test session instead."""
+
+ def __init__(self, session: Session) -> None:
+ self._session = session
+
+ def __enter__(self) -> Session:
+ return self._session
+
+ def __exit__(self, *_exc: object) -> bool:
+ return False
+
+
+@dataclass
+class CallbackRecorder:
+ calls: list[tuple[str, dict[str, Any], str | None]] = field(default_factory=list)
+
+ def __call__(
+ self, url: str, payload: dict[str, Any], secret: str | None = None
+ ) -> bool:
+ self.calls.append((url, payload, secret))
+ return True
+
+ @property
+ def payloads(self) -> list[dict[str, Any]]:
+ return [payload for _url, payload, _secret in self.calls]
+
+
+@pytest.fixture
+def callbacks(
+ db: Session, monkeypatch: pytest.MonkeyPatch
+) -> Iterator[CallbackRecorder]:
+ recorder = CallbackRecorder()
+ monkeypatch.setattr(
+ session_service, "Session", lambda _engine: _NonClosingSession(db)
+ )
+ # The webhook POST is the external boundary.
+ monkeypatch.setattr(session_service, "send_callback", recorder)
+ yield recorder
+
+
+@pytest.fixture
+def chatbot(db: Session, config: Config) -> Chatbot:
+ return create_test_live_chatbot(db, config=config)[0]
+
+
+@pytest.fixture
+def unused_chat_model(monkeypatch: pytest.MonkeyPatch) -> ScriptedChatModel:
+ """A patched model for turns that must fail or skip before any LLM call."""
+ return patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["should never be used"])
+ )
+
+
+def _process(chatbot_session: ChatbotSession, db: Session) -> None:
+ chatbot = db.get(Chatbot, chatbot_session.chatbot_id)
+ assert chatbot is not None
+ project = db.get(Project, chatbot.project_id)
+ assert project is not None
+ process_chatbot_session_message(
+ session_id=chatbot_session.id,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ webhook_url=WEBHOOK_URL,
+ )
+
+
+def _transcript(db: Session, chatbot_session: ChatbotSession) -> list[tuple[str, str]]:
+ return [
+ (m.role.value, m.content)
+ for m in list_chatbot_session_messages(db, session_id=chatbot_session.id)
+ ]
+
+
+def _session_with_turns(
+ db: Session, chatbot: Chatbot, turns: ChatTurns
+) -> ChatbotSession:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ add_test_chatbot_messages(db, chatbot_session, turns)
+ return chatbot_session
+
+
+def _success_webhook(
+ chatbot_session: ChatbotSession,
+ chatbot: Chatbot,
+ *,
+ reply: str,
+ completion_status: str = "in_progress",
+ collected_fields: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ return {
+ "success": True,
+ "data": {
+ "session_id": str(chatbot_session.id),
+ "chatbot_id": str(chatbot.id),
+ "node_id": ONBOARDING_NODE_ID,
+ "reply": reply,
+ "status": "active",
+ "completion_status": completion_status,
+ "collected_fields": collected_fields or {},
+ },
+ }
+
+
+def _failure_webhook(chatbot_session: ChatbotSession, error: str) -> dict[str, Any]:
+ return {
+ "success": False,
+ "error": error,
+ "data": {"session_id": str(chatbot_session.id)},
+ }
+
+
+def _assert_single_failure_webhook(
+ callbacks: CallbackRecorder,
+ chatbot_session: ChatbotSession,
+ *error_fragments: str,
+) -> None:
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ for fragment in error_fragments:
+ assert fragment in payload["error"]
+ assert payload["data"] == {"session_id": str(chatbot_session.id)}
+
+
+class TestProcessChatbotSessionMessage:
+ def test_first_call_stores_greeting_and_posts_success_webhook(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
+ )
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+
+ _process(chatbot_session, db)
+
+ assert _transcript(db, chatbot_session) == [
+ ("assistant", "Hi! What's your name?")
+ ]
+ assert callbacks.calls == [
+ (
+ WEBHOOK_URL,
+ _success_webhook(
+ chatbot_session, chatbot, reply="Hi! What's your name?"
+ ),
+ None,
+ )
+ ]
+
+ def test_pending_user_message_is_answered_with_prior_history(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Nice, which city?"])
+ )
+ chatbot_session = _session_with_turns(
+ db,
+ chatbot,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?"),
+ (ChatbotMessageRoleEnum.USER, "Asha"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert [m.content for m in chat_model.calls[0]] == [
+ DEFAULT_SYSTEM_PROMPT,
+ "Begin the conversation.",
+ "Hi! What's your name?",
+ "Asha",
+ ]
+ assert _transcript(db, chatbot_session) == [
+ ("assistant", "Hi! What's your name?"),
+ ("user", "Asha"),
+ ("assistant", "Nice, which city?"),
+ ]
+ assert callbacks.payloads[0]["data"]["reply"] == "Nice, which city?"
+
+ def test_stored_system_rows_are_not_sent_as_history(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Which city?"])
+ )
+ chatbot_session = _session_with_turns(
+ db,
+ chatbot,
+ [
+ (ChatbotMessageRoleEnum.SYSTEM, "stale stored prompt"),
+ (ChatbotMessageRoleEnum.USER, "I'm Asha"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ sent = [m.content for m in chat_model.calls[0]]
+ assert "stale stored prompt" not in sent
+ assert sent == [DEFAULT_SYSTEM_PROMPT, "I'm Asha"]
+ assert callbacks.payloads[0]["success"] is True
+
+ def test_already_answered_turn_is_skipped(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ chatbot_session = _session_with_turns(
+ db,
+ chatbot,
+ [
+ (ChatbotMessageRoleEnum.USER, "Asha"),
+ (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ assert callbacks.calls == []
+ assert _transcript(db, chatbot_session) == [
+ ("user", "Asha"),
+ ("assistant", "Which city?"),
+ ]
+
+ def test_unknown_session_does_nothing(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ project = db.get(Project, chatbot.project_id)
+ assert project is not None
+
+ process_chatbot_session_message(
+ session_id=uuid4(),
+ organization_id=project.organization_id,
+ project_id=project.id,
+ webhook_url=WEBHOOK_URL,
+ )
+
+ assert unused_chat_model.calls == []
+ assert callbacks.calls == []
+
+ def test_deleted_config_version_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ ).delete_or_raise(1)
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ assert _transcript(db, chatbot_session) == []
+ assert callbacks.calls == [
+ (
+ WEBHOOK_URL,
+ _failure_webhook(
+ chatbot_session,
+ f"Version with number '1' not found for config '{config.id}'",
+ ),
+ None,
+ )
+ ]
+
+ def test_turn_runs_the_pinned_version_after_a_newer_one_is_saved(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Hi! What's your name?"])
+ used_models: list[str] = []
+
+ def _get_chat_model(model_settings: ChatbotModelSettings) -> ScriptedChatModel:
+ used_models.append(model_settings.model)
+ return chat_model
+
+ monkeypatch.setattr(onboarding_graph, "get_chat_model", _get_chat_model)
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ add_test_config_version(db, config)
+
+ _process(chatbot_session, db)
+
+ assert used_models == ["claude-sonnet-4-6"]
+ assert callbacks.calls[0][1]["success"] is True
+
+ def test_non_anthropic_config_posts_failure_webhook(
+ self,
+ db: Session,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ openai_config = create_test_config(db, use_kaapi_schema=True)
+ # Publish would reject this config; the worker must still guard against it.
+ openai_chatbot, _ = create_test_live_chatbot(db, config=openai_config)
+ chatbot_session = create_test_chatbot_session(db, chatbot=openai_chatbot)
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ _assert_single_failure_webhook(
+ callbacks, chatbot_session, "only supports configs"
+ )
+
+ def test_missing_anthropic_credentials_posts_failure_webhook(
+ self,
+ db: Session,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ # The flow went live while credentials existed; the key is gone by turn time.
+ uncredentialed = create_test_anthropic_config(
+ db, with_anthropic_credentials=False
+ )
+ chatbot, _ = create_test_live_chatbot(db, config=uncredentialed)
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ assert _transcript(db, chatbot_session) == []
+ _assert_single_failure_webhook(
+ callbacks,
+ chatbot_session,
+ "Anthropic credentials are not configured for this project",
+ )
+
+ def test_provider_error_posts_failure_webhook_and_stores_nothing(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ error=anthropic.APITimeoutError(request=ANTHROPIC_REQUEST)
+ ),
+ )
+ chatbot_session = _session_with_turns(
+ db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+
+ _process(chatbot_session, db)
+
+ assert _transcript(db, chatbot_session) == [("user", "Asha")]
+ _assert_single_failure_webhook(
+ callbacks, chatbot_session, "(code: APITimeoutError)"
+ )
+
+ def test_transcript_without_pending_user_turn_posts_failure_webhook(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ # A trailing system row dodges the already-answered check, leaving an
+ # assistant turn with no user message after it.
+ chatbot_session = _session_with_turns(
+ db,
+ chatbot,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "Hi!"),
+ (ChatbotMessageRoleEnum.SYSTEM, "note"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "`message` is required" in payload["error"]
+
+
+class TestSessionFieldProgress:
+ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Thanks, all set!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields({"city": "Pune"}),
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ ),
+ )
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha"},
+ completion_status=ChatbotCompletionStatusEnum.IN_PROGRESS,
+ )
+ updated_before_turn = chatbot_session.updated_at
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"),
+ (ChatbotMessageRoleEnum.USER, "Pune"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ # "name" only survives if the stored prior map was handed to the engine.
+ assert stored.collected_fields == {"name": "Asha", "city": "Pune"}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.COMPLETE
+ assert stored.updated_at > updated_before_turn
+ assert callbacks.payloads == [
+ _success_webhook(
+ chatbot_session,
+ chatbot,
+ reply="Thanks, all set!",
+ completion_status="complete",
+ collected_fields={"name": "Asha", "city": "Pune"},
+ )
+ ]
+
+ def test_extraction_value_error_posts_failure_webhook_and_keeps_progress(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["should never be used"],
+ extractions=[ValueError("thinking cannot be disabled for this model")],
+ ),
+ )
+ chatbot_session = _session_with_turns(
+ db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+
+ _process(chatbot_session, db)
+
+ _assert_single_failure_webhook(
+ callbacks,
+ chatbot_session,
+ "[KAAPI] Unsupported model configuration",
+ "thinking cannot be disabled for this model",
+ )
+ assert _transcript(db, chatbot_session) == [("user", "Asha")]
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ assert stored.collected_fields == {}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
+
+ def test_complete_session_still_answers_new_messages(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["You're welcome!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields=extracted_fields(
+ {"name": "Asha", "city": "Pune"}
+ ),
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ ),
+ )
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha", "city": "Pune"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "All set!"),
+ (ChatbotMessageRoleEnum.USER, "Thanks"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert _transcript(db, chatbot_session) == [
+ ("assistant", "All set!"),
+ ("user", "Thanks"),
+ ("assistant", "You're welcome!"),
+ ]
+ [payload] = callbacks.payloads
+ assert payload["success"] is True
+ assert payload["data"]["completion_status"] == "complete"
+
+
+def _session_on_live_flow(
+ db: Session,
+ config: Config,
+ *,
+ nodes: list[dict[str, Any]],
+ active_node_id: str,
+) -> ChatbotSession:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ chatbot = create_test_chatbot(db, project=project)
+ flow_version = create_test_flow_version(
+ db, chatbot=chatbot, nodes=nodes, is_live=True
+ )
+ return create_test_chatbot_session(
+ db, chatbot=chatbot, flow_version=flow_version, active_node_id=active_node_id
+ )
+
+
+class TestProcessChatbotSessionMessageFlowNode:
+ def test_reply_is_stored_against_the_active_node(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Which city?"]))
+ chatbot_session = _session_with_turns(
+ db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+
+ _process(chatbot_session, db)
+
+ stored = list_chatbot_session_messages(db, session_id=chatbot_session.id)
+ assert [(m.role.value, m.node_id) for m in stored] == [
+ ("user", ONBOARDING_NODE_ID),
+ ("assistant", ONBOARDING_NODE_ID),
+ ]
+
+ def test_pinned_flow_prompt_is_used_after_a_newer_flow_is_published(
+ self,
+ db: Session,
+ config: Config,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
+ )
+ chatbot_session = create_test_chatbot_session(db, chatbot=chatbot)
+ create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[
+ build_onboarding_node(
+ config_id=config.id, prompt="Collect the student's grade."
+ )
+ ],
+ is_live=True,
+ )
+
+ _process(chatbot_session, db)
+
+ assert chat_model.calls[0][0].content == DEFAULT_SYSTEM_PROMPT
+ assert callbacks.payloads[0]["success"] is True
+
+ def test_inactive_session_posts_superseded_failure_webhook(
+ self,
+ db: Session,
+ chatbot: Chatbot,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ chatbot_session = _session_with_turns(
+ db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+ create_test_chatbot_session(
+ db, chatbot=chatbot, contact_ref=chatbot_session.contact_ref
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ assert callbacks.payloads == [
+ _failure_webhook(chatbot_session, SUPERSEDED_SESSION_ERROR)
+ ]
+ assert _transcript(db, chatbot_session) == [("user", "Asha")]
+
+ def test_active_node_missing_from_flow_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ chatbot_session = _session_on_live_flow(
+ db,
+ config,
+ nodes=[build_onboarding_node(config_id=config.id)],
+ active_node_id="ghost",
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ assert callbacks.payloads == [
+ _failure_webhook(
+ chatbot_session,
+ "[KAAPI] Active node 'ghost' is not in the session's flow "
+ "version; start a new session.",
+ )
+ ]
+
+ def test_non_onboarding_active_node_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ ) -> None:
+ coordinator = build_onboarding_node(
+ config_id=config.id, node_id="router", node_type="coordinator"
+ )
+ chatbot_session = _session_on_live_flow(
+ db, config, nodes=[coordinator], active_node_id="router"
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "'router' has type 'coordinator'" in payload["error"]
+ assert "only 'onboarding' nodes can run" in payload["error"]
+
+ @pytest.mark.parametrize(
+ "node_overrides",
+ [{"prompt": None}, {"config_id": None}],
+ ids=["missing-prompt", "missing-config"],
+ )
+ def test_node_without_prompt_or_config_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ unused_chat_model: ScriptedChatModel,
+ node_overrides: dict[str, Any],
+ ) -> None:
+ node_kwargs: dict[str, Any] = {"config_id": config.id, **node_overrides}
+ chatbot_session = _session_on_live_flow(
+ db,
+ config,
+ nodes=[build_onboarding_node(**node_kwargs)],
+ active_node_id=ONBOARDING_NODE_ID,
+ )
+
+ _process(chatbot_session, db)
+
+ assert unused_chat_model.calls == []
+ _assert_single_failure_webhook(
+ callbacks, chatbot_session, "missing data.prompt or data.config_id"
+ )
diff --git a/backend/app/tests/services/chatbot/test_utils.py b/backend/app/tests/services/chatbot/test_utils.py
new file mode 100644
index 000000000..21b706742
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_utils.py
@@ -0,0 +1,77 @@
+import pytest
+from langchain_anthropic import ChatAnthropic
+
+from app.core.config import settings
+from app.services.chatbot.utils import (
+ ChatbotModelSettings,
+ default_thinking,
+ get_chat_model,
+)
+
+PROJECT_API_KEY = "sk-ant-project"
+PLATFORM_API_KEY = "sk-ant-platform"
+
+
+class TestChatbotModelSettings:
+ def test_defaults(self) -> None:
+ model_settings = ChatbotModelSettings(model="claude-sonnet-4-5")
+
+ assert model_settings.max_tokens == 1024
+ assert model_settings.reasoning_effort is None
+ assert model_settings.thinking == {"type": "adaptive"}
+ assert model_settings.history_token_limit == 4000
+
+ def test_thinking_default_is_not_shared_between_instances(self) -> None:
+ first = ChatbotModelSettings(model="claude-sonnet-4-5")
+ first.thinking["budget_tokens"] = 2048
+
+ second = ChatbotModelSettings(model="claude-sonnet-4-5")
+
+ assert second.thinking == {"type": "adaptive"}
+ assert default_thinking() == {"type": "adaptive"}
+
+ def test_repr_hides_the_api_key(self) -> None:
+ model_settings = ChatbotModelSettings(
+ model="claude-sonnet-4-5", api_key=PROJECT_API_KEY
+ )
+
+ assert PROJECT_API_KEY not in repr(model_settings)
+ assert "claude-sonnet-4-5" in repr(model_settings)
+
+
+class TestGetChatModel:
+ @pytest.fixture(autouse=True)
+ def platform_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ # A platform/env key must never be used in place of the project's key.
+ monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", PLATFORM_API_KEY)
+ monkeypatch.setenv("ANTHROPIC_API_KEY", PLATFORM_API_KEY)
+
+ def test_missing_api_key_raises(self) -> None:
+ with pytest.raises(RuntimeError, match="model_settings has no api_key"):
+ get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5"))
+
+ def test_builds_model_from_settings(self) -> None:
+ chat_model = get_chat_model(
+ ChatbotModelSettings(
+ model="claude-sonnet-4-5",
+ max_tokens=512,
+ reasoning_effort="high",
+ thinking={"type": "enabled", "budget_tokens": 2048},
+ api_key=PROJECT_API_KEY,
+ )
+ )
+
+ assert isinstance(chat_model, ChatAnthropic)
+ assert chat_model.model == "claude-sonnet-4-5"
+ assert chat_model.max_tokens == 512
+ assert chat_model.reasoning_effort == "high"
+ assert chat_model.thinking == {"type": "enabled", "budget_tokens": 2048}
+ assert chat_model.anthropic_api_key.get_secret_value() == PROJECT_API_KEY
+
+ def test_omits_reasoning_effort_when_unset(self) -> None:
+ chat_model = get_chat_model(
+ ChatbotModelSettings(model="claude-sonnet-4-5", api_key=PROJECT_API_KEY)
+ )
+
+ assert chat_model.reasoning_effort is None
+ assert chat_model.thinking == {"type": "adaptive"}
diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py
new file mode 100644
index 000000000..7a9b68ba2
--- /dev/null
+++ b/backend/app/tests/utils/chatbot.py
@@ -0,0 +1,372 @@
+from collections.abc import Sequence
+from typing import Any
+from uuid import UUID
+
+import httpx
+import pytest
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.messages import AIMessage, BaseMessage
+from langchain_core.outputs import ChatGeneration, ChatResult
+from langchain_core.runnables import Runnable, RunnableLambda
+from pydantic import Field
+from sqlmodel import Session, select
+
+from app.core.config import settings
+from app.crud import set_creds_for_org
+from app.crud.chatbot import (
+ create_chatbot,
+ create_chatbot_flow_version,
+ create_chatbot_session,
+ create_chatbot_session_message,
+ get_latest_chatbot_flow_version,
+ get_live_chatbot_flow_version,
+ publish_chatbot_flow_version,
+)
+from app.crud.credentials import get_provider_credential
+from app.models import (
+ Chatbot,
+ ChatbotFlowNodeTypeEnum,
+ ChatbotFlowVersion,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ Config,
+ CredsCreate,
+ Project,
+)
+from app.models.provider_credentials import Provider
+from app.services.chatbot.agents.onboarding import graph as onboarding_graph
+from app.services.chatbot.agents.onboarding.types import (
+ ExtractedField,
+ FieldExtraction,
+)
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+ create_test_project,
+ create_test_version,
+)
+from app.tests.utils.utils import random_lower_string
+
+DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city."
+ONBOARDING_NODE_ID = "onboarding-1"
+ONBOARDING_NODE_LABEL = "Beneficiary Info Extractor"
+# A model other than build_anthropic_text_config_blob's default, so a newer
+# config version is distinguishable from version 1.
+NEWER_CONFIG_MODEL = "claude-opus-5-5"
+WEBHOOK_URL = "https://hooks.example.com/chatbot"
+ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+
+CHATBOTS_URL = f"{settings.API_V1_STR}/chatbots"
+LATEST_SESSION_URL = f"{CHATBOTS_URL}/sessions/latest"
+UNKNOWN_CHATBOT_ID = UUID("00000000-0000-0000-0000-0000000000c1")
+
+
+def chatbot_url(chatbot_id: UUID) -> str:
+ return f"{CHATBOTS_URL}/{chatbot_id}"
+
+
+def flow_url(chatbot_id: UUID) -> str:
+ return f"{chatbot_url(chatbot_id)}/flow"
+
+
+def publish_url(chatbot_id: UUID) -> str:
+ return f"{flow_url(chatbot_id)}/publish"
+
+
+def sessions_url(chatbot_id: UUID) -> str:
+ return f"{chatbot_url(chatbot_id)}/sessions"
+
+
+def message_url(session_id: UUID) -> str:
+ return f"{CHATBOTS_URL}/sessions/{session_id}/message"
+
+
+def random_contact_ref() -> str:
+ return f"contact-{random_lower_string()}"
+
+
+def extracted_fields(fields: dict[str, str]) -> list[ExtractedField]:
+ return [ExtractedField(name=name, value=value) for name, value in fields.items()]
+
+
+NOTHING_EXTRACTED = FieldExtraction(
+ collected_fields=[], missing_fields=[], is_complete=False
+)
+
+
+class ScriptedChatModel(BaseChatModel):
+ """Stands in for ChatAnthropic: replies from a script and records each call."""
+
+ replies: list[str] = Field(default_factory=list)
+ token_count: int = 0
+ error: Exception | None = None
+ calls: list[list[BaseMessage]] = Field(default_factory=list)
+ # Each entry is a FieldExtraction to return, an exception to raise, or any
+ # other object to simulate a wrongly-typed structured output.
+ extractions: list[Any] = Field(default_factory=list)
+ extraction_calls: list[list[BaseMessage]] = Field(default_factory=list)
+ structured_output_methods: list[str | None] = Field(default_factory=list)
+
+ @property
+ def _llm_type(self) -> str:
+ return "scripted"
+
+ def _generate(
+ self,
+ messages: list[BaseMessage],
+ stop: list[str] | None = None,
+ run_manager: Any = None,
+ **kwargs: Any,
+ ) -> ChatResult:
+ self.calls.append(list(messages))
+ if self.error is not None:
+ raise self.error
+ reply = AIMessage(content=self.replies.pop(0))
+ return ChatResult(generations=[ChatGeneration(message=reply)])
+
+ def get_num_tokens_from_messages(
+ self, messages: list[BaseMessage], tools: Sequence[Any] | None = None
+ ) -> int:
+ return self.token_count
+
+ def with_structured_output(
+ self, schema: Any, *, method: str | None = None, **kwargs: Any
+ ) -> Runnable[Any, Any]:
+ self.structured_output_methods.append(method)
+
+ def _extract(messages: list[BaseMessage]) -> Any:
+ self.extraction_calls.append(list(messages))
+ outcome = self.extractions.pop(0) if self.extractions else NOTHING_EXTRACTED
+ if isinstance(outcome, BaseException):
+ raise outcome
+ return outcome
+
+ return RunnableLambda(_extract)
+
+
+def patch_chat_model(
+ monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel
+) -> ScriptedChatModel:
+ # The Anthropic SDK is the external boundary; everything above it runs for real.
+ monkeypatch.setattr(
+ onboarding_graph, "get_chat_model", lambda _settings: chat_model
+ )
+ return chat_model
+
+
+def create_test_anthropic_credential(
+ db: Session, *, project_id: int, api_key: str | None = None
+) -> str:
+ """The project's stored Anthropic key, storing one first if the project has none."""
+ project = db.get(Project, project_id)
+ assert project is not None
+ existing = get_provider_credential(
+ session=db,
+ org_id=project.organization_id,
+ project_id=project_id,
+ provider=Provider.ANTHROPIC.value,
+ )
+ if isinstance(existing, dict):
+ return existing["api_key"]
+
+ if api_key is None:
+ api_key = f"sk-ant-{random_lower_string()}"
+ set_creds_for_org(
+ session=db,
+ creds_add=CredsCreate(
+ is_active=True,
+ credential={Provider.ANTHROPIC.value: {"api_key": api_key}},
+ ),
+ organization_id=project.organization_id,
+ project_id=project_id,
+ )
+ return api_key
+
+
+def create_test_anthropic_config(
+ db: Session,
+ *,
+ project_id: int | None = None,
+ with_anthropic_credentials: bool = True,
+ **params: Any,
+) -> Config:
+ """An Anthropic config; its project also gets Anthropic credentials unless opted out,
+ since the chatbot only runs with the project's own key."""
+ config = create_test_config(
+ db,
+ project_id=project_id,
+ config_blob=build_anthropic_text_config_blob(**params),
+ )
+ if with_anthropic_credentials:
+ create_test_anthropic_credential(db, project_id=config.project_id)
+ return config
+
+
+def add_test_config_version(
+ db: Session, config: Config, *, model: str = NEWER_CONFIG_MODEL, **params: Any
+) -> None:
+ create_test_version(
+ db,
+ config_id=config.id,
+ project_id=config.project_id,
+ config_blob=build_anthropic_text_config_blob(model=model, **params),
+ )
+
+
+def build_flow_node(node_id: str, node_type: str, **data: Any) -> dict[str, Any]:
+ return {
+ "id": node_id,
+ "type": node_type,
+ "position": {"x": 0, "y": 0},
+ "data": data,
+ }
+
+
+def build_flow_edge(edge_id: str, source: str, target: str) -> dict[str, Any]:
+ return {"id": edge_id, "source": source, "target": target}
+
+
+def build_onboarding_node(
+ *,
+ config_id: UUID | None,
+ config_version: int | None = 1,
+ prompt: str | None = DEFAULT_SYSTEM_PROMPT,
+ node_id: str = ONBOARDING_NODE_ID,
+ node_type: str = ChatbotFlowNodeTypeEnum.ONBOARDING.value,
+) -> dict[str, Any]:
+ data: dict[str, Any] = {"label": ONBOARDING_NODE_LABEL}
+ if config_id is not None:
+ data["config_id"] = str(config_id)
+ if config_version is not None:
+ data["config_version"] = config_version
+ if prompt is not None:
+ data["prompt"] = prompt
+ return build_flow_node(node_id, node_type, **data)
+
+
+def create_test_chatbot(
+ db: Session,
+ *,
+ project: Project | None = None,
+ name: str | None = None,
+) -> Chatbot:
+ if project is None:
+ project = create_test_project(db)
+ return create_chatbot(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ name=name or f"chatbot-{random_lower_string()}",
+ )
+
+
+def create_test_flow_version(
+ db: Session,
+ *,
+ chatbot: Chatbot,
+ nodes: list[dict[str, Any]],
+ edges: list[dict[str, Any]] | None = None,
+ is_live: bool = False,
+) -> ChatbotFlowVersion:
+ latest = get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id)
+ flow_version = create_chatbot_flow_version(
+ db,
+ chatbot_id=chatbot.id,
+ version=1 if latest is None else latest.version + 1,
+ nodes=nodes,
+ edges=edges or [],
+ )
+ if is_live:
+ flow_version = publish_chatbot_flow_version(
+ db, flow_version=flow_version, nodes=nodes
+ )
+ return flow_version
+
+
+def create_test_live_chatbot(
+ db: Session,
+ *,
+ config: Config,
+ name: str | None = None,
+ prompt: str = DEFAULT_SYSTEM_PROMPT,
+ config_version: int = 1,
+) -> tuple[Chatbot, ChatbotFlowVersion]:
+ """A chatbot in the config's project whose live flow is one onboarding node."""
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ chatbot = create_test_chatbot(db, project=project, name=name)
+ flow_version = create_test_flow_version(
+ db,
+ chatbot=chatbot,
+ nodes=[
+ build_onboarding_node(
+ config_id=config.id, config_version=config_version, prompt=prompt
+ )
+ ],
+ is_live=True,
+ )
+ return chatbot, flow_version
+
+
+def create_test_chatbot_session(
+ db: Session,
+ *,
+ chatbot: Chatbot,
+ contact_ref: str | None = None,
+ flow_version: ChatbotFlowVersion | None = None,
+ active_node_id: str = ONBOARDING_NODE_ID,
+) -> ChatbotSession:
+ if flow_version is None:
+ flow_version = get_live_chatbot_flow_version(db, chatbot_id=chatbot.id)
+ assert flow_version is not None
+ return create_chatbot_session(
+ db,
+ chatbot_id=chatbot.id,
+ flow_version_id=flow_version.id,
+ contact_ref=contact_ref or random_contact_ref(),
+ active_node_id=active_node_id,
+ )
+
+
+def add_test_chatbot_messages(
+ db: Session,
+ chatbot_session: ChatbotSession,
+ turns: list[tuple[ChatbotMessageRoleEnum, str]],
+) -> list[ChatbotSessionMessage]:
+ stored: list[ChatbotSessionMessage] = []
+ for role, content in turns:
+ stored.append(
+ create_chatbot_session_message(
+ db,
+ session_id=chatbot_session.id,
+ role=role,
+ content=content,
+ node_id=chatbot_session.active_node_id,
+ )
+ )
+ return stored
+
+
+def list_stored_flow_versions(
+ db: Session, chatbot: Chatbot
+) -> list[ChatbotFlowVersion]:
+ db.expire_all()
+ return list(
+ db.exec(
+ select(ChatbotFlowVersion)
+ .where(ChatbotFlowVersion.chatbot_id == chatbot.id)
+ .order_by(ChatbotFlowVersion.version)
+ ).all()
+ )
+
+
+def list_stored_sessions_for_contact(
+ db: Session, contact_ref: str
+) -> list[ChatbotSession]:
+ db.expire_all()
+ return list(
+ db.exec(
+ select(ChatbotSession).where(ChatbotSession.contact_ref == contact_ref)
+ ).all()
+ )
diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py
index f3da4bcb7..1c0bb5368 100644
--- a/backend/app/tests/utils/test_data.py
+++ b/backend/app/tests/utils/test_data.py
@@ -1,3 +1,5 @@
+from typing import Any
+
from sqlmodel import Session
from app.models.provider_credentials import Provider
@@ -55,21 +57,20 @@ def create_test_organization(db: Session) -> Organization:
return create_organization(session=db, org_create=org_in)
-def create_test_project(db: Session) -> Project:
+def create_test_project(db: Session, organization_id: int | None = None) -> Project:
"""
- Creates and returns a test project under a newly created test organization.
-
- Persists both the organization and the project to the database.
-
+ Creates and returns a test project under `organization_id`, or under a newly
+ created test organization when omitted.
"""
- org = create_test_organization(db)
+ if organization_id is None:
+ organization_id = create_test_organization(db).id
name = f"TestProject-{random_lower_string()}"
project_description = "This is a test project description."
project_in = ProjectCreate(
name=name,
description=project_description,
is_active=True,
- organization_id=org.id,
+ organization_id=organization_id,
)
return create_project(session=db, project_create=project_in)
@@ -329,6 +330,17 @@ def create_test_config(
return config
+def build_anthropic_text_config_blob(**params: Any) -> ConfigBlob:
+ """Kaapi-format Anthropic text config — the only shape the chatbot accepts."""
+ return ConfigBlob(
+ completion=build_kaapi_completion_config(
+ provider="anthropic",
+ type="text",
+ params={"model": "claude-sonnet-4-6", **params},
+ )
+ )
+
+
def create_test_version(
db: Session,
config_id: int,
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 9e584035b..6a136d152 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -63,6 +63,7 @@ dependencies = [
"filetype>=1.2.0",
"langgraph>=1.2.10",
"langgraph-checkpoint-postgres>=3.1.1",
+ "langchain-anthropic>=1.7.5",
]
[tool.uv]
diff --git a/backend/uv.lock b/backend/uv.lock
index bacfc69d1..a9915f93e 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -258,21 +258,20 @@ wheels = [
[[package]]
name = "anthropic"
-version = "0.104.1"
+version = "1.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
- { name = "distro" },
{ name = "docstring-parser" },
- { name = "httpx" },
+ { name = "httpx2" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/22/c7/7a655b948916f777354648ce979f68b94d5b8dbdb5f61fed1f37fad9378c/anthropic-0.104.1.tar.gz", hash = "sha256:17362b6c45f527afcc9b0fdf62011ffd359726ab2ebcb1978ea0cc41bd8d8d40", size = 850081, upload-time = "2026-05-22T15:36:57.432Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/cc/0da23e94020c62c0077c17648252f32d575b187710faaf3b85145e8e555d/anthropic-1.12.1.tar.gz", hash = "sha256:10f720ab6ddf40ef12e0e5743412c3a6dfac1109c244e633c9a2dbd36778ad30", size = 1555908, upload-time = "2026-10-08T03:42:00.588Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b8/12/d9ab42790494d7c428391a46cd28492395566a6a8ccb138d681978594455/anthropic-0.104.1-py3-none-any.whl", hash = "sha256:35c8cb456f5a4405aafe1f10f03f6fcc54fa51fa8ec01d655cc4b437d120e9b7", size = 832996, upload-time = "2026-05-22T15:36:59.519Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/35/1ccdc3e950dd869b66de15035e15d9614607fcef9664dec403fe639f01df/anthropic-1.12.1-py3-none-any.whl", hash = "sha256:1c1469bb3f4118291756816b7850cc839f17b2be5cdc99c8fde3890a61a49ca2", size = 1919752, upload-time = "2026-10-08T03:41:58.596Z" },
]
[[package]]
@@ -313,6 +312,7 @@ dependencies = [
{ name = "indic-nlp-library" },
{ name = "jinja2" },
{ name = "jiwer" },
+ { name = "langchain-anthropic" },
{ name = "langfuse" },
{ name = "langgraph" },
{ name = "langgraph-checkpoint-postgres" },
@@ -388,6 +388,7 @@ requires-dist = [
{ name = "indic-nlp-library", specifier = ">=0.92" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
{ name = "jiwer", specifier = ">=3.1.0" },
+ { name = "langchain-anthropic", specifier = ">=1.7.5" },
{ name = "langfuse", specifier = "==4.7.1" },
{ name = "langgraph", specifier = ">=1.2.10" },
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.1.1" },
@@ -1629,6 +1630,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httpcore2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11", marker = "sys_platform != 'emscripten'" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
+]
+
[[package]]
name = "httptools"
version = "0.7.1"
@@ -1673,6 +1687,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
+[[package]]
+name = "httpx2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio", marker = "sys_platform != 'emscripten'" },
+ { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+ { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
+ { name = "idna" },
+ { name = "truststore", marker = "sys_platform != 'emscripten'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
+]
+
[[package]]
name = "huggingface-hub"
version = "1.7.1"
@@ -1713,11 +1753,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.15"
+version = "3.20"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
@@ -1949,11 +1989,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
]
+[[package]]
+name = "langchain-anthropic"
+version = "1.7.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anthropic" },
+ { name = "langchain-core" },
+ { name = "pydantic" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/e6/45ead93ef6d9f244b502a6a32bf9a185b3da1cc7fa8003896a102dadac09/langchain_anthropic-1.7.5.tar.gz", hash = "sha256:6016f38e5c4bf12c16ecfa76abf133a978f459f4d2887b8e28435ae1ff00beb3", size = 764625, upload-time = "2026-09-29T15:22:07.106Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/a8/c507f2c1859b584843983e98bc71e0066c16a77e6c181921c928e0fdbcf7/langchain_anthropic-1.7.5-py3-none-any.whl", hash = "sha256:b21ef0e0c2d6175235a5b7eef4f5f89057df92d42dbfab66920a05605ef7173a", size = 65239, upload-time = "2026-09-29T15:22:05.832Z" },
+]
+
[[package]]
name = "langchain-core"
-version = "1.5.3"
+version = "1.6.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "httpx" },
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
@@ -1964,9 +2019,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/65/3e/63af6b9d76d9be907c7c524d6ec18a2efed7e0e2d123fea0230d78dbd73f/langchain_core-1.5.3.tar.gz", hash = "sha256:a56457ac444fef41e9404443c187f0ecea708d36e816ea4ba9573c027f7d1a2d", size = 972461, upload-time = "2026-07-30T14:55:55.833Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/d3/e9611e44e62ab8175127e137587c62dc2ed00e2615aebd89dfdb77f5fba0/langchain_core-1.6.9.tar.gz", hash = "sha256:34394228603e6f0bd24ce4e773b58b7d160e1b8cf489ecb350cf41f5366f5e92", size = 1010051, upload-time = "2026-10-08T20:11:19.881Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/36/e6/c7c39efe0bc7e1b7c3d8f54f85846e04c901913c3d3e99068b218558c6f1/langchain_core-1.5.3-py3-none-any.whl", hash = "sha256:48b56fa580277209594dd7baf837f5b9a2a3651613f34ff9fb1728b429df015f", size = 561687, upload-time = "2026-07-30T14:55:54.419Z" },
+ { url = "https://files.pythonhosted.org/packages/49/41/fdcdd869c052874b17e2325c986cf8c2fc6bbefa3b9d5404dd64dfffb12a/langchain_core-1.6.9-py3-none-any.whl", hash = "sha256:2bacea12270fd4cfdeeefdc5977e7e8b3c30814c509534d1ef43f4e5b7d3f657", size = 572951, upload-time = "2026-10-08T20:11:18.189Z" },
]
[[package]]
@@ -4732,6 +4787,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
[[package]]
name = "typer"
version = "0.23.1"
diff --git a/docs/wiki/INDEX.md b/docs/wiki/INDEX.md
index 3eabe09de..8e457234b 100644
--- a/docs/wiki/INDEX.md
+++ b/docs/wiki/INDEX.md
@@ -22,6 +22,7 @@ Deep design narrative lives in `docs/architecture/*.md`; open those only for des
- [modules/knowledge-base.md](modules/knowledge-base.md) — documents, collections, transforms, vector-store providers. Deep dive: `docs/architecture/kaapi-knowledge-base-ARCHITECTURE.md`
- [modules/responses.md](modules/responses.md) — OpenAI Responses API integration, conversations, threads, assistants. No deep-dive doc yet.
- [modules/assessment.md](modules/assessment.md) — assessments and assessment runs. No deep-dive doc yet.
+- [modules/chatbot.md](modules/chatbot.md) — chatbots as versioned React Flow graphs (draft/publish) with per-conversation sessions; onboarding agent with per-turn field extraction + completion status (`/chatbots`, `/chatbots/{id}/flow*`, `/chatbots/*sessions*` via Celery + webhook; LangGraph + Anthropic, model from a saved config per agent node). No deep-dive doc yet.
- [modules/tenancy.md](modules/tenancy.md) — users, orgs, projects, API keys, onboarding, login.
- [modules/platform.md](modules/platform.md) — analytics, notifications, feature flags, languages, credentials, model config, cron.
diff --git a/docs/wiki/domain-map.md b/docs/wiki/domain-map.md
index 788e2c42f..37759e939 100644
--- a/docs/wiki/domain-map.md
+++ b/docs/wiki/domain-map.md
@@ -23,8 +23,8 @@ APIKey → Organization, Project, User # programmatic access
| User | user.py | — | UserProject, APIKey, Notification |
| APIKey | api_key.py | Organization, Project, User | auth dependency on every API route (logical) |
| Credential | credentials.py | Organization, Project | provider clients: OpenAI/Gemini/Anthropic calls (logical); bucket providers (`google-gcp` → GCS signing, logical) |
-| Config | config/config.py | Project | ConfigVersion; LLM call path; Assessment; EvaluationRun (`config_id`) |
-| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references (logical) |
+| Config | config/config.py | Project | ConfigVersion; LLM call path; ChatbotFlowVersion agent nodes (`data.config_id`, logical); Assessment; EvaluationRun (`config_id`) |
+| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references and chatbot flow agent nodes (`data.config_id`/`data.config_version`, pinned at publish, logical) |
| LlmCall | llm/request.py | Job, LlmChain, Org, Project | Langfuse traces (logical); analytics |
| LlmChain | llm/request.py | Org, Project | LlmCall |
| Job | job.py | Project | LlmCall; Assessment (RESPONSE method); Celery job execution (logical); evaluation prompt improvement (`JobType.PROMPT_IMPROVEMENT`, logical) |
@@ -50,6 +50,10 @@ APIKey → Organization, Project, User # programmatic access
| Language | language.py (`global.languages`) | — | EvaluationDataset, EvaluationRun, STT |
| FeatureFlag | feature_flag.py | Org, Project | route gating (logical) |
| Notification | notification.py | Project, User | notification service |
+| Chatbot | chatbot.py | Org, Project | ChatbotFlowVersion, ChatbotSession; soft-deleted (`deleted_at`) because sessions reference it |
+| ChatbotFlowVersion | chatbot.py | Chatbot (CASCADE) | ChatbotSession (`flow_version_id`, RESTRICT); React Flow editor in kaapi-frontend (logical); Config/ConfigVersion via node `data` (logical) |
+| ChatbotSession | chatbot.py | Chatbot (RESTRICT), ChatbotFlowVersion (RESTRICT) | ChatbotSessionMessage; Celery turn worker; caller webhook (logical) |
+| ChatbotSessionMessage | chatbot.py | ChatbotSession (CASCADE) | turn history for the onboarding agent (logical) |
## External consumers (always check in blast radius)
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
new file mode 100644
index 000000000..c21bfabca
--- /dev/null
+++ b/docs/wiki/modules/chatbot.md
@@ -0,0 +1,58 @@
+# Module: Chatbot
+
+A project has many chatbots; each chatbot's behaviour is a React Flow graph (nodes + edges JSON) edited in the UI and versioned with draft/publish, so editing never breaks running conversations. Agent nodes carry their own `prompt` and saved Kaapi config (`config_id` + `config_version`, pinned at publish); only Anthropic text completion configs are supported. Conversations are server-side sessions pinned to the live flow version and an active node; turns run on Celery and reply via webhook. On this branch the runtime only executes `onboarding` agent nodes ("Beneficiary Info Extractor" over a two-node LangGraph): the node prompt defines the goal, fields to collect and rules; each turn extracts the fields given so far and then replies. No deep-dive doc yet.
+
+All paths relative to `backend/app/`.
+
+## Routes (`/chatbots`, all require project scope and the `CHATBOT` feature flag)
+- `api/routes/chatbot/__init__.py` mounts session routes first so literal `/sessions/...` paths are matched before `/{chatbot_id}/...`. `require_feature(FeatureFlag.CHATBOT)` sits on the router → 403 when off. Migration `089` only adds the enum value — no rows are seeded, so the flag is off until a superuser creates it per project via `POST /feature-flags` (`enabled: true`).
+- `api/routes/chatbot/chatbot.py`
+ - `POST /chatbots` {name} → 201 `ChatbotPublic`
+ - `GET /chatbots?skip=&limit=` → the project's non-deleted chatbots, newest `updated_at` first
+ - `GET /chatbots/{chatbot_id}`, `PATCH /chatbots/{chatbot_id}` {name} → 404 when unknown/deleted
+ - `DELETE /chatbots/{chatbot_id}` → 200 `Message`; soft delete + deactivates the chatbot's active sessions in the same transaction
+- `api/routes/chatbot/flow.py` (response `ChatbotFlowVersionPublic` {id, chatbot_id, version, is_live, nodes, edges, inserted_at, updated_at})
+ - `GET /chatbots/{chatbot_id}/flow?version=` → that version; default the draft if one exists, else live; 404 if no versions
+ - `PUT /chatbots/{chatbot_id}/flow` {nodes, edges} → first save creates v1 already live (strict publish validation + pin, 422 on failure); later saves upsert the draft (shape-only validation); 409 on a concurrent version insert
+ - `POST /chatbots/{chatbot_id}/flow/publish` → 409 without a draft, 422 listing every strict-validation problem
+- `api/routes/chatbot/session.py`
+ - `POST /chatbots/{chatbot_id}/sessions` {contact_ref} → 201 {session_id, chatbot_id, flow_version, active_node_id}; 404 unknown/deleted chatbot; 409 no live flow or concurrent create
+ - `GET /chatbots/sessions/latest?contact_ref=&chatbot_id=` → `ChatbotSessionSummary` (incl. `chatbot_name`, `flow_version`, `active_node_id`, the active node's `prompt`); `chatbot_id` omitted = newest across the project's non-deleted chatbots
+ - `POST /chatbots/sessions/{session_id}/message` → stores the user message with `node_id = active_node_id`, enqueues the turn → 202; 409 if the session is inactive
+
+## Tables (SQLModel, `models/chatbot.py`, migration `088`)
+- `chatbot` (`Chatbot`) — `name`, org/project FKs (CASCADE), `deleted_at` soft delete (sessions reference it with RESTRICT).
+- `chatbot_flow_version` (`ChatbotFlowVersion`) — `chatbot_id` (CASCADE), `version` (UNIQUE with chatbot_id, `uq_chatbot_flow_version_chatbot_version`), `nodes` / `edges` JSONB stored as the UI sent them (React Flow camelCase keys, extra UI keys round-trip), `is_live` with partial unique index `uq_chatbot_flow_version_live_chatbot` (`WHERE is_live`). The highest version is the draft when not live; live versions are immutable.
+- `chatbot_session` (`ChatbotSession`) — `chatbot_id` (RESTRICT), `flow_version_id` (RESTRICT, pinned at create), `contact_ref`, `active_node_id` (React Flow node id inside the pinned flow), `status` (`ChatbotSessionStatusEnum` active/inactive), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`). No org/project columns: tenant scoping joins through `chatbot`. Indexes: `(chatbot_id, contact_ref)`, `contact_ref` (cross-chatbot latest lookup), partial unique `uq_chatbot_session_active_chatbot_contact` on `(chatbot_id, contact_ref) WHERE status = 'active'`.
+- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant), `node_id` = the flow node that handled the turn.
+- Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same chatbot + `contact_ref` (same transaction as the insert) — or deleting the chatbot — moves active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued is skipped by the worker, which posts a failure webhook instead. A concurrent double-create hits the partial unique index; crud raises `ChatbotSessionConflictError` → 409.
+
+## Flow JSON (Pydantic in `models/chatbot.py`)
+- `ChatbotFlowNode` {id (1..255), type `ChatbotFlowNodeTypeEnum` (input/output/coordinator/onboarding/rag), position {x, y}, data}; agent nodes (`AGENT_NODE_TYPES`) validate `data` as `ChatbotAgentNodeData` {config_id: UUID?, config_version: int ≥ 1?, prompt?}. `ChatbotFlowEdge` {id, source, target, sourceHandle?, targetHandle?, data} — `data.intent` / `data.description` are reserved for coordinator routing. Extra keys allowed everywhere; dumped by alias.
+- The first save goes live directly (validated + pinned like a publish); later draft saves validate shape only. Publish (`services/chatbot/flow.py:validate_flow_for_publish`) is strict: unique node ids; ≤ 1 input and ≤ 1 output node; edge endpoints exist; coordinator/rag rejected as not supported yet; every onboarding node has `config_id` + non-empty `prompt` and its config passes `get_chatbot_config_version` + `build_chatbot_model_settings`; a null `config_version` is resolved to the latest and written back into the stored node; an entry node must resolve.
+- Entry node (`resolve_entry_node`): no edges → the single agent node; otherwise the agent node targeted by the input node's only outgoing edge.
+
+## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`)
+1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the node prompt, the previously collected map, and the (summarized) conversation. Uses Anthropic native structured output (`with_structured_output(..., method="json_schema")`) with the config's own thinking/effort, because forced tool use is rejected with thinking on and Opus 5.5 can't disable thinking. Merged in code: `{**prior, **extracted}`; `is_complete` only when nothing is missing.
+2. `chatbot_node` — replies using the same summarized view plus a system note listing collected/missing fields, so it asks only for what's missing and wraps up when complete.
+3. `run_chatbot_turn(..., collected_fields=prior)` → `ChatbotTurnResult{reply, collected_fields, is_complete}` (`reply` = text of the graph's final `AIMessage` via `history.extract_final_reply`; empty → `ChatbotProviderError` `EmptyReply`) (`.completion_status` maps to the enum). Anthropic errors from either call, and langchain_anthropic's client-side model-config `ValueError`s ("[KAAPI] Unsupported model configuration"), map to `ChatbotProviderError`, so the session path always sends a failure webhook.
+
+## Services / CRUD
+- `services/chatbot/flow.py` — `get_chatbot_flow`, `save_chatbot_flow_draft`, `publish_chatbot_flow` (row-lock the chatbot via `get_chatbot(for_update=True)` to serialize draft saves and publishes), `validate_flow_for_publish`, `resolve_entry_node`, `find_flow_node`, `parse_flow_nodes/edges`, `dump_flow_nodes/edges`, `build_chatbot_flow_version_public`
+- `services/chatbot/session.py` — `start_chatbot_session` (live version + entry node → `create_chatbot_session`), `get_latest_chatbot_session_summary`, `process_chatbot_session_message` (Celery worker body: load session + pinned flow version + active node; inactive session, missing node or a non-onboarding node → failure webhook; otherwise run the turn with the node prompt/config and stored `collected_fields`, store the reply with `node_id`, update progress, POST the webhook)
+- `services/chatbot/agents/onboarding/{graph,prompts,types}.py` — `run_chatbot_turn`, the extraction/reply nodes, `FieldExtraction`
+- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages`, `extract_final_reply`, kickoff message, `has_user_turn`, `insert_after_leading_system`
+- `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict`
+- `services/chatbot/config.py` — `get_chatbot_config_version` (requested or latest version), `build_chatbot_model_settings` (version row → `ChatbotModelSettings`; 422-class `ChatbotInputError` for non-Anthropic/non-text/no-model configs or a bad thinking budget), `resolve_chatbot_model_settings` (both, used by the worker)
+- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults, `get_chat_model`
+- `services/chatbot/exceptions.py` — `ChatbotInputError`, `ChatbotProviderError`, `ChatbotSessionConflictError`, `ChatbotNotFoundError`, `ChatbotFlowNotFoundError`, `ChatbotFlowConflictError`, `ChatbotFlowValidationError`
+- `crud/chatbot.py` — chatbot: `create_chatbot`, `get_chatbot`, `list_chatbots`, `update_chatbot`, `delete_chatbot`; flow: `get_latest_chatbot_flow_version`, `get_live_chatbot_flow_version`, `get_chatbot_flow_version`, `get_chatbot_flow_version_by_id`, `create_chatbot_flow_version`, `update_chatbot_flow_version_graph`, `publish_chatbot_flow_version` (unsets the previous live before setting the draft live, one commit); session: `create_chatbot_session` (supersedes via `_deactivate_active_chatbot_sessions`), `get_chatbot_session`, `get_latest_chatbot_session` (joined with chatbot + flow version), `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages`
+
+## Async
+- `celery/tasks/job_execution.py` — `run_chatbot_session_message` (enqueued via `celery/utils.py:start_chatbot_session_message`) → `process_chatbot_session_message`.
+- Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_id", "node_id", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`.
+
+## External
+- Anthropic via `langchain-anthropic`, using the **project's own Anthropic credentials** (`services/chatbot/config.py:get_anthropic_api_key`; no platform `ANTHROPIC_API_KEY` fallback — missing credentials fail publish/first save with 422 and a turn with a failure webhook) — two calls per turn once the user has spoken (extraction + reply), plus one summary call when history exceeds the token budget.
+- Caller webhook (`send_callback`, HMAC-signed with the project webhook secret).
+- kaapi-frontend React Flow editor reads/writes the flow JSON as-is.
diff --git a/docs/wiki/modules/platform.md b/docs/wiki/modules/platform.md
index c184ec8b6..390f59c6d 100644
--- a/docs/wiki/modules/platform.md
+++ b/docs/wiki/modules/platform.md
@@ -46,3 +46,16 @@ single source of truth:
Adding a provider = add the enum member, the model, and its `PROVIDER_CONFIGS`
entry; nothing else needs to know.
+
+## Model config catalog
+
+`global.model_config` rows are seeded by migrations (052 OpenAI, 063 STT/TTS,
+066 `google-aistudio` copies, 086 Anthropic `claude-sonnet-4-6` /
+`claude-opus-5-5`). `GET /models/providers` lists only providers that have an
+active row, so a provider with no rows is invisible to the config UI.
+
+A `config.effort` key marks a reasoning model (`is_reasoning_model`): the OpenAI
+mapper routes `effort` into `reasoning`, and config-version updates strip
+`temperature`. A missing (provider, model) row only logs a warning at config
+save — validation is liberal — but the model then gets no effort handling and
+no cost estimate (`pricing` NULL also means no estimate).