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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 From 548e869967b2945776c4b740b68f9d1977efa9b8 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:07:59 +0530 Subject: [PATCH 16/25] fix: coderabbit suggestion --- backend/app/services/chatbot/config.py | 21 +++++++++-- backend/app/services/chatbot/history.py | 25 ++++++++++++- .../chatbot/agents/onboarding/test_graph.py | 14 ++++++-- .../app/tests/services/chatbot/test_config.py | 36 +++++++++++++++++-- 4 files changed, 88 insertions(+), 8 deletions(-) diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 0e4268832..3ab7ec419 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -1,4 +1,4 @@ -from typing import get_args +from typing import Any, get_args from uuid import UUID from fastapi import HTTPException @@ -19,6 +19,7 @@ ) _ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort) +THINKING_TYPE_ENABLED = "enabled" def _get_config_version( @@ -51,6 +52,18 @@ def _to_anthropic_effort(effort: str | None) -> AnthropicEffort | None: ) +def _validate_thinking_budget(thinking: dict[str, Any], *, max_tokens: int) -> None: + if thinking.get("type") != THINKING_TYPE_ENABLED: + return + budget = thinking.get("budget_tokens") + if not isinstance(budget, int) or budget >= max_tokens: + raise ChatbotInputError( + f"[KAAPI] Config thinking.budget_tokens ({budget}) must be an integer below " + f"the chatbot's max output tokens ({max_tokens}); lower the budget or use " + "thinking type 'adaptive'." + ) + + def resolve_chatbot_model_settings( *, session: Session, @@ -93,9 +106,13 @@ def resolve_chatbot_model_settings( ) thinking = params.thinking if params.thinking is not None else default_thinking() + resolved_max_tokens = ( + max_tokens if max_tokens is not None else DEFAULT_MAX_OUTPUT_TOKENS + ) + _validate_thinking_budget(thinking, max_tokens=resolved_max_tokens) model_settings = ChatbotModelSettings( model=params.model, - max_tokens=max_tokens if max_tokens is not None else DEFAULT_MAX_OUTPUT_TOKENS, + max_tokens=resolved_max_tokens, reasoning_effort=_to_anthropic_effort(params.effort), thinking=thinking, history_token_limit=( diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py index 4efbdbe1e..d3680638c 100644 --- a/backend/app/services/chatbot/history.py +++ b/backend/app/services/chatbot/history.py @@ -13,6 +13,9 @@ "Summarize the conversation so far in 2-3 sentences. Keep every fact the " "user has already given -- this note replaces the original messages." ) +SUMMARIZE_TRANSCRIPT_TEMPLATE = "\n{rendered}\n" +TRANSCRIPT_USER_LABEL = "User" +TRANSCRIPT_ASSISTANT_LABEL = "Assistant" KICKOFF_MESSAGE_ID = "chatbot-kickoff" KICKOFF_MESSAGE_CONTENT = "Begin the conversation." @@ -40,13 +43,33 @@ def summarize_if_needed( if not to_summarize: return messages + rendered = _render_transcript(to_summarize) summary = chat_model.invoke( - [SystemMessage(content=SUMMARIZE_INSTRUCTION), *to_summarize] + [ + SystemMessage(content=SUMMARIZE_INSTRUCTION), + HumanMessage( + content=SUMMARIZE_TRANSCRIPT_TEMPLATE.format(rendered=rendered) + ), + ] ) summary_note = SystemMessage(content=f"{SUMMARY_NOTE_PREFIX}{summary.text}") return [system_message, summary_note, *recent] +def _render_transcript(messages: list[BaseMessage]) -> str: + lines: list[str] = [] + for message in messages: + if message.id == KICKOFF_MESSAGE_ID: + continue + speaker = ( + TRANSCRIPT_USER_LABEL + if isinstance(message, HumanMessage) + else TRANSCRIPT_ASSISTANT_LABEL + ) + lines.append(f"{speaker}: {message.text}") + return "\n".join(lines) + + def has_user_turn(messages: list[BaseMessage]) -> bool: for message in messages: if isinstance(message, HumanMessage) and message.id != KICKOFF_MESSAGE_ID: 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 e59978bb6..01f8a9990 100644 --- a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py +++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py @@ -196,8 +196,14 @@ async def test_long_history_over_budget_is_summarized( 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"] + # window widens back to the preceding user turn: [user 0, bot 0] get summarized, + # sent as one user message so the request never ends on an assistant turn. + instruction, transcript_message = summarize_call + assert isinstance(instruction, SystemMessage) + assert isinstance(transcript_message, HumanMessage) + assert transcript_message.content == ( + "\nUser: user 0\nAssistant: bot 0\n" + ) assert [m.content for m in reply_call] == [ SYSTEM_PROMPT, f"[Earlier conversation summary] {SUMMARY_TEXT}", @@ -299,7 +305,9 @@ async def test_long_history_summary_is_shared_by_extraction_and_reply( # 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"] + assert summarize_call[-1].content == ( + "\nUser: user 0\nAssistant: bot 0\n" + ) summary_note = f"[Earlier conversation summary] {SUMMARY_TEXT}" recent = ["user 1", "bot 1", "user 2", "bot 2", "user 3", "bot 3", "user 4"] [extraction_call] = chat_model.extraction_calls diff --git a/backend/app/tests/services/chatbot/test_config.py b/backend/app/tests/services/chatbot/test_config.py index c70f1a4dc..bf29597fa 100644 --- a/backend/app/tests/services/chatbot/test_config.py +++ b/backend/app/tests/services/chatbot/test_config.py @@ -59,7 +59,7 @@ def test_latest_version_with_server_defaults(self, db: Session) -> None: config_blob=build_anthropic_text_config_blob( model="claude-opus-5-5", effort="high", - thinking={"type": "enabled", "budget_tokens": 2048}, + thinking={"type": "adaptive", "display": "summarized"}, ), ) @@ -69,10 +69,42 @@ def test_latest_version_with_server_defaults(self, db: Session) -> None: model="claude-opus-5-5", max_tokens=1024, reasoning_effort="high", - thinking={"type": "enabled", "budget_tokens": 2048}, + thinking={"type": "adaptive", "display": "summarized"}, history_token_limit=4000, ) + def test_enabled_thinking_with_budget_below_max_tokens_is_accepted( + self, db: Session + ) -> None: + thinking = {"type": "enabled", "budget_tokens": 2048} + config = create_test_config( + db, config_blob=build_anthropic_text_config_blob(thinking=thinking) + ) + + model_settings = _resolve(db, config, max_tokens=4096) + + assert model_settings.thinking == thinking + assert model_settings.max_tokens == 4096 + + @pytest.mark.parametrize( + "thinking", + [ + {"type": "enabled", "budget_tokens": 2048}, + {"type": "enabled", "budget_tokens": 1024}, + {"type": "enabled"}, + ], + ids=["budget-above-max", "budget-equal-max", "budget-missing"], + ) + def test_enabled_thinking_budget_not_below_max_tokens_rejected( + self, db: Session, thinking: dict[str, Any] + ) -> None: + config = create_test_config( + db, config_blob=build_anthropic_text_config_blob(thinking=thinking) + ) + + with pytest.raises(ChatbotInputError, match="budget_tokens"): + _resolve(db, config) + def test_explicit_version_and_request_overrides(self, db: Session) -> None: config = create_test_config( db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6") From b193156634ea45e509e33c4e4e84bc0cd15b0984 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:12:06 +0530 Subject: [PATCH 17/25] fix: restructure the flows --- .../087_seed_anthropic_text_model_configs.py | 45 - .../versions/088_add_chatbot_session.py | 212 +++-- backend/app/api/docs/chatbot/create.md | 3 + .../app/api/docs/chatbot/create_session.md | 10 +- backend/app/api/docs/chatbot/delete.md | 3 + backend/app/api/docs/chatbot/get.md | 3 + backend/app/api/docs/chatbot/get_flow.md | 5 + .../api/docs/chatbot/get_latest_session.md | 6 +- backend/app/api/docs/chatbot/list.md | 3 + backend/app/api/docs/chatbot/publish_flow.md | 5 + backend/app/api/docs/chatbot/save_flow.md | 7 + .../api/docs/chatbot/send_session_message.md | 4 +- backend/app/api/docs/chatbot/update.md | 3 + backend/app/api/docs/openapi_config.py | 2 +- backend/app/api/routes/chatbot/__init__.py | 10 +- backend/app/api/routes/chatbot/chatbot.py | 131 +++ backend/app/api/routes/chatbot/flow.py | 110 +++ backend/app/api/routes/chatbot/session.py | 142 ++-- backend/app/crud/chatbot.py | 354 ++++++-- backend/app/models/__init__.py | 19 +- backend/app/models/chatbot.py | 362 ++++++-- backend/app/services/chatbot/chatbot_ref.py | 37 - backend/app/services/chatbot/config.py | 15 +- backend/app/services/chatbot/exceptions.py | 23 +- backend/app/services/chatbot/flow.py | 409 +++++++++ backend/app/services/chatbot/session.py | 175 +++- .../app/tests/api/routes/chatbot/conftest.py | 43 + .../tests/api/routes/chatbot/test_chatbot.py | 232 ++++++ .../api/routes/chatbot/test_feature_flag.py | 34 +- .../app/tests/api/routes/chatbot/test_flow.py | 773 ++++++++++++++++++ .../tests/api/routes/chatbot/test_session.py | 735 +++++++---------- backend/app/tests/crud/test_chatbot.py | 697 ++++++++++------ .../chatbot/agents/onboarding/test_graph.py | 328 ++++---- .../app/tests/services/chatbot/conftest.py | 10 + .../services/chatbot/test_chatbot_ref.py | 104 --- .../app/tests/services/chatbot/test_config.py | 21 +- .../app/tests/services/chatbot/test_flow.py | 247 ++++++ .../tests/services/chatbot/test_session.py | 511 +++++++++--- backend/app/tests/utils/chatbot.py | 232 +++++- docs/wiki/INDEX.md | 2 +- docs/wiki/domain-map.md | 8 +- docs/wiki/modules/chatbot.md | 60 +- 42 files changed, 4550 insertions(+), 1585 deletions(-) delete mode 100644 backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py create mode 100644 backend/app/api/docs/chatbot/create.md create mode 100644 backend/app/api/docs/chatbot/delete.md create mode 100644 backend/app/api/docs/chatbot/get.md create mode 100644 backend/app/api/docs/chatbot/get_flow.md create mode 100644 backend/app/api/docs/chatbot/list.md create mode 100644 backend/app/api/docs/chatbot/publish_flow.md create mode 100644 backend/app/api/docs/chatbot/save_flow.md create mode 100644 backend/app/api/docs/chatbot/update.md create mode 100644 backend/app/api/routes/chatbot/chatbot.py create mode 100644 backend/app/api/routes/chatbot/flow.py delete mode 100644 backend/app/services/chatbot/chatbot_ref.py create mode 100644 backend/app/services/chatbot/flow.py create mode 100644 backend/app/tests/api/routes/chatbot/test_chatbot.py create mode 100644 backend/app/tests/api/routes/chatbot/test_flow.py create mode 100644 backend/app/tests/services/chatbot/conftest.py delete mode 100644 backend/app/tests/services/chatbot/test_chatbot_ref.py create mode 100644 backend/app/tests/services/chatbot/test_flow.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 deleted file mode 100644 index 1d864a12f..000000000 --- a/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py +++ /dev/null @@ -1,45 +0,0 @@ -"""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 index cd06ed363..b49ec41b1 100644 --- a/backend/app/alembic/versions/088_add_chatbot_session.py +++ b/backend/app/alembic/versions/088_add_chatbot_session.py @@ -1,4 +1,4 @@ -"""add chatbot session and message tables +"""add chatbot, flow version, session and message tables Revision ID: 088 Revises: 087 @@ -18,49 +18,170 @@ def upgrade(): op.create_table( - "chatbot_session", + "chatbot", sa.Column( "id", sa.Uuid(), nullable=False, - comment="Session identifier returned to the caller", + comment="Chatbot identifier used in API paths", ), sa.Column( "name", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False, - server_default="Untitled Chatbot", - comment="Display name of the chatbot this session runs", + comment="Display name of the chatbot shown in the UI", ), sa.Column( - "contact_ref", - sqlmodel.sql.sqltypes.AutoString(length=500), + "organization_id", + sa.Integer(), nullable=False, - comment="Caller-side identifier of the end user (e.g. a contact id); opaque to Kaapi", + comment="Organization that owns the chatbot", ), sa.Column( - "chatbot_ref", - sqlmodel.sql.sqltypes.AutoString(length=255), + "project_id", + sa.Integer(), + nullable=False, + comment="Project that owns the chatbot", + ), + sa.Column( + "inserted_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the chatbot was created", + ), + sa.Column( + "updated_at", + sa.DateTime(), nullable=False, - comment="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", + comment="Timestamp when the chatbot was last updated", ), sa.Column( - "config_id", + "deleted_at", + sa.DateTime(), + nullable=True, + comment="Soft-delete timestamp; the row is kept because sessions reference it", + ), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_chatbot_organization_id"), + "chatbot", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_chatbot_project_id"), "chatbot", ["project_id"], unique=False + ) + + op.create_table( + "chatbot_flow_version", + sa.Column( + "id", sa.Uuid(), nullable=False, - comment="Saved Anthropic text config that supplies the chat model", + comment="Flow version identifier pinned by sessions", ), sa.Column( - "config_version", + "chatbot_id", + sa.Uuid(), + nullable=False, + comment="Chatbot this flow version belongs to", + ), + sa.Column( + "version", sa.Integer(), - nullable=True, - comment="Pinned config version; NULL resolves to the latest version on every turn", + nullable=False, + comment="Sequential version number per chatbot, starting at 1", ), sa.Column( - "system_prompt", - sqlmodel.sql.sqltypes.AutoString(), + "nodes", + postgresql.JSONB(astext_type=sa.Text()), nullable=False, - comment="Admin-authored flow prompt captured at session creation", + comment="React Flow nodes as saved by the UI: [{id, type (input/output/coordinator/onboarding/rag), position {x, y}, data}]; agent node data carries config_id, config_version, prompt", + ), + sa.Column( + "edges", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + comment="React Flow edges as saved by the UI: [{id, source, target, sourceHandle, targetHandle, data}]", + ), + sa.Column( + "is_live", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + comment="True for the single published version new sessions run on; live versions are immutable, the highest non-live version is the draft", + ), + sa.Column( + "inserted_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the version was created", + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the draft was last saved or the version was published/unpublished", + ), + sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "chatbot_id", + "version", + name="uq_chatbot_flow_version_chatbot_version", + ), + ) + op.create_index( + op.f("ix_chatbot_flow_version_chatbot_id"), + "chatbot_flow_version", + ["chatbot_id"], + unique=False, + ) + op.create_index( + "uq_chatbot_flow_version_live_chatbot", + "chatbot_flow_version", + ["chatbot_id"], + unique=True, + postgresql_where=sa.text("is_live"), + ) + + op.create_table( + "chatbot_session", + sa.Column( + "id", + sa.Uuid(), + nullable=False, + comment="Session identifier returned to the caller", + ), + sa.Column( + "chatbot_id", + sa.Uuid(), + nullable=False, + comment="Chatbot this session runs; tenant scope comes from the chatbot", + ), + sa.Column( + "flow_version_id", + sa.Uuid(), + nullable=False, + comment="Live flow version pinned at creation, so publishing never changes a running conversation", + ), + sa.Column( + "contact_ref", + sqlmodel.sql.sqltypes.AutoString(length=500), + nullable=False, + comment="Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi", + ), + sa.Column( + "active_node_id", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=False, + comment="React Flow node id inside the pinned flow version that handles the next turn", ), sa.Column( "status", @@ -70,7 +191,7 @@ def upgrade(): name="chatbotsessionstatusenum", ), nullable=False, - comment="Session in use: active, or inactive once superseded by a newer session for the same contact_ref + chatbot_ref", + comment="Session in use: active, or inactive once superseded by a newer session for the same chatbot + contact_ref or the chatbot was deleted", ), sa.Column( "completion_status", @@ -82,7 +203,7 @@ def upgrade(): ), nullable=False, server_default="in_progress", - comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress", + comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when deactivated while in_progress", ), sa.Column( "collected_fields", @@ -90,18 +211,6 @@ def upgrade(): 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(), @@ -114,41 +223,34 @@ def upgrade(): nullable=False, comment="Timestamp when the session was last updated", ), - sa.ForeignKeyConstraint(["config_id"], ["config.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["chatbot_id"], ["chatbot.id"], ondelete="RESTRICT"), sa.ForeignKeyConstraint( - ["organization_id"], ["organization.id"], ondelete="CASCADE" + ["flow_version_id"], ["chatbot_flow_version.id"], ondelete="RESTRICT" ), - 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"), + op.f("ix_chatbot_session_flow_version_id"), "chatbot_session", - ["organization_id"], + ["flow_version_id"], unique=False, ) op.create_index( - op.f("ix_chatbot_session_project_id"), + op.f("ix_chatbot_session_contact_ref"), "chatbot_session", - ["project_id"], + ["contact_ref"], unique=False, ) op.create_index( - "ix_chatbot_session_project_chatbot_contact", + "ix_chatbot_session_chatbot_contact", "chatbot_session", - ["project_id", "chatbot_ref", "contact_ref"], + ["chatbot_id", "contact_ref"], unique=False, ) op.create_index( - "uq_chatbot_session_active_contact_chatbot", + "uq_chatbot_session_active_chatbot_contact", "chatbot_session", - ["project_id", "contact_ref", "chatbot_ref"], + ["chatbot_id", "contact_ref"], unique=True, postgresql_where=sa.text("status = 'active'"), ) @@ -166,7 +268,7 @@ def upgrade(): "role", sa.Enum("system", "user", "assistant", name="chatbotmessageroleenum"), nullable=False, - comment="Speaker: user or assistant (system is reserved; the prompt lives on the session)", + comment="Speaker: user or assistant (system is reserved; the prompt lives on the flow node)", ), sa.Column( "content", @@ -174,6 +276,12 @@ def upgrade(): nullable=False, comment="Message text", ), + sa.Column( + "node_id", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=True, + comment="React Flow node id of the pinned flow that was active for this turn", + ), sa.Column( "inserted_at", sa.DateTime(), @@ -195,5 +303,5 @@ def upgrade(): def downgrade(): raise NotImplementedError( - "not reversible: dropping chatbot_session tables discards stored conversations" + "not reversible: dropping chatbot tables discards chatbot flows and stored conversations" ) diff --git a/backend/app/api/docs/chatbot/create.md b/backend/app/api/docs/chatbot/create.md new file mode 100644 index 000000000..77184ce6f --- /dev/null +++ b/backend/app/api/docs/chatbot/create.md @@ -0,0 +1,3 @@ +Create a chatbot in the caller's project and return it (`201`). + +Only `name` is required (1–255 characters; display only). A new chatbot has no flow: save one with `PUT /chatbots/{chatbot_id}/flow` and publish it with `POST /chatbots/{chatbot_id}/flow/publish` before starting sessions. diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md index 69346c6ae..90a640e80 100644 --- a/backend/app/api/docs/chatbot/create_session.md +++ b/backend/app/api/docs/chatbot/create_session.md @@ -1,9 +1,5 @@ -Start a server-side chatbot conversation and return its `session_id` and `chatbot_ref`. +Start a conversation between a chatbot and an end user and return `{session_id, chatbot_id, flow_version, active_node_id}` (`201`). -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. +The session is pinned to the chatbot's live flow version and starts on its entry node (the agent node the `input` node leads to, or the flow's only agent node). Every turn runs that pinned version, so publishing a newer flow applies to new sessions only. `contact_ref` is the caller's own identifier for the end user and is stored as-is. -`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). +A contact has at most one active session per chatbot. Creating a session marks the contact's previous `active` sessions of this chatbot as `inactive`, in the same transaction; those still `in_progress` become `abandoned`, while `complete` ones stay `complete`. Sessions of other chatbots or other contacts are untouched. Returns 404 if the chatbot doesn't exist in this project or was deleted, 409 if the chatbot has no published flow (publish the flow first) or another session for the same contact was created concurrently (retry). diff --git a/backend/app/api/docs/chatbot/delete.md b/backend/app/api/docs/chatbot/delete.md new file mode 100644 index 000000000..0111864c5 --- /dev/null +++ b/backend/app/api/docs/chatbot/delete.md @@ -0,0 +1,3 @@ +Soft-delete a chatbot; returns `{"message": "Chatbot deleted successfully"}`. It disappears from list/get and its flow can no longer be edited, published, or used for new sessions. + +In the same transaction every `active` session of the chatbot becomes `inactive` (those still `in_progress` become `abandoned`; `complete` ones stay `complete`), so further messages to them return 409. Stored sessions and transcripts are kept. Returns 404 if the chatbot doesn't exist in this project or was already deleted. diff --git a/backend/app/api/docs/chatbot/get.md b/backend/app/api/docs/chatbot/get.md new file mode 100644 index 000000000..462be00a9 --- /dev/null +++ b/backend/app/api/docs/chatbot/get.md @@ -0,0 +1,3 @@ +Get one chatbot of the caller's project by `chatbot_id`. + +Returns 404 if the chatbot doesn't exist in this project or was deleted. The chatbot's flow is fetched separately with `GET /chatbots/{chatbot_id}/flow`. diff --git a/backend/app/api/docs/chatbot/get_flow.md b/backend/app/api/docs/chatbot/get_flow.md new file mode 100644 index 000000000..04b998d42 --- /dev/null +++ b/backend/app/api/docs/chatbot/get_flow.md @@ -0,0 +1,5 @@ +Get a version of the chatbot's React Flow graph: `{id, chatbot_id, version, is_live, nodes, edges, inserted_at, updated_at}`. + +With `?version=N` that version is returned. Without it, the draft is returned if one exists (the highest version when it is not live), otherwise the live version. `nodes` and `edges` come back exactly as saved, with React Flow keys (`sourceHandle`, `targetHandle`, and UI-only keys such as `measured`). Agent nodes of a published version carry the pinned `data.config_version`. + +Returns 404 if the chatbot doesn't exist in this project, has no flow yet, or has no such version. diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md index 3a3aa41a8..20591ec3e 100644 --- a/backend/app/api/docs/chatbot/get_latest_session.md +++ b/backend/app/api/docs/chatbot/get_latest_session.md @@ -1,5 +1,5 @@ -Get the most recently created chatbot session for the given `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_ref`. +Get the most recently created chatbot session for `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_id`. -`contact_ref` is required; `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. +`contact_ref` is required and matched exactly. Omit `chatbot_id` to get the contact's newest session across the project's chatbots — the response's `chatbot_id` and `chatbot_name` say which one. Sessions of deleted chatbots are not returned. -The response carries the 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. +The response carries the pinned `flow_version`, the `active_node_id` and that node's `prompt`, the session `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created or the chatbot was deleted), its `completion_status` (`in_progress` until every field the prompt asks for is collected, then `complete`; `abandoned` if it was deactivated while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when no matching session exists in this project. diff --git a/backend/app/api/docs/chatbot/list.md b/backend/app/api/docs/chatbot/list.md new file mode 100644 index 000000000..b035041f2 --- /dev/null +++ b/backend/app/api/docs/chatbot/list.md @@ -0,0 +1,3 @@ +List the caller's project's chatbots, most recently updated first. Deleted chatbots are excluded. + +Paginate with `skip` (default 0) and `limit` (1–100, default 100). diff --git a/backend/app/api/docs/chatbot/publish_flow.md b/backend/app/api/docs/chatbot/publish_flow.md new file mode 100644 index 000000000..15ae16f7a --- /dev/null +++ b/backend/app/api/docs/chatbot/publish_flow.md @@ -0,0 +1,5 @@ +Publish the chatbot's draft so new sessions run on it. Returns the now-live version. + +The draft is validated strictly: node ids are unique; at most one `input` and one `output` node; every edge's `source` and `target` is a node in the flow; every agent node has `data.config_id` and a non-empty `data.prompt`, and its config resolves to an Anthropic text config in this project with a model set (and a valid thinking budget). Only `onboarding` agent nodes can run for now — `coordinator` and `rag` nodes are rejected as not supported yet. The flow needs an entry node: with no edges it must contain exactly one agent node; otherwise the `input` node must have exactly one outgoing edge, leading to an agent node. A node without `data.config_version` gets the config's latest version written into the stored node, so later config edits don't change the published flow. + +The previous live version (if any) stops being live in the same transaction. Sessions already running stay pinned to the version they started on. Returns 404 for an unknown chatbot, 409 if there is no draft to publish (save the flow first) or on a concurrent change, and 422 listing every validation problem. diff --git a/backend/app/api/docs/chatbot/save_flow.md b/backend/app/api/docs/chatbot/save_flow.md new file mode 100644 index 000000000..d940014cb --- /dev/null +++ b/backend/app/api/docs/chatbot/save_flow.md @@ -0,0 +1,7 @@ +Save the chatbot's flow: `{nodes, edges}` in React Flow shape. Returns the saved version. + +The **first** save of a chatbot creates version 1 and makes it live immediately, so the chatbot can run without a separate publish. Because it goes live, it gets the same strict validation as publish (see the publish endpoint) and its missing `config_version`s are pinned to the latest; an invalid first flow returns 422 and nothing is saved. + +Every later save is a draft that needs a publish to go live: if the highest version is not live it is overwritten, otherwise a new version `max + 1` is created with `is_live: false`. Live versions are never modified, so editing never affects running conversations. + +Draft saves check only the shape, so half-built flows save: each node needs `id` (1–255 chars), `type` (`input`, `output`, `coordinator`, `onboarding`, `rag`), `position {x, y}` and an object `data`; agent nodes (`coordinator`, `onboarding`, `rag`) may carry `data.config_id` (UUID), `data.config_version` (≥ 1) and `data.prompt`. Each edge needs `id`, `source`, `target`; `sourceHandle`, `targetHandle` and `data` (e.g. `intent`, `description`) are optional. Extra keys are stored and returned as sent. Returns 404 for an unknown chatbot, 409 if the flow was changed concurrently (reload and retry), and 422 for an invalid first save. diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md index c6ff7b84f..e6d1034d0 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", "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. +The user `message` is stored immediately (tagged with the session's active node) and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "chatbot_id", "node_id", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model, config or flow failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. -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. +The turn runs the session's active node of its pinned flow version: the node's `data.prompt` is the system prompt and its `data.config_id` / `data.config_version` supply the model. Each turn re-reads the conversation against that prompt and saves every field the user has given into `collected_fields`, a flat `{field_name: value}` map with snake_case keys (values are only ever taken from what the user said; a correction overwrites the old value). `completion_status` is `complete` once every required field is collected and `in_progress` otherwise; it is computed from a structured extraction, not from the reply text. A `complete` session still accepts messages. `status` (`active`/`inactive`) is separate and says whether the session is in use: once a newer session is created for the same contact and chatbot, or the chatbot is deleted, this one is `inactive` and rejects new messages with 409. A turn that was already queued when that happened is not run; Kaapi POSTs the failure payload with error `[KAAPI] Chatbot session was superseded by a newer session; start a new session.` instead. On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project, 409 if the session is inactive, and 422 for an invalid `webhook_url` or a missing `message` on a started session. diff --git a/backend/app/api/docs/chatbot/update.md b/backend/app/api/docs/chatbot/update.md new file mode 100644 index 000000000..6b8642a35 --- /dev/null +++ b/backend/app/api/docs/chatbot/update.md @@ -0,0 +1,3 @@ +Rename a chatbot. Only `name` can change; omitting it leaves the chatbot unchanged. + +Returns 404 if the chatbot doesn't exist in this project or was deleted. diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py index 87de7c47c..9c9adf40e 100644 --- a/backend/app/api/docs/openapi_config.py +++ b/backend/app/api/docs/openapi_config.py @@ -34,7 +34,7 @@ }, { "name": "Chatbot", - "description": "Prompt-driven conversational chatbot turns", + "description": "Chatbots, their versioned React Flow graphs, and conversation sessions", }, { "name": "Guardrails", diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py index 5237ac117..087298822 100644 --- a/backend/app/api/routes/chatbot/__init__.py +++ b/backend/app/api/routes/chatbot/__init__.py @@ -1,12 +1,18 @@ from fastapi import APIRouter, Depends from app.api.permissions import require_feature +from app.api.routes.chatbot.chatbot import router as chatbot_router +from app.api.routes.chatbot.flow import router as flow_router from app.api.routes.chatbot.session import router as session_router from app.core.feature_flags import FeatureFlag +CHATBOTS_PREFIX = "/chatbots" + router = APIRouter( - prefix="/chatbot", tags=["Chatbot"], dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))], ) -router.include_router(session_router) +# Session routes first so literal `/sessions/...` paths win over `/{chatbot_id}/...`. +router.include_router(session_router, prefix=CHATBOTS_PREFIX) +router.include_router(chatbot_router, prefix=CHATBOTS_PREFIX) +router.include_router(flow_router, prefix=CHATBOTS_PREFIX) diff --git a/backend/app/api/routes/chatbot/chatbot.py b/backend/app/api/routes/chatbot/chatbot.py new file mode 100644 index 000000000..ce2c237e3 --- /dev/null +++ b/backend/app/api/routes/chatbot/chatbot.py @@ -0,0 +1,131 @@ +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Path, Query + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.crud.chatbot import ( + create_chatbot, + delete_chatbot, + get_chatbot, + list_chatbots, + update_chatbot, +) +from app.models import Chatbot, ChatbotCreate, ChatbotPublic, ChatbotUpdate, Message +from app.services.chatbot.flow import CHATBOT_NOT_FOUND_MESSAGE +from app.utils import APIResponse, load_description + +CHATBOT_DELETED_MESSAGE = "Chatbot deleted successfully" + +router = APIRouter() + + +def _get_chatbot_or_404( + session: SessionDep, auth_context: AuthContextDep, chatbot_id: UUID +) -> Chatbot: + chatbot = get_chatbot( + session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + ) + if chatbot is None: + raise HTTPException(status_code=404, detail=CHATBOT_NOT_FOUND_MESSAGE) + return chatbot + + +@router.post( + "", + status_code=201, + description=load_description("chatbot/create.md"), + response_model=APIResponse[ChatbotPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def create_chatbot_route( + request: ChatbotCreate, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotPublic]: + chatbot = create_chatbot( + session, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + name=request.name, + ) + return APIResponse.success_response(ChatbotPublic.model_validate(chatbot)) + + +@router.get( + "", + description=load_description("chatbot/list.md"), + response_model=APIResponse[list[ChatbotPublic]], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def list_chatbots_route( + session: SessionDep, + auth_context: AuthContextDep, + skip: Annotated[int, Query(ge=0, description="Number of records to skip")] = 0, + limit: Annotated[ + int, Query(ge=1, le=100, description="Maximum records to return") + ] = 100, +) -> APIResponse[list[ChatbotPublic]]: + chatbots = list_chatbots( + session, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + skip=skip, + limit=limit, + ) + chatbots_public: list[ChatbotPublic] = [] + for chatbot in chatbots: + chatbots_public.append(ChatbotPublic.model_validate(chatbot)) + return APIResponse.success_response(chatbots_public) + + +@router.get( + "/{chatbot_id}", + description=load_description("chatbot/get.md"), + response_model=APIResponse[ChatbotPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def get_chatbot_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotPublic]: + chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id) + return APIResponse.success_response(ChatbotPublic.model_validate(chatbot)) + + +@router.patch( + "/{chatbot_id}", + description=load_description("chatbot/update.md"), + response_model=APIResponse[ChatbotPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def update_chatbot_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + request: ChatbotUpdate, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotPublic]: + chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id) + chatbot = update_chatbot(session, chatbot=chatbot, chatbot_update=request) + return APIResponse.success_response(ChatbotPublic.model_validate(chatbot)) + + +@router.delete( + "/{chatbot_id}", + description=load_description("chatbot/delete.md"), + response_model=APIResponse[Message], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def delete_chatbot_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[Message]: + chatbot = _get_chatbot_or_404(session, auth_context, chatbot_id) + delete_chatbot(session, chatbot=chatbot) + return APIResponse.success_response(Message(message=CHATBOT_DELETED_MESSAGE)) diff --git a/backend/app/api/routes/chatbot/flow.py b/backend/app/api/routes/chatbot/flow.py new file mode 100644 index 000000000..982205dd0 --- /dev/null +++ b/backend/app/api/routes/chatbot/flow.py @@ -0,0 +1,110 @@ +from typing import Annotated +from uuid import UUID + +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.models import ChatbotFlowUpdate, ChatbotFlowVersionPublic +from app.services.chatbot.exceptions import ( + ChatbotFlowConflictError, + ChatbotFlowNotFoundError, + ChatbotFlowValidationError, + ChatbotNotFoundError, +) +from app.services.chatbot.flow import ( + build_chatbot_flow_version_public, + get_chatbot_flow, + publish_chatbot_flow, + save_chatbot_flow_draft, +) +from app.utils import APIResponse, load_description + +router = APIRouter() + + +@router.get( + "/{chatbot_id}/flow", + description=load_description("chatbot/get_flow.md"), + response_model=APIResponse[ChatbotFlowVersionPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def get_chatbot_flow_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + session: SessionDep, + auth_context: AuthContextDep, + version: Annotated[ + int | None, + Query( + ge=1, + description="Flow version to fetch; omit to get the draft if one exists, else the live version", + ), + ] = None, +) -> APIResponse[ChatbotFlowVersionPublic]: + try: + flow_version = get_chatbot_flow( + session=session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + version=version, + ) + except (ChatbotNotFoundError, ChatbotFlowNotFoundError) as e: + raise HTTPException(status_code=404, detail=str(e)) + return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) + + +@router.put( + "/{chatbot_id}/flow", + description=load_description("chatbot/save_flow.md"), + response_model=APIResponse[ChatbotFlowVersionPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def save_chatbot_flow_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + request: ChatbotFlowUpdate, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotFlowVersionPublic]: + try: + flow_version = save_chatbot_flow_draft( + session=session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + flow=request, + ) + except ChatbotNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ChatbotFlowConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ChatbotFlowValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) + + +@router.post( + "/{chatbot_id}/flow/publish", + description=load_description("chatbot/publish_flow.md"), + response_model=APIResponse[ChatbotFlowVersionPublic], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def publish_chatbot_flow_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotFlowVersionPublic]: + try: + flow_version = publish_chatbot_flow( + session=session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + ) + except ChatbotNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ChatbotFlowConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) + except ChatbotFlowValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index efa8948e8..d600fbb42 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -8,14 +8,11 @@ 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, - get_latest_chatbot_session, list_chatbot_session_messages, ) from app.models import ( - CHATBOT_REF_MAX_LENGTH, CONTACT_REF_MAX_LENGTH, ChatbotMessageRoleEnum, ChatbotSessionStatusEnum, @@ -25,13 +22,16 @@ 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, - ChatbotRefGenerationError, + ChatbotFlowConflictError, + ChatbotFlowValidationError, + ChatbotNotFoundError, ChatbotSessionConflictError, ) +from app.services.chatbot.session import ( + get_latest_chatbot_session_summary, + start_chatbot_session, +) from app.utils import APIResponse, load_description, validate_callback_url DEFAULT_TRACE_ID = "N/A" @@ -39,61 +39,8 @@ 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_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, chatbot_ref=chatbot_session.chatbot_ref - ) - ) - - @router.get( - "/sessions", + "/sessions/latest", description=load_description("chatbot/get_latest_session.md"), response_model=APIResponse[ChatbotSessionSummary], dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], @@ -109,44 +56,27 @@ def read_latest_chatbot_session( ], session: SessionDep, auth_context: AuthContextDep, - chatbot_ref: Annotated[ - str | None, + chatbot_id: Annotated[ + UUID | None, Query( - min_length=1, - max_length=CHATBOT_REF_MAX_LENGTH, - description="Chatbot whose latest session to fetch; omit to get the contact's newest session across all chatbots", + description="Chatbot whose latest session to fetch; omit to get the contact's newest session across the project's chatbots", ), ] = None, ) -> APIResponse[ChatbotSessionSummary]: - chatbot_session = get_latest_chatbot_session( - session, + summary = get_latest_chatbot_session_summary( + session=session, organization_id=auth_context.organization_.id, project_id=auth_context.project_.id, contact_ref=contact_ref, - chatbot_ref=chatbot_ref, + chatbot_id=chatbot_id, ) - if chatbot_session is None: + if summary is None: raise HTTPException( status_code=404, detail="No chatbot session found for this contact_ref" - + (" and chatbot_ref" if chatbot_ref is not None else ""), - ) - 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, - system_prompt=chatbot_session.system_prompt, - 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, + + (" and chatbot_id" if chatbot_id is not None else ""), ) - ) + return APIResponse.success_response(summary) @router.post( @@ -196,6 +126,7 @@ def send_chatbot_session_message( session_id=session_id, role=ChatbotMessageRoleEnum.USER, content=request.message, + node_id=chatbot_session.active_node_id, ) start_chatbot_session_message( @@ -206,3 +137,40 @@ def send_chatbot_session_message( trace_id=correlation_id.get() or DEFAULT_TRACE_ID, ) return APIResponse.success_response(SendChatbotSessionMessageResponse()) + + +@router.post( + "/{chatbot_id}/sessions", + status_code=201, + description=load_description("chatbot/create_session.md"), + response_model=APIResponse[CreateChatbotSessionResponse], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def create_chatbot_session_route( + chatbot_id: Annotated[UUID, Path(description="Chatbot identifier")], + request: CreateChatbotSessionRequest, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[CreateChatbotSessionResponse]: + try: + chatbot_session, flow_version = start_chatbot_session( + session=session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + contact_ref=request.contact_ref, + ) + except ChatbotNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except (ChatbotFlowConflictError, ChatbotSessionConflictError) as e: + raise HTTPException(status_code=409, detail=str(e)) + except ChatbotFlowValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) + return APIResponse.success_response( + CreateChatbotSessionResponse( + session_id=chatbot_session.id, + chatbot_id=chatbot_session.chatbot_id, + flow_version=flow_version.version, + active_node_id=chatbot_session.active_node_id, + ) + ) diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index 347bf66d8..773e33ca8 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -1,43 +1,268 @@ +import logging from datetime import datetime from typing import Any from uuid import UUID -from sqlalchemy import update +from sqlalchemy import ColumnElement, update from sqlalchemy.exc import IntegrityError from sqlmodel import Session, col, select from app.core.util import is_constraint_violation, now from app.models import ( ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, + CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT, + LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX, + Chatbot, ChatbotCompletionStatusEnum, + ChatbotFlowVersion, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, ChatbotSessionStatusEnum, + ChatbotUpdate, ) from app.services.chatbot.exceptions import ( + CONCURRENT_FLOW_CONFLICT_MESSAGE, CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotFlowConflictError, ChatbotSessionConflictError, ) +logger = logging.getLogger(__name__) -def _deactivate_active_chatbot_sessions( + +def create_chatbot( + session: Session, *, organization_id: int, project_id: int, name: str +) -> Chatbot: + timestamp = now() + chatbot = Chatbot( + organization_id=organization_id, + project_id=project_id, + name=name, + inserted_at=timestamp, + updated_at=timestamp, + ) + session.add(chatbot) + session.commit() + session.refresh(chatbot) + logger.info( + f"[create_chatbot] Chatbot created | chatbot_id: {chatbot.id}, project_id: {project_id}" + ) + return chatbot + + +def get_chatbot( session: Session, *, + chatbot_id: UUID, organization_id: int, project_id: int, - contact_ref: str, - chatbot_ref: str, + for_update: bool = False, +) -> Chatbot | None: + """A non-deleted chatbot in the project; `for_update` row-locks it to serialize flow writes.""" + statement = select(Chatbot).where( + Chatbot.id == chatbot_id, + Chatbot.organization_id == organization_id, + Chatbot.project_id == project_id, + col(Chatbot.deleted_at).is_(None), + ) + if for_update: + statement = statement.with_for_update() + return session.exec(statement).first() + + +def list_chatbots( + session: Session, + *, + organization_id: int, + project_id: int, + skip: int, + limit: int, +) -> list[Chatbot]: + statement = ( + select(Chatbot) + .where( + Chatbot.organization_id == organization_id, + Chatbot.project_id == project_id, + col(Chatbot.deleted_at).is_(None), + ) + .order_by(col(Chatbot.updated_at).desc()) + .offset(skip) + .limit(limit) + ) + return list(session.exec(statement).all()) + + +def update_chatbot( + session: Session, *, chatbot: Chatbot, chatbot_update: ChatbotUpdate +) -> Chatbot: + update_data = chatbot_update.model_dump(exclude_unset=True, exclude_none=True) + update_data["updated_at"] = now() + chatbot.sqlmodel_update(update_data) + session.add(chatbot) + session.commit() + session.refresh(chatbot) + return chatbot + + +def delete_chatbot(session: Session, *, chatbot: Chatbot) -> Chatbot: + """Soft-delete the chatbot and deactivate its active sessions in the same transaction.""" + timestamp = now() + deactivated_count = _deactivate_active_chatbot_sessions( + session, chatbot_id=chatbot.id, contact_ref=None, timestamp=timestamp + ) + chatbot.sqlmodel_update({"deleted_at": timestamp, "updated_at": timestamp}) + session.add(chatbot) + session.commit() + session.refresh(chatbot) + logger.info( + f"[delete_chatbot] Chatbot soft-deleted | chatbot_id: {chatbot.id}, " + f"deactivated_sessions: {deactivated_count}" + ) + return chatbot + + +def get_latest_chatbot_flow_version( + session: Session, *, chatbot_id: UUID +) -> ChatbotFlowVersion | None: + """The highest version: the draft when it isn't live, otherwise the live one.""" + statement = ( + select(ChatbotFlowVersion) + .where(ChatbotFlowVersion.chatbot_id == chatbot_id) + .order_by(col(ChatbotFlowVersion.version).desc()) + .limit(1) + ) + return session.exec(statement).first() + + +def get_live_chatbot_flow_version( + session: Session, *, chatbot_id: UUID +) -> ChatbotFlowVersion | None: + statement = select(ChatbotFlowVersion).where( + ChatbotFlowVersion.chatbot_id == chatbot_id, + col(ChatbotFlowVersion.is_live).is_(True), + ) + return session.exec(statement).first() + + +def get_chatbot_flow_version( + session: Session, *, chatbot_id: UUID, version: int +) -> ChatbotFlowVersion | None: + statement = select(ChatbotFlowVersion).where( + ChatbotFlowVersion.chatbot_id == chatbot_id, + ChatbotFlowVersion.version == version, + ) + return session.exec(statement).first() + + +def get_chatbot_flow_version_by_id( + session: Session, *, flow_version_id: UUID +) -> ChatbotFlowVersion | None: + return session.get(ChatbotFlowVersion, flow_version_id) + + +def create_chatbot_flow_version( + session: Session, + *, + chatbot_id: UUID, + version: int, + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], + is_live: bool = False, +) -> ChatbotFlowVersion: + timestamp = now() + flow_version = ChatbotFlowVersion( + chatbot_id=chatbot_id, + version=version, + nodes=nodes, + edges=edges, + is_live=is_live, + inserted_at=timestamp, + updated_at=timestamp, + ) + session.add(flow_version) + try: + session.commit() + except IntegrityError as e: + session.rollback() + if not is_constraint_violation(e, CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT): + raise + raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e + session.refresh(flow_version) + logger.info( + f"[create_chatbot_flow_version] Flow version created | chatbot_id: {chatbot_id}, " + f"version: {version}, is_live: {is_live}" + ) + return flow_version + + +def update_chatbot_flow_version_graph( + session: Session, + *, + flow_version: ChatbotFlowVersion, + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], +) -> ChatbotFlowVersion: + flow_version.sqlmodel_update({"nodes": nodes, "edges": edges, "updated_at": now()}) + session.add(flow_version) + session.commit() + session.refresh(flow_version) + return flow_version + + +def publish_chatbot_flow_version( + session: Session, + *, + flow_version: ChatbotFlowVersion, + nodes: list[dict[str, Any]], +) -> ChatbotFlowVersion: + """Make `flow_version` the live one (storing its pinned `nodes`) in one transaction.""" + timestamp = now() + # Must hit the DB before the draft is marked live, or the partial unique + # index on (chatbot_id) WHERE is_live sees two live rows. + unset_previous_live = ( + update(ChatbotFlowVersion) + .where( + col(ChatbotFlowVersion.chatbot_id) == flow_version.chatbot_id, + col(ChatbotFlowVersion.is_live).is_(True), + ) + .values(is_live=False, updated_at=timestamp) + ) + session.exec(unset_previous_live) + + flow_version.sqlmodel_update( + {"nodes": nodes, "is_live": True, "updated_at": timestamp} + ) + session.add(flow_version) + try: + session.commit() + except IntegrityError as e: + session.rollback() + if not is_constraint_violation(e, LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX): + raise + raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e + session.refresh(flow_version) + logger.info( + f"[publish_chatbot_flow_version] Flow published | chatbot_id: {flow_version.chatbot_id}, " + f"version: {flow_version.version}" + ) + return flow_version + + +def _deactivate_active_chatbot_sessions( + session: Session, + *, + chatbot_id: UUID, + contact_ref: str | None, 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, + """Mark active sessions of the chatbot (one contact's, or all when `contact_ref` is None) inactive; does not commit.""" + scope: list[ColumnElement[bool]] = [ + col(ChatbotSession.chatbot_id) == chatbot_id, col(ChatbotSession.status) == ChatbotSessionStatusEnum.ACTIVE, - ) + ] + if contact_ref is not None: + scope.append(col(ChatbotSession.contact_ref) == contact_ref) # Runs first: once the second statement flips status, these rows no longer match. abandon_in_progress = ( @@ -68,34 +293,24 @@ def _deactivate_active_chatbot_sessions( def create_chatbot_session( session: Session, *, - organization_id: int, - project_id: int, - name: str, + chatbot_id: UUID, + flow_version_id: UUID, contact_ref: str, - chatbot_ref: str, - config_id: UUID, - config_version: int | None, - system_prompt: str, + active_node_id: str, ) -> ChatbotSession: """Insert a new active session, superseding the contact's active ones for the same chatbot.""" timestamp = now() _deactivate_active_chatbot_sessions( session, - organization_id=organization_id, - project_id=project_id, + chatbot_id=chatbot_id, contact_ref=contact_ref, - chatbot_ref=chatbot_ref, timestamp=timestamp, ) chatbot_session = ChatbotSession( - organization_id=organization_id, - project_id=project_id, - name=name, + chatbot_id=chatbot_id, + flow_version_id=flow_version_id, contact_ref=contact_ref, - chatbot_ref=chatbot_ref, - config_id=config_id, - config_version=config_version, - system_prompt=system_prompt, + active_node_id=active_node_id, inserted_at=timestamp, updated_at=timestamp, ) @@ -108,6 +323,10 @@ def create_chatbot_session( raise 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"chatbot_id: {chatbot_id}" + ) return chatbot_session @@ -118,14 +337,52 @@ def get_chatbot_session( 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, + """Scoped through the owning chatbot; a deleted chatbot's sessions still resolve (they are inactive).""" + statement = ( + select(ChatbotSession) + .join(Chatbot, col(Chatbot.id) == col(ChatbotSession.chatbot_id)) + .where( + ChatbotSession.id == session_id, + Chatbot.organization_id == organization_id, + Chatbot.project_id == project_id, + ) ) return session.exec(statement).first() +def get_latest_chatbot_session( + session: Session, + *, + organization_id: int, + project_id: int, + contact_ref: str, + chatbot_id: UUID | None = None, +) -> tuple[ChatbotSession, Chatbot, ChatbotFlowVersion] | None: + """Newest session of the contact across the project's non-deleted chatbots, or one chatbot's.""" + statement = ( + select(ChatbotSession, Chatbot, ChatbotFlowVersion) + .join(Chatbot, col(Chatbot.id) == col(ChatbotSession.chatbot_id)) + .join( + ChatbotFlowVersion, + col(ChatbotFlowVersion.id) == col(ChatbotSession.flow_version_id), + ) + .where( + ChatbotSession.contact_ref == contact_ref, + Chatbot.organization_id == organization_id, + Chatbot.project_id == project_id, + col(Chatbot.deleted_at).is_(None), + ) + ) + if chatbot_id is not None: + statement = statement.where(ChatbotSession.chatbot_id == chatbot_id) + statement = statement.order_by(col(ChatbotSession.inserted_at).desc()).limit(1) + row = session.exec(statement).first() + if row is None: + return None + chatbot_session, chatbot, flow_version = row + return chatbot_session, chatbot, flow_version + + def update_chatbot_session_progress( session: Session, *, @@ -156,11 +413,13 @@ def create_chatbot_session_message( session_id: UUID, role: ChatbotMessageRoleEnum, content: str, + node_id: str | None, ) -> ChatbotSessionMessage: message = ChatbotSessionMessage( session_id=session_id, role=role, content=content, + node_id=node_id, inserted_at=now(), ) session.add(message) @@ -178,34 +437,3 @@ def list_chatbot_session_messages( .order_by(col(ChatbotSessionMessage.inserted_at).asc()) ) return list(session.exec(statement).all()) - - -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, - *, - organization_id: int, - project_id: int, - contact_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, - ) - 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/__init__.py b/backend/app/models/__init__.py index 0a112ca9f..bea3419eb 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -37,15 +37,30 @@ ) from .chatbot import ( ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, - CHATBOT_REF_MAX_LENGTH, + AGENT_NODE_TYPES, + CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT, + CHATBOT_NAME_MAX_LENGTH, CONTACT_REF_MAX_LENGTH, - DEFAULT_CHATBOT_NAME, + FLOW_NODE_ID_MAX_LENGTH, + LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX, + Chatbot, + ChatbotAgentNodeData, ChatbotCompletionStatusEnum, + ChatbotCreate, + ChatbotFlowEdge, + ChatbotFlowNode, + ChatbotFlowNodeTypeEnum, + ChatbotFlowPosition, + ChatbotFlowUpdate, + ChatbotFlowVersion, + ChatbotFlowVersionPublic, ChatbotMessageRoleEnum, + ChatbotPublic, ChatbotSession, ChatbotSessionMessage, ChatbotSessionStatusEnum, ChatbotSessionSummary, + ChatbotUpdate, CreateChatbotSessionRequest, CreateChatbotSessionResponse, SendChatbotSessionMessageRequest, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index 2310e2ecb..4c1259646 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -3,8 +3,10 @@ from typing import Any from uuid import UUID, uuid4 -from sqlalchemy import JSON, Column, Index, text -from sqlalchemy.dialects.postgresql import ENUM +from pydantic import BaseModel, ConfigDict, ValidationError, model_validator +from pydantic import Field as PydanticField +from sqlalchemy import JSON, Column, Index, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import ENUM, JSONB from sqlmodel import Field, SQLModel from app.core.util import now @@ -27,6 +29,22 @@ class ChatbotCompletionStatusEnum(StrEnum): ABANDONED = "abandoned" +class ChatbotFlowNodeTypeEnum(StrEnum): + INPUT = "input" + OUTPUT = "output" + COORDINATOR = "coordinator" + ONBOARDING = "onboarding" + RAG = "rag" + + +AGENT_NODE_TYPES: frozenset[ChatbotFlowNodeTypeEnum] = frozenset( + { + ChatbotFlowNodeTypeEnum.COORDINATOR, + ChatbotFlowNodeTypeEnum.ONBOARDING, + ChatbotFlowNodeTypeEnum.RAG, + } +) + _SESSION_STATUS_PG_ENUM = ENUM( ChatbotSessionStatusEnum, name="chatbotsessionstatusenum", @@ -47,28 +65,238 @@ class ChatbotCompletionStatusEnum(StrEnum): ) 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" +FLOW_NODE_ID_MAX_LENGTH = 255 +ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX = "uq_chatbot_session_active_chatbot_contact" +LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX = "uq_chatbot_flow_version_live_chatbot" +CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT = "uq_chatbot_flow_version_chatbot_version" -class ChatbotSession(SQLModel, table=True): - __tablename__ = "chatbot_session" +class ChatbotFlowPosition(BaseModel): + model_config = ConfigDict(extra="allow") + + x: float + y: float + + +class ChatbotAgentNodeData(BaseModel): + """`data` of a coordinator/onboarding/rag node; UI-only keys (labels) pass through.""" + + model_config = ConfigDict(extra="allow") + + config_id: UUID | None = None + config_version: int | None = PydanticField(default=None, ge=1) + prompt: str | None = None + + +class ChatbotFlowNode(BaseModel): + """React Flow node, stored as the UI sent it (extra keys such as `measured` round-trip).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH) + type: ChatbotFlowNodeTypeEnum + position: ChatbotFlowPosition + data: dict[str, Any] = PydanticField(default_factory=dict) + + def agent_data(self) -> ChatbotAgentNodeData: + return ChatbotAgentNodeData.model_validate(self.data) + + @model_validator(mode="after") + def _validate_agent_data(self) -> "ChatbotFlowNode": + if self.type not in AGENT_NODE_TYPES: + return self + try: + self.agent_data() + except ValidationError as e: + raise ValueError(f"Invalid data for node '{self.id}': {e}") from e + return self + + +class ChatbotFlowEdge(BaseModel): + """React Flow edge; `data.intent` / `data.description` are reserved for coordinator routing.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH) + source: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH) + target: str = PydanticField(min_length=1, max_length=FLOW_NODE_ID_MAX_LENGTH) + source_handle: str | None = PydanticField(default=None, alias="sourceHandle") + target_handle: str | None = PydanticField(default=None, alias="targetHandle") + data: dict[str, Any] = PydanticField(default_factory=dict) + + +class ChatbotBase(SQLModel): + name: str = Field( + min_length=1, + max_length=CHATBOT_NAME_MAX_LENGTH, + sa_column_kwargs={"comment": "Display name of the chatbot shown in the UI"}, + ) + + +class ChatbotCreate(ChatbotBase): + pass + + +class ChatbotUpdate(SQLModel): + name: str | None = Field( + default=None, min_length=1, max_length=CHATBOT_NAME_MAX_LENGTH + ) + + +class Chatbot(ChatbotBase, table=True): + __tablename__ = "chatbot" + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"comment": "Chatbot identifier used in API paths"}, + ) + organization_id: int = Field( + foreign_key="organization.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Organization that owns the chatbot"}, + ) + project_id: int = Field( + foreign_key="project.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Project that owns the chatbot"}, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the chatbot was created"}, + ) + updated_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={ + "comment": "Timestamp when the chatbot was last updated", + "onupdate": now, + }, + ) + deleted_at: datetime | None = Field( + default=None, + nullable=True, + sa_column_kwargs={ + "comment": "Soft-delete timestamp; the row is kept because sessions reference it" + }, + ) + + +class ChatbotPublic(ChatbotBase): + id: UUID + organization_id: int + project_id: int + inserted_at: datetime + updated_at: datetime + + +class ChatbotFlowVersion(SQLModel, table=True): + __tablename__ = "chatbot_flow_version" __table_args__ = ( - # Serves GET-latest (includes inactive rows the partial index below skips) - # and, via its (project_id, chatbot_ref) prefix, the generated-ref collision check. + UniqueConstraint( + "chatbot_id", "version", name=CHATBOT_FLOW_VERSION_UNIQUE_CONSTRAINT + ), Index( - "ix_chatbot_session_project_chatbot_contact", - "project_id", - "chatbot_ref", - "contact_ref", + LIVE_CHATBOT_FLOW_VERSION_UNIQUE_INDEX, + "chatbot_id", + unique=True, + postgresql_where=text("is_live"), + ), + ) + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"comment": "Flow version identifier pinned by sessions"}, + ) + chatbot_id: UUID = Field( + foreign_key="chatbot.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Chatbot this flow version belongs to"}, + ) + version: int = Field( + ge=1, + nullable=False, + sa_column_kwargs={ + "comment": "Sequential version number per chatbot, starting at 1" + }, + ) + nodes: list[dict[str, Any]] = Field( + sa_column=Column( + JSONB, + nullable=False, + comment="React Flow nodes as saved by the UI: [{id, type (input/output/coordinator/onboarding/rag), position {x, y}, data}]; agent node data carries config_id, config_version, prompt", + ), + ) + edges: list[dict[str, Any]] = Field( + default_factory=list, + sa_column=Column( + JSONB, + nullable=False, + server_default=text("'[]'::jsonb"), + comment="React Flow edges as saved by the UI: [{id, source, target, sourceHandle, targetHandle, data}]", ), + ) + is_live: bool = Field( + default=False, + nullable=False, + sa_column_kwargs={ + "server_default": text("false"), + "comment": "True for the single published version new sessions run on; live versions are immutable, the highest non-live version is the draft", + }, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the version was created"}, + ) + updated_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={ + "comment": "Timestamp when the draft was last saved or the version was published/unpublished", + "onupdate": now, + }, + ) + + +class ChatbotFlowUpdate(SQLModel): + nodes: list[ChatbotFlowNode] = Field( + description="React Flow nodes; only their shape is checked on save." + ) + edges: list[ChatbotFlowEdge] = Field( + default_factory=list, description="React Flow edges between `nodes`." + ) + + +class ChatbotFlowVersionPublic(SQLModel): + id: UUID + chatbot_id: UUID + version: int + is_live: bool + nodes: list[ChatbotFlowNode] + edges: list[ChatbotFlowEdge] + inserted_at: datetime + updated_at: datetime + + +class ChatbotSession(SQLModel, table=True): + __tablename__ = "chatbot_session" + __table_args__ = ( + # Also serves FK lookups on chatbot_id via its prefix, so chatbot_id has no own index. + Index("ix_chatbot_session_chatbot_contact", "chatbot_id", "contact_ref"), Index( ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, - "project_id", + "chatbot_id", "contact_ref", - "chatbot_ref", unique=True, postgresql_where=text("status = 'active'"), ), @@ -79,43 +307,35 @@ 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={ - "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, + chatbot_id: UUID = Field( + foreign_key="chatbot.id", nullable=False, + ondelete="RESTRICT", 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" + "comment": "Chatbot this session runs; tenant scope comes from the chatbot" }, ) - config_id: UUID = Field( - foreign_key="config.id", + flow_version_id: UUID = Field( + foreign_key="chatbot_flow_version.id", nullable=False, index=True, ondelete="RESTRICT", sa_column_kwargs={ - "comment": "Saved Anthropic text config that supplies the chat model" + "comment": "Live flow version pinned at creation, so publishing never changes a running conversation" }, ) - config_version: int | None = Field( - default=None, - ge=1, + contact_ref: str = Field( + max_length=CONTACT_REF_MAX_LENGTH, + index=True, sa_column_kwargs={ - "comment": "Pinned config version; NULL resolves to the latest version on every turn" + "comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi" }, ) - system_prompt: str = Field( + active_node_id: str = Field( + max_length=FLOW_NODE_ID_MAX_LENGTH, + nullable=False, sa_column_kwargs={ - "comment": "Admin-authored flow prompt captured at session creation" + "comment": "React Flow node id inside the pinned flow version that handles the next turn" }, ) status: ChatbotSessionStatusEnum = Field( @@ -123,7 +343,7 @@ class ChatbotSession(SQLModel, table=True): sa_column=Column( _SESSION_STATUS_PG_ENUM, nullable=False, - comment="Session in use: active, or inactive once superseded by a newer session for the same contact_ref + chatbot_ref", + comment="Session in use: active, or inactive once superseded by a newer session for the same chatbot + contact_ref or the chatbot was deleted", ), ) completion_status: ChatbotCompletionStatusEnum = Field( @@ -132,7 +352,7 @@ class ChatbotSession(SQLModel, table=True): _COMPLETION_STATUS_PG_ENUM, nullable=False, server_default=ChatbotCompletionStatusEnum.IN_PROGRESS.value, - comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress", + comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when deactivated while in_progress", ), ) collected_fields: dict[str, Any] = Field( @@ -143,20 +363,6 @@ class ChatbotSession(SQLModel, table=True): 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, @@ -191,12 +397,20 @@ class ChatbotSessionMessage(SQLModel, table=True): sa_column=Column( _MESSAGE_ROLE_PG_ENUM, nullable=False, - comment="Speaker: user or assistant (system is reserved; the prompt lives on the session)", + comment="Speaker: user or assistant (system is reserved; the prompt lives on the flow node)", ), ) content: str = Field( sa_column_kwargs={"comment": "Message text"}, ) + node_id: str | None = Field( + default=None, + max_length=FLOW_NODE_ID_MAX_LENGTH, + nullable=True, + sa_column_kwargs={ + "comment": "React Flow node id of the pinned flow that was active for this turn" + }, + ) inserted_at: datetime = Field( default_factory=now, nullable=False, @@ -207,53 +421,31 @@ 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.", - ) - 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.", ) - 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.", - ) class CreateChatbotSessionResponse(SQLModel): session_id: UUID - chatbot_ref: str + chatbot_id: UUID + flow_version: int + active_node_id: str class ChatbotSessionSummary(SQLModel): session_id: UUID - name: str + chatbot_id: UUID + chatbot_name: str + flow_version: int + active_node_id: str + prompt: str | None contact_ref: str - chatbot_ref: str - system_prompt: str status: ChatbotSessionStatusEnum completion_status: ChatbotCompletionStatusEnum collected_fields: dict[str, Any] - config_id: UUID - config_version: int | None inserted_at: datetime updated_at: datetime diff --git a/backend/app/services/chatbot/chatbot_ref.py b/backend/app/services/chatbot/chatbot_ref.py deleted file mode 100644 index 97cbaaa7b..000000000 --- a/backend/app/services/chatbot/chatbot_ref.py +++ /dev/null @@ -1,37 +0,0 @@ -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.""" - 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/config.py b/backend/app/services/chatbot/config.py index 3ab7ec419..86fb45815 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -22,9 +22,10 @@ THINKING_TYPE_ENABLED = "enabled" -def _get_config_version( +def get_chatbot_config_version( *, session: Session, project_id: int, config_id: UUID, config_version: int | None ) -> ConfigVersion: + """The requested version, or the latest live one when `config_version` is None.""" version_crud = ConfigVersionCrud( session=session, config_id=config_id, project_id=project_id ) @@ -74,13 +75,23 @@ def resolve_chatbot_model_settings( history_token_limit: int | None, ) -> ChatbotModelSettings: """Turn a saved config (+ request overrides) into chat-model settings.""" - version_row = _get_config_version( + version_row = get_chatbot_config_version( session=session, project_id=project_id, config_id=config_id, config_version=config_version, ) + return build_chatbot_model_settings( + version_row, max_tokens=max_tokens, history_token_limit=history_token_limit + ) + +def build_chatbot_model_settings( + version_row: ConfigVersion, + *, + max_tokens: int | None, + history_token_limit: int | None, +) -> ChatbotModelSettings: try: blob = ConfigBlob.model_validate(version_row.config_blob) except ValidationError as e: diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py index 2239f1a16..1d71e0194 100644 --- a/backend/app/services/chatbot/exceptions.py +++ b/backend/app/services/chatbot/exceptions.py @@ -1,19 +1,34 @@ CONCURRENT_SESSION_CONFLICT_MESSAGE = ( "A concurrent session was just created for this contact and chatbot; retry" ) +CONCURRENT_FLOW_CONFLICT_MESSAGE = ( + "The chatbot flow was changed concurrently; reload the flow and retry" +) class ChatbotInputError(ValueError): """The turn request can't produce a valid model call (caller's fault).""" -class ChatbotRefGenerationError(Exception): - """No unused chatbot_ref could be generated for the project.""" - - class ChatbotProviderError(Exception): """The chat-model provider failed or was unreachable.""" class ChatbotSessionConflictError(Exception): """Another active session for the same contact + chatbot was committed concurrently.""" + + +class ChatbotNotFoundError(Exception): + """The chatbot doesn't exist in the caller's project or was deleted.""" + + +class ChatbotFlowNotFoundError(Exception): + """The chatbot has no flow version (or not the requested one).""" + + +class ChatbotFlowConflictError(Exception): + """The flow isn't in the state the operation needs (no draft, nothing live, or a concurrent write).""" + + +class ChatbotFlowValidationError(ValueError): + """The flow fails publish-time validation (caller's fault).""" diff --git a/backend/app/services/chatbot/flow.py b/backend/app/services/chatbot/flow.py new file mode 100644 index 000000000..2d213eda1 --- /dev/null +++ b/backend/app/services/chatbot/flow.py @@ -0,0 +1,409 @@ +from typing import Any, NoReturn +from uuid import UUID + +from fastapi import HTTPException +from sqlmodel import Session + +from app.crud.chatbot import ( + create_chatbot_flow_version, + get_chatbot, + get_chatbot_flow_version, + get_latest_chatbot_flow_version, + publish_chatbot_flow_version, + update_chatbot_flow_version_graph, +) +from app.models import ( + AGENT_NODE_TYPES, + ChatbotFlowEdge, + ChatbotFlowNode, + ChatbotFlowNodeTypeEnum, + ChatbotFlowUpdate, + ChatbotFlowVersion, + ChatbotFlowVersionPublic, +) +from app.services.chatbot.config import ( + build_chatbot_model_settings, + get_chatbot_config_version, +) +from app.services.chatbot.exceptions import ( + ChatbotFlowConflictError, + ChatbotFlowNotFoundError, + ChatbotFlowValidationError, + ChatbotInputError, + ChatbotNotFoundError, +) + +KAAPI_ERROR_TAG = "[KAAPI]" +FIRST_FLOW_VERSION = 1 +MAX_INPUT_NODES = 1 +MAX_OUTPUT_NODES = 1 +# The runtime only executes onboarding agents on this branch; coordinator/rag are stored but not publishable. +SUPPORTED_AGENT_NODE_TYPES: frozenset[ChatbotFlowNodeTypeEnum] = frozenset( + {ChatbotFlowNodeTypeEnum.ONBOARDING} +) +_SUPPORTED_AGENT_NODE_TYPES_LABEL = ", ".join( + sorted(node_type.value for node_type in SUPPORTED_AGENT_NODE_TYPES) +) +CHATBOT_NOT_FOUND_MESSAGE = "Chatbot not found" +NO_DRAFT_TO_PUBLISH_MESSAGE = ( + "[KAAPI] Chatbot has no draft flow to publish; save the flow first." +) + + +def dump_flow_nodes(nodes: list[ChatbotFlowNode]) -> list[dict[str, Any]]: + """Serialize with React Flow (camelCase) keys so the stored JSON matches what the UI sent.""" + dumped: list[dict[str, Any]] = [] + for node in nodes: + dumped.append(node.model_dump(mode="json", by_alias=True)) + return dumped + + +def dump_flow_edges(edges: list[ChatbotFlowEdge]) -> list[dict[str, Any]]: + dumped: list[dict[str, Any]] = [] + for edge in edges: + dumped.append(edge.model_dump(mode="json", by_alias=True)) + return dumped + + +def parse_flow_nodes(raw_nodes: list[dict[str, Any]]) -> list[ChatbotFlowNode]: + nodes: list[ChatbotFlowNode] = [] + for raw_node in raw_nodes: + nodes.append(ChatbotFlowNode.model_validate(raw_node)) + return nodes + + +def parse_flow_edges(raw_edges: list[dict[str, Any]]) -> list[ChatbotFlowEdge]: + edges: list[ChatbotFlowEdge] = [] + for raw_edge in raw_edges: + edges.append(ChatbotFlowEdge.model_validate(raw_edge)) + return edges + + +def find_flow_node( + nodes: list[ChatbotFlowNode], node_id: str +) -> ChatbotFlowNode | None: + for node in nodes: + if node.id == node_id: + return node + return None + + +def build_chatbot_flow_version_public( + flow_version: ChatbotFlowVersion, +) -> ChatbotFlowVersionPublic: + return ChatbotFlowVersionPublic( + id=flow_version.id, + chatbot_id=flow_version.chatbot_id, + version=flow_version.version, + is_live=flow_version.is_live, + nodes=parse_flow_nodes(flow_version.nodes), + edges=parse_flow_edges(flow_version.edges), + inserted_at=flow_version.inserted_at, + updated_at=flow_version.updated_at, + ) + + +def _raise_flow_validation_error(function_name: str, problems: list[str]) -> NoReturn: + error_message = f"{KAAPI_ERROR_TAG} Chatbot flow is invalid: {'; '.join(problems)}." + raise ChatbotFlowValidationError(error_message) + + +def resolve_entry_node( + *, nodes: list[ChatbotFlowNode], edges: list[ChatbotFlowEdge] +) -> ChatbotFlowNode: + """The agent node a new session starts on. + + Without edges the flow must be a single agent node; otherwise the entry is + the target of the one edge leaving the input node. + """ + if not edges: + agent_nodes = [node for node in nodes if node.type in AGENT_NODE_TYPES] + if len(agent_nodes) != 1: + _raise_flow_validation_error( + "resolve_entry_node", + [ + "a flow without edges must contain exactly one agent node " + f"(found {len(agent_nodes)})" + ], + ) + return agent_nodes[0] + + input_nodes = [node for node in nodes if node.type == ChatbotFlowNodeTypeEnum.INPUT] + if len(input_nodes) != 1: + _raise_flow_validation_error( + "resolve_entry_node", + [ + "a flow with edges must contain exactly one input node " + f"(found {len(input_nodes)})" + ], + ) + input_node_id = input_nodes[0].id + + entry_edges = [edge for edge in edges if edge.source == input_node_id] + if len(entry_edges) != 1: + _raise_flow_validation_error( + "resolve_entry_node", + [ + f"input node '{input_node_id}' must have exactly one outgoing edge " + f"(found {len(entry_edges)})" + ], + ) + + entry_node = find_flow_node(nodes, entry_edges[0].target) + if entry_node is None or entry_node.type not in AGENT_NODE_TYPES: + _raise_flow_validation_error( + "resolve_entry_node", + [ + f"the input node's edge must lead to an agent node " + f"(target '{entry_edges[0].target}')" + ], + ) + return entry_node + + +def _list_structural_problems( + *, nodes: list[ChatbotFlowNode], edges: list[ChatbotFlowEdge] +) -> list[str]: + problems: list[str] = [] + + seen_node_ids: set[str] = set() + duplicate_node_ids: set[str] = set() + input_count = 0 + output_count = 0 + for node in nodes: + if node.id in seen_node_ids: + duplicate_node_ids.add(node.id) + seen_node_ids.add(node.id) + if node.type == ChatbotFlowNodeTypeEnum.INPUT: + input_count += 1 + if node.type == ChatbotFlowNodeTypeEnum.OUTPUT: + output_count += 1 + + if duplicate_node_ids: + problems.append(f"duplicate node ids: {', '.join(sorted(duplicate_node_ids))}") + if input_count > MAX_INPUT_NODES: + problems.append( + f"at most {MAX_INPUT_NODES} input node allowed (found {input_count})" + ) + if output_count > MAX_OUTPUT_NODES: + problems.append( + f"at most {MAX_OUTPUT_NODES} output node allowed (found {output_count})" + ) + + for edge in edges: + if edge.source not in seen_node_ids: + problems.append( + f"edge '{edge.id}' source '{edge.source}' is not a node in the flow" + ) + if edge.target not in seen_node_ids: + problems.append( + f"edge '{edge.id}' target '{edge.target}' is not a node in the flow" + ) + return problems + + +def _pin_agent_node_config( + *, session: Session, project_id: int, node: ChatbotFlowNode +) -> tuple[ChatbotFlowNode, list[str]]: + """Check the agent node is runnable; returns the node with its config_version pinned, plus any problems.""" + if node.type not in SUPPORTED_AGENT_NODE_TYPES: + return node, [ + f"node '{node.id}' has type '{node.type.value}', which is not supported yet " + f"(supported agent types: {_SUPPORTED_AGENT_NODE_TYPES_LABEL})" + ] + + node_data = node.agent_data() + problems: list[str] = [] + if node_data.prompt is None or not node_data.prompt.strip(): + problems.append(f"node '{node.id}' is missing data.prompt") + if node_data.config_id is None: + problems.append(f"node '{node.id}' is missing data.config_id") + return node, problems + + try: + version_row = get_chatbot_config_version( + session=session, + project_id=project_id, + config_id=node_data.config_id, + config_version=node_data.config_version, + ) + except HTTPException as e: + problems.append(f"node '{node.id}': {e.detail}") + return node, problems + + try: + build_chatbot_model_settings( + version_row, max_tokens=None, history_token_limit=None + ) + except ChatbotInputError as e: + reason = str(e).removeprefix(KAAPI_ERROR_TAG).strip() + problems.append(f"node '{node.id}': {reason}") + return node, problems + + # Pinned at publish so later config edits never change a published flow. + pinned_data = {**node.data, "config_version": version_row.version} + return node.model_copy(update={"data": pinned_data}), problems + + +def validate_flow_for_publish( + *, + session: Session, + project_id: int, + nodes: list[ChatbotFlowNode], + edges: list[ChatbotFlowEdge], +) -> list[ChatbotFlowNode]: + """Strict publish-time checks; returns the nodes with every agent config_version pinned.""" + problems = _list_structural_problems(nodes=nodes, edges=edges) + + pinned_nodes: list[ChatbotFlowNode] = [] + for node in nodes: + if node.type not in AGENT_NODE_TYPES: + pinned_nodes.append(node) + continue + pinned_node, node_problems = _pin_agent_node_config( + session=session, project_id=project_id, node=node + ) + pinned_nodes.append(pinned_node) + problems.extend(node_problems) + + if problems: + _raise_flow_validation_error("validate_flow_for_publish", problems) + + resolve_entry_node(nodes=pinned_nodes, edges=edges) + return pinned_nodes + + +def _get_chatbot_or_raise( + *, + session: Session, + chatbot_id: UUID, + organization_id: int, + project_id: int, + for_update: bool, +) -> None: + chatbot = get_chatbot( + session, + chatbot_id=chatbot_id, + organization_id=organization_id, + project_id=project_id, + for_update=for_update, + ) + if chatbot is None: + raise ChatbotNotFoundError(CHATBOT_NOT_FOUND_MESSAGE) + + +def get_chatbot_flow( + *, + session: Session, + chatbot_id: UUID, + organization_id: int, + project_id: int, + version: int | None, +) -> ChatbotFlowVersion: + """The requested version, else the draft if one exists, else the live version.""" + _get_chatbot_or_raise( + session=session, + chatbot_id=chatbot_id, + organization_id=organization_id, + project_id=project_id, + for_update=False, + ) + if version is not None: + flow_version = get_chatbot_flow_version( + session, chatbot_id=chatbot_id, version=version + ) + if flow_version is None: + raise ChatbotFlowNotFoundError( + f"Flow version {version} not found for this chatbot" + ) + return flow_version + + # The highest version is always the draft when one exists, otherwise the live one. + flow_version = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id) + if flow_version is None: + raise ChatbotFlowNotFoundError("Chatbot has no flow yet; save a flow first") + return flow_version + + +def save_chatbot_flow_draft( + *, + session: Session, + chatbot_id: UUID, + organization_id: int, + project_id: int, + flow: ChatbotFlowUpdate, +) -> ChatbotFlowVersion: + """Save the flow: the first version goes live at once; later saves overwrite the + draft, or start a new draft when the highest version is live (live versions are immutable). + """ + # The row lock serializes draft saves and publishes of the same chatbot. + _get_chatbot_or_raise( + session=session, + chatbot_id=chatbot_id, + organization_id=organization_id, + project_id=project_id, + for_update=True, + ) + nodes = dump_flow_nodes(flow.nodes) + edges = dump_flow_edges(flow.edges) + + latest = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id) + if latest is None: + # A new chatbot has nothing to run until its first flow exists, so that save + # goes live directly; it is validated like a publish so live is always runnable. + pinned_nodes = validate_flow_for_publish( + session=session, + project_id=project_id, + nodes=flow.nodes, + edges=flow.edges, + ) + return create_chatbot_flow_version( + session, + chatbot_id=chatbot_id, + version=FIRST_FLOW_VERSION, + nodes=dump_flow_nodes(pinned_nodes), + edges=edges, + is_live=True, + ) + + if not latest.is_live: + return update_chatbot_flow_version_graph( + session, flow_version=latest, nodes=nodes, edges=edges + ) + + return create_chatbot_flow_version( + session, + chatbot_id=chatbot_id, + version=latest.version + 1, + nodes=nodes, + edges=edges, + ) + + +def publish_chatbot_flow( + *, + session: Session, + chatbot_id: UUID, + organization_id: int, + project_id: int, +) -> ChatbotFlowVersion: + _get_chatbot_or_raise( + session=session, + chatbot_id=chatbot_id, + organization_id=organization_id, + project_id=project_id, + for_update=True, + ) + draft = get_latest_chatbot_flow_version(session, chatbot_id=chatbot_id) + if draft is None or draft.is_live: + raise ChatbotFlowConflictError(NO_DRAFT_TO_PUBLISH_MESSAGE) + + pinned_nodes = validate_flow_for_publish( + session=session, + project_id=project_id, + nodes=parse_flow_nodes(draft.nodes), + edges=parse_flow_edges(draft.edges), + ) + return publish_chatbot_flow_version( + session, flow_version=draft, nodes=dump_flow_nodes(pinned_nodes) + ) diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index e142ad8d4..dc0123c47 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -1,4 +1,5 @@ import asyncio +from typing import NamedTuple from uuid import UUID from fastapi import HTTPException @@ -6,19 +7,41 @@ from app.core.db import engine from app.crud.chatbot import ( + create_chatbot_session, create_chatbot_session_message, + get_chatbot, + get_chatbot_flow_version_by_id, get_chatbot_session, + get_latest_chatbot_session, + get_live_chatbot_flow_version, list_chatbot_session_messages, update_chatbot_session_progress, ) from app.models import ( + AGENT_NODE_TYPES, + ChatbotFlowNodeTypeEnum, + ChatbotFlowVersion, ChatbotMessageRoleEnum, + ChatbotSession, ChatbotSessionMessage, ChatbotSessionStatusEnum, + ChatbotSessionSummary, ) from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn from app.services.chatbot.config import resolve_chatbot_model_settings -from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.exceptions import ( + ChatbotFlowConflictError, + ChatbotInputError, + ChatbotNotFoundError, + ChatbotProviderError, +) +from app.services.chatbot.flow import ( + CHATBOT_NOT_FOUND_MESSAGE, + find_flow_node, + parse_flow_edges, + parse_flow_nodes, + resolve_entry_node, +) from app.services.chatbot.types import ChatbotMessageDict from app.services.chatbot.utils import ChatbotModelSettings from app.utils import APIResponse, get_webhook_secret, send_callback @@ -26,6 +49,132 @@ SUPERSEDED_SESSION_ERROR = ( "[KAAPI] Chatbot session was superseded by a newer session; start a new session." ) +NO_LIVE_FLOW_MESSAGE = "[KAAPI] Chatbot has no published flow; publish the flow first." + + +class _TurnNode(NamedTuple): + node_id: str + prompt: str + config_id: UUID + config_version: int | None + + +def start_chatbot_session( + *, + session: Session, + chatbot_id: UUID, + organization_id: int, + project_id: int, + contact_ref: str, +) -> tuple[ChatbotSession, ChatbotFlowVersion]: + """Create a session pinned to the live flow version and its entry node.""" + chatbot = get_chatbot( + session, + chatbot_id=chatbot_id, + organization_id=organization_id, + project_id=project_id, + ) + if chatbot is None: + raise ChatbotNotFoundError(CHATBOT_NOT_FOUND_MESSAGE) + + live_version = get_live_chatbot_flow_version(session, chatbot_id=chatbot_id) + if live_version is None: + raise ChatbotFlowConflictError(NO_LIVE_FLOW_MESSAGE) + + entry_node = resolve_entry_node( + nodes=parse_flow_nodes(live_version.nodes), + edges=parse_flow_edges(live_version.edges), + ) + chatbot_session = create_chatbot_session( + session, + chatbot_id=chatbot_id, + flow_version_id=live_version.id, + contact_ref=contact_ref, + active_node_id=entry_node.id, + ) + return chatbot_session, live_version + + +def get_latest_chatbot_session_summary( + *, + session: Session, + organization_id: int, + project_id: int, + contact_ref: str, + chatbot_id: UUID | None, +) -> ChatbotSessionSummary | None: + row = get_latest_chatbot_session( + session, + organization_id=organization_id, + project_id=project_id, + contact_ref=contact_ref, + chatbot_id=chatbot_id, + ) + if row is None: + return None + chatbot_session, chatbot, flow_version = row + + prompt: str | None = None + active_node = find_flow_node( + parse_flow_nodes(flow_version.nodes), chatbot_session.active_node_id + ) + if active_node is not None and active_node.type in AGENT_NODE_TYPES: + prompt = active_node.agent_data().prompt + + return ChatbotSessionSummary( + session_id=chatbot_session.id, + chatbot_id=chatbot.id, + chatbot_name=chatbot.name, + flow_version=flow_version.version, + active_node_id=chatbot_session.active_node_id, + prompt=prompt, + contact_ref=chatbot_session.contact_ref, + status=chatbot_session.status, + completion_status=chatbot_session.completion_status, + collected_fields=chatbot_session.collected_fields, + inserted_at=chatbot_session.inserted_at, + updated_at=chatbot_session.updated_at, + ) + + +def _resolve_turn_node( + *, flow_version: ChatbotFlowVersion | None, active_node_id: str +) -> _TurnNode: + """The runnable onboarding node the session is on; ChatbotInputError when it can't run.""" + active_node = None + if flow_version is not None: + active_node = find_flow_node( + parse_flow_nodes(flow_version.nodes), active_node_id + ) + if active_node is None: + error_message = ( + f"[KAAPI] Active node '{active_node_id}' is not in the session's flow " + "version; start a new session." + ) + raise ChatbotInputError(error_message) + + if active_node.type != ChatbotFlowNodeTypeEnum.ONBOARDING: + error_message = ( + f"[KAAPI] Active node '{active_node_id}' has type '{active_node.type.value}', " + f"which the chatbot runtime does not support yet; only " + f"'{ChatbotFlowNodeTypeEnum.ONBOARDING.value}' nodes can run." + ) + raise ChatbotInputError(error_message) + + node_data = active_node.agent_data() + if node_data.prompt is None or node_data.config_id is None: + error_message = ( + f"[KAAPI] Active node '{active_node_id}' is missing data.prompt or " + "data.config_id; republish the flow and start a new session." + ) + raise ChatbotInputError(error_message) + + return _TurnNode( + node_id=active_node.id, + prompt=node_data.prompt, + config_id=node_data.config_id, + config_version=node_data.config_version, + ) def _split_pending_turn( @@ -34,7 +183,7 @@ def _split_pending_turn( """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. + # The system prompt lives on the flow node and is passed separately. if stored.role == ChatbotMessageRoleEnum.SYSTEM: continue history.append({"role": stored.role.value, "content": stored.content}) @@ -90,7 +239,7 @@ def process_chatbot_session_message( ) if chatbot_session is None: return - # The turn may have been queued before a newer session superseded this one. + # The turn may have been queued before a newer session (or a chatbot delete) deactivated this one. if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE: _send_failure_webhook( session_id=session_id, @@ -107,17 +256,23 @@ def process_chatbot_session_message( ): return - system_prompt = chatbot_session.system_prompt - chatbot_ref = chatbot_session.chatbot_ref + chatbot_id = chatbot_session.chatbot_id session_status = chatbot_session.status.value collected_fields = dict(chatbot_session.collected_fields) + flow_version = get_chatbot_flow_version_by_id( + session, flow_version_id=chatbot_session.flow_version_id + ) try: + turn_node = _resolve_turn_node( + flow_version=flow_version, + active_node_id=chatbot_session.active_node_id, + ) model_settings: ChatbotModelSettings = resolve_chatbot_model_settings( session=session, project_id=project_id, - config_id=chatbot_session.config_id, - config_version=chatbot_session.config_version, + config_id=turn_node.config_id, + config_version=turn_node.config_version, max_tokens=None, history_token_limit=None, ) @@ -143,7 +298,7 @@ def process_chatbot_session_message( try: turn_result = asyncio.run( run_chatbot_turn( - system_prompt=system_prompt, + system_prompt=turn_node.prompt, history=history, user_message=user_message, model_settings=model_settings, @@ -167,6 +322,7 @@ def process_chatbot_session_message( session_id=session_id, role=ChatbotMessageRoleEnum.ASSISTANT, content=reply, + node_id=turn_node.node_id, ) update_chatbot_session_progress( session, @@ -178,7 +334,8 @@ def process_chatbot_session_message( envelope = APIResponse.success_response( data={ "session_id": str(session_id), - "chatbot_ref": chatbot_ref, + "chatbot_id": str(chatbot_id), + "node_id": turn_node.node_id, "reply": reply, "status": session_status, "completion_status": completion_status.value, diff --git a/backend/app/tests/api/routes/chatbot/conftest.py b/backend/app/tests/api/routes/chatbot/conftest.py index 8192a6b95..450021af7 100644 --- a/backend/app/tests/api/routes/chatbot/conftest.py +++ b/backend/app/tests/api/routes/chatbot/conftest.py @@ -7,7 +7,13 @@ get_feature_flag, update_feature_flag, ) +from app.models import Chatbot, Config, Project from app.tests.utils.auth import TestAuthContext +from app.tests.utils.chatbot import ( + create_test_anthropic_config, + create_test_chatbot, + create_test_live_chatbot, +) def set_chatbot_flag(db: Session, auth: TestAuthContext, *, enabled: bool) -> None: @@ -26,3 +32,40 @@ def set_chatbot_flag(db: Session, auth: TestAuthContext, *, enabled: bool) -> No @pytest.fixture(autouse=True) def chatbot_feature_enabled(db: Session, user_api_key: TestAuthContext) -> None: set_chatbot_flag(db, user_api_key, enabled=True) + + +@pytest.fixture +def headers(user_api_key: TestAuthContext) -> dict[str, str]: + return {"X-API-KEY": user_api_key.key} + + +@pytest.fixture +def project(user_api_key: TestAuthContext) -> Project: + return user_api_key.project + + +@pytest.fixture +def config(db: Session, project: Project) -> Config: + return create_test_anthropic_config(db, project_id=project.id) + + +@pytest.fixture +def chatbot(db: Session, project: Project) -> Chatbot: + """A chatbot in the caller's project with no flow saved yet.""" + return create_test_chatbot(db, project=project) + + +@pytest.fixture +def live_chatbot(db: Session, config: Config) -> Chatbot: + """A chatbot in the caller's project whose live flow is one onboarding node.""" + return create_test_live_chatbot(db, config=config)[0] + + +@pytest.fixture +def foreign_chatbot(db: Session) -> Chatbot: + return create_test_chatbot(db) + + +@pytest.fixture +def foreign_live_chatbot(db: Session) -> Chatbot: + return create_test_live_chatbot(db, config=create_test_anthropic_config(db))[0] diff --git a/backend/app/tests/api/routes/chatbot/test_chatbot.py b/backend/app/tests/api/routes/chatbot/test_chatbot.py new file mode 100644 index 000000000..60eb3302c --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_chatbot.py @@ -0,0 +1,232 @@ +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.models import ( + Chatbot, + ChatbotCompletionStatusEnum, + ChatbotSession, + ChatbotSessionStatusEnum, + Project, +) +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.chatbot import ( + CHATBOTS_URL, + LATEST_SESSION_URL, + UNKNOWN_CHATBOT_ID, + chatbot_url, + create_test_chatbot, + create_test_chatbot_session, +) + + +class TestCreateChatbot: + def test_creates_chatbot_in_callers_project( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + user_api_key: TestAuthContext, + ) -> None: + resp = client.post(CHATBOTS_URL, headers=headers, json={"name": "Onboarding"}) + + assert resp.status_code == 201 + body = resp.json()["data"] + assert body["name"] == "Onboarding" + assert body["project_id"] == user_api_key.project_id + assert body["organization_id"] == user_api_key.organization_id + stored = db.get(Chatbot, UUID(body["id"])) + assert stored is not None + assert stored.name == "Onboarding" + assert stored.project_id == user_api_key.project_id + + @pytest.mark.parametrize( + "payload", + [{}, {"name": ""}, {"name": "x" * 256}], + ids=["missing", "empty", "too-long"], + ) + def test_invalid_name_returns_422( + self, client: TestClient, headers: dict[str, str], payload: dict[str, str] + ) -> None: + resp = client.post(CHATBOTS_URL, headers=headers, json=payload) + + assert resp.status_code == 422 + + +class TestListChatbots: + def test_lists_only_callers_non_deleted_chatbots( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + project: Project, + chatbot: Chatbot, + foreign_chatbot: Chatbot, + ) -> None: + deleted = create_test_chatbot(db, project=project) + assert ( + client.delete(chatbot_url(deleted.id), headers=headers).status_code == 200 + ) + + resp = client.get(CHATBOTS_URL, headers=headers) + + assert resp.status_code == 200 + ids = {item["id"] for item in resp.json()["data"]} + assert str(chatbot.id) in ids + assert str(foreign_chatbot.id) not in ids + assert str(deleted.id) not in ids + + def test_skip_and_limit_page_newest_first( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + project: Project, + ) -> None: + # The seeded project may already own chatbots, so page through a known head. + create_test_chatbot(db, project=project) + middle = create_test_chatbot(db, project=project) + newest = create_test_chatbot(db, project=project) + + first = client.get(CHATBOTS_URL, headers=headers, params={"limit": 1}) + second = client.get( + CHATBOTS_URL, headers=headers, params={"skip": 1, "limit": 1} + ) + + assert [c["id"] for c in first.json()["data"]] == [str(newest.id)] + assert [c["id"] for c in second.json()["data"]] == [str(middle.id)] + + +class TestGetChatbot: + def test_returns_chatbot( + self, client: TestClient, headers: dict[str, str], chatbot: Chatbot + ) -> None: + resp = client.get(chatbot_url(chatbot.id), headers=headers) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["id"] == str(chatbot.id) + assert body["name"] == chatbot.name + + def test_unknown_returns_404( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.get(chatbot_url(UNKNOWN_CHATBOT_ID), headers=headers) + + assert resp.status_code == 404 + assert resp.json()["error"] == "Chatbot not found" + + def test_other_projects_chatbot_returns_404( + self, client: TestClient, headers: dict[str, str], foreign_chatbot: Chatbot + ) -> None: + resp = client.get(chatbot_url(foreign_chatbot.id), headers=headers) + + assert resp.status_code == 404 + + +class TestUpdateChatbot: + def test_renames_chatbot( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot: Chatbot, + ) -> None: + resp = client.patch( + chatbot_url(chatbot.id), headers=headers, json={"name": "Renamed"} + ) + + assert resp.status_code == 200 + assert resp.json()["data"]["name"] == "Renamed" + db.expire_all() + stored = db.get(Chatbot, chatbot.id) + assert stored is not None + assert stored.name == "Renamed" + + def test_other_projects_chatbot_returns_404_and_is_unchanged( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + foreign_chatbot: Chatbot, + ) -> None: + original_name = foreign_chatbot.name + + resp = client.patch( + chatbot_url(foreign_chatbot.id), headers=headers, json={"name": "Hijack"} + ) + + assert resp.status_code == 404 + db.expire_all() + stored = db.get(Chatbot, foreign_chatbot.id) + assert stored is not None + assert stored.name == original_name + + +class TestDeleteChatbot: + def test_soft_deletes_and_hides_chatbot( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot: Chatbot, + ) -> None: + resp = client.delete(chatbot_url(chatbot.id), headers=headers) + + assert resp.status_code == 200 + assert resp.json()["data"] == {"message": "Chatbot deleted successfully"} + db.expire_all() + stored = db.get(Chatbot, chatbot.id) + assert stored is not None + assert stored.deleted_at is not None + assert client.get(chatbot_url(chatbot.id), headers=headers).status_code == 404 + assert ( + client.patch( + chatbot_url(chatbot.id), headers=headers, json={"name": "x"} + ).status_code + == 404 + ) + assert ( + client.delete(chatbot_url(chatbot.id), headers=headers).status_code == 404 + ) + + def test_deactivates_active_sessions_and_hides_them_from_latest( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + live_chatbot: Chatbot, + ) -> None: + chatbot_session = create_test_chatbot_session(db, chatbot=live_chatbot) + + resp = client.delete(chatbot_url(live_chatbot.id), headers=headers) + + assert resp.status_code == 200 + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + assert stored.status == ChatbotSessionStatusEnum.INACTIVE + assert stored.completion_status == ChatbotCompletionStatusEnum.ABANDONED + latest = client.get( + LATEST_SESSION_URL, + headers=headers, + params={"contact_ref": chatbot_session.contact_ref}, + ) + assert latest.status_code == 404 + + def test_other_projects_chatbot_returns_404_and_is_kept( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + foreign_chatbot: Chatbot, + ) -> None: + resp = client.delete(chatbot_url(foreign_chatbot.id), headers=headers) + + assert resp.status_code == 404 + db.expire_all() + stored = db.get(Chatbot, foreign_chatbot.id) + assert stored is not None + assert stored.deleted_at is None diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py index 747d0f2b7..2df8f98de 100644 --- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py +++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py @@ -4,27 +4,47 @@ 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 +from app.tests.utils.chatbot import CHATBOTS_URL, UNKNOWN_CHATBOT_ID -CHATBOT_URL = f"{settings.API_V1_STR}/chatbot" UNKNOWN_SESSION_ID = UUID("00000000-0000-0000-0000-000000000001") @pytest.mark.parametrize( ("method", "path", "kwargs"), [ - ("post", "/sessions", {"json": {}}), - ("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}), + ("post", "", {"json": {"name": "bot"}}), + ("get", "", {}), + ("get", f"/{UNKNOWN_CHATBOT_ID}", {}), + ("patch", f"/{UNKNOWN_CHATBOT_ID}", {"json": {"name": "bot"}}), + ("delete", f"/{UNKNOWN_CHATBOT_ID}", {}), + ("get", f"/{UNKNOWN_CHATBOT_ID}/flow", {}), + ("put", f"/{UNKNOWN_CHATBOT_ID}/flow", {"json": {"nodes": []}}), + ("post", f"/{UNKNOWN_CHATBOT_ID}/flow/publish", {}), + ("post", f"/{UNKNOWN_CHATBOT_ID}/sessions", {"json": {"contact_ref": "c"}}), + ("get", "/sessions/latest", {"params": {"contact_ref": "c"}}), ("post", f"/sessions/{UNKNOWN_SESSION_ID}/message", {"json": {}}), ], - ids=["create_session", "get_latest_session", "send_message"], + ids=[ + "create_chatbot", + "list_chatbots", + "get_chatbot", + "update_chatbot", + "delete_chatbot", + "get_flow", + "save_flow", + "publish_flow", + "create_session", + "get_latest_session", + "send_message", + ], ) def test_chatbot_routes_return_403_when_flag_disabled( client: TestClient, db: Session, user_api_key: TestAuthContext, + headers: dict[str, str], method: str, path: str, kwargs: dict, @@ -33,8 +53,8 @@ def test_chatbot_routes_return_403_when_flag_disabled( resp = client.request( method, - f"{CHATBOT_URL}{path}", - headers={"X-API-KEY": user_api_key.key}, + f"{CHATBOTS_URL}{path}", + headers=headers, **kwargs, ) diff --git a/backend/app/tests/api/routes/chatbot/test_flow.py b/backend/app/tests/api/routes/chatbot/test_flow.py new file mode 100644 index 000000000..16c7965ef --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_flow.py @@ -0,0 +1,773 @@ +from collections.abc import Callable +from typing import Any +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from httpx import Response +from sqlmodel import Session + +from app.models import Chatbot, Config, Project +from app.tests.utils.chatbot import ( + ONBOARDING_NODE_ID, + UNKNOWN_CHATBOT_ID, + add_test_config_version, + build_flow_edge, + build_flow_node, + build_onboarding_node, + create_test_anthropic_config, + create_test_flow_version, + flow_url, + list_stored_flow_versions, + publish_url, + random_contact_ref, + sessions_url, +) +from app.tests.utils.test_data import create_test_config + +INVALID_FLOW_PREFIX = "[KAAPI] Chatbot flow is invalid: " +NO_DRAFT_MESSAGE = "[KAAPI] Chatbot has no draft flow to publish; save the flow first." +INPUT_NODE_ID = "input-1" +OUTPUT_NODE_ID = "output-1" + + +@pytest.fixture +def save( + client: TestClient, headers: dict[str, str], chatbot: Chatbot +) -> Callable[..., Response]: + def _save( + nodes: list[dict[str, Any]], edges: list[dict[str, Any]] | None = None + ) -> Response: + return client.put( + flow_url(chatbot.id), + headers=headers, + json={"nodes": nodes, "edges": edges or []}, + ) + + return _save + + +@pytest.fixture +def publish( + client: TestClient, headers: dict[str, str], chatbot: Chatbot +) -> Callable[[], Response]: + return lambda: client.post(publish_url(chatbot.id), headers=headers) + + +@pytest.fixture +def live_v1(config: Config, save: Callable[..., Response]) -> dict[str, Any]: + """A valid first save, which goes live as v1; later saves are drafts.""" + resp = save([build_onboarding_node(config_id=config.id)]) + assert resp.status_code == 200 + body = resp.json()["data"] + assert (body["version"], body["is_live"]) == (1, True) + return body + + +class TestSaveChatbotFlow: + def test_first_save_creates_live_version_1_with_pinned_config_version( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + ) -> None: + add_test_config_version(db, config) + + resp = save( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_onboarding_node(config_id=config.id, config_version=None), + ], + [build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID)], + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["version"] == 1 + assert body["is_live"] is True + assert body["chatbot_id"] == str(chatbot.id) + assert body["nodes"][1]["data"]["config_version"] == 2 + [stored] = list_stored_flow_versions(db, chatbot) + assert (stored.version, stored.is_live) == (1, True) + assert stored.nodes[1]["data"]["config_version"] == 2 + assert stored.nodes[1]["data"]["label"] == "Beneficiary Info Extractor" + assert "config_version" not in stored.nodes[0]["data"] + + def test_invalid_first_save_returns_422_and_stores_nothing( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + ) -> None: + resp = save([build_onboarding_node(config_id=config.id, prompt=None)]) + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error + assert list_stored_flow_versions(db, chatbot) == [] + assert client.get(flow_url(chatbot.id), headers=headers).status_code == 404 + + def test_session_can_start_right_after_first_save( + self, + client: TestClient, + headers: dict[str, str], + chatbot: Chatbot, + live_v1: dict[str, Any], + ) -> None: + resp = client.post( + sessions_url(chatbot.id), + headers=headers, + json={"contact_ref": random_contact_ref()}, + ) + + assert resp.status_code == 201 + body = resp.json()["data"] + assert body["flow_version"] == 1 + assert body["active_node_id"] == ONBOARDING_NODE_ID + + def test_save_overwrites_existing_draft_in_place( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + live_v1: dict[str, Any], + ) -> None: + draft = save([build_onboarding_node(config_id=config.id, prompt="Draft.")]) + draft_body = draft.json()["data"] + new_nodes = [build_onboarding_node(config_id=config.id, prompt="Ask grade.")] + + resp = save(new_nodes) + + assert resp.status_code == 200 + assert resp.json()["data"]["id"] == draft_body["id"] + assert resp.json()["data"]["version"] == 2 + assert resp.json()["data"]["is_live"] is False + live, stored_draft = list_stored_flow_versions(db, chatbot) + assert str(live.id) == live_v1["id"] + assert live.is_live is True + assert stored_draft.nodes == new_nodes + + def test_save_after_publish_starts_new_draft_and_keeps_live_version( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + ) -> None: + live_nodes = [build_onboarding_node(config_id=config.id)] + create_test_flow_version(db, chatbot=chatbot, nodes=live_nodes, is_live=True) + draft_nodes = [build_onboarding_node(config_id=config.id, prompt="Ask grade.")] + + resp = save(draft_nodes) + + assert resp.status_code == 200 + assert resp.json()["data"]["version"] == 2 + assert resp.json()["data"]["is_live"] is False + live, draft = list_stored_flow_versions(db, chatbot) + assert (live.version, live.is_live, live.nodes) == (1, True, live_nodes) + assert (draft.version, draft.is_live, draft.nodes) == (2, False, draft_nodes) + + def test_react_flow_keys_and_extra_ui_keys_round_trip( + self, + client: TestClient, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + live_v1: dict[str, Any], + ) -> None: + onboarding = build_onboarding_node(config_id=config.id) + onboarding["measured"] = {"width": 240, "height": 80} + onboarding["selected"] = True + onboarding["position"] = {"x": 12.5, "y": -3, "snapped": False} + edge = { + **build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID), + "sourceHandle": "out", + "targetHandle": "in", + "animated": True, + "data": {"intent": "greet"}, + } + + save([build_flow_node(INPUT_NODE_ID, "input"), onboarding], [edge]) + resp = client.get(flow_url(chatbot.id), headers=headers) + + body = resp.json()["data"] + assert (body["version"], body["is_live"]) == (2, False) + returned_onboarding = body["nodes"][1] + assert returned_onboarding["measured"] == {"width": 240, "height": 80} + assert returned_onboarding["selected"] is True + assert returned_onboarding["position"] == { + "x": 12.5, + "y": -3, + "snapped": False, + } + assert returned_onboarding["data"]["label"] == "Beneficiary Info Extractor" + assert body["edges"] == [ + { + "id": "e1", + "source": INPUT_NODE_ID, + "target": ONBOARDING_NODE_ID, + "sourceHandle": "out", + "targetHandle": "in", + "animated": True, + "data": {"intent": "greet"}, + } + ] + + @pytest.mark.parametrize( + "bad_node", + [ + {"id": "n1", "type": "bogus", "position": {"x": 0, "y": 0}}, + {"id": "", "type": "input", "position": {"x": 0, "y": 0}}, + {"id": "n1", "type": "input"}, + { + "id": "n1", + "type": "onboarding", + "position": {"x": 0, "y": 0}, + "data": {"config_version": 0}, + }, + ], + ids=["unknown-type", "empty-id", "missing-position", "config-version-zero"], + ) + def test_malformed_node_returns_422_and_saves_nothing( + self, + db: Session, + chatbot: Chatbot, + save: Callable[..., Response], + bad_node: dict[str, Any], + ) -> None: + resp = save([bad_node]) + + assert resp.status_code == 422 + assert list_stored_flow_versions(db, chatbot) == [] + + def test_draft_save_does_not_validate_publish_rules( + self, save: Callable[..., Response], live_v1: dict[str, Any] + ) -> None: + coordinator = build_flow_node("router", "coordinator", prompt="Route") + + resp = save([coordinator, build_flow_node(INPUT_NODE_ID, "input")]) + + assert resp.status_code == 200 + assert resp.json()["data"]["is_live"] is False + + def test_unknown_chatbot_returns_404( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.put( + flow_url(UNKNOWN_CHATBOT_ID), headers=headers, json={"nodes": []} + ) + + assert resp.status_code == 404 + + def test_other_projects_chatbot_returns_404_and_saves_nothing( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + foreign_chatbot: Chatbot, + ) -> None: + resp = client.put( + flow_url(foreign_chatbot.id), + headers=headers, + json={"nodes": [build_flow_node(INPUT_NODE_ID, "input")]}, + ) + + assert resp.status_code == 404 + assert list_stored_flow_versions(db, foreign_chatbot) == [] + + +class TestGetChatbotFlow: + def test_no_versions_returns_404( + self, client: TestClient, headers: dict[str, str], chatbot: Chatbot + ) -> None: + resp = client.get(flow_url(chatbot.id), headers=headers) + + assert resp.status_code == 404 + assert resp.json()["error"] == "Chatbot has no flow yet; save a flow first" + + def test_default_returns_live_when_no_draft( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + ) -> None: + live = create_test_flow_version( + db, + chatbot=chatbot, + nodes=[build_onboarding_node(config_id=config.id)], + is_live=True, + ) + + resp = client.get(flow_url(chatbot.id), headers=headers) + + assert resp.status_code == 200 + assert resp.json()["data"]["id"] == str(live.id) + assert resp.json()["data"]["is_live"] is True + + def test_default_returns_draft_over_live( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + ) -> None: + nodes = [build_onboarding_node(config_id=config.id)] + create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True) + draft = create_test_flow_version(db, chatbot=chatbot, nodes=nodes) + + resp = client.get(flow_url(chatbot.id), headers=headers) + + assert resp.json()["data"]["id"] == str(draft.id) + assert resp.json()["data"]["version"] == 2 + assert resp.json()["data"]["is_live"] is False + + def test_version_param_returns_that_version( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + ) -> None: + nodes = [build_onboarding_node(config_id=config.id)] + first = create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True) + create_test_flow_version(db, chatbot=chatbot, nodes=nodes) + + resp = client.get(flow_url(chatbot.id), headers=headers, params={"version": 1}) + + assert resp.status_code == 200 + assert resp.json()["data"]["id"] == str(first.id) + + def test_unknown_version_returns_404( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + config: Config, + chatbot: Chatbot, + ) -> None: + create_test_flow_version( + db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)] + ) + + resp = client.get(flow_url(chatbot.id), headers=headers, params={"version": 7}) + + assert resp.status_code == 404 + assert resp.json()["error"] == "Flow version 7 not found for this chatbot" + + def test_other_projects_chatbot_returns_404( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + foreign_chatbot: Chatbot, + ) -> None: + create_test_flow_version( + db, chatbot=foreign_chatbot, nodes=[build_flow_node(INPUT_NODE_ID, "input")] + ) + + resp = client.get(flow_url(foreign_chatbot.id), headers=headers) + + assert resp.status_code == 404 + assert resp.json()["error"] == "Chatbot not found" + + +class TestPublishChatbotFlow: + def test_publishes_draft_and_unsets_previous_live( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + ) -> None: + create_test_flow_version( + db, + chatbot=chatbot, + nodes=[build_onboarding_node(config_id=config.id)], + is_live=True, + ) + save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")]) + + resp = publish() + + assert resp.status_code == 200 + assert resp.json()["data"]["version"] == 2 + assert resp.json()["data"]["is_live"] is True + previous, published = list_stored_flow_versions(db, chatbot) + assert previous.is_live is False + assert published.is_live is True + + def test_missing_config_version_is_pinned_to_latest_and_stored( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + add_test_config_version(db, config) + save( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_onboarding_node(config_id=config.id, config_version=None), + ], + [build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID)], + ) + + resp = publish() + + assert resp.status_code == 200 + assert resp.json()["data"]["version"] == 2 + assert resp.json()["data"]["nodes"][1]["data"]["config_version"] == 2 + _, stored = list_stored_flow_versions(db, chatbot) + assert stored.is_live is True + assert stored.nodes[1]["data"]["config_version"] == 2 + assert stored.nodes[1]["data"]["label"] == "Beneficiary Info Extractor" + assert "config_version" not in stored.nodes[0]["data"] + + def test_explicit_config_version_is_kept( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + add_test_config_version(db, config) + save([build_onboarding_node(config_id=config.id, config_version=1)]) + + resp = publish() + + assert resp.status_code == 200 + _, stored = list_stored_flow_versions(db, chatbot) + assert (stored.version, stored.is_live) == (2, True) + assert stored.nodes[0]["data"]["config_version"] == 1 + + def test_no_versions_returns_409(self, publish: Callable[[], Response]) -> None: + resp = publish() + + assert resp.status_code == 409 + assert resp.json()["error"] == NO_DRAFT_MESSAGE + + def test_publish_right_after_first_save_returns_409( + self, publish: Callable[[], Response], live_v1: dict[str, Any] + ) -> None: + resp = publish() + + assert resp.status_code == 409 + assert resp.json()["error"] == NO_DRAFT_MESSAGE + + def test_latest_already_live_returns_409( + self, + db: Session, + config: Config, + chatbot: Chatbot, + publish: Callable[[], Response], + ) -> None: + create_test_flow_version( + db, + chatbot=chatbot, + nodes=[build_onboarding_node(config_id=config.id)], + is_live=True, + ) + + resp = publish() + + assert resp.status_code == 409 + assert resp.json()["error"] == NO_DRAFT_MESSAGE + + def test_unknown_chatbot_returns_404( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.post(publish_url(UNKNOWN_CHATBOT_ID), headers=headers) + + assert resp.status_code == 404 + + +# Each case builds (nodes, edges) from the project's valid Anthropic config id. +FlowBuilder = Callable[[UUID], tuple[list[dict[str, Any]], list[dict[str, Any]]]] + +INVALID_FLOW_CASES: dict[str, tuple[FlowBuilder, str]] = { + "duplicate-node-ids": ( + lambda cid: ( + [ + build_onboarding_node(config_id=cid), + build_onboarding_node(config_id=cid), + ], + [], + ), + f"duplicate node ids: {ONBOARDING_NODE_ID}", + ), + "two-input-nodes": ( + lambda cid: ( + [ + build_flow_node("in-a", "input"), + build_flow_node("in-b", "input"), + build_onboarding_node(config_id=cid), + ], + [], + ), + "at most 1 input node allowed (found 2)", + ), + "two-output-nodes": ( + lambda cid: ( + [ + build_flow_node("out-a", "output"), + build_flow_node("out-b", "output"), + build_onboarding_node(config_id=cid), + ], + [], + ), + "at most 1 output node allowed (found 2)", + ), + "edge-to-unknown-node": ( + lambda cid: ( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_onboarding_node(config_id=cid), + ], + [build_flow_edge("e1", INPUT_NODE_ID, "ghost")], + ), + "edge 'e1' target 'ghost' is not a node in the flow", + ), + "edge-from-unknown-node": ( + lambda cid: ( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_onboarding_node(config_id=cid), + ], + [ + build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID), + build_flow_edge("e2", "ghost", ONBOARDING_NODE_ID), + ], + ), + "edge 'e2' source 'ghost' is not a node in the flow", + ), + "coordinator-node": ( + lambda cid: ( + [build_onboarding_node(config_id=cid, node_type="coordinator")], + [], + ), + f"node '{ONBOARDING_NODE_ID}' has type 'coordinator', which is not supported yet", + ), + "rag-node": ( + lambda cid: ([build_onboarding_node(config_id=cid, node_type="rag")], []), + f"node '{ONBOARDING_NODE_ID}' has type 'rag', which is not supported yet", + ), + "missing-config-id": ( + lambda cid: ([build_onboarding_node(config_id=None)], []), + f"node '{ONBOARDING_NODE_ID}' is missing data.config_id", + ), + "missing-prompt": ( + lambda cid: ([build_onboarding_node(config_id=cid, prompt=None)], []), + f"node '{ONBOARDING_NODE_ID}' is missing data.prompt", + ), + "blank-prompt": ( + lambda cid: ([build_onboarding_node(config_id=cid, prompt=" ")], []), + f"node '{ONBOARDING_NODE_ID}' is missing data.prompt", + ), + "unknown-config-version": ( + lambda cid: ([build_onboarding_node(config_id=cid, config_version=99)], []), + f"node '{ONBOARDING_NODE_ID}': Version with number '99' not found", + ), + "no-edges-two-agents": ( + lambda cid: ( + [ + build_onboarding_node(config_id=cid), + build_onboarding_node(config_id=cid, node_id="second"), + ], + [], + ), + "a flow without edges must contain exactly one agent node (found 2)", + ), + "no-edges-no-agent": ( + lambda cid: ([build_flow_node(INPUT_NODE_ID, "input")], []), + "a flow without edges must contain exactly one agent node (found 0)", + ), + "edges-without-input": ( + lambda cid: ( + [ + build_onboarding_node(config_id=cid), + build_flow_node(OUTPUT_NODE_ID, "output"), + ], + [build_flow_edge("e1", ONBOARDING_NODE_ID, OUTPUT_NODE_ID)], + ), + "a flow with edges must contain exactly one input node (found 0)", + ), + "input-with-two-outgoing-edges": ( + lambda cid: ( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_onboarding_node(config_id=cid), + build_onboarding_node(config_id=cid, node_id="second"), + ], + [ + build_flow_edge("e1", INPUT_NODE_ID, ONBOARDING_NODE_ID), + build_flow_edge("e2", INPUT_NODE_ID, "second"), + ], + ), + f"input node '{INPUT_NODE_ID}' must have exactly one outgoing edge (found 2)", + ), + "input-edge-to-output": ( + lambda cid: ( + [ + build_flow_node(INPUT_NODE_ID, "input"), + build_flow_node(OUTPUT_NODE_ID, "output"), + build_onboarding_node(config_id=cid), + ], + [ + build_flow_edge("e1", INPUT_NODE_ID, OUTPUT_NODE_ID), + build_flow_edge("e2", ONBOARDING_NODE_ID, OUTPUT_NODE_ID), + ], + ), + f"the input node's edge must lead to an agent node (target '{OUTPUT_NODE_ID}')", + ), +} + + +# A representative subset: the first save shares publish's validator, so the full +# matrix is covered by the publish tests below. +FIRST_SAVE_INVALID_CASES = [ + "coordinator-node", + "duplicate-node-ids", + "input-edge-to-output", + "no-edges-no-agent", + "unknown-config-version", +] + + +class TestFirstSaveValidation: + @pytest.mark.parametrize( + "case", FIRST_SAVE_INVALID_CASES, ids=FIRST_SAVE_INVALID_CASES + ) + def test_invalid_first_save_returns_422_and_stores_nothing( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + case: str, + ) -> None: + build_flow, expected_problem = INVALID_FLOW_CASES[case] + nodes, edges = build_flow(config.id) + + resp = save(nodes, edges) + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert expected_problem in error + assert list_stored_flow_versions(db, chatbot) == [] + + +class TestPublishChatbotFlowValidation: + @pytest.mark.parametrize( + "case", sorted(INVALID_FLOW_CASES), ids=sorted(INVALID_FLOW_CASES) + ) + def test_invalid_flow_returns_422_and_stays_draft( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + case: str, + ) -> None: + build_flow, expected_problem = INVALID_FLOW_CASES[case] + nodes, edges = build_flow(config.id) + assert save(nodes, edges).status_code == 200 + + resp = publish() + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert expected_problem in error + live, draft = list_stored_flow_versions(db, chatbot) + assert (live.version, live.is_live) == (1, True) + assert (draft.version, draft.is_live) == (2, False) + + def test_config_from_another_project_returns_422( + self, + db: Session, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + foreign_config = create_test_anthropic_config(db) + save([build_onboarding_node(config_id=foreign_config.id)]) + + resp = publish() + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert f"node '{ONBOARDING_NODE_ID}': " in error + assert str(foreign_config.id) in error + _, draft = list_stored_flow_versions(db, chatbot) + assert draft.is_live is False + + def test_non_anthropic_config_returns_422( + self, + db: Session, + project: Project, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + openai_config = create_test_config( + db, project_id=project.id, use_kaapi_schema=True + ) + save([build_onboarding_node(config_id=openai_config.id)]) + + resp = publish() + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert f"node '{ONBOARDING_NODE_ID}': Chatbot only supports configs" in error + _, draft = list_stored_flow_versions(db, chatbot) + assert draft.is_live is False + + def test_all_problems_are_reported_together( + self, + config: Config, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + save( + [ + build_flow_node("in-a", "input"), + build_flow_node("in-b", "input"), + build_onboarding_node( + config_id=config.id, node_id="router", node_type="coordinator" + ), + build_onboarding_node(config_id=config.id, prompt=None), + ] + ) + + resp = publish() + + assert resp.status_code == 422 + error = resp.json()["error"] + assert "at most 1 input node allowed (found 2)" in error + assert "node 'router' has type 'coordinator'" in error + assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error + assert error.count(INVALID_FLOW_PREFIX) == 1 diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index 5501b993a..bda18d808 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -1,94 +1,101 @@ -import re import socket +from collections.abc import Callable from typing import Any -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from fastapi.testclient import TestClient -from sqlmodel import Session, select +from httpx import Response +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, + delete_chatbot, list_chatbot_session_messages, update_chatbot_session_progress, ) from app.models import ( - DEFAULT_CHATBOT_NAME, + Chatbot, 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.services.chatbot import session as session_service from app.services.chatbot.exceptions import ( CONCURRENT_SESSION_CONFLICT_MESSAGE, ChatbotSessionConflictError, ) +from app.services.chatbot.session import NO_LIVE_FLOW_MESSAGE from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( + DEFAULT_SYSTEM_PROMPT, + LATEST_SESSION_URL, + ONBOARDING_NODE_ID, + UNKNOWN_CHATBOT_ID, + WEBHOOK_URL, add_test_chatbot_messages, + build_flow_edge, + build_flow_node, + build_onboarding_node, create_test_chatbot_session, + create_test_flow_version, + create_test_live_chatbot, + list_stored_sessions_for_contact, + message_url, + random_contact_ref, + sessions_url, ) -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" - - -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 + chatbot = db.get(Chatbot, chatbot_session.chatbot_id) + assert chatbot is not None return create_test_chatbot_session( - db, - config=config, - contact_ref=chatbot_session.contact_ref, - chatbot_ref=chatbot_session.chatbot_ref, + db, chatbot=chatbot, contact_ref=chatbot_session.contact_ref ) @pytest.fixture -def headers(user_api_key: TestAuthContext) -> dict[str, str]: - return {"X-API-KEY": user_api_key.key} +def chatbot_session(db: Session, live_chatbot: Chatbot) -> ChatbotSession: + return create_test_chatbot_session(db, chatbot=live_chatbot) @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(), +def create_session( + client: TestClient, headers: dict[str, str] +) -> Callable[[UUID, dict[str, Any]], Response]: + return lambda chatbot_id, payload: client.post( + sessions_url(chatbot_id), headers=headers, json=payload ) @pytest.fixture -def chatbot_session(db: Session, anthropic_config: Config) -> ChatbotSession: - return create_test_chatbot_session(db, config=anthropic_config) +def get_latest(client: TestClient, headers: dict[str, str]) -> Callable[..., Response]: + def _get_latest(contact_ref: str, chatbot_id: UUID | None = None) -> Response: + params = {"contact_ref": contact_ref} + if chatbot_id is not None: + params["chatbot_id"] = str(chatbot_id) + return client.get(LATEST_SESSION_URL, headers=headers, params=params) + + return _get_latest + + +@pytest.fixture +def send_message( + client: TestClient, headers: dict[str, str] +) -> Callable[..., Response]: + def _send_message(session_id: UUID, **body: str) -> Response: + return client.post( + message_url(session_id), + headers=headers, + json={"webhook_url": WEBHOOK_URL, **body}, + ) + + return _send_message @pytest.fixture @@ -112,269 +119,147 @@ def enqueued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: 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={ - "name": "Onboarding Bot", - "config_id": str(anthropic_config.id), - "config_version": 1, - "contact_ref": contact_ref, - "chatbot_ref": "chatbot-onboarding", - "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.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( + def test_creates_session_pinned_to_live_flow_and_entry_node( self, - client: TestClient, db: Session, - headers: dict[str, str], - user_api_key: TestAuthContext, - anthropic_config: Config, + config: Config, + chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], ) -> None: - resp = client.post( - SESSIONS_URL, - headers=headers, - 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, - }, - ) - - assert resp.status_code == 201 - stored = get_chatbot_session( + live = create_test_flow_version( db, - session_id=resp.json()["data"]["session_id"], - organization_id=user_api_key.organization_id, - project_id=user_api_key.project_id, + chatbot=chatbot, + nodes=[ + build_flow_node("start", "input"), + build_onboarding_node(config_id=config.id), + ], + edges=[build_flow_edge("e1", "start", ONBOARDING_NODE_ID)], + is_live=True, ) - 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: - resp = client.post( - SESSIONS_URL, - headers=headers, - json={ - "config_id": str(uuid4()), - "contact_ref": "contact-1", - "chatbot_ref": "chatbot-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", - "chatbot_ref": "chatbot-1", - "system_prompt": SYSTEM_PROMPT, - }, - ) - - assert resp.status_code == 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, - }, + # A newer unpublished draft must not be picked up. + create_test_flow_version( + db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)] ) + contact_ref = random_contact_ref() - 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) + resp = create_session(chatbot.id, {"contact_ref": 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 body["chatbot_id"] == str(chatbot.id) + assert body["flow_version"] == 1 + assert body["active_node_id"] == ONBOARDING_NODE_ID + [stored] = list_stored_sessions_for_contact(db, contact_ref) assert str(stored.id) == body["session_id"] - assert stored.chatbot_ref == body["chatbot_ref"] + assert stored.flow_version_id == live.id + assert stored.active_node_id == ONBOARDING_NODE_ID + assert stored.status == ChatbotSessionStatusEnum.ACTIVE + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS - def test_provided_chatbot_ref_is_used_as_is( + def test_new_session_for_same_contact_supersedes_previous( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, + live_chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], + get_latest: Callable[..., Response], ) -> None: - contact_ref = f"contact-{random_lower_string()}" - chatbot_ref = f"Glific Flow {random_lower_string()}" + contact_ref = random_contact_ref() + payload = {"contact_ref": contact_ref} + first = create_session(live_chatbot.id, payload) - resp = self._create( - client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref - ) + resp = create_session(live_chatbot.id, payload) 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" + first_id = first.json()["data"]["session_id"] + second_id = resp.json()["data"]["session_id"] + assert second_id != first_id + latest = get_latest(contact_ref, live_chatbot.id).json()["data"] + assert latest["session_id"] == second_id statuses = { str(s.id): (s.status, s.completion_status) - for s in _sessions_for_contact(db, contact_ref) + for s in list_stored_sessions_for_contact(db, contact_ref) } assert statuses == { - first["session_id"]: ( + first_id: ( ChatbotSessionStatusEnum.INACTIVE, ChatbotCompletionStatusEnum.ABANDONED, ), - second["session_id"]: ( + second_id: ( ChatbotSessionStatusEnum.ACTIVE, ChatbotCompletionStatusEnum.IN_PROGRESS, ), } - @pytest.mark.parametrize("chatbot_ref", ["", "x" * 256], ids=["empty", "too-long"]) - def test_invalid_chatbot_ref_returns_422( + def test_chatbot_without_live_flow_returns_409( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, - chatbot_ref: str, + config: Config, + chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], ) -> None: - contact_ref = f"contact-{random_lower_string()}" - - resp = self._create( - client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref + create_test_flow_version( + db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)] ) + contact_ref = random_contact_ref() - assert resp.status_code == 422 - assert _sessions_for_contact(db, contact_ref) == [] + resp = create_session(chatbot.id, {"contact_ref": contact_ref}) - def test_ref_generation_exhausted_returns_503_and_creates_nothing( + assert resp.status_code == 409 + assert resp.json()["error"] == NO_LIVE_FLOW_MESSAGE + assert list_stored_sessions_for_contact(db, contact_ref) == [] + + def test_unknown_chatbot_returns_404( + self, create_session: Callable[[UUID, dict[str, Any]], Response] + ) -> None: + resp = create_session(UNKNOWN_CHATBOT_ID, {"contact_ref": "contact-1"}) + + assert resp.status_code == 404 + assert resp.json()["error"] == "Chatbot not found" + + def test_other_projects_chatbot_returns_404( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, + foreign_live_chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], ) -> 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()}" + contact_ref = random_contact_ref() - resp = self._create(client, headers, anthropic_config, contact_ref) + resp = create_session(foreign_live_chatbot.id, {"contact_ref": contact_ref}) - assert resp.status_code == 503 - assert "ChatbotRefGenerationError" in resp.json()["error"] - assert _sessions_for_contact(db, contact_ref) == [] + assert resp.status_code == 404 + assert list_stored_sessions_for_contact(db, contact_ref) == [] - def test_concurrent_create_conflict_returns_409( + def test_deleted_chatbot_returns_404( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, + live_chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], + ) -> None: + delete_chatbot(db, chatbot=live_chatbot) + + resp = create_session(live_chatbot.id, {"contact_ref": "c-1"}) + + assert resp.status_code == 404 + + @pytest.mark.parametrize( + "payload", + [{}, {"contact_ref": ""}, {"contact_ref": "x" * 501}], + ids=["missing", "empty", "too-long"], + ) + def test_invalid_contact_ref_returns_422( + self, + live_chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], + payload: dict[str, str], + ) -> None: + resp = create_session(live_chatbot.id, payload) + + assert resp.status_code == 422 + + def test_concurrent_create_conflict_returns_409( + self, + live_chatbot: Chatbot, + create_session: Callable[[UUID, dict[str, Any]], Response], monkeypatch: pytest.MonkeyPatch, ) -> None: # The real unique-index race is covered in the CRUD tests; there the @@ -382,80 +267,58 @@ def test_concurrent_create_conflict_returns_409( def lose_race(*_args: Any, **_kwargs: Any) -> ChatbotSession: raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) - monkeypatch.setattr(session_routes, "create_chatbot_session", lose_race) + monkeypatch.setattr(session_service, "create_chatbot_session", lose_race) - resp = self._create( - client, - headers, - anthropic_config, - f"contact-{random_lower_string()}", - chatbot_ref=f"chatbot-{random_lower_string()}", - ) + resp = create_session(live_chatbot.id, {"contact_ref": random_contact_ref()}) assert resp.status_code == 409 assert resp.json()["error"] == CONCURRENT_SESSION_CONFLICT_MESSAGE class TestGetLatestChatbotSession: - def test_returns_contacts_newest_session_in_callers_project( + def test_returns_contacts_newest_session_with_chatbot_and_node_details( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, + config: Config, + foreign_live_chatbot: Chatbot, + get_latest: Callable[..., Response], ) -> None: - contact_ref = f"contact-{random_lower_string()}" - chatbot_ref = f"chatbot-{random_lower_string()}" - create_test_chatbot_session( + chatbot, _ = create_test_live_chatbot( db, - config=anthropic_config, - contact_ref=contact_ref, - chatbot_ref=chatbot_ref, - ) - newer = create_test_chatbot_session( - db, - config=anthropic_config, + config=config, name="Onboarding Bot", - 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 + prompt="Collect the student's name and grade.", ) - foreign_config = create_test_config( - db, config_blob=build_anthropic_text_config_blob() + contact_ref = random_contact_ref() + create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref) + newer = create_test_chatbot_session( + db, chatbot=chatbot, contact_ref=contact_ref ) + create_test_chatbot_session(db, chatbot=chatbot) create_test_chatbot_session( - db, config=foreign_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + db, chatbot=foreign_live_chatbot, contact_ref=contact_ref ) - resp = client.get( - SESSIONS_URL, - headers=headers, - params={"contact_ref": contact_ref, "chatbot_ref": chatbot_ref}, - ) + resp = get_latest(contact_ref, chatbot.id) assert resp.status_code == 200 body = resp.json()["data"] assert body["session_id"] == str(newer.id) - assert body["name"] == "Onboarding Bot" + assert body["chatbot_id"] == str(chatbot.id) + assert body["chatbot_name"] == "Onboarding Bot" + assert body["flow_version"] == 1 + assert body["active_node_id"] == ONBOARDING_NODE_ID + assert body["prompt"] == "Collect the student's name and grade." assert body["contact_ref"] == contact_ref - assert body["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"] == {} - 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, + get_latest: Callable[..., Response], ) -> None: update_chatbot_session_progress( db, @@ -464,14 +327,7 @@ def test_reports_stored_field_progress( completion_status=ChatbotCompletionStatusEnum.COMPLETE, ) - resp = client.get( - SESSIONS_URL, - headers=headers, - params={ - "contact_ref": chatbot_session.contact_ref, - "chatbot_ref": chatbot_session.chatbot_ref, - }, - ) + resp = get_latest(chatbot_session.contact_ref, chatbot_session.chatbot_id) assert resp.status_code == 200 body = resp.json()["data"] @@ -479,108 +335,138 @@ def test_reports_stored_field_progress( 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] + def test_prompt_comes_from_the_sessions_pinned_flow( + self, + db: Session, + config: Config, + live_chatbot: Chatbot, + chatbot_session: ChatbotSession, + get_latest: Callable[..., Response], ) -> None: - resp = client.get( - SESSIONS_URL, - headers=headers, - params={ - "contact_ref": f"contact-{random_lower_string()}", - "chatbot_ref": f"chatbot-{random_lower_string()}", - }, + create_test_flow_version( + db, + chatbot=live_chatbot, + nodes=[ + build_onboarding_node(config_id=config.id, prompt="A newer prompt.") + ], + is_live=True, ) - assert resp.status_code == 404 + resp = get_latest(chatbot_session.contact_ref) - def test_missing_contact_ref_returns_422( - self, client: TestClient, headers: dict[str, str] + body = resp.json()["data"] + assert body["flow_version"] == 1 + assert body["prompt"] == DEFAULT_SYSTEM_PROMPT + + def test_prompt_is_null_when_active_node_is_not_in_flow( + self, + db: Session, + live_chatbot: Chatbot, + get_latest: Callable[..., Response], ) -> None: - resp = client.get(SESSIONS_URL, headers=headers) + chatbot_session = create_test_chatbot_session( + db, chatbot=live_chatbot, active_node_id="ghost" + ) - assert resp.status_code == 422 + resp = get_latest(chatbot_session.contact_ref) + + assert resp.status_code == 200 + assert resp.json()["data"]["active_node_id"] == "ghost" + assert resp.json()["data"]["prompt"] is None - def test_omitted_chatbot_ref_returns_contacts_newest_session_across_chatbots( + def test_omitted_chatbot_id_returns_newest_session_across_chatbots( self, - client: TestClient, db: Session, - headers: dict[str, str], - anthropic_config: Config, + config: Config, + chatbot_session: ChatbotSession, + get_latest: Callable[..., Response], ) -> 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()}", - ) + other_chatbot, _ = create_test_live_chatbot(db, config=config) newest = create_test_chatbot_session( - db, - config=anthropic_config, - contact_ref=contact_ref, - chatbot_ref=f"chatbot-{random_lower_string()}", + db, chatbot=other_chatbot, contact_ref=chatbot_session.contact_ref ) - resp = client.get( - SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref} - ) + resp = get_latest(chatbot_session.contact_ref) assert resp.status_code == 200 body = resp.json()["data"] assert body["session_id"] == str(newest.id) - assert body["chatbot_ref"] == newest.chatbot_ref + assert body["chatbot_id"] == str(other_chatbot.id) - def test_omitted_chatbot_ref_unknown_contact_returns_404( - self, client: TestClient, headers: dict[str, str] + def test_deleted_chatbots_sessions_are_excluded( + self, + db: Session, + config: Config, + chatbot_session: ChatbotSession, + get_latest: Callable[..., Response], ) -> None: - resp = client.get( - SESSIONS_URL, - headers=headers, - params={"contact_ref": f"contact-{random_lower_string()}"}, + deleted_chatbot, _ = create_test_live_chatbot(db, config=config) + create_test_chatbot_session( + db, chatbot=deleted_chatbot, contact_ref=chatbot_session.contact_ref ) + delete_chatbot(db, chatbot=deleted_chatbot) + + across = get_latest(chatbot_session.contact_ref) + scoped = get_latest(chatbot_session.contact_ref, deleted_chatbot.id) + + assert across.json()["data"]["session_id"] == str(chatbot_session.id) + assert scoped.status_code == 404 + + def test_unknown_contact_returns_404( + self, live_chatbot: Chatbot, get_latest: Callable[..., Response] + ) -> None: + resp = get_latest(random_contact_ref(), live_chatbot.id) + + assert resp.status_code == 404 + assert ( + resp.json()["error"] + == "No chatbot session found for this contact_ref and chatbot_id" + ) + + def test_omitted_chatbot_id_unknown_contact_returns_404( + self, get_latest: Callable[..., Response] + ) -> None: + resp = get_latest(random_contact_ref()) assert resp.status_code == 404 assert resp.json()["error"] == "No chatbot session found for this contact_ref" - def test_unknown_chatbot_ref_for_known_contact_returns_404( + def test_other_projects_session_returns_404( self, - client: TestClient, - headers: dict[str, str], - chatbot_session: ChatbotSession, + db: Session, + foreign_live_chatbot: Chatbot, + get_latest: Callable[..., Response], ) -> None: - resp = client.get( - SESSIONS_URL, - headers=headers, - params={ - "contact_ref": chatbot_session.contact_ref, - "chatbot_ref": f"chatbot-{random_lower_string()}", - }, - ) + foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot) + + resp = get_latest(foreign_session.contact_ref) assert resp.status_code == 404 + def test_missing_contact_ref_returns_422( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.get(LATEST_SESSION_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, + send_message: Callable[..., Response], enqueued: list[dict[str, Any]], ) -> None: - resp = client.post( - _message_url(chatbot_session.id), - headers=headers, - json={"message": "Asha", "webhook_url": WEBHOOK_URL}, - ) + resp = send_message(chatbot_session.id, message="Asha") assert resp.status_code == 202 assert resp.json()["data"] == {"status": "queued"} stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) - assert [(m.role, m.content) for m in stored] == [ - (ChatbotMessageRoleEnum.USER, "Asha") + assert [(m.role, m.content, m.node_id) for m in stored] == [ + (ChatbotMessageRoleEnum.USER, "Asha", ONBOARDING_NODE_ID) ] [job] = enqueued assert job["session_id"] == str(chatbot_session.id) @@ -590,17 +476,12 @@ def test_stores_user_message_and_queues_turn( def test_omitted_message_on_first_call_queues_greeting( self, - client: TestClient, db: Session, - headers: dict[str, str], chatbot_session: ChatbotSession, + send_message: Callable[..., Response], enqueued: list[dict[str, Any]], ) -> None: - resp = client.post( - _message_url(chatbot_session.id), - headers=headers, - json={"webhook_url": WEBHOOK_URL}, - ) + resp = send_message(chatbot_session.id) assert resp.status_code == 202 assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] @@ -608,10 +489,9 @@ def test_omitted_message_on_first_call_queues_greeting( def test_omitted_message_after_session_started_returns_422( self, - client: TestClient, db: Session, - headers: dict[str, str], chatbot_session: ChatbotSession, + send_message: Callable[..., Response], enqueued: list[dict[str, Any]], ) -> None: add_test_chatbot_messages( @@ -620,11 +500,7 @@ def test_omitted_message_after_session_started_returns_422( [(ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?")], ) - resp = client.post( - _message_url(chatbot_session.id), - headers=headers, - json={"webhook_url": WEBHOOK_URL}, - ) + resp = send_message(chatbot_session.id) assert resp.status_code == 422 assert "`message` is required" in resp.json()["error"] @@ -632,36 +508,24 @@ def test_omitted_message_after_session_started_returns_422( def test_unknown_session_returns_404( self, - client: TestClient, - headers: dict[str, str], + send_message: Callable[..., Response], enqueued: list[dict[str, Any]], ) -> None: - resp = client.post( - _message_url(uuid4()), - headers=headers, - json={"message": "Asha", "webhook_url": WEBHOOK_URL}, - ) + resp = send_message(uuid4(), message="Asha") assert resp.status_code == 404 assert enqueued == [] def test_session_in_other_project_returns_404( self, - client: TestClient, db: Session, - headers: dict[str, str], + foreign_live_chatbot: Chatbot, + send_message: Callable[..., Response], 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) + foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot) - resp = client.post( - _message_url(foreign_session.id), - headers=headers, - json={"message": "Asha", "webhook_url": WEBHOOK_URL}, - ) + resp = send_message(foreign_session.id, message="Asha") assert resp.status_code == 404 assert list_chatbot_session_messages(db, session_id=foreign_session.id) == [] @@ -669,16 +533,15 @@ def test_session_in_other_project_returns_404( def test_non_https_webhook_url_returns_422( self, - client: TestClient, db: Session, - headers: dict[str, str], chatbot_session: ChatbotSession, + send_message: Callable[..., Response], 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"}, + resp = send_message( + chatbot_session.id, + message="Asha", + webhook_url="http://hooks.example.com/chatbot", ) assert resp.status_code == 422 @@ -693,20 +556,15 @@ def test_non_https_webhook_url_returns_422( ) def test_inactive_session_returns_409_and_stores_nothing( self, - client: TestClient, db: Session, - headers: dict[str, str], chatbot_session: ChatbotSession, + send_message: Callable[..., Response], 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}, - ) + resp = send_message(chatbot_session.id, **payload) assert resp.status_code == 409 assert ( @@ -717,22 +575,31 @@ def test_inactive_session_returns_409_and_stores_nothing( def test_inactive_session_in_other_project_returns_404( self, - client: TestClient, db: Session, - headers: dict[str, str], + foreign_live_chatbot: Chatbot, + send_message: Callable[..., Response], 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) + foreign_session = create_test_chatbot_session(db, chatbot=foreign_live_chatbot) _supersede(db, foreign_session) - resp = client.post( - _message_url(foreign_session.id), - headers=headers, - json={"message": "Asha", "webhook_url": WEBHOOK_URL}, - ) + resp = send_message(foreign_session.id, message="Asha") assert resp.status_code == 404 assert enqueued == [] + + def test_session_of_deleted_chatbot_returns_409( + self, + db: Session, + live_chatbot: Chatbot, + chatbot_session: ChatbotSession, + send_message: Callable[..., Response], + enqueued: list[dict[str, Any]], + ) -> None: + delete_chatbot(db, chatbot=live_chatbot) + + resp = send_message(chatbot_session.id, message="Asha") + + assert resp.status_code == 409 + assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] + assert enqueued == [] diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py index 7bbb8e490..fef300e2b 100644 --- a/backend/app/tests/crud/test_chatbot.py +++ b/backend/app/tests/crud/test_chatbot.py @@ -1,47 +1,66 @@ from collections.abc import Iterator +from typing import Any from uuid import uuid4 import pytest +from sqlalchemy import event, insert from sqlalchemy.exc import IntegrityError -from sqlmodel import Session, select +from sqlmodel import Session from app.core.db import engine from app.crud.chatbot import ( - chatbot_ref_exists, + create_chatbot, + create_chatbot_flow_version, create_chatbot_session, create_chatbot_session_message, + delete_chatbot, + get_chatbot, get_chatbot_session, + get_latest_chatbot_flow_version, get_latest_chatbot_session, + get_live_chatbot_flow_version, list_chatbot_session_messages, + list_chatbots, + publish_chatbot_flow_version, + update_chatbot, update_chatbot_session_progress, ) from app.models import ( + Chatbot, ChatbotCompletionStatusEnum, + ChatbotFlowVersion, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionStatusEnum, + ChatbotUpdate, Config, Project, ) from app.services.chatbot.exceptions import ( + CONCURRENT_FLOW_CONFLICT_MESSAGE, CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotFlowConflictError, ChatbotSessionConflictError, ) from app.tests.utils.chatbot import ( + ONBOARDING_NODE_ID, add_test_chatbot_messages, + build_onboarding_node, + create_test_chatbot, create_test_chatbot_session, + create_test_flow_version, + create_test_anthropic_config, + create_test_live_chatbot, + list_stored_flow_versions, + list_stored_sessions_for_contact, + random_contact_ref, ) -from app.tests.utils.test_data import ( - build_anthropic_text_config_blob, - create_test_config, - create_test_project, -) -from app.tests.utils.utils import random_lower_string +from app.tests.utils.test_data import create_test_project @pytest.fixture def config(db: Session) -> Config: - return create_test_config(db, config_blob=build_anthropic_text_config_blob()) + return create_test_anthropic_config(db) @pytest.fixture @@ -51,32 +70,209 @@ def project(db: Session, config: Config) -> Project: return project +@pytest.fixture +def live_chatbot_and_flow( + db: Session, config: Config +) -> tuple[Chatbot, ChatbotFlowVersion]: + return create_test_live_chatbot(db, config=config) + + +@pytest.fixture +def chatbot(live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion]) -> Chatbot: + return live_chatbot_and_flow[0] + + +def _reload(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession: + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + return stored + + +def _scope(project: Project) -> dict[str, int]: + return {"organization_id": project.organization_id, "project_id": project.id} + + +class TestChatbotCrud: + def test_create_persists_name_and_scope( + self, db: Session, project: Project + ) -> None: + created = create_chatbot(db, **_scope(project), name="Onboarding Bot") + + db.expire_all() + stored = db.get(Chatbot, created.id) + assert stored is not None + assert stored.name == "Onboarding Bot" + assert stored.project_id == project.id + assert stored.organization_id == project.organization_id + assert stored.deleted_at is None + + def test_get_is_scoped_to_project( + self, db: Session, chatbot: Chatbot, project: Project + ) -> None: + other_project = create_test_project(db, organization_id=project.organization_id) + + assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(project)) is not None + assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(other_project)) is None + + def test_get_hides_deleted_chatbot( + self, db: Session, chatbot: Chatbot, project: Project + ) -> None: + delete_chatbot(db, chatbot=chatbot) + + assert get_chatbot(db, chatbot_id=chatbot.id, **_scope(project)) is None + + def test_list_returns_projects_live_chatbots_most_recently_updated_first( + self, db: Session, project: Project + ) -> None: + older = create_test_chatbot(db, project=project) + newer = create_test_chatbot(db, project=project) + deleted = create_test_chatbot(db, project=project) + delete_chatbot(db, chatbot=deleted) + create_test_chatbot(db) + update_chatbot(db, chatbot=older, chatbot_update=ChatbotUpdate(name="renamed")) + + listed = list_chatbots(db, **_scope(project), skip=0, limit=100) + + assert [c.id for c in listed] == [older.id, newer.id] + + def test_list_paginates(self, db: Session, project: Project) -> None: + create_test_chatbot(db, project=project) + second = create_test_chatbot(db, project=project) + create_test_chatbot(db, project=project) + + listed = list_chatbots(db, **_scope(project), skip=1, limit=1) + + assert [c.id for c in listed] == [second.id] + + def test_update_changes_name_and_bumps_updated_at( + self, db: Session, chatbot: Chatbot + ) -> None: + before = chatbot.updated_at + + update_chatbot(db, chatbot=chatbot, chatbot_update=ChatbotUpdate(name="New")) + + db.expire_all() + stored = db.get(Chatbot, chatbot.id) + assert stored is not None + assert stored.name == "New" + assert stored.updated_at > before + + def test_update_with_no_name_keeps_name( + self, db: Session, chatbot: Chatbot + ) -> None: + original_name = chatbot.name + + update_chatbot(db, chatbot=chatbot, chatbot_update=ChatbotUpdate()) + + db.expire_all() + stored = db.get(Chatbot, chatbot.id) + assert stored is not None + assert stored.name == original_name + + def test_delete_soft_deletes_and_deactivates_only_its_active_sessions( + self, db: Session, config: Config, chatbot: Chatbot + ) -> None: + in_progress = create_test_chatbot_session(db, chatbot=chatbot) + complete = create_test_chatbot_session(db, chatbot=chatbot) + update_chatbot_session_progress( + db, + session_id=complete.id, + collected_fields={"name": "Asha"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + other_chatbot, _ = create_test_live_chatbot(db, config=config) + untouched = create_test_chatbot_session( + db, chatbot=other_chatbot, contact_ref=in_progress.contact_ref + ) + + delete_chatbot(db, chatbot=chatbot) + + db.expire_all() + stored_chatbot = db.get(Chatbot, chatbot.id) + assert stored_chatbot is not None + assert stored_chatbot.deleted_at is not None + stored_in_progress = _reload(db, in_progress) + assert stored_in_progress.status == ChatbotSessionStatusEnum.INACTIVE + assert ( + stored_in_progress.completion_status + == ChatbotCompletionStatusEnum.ABANDONED + ) + stored_complete = _reload(db, complete) + assert stored_complete.status == ChatbotSessionStatusEnum.INACTIVE + assert stored_complete.completion_status == ChatbotCompletionStatusEnum.COMPLETE + assert _reload(db, untouched).status == ChatbotSessionStatusEnum.ACTIVE + + +class TestChatbotFlowVersionCrud: + def test_latest_is_highest_version_and_live_is_the_published_one( + self, + db: Session, + config: Config, + live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion], + ) -> None: + chatbot, live = live_chatbot_and_flow + draft = create_test_flow_version( + db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)] + ) + + latest = get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id) + current_live = get_live_chatbot_flow_version(db, chatbot_id=chatbot.id) + + assert latest is not None and latest.id == draft.id + assert latest.version == 2 + assert current_live is not None and current_live.id == live.id + + def test_no_versions_returns_none(self, db: Session, project: Project) -> None: + chatbot = create_test_chatbot(db, project=project) + + assert get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id) is None + assert get_live_chatbot_flow_version(db, chatbot_id=chatbot.id) is None + + def test_publish_unsets_previous_live_and_stores_given_nodes( + self, + db: Session, + config: Config, + live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion], + ) -> None: + chatbot, previous_live = live_chatbot_and_flow + draft = create_test_flow_version( + db, + chatbot=chatbot, + nodes=[build_onboarding_node(config_id=config.id, config_version=None)], + ) + pinned_nodes = [build_onboarding_node(config_id=config.id, config_version=1)] + + publish_chatbot_flow_version(db, flow_version=draft, nodes=pinned_nodes) + + db.expire_all() + stored_previous = db.get(ChatbotFlowVersion, previous_live.id) + stored_draft = db.get(ChatbotFlowVersion, draft.id) + assert stored_previous is not None and stored_previous.is_live is False + assert stored_draft is not None and stored_draft.is_live is True + assert stored_draft.nodes == pinned_nodes + + class TestCreateChatbotSession: def test_persists_session_as_active_with_no_collected_fields( - self, db: Session, config: Config, project: Project + self, db: Session, live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion] ) -> None: - contact_ref = f"contact-{random_lower_string()}" + chatbot, flow_version = live_chatbot_and_flow + contact_ref = random_contact_ref() created = create_chatbot_session( db, - organization_id=project.organization_id, - project_id=project.id, - name="Onboarding Bot", + chatbot_id=chatbot.id, + flow_version_id=flow_version.id, contact_ref=contact_ref, - chatbot_ref="chatbot-onboarding", - config_id=config.id, - config_version=1, - system_prompt="Collect the user's name.", + active_node_id=ONBOARDING_NODE_ID, ) - db.expire_all() - stored = db.get(ChatbotSession, created.id) - assert stored is not None - assert stored.name == "Onboarding Bot" + stored = _reload(db, created) + assert stored.chatbot_id == chatbot.id + assert stored.flow_version_id == flow_version.id assert stored.contact_ref == contact_ref - assert stored.config_id == config.id - assert stored.config_version == 1 - assert stored.system_prompt == "Collect the user's name." + assert stored.active_node_id == ONBOARDING_NODE_ID assert stored.status == ChatbotSessionStatusEnum.ACTIVE assert stored.collected_fields == {} assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS @@ -85,39 +281,33 @@ def test_persists_session_as_active_with_no_collected_fields( class TestGetChatbotSession: def test_returns_session_in_scope( - self, db: Session, config: Config, project: Project + self, db: Session, chatbot: Chatbot, project: Project ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) found = get_chatbot_session( - db, - session_id=chatbot_session.id, - organization_id=project.organization_id, - project_id=project.id, + db, session_id=chatbot_session.id, **_scope(project) ) assert found is not None assert found.id == chatbot_session.id def test_other_project_cannot_see_session( - self, db: Session, config: Config, project: Project + self, db: Session, chatbot: Chatbot, project: Project ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) - other_project = create_test_project(db) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + other_project = create_test_project(db, organization_id=project.organization_id) found = get_chatbot_session( - db, - session_id=chatbot_session.id, - organization_id=other_project.organization_id, - project_id=other_project.id, + db, session_id=chatbot_session.id, **_scope(other_project) ) assert found is None def test_other_organization_cannot_see_session( - self, db: Session, config: Config, project: Project + self, db: Session, chatbot: Chatbot, project: Project ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) other_project = create_test_project(db) found = get_chatbot_session( @@ -129,41 +319,47 @@ def test_other_organization_cannot_see_session( 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 + def test_deleted_chatbots_session_still_resolves( + self, db: Session, chatbot: Chatbot, project: Project + ) -> None: + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + delete_chatbot(db, chatbot=chatbot) + + found = get_chatbot_session( + db, session_id=chatbot_session.id, **_scope(project) ) + assert found is not None + assert found.status == ChatbotSessionStatusEnum.INACTIVE + + def test_unknown_id_returns_none(self, db: Session, project: Project) -> None: + assert get_chatbot_session(db, session_id=uuid4(), **_scope(project)) is None + class TestChatbotSessionMessages: - def test_create_message_persists_role_and_content( - self, db: Session, config: Config + def test_create_message_persists_role_content_and_node( + self, db: Session, chatbot: Chatbot ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) message = create_chatbot_session_message( db, session_id=chatbot_session.id, role=ChatbotMessageRoleEnum.USER, content="Asha", + node_id=ONBOARDING_NODE_ID, ) stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) - assert [(m.id, m.role, m.content) for m in stored] == [ - (message.id, ChatbotMessageRoleEnum.USER, "Asha") + assert [(m.id, m.role, m.content, m.node_id) for m in stored] == [ + (message.id, ChatbotMessageRoleEnum.USER, "Asha", ONBOARDING_NODE_ID) ] def test_list_returns_oldest_first_and_only_this_session( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) - other_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + other_session = create_test_chatbot_session(db, chatbot=chatbot) add_test_chatbot_messages( db, chatbot_session, @@ -186,106 +382,126 @@ def test_list_returns_oldest_first_and_only_this_session( ] def test_list_empty_session_returns_empty( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] class TestGetLatestChatbotSession: - def test_returns_newest_scoped_to_contact_and_project( - self, db: Session, config: Config, project: Project + def test_returns_newest_with_its_chatbot_and_pinned_flow( + self, + db: Session, + config: Config, + live_chatbot_and_flow: tuple[Chatbot, ChatbotFlowVersion], + project: Project, ) -> None: - contact_ref = f"contact-{random_lower_string()}" - chatbot_ref = f"chatbot-{random_lower_string()}" - create_test_chatbot_session( - db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref - ) + chatbot, flow_version = live_chatbot_and_flow + contact_ref = random_contact_ref() + create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref) newest = create_test_chatbot_session( - db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + db, chatbot=chatbot, contact_ref=contact_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, chatbot=chatbot) + foreign_chatbot, _ = create_test_live_chatbot( + db, config=create_test_anthropic_config(db) ) create_test_chatbot_session( - db, config=other_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + db, chatbot=foreign_chatbot, contact_ref=contact_ref ) - latest = get_latest_chatbot_session( - db, - organization_id=project.organization_id, - project_id=project.id, - contact_ref=contact_ref, - chatbot_ref=chatbot_ref, + row = get_latest_chatbot_session( + db, **_scope(project), contact_ref=contact_ref, chatbot_id=chatbot.id ) - assert latest is not None - assert latest.id == newest.id + assert row is not None + found_session, found_chatbot, found_flow = row + assert found_session.id == newest.id + assert found_chatbot.id == chatbot.id + assert found_flow.id == flow_version.id def test_unknown_contact_returns_none( - self, db: Session, config: Config, project: Project + self, db: Session, chatbot: Chatbot, project: Project ) -> None: - create_test_chatbot_session(db, config=config) + create_test_chatbot_session(db, chatbot=chatbot) assert ( get_latest_chatbot_session( db, - organization_id=project.organization_id, - project_id=project.id, - contact_ref=f"contact-{random_lower_string()}", - chatbot_ref=f"chatbot-{random_lower_string()}", + **_scope(project), + contact_ref=random_contact_ref(), + chatbot_id=chatbot.id, ) is None ) - def test_without_chatbot_ref_returns_newest_across_chatbots( - self, db: Session, config: Config, project: Project + def test_without_chatbot_id_returns_newest_across_chatbots( + self, db: Session, config: Config, chatbot: Chatbot, project: Project ) -> None: - older = create_test_chatbot_session(db, config=config) + older = create_test_chatbot_session(db, chatbot=chatbot) + other_chatbot, _ = create_test_live_chatbot(db, config=config) newest = create_test_chatbot_session( - db, config=config, contact_ref=older.contact_ref + db, chatbot=other_chatbot, 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, + row = get_latest_chatbot_session( + db, **_scope(project), contact_ref=older.contact_ref ) - assert latest is not None - assert latest.id == newest.id - assert latest.chatbot_ref != older.chatbot_ref + assert row is not None + assert row[0].id == newest.id + assert row[1].id == other_chatbot.id def test_ignores_newer_session_of_another_chatbot( - self, db: Session, config: Config, project: Project + self, db: Session, config: Config, chatbot: Chatbot, project: Project ) -> None: - wanted = create_test_chatbot_session(db, config=config) - create_test_chatbot_session(db, config=config, contact_ref=wanted.contact_ref) + wanted = create_test_chatbot_session(db, chatbot=chatbot) + other_chatbot, _ = create_test_live_chatbot(db, config=config) + create_test_chatbot_session( + db, chatbot=other_chatbot, contact_ref=wanted.contact_ref + ) - latest = get_latest_chatbot_session( + row = get_latest_chatbot_session( db, - organization_id=project.organization_id, - project_id=project.id, + **_scope(project), contact_ref=wanted.contact_ref, - chatbot_ref=wanted.chatbot_ref, + chatbot_id=chatbot.id, + ) + + assert row is not None + assert row[0].id == wanted.id + + def test_skips_sessions_of_deleted_chatbots( + self, db: Session, config: Config, chatbot: Chatbot, project: Project + ) -> None: + kept = create_test_chatbot_session(db, chatbot=chatbot) + deleted_chatbot, _ = create_test_live_chatbot(db, config=config) + create_test_chatbot_session( + db, chatbot=deleted_chatbot, contact_ref=kept.contact_ref + ) + delete_chatbot(db, chatbot=deleted_chatbot) + + row = get_latest_chatbot_session( + db, **_scope(project), contact_ref=kept.contact_ref + ) + scoped_row = get_latest_chatbot_session( + db, + **_scope(project), + contact_ref=kept.contact_ref, + chatbot_id=deleted_chatbot.id, ) - assert latest is not None - assert latest.id == wanted.id + assert row is not None + assert row[0].id == kept.id + assert scoped_row is None def test_returns_newest_even_when_inactive( - self, db: Session, config: Config, project: Project + self, db: Session, chatbot: Chatbot, project: Project ) -> None: - older = create_test_chatbot_session(db, config=config) + older = create_test_chatbot_session(db, chatbot=chatbot) newest = create_test_chatbot_session( - db, - config=config, - contact_ref=older.contact_ref, - chatbot_ref=older.chatbot_ref, + db, chatbot=chatbot, contact_ref=older.contact_ref ) # No API path leaves the newest row inactive; set it directly so a # status filter would surface the older row instead. @@ -293,24 +509,23 @@ def test_returns_newest_even_when_inactive( db.add(newest) db.commit() - latest = get_latest_chatbot_session( + row = get_latest_chatbot_session( db, - organization_id=project.organization_id, - project_id=project.id, + **_scope(project), contact_ref=older.contact_ref, - chatbot_ref=older.chatbot_ref, + chatbot_id=chatbot.id, ) - assert latest is not None - assert latest.id == newest.id - assert latest.status == ChatbotSessionStatusEnum.INACTIVE + assert row is not None + assert row[0].id == newest.id + assert row[0].status == ChatbotSessionStatusEnum.INACTIVE class TestUpdateChatbotSessionProgress: def test_persists_fields_and_status_and_bumps_updated_at( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) inserted_at = chatbot_session.inserted_at returned = update_chatbot_session_progress( @@ -321,18 +536,16 @@ def test_persists_fields_and_status_and_bumps_updated_at( ) assert returned is not None - db.expire_all() - stored = db.get(ChatbotSession, chatbot_session.id) - assert stored is not None + stored = _reload(db, chatbot_session) assert stored.collected_fields == {"name": "Asha", "city": "Pune"} assert stored.completion_status == ChatbotCompletionStatusEnum.COMPLETE assert stored.updated_at > inserted_at assert stored.inserted_at == inserted_at def test_replaces_rather_than_merges_fields( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> None: - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) update_chatbot_session_progress( db, session_id=chatbot_session.id, @@ -347,9 +560,7 @@ def test_replaces_rather_than_merges_fields( completion_status=ChatbotCompletionStatusEnum.IN_PROGRESS, ) - db.expire_all() - stored = db.get(ChatbotSession, chatbot_session.id) - assert stored is not None + stored = _reload(db, chatbot_session) assert stored.collected_fields == {"name": "Asha"} assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS @@ -365,25 +576,15 @@ def test_unknown_id_returns_none(self, db: Session) -> 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 + self, db: Session, chatbot: Chatbot ) -> None: - older = create_test_chatbot_session(db, config=config) + older = create_test_chatbot_session(db, chatbot=chatbot) older_updated_at = older.updated_at newer = create_test_chatbot_session( - db, - config=config, - contact_ref=older.contact_ref, - chatbot_ref=older.chatbot_ref, + db, chatbot=chatbot, contact_ref=older.contact_ref ) superseded = _reload(db, older) @@ -393,9 +594,9 @@ def test_in_progress_session_becomes_inactive_and_abandoned( assert _reload(db, newer).status == ChatbotSessionStatusEnum.ACTIVE def test_complete_session_becomes_inactive_but_stays_complete( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> None: - older = create_test_chatbot_session(db, config=config) + older = create_test_chatbot_session(db, chatbot=chatbot) update_chatbot_session_progress( db, session_id=older.id, @@ -404,12 +605,7 @@ def test_complete_session_becomes_inactive_but_stays_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, - ) + create_test_chatbot_session(db, chatbot=chatbot, contact_ref=older.contact_ref) superseded = _reload(db, older) assert superseded.status == ChatbotSessionStatusEnum.INACTIVE @@ -417,38 +613,19 @@ def test_complete_session_becomes_inactive_but_stays_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 + def test_other_chatbot_and_contact_sessions_stay_active( + self, db: Session, config: Config, chatbot: Chatbot ) -> 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 + contact_ref = random_contact_ref() + other_chatbot, _ = create_test_live_chatbot(db, config=config) + same_contact_other_chatbot = create_test_chatbot_session( + db, chatbot=other_chatbot, contact_ref=contact_ref ) - # Same 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] + same_chatbot_other_contact = create_test_chatbot_session(db, chatbot=chatbot) + untouched = [same_contact_other_chatbot, same_chatbot_other_contact] updated_ats = [s.updated_at for s in untouched] - create_test_chatbot_session( - db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref - ) + create_test_chatbot_session(db, chatbot=chatbot, contact_ref=contact_ref) for chatbot_session, updated_at in zip(untouched, updated_ats, strict=True): stored = _reload(db, chatbot_session) @@ -457,14 +634,17 @@ def test_other_chatbot_contact_and_project_sessions_stay_active( assert stored.updated_at == updated_at def test_already_inactive_session_is_left_unmodified( - self, db: Session, config: Config + self, db: Session, chatbot: Chatbot ) -> 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 = create_test_chatbot_session(db, chatbot=chatbot) + second = create_test_chatbot_session( + db, chatbot=chatbot, contact_ref=first.contact_ref + ) first_after_supersede = _reload(db, first).updated_at - third = create_test_chatbot_session(db, config=config, **refs) + third = create_test_chatbot_session( + db, chatbot=chatbot, contact_ref=first.contact_ref + ) stored_first = _reload(db, first) assert stored_first.status == ChatbotSessionStatusEnum.INACTIVE @@ -476,7 +656,7 @@ def test_already_inactive_session_is_left_unmodified( @pytest.fixture def savepoint_db() -> Iterator[Session]: - # create_chatbot_session rolls back on IntegrityError. Under the shared `db` + # The crud functions roll back on IntegrityError. Under the shared `db` # fixture that rollback discards the whole test transaction (rows created # earlier included), so atomicity is unobservable. Joining via savepoints makes # its commit/rollback behave as in production while still undoing everything here. @@ -495,110 +675,87 @@ 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) + config = create_test_anthropic_config(savepoint_db) + chatbot, _ = create_test_live_chatbot(savepoint_db, config=config) + prior = create_test_chatbot_session(savepoint_db, chatbot=chatbot) with pytest.raises(IntegrityError): create_chatbot_session( savepoint_db, - organization_id=prior.organization_id, - project_id=prior.project_id, - name="Onboarding Bot", + chatbot_id=chatbot.id, + flow_version_id=uuid4(), contact_ref=prior.contact_ref, - chatbot_ref=prior.chatbot_ref, - config_id=uuid4(), - config_version=None, - system_prompt="Collect the user's name.", + active_node_id=ONBOARDING_NODE_ID, ) stored = _reload(savepoint_db, prior) assert stored.status == ChatbotSessionStatusEnum.ACTIVE assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS - def test_active_index_violation_raises_conflict_and_inserts_nothing( + def test_active_index_violation_raises_conflict_and_rolls_back_supersede( 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.", - ) + config = create_test_anthropic_config(savepoint_db) + chatbot, flow_version = create_test_live_chatbot(savepoint_db, config=config) + prior = create_test_chatbot_session(savepoint_db, chatbot=chatbot) + racing_row = { + "id": uuid4(), + "chatbot_id": chatbot.id, + "flow_version_id": flow_version.id, + "contact_ref": prior.contact_ref, + "active_node_id": ONBOARDING_NODE_ID, + "status": ChatbotSessionStatusEnum.ACTIVE, + "completion_status": ChatbotCompletionStatusEnum.IN_PROGRESS, + "collected_fields": {}, + "inserted_at": prior.inserted_at, + "updated_at": prior.inserted_at, + } + + # Simulates a racing request committing its active row after this call's + # supersede UPDATE ran but before its INSERT is flushed. + raced: list[bool] = [] + + def insert_racing_row(session: Session, *_args: Any) -> None: + if raced: + return + raced.append(True) + session.connection().execute(insert(ChatbotSession).values(**racing_row)) + + event.listen(savepoint_db, "before_flush", insert_racing_row) + try: + with pytest.raises(ChatbotSessionConflictError) as exc_info: + create_chatbot_session( + savepoint_db, + chatbot_id=chatbot.id, + flow_version_id=flow_version.id, + contact_ref=prior.contact_ref, + active_node_id=ONBOARDING_NODE_ID, + ) + finally: + event.remove(savepoint_db, "before_flush", insert_racing_row) assert str(exc_info.value) == CONCURRENT_SESSION_CONFLICT_MESSAGE - 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] + stored = list_stored_sessions_for_contact(savepoint_db, prior.contact_ref) + assert [s.id for s in stored] == [prior.id] assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE + assert stored[0].completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS -class TestChatbotRefExists: - def test_true_for_active_and_inactive_sessions_in_project( - self, db: Session, config: Config, project: Project +class TestCreateChatbotFlowVersionFailures: + def test_duplicate_version_raises_flow_conflict( + self, savepoint_db: Session ) -> 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 + project = create_test_project(savepoint_db) + chatbot = create_test_chatbot(savepoint_db, project=project) + create_chatbot_flow_version( + savepoint_db, chatbot_id=chatbot.id, version=1, nodes=[], edges=[] ) - 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) + with pytest.raises(ChatbotFlowConflictError) as exc_info: + create_chatbot_flow_version( + savepoint_db, chatbot_id=chatbot.id, version=1, nodes=[], edges=[] + ) - assert not chatbot_ref_exists( - db, project_id=other_project.id, chatbot_ref=chatbot_session.chatbot_ref - ) + assert str(exc_info.value) == CONCURRENT_FLOW_CONFLICT_MESSAGE + versions = list_stored_flow_versions(savepoint_db, chatbot) + assert [v.version for v in versions] == [1] diff --git a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py index 01f8a9990..85f1e1834 100644 --- a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py +++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py @@ -9,20 +9,21 @@ 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.types import ChatbotMessageDict, ChatbotTurnResult from app.services.chatbot.utils import ChatbotModelSettings from app.tests.utils.chatbot import ( + ANTHROPIC_REQUEST, + DEFAULT_SYSTEM_PROMPT as SYSTEM_PROMPT, ScriptedChatModel, extracted_fields, patch_chat_model, ) -SYSTEM_PROMPT = "Collect the user's name and city." +DEFAULT_HISTORY_TOKEN_LIMIT = 4000 SUMMARY_TEXT = "User is Asha from Pune." PROGRESS_PREFIX = "[Field collection progress] " COLLECTED_PREFIX = "Already collected (do not ask for these again): " -_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]] = [] @@ -32,9 +33,21 @@ def _alternating_history(turns: int) -> list[dict[str, str]]: return history -def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings: - return ChatbotModelSettings( - model="claude-sonnet-4-6", history_token_limit=history_token_limit +async def _run_turn( + *, + history: list[ChatbotMessageDict], + user_message: str | None, + history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT, + collected_fields: dict[str, str] | None = None, +) -> ChatbotTurnResult: + return await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=history, + user_message=user_message, + model_settings=ChatbotModelSettings( + model="claude-sonnet-4-6", history_token_limit=history_token_limit + ), + collected_fields=collected_fields, ) @@ -43,16 +56,12 @@ class TestRunChatbotTurn: 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(), + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) ) + result = await _run_turn(history=[], user_message=None) + assert result.transcript == [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "assistant", "content": "Hi! What's your name?"}, @@ -67,19 +76,15 @@ async def test_first_call_returns_greeting_without_kickoff( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Nice to meet you, Asha."]) + ) history = [ {"role": "system", "content": "stale prompt from the previous call"}, {"role": "assistant", "content": "Hi! What's your name?"}, ] - result = await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=history, - user_message="Asha", - model_settings=_model_settings(), - ) + result = await _run_turn(history=history, user_message="Asha") assert result.transcript == [ {"role": "system", "content": SYSTEM_PROMPT}, @@ -94,15 +99,11 @@ async def test_follow_up_turn_appends_user_message_and_reply( async def test_missing_message_with_history_rejected( self, monkeypatch: pytest.MonkeyPatch ) -> None: - chat_model = ScriptedChatModel() - patch_chat_model(monkeypatch, chat_model) + chat_model = patch_chat_model(monkeypatch, ScriptedChatModel()) 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(), + await _run_turn( + history=[{"role": "assistant", "content": "Hi!"}], user_message=None ) assert chat_model.calls == [] @@ -110,15 +111,11 @@ async def test_missing_message_with_history_rejected( async def test_unsupported_role_rejected( self, monkeypatch: pytest.MonkeyPatch ) -> None: - chat_model = ScriptedChatModel() - patch_chat_model(monkeypatch, chat_model) + chat_model = patch_chat_model(monkeypatch, ScriptedChatModel()) 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(), + await _run_turn( + history=[{"role": "tool", "content": "{}"}], user_message="hello" ) assert chat_model.calls == [] @@ -128,11 +125,11 @@ class TestProviderErrorMapping: ("error", "expected_message"), [ ( - anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), + anthropic.APITimeoutError(request=ANTHROPIC_REQUEST), "(code: APITimeoutError)", ), ( - anthropic.APIConnectionError(request=_ANTHROPIC_REQUEST), + anthropic.APIConnectionError(request=ANTHROPIC_REQUEST), "(code: APIConnectionError)", ), ( @@ -145,7 +142,7 @@ class TestProviderErrorMapping: "Overloaded", response=httpx.Response( 529, - request=_ANTHROPIC_REQUEST, + request=ANTHROPIC_REQUEST, headers={"request-id": "req_123"}, ), body=None, @@ -165,12 +162,7 @@ async def test_anthropic_errors_become_provider_errors( 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(), - ) + await _run_turn(history=[], user_message="hello") assert expected_message in str(exc_info.value) assert exc_info.value.__cause__ is error @@ -181,17 +173,14 @@ class TestHistorySummarization: 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 + chat_model = patch_chat_model( + monkeypatch, + 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), + result = await _run_turn( + history=history, user_message="user 4", history_token_limit=4000 ) summarize_call, reply_call = chat_model.calls @@ -227,14 +216,14 @@ async def test_long_history_over_budget_is_summarized( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=100) + ) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( history=_alternating_history(turns=4), user_message="user 4", - model_settings=_model_settings(history_token_limit=4000), + history_token_limit=4000, ) assert len(chat_model.calls) == 1 @@ -244,14 +233,14 @@ async def test_long_history_within_budget_is_sent_whole( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=10_000) + ) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( history=_alternating_history(turns=3)[:5], user_message="more", - model_settings=_model_settings(history_token_limit=10), + history_token_limit=10, ) assert len(chat_model.calls) == 1 @@ -261,19 +250,17 @@ async def test_short_history_never_summarized( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Got it."], token_count=10_000) + ) # Only the injected kickoff precedes the recent window, so widening the # window to start on a user turn consumes everything there was to summarize. assistant_only = [ {"role": "assistant", "content": f"bot {i}"} for i in range(7) ] - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=assistant_only, - user_message="hello", - model_settings=_model_settings(history_token_limit=10), + await _run_turn( + history=assistant_only, user_message="hello", history_token_limit=10 ) assert len(chat_model.calls) == 1 @@ -283,24 +270,25 @@ async def test_no_user_turn_to_split_on_skips_summary( 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=extracted_fields({"name": "Asha"}), - missing_fields=["city"], - is_complete=False, - ) - ], + chat_model = patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=[SUMMARY_TEXT, "Got it."], + token_count=5000, + extractions=[ + FieldExtraction( + collected_fields=extracted_fields({"name": "Asha"}), + missing_fields=["city"], + is_complete=False, + ) + ], + ), ) - patch_chat_model(monkeypatch, chat_model) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( history=_alternating_history(turns=4), user_message="user 4", - model_settings=_model_settings(history_token_limit=4000), + history_token_limit=4000, ) # Exactly one summarize call: the extraction node's view is reused for the reply. @@ -328,24 +316,22 @@ class TestFieldExtraction: 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=extracted_fields({"name": "invented"}), - missing_fields=[], - is_complete=True, - ) - ], + chat_model = patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Hi! What's your name?"], + extractions=[ + FieldExtraction( + collected_fields=extracted_fields({"name": "invented"}), + missing_fields=[], + is_complete=True, + ) + ], + ), ) - 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"}, + result = await _run_turn( + history=[], user_message=None, collected_fields={"name": "Asha"} ) assert chat_model.extraction_calls == [] @@ -361,16 +347,12 @@ async def test_greeting_turn_skips_extraction( 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(), + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Which city?"]) ) + await _run_turn(history=[], user_message="I'm Asha") + # Forced tool use is rejected by Anthropic when thinking is on. assert chat_model.structured_output_methods == ["json_schema"] @@ -378,14 +360,13 @@ async def test_extraction_uses_native_json_schema_output( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Which city?"]) + ) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( history=[{"role": "assistant", "content": "Hi! What's your name?"}], user_message="I'm Asha", - model_settings=_model_settings(), collected_fields={"age": "30"}, ) @@ -409,23 +390,25 @@ async def test_extraction_sees_flow_prompt_prior_fields_and_conversation( 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=extracted_fields({"city": "Mumbai", "age": "30"}), - missing_fields=[], - is_complete=False, - ) - ], + patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Noted."], + extractions=[ + FieldExtraction( + collected_fields=extracted_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, + result = await _run_turn( 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"}, ) @@ -452,24 +435,21 @@ async def test_completion_requires_flag_and_no_missing_fields( missing_fields: list[str], expected_status: ChatbotCompletionStatusEnum, ) -> None: - chat_model = ScriptedChatModel( - replies=["Thanks!"], - extractions=[ - FieldExtraction( - collected_fields=extracted_fields({"name": "Asha"}), - missing_fields=missing_fields, - is_complete=is_complete, - ) - ], + patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Thanks!"], + extractions=[ + FieldExtraction( + collected_fields=extracted_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(), - ) + result = await _run_turn(history=[], user_message="I'm Asha") assert result.completion_status == expected_status @@ -515,14 +495,13 @@ async def test_note_follows_system_prompt_in_reply_call( extraction: FieldExtraction, expected_note: str, ) -> None: - chat_model = ScriptedChatModel(replies=["Reply."], extractions=[extraction]) - patch_chat_model(monkeypatch, chat_model) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Reply."], extractions=[extraction]) + ) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( 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 @@ -539,24 +518,21 @@ async def test_note_follows_system_prompt_in_reply_call( 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=extracted_fields({}), - missing_fields=[], - is_complete=False, - ) - ], + chat_model = patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Reply."], + extractions=[ + FieldExtraction( + collected_fields=extracted_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(), - ) + await _run_turn(history=[], user_message="hello") [reply_call] = chat_model.calls assert [m.content for m in reply_call] == [SYSTEM_PROMPT, "hello"] @@ -565,14 +541,13 @@ async def test_no_note_when_nothing_collected_missing_or_complete( 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) + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Which city?"]) + ) - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, + await _run_turn( history=[{"role": "assistant", "content": "Which city?"}], user_message="hmm", - model_settings=_model_settings(), collected_fields={"name": "Asha"}, ) @@ -613,7 +588,7 @@ class TestExtractionErrors: "effort is not supported for this model", ), ( - anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), + anthropic.APITimeoutError(request=ANTHROPIC_REQUEST), "(code: APITimeoutError)", ), ], @@ -626,18 +601,15 @@ async def test_extraction_failure_becomes_provider_error_before_reply( extraction_outcome: object, expected_message: str, ) -> None: - chat_model = ScriptedChatModel( - replies=["should never be used"], extractions=[extraction_outcome] + chat_model = patch_chat_model( + monkeypatch, + 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(), - ) + await _run_turn(history=[], user_message="I'm Asha") assert expected_message in str(exc_info.value) assert chat_model.calls == [] diff --git a/backend/app/tests/services/chatbot/conftest.py b/backend/app/tests/services/chatbot/conftest.py new file mode 100644 index 000000000..a2ec328dd --- /dev/null +++ b/backend/app/tests/services/chatbot/conftest.py @@ -0,0 +1,10 @@ +import pytest +from sqlmodel import Session + +from app.models import Config +from app.tests.utils.chatbot import create_test_anthropic_config + + +@pytest.fixture +def config(db: Session) -> Config: + return create_test_anthropic_config(db) diff --git a/backend/app/tests/services/chatbot/test_chatbot_ref.py b/backend/app/tests/services/chatbot/test_chatbot_ref.py deleted file mode 100644 index 3fcbea589..000000000 --- a/backend/app/tests/services/chatbot/test_chatbot_ref.py +++ /dev/null @@ -1,104 +0,0 @@ -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_config.py b/backend/app/tests/services/chatbot/test_config.py index bf29597fa..2d61e6e59 100644 --- a/backend/app/tests/services/chatbot/test_config.py +++ b/backend/app/tests/services/chatbot/test_config.py @@ -10,6 +10,10 @@ 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.chatbot import ( + add_test_config_version, + create_test_anthropic_config, +) from app.tests.utils.test_data import ( build_anthropic_text_config_blob, create_test_config, @@ -109,12 +113,7 @@ 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"), - ) + add_test_config_version(db, config) model_settings = _resolve( db, config, config_version=1, max_tokens=256, history_token_limit=900 @@ -127,7 +126,7 @@ def test_explicit_version_and_request_overrides(self, db: Session) -> 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()) + config = create_test_anthropic_config(db) with pytest.raises(HTTPException) as exc_info: _resolve(db, config, config_version=99) @@ -135,7 +134,7 @@ def test_unknown_version_raises_404(self, db: Session) -> None: 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()) + config = create_test_anthropic_config(db) other_project = create_test_project(db) with pytest.raises(HTTPException) as exc_info: @@ -166,7 +165,7 @@ def test_unknown_config_raises_404(self, db: Session) -> 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()) + config = create_test_anthropic_config(db) ConfigVersionCrud( session=db, project_id=config.project_id, config_id=config.id ).delete_or_raise(1) @@ -190,14 +189,14 @@ def test_native_completion_config_rejected(self, db: Session) -> None: _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()) + config = create_test_anthropic_config(db) _overwrite_latest_blob(db, config, {"completion": {"provider": "anthropic"}}) with pytest.raises(ChatbotInputError, match="blob is invalid"): _resolve(db, config) def test_missing_model_rejected(self, db: Session) -> None: - config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + config = create_test_anthropic_config(db) _overwrite_latest_blob( db, config, diff --git a/backend/app/tests/services/chatbot/test_flow.py b/backend/app/tests/services/chatbot/test_flow.py new file mode 100644 index 000000000..3c0eda6ca --- /dev/null +++ b/backend/app/tests/services/chatbot/test_flow.py @@ -0,0 +1,247 @@ +from typing import Any +from uuid import UUID + +import pytest +from sqlmodel import Session + +from app.models import ChatbotFlowEdge, ChatbotFlowNode, Config +from app.services.chatbot.exceptions import ChatbotFlowValidationError +from app.services.chatbot.flow import resolve_entry_node, validate_flow_for_publish +from app.tests.utils.chatbot import ( + ONBOARDING_NODE_ID, + add_test_config_version, + build_flow_edge, + build_flow_node, + build_onboarding_node, + create_test_anthropic_config, +) +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, +) + +CONFIG_ID = UUID("00000000-0000-0000-0000-00000000c0f1") + + +def _node(node_id: str, node_type: str, **data: Any) -> ChatbotFlowNode: + return ChatbotFlowNode.model_validate(build_flow_node(node_id, node_type, **data)) + + +def _onboarding( + node_id: str = ONBOARDING_NODE_ID, config_id: UUID = CONFIG_ID, **overrides: Any +) -> ChatbotFlowNode: + return ChatbotFlowNode.model_validate( + build_onboarding_node(config_id=config_id, node_id=node_id, **overrides) + ) + + +def _edge(edge_id: str, source: str, target: str) -> ChatbotFlowEdge: + return ChatbotFlowEdge.model_validate(build_flow_edge(edge_id, source, target)) + + +class TestResolveEntryNode: + def test_no_edges_returns_the_single_agent_node(self) -> None: + nodes = [_node("in", "input"), _onboarding(), _node("out", "output")] + + entry = resolve_entry_node(nodes=nodes, edges=[]) + + assert entry.id == ONBOARDING_NODE_ID + + def test_with_edges_returns_target_of_the_input_nodes_edge(self) -> None: + nodes = [ + _node("in", "input"), + _onboarding("first"), + _onboarding("second"), + _node("out", "output"), + ] + edges = [ + _edge("e1", "in", "second"), + _edge("e2", "second", "first"), + _edge("e3", "first", "out"), + ] + + entry = resolve_entry_node(nodes=nodes, edges=edges) + + assert entry.id == "second" + + def test_input_edge_may_lead_to_any_agent_type(self) -> None: + nodes = [_node("in", "input"), _node("router", "coordinator")] + + entry = resolve_entry_node(nodes=nodes, edges=[_edge("e1", "in", "router")]) + + assert entry.id == "router" + + @pytest.mark.parametrize( + ("nodes", "edges", "expected_problem"), + [ + ( + [_node("in", "input")], + [], + "a flow without edges must contain exactly one agent node (found 0)", + ), + ( + [_onboarding("a"), _onboarding("b")], + [], + "a flow without edges must contain exactly one agent node (found 2)", + ), + ( + [_onboarding("a"), _node("out", "output")], + [_edge("e1", "a", "out")], + "a flow with edges must contain exactly one input node (found 0)", + ), + ( + [_node("in-a", "input"), _node("in-b", "input"), _onboarding("a")], + [_edge("e1", "in-a", "a")], + "a flow with edges must contain exactly one input node (found 2)", + ), + ( + [_node("in", "input"), _onboarding("a"), _node("out", "output")], + [_edge("e1", "a", "out")], + "input node 'in' must have exactly one outgoing edge (found 0)", + ), + ( + [_node("in", "input"), _onboarding("a"), _onboarding("b")], + [_edge("e1", "in", "a"), _edge("e2", "in", "b")], + "input node 'in' must have exactly one outgoing edge (found 2)", + ), + ( + [_node("in", "input"), _node("out", "output")], + [_edge("e1", "in", "out")], + "the input node's edge must lead to an agent node (target 'out')", + ), + ( + [_node("in", "input"), _onboarding("a")], + [_edge("e1", "in", "ghost")], + "the input node's edge must lead to an agent node (target 'ghost')", + ), + ], + ids=[ + "no-edges-no-agent", + "no-edges-two-agents", + "edges-without-input", + "edges-with-two-inputs", + "input-without-outgoing-edge", + "input-with-two-outgoing-edges", + "input-edge-to-output", + "input-edge-to-missing-node", + ], + ) + def test_unresolvable_entry_raises( + self, + nodes: list[ChatbotFlowNode], + edges: list[ChatbotFlowEdge], + expected_problem: str, + ) -> None: + with pytest.raises(ChatbotFlowValidationError) as exc_info: + resolve_entry_node(nodes=nodes, edges=edges) + + assert str(exc_info.value) == ( + f"[KAAPI] Chatbot flow is invalid: {expected_problem}." + ) + + +class TestValidateFlowForPublish: + def test_returns_nodes_with_missing_config_version_pinned_to_latest( + self, db: Session, config: Config + ) -> None: + add_test_config_version(db, config) + input_node = _node("in", "input", label="Start") + draft_node = _onboarding(config_id=config.id, config_version=None) + + pinned = validate_flow_for_publish( + session=db, + project_id=config.project_id, + nodes=[input_node, draft_node], + edges=[_edge("e1", "in", ONBOARDING_NODE_ID)], + ) + + assert pinned[0] == input_node + assert pinned[1].data == { + "label": "Beneficiary Info Extractor", + "config_id": str(config.id), + "prompt": "Collect the user's name and city.", + "config_version": 2, + } + assert "config_version" not in draft_node.data + + def test_explicit_config_version_is_kept(self, db: Session, config: Config) -> None: + add_test_config_version(db, config) + + [pinned] = validate_flow_for_publish( + session=db, + project_id=config.project_id, + nodes=[_onboarding(config_id=config.id, config_version=1)], + edges=[], + ) + + assert pinned.data["config_version"] == 1 + + def test_problems_are_joined_into_one_error( + self, db: Session, config: Config + ) -> None: + nodes = [ + _onboarding(config_id=config.id), + _onboarding(config_id=config.id), + _node("out-a", "output"), + _node("out-b", "output"), + ] + + with pytest.raises(ChatbotFlowValidationError) as exc_info: + validate_flow_for_publish( + session=db, project_id=config.project_id, nodes=nodes, edges=[] + ) + + assert str(exc_info.value) == ( + "[KAAPI] Chatbot flow is invalid: " + f"duplicate node ids: {ONBOARDING_NODE_ID}; " + "at most 1 output node allowed (found 2)." + ) + + def test_config_error_is_reported_per_node_without_nested_kaapi_tag( + self, db: Session + ) -> None: + bad_thinking_config = create_test_config( + db, + config_blob=build_anthropic_text_config_blob( + thinking={"type": "enabled", "budget_tokens": 999_999} + ), + ) + + with pytest.raises(ChatbotFlowValidationError) as exc_info: + validate_flow_for_publish( + session=db, + project_id=bad_thinking_config.project_id, + nodes=[_onboarding(config_id=bad_thinking_config.id)], + edges=[], + ) + + message = str(exc_info.value) + assert f"node '{ONBOARDING_NODE_ID}': Config thinking.budget_tokens" in message + assert message.count("[KAAPI]") == 1 + + def test_config_of_another_project_is_rejected( + self, db: Session, config: Config + ) -> None: + other_project_config = create_test_anthropic_config(db) + + with pytest.raises(ChatbotFlowValidationError) as exc_info: + validate_flow_for_publish( + session=db, + project_id=config.project_id, + nodes=[_onboarding(config_id=other_project_config.id)], + edges=[], + ) + + assert f"node '{ONBOARDING_NODE_ID}': " in str(exc_info.value) + + def test_structurally_valid_flow_without_entry_raises( + self, db: Session, config: Config + ) -> None: + nodes = [_onboarding("a", config.id), _onboarding("b", config.id)] + + with pytest.raises(ChatbotFlowValidationError) as exc_info: + validate_flow_for_publish( + session=db, project_id=config.project_id, nodes=nodes, edges=[] + ) + + assert "exactly one agent node (found 2)" in str(exc_info.value) diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py index f238a5e5c..3251fd4e9 100644 --- a/backend/app/tests/services/chatbot/test_session.py +++ b/backend/app/tests/services/chatbot/test_session.py @@ -4,7 +4,6 @@ from uuid import uuid4 import anthropic -import httpx import pytest from sqlmodel import Session @@ -14,6 +13,7 @@ ) from app.crud.config import ConfigVersionCrud from app.models import ( + Chatbot, ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, @@ -21,22 +21,32 @@ Project, ) from app.services.chatbot import session as session_service +from app.services.chatbot.agents.onboarding import graph as onboarding_graph from app.services.chatbot.agents.onboarding.types import FieldExtraction -from app.services.chatbot.session import process_chatbot_session_message +from app.services.chatbot.session import ( + SUPERSEDED_SESSION_ERROR, + process_chatbot_session_message, +) +from app.services.chatbot.utils import ChatbotModelSettings from app.tests.utils.chatbot import ( + ANTHROPIC_REQUEST, DEFAULT_SYSTEM_PROMPT, + ONBOARDING_NODE_ID, + WEBHOOK_URL, ScriptedChatModel, add_test_chatbot_messages, + add_test_config_version, + build_onboarding_node, + create_test_chatbot, create_test_chatbot_session, + create_test_flow_version, + create_test_live_chatbot, extracted_fields, patch_chat_model, ) -from app.tests.utils.test_data import ( - build_anthropic_text_config_blob, - create_test_config, -) +from app.tests.utils.test_data import create_test_config -WEBHOOK_URL = "https://hooks.example.com/chatbot" +ChatTurns = list[tuple[ChatbotMessageRoleEnum, str]] class _NonClosingSession: @@ -82,12 +92,22 @@ def callbacks( @pytest.fixture -def config(db: Session) -> Config: - return create_test_config(db, config_blob=build_anthropic_text_config_blob()) +def chatbot(db: Session, config: Config) -> Chatbot: + return create_test_live_chatbot(db, config=config)[0] + + +@pytest.fixture +def unused_chat_model(monkeypatch: pytest.MonkeyPatch) -> ScriptedChatModel: + """A patched model for turns that must fail or skip before any LLM call.""" + return patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["should never be used"]) + ) def _process(chatbot_session: ChatbotSession, db: Session) -> None: - project = db.get(Project, chatbot_session.project_id) + chatbot = db.get(Chatbot, chatbot_session.chatbot_id) + assert chatbot is not None + project = db.get(Project, chatbot.project_id) assert project is not None process_chatbot_session_message( session_id=chatbot_session.id, @@ -104,18 +124,68 @@ def _transcript(db: Session, chatbot_session: ChatbotSession) -> list[tuple[str, ] +def _session_with_turns( + db: Session, chatbot: Chatbot, turns: ChatTurns +) -> ChatbotSession: + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + add_test_chatbot_messages(db, chatbot_session, turns) + return chatbot_session + + +def _success_webhook( + chatbot_session: ChatbotSession, + chatbot: Chatbot, + *, + reply: str, + completion_status: str = "in_progress", + collected_fields: dict[str, str] | None = None, +) -> dict[str, Any]: + return { + "success": True, + "data": { + "session_id": str(chatbot_session.id), + "chatbot_id": str(chatbot.id), + "node_id": ONBOARDING_NODE_ID, + "reply": reply, + "status": "active", + "completion_status": completion_status, + "collected_fields": collected_fields or {}, + }, + } + + +def _failure_webhook(chatbot_session: ChatbotSession, error: str) -> dict[str, Any]: + return { + "success": False, + "error": error, + "data": {"session_id": str(chatbot_session.id)}, + } + + +def _assert_single_failure_webhook( + callbacks: CallbackRecorder, + chatbot_session: ChatbotSession, + *error_fragments: str, +) -> None: + [payload] = callbacks.payloads + assert payload["success"] is False + for fragment in error_fragments: + assert fragment in payload["error"] + assert payload["data"] == {"session_id": str(chatbot_session.id)} + + class TestProcessChatbotSessionMessage: def test_first_call_stores_greeting_and_posts_success_webhook( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, monkeypatch: pytest.MonkeyPatch, ) -> None: patch_chat_model( monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) ) - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) _process(chatbot_session, db) @@ -125,17 +195,9 @@ def test_first_call_stores_greeting_and_posts_success_webhook( assert callbacks.calls == [ ( WEBHOOK_URL, - { - "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", - "collected_fields": {}, - }, - }, + _success_webhook( + chatbot_session, chatbot, reply="Hi! What's your name?" + ), None, ) ] @@ -143,16 +205,16 @@ def test_first_call_stores_greeting_and_posts_success_webhook( def test_pending_user_message_is_answered_with_prior_history( self, db: Session, - config: Config, + chatbot: Chatbot, 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( + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Nice, which city?"]) + ) + chatbot_session = _session_with_turns( db, - chatbot_session, + chatbot, [ (ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?"), (ChatbotMessageRoleEnum.USER, "Asha"), @@ -177,16 +239,16 @@ def test_pending_user_message_is_answered_with_prior_history( def test_stored_system_rows_are_not_sent_as_history( self, db: Session, - config: Config, + chatbot: Chatbot, 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( + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Which city?"]) + ) + chatbot_session = _session_with_turns( db, - chatbot_session, + chatbot, [ (ChatbotMessageRoleEnum.SYSTEM, "stale stored prompt"), (ChatbotMessageRoleEnum.USER, "I'm Asha"), @@ -203,16 +265,13 @@ def test_stored_system_rows_are_not_sent_as_history( def test_already_answered_turn_is_skipped( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, - monkeypatch: pytest.MonkeyPatch, + unused_chat_model: ScriptedChatModel, ) -> 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( + chatbot_session = _session_with_turns( db, - chatbot_session, + chatbot, [ (ChatbotMessageRoleEnum.USER, "Asha"), (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"), @@ -221,7 +280,7 @@ def test_already_answered_turn_is_skipped( _process(chatbot_session, db) - assert chat_model.calls == [] + assert unused_chat_model.calls == [] assert callbacks.calls == [] assert _transcript(db, chatbot_session) == [ ("user", "Asha"), @@ -231,13 +290,11 @@ def test_already_answered_turn_is_skipped( def test_unknown_session_does_nothing( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, - monkeypatch: pytest.MonkeyPatch, + unused_chat_model: ScriptedChatModel, ) -> None: - chat_model = ScriptedChatModel(replies=["should never be used"]) - patch_chat_model(monkeypatch, chat_model) - project = db.get(Project, config.project_id) + project = db.get(Project, chatbot.project_id) assert project is not None process_chatbot_session_message( @@ -247,98 +304,115 @@ def test_unknown_session_does_nothing( webhook_url=WEBHOOK_URL, ) - assert chat_model.calls == [] + assert unused_chat_model.calls == [] assert callbacks.calls == [] def test_deleted_config_version_posts_failure_webhook( self, db: Session, config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, - monkeypatch: pytest.MonkeyPatch, + unused_chat_model: ScriptedChatModel, ) -> None: - chat_model = ScriptedChatModel(replies=["should never be used"]) - patch_chat_model(monkeypatch, chat_model) - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) ConfigVersionCrud( session=db, project_id=config.project_id, config_id=config.id ).delete_or_raise(1) _process(chatbot_session, db) - assert chat_model.calls == [] + assert unused_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)}, - }, + _failure_webhook( + chatbot_session, + f"Version with number '1' not found for config '{config.id}'", + ), None, ) ] - def test_non_anthropic_config_posts_failure_webhook( + def test_turn_runs_the_pinned_version_after_a_newer_one_is_saved( self, db: Session, + config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, monkeypatch: pytest.MonkeyPatch, ) -> None: - chat_model = ScriptedChatModel(replies=["should never be used"]) - patch_chat_model(monkeypatch, chat_model) + chat_model = ScriptedChatModel(replies=["Hi! What's your name?"]) + used_models: list[str] = [] + + def _get_chat_model(model_settings: ChatbotModelSettings) -> ScriptedChatModel: + used_models.append(model_settings.model) + return chat_model + + monkeypatch.setattr(onboarding_graph, "get_chat_model", _get_chat_model) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + add_test_config_version(db, config) + + _process(chatbot_session, db) + + assert used_models == ["claude-sonnet-4-6"] + assert callbacks.calls[0][1]["success"] is True + + def test_non_anthropic_config_posts_failure_webhook( + self, + db: Session, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + ) -> None: openai_config = create_test_config(db, use_kaapi_schema=True) - chatbot_session = create_test_chatbot_session(db, config=openai_config) + # Publish would reject this config; the worker must still guard against it. + openai_chatbot, _ = create_test_live_chatbot(db, config=openai_config) + chatbot_session = create_test_chatbot_session(db, chatbot=openai_chatbot) _process(chatbot_session, db) - assert 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)} + assert unused_chat_model.calls == [] + _assert_single_failure_webhook( + callbacks, chatbot_session, "only supports configs" + ) def test_provider_error_posts_failure_webhook_and_stores_nothing( self, db: Session, - config: Config, + chatbot: Chatbot, 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)), + ScriptedChatModel( + error=anthropic.APITimeoutError(request=ANTHROPIC_REQUEST) + ), ) - chatbot_session = create_test_chatbot_session(db, config=config) - add_test_chatbot_messages( - db, chatbot_session, [(ChatbotMessageRoleEnum.USER, "Asha")] + chatbot_session = _session_with_turns( + db, chatbot, [(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)} + _assert_single_failure_webhook( + callbacks, chatbot_session, "(code: APITimeoutError)" + ) def test_transcript_without_pending_user_turn_posts_failure_webhook( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, - monkeypatch: pytest.MonkeyPatch, + unused_chat_model: ScriptedChatModel, ) -> 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( + chatbot_session = _session_with_turns( db, - chatbot_session, + chatbot, [ (ChatbotMessageRoleEnum.ASSISTANT, "Hi!"), (ChatbotMessageRoleEnum.SYSTEM, "note"), @@ -347,7 +421,7 @@ def test_transcript_without_pending_user_turn_posts_failure_webhook( _process(chatbot_session, db) - assert chat_model.calls == [] + assert unused_chat_model.calls == [] [payload] = callbacks.payloads assert payload["success"] is False assert "`message` is required" in payload["error"] @@ -357,22 +431,24 @@ class TestSessionFieldProgress: def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, monkeypatch: pytest.MonkeyPatch, ) -> None: - chat_model = ScriptedChatModel( - replies=["Thanks, all set!"], - extractions=[ - FieldExtraction( - collected_fields=extracted_fields({"city": "Pune"}), - missing_fields=[], - is_complete=True, - ) - ], + patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Thanks, all set!"], + extractions=[ + FieldExtraction( + collected_fields=extracted_fields({"city": "Pune"}), + missing_fields=[], + is_complete=True, + ) + ], + ), ) - patch_chat_model(monkeypatch, chat_model) - chatbot_session = create_test_chatbot_session(db, config=config) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) update_chatbot_session_progress( db, session_id=chatbot_session.id, @@ -399,43 +475,41 @@ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them( 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), - "chatbot_ref": chatbot_session.chatbot_ref, - "reply": "Thanks, all set!", - "status": "active", - "completion_status": "complete", - "collected_fields": {"name": "Asha", "city": "Pune"}, - }, - } + _success_webhook( + chatbot_session, + chatbot, + reply="Thanks, all set!", + completion_status="complete", + collected_fields={"name": "Asha", "city": "Pune"}, + ) ] def test_extraction_value_error_posts_failure_webhook_and_keeps_progress( self, db: Session, - config: Config, + chatbot: Chatbot, 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, + 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")] + chatbot_session = _session_with_turns( + db, chatbot, [(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_single_failure_webhook( + callbacks, + chatbot_session, + "[KAAPI] Unsupported model configuration", + "thinking cannot be disabled for this model", + ) assert _transcript(db, chatbot_session) == [("user", "Asha")] db.expire_all() stored = db.get(ChatbotSession, chatbot_session.id) @@ -446,22 +520,26 @@ def test_extraction_value_error_posts_failure_webhook_and_keeps_progress( def test_complete_session_still_answers_new_messages( self, db: Session, - config: Config, + chatbot: Chatbot, callbacks: CallbackRecorder, monkeypatch: pytest.MonkeyPatch, ) -> None: - chat_model = ScriptedChatModel( - replies=["You're welcome!"], - extractions=[ - FieldExtraction( - collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}), - missing_fields=[], - is_complete=True, - ) - ], + patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["You're welcome!"], + extractions=[ + FieldExtraction( + collected_fields=extracted_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) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) update_chatbot_session_progress( db, session_id=chatbot_session.id, @@ -487,3 +565,168 @@ def test_complete_session_still_answers_new_messages( [payload] = callbacks.payloads assert payload["success"] is True assert payload["data"]["completion_status"] == "complete" + + +def _session_on_live_flow( + db: Session, + config: Config, + *, + nodes: list[dict[str, Any]], + active_node_id: str, +) -> ChatbotSession: + project = db.get(Project, config.project_id) + assert project is not None + chatbot = create_test_chatbot(db, project=project) + flow_version = create_test_flow_version( + db, chatbot=chatbot, nodes=nodes, is_live=True + ) + return create_test_chatbot_session( + db, chatbot=chatbot, flow_version=flow_version, active_node_id=active_node_id + ) + + +class TestProcessChatbotSessionMessageFlowNode: + def test_reply_is_stored_against_the_active_node( + self, + db: Session, + chatbot: Chatbot, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Which city?"])) + chatbot_session = _session_with_turns( + db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")] + ) + + _process(chatbot_session, db) + + stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) + assert [(m.role.value, m.node_id) for m in stored] == [ + ("user", ONBOARDING_NODE_ID), + ("assistant", ONBOARDING_NODE_ID), + ] + + def test_pinned_flow_prompt_is_used_after_a_newer_flow_is_published( + self, + db: Session, + config: Config, + chatbot: Chatbot, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) + ) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + create_test_flow_version( + db, + chatbot=chatbot, + nodes=[ + build_onboarding_node( + config_id=config.id, prompt="Collect the student's grade." + ) + ], + is_live=True, + ) + + _process(chatbot_session, db) + + assert chat_model.calls[0][0].content == DEFAULT_SYSTEM_PROMPT + assert callbacks.payloads[0]["success"] is True + + def test_inactive_session_posts_superseded_failure_webhook( + self, + db: Session, + chatbot: Chatbot, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + ) -> None: + chatbot_session = _session_with_turns( + db, chatbot, [(ChatbotMessageRoleEnum.USER, "Asha")] + ) + create_test_chatbot_session( + db, chatbot=chatbot, contact_ref=chatbot_session.contact_ref + ) + + _process(chatbot_session, db) + + assert unused_chat_model.calls == [] + assert callbacks.payloads == [ + _failure_webhook(chatbot_session, SUPERSEDED_SESSION_ERROR) + ] + assert _transcript(db, chatbot_session) == [("user", "Asha")] + + def test_active_node_missing_from_flow_posts_failure_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + ) -> None: + chatbot_session = _session_on_live_flow( + db, + config, + nodes=[build_onboarding_node(config_id=config.id)], + active_node_id="ghost", + ) + + _process(chatbot_session, db) + + assert unused_chat_model.calls == [] + assert callbacks.payloads == [ + _failure_webhook( + chatbot_session, + "[KAAPI] Active node 'ghost' is not in the session's flow " + "version; start a new session.", + ) + ] + + def test_non_onboarding_active_node_posts_failure_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + ) -> None: + coordinator = build_onboarding_node( + config_id=config.id, node_id="router", node_type="coordinator" + ) + chatbot_session = _session_on_live_flow( + db, config, nodes=[coordinator], active_node_id="router" + ) + + _process(chatbot_session, db) + + assert unused_chat_model.calls == [] + [payload] = callbacks.payloads + assert payload["success"] is False + assert "'router' has type 'coordinator'" in payload["error"] + assert "only 'onboarding' nodes can run" in payload["error"] + + @pytest.mark.parametrize( + "node_overrides", + [{"prompt": None}, {"config_id": None}], + ids=["missing-prompt", "missing-config"], + ) + def test_node_without_prompt_or_config_posts_failure_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + node_overrides: dict[str, Any], + ) -> None: + node_kwargs: dict[str, Any] = {"config_id": config.id, **node_overrides} + chatbot_session = _session_on_live_flow( + db, + config, + nodes=[build_onboarding_node(**node_kwargs)], + active_node_id=ONBOARDING_NODE_ID, + ) + + _process(chatbot_session, db) + + assert unused_chat_model.calls == [] + _assert_single_failure_webhook( + callbacks, chatbot_session, "missing data.prompt or data.config_id" + ) diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index 7137bc766..3e759cd09 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -1,17 +1,30 @@ from collections.abc import Sequence from typing import Any +from uuid import UUID +import httpx import pytest from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.runnables import Runnable, RunnableLambda from pydantic import Field -from sqlmodel import Session +from sqlmodel import Session, select -from app.crud.chatbot import create_chatbot_session, create_chatbot_session_message +from app.core.config import settings +from app.crud.chatbot import ( + create_chatbot, + create_chatbot_flow_version, + create_chatbot_session, + create_chatbot_session_message, + get_latest_chatbot_flow_version, + get_live_chatbot_flow_version, + publish_chatbot_flow_version, +) from app.models import ( - DEFAULT_CHATBOT_NAME, + Chatbot, + ChatbotFlowNodeTypeEnum, + ChatbotFlowVersion, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, @@ -23,9 +36,50 @@ ExtractedField, FieldExtraction, ) +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, + create_test_project, + create_test_version, +) from app.tests.utils.utils import random_lower_string DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city." +ONBOARDING_NODE_ID = "onboarding-1" +ONBOARDING_NODE_LABEL = "Beneficiary Info Extractor" +# A model other than build_anthropic_text_config_blob's default, so a newer +# config version is distinguishable from version 1. +NEWER_CONFIG_MODEL = "claude-opus-5-5" +WEBHOOK_URL = "https://hooks.example.com/chatbot" +ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + +CHATBOTS_URL = f"{settings.API_V1_STR}/chatbots" +LATEST_SESSION_URL = f"{CHATBOTS_URL}/sessions/latest" +UNKNOWN_CHATBOT_ID = UUID("00000000-0000-0000-0000-0000000000c1") + + +def chatbot_url(chatbot_id: UUID) -> str: + return f"{CHATBOTS_URL}/{chatbot_id}" + + +def flow_url(chatbot_id: UUID) -> str: + return f"{chatbot_url(chatbot_id)}/flow" + + +def publish_url(chatbot_id: UUID) -> str: + return f"{flow_url(chatbot_id)}/publish" + + +def sessions_url(chatbot_id: UUID) -> str: + return f"{chatbot_url(chatbot_id)}/sessions" + + +def message_url(session_id: UUID) -> str: + return f"{CHATBOTS_URL}/sessions/{session_id}/message" + + +def random_contact_ref() -> str: + return f"contact-{random_lower_string()}" def extracted_fields(fields: dict[str, str]) -> list[ExtractedField]: @@ -89,35 +143,145 @@ def _extract(messages: list[BaseMessage]) -> Any: def patch_chat_model( monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel -) -> None: +) -> ScriptedChatModel: # The Anthropic SDK is the external boundary; everything above it runs for real. monkeypatch.setattr( onboarding_graph, "get_chat_model", lambda _settings: chat_model ) + return chat_model -def create_test_chatbot_session( +def create_test_anthropic_config( + db: Session, *, project_id: int | None = None +) -> Config: + return create_test_config( + db, project_id=project_id, config_blob=build_anthropic_text_config_blob() + ) + + +def add_test_config_version( + db: Session, config: Config, *, model: str = NEWER_CONFIG_MODEL +) -> None: + create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + config_blob=build_anthropic_text_config_blob(model=model), + ) + + +def build_flow_node(node_id: str, node_type: str, **data: Any) -> dict[str, Any]: + return { + "id": node_id, + "type": node_type, + "position": {"x": 0, "y": 0}, + "data": data, + } + + +def build_flow_edge(edge_id: str, source: str, target: str) -> dict[str, Any]: + return {"id": edge_id, "source": source, "target": target} + + +def build_onboarding_node( + *, + config_id: UUID | None, + config_version: int | None = 1, + prompt: str | None = DEFAULT_SYSTEM_PROMPT, + node_id: str = ONBOARDING_NODE_ID, + node_type: str = ChatbotFlowNodeTypeEnum.ONBOARDING.value, +) -> dict[str, Any]: + data: dict[str, Any] = {"label": ONBOARDING_NODE_LABEL} + if config_id is not None: + data["config_id"] = str(config_id) + if config_version is not None: + data["config_version"] = config_version + if prompt is not None: + data["prompt"] = prompt + return build_flow_node(node_id, node_type, **data) + + +def create_test_chatbot( + db: Session, + *, + project: Project | None = None, + name: str | None = None, +) -> Chatbot: + if project is None: + project = create_test_project(db) + return create_chatbot( + db, + organization_id=project.organization_id, + project_id=project.id, + name=name or f"chatbot-{random_lower_string()}", + ) + + +def create_test_flow_version( + db: Session, + *, + chatbot: Chatbot, + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]] | None = None, + is_live: bool = False, +) -> ChatbotFlowVersion: + latest = get_latest_chatbot_flow_version(db, chatbot_id=chatbot.id) + flow_version = create_chatbot_flow_version( + db, + chatbot_id=chatbot.id, + version=1 if latest is None else latest.version + 1, + nodes=nodes, + edges=edges or [], + ) + if is_live: + flow_version = publish_chatbot_flow_version( + db, flow_version=flow_version, nodes=nodes + ) + return flow_version + + +def create_test_live_chatbot( db: Session, *, config: Config, - name: str = 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: + name: str | None = None, + prompt: str = DEFAULT_SYSTEM_PROMPT, + config_version: int = 1, +) -> tuple[Chatbot, ChatbotFlowVersion]: + """A chatbot in the config's project whose live flow is one onboarding node.""" project = db.get(Project, config.project_id) assert project is not None + chatbot = create_test_chatbot(db, project=project, name=name) + flow_version = create_test_flow_version( + db, + chatbot=chatbot, + nodes=[ + build_onboarding_node( + config_id=config.id, config_version=config_version, prompt=prompt + ) + ], + is_live=True, + ) + return chatbot, flow_version + + +def create_test_chatbot_session( + db: Session, + *, + chatbot: Chatbot, + contact_ref: str | None = None, + flow_version: ChatbotFlowVersion | None = None, + active_node_id: str = ONBOARDING_NODE_ID, +) -> ChatbotSession: + if flow_version is None: + flow_version = get_live_chatbot_flow_version(db, chatbot_id=chatbot.id) + assert flow_version is not None return create_chatbot_session( db, - organization_id=project.organization_id, - 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, + chatbot_id=chatbot.id, + flow_version_id=flow_version.id, + contact_ref=contact_ref or random_contact_ref(), + active_node_id=active_node_id, ) @@ -130,7 +294,35 @@ def add_test_chatbot_messages( for role, content in turns: stored.append( create_chatbot_session_message( - db, session_id=chatbot_session.id, role=role, content=content + db, + session_id=chatbot_session.id, + role=role, + content=content, + node_id=chatbot_session.active_node_id, ) ) return stored + + +def list_stored_flow_versions( + db: Session, chatbot: Chatbot +) -> list[ChatbotFlowVersion]: + db.expire_all() + return list( + db.exec( + select(ChatbotFlowVersion) + .where(ChatbotFlowVersion.chatbot_id == chatbot.id) + .order_by(ChatbotFlowVersion.version) + ).all() + ) + + +def list_stored_sessions_for_contact( + db: Session, contact_ref: str +) -> list[ChatbotSession]: + db.expire_all() + return list( + db.exec( + select(ChatbotSession).where(ChatbotSession.contact_ref == contact_ref) + ).all() + ) diff --git a/docs/wiki/INDEX.md b/docs/wiki/INDEX.md index 30ee8ba93..8e457234b 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) — 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/chatbot.md](modules/chatbot.md) — chatbots as versioned React Flow graphs (draft/publish) with per-conversation sessions; onboarding agent with per-turn field extraction + completion status (`/chatbots`, `/chatbots/{id}/flow*`, `/chatbots/*sessions*` via Celery + webhook; LangGraph + Anthropic, model from a saved config per agent node). No deep-dive doc yet. - [modules/tenancy.md](modules/tenancy.md) — users, orgs, projects, API keys, onboarding, login. - [modules/platform.md](modules/platform.md) — analytics, notifications, feature flags, languages, credentials, model config, cron. diff --git a/docs/wiki/domain-map.md b/docs/wiki/domain-map.md index 412da4a73..37759e939 100644 --- a/docs/wiki/domain-map.md +++ b/docs/wiki/domain-map.md @@ -23,8 +23,8 @@ APIKey → Organization, Project, User # programmatic access | User | user.py | — | UserProject, APIKey, Notification | | APIKey | api_key.py | Organization, Project, User | auth dependency on every API route (logical) | | Credential | credentials.py | Organization, Project | provider clients: OpenAI/Gemini/Anthropic calls (logical); bucket providers (`google-gcp` → GCS signing, logical) | -| Config | config/config.py | Project | ConfigVersion; LLM call path; 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) | +| Config | config/config.py | Project | ConfigVersion; LLM call path; ChatbotFlowVersion agent nodes (`data.config_id`, logical); Assessment; EvaluationRun (`config_id`) | +| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references and chatbot flow agent nodes (`data.config_id`/`data.config_version`, pinned at publish, logical) | | LlmCall | llm/request.py | Job, LlmChain, Org, Project | Langfuse traces (logical); analytics | | LlmChain | llm/request.py | Org, Project | LlmCall | | Job | job.py | Project | LlmCall; Assessment (RESPONSE method); Celery job execution (logical); evaluation prompt improvement (`JobType.PROMPT_IMPROVEMENT`, logical) | @@ -50,6 +50,10 @@ APIKey → Organization, Project, User # programmatic access | Language | language.py (`global.languages`) | — | EvaluationDataset, EvaluationRun, STT | | FeatureFlag | feature_flag.py | Org, Project | route gating (logical) | | Notification | notification.py | Project, User | notification service | +| Chatbot | chatbot.py | Org, Project | ChatbotFlowVersion, ChatbotSession; soft-deleted (`deleted_at`) because sessions reference it | +| ChatbotFlowVersion | chatbot.py | Chatbot (CASCADE) | ChatbotSession (`flow_version_id`, RESTRICT); React Flow editor in kaapi-frontend (logical); Config/ConfigVersion via node `data` (logical) | +| ChatbotSession | chatbot.py | Chatbot (RESTRICT), ChatbotFlowVersion (RESTRICT) | ChatbotSessionMessage; Celery turn worker; caller webhook (logical) | +| ChatbotSessionMessage | chatbot.py | ChatbotSession (CASCADE) | turn history for the onboarding agent (logical) | ## External consumers (always check in blast radius) diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 4b482448a..f385f07b1 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -1,44 +1,58 @@ # 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. Conversations are server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet. +A project has many chatbots; each chatbot's behaviour is a React Flow graph (nodes + edges JSON) edited in the UI and versioned with draft/publish, so editing never breaks running conversations. Agent nodes carry their own `prompt` and saved Kaapi config (`config_id` + `config_version`, pinned at publish); only Anthropic text completion configs are supported. Conversations are server-side sessions pinned to the live flow version and an active node; turns run on Celery and reply via webhook. On this branch the runtime only executes `onboarding` agent nodes ("Beneficiary Info Extractor" over a two-node LangGraph): the node prompt defines the goal, fields to collect and rules; each turn extracts the fields given so far and then replies. No deep-dive doc yet. All paths relative to `backend/app/`. -## Routes +## Routes (`/chatbots`, all require project scope and the `CHATBOT` feature flag) +- `api/routes/chatbot/__init__.py` mounts session routes first so literal `/sessions/...` paths are matched before `/{chatbot_id}/...`. `require_feature(FeatureFlag.CHATBOT)` sits on the router → 403 when off. Migration `089` only adds the enum value — no rows are seeded, so the flag is off until a superuser creates it per project via `POST /feature-flags` (`enabled: true`). +- `api/routes/chatbot/chatbot.py` + - `POST /chatbots` {name} → 201 `ChatbotPublic` + - `GET /chatbots?skip=&limit=` → the project's non-deleted chatbots, newest `updated_at` first + - `GET /chatbots/{chatbot_id}`, `PATCH /chatbots/{chatbot_id}` {name} → 404 when unknown/deleted + - `DELETE /chatbots/{chatbot_id}` → 200 `Message`; soft delete + deactivates the chatbot's active sessions in the same transaction +- `api/routes/chatbot/flow.py` (response `ChatbotFlowVersionPublic` {id, chatbot_id, version, is_live, nodes, edges, inserted_at, updated_at}) + - `GET /chatbots/{chatbot_id}/flow?version=` → that version; default the draft if one exists, else live; 404 if no versions + - `PUT /chatbots/{chatbot_id}/flow` {nodes, edges} → first save creates v1 already live (strict publish validation + pin, 422 on failure); later saves upsert the draft (shape-only validation); 409 on a concurrent version insert + - `POST /chatbots/{chatbot_id}/flow/publish` → 409 without a draft, 422 listing every strict-validation problem - `api/routes/chatbot/session.py` - - `POST /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 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`). - -## 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`. -- `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: `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`. + - `POST /chatbots/{chatbot_id}/sessions` {contact_ref} → 201 {session_id, chatbot_id, flow_version, active_node_id}; 404 unknown/deleted chatbot; 409 no live flow or concurrent create + - `GET /chatbots/sessions/latest?contact_ref=&chatbot_id=` → `ChatbotSessionSummary` (incl. `chatbot_name`, `flow_version`, `active_node_id`, the active node's `prompt`); `chatbot_id` omitted = newest across the project's non-deleted chatbots + - `POST /chatbots/sessions/{session_id}/message` → stores the user message with `node_id = active_node_id`, enqueues the turn → 202; 409 if the session is inactive + +## Tables (SQLModel, `models/chatbot.py`, migration `088`) +- `chatbot` (`Chatbot`) — `name`, org/project FKs (CASCADE), `deleted_at` soft delete (sessions reference it with RESTRICT). +- `chatbot_flow_version` (`ChatbotFlowVersion`) — `chatbot_id` (CASCADE), `version` (UNIQUE with chatbot_id, `uq_chatbot_flow_version_chatbot_version`), `nodes` / `edges` JSONB stored as the UI sent them (React Flow camelCase keys, extra UI keys round-trip), `is_live` with partial unique index `uq_chatbot_flow_version_live_chatbot` (`WHERE is_live`). The highest version is the draft when not live; live versions are immutable. +- `chatbot_session` (`ChatbotSession`) — `chatbot_id` (RESTRICT), `flow_version_id` (RESTRICT, pinned at create), `contact_ref`, `active_node_id` (React Flow node id inside the pinned flow), `status` (`ChatbotSessionStatusEnum` active/inactive), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`). No org/project columns: tenant scoping joins through `chatbot`. Indexes: `(chatbot_id, contact_ref)`, `contact_ref` (cross-chatbot latest lookup), partial unique `uq_chatbot_session_active_chatbot_contact` on `(chatbot_id, contact_ref) WHERE status = 'active'`. +- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant), `node_id` = the flow node that handled the turn. +- Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same chatbot + `contact_ref` (same transaction as the insert) — or deleting the chatbot — moves active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued is skipped by the worker, which posts a failure webhook instead. A concurrent double-create hits the partial unique index; crud raises `ChatbotSessionConflictError` → 409. + +## Flow JSON (Pydantic in `models/chatbot.py`) +- `ChatbotFlowNode` {id (1..255), type `ChatbotFlowNodeTypeEnum` (input/output/coordinator/onboarding/rag), position {x, y}, data}; agent nodes (`AGENT_NODE_TYPES`) validate `data` as `ChatbotAgentNodeData` {config_id: UUID?, config_version: int ≥ 1?, prompt?}. `ChatbotFlowEdge` {id, source, target, sourceHandle?, targetHandle?, data} — `data.intent` / `data.description` are reserved for coordinator routing. Extra keys allowed everywhere; dumped by alias. +- The first save goes live directly (validated + pinned like a publish); later draft saves validate shape only. Publish (`services/chatbot/flow.py:validate_flow_for_publish`) is strict: unique node ids; ≤ 1 input and ≤ 1 output node; edge endpoints exist; coordinator/rag rejected as not supported yet; every onboarding node has `config_id` + non-empty `prompt` and its config passes `get_chatbot_config_version` + `build_chatbot_model_settings`; a null `config_version` is resolved to the latest and written back into the stored node; an entry node must resolve. +- Entry node (`resolve_entry_node`): no edges → the single agent node; otherwise the agent node targeted by the input node's only outgoing edge. ## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`) -1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the 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. +1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the node prompt, the previously collected map, and the (summarized) conversation. Uses Anthropic native structured output (`with_structured_output(..., method="json_schema")`) with the config's own thinking/effort, because forced tool use is rejected with thinking on and Opus 5.5 can't disable thinking. Merged in code: `{**prior, **extracted}`; `is_complete` only when nothing is missing. 2. `chatbot_node` — replies using the same summarized view plus a system note listing collected/missing fields, so it asks only for what's missing and wraps up when complete. 3. `run_chatbot_turn(..., collected_fields=prior)` → `ChatbotTurnResult{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/flow.py` — `get_chatbot_flow`, `save_chatbot_flow_draft`, `publish_chatbot_flow` (row-lock the chatbot via `get_chatbot(for_update=True)` to serialize draft saves and publishes), `validate_flow_for_publish`, `resolve_entry_node`, `find_flow_node`, `parse_flow_nodes/edges`, `dump_flow_nodes/edges`, `build_chatbot_flow_version_public` +- `services/chatbot/session.py` — `start_chatbot_session` (live version + entry node → `create_chatbot_session`), `get_latest_chatbot_session_summary`, `process_chatbot_session_message` (Celery worker body: load session + pinned flow version + active node; inactive session, missing node or a non-onboarding node → failure webhook; otherwise run the turn with the node prompt/config and stored `collected_fields`, store the reply with `node_id`, update progress, POST the webhook) +- `services/chatbot/agents/onboarding/{graph,prompts,types}.py` — `run_chatbot_turn`, the extraction/reply nodes, `FieldExtraction` - `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `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; 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` -- `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) +- `services/chatbot/config.py` — `get_chatbot_config_version` (requested or latest version), `build_chatbot_model_settings` (version row → `ChatbotModelSettings`; 422-class `ChatbotInputError` for non-Anthropic/non-text/no-model configs or a bad thinking budget), `resolve_chatbot_model_settings` (both, used by the worker) +- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults, `get_chat_model` +- `services/chatbot/exceptions.py` — `ChatbotInputError`, `ChatbotProviderError`, `ChatbotSessionConflictError`, `ChatbotNotFoundError`, `ChatbotFlowNotFoundError`, `ChatbotFlowConflictError`, `ChatbotFlowValidationError` +- `crud/chatbot.py` — chatbot: `create_chatbot`, `get_chatbot`, `list_chatbots`, `update_chatbot`, `delete_chatbot`; flow: `get_latest_chatbot_flow_version`, `get_live_chatbot_flow_version`, `get_chatbot_flow_version`, `get_chatbot_flow_version_by_id`, `create_chatbot_flow_version`, `update_chatbot_flow_version_graph`, `publish_chatbot_flow_version` (unsets the previous live before setting the draft live, one commit); session: `create_chatbot_session` (supersedes via `_deactivate_active_chatbot_sessions`), `get_chatbot_session`, `get_latest_chatbot_session` (joined with chatbot + flow version), `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages` ## Async - `celery/tasks/job_execution.py` — `run_chatbot_session_message` (enqueued via `celery/utils.py:start_chatbot_session_message`) → `process_chatbot_session_message`. -- Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_ref", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. +- Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_id", "node_id", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. ## External - Anthropic via `langchain-anthropic` (`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). +- kaapi-frontend React Flow editor reads/writes the flow JSON as-is. From 0a2af917e5702e23ec4d91a36d4ec1af7c60b988 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:35:19 +0530 Subject: [PATCH 18/25] fix: update the create.md --- backend/app/api/docs/chatbot/create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/docs/chatbot/create.md b/backend/app/api/docs/chatbot/create.md index 77184ce6f..fcc16ea5d 100644 --- a/backend/app/api/docs/chatbot/create.md +++ b/backend/app/api/docs/chatbot/create.md @@ -1,3 +1,3 @@ Create a chatbot in the caller's project and return it (`201`). -Only `name` is required (1–255 characters; display only). A new chatbot has no flow: save one with `PUT /chatbots/{chatbot_id}/flow` and publish it with `POST /chatbots/{chatbot_id}/flow/publish` before starting sessions. +Only `name` is required (1–255 characters; display only). A new chatbot has no flow: its first `PUT /chatbots/{chatbot_id}/flow` goes live at once, after which sessions can start. Later saves are drafts that go live with `POST /chatbots/{chatbot_id}/flow/publish`. From 3b8d55f377fa71790b9daa7b31957f7119829c12 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:01:54 +0530 Subject: [PATCH 19/25] fix: few more test cases updates --- .../app/tests/api/routes/chatbot/conftest.py | 20 +--- .../tests/api/routes/chatbot/test_chatbot.py | 98 ++++++++++++------- .../api/routes/chatbot/test_feature_flag.py | 4 +- .../app/tests/api/routes/chatbot/test_flow.py | 72 ++++++++------ .../tests/api/routes/chatbot/test_session.py | 20 ++-- .../app/tests/services/chatbot/test_config.py | 40 +++----- .../app/tests/services/chatbot/test_flow.py | 11 +-- backend/app/tests/utils/chatbot.py | 10 +- 8 files changed, 140 insertions(+), 135 deletions(-) diff --git a/backend/app/tests/api/routes/chatbot/conftest.py b/backend/app/tests/api/routes/chatbot/conftest.py index 450021af7..31f3d5ba9 100644 --- a/backend/app/tests/api/routes/chatbot/conftest.py +++ b/backend/app/tests/api/routes/chatbot/conftest.py @@ -7,7 +7,7 @@ get_feature_flag, update_feature_flag, ) -from app.models import Chatbot, Config, Project +from app.models import Chatbot, Config from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( create_test_anthropic_config, @@ -35,24 +35,14 @@ def chatbot_feature_enabled(db: Session, user_api_key: TestAuthContext) -> None: @pytest.fixture -def headers(user_api_key: TestAuthContext) -> dict[str, str]: - return {"X-API-KEY": user_api_key.key} +def config(db: Session, user_api_key: TestAuthContext) -> Config: + return create_test_anthropic_config(db, project_id=user_api_key.project_id) @pytest.fixture -def project(user_api_key: TestAuthContext) -> Project: - return user_api_key.project - - -@pytest.fixture -def config(db: Session, project: Project) -> Config: - return create_test_anthropic_config(db, project_id=project.id) - - -@pytest.fixture -def chatbot(db: Session, project: Project) -> Chatbot: +def chatbot(db: Session, user_api_key: TestAuthContext) -> Chatbot: """A chatbot in the caller's project with no flow saved yet.""" - return create_test_chatbot(db, project=project) + return create_test_chatbot(db, project=user_api_key.project) @pytest.fixture diff --git a/backend/app/tests/api/routes/chatbot/test_chatbot.py b/backend/app/tests/api/routes/chatbot/test_chatbot.py index 60eb3302c..4d9261445 100644 --- a/backend/app/tests/api/routes/chatbot/test_chatbot.py +++ b/backend/app/tests/api/routes/chatbot/test_chatbot.py @@ -9,7 +9,6 @@ ChatbotCompletionStatusEnum, ChatbotSession, ChatbotSessionStatusEnum, - Project, ) from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( @@ -27,10 +26,12 @@ def test_creates_chatbot_in_callers_project( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], user_api_key: TestAuthContext, ) -> None: - resp = client.post(CHATBOTS_URL, headers=headers, json={"name": "Onboarding"}) + resp = client.post( + CHATBOTS_URL, headers=user_api_key_header, json={"name": "Onboarding"} + ) assert resp.status_code == 201 body = resp.json()["data"] @@ -48,9 +49,12 @@ def test_creates_chatbot_in_callers_project( ids=["missing", "empty", "too-long"], ) def test_invalid_name_returns_422( - self, client: TestClient, headers: dict[str, str], payload: dict[str, str] + self, + client: TestClient, + user_api_key_header: dict[str, str], + payload: dict[str, str], ) -> None: - resp = client.post(CHATBOTS_URL, headers=headers, json=payload) + resp = client.post(CHATBOTS_URL, headers=user_api_key_header, json=payload) assert resp.status_code == 422 @@ -60,17 +64,20 @@ def test_lists_only_callers_non_deleted_chatbots( self, client: TestClient, db: Session, - headers: dict[str, str], - project: Project, + user_api_key_header: dict[str, str], + user_api_key: TestAuthContext, chatbot: Chatbot, foreign_chatbot: Chatbot, ) -> None: - deleted = create_test_chatbot(db, project=project) + deleted = create_test_chatbot(db, project=user_api_key.project) assert ( - client.delete(chatbot_url(deleted.id), headers=headers).status_code == 200 + client.delete( + chatbot_url(deleted.id), headers=user_api_key_header + ).status_code + == 200 ) - resp = client.get(CHATBOTS_URL, headers=headers) + resp = client.get(CHATBOTS_URL, headers=user_api_key_header) assert resp.status_code == 200 ids = {item["id"] for item in resp.json()["data"]} @@ -82,17 +89,19 @@ def test_skip_and_limit_page_newest_first( self, client: TestClient, db: Session, - headers: dict[str, str], - project: Project, + user_api_key_header: dict[str, str], + user_api_key: TestAuthContext, ) -> None: # The seeded project may already own chatbots, so page through a known head. - create_test_chatbot(db, project=project) - middle = create_test_chatbot(db, project=project) - newest = create_test_chatbot(db, project=project) + create_test_chatbot(db, project=user_api_key.project) + middle = create_test_chatbot(db, project=user_api_key.project) + newest = create_test_chatbot(db, project=user_api_key.project) - first = client.get(CHATBOTS_URL, headers=headers, params={"limit": 1}) + first = client.get( + CHATBOTS_URL, headers=user_api_key_header, params={"limit": 1} + ) second = client.get( - CHATBOTS_URL, headers=headers, params={"skip": 1, "limit": 1} + CHATBOTS_URL, headers=user_api_key_header, params={"skip": 1, "limit": 1} ) assert [c["id"] for c in first.json()["data"]] == [str(newest.id)] @@ -101,9 +110,9 @@ def test_skip_and_limit_page_newest_first( class TestGetChatbot: def test_returns_chatbot( - self, client: TestClient, headers: dict[str, str], chatbot: Chatbot + self, client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot ) -> None: - resp = client.get(chatbot_url(chatbot.id), headers=headers) + resp = client.get(chatbot_url(chatbot.id), headers=user_api_key_header) assert resp.status_code == 200 body = resp.json()["data"] @@ -111,17 +120,20 @@ def test_returns_chatbot( assert body["name"] == chatbot.name def test_unknown_returns_404( - self, client: TestClient, headers: dict[str, str] + self, client: TestClient, user_api_key_header: dict[str, str] ) -> None: - resp = client.get(chatbot_url(UNKNOWN_CHATBOT_ID), headers=headers) + resp = client.get(chatbot_url(UNKNOWN_CHATBOT_ID), headers=user_api_key_header) assert resp.status_code == 404 assert resp.json()["error"] == "Chatbot not found" def test_other_projects_chatbot_returns_404( - self, client: TestClient, headers: dict[str, str], foreign_chatbot: Chatbot + self, + client: TestClient, + user_api_key_header: dict[str, str], + foreign_chatbot: Chatbot, ) -> None: - resp = client.get(chatbot_url(foreign_chatbot.id), headers=headers) + resp = client.get(chatbot_url(foreign_chatbot.id), headers=user_api_key_header) assert resp.status_code == 404 @@ -131,11 +143,13 @@ def test_renames_chatbot( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], chatbot: Chatbot, ) -> None: resp = client.patch( - chatbot_url(chatbot.id), headers=headers, json={"name": "Renamed"} + chatbot_url(chatbot.id), + headers=user_api_key_header, + json={"name": "Renamed"}, ) assert resp.status_code == 200 @@ -149,13 +163,15 @@ def test_other_projects_chatbot_returns_404_and_is_unchanged( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], foreign_chatbot: Chatbot, ) -> None: original_name = foreign_chatbot.name resp = client.patch( - chatbot_url(foreign_chatbot.id), headers=headers, json={"name": "Hijack"} + chatbot_url(foreign_chatbot.id), + headers=user_api_key_header, + json={"name": "Hijack"}, ) assert resp.status_code == 404 @@ -170,10 +186,10 @@ def test_soft_deletes_and_hides_chatbot( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], chatbot: Chatbot, ) -> None: - resp = client.delete(chatbot_url(chatbot.id), headers=headers) + resp = client.delete(chatbot_url(chatbot.id), headers=user_api_key_header) assert resp.status_code == 200 assert resp.json()["data"] == {"message": "Chatbot deleted successfully"} @@ -181,27 +197,33 @@ def test_soft_deletes_and_hides_chatbot( stored = db.get(Chatbot, chatbot.id) assert stored is not None assert stored.deleted_at is not None - assert client.get(chatbot_url(chatbot.id), headers=headers).status_code == 404 + assert ( + client.get(chatbot_url(chatbot.id), headers=user_api_key_header).status_code + == 404 + ) assert ( client.patch( - chatbot_url(chatbot.id), headers=headers, json={"name": "x"} + chatbot_url(chatbot.id), headers=user_api_key_header, json={"name": "x"} ).status_code == 404 ) assert ( - client.delete(chatbot_url(chatbot.id), headers=headers).status_code == 404 + client.delete( + chatbot_url(chatbot.id), headers=user_api_key_header + ).status_code + == 404 ) def test_deactivates_active_sessions_and_hides_them_from_latest( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], live_chatbot: Chatbot, ) -> None: chatbot_session = create_test_chatbot_session(db, chatbot=live_chatbot) - resp = client.delete(chatbot_url(live_chatbot.id), headers=headers) + resp = client.delete(chatbot_url(live_chatbot.id), headers=user_api_key_header) assert resp.status_code == 200 db.expire_all() @@ -211,7 +233,7 @@ def test_deactivates_active_sessions_and_hides_them_from_latest( assert stored.completion_status == ChatbotCompletionStatusEnum.ABANDONED latest = client.get( LATEST_SESSION_URL, - headers=headers, + headers=user_api_key_header, params={"contact_ref": chatbot_session.contact_ref}, ) assert latest.status_code == 404 @@ -220,10 +242,12 @@ def test_other_projects_chatbot_returns_404_and_is_kept( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], foreign_chatbot: Chatbot, ) -> None: - resp = client.delete(chatbot_url(foreign_chatbot.id), headers=headers) + resp = client.delete( + chatbot_url(foreign_chatbot.id), headers=user_api_key_header + ) assert resp.status_code == 404 db.expire_all() 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 2df8f98de..a75c823e5 100644 --- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py +++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py @@ -44,7 +44,7 @@ def test_chatbot_routes_return_403_when_flag_disabled( client: TestClient, db: Session, user_api_key: TestAuthContext, - headers: dict[str, str], + user_api_key_header: dict[str, str], method: str, path: str, kwargs: dict, @@ -54,7 +54,7 @@ def test_chatbot_routes_return_403_when_flag_disabled( resp = client.request( method, f"{CHATBOTS_URL}{path}", - headers=headers, + headers=user_api_key_header, **kwargs, ) diff --git a/backend/app/tests/api/routes/chatbot/test_flow.py b/backend/app/tests/api/routes/chatbot/test_flow.py index 16c7965ef..49e630517 100644 --- a/backend/app/tests/api/routes/chatbot/test_flow.py +++ b/backend/app/tests/api/routes/chatbot/test_flow.py @@ -7,7 +7,8 @@ from httpx import Response from sqlmodel import Session -from app.models import Chatbot, Config, Project +from app.models import Chatbot, Config +from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( ONBOARDING_NODE_ID, UNKNOWN_CHATBOT_ID, @@ -33,14 +34,14 @@ @pytest.fixture def save( - client: TestClient, headers: dict[str, str], chatbot: Chatbot + client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot ) -> Callable[..., Response]: def _save( nodes: list[dict[str, Any]], edges: list[dict[str, Any]] | None = None ) -> Response: return client.put( flow_url(chatbot.id), - headers=headers, + headers=user_api_key_header, json={"nodes": nodes, "edges": edges or []}, ) @@ -49,9 +50,9 @@ def _save( @pytest.fixture def publish( - client: TestClient, headers: dict[str, str], chatbot: Chatbot + client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot ) -> Callable[[], Response]: - return lambda: client.post(publish_url(chatbot.id), headers=headers) + return lambda: client.post(publish_url(chatbot.id), headers=user_api_key_header) @pytest.fixture @@ -98,7 +99,7 @@ def test_invalid_first_save_returns_422_and_stores_nothing( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, save: Callable[..., Response], @@ -110,18 +111,21 @@ def test_invalid_first_save_returns_422_and_stores_nothing( assert error.startswith(INVALID_FLOW_PREFIX) assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error assert list_stored_flow_versions(db, chatbot) == [] - assert client.get(flow_url(chatbot.id), headers=headers).status_code == 404 + assert ( + client.get(flow_url(chatbot.id), headers=user_api_key_header).status_code + == 404 + ) def test_session_can_start_right_after_first_save( self, client: TestClient, - headers: dict[str, str], + user_api_key_header: dict[str, str], chatbot: Chatbot, live_v1: dict[str, Any], ) -> None: resp = client.post( sessions_url(chatbot.id), - headers=headers, + headers=user_api_key_header, json={"contact_ref": random_contact_ref()}, ) @@ -176,7 +180,7 @@ def test_save_after_publish_starts_new_draft_and_keeps_live_version( def test_react_flow_keys_and_extra_ui_keys_round_trip( self, client: TestClient, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, save: Callable[..., Response], @@ -195,7 +199,7 @@ def test_react_flow_keys_and_extra_ui_keys_round_trip( } save([build_flow_node(INPUT_NODE_ID, "input"), onboarding], [edge]) - resp = client.get(flow_url(chatbot.id), headers=headers) + resp = client.get(flow_url(chatbot.id), headers=user_api_key_header) body = resp.json()["data"] assert (body["version"], body["is_live"]) == (2, False) @@ -258,10 +262,12 @@ def test_draft_save_does_not_validate_publish_rules( assert resp.json()["data"]["is_live"] is False def test_unknown_chatbot_returns_404( - self, client: TestClient, headers: dict[str, str] + self, client: TestClient, user_api_key_header: dict[str, str] ) -> None: resp = client.put( - flow_url(UNKNOWN_CHATBOT_ID), headers=headers, json={"nodes": []} + flow_url(UNKNOWN_CHATBOT_ID), + headers=user_api_key_header, + json={"nodes": []}, ) assert resp.status_code == 404 @@ -270,12 +276,12 @@ def test_other_projects_chatbot_returns_404_and_saves_nothing( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], foreign_chatbot: Chatbot, ) -> None: resp = client.put( flow_url(foreign_chatbot.id), - headers=headers, + headers=user_api_key_header, json={"nodes": [build_flow_node(INPUT_NODE_ID, "input")]}, ) @@ -285,9 +291,9 @@ def test_other_projects_chatbot_returns_404_and_saves_nothing( class TestGetChatbotFlow: def test_no_versions_returns_404( - self, client: TestClient, headers: dict[str, str], chatbot: Chatbot + self, client: TestClient, user_api_key_header: dict[str, str], chatbot: Chatbot ) -> None: - resp = client.get(flow_url(chatbot.id), headers=headers) + resp = client.get(flow_url(chatbot.id), headers=user_api_key_header) assert resp.status_code == 404 assert resp.json()["error"] == "Chatbot has no flow yet; save a flow first" @@ -296,7 +302,7 @@ def test_default_returns_live_when_no_draft( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, ) -> None: @@ -307,7 +313,7 @@ def test_default_returns_live_when_no_draft( is_live=True, ) - resp = client.get(flow_url(chatbot.id), headers=headers) + resp = client.get(flow_url(chatbot.id), headers=user_api_key_header) assert resp.status_code == 200 assert resp.json()["data"]["id"] == str(live.id) @@ -317,7 +323,7 @@ def test_default_returns_draft_over_live( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, ) -> None: @@ -325,7 +331,7 @@ def test_default_returns_draft_over_live( create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True) draft = create_test_flow_version(db, chatbot=chatbot, nodes=nodes) - resp = client.get(flow_url(chatbot.id), headers=headers) + resp = client.get(flow_url(chatbot.id), headers=user_api_key_header) assert resp.json()["data"]["id"] == str(draft.id) assert resp.json()["data"]["version"] == 2 @@ -335,7 +341,7 @@ def test_version_param_returns_that_version( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, ) -> None: @@ -343,7 +349,9 @@ def test_version_param_returns_that_version( first = create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True) create_test_flow_version(db, chatbot=chatbot, nodes=nodes) - resp = client.get(flow_url(chatbot.id), headers=headers, params={"version": 1}) + resp = client.get( + flow_url(chatbot.id), headers=user_api_key_header, params={"version": 1} + ) assert resp.status_code == 200 assert resp.json()["data"]["id"] == str(first.id) @@ -352,7 +360,7 @@ def test_unknown_version_returns_404( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], config: Config, chatbot: Chatbot, ) -> None: @@ -360,7 +368,9 @@ def test_unknown_version_returns_404( db, chatbot=chatbot, nodes=[build_onboarding_node(config_id=config.id)] ) - resp = client.get(flow_url(chatbot.id), headers=headers, params={"version": 7}) + resp = client.get( + flow_url(chatbot.id), headers=user_api_key_header, params={"version": 7} + ) assert resp.status_code == 404 assert resp.json()["error"] == "Flow version 7 not found for this chatbot" @@ -369,14 +379,14 @@ def test_other_projects_chatbot_returns_404( self, client: TestClient, db: Session, - headers: dict[str, str], + user_api_key_header: dict[str, str], foreign_chatbot: Chatbot, ) -> None: create_test_flow_version( db, chatbot=foreign_chatbot, nodes=[build_flow_node(INPUT_NODE_ID, "input")] ) - resp = client.get(flow_url(foreign_chatbot.id), headers=headers) + resp = client.get(flow_url(foreign_chatbot.id), headers=user_api_key_header) assert resp.status_code == 404 assert resp.json()["error"] == "Chatbot not found" @@ -490,9 +500,9 @@ def test_latest_already_live_returns_409( assert resp.json()["error"] == NO_DRAFT_MESSAGE def test_unknown_chatbot_returns_404( - self, client: TestClient, headers: dict[str, str] + self, client: TestClient, user_api_key_header: dict[str, str] ) -> None: - resp = client.post(publish_url(UNKNOWN_CHATBOT_ID), headers=headers) + resp = client.post(publish_url(UNKNOWN_CHATBOT_ID), headers=user_api_key_header) assert resp.status_code == 404 @@ -725,14 +735,14 @@ def test_config_from_another_project_returns_422( def test_non_anthropic_config_returns_422( self, db: Session, - project: Project, + user_api_key: TestAuthContext, chatbot: Chatbot, save: Callable[..., Response], publish: Callable[[], Response], live_v1: dict[str, Any], ) -> None: openai_config = create_test_config( - db, project_id=project.id, use_kaapi_schema=True + db, project_id=user_api_key.project_id, use_kaapi_schema=True ) save([build_onboarding_node(config_id=openai_config.id)]) diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index bda18d808..94e0b8448 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -66,32 +66,36 @@ def chatbot_session(db: Session, live_chatbot: Chatbot) -> ChatbotSession: @pytest.fixture def create_session( - client: TestClient, headers: dict[str, str] + client: TestClient, user_api_key_header: dict[str, str] ) -> Callable[[UUID, dict[str, Any]], Response]: return lambda chatbot_id, payload: client.post( - sessions_url(chatbot_id), headers=headers, json=payload + sessions_url(chatbot_id), headers=user_api_key_header, json=payload ) @pytest.fixture -def get_latest(client: TestClient, headers: dict[str, str]) -> Callable[..., Response]: +def get_latest( + client: TestClient, user_api_key_header: dict[str, str] +) -> Callable[..., Response]: def _get_latest(contact_ref: str, chatbot_id: UUID | None = None) -> Response: params = {"contact_ref": contact_ref} if chatbot_id is not None: params["chatbot_id"] = str(chatbot_id) - return client.get(LATEST_SESSION_URL, headers=headers, params=params) + return client.get( + LATEST_SESSION_URL, headers=user_api_key_header, params=params + ) return _get_latest @pytest.fixture def send_message( - client: TestClient, headers: dict[str, str] + client: TestClient, user_api_key_header: dict[str, str] ) -> Callable[..., Response]: def _send_message(session_id: UUID, **body: str) -> Response: return client.post( message_url(session_id), - headers=headers, + headers=user_api_key_header, json={"webhook_url": WEBHOOK_URL, **body}, ) @@ -444,9 +448,9 @@ def test_other_projects_session_returns_404( assert resp.status_code == 404 def test_missing_contact_ref_returns_422( - self, client: TestClient, headers: dict[str, str] + self, client: TestClient, user_api_key_header: dict[str, str] ) -> None: - resp = client.get(LATEST_SESSION_URL, headers=headers) + resp = client.get(LATEST_SESSION_URL, headers=user_api_key_header) assert resp.status_code == 422 diff --git a/backend/app/tests/services/chatbot/test_config.py b/backend/app/tests/services/chatbot/test_config.py index 2d61e6e59..b8b642d13 100644 --- a/backend/app/tests/services/chatbot/test_config.py +++ b/backend/app/tests/services/chatbot/test_config.py @@ -14,12 +14,7 @@ add_test_config_version, create_test_anthropic_config, ) -from app.tests.utils.test_data import ( - build_anthropic_text_config_blob, - create_test_config, - create_test_project, - create_test_version, -) +from app.tests.utils.test_data import create_test_config, create_test_project def _resolve( @@ -53,18 +48,13 @@ def _overwrite_latest_blob(db: Session, config: Config, blob: dict[str, Any]) -> 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( + config = create_test_anthropic_config(db, model="claude-sonnet-4-6") + add_test_config_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": "adaptive", "display": "summarized"}, - ), + config, + model="claude-opus-5-5", + effort="high", + thinking={"type": "adaptive", "display": "summarized"}, ) model_settings = _resolve(db, config) @@ -81,9 +71,7 @@ def test_enabled_thinking_with_budget_below_max_tokens_is_accepted( self, db: Session ) -> None: thinking = {"type": "enabled", "budget_tokens": 2048} - config = create_test_config( - db, config_blob=build_anthropic_text_config_blob(thinking=thinking) - ) + config = create_test_anthropic_config(db, thinking=thinking) model_settings = _resolve(db, config, max_tokens=4096) @@ -102,17 +90,13 @@ def test_enabled_thinking_with_budget_below_max_tokens_is_accepted( def test_enabled_thinking_budget_not_below_max_tokens_rejected( self, db: Session, thinking: dict[str, Any] ) -> None: - config = create_test_config( - db, config_blob=build_anthropic_text_config_blob(thinking=thinking) - ) + config = create_test_anthropic_config(db, thinking=thinking) with pytest.raises(ChatbotInputError, match="budget_tokens"): _resolve(db, config) def test_explicit_version_and_request_overrides(self, db: Session) -> None: - config = create_test_config( - db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6") - ) + config = create_test_anthropic_config(db, model="claude-sonnet-4-6") add_test_config_version(db, config) model_settings = _resolve( @@ -208,9 +192,7 @@ def test_missing_model_rejected(self, db: Session) -> None: @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) - ) + config = create_test_anthropic_config(db, effort=effort) with pytest.raises(ChatbotInputError, match=f"'{effort}' is not an Anthropic"): _resolve(db, config) diff --git a/backend/app/tests/services/chatbot/test_flow.py b/backend/app/tests/services/chatbot/test_flow.py index 3c0eda6ca..235d0024d 100644 --- a/backend/app/tests/services/chatbot/test_flow.py +++ b/backend/app/tests/services/chatbot/test_flow.py @@ -15,10 +15,6 @@ build_onboarding_node, create_test_anthropic_config, ) -from app.tests.utils.test_data import ( - build_anthropic_text_config_blob, - create_test_config, -) CONFIG_ID = UUID("00000000-0000-0000-0000-00000000c0f1") @@ -200,11 +196,8 @@ def test_problems_are_joined_into_one_error( def test_config_error_is_reported_per_node_without_nested_kaapi_tag( self, db: Session ) -> None: - bad_thinking_config = create_test_config( - db, - config_blob=build_anthropic_text_config_blob( - thinking={"type": "enabled", "budget_tokens": 999_999} - ), + bad_thinking_config = create_test_anthropic_config( + db, thinking={"type": "enabled", "budget_tokens": 999_999} ) with pytest.raises(ChatbotFlowValidationError) as exc_info: diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index 3e759cd09..1bd209926 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -152,21 +152,23 @@ def patch_chat_model( def create_test_anthropic_config( - db: Session, *, project_id: int | None = None + db: Session, *, project_id: int | None = None, **params: Any ) -> Config: return create_test_config( - db, project_id=project_id, config_blob=build_anthropic_text_config_blob() + db, + project_id=project_id, + config_blob=build_anthropic_text_config_blob(**params), ) def add_test_config_version( - db: Session, config: Config, *, model: str = NEWER_CONFIG_MODEL + db: Session, config: Config, *, model: str = NEWER_CONFIG_MODEL, **params: Any ) -> None: create_test_version( db, config_id=config.id, project_id=config.project_id, - config_blob=build_anthropic_text_config_blob(model=model), + config_blob=build_anthropic_text_config_blob(model=model, **params), ) From 3f0a7433c2ade7b9dd94441ad000058074184b14 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:04:56 +0530 Subject: [PATCH 20/25] fix: get the antropic api key via credentials --- backend/app/api/docs/chatbot/publish_flow.md | 2 +- backend/app/services/chatbot/config.py | 34 ++++++++++++++++++-- backend/app/services/chatbot/flow.py | 13 ++++++++ backend/app/services/chatbot/session.py | 1 + backend/app/services/chatbot/utils.py | 10 +++--- docs/wiki/modules/chatbot.md | 2 +- 6 files changed, 53 insertions(+), 9 deletions(-) diff --git a/backend/app/api/docs/chatbot/publish_flow.md b/backend/app/api/docs/chatbot/publish_flow.md index 15ae16f7a..29079d486 100644 --- a/backend/app/api/docs/chatbot/publish_flow.md +++ b/backend/app/api/docs/chatbot/publish_flow.md @@ -1,5 +1,5 @@ Publish the chatbot's draft so new sessions run on it. Returns the now-live version. -The draft is validated strictly: node ids are unique; at most one `input` and one `output` node; every edge's `source` and `target` is a node in the flow; every agent node has `data.config_id` and a non-empty `data.prompt`, and its config resolves to an Anthropic text config in this project with a model set (and a valid thinking budget). Only `onboarding` agent nodes can run for now — `coordinator` and `rag` nodes are rejected as not supported yet. The flow needs an entry node: with no edges it must contain exactly one agent node; otherwise the `input` node must have exactly one outgoing edge, leading to an agent node. A node without `data.config_version` gets the config's latest version written into the stored node, so later config edits don't change the published flow. +The draft is validated strictly: node ids are unique; at most one `input` and one `output` node; every edge's `source` and `target` is a node in the flow; every agent node has `data.config_id` and a non-empty `data.prompt`, and its config resolves to an Anthropic text config in this project with a model set (and a valid thinking budget). The project must have Anthropic credentials configured, since agent nodes call Anthropic with the project's own key. Only `onboarding` agent nodes can run for now — `coordinator` and `rag` nodes are rejected as not supported yet. The flow needs an entry node: with no edges it must contain exactly one agent node; otherwise the `input` node must have exactly one outgoing edge, leading to an agent node. A node without `data.config_version` gets the config's latest version written into the stored node, so later config edits don't change the published flow. The previous live version (if any) stops being live in the same transaction. Sessions already running stay pinned to the version they started on. Returns 404 for an unknown chatbot, 409 if there is no draft to publish (save the flow first) or on a concurrent change, and 422 listing every validation problem. diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 86fb45815..994f30cde 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -1,3 +1,5 @@ +import logging +from dataclasses import replace from typing import Any, get_args from uuid import UUID @@ -6,6 +8,7 @@ from sqlmodel import Session from app.crud.config import ConfigVersionCrud +from app.crud.credentials import get_provider_credential from app.models import ConfigVersion from app.models.llm.constants import Provider from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig @@ -18,8 +21,30 @@ default_thinking, ) +logger = logging.getLogger(__name__) + _ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort) THINKING_TYPE_ENABLED = "enabled" +CREDENTIAL_API_KEY = "api_key" +ANTHROPIC_CREDENTIALS_MISSING_MESSAGE = ( + "[KAAPI] Anthropic credentials are not configured for this project; add an " + "Anthropic API key in the project's credentials." +) + + +def get_anthropic_api_key( + *, session: Session, organization_id: int, project_id: int +) -> str: + """The project's Anthropic API key; the chatbot never falls back to a platform key.""" + credentials = get_provider_credential( + session=session, + org_id=organization_id, + project_id=project_id, + provider=Provider.ANTHROPIC.value, + ) + if not isinstance(credentials, dict) or not credentials.get(CREDENTIAL_API_KEY): + raise ChatbotInputError(ANTHROPIC_CREDENTIALS_MISSING_MESSAGE) + return credentials[CREDENTIAL_API_KEY] def get_chatbot_config_version( @@ -68,22 +93,27 @@ def _validate_thinking_budget(thinking: dict[str, Any], *, max_tokens: int) -> N def resolve_chatbot_model_settings( *, session: Session, + organization_id: int, project_id: int, config_id: UUID, config_version: int | None, max_tokens: int | None, history_token_limit: int | None, ) -> ChatbotModelSettings: - """Turn a saved config (+ request overrides) into chat-model settings.""" + """Turn a saved config (+ request overrides) and the project's Anthropic key into chat-model settings.""" version_row = get_chatbot_config_version( session=session, project_id=project_id, config_id=config_id, config_version=config_version, ) - return build_chatbot_model_settings( + model_settings = build_chatbot_model_settings( version_row, max_tokens=max_tokens, history_token_limit=history_token_limit ) + api_key = get_anthropic_api_key( + session=session, organization_id=organization_id, project_id=project_id + ) + return replace(model_settings, api_key=api_key) def build_chatbot_model_settings( diff --git a/backend/app/services/chatbot/flow.py b/backend/app/services/chatbot/flow.py index 2d213eda1..a24d5bed4 100644 --- a/backend/app/services/chatbot/flow.py +++ b/backend/app/services/chatbot/flow.py @@ -23,6 +23,7 @@ ) from app.services.chatbot.config import ( build_chatbot_model_settings, + get_anthropic_api_key, get_chatbot_config_version, ) from app.services.chatbot.exceptions import ( @@ -248,6 +249,7 @@ def _pin_agent_node_config( def validate_flow_for_publish( *, session: Session, + organization_id: int, project_id: int, nodes: list[ChatbotFlowNode], edges: list[ChatbotFlowEdge], @@ -255,6 +257,15 @@ def validate_flow_for_publish( """Strict publish-time checks; returns the nodes with every agent config_version pinned.""" problems = _list_structural_problems(nodes=nodes, edges=edges) + # Agent nodes call Anthropic with the project's own key, so a live flow needs one. + if any(node.type in AGENT_NODE_TYPES for node in nodes): + try: + get_anthropic_api_key( + session=session, organization_id=organization_id, project_id=project_id + ) + except ChatbotInputError as e: + problems.append(str(e).removeprefix(KAAPI_ERROR_TAG).strip()) + pinned_nodes: list[ChatbotFlowNode] = [] for node in nodes: if node.type not in AGENT_NODE_TYPES: @@ -353,6 +364,7 @@ def save_chatbot_flow_draft( # goes live directly; it is validated like a publish so live is always runnable. pinned_nodes = validate_flow_for_publish( session=session, + organization_id=organization_id, project_id=project_id, nodes=flow.nodes, edges=flow.edges, @@ -400,6 +412,7 @@ def publish_chatbot_flow( pinned_nodes = validate_flow_for_publish( session=session, + organization_id=organization_id, project_id=project_id, nodes=parse_flow_nodes(draft.nodes), edges=parse_flow_edges(draft.edges), diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index dc0123c47..6f54ef0e6 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -270,6 +270,7 @@ def process_chatbot_session_message( ) model_settings: ChatbotModelSettings = resolve_chatbot_model_settings( session=session, + organization_id=organization_id, project_id=project_id, config_id=turn_node.config_id, config_version=turn_node.config_version, diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py index d4831b46f..764f261ca 100644 --- a/backend/app/services/chatbot/utils.py +++ b/backend/app/services/chatbot/utils.py @@ -5,8 +5,6 @@ from langchain_anthropic import ChatAnthropic -from app.core.config import settings - AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"] DEFAULT_MAX_OUTPUT_TOKENS = 1024 @@ -27,21 +25,23 @@ class ChatbotModelSettings: reasoning_effort: AnthropicEffort | None = None thinking: dict[str, Any] = field(default_factory=default_thinking) history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT + api_key: str = field(default="", repr=False, compare=False) def get_chat_model(model_settings: ChatbotModelSettings) -> ChatAnthropic: """LangChain's provider abstraction, so swapping providers later is a config change here, not a rewrite of the node. """ - if not settings.ANTHROPIC_API_KEY: + if not model_settings.api_key: raise RuntimeError( - "[get_chat_model] ANTHROPIC_API_KEY is not set; export it or set it in .env" + "[get_chat_model] model_settings has no api_key; build it with " + "resolve_chatbot_model_settings so the project's Anthropic credentials are loaded" ) kwargs: dict[str, Any] = { "model": model_settings.model, "max_tokens": model_settings.max_tokens, "thinking": model_settings.thinking, - "anthropic_api_key": settings.ANTHROPIC_API_KEY, + "anthropic_api_key": model_settings.api_key, } if model_settings.reasoning_effort is not None: kwargs["reasoning_effort"] = model_settings.reasoning_effort diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index f385f07b1..655357520 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -53,6 +53,6 @@ All paths relative to `backend/app/`. - Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_id", "node_id", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. ## External -- Anthropic via `langchain-anthropic` (`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. +- Anthropic via `langchain-anthropic`, using the **project's own Anthropic credentials** (`services/chatbot/config.py:get_anthropic_api_key`; no platform `ANTHROPIC_API_KEY` fallback — missing credentials fail publish/first save with 422 and a turn with a failure webhook) — two calls per turn once the user has spoken (extraction + reply), plus one summary call when history exceeds the token budget. - Caller webhook (`send_callback`, HMAC-signed with the project webhook secret). - kaapi-frontend React Flow editor reads/writes the flow JSON as-is. From 46d6533f90f4ebb8e7f487f3e778074d2c88387f Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:33:42 +0530 Subject: [PATCH 21/25] fix: update the updated at --- backend/app/services/chatbot/flow.py | 18 +- .../app/tests/api/routes/chatbot/test_flow.py | 189 ++++++++++++++++++ .../app/tests/services/chatbot/test_config.py | 63 +++++- .../app/tests/services/chatbot/test_flow.py | 51 ++++- .../tests/services/chatbot/test_session.py | 24 +++ .../app/tests/services/chatbot/test_utils.py | 40 ++-- backend/app/tests/utils/chatbot.py | 46 ++++- 7 files changed, 407 insertions(+), 24 deletions(-) diff --git a/backend/app/services/chatbot/flow.py b/backend/app/services/chatbot/flow.py index a24d5bed4..99fd1bb07 100644 --- a/backend/app/services/chatbot/flow.py +++ b/backend/app/services/chatbot/flow.py @@ -4,6 +4,8 @@ from fastapi import HTTPException from sqlmodel import Session +from app.core.util import now + from app.crud.chatbot import ( create_chatbot_flow_version, get_chatbot, @@ -14,6 +16,7 @@ ) from app.models import ( AGENT_NODE_TYPES, + Chatbot, ChatbotFlowEdge, ChatbotFlowNode, ChatbotFlowNodeTypeEnum, @@ -291,7 +294,7 @@ def _get_chatbot_or_raise( organization_id: int, project_id: int, for_update: bool, -) -> None: +) -> Chatbot: chatbot = get_chatbot( session, chatbot_id=chatbot_id, @@ -301,6 +304,12 @@ def _get_chatbot_or_raise( ) if chatbot is None: raise ChatbotNotFoundError(CHATBOT_NOT_FOUND_MESSAGE) + return chatbot + + +def _mark_chatbot_edited(session: Session, chatbot: Chatbot) -> None: + chatbot.updated_at = now() + session.add(chatbot) def get_chatbot_flow( @@ -348,7 +357,7 @@ def save_chatbot_flow_draft( draft, or start a new draft when the highest version is live (live versions are immutable). """ # The row lock serializes draft saves and publishes of the same chatbot. - _get_chatbot_or_raise( + chatbot = _get_chatbot_or_raise( session=session, chatbot_id=chatbot_id, organization_id=organization_id, @@ -369,6 +378,7 @@ def save_chatbot_flow_draft( nodes=flow.nodes, edges=flow.edges, ) + _mark_chatbot_edited(session, chatbot) return create_chatbot_flow_version( session, chatbot_id=chatbot_id, @@ -378,6 +388,7 @@ def save_chatbot_flow_draft( is_live=True, ) + _mark_chatbot_edited(session, chatbot) if not latest.is_live: return update_chatbot_flow_version_graph( session, flow_version=latest, nodes=nodes, edges=edges @@ -399,7 +410,7 @@ def publish_chatbot_flow( organization_id: int, project_id: int, ) -> ChatbotFlowVersion: - _get_chatbot_or_raise( + chatbot = _get_chatbot_or_raise( session=session, chatbot_id=chatbot_id, organization_id=organization_id, @@ -417,6 +428,7 @@ def publish_chatbot_flow( nodes=parse_flow_nodes(draft.nodes), edges=parse_flow_edges(draft.edges), ) + _mark_chatbot_edited(session, chatbot) return publish_chatbot_flow_version( session, flow_version=draft, nodes=dump_flow_nodes(pinned_nodes) ) diff --git a/backend/app/tests/api/routes/chatbot/test_flow.py b/backend/app/tests/api/routes/chatbot/test_flow.py index 49e630517..06c10b9f8 100644 --- a/backend/app/tests/api/routes/chatbot/test_flow.py +++ b/backend/app/tests/api/routes/chatbot/test_flow.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from datetime import datetime from typing import Any from uuid import UUID @@ -10,6 +11,7 @@ from app.models import Chatbot, Config from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( + CHATBOTS_URL, ONBOARDING_NODE_ID, UNKNOWN_CHATBOT_ID, add_test_config_version, @@ -17,6 +19,7 @@ build_flow_node, build_onboarding_node, create_test_anthropic_config, + create_test_chatbot, create_test_flow_version, flow_url, list_stored_flow_versions, @@ -28,6 +31,7 @@ INVALID_FLOW_PREFIX = "[KAAPI] Chatbot flow is invalid: " NO_DRAFT_MESSAGE = "[KAAPI] Chatbot has no draft flow to publish; save the flow first." +MISSING_CREDENTIALS = "Anthropic credentials are not configured for this project" INPUT_NODE_ID = "input-1" OUTPUT_NODE_ID = "output-1" @@ -55,6 +59,14 @@ def publish( return lambda: client.post(publish_url(chatbot.id), headers=user_api_key_header) +@pytest.fixture +def uncredentialed_config(db: Session, user_api_key: TestAuthContext) -> Config: + """An Anthropic config in the caller's project, which has no Anthropic credentials.""" + return create_test_anthropic_config( + db, project_id=user_api_key.project_id, with_anthropic_credentials=False + ) + + @pytest.fixture def live_v1(config: Config, save: Callable[..., Response]) -> dict[str, Any]: """A valid first save, which goes live as v1; later saves are drafts.""" @@ -781,3 +793,180 @@ def test_all_problems_are_reported_together( assert "node 'router' has type 'coordinator'" in error assert f"node '{ONBOARDING_NODE_ID}' is missing data.prompt" in error assert error.count(INVALID_FLOW_PREFIX) == 1 + + +class TestMissingAnthropicCredentials: + def test_first_save_returns_422_and_stores_nothing( + self, + client: TestClient, + db: Session, + user_api_key_header: dict[str, str], + uncredentialed_config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + ) -> None: + resp = save([build_onboarding_node(config_id=uncredentialed_config.id)]) + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert MISSING_CREDENTIALS in error + assert error.count("[KAAPI]") == 1 + assert list_stored_flow_versions(db, chatbot) == [] + assert ( + client.get(flow_url(chatbot.id), headers=user_api_key_header).status_code + == 404 + ) + + def test_publish_returns_422_and_stays_draft( + self, + db: Session, + uncredentialed_config: Config, + chatbot: Chatbot, + publish: Callable[[], Response], + ) -> None: + # Stored directly: the credentials were removed after the flow went live. + nodes = [build_onboarding_node(config_id=uncredentialed_config.id)] + create_test_flow_version(db, chatbot=chatbot, nodes=nodes, is_live=True) + create_test_flow_version(db, chatbot=chatbot, nodes=nodes) + + resp = publish() + + assert resp.status_code == 422 + error = resp.json()["error"] + assert error.startswith(INVALID_FLOW_PREFIX) + assert MISSING_CREDENTIALS in error + live, draft = list_stored_flow_versions(db, chatbot) + assert (live.version, live.is_live) == (1, True) + assert (draft.version, draft.is_live) == (2, False) + + +def _stored_updated_at(db: Session, chatbot: Chatbot) -> datetime: + db.expire_all() + stored = db.get(Chatbot, chatbot.id) + assert stored is not None + return stored.updated_at + + +def _newest_chatbot_id(client: TestClient, headers: dict[str, str]) -> str: + resp = client.get(CHATBOTS_URL, headers=headers, params={"limit": 1}) + assert resp.status_code == 200 + return resp.json()["data"][0]["id"] + + +SaveBranchSetup = Callable[[Session, Chatbot, Config, Callable[..., Response]], None] + + +def _no_flow_yet( + db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response] +) -> None: + pass + + +def _live_and_draft( + db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response] +) -> None: + assert save([build_onboarding_node(config_id=config.id)]).status_code == 200 + assert ( + save([build_onboarding_node(config_id=config.id, prompt="Draft.")]).status_code + == 200 + ) + + +def _live_only( + db: Session, chatbot: Chatbot, config: Config, save: Callable[..., Response] +) -> None: + create_test_flow_version( + db, + chatbot=chatbot, + nodes=[build_onboarding_node(config_id=config.id)], + is_live=True, + ) + + +SAVE_BRANCH_SETUPS: dict[str, SaveBranchSetup] = { + "first-live-save": _no_flow_yet, + "draft-overwrite": _live_and_draft, + "new-draft-after-live": _live_only, +} + + +class TestFlowEditsBumpChatbotUpdatedAt: + @pytest.mark.parametrize( + "branch", sorted(SAVE_BRANCH_SETUPS), ids=sorted(SAVE_BRANCH_SETUPS) + ) + def test_save_moves_chatbot_to_top_of_list( + self, + client: TestClient, + db: Session, + user_api_key_header: dict[str, str], + user_api_key: TestAuthContext, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + branch: str, + ) -> None: + SAVE_BRANCH_SETUPS[branch](db, chatbot, config, save) + newer = create_test_chatbot(db, project=user_api_key.project) + assert _newest_chatbot_id(client, user_api_key_header) == str(newer.id) + before = _stored_updated_at(db, chatbot) + + resp = save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")]) + + assert resp.status_code == 200 + assert _stored_updated_at(db, chatbot) > before + assert _newest_chatbot_id(client, user_api_key_header) == str(chatbot.id) + + def test_publish_moves_chatbot_to_top_of_list( + self, + client: TestClient, + db: Session, + user_api_key_header: dict[str, str], + user_api_key: TestAuthContext, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + save([build_onboarding_node(config_id=config.id, prompt="Ask grade.")]) + newer = create_test_chatbot(db, project=user_api_key.project) + assert _newest_chatbot_id(client, user_api_key_header) == str(newer.id) + before = _stored_updated_at(db, chatbot) + + resp = publish() + + assert resp.status_code == 200 + assert _stored_updated_at(db, chatbot) > before + assert _newest_chatbot_id(client, user_api_key_header) == str(chatbot.id) + + def test_rejected_first_save_leaves_updated_at_unchanged( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + ) -> None: + before = _stored_updated_at(db, chatbot) + + resp = save([build_onboarding_node(config_id=config.id, prompt=None)]) + + assert resp.status_code == 422 + assert _stored_updated_at(db, chatbot) == before + + def test_rejected_publish_leaves_updated_at_unchanged( + self, + db: Session, + config: Config, + chatbot: Chatbot, + save: Callable[..., Response], + publish: Callable[[], Response], + live_v1: dict[str, Any], + ) -> None: + save([build_onboarding_node(config_id=config.id, prompt=None)]) + before = _stored_updated_at(db, chatbot) + + resp = publish() + + assert resp.status_code == 422 + assert _stored_updated_at(db, chatbot) == before diff --git a/backend/app/tests/services/chatbot/test_config.py b/backend/app/tests/services/chatbot/test_config.py index b8b642d13..d02fb4fc4 100644 --- a/backend/app/tests/services/chatbot/test_config.py +++ b/backend/app/tests/services/chatbot/test_config.py @@ -6,16 +6,22 @@ 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.models import Config, Project +from app.services.chatbot.config import ( + get_anthropic_api_key, + resolve_chatbot_model_settings, +) from app.services.chatbot.exceptions import ChatbotInputError from app.services.chatbot.utils import ChatbotModelSettings from app.tests.utils.chatbot import ( add_test_config_version, create_test_anthropic_config, + create_test_anthropic_credential, ) from app.tests.utils.test_data import create_test_config, create_test_project +MISSING_CREDENTIALS = "Anthropic credentials are not configured for this project" + def _resolve( db: Session, @@ -25,8 +31,11 @@ def _resolve( max_tokens: int | None = None, history_token_limit: int | None = None, ) -> ChatbotModelSettings: + project = db.get(Project, config.project_id) + assert project is not None return resolve_chatbot_model_settings( session=db, + organization_id=project.organization_id, project_id=config.project_id, config_id=config.id, config_version=config_version, @@ -46,6 +55,38 @@ def _overwrite_latest_blob(db: Session, config: Config, blob: dict[str, Any]) -> db.commit() +def _project_api_key(db: Session, project: Project) -> str: + return get_anthropic_api_key( + session=db, organization_id=project.organization_id, project_id=project.id + ) + + +class TestGetAnthropicApiKey: + def test_returns_the_projects_stored_key(self, db: Session) -> None: + project = create_test_project(db) + create_test_anthropic_credential( + db, project_id=project.id, api_key="sk-ant-project-key" + ) + + assert _project_api_key(db, project) == "sk-ant-project-key" + + def test_project_without_anthropic_credentials_raises(self, db: Session) -> None: + # A credential in another project of the same org must not leak across. + project = create_test_project(db) + sibling = create_test_project(db, organization_id=project.organization_id) + create_test_anthropic_credential(db, project_id=sibling.id) + + with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS): + _project_api_key(db, project) + + def test_empty_stored_key_raises(self, db: Session) -> None: + project = create_test_project(db) + create_test_anthropic_credential(db, project_id=project.id, api_key="") + + with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS): + _project_api_key(db, project) + + class TestResolveChatbotModelSettings: def test_latest_version_with_server_defaults(self, db: Session) -> None: config = create_test_anthropic_config(db, model="claude-sonnet-4-6") @@ -67,6 +108,22 @@ def test_latest_version_with_server_defaults(self, db: Session) -> None: history_token_limit=4000, ) + def test_settings_carry_the_projects_anthropic_key(self, db: Session) -> None: + config = create_test_anthropic_config(db, with_anthropic_credentials=False) + create_test_anthropic_credential( + db, project_id=config.project_id, api_key="sk-ant-project-key" + ) + + model_settings = _resolve(db, config) + + assert model_settings.api_key == "sk-ant-project-key" + + def test_project_without_anthropic_credentials_raises(self, db: Session) -> None: + config = create_test_anthropic_config(db, with_anthropic_credentials=False) + + with pytest.raises(ChatbotInputError, match=MISSING_CREDENTIALS): + _resolve(db, config) + def test_enabled_thinking_with_budget_below_max_tokens_is_accepted( self, db: Session ) -> None: @@ -124,6 +181,7 @@ def test_config_from_other_project_raises_404(self, db: Session) -> None: with pytest.raises(HTTPException) as exc_info: resolve_chatbot_model_settings( session=db, + organization_id=other_project.organization_id, project_id=other_project.id, config_id=config.id, config_version=None, @@ -139,6 +197,7 @@ def test_unknown_config_raises_404(self, db: Session) -> None: with pytest.raises(HTTPException) as exc_info: resolve_chatbot_model_settings( session=db, + organization_id=project.organization_id, project_id=project.id, config_id=uuid4(), config_version=None, diff --git a/backend/app/tests/services/chatbot/test_flow.py b/backend/app/tests/services/chatbot/test_flow.py index 235d0024d..1bce0facb 100644 --- a/backend/app/tests/services/chatbot/test_flow.py +++ b/backend/app/tests/services/chatbot/test_flow.py @@ -4,7 +4,7 @@ import pytest from sqlmodel import Session -from app.models import ChatbotFlowEdge, ChatbotFlowNode, Config +from app.models import ChatbotFlowEdge, ChatbotFlowNode, Config, Project from app.services.chatbot.exceptions import ChatbotFlowValidationError from app.services.chatbot.flow import resolve_entry_node, validate_flow_for_publish from app.tests.utils.chatbot import ( @@ -31,6 +31,12 @@ def _onboarding( ) +def _organization_id(db: Session, config: Config) -> int: + project = db.get(Project, config.project_id) + assert project is not None + return project.organization_id + + def _edge(edge_id: str, source: str, target: str) -> ChatbotFlowEdge: return ChatbotFlowEdge.model_validate(build_flow_edge(edge_id, source, target)) @@ -146,6 +152,7 @@ def test_returns_nodes_with_missing_config_version_pinned_to_latest( pinned = validate_flow_for_publish( session=db, + organization_id=_organization_id(db, config), project_id=config.project_id, nodes=[input_node, draft_node], edges=[_edge("e1", "in", ONBOARDING_NODE_ID)], @@ -165,6 +172,7 @@ def test_explicit_config_version_is_kept(self, db: Session, config: Config) -> N [pinned] = validate_flow_for_publish( session=db, + organization_id=_organization_id(db, config), project_id=config.project_id, nodes=[_onboarding(config_id=config.id, config_version=1)], edges=[], @@ -184,7 +192,11 @@ def test_problems_are_joined_into_one_error( with pytest.raises(ChatbotFlowValidationError) as exc_info: validate_flow_for_publish( - session=db, project_id=config.project_id, nodes=nodes, edges=[] + session=db, + organization_id=_organization_id(db, config), + project_id=config.project_id, + nodes=nodes, + edges=[], ) assert str(exc_info.value) == ( @@ -203,6 +215,7 @@ def test_config_error_is_reported_per_node_without_nested_kaapi_tag( with pytest.raises(ChatbotFlowValidationError) as exc_info: validate_flow_for_publish( session=db, + organization_id=_organization_id(db, bad_thinking_config), project_id=bad_thinking_config.project_id, nodes=[_onboarding(config_id=bad_thinking_config.id)], edges=[], @@ -220,6 +233,7 @@ def test_config_of_another_project_is_rejected( with pytest.raises(ChatbotFlowValidationError) as exc_info: validate_flow_for_publish( session=db, + organization_id=_organization_id(db, config), project_id=config.project_id, nodes=[_onboarding(config_id=other_project_config.id)], edges=[], @@ -234,7 +248,38 @@ def test_structurally_valid_flow_without_entry_raises( with pytest.raises(ChatbotFlowValidationError) as exc_info: validate_flow_for_publish( - session=db, project_id=config.project_id, nodes=nodes, edges=[] + session=db, + organization_id=_organization_id(db, config), + project_id=config.project_id, + nodes=nodes, + edges=[], ) assert "exactly one agent node (found 2)" in str(exc_info.value) + + def test_missing_anthropic_credentials_joins_the_other_problems( + self, db: Session + ) -> None: + uncredentialed = create_test_anthropic_config( + db, with_anthropic_credentials=False + ) + nodes = [ + _onboarding(config_id=uncredentialed.id), + _node("out-a", "output"), + _node("out-b", "output"), + ] + + with pytest.raises(ChatbotFlowValidationError) as exc_info: + validate_flow_for_publish( + session=db, + organization_id=_organization_id(db, uncredentialed), + project_id=uncredentialed.project_id, + nodes=nodes, + edges=[], + ) + + message = str(exc_info.value) + assert message.startswith("[KAAPI] Chatbot flow is invalid: ") + assert "at most 1 output node allowed (found 2)" in message + assert "Anthropic credentials are not configured for this project" in message + assert message.count("[KAAPI]") == 1 diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py index 3251fd4e9..295a8a8fc 100644 --- a/backend/app/tests/services/chatbot/test_session.py +++ b/backend/app/tests/services/chatbot/test_session.py @@ -37,6 +37,7 @@ add_test_chatbot_messages, add_test_config_version, build_onboarding_node, + create_test_anthropic_config, create_test_chatbot, create_test_chatbot_session, create_test_flow_version, @@ -377,6 +378,29 @@ def test_non_anthropic_config_posts_failure_webhook( callbacks, chatbot_session, "only supports configs" ) + def test_missing_anthropic_credentials_posts_failure_webhook( + self, + db: Session, + callbacks: CallbackRecorder, + unused_chat_model: ScriptedChatModel, + ) -> None: + # The flow went live while credentials existed; the key is gone by turn time. + uncredentialed = create_test_anthropic_config( + db, with_anthropic_credentials=False + ) + chatbot, _ = create_test_live_chatbot(db, config=uncredentialed) + chatbot_session = create_test_chatbot_session(db, chatbot=chatbot) + + _process(chatbot_session, db) + + assert unused_chat_model.calls == [] + assert _transcript(db, chatbot_session) == [] + _assert_single_failure_webhook( + callbacks, + chatbot_session, + "Anthropic credentials are not configured for this project", + ) + def test_provider_error_posts_failure_webhook_and_stores_nothing( self, db: Session, diff --git a/backend/app/tests/services/chatbot/test_utils.py b/backend/app/tests/services/chatbot/test_utils.py index 512fca063..21b706742 100644 --- a/backend/app/tests/services/chatbot/test_utils.py +++ b/backend/app/tests/services/chatbot/test_utils.py @@ -8,6 +8,9 @@ get_chat_model, ) +PROJECT_API_KEY = "sk-ant-project" +PLATFORM_API_KEY = "sk-ant-platform" + class TestChatbotModelSettings: def test_defaults(self) -> None: @@ -27,23 +30,34 @@ def test_thinking_default_is_not_shared_between_instances(self) -> None: assert second.thinking == {"type": "adaptive"} assert default_thinking() == {"type": "adaptive"} + def test_repr_hides_the_api_key(self) -> None: + model_settings = ChatbotModelSettings( + model="claude-sonnet-4-5", api_key=PROJECT_API_KEY + ) -class TestGetChatModel: - def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "") + assert PROJECT_API_KEY not in repr(model_settings) + assert "claude-sonnet-4-5" in repr(model_settings) - 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") +class TestGetChatModel: + @pytest.fixture(autouse=True) + def platform_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + # A platform/env key must never be used in place of the project's key. + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", PLATFORM_API_KEY) + monkeypatch.setenv("ANTHROPIC_API_KEY", PLATFORM_API_KEY) + + def test_missing_api_key_raises(self) -> None: + with pytest.raises(RuntimeError, match="model_settings has no api_key"): + get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5")) + def test_builds_model_from_settings(self) -> None: chat_model = get_chat_model( ChatbotModelSettings( model="claude-sonnet-4-5", max_tokens=512, reasoning_effort="high", thinking={"type": "enabled", "budget_tokens": 2048}, + api_key=PROJECT_API_KEY, ) ) @@ -52,14 +66,12 @@ def test_builds_model_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> No 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") + assert chat_model.anthropic_api_key.get_secret_value() == PROJECT_API_KEY - chat_model = get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5")) + def test_omits_reasoning_effort_when_unset(self) -> None: + chat_model = get_chat_model( + ChatbotModelSettings(model="claude-sonnet-4-5", api_key=PROJECT_API_KEY) + ) assert chat_model.reasoning_effort is None assert chat_model.thinking == {"type": "adaptive"} diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index 1bd209926..7a9b68ba2 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -12,6 +12,7 @@ from sqlmodel import Session, select from app.core.config import settings +from app.crud import set_creds_for_org from app.crud.chatbot import ( create_chatbot, create_chatbot_flow_version, @@ -21,6 +22,7 @@ get_live_chatbot_flow_version, publish_chatbot_flow_version, ) +from app.crud.credentials import get_provider_credential from app.models import ( Chatbot, ChatbotFlowNodeTypeEnum, @@ -29,8 +31,10 @@ ChatbotSession, ChatbotSessionMessage, Config, + CredsCreate, Project, ) +from app.models.provider_credentials import Provider from app.services.chatbot.agents.onboarding import graph as onboarding_graph from app.services.chatbot.agents.onboarding.types import ( ExtractedField, @@ -151,14 +155,52 @@ def patch_chat_model( return chat_model +def create_test_anthropic_credential( + db: Session, *, project_id: int, api_key: str | None = None +) -> str: + """The project's stored Anthropic key, storing one first if the project has none.""" + project = db.get(Project, project_id) + assert project is not None + existing = get_provider_credential( + session=db, + org_id=project.organization_id, + project_id=project_id, + provider=Provider.ANTHROPIC.value, + ) + if isinstance(existing, dict): + return existing["api_key"] + + if api_key is None: + api_key = f"sk-ant-{random_lower_string()}" + set_creds_for_org( + session=db, + creds_add=CredsCreate( + is_active=True, + credential={Provider.ANTHROPIC.value: {"api_key": api_key}}, + ), + organization_id=project.organization_id, + project_id=project_id, + ) + return api_key + + def create_test_anthropic_config( - db: Session, *, project_id: int | None = None, **params: Any + db: Session, + *, + project_id: int | None = None, + with_anthropic_credentials: bool = True, + **params: Any, ) -> Config: - return create_test_config( + """An Anthropic config; its project also gets Anthropic credentials unless opted out, + since the chatbot only runs with the project's own key.""" + config = create_test_config( db, project_id=project_id, config_blob=build_anthropic_text_config_blob(**params), ) + if with_anthropic_credentials: + create_test_anthropic_credential(db, project_id=config.project_id) + return config def add_test_config_version( From 4cf240da3c455394f797584e4e28f26ad5b7c151 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:26:30 +0530 Subject: [PATCH 22/25] fix: remove the duplicate code --- backend/app/api/routes/chatbot/chatbot.py | 24 +++++++++---------- backend/app/api/routes/chatbot/errors.py | 28 ++++++++++++++++++++++ backend/app/api/routes/chatbot/flow.py | 29 +++++++---------------- backend/app/api/routes/chatbot/session.py | 15 +++--------- backend/app/services/chatbot/flow.py | 11 +++++---- backend/app/services/chatbot/session.py | 23 +++++------------- 6 files changed, 63 insertions(+), 67 deletions(-) create mode 100644 backend/app/api/routes/chatbot/errors.py diff --git a/backend/app/api/routes/chatbot/chatbot.py b/backend/app/api/routes/chatbot/chatbot.py index ce2c237e3..5727748c8 100644 --- a/backend/app/api/routes/chatbot/chatbot.py +++ b/backend/app/api/routes/chatbot/chatbot.py @@ -1,19 +1,19 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Depends, Path, Query from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission +from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception from app.crud.chatbot import ( create_chatbot, delete_chatbot, - get_chatbot, list_chatbots, update_chatbot, ) from app.models import Chatbot, ChatbotCreate, ChatbotPublic, ChatbotUpdate, Message -from app.services.chatbot.flow import CHATBOT_NOT_FOUND_MESSAGE +from app.services.chatbot.flow import get_chatbot_or_raise from app.utils import APIResponse, load_description CHATBOT_DELETED_MESSAGE = "Chatbot deleted successfully" @@ -24,15 +24,15 @@ def _get_chatbot_or_404( session: SessionDep, auth_context: AuthContextDep, chatbot_id: UUID ) -> Chatbot: - chatbot = get_chatbot( - session, - chatbot_id=chatbot_id, - organization_id=auth_context.organization_.id, - project_id=auth_context.project_.id, - ) - if chatbot is None: - raise HTTPException(status_code=404, detail=CHATBOT_NOT_FOUND_MESSAGE) - return chatbot + try: + return get_chatbot_or_raise( + session=session, + chatbot_id=chatbot_id, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + ) + except CHATBOT_SERVICE_ERRORS as e: + raise to_http_exception(e) from e @router.post( diff --git a/backend/app/api/routes/chatbot/errors.py b/backend/app/api/routes/chatbot/errors.py new file mode 100644 index 000000000..467bdbad5 --- /dev/null +++ b/backend/app/api/routes/chatbot/errors.py @@ -0,0 +1,28 @@ +from fastapi import HTTPException + +from app.services.chatbot.exceptions import ( + ChatbotFlowConflictError, + ChatbotFlowNotFoundError, + ChatbotFlowValidationError, + ChatbotNotFoundError, + ChatbotSessionConflictError, +) + +_STATUS_BY_CHATBOT_ERROR: dict[type[Exception], int] = { + ChatbotNotFoundError: 404, + ChatbotFlowNotFoundError: 404, + ChatbotFlowConflictError: 409, + ChatbotSessionConflictError: 409, + ChatbotFlowValidationError: 422, +} +CHATBOT_SERVICE_ERRORS: tuple[type[Exception], ...] = tuple(_STATUS_BY_CHATBOT_ERROR) + + +def to_http_exception(error: Exception) -> HTTPException: + """Map a chatbot service error to the HTTP status every chatbot route uses for it.""" + for error_type, status_code in _STATUS_BY_CHATBOT_ERROR.items(): + if isinstance(error, error_type): + return HTTPException(status_code=status_code, detail=str(error)) + raise TypeError( + f"[to_http_exception] Unmapped chatbot error: {type(error).__name__}" + ) diff --git a/backend/app/api/routes/chatbot/flow.py b/backend/app/api/routes/chatbot/flow.py index 982205dd0..5dafaf715 100644 --- a/backend/app/api/routes/chatbot/flow.py +++ b/backend/app/api/routes/chatbot/flow.py @@ -1,17 +1,12 @@ from typing import Annotated from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Path, Query +from fastapi import APIRouter, Depends, Path, Query from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission +from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception from app.models import ChatbotFlowUpdate, ChatbotFlowVersionPublic -from app.services.chatbot.exceptions import ( - ChatbotFlowConflictError, - ChatbotFlowNotFoundError, - ChatbotFlowValidationError, - ChatbotNotFoundError, -) from app.services.chatbot.flow import ( build_chatbot_flow_version_public, get_chatbot_flow, @@ -49,8 +44,8 @@ def get_chatbot_flow_route( project_id=auth_context.project_.id, version=version, ) - except (ChatbotNotFoundError, ChatbotFlowNotFoundError) as e: - raise HTTPException(status_code=404, detail=str(e)) + except CHATBOT_SERVICE_ERRORS as e: + raise to_http_exception(e) from e return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) @@ -74,12 +69,8 @@ def save_chatbot_flow_route( project_id=auth_context.project_.id, flow=request, ) - except ChatbotNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ChatbotFlowConflictError as e: - raise HTTPException(status_code=409, detail=str(e)) - except ChatbotFlowValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) + except CHATBOT_SERVICE_ERRORS as e: + raise to_http_exception(e) from e return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) @@ -101,10 +92,6 @@ def publish_chatbot_flow_route( organization_id=auth_context.organization_.id, project_id=auth_context.project_.id, ) - except ChatbotNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except ChatbotFlowConflictError as e: - raise HTTPException(status_code=409, detail=str(e)) - except ChatbotFlowValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) + except CHATBOT_SERVICE_ERRORS as e: + raise to_http_exception(e) from e return APIResponse.success_response(build_chatbot_flow_version_public(flow_version)) diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index d600fbb42..713e561f1 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -6,6 +6,7 @@ from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission +from app.api.routes.chatbot.errors import CHATBOT_SERVICE_ERRORS, to_http_exception from app.celery.utils import start_chatbot_session_message from app.crud.chatbot import ( create_chatbot_session_message, @@ -22,12 +23,6 @@ SendChatbotSessionMessageRequest, SendChatbotSessionMessageResponse, ) -from app.services.chatbot.exceptions import ( - ChatbotFlowConflictError, - ChatbotFlowValidationError, - ChatbotNotFoundError, - ChatbotSessionConflictError, -) from app.services.chatbot.session import ( get_latest_chatbot_session_summary, start_chatbot_session, @@ -160,12 +155,8 @@ def create_chatbot_session_route( project_id=auth_context.project_.id, contact_ref=request.contact_ref, ) - except ChatbotNotFoundError as e: - raise HTTPException(status_code=404, detail=str(e)) - except (ChatbotFlowConflictError, ChatbotSessionConflictError) as e: - raise HTTPException(status_code=409, detail=str(e)) - except ChatbotFlowValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) + except CHATBOT_SERVICE_ERRORS as e: + raise to_http_exception(e) from e return APIResponse.success_response( CreateChatbotSessionResponse( session_id=chatbot_session.id, diff --git a/backend/app/services/chatbot/flow.py b/backend/app/services/chatbot/flow.py index 99fd1bb07..b5f78bac9 100644 --- a/backend/app/services/chatbot/flow.py +++ b/backend/app/services/chatbot/flow.py @@ -287,14 +287,15 @@ def validate_flow_for_publish( return pinned_nodes -def _get_chatbot_or_raise( +def get_chatbot_or_raise( *, session: Session, chatbot_id: UUID, organization_id: int, project_id: int, - for_update: bool, + for_update: bool = False, ) -> Chatbot: + """The non-deleted chatbot in the project, else ChatbotNotFoundError; `for_update` row-locks it.""" chatbot = get_chatbot( session, chatbot_id=chatbot_id, @@ -321,7 +322,7 @@ def get_chatbot_flow( version: int | None, ) -> ChatbotFlowVersion: """The requested version, else the draft if one exists, else the live version.""" - _get_chatbot_or_raise( + get_chatbot_or_raise( session=session, chatbot_id=chatbot_id, organization_id=organization_id, @@ -357,7 +358,7 @@ def save_chatbot_flow_draft( draft, or start a new draft when the highest version is live (live versions are immutable). """ # The row lock serializes draft saves and publishes of the same chatbot. - chatbot = _get_chatbot_or_raise( + chatbot = get_chatbot_or_raise( session=session, chatbot_id=chatbot_id, organization_id=organization_id, @@ -410,7 +411,7 @@ def publish_chatbot_flow( organization_id: int, project_id: int, ) -> ChatbotFlowVersion: - chatbot = _get_chatbot_or_raise( + chatbot = get_chatbot_or_raise( session=session, chatbot_id=chatbot_id, organization_id=organization_id, diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 6f54ef0e6..ee5c4a3a3 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -9,7 +9,6 @@ from app.crud.chatbot import ( create_chatbot_session, create_chatbot_session_message, - get_chatbot, get_chatbot_flow_version_by_id, get_chatbot_session, get_latest_chatbot_session, @@ -32,12 +31,11 @@ from app.services.chatbot.exceptions import ( ChatbotFlowConflictError, ChatbotInputError, - ChatbotNotFoundError, ChatbotProviderError, ) from app.services.chatbot.flow import ( - CHATBOT_NOT_FOUND_MESSAGE, find_flow_node, + get_chatbot_or_raise, parse_flow_edges, parse_flow_nodes, resolve_entry_node, @@ -68,14 +66,12 @@ def start_chatbot_session( contact_ref: str, ) -> tuple[ChatbotSession, ChatbotFlowVersion]: """Create a session pinned to the live flow version and its entry node.""" - chatbot = get_chatbot( - session, + get_chatbot_or_raise( + session=session, chatbot_id=chatbot_id, organization_id=organization_id, project_id=project_id, ) - if chatbot is None: - raise ChatbotNotFoundError(CHATBOT_NOT_FOUND_MESSAGE) live_version = get_live_chatbot_flow_version(session, chatbot_id=chatbot_id) if live_version is None: @@ -277,20 +273,13 @@ def process_chatbot_session_message( max_tokens=None, history_token_limit=None, ) - except HTTPException as e: - _send_failure_webhook( - session_id=session_id, - webhook_url=webhook_url, - webhook_secret=webhook_secret, - error=str(e.detail), - ) - return - except ChatbotInputError as e: + except (HTTPException, ChatbotInputError) as e: + error = str(e.detail) if isinstance(e, HTTPException) else str(e) _send_failure_webhook( session_id=session_id, webhook_url=webhook_url, webhook_secret=webhook_secret, - error=str(e), + error=error, ) return From b60fa1ba4f8356a2b64c45d45a92d2589fe8395f Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:41:21 +0530 Subject: [PATCH 23/25] fix: few more cleanups --- .../chatbot/agents/onboarding/graph.py | 7 ++-- backend/app/services/chatbot/history.py | 36 +++++++++---------- backend/app/services/chatbot/session.py | 9 +---- backend/app/services/chatbot/types.py | 2 +- .../chatbot/agents/onboarding/test_graph.py | 35 +++++++++--------- docs/wiki/modules/chatbot.md | 4 +-- 6 files changed, 42 insertions(+), 51 deletions(-) diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py index 2c1c69893..4c8f32ded 100644 --- a/backend/app/services/chatbot/agents/onboarding/graph.py +++ b/backend/app/services/chatbot/agents/onboarding/graph.py @@ -30,11 +30,11 @@ ) from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.services.chatbot.history import ( + extract_final_reply, has_user_turn, insert_after_leading_system, summarize_if_needed, to_langchain_messages, - to_message_dicts, ) from app.services.chatbot.types import ChatbotMessageDict, ChatbotTurnResult from app.services.chatbot.utils import ChatbotModelSettings, get_chat_model @@ -225,11 +225,8 @@ async def run_chatbot_turn( 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"] - ) return ChatbotTurnResult( - transcript=transcript, + reply=extract_final_reply(result["messages"]), collected_fields=result["collected_fields"], is_complete=result.get("is_complete", False), ) diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py index d3680638c..8acfecde6 100644 --- a/backend/app/services/chatbot/history.py +++ b/backend/app/services/chatbot/history.py @@ -1,13 +1,17 @@ """Conversation-history plumbing shared by every chatbot agent graph.""" +import logging + 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.exceptions import ChatbotInputError, ChatbotProviderError from app.services.chatbot.types import ChatbotMessageDict from app.services.chatbot.utils import FLOW_KEEP_RECENT_MESSAGES +logger = logging.getLogger(__name__) + SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " SUMMARIZE_INSTRUCTION = ( "Summarize the conversation so far in 2-3 sentences. Keep every fact the " @@ -112,20 +116,16 @@ def to_langchain_messages( 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 +def extract_final_reply(messages: list[BaseMessage]) -> str: + """Text of the turn's last message, which every agent graph ends on.""" + reply = "" + if messages and isinstance(messages[-1], AIMessage): + reply = messages[-1].text + if not reply.strip(): + error_message = ( + "[ANTHROPIC] Chat model returned no reply text — retry the turn. " + "If persistent, contact Kaapi. (code: EmptyReply)" + ) + logger.warning(f"[extract_final_reply] {error_message}") + raise ChatbotProviderError(error_message) + return reply diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index ee5c4a3a3..3f7da127c 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -193,13 +193,6 @@ def _split_pending_turn( 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, @@ -295,7 +288,6 @@ def process_chatbot_session_message( collected_fields=collected_fields, ) ) - reply = _extract_reply(turn_result.transcript) except (ChatbotInputError, ChatbotProviderError) as e: _send_failure_webhook( session_id=session_id, @@ -305,6 +297,7 @@ def process_chatbot_session_message( ) return + reply = turn_result.reply completion_status = turn_result.completion_status with Session(engine) as session: create_chatbot_session_message( diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py index 2333353e2..bcc08759e 100644 --- a/backend/app/services/chatbot/types.py +++ b/backend/app/services/chatbot/types.py @@ -8,7 +8,7 @@ @dataclass(frozen=True) class ChatbotTurnResult: - transcript: list[ChatbotMessageDict] + reply: str collected_fields: dict[str, Any] is_complete: bool 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 85f1e1834..220cd9d8b 100644 --- a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py +++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py @@ -9,6 +9,7 @@ from app.services.chatbot.agents.onboarding.prompts import EXTRACTION_INSTRUCTION from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.history import extract_final_reply from app.services.chatbot.types import ChatbotMessageDict, ChatbotTurnResult from app.services.chatbot.utils import ChatbotModelSettings from app.tests.utils.chatbot import ( @@ -62,10 +63,7 @@ async def test_first_call_returns_greeting_without_kickoff( result = await _run_turn(history=[], user_message=None) - assert result.transcript == [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": "Hi! What's your name?"}, - ] + assert result.reply == "Hi! What's your name?" # Anthropic rejects a conversation that doesn't open with a user turn. sent = chat_model.calls[0] assert isinstance(sent[0], SystemMessage) @@ -86,15 +84,23 @@ async def test_follow_up_turn_appends_user_message_and_reply( result = await _run_turn(history=history, user_message="Asha") - 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."}, - ] + assert result.reply == "Nice to meet you, Asha." sent_contents = [m.content for m in chat_model.calls[0]] assert "stale prompt from the previous call" not in sent_contents + @pytest.mark.asyncio + async def test_empty_reply_becomes_provider_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + patch_chat_model(monkeypatch, ScriptedChatModel(replies=[" "])) + + with pytest.raises(ChatbotProviderError, match="EmptyReply"): + await _run_turn(history=[], user_message=None) + + def test_reply_must_come_from_a_final_ai_message(self) -> None: + with pytest.raises(ChatbotProviderError, match="EmptyReply"): + extract_final_reply([HumanMessage(content="Asha")]) + @pytest.mark.asyncio async def test_missing_message_with_history_rejected( self, monkeypatch: pytest.MonkeyPatch @@ -204,13 +210,8 @@ async def test_long_history_over_budget_is_summarized( "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."}, - ] + # The reply is the turn's answer, not the summary text. + assert result.reply == "Got it." @pytest.mark.asyncio async def test_long_history_within_budget_is_sent_whole( diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 655357520..c21bfabca 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -35,13 +35,13 @@ All paths relative to `backend/app/`. ## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`) 1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the node prompt, the previously collected map, and the (summarized) conversation. Uses Anthropic native structured output (`with_structured_output(..., method="json_schema")`) with the config's own thinking/effort, because forced tool use is rejected with thinking on and Opus 5.5 can't disable thinking. Merged in code: `{**prior, **extracted}`; `is_complete` only when nothing is missing. 2. `chatbot_node` — replies using the same summarized view plus a system note listing collected/missing fields, so it asks only for what's missing and wraps up when complete. -3. `run_chatbot_turn(..., collected_fields=prior)` → `ChatbotTurnResult{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. +3. `run_chatbot_turn(..., collected_fields=prior)` → `ChatbotTurnResult{reply, collected_fields, is_complete}` (`reply` = text of the graph's final `AIMessage` via `history.extract_final_reply`; empty → `ChatbotProviderError` `EmptyReply`) (`.completion_status` maps to the enum). Anthropic errors from either call, and langchain_anthropic's client-side model-config `ValueError`s ("[KAAPI] Unsupported model configuration"), map to `ChatbotProviderError`, so the session path always sends a failure webhook. ## Services / CRUD - `services/chatbot/flow.py` — `get_chatbot_flow`, `save_chatbot_flow_draft`, `publish_chatbot_flow` (row-lock the chatbot via `get_chatbot(for_update=True)` to serialize draft saves and publishes), `validate_flow_for_publish`, `resolve_entry_node`, `find_flow_node`, `parse_flow_nodes/edges`, `dump_flow_nodes/edges`, `build_chatbot_flow_version_public` - `services/chatbot/session.py` — `start_chatbot_session` (live version + entry node → `create_chatbot_session`), `get_latest_chatbot_session_summary`, `process_chatbot_session_message` (Celery worker body: load session + pinned flow version + active node; inactive session, missing node or a non-onboarding node → failure webhook; otherwise run the turn with the node prompt/config and stored `collected_fields`, store the reply with `node_id`, update progress, POST the webhook) - `services/chatbot/agents/onboarding/{graph,prompts,types}.py` — `run_chatbot_turn`, the extraction/reply nodes, `FieldExtraction` -- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `to_message_dicts`, kickoff message, `has_user_turn`, `insert_after_leading_system` +- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages`, `extract_final_reply`, kickoff message, `has_user_turn`, `insert_after_leading_system` - `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict` - `services/chatbot/config.py` — `get_chatbot_config_version` (requested or latest version), `build_chatbot_model_settings` (version row → `ChatbotModelSettings`; 422-class `ChatbotInputError` for non-Anthropic/non-text/no-model configs or a bad thinking budget), `resolve_chatbot_model_settings` (both, used by the worker) - `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults, `get_chat_model` From 251c34f71914046bc0ebd99b4db83964a6a2ea4b Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:45:21 +0530 Subject: [PATCH 24/25] fix: remove the logger --- backend/app/crud/chatbot.py | 24 +----------------------- backend/app/services/chatbot/config.py | 3 --- backend/app/services/chatbot/history.py | 5 ----- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index 773e33ca8..7ab7e93b2 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 @@ -28,8 +27,6 @@ ChatbotSessionConflictError, ) -logger = logging.getLogger(__name__) - def create_chatbot( session: Session, *, organization_id: int, project_id: int, name: str @@ -45,9 +42,6 @@ def create_chatbot( session.add(chatbot) session.commit() session.refresh(chatbot) - logger.info( - f"[create_chatbot] Chatbot created | chatbot_id: {chatbot.id}, project_id: {project_id}" - ) return chatbot @@ -108,17 +102,13 @@ def update_chatbot( def delete_chatbot(session: Session, *, chatbot: Chatbot) -> Chatbot: """Soft-delete the chatbot and deactivate its active sessions in the same transaction.""" timestamp = now() - deactivated_count = _deactivate_active_chatbot_sessions( + _deactivate_active_chatbot_sessions( session, chatbot_id=chatbot.id, contact_ref=None, timestamp=timestamp ) chatbot.sqlmodel_update({"deleted_at": timestamp, "updated_at": timestamp}) session.add(chatbot) session.commit() session.refresh(chatbot) - logger.info( - f"[delete_chatbot] Chatbot soft-deleted | chatbot_id: {chatbot.id}, " - f"deactivated_sessions: {deactivated_count}" - ) return chatbot @@ -189,10 +179,6 @@ def create_chatbot_flow_version( raise raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e session.refresh(flow_version) - logger.info( - f"[create_chatbot_flow_version] Flow version created | chatbot_id: {chatbot_id}, " - f"version: {version}, is_live: {is_live}" - ) return flow_version @@ -242,10 +228,6 @@ def publish_chatbot_flow_version( raise raise ChatbotFlowConflictError(CONCURRENT_FLOW_CONFLICT_MESSAGE) from e session.refresh(flow_version) - logger.info( - f"[publish_chatbot_flow_version] Flow published | chatbot_id: {flow_version.chatbot_id}, " - f"version: {flow_version.version}" - ) return flow_version @@ -323,10 +305,6 @@ def create_chatbot_session( raise 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"chatbot_id: {chatbot_id}" - ) return chatbot_session diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 994f30cde..30b4e74ab 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -1,4 +1,3 @@ -import logging from dataclasses import replace from typing import Any, get_args from uuid import UUID @@ -21,8 +20,6 @@ default_thinking, ) -logger = logging.getLogger(__name__) - _ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort) THINKING_TYPE_ENABLED = "enabled" CREDENTIAL_API_KEY = "api_key" diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py index 8acfecde6..2c1d94595 100644 --- a/backend/app/services/chatbot/history.py +++ b/backend/app/services/chatbot/history.py @@ -1,7 +1,5 @@ """Conversation-history plumbing shared by every chatbot agent graph.""" -import logging - from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage @@ -10,8 +8,6 @@ from app.services.chatbot.types import ChatbotMessageDict from app.services.chatbot.utils import FLOW_KEEP_RECENT_MESSAGES -logger = logging.getLogger(__name__) - SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " SUMMARIZE_INSTRUCTION = ( "Summarize the conversation so far in 2-3 sentences. Keep every fact the " @@ -126,6 +122,5 @@ def extract_final_reply(messages: list[BaseMessage]) -> str: "[ANTHROPIC] Chat model returned no reply text — retry the turn. " "If persistent, contact Kaapi. (code: EmptyReply)" ) - logger.warning(f"[extract_final_reply] {error_message}") raise ChatbotProviderError(error_message) return reply From 86afc385a5a4c55b921eb36f1cbf483a182245d7 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:50:25 +0530 Subject: [PATCH 25/25] fix: remove the unwanted code comment --- backend/app/api/routes/chatbot/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py index 087298822..47f1c4ff3 100644 --- a/backend/app/api/routes/chatbot/__init__.py +++ b/backend/app/api/routes/chatbot/__init__.py @@ -12,7 +12,6 @@ tags=["Chatbot"], dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))], ) -# Session routes first so literal `/sessions/...` paths win over `/{chatbot_id}/...`. router.include_router(session_router, prefix=CHATBOTS_PREFIX) router.include_router(chatbot_router, prefix=CHATBOTS_PREFIX) router.include_router(flow_router, prefix=CHATBOTS_PREFIX)