Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""seed anthropic text model_config rows

Revision ID: 087
Revises: 086
Create Date: 2026-10-09 00:00:00.000000
"""

from alembic import op

revision = "087"
down_revision = "086"
branch_labels = None
depends_on = None


def upgrade():
op.execute(
"""
INSERT INTO global.model_config
(provider, model_name, completion_type, config, input_modalities,
output_modalities, pricing, is_active, inserted_at, updated_at)
VALUES
('anthropic', 'claude-sonnet-4-6', '{text}',
'{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}',
'{TEXT,IMAGE,FILES}', '{TEXT}',
'{"response": {"input_token_cost": 3, "output_token_cost": 15}, "batch": {"input_token_cost": 1.5, "output_token_cost": 7.5}}',
true, NOW(), NOW()),
('anthropic', 'claude-opus-5-5', '{text}',
'{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "xhigh", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}',
'{TEXT,IMAGE,FILES}', '{TEXT}',
NULL,
true, NOW(), NOW())
ON CONFLICT (provider, model_name) DO NOTHING
"""
)


def downgrade():
op.execute(
"""
DELETE FROM global.model_config
WHERE provider = 'anthropic'
AND model_name IN ('claude-sonnet-4-6', 'claude-opus-5-5')
"""
)
199 changes: 199 additions & 0 deletions backend/app/alembic/versions/088_add_chatbot_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""add chatbot 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_session",
sa.Column(
"id",
sa.Uuid(),
nullable=False,
comment="Session identifier returned to the caller",
),
sa.Column(
"name",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=False,
server_default="Untitled Chatbot",
comment="Display name of the chatbot this session runs",
),
sa.Column(
"contact_ref",
sqlmodel.sql.sqltypes.AutoString(length=500),
nullable=False,
comment="Caller-side identifier of the end user (e.g. a contact id); opaque to Kaapi",
),
sa.Column(
"chatbot_ref",
sqlmodel.sql.sqltypes.AutoString(length=255),
nullable=False,
comment="Stable chatbot identifier, generated by Kaapi (LLMResponseWithPrompt-<5 hex>) on first session and sent back by the caller for later ones; at most one active session per contact_ref + chatbot_ref",
),
sa.Column(
"config_id",
sa.Uuid(),
nullable=False,
comment="Saved Anthropic text config that supplies the chat model",
),
sa.Column(
"config_version",
sa.Integer(),
nullable=True,
comment="Pinned config version; NULL resolves to the latest version on every turn",
),
sa.Column(
"system_prompt",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
comment="Admin-authored flow prompt captured at session creation",
),
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 contact_ref + chatbot_ref",
),
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 superseded 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(
"organization_id",
sa.Integer(),
nullable=False,
comment="Reference to the organization",
),
sa.Column(
"project_id",
sa.Integer(),
nullable=False,
comment="Reference to the project",
),
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(["config_id"], ["config.id"], ondelete="RESTRICT"),
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_session_config_id"),
"chatbot_session",
["config_id"],
unique=False,
)
op.create_index(
op.f("ix_chatbot_session_organization_id"),
"chatbot_session",
["organization_id"],
unique=False,
)
op.create_index(
op.f("ix_chatbot_session_project_id"),
"chatbot_session",
["project_id"],
unique=False,
)
op.create_index(
"ix_chatbot_session_project_chatbot_contact",
"chatbot_session",
["project_id", "chatbot_ref", "contact_ref"],
unique=False,
)
op.create_index(
"uq_chatbot_session_active_contact_chatbot",
"chatbot_session",
["project_id", "contact_ref", "chatbot_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 session)",
),
sa.Column(
"content",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
comment="Message text",
),
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_session tables discards stored conversations"
)
22 changes: 22 additions & 0 deletions backend/app/alembic/versions/089_add_chatbot_feature_flag.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions backend/app/api/docs/chatbot/create_session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Start a server-side chatbot conversation and return its `session_id` and `chatbot_ref`.

The `system_prompt` and the `config_id` / `config_version` pin are stored on the session, so later turns only send the new user message. `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set; when `config_version` is omitted, each turn uses the config's latest version at that time.

`name` is the chatbot's display name only; omit it to get `"Untitled Chatbot"`. `contact_ref` is the caller's own identifier for the end user and is stored as-is.

`chatbot_ref` identifies the chatbot. Omit it to create a new chatbot: Kaapi generates a ref of the form `LLMResponseWithPrompt-<5 hex chars>` (e.g. `LLMResponseWithPrompt-913f0`), unique within the project, and returns it. To start a new session for the same chatbot, send that returned `chatbot_ref` back; it is used as-is.

A contact has at most one active session per chatbot. Creating a session marks the contact's previous `active` sessions with the same `chatbot_ref` 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 config or version isn't found in the project, 422 if it isn't a usable Anthropic text config, 409 if another session for the same `contact_ref` + `chatbot_ref` was created concurrently (retry the request), and 503 in the unlikely case no unused `chatbot_ref` could be generated (retry).
5 changes: 5 additions & 0 deletions backend/app/api/docs/chatbot/get_latest_session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Get the most recently created chatbot session for the given `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_ref`.

`contact_ref` is required; `chatbot_ref` is optional. Both are matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). Omit it to get the contact's newest session across all chatbots — the response's `chatbot_ref` says which chatbot that is.

The response carries the session's `system_prompt` (as stored at creation), its current `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`; `abandoned` if it was superseded 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.
7 changes: 7 additions & 0 deletions backend/app/api/docs/chatbot/send_session_message.md
Original file line number Diff line number Diff line change
@@ -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 and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "chatbot_ref", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead.

Each turn re-reads the conversation against the session's `system_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_ref` + `chatbot_ref`, 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.
8 changes: 5 additions & 3 deletions backend/app/api/docs/openapi_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +32,10 @@
"name": "LLM",
"description": "Large Language Model inference and interaction endpoints",
},
{
"name": "Chatbot",
"description": "Prompt-driven conversational chatbot turns",
},
{
"name": "Guardrails",
"description": (
Expand Down Expand Up @@ -104,6 +105,7 @@
"Collections",
"Config Management",
"LLM",
"Chatbot",
"Guardrails",
"Evaluation",
"Fine Tuning",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
api_keys,
assistants,
auth,
chatbot,
collection_job,
collections,
config,
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions backend/app/api/routes/chatbot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends

from app.api.permissions import require_feature
from app.api.routes.chatbot.session import router as session_router
from app.core.feature_flags import FeatureFlag

router = APIRouter(
prefix="/chatbot",
tags=["Chatbot"],
dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))],
)
router.include_router(session_router)
Loading
Loading