From dbba788ff1bdbc674738be0b82a0b5d1c40f0dbb Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 9 Oct 2026 23:14:33 +0530
Subject: [PATCH 01/15] feat(*): init chatbot flow
---
.../086_seed_anthropic_text_model_configs.py | 60 ++++
backend/app/api/docs/chatbot/message.md | 7 +
backend/app/api/docs/openapi_config.py | 5 +
backend/app/api/main.py | 2 +
backend/app/api/routes/chatbot.py | 64 +++++
backend/app/crud/config/version.py | 5 +
backend/app/models/__init__.py | 6 +
backend/app/models/chatbot.py | 56 ++++
backend/app/services/chatbot/config.py | 141 ++++++++++
backend/app/services/chatbot/engine.py | 260 ++++++++++++++++++
backend/app/services/chatbot/utils.py | 59 ++++
backend/pyproject.toml | 1 +
backend/uv.lock | 86 +++++-
docs/wiki/INDEX.md | 1 +
docs/wiki/domain-map.md | 4 +-
docs/wiki/modules/chatbot.md | 23 ++
docs/wiki/modules/platform.md | 13 +
17 files changed, 780 insertions(+), 13 deletions(-)
create mode 100644 backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
create mode 100644 backend/app/api/docs/chatbot/message.md
create mode 100644 backend/app/api/routes/chatbot.py
create mode 100644 backend/app/models/chatbot.py
create mode 100644 backend/app/services/chatbot/config.py
create mode 100644 backend/app/services/chatbot/engine.py
create mode 100644 backend/app/services/chatbot/utils.py
create mode 100644 docs/wiki/modules/chatbot.md
diff --git a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
new file mode 100644
index 000000000..915218568
--- /dev/null
+++ b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
@@ -0,0 +1,60 @@
+"""seed anthropic text model_config rows
+
+Revision ID: 086
+Revises: 085
+Create Date: 2026-10-09 00:00:00.000000
+
+`anthropic` has been a valid provider since 066 but had no model_config rows,
+so it never showed up in /models/providers and config-version updates could
+not tell its models are reasoning models. The `effort` key in `config` is what
+`is_reasoning_model` keys on — it makes `_strip_unsupported_params` drop
+`temperature`, which the Messages API rejects at non-default values.
+
+`effort.default` is "high" because that is what the Messages API applies when
+the param is omitted. Sonnet 4.6 does not expose the "xhigh" level.
+
+claude-opus-5-5 pricing is left NULL until list prices are confirmed; cost
+estimation returns None for NULL pricing rather than a wrong number.
+
+Downgrade deletes the two rows by key, so a row that already existed before
+upgrade (ON CONFLICT DO NOTHING skipped it) is removed as well.
+"""
+
+from alembic import op
+
+revision = "086"
+down_revision = "085"
+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')
+ """
+ )
diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md
new file mode 100644
index 000000000..75ee043c9
--- /dev/null
+++ b/backend/app/api/docs/chatbot/message.md
@@ -0,0 +1,7 @@
+Run one turn of a prompt-driven chatbot conversation and return the full updated transcript.
+
+The chat model comes from a saved Kaapi config: `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set. `config_version` picks a version (latest if omitted). The config's `params.effort` sets reasoning effort (provider default if absent) and `params.thinking` overrides the default adaptive thinking. `max_tokens` (default 1024) and `history_token_limit` (default 4000; older turns are summarized above it) are optional per-request overrides.
+
+There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`.
+
+On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures.
diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py
index 317f05d28..654fb8ed0 100644
--- a/backend/app/api/docs/openapi_config.py
+++ b/backend/app/api/docs/openapi_config.py
@@ -35,6 +35,10 @@
"name": "LLM",
"description": "Large Language Model inference and interaction endpoints",
},
+ {
+ "name": "Chatbot",
+ "description": "Prompt-driven conversational chatbot turns",
+ },
{
"name": "Guardrails",
"description": (
@@ -104,6 +108,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.py b/backend/app/api/routes/chatbot.py
new file mode 100644
index 000000000..454f4853b
--- /dev/null
+++ b/backend/app/api/routes/chatbot.py
@@ -0,0 +1,64 @@
+import logging
+
+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.engine import (
+ ChatbotInputError,
+ ChatbotProviderError,
+ run_chatbot_turn,
+)
+from app.services.chatbot.config import resolve_chatbot_model_settings
+from app.utils import APIResponse, load_description
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/chatbot", tags=["Chatbot"])
+
+
+# Synchronous by design (real-time request/response turn), not a Celery job.
+@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:
+ logger.warning(
+ f"[send_chatbot_message] Chatbot turn failed upstream | "
+ f"project_id: {auth_context.project_.id}"
+ )
+ 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))
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..e13fe062a 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -35,6 +35,12 @@
BatchJobType,
BatchJobUpdate,
)
+from .chatbot import (
+ ChatbotMessage,
+ ChatbotMessageRoleEnum,
+ ChatbotTurnRequest,
+ ChatbotTurnResponse,
+)
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..c06a6540b
--- /dev/null
+++ b/backend/app/models/chatbot.py
@@ -0,0 +1,56 @@
+from enum import StrEnum
+from uuid import UUID
+
+from sqlmodel import Field, SQLModel
+
+
+class ChatbotMessageRoleEnum(StrEnum):
+ SYSTEM = "system"
+ USER = "user"
+ ASSISTANT = "assistant"
+
+
+class ChatbotMessage(SQLModel):
+ role: ChatbotMessageRoleEnum
+ content: str
+
+
+class ChatbotTurnRequest(SQLModel):
+ config_id: UUID = Field(
+ description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.",
+ )
+ config_version: int | None = Field(
+ default=None,
+ ge=1,
+ description="Version of `config_id` to use; omit to use its latest version.",
+ )
+ max_tokens: int | None = Field(
+ default=None,
+ gt=0,
+ description="Cap on output tokens per model call; omit for the server default.",
+ )
+ history_token_limit: int | None = Field(
+ default=None,
+ gt=0,
+ description="Token size of the history above which older turns are summarized; omit for the server default.",
+ )
+ # Required every call in v0: there is no server-side storage of the flow prompt yet.
+ system_prompt: str = Field(
+ min_length=1,
+ description="Admin-authored flow prompt (goal, fields to collect, rules).",
+ )
+ history: list[ChatbotMessage] = Field(
+ default_factory=list,
+ description="Every message before this turn, as returned by the previous call; empty on the first call.",
+ )
+ message: str | None = Field(
+ default=None,
+ min_length=1,
+ description="The new user message; omit only to get the opening greeting on the first call.",
+ )
+
+
+class ChatbotTurnResponse(SQLModel):
+ messages: list[ChatbotMessage] = Field(
+ description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.",
+ )
diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py
new file mode 100644
index 000000000..9f3d4fc0f
--- /dev/null
+++ b/backend/app/services/chatbot/config.py
@@ -0,0 +1,141 @@
+import logging
+from typing import 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.models import ConfigVersion
+from app.models.llm.constants import Provider
+from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig
+from app.services.chatbot.engine import ChatbotInputError
+from app.services.chatbot.utils import (
+ DEFAULT_HISTORY_TOKEN_LIMIT,
+ DEFAULT_MAX_OUTPUT_TOKENS,
+ AnthropicEffort,
+ ChatbotModelSettings,
+ default_thinking,
+)
+
+logger = logging.getLogger(__name__)
+
+_ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort)
+
+
+def _get_config_version(
+ *, session: Session, project_id: int, config_id: UUID, config_version: int | None
+) -> ConfigVersion:
+ # The crud constructor and `exists_or_raise` already 404 when the config or
+ # version is outside this project, so ownership is enforced here.
+ 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:
+ logger.warning(
+ f"[_get_config_version] Config has no versions | "
+ f"config_id: {config_id}, project_id: {project_id}"
+ )
+ 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
+ # Kaapi's "none"/"minimal" have no Anthropic equivalent; dropping them
+ # silently would let the provider default (often higher) take over.
+ raise ChatbotInputError(
+ f"[KAAPI] Config param 'effort' value '{effort}' is not an Anthropic effort "
+ f"level ({', '.join(_ANTHROPIC_EFFORT_LEVELS)}); update the config."
+ )
+
+
+def resolve_chatbot_model_settings(
+ *,
+ session: Session,
+ 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) into chat-model settings.
+
+ Raises HTTPException(404) when the config/version isn't in the project and
+ ChatbotInputError when the config isn't a usable Anthropic text config.
+ """
+ version_row = _get_config_version(
+ session=session,
+ project_id=project_id,
+ config_id=config_id,
+ config_version=config_version,
+ )
+
+ try:
+ blob = ConfigBlob.model_validate(version_row.config_blob)
+ except ValidationError as e:
+ logger.warning(
+ f"[resolve_chatbot_model_settings] [KAAPI] Stored config blob is invalid | "
+ f"config_id: {config_id}, version: {version_row.version}",
+ exc_info=True,
+ )
+ 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
+ ):
+ logger.warning(
+ f"[resolve_chatbot_model_settings] [KAAPI] Unsupported config for chatbot | "
+ f"config_id: {config_id}, version: {version_row.version}, "
+ f"provider: {completion.provider}, type: {completion.type}"
+ )
+ 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:
+ logger.warning(
+ f"[resolve_chatbot_model_settings] [KAAPI] Config has no model | "
+ f"config_id: {config_id}, version: {version_row.version}"
+ )
+ 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()
+ model_settings = ChatbotModelSettings(
+ model=params.model,
+ max_tokens=max_tokens if max_tokens is not None else DEFAULT_MAX_OUTPUT_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
+ ),
+ )
+ logger.info(
+ f"[resolve_chatbot_model_settings] Resolved chatbot model settings | "
+ f"config_id: {config_id}, version: {version_row.version}, "
+ f"model: {model_settings.model}, effort: {model_settings.reasoning_effort}"
+ )
+ return model_settings
diff --git a/backend/app/services/chatbot/engine.py b/backend/app/services/chatbot/engine.py
new file mode 100644
index 000000000..380bf3eb0
--- /dev/null
+++ b/backend/app/services/chatbot/engine.py
@@ -0,0 +1,260 @@
+"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model.
+
+Unlike a field-by-field collector, this mirrors OCS's own approach: the
+admin supplies the full prompt (goal + fields + rules, written as free text --
+the same shape as OCS's `prompt` field) and the model manages the conversation
+itself, reading the full message history every turn. There is no field-by-field
+state machine in code here.
+
+No tools in v0 -- OCS's node also lets the admin attach tools (e.g.
+set-session-state); that is deliberately left out here and is the natural next
+step once this shape is proven.
+
+No checkpointer: like OCS, there is no LangGraph-level pause/resume. The caller
+keeps the running message list itself and passes the full history into
+`graph.invoke(...)` on every turn -- a fresh, complete run each time.
+"""
+
+import asyncio
+import logging
+from typing import Annotated, TypedDict
+
+import anthropic
+from langchain_core.language_models.chat_models import BaseChatModel
+from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
+from langgraph.graph import END, START, StateGraph
+from langgraph.graph.message import add_messages
+from langgraph.graph.state import CompiledStateGraph
+
+from app.models.chatbot import ChatbotMessageRoleEnum
+from app.services.chatbot.utils import (
+ FLOW_KEEP_RECENT_MESSAGES,
+ ChatbotModelSettings,
+ get_chat_model,
+)
+
+logger = logging.getLogger(__name__)
+
+CHATBOT_NODE = "chatbot_node"
+
+_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."
+)
+
+# Anthropic rejects a request whose first non-system turn isn't a user turn, so
+# the opening greeting (and every later call, whose history starts with that
+# greeting) needs a synthetic user turn in front. It's stripped from the
+# returned transcript by this fixed id, so callers never see it.
+_KICKOFF_MESSAGE_ID = "chatbot-kickoff"
+_KICKOFF_MESSAGE_CONTENT = "Begin the conversation."
+
+ChatbotMessageDict = dict[str, str]
+
+
+class ChatbotState(TypedDict):
+ # `add_messages` is LangGraph's prebuilt reducer for LangChain message
+ # lists: it appends new messages and merges by `.id` instead of naively
+ # concatenating, which is what lets callers keep re-sending the running
+ # history without duplicating turns.
+ messages: Annotated[list[BaseMessage], add_messages]
+
+
+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."""
+
+
+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.
+
+ Simplification: this only shrinks what THIS call sends to Anthropic: the
+ caller's own running history keeps growing untouched, so a long-enough
+ conversation re-summarizes from scratch each turn rather than compacting
+ state permanently. OCS's real history middleware persists the compression
+ point instead -- worth doing if this gets reused past v0.
+ """
+ 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:],
+ )
+ # Anthropic requires the first non-system turn to be a user turn. `rest`
+ # always ends on this turn's unanswered human message, so it has odd
+ # length; cutting an even-sized window from its tail always lands one
+ # message short, opening on an assistant turn instead. Shift the boundary
+ # back until `recent` starts on a human message.
+ while recent and to_summarize and not isinstance(recent[0], HumanMessage):
+ recent.insert(0, to_summarize.pop())
+ if not to_summarize:
+ return messages
+
+ summary = chat_model.invoke(
+ [SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize]
+ )
+ logger.info(
+ f"[_summarize_if_needed] Compressed history | "
+ f"from: {len(to_summarize)} messages, to: 1 summary note"
+ )
+ summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}")
+ return [system_message, summary_note, *recent]
+
+
+def build_chatbot_graph(
+ chat_model: BaseChatModel, *, history_token_limit: int
+) -> CompiledStateGraph:
+ """One node, no branching -- `chat_model` is fixed for the graph's lifetime."""
+
+ def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]:
+ messages = _summarize_if_needed(
+ chat_model, state["messages"], history_token_limit=history_token_limit
+ )
+ response = chat_model.invoke(messages)
+ return {"messages": [response]}
+
+ graph = StateGraph(ChatbotState)
+ graph.add_node(CHATBOT_NODE, chatbot_node)
+ graph.add_edge(START, CHATBOT_NODE)
+ graph.add_edge(CHATBOT_NODE, END)
+ return graph.compile()
+
+
+def _to_langchain_messages(
+ *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None
+) -> list[BaseMessage]:
+ # `system_prompt` is the single source of the system turn; any system entry
+ # echoed back in `history` (the previous response includes it) is dropped
+ # so it isn't sent twice.
+ 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 _to_message_dicts(
+ *, system_prompt: str, messages: list[BaseMessage]
+) -> list[ChatbotMessageDict]:
+ transcript: list[ChatbotMessageDict] = [
+ {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt}
+ ]
+ for message in messages:
+ if message.id == _KICKOFF_MESSAGE_ID:
+ continue
+ if isinstance(message, HumanMessage):
+ role = ChatbotMessageRoleEnum.USER
+ elif isinstance(message, AIMessage):
+ role = ChatbotMessageRoleEnum.ASSISTANT
+ else:
+ continue
+ # `.text` drops thinking/tool blocks; the wire shape carries text only.
+ transcript.append({"role": role.value, "content": message.text})
+ return transcript
+
+
+async def run_chatbot_turn(
+ *,
+ system_prompt: str,
+ history: list[ChatbotMessageDict],
+ user_message: str | None,
+ model_settings: ChatbotModelSettings,
+) -> list[ChatbotMessageDict]:
+ """Run one stateless turn and return the full updated transcript.
+
+ `user_message` may be None only on the very first call (empty `history`),
+ which yields the bot's opening greeting.
+ """
+ if user_message is None and history:
+ logger.warning(
+ f"[run_chatbot_turn] [KAAPI] Missing user message on a non-initial turn | "
+ f"history_count: {len(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
+ )
+ logger.info(
+ f"[run_chatbot_turn] Starting turn | history_count: {len(history)}, "
+ f"has_user_message: {user_message is not None}, model: {model_settings.model}"
+ )
+
+ graph = build_chatbot_graph(
+ get_chat_model(model_settings),
+ history_token_limit=model_settings.history_token_limit,
+ )
+ try:
+ # ChatAnthropic.invoke is sync; keep it off the event loop.
+ result = await asyncio.to_thread(graph.invoke, {"messages": messages})
+ except anthropic.APITimeoutError as e:
+ logger.error(
+ f"[run_chatbot_turn] [KAAPI] Request to Anthropic timed out (code: APITimeoutError) | "
+ f"provider=anthropic, model={model_settings.model}",
+ exc_info=True,
+ )
+ raise ChatbotProviderError(
+ "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
+ "(code: APITimeoutError)"
+ ) from e
+ except anthropic.APIConnectionError as e:
+ logger.error(
+ f"[run_chatbot_turn] [KAAPI] Could not reach Anthropic (code: APIConnectionError) | "
+ f"provider=anthropic, model={model_settings.model}",
+ exc_info=True,
+ )
+ raise ChatbotProviderError(
+ "[KAAPI] Network/DNS issue reaching provider — check connectivity. "
+ "If persistent, contact Kaapi. (code: APIConnectionError)"
+ ) from e
+ except anthropic.APIStatusError as e:
+ # 5xx is provider-side (alert-worthy); 4xx is caller's fault (noise if alerted)
+ log = logger.error if e.status_code >= 500 else logger.warning
+ log(
+ f"[run_chatbot_turn] [ANTHROPIC] {e.message} (code: {e.status_code}) | "
+ f"provider=anthropic, model={model_settings.model}, request_id={e.request_id}",
+ exc_info=True,
+ )
+ raise ChatbotProviderError(
+ f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, "
+ f"request_id: {e.request_id})"
+ ) from e
+
+ transcript = _to_message_dicts(
+ system_prompt=system_prompt, messages=result["messages"]
+ )
+ logger.info(
+ f"[run_chatbot_turn] Turn completed | transcript_count: {len(transcript)}, "
+ f"reply_length: {len(transcript[-1]['content'])}"
+ )
+ return transcript
diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py
new file mode 100644
index 000000000..2ec9571a1
--- /dev/null
+++ b/backend/app/services/chatbot/utils.py
@@ -0,0 +1,59 @@
+"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node.
+
+Like OCS's `LLMResponseWithPrompt` node, the model and reasoning settings come
+from per-request input -- here a saved Kaapi config (see
+`services/chatbot/config.py`) plus optional token overrides on the request.
+Only Anthropic is wired up, since the node's chat model is `ChatAnthropic`.
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Literal
+
+from langchain_anthropic import ChatAnthropic
+
+from app.core.config import settings
+
+AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"]
+
+DEFAULT_MAX_OUTPUT_TOKENS = 1024
+DEFAULT_THINKING: dict[str, Any] = {"type": "adaptive"}
+
+# OCS's `history_mode=summarize`, simplified: once the running history crosses
+# this many tokens, everything except the system prompt and the most recent
+# turns is summarized into one note before the next model call.
+DEFAULT_HISTORY_TOKEN_LIMIT = 4000
+FLOW_KEEP_RECENT_MESSAGES = 6
+
+
+def default_thinking() -> dict[str, Any]:
+ # Fresh copy so one request can't mutate the module-level default.
+ return dict(DEFAULT_THINKING)
+
+
+@dataclass(frozen=True)
+class ChatbotModelSettings:
+ model: str
+ max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
+ # None lets the provider pick its own default effort.
+ reasoning_effort: AnthropicEffort | None = None
+ thinking: dict[str, Any] = field(default_factory=default_thinking)
+ history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT
+
+
+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 settings.ANTHROPIC_API_KEY:
+ raise RuntimeError(
+ "[get_chat_model] ANTHROPIC_API_KEY is not set; export it or set it in .env"
+ )
+ kwargs: dict[str, Any] = {
+ "model": model_settings.model,
+ "max_tokens": model_settings.max_tokens,
+ "thinking": model_settings.thinking,
+ "anthropic_api_key": settings.ANTHROPIC_API_KEY,
+ }
+ if model_settings.reasoning_effort is not None:
+ kwargs["reasoning_effort"] = model_settings.reasoning_effort
+ return ChatAnthropic(**kwargs)
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index 3a7aab594..300453b2c 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 852753933..0e57889d3 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..89a96f8c9 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) — v0 stateless prompt-driven chatbot (`POST /chatbot/message`, LangGraph + Anthropic, model driven by a saved config). 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..412da4a73 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; Chatbot turn (`POST /chatbot/message`, logical); Assessment; EvaluationRun (`config_id`) |
+| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references and chatbot `config_id`/`config_version` (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) |
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
new file mode 100644
index 000000000..261046755
--- /dev/null
+++ b/docs/wiki/modules/chatbot.md
@@ -0,0 +1,23 @@
+# Module: Chatbot
+
+v0 prompt-driven chatbot: one LangGraph node over a LangChain chat model (Anthropic). Stateless — the caller resends `system_prompt` + full `history` every turn. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. No deep-dive doc yet.
+
+All paths relative to `backend/app/`.
+
+## Routes
+- `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope)
+
+## Tables (SQLModel)
+None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model.
+
+## Services / CRUD
+- `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs)
+- `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization
+- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model`
+- `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted)
+
+## Async
+- None; synchronous request/response, not Celery.
+
+## External
+- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting).
diff --git a/docs/wiki/modules/platform.md b/docs/wiki/modules/platform.md
index 52949fb35..a1e5c3ffa 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).
From e0130e8c8ac67eba2ac5659c9ff2b7b1c90333c7 Mon Sep 17 00:00:00 2001
From: Ayush <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 22 Oct 2026 00:38:58 +0530
Subject: [PATCH 02/15] feat: Chatbot State Management (#1301)
---
.../087_seed_anthropic_text_model_configs.py | 45 ++++
.../versions/088_add_chatbot_session.py | 161 ++++++++++++++
.../app/api/docs/chatbot/create_session.md | 5 +
backend/app/api/docs/chatbot/list_sessions.md | 3 +
.../api/docs/chatbot/send_session_message.md | 5 +
backend/app/api/docs/openapi_config.py | 3 -
backend/app/api/routes/chatbot/__init__.py | 8 +
backend/app/api/routes/chatbot/message.py | 52 +++++
backend/app/api/routes/chatbot/session.py | 165 +++++++++++++++
backend/app/celery/tasks/job_execution.py | 30 ++-
backend/app/celery/utils.py | 24 +++
backend/app/crud/chatbot.py | 114 ++++++++++
backend/app/models/__init__.py | 9 +
backend/app/models/chatbot.py | 195 ++++++++++++++++-
backend/app/services/chatbot/config.py | 32 +--
backend/app/services/chatbot/engine.py | 97 +--------
backend/app/services/chatbot/exceptions.py | 6 +
backend/app/services/chatbot/session.py | 199 ++++++++++++++++++
backend/app/services/chatbot/types.py | 10 +
backend/app/services/chatbot/utils.py | 13 +-
docs/wiki/modules/chatbot.md | 2 +
21 files changed, 1036 insertions(+), 142 deletions(-)
create mode 100644 backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py
create mode 100644 backend/app/alembic/versions/088_add_chatbot_session.py
create mode 100644 backend/app/api/docs/chatbot/create_session.md
create mode 100644 backend/app/api/docs/chatbot/list_sessions.md
create mode 100644 backend/app/api/docs/chatbot/send_session_message.md
create mode 100644 backend/app/api/routes/chatbot/__init__.py
create mode 100644 backend/app/api/routes/chatbot/message.py
create mode 100644 backend/app/api/routes/chatbot/session.py
create mode 100644 backend/app/crud/chatbot.py
create mode 100644 backend/app/services/chatbot/exceptions.py
create mode 100644 backend/app/services/chatbot/session.py
create mode 100644 backend/app/services/chatbot/types.py
diff --git a/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py
new file mode 100644
index 000000000..1d864a12f
--- /dev/null
+++ b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py
@@ -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')
+ """
+ )
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..367a924ce
--- /dev/null
+++ b/backend/app/alembic/versions/088_add_chatbot_session.py
@@ -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"
+ )
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..543966e2d
--- /dev/null
+++ b/backend/app/api/docs/chatbot/create_session.md
@@ -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.
diff --git a/backend/app/api/docs/chatbot/list_sessions.md b/backend/app/api/docs/chatbot/list_sessions.md
new file mode 100644
index 000000000..202480359
--- /dev/null
+++ b/backend/app/api/docs/chatbot/list_sessions.md
@@ -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.
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..237e0fa97
--- /dev/null
+++ b/backend/app/api/docs/chatbot/send_session_message.md
@@ -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.
diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py
index 654fb8ed0..87de7c47c 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
diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py
new file mode 100644
index 000000000..151b59066
--- /dev/null
+++ b/backend/app/api/routes/chatbot/__init__.py
@@ -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)
diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py
new file mode 100644
index 000000000..9a5368258
--- /dev/null
+++ b/backend/app/api/routes/chatbot/message.py
@@ -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))
diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
new file mode 100644
index 000000000..0ea7d60f1
--- /dev/null
+++ b/backend/app/api/routes/chatbot/session.py
@@ -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())
diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py
index 1288876a8..968b88d5b 100644
--- a/backend/app/celery/tasks/job_execution.py
+++ b/backend/app/celery/tasks/job_execution.py
@@ -3,7 +3,8 @@
All tasks share one queue (`default`, declared with `x-max-priority=10`) and are
ordered by the per-task `priority`:
- 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job)
+ 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job,
+ run_chatbot_session_message)
6 Fast evaluation (run_evaluation_fast_chunk, run_evaluation_fast_aggregate,
run_prompt_improvement, run_evaluation_iteration_graph_step)
2 Everything else (doctransform, collections, STT/TTS evaluation, assessment)
@@ -15,6 +16,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
@@ -567,3 +569,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..88285c0bb 100644
--- a/backend/app/celery/utils.py
+++ b/backend/app/celery/utils.py
@@ -337,6 +337,30 @@ 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,
+ )
+ logger.info(
+ f"[start_chatbot_session_message] Enqueued chatbot turn | "
+ f"session_id: {session_id}, task_id: {task_id}"
+ )
+ return task_id
+
+
def get_task_status(task_id: str) -> Dict[str, Any]:
result = AsyncResult(task_id)
return {
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
new file mode 100644
index 000000000..3b1d10304
--- /dev/null
+++ b/backend/app/crud/chatbot.py
@@ -0,0 +1,114 @@
+import logging
+from uuid import UUID
+
+from sqlmodel import Session, col, select
+
+from app.core.util import now
+from app.models import (
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def create_chatbot_session(
+ session: Session,
+ *,
+ organization_id: int,
+ project_id: int,
+ contact_ref: str,
+ config_id: UUID,
+ config_version: int | None,
+ system_prompt: str,
+) -> ChatbotSession:
+ timestamp = now()
+ chatbot_session = ChatbotSession(
+ organization_id=organization_id,
+ project_id=project_id,
+ contact_ref=contact_ref,
+ config_id=config_id,
+ config_version=config_version,
+ system_prompt=system_prompt,
+ inserted_at=timestamp,
+ updated_at=timestamp,
+ )
+ session.add(chatbot_session)
+ session.commit()
+ session.refresh(chatbot_session)
+ logger.info(
+ f"[create_chatbot_session] Chatbot session created | "
+ f"session_id: {chatbot_session.id}, project_id: {project_id}, config_id: {config_id}"
+ )
+ return chatbot_session
+
+
+def get_chatbot_session(
+ session: Session,
+ *,
+ session_id: UUID,
+ organization_id: int,
+ project_id: int,
+) -> ChatbotSession | None:
+ statement = select(ChatbotSession).where(
+ ChatbotSession.id == session_id,
+ ChatbotSession.organization_id == organization_id,
+ ChatbotSession.project_id == project_id,
+ )
+ return session.exec(statement).first()
+
+
+def create_chatbot_session_message(
+ session: Session,
+ *,
+ session_id: UUID,
+ role: ChatbotMessageRoleEnum,
+ content: str,
+) -> ChatbotSessionMessage:
+ message = ChatbotSessionMessage(
+ session_id=session_id,
+ role=role,
+ content=content,
+ inserted_at=now(),
+ )
+ session.add(message)
+ session.commit()
+ session.refresh(message)
+ logger.info(
+ f"[create_chatbot_session_message] Chatbot message stored | "
+ f"session_id: {session_id}, role: {role.value}, message_id: {message.id}"
+ )
+ return message
+
+
+def list_chatbot_session_messages(
+ session: Session, *, session_id: UUID
+) -> list[ChatbotSessionMessage]:
+ # Unpaginated on purpose: the engine needs the whole transcript each turn
+ # and summarizes long histories itself.
+ statement = (
+ select(ChatbotSessionMessage)
+ .where(ChatbotSessionMessage.session_id == session_id)
+ .order_by(col(ChatbotSessionMessage.inserted_at).asc())
+ )
+ return list(session.exec(statement).all())
+
+
+def list_chatbot_sessions_by_contact_ref(
+ session: Session,
+ *,
+ contact_ref: str,
+ organization_id: int,
+ project_id: int,
+) -> list[ChatbotSession]:
+ statement = (
+ select(ChatbotSession)
+ .where(
+ ChatbotSession.contact_ref == contact_ref,
+ ChatbotSession.organization_id == organization_id,
+ ChatbotSession.project_id == project_id,
+ )
+ .order_by(col(ChatbotSession.inserted_at).desc())
+ )
+ return list(session.exec(statement).all())
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index e13fe062a..dc7f2e3be 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -36,10 +36,19 @@
BatchJobUpdate,
)
from .chatbot import (
+ CONTACT_REF_MAX_LENGTH,
ChatbotMessage,
ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
+ ChatbotSessionSummary,
ChatbotTurnRequest,
ChatbotTurnResponse,
+ CreateChatbotSessionRequest,
+ CreateChatbotSessionResponse,
+ SendChatbotSessionMessageRequest,
+ SendChatbotSessionMessageResponse,
)
from .collection import (
Collection,
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
index c06a6540b..a5cfe381b 100644
--- a/backend/app/models/chatbot.py
+++ b/backend/app/models/chatbot.py
@@ -1,8 +1,14 @@
+from datetime import datetime
from enum import StrEnum
-from uuid import UUID
+from typing import Any
+from uuid import UUID, uuid4
+from sqlalchemy import JSON, Column
+from sqlalchemy.dialects.postgresql import ENUM
from sqlmodel import Field, SQLModel
+from app.core.util import now
+
class ChatbotMessageRoleEnum(StrEnum):
SYSTEM = "system"
@@ -34,7 +40,6 @@ class ChatbotTurnRequest(SQLModel):
gt=0,
description="Token size of the history above which older turns are summarized; omit for the server default.",
)
- # Required every call in v0: there is no server-side storage of the flow prompt yet.
system_prompt: str = Field(
min_length=1,
description="Admin-authored flow prompt (goal, fields to collect, rules).",
@@ -54,3 +59,189 @@ class ChatbotTurnResponse(SQLModel):
messages: list[ChatbotMessage] = Field(
description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.",
)
+
+
+class ChatbotSessionStatusEnum(StrEnum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+
+
+_SESSION_STATUS_PG_ENUM = ENUM(
+ ChatbotSessionStatusEnum,
+ name="chatbotsessionstatusenum",
+ 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
+
+
+class ChatbotSession(SQLModel, table=True):
+ __tablename__ = "chatbot_session"
+
+ id: UUID = Field(
+ default_factory=uuid4,
+ primary_key=True,
+ sa_column_kwargs={"comment": "Session identifier returned to the caller"},
+ )
+ contact_ref: str = Field(
+ max_length=CONTACT_REF_MAX_LENGTH,
+ sa_column_kwargs={
+ "comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi"
+ },
+ )
+ config_id: UUID = Field(
+ foreign_key="config.id",
+ nullable=False,
+ index=True,
+ ondelete="RESTRICT",
+ sa_column_kwargs={
+ "comment": "Saved Anthropic text config that supplies the chat model"
+ },
+ )
+ config_version: int | None = Field(
+ default=None,
+ ge=1,
+ sa_column_kwargs={
+ "comment": "Pinned config version; NULL resolves to the latest version on every turn"
+ },
+ )
+ system_prompt: str = Field(
+ sa_column_kwargs={
+ "comment": "Admin-authored flow prompt captured at session creation"
+ },
+ )
+ status: ChatbotSessionStatusEnum = Field(
+ default=ChatbotSessionStatusEnum.ACTIVE,
+ sa_column=Column(
+ _SESSION_STATUS_PG_ENUM,
+ nullable=False,
+ comment="Conversation lifecycle: active or inactive",
+ ),
+ )
+ 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",
+ ),
+ )
+ organization_id: int = Field(
+ foreign_key="organization.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Reference to the organization"},
+ )
+ project_id: int = Field(
+ foreign_key="project.id",
+ nullable=False,
+ index=True,
+ ondelete="CASCADE",
+ sa_column_kwargs={"comment": "Reference to the project"},
+ )
+ 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 session)",
+ ),
+ )
+ content: str = Field(
+ sa_column_kwargs={"comment": "Message text"},
+ )
+ 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):
+ config_id: UUID = Field(
+ description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.",
+ )
+ config_version: int | None = Field(
+ default=None,
+ ge=1,
+ description="Version of `config_id` to pin; omit to use the latest version on every turn.",
+ )
+ 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.",
+ )
+ system_prompt: str = Field(
+ min_length=1,
+ description="Admin-authored flow prompt (goal, fields to collect, rules); stored for the session's lifetime.",
+ )
+
+
+class CreateChatbotSessionResponse(SQLModel):
+ session_id: UUID
+
+
+class ChatbotSessionSummary(SQLModel):
+ session_id: UUID
+ contact_ref: str
+ status: ChatbotSessionStatusEnum
+ collected_fields: dict[str, Any]
+ config_id: UUID
+ config_version: int | None
+ 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/config.py b/backend/app/services/chatbot/config.py
index 9f3d4fc0f..e8f09c666 100644
--- a/backend/app/services/chatbot/config.py
+++ b/backend/app/services/chatbot/config.py
@@ -1,4 +1,3 @@
-import logging
from typing import get_args
from uuid import UUID
@@ -10,7 +9,7 @@
from app.models import ConfigVersion
from app.models.llm.constants import Provider
from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig
-from app.services.chatbot.engine import ChatbotInputError
+from app.services.chatbot.exceptions import ChatbotInputError
from app.services.chatbot.utils import (
DEFAULT_HISTORY_TOKEN_LIMIT,
DEFAULT_MAX_OUTPUT_TOKENS,
@@ -19,16 +18,12 @@
default_thinking,
)
-logger = logging.getLogger(__name__)
-
_ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort)
def _get_config_version(
*, session: Session, project_id: int, config_id: UUID, config_version: int | None
) -> ConfigVersion:
- # The crud constructor and `exists_or_raise` already 404 when the config or
- # version is outside this project, so ownership is enforced here.
version_crud = ConfigVersionCrud(
session=session, config_id=config_id, project_id=project_id
)
@@ -37,10 +32,6 @@ def _get_config_version(
latest = version_crud.read_latest()
if latest is None:
- logger.warning(
- f"[_get_config_version] Config has no versions | "
- f"config_id: {config_id}, project_id: {project_id}"
- )
raise HTTPException(
status_code=404,
detail=f"No version found for config '{config_id}'",
@@ -54,8 +45,6 @@ def _to_anthropic_effort(effort: str | None) -> AnthropicEffort | None:
for level in _ANTHROPIC_EFFORT_LEVELS:
if level == effort:
return level
- # Kaapi's "none"/"minimal" have no Anthropic equivalent; dropping them
- # silently would let the provider default (often higher) take over.
raise ChatbotInputError(
f"[KAAPI] Config param 'effort' value '{effort}' is not an Anthropic effort "
f"level ({', '.join(_ANTHROPIC_EFFORT_LEVELS)}); update the config."
@@ -86,11 +75,6 @@ def resolve_chatbot_model_settings(
try:
blob = ConfigBlob.model_validate(version_row.config_blob)
except ValidationError as e:
- logger.warning(
- f"[resolve_chatbot_model_settings] [KAAPI] Stored config blob is invalid | "
- f"config_id: {config_id}, version: {version_row.version}",
- exc_info=True,
- )
raise ChatbotInputError(
f"[KAAPI] Stored configuration blob is invalid: {e}"
) from e
@@ -100,11 +84,6 @@ def resolve_chatbot_model_settings(
not isinstance(completion, KaapiTextCompletionConfig)
or completion.provider != Provider.ANTHROPIC
):
- logger.warning(
- f"[resolve_chatbot_model_settings] [KAAPI] Unsupported config for chatbot | "
- f"config_id: {config_id}, version: {version_row.version}, "
- f"provider: {completion.provider}, type: {completion.type}"
- )
raise ChatbotInputError(
"[KAAPI] Chatbot only supports configs with completion provider "
f"'{Provider.ANTHROPIC.value}' and type 'text'; got provider "
@@ -113,10 +92,6 @@ def resolve_chatbot_model_settings(
params = completion.params
if not params.model:
- logger.warning(
- f"[resolve_chatbot_model_settings] [KAAPI] Config has no model | "
- f"config_id: {config_id}, version: {version_row.version}"
- )
raise ChatbotInputError(
"[KAAPI] Config is missing completion.params.model; set a model on the config."
)
@@ -133,9 +108,4 @@ def resolve_chatbot_model_settings(
else DEFAULT_HISTORY_TOKEN_LIMIT
),
)
- logger.info(
- f"[resolve_chatbot_model_settings] Resolved chatbot model settings | "
- f"config_id: {config_id}, version: {version_row.version}, "
- f"model: {model_settings.model}, effort: {model_settings.reasoning_effort}"
- )
return model_settings
diff --git a/backend/app/services/chatbot/engine.py b/backend/app/services/chatbot/engine.py
index 380bf3eb0..17aeb6ca2 100644
--- a/backend/app/services/chatbot/engine.py
+++ b/backend/app/services/chatbot/engine.py
@@ -1,40 +1,22 @@
-"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model.
-
-Unlike a field-by-field collector, this mirrors OCS's own approach: the
-admin supplies the full prompt (goal + fields + rules, written as free text --
-the same shape as OCS's `prompt` field) and the model manages the conversation
-itself, reading the full message history every turn. There is no field-by-field
-state machine in code here.
-
-No tools in v0 -- OCS's node also lets the admin attach tools (e.g.
-set-session-state); that is deliberately left out here and is the natural next
-step once this shape is proven.
-
-No checkpointer: like OCS, there is no LangGraph-level pause/resume. The caller
-keeps the running message list itself and passes the full history into
-`graph.invoke(...)` on every turn -- a fresh, complete run each time.
-"""
+"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model."""
import asyncio
-import logging
-from typing import Annotated, TypedDict
import anthropic
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
-from langgraph.graph.message import add_messages
from langgraph.graph.state import CompiledStateGraph
from app.models.chatbot import ChatbotMessageRoleEnum
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.types import ChatbotMessageDict, ChatbotState
from app.services.chatbot.utils import (
FLOW_KEEP_RECENT_MESSAGES,
ChatbotModelSettings,
get_chat_model,
)
-logger = logging.getLogger(__name__)
-
CHATBOT_NODE = "chatbot_node"
_SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] "
@@ -43,31 +25,9 @@
"user has already given -- this note replaces the original messages."
)
-# Anthropic rejects a request whose first non-system turn isn't a user turn, so
-# the opening greeting (and every later call, whose history starts with that
-# greeting) needs a synthetic user turn in front. It's stripped from the
-# returned transcript by this fixed id, so callers never see it.
_KICKOFF_MESSAGE_ID = "chatbot-kickoff"
_KICKOFF_MESSAGE_CONTENT = "Begin the conversation."
-ChatbotMessageDict = dict[str, str]
-
-
-class ChatbotState(TypedDict):
- # `add_messages` is LangGraph's prebuilt reducer for LangChain message
- # lists: it appends new messages and merges by `.id` instead of naively
- # concatenating, which is what lets callers keep re-sending the running
- # history without duplicating turns.
- messages: Annotated[list[BaseMessage], add_messages]
-
-
-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."""
-
def _summarize_if_needed(
chat_model: BaseChatModel,
@@ -75,14 +35,7 @@ def _summarize_if_needed(
*,
history_token_limit: int,
) -> list[BaseMessage]:
- """Trim the view sent to the model once history crosses the token budget.
-
- Simplification: this only shrinks what THIS call sends to Anthropic: the
- caller's own running history keeps growing untouched, so a long-enough
- conversation re-summarizes from scratch each turn rather than compacting
- state permanently. OCS's real history middleware persists the compression
- point instead -- worth doing if this gets reused past v0.
- """
+ """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:
@@ -93,11 +46,6 @@ def _summarize_if_needed(
rest[:-FLOW_KEEP_RECENT_MESSAGES],
rest[-FLOW_KEEP_RECENT_MESSAGES:],
)
- # Anthropic requires the first non-system turn to be a user turn. `rest`
- # always ends on this turn's unanswered human message, so it has odd
- # length; cutting an even-sized window from its tail always lands one
- # message short, opening on an assistant turn instead. Shift the boundary
- # back until `recent` starts on a human message.
while recent and to_summarize and not isinstance(recent[0], HumanMessage):
recent.insert(0, to_summarize.pop())
if not to_summarize:
@@ -106,10 +54,6 @@ def _summarize_if_needed(
summary = chat_model.invoke(
[SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize]
)
- logger.info(
- f"[_summarize_if_needed] Compressed history | "
- f"from: {len(to_summarize)} messages, to: 1 summary note"
- )
summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}")
return [system_message, summary_note, *recent]
@@ -136,9 +80,6 @@ def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]:
def _to_langchain_messages(
*, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None
) -> list[BaseMessage]:
- # `system_prompt` is the single source of the system turn; any system entry
- # echoed back in `history` (the previous response includes it) is dropped
- # so it isn't sent twice.
conversation: list[BaseMessage] = []
for entry in history:
role = entry["role"]
@@ -175,7 +116,6 @@ def _to_message_dicts(
role = ChatbotMessageRoleEnum.ASSISTANT
else:
continue
- # `.text` drops thinking/tool blocks; the wire shape carries text only.
transcript.append({"role": role.value, "content": message.text})
return transcript
@@ -193,10 +133,6 @@ async def run_chatbot_turn(
which yields the bot's opening greeting.
"""
if user_message is None and history:
- logger.warning(
- f"[run_chatbot_turn] [KAAPI] Missing user message on a non-initial turn | "
- f"history_count: {len(history)}"
- )
raise ChatbotInputError(
"[KAAPI] `message` is required once `history` is non-empty; omit it only "
"on the first call to get the opening greeting."
@@ -205,10 +141,6 @@ async def run_chatbot_turn(
messages = _to_langchain_messages(
system_prompt=system_prompt, history=history, user_message=user_message
)
- logger.info(
- f"[run_chatbot_turn] Starting turn | history_count: {len(history)}, "
- f"has_user_message: {user_message is not None}, model: {model_settings.model}"
- )
graph = build_chatbot_graph(
get_chat_model(model_settings),
@@ -218,33 +150,16 @@ async def run_chatbot_turn(
# ChatAnthropic.invoke is sync; keep it off the event loop.
result = await asyncio.to_thread(graph.invoke, {"messages": messages})
except anthropic.APITimeoutError as e:
- logger.error(
- f"[run_chatbot_turn] [KAAPI] Request to Anthropic timed out (code: APITimeoutError) | "
- f"provider=anthropic, model={model_settings.model}",
- exc_info=True,
- )
raise ChatbotProviderError(
"[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
"(code: APITimeoutError)"
) from e
except anthropic.APIConnectionError as e:
- logger.error(
- f"[run_chatbot_turn] [KAAPI] Could not reach Anthropic (code: APIConnectionError) | "
- f"provider=anthropic, model={model_settings.model}",
- exc_info=True,
- )
raise ChatbotProviderError(
"[KAAPI] Network/DNS issue reaching provider — check connectivity. "
"If persistent, contact Kaapi. (code: APIConnectionError)"
) from e
except anthropic.APIStatusError as e:
- # 5xx is provider-side (alert-worthy); 4xx is caller's fault (noise if alerted)
- log = logger.error if e.status_code >= 500 else logger.warning
- log(
- f"[run_chatbot_turn] [ANTHROPIC] {e.message} (code: {e.status_code}) | "
- f"provider=anthropic, model={model_settings.model}, request_id={e.request_id}",
- exc_info=True,
- )
raise ChatbotProviderError(
f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, "
f"request_id: {e.request_id})"
@@ -253,8 +168,4 @@ async def run_chatbot_turn(
transcript = _to_message_dicts(
system_prompt=system_prompt, messages=result["messages"]
)
- logger.info(
- f"[run_chatbot_turn] Turn completed | transcript_count: {len(transcript)}, "
- f"reply_length: {len(transcript[-1]['content'])}"
- )
return transcript
diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py
new file mode 100644
index 000000000..aac653752
--- /dev/null
+++ b/backend/app/services/chatbot/exceptions.py
@@ -0,0 +1,6 @@
+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."""
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
new file mode 100644
index 000000000..06f899308
--- /dev/null
+++ b/backend/app/services/chatbot/session.py
@@ -0,0 +1,199 @@
+import asyncio
+import logging
+from typing import Any
+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_message,
+ get_chatbot_session,
+ list_chatbot_session_messages,
+)
+from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage
+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.services.chatbot.types import ChatbotMessageDict
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.utils import get_webhook_secret, send_callback
+
+logger = logging.getLogger(__name__)
+
+
+def _split_pending_turn(
+ stored_messages: list[ChatbotSessionMessage],
+) -> tuple[list[ChatbotMessageDict], str | None]:
+ """Split the stored transcript into (prior history, pending user message).
+
+ The route persists the user's message before enqueueing, so it is the last
+ stored row; the engine wants it separately from the history before it.
+ """
+ history: list[ChatbotMessageDict] = []
+ for stored in stored_messages:
+ # The system prompt lives on the session row 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; the engine rejects this with ChatbotInputError.
+ return history, None
+ pending = history.pop()
+ return history, pending["content"]
+
+
+def _extract_reply(transcript: list[ChatbotMessageDict]) -> str:
+ for entry in reversed(transcript):
+ if entry["role"] == ChatbotMessageRoleEnum.ASSISTANT:
+ return entry["content"]
+ raise ChatbotInputError("[KAAPI] Chat model returned no assistant reply.")
+
+
+def _send_failure_webhook(
+ *,
+ session_id: UUID,
+ webhook_url: str,
+ webhook_secret: str | None,
+ error: str,
+) -> None:
+ payload: dict[str, Any] = {
+ "success": False,
+ "error": error,
+ "data": {"session_id": str(session_id)},
+ }
+ delivered = send_callback(webhook_url, payload, webhook_secret)
+ logger.info(
+ f"[_send_failure_webhook] Failure webhook dispatched | "
+ f"session_id: {session_id}, delivered: {delivered}"
+ )
+
+
+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`.
+
+ Idempotent under Celery redelivery: if the transcript already ends on an
+ assistant turn, the pending turn was answered and nothing is re-run.
+ """
+ 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:
+ logger.error(
+ f"[process_chatbot_session_message] Chatbot session not found | "
+ f"session_id: {session_id}, project_id: {project_id}"
+ )
+ return
+
+ stored_messages = list_chatbot_session_messages(session, session_id=session_id)
+ if (
+ stored_messages
+ and stored_messages[-1].role == ChatbotMessageRoleEnum.ASSISTANT
+ ):
+ logger.warning(
+ f"[process_chatbot_session_message] Turn already answered, skipping | "
+ f"session_id: {session_id}"
+ )
+ return
+
+ system_prompt = chatbot_session.system_prompt
+ session_status = chatbot_session.status.value
+ collected_fields = dict(chatbot_session.collected_fields)
+
+ try:
+ model_settings: ChatbotModelSettings = resolve_chatbot_model_settings(
+ session=session,
+ project_id=project_id,
+ config_id=chatbot_session.config_id,
+ config_version=chatbot_session.config_version,
+ max_tokens=None,
+ history_token_limit=None,
+ )
+ except HTTPException as e:
+ # The config was resolvable at session creation; it has since been
+ # deleted or lost its version, and the caller only hears via webhook.
+ logger.warning(
+ f"[process_chatbot_session_message] Config no longer resolvable | "
+ f"session_id: {session_id}, detail: {e.detail}"
+ )
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=str(e.detail),
+ )
+ return
+ except ChatbotInputError as e:
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=str(e),
+ )
+ return
+
+ history, user_message = _split_pending_turn(stored_messages)
+ logger.info(
+ f"[process_chatbot_session_message] Running turn | session_id: {session_id}, "
+ f"history_count: {len(history)}, has_user_message: {user_message is not None}"
+ )
+
+ try:
+ transcript = asyncio.run(
+ run_chatbot_turn(
+ system_prompt=system_prompt,
+ history=history,
+ user_message=user_message,
+ model_settings=model_settings,
+ )
+ )
+ reply = _extract_reply(transcript)
+ except (ChatbotInputError, ChatbotProviderError) as e:
+ _send_failure_webhook(
+ session_id=session_id,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
+ error=str(e),
+ )
+ return
+
+ with Session(engine) as session:
+ create_chatbot_session_message(
+ session,
+ session_id=session_id,
+ role=ChatbotMessageRoleEnum.ASSISTANT,
+ content=reply,
+ )
+
+ payload: dict[str, Any] = {
+ "success": True,
+ "data": {
+ "session_id": str(session_id),
+ "reply": reply,
+ "status": session_status,
+ "collected_fields": collected_fields,
+ },
+ }
+ delivered = send_callback(webhook_url, payload, webhook_secret)
+ logger.info(
+ f"[process_chatbot_session_message] Turn completed | session_id: {session_id}, "
+ f"reply_length: {len(reply)}, webhook_delivered: {delivered}"
+ )
diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py
new file mode 100644
index 000000000..7746f215d
--- /dev/null
+++ b/backend/app/services/chatbot/types.py
@@ -0,0 +1,10 @@
+from typing import Annotated, TypedDict
+
+from langchain_core.messages import BaseMessage
+from langgraph.graph.message import add_messages
+
+ChatbotMessageDict = dict[str, str]
+
+
+class ChatbotState(TypedDict):
+ messages: Annotated[list[BaseMessage], add_messages]
diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py
index 2ec9571a1..d4831b46f 100644
--- a/backend/app/services/chatbot/utils.py
+++ b/backend/app/services/chatbot/utils.py
@@ -1,10 +1,4 @@
-"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node.
-
-Like OCS's `LLMResponseWithPrompt` node, the model and reasoning settings come
-from per-request input -- here a saved Kaapi config (see
-`services/chatbot/config.py`) plus optional token overrides on the request.
-Only Anthropic is wired up, since the node's chat model is `ChatAnthropic`.
-"""
+"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node."""
from dataclasses import dataclass, field
from typing import Any, Literal
@@ -18,15 +12,11 @@
DEFAULT_MAX_OUTPUT_TOKENS = 1024
DEFAULT_THINKING: dict[str, Any] = {"type": "adaptive"}
-# OCS's `history_mode=summarize`, simplified: once the running history crosses
-# this many tokens, everything except the system prompt and the most recent
-# turns is summarized into one note before the next model call.
DEFAULT_HISTORY_TOKEN_LIMIT = 4000
FLOW_KEEP_RECENT_MESSAGES = 6
def default_thinking() -> dict[str, Any]:
- # Fresh copy so one request can't mutate the module-level default.
return dict(DEFAULT_THINKING)
@@ -34,7 +24,6 @@ def default_thinking() -> dict[str, Any]:
class ChatbotModelSettings:
model: str
max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
- # None lets the provider pick its own default effort.
reasoning_effort: AnthropicEffort | None = None
thinking: dict[str, Any] = field(default_factory=default_thinking)
history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index 261046755..8023c2521 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -6,6 +6,7 @@ All paths relative to `backend/app/`.
## Routes
- `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope)
+- `api/routes/chatbot/session.py` — `GET /chatbot/sessions?contact_ref=` (all sessions for a contact in the caller's project, newest first → `list[ChatbotSessionSummary]`)
## Tables (SQLModel)
None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model.
@@ -14,6 +15,7 @@ None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMess
- `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs)
- `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization
- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model`
+- `crud/chatbot.py` — `list_chatbot_sessions_by_contact_ref` (org + project scoped, `inserted_at` desc, unpaginated)
- `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted)
## Async
From 8d9919c7e8ca5a47ceaba9a776e6941d7a42360f Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:52:51 +0530
Subject: [PATCH 03/15] fix: test cases updates
---
backend/app/api/routes/chatbot.py | 64 ----
.../app/tests/api/routes/chatbot/__init__.py | 0
.../tests/api/routes/chatbot/test_message.py | 109 ++++++
.../tests/api/routes/chatbot/test_session.py | 316 ++++++++++++++++
.../app/tests/celery/test_job_execution.py | 30 ++
backend/app/tests/celery/test_utils.py | 36 ++
backend/app/tests/crud/config/test_version.py | 54 +++
backend/app/tests/crud/test_chatbot.py | 217 +++++++++++
.../app/tests/services/chatbot/__init__.py | 0
.../app/tests/services/chatbot/test_config.py | 185 ++++++++++
.../app/tests/services/chatbot/test_engine.py | 259 +++++++++++++
.../tests/services/chatbot/test_session.py | 340 ++++++++++++++++++
.../app/tests/services/chatbot/test_utils.py | 65 ++++
backend/app/tests/utils/chatbot.py | 96 +++++
backend/app/tests/utils/test_data.py | 13 +
15 files changed, 1720 insertions(+), 64 deletions(-)
delete mode 100644 backend/app/api/routes/chatbot.py
create mode 100644 backend/app/tests/api/routes/chatbot/__init__.py
create mode 100644 backend/app/tests/api/routes/chatbot/test_message.py
create mode 100644 backend/app/tests/api/routes/chatbot/test_session.py
create mode 100644 backend/app/tests/celery/test_utils.py
create mode 100644 backend/app/tests/crud/test_chatbot.py
create mode 100644 backend/app/tests/services/chatbot/__init__.py
create mode 100644 backend/app/tests/services/chatbot/test_config.py
create mode 100644 backend/app/tests/services/chatbot/test_engine.py
create mode 100644 backend/app/tests/services/chatbot/test_session.py
create mode 100644 backend/app/tests/services/chatbot/test_utils.py
create mode 100644 backend/app/tests/utils/chatbot.py
diff --git a/backend/app/api/routes/chatbot.py b/backend/app/api/routes/chatbot.py
deleted file mode 100644
index 454f4853b..000000000
--- a/backend/app/api/routes/chatbot.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import logging
-
-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.engine import (
- ChatbotInputError,
- ChatbotProviderError,
- run_chatbot_turn,
-)
-from app.services.chatbot.config import resolve_chatbot_model_settings
-from app.utils import APIResponse, load_description
-
-logger = logging.getLogger(__name__)
-
-router = APIRouter(prefix="/chatbot", tags=["Chatbot"])
-
-
-# Synchronous by design (real-time request/response turn), not a Celery job.
-@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:
- logger.warning(
- f"[send_chatbot_message] Chatbot turn failed upstream | "
- f"project_id: {auth_context.project_.id}"
- )
- 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))
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/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py
new file mode 100644
index 000000000..5f2be7f19
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_message.py
@@ -0,0 +1,109 @@
+import anthropic
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.core.config import settings
+from app.models import Config
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+)
+
+MESSAGE_URL = f"{settings.API_V1_STR}/chatbot/message"
+SYSTEM_PROMPT = "Collect the user's name and city."
+
+
+@pytest.fixture
+def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config:
+ return create_test_config(
+ db,
+ project_id=user_api_key.project_id,
+ config_blob=build_anthropic_text_config_blob(),
+ )
+
+
+class TestSendChatbotMessage:
+ def test_returns_updated_transcript(
+ self,
+ client: TestClient,
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Nice, which city?"]))
+
+ resp = client.post(
+ MESSAGE_URL,
+ headers={"X-API-KEY": user_api_key.key},
+ json={
+ "config_id": str(anthropic_config.id),
+ "system_prompt": SYSTEM_PROMPT,
+ "history": [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ ],
+ "message": "Asha",
+ },
+ )
+
+ assert resp.status_code == 200
+ assert resp.json()["data"]["messages"] == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ {"role": "user", "content": "Asha"},
+ {"role": "assistant", "content": "Nice, which city?"},
+ ]
+
+ def test_missing_message_with_history_returns_422(
+ self,
+ client: TestClient,
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel()
+ patch_chat_model(monkeypatch, chat_model)
+
+ resp = client.post(
+ MESSAGE_URL,
+ headers={"X-API-KEY": user_api_key.key},
+ json={
+ "config_id": str(anthropic_config.id),
+ "system_prompt": SYSTEM_PROMPT,
+ "history": [{"role": "assistant", "content": "Hi! What's your name?"}],
+ },
+ )
+
+ assert resp.status_code == 422
+ assert "`message` is required" in resp.json()["error"]
+ assert chat_model.calls == []
+
+ def test_provider_failure_returns_502(
+ self,
+ client: TestClient,
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(error=anthropic.APIConnectionError(request=request)),
+ )
+
+ resp = client.post(
+ MESSAGE_URL,
+ headers={"X-API-KEY": user_api_key.key},
+ json={
+ "config_id": str(anthropic_config.id),
+ "system_prompt": SYSTEM_PROMPT,
+ "message": "Asha",
+ },
+ )
+
+ assert resp.status_code == 502
+ assert "(code: APIConnectionError)" in resp.json()["error"]
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..128ede74f
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -0,0 +1,316 @@
+import socket
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.api.routes.chatbot import session as session_routes
+from app.core.config import settings
+from app.crud.chatbot import get_chatbot_session, list_chatbot_session_messages
+from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config
+from app.tests.utils.auth import TestAuthContext
+from app.tests.utils.chatbot import (
+ add_test_chatbot_messages,
+ create_test_chatbot_session,
+)
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+)
+from app.tests.utils.utils import random_lower_string
+
+SESSIONS_URL = f"{settings.API_V1_STR}/chatbot/sessions"
+WEBHOOK_URL = "https://hooks.example.com/chatbot"
+PUBLIC_IP = "93.184.216.34"
+SYSTEM_PROMPT = "Collect the user's name and city."
+
+
+def _message_url(session_id: Any) -> str:
+ return f"{SESSIONS_URL}/{session_id}/message"
+
+
+@pytest.fixture
+def headers(user_api_key: TestAuthContext) -> dict[str, str]:
+ return {"X-API-KEY": user_api_key.key}
+
+
+@pytest.fixture
+def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config:
+ return create_test_config(
+ db,
+ project_id=user_api_key.project_id,
+ config_blob=build_anthropic_text_config_blob(),
+ )
+
+
+@pytest.fixture
+def chatbot_session(db: Session, anthropic_config: Config) -> ChatbotSession:
+ return create_test_chatbot_session(db, config=anthropic_config)
+
+
+@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_in_callers_project(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+
+ resp = client.post(
+ SESSIONS_URL,
+ headers=headers,
+ json={
+ "config_id": str(anthropic_config.id),
+ "config_version": 1,
+ "contact_ref": contact_ref,
+ "system_prompt": SYSTEM_PROMPT,
+ },
+ )
+
+ assert resp.status_code == 201
+ session_id = resp.json()["data"]["session_id"]
+ stored = get_chatbot_session(
+ db,
+ session_id=session_id,
+ organization_id=user_api_key.organization_id,
+ project_id=user_api_key.project_id,
+ )
+ assert stored is not None
+ assert stored.contact_ref == contact_ref
+ assert stored.config_id == anthropic_config.id
+ assert stored.config_version == 1
+ assert stored.system_prompt == SYSTEM_PROMPT
+
+ def test_unknown_config_returns_404(
+ self, client: TestClient, headers: dict[str, str]
+ ) -> None:
+ resp = client.post(
+ SESSIONS_URL,
+ headers=headers,
+ json={
+ "config_id": str(uuid4()),
+ "contact_ref": "contact-1",
+ "system_prompt": SYSTEM_PROMPT,
+ },
+ )
+
+ assert resp.status_code == 404
+
+ def test_non_anthropic_config_returns_422(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ user_api_key: TestAuthContext,
+ ) -> None:
+ openai_config = create_test_config(
+ db, project_id=user_api_key.project_id, use_kaapi_schema=True
+ )
+
+ resp = client.post(
+ SESSIONS_URL,
+ headers=headers,
+ json={
+ "config_id": str(openai_config.id),
+ "contact_ref": "contact-1",
+ "system_prompt": SYSTEM_PROMPT,
+ },
+ )
+
+ assert resp.status_code == 422
+ assert "only supports configs" in resp.json()["error"]
+
+
+class TestListChatbotSessions:
+ def test_lists_contacts_sessions_newest_first_in_callers_project(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ older = create_test_chatbot_session(
+ db, config=anthropic_config, contact_ref=contact_ref
+ )
+ newer = create_test_chatbot_session(
+ db, config=anthropic_config, contact_ref=contact_ref, config_version=1
+ )
+ create_test_chatbot_session(db, config=anthropic_config)
+ foreign_config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob()
+ )
+ create_test_chatbot_session(db, config=foreign_config, contact_ref=contact_ref)
+
+ resp = client.get(
+ SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref}
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert [s["session_id"] for s in body] == [str(newer.id), str(older.id)]
+ assert body[0]["contact_ref"] == contact_ref
+ assert body[0]["status"] == "active"
+ assert body[0]["collected_fields"] == {}
+ assert body[0]["config_id"] == str(anthropic_config.id)
+ assert body[0]["config_version"] == 1
+ assert body[1]["config_version"] is None
+
+ def test_missing_contact_ref_returns_422(
+ self, client: TestClient, headers: dict[str, str]
+ ) -> None:
+ resp = client.get(SESSIONS_URL, headers=headers)
+
+ assert resp.status_code == 422
+
+
+class TestSendChatbotSessionMessage:
+ def test_stores_user_message_and_queues_turn(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ user_api_key: TestAuthContext,
+ chatbot_session: ChatbotSession,
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = client.post(
+ _message_url(chatbot_session.id),
+ headers=headers,
+ json={"message": "Asha", "webhook_url": WEBHOOK_URL},
+ )
+
+ 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) for m in stored] == [
+ (ChatbotMessageRoleEnum.USER, "Asha")
+ ]
+ [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,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = client.post(
+ _message_url(chatbot_session.id),
+ headers=headers,
+ json={"webhook_url": WEBHOOK_URL},
+ )
+
+ 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,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [(ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?")],
+ )
+
+ resp = client.post(
+ _message_url(chatbot_session.id),
+ headers=headers,
+ json={"webhook_url": WEBHOOK_URL},
+ )
+
+ assert resp.status_code == 422
+ assert "`message` is required" in resp.json()["error"]
+ assert enqueued == []
+
+ def test_unknown_session_returns_404(
+ self,
+ client: TestClient,
+ headers: dict[str, str],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = client.post(
+ _message_url(uuid4()),
+ headers=headers,
+ json={"message": "Asha", "webhook_url": WEBHOOK_URL},
+ )
+
+ assert resp.status_code == 404
+ assert enqueued == []
+
+ def test_session_in_other_project_returns_404(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ foreign_config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob()
+ )
+ foreign_session = create_test_chatbot_session(db, config=foreign_config)
+
+ resp = client.post(
+ _message_url(foreign_session.id),
+ headers=headers,
+ json={"message": "Asha", "webhook_url": WEBHOOK_URL},
+ )
+
+ 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,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ resp = client.post(
+ _message_url(chatbot_session.id),
+ headers=headers,
+ json={"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 == []
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/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..54d276eeb
--- /dev/null
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -0,0 +1,217 @@
+from uuid import uuid4
+
+import pytest
+from sqlmodel import Session
+
+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 (
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionStatusEnum,
+ Config,
+ Project,
+)
+from app.tests.utils.chatbot import (
+ add_test_chatbot_messages,
+ create_test_chatbot_session,
+)
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+ create_test_project,
+)
+from app.tests.utils.utils import random_lower_string
+
+
+@pytest.fixture
+def config(db: Session) -> Config:
+ return create_test_config(db, config_blob=build_anthropic_text_config_blob())
+
+
+@pytest.fixture
+def project(db: Session, config: Config) -> Project:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ return project
+
+
+class TestCreateChatbotSession:
+ def test_persists_session_as_active_with_no_collected_fields(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+
+ created = create_chatbot_session(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ contact_ref=contact_ref,
+ config_id=config.id,
+ config_version=1,
+ system_prompt="Collect the user's name.",
+ )
+
+ db.expire_all()
+ stored = db.get(ChatbotSession, created.id)
+ assert stored is not None
+ assert stored.contact_ref == contact_ref
+ assert stored.config_id == config.id
+ assert stored.config_version == 1
+ assert stored.system_prompt == "Collect the user's name."
+ assert stored.status == ChatbotSessionStatusEnum.ACTIVE
+ assert stored.collected_fields == {}
+ assert stored.inserted_at == stored.updated_at
+
+
+class TestGetChatbotSession:
+ def test_returns_session_in_scope(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+
+ found = get_chatbot_session(
+ db,
+ session_id=chatbot_session.id,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ )
+
+ assert found is not None
+ assert found.id == chatbot_session.id
+
+ def test_other_project_cannot_see_session(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ other_project = create_test_project(db)
+
+ found = get_chatbot_session(
+ db,
+ session_id=chatbot_session.id,
+ organization_id=other_project.organization_id,
+ project_id=other_project.id,
+ )
+
+ assert found is None
+
+ def test_other_organization_cannot_see_session(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ 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_unknown_id_returns_none(self, db: Session, project: Project) -> None:
+ assert (
+ get_chatbot_session(
+ db,
+ session_id=uuid4(),
+ organization_id=project.organization_id,
+ project_id=project.id,
+ )
+ is None
+ )
+
+
+class TestChatbotSessionMessages:
+ def test_create_message_persists_role_and_content(
+ self, db: Session, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+
+ message = create_chatbot_session_message(
+ db,
+ session_id=chatbot_session.id,
+ role=ChatbotMessageRoleEnum.USER,
+ content="Asha",
+ )
+
+ stored = list_chatbot_session_messages(db, session_id=chatbot_session.id)
+ assert [(m.id, m.role, m.content) for m in stored] == [
+ (message.id, ChatbotMessageRoleEnum.USER, "Asha")
+ ]
+
+ def test_list_returns_oldest_first_and_only_this_session(
+ self, db: Session, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ other_session = create_test_chatbot_session(db, config=config)
+ 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, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+
+ assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
+
+
+class TestListChatbotSessionsByContactRef:
+ def test_returns_newest_first_scoped_to_contact_and_project(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ oldest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
+ newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
+ create_test_chatbot_session(db, config=config)
+ other_config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob()
+ )
+ create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref)
+
+ sessions = list_chatbot_sessions_by_contact_ref(
+ db,
+ contact_ref=contact_ref,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ )
+
+ assert [s.id for s in sessions] == [newest.id, oldest.id]
+
+ def test_unknown_contact_returns_empty(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ create_test_chatbot_session(db, config=config)
+
+ assert (
+ list_chatbot_sessions_by_contact_ref(
+ db,
+ contact_ref=f"contact-{random_lower_string()}",
+ organization_id=project.organization_id,
+ project_id=project.id,
+ )
+ == []
+ )
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/test_config.py b/backend/app/tests/services/chatbot/test_config.py
new file mode 100644
index 000000000..c70f1a4dc
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_config.py
@@ -0,0 +1,185 @@
+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
+from app.services.chatbot.config import resolve_chatbot_model_settings
+from app.services.chatbot.exceptions import ChatbotInputError
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+ create_test_project,
+ create_test_version,
+)
+
+
+def _resolve(
+ db: Session,
+ config: Config,
+ *,
+ config_version: int | None = None,
+ max_tokens: int | None = None,
+ history_token_limit: int | None = None,
+) -> ChatbotModelSettings:
+ return resolve_chatbot_model_settings(
+ session=db,
+ 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()
+
+
+class TestResolveChatbotModelSettings:
+ def test_latest_version_with_server_defaults(self, db: Session) -> None:
+ config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6")
+ )
+ create_test_version(
+ db,
+ config_id=config.id,
+ project_id=config.project_id,
+ config_blob=build_anthropic_text_config_blob(
+ model="claude-opus-5-5",
+ effort="high",
+ thinking={"type": "enabled", "budget_tokens": 2048},
+ ),
+ )
+
+ model_settings = _resolve(db, config)
+
+ assert model_settings == ChatbotModelSettings(
+ model="claude-opus-5-5",
+ max_tokens=1024,
+ reasoning_effort="high",
+ thinking={"type": "enabled", "budget_tokens": 2048},
+ history_token_limit=4000,
+ )
+
+ def test_explicit_version_and_request_overrides(self, db: Session) -> None:
+ config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6")
+ )
+ create_test_version(
+ db,
+ config_id=config.id,
+ project_id=config.project_id,
+ config_blob=build_anthropic_text_config_blob(model="claude-opus-5-5"),
+ )
+
+ 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_config(db, config_blob=build_anthropic_text_config_blob())
+
+ 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_config(db, config_blob=build_anthropic_text_config_blob())
+ other_project = create_test_project(db)
+
+ with pytest.raises(HTTPException) as exc_info:
+ resolve_chatbot_model_settings(
+ session=db,
+ 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,
+ 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_config(db, config_blob=build_anthropic_text_config_blob())
+ 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_config(db, config_blob=build_anthropic_text_config_blob())
+ _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_config(db, config_blob=build_anthropic_text_config_blob())
+ _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_config(
+ db, config_blob=build_anthropic_text_config_blob(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_engine.py b/backend/app/tests/services/chatbot/test_engine.py
new file mode 100644
index 000000000..610c6a49d
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_engine.py
@@ -0,0 +1,259 @@
+import anthropic
+import httpx
+import pytest
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from app.services.chatbot.engine import run_chatbot_turn
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
+
+SYSTEM_PROMPT = "Collect the user's name and city."
+SUMMARY_TEXT = "User is Asha from Pune."
+
+_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+
+
+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
+
+
+def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings:
+ return ChatbotModelSettings(
+ model="claude-sonnet-4-6", history_token_limit=history_token_limit
+ )
+
+
+class TestRunChatbotTurn:
+ @pytest.mark.asyncio
+ async def test_first_call_returns_greeting_without_kickoff(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Hi! What's your name?"])
+ patch_chat_model(monkeypatch, chat_model)
+
+ transcript = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message=None,
+ model_settings=_model_settings(),
+ )
+
+ assert transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "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 = ScriptedChatModel(replies=["Nice to meet you, Asha."])
+ patch_chat_model(monkeypatch, chat_model)
+ history = [
+ {"role": "system", "content": "stale prompt from the previous call"},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ ]
+
+ transcript = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=history,
+ user_message="Asha",
+ model_settings=_model_settings(),
+ )
+
+ assert transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ {"role": "user", "content": "Asha"},
+ {"role": "assistant", "content": "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_missing_message_with_history_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel()
+ patch_chat_model(monkeypatch, chat_model)
+
+ with pytest.raises(ChatbotInputError, match="`message` is required"):
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Hi!"}],
+ user_message=None,
+ model_settings=_model_settings(),
+ )
+ assert chat_model.calls == []
+
+ @pytest.mark.asyncio
+ async def test_unsupported_role_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel()
+ patch_chat_model(monkeypatch, chat_model)
+
+ with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"):
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "tool", "content": "{}"}],
+ user_message="hello",
+ model_settings=_model_settings(),
+ )
+ 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)",
+ ),
+ (
+ 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", "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_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="hello",
+ model_settings=_model_settings(),
+ )
+
+ 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 = ScriptedChatModel(
+ replies=[SUMMARY_TEXT, "Got it."], token_count=5000
+ )
+ patch_chat_model(monkeypatch, chat_model)
+ history = _alternating_history(turns=4)
+
+ transcript = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=history,
+ user_message="user 4",
+ model_settings=_model_settings(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.
+ assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"]
+ 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",
+ ]
+ # Summarization only trims the model's view; the caller keeps the full transcript.
+ assert transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ *history,
+ {"role": "user", "content": "user 4"},
+ {"role": "assistant", "content": "Got it."},
+ ]
+
+ @pytest.mark.asyncio
+ async def test_long_history_within_budget_is_sent_whole(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Got it."], token_count=100)
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=_alternating_history(turns=4),
+ user_message="user 4",
+ model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=_alternating_history(turns=3)[:5],
+ user_message="more",
+ model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ patch_chat_model(monkeypatch, chat_model)
+ # 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_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=assistant_only,
+ user_message="hello",
+ model_settings=_model_settings(history_token_limit=10),
+ )
+
+ assert len(chat_model.calls) == 1
+ assert len(chat_model.calls[0]) == 10
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..22ad3b93e
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_session.py
@@ -0,0 +1,340 @@
+from collections.abc import Iterator
+from dataclasses import dataclass, field
+from typing import Any
+from uuid import uuid4
+
+import anthropic
+import httpx
+import pytest
+from sqlmodel import Session
+
+from app.crud.chatbot import list_chatbot_session_messages
+from app.crud.config import ConfigVersionCrud
+from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config, Project
+from app.services.chatbot import session as session_service
+from app.services.chatbot.session import process_chatbot_session_message
+from app.tests.utils.chatbot import (
+ DEFAULT_SYSTEM_PROMPT,
+ ScriptedChatModel,
+ add_test_chatbot_messages,
+ create_test_chatbot_session,
+ patch_chat_model,
+)
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+)
+
+WEBHOOK_URL = "https://hooks.example.com/chatbot"
+
+
+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 config(db: Session) -> Config:
+ return create_test_config(db, config_blob=build_anthropic_text_config_blob())
+
+
+def _process(chatbot_session: ChatbotSession, db: Session) -> None:
+ project = db.get(Project, chatbot_session.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)
+ ]
+
+
+class TestProcessChatbotSessionMessage:
+ def test_first_call_stores_greeting_and_posts_success_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
+ )
+ chatbot_session = create_test_chatbot_session(db, config=config)
+
+ _process(chatbot_session, db)
+
+ assert _transcript(db, chatbot_session) == [
+ ("assistant", "Hi! What's your name?")
+ ]
+ assert callbacks.calls == [
+ (
+ WEBHOOK_URL,
+ {
+ "success": True,
+ "data": {
+ "session_id": str(chatbot_session.id),
+ "reply": "Hi! What's your name?",
+ "status": "active",
+ "collected_fields": {},
+ },
+ },
+ None,
+ )
+ ]
+
+ def test_pending_user_message_is_answered_with_prior_history(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Nice, which city?"])
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (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,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Which city?"])
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (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,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["should never be used"])
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (ChatbotMessageRoleEnum.USER, "Asha"),
+ (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert 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,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["should never be used"])
+ patch_chat_model(monkeypatch, chat_model)
+ project = db.get(Project, config.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 chat_model.calls == []
+ assert callbacks.calls == []
+
+ def test_deleted_config_version_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["should never be used"])
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ ConfigVersionCrud(
+ session=db, project_id=config.project_id, config_id=config.id
+ ).delete_or_raise(1)
+
+ _process(chatbot_session, db)
+
+ assert chat_model.calls == []
+ assert _transcript(db, chatbot_session) == []
+ assert callbacks.calls == [
+ (
+ WEBHOOK_URL,
+ {
+ "success": False,
+ "error": f"No version found for config '{config.id}'",
+ "data": {"session_id": str(chatbot_session.id)},
+ },
+ None,
+ )
+ ]
+
+ def test_non_anthropic_config_posts_failure_webhook(
+ self,
+ db: Session,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["should never be used"])
+ patch_chat_model(monkeypatch, chat_model)
+ openai_config = create_test_config(db, use_kaapi_schema=True)
+ chatbot_session = create_test_chatbot_session(db, config=openai_config)
+
+ _process(chatbot_session, db)
+
+ assert chat_model.calls == []
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "only supports configs" in payload["error"]
+ assert payload["data"] == {"session_id": str(chatbot_session.id)}
+
+ def test_provider_error_posts_failure_webhook_and_stores_nothing(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(error=anthropic.APITimeoutError(request=request)),
+ )
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ add_test_chatbot_messages(
+ db, chatbot_session, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+
+ _process(chatbot_session, db)
+
+ assert _transcript(db, chatbot_session) == [("user", "Asha")]
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "(code: APITimeoutError)" in payload["error"]
+ assert payload["data"] == {"session_id": str(chatbot_session.id)}
+
+ def test_transcript_without_pending_user_turn_posts_failure_webhook(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["should never be used"])
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ # A trailing system row dodges the already-answered check, leaving an
+ # assistant turn with no user message after it.
+ add_test_chatbot_messages(
+ db,
+ chatbot_session,
+ [
+ (ChatbotMessageRoleEnum.ASSISTANT, "Hi!"),
+ (ChatbotMessageRoleEnum.SYSTEM, "note"),
+ ],
+ )
+
+ _process(chatbot_session, db)
+
+ assert chat_model.calls == []
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "`message` is required" in payload["error"]
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..512fca063
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_utils.py
@@ -0,0 +1,65 @@
+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,
+)
+
+
+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"}
+
+
+class TestGetChatModel:
+ def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "")
+
+ with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY is not set"):
+ get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5"))
+
+ def test_builds_model_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "sk-ant-test")
+
+ chat_model = get_chat_model(
+ ChatbotModelSettings(
+ model="claude-sonnet-4-5",
+ max_tokens=512,
+ reasoning_effort="high",
+ thinking={"type": "enabled", "budget_tokens": 2048},
+ )
+ )
+
+ 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() == "sk-ant-test"
+
+ def test_omits_reasoning_effort_when_unset(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "sk-ant-test")
+
+ chat_model = get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5"))
+
+ 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..dac32ca06
--- /dev/null
+++ b/backend/app/tests/utils/chatbot.py
@@ -0,0 +1,96 @@
+from collections.abc import Sequence
+from typing import Any
+
+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 pydantic import Field
+from sqlmodel import Session
+
+from app.crud.chatbot import create_chatbot_session, create_chatbot_session_message
+from app.models import (
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ ChatbotSessionMessage,
+ Config,
+ Project,
+)
+from app.services.chatbot import engine
+from app.tests.utils.utils import random_lower_string
+
+DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city."
+
+
+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)
+
+ @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 patch_chat_model(
+ monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel
+) -> None:
+ # The Anthropic SDK is the external boundary; everything above it runs for real.
+ monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model)
+
+
+def create_test_chatbot_session(
+ db: Session,
+ *,
+ config: Config,
+ contact_ref: str | None = None,
+ config_version: int | None = None,
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT,
+) -> ChatbotSession:
+ project = db.get(Project, config.project_id)
+ assert project is not None
+ return create_chatbot_session(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ contact_ref=contact_ref or f"contact-{random_lower_string()}",
+ config_id=config.id,
+ config_version=config_version,
+ system_prompt=system_prompt,
+ )
+
+
+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
+ )
+ )
+ return stored
diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py
index be27d49f0..a447e479b 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
@@ -328,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,
From 54c00ade57cf2805149b1cdecc3c6152bab74494 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 22 Oct 2026 19:22:59 +0530
Subject: [PATCH 04/15] fix: remove the unwanted migration
---
.../086_seed_anthropic_text_model_configs.py | 60 -------------------
1 file changed, 60 deletions(-)
delete mode 100644 backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
diff --git a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
deleted file mode 100644
index 915218568..000000000
--- a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""seed anthropic text model_config rows
-
-Revision ID: 086
-Revises: 085
-Create Date: 2026-10-09 00:00:00.000000
-
-`anthropic` has been a valid provider since 066 but had no model_config rows,
-so it never showed up in /models/providers and config-version updates could
-not tell its models are reasoning models. The `effort` key in `config` is what
-`is_reasoning_model` keys on — it makes `_strip_unsupported_params` drop
-`temperature`, which the Messages API rejects at non-default values.
-
-`effort.default` is "high" because that is what the Messages API applies when
-the param is omitted. Sonnet 4.6 does not expose the "xhigh" level.
-
-claude-opus-5-5 pricing is left NULL until list prices are confirmed; cost
-estimation returns None for NULL pricing rather than a wrong number.
-
-Downgrade deletes the two rows by key, so a row that already existed before
-upgrade (ON CONFLICT DO NOTHING skipped it) is removed as well.
-"""
-
-from alembic import op
-
-revision = "086"
-down_revision = "085"
-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')
- """
- )
From 00bbe9ae33534029d98c4a07281775a831f45fa7 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 22 Oct 2026 20:51:35 +0530
Subject: [PATCH 05/15] fix: few clenaups
---
backend/app/celery/utils.py | 4 ---
backend/app/crud/chatbot.py | 13 --------
backend/app/services/chatbot/config.py | 6 +---
backend/app/services/chatbot/session.py | 41 +++----------------------
4 files changed, 6 insertions(+), 58 deletions(-)
diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py
index 88285c0bb..a7b31f9e4 100644
--- a/backend/app/celery/utils.py
+++ b/backend/app/celery/utils.py
@@ -354,10 +354,6 @@ def start_chatbot_session_message(
webhook_url=webhook_url,
trace_id=trace_id,
)
- logger.info(
- f"[start_chatbot_session_message] Enqueued chatbot turn | "
- f"session_id: {session_id}, task_id: {task_id}"
- )
return task_id
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
index 3b1d10304..31bb9e5af 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -1,4 +1,3 @@
-import logging
from uuid import UUID
from sqlmodel import Session, col, select
@@ -10,8 +9,6 @@
ChatbotSessionMessage,
)
-logger = logging.getLogger(__name__)
-
def create_chatbot_session(
session: Session,
@@ -37,10 +34,6 @@ def create_chatbot_session(
session.add(chatbot_session)
session.commit()
session.refresh(chatbot_session)
- logger.info(
- f"[create_chatbot_session] Chatbot session created | "
- f"session_id: {chatbot_session.id}, project_id: {project_id}, config_id: {config_id}"
- )
return chatbot_session
@@ -75,18 +68,12 @@ def create_chatbot_session_message(
session.add(message)
session.commit()
session.refresh(message)
- logger.info(
- f"[create_chatbot_session_message] Chatbot message stored | "
- f"session_id: {session_id}, role: {role.value}, message_id: {message.id}"
- )
return message
def list_chatbot_session_messages(
session: Session, *, session_id: UUID
) -> list[ChatbotSessionMessage]:
- # Unpaginated on purpose: the engine needs the whole transcript each turn
- # and summarizes long histories itself.
statement = (
select(ChatbotSessionMessage)
.where(ChatbotSessionMessage.session_id == session_id)
diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py
index e8f09c666..0e4268832 100644
--- a/backend/app/services/chatbot/config.py
+++ b/backend/app/services/chatbot/config.py
@@ -60,11 +60,7 @@ def resolve_chatbot_model_settings(
max_tokens: int | None,
history_token_limit: int | None,
) -> ChatbotModelSettings:
- """Turn a saved config (+ request overrides) into chat-model settings.
-
- Raises HTTPException(404) when the config/version isn't in the project and
- ChatbotInputError when the config isn't a usable Anthropic text config.
- """
+ """Turn a saved config (+ request overrides) into chat-model settings."""
version_row = _get_config_version(
session=session,
project_id=project_id,
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
index 06f899308..279840256 100644
--- a/backend/app/services/chatbot/session.py
+++ b/backend/app/services/chatbot/session.py
@@ -1,5 +1,4 @@
import asyncio
-import logging
from typing import Any
from uuid import UUID
@@ -20,8 +19,6 @@
from app.services.chatbot.utils import ChatbotModelSettings
from app.utils import get_webhook_secret, send_callback
-logger = logging.getLogger(__name__)
-
def _split_pending_turn(
stored_messages: list[ChatbotSessionMessage],
@@ -66,11 +63,8 @@ def _send_failure_webhook(
"error": error,
"data": {"session_id": str(session_id)},
}
- delivered = send_callback(webhook_url, payload, webhook_secret)
- logger.info(
- f"[_send_failure_webhook] Failure webhook dispatched | "
- f"session_id: {session_id}, delivered: {delivered}"
- )
+ send_callback(webhook_url, payload, webhook_secret)
+ return
def process_chatbot_session_message(
@@ -80,11 +74,7 @@ def process_chatbot_session_message(
project_id: int,
webhook_url: str,
) -> None:
- """Run the pending chatbot turn for a session and POST the reply to `webhook_url`.
-
- Idempotent under Celery redelivery: if the transcript already ends on an
- assistant turn, the pending turn was answered and nothing is re-run.
- """
+ """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
@@ -97,10 +87,6 @@ def process_chatbot_session_message(
project_id=project_id,
)
if chatbot_session is None:
- logger.error(
- f"[process_chatbot_session_message] Chatbot session not found | "
- f"session_id: {session_id}, project_id: {project_id}"
- )
return
stored_messages = list_chatbot_session_messages(session, session_id=session_id)
@@ -108,10 +94,6 @@ def process_chatbot_session_message(
stored_messages
and stored_messages[-1].role == ChatbotMessageRoleEnum.ASSISTANT
):
- logger.warning(
- f"[process_chatbot_session_message] Turn already answered, skipping | "
- f"session_id: {session_id}"
- )
return
system_prompt = chatbot_session.system_prompt
@@ -128,12 +110,6 @@ def process_chatbot_session_message(
history_token_limit=None,
)
except HTTPException as e:
- # The config was resolvable at session creation; it has since been
- # deleted or lost its version, and the caller only hears via webhook.
- logger.warning(
- f"[process_chatbot_session_message] Config no longer resolvable | "
- f"session_id: {session_id}, detail: {e.detail}"
- )
_send_failure_webhook(
session_id=session_id,
webhook_url=webhook_url,
@@ -151,10 +127,6 @@ def process_chatbot_session_message(
return
history, user_message = _split_pending_turn(stored_messages)
- logger.info(
- f"[process_chatbot_session_message] Running turn | session_id: {session_id}, "
- f"history_count: {len(history)}, has_user_message: {user_message is not None}"
- )
try:
transcript = asyncio.run(
@@ -192,8 +164,5 @@ def process_chatbot_session_message(
"collected_fields": collected_fields,
},
}
- delivered = send_callback(webhook_url, payload, webhook_secret)
- logger.info(
- f"[process_chatbot_session_message] Turn completed | session_id: {session_id}, "
- f"reply_length: {len(reply)}, webhook_delivered: {delivered}"
- )
+ send_callback(webhook_url, payload, webhook_secret)
+ return
From e9927dfbabc031668b12ac234c613a18be4498ce Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Thu, 22 Oct 2026 23:56:41 +0530
Subject: [PATCH 06/15] fix: cleanups and save collected fields in db
---
.../versions/088_add_chatbot_session.py | 18 +
.../app/api/docs/chatbot/create_session.md | 2 +-
.../api/docs/chatbot/get_latest_session.md | 3 +
backend/app/api/docs/chatbot/list_sessions.md | 3 -
backend/app/api/docs/chatbot/message.md | 2 +
.../api/docs/chatbot/send_session_message.md | 4 +-
backend/app/api/routes/chatbot/message.py | 14 +-
backend/app/api/routes/chatbot/session.py | 29 +-
backend/app/crud/chatbot.py | 43 +-
backend/app/models/__init__.py | 2 +
backend/app/models/chatbot.py | 51 +-
.../app/services/chatbot/agents/__init__.py | 0
.../chatbot/agents/onboarding/__init__.py | 0
.../chatbot/agents/onboarding/graph.py | 236 +++++++
.../chatbot/agents/onboarding/prompts.py | 25 +
.../chatbot/agents/onboarding/types.py | 29 +
backend/app/services/chatbot/engine.py | 171 -----
backend/app/services/chatbot/history.py | 108 +++
backend/app/services/chatbot/session.py | 22 +-
backend/app/services/chatbot/types.py | 19 +-
.../tests/api/routes/chatbot/test_message.py | 65 ++
.../tests/api/routes/chatbot/test_session.py | 108 ++-
backend/app/tests/crud/test_chatbot.py | 83 ++-
.../tests/services/chatbot/agents/__init__.py | 0
.../chatbot/agents/onboarding/__init__.py | 0
.../chatbot/agents/onboarding/test_graph.py | 614 ++++++++++++++++++
.../app/tests/services/chatbot/test_engine.py | 259 --------
.../tests/services/chatbot/test_session.py | 150 ++++-
backend/app/tests/utils/chatbot.py | 34 +-
docs/wiki/INDEX.md | 2 +-
docs/wiki/modules/chatbot.md | 36 +-
31 files changed, 1626 insertions(+), 506 deletions(-)
create mode 100644 backend/app/api/docs/chatbot/get_latest_session.md
delete mode 100644 backend/app/api/docs/chatbot/list_sessions.md
create mode 100644 backend/app/services/chatbot/agents/__init__.py
create mode 100644 backend/app/services/chatbot/agents/onboarding/__init__.py
create mode 100644 backend/app/services/chatbot/agents/onboarding/graph.py
create mode 100644 backend/app/services/chatbot/agents/onboarding/prompts.py
create mode 100644 backend/app/services/chatbot/agents/onboarding/types.py
delete mode 100644 backend/app/services/chatbot/engine.py
create mode 100644 backend/app/services/chatbot/history.py
create mode 100644 backend/app/tests/services/chatbot/agents/__init__.py
create mode 100644 backend/app/tests/services/chatbot/agents/onboarding/__init__.py
create mode 100644 backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
delete mode 100644 backend/app/tests/services/chatbot/test_engine.py
diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py
index 367a924ce..99859aaf0 100644
--- a/backend/app/alembic/versions/088_add_chatbot_session.py
+++ b/backend/app/alembic/versions/088_add_chatbot_session.py
@@ -25,6 +25,13 @@ def upgrade():
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),
@@ -59,6 +66,17 @@ def upgrade():
nullable=False,
comment="Conversation lifecycle: active or inactive",
),
+ sa.Column(
+ "completion_status",
+ sa.Enum(
+ "in_progress",
+ "complete",
+ name="chatbotcompletionstatusenum",
+ ),
+ nullable=False,
+ server_default="in_progress",
+ comment="Field collection progress: in_progress, or complete once every required field is gathered",
+ ),
sa.Column(
"collected_fields",
postgresql.JSON(astext_type=sa.Text()),
diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md
index 543966e2d..ec34000aa 100644
--- a/backend/app/api/docs/chatbot/create_session.md
+++ b/backend/app/api/docs/chatbot/create_session.md
@@ -2,4 +2,4 @@ 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.
+`name` is the chatbot's display name; omit it to get `"Untitled Chatbot"`. `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.
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..c2ddf0e11
--- /dev/null
+++ b/backend/app/api/docs/chatbot/get_latest_session.md
@@ -0,0 +1,3 @@
+Get the most recently created chatbot session for the given `contact_ref` in the caller's project.
+
+`contact_ref` is matched exactly against the value supplied when the session was created. The response carries the session's current `status` (`active`/`inactive`), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions in this project.
diff --git a/backend/app/api/docs/chatbot/list_sessions.md b/backend/app/api/docs/chatbot/list_sessions.md
deleted file mode 100644
index 202480359..000000000
--- a/backend/app/api/docs/chatbot/list_sessions.md
+++ /dev/null
@@ -1,3 +0,0 @@
-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.
diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md
index 75ee043c9..1e189473f 100644
--- a/backend/app/api/docs/chatbot/message.md
+++ b/backend/app/api/docs/chatbot/message.md
@@ -4,4 +4,6 @@ The chat model comes from a saved Kaapi config: `config_id` must be a config in
There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`.
+Each turn also extracts the fields `system_prompt` asks to collect from the whole conversation: `collected_fields` is a flat `{field_name: value}` map (snake_case keys, only values the user actually gave) and `completion_status` is `complete` once nothing required is missing, else `in_progress`. Both are recomputed from the transcript on every call; the opening greeting returns `{}` and `in_progress`.
+
On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures.
diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md
index 237e0fa97..b4b4d816d 100644
--- a/backend/app/api/docs/chatbot/send_session_message.md
+++ b/backend/app/api/docs/chatbot/send_session_message.md
@@ -1,5 +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", "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.
+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", "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.
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.
diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py
index 9a5368258..c5020da48 100644
--- a/backend/app/api/routes/chatbot/message.py
+++ b/backend/app/api/routes/chatbot/message.py
@@ -3,8 +3,8 @@
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.agents.onboarding.graph import run_chatbot_turn
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
@@ -35,7 +35,7 @@ async def send_chatbot_message(
max_tokens=request.max_tokens,
history_token_limit=request.history_token_limit,
)
- transcript = await run_chatbot_turn(
+ turn_result = await run_chatbot_turn(
system_prompt=request.system_prompt,
history=history,
user_message=request.message,
@@ -47,6 +47,12 @@ async def send_chatbot_message(
raise HTTPException(status_code=502, detail=str(e))
messages: list[ChatbotMessage] = []
- for entry in transcript:
+ for entry in turn_result.transcript:
messages.append(ChatbotMessage.model_validate(entry))
- return APIResponse.success_response(ChatbotTurnResponse(messages=messages))
+ return APIResponse.success_response(
+ ChatbotTurnResponse(
+ messages=messages,
+ collected_fields=turn_result.collected_fields,
+ completion_status=turn_result.completion_status,
+ )
+ )
diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
index 0ea7d60f1..d3316e01b 100644
--- a/backend/app/api/routes/chatbot/session.py
+++ b/backend/app/api/routes/chatbot/session.py
@@ -11,8 +11,8 @@
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
+ get_latest_chatbot_session_by_contact_ref,
list_chatbot_session_messages,
- list_chatbot_sessions_by_contact_ref,
)
from app.models import (
CONTACT_REF_MAX_LENGTH,
@@ -61,6 +61,7 @@ def start_chatbot_session(
session,
organization_id=auth_context.organization_.id,
project_id=project_id,
+ name=request.name,
contact_ref=request.contact_ref,
config_id=request.config_id,
config_version=request.config_version,
@@ -73,42 +74,46 @@ def start_chatbot_session(
@router.get(
"/sessions",
- description=load_description("chatbot/list_sessions.md"),
- response_model=APIResponse[list[ChatbotSessionSummary]],
+ description=load_description("chatbot/get_latest_session.md"),
+ response_model=APIResponse[ChatbotSessionSummary],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
-def list_chatbot_sessions(
+def get_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 sessions to list",
+ description="Caller-side identifier of the end user whose latest session to fetch",
),
],
session: SessionDep,
auth_context: AuthContextDep,
-) -> APIResponse[list[ChatbotSessionSummary]]:
- chatbot_sessions = list_chatbot_sessions_by_contact_ref(
+) -> APIResponse[ChatbotSessionSummary]:
+ chatbot_session = get_latest_chatbot_session_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(
+ if chatbot_session is None:
+ raise HTTPException(
+ status_code=404, detail="No chatbot session found for this contact_ref"
+ )
+ return APIResponse.success_response(
+ ChatbotSessionSummary(
session_id=chatbot_session.id,
+ name=chatbot_session.name,
contact_ref=chatbot_session.contact_ref,
status=chatbot_session.status,
+ completion_status=chatbot_session.completion_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(
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
index 31bb9e5af..e0b1fda75 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -1,20 +1,26 @@
+import logging
+from typing import Any
from uuid import UUID
from sqlmodel import Session, col, select
from app.core.util import now
from app.models import (
+ ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
ChatbotSessionMessage,
)
+logger = logging.getLogger(__name__)
+
def create_chatbot_session(
session: Session,
*,
organization_id: int,
project_id: int,
+ name: str,
contact_ref: str,
config_id: UUID,
config_version: int | None,
@@ -24,6 +30,7 @@ def create_chatbot_session(
chatbot_session = ChatbotSession(
organization_id=organization_id,
project_id=project_id,
+ name=name,
contact_ref=contact_ref,
config_id=config_id,
config_version=config_version,
@@ -52,6 +59,35 @@ def get_chatbot_session(
return session.exec(statement).first()
+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)
+ logger.info(
+ f"[update_chatbot_session_progress] Session progress saved | "
+ f"session_id: {session_id}, completion_status: {completion_status.value}, "
+ f"field_count: {len(collected_fields)}"
+ )
+ return chatbot_session
+
+
def create_chatbot_session_message(
session: Session,
*,
@@ -82,13 +118,13 @@ def list_chatbot_session_messages(
return list(session.exec(statement).all())
-def list_chatbot_sessions_by_contact_ref(
+def get_latest_chatbot_session_by_contact_ref(
session: Session,
*,
contact_ref: str,
organization_id: int,
project_id: int,
-) -> list[ChatbotSession]:
+) -> ChatbotSession | None:
statement = (
select(ChatbotSession)
.where(
@@ -97,5 +133,6 @@ def list_chatbot_sessions_by_contact_ref(
ChatbotSession.project_id == project_id,
)
.order_by(col(ChatbotSession.inserted_at).desc())
+ .limit(1)
)
- return list(session.exec(statement).all())
+ return session.exec(statement).first()
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index dc7f2e3be..2c1cbcfc9 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -37,6 +37,8 @@
)
from .chatbot import (
CONTACT_REF_MAX_LENGTH,
+ DEFAULT_CHATBOT_NAME,
+ ChatbotCompletionStatusEnum,
ChatbotMessage,
ChatbotMessageRoleEnum,
ChatbotSession,
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
index a5cfe381b..7114c9fcc 100644
--- a/backend/app/models/chatbot.py
+++ b/backend/app/models/chatbot.py
@@ -55,15 +55,26 @@ class ChatbotTurnRequest(SQLModel):
)
+class ChatbotSessionStatusEnum(StrEnum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+
+
+class ChatbotCompletionStatusEnum(StrEnum):
+ IN_PROGRESS = "in_progress"
+ COMPLETE = "complete"
+
+
class ChatbotTurnResponse(SQLModel):
messages: list[ChatbotMessage] = Field(
description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.",
)
-
-
-class ChatbotSessionStatusEnum(StrEnum):
- ACTIVE = "active"
- INACTIVE = "inactive"
+ collected_fields: dict[str, Any] = Field(
+ description="Flat map of field name -> value the user has provided so far, as extracted from the transcript.",
+ )
+ completion_status: ChatbotCompletionStatusEnum = Field(
+ description="`complete` once every field the system prompt asks for has been collected, else `in_progress`.",
+ )
_SESSION_STATUS_PG_ENUM = ENUM(
@@ -72,6 +83,12 @@ class ChatbotSessionStatusEnum(StrEnum):
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",
@@ -80,6 +97,8 @@ class ChatbotSessionStatusEnum(StrEnum):
)
CONTACT_REF_MAX_LENGTH = 500
+CHATBOT_NAME_MAX_LENGTH = 255
+DEFAULT_CHATBOT_NAME = "Untitled Chatbot"
class ChatbotSession(SQLModel, table=True):
@@ -90,6 +109,11 @@ class ChatbotSession(SQLModel, table=True):
primary_key=True,
sa_column_kwargs={"comment": "Session identifier returned to the caller"},
)
+ name: str = Field(
+ default=DEFAULT_CHATBOT_NAME,
+ max_length=CHATBOT_NAME_MAX_LENGTH,
+ sa_column_kwargs={"comment": "Display name of the chatbot this session runs"},
+ )
contact_ref: str = Field(
max_length=CONTACT_REF_MAX_LENGTH,
sa_column_kwargs={
@@ -125,6 +149,15 @@ class ChatbotSession(SQLModel, table=True):
comment="Conversation lifecycle: active or inactive",
),
)
+ 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, or complete once every required field is gathered",
+ ),
+ )
collected_fields: dict[str, Any] = Field(
default_factory=dict,
sa_column=Column(
@@ -197,6 +230,12 @@ class ChatbotSessionMessage(SQLModel, table=True):
class CreateChatbotSessionRequest(SQLModel):
+ name: str = Field(
+ default=DEFAULT_CHATBOT_NAME,
+ min_length=1,
+ max_length=CHATBOT_NAME_MAX_LENGTH,
+ description=f"Display name of the chatbot; defaults to '{DEFAULT_CHATBOT_NAME}'.",
+ )
config_id: UUID = Field(
description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.",
)
@@ -222,8 +261,10 @@ class CreateChatbotSessionResponse(SQLModel):
class ChatbotSessionSummary(SQLModel):
session_id: UUID
+ name: str
contact_ref: str
status: ChatbotSessionStatusEnum
+ completion_status: ChatbotCompletionStatusEnum
collected_fields: dict[str, Any]
config_id: UUID
config_version: int | None
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..c96e10b15
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/graph.py
@@ -0,0 +1,236 @@
+"""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 (
+ has_user_turn,
+ insert_after_leading_system,
+ summarize_if_needed,
+ to_langchain_messages,
+ to_message_dicts,
+)
+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,
+ ),
+ )
+ merged_fields = {**prior_fields, **extraction.collected_fields}
+ 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)
+
+
+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.
+
+ `user_message` may be None only on the very first call (empty `history`),
+ which yields the bot's opening greeting. `collected_fields` is the map from
+ earlier turns; it is merged with (and corrected by) this turn's extraction.
+ """
+ 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.APITimeoutError as e:
+ raise ChatbotProviderError(
+ "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
+ "(code: APITimeoutError)"
+ ) from e
+ except anthropic.APIConnectionError as e:
+ raise ChatbotProviderError(
+ "[KAAPI] Network/DNS issue reaching provider — check connectivity. "
+ "If persistent, contact Kaapi. (code: APIConnectionError)"
+ ) from e
+ except anthropic.APIStatusError as e:
+ raise ChatbotProviderError(
+ f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, "
+ f"request_id: {e.request_id})"
+ ) from e
+ except ValueError as e:
+ error_message = (
+ f"[KAAPI] Unsupported model configuration for '{model_settings.model}': "
+ f"{e} — update the config's model, thinking, or effort. (code: ValueError)"
+ )
+ raise ChatbotProviderError(error_message) from e
+
+ transcript = to_message_dicts(
+ system_prompt=system_prompt, messages=result["messages"]
+ )
+ return ChatbotTurnResult(
+ transcript=transcript,
+ 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..698659741
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/prompts.py
@@ -0,0 +1,25 @@
+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. Never invent, guess, or infer a value the user did not give.\n"
+ "4. missing_fields: the required fields the user has not answered yet.\n"
+ "5. 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..045646d43
--- /dev/null
+++ b/backend/app/services/chatbot/agents/onboarding/types.py
@@ -0,0 +1,29 @@
+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 FieldExtraction(BaseModel):
+ """Fields the admin's flow prompt asks for, as answered so far by the user."""
+
+ collected_fields: dict[str, str] = Field(
+ default_factory=dict,
+ description="snake_case field name -> value the user clearly provided; never guessed.",
+ )
+ 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/engine.py b/backend/app/services/chatbot/engine.py
deleted file mode 100644
index 17aeb6ca2..000000000
--- a/backend/app/services/chatbot/engine.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model."""
-
-import asyncio
-
-import anthropic
-from langchain_core.language_models.chat_models import BaseChatModel
-from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
-from langgraph.graph import END, START, StateGraph
-from langgraph.graph.state import CompiledStateGraph
-
-from app.models.chatbot import ChatbotMessageRoleEnum
-from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
-from app.services.chatbot.types import ChatbotMessageDict, ChatbotState
-from app.services.chatbot.utils import (
- FLOW_KEEP_RECENT_MESSAGES,
- ChatbotModelSettings,
- get_chat_model,
-)
-
-CHATBOT_NODE = "chatbot_node"
-
-_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."
-)
-
-_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
-
- summary = chat_model.invoke(
- [SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize]
- )
- summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}")
- return [system_message, summary_note, *recent]
-
-
-def build_chatbot_graph(
- chat_model: BaseChatModel, *, history_token_limit: int
-) -> CompiledStateGraph:
- """One node, no branching -- `chat_model` is fixed for the graph's lifetime."""
-
- def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]:
- messages = _summarize_if_needed(
- chat_model, state["messages"], history_token_limit=history_token_limit
- )
- response = chat_model.invoke(messages)
- return {"messages": [response]}
-
- graph = StateGraph(ChatbotState)
- graph.add_node(CHATBOT_NODE, chatbot_node)
- graph.add_edge(START, CHATBOT_NODE)
- graph.add_edge(CHATBOT_NODE, END)
- return graph.compile()
-
-
-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 _to_message_dicts(
- *, system_prompt: str, messages: list[BaseMessage]
-) -> list[ChatbotMessageDict]:
- transcript: list[ChatbotMessageDict] = [
- {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt}
- ]
- for message in messages:
- if message.id == _KICKOFF_MESSAGE_ID:
- continue
- if isinstance(message, HumanMessage):
- role = ChatbotMessageRoleEnum.USER
- elif isinstance(message, AIMessage):
- role = ChatbotMessageRoleEnum.ASSISTANT
- else:
- continue
- transcript.append({"role": role.value, "content": message.text})
- return transcript
-
-
-async def run_chatbot_turn(
- *,
- system_prompt: str,
- history: list[ChatbotMessageDict],
- user_message: str | None,
- model_settings: ChatbotModelSettings,
-) -> list[ChatbotMessageDict]:
- """Run one stateless turn and return the full updated transcript.
-
- `user_message` may be None only on the very first call (empty `history`),
- which yields the bot's opening greeting.
- """
- 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
- )
-
- graph = build_chatbot_graph(
- get_chat_model(model_settings),
- history_token_limit=model_settings.history_token_limit,
- )
- try:
- # ChatAnthropic.invoke is sync; keep it off the event loop.
- result = await asyncio.to_thread(graph.invoke, {"messages": messages})
- except anthropic.APITimeoutError as e:
- raise ChatbotProviderError(
- "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
- "(code: APITimeoutError)"
- ) from e
- except anthropic.APIConnectionError as e:
- raise ChatbotProviderError(
- "[KAAPI] Network/DNS issue reaching provider — check connectivity. "
- "If persistent, contact Kaapi. (code: APIConnectionError)"
- ) from e
- except anthropic.APIStatusError as e:
- raise ChatbotProviderError(
- f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, "
- f"request_id: {e.request_id})"
- ) from e
-
- transcript = _to_message_dicts(
- system_prompt=system_prompt, messages=result["messages"]
- )
- return transcript
diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py
new file mode 100644
index 000000000..4efbdbe1e
--- /dev/null
+++ b/backend/app/services/chatbot/history.py
@@ -0,0 +1,108 @@
+"""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
+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."
+)
+
+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
+
+ summary = chat_model.invoke(
+ [SystemMessage(content=SUMMARIZE_INSTRUCTION), *to_summarize]
+ )
+ summary_note = SystemMessage(content=f"{SUMMARY_NOTE_PREFIX}{summary.text}")
+ return [system_message, summary_note, *recent]
+
+
+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 to_message_dicts(
+ *, system_prompt: str, messages: list[BaseMessage]
+) -> list[ChatbotMessageDict]:
+ transcript: list[ChatbotMessageDict] = [
+ {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt}
+ ]
+ for message in messages:
+ if message.id == KICKOFF_MESSAGE_ID:
+ continue
+ if isinstance(message, HumanMessage):
+ role = ChatbotMessageRoleEnum.USER
+ elif isinstance(message, AIMessage):
+ role = ChatbotMessageRoleEnum.ASSISTANT
+ else:
+ continue
+ transcript.append({"role": role.value, "content": message.text})
+ return transcript
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
index 279840256..496e08e97 100644
--- a/backend/app/services/chatbot/session.py
+++ b/backend/app/services/chatbot/session.py
@@ -10,10 +10,11 @@
create_chatbot_session_message,
get_chatbot_session,
list_chatbot_session_messages,
+ update_chatbot_session_progress,
)
from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage
+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.engine import run_chatbot_turn
from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
from app.services.chatbot.types import ChatbotMessageDict
from app.services.chatbot.utils import ChatbotModelSettings
@@ -26,7 +27,7 @@ def _split_pending_turn(
"""Split the stored transcript into (prior history, pending user message).
The route persists the user's message before enqueueing, so it is the last
- stored row; the engine wants it separately from the history before it.
+ stored row; run_chatbot_turn wants it separately from the history before it.
"""
history: list[ChatbotMessageDict] = []
for stored in stored_messages:
@@ -38,7 +39,7 @@ def _split_pending_turn(
if not history:
return history, None
if history[-1]["role"] != ChatbotMessageRoleEnum.USER:
- # No pending user turn; the engine rejects this with ChatbotInputError.
+ # No pending user turn; run_chatbot_turn rejects this with ChatbotInputError.
return history, None
pending = history.pop()
return history, pending["content"]
@@ -129,15 +130,16 @@ def process_chatbot_session_message(
history, user_message = _split_pending_turn(stored_messages)
try:
- transcript = asyncio.run(
+ turn_result = asyncio.run(
run_chatbot_turn(
system_prompt=system_prompt,
history=history,
user_message=user_message,
model_settings=model_settings,
+ collected_fields=collected_fields,
)
)
- reply = _extract_reply(transcript)
+ reply = _extract_reply(turn_result.transcript)
except (ChatbotInputError, ChatbotProviderError) as e:
_send_failure_webhook(
session_id=session_id,
@@ -147,6 +149,7 @@ def process_chatbot_session_message(
)
return
+ completion_status = turn_result.completion_status
with Session(engine) as session:
create_chatbot_session_message(
session,
@@ -154,6 +157,12 @@ def process_chatbot_session_message(
role=ChatbotMessageRoleEnum.ASSISTANT,
content=reply,
)
+ update_chatbot_session_progress(
+ session,
+ session_id=session_id,
+ collected_fields=turn_result.collected_fields,
+ completion_status=completion_status,
+ )
payload: dict[str, Any] = {
"success": True,
@@ -161,7 +170,8 @@ def process_chatbot_session_message(
"session_id": str(session_id),
"reply": reply,
"status": session_status,
- "collected_fields": collected_fields,
+ "completion_status": completion_status.value,
+ "collected_fields": turn_result.collected_fields,
},
}
send_callback(webhook_url, payload, webhook_secret)
diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py
index 7746f215d..2333353e2 100644
--- a/backend/app/services/chatbot/types.py
+++ b/backend/app/services/chatbot/types.py
@@ -1,10 +1,19 @@
-from typing import Annotated, TypedDict
+from dataclasses import dataclass
+from typing import Any
-from langchain_core.messages import BaseMessage
-from langgraph.graph.message import add_messages
+from app.models.chatbot import ChatbotCompletionStatusEnum
ChatbotMessageDict = dict[str, str]
-class ChatbotState(TypedDict):
- messages: Annotated[list[BaseMessage], add_messages]
+@dataclass(frozen=True)
+class ChatbotTurnResult:
+ transcript: list[ChatbotMessageDict]
+ 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/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py
index 5f2be7f19..66ebfc95f 100644
--- a/backend/app/tests/api/routes/chatbot/test_message.py
+++ b/backend/app/tests/api/routes/chatbot/test_message.py
@@ -6,6 +6,7 @@
from app.core.config import settings
from app.models import Config
+from app.services.chatbot.agents.onboarding.types import FieldExtraction
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
from app.tests.utils.test_data import (
@@ -58,6 +59,70 @@ def test_returns_updated_transcript(
{"role": "assistant", "content": "Nice, which city?"},
]
+ def test_returns_extracted_fields_and_completion_status(
+ self,
+ client: TestClient,
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch,
+ ScriptedChatModel(
+ replies=["Thanks, Asha from Pune!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"name": "Asha", "city": "Pune"},
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ ),
+ )
+
+ resp = client.post(
+ MESSAGE_URL,
+ headers={"X-API-KEY": user_api_key.key},
+ json={
+ "config_id": str(anthropic_config.id),
+ "system_prompt": SYSTEM_PROMPT,
+ "history": [
+ {"role": "assistant", "content": "Hi! What's your name and city?"}
+ ],
+ "message": "Asha, Pune",
+ },
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["collected_fields"] == {"name": "Asha", "city": "Pune"}
+ assert body["completion_status"] == "complete"
+
+ def test_greeting_returns_no_fields_in_progress(
+ self,
+ client: TestClient,
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ patch_chat_model(
+ monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
+ )
+
+ resp = client.post(
+ MESSAGE_URL,
+ headers={"X-API-KEY": user_api_key.key},
+ json={
+ "config_id": str(anthropic_config.id),
+ "system_prompt": SYSTEM_PROMPT,
+ },
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["collected_fields"] == {}
+ assert body["completion_status"] == "in_progress"
+
def test_missing_message_with_history_returns_422(
self,
client: TestClient,
diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py
index 128ede74f..6dde33189 100644
--- a/backend/app/tests/api/routes/chatbot/test_session.py
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -8,8 +8,18 @@
from app.api.routes.chatbot import session as session_routes
from app.core.config import settings
-from app.crud.chatbot import get_chatbot_session, list_chatbot_session_messages
-from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config
+from app.crud.chatbot import (
+ get_chatbot_session,
+ list_chatbot_session_messages,
+ update_chatbot_session_progress,
+)
+from app.models import (
+ DEFAULT_CHATBOT_NAME,
+ ChatbotCompletionStatusEnum,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ Config,
+)
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.chatbot import (
add_test_chatbot_messages,
@@ -85,6 +95,7 @@ def test_creates_session_in_callers_project(
SESSIONS_URL,
headers=headers,
json={
+ "name": "Onboarding Bot",
"config_id": str(anthropic_config.id),
"config_version": 1,
"contact_ref": contact_ref,
@@ -101,11 +112,40 @@ def test_creates_session_in_callers_project(
project_id=user_api_key.project_id,
)
assert stored is not None
+ assert stored.name == "Onboarding Bot"
assert stored.contact_ref == contact_ref
assert stored.config_id == anthropic_config.id
assert stored.config_version == 1
assert stored.system_prompt == SYSTEM_PROMPT
+ def test_omitted_name_uses_default(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ user_api_key: TestAuthContext,
+ anthropic_config: Config,
+ ) -> None:
+ resp = client.post(
+ SESSIONS_URL,
+ headers=headers,
+ json={
+ "config_id": str(anthropic_config.id),
+ "contact_ref": f"contact-{random_lower_string()}",
+ "system_prompt": SYSTEM_PROMPT,
+ },
+ )
+
+ assert resp.status_code == 201
+ stored = get_chatbot_session(
+ db,
+ session_id=resp.json()["data"]["session_id"],
+ organization_id=user_api_key.organization_id,
+ project_id=user_api_key.project_id,
+ )
+ assert stored is not None
+ assert stored.name == DEFAULT_CHATBOT_NAME
+
def test_unknown_config_returns_404(
self, client: TestClient, headers: dict[str, str]
) -> None:
@@ -146,8 +186,8 @@ def test_non_anthropic_config_returns_422(
assert "only supports configs" in resp.json()["error"]
-class TestListChatbotSessions:
- def test_lists_contacts_sessions_newest_first_in_callers_project(
+class TestGetLatestChatbotSession:
+ def test_returns_contacts_newest_session_in_callers_project(
self,
client: TestClient,
db: Session,
@@ -155,11 +195,15 @@ def test_lists_contacts_sessions_newest_first_in_callers_project(
anthropic_config: Config,
) -> None:
contact_ref = f"contact-{random_lower_string()}"
- older = create_test_chatbot_session(
+ create_test_chatbot_session(
db, config=anthropic_config, contact_ref=contact_ref
)
newer = create_test_chatbot_session(
- db, config=anthropic_config, contact_ref=contact_ref, config_version=1
+ db,
+ config=anthropic_config,
+ name="Onboarding Bot",
+ contact_ref=contact_ref,
+ config_version=1,
)
create_test_chatbot_session(db, config=anthropic_config)
foreign_config = create_test_config(
@@ -173,13 +217,51 @@ def test_lists_contacts_sessions_newest_first_in_callers_project(
assert resp.status_code == 200
body = resp.json()["data"]
- assert [s["session_id"] for s in body] == [str(newer.id), str(older.id)]
- assert body[0]["contact_ref"] == contact_ref
- assert body[0]["status"] == "active"
- assert body[0]["collected_fields"] == {}
- assert body[0]["config_id"] == str(anthropic_config.id)
- assert body[0]["config_version"] == 1
- assert body[1]["config_version"] is None
+ assert body["session_id"] == str(newer.id)
+ assert body["name"] == "Onboarding Bot"
+ assert body["contact_ref"] == contact_ref
+ assert body["status"] == "active"
+ assert body["completion_status"] == "in_progress"
+ assert body["collected_fields"] == {}
+ assert body["config_id"] == str(anthropic_config.id)
+ assert body["config_version"] == 1
+
+ def test_reports_stored_field_progress(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ ) -> None:
+ update_chatbot_session_progress(
+ db,
+ session_id=chatbot_session.id,
+ collected_fields={"name": "Asha", "city": "Pune"},
+ completion_status=ChatbotCompletionStatusEnum.COMPLETE,
+ )
+
+ resp = client.get(
+ SESSIONS_URL,
+ headers=headers,
+ params={"contact_ref": chatbot_session.contact_ref},
+ )
+
+ 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_unknown_contact_returns_404(
+ self, client: TestClient, headers: dict[str, str]
+ ) -> None:
+ resp = client.get(
+ SESSIONS_URL,
+ headers=headers,
+ params={"contact_ref": f"contact-{random_lower_string()}"},
+ )
+
+ assert resp.status_code == 404
def test_missing_contact_ref_returns_422(
self, client: TestClient, headers: dict[str, str]
diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py
index 54d276eeb..b3bdb8fcd 100644
--- a/backend/app/tests/crud/test_chatbot.py
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -7,10 +7,12 @@
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
+ get_latest_chatbot_session_by_contact_ref,
list_chatbot_session_messages,
- list_chatbot_sessions_by_contact_ref,
+ update_chatbot_session_progress,
)
from app.models import (
+ ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
ChatbotSessionStatusEnum,
@@ -51,6 +53,7 @@ def test_persists_session_as_active_with_no_collected_fields(
db,
organization_id=project.organization_id,
project_id=project.id,
+ name="Onboarding Bot",
contact_ref=contact_ref,
config_id=config.id,
config_version=1,
@@ -60,12 +63,14 @@ def test_persists_session_as_active_with_no_collected_fields(
db.expire_all()
stored = db.get(ChatbotSession, created.id)
assert stored is not None
+ assert stored.name == "Onboarding Bot"
assert stored.contact_ref == contact_ref
assert stored.config_id == config.id
assert stored.config_version == 1
assert stored.system_prompt == "Collect the user's name."
assert stored.status == ChatbotSessionStatusEnum.ACTIVE
assert stored.collected_fields == {}
+ assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS
assert stored.inserted_at == stored.updated_at
@@ -179,12 +184,12 @@ def test_list_empty_session_returns_empty(
assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
-class TestListChatbotSessionsByContactRef:
- def test_returns_newest_first_scoped_to_contact_and_project(
+class TestGetLatestChatbotSessionByContactRef:
+ def test_returns_newest_scoped_to_contact_and_project(
self, db: Session, config: Config, project: Project
) -> None:
contact_ref = f"contact-{random_lower_string()}"
- oldest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
+ create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
create_test_chatbot_session(db, config=config)
other_config = create_test_config(
@@ -192,26 +197,86 @@ def test_returns_newest_first_scoped_to_contact_and_project(
)
create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref)
- sessions = list_chatbot_sessions_by_contact_ref(
+ latest = get_latest_chatbot_session_by_contact_ref(
db,
contact_ref=contact_ref,
organization_id=project.organization_id,
project_id=project.id,
)
- assert [s.id for s in sessions] == [newest.id, oldest.id]
+ assert latest is not None
+ assert latest.id == newest.id
- def test_unknown_contact_returns_empty(
+ def test_unknown_contact_returns_none(
self, db: Session, config: Config, project: Project
) -> None:
create_test_chatbot_session(db, config=config)
assert (
- list_chatbot_sessions_by_contact_ref(
+ get_latest_chatbot_session_by_contact_ref(
db,
contact_ref=f"contact-{random_lower_string()}",
organization_id=project.organization_id,
project_id=project.id,
)
- == []
+ is None
+ )
+
+
+class TestUpdateChatbotSessionProgress:
+ def test_persists_fields_and_status_and_bumps_updated_at(
+ self, db: Session, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ 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
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ 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, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ 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,
+ )
+
+ db.expire_all()
+ stored = db.get(ChatbotSession, chatbot_session.id)
+ assert stored is not None
+ 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
)
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..c5d932438
--- /dev/null
+++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
@@ -0,0 +1,614 @@
+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.types import FieldExtraction
+from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
+from app.services.chatbot.utils import ChatbotModelSettings
+from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
+
+SYSTEM_PROMPT = "Collect the user's name and city."
+SUMMARY_TEXT = "User is Asha from Pune."
+PROGRESS_PREFIX = "[Field collection progress] "
+COLLECTED_PREFIX = "Already collected (do not ask for these again): "
+
+_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
+
+
+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
+
+
+def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings:
+ return ChatbotModelSettings(
+ model="claude-sonnet-4-6", history_token_limit=history_token_limit
+ )
+
+
+class TestRunChatbotTurn:
+ @pytest.mark.asyncio
+ async def test_first_call_returns_greeting_without_kickoff(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Hi! What's your name?"])
+ patch_chat_model(monkeypatch, chat_model)
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message=None,
+ model_settings=_model_settings(),
+ )
+
+ assert result.transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "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 = ScriptedChatModel(replies=["Nice to meet you, Asha."])
+ patch_chat_model(monkeypatch, chat_model)
+ history = [
+ {"role": "system", "content": "stale prompt from the previous call"},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ ]
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=history,
+ user_message="Asha",
+ model_settings=_model_settings(),
+ )
+
+ assert result.transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "assistant", "content": "Hi! What's your name?"},
+ {"role": "user", "content": "Asha"},
+ {"role": "assistant", "content": "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_missing_message_with_history_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel()
+ patch_chat_model(monkeypatch, chat_model)
+
+ with pytest.raises(ChatbotInputError, match="`message` is required"):
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Hi!"}],
+ user_message=None,
+ model_settings=_model_settings(),
+ )
+ assert chat_model.calls == []
+
+ @pytest.mark.asyncio
+ async def test_unsupported_role_rejected(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel()
+ patch_chat_model(monkeypatch, chat_model)
+
+ with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"):
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "tool", "content": "{}"}],
+ user_message="hello",
+ model_settings=_model_settings(),
+ )
+ 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_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="hello",
+ model_settings=_model_settings(),
+ )
+
+ 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 = ScriptedChatModel(
+ replies=[SUMMARY_TEXT, "Got it."], token_count=5000
+ )
+ patch_chat_model(monkeypatch, chat_model)
+ history = _alternating_history(turns=4)
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=history,
+ user_message="user 4",
+ model_settings=_model_settings(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.
+ assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"]
+ 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",
+ ]
+ # Summarization only trims the model's view; the caller keeps the full transcript.
+ assert result.transcript == [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ *history,
+ {"role": "user", "content": "user 4"},
+ {"role": "assistant", "content": "Got it."},
+ ]
+
+ @pytest.mark.asyncio
+ async def test_long_history_within_budget_is_sent_whole(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ chat_model = ScriptedChatModel(replies=["Got it."], token_count=100)
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=_alternating_history(turns=4),
+ user_message="user 4",
+ model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=_alternating_history(turns=3)[:5],
+ user_message="more",
+ model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
+ patch_chat_model(monkeypatch, chat_model)
+ # 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_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=assistant_only,
+ user_message="hello",
+ model_settings=_model_settings(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 = ScriptedChatModel(
+ replies=[SUMMARY_TEXT, "Got it."],
+ token_count=5000,
+ extractions=[
+ FieldExtraction(
+ collected_fields={"name": "Asha"},
+ missing_fields=["city"],
+ is_complete=False,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=_alternating_history(turns=4),
+ user_message="user 4",
+ model_settings=_model_settings(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 [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"]
+ 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 = ScriptedChatModel(
+ replies=["Hi! What's your name?"],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"name": "invented"},
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message=None,
+ model_settings=_model_settings(),
+ 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 = ScriptedChatModel(replies=["Which city?"])
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="I'm Asha",
+ model_settings=_model_settings(),
+ )
+
+ # 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 = ScriptedChatModel(replies=["Which city?"])
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Hi! What's your name?"}],
+ user_message="I'm Asha",
+ model_settings=_model_settings(),
+ collected_fields={"age": "30"},
+ )
+
+ [extraction_call] = chat_model.extraction_calls
+ instruction, *conversation = extraction_call
+ assert isinstance(instruction, SystemMessage)
+ 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:
+ chat_model = ScriptedChatModel(
+ replies=["Noted."],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"city": "Mumbai", "age": "30"},
+ missing_fields=[],
+ is_complete=False,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Anything to change?"}],
+ user_message="Actually I live in Mumbai now, and I'm 30",
+ model_settings=_model_settings(),
+ 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:
+ chat_model = ScriptedChatModel(
+ replies=["Thanks!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"name": "Asha"},
+ missing_fields=missing_fields,
+ is_complete=is_complete,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ result = await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="I'm Asha",
+ model_settings=_model_settings(),
+ )
+
+ assert result.completion_status == expected_status
+
+
+class TestProgressNote:
+ @pytest.mark.parametrize(
+ ("extraction", "expected_note"),
+ [
+ (
+ FieldExtraction(
+ collected_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={},
+ missing_fields=["name", "city"],
+ is_complete=False,
+ ),
+ "Still missing: name, city. Ask only for the missing fields.",
+ ),
+ (
+ FieldExtraction(
+ collected_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 = ScriptedChatModel(replies=["Reply."], extractions=[extraction])
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Hi! What's your name?"}],
+ user_message="I'm Asha from Pune",
+ model_settings=_model_settings(),
+ )
+
+ [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 = ScriptedChatModel(
+ replies=["Reply."],
+ extractions=[
+ FieldExtraction(
+ collected_fields={}, missing_fields=[], is_complete=False
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="hello",
+ model_settings=_model_settings(),
+ )
+
+ [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 = ScriptedChatModel(replies=["Which city?"])
+ patch_chat_model(monkeypatch, chat_model)
+
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[{"role": "assistant", "content": "Which city?"}],
+ user_message="hmm",
+ model_settings=_model_settings(),
+ collected_fields={"name": "Asha"},
+ )
+
+ [reply_call] = chat_model.calls
+ assert reply_call[1].content == (
+ f'{PROGRESS_PREFIX}{COLLECTED_PREFIX}{{"name": "Asha"}}.'
+ )
+
+
+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 = ScriptedChatModel(
+ replies=["should never be used"], extractions=[extraction_outcome]
+ )
+ patch_chat_model(monkeypatch, chat_model)
+
+ with pytest.raises(ChatbotProviderError) as exc_info:
+ await run_chatbot_turn(
+ system_prompt=SYSTEM_PROMPT,
+ history=[],
+ user_message="I'm Asha",
+ model_settings=_model_settings(),
+ )
+
+ assert expected_message in str(exc_info.value)
+ assert chat_model.calls == []
diff --git a/backend/app/tests/services/chatbot/test_engine.py b/backend/app/tests/services/chatbot/test_engine.py
deleted file mode 100644
index 610c6a49d..000000000
--- a/backend/app/tests/services/chatbot/test_engine.py
+++ /dev/null
@@ -1,259 +0,0 @@
-import anthropic
-import httpx
-import pytest
-from langchain_core.messages import HumanMessage, SystemMessage
-
-from app.services.chatbot.engine import run_chatbot_turn
-from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
-from app.services.chatbot.utils import ChatbotModelSettings
-from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
-
-SYSTEM_PROMPT = "Collect the user's name and city."
-SUMMARY_TEXT = "User is Asha from Pune."
-
-_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
-
-
-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
-
-
-def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings:
- return ChatbotModelSettings(
- model="claude-sonnet-4-6", history_token_limit=history_token_limit
- )
-
-
-class TestRunChatbotTurn:
- @pytest.mark.asyncio
- async def test_first_call_returns_greeting_without_kickoff(
- self, monkeypatch: pytest.MonkeyPatch
- ) -> None:
- chat_model = ScriptedChatModel(replies=["Hi! What's your name?"])
- patch_chat_model(monkeypatch, chat_model)
-
- transcript = await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=[],
- user_message=None,
- model_settings=_model_settings(),
- )
-
- assert transcript == [
- {"role": "system", "content": SYSTEM_PROMPT},
- {"role": "assistant", "content": "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 = ScriptedChatModel(replies=["Nice to meet you, Asha."])
- patch_chat_model(monkeypatch, chat_model)
- history = [
- {"role": "system", "content": "stale prompt from the previous call"},
- {"role": "assistant", "content": "Hi! What's your name?"},
- ]
-
- transcript = await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=history,
- user_message="Asha",
- model_settings=_model_settings(),
- )
-
- assert transcript == [
- {"role": "system", "content": SYSTEM_PROMPT},
- {"role": "assistant", "content": "Hi! What's your name?"},
- {"role": "user", "content": "Asha"},
- {"role": "assistant", "content": "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_missing_message_with_history_rejected(
- self, monkeypatch: pytest.MonkeyPatch
- ) -> None:
- chat_model = ScriptedChatModel()
- patch_chat_model(monkeypatch, chat_model)
-
- with pytest.raises(ChatbotInputError, match="`message` is required"):
- await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=[{"role": "assistant", "content": "Hi!"}],
- user_message=None,
- model_settings=_model_settings(),
- )
- assert chat_model.calls == []
-
- @pytest.mark.asyncio
- async def test_unsupported_role_rejected(
- self, monkeypatch: pytest.MonkeyPatch
- ) -> None:
- chat_model = ScriptedChatModel()
- patch_chat_model(monkeypatch, chat_model)
-
- with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"):
- await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=[{"role": "tool", "content": "{}"}],
- user_message="hello",
- model_settings=_model_settings(),
- )
- 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)",
- ),
- (
- 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", "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_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=[],
- user_message="hello",
- model_settings=_model_settings(),
- )
-
- 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 = ScriptedChatModel(
- replies=[SUMMARY_TEXT, "Got it."], token_count=5000
- )
- patch_chat_model(monkeypatch, chat_model)
- history = _alternating_history(turns=4)
-
- transcript = await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=history,
- user_message="user 4",
- model_settings=_model_settings(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.
- assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"]
- 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",
- ]
- # Summarization only trims the model's view; the caller keeps the full transcript.
- assert transcript == [
- {"role": "system", "content": SYSTEM_PROMPT},
- *history,
- {"role": "user", "content": "user 4"},
- {"role": "assistant", "content": "Got it."},
- ]
-
- @pytest.mark.asyncio
- async def test_long_history_within_budget_is_sent_whole(
- self, monkeypatch: pytest.MonkeyPatch
- ) -> None:
- chat_model = ScriptedChatModel(replies=["Got it."], token_count=100)
- patch_chat_model(monkeypatch, chat_model)
-
- await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=_alternating_history(turns=4),
- user_message="user 4",
- model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
- patch_chat_model(monkeypatch, chat_model)
-
- await run_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=_alternating_history(turns=3)[:5],
- user_message="more",
- model_settings=_model_settings(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 = ScriptedChatModel(replies=["Got it."], token_count=10_000)
- patch_chat_model(monkeypatch, chat_model)
- # 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_chatbot_turn(
- system_prompt=SYSTEM_PROMPT,
- history=assistant_only,
- user_message="hello",
- model_settings=_model_settings(history_token_limit=10),
- )
-
- assert len(chat_model.calls) == 1
- assert len(chat_model.calls[0]) == 10
diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py
index 22ad3b93e..6a5204607 100644
--- a/backend/app/tests/services/chatbot/test_session.py
+++ b/backend/app/tests/services/chatbot/test_session.py
@@ -8,10 +8,20 @@
import pytest
from sqlmodel import Session
-from app.crud.chatbot import list_chatbot_session_messages
+from app.crud.chatbot import (
+ list_chatbot_session_messages,
+ update_chatbot_session_progress,
+)
from app.crud.config import ConfigVersionCrud
-from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config, Project
+from app.models import (
+ ChatbotCompletionStatusEnum,
+ ChatbotMessageRoleEnum,
+ ChatbotSession,
+ Config,
+ Project,
+)
from app.services.chatbot import session as session_service
+from app.services.chatbot.agents.onboarding.types import FieldExtraction
from app.services.chatbot.session import process_chatbot_session_message
from app.tests.utils.chatbot import (
DEFAULT_SYSTEM_PROMPT,
@@ -120,6 +130,7 @@ def test_first_call_stores_greeting_and_posts_success_webhook(
"session_id": str(chatbot_session.id),
"reply": "Hi! What's your name?",
"status": "active",
+ "completion_status": "in_progress",
"collected_fields": {},
},
},
@@ -338,3 +349,138 @@ def test_transcript_without_pending_user_turn_posts_failure_webhook(
[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,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(
+ replies=["Thanks, all set!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"city": "Pune"},
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ 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": True,
+ "data": {
+ "session_id": str(chatbot_session.id),
+ "reply": "Thanks, all set!",
+ "status": "active",
+ "completion_status": "complete",
+ "collected_fields": {"name": "Asha", "city": "Pune"},
+ },
+ }
+ ]
+
+ def test_extraction_value_error_posts_failure_webhook_and_keeps_progress(
+ self,
+ db: Session,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(
+ replies=["should never be used"],
+ extractions=[ValueError("thinking cannot be disabled for this model")],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ add_test_chatbot_messages(
+ db, chatbot_session, [(ChatbotMessageRoleEnum.USER, "Asha")]
+ )
+
+ _process(chatbot_session, db)
+
+ [payload] = callbacks.payloads
+ assert payload["success"] is False
+ assert "[KAAPI] Unsupported model configuration" in payload["error"]
+ assert "thinking cannot be disabled for this model" in payload["error"]
+ assert payload["data"] == {"session_id": str(chatbot_session.id)}
+ 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,
+ config: Config,
+ callbacks: CallbackRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ chat_model = ScriptedChatModel(
+ replies=["You're welcome!"],
+ extractions=[
+ FieldExtraction(
+ collected_fields={"name": "Asha", "city": "Pune"},
+ missing_fields=[],
+ is_complete=True,
+ )
+ ],
+ )
+ patch_chat_model(monkeypatch, chat_model)
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ 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"
diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py
index dac32ca06..6a503ba68 100644
--- a/backend/app/tests/utils/chatbot.py
+++ b/backend/app/tests/utils/chatbot.py
@@ -5,22 +5,29 @@
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
from app.crud.chatbot import create_chatbot_session, create_chatbot_session_message
from app.models import (
+ DEFAULT_CHATBOT_NAME,
ChatbotMessageRoleEnum,
ChatbotSession,
ChatbotSessionMessage,
Config,
Project,
)
-from app.services.chatbot import engine
+from app.services.chatbot.agents.onboarding import graph as onboarding_graph
+from app.services.chatbot.agents.onboarding.types import FieldExtraction
from app.tests.utils.utils import random_lower_string
DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city."
+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."""
@@ -29,6 +36,11 @@ class ScriptedChatModel(BaseChatModel):
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:
@@ -52,18 +64,35 @@ def get_num_tokens_from_messages(
) -> 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
) -> None:
# The Anthropic SDK is the external boundary; everything above it runs for real.
- monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model)
+ monkeypatch.setattr(
+ onboarding_graph, "get_chat_model", lambda _settings: chat_model
+ )
def create_test_chatbot_session(
db: Session,
*,
config: Config,
+ name: str = DEFAULT_CHATBOT_NAME,
contact_ref: str | None = None,
config_version: int | None = None,
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
@@ -74,6 +103,7 @@ def create_test_chatbot_session(
db,
organization_id=project.organization_id,
project_id=project.id,
+ name=name,
contact_ref=contact_ref or f"contact-{random_lower_string()}",
config_id=config.id,
config_version=config_version,
diff --git a/docs/wiki/INDEX.md b/docs/wiki/INDEX.md
index 89a96f8c9..30ee8ba93 100644
--- a/docs/wiki/INDEX.md
+++ b/docs/wiki/INDEX.md
@@ -22,7 +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) — v0 stateless prompt-driven chatbot (`POST /chatbot/message`, LangGraph + Anthropic, model driven by a saved config). No deep-dive doc yet.
+- [modules/chatbot.md](modules/chatbot.md) — prompt-driven chatbot with per-turn field extraction + completion status (`POST /chatbot/message`, `/chatbot/sessions*` via Celery + webhook; LangGraph + Anthropic, model driven by a saved config). 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/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index 8023c2521..4671f7799 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -1,25 +1,43 @@
# Module: Chatbot
-v0 prompt-driven chatbot: one LangGraph node over a LangChain chat model (Anthropic). Stateless — the caller resends `system_prompt` + full `history` every turn. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. No deep-dive doc yet.
+Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Two entry points: a stateless synchronous turn (caller resends `system_prompt` + full `history`) and server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet.
All paths relative to `backend/app/`.
## Routes
-- `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope)
-- `api/routes/chatbot/session.py` — `GET /chatbot/sessions?contact_ref=` (all sessions for a contact in the caller's project, newest first → `list[ChatbotSessionSummary]`)
+- `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`)
+- `api/routes/chatbot/session.py`
+ - `POST /chatbot/sessions` (create; validates the config → `{session_id}`)
+ - `GET /chatbot/sessions?contact_ref=` (latest session for a contact in the caller's project → `ChatbotSessionSummary`, incl. `status`, `completion_status`, `collected_fields`)
+ - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`)
+- All require project scope.
-## Tables (SQLModel)
-None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model.
+## Tables (SQLModel, `models/chatbot.py`)
+- `chatbot_session` (`ChatbotSession`) — `name`, `contact_ref`, `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete = all required fields collected), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Migration `088`.
+- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session).
+- Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`.
+
+## 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 admin 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{transcript, collected_fields, is_complete}` (`.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/agents/onboarding/graph.py` — `run_chatbot_turn` (public entry point), `build_onboarding_graph`, the extraction/reply nodes
+- `services/chatbot/agents/onboarding/prompts.py` — extraction instruction/templates, progress-note templates, `EXTRACTION_METHOD`
+- `services/chatbot/agents/onboarding/types.py` — `OnboardingState`, `FieldExtraction`
+- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `to_message_dicts`, kickoff message, `has_user_turn`, `insert_after_leading_system`
+- `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict`
+- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history, run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook)
- `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs)
-- `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization
- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model`
-- `crud/chatbot.py` — `list_chatbot_sessions_by_contact_ref` (org + project scoped, `inserted_at` desc, unpaginated)
+- `crud/chatbot.py` — `create_chatbot_session`, `get_chatbot_session`, `get_latest_chatbot_session_by_contact_ref`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages`
- `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted)
## Async
-- None; synchronous request/response, not Celery.
+- `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", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`.
## External
-- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting).
+- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting) — 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).
From 90a2cbdff6ea1d5e73a1cb37907edd7bd108c378 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:18:54 +0530
Subject: [PATCH 07/15] fix: refinement system prompt
---
.../chatbot/agents/onboarding/graph.py | 3 +-
.../chatbot/agents/onboarding/prompts.py | 10 +++--
.../chatbot/agents/onboarding/types.py | 11 ++++--
.../tests/api/routes/chatbot/test_message.py | 10 ++++-
.../chatbot/agents/onboarding/test_graph.py | 39 ++++++++++++++-----
.../tests/services/chatbot/test_session.py | 5 ++-
backend/app/tests/utils/chatbot.py | 12 +++++-
7 files changed, 68 insertions(+), 22 deletions(-)
diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py
index c96e10b15..4fcf3acee 100644
--- a/backend/app/services/chatbot/agents/onboarding/graph.py
+++ b/backend/app/services/chatbot/agents/onboarding/graph.py
@@ -131,7 +131,8 @@ def extract_fields_node(state: OnboardingState) -> dict[str, Any]:
conversation_view=conversation_view,
),
)
- merged_fields = {**prior_fields, **extraction.collected_fields}
+ 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,
diff --git a/backend/app/services/chatbot/agents/onboarding/prompts.py b/backend/app/services/chatbot/agents/onboarding/prompts.py
index 698659741..b483cbe1d 100644
--- a/backend/app/services/chatbot/agents/onboarding/prompts.py
+++ b/backend/app/services/chatbot/agents/onboarding/prompts.py
@@ -8,9 +8,13 @@
"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. Never invent, guess, or infer a value the user did not give.\n"
- "4. missing_fields: the required fields the user has not answered yet.\n"
- "5. is_complete: true only when no required field is missing."
+ "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 = (
diff --git a/backend/app/services/chatbot/agents/onboarding/types.py b/backend/app/services/chatbot/agents/onboarding/types.py
index 045646d43..850e4d958 100644
--- a/backend/app/services/chatbot/agents/onboarding/types.py
+++ b/backend/app/services/chatbot/agents/onboarding/types.py
@@ -13,12 +13,17 @@ class OnboardingState(TypedDict):
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: dict[str, str] = Field(
- default_factory=dict,
- description="snake_case field name -> value the user clearly provided; never guessed.",
+ 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,
diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py
index 66ebfc95f..54b57250d 100644
--- a/backend/app/tests/api/routes/chatbot/test_message.py
+++ b/backend/app/tests/api/routes/chatbot/test_message.py
@@ -8,7 +8,11 @@
from app.models import Config
from app.services.chatbot.agents.onboarding.types import FieldExtraction
from app.tests.utils.auth import TestAuthContext
-from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
+from app.tests.utils.chatbot import (
+ ScriptedChatModel,
+ extracted_fields,
+ patch_chat_model,
+)
from app.tests.utils.test_data import (
build_anthropic_text_config_blob,
create_test_config,
@@ -72,7 +76,9 @@ def test_returns_extracted_fields_and_completion_status(
replies=["Thanks, Asha from Pune!"],
extractions=[
FieldExtraction(
- collected_fields={"name": "Asha", "city": "Pune"},
+ collected_fields=extracted_fields(
+ {"name": "Asha", "city": "Pune"}
+ ),
missing_fields=[],
is_complete=True,
)
diff --git a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
index c5d932438..e59978bb6 100644
--- a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
+++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py
@@ -6,10 +6,15 @@
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.utils import ChatbotModelSettings
-from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model
+from app.tests.utils.chatbot import (
+ ScriptedChatModel,
+ extracted_fields,
+ patch_chat_model,
+)
SYSTEM_PROMPT = "Collect the user's name and city."
SUMMARY_TEXT = "User is Asha from Pune."
@@ -277,7 +282,7 @@ async def test_long_history_summary_is_shared_by_extraction_and_reply(
token_count=5000,
extractions=[
FieldExtraction(
- collected_fields={"name": "Asha"},
+ collected_fields=extracted_fields({"name": "Asha"}),
missing_fields=["city"],
is_complete=False,
)
@@ -319,7 +324,7 @@ async def test_greeting_turn_skips_extraction(
replies=["Hi! What's your name?"],
extractions=[
FieldExtraction(
- collected_fields={"name": "invented"},
+ collected_fields=extracted_fields({"name": "invented"}),
missing_fields=[],
is_complete=True,
)
@@ -379,6 +384,7 @@ async def test_extraction_sees_flow_prompt_prior_fields_and_conversation(
[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'
@@ -399,7 +405,7 @@ async def test_extracted_fields_merge_over_prior_with_corrections_winning(
replies=["Noted."],
extractions=[
FieldExtraction(
- collected_fields={"city": "Mumbai", "age": "30"},
+ collected_fields=extracted_fields({"city": "Mumbai", "age": "30"}),
missing_fields=[],
is_complete=False,
)
@@ -442,7 +448,7 @@ async def test_completion_requires_flag_and_no_missing_fields(
replies=["Thanks!"],
extractions=[
FieldExtraction(
- collected_fields={"name": "Asha"},
+ collected_fields=extracted_fields({"name": "Asha"}),
missing_fields=missing_fields,
is_complete=is_complete,
)
@@ -466,7 +472,7 @@ class TestProgressNote:
[
(
FieldExtraction(
- collected_fields={"name": "Asha"},
+ collected_fields=extracted_fields({"name": "Asha"}),
missing_fields=["city"],
is_complete=False,
),
@@ -475,7 +481,7 @@ class TestProgressNote:
),
(
FieldExtraction(
- collected_fields={},
+ collected_fields=extracted_fields({}),
missing_fields=["name", "city"],
is_complete=False,
),
@@ -483,7 +489,7 @@ class TestProgressNote:
),
(
FieldExtraction(
- collected_fields={"name": "Asha", "city": "Pune"},
+ collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}),
missing_fields=[],
is_complete=True,
),
@@ -529,7 +535,9 @@ async def test_no_note_when_nothing_collected_missing_or_complete(
replies=["Reply."],
extractions=[
FieldExtraction(
- collected_fields={}, missing_fields=[], is_complete=False
+ collected_fields=extracted_fields({}),
+ missing_fields=[],
+ is_complete=False,
)
],
)
@@ -566,6 +574,19 @@ async def test_prior_fields_survive_into_note_when_extraction_adds_none(
)
+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"),
diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py
index 6a5204607..f0a71971b 100644
--- a/backend/app/tests/services/chatbot/test_session.py
+++ b/backend/app/tests/services/chatbot/test_session.py
@@ -28,6 +28,7 @@
ScriptedChatModel,
add_test_chatbot_messages,
create_test_chatbot_session,
+ extracted_fields,
patch_chat_model,
)
from app.tests.utils.test_data import (
@@ -363,7 +364,7 @@ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them(
replies=["Thanks, all set!"],
extractions=[
FieldExtraction(
- collected_fields={"city": "Pune"},
+ collected_fields=extracted_fields({"city": "Pune"}),
missing_fields=[],
is_complete=True,
)
@@ -451,7 +452,7 @@ def test_complete_session_still_answers_new_messages(
replies=["You're welcome!"],
extractions=[
FieldExtraction(
- collected_fields={"name": "Asha", "city": "Pune"},
+ collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}),
missing_fields=[],
is_complete=True,
)
diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py
index 6a503ba68..2fa5a3772 100644
--- a/backend/app/tests/utils/chatbot.py
+++ b/backend/app/tests/utils/chatbot.py
@@ -19,13 +19,21 @@
Project,
)
from app.services.chatbot.agents.onboarding import graph as onboarding_graph
-from app.services.chatbot.agents.onboarding.types import FieldExtraction
+from app.services.chatbot.agents.onboarding.types import (
+ ExtractedField,
+ FieldExtraction,
+)
from app.tests.utils.utils import random_lower_string
DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city."
+
+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
+ collected_fields=[], missing_fields=[], is_complete=False
)
From e884d24a9e33f31dc17e347350f74dbf7c8110b2 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:44:47 +0530
Subject: [PATCH 08/15] fix: implement the state management api flow creation
---
.../versions/088_add_chatbot_session.py | 31 +-
.../app/api/docs/chatbot/create_session.md | 8 +-
.../api/docs/chatbot/get_latest_session.md | 4 +-
.../api/docs/chatbot/send_session_message.md | 6 +-
backend/app/api/routes/chatbot/session.py | 70 +++-
backend/app/crud/chatbot.py | 118 +++++-
backend/app/models/__init__.py | 2 +
backend/app/models/chatbot.py | 42 +-
backend/app/services/chatbot/chatbot_ref.py | 40 ++
backend/app/services/chatbot/exceptions.py | 4 +
backend/app/services/chatbot/session.py | 28 +-
.../tests/api/routes/chatbot/test_session.py | 322 ++++++++++++++-
backend/app/tests/crud/test_chatbot.py | 365 +++++++++++++++++-
.../services/chatbot/test_chatbot_ref.py | 104 +++++
.../tests/services/chatbot/test_session.py | 2 +
backend/app/tests/utils/chatbot.py | 2 +
backend/app/tests/utils/test_data.py | 13 +-
docs/wiki/modules/chatbot.md | 16 +-
18 files changed, 1111 insertions(+), 66 deletions(-)
create mode 100644 backend/app/services/chatbot/chatbot_ref.py
create mode 100644 backend/app/tests/services/chatbot/test_chatbot_ref.py
diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py
index 99859aaf0..b7f94ce8b 100644
--- a/backend/app/alembic/versions/088_add_chatbot_session.py
+++ b/backend/app/alembic/versions/088_add_chatbot_session.py
@@ -3,6 +3,13 @@
Revision ID: 088
Revises: 087
Create Date: 2026-10-20 00:00:00.000000
+
+At most one session per (contact_ref, chatbot_ref) is active: creating a new one
+deactivates the rest in crud/chatbot.py, and the partial unique index makes a
+concurrent double-create fail instead of leaving two active rows. The plain
+composite index stays because the partial one can't serve the GET-latest lookup,
+which includes inactive rows; chatbot_ref comes before contact_ref so its prefix
+also serves the project-wide uniqueness check on generated chatbot_refs.
"""
import sqlalchemy as sa
@@ -38,6 +45,12 @@ def upgrade():
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(),
@@ -64,18 +77,19 @@ def upgrade():
name="chatbotsessionstatusenum",
),
nullable=False,
- comment="Conversation lifecycle: active or inactive",
+ 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, or complete once every required field is gathered",
+ comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress",
),
sa.Column(
"collected_fields",
@@ -132,6 +146,19 @@ def upgrade():
["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",
diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md
index ec34000aa..69346c6ae 100644
--- a/backend/app/api/docs/chatbot/create_session.md
+++ b/backend/app/api/docs/chatbot/create_session.md
@@ -1,5 +1,9 @@
-Start a server-side chatbot conversation and return its `session_id`.
+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; omit it to get `"Untitled Chatbot"`. `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.
+`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).
diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md
index c2ddf0e11..bbec7bbff 100644
--- a/backend/app/api/docs/chatbot/get_latest_session.md
+++ b/backend/app/api/docs/chatbot/get_latest_session.md
@@ -1,3 +1,3 @@
-Get the most recently created chatbot session for the given `contact_ref` in the caller's project.
+Get the most recently created chatbot session for the given `contact_ref` and `chatbot_ref` in the caller's project.
-`contact_ref` is matched exactly against the value supplied when the session was created. The response carries the session's current `status` (`active`/`inactive`), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions in this project.
+Both refs are required and matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). The response carries the session's 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 the contact has no sessions for this chatbot in this project.
diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md
index b4b4d816d..c6ff7b84f 100644
--- a/backend/app/api/docs/chatbot/send_session_message.md
+++ b/backend/app/api/docs/chatbot/send_session_message.md
@@ -1,7 +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", "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.
+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.
+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 and 422 for an invalid `webhook_url` or a missing `message` on a started session.
+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/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
index d3316e01b..2ab7f2ad1 100644
--- a/backend/app/api/routes/chatbot/session.py
+++ b/backend/app/api/routes/chatbot/session.py
@@ -8,23 +8,30 @@
from app.api.permissions import Permission, require_permission
from app.celery.utils import start_chatbot_session_message
from app.crud.chatbot import (
+ ChatbotSessionConflictError,
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
- get_latest_chatbot_session_by_contact_ref,
+ get_latest_chatbot_session,
list_chatbot_session_messages,
)
from app.models import (
+ CHATBOT_REF_MAX_LENGTH,
CONTACT_REF_MAX_LENGTH,
ChatbotMessageRoleEnum,
+ ChatbotSessionStatusEnum,
ChatbotSessionSummary,
CreateChatbotSessionRequest,
CreateChatbotSessionResponse,
SendChatbotSessionMessageRequest,
SendChatbotSessionMessageResponse,
)
+from app.services.chatbot.chatbot_ref import generate_chatbot_ref
from app.services.chatbot.config import resolve_chatbot_model_settings
-from app.services.chatbot.exceptions import ChatbotInputError
+from app.services.chatbot.exceptions import (
+ ChatbotInputError,
+ ChatbotRefGenerationError,
+)
from app.utils import APIResponse, load_description, validate_callback_url
DEFAULT_TRACE_ID = "N/A"
@@ -57,18 +64,31 @@ def start_chatbot_session(
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,
- name=request.name,
- contact_ref=request.contact_ref,
- config_id=request.config_id,
- config_version=request.config_version,
- system_prompt=request.system_prompt,
- )
+ chatbot_ref = request.chatbot_ref
+ if chatbot_ref is None:
+ try:
+ chatbot_ref = generate_chatbot_ref(session, project_id=project_id)
+ except ChatbotRefGenerationError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+
+ try:
+ chatbot_session = create_chatbot_session(
+ session,
+ organization_id=auth_context.organization_.id,
+ project_id=project_id,
+ name=request.name,
+ contact_ref=request.contact_ref,
+ chatbot_ref=chatbot_ref,
+ config_id=request.config_id,
+ config_version=request.config_version,
+ system_prompt=request.system_prompt,
+ )
+ except ChatbotSessionConflictError as e:
+ raise HTTPException(status_code=409, detail=str(e))
return APIResponse.success_response(
- CreateChatbotSessionResponse(session_id=chatbot_session.id)
+ CreateChatbotSessionResponse(
+ session_id=chatbot_session.id, chatbot_ref=chatbot_session.chatbot_ref
+ )
)
@@ -78,7 +98,7 @@ def start_chatbot_session(
response_model=APIResponse[ChatbotSessionSummary],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
-def get_latest_chatbot_session(
+def read_latest_chatbot_session(
contact_ref: Annotated[
str,
Query(
@@ -87,24 +107,35 @@ def get_latest_chatbot_session(
description="Caller-side identifier of the end user whose latest session to fetch",
),
],
+ chatbot_ref: Annotated[
+ str,
+ Query(
+ min_length=1,
+ max_length=CHATBOT_REF_MAX_LENGTH,
+ description="Caller-side identifier of the chatbot whose latest session to fetch",
+ ),
+ ],
session: SessionDep,
auth_context: AuthContextDep,
) -> APIResponse[ChatbotSessionSummary]:
- chatbot_session = get_latest_chatbot_session_by_contact_ref(
+ chatbot_session = get_latest_chatbot_session(
session,
- contact_ref=contact_ref,
organization_id=auth_context.organization_.id,
project_id=auth_context.project_.id,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
)
if chatbot_session is None:
raise HTTPException(
- status_code=404, detail="No chatbot session found for this contact_ref"
+ status_code=404,
+ detail="No chatbot session found for this contact_ref and chatbot_ref",
)
return APIResponse.success_response(
ChatbotSessionSummary(
session_id=chatbot_session.id,
name=chatbot_session.name,
contact_ref=chatbot_session.contact_ref,
+ chatbot_ref=chatbot_session.chatbot_ref,
status=chatbot_session.status,
completion_status=chatbot_session.completion_status,
collected_fields=chatbot_session.collected_fields,
@@ -145,6 +176,11 @@ def send_chatbot_session_message(
)
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):
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
index e0b1fda75..fdb6d9f41 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -1,19 +1,85 @@
import logging
+from datetime import datetime
from typing import Any
from uuid import UUID
+from sqlalchemy import update
+from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, col, select
from app.core.util import now
from app.models import (
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
)
logger = logging.getLogger(__name__)
+CONCURRENT_SESSION_CONFLICT_MESSAGE = (
+ "A concurrent session was just created for this contact and chatbot; retry"
+)
+
+
+class ChatbotSessionConflictError(Exception):
+ """Another active session for the same contact + chatbot was committed concurrently."""
+
+
+def _is_active_session_conflict(error: IntegrityError) -> bool:
+ constraint_name = getattr(
+ getattr(error.orig, "diag", None), "constraint_name", None
+ )
+ if constraint_name:
+ return constraint_name == ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX
+ return ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX in str(error.orig or error)
+
+
+def _deactivate_active_chatbot_sessions(
+ session: Session,
+ *,
+ organization_id: int,
+ project_id: int,
+ contact_ref: str,
+ chatbot_ref: str,
+ timestamp: datetime,
+) -> int:
+ """Mark this contact's active sessions of this chatbot inactive; does not commit."""
+ scope = (
+ col(ChatbotSession.organization_id) == organization_id,
+ col(ChatbotSession.project_id) == project_id,
+ col(ChatbotSession.contact_ref) == contact_ref,
+ col(ChatbotSession.chatbot_ref) == chatbot_ref,
+ col(ChatbotSession.status) == ChatbotSessionStatusEnum.ACTIVE,
+ )
+
+ # 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,
@@ -22,16 +88,30 @@ def create_chatbot_session(
project_id: int,
name: str,
contact_ref: str,
+ chatbot_ref: str,
config_id: UUID,
config_version: int | None,
system_prompt: str,
) -> ChatbotSession:
+ """Insert a new active session, superseding the contact's active ones for the same chatbot.
+
+ Deactivation and insert share one commit, so a failed insert leaves prior sessions active.
+ """
timestamp = now()
+ superseded_count = _deactivate_active_chatbot_sessions(
+ session,
+ organization_id=organization_id,
+ project_id=project_id,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
+ timestamp=timestamp,
+ )
chatbot_session = ChatbotSession(
organization_id=organization_id,
project_id=project_id,
name=name,
contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
config_id=config_id,
config_version=config_version,
system_prompt=system_prompt,
@@ -39,8 +119,24 @@ def create_chatbot_session(
updated_at=timestamp,
)
session.add(chatbot_session)
- session.commit()
+ try:
+ session.commit()
+ except IntegrityError as e:
+ session.rollback()
+ if not _is_active_session_conflict(e):
+ raise
+ logger.warning(
+ f"[create_chatbot_session] {CONCURRENT_SESSION_CONFLICT_MESSAGE} | "
+ f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, code: 409",
+ exc_info=True,
+ )
+ raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e
session.refresh(chatbot_session)
+ logger.info(
+ f"[create_chatbot_session] Session created | session_id: {chatbot_session.id}, "
+ f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, "
+ f"superseded_count: {superseded_count}"
+ )
return chatbot_session
@@ -118,19 +214,33 @@ def list_chatbot_session_messages(
return list(session.exec(statement).all())
-def get_latest_chatbot_session_by_contact_ref(
+def chatbot_ref_exists(session: Session, *, project_id: int, chatbot_ref: str) -> bool:
+ statement = (
+ select(ChatbotSession.id)
+ .where(
+ ChatbotSession.project_id == project_id,
+ ChatbotSession.chatbot_ref == chatbot_ref,
+ )
+ .limit(1)
+ )
+ return session.exec(statement).first() is not None
+
+
+def get_latest_chatbot_session(
session: Session,
*,
- contact_ref: str,
organization_id: int,
project_id: int,
+ contact_ref: str,
+ chatbot_ref: str,
) -> ChatbotSession | None:
statement = (
select(ChatbotSession)
.where(
- ChatbotSession.contact_ref == contact_ref,
ChatbotSession.organization_id == organization_id,
ChatbotSession.project_id == project_id,
+ ChatbotSession.contact_ref == contact_ref,
+ ChatbotSession.chatbot_ref == chatbot_ref,
)
.order_by(col(ChatbotSession.inserted_at).desc())
.limit(1)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 2c1cbcfc9..f51aa778f 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -36,6 +36,8 @@
BatchJobUpdate,
)
from .chatbot import (
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
+ CHATBOT_REF_MAX_LENGTH,
CONTACT_REF_MAX_LENGTH,
DEFAULT_CHATBOT_NAME,
ChatbotCompletionStatusEnum,
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
index 7114c9fcc..71c808c8f 100644
--- a/backend/app/models/chatbot.py
+++ b/backend/app/models/chatbot.py
@@ -3,7 +3,7 @@
from typing import Any
from uuid import UUID, uuid4
-from sqlalchemy import JSON, Column
+from sqlalchemy import JSON, Column, Index, text
from sqlalchemy.dialects.postgresql import ENUM
from sqlmodel import Field, SQLModel
@@ -63,6 +63,7 @@ class ChatbotSessionStatusEnum(StrEnum):
class ChatbotCompletionStatusEnum(StrEnum):
IN_PROGRESS = "in_progress"
COMPLETE = "complete"
+ ABANDONED = "abandoned"
class ChatbotTurnResponse(SQLModel):
@@ -97,12 +98,32 @@ class ChatbotTurnResponse(SQLModel):
)
CONTACT_REF_MAX_LENGTH = 500
+CHATBOT_REF_MAX_LENGTH = 255
+ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX = "uq_chatbot_session_active_contact_chatbot"
CHATBOT_NAME_MAX_LENGTH = 255
DEFAULT_CHATBOT_NAME = "Untitled Chatbot"
class ChatbotSession(SQLModel, table=True):
__tablename__ = "chatbot_session"
+ __table_args__ = (
+ # Serves GET-latest (includes inactive rows the partial index below skips)
+ # and, via its (project_id, chatbot_ref) prefix, the generated-ref collision check.
+ Index(
+ "ix_chatbot_session_project_chatbot_contact",
+ "project_id",
+ "chatbot_ref",
+ "contact_ref",
+ ),
+ Index(
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
+ "project_id",
+ "contact_ref",
+ "chatbot_ref",
+ unique=True,
+ postgresql_where=text("status = 'active'"),
+ ),
+ )
id: UUID = Field(
default_factory=uuid4,
@@ -120,6 +141,13 @@ class ChatbotSession(SQLModel, table=True):
"comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi"
},
)
+ chatbot_ref: str = Field(
+ max_length=CHATBOT_REF_MAX_LENGTH,
+ nullable=False,
+ sa_column_kwargs={
+ "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"
+ },
+ )
config_id: UUID = Field(
foreign_key="config.id",
nullable=False,
@@ -146,7 +174,7 @@ class ChatbotSession(SQLModel, table=True):
sa_column=Column(
_SESSION_STATUS_PG_ENUM,
nullable=False,
- comment="Conversation lifecycle: active or inactive",
+ comment="Session in use: active, or inactive once superseded by a newer session for the same contact_ref + chatbot_ref",
),
)
completion_status: ChatbotCompletionStatusEnum = Field(
@@ -155,7 +183,7 @@ class ChatbotSession(SQLModel, table=True):
_COMPLETION_STATUS_PG_ENUM,
nullable=False,
server_default=ChatbotCompletionStatusEnum.IN_PROGRESS.value,
- comment="Field collection progress: in_progress, or complete once every required field is gathered",
+ comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress",
),
)
collected_fields: dict[str, Any] = Field(
@@ -249,6 +277,12 @@ class CreateChatbotSessionRequest(SQLModel):
max_length=CONTACT_REF_MAX_LENGTH,
description="Caller-side identifier of the end user this conversation is with.",
)
+ chatbot_ref: str | None = Field(
+ default=None,
+ min_length=1,
+ max_length=CHATBOT_REF_MAX_LENGTH,
+ description="Identifier of an existing chatbot, as returned by a previous create; supersedes this contact's active session for that chatbot. Omit to create a new chatbot with a generated ref.",
+ )
system_prompt: str = Field(
min_length=1,
description="Admin-authored flow prompt (goal, fields to collect, rules); stored for the session's lifetime.",
@@ -257,12 +291,14 @@ class CreateChatbotSessionRequest(SQLModel):
class CreateChatbotSessionResponse(SQLModel):
session_id: UUID
+ chatbot_ref: str
class ChatbotSessionSummary(SQLModel):
session_id: UUID
name: str
contact_ref: str
+ chatbot_ref: str
status: ChatbotSessionStatusEnum
completion_status: ChatbotCompletionStatusEnum
collected_fields: dict[str, Any]
diff --git a/backend/app/services/chatbot/chatbot_ref.py b/backend/app/services/chatbot/chatbot_ref.py
new file mode 100644
index 000000000..1af3ffa2a
--- /dev/null
+++ b/backend/app/services/chatbot/chatbot_ref.py
@@ -0,0 +1,40 @@
+import logging
+import secrets
+
+from sqlmodel import Session
+
+from app.crud.chatbot import chatbot_ref_exists
+from app.services.chatbot.exceptions import ChatbotRefGenerationError
+
+logger = logging.getLogger(__name__)
+
+CHATBOT_REF_PREFIX = "LLMResponseWithPrompt-"
+CHATBOT_REF_SUFFIX_LENGTH = 5
+HEX_BASE = 16
+CHATBOT_REF_MAX_ATTEMPTS = 5
+
+
+def _random_chatbot_ref() -> str:
+ suffix_value = secrets.randbelow(HEX_BASE**CHATBOT_REF_SUFFIX_LENGTH)
+ return f"{CHATBOT_REF_PREFIX}{suffix_value:0{CHATBOT_REF_SUFFIX_LENGTH}x}"
+
+
+def generate_chatbot_ref(session: Session, *, project_id: int) -> str:
+ """Return a chatbot_ref not yet used by any session in the project.
+
+ The suffix space is only ~1M values, and reusing a ref would merge two chatbots' sessions.
+ """
+ for _attempt in range(CHATBOT_REF_MAX_ATTEMPTS):
+ candidate = _random_chatbot_ref()
+ if not chatbot_ref_exists(
+ session, project_id=project_id, chatbot_ref=candidate
+ ):
+ return candidate
+
+ error_message = (
+ f"[KAAPI] Could not generate an unused chatbot_ref after "
+ f"{CHATBOT_REF_MAX_ATTEMPTS} attempts; retry, or pass an existing chatbot_ref. "
+ f"If this persists, contact Kaapi. (code: ChatbotRefGenerationError)"
+ )
+ logger.error(f"[generate_chatbot_ref] {error_message} | project_id: {project_id}")
+ raise ChatbotRefGenerationError(error_message)
diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py
index aac653752..fab852789 100644
--- a/backend/app/services/chatbot/exceptions.py
+++ b/backend/app/services/chatbot/exceptions.py
@@ -2,5 +2,9 @@ class ChatbotInputError(ValueError):
"""The turn request can't produce a valid model call (caller's fault)."""
+class ChatbotRefGenerationError(Exception):
+ """No unused chatbot_ref could be generated for the project."""
+
+
class ChatbotProviderError(Exception):
"""The chat-model provider failed or was unreachable."""
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
index 496e08e97..9d8acd14f 100644
--- a/backend/app/services/chatbot/session.py
+++ b/backend/app/services/chatbot/session.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
from typing import Any
from uuid import UUID
@@ -12,7 +13,11 @@
list_chatbot_session_messages,
update_chatbot_session_progress,
)
-from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage
+from app.models import (
+ ChatbotMessageRoleEnum,
+ ChatbotSessionMessage,
+ ChatbotSessionStatusEnum,
+)
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 ChatbotInputError, ChatbotProviderError
@@ -20,6 +25,12 @@
from app.services.chatbot.utils import ChatbotModelSettings
from app.utils import get_webhook_secret, send_callback
+logger = logging.getLogger(__name__)
+
+SUPERSEDED_SESSION_ERROR = (
+ "[KAAPI] Chatbot session was superseded by a newer session; start a new session."
+)
+
def _split_pending_turn(
stored_messages: list[ChatbotSessionMessage],
@@ -89,6 +100,19 @@ def process_chatbot_session_message(
)
if chatbot_session is None:
return
+ # The turn may have been queued before a newer session superseded this one.
+ if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE:
+ logger.warning(
+ f"[process_chatbot_session_message] {SUPERSEDED_SESSION_ERROR} | "
+ f"session_id: {session_id}, project_id: {project_id}"
+ )
+ _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 (
@@ -98,6 +122,7 @@ def process_chatbot_session_message(
return
system_prompt = chatbot_session.system_prompt
+ chatbot_ref = chatbot_session.chatbot_ref
session_status = chatbot_session.status.value
collected_fields = dict(chatbot_session.collected_fields)
@@ -168,6 +193,7 @@ def process_chatbot_session_message(
"success": True,
"data": {
"session_id": str(session_id),
+ "chatbot_ref": chatbot_ref,
"reply": reply,
"status": session_status,
"completion_status": completion_status.value,
diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py
index 6dde33189..36d9c9887 100644
--- a/backend/app/tests/api/routes/chatbot/test_session.py
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -1,14 +1,17 @@
+import re
import socket
from typing import Any
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
-from sqlmodel import Session
+from sqlmodel import Session, select
from app.api.routes.chatbot import session as session_routes
from app.core.config import settings
from app.crud.chatbot import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
get_chatbot_session,
list_chatbot_session_messages,
update_chatbot_session_progress,
@@ -18,8 +21,14 @@
ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
+ ChatbotSessionStatusEnum,
Config,
)
+from app.services.chatbot import chatbot_ref as chatbot_ref_module
+from app.services.chatbot.chatbot_ref import (
+ CHATBOT_REF_PREFIX,
+ CHATBOT_REF_SUFFIX_LENGTH,
+)
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.chatbot import (
add_test_chatbot_messages,
@@ -41,6 +50,26 @@ def _message_url(session_id: Any) -> str:
return f"{SESSIONS_URL}/{session_id}/message"
+def _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()
+ )
+
+
+def _supersede(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession:
+ config = db.get(Config, chatbot_session.config_id)
+ assert config is not None
+ return create_test_chatbot_session(
+ db,
+ config=config,
+ contact_ref=chatbot_session.contact_ref,
+ chatbot_ref=chatbot_session.chatbot_ref,
+ )
+
+
@pytest.fixture
def headers(user_api_key: TestAuthContext) -> dict[str, str]:
return {"X-API-KEY": user_api_key.key}
@@ -99,6 +128,7 @@ def test_creates_session_in_callers_project(
"config_id": str(anthropic_config.id),
"config_version": 1,
"contact_ref": contact_ref,
+ "chatbot_ref": "chatbot-onboarding",
"system_prompt": SYSTEM_PROMPT,
},
)
@@ -132,6 +162,7 @@ def test_omitted_name_uses_default(
json={
"config_id": str(anthropic_config.id),
"contact_ref": f"contact-{random_lower_string()}",
+ "chatbot_ref": f"chatbot-{random_lower_string()}",
"system_prompt": SYSTEM_PROMPT,
},
)
@@ -155,6 +186,7 @@ def test_unknown_config_returns_404(
json={
"config_id": str(uuid4()),
"contact_ref": "contact-1",
+ "chatbot_ref": "chatbot-1",
"system_prompt": SYSTEM_PROMPT,
},
)
@@ -178,6 +210,7 @@ def test_non_anthropic_config_returns_422(
json={
"config_id": str(openai_config.id),
"contact_ref": "contact-1",
+ "chatbot_ref": "chatbot-1",
"system_prompt": SYSTEM_PROMPT,
},
)
@@ -186,6 +219,181 @@ def test_non_anthropic_config_returns_422(
assert "only supports configs" in resp.json()["error"]
+class TestCreateChatbotSessionChatbotRef:
+ def _create(
+ self,
+ client: TestClient,
+ headers: dict[str, str],
+ config: Config,
+ contact_ref: str,
+ **extra: Any,
+ ) -> Any:
+ return client.post(
+ SESSIONS_URL,
+ headers=headers,
+ json={
+ "config_id": str(config.id),
+ "contact_ref": contact_ref,
+ "system_prompt": SYSTEM_PROMPT,
+ **extra,
+ },
+ )
+
+ def test_omitted_chatbot_ref_is_generated_and_stored(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+
+ resp = self._create(client, headers, anthropic_config, contact_ref)
+
+ assert resp.status_code == 201
+ body = resp.json()["data"]
+ pattern = (
+ rf"{re.escape(CHATBOT_REF_PREFIX)}[0-9a-f]{{{CHATBOT_REF_SUFFIX_LENGTH}}}"
+ )
+ assert re.fullmatch(pattern, body["chatbot_ref"])
+ [stored] = _sessions_for_contact(db, contact_ref)
+ assert str(stored.id) == body["session_id"]
+ assert stored.chatbot_ref == body["chatbot_ref"]
+
+ def test_provided_chatbot_ref_is_used_as_is(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ chatbot_ref = f"Glific Flow {random_lower_string()}"
+
+ resp = self._create(
+ client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref
+ )
+
+ assert resp.status_code == 201
+ assert resp.json()["data"]["chatbot_ref"] == chatbot_ref
+ [stored] = _sessions_for_contact(db, contact_ref)
+ assert stored.chatbot_ref == chatbot_ref
+
+ def test_reusing_returned_ref_supersedes_previous_session(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ first = self._create(client, headers, anthropic_config, contact_ref).json()[
+ "data"
+ ]
+
+ resp = self._create(
+ client,
+ headers,
+ anthropic_config,
+ contact_ref,
+ chatbot_ref=first["chatbot_ref"],
+ )
+
+ assert resp.status_code == 201
+ second = resp.json()["data"]
+ assert second["chatbot_ref"] == first["chatbot_ref"]
+ assert second["session_id"] != first["session_id"]
+ latest = client.get(
+ SESSIONS_URL,
+ headers=headers,
+ params={"contact_ref": contact_ref, "chatbot_ref": first["chatbot_ref"]},
+ ).json()["data"]
+ assert latest["session_id"] == second["session_id"]
+ assert latest["status"] == "active"
+ assert latest["completion_status"] == "in_progress"
+ statuses = {
+ str(s.id): (s.status, s.completion_status)
+ for s in _sessions_for_contact(db, contact_ref)
+ }
+ assert statuses == {
+ first["session_id"]: (
+ ChatbotSessionStatusEnum.INACTIVE,
+ ChatbotCompletionStatusEnum.ABANDONED,
+ ),
+ second["session_id"]: (
+ ChatbotSessionStatusEnum.ACTIVE,
+ ChatbotCompletionStatusEnum.IN_PROGRESS,
+ ),
+ }
+
+ @pytest.mark.parametrize("chatbot_ref", ["", "x" * 256], ids=["empty", "too-long"])
+ def test_invalid_chatbot_ref_returns_422(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ chatbot_ref: str,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+
+ resp = self._create(
+ client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref
+ )
+
+ assert resp.status_code == 422
+ assert _sessions_for_contact(db, contact_ref) == []
+
+ def test_ref_generation_exhausted_returns_503_and_creates_nothing(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ create_test_chatbot_session(
+ db, config=anthropic_config, chatbot_ref="LLMResponseWithPrompt-abcde"
+ )
+ # Randomness is the boundary: every draw collides with the ref above.
+ monkeypatch.setattr(
+ chatbot_ref_module.secrets, "randbelow", lambda _bound: 0xABCDE
+ )
+ contact_ref = f"contact-{random_lower_string()}"
+
+ resp = self._create(client, headers, anthropic_config, contact_ref)
+
+ assert resp.status_code == 503
+ assert "ChatbotRefGenerationError" in resp.json()["error"]
+ assert _sessions_for_contact(db, contact_ref) == []
+
+ def test_concurrent_create_conflict_returns_409(
+ self,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ anthropic_config: Config,
+ 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_routes, "create_chatbot_session", lose_race)
+
+ resp = self._create(
+ client,
+ headers,
+ anthropic_config,
+ f"contact-{random_lower_string()}",
+ chatbot_ref=f"chatbot-{random_lower_string()}",
+ )
+
+ assert resp.status_code == 409
+ assert resp.json()["error"] == CONCURRENT_SESSION_CONFLICT_MESSAGE
+
+
class TestGetLatestChatbotSession:
def test_returns_contacts_newest_session_in_callers_project(
self,
@@ -195,24 +403,35 @@ def test_returns_contacts_newest_session_in_callers_project(
anthropic_config: Config,
) -> None:
contact_ref = f"contact-{random_lower_string()}"
+ chatbot_ref = f"chatbot-{random_lower_string()}"
create_test_chatbot_session(
- db, config=anthropic_config, contact_ref=contact_ref
+ db,
+ config=anthropic_config,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
)
newer = create_test_chatbot_session(
db,
config=anthropic_config,
name="Onboarding Bot",
contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
config_version=1,
)
- create_test_chatbot_session(db, config=anthropic_config)
+ create_test_chatbot_session(
+ db, config=anthropic_config, chatbot_ref=chatbot_ref
+ )
foreign_config = create_test_config(
db, config_blob=build_anthropic_text_config_blob()
)
- create_test_chatbot_session(db, config=foreign_config, contact_ref=contact_ref)
+ create_test_chatbot_session(
+ db, config=foreign_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
+ )
resp = client.get(
- SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref}
+ SESSIONS_URL,
+ headers=headers,
+ params={"contact_ref": contact_ref, "chatbot_ref": chatbot_ref},
)
assert resp.status_code == 200
@@ -220,6 +439,7 @@ def test_returns_contacts_newest_session_in_callers_project(
assert body["session_id"] == str(newer.id)
assert body["name"] == "Onboarding Bot"
assert body["contact_ref"] == contact_ref
+ assert body["chatbot_ref"] == chatbot_ref
assert body["status"] == "active"
assert body["completion_status"] == "in_progress"
assert body["collected_fields"] == {}
@@ -243,7 +463,10 @@ def test_reports_stored_field_progress(
resp = client.get(
SESSIONS_URL,
headers=headers,
- params={"contact_ref": chatbot_session.contact_ref},
+ params={
+ "contact_ref": chatbot_session.contact_ref,
+ "chatbot_ref": chatbot_session.chatbot_ref,
+ },
)
assert resp.status_code == 200
@@ -258,7 +481,10 @@ def test_unknown_contact_returns_404(
resp = client.get(
SESSIONS_URL,
headers=headers,
- params={"contact_ref": f"contact-{random_lower_string()}"},
+ params={
+ "contact_ref": f"contact-{random_lower_string()}",
+ "chatbot_ref": f"chatbot-{random_lower_string()}",
+ },
)
assert resp.status_code == 404
@@ -270,6 +496,37 @@ def test_missing_contact_ref_returns_422(
assert resp.status_code == 422
+ def test_missing_chatbot_ref_returns_422(
+ self,
+ client: TestClient,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ ) -> None:
+ resp = client.get(
+ SESSIONS_URL,
+ headers=headers,
+ params={"contact_ref": chatbot_session.contact_ref},
+ )
+
+ assert resp.status_code == 422
+
+ def test_unknown_chatbot_ref_for_known_contact_returns_404(
+ self,
+ client: TestClient,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ ) -> None:
+ resp = client.get(
+ SESSIONS_URL,
+ headers=headers,
+ params={
+ "contact_ref": chatbot_session.contact_ref,
+ "chatbot_ref": f"chatbot-{random_lower_string()}",
+ },
+ )
+
+ assert resp.status_code == 404
+
class TestSendChatbotSessionMessage:
def test_stores_user_message_and_queues_turn(
@@ -396,3 +653,54 @@ def test_non_https_webhook_url_returns_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,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ chatbot_session: ChatbotSession,
+ enqueued: list[dict[str, Any]],
+ payload: dict[str, str],
+ ) -> None:
+ _supersede(db, chatbot_session)
+
+ resp = client.post(
+ _message_url(chatbot_session.id),
+ headers=headers,
+ json={**payload, "webhook_url": WEBHOOK_URL},
+ )
+
+ 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,
+ client: TestClient,
+ db: Session,
+ headers: dict[str, str],
+ enqueued: list[dict[str, Any]],
+ ) -> None:
+ foreign_config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob()
+ )
+ foreign_session = create_test_chatbot_session(db, config=foreign_config)
+ _supersede(db, foreign_session)
+
+ resp = client.post(
+ _message_url(foreign_session.id),
+ headers=headers,
+ json={"message": "Asha", "webhook_url": WEBHOOK_URL},
+ )
+
+ assert resp.status_code == 404
+ assert enqueued == []
diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py
index b3bdb8fcd..fbd22fa80 100644
--- a/backend/app/tests/crud/test_chatbot.py
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -1,17 +1,26 @@
+from collections.abc import Iterator
+from types import SimpleNamespace
from uuid import uuid4
import pytest
-from sqlmodel import Session
+from sqlalchemy.exc import IntegrityError
+from sqlmodel import Session, select
+from app.core.db import engine
from app.crud.chatbot import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
+ _is_active_session_conflict,
+ chatbot_ref_exists,
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
- get_latest_chatbot_session_by_contact_ref,
+ get_latest_chatbot_session,
list_chatbot_session_messages,
update_chatbot_session_progress,
)
from app.models import (
+ ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
@@ -55,6 +64,7 @@ def test_persists_session_as_active_with_no_collected_fields(
project_id=project.id,
name="Onboarding Bot",
contact_ref=contact_ref,
+ chatbot_ref="chatbot-onboarding",
config_id=config.id,
config_version=1,
system_prompt="Collect the user's name.",
@@ -184,24 +194,32 @@ def test_list_empty_session_returns_empty(
assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == []
-class TestGetLatestChatbotSessionByContactRef:
+class TestGetLatestChatbotSession:
def test_returns_newest_scoped_to_contact_and_project(
self, db: Session, config: Config, project: Project
) -> None:
contact_ref = f"contact-{random_lower_string()}"
- create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
- newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref)
- create_test_chatbot_session(db, config=config)
+ chatbot_ref = f"chatbot-{random_lower_string()}"
+ create_test_chatbot_session(
+ db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
+ )
+ newest = create_test_chatbot_session(
+ db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
+ )
+ create_test_chatbot_session(db, config=config, chatbot_ref=chatbot_ref)
other_config = create_test_config(
db, config_blob=build_anthropic_text_config_blob()
)
- create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref)
+ create_test_chatbot_session(
+ db, config=other_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
+ )
- latest = get_latest_chatbot_session_by_contact_ref(
+ latest = get_latest_chatbot_session(
db,
- contact_ref=contact_ref,
organization_id=project.organization_id,
project_id=project.id,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
)
assert latest is not None
@@ -213,15 +231,61 @@ def test_unknown_contact_returns_none(
create_test_chatbot_session(db, config=config)
assert (
- get_latest_chatbot_session_by_contact_ref(
+ get_latest_chatbot_session(
db,
- contact_ref=f"contact-{random_lower_string()}",
organization_id=project.organization_id,
project_id=project.id,
+ contact_ref=f"contact-{random_lower_string()}",
+ chatbot_ref=f"chatbot-{random_lower_string()}",
)
is None
)
+ def test_ignores_newer_session_of_another_chatbot(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ wanted = create_test_chatbot_session(db, config=config)
+ create_test_chatbot_session(db, config=config, contact_ref=wanted.contact_ref)
+
+ latest = get_latest_chatbot_session(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ contact_ref=wanted.contact_ref,
+ chatbot_ref=wanted.chatbot_ref,
+ )
+
+ assert latest is not None
+ assert latest.id == wanted.id
+
+ def test_returns_newest_even_when_inactive(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ older = create_test_chatbot_session(db, config=config)
+ newest = create_test_chatbot_session(
+ db,
+ config=config,
+ contact_ref=older.contact_ref,
+ chatbot_ref=older.chatbot_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()
+
+ latest = get_latest_chatbot_session(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ contact_ref=older.contact_ref,
+ chatbot_ref=older.chatbot_ref,
+ )
+
+ assert latest is not None
+ assert latest.id == newest.id
+ assert latest.status == ChatbotSessionStatusEnum.INACTIVE
+
class TestUpdateChatbotSessionProgress:
def test_persists_fields_and_status_and_bumps_updated_at(
@@ -280,3 +344,282 @@ def test_unknown_id_returns_none(self, db: Session) -> None:
)
is None
)
+
+
+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
+
+
+class TestCreateChatbotSessionSupersedes:
+ def test_in_progress_session_becomes_inactive_and_abandoned(
+ self, db: Session, config: Config
+ ) -> None:
+ older = create_test_chatbot_session(db, config=config)
+ older_updated_at = older.updated_at
+
+ newer = create_test_chatbot_session(
+ db,
+ config=config,
+ contact_ref=older.contact_ref,
+ chatbot_ref=older.chatbot_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, config: Config
+ ) -> None:
+ older = create_test_chatbot_session(db, config=config)
+ 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,
+ config=config,
+ contact_ref=older.contact_ref,
+ chatbot_ref=older.chatbot_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_contact_and_project_sessions_stay_active(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ chatbot_ref = f"chatbot-{random_lower_string()}"
+ other_chatbot = create_test_chatbot_session(
+ db, config=config, contact_ref=contact_ref
+ )
+ other_contact = create_test_chatbot_session(
+ db, config=config, chatbot_ref=chatbot_ref
+ )
+ # Same organization, so only the project_id filter keeps it out of scope.
+ sibling_project = create_test_project(
+ db, organization_id=project.organization_id
+ )
+ other_project_config = create_test_config(
+ db,
+ project_id=sibling_project.id,
+ config_blob=build_anthropic_text_config_blob(),
+ )
+ other_project = create_test_chatbot_session(
+ db,
+ config=other_project_config,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
+ )
+ untouched = [other_chatbot, other_contact, other_project]
+ updated_ats = [s.updated_at for s in untouched]
+
+ create_test_chatbot_session(
+ db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
+ )
+
+ for chatbot_session, updated_at in zip(untouched, updated_ats):
+ 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, config: Config
+ ) -> None:
+ first = create_test_chatbot_session(db, config=config)
+ refs = {"contact_ref": first.contact_ref, "chatbot_ref": first.chatbot_ref}
+ second = create_test_chatbot_session(db, config=config, **refs)
+ first_after_supersede = _reload(db, first).updated_at
+
+ third = create_test_chatbot_session(db, config=config, **refs)
+
+ 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]:
+ # create_chatbot_session rolls 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_config(
+ savepoint_db, config_blob=build_anthropic_text_config_blob()
+ )
+ prior = create_test_chatbot_session(savepoint_db, config=config)
+
+ with pytest.raises(IntegrityError):
+ create_chatbot_session(
+ savepoint_db,
+ organization_id=prior.organization_id,
+ project_id=prior.project_id,
+ name="Onboarding Bot",
+ contact_ref=prior.contact_ref,
+ chatbot_ref=prior.chatbot_ref,
+ config_id=uuid4(),
+ config_version=None,
+ system_prompt="Collect the user's name.",
+ )
+
+ 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_inserts_nothing(
+ self, savepoint_db: Session
+ ) -> None:
+ config = create_test_config(
+ savepoint_db, config_blob=build_anthropic_text_config_blob()
+ )
+ project = savepoint_db.get(Project, config.project_id)
+ assert project is not None
+ unrelated_org_id = create_test_project(savepoint_db).organization_id
+ contact_ref = f"contact-{random_lower_string()}"
+ chatbot_ref = f"chatbot-{random_lower_string()}"
+ # Stands in for a racing request's committed row: the unique index ignores
+ # organization_id but the supersede UPDATE filters on it, so this row
+ # survives the UPDATE exactly like one committed after it ran.
+ racing = ChatbotSession(
+ organization_id=unrelated_org_id,
+ project_id=project.id,
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
+ config_id=config.id,
+ system_prompt="racing",
+ )
+ savepoint_db.add(racing)
+ savepoint_db.commit()
+
+ with pytest.raises(ChatbotSessionConflictError) as exc_info:
+ create_chatbot_session(
+ savepoint_db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ name="Onboarding Bot",
+ contact_ref=contact_ref,
+ chatbot_ref=chatbot_ref,
+ config_id=config.id,
+ config_version=None,
+ system_prompt="Collect the user's name.",
+ )
+
+ assert str(exc_info.value) == CONCURRENT_SESSION_CONFLICT_MESSAGE
+ savepoint_db.expire_all()
+ stored = savepoint_db.exec(
+ select(ChatbotSession).where(
+ ChatbotSession.project_id == project.id,
+ ChatbotSession.chatbot_ref == chatbot_ref,
+ )
+ ).all()
+ assert [s.id for s in stored] == [racing.id]
+ assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE
+
+
+class TestIsActiveSessionConflict:
+ # 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=ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX
+ )
+ ),
+ True,
+ ),
+ (
+ SimpleNamespace(
+ diag=SimpleNamespace(
+ constraint_name="chatbot_session_config_id_fkey"
+ )
+ ),
+ False,
+ ),
+ (
+ Exception(
+ f'duplicate key value violates unique constraint "{ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX}"'
+ ),
+ True,
+ ),
+ (Exception("violates foreign key constraint"), False),
+ ],
+ ids=["diag-match", "diag-other", "message-match", "message-other"],
+ )
+ def test_detects_only_the_active_session_index(
+ self, orig: object, expected: bool
+ ) -> None:
+ error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type]
+
+ assert _is_active_session_conflict(error) is expected
+
+
+class TestChatbotRefExists:
+ def test_true_for_active_and_inactive_sessions_in_project(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ first = create_test_chatbot_session(db, config=config)
+ create_test_chatbot_session(
+ db,
+ config=config,
+ contact_ref=first.contact_ref,
+ chatbot_ref=first.chatbot_ref,
+ )
+ inactive_only = create_test_chatbot_session(db, config=config)
+ create_test_chatbot_session(
+ db,
+ config=config,
+ contact_ref=inactive_only.contact_ref,
+ chatbot_ref=inactive_only.chatbot_ref,
+ )
+
+ assert chatbot_ref_exists(
+ db, project_id=project.id, chatbot_ref=first.chatbot_ref
+ )
+ assert chatbot_ref_exists(
+ db, project_id=project.id, chatbot_ref=inactive_only.chatbot_ref
+ )
+
+ def test_false_for_ref_used_only_in_another_project(
+ self, db: Session, config: Config
+ ) -> None:
+ chatbot_session = create_test_chatbot_session(db, config=config)
+ other_project = create_test_project(db)
+
+ assert not chatbot_ref_exists(
+ db, project_id=other_project.id, chatbot_ref=chatbot_session.chatbot_ref
+ )
diff --git a/backend/app/tests/services/chatbot/test_chatbot_ref.py b/backend/app/tests/services/chatbot/test_chatbot_ref.py
new file mode 100644
index 000000000..3fcbea589
--- /dev/null
+++ b/backend/app/tests/services/chatbot/test_chatbot_ref.py
@@ -0,0 +1,104 @@
+import logging
+import re
+
+import pytest
+from sqlmodel import Session
+
+from app.models import Config
+from app.services.chatbot import chatbot_ref as chatbot_ref_module
+from app.services.chatbot.chatbot_ref import (
+ CHATBOT_REF_MAX_ATTEMPTS,
+ CHATBOT_REF_PREFIX,
+ CHATBOT_REF_SUFFIX_LENGTH,
+ generate_chatbot_ref,
+)
+from app.services.chatbot.exceptions import ChatbotRefGenerationError
+from app.tests.utils.chatbot import create_test_chatbot_session
+from app.tests.utils.test_data import (
+ build_anthropic_text_config_blob,
+ create_test_config,
+)
+
+USED_SUFFIX = 0xABCDE
+USED_REF = "LLMResponseWithPrompt-abcde"
+FRESH_SUFFIX = 0x1
+FRESH_REF = "LLMResponseWithPrompt-00001"
+
+
+@pytest.fixture
+def config(db: Session) -> Config:
+ return create_test_config(db, config_blob=build_anthropic_text_config_blob())
+
+
+def _script_randbelow(monkeypatch: pytest.MonkeyPatch, values: list[int]) -> list[int]:
+ """Replace the CSPRNG with a script; returns the list of bounds it was asked for."""
+ bounds: list[int] = []
+ remaining = iter(values)
+
+ def fake_randbelow(bound: int) -> int:
+ bounds.append(bound)
+ return next(remaining)
+
+ monkeypatch.setattr(chatbot_ref_module.secrets, "randbelow", fake_randbelow)
+ return bounds
+
+
+class TestGenerateChatbotRef:
+ def test_ref_is_prefix_plus_lowercase_hex_suffix(
+ self, db: Session, config: Config
+ ) -> None:
+ ref = generate_chatbot_ref(db, project_id=config.project_id)
+
+ pattern = (
+ rf"{re.escape(CHATBOT_REF_PREFIX)}[0-9a-f]{{{CHATBOT_REF_SUFFIX_LENGTH}}}"
+ )
+ assert re.fullmatch(pattern, ref)
+
+ def test_small_suffix_is_zero_padded(
+ self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ _script_randbelow(monkeypatch, [FRESH_SUFFIX])
+
+ assert generate_chatbot_ref(db, project_id=config.project_id) == FRESH_REF
+
+ def test_skips_ref_already_used_in_project(
+ self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ create_test_chatbot_session(db, config=config, chatbot_ref=USED_REF)
+ _script_randbelow(monkeypatch, [USED_SUFFIX, FRESH_SUFFIX])
+
+ assert generate_chatbot_ref(db, project_id=config.project_id) == FRESH_REF
+
+ def test_accepts_ref_used_only_in_another_project(
+ self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ other_config = create_test_config(
+ db, config_blob=build_anthropic_text_config_blob()
+ )
+ create_test_chatbot_session(db, config=other_config, chatbot_ref=USED_REF)
+ _script_randbelow(monkeypatch, [USED_SUFFIX])
+
+ assert generate_chatbot_ref(db, project_id=config.project_id) == USED_REF
+
+ def test_raises_and_logs_after_max_attempts(
+ self,
+ db: Session,
+ config: Config,
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+ ) -> None:
+ create_test_chatbot_session(db, config=config, chatbot_ref=USED_REF)
+ # One extra value: a loop that over-runs the cap would return FRESH_REF.
+ bounds = _script_randbelow(
+ monkeypatch, [USED_SUFFIX] * CHATBOT_REF_MAX_ATTEMPTS + [FRESH_SUFFIX]
+ )
+
+ with caplog.at_level(logging.ERROR, logger=chatbot_ref_module.__name__):
+ with pytest.raises(ChatbotRefGenerationError) as exc_info:
+ generate_chatbot_ref(db, project_id=config.project_id)
+
+ assert len(bounds) == CHATBOT_REF_MAX_ATTEMPTS
+ assert "(code: ChatbotRefGenerationError)" in str(exc_info.value)
+ [record] = [r for r in caplog.records if r.levelno == logging.ERROR]
+ assert record.getMessage().startswith("[generate_chatbot_ref]")
+ assert f"project_id: {config.project_id}" in record.getMessage()
diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py
index f0a71971b..f238a5e5c 100644
--- a/backend/app/tests/services/chatbot/test_session.py
+++ b/backend/app/tests/services/chatbot/test_session.py
@@ -129,6 +129,7 @@ def test_first_call_stores_greeting_and_posts_success_webhook(
"success": True,
"data": {
"session_id": str(chatbot_session.id),
+ "chatbot_ref": chatbot_session.chatbot_ref,
"reply": "Hi! What's your name?",
"status": "active",
"completion_status": "in_progress",
@@ -402,6 +403,7 @@ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them(
"success": True,
"data": {
"session_id": str(chatbot_session.id),
+ "chatbot_ref": chatbot_session.chatbot_ref,
"reply": "Thanks, all set!",
"status": "active",
"completion_status": "complete",
diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py
index 2fa5a3772..7137bc766 100644
--- a/backend/app/tests/utils/chatbot.py
+++ b/backend/app/tests/utils/chatbot.py
@@ -102,6 +102,7 @@ def create_test_chatbot_session(
config: Config,
name: str = DEFAULT_CHATBOT_NAME,
contact_ref: str | None = None,
+ chatbot_ref: str | None = None,
config_version: int | None = None,
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
) -> ChatbotSession:
@@ -113,6 +114,7 @@ def create_test_chatbot_session(
project_id=project.id,
name=name,
contact_ref=contact_ref or f"contact-{random_lower_string()}",
+ chatbot_ref=chatbot_ref or f"chatbot-{random_lower_string()}",
config_id=config.id,
config_version=config_version,
system_prompt=system_prompt,
diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py
index 34fb4ce50..1c0bb5368 100644
--- a/backend/app/tests/utils/test_data.py
+++ b/backend/app/tests/utils/test_data.py
@@ -57,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)
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index 4671f7799..eb1ec0f82 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -7,14 +7,15 @@ All paths relative to `backend/app/`.
## Routes
- `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`)
- `api/routes/chatbot/session.py`
- - `POST /chatbot/sessions` (create; validates the config → `{session_id}`)
- - `GET /chatbot/sessions?contact_ref=` (latest session for a contact in the caller's project → `ChatbotSessionSummary`, incl. `status`, `completion_status`, `collected_fields`)
- - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`)
+ - `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated)
+ - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`)
+ - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive)
- All require project scope.
## Tables (SQLModel, `models/chatbot.py`)
-- `chatbot_session` (`ChatbotSession`) — `name`, `contact_ref`, `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete = all required fields collected), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Migration `088`.
+- `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`.
- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session).
+- Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same `contact_ref` + `chatbot_ref` (same transaction as the insert) moves the previous active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued when the session was superseded is skipped by the worker, which posts a failure webhook instead. A partial unique index `uq_chatbot_session_active_contact_chatbot` (`WHERE status = 'active'`) makes a concurrent double-create fail; crud raises `ChatbotSessionConflictError` → 409.
- Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`.
## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`)
@@ -28,15 +29,16 @@ All paths relative to `backend/app/`.
- `services/chatbot/agents/onboarding/types.py` — `OnboardingState`, `FieldExtraction`
- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `to_message_dicts`, kickoff message, `has_user_turn`, `insert_after_leading_system`
- `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict`
-- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history, run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook)
+- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history; an inactive session gets a failure webhook and no turn; otherwise run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook)
- `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs)
- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model`
-- `crud/chatbot.py` — `create_chatbot_session`, `get_chatbot_session`, `get_latest_chatbot_session_by_contact_ref`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages`
+- `services/chatbot/chatbot_ref.py` — `generate_chatbot_ref` (random `LLMResponseWithPrompt-<5 hex>`, retried against `chatbot_ref_exists` up to `CHATBOT_REF_MAX_ATTEMPTS`, else `ChatbotRefGenerationError`)
+- `crud/chatbot.py` — `chatbot_ref_exists`, `create_chatbot_session` (also supersedes prior active sessions via `_deactivate_active_chatbot_sessions`; raises `ChatbotSessionConflictError` on the active-session unique index), `get_chatbot_session`, `get_latest_chatbot_session`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages`
- `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted)
## 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", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`.
+- Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_ref", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`.
## External
- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting) — two calls per turn once the user has spoken (extraction + reply), plus one summary call when history exceeds the token budget.
From 3e09675309e422e3112d2e77846e4138d4cbad05 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 22:52:47 +0530
Subject: [PATCH 09/15] feat: added the feature flag
---
.../versions/089_add_chatbot_feature_flag.py | 22 ++++++++++
backend/app/api/routes/chatbot/__init__.py | 10 ++++-
backend/app/core/feature_flags/constants.py | 1 +
.../app/tests/api/routes/chatbot/conftest.py | 28 +++++++++++++
.../api/routes/chatbot/test_feature_flag.py | 41 +++++++++++++++++++
docs/wiki/modules/chatbot.md | 2 +-
6 files changed, 101 insertions(+), 3 deletions(-)
create mode 100644 backend/app/alembic/versions/089_add_chatbot_feature_flag.py
create mode 100644 backend/app/tests/api/routes/chatbot/conftest.py
create mode 100644 backend/app/tests/api/routes/chatbot/test_feature_flag.py
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/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py
index 151b59066..8160b15b0 100644
--- a/backend/app/api/routes/chatbot/__init__.py
+++ b/backend/app/api/routes/chatbot/__init__.py
@@ -1,8 +1,14 @@
-from fastapi import APIRouter
+from fastapi import APIRouter, Depends
+from app.api.permissions import require_feature
from app.api.routes.chatbot.message import router as message_router
from app.api.routes.chatbot.session import router as session_router
+from app.core.feature_flags import FeatureFlag
-router = APIRouter(prefix="/chatbot", tags=["Chatbot"])
+router = APIRouter(
+ prefix="/chatbot",
+ tags=["Chatbot"],
+ dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))],
+)
router.include_router(message_router)
router.include_router(session_router)
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/tests/api/routes/chatbot/conftest.py b/backend/app/tests/api/routes/chatbot/conftest.py
new file mode 100644
index 000000000..8192a6b95
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/conftest.py
@@ -0,0 +1,28 @@
+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.tests.utils.auth import TestAuthContext
+
+
+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)
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..67824e841
--- /dev/null
+++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
@@ -0,0 +1,41 @@
+from uuid import uuid4
+
+import pytest
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.core.config import settings
+from app.tests.api.routes.chatbot.conftest import set_chatbot_flag
+from app.tests.utils.auth import TestAuthContext
+
+CHATBOT_URL = f"{settings.API_V1_STR}/chatbot"
+
+
+@pytest.mark.parametrize(
+ ("method", "path", "kwargs"),
+ [
+ ("post", "/message", {"json": {}}),
+ ("post", "/sessions", {"json": {}}),
+ ("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}),
+ ("post", f"/sessions/{uuid4()}/message", {"json": {}}),
+ ],
+)
+def test_chatbot_routes_return_403_when_flag_disabled(
+ client: TestClient,
+ db: Session,
+ user_api_key: TestAuthContext,
+ method: str,
+ path: str,
+ kwargs: dict,
+) -> None:
+ set_chatbot_flag(db, user_api_key, enabled=False)
+
+ resp = client.request(
+ method,
+ f"{CHATBOT_URL}{path}",
+ headers={"X-API-KEY": user_api_key.key},
+ **kwargs,
+ )
+
+ assert resp.status_code == 403
+ assert "CHATBOT" in resp.json()["error"]
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index eb1ec0f82..b1f443873 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -10,7 +10,7 @@ All paths relative to `backend/app/`.
- `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated)
- `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`)
- `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive)
-- All require project scope.
+- All require project scope and the `CHATBOT` feature flag (`require_feature` on the `/chatbot` 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`).
## Tables (SQLModel, `models/chatbot.py`)
- `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`.
From c18a3a82de54e8e5d32696aaf36925fef3815103 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 22:58:19 +0530
Subject: [PATCH 10/15] chore: remove the unused code
---
backend/app/api/docs/chatbot/message.md | 9 -
backend/app/api/routes/chatbot/__init__.py | 2 -
backend/app/api/routes/chatbot/message.py | 58 ------
backend/app/models/__init__.py | 3 -
backend/app/models/chatbot.py | 51 -----
.../api/routes/chatbot/test_feature_flag.py | 1 -
.../tests/api/routes/chatbot/test_message.py | 180 ------------------
docs/wiki/modules/chatbot.md | 5 +-
8 files changed, 2 insertions(+), 307 deletions(-)
delete mode 100644 backend/app/api/docs/chatbot/message.md
delete mode 100644 backend/app/api/routes/chatbot/message.py
delete mode 100644 backend/app/tests/api/routes/chatbot/test_message.py
diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md
deleted file mode 100644
index 1e189473f..000000000
--- a/backend/app/api/docs/chatbot/message.md
+++ /dev/null
@@ -1,9 +0,0 @@
-Run one turn of a prompt-driven chatbot conversation and return the full updated transcript.
-
-The chat model comes from a saved Kaapi config: `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set. `config_version` picks a version (latest if omitted). The config's `params.effort` sets reasoning effort (provider default if absent) and `params.thinking` overrides the default adaptive thinking. `max_tokens` (default 1024) and `history_token_limit` (default 4000; older turns are summarized above it) are optional per-request overrides.
-
-There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`.
-
-Each turn also extracts the fields `system_prompt` asks to collect from the whole conversation: `collected_fields` is a flat `{field_name: value}` map (snake_case keys, only values the user actually gave) and `completion_status` is `complete` once nothing required is missing, else `in_progress`. Both are recomputed from the transcript on every call; the opening greeting returns `{}` and `in_progress`.
-
-On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures.
diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py
index 8160b15b0..5237ac117 100644
--- a/backend/app/api/routes/chatbot/__init__.py
+++ b/backend/app/api/routes/chatbot/__init__.py
@@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends
from app.api.permissions import require_feature
-from app.api.routes.chatbot.message import router as message_router
from app.api.routes.chatbot.session import router as session_router
from app.core.feature_flags import FeatureFlag
@@ -10,5 +9,4 @@
tags=["Chatbot"],
dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))],
)
-router.include_router(message_router)
router.include_router(session_router)
diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py
deleted file mode 100644
index c5020da48..000000000
--- a/backend/app/api/routes/chatbot/message.py
+++ /dev/null
@@ -1,58 +0,0 @@
-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.agents.onboarding.graph import run_chatbot_turn
-from app.services.chatbot.config import resolve_chatbot_model_settings
-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,
- )
- turn_result = 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 turn_result.transcript:
- messages.append(ChatbotMessage.model_validate(entry))
- return APIResponse.success_response(
- ChatbotTurnResponse(
- messages=messages,
- collected_fields=turn_result.collected_fields,
- completion_status=turn_result.completion_status,
- )
- )
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index f51aa778f..0a112ca9f 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -41,14 +41,11 @@
CONTACT_REF_MAX_LENGTH,
DEFAULT_CHATBOT_NAME,
ChatbotCompletionStatusEnum,
- ChatbotMessage,
ChatbotMessageRoleEnum,
ChatbotSession,
ChatbotSessionMessage,
ChatbotSessionStatusEnum,
ChatbotSessionSummary,
- ChatbotTurnRequest,
- ChatbotTurnResponse,
CreateChatbotSessionRequest,
CreateChatbotSessionResponse,
SendChatbotSessionMessageRequest,
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
index 71c808c8f..14db3cae4 100644
--- a/backend/app/models/chatbot.py
+++ b/backend/app/models/chatbot.py
@@ -16,45 +16,6 @@ class ChatbotMessageRoleEnum(StrEnum):
ASSISTANT = "assistant"
-class ChatbotMessage(SQLModel):
- role: ChatbotMessageRoleEnum
- content: str
-
-
-class ChatbotTurnRequest(SQLModel):
- config_id: UUID = Field(
- description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.",
- )
- config_version: int | None = Field(
- default=None,
- ge=1,
- description="Version of `config_id` to use; omit to use its latest version.",
- )
- max_tokens: int | None = Field(
- default=None,
- gt=0,
- description="Cap on output tokens per model call; omit for the server default.",
- )
- history_token_limit: int | None = Field(
- default=None,
- gt=0,
- description="Token size of the history above which older turns are summarized; omit for the server default.",
- )
- system_prompt: str = Field(
- min_length=1,
- description="Admin-authored flow prompt (goal, fields to collect, rules).",
- )
- history: list[ChatbotMessage] = Field(
- default_factory=list,
- description="Every message before this turn, as returned by the previous call; empty on the first call.",
- )
- message: str | None = Field(
- default=None,
- min_length=1,
- description="The new user message; omit only to get the opening greeting on the first call.",
- )
-
-
class ChatbotSessionStatusEnum(StrEnum):
ACTIVE = "active"
INACTIVE = "inactive"
@@ -66,18 +27,6 @@ class ChatbotCompletionStatusEnum(StrEnum):
ABANDONED = "abandoned"
-class ChatbotTurnResponse(SQLModel):
- messages: list[ChatbotMessage] = Field(
- description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.",
- )
- collected_fields: dict[str, Any] = Field(
- description="Flat map of field name -> value the user has provided so far, as extracted from the transcript.",
- )
- completion_status: ChatbotCompletionStatusEnum = Field(
- description="`complete` once every field the system prompt asks for has been collected, else `in_progress`.",
- )
-
-
_SESSION_STATUS_PG_ENUM = ENUM(
ChatbotSessionStatusEnum,
name="chatbotsessionstatusenum",
diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
index 67824e841..3a6dbf14c 100644
--- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py
+++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
@@ -14,7 +14,6 @@
@pytest.mark.parametrize(
("method", "path", "kwargs"),
[
- ("post", "/message", {"json": {}}),
("post", "/sessions", {"json": {}}),
("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}),
("post", f"/sessions/{uuid4()}/message", {"json": {}}),
diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py
deleted file mode 100644
index 54b57250d..000000000
--- a/backend/app/tests/api/routes/chatbot/test_message.py
+++ /dev/null
@@ -1,180 +0,0 @@
-import anthropic
-import httpx
-import pytest
-from fastapi.testclient import TestClient
-from sqlmodel import Session
-
-from app.core.config import settings
-from app.models import Config
-from app.services.chatbot.agents.onboarding.types import FieldExtraction
-from app.tests.utils.auth import TestAuthContext
-from app.tests.utils.chatbot import (
- ScriptedChatModel,
- extracted_fields,
- patch_chat_model,
-)
-from app.tests.utils.test_data import (
- build_anthropic_text_config_blob,
- create_test_config,
-)
-
-MESSAGE_URL = f"{settings.API_V1_STR}/chatbot/message"
-SYSTEM_PROMPT = "Collect the user's name and city."
-
-
-@pytest.fixture
-def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config:
- return create_test_config(
- db,
- project_id=user_api_key.project_id,
- config_blob=build_anthropic_text_config_blob(),
- )
-
-
-class TestSendChatbotMessage:
- def test_returns_updated_transcript(
- self,
- client: TestClient,
- user_api_key: TestAuthContext,
- anthropic_config: Config,
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Nice, which city?"]))
-
- resp = client.post(
- MESSAGE_URL,
- headers={"X-API-KEY": user_api_key.key},
- json={
- "config_id": str(anthropic_config.id),
- "system_prompt": SYSTEM_PROMPT,
- "history": [
- {"role": "system", "content": SYSTEM_PROMPT},
- {"role": "assistant", "content": "Hi! What's your name?"},
- ],
- "message": "Asha",
- },
- )
-
- assert resp.status_code == 200
- assert resp.json()["data"]["messages"] == [
- {"role": "system", "content": SYSTEM_PROMPT},
- {"role": "assistant", "content": "Hi! What's your name?"},
- {"role": "user", "content": "Asha"},
- {"role": "assistant", "content": "Nice, which city?"},
- ]
-
- def test_returns_extracted_fields_and_completion_status(
- self,
- client: TestClient,
- user_api_key: TestAuthContext,
- anthropic_config: Config,
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- patch_chat_model(
- monkeypatch,
- ScriptedChatModel(
- replies=["Thanks, Asha from Pune!"],
- extractions=[
- FieldExtraction(
- collected_fields=extracted_fields(
- {"name": "Asha", "city": "Pune"}
- ),
- missing_fields=[],
- is_complete=True,
- )
- ],
- ),
- )
-
- resp = client.post(
- MESSAGE_URL,
- headers={"X-API-KEY": user_api_key.key},
- json={
- "config_id": str(anthropic_config.id),
- "system_prompt": SYSTEM_PROMPT,
- "history": [
- {"role": "assistant", "content": "Hi! What's your name and city?"}
- ],
- "message": "Asha, Pune",
- },
- )
-
- assert resp.status_code == 200
- body = resp.json()["data"]
- assert body["collected_fields"] == {"name": "Asha", "city": "Pune"}
- assert body["completion_status"] == "complete"
-
- def test_greeting_returns_no_fields_in_progress(
- self,
- client: TestClient,
- user_api_key: TestAuthContext,
- anthropic_config: Config,
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- patch_chat_model(
- monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"])
- )
-
- resp = client.post(
- MESSAGE_URL,
- headers={"X-API-KEY": user_api_key.key},
- json={
- "config_id": str(anthropic_config.id),
- "system_prompt": SYSTEM_PROMPT,
- },
- )
-
- assert resp.status_code == 200
- body = resp.json()["data"]
- assert body["collected_fields"] == {}
- assert body["completion_status"] == "in_progress"
-
- def test_missing_message_with_history_returns_422(
- self,
- client: TestClient,
- user_api_key: TestAuthContext,
- anthropic_config: Config,
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- chat_model = ScriptedChatModel()
- patch_chat_model(monkeypatch, chat_model)
-
- resp = client.post(
- MESSAGE_URL,
- headers={"X-API-KEY": user_api_key.key},
- json={
- "config_id": str(anthropic_config.id),
- "system_prompt": SYSTEM_PROMPT,
- "history": [{"role": "assistant", "content": "Hi! What's your name?"}],
- },
- )
-
- assert resp.status_code == 422
- assert "`message` is required" in resp.json()["error"]
- assert chat_model.calls == []
-
- def test_provider_failure_returns_502(
- self,
- client: TestClient,
- user_api_key: TestAuthContext,
- anthropic_config: Config,
- monkeypatch: pytest.MonkeyPatch,
- ) -> None:
- request = httpx.Request("POST", "https://api.anthropic.com/v1/messages")
- patch_chat_model(
- monkeypatch,
- ScriptedChatModel(error=anthropic.APIConnectionError(request=request)),
- )
-
- resp = client.post(
- MESSAGE_URL,
- headers={"X-API-KEY": user_api_key.key},
- json={
- "config_id": str(anthropic_config.id),
- "system_prompt": SYSTEM_PROMPT,
- "message": "Asha",
- },
- )
-
- assert resp.status_code == 502
- assert "(code: APIConnectionError)" in resp.json()["error"]
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index b1f443873..00c2d7259 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -1,11 +1,10 @@
# Module: Chatbot
-Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Two entry points: a stateless synchronous turn (caller resends `system_prompt` + full `history`) and server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet.
+Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Conversations are server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet.
All paths relative to `backend/app/`.
## Routes
-- `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`)
- `api/routes/chatbot/session.py`
- `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated)
- `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`)
@@ -16,7 +15,7 @@ All paths relative to `backend/app/`.
- `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`.
- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session).
- Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same `contact_ref` + `chatbot_ref` (same transaction as the insert) moves the previous active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued when the session was superseded is skipped by the worker, which posts a failure webhook instead. A partial unique index `uq_chatbot_session_active_contact_chatbot` (`WHERE status = 'active'`) makes a concurrent double-create fail; crud raises `ChatbotSessionConflictError` → 409.
-- Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`.
+- Request/response shapes: `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`.
## 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 admin 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.
From 440e5c888b5bd67ac15164817e7a4b20fa4195ff Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:30:54 +0530
Subject: [PATCH 11/15] fix: few updates on the get request
---
.../api/docs/chatbot/get_latest_session.md | 6 ++-
backend/app/api/routes/chatbot/session.py | 14 ++++---
backend/app/crud/chatbot.py | 19 ++++------
backend/app/models/chatbot.py | 1 +
.../tests/api/routes/chatbot/test_session.py | 38 +++++++++++++++++--
backend/app/tests/crud/test_chatbot.py | 22 ++++++++++-
docs/wiki/modules/chatbot.md | 2 +-
7 files changed, 77 insertions(+), 25 deletions(-)
diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md
index bbec7bbff..3a3aa41a8 100644
--- a/backend/app/api/docs/chatbot/get_latest_session.md
+++ b/backend/app/api/docs/chatbot/get_latest_session.md
@@ -1,3 +1,5 @@
-Get the most recently created chatbot session for the given `contact_ref` and `chatbot_ref` in the caller's project.
+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`.
-Both refs are required and matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). The response carries the session's 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 the contact has no sessions for this chatbot in this project.
+`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.
diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
index 2ab7f2ad1..ace46ec2f 100644
--- a/backend/app/api/routes/chatbot/session.py
+++ b/backend/app/api/routes/chatbot/session.py
@@ -107,16 +107,16 @@ def read_latest_chatbot_session(
description="Caller-side identifier of the end user whose latest session to fetch",
),
],
+ session: SessionDep,
+ auth_context: AuthContextDep,
chatbot_ref: Annotated[
- str,
+ str | None,
Query(
min_length=1,
max_length=CHATBOT_REF_MAX_LENGTH,
- description="Caller-side identifier of the chatbot whose latest session to fetch",
+ description="Chatbot whose latest session to fetch; omit to get the contact's newest session across all chatbots",
),
- ],
- session: SessionDep,
- auth_context: AuthContextDep,
+ ] = None,
) -> APIResponse[ChatbotSessionSummary]:
chatbot_session = get_latest_chatbot_session(
session,
@@ -128,7 +128,8 @@ def read_latest_chatbot_session(
if chatbot_session is None:
raise HTTPException(
status_code=404,
- detail="No chatbot session found for this contact_ref and chatbot_ref",
+ detail="No chatbot session found for this contact_ref"
+ + (" and chatbot_ref" if chatbot_ref is not None else ""),
)
return APIResponse.success_response(
ChatbotSessionSummary(
@@ -136,6 +137,7 @@ def read_latest_chatbot_session(
name=chatbot_session.name,
contact_ref=chatbot_session.contact_ref,
chatbot_ref=chatbot_session.chatbot_ref,
+ system_prompt=chatbot_session.system_prompt,
status=chatbot_session.status,
completion_status=chatbot_session.completion_status,
collected_fields=chatbot_session.collected_fields,
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
index fdb6d9f41..0ca902bd1 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -232,17 +232,14 @@ def get_latest_chatbot_session(
organization_id: int,
project_id: int,
contact_ref: str,
- chatbot_ref: str,
+ chatbot_ref: str | None = None,
) -> ChatbotSession | None:
- statement = (
- select(ChatbotSession)
- .where(
- ChatbotSession.organization_id == organization_id,
- ChatbotSession.project_id == project_id,
- ChatbotSession.contact_ref == contact_ref,
- ChatbotSession.chatbot_ref == chatbot_ref,
- )
- .order_by(col(ChatbotSession.inserted_at).desc())
- .limit(1)
+ statement = select(ChatbotSession).where(
+ ChatbotSession.organization_id == organization_id,
+ ChatbotSession.project_id == project_id,
+ ChatbotSession.contact_ref == contact_ref,
)
+ if chatbot_ref is not None:
+ statement = statement.where(ChatbotSession.chatbot_ref == chatbot_ref)
+ statement = statement.order_by(col(ChatbotSession.inserted_at).desc()).limit(1)
return session.exec(statement).first()
diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py
index 14db3cae4..2310e2ecb 100644
--- a/backend/app/models/chatbot.py
+++ b/backend/app/models/chatbot.py
@@ -248,6 +248,7 @@ class ChatbotSessionSummary(SQLModel):
name: str
contact_ref: str
chatbot_ref: str
+ system_prompt: str
status: ChatbotSessionStatusEnum
completion_status: ChatbotCompletionStatusEnum
collected_fields: dict[str, Any]
diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py
index 36d9c9887..55d5c972f 100644
--- a/backend/app/tests/api/routes/chatbot/test_session.py
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -417,6 +417,7 @@ def test_returns_contacts_newest_session_in_callers_project(
contact_ref=contact_ref,
chatbot_ref=chatbot_ref,
config_version=1,
+ system_prompt="Collect the student's name and grade.",
)
create_test_chatbot_session(
db, config=anthropic_config, chatbot_ref=chatbot_ref
@@ -440,6 +441,7 @@ def test_returns_contacts_newest_session_in_callers_project(
assert body["name"] == "Onboarding Bot"
assert body["contact_ref"] == contact_ref
assert body["chatbot_ref"] == chatbot_ref
+ assert body["system_prompt"] == "Collect the student's name and grade."
assert body["status"] == "active"
assert body["completion_status"] == "in_progress"
assert body["collected_fields"] == {}
@@ -496,19 +498,47 @@ def test_missing_contact_ref_returns_422(
assert resp.status_code == 422
- def test_missing_chatbot_ref_returns_422(
+ def test_omitted_chatbot_ref_returns_contacts_newest_session_across_chatbots(
self,
client: TestClient,
+ db: Session,
headers: dict[str, str],
- chatbot_session: ChatbotSession,
+ anthropic_config: Config,
+ ) -> None:
+ contact_ref = f"contact-{random_lower_string()}"
+ create_test_chatbot_session(
+ db,
+ config=anthropic_config,
+ contact_ref=contact_ref,
+ chatbot_ref=f"chatbot-{random_lower_string()}",
+ )
+ newest = create_test_chatbot_session(
+ db,
+ config=anthropic_config,
+ contact_ref=contact_ref,
+ chatbot_ref=f"chatbot-{random_lower_string()}",
+ )
+
+ resp = client.get(
+ SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref}
+ )
+
+ assert resp.status_code == 200
+ body = resp.json()["data"]
+ assert body["session_id"] == str(newest.id)
+ assert body["chatbot_ref"] == newest.chatbot_ref
+
+ def test_omitted_chatbot_ref_unknown_contact_returns_404(
+ self, client: TestClient, headers: dict[str, str]
) -> None:
resp = client.get(
SESSIONS_URL,
headers=headers,
- params={"contact_ref": chatbot_session.contact_ref},
+ params={"contact_ref": f"contact-{random_lower_string()}"},
)
- assert resp.status_code == 422
+ assert resp.status_code == 404
+ assert resp.json()["error"] == "No chatbot session found for this contact_ref"
def test_unknown_chatbot_ref_for_known_contact_returns_404(
self,
diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py
index fbd22fa80..6a5b5c0ce 100644
--- a/backend/app/tests/crud/test_chatbot.py
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -241,6 +241,26 @@ def test_unknown_contact_returns_none(
is None
)
+ def test_without_chatbot_ref_returns_newest_across_chatbots(
+ self, db: Session, config: Config, project: Project
+ ) -> None:
+ older = create_test_chatbot_session(db, config=config)
+ newest = create_test_chatbot_session(
+ db, config=config, contact_ref=older.contact_ref
+ )
+ create_test_chatbot_session(db, config=config)
+
+ latest = get_latest_chatbot_session(
+ db,
+ organization_id=project.organization_id,
+ project_id=project.id,
+ contact_ref=older.contact_ref,
+ )
+
+ assert latest is not None
+ assert latest.id == newest.id
+ assert latest.chatbot_ref != older.chatbot_ref
+
def test_ignores_newer_session_of_another_chatbot(
self, db: Session, config: Config, project: Project
) -> None:
@@ -431,7 +451,7 @@ def test_other_chatbot_contact_and_project_sessions_stay_active(
db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref
)
- for chatbot_session, updated_at in zip(untouched, updated_ats):
+ 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
diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md
index 00c2d7259..4b482448a 100644
--- a/docs/wiki/modules/chatbot.md
+++ b/docs/wiki/modules/chatbot.md
@@ -7,7 +7,7 @@ All paths relative to `backend/app/`.
## Routes
- `api/routes/chatbot/session.py`
- `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated)
- - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`)
+ - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact in the caller's project; `chatbot_ref` optional — omitted means newest across all chatbots → `ChatbotSessionSummary`, incl. `chatbot_ref`, `system_prompt`, `status`, `completion_status`, `collected_fields`)
- `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive)
- All require project scope and the `CHATBOT` feature flag (`require_feature` on the `/chatbot` 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`).
From f44ba822ea2656ca36564826dace3eebb5169736 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sat, 24 Oct 2026 16:21:30 +0530
Subject: [PATCH 12/15] fix: cleanups
---
.../versions/088_add_chatbot_session.py | 7 ------
backend/app/crud/chatbot.py | 25 ++-----------------
.../chatbot/agents/onboarding/graph.py | 7 +-----
backend/app/services/chatbot/chatbot_ref.py | 5 +---
backend/app/services/chatbot/session.py | 13 +---------
5 files changed, 5 insertions(+), 52 deletions(-)
diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py
index b7f94ce8b..cd06ed363 100644
--- a/backend/app/alembic/versions/088_add_chatbot_session.py
+++ b/backend/app/alembic/versions/088_add_chatbot_session.py
@@ -3,13 +3,6 @@
Revision ID: 088
Revises: 087
Create Date: 2026-10-20 00:00:00.000000
-
-At most one session per (contact_ref, chatbot_ref) is active: creating a new one
-deactivates the rest in crud/chatbot.py, and the partial unique index makes a
-concurrent double-create fail instead of leaving two active rows. The plain
-composite index stays because the partial one can't serve the GET-latest lookup,
-which includes inactive rows; chatbot_ref comes before contact_ref so its prefix
-also serves the project-wide uniqueness check on generated chatbot_refs.
"""
import sqlalchemy as sa
diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py
index 0ca902bd1..df2e1f99b 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -1,4 +1,3 @@
-import logging
from datetime import datetime
from typing import Any
from uuid import UUID
@@ -17,8 +16,6 @@
ChatbotSessionStatusEnum,
)
-logger = logging.getLogger(__name__)
-
CONCURRENT_SESSION_CONFLICT_MESSAGE = (
"A concurrent session was just created for this contact and chatbot; retry"
)
@@ -93,12 +90,9 @@ def create_chatbot_session(
config_version: int | None,
system_prompt: str,
) -> ChatbotSession:
- """Insert a new active session, superseding the contact's active ones for the same chatbot.
-
- Deactivation and insert share one commit, so a failed insert leaves prior sessions active.
- """
+ """Insert a new active session, superseding the contact's active ones for the same chatbot."""
timestamp = now()
- superseded_count = _deactivate_active_chatbot_sessions(
+ _deactivate_active_chatbot_sessions(
session,
organization_id=organization_id,
project_id=project_id,
@@ -125,18 +119,8 @@ def create_chatbot_session(
session.rollback()
if not _is_active_session_conflict(e):
raise
- logger.warning(
- f"[create_chatbot_session] {CONCURRENT_SESSION_CONFLICT_MESSAGE} | "
- f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, code: 409",
- exc_info=True,
- )
raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e
session.refresh(chatbot_session)
- logger.info(
- f"[create_chatbot_session] Session created | session_id: {chatbot_session.id}, "
- f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, "
- f"superseded_count: {superseded_count}"
- )
return chatbot_session
@@ -176,11 +160,6 @@ def update_chatbot_session_progress(
session.add(chatbot_session)
session.commit()
session.refresh(chatbot_session)
- logger.info(
- f"[update_chatbot_session_progress] Session progress saved | "
- f"session_id: {session_id}, completion_status: {completion_status.value}, "
- f"field_count: {len(collected_fields)}"
- )
return chatbot_session
diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py
index 4fcf3acee..ce52dbfc7 100644
--- a/backend/app/services/chatbot/agents/onboarding/graph.py
+++ b/backend/app/services/chatbot/agents/onboarding/graph.py
@@ -174,12 +174,7 @@ async def run_chatbot_turn(
model_settings: ChatbotModelSettings,
collected_fields: dict[str, Any] | None = None,
) -> ChatbotTurnResult:
- """Run one stateless turn: update the collected fields, then reply.
-
- `user_message` may be None only on the very first call (empty `history`),
- which yields the bot's opening greeting. `collected_fields` is the map from
- earlier turns; it is merged with (and corrected by) this turn's extraction.
- """
+ """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 "
diff --git a/backend/app/services/chatbot/chatbot_ref.py b/backend/app/services/chatbot/chatbot_ref.py
index 1af3ffa2a..97cbaaa7b 100644
--- a/backend/app/services/chatbot/chatbot_ref.py
+++ b/backend/app/services/chatbot/chatbot_ref.py
@@ -20,10 +20,7 @@ def _random_chatbot_ref() -> str:
def generate_chatbot_ref(session: Session, *, project_id: int) -> str:
- """Return a chatbot_ref not yet used by any session in the project.
-
- The suffix space is only ~1M values, and reusing a ref would merge two chatbots' sessions.
- """
+ """Return a chatbot_ref not yet used by any session in the project."""
for _attempt in range(CHATBOT_REF_MAX_ATTEMPTS):
candidate = _random_chatbot_ref()
if not chatbot_ref_exists(
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
index 9d8acd14f..598ee43d5 100644
--- a/backend/app/services/chatbot/session.py
+++ b/backend/app/services/chatbot/session.py
@@ -1,5 +1,4 @@
import asyncio
-import logging
from typing import Any
from uuid import UUID
@@ -25,8 +24,6 @@
from app.services.chatbot.utils import ChatbotModelSettings
from app.utils import get_webhook_secret, send_callback
-logger = logging.getLogger(__name__)
-
SUPERSEDED_SESSION_ERROR = (
"[KAAPI] Chatbot session was superseded by a newer session; start a new session."
)
@@ -35,11 +32,7 @@
def _split_pending_turn(
stored_messages: list[ChatbotSessionMessage],
) -> tuple[list[ChatbotMessageDict], str | None]:
- """Split the stored transcript into (prior history, pending user message).
-
- The route persists the user's message before enqueueing, so it is the last
- stored row; run_chatbot_turn wants it separately from the history before it.
- """
+ """Split the stored transcript into (prior history, pending user message)."""
history: list[ChatbotMessageDict] = []
for stored in stored_messages:
# The system prompt lives on the session row and is passed separately.
@@ -102,10 +95,6 @@ def process_chatbot_session_message(
return
# The turn may have been queued before a newer session superseded this one.
if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE:
- logger.warning(
- f"[process_chatbot_session_message] {SUPERSEDED_SESSION_ERROR} | "
- f"session_id: {session_id}, project_id: {project_id}"
- )
_send_failure_webhook(
session_id=session_id,
webhook_url=webhook_url,
From daf4b92eb83ddae91c70c9ff2a08d51a62634dbe Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sat, 24 Oct 2026 16:29:40 +0530
Subject: [PATCH 13/15] fix: refactoring
---
.../chatbot/agents/onboarding/graph.py | 45 ++++++++++---------
1 file changed, 24 insertions(+), 21 deletions(-)
diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py
index ce52dbfc7..2c1c69893 100644
--- a/backend/app/services/chatbot/agents/onboarding/graph.py
+++ b/backend/app/services/chatbot/agents/onboarding/graph.py
@@ -166,6 +166,28 @@ 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,
@@ -200,27 +222,8 @@ async def run_chatbot_turn(
)
# ChatAnthropic.invoke is sync; keep it off the event loop.
result = await asyncio.to_thread(graph.invoke, initial_state)
- except anthropic.APITimeoutError as e:
- raise ChatbotProviderError(
- "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. "
- "(code: APITimeoutError)"
- ) from e
- except anthropic.APIConnectionError as e:
- raise ChatbotProviderError(
- "[KAAPI] Network/DNS issue reaching provider — check connectivity. "
- "If persistent, contact Kaapi. (code: APIConnectionError)"
- ) from e
- except anthropic.APIStatusError as e:
- raise ChatbotProviderError(
- f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, "
- f"request_id: {e.request_id})"
- ) from e
- except ValueError as e:
- error_message = (
- f"[KAAPI] Unsupported model configuration for '{model_settings.model}': "
- f"{e} — update the config's model, thinking, or effort. (code: ValueError)"
- )
- raise ChatbotProviderError(error_message) from e
+ except (anthropic.APIConnectionError, anthropic.APIStatusError, ValueError) as e:
+ raise _to_provider_error(e, model=model_settings.model) from e
transcript = to_message_dicts(
system_prompt=system_prompt, messages=result["messages"]
From ab342629ea02be24f08f4e2396f770c765a48607 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sat, 24 Oct 2026 16:46:45 +0530
Subject: [PATCH 14/15] fix: update test cases
---
backend/app/tests/api/routes/chatbot/test_feature_flag.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
index 3a6dbf14c..747d0f2b7 100644
--- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py
+++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py
@@ -1,4 +1,4 @@
-from uuid import uuid4
+from uuid import UUID
import pytest
from fastapi.testclient import TestClient
@@ -9,6 +9,7 @@
from app.tests.utils.auth import TestAuthContext
CHATBOT_URL = f"{settings.API_V1_STR}/chatbot"
+UNKNOWN_SESSION_ID = UUID("00000000-0000-0000-0000-000000000001")
@pytest.mark.parametrize(
@@ -16,8 +17,9 @@
[
("post", "/sessions", {"json": {}}),
("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}),
- ("post", f"/sessions/{uuid4()}/message", {"json": {}}),
+ ("post", f"/sessions/{UNKNOWN_SESSION_ID}/message", {"json": {}}),
],
+ ids=["create_session", "get_latest_session", "send_message"],
)
def test_chatbot_routes_return_403_when_flag_disabled(
client: TestClient,
From 26f70a2f8bccc11e8a09899969e8d58800197ca2 Mon Sep 17 00:00:00 2001
From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com>
Date: Sat, 24 Oct 2026 16:56:38 +0530
Subject: [PATCH 15/15] fix: cleanups
---
backend/app/api/routes/chatbot/session.py | 2 +-
backend/app/core/util.py | 9 ++++
backend/app/crud/chatbot.py | 23 ++-------
backend/app/services/chatbot/exceptions.py | 9 ++++
backend/app/services/chatbot/session.py | 24 ++++-----
.../tests/api/routes/chatbot/test_session.py | 6 ++-
backend/app/tests/core/test_util.py | 37 ++++++++++++++
backend/app/tests/crud/test_chatbot.py | 49 ++-----------------
8 files changed, 79 insertions(+), 80 deletions(-)
create mode 100644 backend/app/tests/core/test_util.py
diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py
index ace46ec2f..efa8948e8 100644
--- a/backend/app/api/routes/chatbot/session.py
+++ b/backend/app/api/routes/chatbot/session.py
@@ -8,7 +8,6 @@
from app.api.permissions import Permission, require_permission
from app.celery.utils import start_chatbot_session_message
from app.crud.chatbot import (
- ChatbotSessionConflictError,
create_chatbot_session,
create_chatbot_session_message,
get_chatbot_session,
@@ -31,6 +30,7 @@
from app.services.chatbot.exceptions import (
ChatbotInputError,
ChatbotRefGenerationError,
+ ChatbotSessionConflictError,
)
from app.utils import APIResponse, load_description, validate_callback_url
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
index df2e1f99b..347bf66d8 100644
--- a/backend/app/crud/chatbot.py
+++ b/backend/app/crud/chatbot.py
@@ -6,7 +6,7 @@
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, col, select
-from app.core.util import now
+from app.core.util import is_constraint_violation, now
from app.models import (
ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
ChatbotCompletionStatusEnum,
@@ -15,25 +15,12 @@
ChatbotSessionMessage,
ChatbotSessionStatusEnum,
)
-
-CONCURRENT_SESSION_CONFLICT_MESSAGE = (
- "A concurrent session was just created for this contact and chatbot; retry"
+from app.services.chatbot.exceptions import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
)
-class ChatbotSessionConflictError(Exception):
- """Another active session for the same contact + chatbot was committed concurrently."""
-
-
-def _is_active_session_conflict(error: IntegrityError) -> bool:
- constraint_name = getattr(
- getattr(error.orig, "diag", None), "constraint_name", None
- )
- if constraint_name:
- return constraint_name == ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX
- return ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX in str(error.orig or error)
-
-
def _deactivate_active_chatbot_sessions(
session: Session,
*,
@@ -117,7 +104,7 @@ def create_chatbot_session(
session.commit()
except IntegrityError as e:
session.rollback()
- if not _is_active_session_conflict(e):
+ if not is_constraint_violation(e, ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX):
raise
raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e
session.refresh(chatbot_session)
diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py
index fab852789..2239f1a16 100644
--- a/backend/app/services/chatbot/exceptions.py
+++ b/backend/app/services/chatbot/exceptions.py
@@ -1,3 +1,8 @@
+CONCURRENT_SESSION_CONFLICT_MESSAGE = (
+ "A concurrent session was just created for this contact and chatbot; retry"
+)
+
+
class ChatbotInputError(ValueError):
"""The turn request can't produce a valid model call (caller's fault)."""
@@ -8,3 +13,7 @@ class ChatbotRefGenerationError(Exception):
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."""
diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py
index 598ee43d5..e142ad8d4 100644
--- a/backend/app/services/chatbot/session.py
+++ b/backend/app/services/chatbot/session.py
@@ -1,5 +1,4 @@
import asyncio
-from typing import Any
from uuid import UUID
from fastapi import HTTPException
@@ -22,7 +21,7 @@
from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError
from app.services.chatbot.types import ChatbotMessageDict
from app.services.chatbot.utils import ChatbotModelSettings
-from app.utils import get_webhook_secret, send_callback
+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."
@@ -63,12 +62,10 @@ def _send_failure_webhook(
webhook_secret: str | None,
error: str,
) -> None:
- payload: dict[str, Any] = {
- "success": False,
- "error": error,
- "data": {"session_id": str(session_id)},
- }
- send_callback(webhook_url, payload, webhook_secret)
+ 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
@@ -178,16 +175,15 @@ def process_chatbot_session_message(
completion_status=completion_status,
)
- payload: dict[str, Any] = {
- "success": True,
- "data": {
+ envelope = APIResponse.success_response(
+ data={
"session_id": str(session_id),
"chatbot_ref": chatbot_ref,
"reply": reply,
"status": session_status,
"completion_status": completion_status.value,
"collected_fields": turn_result.collected_fields,
- },
- }
- send_callback(webhook_url, payload, webhook_secret)
+ }
+ )
+ send_callback(webhook_url, envelope.model_dump(exclude_none=True), webhook_secret)
return
diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py
index 55d5c972f..5501b993a 100644
--- a/backend/app/tests/api/routes/chatbot/test_session.py
+++ b/backend/app/tests/api/routes/chatbot/test_session.py
@@ -10,8 +10,6 @@
from app.api.routes.chatbot import session as session_routes
from app.core.config import settings
from app.crud.chatbot import (
- CONCURRENT_SESSION_CONFLICT_MESSAGE,
- ChatbotSessionConflictError,
get_chatbot_session,
list_chatbot_session_messages,
update_chatbot_session_progress,
@@ -29,6 +27,10 @@
CHATBOT_REF_PREFIX,
CHATBOT_REF_SUFFIX_LENGTH,
)
+from app.services.chatbot.exceptions import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
+)
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.chatbot import (
add_test_chatbot_messages,
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/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py
index 6a5b5c0ce..7bbb8e490 100644
--- a/backend/app/tests/crud/test_chatbot.py
+++ b/backend/app/tests/crud/test_chatbot.py
@@ -1,5 +1,4 @@
from collections.abc import Iterator
-from types import SimpleNamespace
from uuid import uuid4
import pytest
@@ -8,9 +7,6 @@
from app.core.db import engine
from app.crud.chatbot import (
- CONCURRENT_SESSION_CONFLICT_MESSAGE,
- ChatbotSessionConflictError,
- _is_active_session_conflict,
chatbot_ref_exists,
create_chatbot_session,
create_chatbot_session_message,
@@ -20,7 +16,6 @@
update_chatbot_session_progress,
)
from app.models import (
- ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX,
ChatbotCompletionStatusEnum,
ChatbotMessageRoleEnum,
ChatbotSession,
@@ -28,6 +23,10 @@
Config,
Project,
)
+from app.services.chatbot.exceptions import (
+ CONCURRENT_SESSION_CONFLICT_MESSAGE,
+ ChatbotSessionConflictError,
+)
from app.tests.utils.chatbot import (
add_test_chatbot_messages,
create_test_chatbot_session,
@@ -568,46 +567,6 @@ def test_active_index_violation_raises_conflict_and_inserts_nothing(
assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE
-class TestIsActiveSessionConflict:
- # 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=ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX
- )
- ),
- True,
- ),
- (
- SimpleNamespace(
- diag=SimpleNamespace(
- constraint_name="chatbot_session_config_id_fkey"
- )
- ),
- False,
- ),
- (
- Exception(
- f'duplicate key value violates unique constraint "{ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX}"'
- ),
- True,
- ),
- (Exception("violates foreign key constraint"), False),
- ],
- ids=["diag-match", "diag-other", "message-match", "message-other"],
- )
- def test_detects_only_the_active_session_index(
- self, orig: object, expected: bool
- ) -> None:
- error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type]
-
- assert _is_active_session_conflict(error) is expected
-
-
class TestChatbotRefExists:
def test_true_for_active_and_inactive_sessions_in_project(
self, db: Session, config: Config, project: Project