Skip to content
Merged
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')
"""
)
161 changes: 161 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,161 @@
"""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(
"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(
"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="Conversation lifecycle: active or inactive",
),
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_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"
)
5 changes: 5 additions & 0 deletions backend/app/api/docs/chatbot/create_session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Start a server-side chatbot conversation and return its `session_id`.

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.

`contact_ref` is the caller's own identifier for the end user and is stored as-is. Returns 404 if the config or version isn't found in the project and 422 if it isn't a usable Anthropic text config.
3 changes: 3 additions & 0 deletions backend/app/api/docs/chatbot/list_sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
List every chatbot session for the given `contact_ref` in the caller's project, most recent first.

`contact_ref` is matched exactly against the value supplied when the session was created. Each entry carries the session's current `status` and the `collected_fields` gathered so far. Returns an empty list when the contact has no sessions in this project.
5 changes: 5 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,5 @@
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", "reply", "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.

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 and 422 for an invalid `webhook_url` or a missing `message` on a started session.
3 changes: 0 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 Down
8 changes: 8 additions & 0 deletions backend/app/api/routes/chatbot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

from app.api.routes.chatbot.message import router as message_router
from app.api.routes.chatbot.session import router as session_router

router = APIRouter(prefix="/chatbot", tags=["Chatbot"])
router.include_router(message_router)
router.include_router(session_router)
52 changes: 52 additions & 0 deletions backend/app/api/routes/chatbot/message.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, HTTPException

from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse
from app.services.chatbot.config import resolve_chatbot_model_settings
from app.services.chatbot.engine import run_chatbot_turn
from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
from app.utils import APIResponse, load_description

router = APIRouter()


@router.post(
"/message",
description=load_description("chatbot/message.md"),
response_model=APIResponse[ChatbotTurnResponse],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
async def send_chatbot_message(
request: ChatbotTurnRequest,
session: SessionDep,
auth_context: AuthContextDep,
) -> APIResponse[ChatbotTurnResponse]:
history: list[dict[str, str]] = []
for entry in request.history:
history.append(entry.model_dump(mode="json"))

try:
model_settings = resolve_chatbot_model_settings(
session=session,
project_id=auth_context.project_.id,
config_id=request.config_id,
config_version=request.config_version,
max_tokens=request.max_tokens,
history_token_limit=request.history_token_limit,
)
transcript = await run_chatbot_turn(
system_prompt=request.system_prompt,
history=history,
user_message=request.message,
model_settings=model_settings,
)
except ChatbotInputError as e:
raise HTTPException(status_code=422, detail=str(e))
except ChatbotProviderError as e:
raise HTTPException(status_code=502, detail=str(e))

messages: list[ChatbotMessage] = []
for entry in transcript:
messages.append(ChatbotMessage.model_validate(entry))
return APIResponse.success_response(ChatbotTurnResponse(messages=messages))
Loading
Loading