Skip to content
Closed
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
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.
2 changes: 2 additions & 0 deletions backend/app/api/routes/chatbot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +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)
165 changes: 165 additions & 0 deletions backend/app/api/routes/chatbot/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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.celery.utils import start_chatbot_session_message
from app.crud.chatbot import (
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
list_chatbot_session_messages,
list_chatbot_sessions_by_contact_ref,
)
from app.models import (
CONTACT_REF_MAX_LENGTH,
ChatbotMessageRoleEnum,
ChatbotSessionSummary,
CreateChatbotSessionRequest,
CreateChatbotSessionResponse,
SendChatbotSessionMessageRequest,
SendChatbotSessionMessageResponse,
)
from app.services.chatbot.config import resolve_chatbot_model_settings
from app.services.chatbot.exceptions import ChatbotInputError
from app.utils import APIResponse, load_description, validate_callback_url

DEFAULT_TRACE_ID = "N/A"

router = APIRouter()


@router.post(
"/sessions",
status_code=201,
description=load_description("chatbot/create_session.md"),
response_model=APIResponse[CreateChatbotSessionResponse],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def start_chatbot_session(
request: CreateChatbotSessionRequest,
session: SessionDep,
auth_context: AuthContextDep,
) -> APIResponse[CreateChatbotSessionResponse]:
project_id = auth_context.project_.id
try:
resolve_chatbot_model_settings(
session=session,
project_id=project_id,
config_id=request.config_id,
config_version=request.config_version,
max_tokens=None,
history_token_limit=None,
)
except ChatbotInputError as e:
raise HTTPException(status_code=422, detail=str(e))

chatbot_session = create_chatbot_session(
session,
organization_id=auth_context.organization_.id,
project_id=project_id,
contact_ref=request.contact_ref,
config_id=request.config_id,
config_version=request.config_version,
system_prompt=request.system_prompt,
)
return APIResponse.success_response(
CreateChatbotSessionResponse(session_id=chatbot_session.id)
)


@router.get(
"/sessions",
description=load_description("chatbot/list_sessions.md"),
response_model=APIResponse[list[ChatbotSessionSummary]],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def list_chatbot_sessions(
contact_ref: Annotated[
str,
Query(
min_length=1,
max_length=CONTACT_REF_MAX_LENGTH,
description="Caller-side identifier of the end user whose sessions to list",
),
],
session: SessionDep,
auth_context: AuthContextDep,
) -> APIResponse[list[ChatbotSessionSummary]]:
chatbot_sessions = list_chatbot_sessions_by_contact_ref(
session,
contact_ref=contact_ref,
organization_id=auth_context.organization_.id,
project_id=auth_context.project_.id,
)
summaries: list[ChatbotSessionSummary] = []
for chatbot_session in chatbot_sessions:
summary = ChatbotSessionSummary(
session_id=chatbot_session.id,
contact_ref=chatbot_session.contact_ref,
status=chatbot_session.status,
collected_fields=chatbot_session.collected_fields,
config_id=chatbot_session.config_id,
config_version=chatbot_session.config_version,
inserted_at=chatbot_session.inserted_at,
updated_at=chatbot_session.updated_at,
)
summaries.append(summary)
return APIResponse.success_response(summaries)


@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 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,
)

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())
Loading
Loading