From dbba788ff1bdbc674738be0b82a0b5d1c40f0dbb Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:14:33 +0530 Subject: [PATCH 01/15] feat(*): init chatbot flow --- .../086_seed_anthropic_text_model_configs.py | 60 ++++ backend/app/api/docs/chatbot/message.md | 7 + backend/app/api/docs/openapi_config.py | 5 + backend/app/api/main.py | 2 + backend/app/api/routes/chatbot.py | 64 +++++ backend/app/crud/config/version.py | 5 + backend/app/models/__init__.py | 6 + backend/app/models/chatbot.py | 56 ++++ backend/app/services/chatbot/config.py | 141 ++++++++++ backend/app/services/chatbot/engine.py | 260 ++++++++++++++++++ backend/app/services/chatbot/utils.py | 59 ++++ backend/pyproject.toml | 1 + backend/uv.lock | 86 +++++- docs/wiki/INDEX.md | 1 + docs/wiki/domain-map.md | 4 +- docs/wiki/modules/chatbot.md | 23 ++ docs/wiki/modules/platform.md | 13 + 17 files changed, 780 insertions(+), 13 deletions(-) create mode 100644 backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py create mode 100644 backend/app/api/docs/chatbot/message.md create mode 100644 backend/app/api/routes/chatbot.py create mode 100644 backend/app/models/chatbot.py create mode 100644 backend/app/services/chatbot/config.py create mode 100644 backend/app/services/chatbot/engine.py create mode 100644 backend/app/services/chatbot/utils.py create mode 100644 docs/wiki/modules/chatbot.md diff --git a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py new file mode 100644 index 000000000..915218568 --- /dev/null +++ b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py @@ -0,0 +1,60 @@ +"""seed anthropic text model_config rows + +Revision ID: 086 +Revises: 085 +Create Date: 2026-10-09 00:00:00.000000 + +`anthropic` has been a valid provider since 066 but had no model_config rows, +so it never showed up in /models/providers and config-version updates could +not tell its models are reasoning models. The `effort` key in `config` is what +`is_reasoning_model` keys on — it makes `_strip_unsupported_params` drop +`temperature`, which the Messages API rejects at non-default values. + +`effort.default` is "high" because that is what the Messages API applies when +the param is omitted. Sonnet 4.6 does not expose the "xhigh" level. + +claude-opus-5-5 pricing is left NULL until list prices are confirmed; cost +estimation returns None for NULL pricing rather than a wrong number. + +Downgrade deletes the two rows by key, so a row that already existed before +upgrade (ON CONFLICT DO NOTHING skipped it) is removed as well. +""" + +from alembic import op + +revision = "086" +down_revision = "085" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + INSERT INTO global.model_config + (provider, model_name, completion_type, config, input_modalities, + output_modalities, pricing, is_active, inserted_at, updated_at) + VALUES + ('anthropic', 'claude-sonnet-4-6', '{text}', + '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', + '{TEXT,IMAGE,FILES}', '{TEXT}', + '{"response": {"input_token_cost": 3, "output_token_cost": 15}, "batch": {"input_token_cost": 1.5, "output_token_cost": 7.5}}', + true, NOW(), NOW()), + ('anthropic', 'claude-opus-5-5', '{text}', + '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "xhigh", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', + '{TEXT,IMAGE,FILES}', '{TEXT}', + NULL, + true, NOW(), NOW()) + ON CONFLICT (provider, model_name) DO NOTHING + """ + ) + + +def downgrade(): + op.execute( + """ + DELETE FROM global.model_config + WHERE provider = 'anthropic' + AND model_name IN ('claude-sonnet-4-6', 'claude-opus-5-5') + """ + ) diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md new file mode 100644 index 000000000..75ee043c9 --- /dev/null +++ b/backend/app/api/docs/chatbot/message.md @@ -0,0 +1,7 @@ +Run one turn of a prompt-driven chatbot conversation and return the full updated transcript. + +The chat model comes from a saved Kaapi config: `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set. `config_version` picks a version (latest if omitted). The config's `params.effort` sets reasoning effort (provider default if absent) and `params.thinking` overrides the default adaptive thinking. `max_tokens` (default 1024) and `history_token_limit` (default 4000; older turns are summarized above it) are optional per-request overrides. + +There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`. + +On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures. diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py index 317f05d28..654fb8ed0 100644 --- a/backend/app/api/docs/openapi_config.py +++ b/backend/app/api/docs/openapi_config.py @@ -35,6 +35,10 @@ "name": "LLM", "description": "Large Language Model inference and interaction endpoints", }, + { + "name": "Chatbot", + "description": "Prompt-driven conversational chatbot turns", + }, { "name": "Guardrails", "description": ( @@ -104,6 +108,7 @@ "Collections", "Config Management", "LLM", + "Chatbot", "Guardrails", "Evaluation", "Fine Tuning", diff --git a/backend/app/api/main.py b/backend/app/api/main.py index a796c58b6..c181ddf2c 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -5,6 +5,7 @@ api_keys, assistants, auth, + chatbot, collection_job, collections, config, @@ -47,6 +48,7 @@ api_router.include_router(assessment_api_routes.router) api_router.include_router(assistants.router) api_router.include_router(auth.router) +api_router.include_router(chatbot.router) api_router.include_router(collection_job.router) api_router.include_router(collections.router) api_router.include_router(config.router) diff --git a/backend/app/api/routes/chatbot.py b/backend/app/api/routes/chatbot.py new file mode 100644 index 000000000..454f4853b --- /dev/null +++ b/backend/app/api/routes/chatbot.py @@ -0,0 +1,64 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse +from app.services.chatbot.engine import ( + ChatbotInputError, + ChatbotProviderError, + run_chatbot_turn, +) +from app.services.chatbot.config import resolve_chatbot_model_settings +from app.utils import APIResponse, load_description + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) + + +# Synchronous by design (real-time request/response turn), not a Celery job. +@router.post( + "/message", + description=load_description("chatbot/message.md"), + response_model=APIResponse[ChatbotTurnResponse], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +async def send_chatbot_message( + request: ChatbotTurnRequest, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotTurnResponse]: + history: list[dict[str, str]] = [] + for entry in request.history: + history.append(entry.model_dump(mode="json")) + + try: + model_settings = resolve_chatbot_model_settings( + session=session, + project_id=auth_context.project_.id, + config_id=request.config_id, + config_version=request.config_version, + max_tokens=request.max_tokens, + history_token_limit=request.history_token_limit, + ) + transcript = await run_chatbot_turn( + system_prompt=request.system_prompt, + history=history, + user_message=request.message, + model_settings=model_settings, + ) + except ChatbotInputError as e: + raise HTTPException(status_code=422, detail=str(e)) + except ChatbotProviderError as e: + logger.warning( + f"[send_chatbot_message] Chatbot turn failed upstream | " + f"project_id: {auth_context.project_.id}" + ) + raise HTTPException(status_code=502, detail=str(e)) + + messages: list[ChatbotMessage] = [] + for entry in transcript: + messages.append(ChatbotMessage.model_validate(entry)) + return APIResponse.success_response(ChatbotTurnResponse(messages=messages)) diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py index 313a4e27c..85be0ed64 100644 --- a/backend/app/crud/config/version.py +++ b/backend/app/crud/config/version.py @@ -213,6 +213,11 @@ def read_one(self, version_number: int) -> ConfigVersion | None: ) return self.session.exec(statement).one_or_none() + def read_latest(self) -> ConfigVersion | None: + """Read the highest non-deleted version of the configuration.""" + self._config_exists_or_raise(self.config_id) + return self._get_latest_version() + def read_all(self, skip: int = 0, limit: int = 100) -> list[ConfigVersionItems]: """ Read all versions for a specific configuration with pagination. diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8a1559573..e13fe062a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -35,6 +35,12 @@ BatchJobType, BatchJobUpdate, ) +from .chatbot import ( + ChatbotMessage, + ChatbotMessageRoleEnum, + ChatbotTurnRequest, + ChatbotTurnResponse, +) from .collection import ( Collection, CollectionIDPublic, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py new file mode 100644 index 000000000..c06a6540b --- /dev/null +++ b/backend/app/models/chatbot.py @@ -0,0 +1,56 @@ +from enum import StrEnum +from uuid import UUID + +from sqlmodel import Field, SQLModel + + +class ChatbotMessageRoleEnum(StrEnum): + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + + +class ChatbotMessage(SQLModel): + role: ChatbotMessageRoleEnum + content: str + + +class ChatbotTurnRequest(SQLModel): + config_id: UUID = Field( + description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.", + ) + config_version: int | None = Field( + default=None, + ge=1, + description="Version of `config_id` to use; omit to use its latest version.", + ) + max_tokens: int | None = Field( + default=None, + gt=0, + description="Cap on output tokens per model call; omit for the server default.", + ) + history_token_limit: int | None = Field( + default=None, + gt=0, + description="Token size of the history above which older turns are summarized; omit for the server default.", + ) + # Required every call in v0: there is no server-side storage of the flow prompt yet. + system_prompt: str = Field( + min_length=1, + description="Admin-authored flow prompt (goal, fields to collect, rules).", + ) + history: list[ChatbotMessage] = Field( + default_factory=list, + description="Every message before this turn, as returned by the previous call; empty on the first call.", + ) + message: str | None = Field( + default=None, + min_length=1, + description="The new user message; omit only to get the opening greeting on the first call.", + ) + + +class ChatbotTurnResponse(SQLModel): + messages: list[ChatbotMessage] = Field( + description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.", + ) diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py new file mode 100644 index 000000000..9f3d4fc0f --- /dev/null +++ b/backend/app/services/chatbot/config.py @@ -0,0 +1,141 @@ +import logging +from typing import get_args +from uuid import UUID + +from fastapi import HTTPException +from pydantic import ValidationError +from sqlmodel import Session + +from app.crud.config import ConfigVersionCrud +from app.models import ConfigVersion +from app.models.llm.constants import Provider +from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig +from app.services.chatbot.engine import ChatbotInputError +from app.services.chatbot.utils import ( + DEFAULT_HISTORY_TOKEN_LIMIT, + DEFAULT_MAX_OUTPUT_TOKENS, + AnthropicEffort, + ChatbotModelSettings, + default_thinking, +) + +logger = logging.getLogger(__name__) + +_ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort) + + +def _get_config_version( + *, session: Session, project_id: int, config_id: UUID, config_version: int | None +) -> ConfigVersion: + # The crud constructor and `exists_or_raise` already 404 when the config or + # version is outside this project, so ownership is enforced here. + version_crud = ConfigVersionCrud( + session=session, config_id=config_id, project_id=project_id + ) + if config_version is not None: + return version_crud.exists_or_raise(version_number=config_version) + + latest = version_crud.read_latest() + if latest is None: + logger.warning( + f"[_get_config_version] Config has no versions | " + f"config_id: {config_id}, project_id: {project_id}" + ) + raise HTTPException( + status_code=404, + detail=f"No version found for config '{config_id}'", + ) + return latest + + +def _to_anthropic_effort(effort: str | None) -> AnthropicEffort | None: + if effort is None: + return None + for level in _ANTHROPIC_EFFORT_LEVELS: + if level == effort: + return level + # Kaapi's "none"/"minimal" have no Anthropic equivalent; dropping them + # silently would let the provider default (often higher) take over. + raise ChatbotInputError( + f"[KAAPI] Config param 'effort' value '{effort}' is not an Anthropic effort " + f"level ({', '.join(_ANTHROPIC_EFFORT_LEVELS)}); update the config." + ) + + +def resolve_chatbot_model_settings( + *, + session: Session, + project_id: int, + config_id: UUID, + config_version: int | None, + max_tokens: int | None, + history_token_limit: int | None, +) -> ChatbotModelSettings: + """Turn a saved config (+ request overrides) into chat-model settings. + + Raises HTTPException(404) when the config/version isn't in the project and + ChatbotInputError when the config isn't a usable Anthropic text config. + """ + version_row = _get_config_version( + session=session, + project_id=project_id, + config_id=config_id, + config_version=config_version, + ) + + try: + blob = ConfigBlob.model_validate(version_row.config_blob) + except ValidationError as e: + logger.warning( + f"[resolve_chatbot_model_settings] [KAAPI] Stored config blob is invalid | " + f"config_id: {config_id}, version: {version_row.version}", + exc_info=True, + ) + raise ChatbotInputError( + f"[KAAPI] Stored configuration blob is invalid: {e}" + ) from e + + completion = blob.completion + if ( + not isinstance(completion, KaapiTextCompletionConfig) + or completion.provider != Provider.ANTHROPIC + ): + logger.warning( + f"[resolve_chatbot_model_settings] [KAAPI] Unsupported config for chatbot | " + f"config_id: {config_id}, version: {version_row.version}, " + f"provider: {completion.provider}, type: {completion.type}" + ) + raise ChatbotInputError( + "[KAAPI] Chatbot only supports configs with completion provider " + f"'{Provider.ANTHROPIC.value}' and type 'text'; got provider " + f"'{completion.provider}', type '{completion.type}'." + ) + + params = completion.params + if not params.model: + logger.warning( + f"[resolve_chatbot_model_settings] [KAAPI] Config has no model | " + f"config_id: {config_id}, version: {version_row.version}" + ) + raise ChatbotInputError( + "[KAAPI] Config is missing completion.params.model; set a model on the config." + ) + + thinking = params.thinking if params.thinking is not None else default_thinking() + model_settings = ChatbotModelSettings( + model=params.model, + max_tokens=max_tokens if max_tokens is not None else DEFAULT_MAX_OUTPUT_TOKENS, + reasoning_effort=_to_anthropic_effort(params.effort), + thinking=thinking, + history_token_limit=( + history_token_limit + if history_token_limit is not None + else DEFAULT_HISTORY_TOKEN_LIMIT + ), + ) + logger.info( + f"[resolve_chatbot_model_settings] Resolved chatbot model settings | " + f"config_id: {config_id}, version: {version_row.version}, " + f"model: {model_settings.model}, effort: {model_settings.reasoning_effort}" + ) + return model_settings diff --git a/backend/app/services/chatbot/engine.py b/backend/app/services/chatbot/engine.py new file mode 100644 index 000000000..380bf3eb0 --- /dev/null +++ b/backend/app/services/chatbot/engine.py @@ -0,0 +1,260 @@ +"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model. + +Unlike a field-by-field collector, this mirrors OCS's own approach: the +admin supplies the full prompt (goal + fields + rules, written as free text -- +the same shape as OCS's `prompt` field) and the model manages the conversation +itself, reading the full message history every turn. There is no field-by-field +state machine in code here. + +No tools in v0 -- OCS's node also lets the admin attach tools (e.g. +set-session-state); that is deliberately left out here and is the natural next +step once this shape is proven. + +No checkpointer: like OCS, there is no LangGraph-level pause/resume. The caller +keeps the running message list itself and passes the full history into +`graph.invoke(...)` on every turn -- a fresh, complete run each time. +""" + +import asyncio +import logging +from typing import Annotated, TypedDict + +import anthropic +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.graph.state import CompiledStateGraph + +from app.models.chatbot import ChatbotMessageRoleEnum +from app.services.chatbot.utils import ( + FLOW_KEEP_RECENT_MESSAGES, + ChatbotModelSettings, + get_chat_model, +) + +logger = logging.getLogger(__name__) + +CHATBOT_NODE = "chatbot_node" + +_SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " +_SUMMARIZE_INSTRUCTION = ( + "Summarize the conversation so far in 2-3 sentences. Keep every fact the " + "user has already given -- this note replaces the original messages." +) + +# Anthropic rejects a request whose first non-system turn isn't a user turn, so +# the opening greeting (and every later call, whose history starts with that +# greeting) needs a synthetic user turn in front. It's stripped from the +# returned transcript by this fixed id, so callers never see it. +_KICKOFF_MESSAGE_ID = "chatbot-kickoff" +_KICKOFF_MESSAGE_CONTENT = "Begin the conversation." + +ChatbotMessageDict = dict[str, str] + + +class ChatbotState(TypedDict): + # `add_messages` is LangGraph's prebuilt reducer for LangChain message + # lists: it appends new messages and merges by `.id` instead of naively + # concatenating, which is what lets callers keep re-sending the running + # history without duplicating turns. + messages: Annotated[list[BaseMessage], add_messages] + + +class ChatbotInputError(ValueError): + """The turn request can't produce a valid model call (caller's fault).""" + + +class ChatbotProviderError(Exception): + """The chat-model provider failed or was unreachable.""" + + +def _summarize_if_needed( + chat_model: BaseChatModel, + messages: list[BaseMessage], + *, + history_token_limit: int, +) -> list[BaseMessage]: + """Trim the view sent to the model once history crosses the token budget. + + Simplification: this only shrinks what THIS call sends to Anthropic: the + caller's own running history keeps growing untouched, so a long-enough + conversation re-summarizes from scratch each turn rather than compacting + state permanently. OCS's real history middleware persists the compression + point instead -- worth doing if this gets reused past v0. + """ + if len(messages) <= FLOW_KEEP_RECENT_MESSAGES + 1: # +1 for the system message + return messages + if chat_model.get_num_tokens_from_messages(messages) <= history_token_limit: + return messages + + system_message, *rest = messages + to_summarize, recent = ( + rest[:-FLOW_KEEP_RECENT_MESSAGES], + rest[-FLOW_KEEP_RECENT_MESSAGES:], + ) + # Anthropic requires the first non-system turn to be a user turn. `rest` + # always ends on this turn's unanswered human message, so it has odd + # length; cutting an even-sized window from its tail always lands one + # message short, opening on an assistant turn instead. Shift the boundary + # back until `recent` starts on a human message. + while recent and to_summarize and not isinstance(recent[0], HumanMessage): + recent.insert(0, to_summarize.pop()) + if not to_summarize: + return messages + + summary = chat_model.invoke( + [SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize] + ) + logger.info( + f"[_summarize_if_needed] Compressed history | " + f"from: {len(to_summarize)} messages, to: 1 summary note" + ) + summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}") + return [system_message, summary_note, *recent] + + +def build_chatbot_graph( + chat_model: BaseChatModel, *, history_token_limit: int +) -> CompiledStateGraph: + """One node, no branching -- `chat_model` is fixed for the graph's lifetime.""" + + def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]: + messages = _summarize_if_needed( + chat_model, state["messages"], history_token_limit=history_token_limit + ) + response = chat_model.invoke(messages) + return {"messages": [response]} + + graph = StateGraph(ChatbotState) + graph.add_node(CHATBOT_NODE, chatbot_node) + graph.add_edge(START, CHATBOT_NODE) + graph.add_edge(CHATBOT_NODE, END) + return graph.compile() + + +def _to_langchain_messages( + *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None +) -> list[BaseMessage]: + # `system_prompt` is the single source of the system turn; any system entry + # echoed back in `history` (the previous response includes it) is dropped + # so it isn't sent twice. + conversation: list[BaseMessage] = [] + for entry in history: + role = entry["role"] + content = entry["content"] + if role == ChatbotMessageRoleEnum.USER: + conversation.append(HumanMessage(content=content)) + elif role == ChatbotMessageRoleEnum.ASSISTANT: + conversation.append(AIMessage(content=content)) + elif role != ChatbotMessageRoleEnum.SYSTEM: + raise ChatbotInputError(f"Unsupported message role: {role}") + + if user_message is not None: + conversation.append(HumanMessage(content=user_message)) + + if not conversation or not isinstance(conversation[0], HumanMessage): + kickoff = HumanMessage(content=_KICKOFF_MESSAGE_CONTENT, id=_KICKOFF_MESSAGE_ID) + conversation.insert(0, kickoff) + + return [SystemMessage(content=system_prompt), *conversation] + + +def _to_message_dicts( + *, system_prompt: str, messages: list[BaseMessage] +) -> list[ChatbotMessageDict]: + transcript: list[ChatbotMessageDict] = [ + {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt} + ] + for message in messages: + if message.id == _KICKOFF_MESSAGE_ID: + continue + if isinstance(message, HumanMessage): + role = ChatbotMessageRoleEnum.USER + elif isinstance(message, AIMessage): + role = ChatbotMessageRoleEnum.ASSISTANT + else: + continue + # `.text` drops thinking/tool blocks; the wire shape carries text only. + transcript.append({"role": role.value, "content": message.text}) + return transcript + + +async def run_chatbot_turn( + *, + system_prompt: str, + history: list[ChatbotMessageDict], + user_message: str | None, + model_settings: ChatbotModelSettings, +) -> list[ChatbotMessageDict]: + """Run one stateless turn and return the full updated transcript. + + `user_message` may be None only on the very first call (empty `history`), + which yields the bot's opening greeting. + """ + if user_message is None and history: + logger.warning( + f"[run_chatbot_turn] [KAAPI] Missing user message on a non-initial turn | " + f"history_count: {len(history)}" + ) + raise ChatbotInputError( + "[KAAPI] `message` is required once `history` is non-empty; omit it only " + "on the first call to get the opening greeting." + ) + + messages = _to_langchain_messages( + system_prompt=system_prompt, history=history, user_message=user_message + ) + logger.info( + f"[run_chatbot_turn] Starting turn | history_count: {len(history)}, " + f"has_user_message: {user_message is not None}, model: {model_settings.model}" + ) + + graph = build_chatbot_graph( + get_chat_model(model_settings), + history_token_limit=model_settings.history_token_limit, + ) + try: + # ChatAnthropic.invoke is sync; keep it off the event loop. + result = await asyncio.to_thread(graph.invoke, {"messages": messages}) + except anthropic.APITimeoutError as e: + logger.error( + f"[run_chatbot_turn] [KAAPI] Request to Anthropic timed out (code: APITimeoutError) | " + f"provider=anthropic, model={model_settings.model}", + exc_info=True, + ) + raise ChatbotProviderError( + "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " + "(code: APITimeoutError)" + ) from e + except anthropic.APIConnectionError as e: + logger.error( + f"[run_chatbot_turn] [KAAPI] Could not reach Anthropic (code: APIConnectionError) | " + f"provider=anthropic, model={model_settings.model}", + exc_info=True, + ) + raise ChatbotProviderError( + "[KAAPI] Network/DNS issue reaching provider — check connectivity. " + "If persistent, contact Kaapi. (code: APIConnectionError)" + ) from e + except anthropic.APIStatusError as e: + # 5xx is provider-side (alert-worthy); 4xx is caller's fault (noise if alerted) + log = logger.error if e.status_code >= 500 else logger.warning + log( + f"[run_chatbot_turn] [ANTHROPIC] {e.message} (code: {e.status_code}) | " + f"provider=anthropic, model={model_settings.model}, request_id={e.request_id}", + exc_info=True, + ) + raise ChatbotProviderError( + f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, " + f"request_id: {e.request_id})" + ) from e + + transcript = _to_message_dicts( + system_prompt=system_prompt, messages=result["messages"] + ) + logger.info( + f"[run_chatbot_turn] Turn completed | transcript_count: {len(transcript)}, " + f"reply_length: {len(transcript[-1]['content'])}" + ) + return transcript diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py new file mode 100644 index 000000000..2ec9571a1 --- /dev/null +++ b/backend/app/services/chatbot/utils.py @@ -0,0 +1,59 @@ +"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node. + +Like OCS's `LLMResponseWithPrompt` node, the model and reasoning settings come +from per-request input -- here a saved Kaapi config (see +`services/chatbot/config.py`) plus optional token overrides on the request. +Only Anthropic is wired up, since the node's chat model is `ChatAnthropic`. +""" + +from dataclasses import dataclass, field +from typing import Any, Literal + +from langchain_anthropic import ChatAnthropic + +from app.core.config import settings + +AnthropicEffort = Literal["low", "medium", "high", "xhigh", "max"] + +DEFAULT_MAX_OUTPUT_TOKENS = 1024 +DEFAULT_THINKING: dict[str, Any] = {"type": "adaptive"} + +# OCS's `history_mode=summarize`, simplified: once the running history crosses +# this many tokens, everything except the system prompt and the most recent +# turns is summarized into one note before the next model call. +DEFAULT_HISTORY_TOKEN_LIMIT = 4000 +FLOW_KEEP_RECENT_MESSAGES = 6 + + +def default_thinking() -> dict[str, Any]: + # Fresh copy so one request can't mutate the module-level default. + return dict(DEFAULT_THINKING) + + +@dataclass(frozen=True) +class ChatbotModelSettings: + model: str + max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS + # None lets the provider pick its own default effort. + reasoning_effort: AnthropicEffort | None = None + thinking: dict[str, Any] = field(default_factory=default_thinking) + history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT + + +def get_chat_model(model_settings: ChatbotModelSettings) -> ChatAnthropic: + """LangChain's provider abstraction, so swapping providers later is a + config change here, not a rewrite of the node. + """ + if not settings.ANTHROPIC_API_KEY: + raise RuntimeError( + "[get_chat_model] ANTHROPIC_API_KEY is not set; export it or set it in .env" + ) + kwargs: dict[str, Any] = { + "model": model_settings.model, + "max_tokens": model_settings.max_tokens, + "thinking": model_settings.thinking, + "anthropic_api_key": settings.ANTHROPIC_API_KEY, + } + if model_settings.reasoning_effort is not None: + kwargs["reasoning_effort"] = model_settings.reasoning_effort + return ChatAnthropic(**kwargs) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3a7aab594..300453b2c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -63,6 +63,7 @@ dependencies = [ "filetype>=1.2.0", "langgraph>=1.2.10", "langgraph-checkpoint-postgres>=3.1.1", + "langchain-anthropic>=1.7.5", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 852753933..0e57889d3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -258,21 +258,20 @@ wheels = [ [[package]] name = "anthropic" -version = "0.104.1" +version = "1.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, { name = "docstring-parser" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/c7/7a655b948916f777354648ce979f68b94d5b8dbdb5f61fed1f37fad9378c/anthropic-0.104.1.tar.gz", hash = "sha256:17362b6c45f527afcc9b0fdf62011ffd359726ab2ebcb1978ea0cc41bd8d8d40", size = 850081, upload-time = "2026-05-22T15:36:57.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cc/0da23e94020c62c0077c17648252f32d575b187710faaf3b85145e8e555d/anthropic-1.12.1.tar.gz", hash = "sha256:10f720ab6ddf40ef12e0e5743412c3a6dfac1109c244e633c9a2dbd36778ad30", size = 1555908, upload-time = "2026-10-08T03:42:00.588Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/12/d9ab42790494d7c428391a46cd28492395566a6a8ccb138d681978594455/anthropic-0.104.1-py3-none-any.whl", hash = "sha256:35c8cb456f5a4405aafe1f10f03f6fcc54fa51fa8ec01d655cc4b437d120e9b7", size = 832996, upload-time = "2026-05-22T15:36:59.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/35/1ccdc3e950dd869b66de15035e15d9614607fcef9664dec403fe639f01df/anthropic-1.12.1-py3-none-any.whl", hash = "sha256:1c1469bb3f4118291756816b7850cc839f17b2be5cdc99c8fde3890a61a49ca2", size = 1919752, upload-time = "2026-10-08T03:41:58.596Z" }, ] [[package]] @@ -313,6 +312,7 @@ dependencies = [ { name = "indic-nlp-library" }, { name = "jinja2" }, { name = "jiwer" }, + { name = "langchain-anthropic" }, { name = "langfuse" }, { name = "langgraph" }, { name = "langgraph-checkpoint-postgres" }, @@ -388,6 +388,7 @@ requires-dist = [ { name = "indic-nlp-library", specifier = ">=0.92" }, { name = "jinja2", specifier = ">=3.1.4,<4.0.0" }, { name = "jiwer", specifier = ">=3.1.0" }, + { name = "langchain-anthropic", specifier = ">=1.7.5" }, { name = "langfuse", specifier = "==4.7.1" }, { name = "langgraph", specifier = ">=1.2.10" }, { name = "langgraph-checkpoint-postgres", specifier = ">=3.1.1" }, @@ -1629,6 +1630,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -1673,6 +1687,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "huggingface-hub" version = "1.7.1" @@ -1713,11 +1753,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -1949,11 +1989,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, ] +[[package]] +name = "langchain-anthropic" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anthropic" }, + { name = "langchain-core" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/e6/45ead93ef6d9f244b502a6a32bf9a185b3da1cc7fa8003896a102dadac09/langchain_anthropic-1.7.5.tar.gz", hash = "sha256:6016f38e5c4bf12c16ecfa76abf133a978f459f4d2887b8e28435ae1ff00beb3", size = 764625, upload-time = "2026-09-29T15:22:07.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a8/c507f2c1859b584843983e98bc71e0066c16a77e6c181921c928e0fdbcf7/langchain_anthropic-1.7.5-py3-none-any.whl", hash = "sha256:b21ef0e0c2d6175235a5b7eef4f5f89057df92d42dbfab66920a05605ef7173a", size = 65239, upload-time = "2026-09-29T15:22:05.832Z" }, +] + [[package]] name = "langchain-core" -version = "1.5.3" +version = "1.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx" }, { name = "jsonpatch" }, { name = "langchain-protocol" }, { name = "langsmith" }, @@ -1964,9 +2019,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/3e/63af6b9d76d9be907c7c524d6ec18a2efed7e0e2d123fea0230d78dbd73f/langchain_core-1.5.3.tar.gz", hash = "sha256:a56457ac444fef41e9404443c187f0ecea708d36e816ea4ba9573c027f7d1a2d", size = 972461, upload-time = "2026-07-30T14:55:55.833Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/d3/e9611e44e62ab8175127e137587c62dc2ed00e2615aebd89dfdb77f5fba0/langchain_core-1.6.9.tar.gz", hash = "sha256:34394228603e6f0bd24ce4e773b58b7d160e1b8cf489ecb350cf41f5366f5e92", size = 1010051, upload-time = "2026-10-08T20:11:19.881Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/e6/c7c39efe0bc7e1b7c3d8f54f85846e04c901913c3d3e99068b218558c6f1/langchain_core-1.5.3-py3-none-any.whl", hash = "sha256:48b56fa580277209594dd7baf837f5b9a2a3651613f34ff9fb1728b429df015f", size = 561687, upload-time = "2026-07-30T14:55:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/fdcdd869c052874b17e2325c986cf8c2fc6bbefa3b9d5404dd64dfffb12a/langchain_core-1.6.9-py3-none-any.whl", hash = "sha256:2bacea12270fd4cfdeeefdc5977e7e8b3c30814c509534d1ef43f4e5b7d3f657", size = 572951, upload-time = "2026-10-08T20:11:18.189Z" }, ] [[package]] @@ -4732,6 +4787,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.23.1" diff --git a/docs/wiki/INDEX.md b/docs/wiki/INDEX.md index 3eabe09de..89a96f8c9 100644 --- a/docs/wiki/INDEX.md +++ b/docs/wiki/INDEX.md @@ -22,6 +22,7 @@ Deep design narrative lives in `docs/architecture/*.md`; open those only for des - [modules/knowledge-base.md](modules/knowledge-base.md) — documents, collections, transforms, vector-store providers. Deep dive: `docs/architecture/kaapi-knowledge-base-ARCHITECTURE.md` - [modules/responses.md](modules/responses.md) — OpenAI Responses API integration, conversations, threads, assistants. No deep-dive doc yet. - [modules/assessment.md](modules/assessment.md) — assessments and assessment runs. No deep-dive doc yet. +- [modules/chatbot.md](modules/chatbot.md) — v0 stateless prompt-driven chatbot (`POST /chatbot/message`, LangGraph + Anthropic, model driven by a saved config). No deep-dive doc yet. - [modules/tenancy.md](modules/tenancy.md) — users, orgs, projects, API keys, onboarding, login. - [modules/platform.md](modules/platform.md) — analytics, notifications, feature flags, languages, credentials, model config, cron. diff --git a/docs/wiki/domain-map.md b/docs/wiki/domain-map.md index 788e2c42f..412da4a73 100644 --- a/docs/wiki/domain-map.md +++ b/docs/wiki/domain-map.md @@ -23,8 +23,8 @@ APIKey → Organization, Project, User # programmatic access | User | user.py | — | UserProject, APIKey, Notification | | APIKey | api_key.py | Organization, Project, User | auth dependency on every API route (logical) | | Credential | credentials.py | Organization, Project | provider clients: OpenAI/Gemini/Anthropic calls (logical); bucket providers (`google-gcp` → GCS signing, logical) | -| Config | config/config.py | Project | ConfigVersion; LLM call path; Assessment; EvaluationRun (`config_id`) | -| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references (logical) | +| Config | config/config.py | Project | ConfigVersion; LLM call path; Chatbot turn (`POST /chatbot/message`, logical); Assessment; EvaluationRun (`config_id`) | +| ConfigVersion | config/version.py | Config | resolved by `LLMCallConfig` saved references and chatbot `config_id`/`config_version` (logical) | | LlmCall | llm/request.py | Job, LlmChain, Org, Project | Langfuse traces (logical); analytics | | LlmChain | llm/request.py | Org, Project | LlmCall | | Job | job.py | Project | LlmCall; Assessment (RESPONSE method); Celery job execution (logical); evaluation prompt improvement (`JobType.PROMPT_IMPROVEMENT`, logical) | diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md new file mode 100644 index 000000000..261046755 --- /dev/null +++ b/docs/wiki/modules/chatbot.md @@ -0,0 +1,23 @@ +# Module: Chatbot + +v0 prompt-driven chatbot: one LangGraph node over a LangChain chat model (Anthropic). Stateless — the caller resends `system_prompt` + full `history` every turn. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. No deep-dive doc yet. + +All paths relative to `backend/app/`. + +## Routes +- `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope) + +## Tables (SQLModel) +None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model. + +## Services / CRUD +- `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs) +- `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization +- `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model` +- `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted) + +## Async +- None; synchronous request/response, not Celery. + +## External +- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting). diff --git a/docs/wiki/modules/platform.md b/docs/wiki/modules/platform.md index 52949fb35..a1e5c3ffa 100644 --- a/docs/wiki/modules/platform.md +++ b/docs/wiki/modules/platform.md @@ -46,3 +46,16 @@ single source of truth: Adding a provider = add the enum member, the model, and its `PROVIDER_CONFIGS` entry; nothing else needs to know. + +## Model config catalog + +`global.model_config` rows are seeded by migrations (052 OpenAI, 063 STT/TTS, +066 `google-aistudio` copies, 086 Anthropic `claude-sonnet-4-6` / +`claude-opus-5-5`). `GET /models/providers` lists only providers that have an +active row, so a provider with no rows is invisible to the config UI. + +A `config.effort` key marks a reasoning model (`is_reasoning_model`): the OpenAI +mapper routes `effort` into `reasoning`, and config-version updates strip +`temperature`. A missing (provider, model) row only logs a warning at config +save — validation is liberal — but the model then gets no effort handling and +no cost estimate (`pricing` NULL also means no estimate). From e0130e8c8ac67eba2ac5659c9ff2b7b1c90333c7 Mon Sep 17 00:00:00 2001 From: Ayush <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:38:58 +0530 Subject: [PATCH 02/15] feat: Chatbot State Management (#1301) --- .../087_seed_anthropic_text_model_configs.py | 45 ++++ .../versions/088_add_chatbot_session.py | 161 ++++++++++++++ .../app/api/docs/chatbot/create_session.md | 5 + backend/app/api/docs/chatbot/list_sessions.md | 3 + .../api/docs/chatbot/send_session_message.md | 5 + backend/app/api/docs/openapi_config.py | 3 - backend/app/api/routes/chatbot/__init__.py | 8 + backend/app/api/routes/chatbot/message.py | 52 +++++ backend/app/api/routes/chatbot/session.py | 165 +++++++++++++++ backend/app/celery/tasks/job_execution.py | 30 ++- backend/app/celery/utils.py | 24 +++ backend/app/crud/chatbot.py | 114 ++++++++++ backend/app/models/__init__.py | 9 + backend/app/models/chatbot.py | 195 ++++++++++++++++- backend/app/services/chatbot/config.py | 32 +-- backend/app/services/chatbot/engine.py | 97 +-------- backend/app/services/chatbot/exceptions.py | 6 + backend/app/services/chatbot/session.py | 199 ++++++++++++++++++ backend/app/services/chatbot/types.py | 10 + backend/app/services/chatbot/utils.py | 13 +- docs/wiki/modules/chatbot.md | 2 + 21 files changed, 1036 insertions(+), 142 deletions(-) create mode 100644 backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py create mode 100644 backend/app/alembic/versions/088_add_chatbot_session.py create mode 100644 backend/app/api/docs/chatbot/create_session.md create mode 100644 backend/app/api/docs/chatbot/list_sessions.md create mode 100644 backend/app/api/docs/chatbot/send_session_message.md create mode 100644 backend/app/api/routes/chatbot/__init__.py create mode 100644 backend/app/api/routes/chatbot/message.py create mode 100644 backend/app/api/routes/chatbot/session.py create mode 100644 backend/app/crud/chatbot.py create mode 100644 backend/app/services/chatbot/exceptions.py create mode 100644 backend/app/services/chatbot/session.py create mode 100644 backend/app/services/chatbot/types.py diff --git a/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py new file mode 100644 index 000000000..1d864a12f --- /dev/null +++ b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py @@ -0,0 +1,45 @@ +"""seed anthropic text model_config rows + +Revision ID: 087 +Revises: 086 +Create Date: 2026-10-09 00:00:00.000000 +""" + +from alembic import op + +revision = "087" +down_revision = "086" +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute( + """ + INSERT INTO global.model_config + (provider, model_name, completion_type, config, input_modalities, + output_modalities, pricing, is_active, inserted_at, updated_at) + VALUES + ('anthropic', 'claude-sonnet-4-6', '{text}', + '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', + '{TEXT,IMAGE,FILES}', '{TEXT}', + '{"response": {"input_token_cost": 3, "output_token_cost": 15}, "batch": {"input_token_cost": 1.5, "output_token_cost": 7.5}}', + true, NOW(), NOW()), + ('anthropic', 'claude-opus-5-5', '{text}', + '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "xhigh", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', + '{TEXT,IMAGE,FILES}', '{TEXT}', + NULL, + true, NOW(), NOW()) + ON CONFLICT (provider, model_name) DO NOTHING + """ + ) + + +def downgrade(): + op.execute( + """ + DELETE FROM global.model_config + WHERE provider = 'anthropic' + AND model_name IN ('claude-sonnet-4-6', 'claude-opus-5-5') + """ + ) diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py new file mode 100644 index 000000000..367a924ce --- /dev/null +++ b/backend/app/alembic/versions/088_add_chatbot_session.py @@ -0,0 +1,161 @@ +"""add chatbot session and message tables + +Revision ID: 088 +Revises: 087 +Create Date: 2026-10-20 00:00:00.000000 +""" + +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "088" +down_revision = "087" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "chatbot_session", + sa.Column( + "id", + sa.Uuid(), + nullable=False, + comment="Session identifier returned to the caller", + ), + sa.Column( + "contact_ref", + sqlmodel.sql.sqltypes.AutoString(length=500), + nullable=False, + comment="Caller-side identifier of the end user (e.g. a contact id); opaque to Kaapi", + ), + sa.Column( + "config_id", + sa.Uuid(), + nullable=False, + comment="Saved Anthropic text config that supplies the chat model", + ), + sa.Column( + "config_version", + sa.Integer(), + nullable=True, + comment="Pinned config version; NULL resolves to the latest version on every turn", + ), + sa.Column( + "system_prompt", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + comment="Admin-authored flow prompt captured at session creation", + ), + sa.Column( + "status", + sa.Enum( + "active", + "inactive", + name="chatbotsessionstatusenum", + ), + nullable=False, + comment="Conversation lifecycle: active or inactive", + ), + sa.Column( + "collected_fields", + postgresql.JSON(astext_type=sa.Text()), + nullable=False, + comment="Flat map of field name -> value gathered from the user so far", + ), + sa.Column( + "organization_id", + sa.Integer(), + nullable=False, + comment="Reference to the organization", + ), + sa.Column( + "project_id", + sa.Integer(), + nullable=False, + comment="Reference to the project", + ), + sa.Column( + "inserted_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the session was created", + ), + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the session was last updated", + ), + sa.ForeignKeyConstraint(["config_id"], ["config.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["organization_id"], ["organization.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint(["project_id"], ["project.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_chatbot_session_config_id"), + "chatbot_session", + ["config_id"], + unique=False, + ) + op.create_index( + op.f("ix_chatbot_session_organization_id"), + "chatbot_session", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_chatbot_session_project_id"), + "chatbot_session", + ["project_id"], + unique=False, + ) + + op.create_table( + "chatbot_session_message", + sa.Column("id", sa.Uuid(), nullable=False, comment="Unique identifier"), + sa.Column( + "session_id", + sa.Uuid(), + nullable=False, + comment="Reference to the owning chatbot session", + ), + sa.Column( + "role", + sa.Enum("system", "user", "assistant", name="chatbotmessageroleenum"), + nullable=False, + comment="Speaker: user or assistant (system is reserved; the prompt lives on the session)", + ), + sa.Column( + "content", + sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + comment="Message text", + ), + sa.Column( + "inserted_at", + sa.DateTime(), + nullable=False, + comment="Timestamp when the message was stored; defines transcript order", + ), + sa.ForeignKeyConstraint( + ["session_id"], ["chatbot_session.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_chatbot_session_message_session_id"), + "chatbot_session_message", + ["session_id"], + unique=False, + ) + + +def downgrade(): + raise NotImplementedError( + "not reversible: dropping chatbot_session tables discards stored conversations" + ) diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md new file mode 100644 index 000000000..543966e2d --- /dev/null +++ b/backend/app/api/docs/chatbot/create_session.md @@ -0,0 +1,5 @@ +Start a server-side chatbot conversation and return its `session_id`. + +The `system_prompt` and the `config_id` / `config_version` pin are stored on the session, so later turns only send the new user message. `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set; when `config_version` is omitted, each turn uses the config's latest version at that time. + +`contact_ref` is the caller's own identifier for the end user and is stored as-is. Returns 404 if the config or version isn't found in the project and 422 if it isn't a usable Anthropic text config. diff --git a/backend/app/api/docs/chatbot/list_sessions.md b/backend/app/api/docs/chatbot/list_sessions.md new file mode 100644 index 000000000..202480359 --- /dev/null +++ b/backend/app/api/docs/chatbot/list_sessions.md @@ -0,0 +1,3 @@ +List every chatbot session for the given `contact_ref` in the caller's project, most recent first. + +`contact_ref` is matched exactly against the value supplied when the session was created. Each entry carries the session's current `status` and the `collected_fields` gathered so far. Returns an empty list when the contact has no sessions in this project. diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md new file mode 100644 index 000000000..237e0fa97 --- /dev/null +++ b/backend/app/api/docs/chatbot/send_session_message.md @@ -0,0 +1,5 @@ +Queue one turn of a chatbot session; the bot reply is delivered asynchronously to `webhook_url`. + +The user `message` is stored immediately and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "reply", "status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. + +On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project and 422 for an invalid `webhook_url` or a missing `message` on a started session. diff --git a/backend/app/api/docs/openapi_config.py b/backend/app/api/docs/openapi_config.py index 654fb8ed0..87de7c47c 100644 --- a/backend/app/api/docs/openapi_config.py +++ b/backend/app/api/docs/openapi_config.py @@ -8,9 +8,6 @@ from app.api.docs.guardrails_schemas import GUARDRAILS_COMPONENT_SCHEMAS from app.api.openapi_docs import inject_component_schemas, register_component_schemas -# Schemas for routes that document a contract they do not validate (the -# guardrails proxy). Registered at import so they are in `components` before -# any `$ref` to them is resolved. register_component_schemas(GUARDRAILS_COMPONENT_SCHEMAS) # Tag metadata for organizing endpoints in documentation diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py new file mode 100644 index 000000000..151b59066 --- /dev/null +++ b/backend/app/api/routes/chatbot/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.api.routes.chatbot.message import router as message_router +from app.api.routes.chatbot.session import router as session_router + +router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) +router.include_router(message_router) +router.include_router(session_router) diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py new file mode 100644 index 000000000..9a5368258 --- /dev/null +++ b/backend/app/api/routes/chatbot/message.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse +from app.services.chatbot.config import resolve_chatbot_model_settings +from app.services.chatbot.engine import run_chatbot_turn +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.utils import APIResponse, load_description + +router = APIRouter() + + +@router.post( + "/message", + description=load_description("chatbot/message.md"), + response_model=APIResponse[ChatbotTurnResponse], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +async def send_chatbot_message( + request: ChatbotTurnRequest, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[ChatbotTurnResponse]: + history: list[dict[str, str]] = [] + for entry in request.history: + history.append(entry.model_dump(mode="json")) + + try: + model_settings = resolve_chatbot_model_settings( + session=session, + project_id=auth_context.project_.id, + config_id=request.config_id, + config_version=request.config_version, + max_tokens=request.max_tokens, + history_token_limit=request.history_token_limit, + ) + transcript = await run_chatbot_turn( + system_prompt=request.system_prompt, + history=history, + user_message=request.message, + model_settings=model_settings, + ) + except ChatbotInputError as e: + raise HTTPException(status_code=422, detail=str(e)) + except ChatbotProviderError as e: + raise HTTPException(status_code=502, detail=str(e)) + + messages: list[ChatbotMessage] = [] + for entry in transcript: + messages.append(ChatbotMessage.model_validate(entry)) + return APIResponse.success_response(ChatbotTurnResponse(messages=messages)) diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py new file mode 100644 index 000000000..0ea7d60f1 --- /dev/null +++ b/backend/app/api/routes/chatbot/session.py @@ -0,0 +1,165 @@ +from typing import Annotated +from uuid import UUID + +from asgi_correlation_id import correlation_id +from fastapi import APIRouter, Depends, HTTPException, Path, Query + +from app.api.deps import AuthContextDep, SessionDep +from app.api.permissions import Permission, require_permission +from app.celery.utils import start_chatbot_session_message +from app.crud.chatbot import ( + create_chatbot_session, + create_chatbot_session_message, + get_chatbot_session, + list_chatbot_session_messages, + list_chatbot_sessions_by_contact_ref, +) +from app.models import ( + CONTACT_REF_MAX_LENGTH, + ChatbotMessageRoleEnum, + ChatbotSessionSummary, + CreateChatbotSessionRequest, + CreateChatbotSessionResponse, + SendChatbotSessionMessageRequest, + SendChatbotSessionMessageResponse, +) +from app.services.chatbot.config import resolve_chatbot_model_settings +from app.services.chatbot.exceptions import ChatbotInputError +from app.utils import APIResponse, load_description, validate_callback_url + +DEFAULT_TRACE_ID = "N/A" + +router = APIRouter() + + +@router.post( + "/sessions", + status_code=201, + description=load_description("chatbot/create_session.md"), + response_model=APIResponse[CreateChatbotSessionResponse], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def start_chatbot_session( + request: CreateChatbotSessionRequest, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[CreateChatbotSessionResponse]: + project_id = auth_context.project_.id + try: + resolve_chatbot_model_settings( + session=session, + project_id=project_id, + config_id=request.config_id, + config_version=request.config_version, + max_tokens=None, + history_token_limit=None, + ) + except ChatbotInputError as e: + raise HTTPException(status_code=422, detail=str(e)) + + chatbot_session = create_chatbot_session( + session, + organization_id=auth_context.organization_.id, + project_id=project_id, + contact_ref=request.contact_ref, + config_id=request.config_id, + config_version=request.config_version, + system_prompt=request.system_prompt, + ) + return APIResponse.success_response( + CreateChatbotSessionResponse(session_id=chatbot_session.id) + ) + + +@router.get( + "/sessions", + description=load_description("chatbot/list_sessions.md"), + response_model=APIResponse[list[ChatbotSessionSummary]], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def list_chatbot_sessions( + contact_ref: Annotated[ + str, + Query( + min_length=1, + max_length=CONTACT_REF_MAX_LENGTH, + description="Caller-side identifier of the end user whose sessions to list", + ), + ], + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[list[ChatbotSessionSummary]]: + chatbot_sessions = list_chatbot_sessions_by_contact_ref( + session, + contact_ref=contact_ref, + organization_id=auth_context.organization_.id, + project_id=auth_context.project_.id, + ) + summaries: list[ChatbotSessionSummary] = [] + for chatbot_session in chatbot_sessions: + summary = ChatbotSessionSummary( + session_id=chatbot_session.id, + contact_ref=chatbot_session.contact_ref, + status=chatbot_session.status, + collected_fields=chatbot_session.collected_fields, + config_id=chatbot_session.config_id, + config_version=chatbot_session.config_version, + inserted_at=chatbot_session.inserted_at, + updated_at=chatbot_session.updated_at, + ) + summaries.append(summary) + return APIResponse.success_response(summaries) + + +@router.post( + "/sessions/{session_id}/message", + status_code=202, + description=load_description("chatbot/send_session_message.md"), + response_model=APIResponse[SendChatbotSessionMessageResponse], + dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], +) +def send_chatbot_session_message( + session_id: Annotated[UUID, Path(description="Chatbot session identifier")], + request: SendChatbotSessionMessageRequest, + session: SessionDep, + auth_context: AuthContextDep, +) -> APIResponse[SendChatbotSessionMessageResponse]: + organization_id = auth_context.organization_.id + project_id = auth_context.project_.id + + try: + validate_callback_url(request.webhook_url) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"Invalid webhook_url: {e}") + + chatbot_session = get_chatbot_session( + session, + session_id=session_id, + organization_id=organization_id, + project_id=project_id, + ) + if chatbot_session is None: + raise HTTPException(status_code=404, detail="Chatbot session not found") + + if request.message is None: + if list_chatbot_session_messages(session, session_id=session_id): + raise HTTPException( + status_code=422, + detail="`message` is required once the session has started; omit it only on the first call.", + ) + else: + create_chatbot_session_message( + session, + session_id=session_id, + role=ChatbotMessageRoleEnum.USER, + content=request.message, + ) + + start_chatbot_session_message( + session_id=str(session_id), + organization_id=organization_id, + project_id=project_id, + webhook_url=request.webhook_url, + trace_id=correlation_id.get() or DEFAULT_TRACE_ID, + ) + return APIResponse.success_response(SendChatbotSessionMessageResponse()) diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py index 1288876a8..968b88d5b 100644 --- a/backend/app/celery/tasks/job_execution.py +++ b/backend/app/celery/tasks/job_execution.py @@ -3,7 +3,8 @@ All tasks share one queue (`default`, declared with `x-max-priority=10`) and are ordered by the per-task `priority`: - 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job) + 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job, + run_chatbot_session_message) 6 Fast evaluation (run_evaluation_fast_chunk, run_evaluation_fast_aggregate, run_prompt_improvement, run_evaluation_iteration_graph_step) 2 Everything else (doctransform, collections, STT/TTS evaluation, assessment) @@ -15,6 +16,7 @@ import logging from collections.abc import Callable from typing import TYPE_CHECKING, TypeVar +from uuid import UUID from asgi_correlation_id import correlation_id from celery import Task, current_task @@ -567,3 +569,29 @@ def send_eval_completion_callback( ) return execute_eval_completion_callback(evaluation_id=evaluation_id) + + +@celery_app.task(bind=True, queue="default", priority=9) +@gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_chatbot_session_message") +def run_chatbot_session_message( + self: Task, + session_id: str, + organization_id: int, + project_id: int, + webhook_url: str, + trace_id: str, +) -> None: + """Idempotency: the service skips the turn if the transcript already ends + on an assistant reply, so a redelivery doesn't re-bill or double-reply.""" + from app.services.chatbot.session import process_chatbot_session_message + + _set_trace(trace_id) + return _run_with_otel_parent( + self, + lambda: process_chatbot_session_message( + session_id=UUID(session_id), + organization_id=organization_id, + project_id=project_id, + webhook_url=webhook_url, + ), + ) diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py index 3bd205d22..88285c0bb 100644 --- a/backend/app/celery/utils.py +++ b/backend/app/celery/utils.py @@ -337,6 +337,30 @@ def start_evaluation_iteration_round( return task_id +def start_chatbot_session_message( + session_id: str, + organization_id: int, + project_id: int, + webhook_url: str, + trace_id: str = "N/A", +) -> str: + from app.celery.tasks.job_execution import run_chatbot_session_message + + task_id = _enqueue_with_trace_context( + run_chatbot_session_message, + session_id=session_id, + organization_id=organization_id, + project_id=project_id, + webhook_url=webhook_url, + trace_id=trace_id, + ) + logger.info( + f"[start_chatbot_session_message] Enqueued chatbot turn | " + f"session_id: {session_id}, task_id: {task_id}" + ) + return task_id + + def get_task_status(task_id: str) -> Dict[str, Any]: result = AsyncResult(task_id) return { diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py new file mode 100644 index 000000000..3b1d10304 --- /dev/null +++ b/backend/app/crud/chatbot.py @@ -0,0 +1,114 @@ +import logging +from uuid import UUID + +from sqlmodel import Session, col, select + +from app.core.util import now +from app.models import ( + ChatbotMessageRoleEnum, + ChatbotSession, + ChatbotSessionMessage, +) + +logger = logging.getLogger(__name__) + + +def create_chatbot_session( + session: Session, + *, + organization_id: int, + project_id: int, + contact_ref: str, + config_id: UUID, + config_version: int | None, + system_prompt: str, +) -> ChatbotSession: + timestamp = now() + chatbot_session = ChatbotSession( + organization_id=organization_id, + project_id=project_id, + contact_ref=contact_ref, + config_id=config_id, + config_version=config_version, + system_prompt=system_prompt, + inserted_at=timestamp, + updated_at=timestamp, + ) + session.add(chatbot_session) + session.commit() + session.refresh(chatbot_session) + logger.info( + f"[create_chatbot_session] Chatbot session created | " + f"session_id: {chatbot_session.id}, project_id: {project_id}, config_id: {config_id}" + ) + return chatbot_session + + +def get_chatbot_session( + session: Session, + *, + session_id: UUID, + organization_id: int, + project_id: int, +) -> ChatbotSession | None: + statement = select(ChatbotSession).where( + ChatbotSession.id == session_id, + ChatbotSession.organization_id == organization_id, + ChatbotSession.project_id == project_id, + ) + return session.exec(statement).first() + + +def create_chatbot_session_message( + session: Session, + *, + session_id: UUID, + role: ChatbotMessageRoleEnum, + content: str, +) -> ChatbotSessionMessage: + message = ChatbotSessionMessage( + session_id=session_id, + role=role, + content=content, + inserted_at=now(), + ) + session.add(message) + session.commit() + session.refresh(message) + logger.info( + f"[create_chatbot_session_message] Chatbot message stored | " + f"session_id: {session_id}, role: {role.value}, message_id: {message.id}" + ) + return message + + +def list_chatbot_session_messages( + session: Session, *, session_id: UUID +) -> list[ChatbotSessionMessage]: + # Unpaginated on purpose: the engine needs the whole transcript each turn + # and summarizes long histories itself. + statement = ( + select(ChatbotSessionMessage) + .where(ChatbotSessionMessage.session_id == session_id) + .order_by(col(ChatbotSessionMessage.inserted_at).asc()) + ) + return list(session.exec(statement).all()) + + +def list_chatbot_sessions_by_contact_ref( + session: Session, + *, + contact_ref: str, + organization_id: int, + project_id: int, +) -> list[ChatbotSession]: + statement = ( + select(ChatbotSession) + .where( + ChatbotSession.contact_ref == contact_ref, + ChatbotSession.organization_id == organization_id, + ChatbotSession.project_id == project_id, + ) + .order_by(col(ChatbotSession.inserted_at).desc()) + ) + return list(session.exec(statement).all()) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e13fe062a..dc7f2e3be 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -36,10 +36,19 @@ BatchJobUpdate, ) from .chatbot import ( + CONTACT_REF_MAX_LENGTH, ChatbotMessage, ChatbotMessageRoleEnum, + ChatbotSession, + ChatbotSessionMessage, + ChatbotSessionStatusEnum, + ChatbotSessionSummary, ChatbotTurnRequest, ChatbotTurnResponse, + CreateChatbotSessionRequest, + CreateChatbotSessionResponse, + SendChatbotSessionMessageRequest, + SendChatbotSessionMessageResponse, ) from .collection import ( Collection, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index c06a6540b..a5cfe381b 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -1,8 +1,14 @@ +from datetime import datetime from enum import StrEnum -from uuid import UUID +from typing import Any +from uuid import UUID, uuid4 +from sqlalchemy import JSON, Column +from sqlalchemy.dialects.postgresql import ENUM from sqlmodel import Field, SQLModel +from app.core.util import now + class ChatbotMessageRoleEnum(StrEnum): SYSTEM = "system" @@ -34,7 +40,6 @@ class ChatbotTurnRequest(SQLModel): gt=0, description="Token size of the history above which older turns are summarized; omit for the server default.", ) - # Required every call in v0: there is no server-side storage of the flow prompt yet. system_prompt: str = Field( min_length=1, description="Admin-authored flow prompt (goal, fields to collect, rules).", @@ -54,3 +59,189 @@ class ChatbotTurnResponse(SQLModel): messages: list[ChatbotMessage] = Field( description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.", ) + + +class ChatbotSessionStatusEnum(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +_SESSION_STATUS_PG_ENUM = ENUM( + ChatbotSessionStatusEnum, + name="chatbotsessionstatusenum", + values_callable=lambda enum_cls: [member.value for member in enum_cls], + create_type=False, +) +_MESSAGE_ROLE_PG_ENUM = ENUM( + ChatbotMessageRoleEnum, + name="chatbotmessageroleenum", + values_callable=lambda enum_cls: [member.value for member in enum_cls], + create_type=False, +) + +CONTACT_REF_MAX_LENGTH = 500 + + +class ChatbotSession(SQLModel, table=True): + __tablename__ = "chatbot_session" + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"comment": "Session identifier returned to the caller"}, + ) + contact_ref: str = Field( + max_length=CONTACT_REF_MAX_LENGTH, + sa_column_kwargs={ + "comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi" + }, + ) + config_id: UUID = Field( + foreign_key="config.id", + nullable=False, + index=True, + ondelete="RESTRICT", + sa_column_kwargs={ + "comment": "Saved Anthropic text config that supplies the chat model" + }, + ) + config_version: int | None = Field( + default=None, + ge=1, + sa_column_kwargs={ + "comment": "Pinned config version; NULL resolves to the latest version on every turn" + }, + ) + system_prompt: str = Field( + sa_column_kwargs={ + "comment": "Admin-authored flow prompt captured at session creation" + }, + ) + status: ChatbotSessionStatusEnum = Field( + default=ChatbotSessionStatusEnum.ACTIVE, + sa_column=Column( + _SESSION_STATUS_PG_ENUM, + nullable=False, + comment="Conversation lifecycle: active or inactive", + ), + ) + collected_fields: dict[str, Any] = Field( + default_factory=dict, + sa_column=Column( + JSON, + nullable=False, + comment="Flat map of field name -> value gathered from the user so far", + ), + ) + organization_id: int = Field( + foreign_key="organization.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the organization"}, + ) + project_id: int = Field( + foreign_key="project.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the project"}, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={"comment": "Timestamp when the session was created"}, + ) + updated_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={ + "comment": "Timestamp when the session was last updated", + "onupdate": now, + }, + ) + + +class ChatbotSessionMessage(SQLModel, table=True): + __tablename__ = "chatbot_session_message" + + id: UUID = Field( + default_factory=uuid4, + primary_key=True, + sa_column_kwargs={"comment": "Unique identifier"}, + ) + session_id: UUID = Field( + foreign_key="chatbot_session.id", + nullable=False, + index=True, + ondelete="CASCADE", + sa_column_kwargs={"comment": "Reference to the owning chatbot session"}, + ) + role: ChatbotMessageRoleEnum = Field( + sa_column=Column( + _MESSAGE_ROLE_PG_ENUM, + nullable=False, + comment="Speaker: user or assistant (system is reserved; the prompt lives on the session)", + ), + ) + content: str = Field( + sa_column_kwargs={"comment": "Message text"}, + ) + inserted_at: datetime = Field( + default_factory=now, + nullable=False, + sa_column_kwargs={ + "comment": "Timestamp when the message was stored; defines transcript order" + }, + ) + + +class CreateChatbotSessionRequest(SQLModel): + config_id: UUID = Field( + description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.", + ) + config_version: int | None = Field( + default=None, + ge=1, + description="Version of `config_id` to pin; omit to use the latest version on every turn.", + ) + contact_ref: str = Field( + min_length=1, + max_length=CONTACT_REF_MAX_LENGTH, + description="Caller-side identifier of the end user this conversation is with.", + ) + system_prompt: str = Field( + min_length=1, + description="Admin-authored flow prompt (goal, fields to collect, rules); stored for the session's lifetime.", + ) + + +class CreateChatbotSessionResponse(SQLModel): + session_id: UUID + + +class ChatbotSessionSummary(SQLModel): + session_id: UUID + contact_ref: str + status: ChatbotSessionStatusEnum + collected_fields: dict[str, Any] + config_id: UUID + config_version: int | None + inserted_at: datetime + updated_at: datetime + + +class SendChatbotSessionMessageRequest(SQLModel): + message: str | None = Field( + default=None, + min_length=1, + description="The new user message; omit only on the first call to get the opening greeting.", + ) + webhook_url: str = Field( + min_length=1, + description="HTTPS URL the bot reply is POSTed to once the turn completes.", + ) + + +class SendChatbotSessionMessageResponse(SQLModel): + status: str = "queued" diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 9f3d4fc0f..e8f09c666 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -1,4 +1,3 @@ -import logging from typing import get_args from uuid import UUID @@ -10,7 +9,7 @@ from app.models import ConfigVersion from app.models.llm.constants import Provider from app.models.llm.request import ConfigBlob, KaapiTextCompletionConfig -from app.services.chatbot.engine import ChatbotInputError +from app.services.chatbot.exceptions import ChatbotInputError from app.services.chatbot.utils import ( DEFAULT_HISTORY_TOKEN_LIMIT, DEFAULT_MAX_OUTPUT_TOKENS, @@ -19,16 +18,12 @@ default_thinking, ) -logger = logging.getLogger(__name__) - _ANTHROPIC_EFFORT_LEVELS: tuple[AnthropicEffort, ...] = get_args(AnthropicEffort) def _get_config_version( *, session: Session, project_id: int, config_id: UUID, config_version: int | None ) -> ConfigVersion: - # The crud constructor and `exists_or_raise` already 404 when the config or - # version is outside this project, so ownership is enforced here. version_crud = ConfigVersionCrud( session=session, config_id=config_id, project_id=project_id ) @@ -37,10 +32,6 @@ def _get_config_version( latest = version_crud.read_latest() if latest is None: - logger.warning( - f"[_get_config_version] Config has no versions | " - f"config_id: {config_id}, project_id: {project_id}" - ) raise HTTPException( status_code=404, detail=f"No version found for config '{config_id}'", @@ -54,8 +45,6 @@ def _to_anthropic_effort(effort: str | None) -> AnthropicEffort | None: for level in _ANTHROPIC_EFFORT_LEVELS: if level == effort: return level - # Kaapi's "none"/"minimal" have no Anthropic equivalent; dropping them - # silently would let the provider default (often higher) take over. raise ChatbotInputError( f"[KAAPI] Config param 'effort' value '{effort}' is not an Anthropic effort " f"level ({', '.join(_ANTHROPIC_EFFORT_LEVELS)}); update the config." @@ -86,11 +75,6 @@ def resolve_chatbot_model_settings( try: blob = ConfigBlob.model_validate(version_row.config_blob) except ValidationError as e: - logger.warning( - f"[resolve_chatbot_model_settings] [KAAPI] Stored config blob is invalid | " - f"config_id: {config_id}, version: {version_row.version}", - exc_info=True, - ) raise ChatbotInputError( f"[KAAPI] Stored configuration blob is invalid: {e}" ) from e @@ -100,11 +84,6 @@ def resolve_chatbot_model_settings( not isinstance(completion, KaapiTextCompletionConfig) or completion.provider != Provider.ANTHROPIC ): - logger.warning( - f"[resolve_chatbot_model_settings] [KAAPI] Unsupported config for chatbot | " - f"config_id: {config_id}, version: {version_row.version}, " - f"provider: {completion.provider}, type: {completion.type}" - ) raise ChatbotInputError( "[KAAPI] Chatbot only supports configs with completion provider " f"'{Provider.ANTHROPIC.value}' and type 'text'; got provider " @@ -113,10 +92,6 @@ def resolve_chatbot_model_settings( params = completion.params if not params.model: - logger.warning( - f"[resolve_chatbot_model_settings] [KAAPI] Config has no model | " - f"config_id: {config_id}, version: {version_row.version}" - ) raise ChatbotInputError( "[KAAPI] Config is missing completion.params.model; set a model on the config." ) @@ -133,9 +108,4 @@ def resolve_chatbot_model_settings( else DEFAULT_HISTORY_TOKEN_LIMIT ), ) - logger.info( - f"[resolve_chatbot_model_settings] Resolved chatbot model settings | " - f"config_id: {config_id}, version: {version_row.version}, " - f"model: {model_settings.model}, effort: {model_settings.reasoning_effort}" - ) return model_settings diff --git a/backend/app/services/chatbot/engine.py b/backend/app/services/chatbot/engine.py index 380bf3eb0..17aeb6ca2 100644 --- a/backend/app/services/chatbot/engine.py +++ b/backend/app/services/chatbot/engine.py @@ -1,40 +1,22 @@ -"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model. - -Unlike a field-by-field collector, this mirrors OCS's own approach: the -admin supplies the full prompt (goal + fields + rules, written as free text -- -the same shape as OCS's `prompt` field) and the model manages the conversation -itself, reading the full message history every turn. There is no field-by-field -state machine in code here. - -No tools in v0 -- OCS's node also lets the admin attach tools (e.g. -set-session-state); that is deliberately left out here and is the natural next -step once this shape is proven. - -No checkpointer: like OCS, there is no LangGraph-level pause/resume. The caller -keeps the running message list itself and passes the full history into -`graph.invoke(...)` on every turn -- a fresh, complete run each time. -""" +"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model.""" import asyncio -import logging -from typing import Annotated, TypedDict import anthropic from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage from langgraph.graph import END, START, StateGraph -from langgraph.graph.message import add_messages from langgraph.graph.state import CompiledStateGraph from app.models.chatbot import ChatbotMessageRoleEnum +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.types import ChatbotMessageDict, ChatbotState from app.services.chatbot.utils import ( FLOW_KEEP_RECENT_MESSAGES, ChatbotModelSettings, get_chat_model, ) -logger = logging.getLogger(__name__) - CHATBOT_NODE = "chatbot_node" _SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " @@ -43,31 +25,9 @@ "user has already given -- this note replaces the original messages." ) -# Anthropic rejects a request whose first non-system turn isn't a user turn, so -# the opening greeting (and every later call, whose history starts with that -# greeting) needs a synthetic user turn in front. It's stripped from the -# returned transcript by this fixed id, so callers never see it. _KICKOFF_MESSAGE_ID = "chatbot-kickoff" _KICKOFF_MESSAGE_CONTENT = "Begin the conversation." -ChatbotMessageDict = dict[str, str] - - -class ChatbotState(TypedDict): - # `add_messages` is LangGraph's prebuilt reducer for LangChain message - # lists: it appends new messages and merges by `.id` instead of naively - # concatenating, which is what lets callers keep re-sending the running - # history without duplicating turns. - messages: Annotated[list[BaseMessage], add_messages] - - -class ChatbotInputError(ValueError): - """The turn request can't produce a valid model call (caller's fault).""" - - -class ChatbotProviderError(Exception): - """The chat-model provider failed or was unreachable.""" - def _summarize_if_needed( chat_model: BaseChatModel, @@ -75,14 +35,7 @@ def _summarize_if_needed( *, history_token_limit: int, ) -> list[BaseMessage]: - """Trim the view sent to the model once history crosses the token budget. - - Simplification: this only shrinks what THIS call sends to Anthropic: the - caller's own running history keeps growing untouched, so a long-enough - conversation re-summarizes from scratch each turn rather than compacting - state permanently. OCS's real history middleware persists the compression - point instead -- worth doing if this gets reused past v0. - """ + """Trim the view sent to the model once history crosses the token budget.""" if len(messages) <= FLOW_KEEP_RECENT_MESSAGES + 1: # +1 for the system message return messages if chat_model.get_num_tokens_from_messages(messages) <= history_token_limit: @@ -93,11 +46,6 @@ def _summarize_if_needed( rest[:-FLOW_KEEP_RECENT_MESSAGES], rest[-FLOW_KEEP_RECENT_MESSAGES:], ) - # Anthropic requires the first non-system turn to be a user turn. `rest` - # always ends on this turn's unanswered human message, so it has odd - # length; cutting an even-sized window from its tail always lands one - # message short, opening on an assistant turn instead. Shift the boundary - # back until `recent` starts on a human message. while recent and to_summarize and not isinstance(recent[0], HumanMessage): recent.insert(0, to_summarize.pop()) if not to_summarize: @@ -106,10 +54,6 @@ def _summarize_if_needed( summary = chat_model.invoke( [SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize] ) - logger.info( - f"[_summarize_if_needed] Compressed history | " - f"from: {len(to_summarize)} messages, to: 1 summary note" - ) summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}") return [system_message, summary_note, *recent] @@ -136,9 +80,6 @@ def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]: def _to_langchain_messages( *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None ) -> list[BaseMessage]: - # `system_prompt` is the single source of the system turn; any system entry - # echoed back in `history` (the previous response includes it) is dropped - # so it isn't sent twice. conversation: list[BaseMessage] = [] for entry in history: role = entry["role"] @@ -175,7 +116,6 @@ def _to_message_dicts( role = ChatbotMessageRoleEnum.ASSISTANT else: continue - # `.text` drops thinking/tool blocks; the wire shape carries text only. transcript.append({"role": role.value, "content": message.text}) return transcript @@ -193,10 +133,6 @@ async def run_chatbot_turn( which yields the bot's opening greeting. """ if user_message is None and history: - logger.warning( - f"[run_chatbot_turn] [KAAPI] Missing user message on a non-initial turn | " - f"history_count: {len(history)}" - ) raise ChatbotInputError( "[KAAPI] `message` is required once `history` is non-empty; omit it only " "on the first call to get the opening greeting." @@ -205,10 +141,6 @@ async def run_chatbot_turn( messages = _to_langchain_messages( system_prompt=system_prompt, history=history, user_message=user_message ) - logger.info( - f"[run_chatbot_turn] Starting turn | history_count: {len(history)}, " - f"has_user_message: {user_message is not None}, model: {model_settings.model}" - ) graph = build_chatbot_graph( get_chat_model(model_settings), @@ -218,33 +150,16 @@ async def run_chatbot_turn( # ChatAnthropic.invoke is sync; keep it off the event loop. result = await asyncio.to_thread(graph.invoke, {"messages": messages}) except anthropic.APITimeoutError as e: - logger.error( - f"[run_chatbot_turn] [KAAPI] Request to Anthropic timed out (code: APITimeoutError) | " - f"provider=anthropic, model={model_settings.model}", - exc_info=True, - ) raise ChatbotProviderError( "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " "(code: APITimeoutError)" ) from e except anthropic.APIConnectionError as e: - logger.error( - f"[run_chatbot_turn] [KAAPI] Could not reach Anthropic (code: APIConnectionError) | " - f"provider=anthropic, model={model_settings.model}", - exc_info=True, - ) raise ChatbotProviderError( "[KAAPI] Network/DNS issue reaching provider — check connectivity. " "If persistent, contact Kaapi. (code: APIConnectionError)" ) from e except anthropic.APIStatusError as e: - # 5xx is provider-side (alert-worthy); 4xx is caller's fault (noise if alerted) - log = logger.error if e.status_code >= 500 else logger.warning - log( - f"[run_chatbot_turn] [ANTHROPIC] {e.message} (code: {e.status_code}) | " - f"provider=anthropic, model={model_settings.model}, request_id={e.request_id}", - exc_info=True, - ) raise ChatbotProviderError( f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, " f"request_id: {e.request_id})" @@ -253,8 +168,4 @@ async def run_chatbot_turn( transcript = _to_message_dicts( system_prompt=system_prompt, messages=result["messages"] ) - logger.info( - f"[run_chatbot_turn] Turn completed | transcript_count: {len(transcript)}, " - f"reply_length: {len(transcript[-1]['content'])}" - ) return transcript diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py new file mode 100644 index 000000000..aac653752 --- /dev/null +++ b/backend/app/services/chatbot/exceptions.py @@ -0,0 +1,6 @@ +class ChatbotInputError(ValueError): + """The turn request can't produce a valid model call (caller's fault).""" + + +class ChatbotProviderError(Exception): + """The chat-model provider failed or was unreachable.""" diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py new file mode 100644 index 000000000..06f899308 --- /dev/null +++ b/backend/app/services/chatbot/session.py @@ -0,0 +1,199 @@ +import asyncio +import logging +from typing import Any +from uuid import UUID + +from fastapi import HTTPException +from sqlmodel import Session + +from app.core.db import engine +from app.crud.chatbot import ( + create_chatbot_session_message, + get_chatbot_session, + list_chatbot_session_messages, +) +from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage +from app.services.chatbot.config import resolve_chatbot_model_settings +from app.services.chatbot.engine import run_chatbot_turn +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.types import ChatbotMessageDict +from app.services.chatbot.utils import ChatbotModelSettings +from app.utils import get_webhook_secret, send_callback + +logger = logging.getLogger(__name__) + + +def _split_pending_turn( + stored_messages: list[ChatbotSessionMessage], +) -> tuple[list[ChatbotMessageDict], str | None]: + """Split the stored transcript into (prior history, pending user message). + + The route persists the user's message before enqueueing, so it is the last + stored row; the engine wants it separately from the history before it. + """ + history: list[ChatbotMessageDict] = [] + for stored in stored_messages: + # The system prompt lives on the session row and is passed separately. + if stored.role == ChatbotMessageRoleEnum.SYSTEM: + continue + history.append({"role": stored.role.value, "content": stored.content}) + + if not history: + return history, None + if history[-1]["role"] != ChatbotMessageRoleEnum.USER: + # No pending user turn; the engine rejects this with ChatbotInputError. + return history, None + pending = history.pop() + return history, pending["content"] + + +def _extract_reply(transcript: list[ChatbotMessageDict]) -> str: + for entry in reversed(transcript): + if entry["role"] == ChatbotMessageRoleEnum.ASSISTANT: + return entry["content"] + raise ChatbotInputError("[KAAPI] Chat model returned no assistant reply.") + + +def _send_failure_webhook( + *, + session_id: UUID, + webhook_url: str, + webhook_secret: str | None, + error: str, +) -> None: + payload: dict[str, Any] = { + "success": False, + "error": error, + "data": {"session_id": str(session_id)}, + } + delivered = send_callback(webhook_url, payload, webhook_secret) + logger.info( + f"[_send_failure_webhook] Failure webhook dispatched | " + f"session_id: {session_id}, delivered: {delivered}" + ) + + +def process_chatbot_session_message( + *, + session_id: UUID, + organization_id: int, + project_id: int, + webhook_url: str, +) -> None: + """Run the pending chatbot turn for a session and POST the reply to `webhook_url`. + + Idempotent under Celery redelivery: if the transcript already ends on an + assistant turn, the pending turn was answered and nothing is re-run. + """ + webhook_secret = get_webhook_secret(project_id, organization_id) + + # The DB session is closed before the LLM call so a slow provider doesn't + # pin a pooled connection for the whole turn. + with Session(engine) as session: + chatbot_session = get_chatbot_session( + session, + session_id=session_id, + organization_id=organization_id, + project_id=project_id, + ) + if chatbot_session is None: + logger.error( + f"[process_chatbot_session_message] Chatbot session not found | " + f"session_id: {session_id}, project_id: {project_id}" + ) + return + + stored_messages = list_chatbot_session_messages(session, session_id=session_id) + if ( + stored_messages + and stored_messages[-1].role == ChatbotMessageRoleEnum.ASSISTANT + ): + logger.warning( + f"[process_chatbot_session_message] Turn already answered, skipping | " + f"session_id: {session_id}" + ) + return + + system_prompt = chatbot_session.system_prompt + session_status = chatbot_session.status.value + collected_fields = dict(chatbot_session.collected_fields) + + try: + model_settings: ChatbotModelSettings = resolve_chatbot_model_settings( + session=session, + project_id=project_id, + config_id=chatbot_session.config_id, + config_version=chatbot_session.config_version, + max_tokens=None, + history_token_limit=None, + ) + except HTTPException as e: + # The config was resolvable at session creation; it has since been + # deleted or lost its version, and the caller only hears via webhook. + logger.warning( + f"[process_chatbot_session_message] Config no longer resolvable | " + f"session_id: {session_id}, detail: {e.detail}" + ) + _send_failure_webhook( + session_id=session_id, + webhook_url=webhook_url, + webhook_secret=webhook_secret, + error=str(e.detail), + ) + return + except ChatbotInputError as e: + _send_failure_webhook( + session_id=session_id, + webhook_url=webhook_url, + webhook_secret=webhook_secret, + error=str(e), + ) + return + + history, user_message = _split_pending_turn(stored_messages) + logger.info( + f"[process_chatbot_session_message] Running turn | session_id: {session_id}, " + f"history_count: {len(history)}, has_user_message: {user_message is not None}" + ) + + try: + transcript = asyncio.run( + run_chatbot_turn( + system_prompt=system_prompt, + history=history, + user_message=user_message, + model_settings=model_settings, + ) + ) + reply = _extract_reply(transcript) + except (ChatbotInputError, ChatbotProviderError) as e: + _send_failure_webhook( + session_id=session_id, + webhook_url=webhook_url, + webhook_secret=webhook_secret, + error=str(e), + ) + return + + with Session(engine) as session: + create_chatbot_session_message( + session, + session_id=session_id, + role=ChatbotMessageRoleEnum.ASSISTANT, + content=reply, + ) + + payload: dict[str, Any] = { + "success": True, + "data": { + "session_id": str(session_id), + "reply": reply, + "status": session_status, + "collected_fields": collected_fields, + }, + } + delivered = send_callback(webhook_url, payload, webhook_secret) + logger.info( + f"[process_chatbot_session_message] Turn completed | session_id: {session_id}, " + f"reply_length: {len(reply)}, webhook_delivered: {delivered}" + ) diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py new file mode 100644 index 000000000..7746f215d --- /dev/null +++ b/backend/app/services/chatbot/types.py @@ -0,0 +1,10 @@ +from typing import Annotated, TypedDict + +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages + +ChatbotMessageDict = dict[str, str] + + +class ChatbotState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] diff --git a/backend/app/services/chatbot/utils.py b/backend/app/services/chatbot/utils.py index 2ec9571a1..d4831b46f 100644 --- a/backend/app/services/chatbot/utils.py +++ b/backend/app/services/chatbot/utils.py @@ -1,10 +1,4 @@ -"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node. - -Like OCS's `LLMResponseWithPrompt` node, the model and reasoning settings come -from per-request input -- here a saved Kaapi config (see -`services/chatbot/config.py`) plus optional token overrides on the request. -Only Anthropic is wired up, since the node's chat model is `ChatAnthropic`. -""" +"""Model/runtime settings for the v0 LangGraph + LangChain chatbot node.""" from dataclasses import dataclass, field from typing import Any, Literal @@ -18,15 +12,11 @@ DEFAULT_MAX_OUTPUT_TOKENS = 1024 DEFAULT_THINKING: dict[str, Any] = {"type": "adaptive"} -# OCS's `history_mode=summarize`, simplified: once the running history crosses -# this many tokens, everything except the system prompt and the most recent -# turns is summarized into one note before the next model call. DEFAULT_HISTORY_TOKEN_LIMIT = 4000 FLOW_KEEP_RECENT_MESSAGES = 6 def default_thinking() -> dict[str, Any]: - # Fresh copy so one request can't mutate the module-level default. return dict(DEFAULT_THINKING) @@ -34,7 +24,6 @@ def default_thinking() -> dict[str, Any]: class ChatbotModelSettings: model: str max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS - # None lets the provider pick its own default effort. reasoning_effort: AnthropicEffort | None = None thinking: dict[str, Any] = field(default_factory=default_thinking) history_token_limit: int = DEFAULT_HISTORY_TOKEN_LIMIT diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 261046755..8023c2521 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -6,6 +6,7 @@ All paths relative to `backend/app/`. ## Routes - `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope) +- `api/routes/chatbot/session.py` — `GET /chatbot/sessions?contact_ref=` (all sessions for a contact in the caller's project, newest first → `list[ChatbotSessionSummary]`) ## Tables (SQLModel) None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model. @@ -14,6 +15,7 @@ None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMess - `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs) - `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization - `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model` +- `crud/chatbot.py` — `list_chatbot_sessions_by_contact_ref` (org + project scoped, `inserted_at` desc, unpaginated) - `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted) ## Async From 8d9919c7e8ca5a47ceaba9a776e6941d7a42360f Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:52:51 +0530 Subject: [PATCH 03/15] fix: test cases updates --- backend/app/api/routes/chatbot.py | 64 ---- .../app/tests/api/routes/chatbot/__init__.py | 0 .../tests/api/routes/chatbot/test_message.py | 109 ++++++ .../tests/api/routes/chatbot/test_session.py | 316 ++++++++++++++++ .../app/tests/celery/test_job_execution.py | 30 ++ backend/app/tests/celery/test_utils.py | 36 ++ backend/app/tests/crud/config/test_version.py | 54 +++ backend/app/tests/crud/test_chatbot.py | 217 +++++++++++ .../app/tests/services/chatbot/__init__.py | 0 .../app/tests/services/chatbot/test_config.py | 185 ++++++++++ .../app/tests/services/chatbot/test_engine.py | 259 +++++++++++++ .../tests/services/chatbot/test_session.py | 340 ++++++++++++++++++ .../app/tests/services/chatbot/test_utils.py | 65 ++++ backend/app/tests/utils/chatbot.py | 96 +++++ backend/app/tests/utils/test_data.py | 13 + 15 files changed, 1720 insertions(+), 64 deletions(-) delete mode 100644 backend/app/api/routes/chatbot.py create mode 100644 backend/app/tests/api/routes/chatbot/__init__.py create mode 100644 backend/app/tests/api/routes/chatbot/test_message.py create mode 100644 backend/app/tests/api/routes/chatbot/test_session.py create mode 100644 backend/app/tests/celery/test_utils.py create mode 100644 backend/app/tests/crud/test_chatbot.py create mode 100644 backend/app/tests/services/chatbot/__init__.py create mode 100644 backend/app/tests/services/chatbot/test_config.py create mode 100644 backend/app/tests/services/chatbot/test_engine.py create mode 100644 backend/app/tests/services/chatbot/test_session.py create mode 100644 backend/app/tests/services/chatbot/test_utils.py create mode 100644 backend/app/tests/utils/chatbot.py diff --git a/backend/app/api/routes/chatbot.py b/backend/app/api/routes/chatbot.py deleted file mode 100644 index 454f4853b..000000000 --- a/backend/app/api/routes/chatbot.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging - -from fastapi import APIRouter, Depends, HTTPException - -from app.api.deps import AuthContextDep, SessionDep -from app.api.permissions import Permission, require_permission -from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse -from app.services.chatbot.engine import ( - ChatbotInputError, - ChatbotProviderError, - run_chatbot_turn, -) -from app.services.chatbot.config import resolve_chatbot_model_settings -from app.utils import APIResponse, load_description - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) - - -# Synchronous by design (real-time request/response turn), not a Celery job. -@router.post( - "/message", - description=load_description("chatbot/message.md"), - response_model=APIResponse[ChatbotTurnResponse], - dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], -) -async def send_chatbot_message( - request: ChatbotTurnRequest, - session: SessionDep, - auth_context: AuthContextDep, -) -> APIResponse[ChatbotTurnResponse]: - history: list[dict[str, str]] = [] - for entry in request.history: - history.append(entry.model_dump(mode="json")) - - try: - model_settings = resolve_chatbot_model_settings( - session=session, - project_id=auth_context.project_.id, - config_id=request.config_id, - config_version=request.config_version, - max_tokens=request.max_tokens, - history_token_limit=request.history_token_limit, - ) - transcript = await run_chatbot_turn( - system_prompt=request.system_prompt, - history=history, - user_message=request.message, - model_settings=model_settings, - ) - except ChatbotInputError as e: - raise HTTPException(status_code=422, detail=str(e)) - except ChatbotProviderError as e: - logger.warning( - f"[send_chatbot_message] Chatbot turn failed upstream | " - f"project_id: {auth_context.project_.id}" - ) - raise HTTPException(status_code=502, detail=str(e)) - - messages: list[ChatbotMessage] = [] - for entry in transcript: - messages.append(ChatbotMessage.model_validate(entry)) - return APIResponse.success_response(ChatbotTurnResponse(messages=messages)) diff --git a/backend/app/tests/api/routes/chatbot/__init__.py b/backend/app/tests/api/routes/chatbot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py new file mode 100644 index 000000000..5f2be7f19 --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_message.py @@ -0,0 +1,109 @@ +import anthropic +import httpx +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.models import Config +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, +) + +MESSAGE_URL = f"{settings.API_V1_STR}/chatbot/message" +SYSTEM_PROMPT = "Collect the user's name and city." + + +@pytest.fixture +def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config: + return create_test_config( + db, + project_id=user_api_key.project_id, + config_blob=build_anthropic_text_config_blob(), + ) + + +class TestSendChatbotMessage: + def test_returns_updated_transcript( + self, + client: TestClient, + user_api_key: TestAuthContext, + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Nice, which city?"])) + + resp = client.post( + MESSAGE_URL, + headers={"X-API-KEY": user_api_key.key}, + json={ + "config_id": str(anthropic_config.id), + "system_prompt": SYSTEM_PROMPT, + "history": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + ], + "message": "Asha", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["data"]["messages"] == [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + {"role": "user", "content": "Asha"}, + {"role": "assistant", "content": "Nice, which city?"}, + ] + + def test_missing_message_with_history_returns_422( + self, + client: TestClient, + user_api_key: TestAuthContext, + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel() + patch_chat_model(monkeypatch, chat_model) + + resp = client.post( + MESSAGE_URL, + headers={"X-API-KEY": user_api_key.key}, + json={ + "config_id": str(anthropic_config.id), + "system_prompt": SYSTEM_PROMPT, + "history": [{"role": "assistant", "content": "Hi! What's your name?"}], + }, + ) + + assert resp.status_code == 422 + assert "`message` is required" in resp.json()["error"] + assert chat_model.calls == [] + + def test_provider_failure_returns_502( + self, + client: TestClient, + user_api_key: TestAuthContext, + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + patch_chat_model( + monkeypatch, + ScriptedChatModel(error=anthropic.APIConnectionError(request=request)), + ) + + resp = client.post( + MESSAGE_URL, + headers={"X-API-KEY": user_api_key.key}, + json={ + "config_id": str(anthropic_config.id), + "system_prompt": SYSTEM_PROMPT, + "message": "Asha", + }, + ) + + assert resp.status_code == 502 + assert "(code: APIConnectionError)" in resp.json()["error"] diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py new file mode 100644 index 000000000..128ede74f --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -0,0 +1,316 @@ +import socket +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.api.routes.chatbot import session as session_routes +from app.core.config import settings +from app.crud.chatbot import get_chatbot_session, list_chatbot_session_messages +from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config +from app.tests.utils.auth import TestAuthContext +from app.tests.utils.chatbot import ( + add_test_chatbot_messages, + create_test_chatbot_session, +) +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, +) +from app.tests.utils.utils import random_lower_string + +SESSIONS_URL = f"{settings.API_V1_STR}/chatbot/sessions" +WEBHOOK_URL = "https://hooks.example.com/chatbot" +PUBLIC_IP = "93.184.216.34" +SYSTEM_PROMPT = "Collect the user's name and city." + + +def _message_url(session_id: Any) -> str: + return f"{SESSIONS_URL}/{session_id}/message" + + +@pytest.fixture +def headers(user_api_key: TestAuthContext) -> dict[str, str]: + return {"X-API-KEY": user_api_key.key} + + +@pytest.fixture +def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config: + return create_test_config( + db, + project_id=user_api_key.project_id, + config_blob=build_anthropic_text_config_blob(), + ) + + +@pytest.fixture +def chatbot_session(db: Session, anthropic_config: Config) -> ChatbotSession: + return create_test_chatbot_session(db, config=anthropic_config) + + +@pytest.fixture +def enqueued(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + calls: list[dict[str, Any]] = [] + # The Celery broker is the external boundary; patched where the route imported it. + monkeypatch.setattr( + session_routes, + "start_chatbot_session_message", + lambda **kwargs: calls.append(kwargs) or "task-id", + ) + # validate_callback_url resolves DNS; pin it to a public address. + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 443)) + ], + ) + return calls + + +class TestCreateChatbotSession: + def test_creates_session_in_callers_project( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + user_api_key: TestAuthContext, + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + + resp = client.post( + SESSIONS_URL, + headers=headers, + json={ + "config_id": str(anthropic_config.id), + "config_version": 1, + "contact_ref": contact_ref, + "system_prompt": SYSTEM_PROMPT, + }, + ) + + assert resp.status_code == 201 + session_id = resp.json()["data"]["session_id"] + stored = get_chatbot_session( + db, + session_id=session_id, + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert stored is not None + assert stored.contact_ref == contact_ref + assert stored.config_id == anthropic_config.id + assert stored.config_version == 1 + assert stored.system_prompt == SYSTEM_PROMPT + + def test_unknown_config_returns_404( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.post( + SESSIONS_URL, + headers=headers, + json={ + "config_id": str(uuid4()), + "contact_ref": "contact-1", + "system_prompt": SYSTEM_PROMPT, + }, + ) + + assert resp.status_code == 404 + + def test_non_anthropic_config_returns_422( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + user_api_key: TestAuthContext, + ) -> None: + openai_config = create_test_config( + db, project_id=user_api_key.project_id, use_kaapi_schema=True + ) + + resp = client.post( + SESSIONS_URL, + headers=headers, + json={ + "config_id": str(openai_config.id), + "contact_ref": "contact-1", + "system_prompt": SYSTEM_PROMPT, + }, + ) + + assert resp.status_code == 422 + assert "only supports configs" in resp.json()["error"] + + +class TestListChatbotSessions: + def test_lists_contacts_sessions_newest_first_in_callers_project( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + older = create_test_chatbot_session( + db, config=anthropic_config, contact_ref=contact_ref + ) + newer = create_test_chatbot_session( + db, config=anthropic_config, contact_ref=contact_ref, config_version=1 + ) + create_test_chatbot_session(db, config=anthropic_config) + foreign_config = create_test_config( + db, config_blob=build_anthropic_text_config_blob() + ) + create_test_chatbot_session(db, config=foreign_config, contact_ref=contact_ref) + + resp = client.get( + SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref} + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert [s["session_id"] for s in body] == [str(newer.id), str(older.id)] + assert body[0]["contact_ref"] == contact_ref + assert body[0]["status"] == "active" + assert body[0]["collected_fields"] == {} + assert body[0]["config_id"] == str(anthropic_config.id) + assert body[0]["config_version"] == 1 + assert body[1]["config_version"] is None + + def test_missing_contact_ref_returns_422( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.get(SESSIONS_URL, headers=headers) + + assert resp.status_code == 422 + + +class TestSendChatbotSessionMessage: + def test_stores_user_message_and_queues_turn( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + user_api_key: TestAuthContext, + chatbot_session: ChatbotSession, + enqueued: list[dict[str, Any]], + ) -> None: + resp = client.post( + _message_url(chatbot_session.id), + headers=headers, + json={"message": "Asha", "webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 202 + assert resp.json()["data"] == {"status": "queued"} + stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) + assert [(m.role, m.content) for m in stored] == [ + (ChatbotMessageRoleEnum.USER, "Asha") + ] + [job] = enqueued + assert job["session_id"] == str(chatbot_session.id) + assert job["organization_id"] == user_api_key.organization_id + assert job["project_id"] == user_api_key.project_id + assert job["webhook_url"] == WEBHOOK_URL + + def test_omitted_message_on_first_call_queues_greeting( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot_session: ChatbotSession, + enqueued: list[dict[str, Any]], + ) -> None: + resp = client.post( + _message_url(chatbot_session.id), + headers=headers, + json={"webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 202 + assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] + assert [job["session_id"] for job in enqueued] == [str(chatbot_session.id)] + + def test_omitted_message_after_session_started_returns_422( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot_session: ChatbotSession, + enqueued: list[dict[str, Any]], + ) -> None: + add_test_chatbot_messages( + db, + chatbot_session, + [(ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?")], + ) + + resp = client.post( + _message_url(chatbot_session.id), + headers=headers, + json={"webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 422 + assert "`message` is required" in resp.json()["error"] + assert enqueued == [] + + def test_unknown_session_returns_404( + self, + client: TestClient, + headers: dict[str, str], + enqueued: list[dict[str, Any]], + ) -> None: + resp = client.post( + _message_url(uuid4()), + headers=headers, + json={"message": "Asha", "webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 404 + assert enqueued == [] + + def test_session_in_other_project_returns_404( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + enqueued: list[dict[str, Any]], + ) -> None: + foreign_config = create_test_config( + db, config_blob=build_anthropic_text_config_blob() + ) + foreign_session = create_test_chatbot_session(db, config=foreign_config) + + resp = client.post( + _message_url(foreign_session.id), + headers=headers, + json={"message": "Asha", "webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 404 + assert list_chatbot_session_messages(db, session_id=foreign_session.id) == [] + assert enqueued == [] + + def test_non_https_webhook_url_returns_422( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot_session: ChatbotSession, + enqueued: list[dict[str, Any]], + ) -> None: + resp = client.post( + _message_url(chatbot_session.id), + headers=headers, + json={"message": "Asha", "webhook_url": "http://hooks.example.com/chatbot"}, + ) + + assert resp.status_code == 422 + assert "Invalid webhook_url" in resp.json()["error"] + assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] + assert enqueued == [] diff --git a/backend/app/tests/celery/test_job_execution.py b/backend/app/tests/celery/test_job_execution.py index c18eb32e8..74484e3f6 100644 --- a/backend/app/tests/celery/test_job_execution.py +++ b/backend/app/tests/celery/test_job_execution.py @@ -1,4 +1,5 @@ from unittest.mock import patch +from uuid import uuid4 import pytest @@ -56,3 +57,32 @@ def test_no_reenqueue_when_stage_not_requeued(self): apply_async = self._run(requeue=False) apply_async.assert_not_called() + + +class TestRunChatbotSessionMessage: + def test_hands_service_a_uuid_session_id(self): + session_id = uuid4() + received: list[dict] = [] + with ( + patch.object(job_execution, "_set_trace", lambda trace_id: None), + patch( + "app.services.chatbot.session.process_chatbot_session_message", + lambda **kwargs: received.append(kwargs), + ), + ): + job_execution.run_chatbot_session_message( + session_id=str(session_id), + organization_id=2, + project_id=3, + webhook_url="https://hooks.example.com/chatbot", + trace_id="trace-1", + ) + + assert received == [ + { + "session_id": session_id, + "organization_id": 2, + "project_id": 3, + "webhook_url": "https://hooks.example.com/chatbot", + } + ] diff --git a/backend/app/tests/celery/test_utils.py b/backend/app/tests/celery/test_utils.py new file mode 100644 index 000000000..314b593e2 --- /dev/null +++ b/backend/app/tests/celery/test_utils.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace +from typing import Any +from unittest.mock import patch + +from app.celery.tasks.job_execution import run_chatbot_session_message +from app.celery.utils import start_chatbot_session_message + + +class TestStartChatbotSessionMessage: + def test_publishes_turn_and_returns_task_id(self): + published: list[dict[str, Any]] = [] + + def fake_apply_async(**kwargs: Any) -> SimpleNamespace: + published.append(kwargs) + return SimpleNamespace(id="task-123") + + # apply_async is the broker boundary. + with patch.object(run_chatbot_session_message, "apply_async", fake_apply_async): + task_id = start_chatbot_session_message( + session_id="session-1", + organization_id=2, + project_id=3, + webhook_url="https://hooks.example.com/chatbot", + trace_id="trace-1", + ) + + assert task_id == "task-123" + [message] = published + assert message["kwargs"] == { + "session_id": "session-1", + "organization_id": 2, + "project_id": 3, + "webhook_url": "https://hooks.example.com/chatbot", + "trace_id": "trace-1", + } + assert "otel" in message["headers"] diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py index ed122b721..2610544af 100644 --- a/backend/app/tests/crud/config/test_version.py +++ b/backend/app/tests/crud/config/test_version.py @@ -650,3 +650,57 @@ def test_does_not_mutate_shared_params_dict(self) -> None: # prior_blob must NOT have been mutated through the shared reference assert prior_blob["completion"]["params"]["temperature"] == 0.7 assert shared_params["temperature"] == 0.7 + + +class TestReadLatest: + def test_returns_highest_version(self, db: Session) -> None: + config = create_test_config(db) + create_test_version(db, config_id=config.id, project_id=config.project_id) + create_test_version(db, config_id=config.id, project_id=config.project_id) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + latest = version_crud.read_latest() + + assert latest is not None + assert latest.version == 3 + + def test_skips_soft_deleted_versions(self, db: Session) -> None: + config = create_test_config(db) + create_test_version(db, config_id=config.id, project_id=config.project_id) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + version_crud.delete_or_raise(2) + + latest = version_crud.read_latest() + + assert latest is not None + assert latest.version == 1 + + def test_returns_none_when_every_version_deleted(self, db: Session) -> None: + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + version_crud.delete_or_raise(1) + + assert version_crud.read_latest() is None + + def test_config_in_other_project_raises_404(self, db: Session) -> None: + config = create_test_config(db) + other_project = create_test_project(db) + # An explicit tag skips the constructor's lookup, so the scope check + # inside read_latest is the one that has to reject the foreign config. + version_crud = ConfigVersionCrud( + session=db, + project_id=other_project.id, + config_id=config.id, + tag=ConfigTag.DEFAULT, + ) + + with pytest.raises(HTTPException) as exc_info: + version_crud.read_latest() + + assert exc_info.value.status_code == 404 diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py new file mode 100644 index 000000000..54d276eeb --- /dev/null +++ b/backend/app/tests/crud/test_chatbot.py @@ -0,0 +1,217 @@ +from uuid import uuid4 + +import pytest +from sqlmodel import Session + +from app.crud.chatbot import ( + create_chatbot_session, + create_chatbot_session_message, + get_chatbot_session, + list_chatbot_session_messages, + list_chatbot_sessions_by_contact_ref, +) +from app.models import ( + ChatbotMessageRoleEnum, + ChatbotSession, + ChatbotSessionStatusEnum, + Config, + Project, +) +from app.tests.utils.chatbot import ( + add_test_chatbot_messages, + create_test_chatbot_session, +) +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, + create_test_project, +) +from app.tests.utils.utils import random_lower_string + + +@pytest.fixture +def config(db: Session) -> Config: + return create_test_config(db, config_blob=build_anthropic_text_config_blob()) + + +@pytest.fixture +def project(db: Session, config: Config) -> Project: + project = db.get(Project, config.project_id) + assert project is not None + return project + + +class TestCreateChatbotSession: + def test_persists_session_as_active_with_no_collected_fields( + self, db: Session, config: Config, project: Project + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + + created = create_chatbot_session( + db, + organization_id=project.organization_id, + project_id=project.id, + contact_ref=contact_ref, + config_id=config.id, + config_version=1, + system_prompt="Collect the user's name.", + ) + + db.expire_all() + stored = db.get(ChatbotSession, created.id) + assert stored is not None + assert stored.contact_ref == contact_ref + assert stored.config_id == config.id + assert stored.config_version == 1 + assert stored.system_prompt == "Collect the user's name." + assert stored.status == ChatbotSessionStatusEnum.ACTIVE + assert stored.collected_fields == {} + assert stored.inserted_at == stored.updated_at + + +class TestGetChatbotSession: + def test_returns_session_in_scope( + self, db: Session, config: Config, project: Project + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + + found = get_chatbot_session( + db, + session_id=chatbot_session.id, + organization_id=project.organization_id, + project_id=project.id, + ) + + assert found is not None + assert found.id == chatbot_session.id + + def test_other_project_cannot_see_session( + self, db: Session, config: Config, project: Project + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + other_project = create_test_project(db) + + found = get_chatbot_session( + db, + session_id=chatbot_session.id, + organization_id=other_project.organization_id, + project_id=other_project.id, + ) + + assert found is None + + def test_other_organization_cannot_see_session( + self, db: Session, config: Config, project: Project + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + other_project = create_test_project(db) + + found = get_chatbot_session( + db, + session_id=chatbot_session.id, + organization_id=other_project.organization_id, + project_id=project.id, + ) + + assert found is None + + def test_unknown_id_returns_none(self, db: Session, project: Project) -> None: + assert ( + get_chatbot_session( + db, + session_id=uuid4(), + organization_id=project.organization_id, + project_id=project.id, + ) + is None + ) + + +class TestChatbotSessionMessages: + def test_create_message_persists_role_and_content( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + + message = create_chatbot_session_message( + db, + session_id=chatbot_session.id, + role=ChatbotMessageRoleEnum.USER, + content="Asha", + ) + + stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) + assert [(m.id, m.role, m.content) for m in stored] == [ + (message.id, ChatbotMessageRoleEnum.USER, "Asha") + ] + + def test_list_returns_oldest_first_and_only_this_session( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + other_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?"), + (ChatbotMessageRoleEnum.USER, "Asha"), + (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"), + ], + ) + add_test_chatbot_messages( + db, other_session, [(ChatbotMessageRoleEnum.USER, "unrelated")] + ) + + stored = list_chatbot_session_messages(db, session_id=chatbot_session.id) + + assert [m.content for m in stored] == [ + "Hi! What's your name?", + "Asha", + "Which city?", + ] + + def test_list_empty_session_returns_empty( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + + assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] + + +class TestListChatbotSessionsByContactRef: + def test_returns_newest_first_scoped_to_contact_and_project( + self, db: Session, config: Config, project: Project + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + oldest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref) + newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref) + create_test_chatbot_session(db, config=config) + other_config = create_test_config( + db, config_blob=build_anthropic_text_config_blob() + ) + create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref) + + sessions = list_chatbot_sessions_by_contact_ref( + db, + contact_ref=contact_ref, + organization_id=project.organization_id, + project_id=project.id, + ) + + assert [s.id for s in sessions] == [newest.id, oldest.id] + + def test_unknown_contact_returns_empty( + self, db: Session, config: Config, project: Project + ) -> None: + create_test_chatbot_session(db, config=config) + + assert ( + list_chatbot_sessions_by_contact_ref( + db, + contact_ref=f"contact-{random_lower_string()}", + organization_id=project.organization_id, + project_id=project.id, + ) + == [] + ) diff --git a/backend/app/tests/services/chatbot/__init__.py b/backend/app/tests/services/chatbot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/services/chatbot/test_config.py b/backend/app/tests/services/chatbot/test_config.py new file mode 100644 index 000000000..c70f1a4dc --- /dev/null +++ b/backend/app/tests/services/chatbot/test_config.py @@ -0,0 +1,185 @@ +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi import HTTPException +from sqlmodel import Session + +from app.crud.config import ConfigVersionCrud +from app.models import Config +from app.services.chatbot.config import resolve_chatbot_model_settings +from app.services.chatbot.exceptions import ChatbotInputError +from app.services.chatbot.utils import ChatbotModelSettings +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, + create_test_project, + create_test_version, +) + + +def _resolve( + db: Session, + config: Config, + *, + config_version: int | None = None, + max_tokens: int | None = None, + history_token_limit: int | None = None, +) -> ChatbotModelSettings: + return resolve_chatbot_model_settings( + session=db, + project_id=config.project_id, + config_id=config.id, + config_version=config_version, + max_tokens=max_tokens, + history_token_limit=history_token_limit, + ) + + +def _overwrite_latest_blob(db: Session, config: Config, blob: dict[str, Any]) -> None: + # Bypasses ConfigVersionCrud validation to simulate a legacy/corrupt stored row. + version = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ).read_latest() + assert version is not None + version.config_blob = blob + db.add(version) + db.commit() + + +class TestResolveChatbotModelSettings: + def test_latest_version_with_server_defaults(self, db: Session) -> None: + config = create_test_config( + db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6") + ) + create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + config_blob=build_anthropic_text_config_blob( + model="claude-opus-5-5", + effort="high", + thinking={"type": "enabled", "budget_tokens": 2048}, + ), + ) + + model_settings = _resolve(db, config) + + assert model_settings == ChatbotModelSettings( + model="claude-opus-5-5", + max_tokens=1024, + reasoning_effort="high", + thinking={"type": "enabled", "budget_tokens": 2048}, + history_token_limit=4000, + ) + + def test_explicit_version_and_request_overrides(self, db: Session) -> None: + config = create_test_config( + db, config_blob=build_anthropic_text_config_blob(model="claude-sonnet-4-6") + ) + create_test_version( + db, + config_id=config.id, + project_id=config.project_id, + config_blob=build_anthropic_text_config_blob(model="claude-opus-5-5"), + ) + + model_settings = _resolve( + db, config, config_version=1, max_tokens=256, history_token_limit=900 + ) + + assert model_settings.model == "claude-sonnet-4-6" + assert model_settings.max_tokens == 256 + assert model_settings.history_token_limit == 900 + assert model_settings.reasoning_effort is None + assert model_settings.thinking == {"type": "adaptive"} + + def test_unknown_version_raises_404(self, db: Session) -> None: + config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + + with pytest.raises(HTTPException) as exc_info: + _resolve(db, config, config_version=99) + + assert exc_info.value.status_code == 404 + + def test_config_from_other_project_raises_404(self, db: Session) -> None: + config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + other_project = create_test_project(db) + + with pytest.raises(HTTPException) as exc_info: + resolve_chatbot_model_settings( + session=db, + project_id=other_project.id, + config_id=config.id, + config_version=None, + max_tokens=None, + history_token_limit=None, + ) + + assert exc_info.value.status_code == 404 + + def test_unknown_config_raises_404(self, db: Session) -> None: + project = create_test_project(db) + + with pytest.raises(HTTPException) as exc_info: + resolve_chatbot_model_settings( + session=db, + project_id=project.id, + config_id=uuid4(), + config_version=None, + max_tokens=None, + history_token_limit=None, + ) + + assert exc_info.value.status_code == 404 + + def test_no_live_versions_raises_404(self, db: Session) -> None: + config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ).delete_or_raise(1) + + with pytest.raises(HTTPException) as exc_info: + _resolve(db, config) + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == f"No version found for config '{config.id}'" + + def test_non_anthropic_provider_rejected(self, db: Session) -> None: + config = create_test_config(db, use_kaapi_schema=True) + + with pytest.raises(ChatbotInputError, match="only supports configs"): + _resolve(db, config) + + def test_native_completion_config_rejected(self, db: Session) -> None: + config = create_test_config(db) + + with pytest.raises(ChatbotInputError, match="only supports configs"): + _resolve(db, config) + + def test_invalid_stored_blob_rejected(self, db: Session) -> None: + config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + _overwrite_latest_blob(db, config, {"completion": {"provider": "anthropic"}}) + + with pytest.raises(ChatbotInputError, match="blob is invalid"): + _resolve(db, config) + + def test_missing_model_rejected(self, db: Session) -> None: + config = create_test_config(db, config_blob=build_anthropic_text_config_blob()) + _overwrite_latest_blob( + db, + config, + {"completion": {"provider": "anthropic", "type": "text", "params": {}}}, + ) + + with pytest.raises(ChatbotInputError, match="missing completion.params.model"): + _resolve(db, config) + + @pytest.mark.parametrize("effort", ["none", "minimal"]) + def test_non_anthropic_effort_rejected(self, db: Session, effort: str) -> None: + config = create_test_config( + db, config_blob=build_anthropic_text_config_blob(effort=effort) + ) + + with pytest.raises(ChatbotInputError, match=f"'{effort}' is not an Anthropic"): + _resolve(db, config) diff --git a/backend/app/tests/services/chatbot/test_engine.py b/backend/app/tests/services/chatbot/test_engine.py new file mode 100644 index 000000000..610c6a49d --- /dev/null +++ b/backend/app/tests/services/chatbot/test_engine.py @@ -0,0 +1,259 @@ +import anthropic +import httpx +import pytest +from langchain_core.messages import HumanMessage, SystemMessage + +from app.services.chatbot.engine import run_chatbot_turn +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.utils import ChatbotModelSettings +from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model + +SYSTEM_PROMPT = "Collect the user's name and city." +SUMMARY_TEXT = "User is Asha from Pune." + +_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + + +def _alternating_history(turns: int) -> list[dict[str, str]]: + history: list[dict[str, str]] = [] + for i in range(turns): + history.append({"role": "user", "content": f"user {i}"}) + history.append({"role": "assistant", "content": f"bot {i}"}) + return history + + +def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings: + return ChatbotModelSettings( + model="claude-sonnet-4-6", history_token_limit=history_token_limit + ) + + +class TestRunChatbotTurn: + @pytest.mark.asyncio + async def test_first_call_returns_greeting_without_kickoff( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Hi! What's your name?"]) + patch_chat_model(monkeypatch, chat_model) + + transcript = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message=None, + model_settings=_model_settings(), + ) + + assert transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + ] + # Anthropic rejects a conversation that doesn't open with a user turn. + sent = chat_model.calls[0] + assert isinstance(sent[0], SystemMessage) + assert isinstance(sent[1], HumanMessage) + assert sent[1].content == "Begin the conversation." + + @pytest.mark.asyncio + async def test_follow_up_turn_appends_user_message_and_reply( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Nice to meet you, Asha."]) + patch_chat_model(monkeypatch, chat_model) + history = [ + {"role": "system", "content": "stale prompt from the previous call"}, + {"role": "assistant", "content": "Hi! What's your name?"}, + ] + + transcript = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=history, + user_message="Asha", + model_settings=_model_settings(), + ) + + assert transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + {"role": "user", "content": "Asha"}, + {"role": "assistant", "content": "Nice to meet you, Asha."}, + ] + sent_contents = [m.content for m in chat_model.calls[0]] + assert "stale prompt from the previous call" not in sent_contents + + @pytest.mark.asyncio + async def test_missing_message_with_history_rejected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel() + patch_chat_model(monkeypatch, chat_model) + + with pytest.raises(ChatbotInputError, match="`message` is required"): + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Hi!"}], + user_message=None, + model_settings=_model_settings(), + ) + assert chat_model.calls == [] + + @pytest.mark.asyncio + async def test_unsupported_role_rejected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel() + patch_chat_model(monkeypatch, chat_model) + + with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"): + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "tool", "content": "{}"}], + user_message="hello", + model_settings=_model_settings(), + ) + assert chat_model.calls == [] + + +class TestProviderErrorMapping: + @pytest.mark.parametrize( + ("error", "expected_message"), + [ + ( + anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), + "(code: APITimeoutError)", + ), + ( + anthropic.APIConnectionError(request=_ANTHROPIC_REQUEST), + "(code: APIConnectionError)", + ), + ( + anthropic.APIStatusError( + "Overloaded", + response=httpx.Response( + 529, + request=_ANTHROPIC_REQUEST, + headers={"request-id": "req_123"}, + ), + body=None, + ), + "[ANTHROPIC] Chat model call failed: Overloaded (code: 529, request_id: req_123)", + ), + ], + ids=["timeout", "connection", "status"], + ) + @pytest.mark.asyncio + async def test_anthropic_errors_become_provider_errors( + self, + monkeypatch: pytest.MonkeyPatch, + error: anthropic.APIError, + expected_message: str, + ) -> None: + patch_chat_model(monkeypatch, ScriptedChatModel(error=error)) + + with pytest.raises(ChatbotProviderError) as exc_info: + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="hello", + model_settings=_model_settings(), + ) + + assert expected_message in str(exc_info.value) + assert exc_info.value.__cause__ is error + + +class TestHistorySummarization: + @pytest.mark.asyncio + async def test_long_history_over_budget_is_summarized( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=[SUMMARY_TEXT, "Got it."], token_count=5000 + ) + patch_chat_model(monkeypatch, chat_model) + history = _alternating_history(turns=4) + + transcript = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=history, + user_message="user 4", + model_settings=_model_settings(history_token_limit=4000), + ) + + summarize_call, reply_call = chat_model.calls + # 9 conversation messages, keep-recent=6 lands on an assistant turn, so the + # window widens back to the preceding user turn: [user 0, bot 0] get summarized. + assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"] + assert [m.content for m in reply_call] == [ + SYSTEM_PROMPT, + f"[Earlier conversation summary] {SUMMARY_TEXT}", + "user 1", + "bot 1", + "user 2", + "bot 2", + "user 3", + "bot 3", + "user 4", + ] + # Summarization only trims the model's view; the caller keeps the full transcript. + assert transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + *history, + {"role": "user", "content": "user 4"}, + {"role": "assistant", "content": "Got it."}, + ] + + @pytest.mark.asyncio + async def test_long_history_within_budget_is_sent_whole( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=100) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=_alternating_history(turns=4), + user_message="user 4", + model_settings=_model_settings(history_token_limit=4000), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 10 + + @pytest.mark.asyncio + async def test_short_history_never_summarized( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=_alternating_history(turns=3)[:5], + user_message="more", + model_settings=_model_settings(history_token_limit=10), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 7 + + @pytest.mark.asyncio + async def test_no_user_turn_to_split_on_skips_summary( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) + patch_chat_model(monkeypatch, chat_model) + # Only the injected kickoff precedes the recent window, so widening the + # window to start on a user turn consumes everything there was to summarize. + assistant_only = [ + {"role": "assistant", "content": f"bot {i}"} for i in range(7) + ] + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=assistant_only, + user_message="hello", + model_settings=_model_settings(history_token_limit=10), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 10 diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py new file mode 100644 index 000000000..22ad3b93e --- /dev/null +++ b/backend/app/tests/services/chatbot/test_session.py @@ -0,0 +1,340 @@ +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any +from uuid import uuid4 + +import anthropic +import httpx +import pytest +from sqlmodel import Session + +from app.crud.chatbot import list_chatbot_session_messages +from app.crud.config import ConfigVersionCrud +from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config, Project +from app.services.chatbot import session as session_service +from app.services.chatbot.session import process_chatbot_session_message +from app.tests.utils.chatbot import ( + DEFAULT_SYSTEM_PROMPT, + ScriptedChatModel, + add_test_chatbot_messages, + create_test_chatbot_session, + patch_chat_model, +) +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, +) + +WEBHOOK_URL = "https://hooks.example.com/chatbot" + + +class _NonClosingSession: + """The worker opens `Session(engine)`, which can't see this test's + uncommitted rows; hand it the transactional test session instead.""" + + def __init__(self, session: Session) -> None: + self._session = session + + def __enter__(self) -> Session: + return self._session + + def __exit__(self, *_exc: object) -> bool: + return False + + +@dataclass +class CallbackRecorder: + calls: list[tuple[str, dict[str, Any], str | None]] = field(default_factory=list) + + def __call__( + self, url: str, payload: dict[str, Any], secret: str | None = None + ) -> bool: + self.calls.append((url, payload, secret)) + return True + + @property + def payloads(self) -> list[dict[str, Any]]: + return [payload for _url, payload, _secret in self.calls] + + +@pytest.fixture +def callbacks( + db: Session, monkeypatch: pytest.MonkeyPatch +) -> Iterator[CallbackRecorder]: + recorder = CallbackRecorder() + monkeypatch.setattr( + session_service, "Session", lambda _engine: _NonClosingSession(db) + ) + # The webhook POST is the external boundary. + monkeypatch.setattr(session_service, "send_callback", recorder) + yield recorder + + +@pytest.fixture +def config(db: Session) -> Config: + return create_test_config(db, config_blob=build_anthropic_text_config_blob()) + + +def _process(chatbot_session: ChatbotSession, db: Session) -> None: + project = db.get(Project, chatbot_session.project_id) + assert project is not None + process_chatbot_session_message( + session_id=chatbot_session.id, + organization_id=project.organization_id, + project_id=project.id, + webhook_url=WEBHOOK_URL, + ) + + +def _transcript(db: Session, chatbot_session: ChatbotSession) -> list[tuple[str, str]]: + return [ + (m.role.value, m.content) + for m in list_chatbot_session_messages(db, session_id=chatbot_session.id) + ] + + +class TestProcessChatbotSessionMessage: + def test_first_call_stores_greeting_and_posts_success_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) + ) + chatbot_session = create_test_chatbot_session(db, config=config) + + _process(chatbot_session, db) + + assert _transcript(db, chatbot_session) == [ + ("assistant", "Hi! What's your name?") + ] + assert callbacks.calls == [ + ( + WEBHOOK_URL, + { + "success": True, + "data": { + "session_id": str(chatbot_session.id), + "reply": "Hi! What's your name?", + "status": "active", + "collected_fields": {}, + }, + }, + None, + ) + ] + + def test_pending_user_message_is_answered_with_prior_history( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["Nice, which city?"]) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.ASSISTANT, "Hi! What's your name?"), + (ChatbotMessageRoleEnum.USER, "Asha"), + ], + ) + + _process(chatbot_session, db) + + assert [m.content for m in chat_model.calls[0]] == [ + DEFAULT_SYSTEM_PROMPT, + "Begin the conversation.", + "Hi! What's your name?", + "Asha", + ] + assert _transcript(db, chatbot_session) == [ + ("assistant", "Hi! What's your name?"), + ("user", "Asha"), + ("assistant", "Nice, which city?"), + ] + assert callbacks.payloads[0]["data"]["reply"] == "Nice, which city?" + + def test_stored_system_rows_are_not_sent_as_history( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["Which city?"]) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.SYSTEM, "stale stored prompt"), + (ChatbotMessageRoleEnum.USER, "I'm Asha"), + ], + ) + + _process(chatbot_session, db) + + sent = [m.content for m in chat_model.calls[0]] + assert "stale stored prompt" not in sent + assert sent == [DEFAULT_SYSTEM_PROMPT, "I'm Asha"] + assert callbacks.payloads[0]["success"] is True + + def test_already_answered_turn_is_skipped( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["should never be used"]) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.USER, "Asha"), + (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"), + ], + ) + + _process(chatbot_session, db) + + assert chat_model.calls == [] + assert callbacks.calls == [] + assert _transcript(db, chatbot_session) == [ + ("user", "Asha"), + ("assistant", "Which city?"), + ] + + def test_unknown_session_does_nothing( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["should never be used"]) + patch_chat_model(monkeypatch, chat_model) + project = db.get(Project, config.project_id) + assert project is not None + + process_chatbot_session_message( + session_id=uuid4(), + organization_id=project.organization_id, + project_id=project.id, + webhook_url=WEBHOOK_URL, + ) + + assert chat_model.calls == [] + assert callbacks.calls == [] + + def test_deleted_config_version_posts_failure_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["should never be used"]) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ).delete_or_raise(1) + + _process(chatbot_session, db) + + assert chat_model.calls == [] + assert _transcript(db, chatbot_session) == [] + assert callbacks.calls == [ + ( + WEBHOOK_URL, + { + "success": False, + "error": f"No version found for config '{config.id}'", + "data": {"session_id": str(chatbot_session.id)}, + }, + None, + ) + ] + + def test_non_anthropic_config_posts_failure_webhook( + self, + db: Session, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["should never be used"]) + patch_chat_model(monkeypatch, chat_model) + openai_config = create_test_config(db, use_kaapi_schema=True) + chatbot_session = create_test_chatbot_session(db, config=openai_config) + + _process(chatbot_session, db) + + assert chat_model.calls == [] + [payload] = callbacks.payloads + assert payload["success"] is False + assert "only supports configs" in payload["error"] + assert payload["data"] == {"session_id": str(chatbot_session.id)} + + def test_provider_error_posts_failure_webhook_and_stores_nothing( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + patch_chat_model( + monkeypatch, + ScriptedChatModel(error=anthropic.APITimeoutError(request=request)), + ) + chatbot_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, chatbot_session, [(ChatbotMessageRoleEnum.USER, "Asha")] + ) + + _process(chatbot_session, db) + + assert _transcript(db, chatbot_session) == [("user", "Asha")] + [payload] = callbacks.payloads + assert payload["success"] is False + assert "(code: APITimeoutError)" in payload["error"] + assert payload["data"] == {"session_id": str(chatbot_session.id)} + + def test_transcript_without_pending_user_turn_posts_failure_webhook( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel(replies=["should never be used"]) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + # A trailing system row dodges the already-answered check, leaving an + # assistant turn with no user message after it. + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.ASSISTANT, "Hi!"), + (ChatbotMessageRoleEnum.SYSTEM, "note"), + ], + ) + + _process(chatbot_session, db) + + assert chat_model.calls == [] + [payload] = callbacks.payloads + assert payload["success"] is False + assert "`message` is required" in payload["error"] diff --git a/backend/app/tests/services/chatbot/test_utils.py b/backend/app/tests/services/chatbot/test_utils.py new file mode 100644 index 000000000..512fca063 --- /dev/null +++ b/backend/app/tests/services/chatbot/test_utils.py @@ -0,0 +1,65 @@ +import pytest +from langchain_anthropic import ChatAnthropic + +from app.core.config import settings +from app.services.chatbot.utils import ( + ChatbotModelSettings, + default_thinking, + get_chat_model, +) + + +class TestChatbotModelSettings: + def test_defaults(self) -> None: + model_settings = ChatbotModelSettings(model="claude-sonnet-4-5") + + assert model_settings.max_tokens == 1024 + assert model_settings.reasoning_effort is None + assert model_settings.thinking == {"type": "adaptive"} + assert model_settings.history_token_limit == 4000 + + def test_thinking_default_is_not_shared_between_instances(self) -> None: + first = ChatbotModelSettings(model="claude-sonnet-4-5") + first.thinking["budget_tokens"] = 2048 + + second = ChatbotModelSettings(model="claude-sonnet-4-5") + + assert second.thinking == {"type": "adaptive"} + assert default_thinking() == {"type": "adaptive"} + + +class TestGetChatModel: + def test_missing_api_key_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "") + + with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY is not set"): + get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5")) + + def test_builds_model_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "sk-ant-test") + + chat_model = get_chat_model( + ChatbotModelSettings( + model="claude-sonnet-4-5", + max_tokens=512, + reasoning_effort="high", + thinking={"type": "enabled", "budget_tokens": 2048}, + ) + ) + + assert isinstance(chat_model, ChatAnthropic) + assert chat_model.model == "claude-sonnet-4-5" + assert chat_model.max_tokens == 512 + assert chat_model.reasoning_effort == "high" + assert chat_model.thinking == {"type": "enabled", "budget_tokens": 2048} + assert chat_model.anthropic_api_key.get_secret_value() == "sk-ant-test" + + def test_omits_reasoning_effort_when_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr(settings, "ANTHROPIC_API_KEY", "sk-ant-test") + + chat_model = get_chat_model(ChatbotModelSettings(model="claude-sonnet-4-5")) + + assert chat_model.reasoning_effort is None + assert chat_model.thinking == {"type": "adaptive"} diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py new file mode 100644 index 000000000..dac32ca06 --- /dev/null +++ b/backend/app/tests/utils/chatbot.py @@ -0,0 +1,96 @@ +from collections.abc import Sequence +from typing import Any + +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from pydantic import Field +from sqlmodel import Session + +from app.crud.chatbot import create_chatbot_session, create_chatbot_session_message +from app.models import ( + ChatbotMessageRoleEnum, + ChatbotSession, + ChatbotSessionMessage, + Config, + Project, +) +from app.services.chatbot import engine +from app.tests.utils.utils import random_lower_string + +DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city." + + +class ScriptedChatModel(BaseChatModel): + """Stands in for ChatAnthropic: replies from a script and records each call.""" + + replies: list[str] = Field(default_factory=list) + token_count: int = 0 + error: Exception | None = None + calls: list[list[BaseMessage]] = Field(default_factory=list) + + @property + def _llm_type(self) -> str: + return "scripted" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + self.calls.append(list(messages)) + if self.error is not None: + raise self.error + reply = AIMessage(content=self.replies.pop(0)) + return ChatResult(generations=[ChatGeneration(message=reply)]) + + def get_num_tokens_from_messages( + self, messages: list[BaseMessage], tools: Sequence[Any] | None = None + ) -> int: + return self.token_count + + +def patch_chat_model( + monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel +) -> None: + # The Anthropic SDK is the external boundary; everything above it runs for real. + monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model) + + +def create_test_chatbot_session( + db: Session, + *, + config: Config, + contact_ref: str | None = None, + config_version: int | None = None, + system_prompt: str = DEFAULT_SYSTEM_PROMPT, +) -> ChatbotSession: + project = db.get(Project, config.project_id) + assert project is not None + return create_chatbot_session( + db, + organization_id=project.organization_id, + project_id=project.id, + contact_ref=contact_ref or f"contact-{random_lower_string()}", + config_id=config.id, + config_version=config_version, + system_prompt=system_prompt, + ) + + +def add_test_chatbot_messages( + db: Session, + chatbot_session: ChatbotSession, + turns: list[tuple[ChatbotMessageRoleEnum, str]], +) -> list[ChatbotSessionMessage]: + stored: list[ChatbotSessionMessage] = [] + for role, content in turns: + stored.append( + create_chatbot_session_message( + db, session_id=chatbot_session.id, role=role, content=content + ) + ) + return stored diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py index be27d49f0..a447e479b 100644 --- a/backend/app/tests/utils/test_data.py +++ b/backend/app/tests/utils/test_data.py @@ -1,3 +1,5 @@ +from typing import Any + from sqlmodel import Session from app.models.provider_credentials import Provider @@ -328,6 +330,17 @@ def create_test_config( return config +def build_anthropic_text_config_blob(**params: Any) -> ConfigBlob: + """Kaapi-format Anthropic text config — the only shape the chatbot accepts.""" + return ConfigBlob( + completion=build_kaapi_completion_config( + provider="anthropic", + type="text", + params={"model": "claude-sonnet-4-6", **params}, + ) + ) + + def create_test_version( db: Session, config_id: int, From 54c00ade57cf2805149b1cdecc3c6152bab74494 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:22:59 +0530 Subject: [PATCH 04/15] fix: remove the unwanted migration --- .../086_seed_anthropic_text_model_configs.py | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py diff --git a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py deleted file mode 100644 index 915218568..000000000 --- a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py +++ /dev/null @@ -1,60 +0,0 @@ -"""seed anthropic text model_config rows - -Revision ID: 086 -Revises: 085 -Create Date: 2026-10-09 00:00:00.000000 - -`anthropic` has been a valid provider since 066 but had no model_config rows, -so it never showed up in /models/providers and config-version updates could -not tell its models are reasoning models. The `effort` key in `config` is what -`is_reasoning_model` keys on — it makes `_strip_unsupported_params` drop -`temperature`, which the Messages API rejects at non-default values. - -`effort.default` is "high" because that is what the Messages API applies when -the param is omitted. Sonnet 4.6 does not expose the "xhigh" level. - -claude-opus-5-5 pricing is left NULL until list prices are confirmed; cost -estimation returns None for NULL pricing rather than a wrong number. - -Downgrade deletes the two rows by key, so a row that already existed before -upgrade (ON CONFLICT DO NOTHING skipped it) is removed as well. -""" - -from alembic import op - -revision = "086" -down_revision = "085" -branch_labels = None -depends_on = None - - -def upgrade(): - op.execute( - """ - INSERT INTO global.model_config - (provider, model_name, completion_type, config, input_modalities, - output_modalities, pricing, is_active, inserted_at, updated_at) - VALUES - ('anthropic', 'claude-sonnet-4-6', '{text}', - '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', - '{TEXT,IMAGE,FILES}', '{TEXT}', - '{"response": {"input_token_cost": 3, "output_token_cost": 15}, "batch": {"input_token_cost": 1.5, "output_token_cost": 7.5}}', - true, NOW(), NOW()), - ('anthropic', 'claude-opus-5-5', '{text}', - '{"effort": {"type": "enum", "default": "high", "options": ["low", "medium", "high", "xhigh", "max"], "description": "How long the model spends reasoning. Higher = better but slower."}}', - '{TEXT,IMAGE,FILES}', '{TEXT}', - NULL, - true, NOW(), NOW()) - ON CONFLICT (provider, model_name) DO NOTHING - """ - ) - - -def downgrade(): - op.execute( - """ - DELETE FROM global.model_config - WHERE provider = 'anthropic' - AND model_name IN ('claude-sonnet-4-6', 'claude-opus-5-5') - """ - ) From 00bbe9ae33534029d98c4a07281775a831f45fa7 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:51:35 +0530 Subject: [PATCH 05/15] fix: few clenaups --- backend/app/celery/utils.py | 4 --- backend/app/crud/chatbot.py | 13 -------- backend/app/services/chatbot/config.py | 6 +--- backend/app/services/chatbot/session.py | 41 +++---------------------- 4 files changed, 6 insertions(+), 58 deletions(-) diff --git a/backend/app/celery/utils.py b/backend/app/celery/utils.py index 88285c0bb..a7b31f9e4 100644 --- a/backend/app/celery/utils.py +++ b/backend/app/celery/utils.py @@ -354,10 +354,6 @@ def start_chatbot_session_message( webhook_url=webhook_url, trace_id=trace_id, ) - logger.info( - f"[start_chatbot_session_message] Enqueued chatbot turn | " - f"session_id: {session_id}, task_id: {task_id}" - ) return task_id diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index 3b1d10304..31bb9e5af 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -1,4 +1,3 @@ -import logging from uuid import UUID from sqlmodel import Session, col, select @@ -10,8 +9,6 @@ ChatbotSessionMessage, ) -logger = logging.getLogger(__name__) - def create_chatbot_session( session: Session, @@ -37,10 +34,6 @@ def create_chatbot_session( session.add(chatbot_session) session.commit() session.refresh(chatbot_session) - logger.info( - f"[create_chatbot_session] Chatbot session created | " - f"session_id: {chatbot_session.id}, project_id: {project_id}, config_id: {config_id}" - ) return chatbot_session @@ -75,18 +68,12 @@ def create_chatbot_session_message( session.add(message) session.commit() session.refresh(message) - logger.info( - f"[create_chatbot_session_message] Chatbot message stored | " - f"session_id: {session_id}, role: {role.value}, message_id: {message.id}" - ) return message def list_chatbot_session_messages( session: Session, *, session_id: UUID ) -> list[ChatbotSessionMessage]: - # Unpaginated on purpose: the engine needs the whole transcript each turn - # and summarizes long histories itself. statement = ( select(ChatbotSessionMessage) .where(ChatbotSessionMessage.session_id == session_id) diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index e8f09c666..0e4268832 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -60,11 +60,7 @@ def resolve_chatbot_model_settings( max_tokens: int | None, history_token_limit: int | None, ) -> ChatbotModelSettings: - """Turn a saved config (+ request overrides) into chat-model settings. - - Raises HTTPException(404) when the config/version isn't in the project and - ChatbotInputError when the config isn't a usable Anthropic text config. - """ + """Turn a saved config (+ request overrides) into chat-model settings.""" version_row = _get_config_version( session=session, project_id=project_id, diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 06f899308..279840256 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -1,5 +1,4 @@ import asyncio -import logging from typing import Any from uuid import UUID @@ -20,8 +19,6 @@ from app.services.chatbot.utils import ChatbotModelSettings from app.utils import get_webhook_secret, send_callback -logger = logging.getLogger(__name__) - def _split_pending_turn( stored_messages: list[ChatbotSessionMessage], @@ -66,11 +63,8 @@ def _send_failure_webhook( "error": error, "data": {"session_id": str(session_id)}, } - delivered = send_callback(webhook_url, payload, webhook_secret) - logger.info( - f"[_send_failure_webhook] Failure webhook dispatched | " - f"session_id: {session_id}, delivered: {delivered}" - ) + send_callback(webhook_url, payload, webhook_secret) + return def process_chatbot_session_message( @@ -80,11 +74,7 @@ def process_chatbot_session_message( project_id: int, webhook_url: str, ) -> None: - """Run the pending chatbot turn for a session and POST the reply to `webhook_url`. - - Idempotent under Celery redelivery: if the transcript already ends on an - assistant turn, the pending turn was answered and nothing is re-run. - """ + """Run the pending chatbot turn for a session and POST the reply to `webhook_url`.""" webhook_secret = get_webhook_secret(project_id, organization_id) # The DB session is closed before the LLM call so a slow provider doesn't @@ -97,10 +87,6 @@ def process_chatbot_session_message( project_id=project_id, ) if chatbot_session is None: - logger.error( - f"[process_chatbot_session_message] Chatbot session not found | " - f"session_id: {session_id}, project_id: {project_id}" - ) return stored_messages = list_chatbot_session_messages(session, session_id=session_id) @@ -108,10 +94,6 @@ def process_chatbot_session_message( stored_messages and stored_messages[-1].role == ChatbotMessageRoleEnum.ASSISTANT ): - logger.warning( - f"[process_chatbot_session_message] Turn already answered, skipping | " - f"session_id: {session_id}" - ) return system_prompt = chatbot_session.system_prompt @@ -128,12 +110,6 @@ def process_chatbot_session_message( history_token_limit=None, ) except HTTPException as e: - # The config was resolvable at session creation; it has since been - # deleted or lost its version, and the caller only hears via webhook. - logger.warning( - f"[process_chatbot_session_message] Config no longer resolvable | " - f"session_id: {session_id}, detail: {e.detail}" - ) _send_failure_webhook( session_id=session_id, webhook_url=webhook_url, @@ -151,10 +127,6 @@ def process_chatbot_session_message( return history, user_message = _split_pending_turn(stored_messages) - logger.info( - f"[process_chatbot_session_message] Running turn | session_id: {session_id}, " - f"history_count: {len(history)}, has_user_message: {user_message is not None}" - ) try: transcript = asyncio.run( @@ -192,8 +164,5 @@ def process_chatbot_session_message( "collected_fields": collected_fields, }, } - delivered = send_callback(webhook_url, payload, webhook_secret) - logger.info( - f"[process_chatbot_session_message] Turn completed | session_id: {session_id}, " - f"reply_length: {len(reply)}, webhook_delivered: {delivered}" - ) + send_callback(webhook_url, payload, webhook_secret) + return From e9927dfbabc031668b12ac234c613a18be4498ce Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:56:41 +0530 Subject: [PATCH 06/15] fix: cleanups and save collected fields in db --- .../versions/088_add_chatbot_session.py | 18 + .../app/api/docs/chatbot/create_session.md | 2 +- .../api/docs/chatbot/get_latest_session.md | 3 + backend/app/api/docs/chatbot/list_sessions.md | 3 - backend/app/api/docs/chatbot/message.md | 2 + .../api/docs/chatbot/send_session_message.md | 4 +- backend/app/api/routes/chatbot/message.py | 14 +- backend/app/api/routes/chatbot/session.py | 29 +- backend/app/crud/chatbot.py | 43 +- backend/app/models/__init__.py | 2 + backend/app/models/chatbot.py | 51 +- .../app/services/chatbot/agents/__init__.py | 0 .../chatbot/agents/onboarding/__init__.py | 0 .../chatbot/agents/onboarding/graph.py | 236 +++++++ .../chatbot/agents/onboarding/prompts.py | 25 + .../chatbot/agents/onboarding/types.py | 29 + backend/app/services/chatbot/engine.py | 171 ----- backend/app/services/chatbot/history.py | 108 +++ backend/app/services/chatbot/session.py | 22 +- backend/app/services/chatbot/types.py | 19 +- .../tests/api/routes/chatbot/test_message.py | 65 ++ .../tests/api/routes/chatbot/test_session.py | 108 ++- backend/app/tests/crud/test_chatbot.py | 83 ++- .../tests/services/chatbot/agents/__init__.py | 0 .../chatbot/agents/onboarding/__init__.py | 0 .../chatbot/agents/onboarding/test_graph.py | 614 ++++++++++++++++++ .../app/tests/services/chatbot/test_engine.py | 259 -------- .../tests/services/chatbot/test_session.py | 150 ++++- backend/app/tests/utils/chatbot.py | 34 +- docs/wiki/INDEX.md | 2 +- docs/wiki/modules/chatbot.md | 36 +- 31 files changed, 1626 insertions(+), 506 deletions(-) create mode 100644 backend/app/api/docs/chatbot/get_latest_session.md delete mode 100644 backend/app/api/docs/chatbot/list_sessions.md create mode 100644 backend/app/services/chatbot/agents/__init__.py create mode 100644 backend/app/services/chatbot/agents/onboarding/__init__.py create mode 100644 backend/app/services/chatbot/agents/onboarding/graph.py create mode 100644 backend/app/services/chatbot/agents/onboarding/prompts.py create mode 100644 backend/app/services/chatbot/agents/onboarding/types.py delete mode 100644 backend/app/services/chatbot/engine.py create mode 100644 backend/app/services/chatbot/history.py create mode 100644 backend/app/tests/services/chatbot/agents/__init__.py create mode 100644 backend/app/tests/services/chatbot/agents/onboarding/__init__.py create mode 100644 backend/app/tests/services/chatbot/agents/onboarding/test_graph.py delete mode 100644 backend/app/tests/services/chatbot/test_engine.py diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py index 367a924ce..99859aaf0 100644 --- a/backend/app/alembic/versions/088_add_chatbot_session.py +++ b/backend/app/alembic/versions/088_add_chatbot_session.py @@ -25,6 +25,13 @@ def upgrade(): nullable=False, comment="Session identifier returned to the caller", ), + sa.Column( + "name", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=False, + server_default="Untitled Chatbot", + comment="Display name of the chatbot this session runs", + ), sa.Column( "contact_ref", sqlmodel.sql.sqltypes.AutoString(length=500), @@ -59,6 +66,17 @@ def upgrade(): nullable=False, comment="Conversation lifecycle: active or inactive", ), + sa.Column( + "completion_status", + sa.Enum( + "in_progress", + "complete", + name="chatbotcompletionstatusenum", + ), + nullable=False, + server_default="in_progress", + comment="Field collection progress: in_progress, or complete once every required field is gathered", + ), sa.Column( "collected_fields", postgresql.JSON(astext_type=sa.Text()), diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md index 543966e2d..ec34000aa 100644 --- a/backend/app/api/docs/chatbot/create_session.md +++ b/backend/app/api/docs/chatbot/create_session.md @@ -2,4 +2,4 @@ Start a server-side chatbot conversation and return its `session_id`. The `system_prompt` and the `config_id` / `config_version` pin are stored on the session, so later turns only send the new user message. `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set; when `config_version` is omitted, each turn uses the config's latest version at that time. -`contact_ref` is the caller's own identifier for the end user and is stored as-is. Returns 404 if the config or version isn't found in the project and 422 if it isn't a usable Anthropic text config. +`name` is the chatbot's display name; omit it to get `"Untitled Chatbot"`. `contact_ref` is the caller's own identifier for the end user and is stored as-is. Returns 404 if the config or version isn't found in the project and 422 if it isn't a usable Anthropic text config. diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md new file mode 100644 index 000000000..c2ddf0e11 --- /dev/null +++ b/backend/app/api/docs/chatbot/get_latest_session.md @@ -0,0 +1,3 @@ +Get the most recently created chatbot session for the given `contact_ref` in the caller's project. + +`contact_ref` is matched exactly against the value supplied when the session was created. The response carries the session's current `status` (`active`/`inactive`), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions in this project. diff --git a/backend/app/api/docs/chatbot/list_sessions.md b/backend/app/api/docs/chatbot/list_sessions.md deleted file mode 100644 index 202480359..000000000 --- a/backend/app/api/docs/chatbot/list_sessions.md +++ /dev/null @@ -1,3 +0,0 @@ -List every chatbot session for the given `contact_ref` in the caller's project, most recent first. - -`contact_ref` is matched exactly against the value supplied when the session was created. Each entry carries the session's current `status` and the `collected_fields` gathered so far. Returns an empty list when the contact has no sessions in this project. diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md index 75ee043c9..1e189473f 100644 --- a/backend/app/api/docs/chatbot/message.md +++ b/backend/app/api/docs/chatbot/message.md @@ -4,4 +4,6 @@ The chat model comes from a saved Kaapi config: `config_id` must be a config in There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`. +Each turn also extracts the fields `system_prompt` asks to collect from the whole conversation: `collected_fields` is a flat `{field_name: value}` map (snake_case keys, only values the user actually gave) and `completion_status` is `complete` once nothing required is missing, else `in_progress`. Both are recomputed from the transcript on every call; the opening greeting returns `{}` and `in_progress`. + On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures. diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md index 237e0fa97..b4b4d816d 100644 --- a/backend/app/api/docs/chatbot/send_session_message.md +++ b/backend/app/api/docs/chatbot/send_session_message.md @@ -1,5 +1,7 @@ Queue one turn of a chatbot session; the bot reply is delivered asynchronously to `webhook_url`. -The user `message` is stored immediately and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "reply", "status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. +The user `message` is stored immediately and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. + +Each turn re-reads the conversation against the session's `system_prompt` and saves every field the user has given into `collected_fields`, a flat `{field_name: value}` map with snake_case keys (values are only ever taken from what the user said; a correction overwrites the old value). `completion_status` is `complete` once every required field is collected and `in_progress` otherwise; it is computed from a structured extraction, not from the reply text. A `complete` session still accepts messages. `status` (`active`/`inactive`) is separate and says whether the session is in use. On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project and 422 for an invalid `webhook_url` or a missing `message` on a started session. diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py index 9a5368258..c5020da48 100644 --- a/backend/app/api/routes/chatbot/message.py +++ b/backend/app/api/routes/chatbot/message.py @@ -3,8 +3,8 @@ from app.api.deps import AuthContextDep, SessionDep from app.api.permissions import Permission, require_permission from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse +from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn from app.services.chatbot.config import resolve_chatbot_model_settings -from app.services.chatbot.engine import run_chatbot_turn from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.utils import APIResponse, load_description @@ -35,7 +35,7 @@ async def send_chatbot_message( max_tokens=request.max_tokens, history_token_limit=request.history_token_limit, ) - transcript = await run_chatbot_turn( + turn_result = await run_chatbot_turn( system_prompt=request.system_prompt, history=history, user_message=request.message, @@ -47,6 +47,12 @@ async def send_chatbot_message( raise HTTPException(status_code=502, detail=str(e)) messages: list[ChatbotMessage] = [] - for entry in transcript: + for entry in turn_result.transcript: messages.append(ChatbotMessage.model_validate(entry)) - return APIResponse.success_response(ChatbotTurnResponse(messages=messages)) + return APIResponse.success_response( + ChatbotTurnResponse( + messages=messages, + collected_fields=turn_result.collected_fields, + completion_status=turn_result.completion_status, + ) + ) diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index 0ea7d60f1..d3316e01b 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -11,8 +11,8 @@ create_chatbot_session, create_chatbot_session_message, get_chatbot_session, + get_latest_chatbot_session_by_contact_ref, list_chatbot_session_messages, - list_chatbot_sessions_by_contact_ref, ) from app.models import ( CONTACT_REF_MAX_LENGTH, @@ -61,6 +61,7 @@ def start_chatbot_session( session, organization_id=auth_context.organization_.id, project_id=project_id, + name=request.name, contact_ref=request.contact_ref, config_id=request.config_id, config_version=request.config_version, @@ -73,42 +74,46 @@ def start_chatbot_session( @router.get( "/sessions", - description=load_description("chatbot/list_sessions.md"), - response_model=APIResponse[list[ChatbotSessionSummary]], + description=load_description("chatbot/get_latest_session.md"), + response_model=APIResponse[ChatbotSessionSummary], dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], ) -def list_chatbot_sessions( +def get_latest_chatbot_session( contact_ref: Annotated[ str, Query( min_length=1, max_length=CONTACT_REF_MAX_LENGTH, - description="Caller-side identifier of the end user whose sessions to list", + description="Caller-side identifier of the end user whose latest session to fetch", ), ], session: SessionDep, auth_context: AuthContextDep, -) -> APIResponse[list[ChatbotSessionSummary]]: - chatbot_sessions = list_chatbot_sessions_by_contact_ref( +) -> APIResponse[ChatbotSessionSummary]: + chatbot_session = get_latest_chatbot_session_by_contact_ref( session, contact_ref=contact_ref, organization_id=auth_context.organization_.id, project_id=auth_context.project_.id, ) - summaries: list[ChatbotSessionSummary] = [] - for chatbot_session in chatbot_sessions: - summary = ChatbotSessionSummary( + if chatbot_session is None: + raise HTTPException( + status_code=404, detail="No chatbot session found for this contact_ref" + ) + return APIResponse.success_response( + ChatbotSessionSummary( session_id=chatbot_session.id, + name=chatbot_session.name, contact_ref=chatbot_session.contact_ref, status=chatbot_session.status, + completion_status=chatbot_session.completion_status, collected_fields=chatbot_session.collected_fields, config_id=chatbot_session.config_id, config_version=chatbot_session.config_version, inserted_at=chatbot_session.inserted_at, updated_at=chatbot_session.updated_at, ) - summaries.append(summary) - return APIResponse.success_response(summaries) + ) @router.post( diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index 31bb9e5af..e0b1fda75 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -1,20 +1,26 @@ +import logging +from typing import Any from uuid import UUID from sqlmodel import Session, col, select from app.core.util import now from app.models import ( + ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, ) +logger = logging.getLogger(__name__) + def create_chatbot_session( session: Session, *, organization_id: int, project_id: int, + name: str, contact_ref: str, config_id: UUID, config_version: int | None, @@ -24,6 +30,7 @@ def create_chatbot_session( chatbot_session = ChatbotSession( organization_id=organization_id, project_id=project_id, + name=name, contact_ref=contact_ref, config_id=config_id, config_version=config_version, @@ -52,6 +59,35 @@ def get_chatbot_session( return session.exec(statement).first() +def update_chatbot_session_progress( + session: Session, + *, + session_id: UUID, + collected_fields: dict[str, Any], + completion_status: ChatbotCompletionStatusEnum, +) -> ChatbotSession | None: + chatbot_session = session.get(ChatbotSession, session_id) + if chatbot_session is None: + return None + + chatbot_session.sqlmodel_update( + { + "collected_fields": collected_fields, + "completion_status": completion_status, + "updated_at": now(), + } + ) + session.add(chatbot_session) + session.commit() + session.refresh(chatbot_session) + logger.info( + f"[update_chatbot_session_progress] Session progress saved | " + f"session_id: {session_id}, completion_status: {completion_status.value}, " + f"field_count: {len(collected_fields)}" + ) + return chatbot_session + + def create_chatbot_session_message( session: Session, *, @@ -82,13 +118,13 @@ def list_chatbot_session_messages( return list(session.exec(statement).all()) -def list_chatbot_sessions_by_contact_ref( +def get_latest_chatbot_session_by_contact_ref( session: Session, *, contact_ref: str, organization_id: int, project_id: int, -) -> list[ChatbotSession]: +) -> ChatbotSession | None: statement = ( select(ChatbotSession) .where( @@ -97,5 +133,6 @@ def list_chatbot_sessions_by_contact_ref( ChatbotSession.project_id == project_id, ) .order_by(col(ChatbotSession.inserted_at).desc()) + .limit(1) ) - return list(session.exec(statement).all()) + return session.exec(statement).first() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index dc7f2e3be..2c1cbcfc9 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -37,6 +37,8 @@ ) from .chatbot import ( CONTACT_REF_MAX_LENGTH, + DEFAULT_CHATBOT_NAME, + ChatbotCompletionStatusEnum, ChatbotMessage, ChatbotMessageRoleEnum, ChatbotSession, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index a5cfe381b..7114c9fcc 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -55,15 +55,26 @@ class ChatbotTurnRequest(SQLModel): ) +class ChatbotSessionStatusEnum(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class ChatbotCompletionStatusEnum(StrEnum): + IN_PROGRESS = "in_progress" + COMPLETE = "complete" + + class ChatbotTurnResponse(SQLModel): messages: list[ChatbotMessage] = Field( description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.", ) - - -class ChatbotSessionStatusEnum(StrEnum): - ACTIVE = "active" - INACTIVE = "inactive" + collected_fields: dict[str, Any] = Field( + description="Flat map of field name -> value the user has provided so far, as extracted from the transcript.", + ) + completion_status: ChatbotCompletionStatusEnum = Field( + description="`complete` once every field the system prompt asks for has been collected, else `in_progress`.", + ) _SESSION_STATUS_PG_ENUM = ENUM( @@ -72,6 +83,12 @@ class ChatbotSessionStatusEnum(StrEnum): values_callable=lambda enum_cls: [member.value for member in enum_cls], create_type=False, ) +_COMPLETION_STATUS_PG_ENUM = ENUM( + ChatbotCompletionStatusEnum, + name="chatbotcompletionstatusenum", + values_callable=lambda enum_cls: [member.value for member in enum_cls], + create_type=False, +) _MESSAGE_ROLE_PG_ENUM = ENUM( ChatbotMessageRoleEnum, name="chatbotmessageroleenum", @@ -80,6 +97,8 @@ class ChatbotSessionStatusEnum(StrEnum): ) CONTACT_REF_MAX_LENGTH = 500 +CHATBOT_NAME_MAX_LENGTH = 255 +DEFAULT_CHATBOT_NAME = "Untitled Chatbot" class ChatbotSession(SQLModel, table=True): @@ -90,6 +109,11 @@ class ChatbotSession(SQLModel, table=True): primary_key=True, sa_column_kwargs={"comment": "Session identifier returned to the caller"}, ) + name: str = Field( + default=DEFAULT_CHATBOT_NAME, + max_length=CHATBOT_NAME_MAX_LENGTH, + sa_column_kwargs={"comment": "Display name of the chatbot this session runs"}, + ) contact_ref: str = Field( max_length=CONTACT_REF_MAX_LENGTH, sa_column_kwargs={ @@ -125,6 +149,15 @@ class ChatbotSession(SQLModel, table=True): comment="Conversation lifecycle: active or inactive", ), ) + completion_status: ChatbotCompletionStatusEnum = Field( + default=ChatbotCompletionStatusEnum.IN_PROGRESS, + sa_column=Column( + _COMPLETION_STATUS_PG_ENUM, + nullable=False, + server_default=ChatbotCompletionStatusEnum.IN_PROGRESS.value, + comment="Field collection progress: in_progress, or complete once every required field is gathered", + ), + ) collected_fields: dict[str, Any] = Field( default_factory=dict, sa_column=Column( @@ -197,6 +230,12 @@ class ChatbotSessionMessage(SQLModel, table=True): class CreateChatbotSessionRequest(SQLModel): + name: str = Field( + default=DEFAULT_CHATBOT_NAME, + min_length=1, + max_length=CHATBOT_NAME_MAX_LENGTH, + description=f"Display name of the chatbot; defaults to '{DEFAULT_CHATBOT_NAME}'.", + ) config_id: UUID = Field( description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.", ) @@ -222,8 +261,10 @@ class CreateChatbotSessionResponse(SQLModel): class ChatbotSessionSummary(SQLModel): session_id: UUID + name: str contact_ref: str status: ChatbotSessionStatusEnum + completion_status: ChatbotCompletionStatusEnum collected_fields: dict[str, Any] config_id: UUID config_version: int | None diff --git a/backend/app/services/chatbot/agents/__init__.py b/backend/app/services/chatbot/agents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/services/chatbot/agents/onboarding/__init__.py b/backend/app/services/chatbot/agents/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py new file mode 100644 index 000000000..c96e10b15 --- /dev/null +++ b/backend/app/services/chatbot/agents/onboarding/graph.py @@ -0,0 +1,236 @@ +"""Onboarding turn: extract the flow's fields, then reply -- two LangGraph nodes over a LangChain chat model.""" + +import asyncio +import json +from typing import Any + +import anthropic +from langchain_core.exceptions import OutputParserException +from langchain_core.language_models import LanguageModelInput +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import BaseMessage, SystemMessage +from langchain_core.runnables import Runnable +from langgraph.graph import END, START, StateGraph +from langgraph.graph.state import CompiledStateGraph +from pydantic import BaseModel, ValidationError + +from app.services.chatbot.agents.onboarding.prompts import ( + EXTRACTION_FLOW_PROMPT_TEMPLATE, + EXTRACTION_INSTRUCTION, + EXTRACTION_METHOD, + EXTRACTION_PRIOR_FIELDS_TEMPLATE, + PROGRESS_COLLECTED_TEMPLATE, + PROGRESS_COMPLETE_NOTE, + PROGRESS_MISSING_TEMPLATE, + PROGRESS_NOTE_PREFIX, +) +from app.services.chatbot.agents.onboarding.types import ( + FieldExtraction, + OnboardingState, +) +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.history import ( + has_user_turn, + insert_after_leading_system, + summarize_if_needed, + to_langchain_messages, + to_message_dicts, +) +from app.services.chatbot.types import ChatbotMessageDict, ChatbotTurnResult +from app.services.chatbot.utils import ChatbotModelSettings, get_chat_model + +ExtractionModel = Runnable[LanguageModelInput, dict | BaseModel] + +EXTRACT_FIELDS_NODE = "extract_fields_node" +CHATBOT_NODE = "chatbot_node" + + +def _format_fields(fields: dict[str, Any]) -> str: + return json.dumps(fields, ensure_ascii=False, default=str) + + +def _build_extraction_messages( + *, + flow_prompt: str, + prior_fields: dict[str, Any], + conversation_view: list[BaseMessage], +) -> list[BaseMessage]: + instruction = ( + EXTRACTION_INSTRUCTION + + EXTRACTION_FLOW_PROMPT_TEMPLATE.format(flow_prompt=flow_prompt) + + EXTRACTION_PRIOR_FIELDS_TEMPLATE.format( + prior_fields=_format_fields(prior_fields) + ) + ) + # The view's own first message is the flow prompt, already embedded above. + return [SystemMessage(content=instruction), *conversation_view[1:]] + + +def _build_progress_note(state: OnboardingState) -> SystemMessage | None: + if "is_complete" not in state: + return None + + note = "" + collected = state["collected_fields"] + if collected: + note += PROGRESS_COLLECTED_TEMPLATE.format(collected=_format_fields(collected)) + missing = state.get("missing_fields", []) + if state["is_complete"]: + note += PROGRESS_COMPLETE_NOTE + elif missing: + note += PROGRESS_MISSING_TEMPLATE.format(missing=", ".join(missing)) + if not note: + return None + return SystemMessage(content=f"{PROGRESS_NOTE_PREFIX}{note.strip()}") + + +def _invoke_extraction( + extraction_model: ExtractionModel, + messages: list[BaseMessage], +) -> FieldExtraction: + try: + extraction = extraction_model.invoke(messages) + except (OutputParserException, ValidationError) as e: + error_message = ( + "[ANTHROPIC] Field extraction returned malformed structured output — " + f"retry the turn. If persistent, contact Kaapi. (code: {type(e).__name__})" + ) + raise ChatbotProviderError(error_message) from e + + if not isinstance(extraction, FieldExtraction): + error_message = ( + "[ANTHROPIC] Field extraction returned an unexpected output shape — retry " + "the turn. If persistent, contact Kaapi. (code: UnexpectedOutputType)" + ) + raise ChatbotProviderError(error_message) + return extraction + + +def build_onboarding_graph( + chat_model: BaseChatModel, + extraction_model: ExtractionModel, + *, + flow_prompt: str, + history_token_limit: int, +) -> CompiledStateGraph: + """extract_fields_node -> chatbot_node; both models are fixed for the graph's lifetime.""" + + def extract_fields_node(state: OnboardingState) -> dict[str, Any]: + if not has_user_turn(state["messages"]): + return {} + + conversation_view = summarize_if_needed( + chat_model, state["messages"], history_token_limit=history_token_limit + ) + prior_fields = state["collected_fields"] + extraction = _invoke_extraction( + extraction_model, + _build_extraction_messages( + flow_prompt=flow_prompt, + prior_fields=prior_fields, + conversation_view=conversation_view, + ), + ) + merged_fields = {**prior_fields, **extraction.collected_fields} + is_complete = extraction.is_complete and not extraction.missing_fields + return { + "context_messages": conversation_view, + "collected_fields": merged_fields, + "missing_fields": extraction.missing_fields, + "is_complete": is_complete, + } + + def chatbot_node(state: OnboardingState) -> dict[str, list[BaseMessage]]: + messages = state.get("context_messages") + if messages is None: + messages = summarize_if_needed( + chat_model, state["messages"], history_token_limit=history_token_limit + ) + progress_note = _build_progress_note(state) + if progress_note is not None: + messages = insert_after_leading_system(messages, progress_note) + response = chat_model.invoke(messages) + return {"messages": [response]} + + graph = StateGraph(OnboardingState) + graph.add_node(EXTRACT_FIELDS_NODE, extract_fields_node) + graph.add_node(CHATBOT_NODE, chatbot_node) + graph.add_edge(START, EXTRACT_FIELDS_NODE) + graph.add_edge(EXTRACT_FIELDS_NODE, CHATBOT_NODE) + graph.add_edge(CHATBOT_NODE, END) + return graph.compile() + + +def _get_extraction_model(chat_model: BaseChatModel) -> ExtractionModel: + return chat_model.with_structured_output(FieldExtraction, method=EXTRACTION_METHOD) + + +async def run_chatbot_turn( + *, + system_prompt: str, + history: list[ChatbotMessageDict], + user_message: str | None, + model_settings: ChatbotModelSettings, + collected_fields: dict[str, Any] | None = None, +) -> ChatbotTurnResult: + """Run one stateless turn: update the collected fields, then reply. + + `user_message` may be None only on the very first call (empty `history`), + which yields the bot's opening greeting. `collected_fields` is the map from + earlier turns; it is merged with (and corrected by) this turn's extraction. + """ + if user_message is None and history: + raise ChatbotInputError( + "[KAAPI] `message` is required once `history` is non-empty; omit it only " + "on the first call to get the opening greeting." + ) + + messages = to_langchain_messages( + system_prompt=system_prompt, history=history, user_message=user_message + ) + prior_fields = dict(collected_fields) if collected_fields else {} + + initial_state: OnboardingState = { + "messages": messages, + "collected_fields": prior_fields, + } + try: + chat_model = get_chat_model(model_settings) + graph = build_onboarding_graph( + chat_model, + _get_extraction_model(chat_model), + flow_prompt=system_prompt, + history_token_limit=model_settings.history_token_limit, + ) + # ChatAnthropic.invoke is sync; keep it off the event loop. + result = await asyncio.to_thread(graph.invoke, initial_state) + except anthropic.APITimeoutError as e: + raise ChatbotProviderError( + "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " + "(code: APITimeoutError)" + ) from e + except anthropic.APIConnectionError as e: + raise ChatbotProviderError( + "[KAAPI] Network/DNS issue reaching provider — check connectivity. " + "If persistent, contact Kaapi. (code: APIConnectionError)" + ) from e + except anthropic.APIStatusError as e: + raise ChatbotProviderError( + f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, " + f"request_id: {e.request_id})" + ) from e + except ValueError as e: + error_message = ( + f"[KAAPI] Unsupported model configuration for '{model_settings.model}': " + f"{e} — update the config's model, thinking, or effort. (code: ValueError)" + ) + raise ChatbotProviderError(error_message) from e + + transcript = to_message_dicts( + system_prompt=system_prompt, messages=result["messages"] + ) + return ChatbotTurnResult( + transcript=transcript, + collected_fields=result["collected_fields"], + is_complete=result.get("is_complete", False), + ) diff --git a/backend/app/services/chatbot/agents/onboarding/prompts.py b/backend/app/services/chatbot/agents/onboarding/prompts.py new file mode 100644 index 000000000..698659741 --- /dev/null +++ b/backend/app/services/chatbot/agents/onboarding/prompts.py @@ -0,0 +1,25 @@ +EXTRACTION_METHOD = "json_schema" + +EXTRACTION_INSTRUCTION = ( + "You extract data from a chatbot conversation. You do not talk to the user.\n" + "The admin flow prompt below defines the goal, the fields to collect, and the rules.\n" + "1. Identify every field the flow prompt asks to collect; name each in snake_case. " + "Treat every field as required unless the flow prompt explicitly marks it optional.\n" + "2. collected_fields: every field whose value the user has clearly provided in the " + "conversation, including those in the previously collected map. Reuse the exact " + "keys of the previously collected map; change a value only if the user corrected it.\n" + "3. Never invent, guess, or infer a value the user did not give.\n" + "4. missing_fields: the required fields the user has not answered yet.\n" + "5. is_complete: true only when no required field is missing." +) +EXTRACTION_FLOW_PROMPT_TEMPLATE = "\n\n\n{flow_prompt}\n" +EXTRACTION_PRIOR_FIELDS_TEMPLATE = ( + "\n\n\n{prior_fields}\n" +) + +PROGRESS_NOTE_PREFIX = "[Field collection progress] " +PROGRESS_COLLECTED_TEMPLATE = ( + "Already collected (do not ask for these again): {collected}. " +) +PROGRESS_MISSING_TEMPLATE = "Still missing: {missing}. Ask only for the missing fields." +PROGRESS_COMPLETE_NOTE = "Every required field is collected; confirm and wrap up as the flow prompt describes." diff --git a/backend/app/services/chatbot/agents/onboarding/types.py b/backend/app/services/chatbot/agents/onboarding/types.py new file mode 100644 index 000000000..045646d43 --- /dev/null +++ b/backend/app/services/chatbot/agents/onboarding/types.py @@ -0,0 +1,29 @@ +from typing import Annotated, Any, NotRequired, TypedDict + +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages +from pydantic import BaseModel, Field + + +class OnboardingState(TypedDict): + messages: Annotated[list[BaseMessage], add_messages] + context_messages: NotRequired[list[BaseMessage]] + collected_fields: dict[str, Any] + missing_fields: NotRequired[list[str]] + is_complete: NotRequired[bool] + + +class FieldExtraction(BaseModel): + """Fields the admin's flow prompt asks for, as answered so far by the user.""" + + collected_fields: dict[str, str] = Field( + default_factory=dict, + description="snake_case field name -> value the user clearly provided; never guessed.", + ) + missing_fields: list[str] = Field( + default_factory=list, + description="snake_case names of required fields the user has not answered yet.", + ) + is_complete: bool = Field( + description="True only when no required field is missing.", + ) diff --git a/backend/app/services/chatbot/engine.py b/backend/app/services/chatbot/engine.py deleted file mode 100644 index 17aeb6ca2..000000000 --- a/backend/app/services/chatbot/engine.py +++ /dev/null @@ -1,171 +0,0 @@ -"""v0 chatbot: a single LangGraph node wrapping a LangChain chat model.""" - -import asyncio - -import anthropic -from langchain_core.language_models.chat_models import BaseChatModel -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage -from langgraph.graph import END, START, StateGraph -from langgraph.graph.state import CompiledStateGraph - -from app.models.chatbot import ChatbotMessageRoleEnum -from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError -from app.services.chatbot.types import ChatbotMessageDict, ChatbotState -from app.services.chatbot.utils import ( - FLOW_KEEP_RECENT_MESSAGES, - ChatbotModelSettings, - get_chat_model, -) - -CHATBOT_NODE = "chatbot_node" - -_SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " -_SUMMARIZE_INSTRUCTION = ( - "Summarize the conversation so far in 2-3 sentences. Keep every fact the " - "user has already given -- this note replaces the original messages." -) - -_KICKOFF_MESSAGE_ID = "chatbot-kickoff" -_KICKOFF_MESSAGE_CONTENT = "Begin the conversation." - - -def _summarize_if_needed( - chat_model: BaseChatModel, - messages: list[BaseMessage], - *, - history_token_limit: int, -) -> list[BaseMessage]: - """Trim the view sent to the model once history crosses the token budget.""" - if len(messages) <= FLOW_KEEP_RECENT_MESSAGES + 1: # +1 for the system message - return messages - if chat_model.get_num_tokens_from_messages(messages) <= history_token_limit: - return messages - - system_message, *rest = messages - to_summarize, recent = ( - rest[:-FLOW_KEEP_RECENT_MESSAGES], - rest[-FLOW_KEEP_RECENT_MESSAGES:], - ) - while recent and to_summarize and not isinstance(recent[0], HumanMessage): - recent.insert(0, to_summarize.pop()) - if not to_summarize: - return messages - - summary = chat_model.invoke( - [SystemMessage(content=_SUMMARIZE_INSTRUCTION), *to_summarize] - ) - summary_note = SystemMessage(content=f"{_SUMMARY_NOTE_PREFIX}{summary.text}") - return [system_message, summary_note, *recent] - - -def build_chatbot_graph( - chat_model: BaseChatModel, *, history_token_limit: int -) -> CompiledStateGraph: - """One node, no branching -- `chat_model` is fixed for the graph's lifetime.""" - - def chatbot_node(state: ChatbotState) -> dict[str, list[BaseMessage]]: - messages = _summarize_if_needed( - chat_model, state["messages"], history_token_limit=history_token_limit - ) - response = chat_model.invoke(messages) - return {"messages": [response]} - - graph = StateGraph(ChatbotState) - graph.add_node(CHATBOT_NODE, chatbot_node) - graph.add_edge(START, CHATBOT_NODE) - graph.add_edge(CHATBOT_NODE, END) - return graph.compile() - - -def _to_langchain_messages( - *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None -) -> list[BaseMessage]: - conversation: list[BaseMessage] = [] - for entry in history: - role = entry["role"] - content = entry["content"] - if role == ChatbotMessageRoleEnum.USER: - conversation.append(HumanMessage(content=content)) - elif role == ChatbotMessageRoleEnum.ASSISTANT: - conversation.append(AIMessage(content=content)) - elif role != ChatbotMessageRoleEnum.SYSTEM: - raise ChatbotInputError(f"Unsupported message role: {role}") - - if user_message is not None: - conversation.append(HumanMessage(content=user_message)) - - if not conversation or not isinstance(conversation[0], HumanMessage): - kickoff = HumanMessage(content=_KICKOFF_MESSAGE_CONTENT, id=_KICKOFF_MESSAGE_ID) - conversation.insert(0, kickoff) - - return [SystemMessage(content=system_prompt), *conversation] - - -def _to_message_dicts( - *, system_prompt: str, messages: list[BaseMessage] -) -> list[ChatbotMessageDict]: - transcript: list[ChatbotMessageDict] = [ - {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt} - ] - for message in messages: - if message.id == _KICKOFF_MESSAGE_ID: - continue - if isinstance(message, HumanMessage): - role = ChatbotMessageRoleEnum.USER - elif isinstance(message, AIMessage): - role = ChatbotMessageRoleEnum.ASSISTANT - else: - continue - transcript.append({"role": role.value, "content": message.text}) - return transcript - - -async def run_chatbot_turn( - *, - system_prompt: str, - history: list[ChatbotMessageDict], - user_message: str | None, - model_settings: ChatbotModelSettings, -) -> list[ChatbotMessageDict]: - """Run one stateless turn and return the full updated transcript. - - `user_message` may be None only on the very first call (empty `history`), - which yields the bot's opening greeting. - """ - if user_message is None and history: - raise ChatbotInputError( - "[KAAPI] `message` is required once `history` is non-empty; omit it only " - "on the first call to get the opening greeting." - ) - - messages = _to_langchain_messages( - system_prompt=system_prompt, history=history, user_message=user_message - ) - - graph = build_chatbot_graph( - get_chat_model(model_settings), - history_token_limit=model_settings.history_token_limit, - ) - try: - # ChatAnthropic.invoke is sync; keep it off the event loop. - result = await asyncio.to_thread(graph.invoke, {"messages": messages}) - except anthropic.APITimeoutError as e: - raise ChatbotProviderError( - "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " - "(code: APITimeoutError)" - ) from e - except anthropic.APIConnectionError as e: - raise ChatbotProviderError( - "[KAAPI] Network/DNS issue reaching provider — check connectivity. " - "If persistent, contact Kaapi. (code: APIConnectionError)" - ) from e - except anthropic.APIStatusError as e: - raise ChatbotProviderError( - f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, " - f"request_id: {e.request_id})" - ) from e - - transcript = _to_message_dicts( - system_prompt=system_prompt, messages=result["messages"] - ) - return transcript diff --git a/backend/app/services/chatbot/history.py b/backend/app/services/chatbot/history.py new file mode 100644 index 000000000..4efbdbe1e --- /dev/null +++ b/backend/app/services/chatbot/history.py @@ -0,0 +1,108 @@ +"""Conversation-history plumbing shared by every chatbot agent graph.""" + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage + +from app.models.chatbot import ChatbotMessageRoleEnum +from app.services.chatbot.exceptions import ChatbotInputError +from app.services.chatbot.types import ChatbotMessageDict +from app.services.chatbot.utils import FLOW_KEEP_RECENT_MESSAGES + +SUMMARY_NOTE_PREFIX = "[Earlier conversation summary] " +SUMMARIZE_INSTRUCTION = ( + "Summarize the conversation so far in 2-3 sentences. Keep every fact the " + "user has already given -- this note replaces the original messages." +) + +KICKOFF_MESSAGE_ID = "chatbot-kickoff" +KICKOFF_MESSAGE_CONTENT = "Begin the conversation." + + +def summarize_if_needed( + chat_model: BaseChatModel, + messages: list[BaseMessage], + *, + history_token_limit: int, +) -> list[BaseMessage]: + """Trim the view sent to the model once history crosses the token budget.""" + if len(messages) <= FLOW_KEEP_RECENT_MESSAGES + 1: # +1 for the system message + return messages + if chat_model.get_num_tokens_from_messages(messages) <= history_token_limit: + return messages + + system_message, *rest = messages + to_summarize, recent = ( + rest[:-FLOW_KEEP_RECENT_MESSAGES], + rest[-FLOW_KEEP_RECENT_MESSAGES:], + ) + while recent and to_summarize and not isinstance(recent[0], HumanMessage): + recent.insert(0, to_summarize.pop()) + if not to_summarize: + return messages + + summary = chat_model.invoke( + [SystemMessage(content=SUMMARIZE_INSTRUCTION), *to_summarize] + ) + summary_note = SystemMessage(content=f"{SUMMARY_NOTE_PREFIX}{summary.text}") + return [system_message, summary_note, *recent] + + +def has_user_turn(messages: list[BaseMessage]) -> bool: + for message in messages: + if isinstance(message, HumanMessage) and message.id != KICKOFF_MESSAGE_ID: + return True + return False + + +def insert_after_leading_system( + messages: list[BaseMessage], note: SystemMessage +) -> list[BaseMessage]: + # Adjacent system messages merge into Anthropic's top-level `system`; a + # trailing one is rejected as "non-consecutive" on pre-Opus-5 models. + insert_at = 0 + while insert_at < len(messages) and isinstance(messages[insert_at], SystemMessage): + insert_at += 1 + return [*messages[:insert_at], note, *messages[insert_at:]] + + +def to_langchain_messages( + *, system_prompt: str, history: list[ChatbotMessageDict], user_message: str | None +) -> list[BaseMessage]: + conversation: list[BaseMessage] = [] + for entry in history: + role = entry["role"] + content = entry["content"] + if role == ChatbotMessageRoleEnum.USER: + conversation.append(HumanMessage(content=content)) + elif role == ChatbotMessageRoleEnum.ASSISTANT: + conversation.append(AIMessage(content=content)) + elif role != ChatbotMessageRoleEnum.SYSTEM: + raise ChatbotInputError(f"Unsupported message role: {role}") + + if user_message is not None: + conversation.append(HumanMessage(content=user_message)) + + if not conversation or not isinstance(conversation[0], HumanMessage): + kickoff = HumanMessage(content=KICKOFF_MESSAGE_CONTENT, id=KICKOFF_MESSAGE_ID) + conversation.insert(0, kickoff) + + return [SystemMessage(content=system_prompt), *conversation] + + +def to_message_dicts( + *, system_prompt: str, messages: list[BaseMessage] +) -> list[ChatbotMessageDict]: + transcript: list[ChatbotMessageDict] = [ + {"role": ChatbotMessageRoleEnum.SYSTEM.value, "content": system_prompt} + ] + for message in messages: + if message.id == KICKOFF_MESSAGE_ID: + continue + if isinstance(message, HumanMessage): + role = ChatbotMessageRoleEnum.USER + elif isinstance(message, AIMessage): + role = ChatbotMessageRoleEnum.ASSISTANT + else: + continue + transcript.append({"role": role.value, "content": message.text}) + return transcript diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 279840256..496e08e97 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -10,10 +10,11 @@ create_chatbot_session_message, get_chatbot_session, list_chatbot_session_messages, + update_chatbot_session_progress, ) from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage +from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn from app.services.chatbot.config import resolve_chatbot_model_settings -from app.services.chatbot.engine import run_chatbot_turn from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.services.chatbot.types import ChatbotMessageDict from app.services.chatbot.utils import ChatbotModelSettings @@ -26,7 +27,7 @@ def _split_pending_turn( """Split the stored transcript into (prior history, pending user message). The route persists the user's message before enqueueing, so it is the last - stored row; the engine wants it separately from the history before it. + stored row; run_chatbot_turn wants it separately from the history before it. """ history: list[ChatbotMessageDict] = [] for stored in stored_messages: @@ -38,7 +39,7 @@ def _split_pending_turn( if not history: return history, None if history[-1]["role"] != ChatbotMessageRoleEnum.USER: - # No pending user turn; the engine rejects this with ChatbotInputError. + # No pending user turn; run_chatbot_turn rejects this with ChatbotInputError. return history, None pending = history.pop() return history, pending["content"] @@ -129,15 +130,16 @@ def process_chatbot_session_message( history, user_message = _split_pending_turn(stored_messages) try: - transcript = asyncio.run( + turn_result = asyncio.run( run_chatbot_turn( system_prompt=system_prompt, history=history, user_message=user_message, model_settings=model_settings, + collected_fields=collected_fields, ) ) - reply = _extract_reply(transcript) + reply = _extract_reply(turn_result.transcript) except (ChatbotInputError, ChatbotProviderError) as e: _send_failure_webhook( session_id=session_id, @@ -147,6 +149,7 @@ def process_chatbot_session_message( ) return + completion_status = turn_result.completion_status with Session(engine) as session: create_chatbot_session_message( session, @@ -154,6 +157,12 @@ def process_chatbot_session_message( role=ChatbotMessageRoleEnum.ASSISTANT, content=reply, ) + update_chatbot_session_progress( + session, + session_id=session_id, + collected_fields=turn_result.collected_fields, + completion_status=completion_status, + ) payload: dict[str, Any] = { "success": True, @@ -161,7 +170,8 @@ def process_chatbot_session_message( "session_id": str(session_id), "reply": reply, "status": session_status, - "collected_fields": collected_fields, + "completion_status": completion_status.value, + "collected_fields": turn_result.collected_fields, }, } send_callback(webhook_url, payload, webhook_secret) diff --git a/backend/app/services/chatbot/types.py b/backend/app/services/chatbot/types.py index 7746f215d..2333353e2 100644 --- a/backend/app/services/chatbot/types.py +++ b/backend/app/services/chatbot/types.py @@ -1,10 +1,19 @@ -from typing import Annotated, TypedDict +from dataclasses import dataclass +from typing import Any -from langchain_core.messages import BaseMessage -from langgraph.graph.message import add_messages +from app.models.chatbot import ChatbotCompletionStatusEnum ChatbotMessageDict = dict[str, str] -class ChatbotState(TypedDict): - messages: Annotated[list[BaseMessage], add_messages] +@dataclass(frozen=True) +class ChatbotTurnResult: + transcript: list[ChatbotMessageDict] + collected_fields: dict[str, Any] + is_complete: bool + + @property + def completion_status(self) -> ChatbotCompletionStatusEnum: + if self.is_complete: + return ChatbotCompletionStatusEnum.COMPLETE + return ChatbotCompletionStatusEnum.IN_PROGRESS diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py index 5f2be7f19..66ebfc95f 100644 --- a/backend/app/tests/api/routes/chatbot/test_message.py +++ b/backend/app/tests/api/routes/chatbot/test_message.py @@ -6,6 +6,7 @@ from app.core.config import settings from app.models import Config +from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model from app.tests.utils.test_data import ( @@ -58,6 +59,70 @@ def test_returns_updated_transcript( {"role": "assistant", "content": "Nice, which city?"}, ] + def test_returns_extracted_fields_and_completion_status( + self, + client: TestClient, + user_api_key: TestAuthContext, + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + patch_chat_model( + monkeypatch, + ScriptedChatModel( + replies=["Thanks, Asha from Pune!"], + extractions=[ + FieldExtraction( + collected_fields={"name": "Asha", "city": "Pune"}, + missing_fields=[], + is_complete=True, + ) + ], + ), + ) + + resp = client.post( + MESSAGE_URL, + headers={"X-API-KEY": user_api_key.key}, + json={ + "config_id": str(anthropic_config.id), + "system_prompt": SYSTEM_PROMPT, + "history": [ + {"role": "assistant", "content": "Hi! What's your name and city?"} + ], + "message": "Asha, Pune", + }, + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["collected_fields"] == {"name": "Asha", "city": "Pune"} + assert body["completion_status"] == "complete" + + def test_greeting_returns_no_fields_in_progress( + self, + client: TestClient, + user_api_key: TestAuthContext, + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + patch_chat_model( + monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) + ) + + resp = client.post( + MESSAGE_URL, + headers={"X-API-KEY": user_api_key.key}, + json={ + "config_id": str(anthropic_config.id), + "system_prompt": SYSTEM_PROMPT, + }, + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["collected_fields"] == {} + assert body["completion_status"] == "in_progress" + def test_missing_message_with_history_returns_422( self, client: TestClient, diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index 128ede74f..6dde33189 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -8,8 +8,18 @@ from app.api.routes.chatbot import session as session_routes from app.core.config import settings -from app.crud.chatbot import get_chatbot_session, list_chatbot_session_messages -from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config +from app.crud.chatbot import ( + get_chatbot_session, + list_chatbot_session_messages, + update_chatbot_session_progress, +) +from app.models import ( + DEFAULT_CHATBOT_NAME, + ChatbotCompletionStatusEnum, + ChatbotMessageRoleEnum, + ChatbotSession, + Config, +) from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( add_test_chatbot_messages, @@ -85,6 +95,7 @@ def test_creates_session_in_callers_project( SESSIONS_URL, headers=headers, json={ + "name": "Onboarding Bot", "config_id": str(anthropic_config.id), "config_version": 1, "contact_ref": contact_ref, @@ -101,11 +112,40 @@ def test_creates_session_in_callers_project( project_id=user_api_key.project_id, ) assert stored is not None + assert stored.name == "Onboarding Bot" assert stored.contact_ref == contact_ref assert stored.config_id == anthropic_config.id assert stored.config_version == 1 assert stored.system_prompt == SYSTEM_PROMPT + def test_omitted_name_uses_default( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + user_api_key: TestAuthContext, + anthropic_config: Config, + ) -> None: + resp = client.post( + SESSIONS_URL, + headers=headers, + json={ + "config_id": str(anthropic_config.id), + "contact_ref": f"contact-{random_lower_string()}", + "system_prompt": SYSTEM_PROMPT, + }, + ) + + assert resp.status_code == 201 + stored = get_chatbot_session( + db, + session_id=resp.json()["data"]["session_id"], + organization_id=user_api_key.organization_id, + project_id=user_api_key.project_id, + ) + assert stored is not None + assert stored.name == DEFAULT_CHATBOT_NAME + def test_unknown_config_returns_404( self, client: TestClient, headers: dict[str, str] ) -> None: @@ -146,8 +186,8 @@ def test_non_anthropic_config_returns_422( assert "only supports configs" in resp.json()["error"] -class TestListChatbotSessions: - def test_lists_contacts_sessions_newest_first_in_callers_project( +class TestGetLatestChatbotSession: + def test_returns_contacts_newest_session_in_callers_project( self, client: TestClient, db: Session, @@ -155,11 +195,15 @@ def test_lists_contacts_sessions_newest_first_in_callers_project( anthropic_config: Config, ) -> None: contact_ref = f"contact-{random_lower_string()}" - older = create_test_chatbot_session( + create_test_chatbot_session( db, config=anthropic_config, contact_ref=contact_ref ) newer = create_test_chatbot_session( - db, config=anthropic_config, contact_ref=contact_ref, config_version=1 + db, + config=anthropic_config, + name="Onboarding Bot", + contact_ref=contact_ref, + config_version=1, ) create_test_chatbot_session(db, config=anthropic_config) foreign_config = create_test_config( @@ -173,13 +217,51 @@ def test_lists_contacts_sessions_newest_first_in_callers_project( assert resp.status_code == 200 body = resp.json()["data"] - assert [s["session_id"] for s in body] == [str(newer.id), str(older.id)] - assert body[0]["contact_ref"] == contact_ref - assert body[0]["status"] == "active" - assert body[0]["collected_fields"] == {} - assert body[0]["config_id"] == str(anthropic_config.id) - assert body[0]["config_version"] == 1 - assert body[1]["config_version"] is None + assert body["session_id"] == str(newer.id) + assert body["name"] == "Onboarding Bot" + assert body["contact_ref"] == contact_ref + assert body["status"] == "active" + assert body["completion_status"] == "in_progress" + assert body["collected_fields"] == {} + assert body["config_id"] == str(anthropic_config.id) + assert body["config_version"] == 1 + + def test_reports_stored_field_progress( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot_session: ChatbotSession, + ) -> None: + update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha", "city": "Pune"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + + resp = client.get( + SESSIONS_URL, + headers=headers, + params={"contact_ref": chatbot_session.contact_ref}, + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["session_id"] == str(chatbot_session.id) + assert body["completion_status"] == "complete" + assert body["collected_fields"] == {"name": "Asha", "city": "Pune"} + + def test_unknown_contact_returns_404( + self, client: TestClient, headers: dict[str, str] + ) -> None: + resp = client.get( + SESSIONS_URL, + headers=headers, + params={"contact_ref": f"contact-{random_lower_string()}"}, + ) + + assert resp.status_code == 404 def test_missing_contact_ref_returns_422( self, client: TestClient, headers: dict[str, str] diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py index 54d276eeb..b3bdb8fcd 100644 --- a/backend/app/tests/crud/test_chatbot.py +++ b/backend/app/tests/crud/test_chatbot.py @@ -7,10 +7,12 @@ create_chatbot_session, create_chatbot_session_message, get_chatbot_session, + get_latest_chatbot_session_by_contact_ref, list_chatbot_session_messages, - list_chatbot_sessions_by_contact_ref, + update_chatbot_session_progress, ) from app.models import ( + ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionStatusEnum, @@ -51,6 +53,7 @@ def test_persists_session_as_active_with_no_collected_fields( db, organization_id=project.organization_id, project_id=project.id, + name="Onboarding Bot", contact_ref=contact_ref, config_id=config.id, config_version=1, @@ -60,12 +63,14 @@ def test_persists_session_as_active_with_no_collected_fields( db.expire_all() stored = db.get(ChatbotSession, created.id) assert stored is not None + assert stored.name == "Onboarding Bot" assert stored.contact_ref == contact_ref assert stored.config_id == config.id assert stored.config_version == 1 assert stored.system_prompt == "Collect the user's name." assert stored.status == ChatbotSessionStatusEnum.ACTIVE assert stored.collected_fields == {} + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS assert stored.inserted_at == stored.updated_at @@ -179,12 +184,12 @@ def test_list_empty_session_returns_empty( assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] -class TestListChatbotSessionsByContactRef: - def test_returns_newest_first_scoped_to_contact_and_project( +class TestGetLatestChatbotSessionByContactRef: + def test_returns_newest_scoped_to_contact_and_project( self, db: Session, config: Config, project: Project ) -> None: contact_ref = f"contact-{random_lower_string()}" - oldest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref) + create_test_chatbot_session(db, config=config, contact_ref=contact_ref) newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref) create_test_chatbot_session(db, config=config) other_config = create_test_config( @@ -192,26 +197,86 @@ def test_returns_newest_first_scoped_to_contact_and_project( ) create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref) - sessions = list_chatbot_sessions_by_contact_ref( + latest = get_latest_chatbot_session_by_contact_ref( db, contact_ref=contact_ref, organization_id=project.organization_id, project_id=project.id, ) - assert [s.id for s in sessions] == [newest.id, oldest.id] + assert latest is not None + assert latest.id == newest.id - def test_unknown_contact_returns_empty( + def test_unknown_contact_returns_none( self, db: Session, config: Config, project: Project ) -> None: create_test_chatbot_session(db, config=config) assert ( - list_chatbot_sessions_by_contact_ref( + get_latest_chatbot_session_by_contact_ref( db, contact_ref=f"contact-{random_lower_string()}", organization_id=project.organization_id, project_id=project.id, ) - == [] + is None + ) + + +class TestUpdateChatbotSessionProgress: + def test_persists_fields_and_status_and_bumps_updated_at( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + inserted_at = chatbot_session.inserted_at + + returned = update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha", "city": "Pune"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + + assert returned is not None + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + assert stored.collected_fields == {"name": "Asha", "city": "Pune"} + assert stored.completion_status == ChatbotCompletionStatusEnum.COMPLETE + assert stored.updated_at > inserted_at + assert stored.inserted_at == inserted_at + + def test_replaces_rather_than_merges_fields( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha", "city": "Pune"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + + update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha"}, + completion_status=ChatbotCompletionStatusEnum.IN_PROGRESS, + ) + + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + assert stored.collected_fields == {"name": "Asha"} + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS + + def test_unknown_id_returns_none(self, db: Session) -> None: + assert ( + update_chatbot_session_progress( + db, + session_id=uuid4(), + collected_fields={"name": "Asha"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + is None ) diff --git a/backend/app/tests/services/chatbot/agents/__init__.py b/backend/app/tests/services/chatbot/agents/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/services/chatbot/agents/onboarding/__init__.py b/backend/app/tests/services/chatbot/agents/onboarding/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py new file mode 100644 index 000000000..c5d932438 --- /dev/null +++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py @@ -0,0 +1,614 @@ +import anthropic +import httpx +import pytest +from langchain_core.exceptions import OutputParserException +from langchain_core.messages import HumanMessage, SystemMessage + +from app.models import ChatbotCompletionStatusEnum +from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn +from app.services.chatbot.agents.onboarding.types import FieldExtraction +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.utils import ChatbotModelSettings +from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model + +SYSTEM_PROMPT = "Collect the user's name and city." +SUMMARY_TEXT = "User is Asha from Pune." +PROGRESS_PREFIX = "[Field collection progress] " +COLLECTED_PREFIX = "Already collected (do not ask for these again): " + +_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + + +def _alternating_history(turns: int) -> list[dict[str, str]]: + history: list[dict[str, str]] = [] + for i in range(turns): + history.append({"role": "user", "content": f"user {i}"}) + history.append({"role": "assistant", "content": f"bot {i}"}) + return history + + +def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings: + return ChatbotModelSettings( + model="claude-sonnet-4-6", history_token_limit=history_token_limit + ) + + +class TestRunChatbotTurn: + @pytest.mark.asyncio + async def test_first_call_returns_greeting_without_kickoff( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Hi! What's your name?"]) + patch_chat_model(monkeypatch, chat_model) + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message=None, + model_settings=_model_settings(), + ) + + assert result.transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + ] + # Anthropic rejects a conversation that doesn't open with a user turn. + sent = chat_model.calls[0] + assert isinstance(sent[0], SystemMessage) + assert isinstance(sent[1], HumanMessage) + assert sent[1].content == "Begin the conversation." + + @pytest.mark.asyncio + async def test_follow_up_turn_appends_user_message_and_reply( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Nice to meet you, Asha."]) + patch_chat_model(monkeypatch, chat_model) + history = [ + {"role": "system", "content": "stale prompt from the previous call"}, + {"role": "assistant", "content": "Hi! What's your name?"}, + ] + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=history, + user_message="Asha", + model_settings=_model_settings(), + ) + + assert result.transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "assistant", "content": "Hi! What's your name?"}, + {"role": "user", "content": "Asha"}, + {"role": "assistant", "content": "Nice to meet you, Asha."}, + ] + sent_contents = [m.content for m in chat_model.calls[0]] + assert "stale prompt from the previous call" not in sent_contents + + @pytest.mark.asyncio + async def test_missing_message_with_history_rejected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel() + patch_chat_model(monkeypatch, chat_model) + + with pytest.raises(ChatbotInputError, match="`message` is required"): + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Hi!"}], + user_message=None, + model_settings=_model_settings(), + ) + assert chat_model.calls == [] + + @pytest.mark.asyncio + async def test_unsupported_role_rejected( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel() + patch_chat_model(monkeypatch, chat_model) + + with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"): + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "tool", "content": "{}"}], + user_message="hello", + model_settings=_model_settings(), + ) + assert chat_model.calls == [] + + +class TestProviderErrorMapping: + @pytest.mark.parametrize( + ("error", "expected_message"), + [ + ( + anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), + "(code: APITimeoutError)", + ), + ( + anthropic.APIConnectionError(request=_ANTHROPIC_REQUEST), + "(code: APIConnectionError)", + ), + ( + ValueError("thinking.type=disabled is not supported"), + "[KAAPI] Unsupported model configuration for 'claude-sonnet-4-6': " + "thinking.type=disabled is not supported", + ), + ( + anthropic.APIStatusError( + "Overloaded", + response=httpx.Response( + 529, + request=_ANTHROPIC_REQUEST, + headers={"request-id": "req_123"}, + ), + body=None, + ), + "[ANTHROPIC] Chat model call failed: Overloaded (code: 529, request_id: req_123)", + ), + ], + ids=["timeout", "connection", "value_error", "status"], + ) + @pytest.mark.asyncio + async def test_anthropic_errors_become_provider_errors( + self, + monkeypatch: pytest.MonkeyPatch, + error: anthropic.APIError, + expected_message: str, + ) -> None: + patch_chat_model(monkeypatch, ScriptedChatModel(error=error)) + + with pytest.raises(ChatbotProviderError) as exc_info: + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="hello", + model_settings=_model_settings(), + ) + + assert expected_message in str(exc_info.value) + assert exc_info.value.__cause__ is error + + +class TestHistorySummarization: + @pytest.mark.asyncio + async def test_long_history_over_budget_is_summarized( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=[SUMMARY_TEXT, "Got it."], token_count=5000 + ) + patch_chat_model(monkeypatch, chat_model) + history = _alternating_history(turns=4) + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=history, + user_message="user 4", + model_settings=_model_settings(history_token_limit=4000), + ) + + summarize_call, reply_call = chat_model.calls + # 9 conversation messages, keep-recent=6 lands on an assistant turn, so the + # window widens back to the preceding user turn: [user 0, bot 0] get summarized. + assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"] + assert [m.content for m in reply_call] == [ + SYSTEM_PROMPT, + f"[Earlier conversation summary] {SUMMARY_TEXT}", + "user 1", + "bot 1", + "user 2", + "bot 2", + "user 3", + "bot 3", + "user 4", + ] + # Summarization only trims the model's view; the caller keeps the full transcript. + assert result.transcript == [ + {"role": "system", "content": SYSTEM_PROMPT}, + *history, + {"role": "user", "content": "user 4"}, + {"role": "assistant", "content": "Got it."}, + ] + + @pytest.mark.asyncio + async def test_long_history_within_budget_is_sent_whole( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=100) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=_alternating_history(turns=4), + user_message="user 4", + model_settings=_model_settings(history_token_limit=4000), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 10 + + @pytest.mark.asyncio + async def test_short_history_never_summarized( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=_alternating_history(turns=3)[:5], + user_message="more", + model_settings=_model_settings(history_token_limit=10), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 7 + + @pytest.mark.asyncio + async def test_no_user_turn_to_split_on_skips_summary( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) + patch_chat_model(monkeypatch, chat_model) + # Only the injected kickoff precedes the recent window, so widening the + # window to start on a user turn consumes everything there was to summarize. + assistant_only = [ + {"role": "assistant", "content": f"bot {i}"} for i in range(7) + ] + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=assistant_only, + user_message="hello", + model_settings=_model_settings(history_token_limit=10), + ) + + assert len(chat_model.calls) == 1 + assert len(chat_model.calls[0]) == 10 + + @pytest.mark.asyncio + async def test_long_history_summary_is_shared_by_extraction_and_reply( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=[SUMMARY_TEXT, "Got it."], + token_count=5000, + extractions=[ + FieldExtraction( + collected_fields={"name": "Asha"}, + missing_fields=["city"], + is_complete=False, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=_alternating_history(turns=4), + user_message="user 4", + model_settings=_model_settings(history_token_limit=4000), + ) + + # Exactly one summarize call: the extraction node's view is reused for the reply. + summarize_call, reply_call = chat_model.calls + assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"] + summary_note = f"[Earlier conversation summary] {SUMMARY_TEXT}" + recent = ["user 1", "bot 1", "user 2", "bot 2", "user 3", "bot 3", "user 4"] + [extraction_call] = chat_model.extraction_calls + assert [m.content for m in extraction_call[1:]] == [summary_note, *recent] + # The progress note joins the leading system block, after the summary note. + assert [m.content for m in reply_call] == [ + SYSTEM_PROMPT, + summary_note, + f'{PROGRESS_PREFIX}{COLLECTED_PREFIX}{{"name": "Asha"}}. ' + "Still missing: city. Ask only for the missing fields.", + *recent, + ] + assert all(isinstance(m, SystemMessage) for m in reply_call[:3]) + + +class TestFieldExtraction: + @pytest.mark.asyncio + async def test_greeting_turn_skips_extraction( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=["Hi! What's your name?"], + extractions=[ + FieldExtraction( + collected_fields={"name": "invented"}, + missing_fields=[], + is_complete=True, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message=None, + model_settings=_model_settings(), + collected_fields={"name": "Asha"}, + ) + + assert chat_model.extraction_calls == [] + assert result.collected_fields == {"name": "Asha"} + assert result.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS + [reply_call] = chat_model.calls + assert [m.content for m in reply_call] == [ + SYSTEM_PROMPT, + "Begin the conversation.", + ] + + @pytest.mark.asyncio + async def test_extraction_uses_native_json_schema_output( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Which city?"]) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="I'm Asha", + model_settings=_model_settings(), + ) + + # Forced tool use is rejected by Anthropic when thinking is on. + assert chat_model.structured_output_methods == ["json_schema"] + + @pytest.mark.asyncio + async def test_extraction_sees_flow_prompt_prior_fields_and_conversation( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Which city?"]) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Hi! What's your name?"}], + user_message="I'm Asha", + model_settings=_model_settings(), + collected_fields={"age": "30"}, + ) + + [extraction_call] = chat_model.extraction_calls + instruction, *conversation = extraction_call + assert isinstance(instruction, SystemMessage) + assert f"\n{SYSTEM_PROMPT}\n" in instruction.text + assert ( + '\n{"age": "30"}\n' + in instruction.text + ) + # The flow prompt is embedded in the instruction, not repeated as a message. + assert [m.content for m in conversation] == [ + "Begin the conversation.", + "Hi! What's your name?", + "I'm Asha", + ] + + @pytest.mark.asyncio + async def test_extracted_fields_merge_over_prior_with_corrections_winning( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=["Noted."], + extractions=[ + FieldExtraction( + collected_fields={"city": "Mumbai", "age": "30"}, + missing_fields=[], + is_complete=False, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Anything to change?"}], + user_message="Actually I live in Mumbai now, and I'm 30", + model_settings=_model_settings(), + collected_fields={"name": "Asha", "city": "Pune"}, + ) + + assert result.collected_fields == { + "name": "Asha", + "city": "Mumbai", + "age": "30", + } + + @pytest.mark.parametrize( + ("is_complete", "missing_fields", "expected_status"), + [ + (True, [], ChatbotCompletionStatusEnum.COMPLETE), + (True, ["city"], ChatbotCompletionStatusEnum.IN_PROGRESS), + (False, [], ChatbotCompletionStatusEnum.IN_PROGRESS), + ], + ids=["complete", "complete_but_lists_missing", "not_complete"], + ) + @pytest.mark.asyncio + async def test_completion_requires_flag_and_no_missing_fields( + self, + monkeypatch: pytest.MonkeyPatch, + is_complete: bool, + missing_fields: list[str], + expected_status: ChatbotCompletionStatusEnum, + ) -> None: + chat_model = ScriptedChatModel( + replies=["Thanks!"], + extractions=[ + FieldExtraction( + collected_fields={"name": "Asha"}, + missing_fields=missing_fields, + is_complete=is_complete, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + + result = await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="I'm Asha", + model_settings=_model_settings(), + ) + + assert result.completion_status == expected_status + + +class TestProgressNote: + @pytest.mark.parametrize( + ("extraction", "expected_note"), + [ + ( + FieldExtraction( + collected_fields={"name": "Asha"}, + missing_fields=["city"], + is_complete=False, + ), + f'{COLLECTED_PREFIX}{{"name": "Asha"}}. ' + "Still missing: city. Ask only for the missing fields.", + ), + ( + FieldExtraction( + collected_fields={}, + missing_fields=["name", "city"], + is_complete=False, + ), + "Still missing: name, city. Ask only for the missing fields.", + ), + ( + FieldExtraction( + collected_fields={"name": "Asha", "city": "Pune"}, + missing_fields=[], + is_complete=True, + ), + f'{COLLECTED_PREFIX}{{"name": "Asha", "city": "Pune"}}. ' + "Every required field is collected; confirm and wrap up as the " + "flow prompt describes.", + ), + ], + ids=["collected_and_missing", "missing_only", "complete"], + ) + @pytest.mark.asyncio + async def test_note_follows_system_prompt_in_reply_call( + self, + monkeypatch: pytest.MonkeyPatch, + extraction: FieldExtraction, + expected_note: str, + ) -> None: + chat_model = ScriptedChatModel(replies=["Reply."], extractions=[extraction]) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Hi! What's your name?"}], + user_message="I'm Asha from Pune", + model_settings=_model_settings(), + ) + + [reply_call] = chat_model.calls + assert [type(m) for m in reply_call[:2]] == [SystemMessage, SystemMessage] + assert [m.content for m in reply_call] == [ + SYSTEM_PROMPT, + f"{PROGRESS_PREFIX}{expected_note}", + "Begin the conversation.", + "Hi! What's your name?", + "I'm Asha from Pune", + ] + + @pytest.mark.asyncio + async def test_no_note_when_nothing_collected_missing_or_complete( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel( + replies=["Reply."], + extractions=[ + FieldExtraction( + collected_fields={}, missing_fields=[], is_complete=False + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="hello", + model_settings=_model_settings(), + ) + + [reply_call] = chat_model.calls + assert [m.content for m in reply_call] == [SYSTEM_PROMPT, "hello"] + + @pytest.mark.asyncio + async def test_prior_fields_survive_into_note_when_extraction_adds_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + chat_model = ScriptedChatModel(replies=["Which city?"]) + patch_chat_model(monkeypatch, chat_model) + + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[{"role": "assistant", "content": "Which city?"}], + user_message="hmm", + model_settings=_model_settings(), + collected_fields={"name": "Asha"}, + ) + + [reply_call] = chat_model.calls + assert reply_call[1].content == ( + f'{PROGRESS_PREFIX}{COLLECTED_PREFIX}{{"name": "Asha"}}.' + ) + + +class TestExtractionErrors: + @pytest.mark.parametrize( + ("extraction_outcome", "expected_message"), + [ + ( + OutputParserException("not valid JSON"), + "[ANTHROPIC] Field extraction returned malformed structured output", + ), + ( + {"collected_fields": {}, "missing_fields": [], "is_complete": True}, + "[ANTHROPIC] Field extraction returned an unexpected output shape", + ), + ( + ValueError("effort is not supported for this model"), + "[KAAPI] Unsupported model configuration for 'claude-sonnet-4-6': " + "effort is not supported for this model", + ), + ( + anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), + "(code: APITimeoutError)", + ), + ], + ids=["output_parser", "wrong_type", "value_error", "anthropic_timeout"], + ) + @pytest.mark.asyncio + async def test_extraction_failure_becomes_provider_error_before_reply( + self, + monkeypatch: pytest.MonkeyPatch, + extraction_outcome: object, + expected_message: str, + ) -> None: + chat_model = ScriptedChatModel( + replies=["should never be used"], extractions=[extraction_outcome] + ) + patch_chat_model(monkeypatch, chat_model) + + with pytest.raises(ChatbotProviderError) as exc_info: + await run_chatbot_turn( + system_prompt=SYSTEM_PROMPT, + history=[], + user_message="I'm Asha", + model_settings=_model_settings(), + ) + + assert expected_message in str(exc_info.value) + assert chat_model.calls == [] diff --git a/backend/app/tests/services/chatbot/test_engine.py b/backend/app/tests/services/chatbot/test_engine.py deleted file mode 100644 index 610c6a49d..000000000 --- a/backend/app/tests/services/chatbot/test_engine.py +++ /dev/null @@ -1,259 +0,0 @@ -import anthropic -import httpx -import pytest -from langchain_core.messages import HumanMessage, SystemMessage - -from app.services.chatbot.engine import run_chatbot_turn -from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError -from app.services.chatbot.utils import ChatbotModelSettings -from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model - -SYSTEM_PROMPT = "Collect the user's name and city." -SUMMARY_TEXT = "User is Asha from Pune." - -_ANTHROPIC_REQUEST = httpx.Request("POST", "https://api.anthropic.com/v1/messages") - - -def _alternating_history(turns: int) -> list[dict[str, str]]: - history: list[dict[str, str]] = [] - for i in range(turns): - history.append({"role": "user", "content": f"user {i}"}) - history.append({"role": "assistant", "content": f"bot {i}"}) - return history - - -def _model_settings(history_token_limit: int = 4000) -> ChatbotModelSettings: - return ChatbotModelSettings( - model="claude-sonnet-4-6", history_token_limit=history_token_limit - ) - - -class TestRunChatbotTurn: - @pytest.mark.asyncio - async def test_first_call_returns_greeting_without_kickoff( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel(replies=["Hi! What's your name?"]) - patch_chat_model(monkeypatch, chat_model) - - transcript = await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=[], - user_message=None, - model_settings=_model_settings(), - ) - - assert transcript == [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": "Hi! What's your name?"}, - ] - # Anthropic rejects a conversation that doesn't open with a user turn. - sent = chat_model.calls[0] - assert isinstance(sent[0], SystemMessage) - assert isinstance(sent[1], HumanMessage) - assert sent[1].content == "Begin the conversation." - - @pytest.mark.asyncio - async def test_follow_up_turn_appends_user_message_and_reply( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel(replies=["Nice to meet you, Asha."]) - patch_chat_model(monkeypatch, chat_model) - history = [ - {"role": "system", "content": "stale prompt from the previous call"}, - {"role": "assistant", "content": "Hi! What's your name?"}, - ] - - transcript = await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=history, - user_message="Asha", - model_settings=_model_settings(), - ) - - assert transcript == [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": "Hi! What's your name?"}, - {"role": "user", "content": "Asha"}, - {"role": "assistant", "content": "Nice to meet you, Asha."}, - ] - sent_contents = [m.content for m in chat_model.calls[0]] - assert "stale prompt from the previous call" not in sent_contents - - @pytest.mark.asyncio - async def test_missing_message_with_history_rejected( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel() - patch_chat_model(monkeypatch, chat_model) - - with pytest.raises(ChatbotInputError, match="`message` is required"): - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=[{"role": "assistant", "content": "Hi!"}], - user_message=None, - model_settings=_model_settings(), - ) - assert chat_model.calls == [] - - @pytest.mark.asyncio - async def test_unsupported_role_rejected( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel() - patch_chat_model(monkeypatch, chat_model) - - with pytest.raises(ChatbotInputError, match="Unsupported message role: tool"): - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=[{"role": "tool", "content": "{}"}], - user_message="hello", - model_settings=_model_settings(), - ) - assert chat_model.calls == [] - - -class TestProviderErrorMapping: - @pytest.mark.parametrize( - ("error", "expected_message"), - [ - ( - anthropic.APITimeoutError(request=_ANTHROPIC_REQUEST), - "(code: APITimeoutError)", - ), - ( - anthropic.APIConnectionError(request=_ANTHROPIC_REQUEST), - "(code: APIConnectionError)", - ), - ( - anthropic.APIStatusError( - "Overloaded", - response=httpx.Response( - 529, - request=_ANTHROPIC_REQUEST, - headers={"request-id": "req_123"}, - ), - body=None, - ), - "[ANTHROPIC] Chat model call failed: Overloaded (code: 529, request_id: req_123)", - ), - ], - ids=["timeout", "connection", "status"], - ) - @pytest.mark.asyncio - async def test_anthropic_errors_become_provider_errors( - self, - monkeypatch: pytest.MonkeyPatch, - error: anthropic.APIError, - expected_message: str, - ) -> None: - patch_chat_model(monkeypatch, ScriptedChatModel(error=error)) - - with pytest.raises(ChatbotProviderError) as exc_info: - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=[], - user_message="hello", - model_settings=_model_settings(), - ) - - assert expected_message in str(exc_info.value) - assert exc_info.value.__cause__ is error - - -class TestHistorySummarization: - @pytest.mark.asyncio - async def test_long_history_over_budget_is_summarized( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel( - replies=[SUMMARY_TEXT, "Got it."], token_count=5000 - ) - patch_chat_model(monkeypatch, chat_model) - history = _alternating_history(turns=4) - - transcript = await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=history, - user_message="user 4", - model_settings=_model_settings(history_token_limit=4000), - ) - - summarize_call, reply_call = chat_model.calls - # 9 conversation messages, keep-recent=6 lands on an assistant turn, so the - # window widens back to the preceding user turn: [user 0, bot 0] get summarized. - assert [m.content for m in summarize_call[1:]] == ["user 0", "bot 0"] - assert [m.content for m in reply_call] == [ - SYSTEM_PROMPT, - f"[Earlier conversation summary] {SUMMARY_TEXT}", - "user 1", - "bot 1", - "user 2", - "bot 2", - "user 3", - "bot 3", - "user 4", - ] - # Summarization only trims the model's view; the caller keeps the full transcript. - assert transcript == [ - {"role": "system", "content": SYSTEM_PROMPT}, - *history, - {"role": "user", "content": "user 4"}, - {"role": "assistant", "content": "Got it."}, - ] - - @pytest.mark.asyncio - async def test_long_history_within_budget_is_sent_whole( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel(replies=["Got it."], token_count=100) - patch_chat_model(monkeypatch, chat_model) - - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=_alternating_history(turns=4), - user_message="user 4", - model_settings=_model_settings(history_token_limit=4000), - ) - - assert len(chat_model.calls) == 1 - assert len(chat_model.calls[0]) == 10 - - @pytest.mark.asyncio - async def test_short_history_never_summarized( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) - patch_chat_model(monkeypatch, chat_model) - - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=_alternating_history(turns=3)[:5], - user_message="more", - model_settings=_model_settings(history_token_limit=10), - ) - - assert len(chat_model.calls) == 1 - assert len(chat_model.calls[0]) == 7 - - @pytest.mark.asyncio - async def test_no_user_turn_to_split_on_skips_summary( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - chat_model = ScriptedChatModel(replies=["Got it."], token_count=10_000) - patch_chat_model(monkeypatch, chat_model) - # Only the injected kickoff precedes the recent window, so widening the - # window to start on a user turn consumes everything there was to summarize. - assistant_only = [ - {"role": "assistant", "content": f"bot {i}"} for i in range(7) - ] - - await run_chatbot_turn( - system_prompt=SYSTEM_PROMPT, - history=assistant_only, - user_message="hello", - model_settings=_model_settings(history_token_limit=10), - ) - - assert len(chat_model.calls) == 1 - assert len(chat_model.calls[0]) == 10 diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py index 22ad3b93e..6a5204607 100644 --- a/backend/app/tests/services/chatbot/test_session.py +++ b/backend/app/tests/services/chatbot/test_session.py @@ -8,10 +8,20 @@ import pytest from sqlmodel import Session -from app.crud.chatbot import list_chatbot_session_messages +from app.crud.chatbot import ( + list_chatbot_session_messages, + update_chatbot_session_progress, +) from app.crud.config import ConfigVersionCrud -from app.models import ChatbotMessageRoleEnum, ChatbotSession, Config, Project +from app.models import ( + ChatbotCompletionStatusEnum, + ChatbotMessageRoleEnum, + ChatbotSession, + Config, + Project, +) from app.services.chatbot import session as session_service +from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.services.chatbot.session import process_chatbot_session_message from app.tests.utils.chatbot import ( DEFAULT_SYSTEM_PROMPT, @@ -120,6 +130,7 @@ def test_first_call_stores_greeting_and_posts_success_webhook( "session_id": str(chatbot_session.id), "reply": "Hi! What's your name?", "status": "active", + "completion_status": "in_progress", "collected_fields": {}, }, }, @@ -338,3 +349,138 @@ def test_transcript_without_pending_user_turn_posts_failure_webhook( [payload] = callbacks.payloads assert payload["success"] is False assert "`message` is required" in payload["error"] + + +class TestSessionFieldProgress: + def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel( + replies=["Thanks, all set!"], + extractions=[ + FieldExtraction( + collected_fields={"city": "Pune"}, + missing_fields=[], + is_complete=True, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha"}, + completion_status=ChatbotCompletionStatusEnum.IN_PROGRESS, + ) + updated_before_turn = chatbot_session.updated_at + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.ASSISTANT, "Which city?"), + (ChatbotMessageRoleEnum.USER, "Pune"), + ], + ) + + _process(chatbot_session, db) + + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + # "name" only survives if the stored prior map was handed to the engine. + assert stored.collected_fields == {"name": "Asha", "city": "Pune"} + assert stored.completion_status == ChatbotCompletionStatusEnum.COMPLETE + assert stored.updated_at > updated_before_turn + assert callbacks.payloads == [ + { + "success": True, + "data": { + "session_id": str(chatbot_session.id), + "reply": "Thanks, all set!", + "status": "active", + "completion_status": "complete", + "collected_fields": {"name": "Asha", "city": "Pune"}, + }, + } + ] + + def test_extraction_value_error_posts_failure_webhook_and_keeps_progress( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel( + replies=["should never be used"], + extractions=[ValueError("thinking cannot be disabled for this model")], + ) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + add_test_chatbot_messages( + db, chatbot_session, [(ChatbotMessageRoleEnum.USER, "Asha")] + ) + + _process(chatbot_session, db) + + [payload] = callbacks.payloads + assert payload["success"] is False + assert "[KAAPI] Unsupported model configuration" in payload["error"] + assert "thinking cannot be disabled for this model" in payload["error"] + assert payload["data"] == {"session_id": str(chatbot_session.id)} + assert _transcript(db, chatbot_session) == [("user", "Asha")] + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + assert stored.collected_fields == {} + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS + + def test_complete_session_still_answers_new_messages( + self, + db: Session, + config: Config, + callbacks: CallbackRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + chat_model = ScriptedChatModel( + replies=["You're welcome!"], + extractions=[ + FieldExtraction( + collected_fields={"name": "Asha", "city": "Pune"}, + missing_fields=[], + is_complete=True, + ) + ], + ) + patch_chat_model(monkeypatch, chat_model) + chatbot_session = create_test_chatbot_session(db, config=config) + update_chatbot_session_progress( + db, + session_id=chatbot_session.id, + collected_fields={"name": "Asha", "city": "Pune"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + add_test_chatbot_messages( + db, + chatbot_session, + [ + (ChatbotMessageRoleEnum.ASSISTANT, "All set!"), + (ChatbotMessageRoleEnum.USER, "Thanks"), + ], + ) + + _process(chatbot_session, db) + + assert _transcript(db, chatbot_session) == [ + ("assistant", "All set!"), + ("user", "Thanks"), + ("assistant", "You're welcome!"), + ] + [payload] = callbacks.payloads + assert payload["success"] is True + assert payload["data"]["completion_status"] == "complete" diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index dac32ca06..6a503ba68 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -5,22 +5,29 @@ from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.runnables import Runnable, RunnableLambda from pydantic import Field from sqlmodel import Session from app.crud.chatbot import create_chatbot_session, create_chatbot_session_message from app.models import ( + DEFAULT_CHATBOT_NAME, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, Config, Project, ) -from app.services.chatbot import engine +from app.services.chatbot.agents.onboarding import graph as onboarding_graph +from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.tests.utils.utils import random_lower_string DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city." +NOTHING_EXTRACTED = FieldExtraction( + collected_fields={}, missing_fields=[], is_complete=False +) + class ScriptedChatModel(BaseChatModel): """Stands in for ChatAnthropic: replies from a script and records each call.""" @@ -29,6 +36,11 @@ class ScriptedChatModel(BaseChatModel): token_count: int = 0 error: Exception | None = None calls: list[list[BaseMessage]] = Field(default_factory=list) + # Each entry is a FieldExtraction to return, an exception to raise, or any + # other object to simulate a wrongly-typed structured output. + extractions: list[Any] = Field(default_factory=list) + extraction_calls: list[list[BaseMessage]] = Field(default_factory=list) + structured_output_methods: list[str | None] = Field(default_factory=list) @property def _llm_type(self) -> str: @@ -52,18 +64,35 @@ def get_num_tokens_from_messages( ) -> int: return self.token_count + def with_structured_output( + self, schema: Any, *, method: str | None = None, **kwargs: Any + ) -> Runnable[Any, Any]: + self.structured_output_methods.append(method) + + def _extract(messages: list[BaseMessage]) -> Any: + self.extraction_calls.append(list(messages)) + outcome = self.extractions.pop(0) if self.extractions else NOTHING_EXTRACTED + if isinstance(outcome, BaseException): + raise outcome + return outcome + + return RunnableLambda(_extract) + def patch_chat_model( monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel ) -> None: # The Anthropic SDK is the external boundary; everything above it runs for real. - monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model) + monkeypatch.setattr( + onboarding_graph, "get_chat_model", lambda _settings: chat_model + ) def create_test_chatbot_session( db: Session, *, config: Config, + name: str = DEFAULT_CHATBOT_NAME, contact_ref: str | None = None, config_version: int | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, @@ -74,6 +103,7 @@ def create_test_chatbot_session( db, organization_id=project.organization_id, project_id=project.id, + name=name, contact_ref=contact_ref or f"contact-{random_lower_string()}", config_id=config.id, config_version=config_version, diff --git a/docs/wiki/INDEX.md b/docs/wiki/INDEX.md index 89a96f8c9..30ee8ba93 100644 --- a/docs/wiki/INDEX.md +++ b/docs/wiki/INDEX.md @@ -22,7 +22,7 @@ Deep design narrative lives in `docs/architecture/*.md`; open those only for des - [modules/knowledge-base.md](modules/knowledge-base.md) — documents, collections, transforms, vector-store providers. Deep dive: `docs/architecture/kaapi-knowledge-base-ARCHITECTURE.md` - [modules/responses.md](modules/responses.md) — OpenAI Responses API integration, conversations, threads, assistants. No deep-dive doc yet. - [modules/assessment.md](modules/assessment.md) — assessments and assessment runs. No deep-dive doc yet. -- [modules/chatbot.md](modules/chatbot.md) — v0 stateless prompt-driven chatbot (`POST /chatbot/message`, LangGraph + Anthropic, model driven by a saved config). No deep-dive doc yet. +- [modules/chatbot.md](modules/chatbot.md) — prompt-driven chatbot with per-turn field extraction + completion status (`POST /chatbot/message`, `/chatbot/sessions*` via Celery + webhook; LangGraph + Anthropic, model driven by a saved config). No deep-dive doc yet. - [modules/tenancy.md](modules/tenancy.md) — users, orgs, projects, API keys, onboarding, login. - [modules/platform.md](modules/platform.md) — analytics, notifications, feature flags, languages, credentials, model config, cron. diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 8023c2521..4671f7799 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -1,25 +1,43 @@ # Module: Chatbot -v0 prompt-driven chatbot: one LangGraph node over a LangChain chat model (Anthropic). Stateless — the caller resends `system_prompt` + full `history` every turn. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. No deep-dive doc yet. +Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Two entry points: a stateless synchronous turn (caller resends `system_prompt` + full `history`) and server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet. All paths relative to `backend/app/`. ## Routes -- `api/routes/chatbot.py` — `POST /chatbot/message` (one synchronous turn; requires project scope) -- `api/routes/chatbot/session.py` — `GET /chatbot/sessions?contact_ref=` (all sessions for a contact in the caller's project, newest first → `list[ChatbotSessionSummary]`) +- `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`) +- `api/routes/chatbot/session.py` + - `POST /chatbot/sessions` (create; validates the config → `{session_id}`) + - `GET /chatbot/sessions?contact_ref=` (latest session for a contact in the caller's project → `ChatbotSessionSummary`, incl. `status`, `completion_status`, `collected_fields`) + - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`) +- All require project scope. -## Tables (SQLModel) -None of its own. Request/response shapes only: `models/chatbot.py` (`ChatbotMessage`, `ChatbotMessageRoleEnum`, `ChatbotTurnRequest`, `ChatbotTurnResponse`). Reads `config` / `config_version` (see the config module) to resolve the model. +## Tables (SQLModel, `models/chatbot.py`) +- `chatbot_session` (`ChatbotSession`) — `name`, `contact_ref`, `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete = all required fields collected), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Migration `088`. +- `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session). +- Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`. + +## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`) +1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the admin prompt, the previously collected map, and the (summarized) conversation. Uses Anthropic native structured output (`with_structured_output(..., method="json_schema")`) with the config's own thinking/effort, because forced tool use is rejected with thinking on and Opus 5.5 can't disable thinking. Merged in code: `{**prior, **extracted}`; `is_complete` only when nothing is missing. +2. `chatbot_node` — replies using the same summarized view plus a system note listing collected/missing fields, so it asks only for what's missing and wraps up when complete. +3. `run_chatbot_turn(..., collected_fields=prior)` → `ChatbotTurnResult{transcript, collected_fields, is_complete}` (`.completion_status` maps to the enum). Anthropic errors from either call, and langchain_anthropic's client-side model-config `ValueError`s ("[KAAPI] Unsupported model configuration"), map to `ChatbotProviderError`, so the session path always sends a failure webhook. ## Services / CRUD +- `services/chatbot/agents/onboarding/graph.py` — `run_chatbot_turn` (public entry point), `build_onboarding_graph`, the extraction/reply nodes +- `services/chatbot/agents/onboarding/prompts.py` — extraction instruction/templates, progress-note templates, `EXTRACTION_METHOD` +- `services/chatbot/agents/onboarding/types.py` — `OnboardingState`, `FieldExtraction` +- `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `to_message_dicts`, kickoff message, `has_user_turn`, `insert_after_leading_system` +- `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict` +- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history, run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook) - `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs) -- `services/chatbot/engine.py` — `run_chatbot_turn` (dict <-> LangChain message conversion, error mapping), `build_chatbot_graph`, history summarization - `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model` -- `crud/chatbot.py` — `list_chatbot_sessions_by_contact_ref` (org + project scoped, `inserted_at` desc, unpaginated) +- `crud/chatbot.py` — `create_chatbot_session`, `get_chatbot_session`, `get_latest_chatbot_session_by_contact_ref`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages` - `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted) ## Async -- None; synchronous request/response, not Celery. +- `celery/tasks/job_execution.py` — `run_chatbot_session_message` (enqueued via `celery/utils.py:start_chatbot_session_message`) → `process_chatbot_session_message`. +- Webhook success payload: `{"success": true, "data": {"session_id", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. ## External -- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting). +- Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting) — two calls per turn once the user has spoken (extraction + reply), plus one summary call when history exceeds the token budget. +- Caller webhook (`send_callback`, HMAC-signed with the project webhook secret). From 90a2cbdff6ea1d5e73a1cb37907edd7bd108c378 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:18:54 +0530 Subject: [PATCH 07/15] fix: refinement system prompt --- .../chatbot/agents/onboarding/graph.py | 3 +- .../chatbot/agents/onboarding/prompts.py | 10 +++-- .../chatbot/agents/onboarding/types.py | 11 ++++-- .../tests/api/routes/chatbot/test_message.py | 10 ++++- .../chatbot/agents/onboarding/test_graph.py | 39 ++++++++++++++----- .../tests/services/chatbot/test_session.py | 5 ++- backend/app/tests/utils/chatbot.py | 12 +++++- 7 files changed, 68 insertions(+), 22 deletions(-) diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py index c96e10b15..4fcf3acee 100644 --- a/backend/app/services/chatbot/agents/onboarding/graph.py +++ b/backend/app/services/chatbot/agents/onboarding/graph.py @@ -131,7 +131,8 @@ def extract_fields_node(state: OnboardingState) -> dict[str, Any]: conversation_view=conversation_view, ), ) - merged_fields = {**prior_fields, **extraction.collected_fields} + extracted = {field.name: field.value for field in extraction.collected_fields} + merged_fields = {**prior_fields, **extracted} is_complete = extraction.is_complete and not extraction.missing_fields return { "context_messages": conversation_view, diff --git a/backend/app/services/chatbot/agents/onboarding/prompts.py b/backend/app/services/chatbot/agents/onboarding/prompts.py index 698659741..b483cbe1d 100644 --- a/backend/app/services/chatbot/agents/onboarding/prompts.py +++ b/backend/app/services/chatbot/agents/onboarding/prompts.py @@ -8,9 +8,13 @@ "2. collected_fields: every field whose value the user has clearly provided in the " "conversation, including those in the previously collected map. Reuse the exact " "keys of the previously collected map; change a value only if the user corrected it.\n" - "3. Never invent, guess, or infer a value the user did not give.\n" - "4. missing_fields: the required fields the user has not answered yet.\n" - "5. is_complete: true only when no required field is missing." + "3. A value counts as collected only once every rule the flow prompt sets for that " + "field is met. For example, if the flow prompt says to confirm or verify a value, " + "include it only after the user has confirmed it; until then leave it out of " + "collected_fields and keep the field in missing_fields.\n" + "4. Never invent, guess, or infer a value the user did not give.\n" + "5. missing_fields: the required fields not yet collected under these rules.\n" + "6. is_complete: true only when no required field is missing." ) EXTRACTION_FLOW_PROMPT_TEMPLATE = "\n\n\n{flow_prompt}\n" EXTRACTION_PRIOR_FIELDS_TEMPLATE = ( diff --git a/backend/app/services/chatbot/agents/onboarding/types.py b/backend/app/services/chatbot/agents/onboarding/types.py index 045646d43..850e4d958 100644 --- a/backend/app/services/chatbot/agents/onboarding/types.py +++ b/backend/app/services/chatbot/agents/onboarding/types.py @@ -13,12 +13,17 @@ class OnboardingState(TypedDict): is_complete: NotRequired[bool] +class ExtractedField(BaseModel): + name: str = Field(description="snake_case field name.") + value: str = Field(description="Value the user clearly provided; never guessed.") + + class FieldExtraction(BaseModel): """Fields the admin's flow prompt asks for, as answered so far by the user.""" - collected_fields: dict[str, str] = Field( - default_factory=dict, - description="snake_case field name -> value the user clearly provided; never guessed.", + collected_fields: list[ExtractedField] = Field( + default_factory=list, + description="Every field the user has clearly provided, one entry per field.", ) missing_fields: list[str] = Field( default_factory=list, diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py index 66ebfc95f..54b57250d 100644 --- a/backend/app/tests/api/routes/chatbot/test_message.py +++ b/backend/app/tests/api/routes/chatbot/test_message.py @@ -8,7 +8,11 @@ from app.models import Config from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.tests.utils.auth import TestAuthContext -from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model +from app.tests.utils.chatbot import ( + ScriptedChatModel, + extracted_fields, + patch_chat_model, +) from app.tests.utils.test_data import ( build_anthropic_text_config_blob, create_test_config, @@ -72,7 +76,9 @@ def test_returns_extracted_fields_and_completion_status( replies=["Thanks, Asha from Pune!"], extractions=[ FieldExtraction( - collected_fields={"name": "Asha", "city": "Pune"}, + collected_fields=extracted_fields( + {"name": "Asha", "city": "Pune"} + ), missing_fields=[], is_complete=True, ) diff --git a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py index c5d932438..e59978bb6 100644 --- a/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py +++ b/backend/app/tests/services/chatbot/agents/onboarding/test_graph.py @@ -6,10 +6,15 @@ from app.models import ChatbotCompletionStatusEnum from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn +from app.services.chatbot.agents.onboarding.prompts import EXTRACTION_INSTRUCTION from app.services.chatbot.agents.onboarding.types import FieldExtraction from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.services.chatbot.utils import ChatbotModelSettings -from app.tests.utils.chatbot import ScriptedChatModel, patch_chat_model +from app.tests.utils.chatbot import ( + ScriptedChatModel, + extracted_fields, + patch_chat_model, +) SYSTEM_PROMPT = "Collect the user's name and city." SUMMARY_TEXT = "User is Asha from Pune." @@ -277,7 +282,7 @@ async def test_long_history_summary_is_shared_by_extraction_and_reply( token_count=5000, extractions=[ FieldExtraction( - collected_fields={"name": "Asha"}, + collected_fields=extracted_fields({"name": "Asha"}), missing_fields=["city"], is_complete=False, ) @@ -319,7 +324,7 @@ async def test_greeting_turn_skips_extraction( replies=["Hi! What's your name?"], extractions=[ FieldExtraction( - collected_fields={"name": "invented"}, + collected_fields=extracted_fields({"name": "invented"}), missing_fields=[], is_complete=True, ) @@ -379,6 +384,7 @@ async def test_extraction_sees_flow_prompt_prior_fields_and_conversation( [extraction_call] = chat_model.extraction_calls instruction, *conversation = extraction_call assert isinstance(instruction, SystemMessage) + assert instruction.text.startswith(EXTRACTION_INSTRUCTION) assert f"\n{SYSTEM_PROMPT}\n" in instruction.text assert ( '\n{"age": "30"}\n' @@ -399,7 +405,7 @@ async def test_extracted_fields_merge_over_prior_with_corrections_winning( replies=["Noted."], extractions=[ FieldExtraction( - collected_fields={"city": "Mumbai", "age": "30"}, + collected_fields=extracted_fields({"city": "Mumbai", "age": "30"}), missing_fields=[], is_complete=False, ) @@ -442,7 +448,7 @@ async def test_completion_requires_flag_and_no_missing_fields( replies=["Thanks!"], extractions=[ FieldExtraction( - collected_fields={"name": "Asha"}, + collected_fields=extracted_fields({"name": "Asha"}), missing_fields=missing_fields, is_complete=is_complete, ) @@ -466,7 +472,7 @@ class TestProgressNote: [ ( FieldExtraction( - collected_fields={"name": "Asha"}, + collected_fields=extracted_fields({"name": "Asha"}), missing_fields=["city"], is_complete=False, ), @@ -475,7 +481,7 @@ class TestProgressNote: ), ( FieldExtraction( - collected_fields={}, + collected_fields=extracted_fields({}), missing_fields=["name", "city"], is_complete=False, ), @@ -483,7 +489,7 @@ class TestProgressNote: ), ( FieldExtraction( - collected_fields={"name": "Asha", "city": "Pune"}, + collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}), missing_fields=[], is_complete=True, ), @@ -529,7 +535,9 @@ async def test_no_note_when_nothing_collected_missing_or_complete( replies=["Reply."], extractions=[ FieldExtraction( - collected_fields={}, missing_fields=[], is_complete=False + collected_fields=extracted_fields({}), + missing_fields=[], + is_complete=False, ) ], ) @@ -566,6 +574,19 @@ async def test_prior_fields_survive_into_note_when_extraction_adds_none( ) +class TestFieldExtractionSchema: + def test_collected_fields_is_a_list_of_name_value_pairs(self) -> None: + # Anthropic's json_schema mode forces additionalProperties=false on every + # object, so a free-form dict here would only ever come back empty. + schema = FieldExtraction.model_json_schema() + collected = schema["properties"]["collected_fields"] + item = schema["$defs"][collected["items"]["$ref"].split("/")[-1]] + + assert collected["type"] == "array" + assert set(item["properties"]) == {"name", "value"} + assert set(item["required"]) == {"name", "value"} + + class TestExtractionErrors: @pytest.mark.parametrize( ("extraction_outcome", "expected_message"), diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py index 6a5204607..f0a71971b 100644 --- a/backend/app/tests/services/chatbot/test_session.py +++ b/backend/app/tests/services/chatbot/test_session.py @@ -28,6 +28,7 @@ ScriptedChatModel, add_test_chatbot_messages, create_test_chatbot_session, + extracted_fields, patch_chat_model, ) from app.tests.utils.test_data import ( @@ -363,7 +364,7 @@ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them( replies=["Thanks, all set!"], extractions=[ FieldExtraction( - collected_fields={"city": "Pune"}, + collected_fields=extracted_fields({"city": "Pune"}), missing_fields=[], is_complete=True, ) @@ -451,7 +452,7 @@ def test_complete_session_still_answers_new_messages( replies=["You're welcome!"], extractions=[ FieldExtraction( - collected_fields={"name": "Asha", "city": "Pune"}, + collected_fields=extracted_fields({"name": "Asha", "city": "Pune"}), missing_fields=[], is_complete=True, ) diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index 6a503ba68..2fa5a3772 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -19,13 +19,21 @@ Project, ) from app.services.chatbot.agents.onboarding import graph as onboarding_graph -from app.services.chatbot.agents.onboarding.types import FieldExtraction +from app.services.chatbot.agents.onboarding.types import ( + ExtractedField, + FieldExtraction, +) from app.tests.utils.utils import random_lower_string DEFAULT_SYSTEM_PROMPT = "Collect the user's name and city." + +def extracted_fields(fields: dict[str, str]) -> list[ExtractedField]: + return [ExtractedField(name=name, value=value) for name, value in fields.items()] + + NOTHING_EXTRACTED = FieldExtraction( - collected_fields={}, missing_fields=[], is_complete=False + collected_fields=[], missing_fields=[], is_complete=False ) From e884d24a9e33f31dc17e347350f74dbf7c8110b2 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:44:47 +0530 Subject: [PATCH 08/15] fix: implement the state management api flow creation --- .../versions/088_add_chatbot_session.py | 31 +- .../app/api/docs/chatbot/create_session.md | 8 +- .../api/docs/chatbot/get_latest_session.md | 4 +- .../api/docs/chatbot/send_session_message.md | 6 +- backend/app/api/routes/chatbot/session.py | 70 +++- backend/app/crud/chatbot.py | 118 +++++- backend/app/models/__init__.py | 2 + backend/app/models/chatbot.py | 42 +- backend/app/services/chatbot/chatbot_ref.py | 40 ++ backend/app/services/chatbot/exceptions.py | 4 + backend/app/services/chatbot/session.py | 28 +- .../tests/api/routes/chatbot/test_session.py | 322 ++++++++++++++- backend/app/tests/crud/test_chatbot.py | 365 +++++++++++++++++- .../services/chatbot/test_chatbot_ref.py | 104 +++++ .../tests/services/chatbot/test_session.py | 2 + backend/app/tests/utils/chatbot.py | 2 + backend/app/tests/utils/test_data.py | 13 +- docs/wiki/modules/chatbot.md | 16 +- 18 files changed, 1111 insertions(+), 66 deletions(-) create mode 100644 backend/app/services/chatbot/chatbot_ref.py create mode 100644 backend/app/tests/services/chatbot/test_chatbot_ref.py diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py index 99859aaf0..b7f94ce8b 100644 --- a/backend/app/alembic/versions/088_add_chatbot_session.py +++ b/backend/app/alembic/versions/088_add_chatbot_session.py @@ -3,6 +3,13 @@ Revision ID: 088 Revises: 087 Create Date: 2026-10-20 00:00:00.000000 + +At most one session per (contact_ref, chatbot_ref) is active: creating a new one +deactivates the rest in crud/chatbot.py, and the partial unique index makes a +concurrent double-create fail instead of leaving two active rows. The plain +composite index stays because the partial one can't serve the GET-latest lookup, +which includes inactive rows; chatbot_ref comes before contact_ref so its prefix +also serves the project-wide uniqueness check on generated chatbot_refs. """ import sqlalchemy as sa @@ -38,6 +45,12 @@ def upgrade(): nullable=False, comment="Caller-side identifier of the end user (e.g. a contact id); opaque to Kaapi", ), + sa.Column( + "chatbot_ref", + sqlmodel.sql.sqltypes.AutoString(length=255), + nullable=False, + comment="Stable chatbot identifier, generated by Kaapi (LLMResponseWithPrompt-<5 hex>) on first session and sent back by the caller for later ones; at most one active session per contact_ref + chatbot_ref", + ), sa.Column( "config_id", sa.Uuid(), @@ -64,18 +77,19 @@ def upgrade(): name="chatbotsessionstatusenum", ), nullable=False, - comment="Conversation lifecycle: active or inactive", + comment="Session in use: active, or inactive once superseded by a newer session for the same contact_ref + chatbot_ref", ), sa.Column( "completion_status", sa.Enum( "in_progress", "complete", + "abandoned", name="chatbotcompletionstatusenum", ), nullable=False, server_default="in_progress", - comment="Field collection progress: in_progress, or complete once every required field is gathered", + comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress", ), sa.Column( "collected_fields", @@ -132,6 +146,19 @@ def upgrade(): ["project_id"], unique=False, ) + op.create_index( + "ix_chatbot_session_project_chatbot_contact", + "chatbot_session", + ["project_id", "chatbot_ref", "contact_ref"], + unique=False, + ) + op.create_index( + "uq_chatbot_session_active_contact_chatbot", + "chatbot_session", + ["project_id", "contact_ref", "chatbot_ref"], + unique=True, + postgresql_where=sa.text("status = 'active'"), + ) op.create_table( "chatbot_session_message", diff --git a/backend/app/api/docs/chatbot/create_session.md b/backend/app/api/docs/chatbot/create_session.md index ec34000aa..69346c6ae 100644 --- a/backend/app/api/docs/chatbot/create_session.md +++ b/backend/app/api/docs/chatbot/create_session.md @@ -1,5 +1,9 @@ -Start a server-side chatbot conversation and return its `session_id`. +Start a server-side chatbot conversation and return its `session_id` and `chatbot_ref`. The `system_prompt` and the `config_id` / `config_version` pin are stored on the session, so later turns only send the new user message. `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set; when `config_version` is omitted, each turn uses the config's latest version at that time. -`name` is the chatbot's display name; omit it to get `"Untitled Chatbot"`. `contact_ref` is the caller's own identifier for the end user and is stored as-is. Returns 404 if the config or version isn't found in the project and 422 if it isn't a usable Anthropic text config. +`name` is the chatbot's display name only; omit it to get `"Untitled Chatbot"`. `contact_ref` is the caller's own identifier for the end user and is stored as-is. + +`chatbot_ref` identifies the chatbot. Omit it to create a new chatbot: Kaapi generates a ref of the form `LLMResponseWithPrompt-<5 hex chars>` (e.g. `LLMResponseWithPrompt-913f0`), unique within the project, and returns it. To start a new session for the same chatbot, send that returned `chatbot_ref` back; it is used as-is. + +A contact has at most one active session per chatbot. Creating a session marks the contact's previous `active` sessions with the same `chatbot_ref` as `inactive`, in the same transaction; those still `in_progress` become `abandoned`, while `complete` ones stay `complete`. Sessions of other chatbots or other contacts are untouched. Returns 404 if the config or version isn't found in the project, 422 if it isn't a usable Anthropic text config, 409 if another session for the same `contact_ref` + `chatbot_ref` was created concurrently (retry the request), and 503 in the unlikely case no unused `chatbot_ref` could be generated (retry). diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md index c2ddf0e11..bbec7bbff 100644 --- a/backend/app/api/docs/chatbot/get_latest_session.md +++ b/backend/app/api/docs/chatbot/get_latest_session.md @@ -1,3 +1,3 @@ -Get the most recently created chatbot session for the given `contact_ref` in the caller's project. +Get the most recently created chatbot session for the given `contact_ref` and `chatbot_ref` in the caller's project. -`contact_ref` is matched exactly against the value supplied when the session was created. The response carries the session's current `status` (`active`/`inactive`), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions in this project. +Both refs are required and matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). The response carries the session's current `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`; `abandoned` if it was superseded while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions for this chatbot in this project. diff --git a/backend/app/api/docs/chatbot/send_session_message.md b/backend/app/api/docs/chatbot/send_session_message.md index b4b4d816d..c6ff7b84f 100644 --- a/backend/app/api/docs/chatbot/send_session_message.md +++ b/backend/app/api/docs/chatbot/send_session_message.md @@ -1,7 +1,7 @@ Queue one turn of a chatbot session; the bot reply is delivered asynchronously to `webhook_url`. -The user `message` is stored immediately and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. +The user `message` is stored immediately and the response is `202` with `status: "queued"`. Once the turn completes, Kaapi POSTs `{"success": true, "data": {"session_id", "chatbot_ref", "reply", "status", "completion_status", "collected_fields"}}` to `webhook_url` (HMAC-signed when the project has a webhook secret). On a model or config failure it POSTs `{"success": false, "error": "...", "data": {"session_id"}}` instead. -Each turn re-reads the conversation against the session's `system_prompt` and saves every field the user has given into `collected_fields`, a flat `{field_name: value}` map with snake_case keys (values are only ever taken from what the user said; a correction overwrites the old value). `completion_status` is `complete` once every required field is collected and `in_progress` otherwise; it is computed from a structured extraction, not from the reply text. A `complete` session still accepts messages. `status` (`active`/`inactive`) is separate and says whether the session is in use. +Each turn re-reads the conversation against the session's `system_prompt` and saves every field the user has given into `collected_fields`, a flat `{field_name: value}` map with snake_case keys (values are only ever taken from what the user said; a correction overwrites the old value). `completion_status` is `complete` once every required field is collected and `in_progress` otherwise; it is computed from a structured extraction, not from the reply text. A `complete` session still accepts messages. `status` (`active`/`inactive`) is separate and says whether the session is in use: once a newer session is created for the same `contact_ref` + `chatbot_ref`, this one is `inactive` and rejects new messages with 409. A turn that was already queued when that happened is not run; Kaapi POSTs the failure payload with error `[KAAPI] Chatbot session was superseded by a newer session; start a new session.` instead. -On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project and 422 for an invalid `webhook_url` or a missing `message` on a started session. +On the first call of a session, omit `message` to get the bot's opening greeting; after that `message` is required. `webhook_url` must be a public HTTPS URL. Returns 404 if the session isn't in the caller's project, 409 if the session is inactive, and 422 for an invalid `webhook_url` or a missing `message` on a started session. diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index d3316e01b..2ab7f2ad1 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -8,23 +8,30 @@ from app.api.permissions import Permission, require_permission from app.celery.utils import start_chatbot_session_message from app.crud.chatbot import ( + ChatbotSessionConflictError, create_chatbot_session, create_chatbot_session_message, get_chatbot_session, - get_latest_chatbot_session_by_contact_ref, + get_latest_chatbot_session, list_chatbot_session_messages, ) from app.models import ( + CHATBOT_REF_MAX_LENGTH, CONTACT_REF_MAX_LENGTH, ChatbotMessageRoleEnum, + ChatbotSessionStatusEnum, ChatbotSessionSummary, CreateChatbotSessionRequest, CreateChatbotSessionResponse, SendChatbotSessionMessageRequest, SendChatbotSessionMessageResponse, ) +from app.services.chatbot.chatbot_ref import generate_chatbot_ref from app.services.chatbot.config import resolve_chatbot_model_settings -from app.services.chatbot.exceptions import ChatbotInputError +from app.services.chatbot.exceptions import ( + ChatbotInputError, + ChatbotRefGenerationError, +) from app.utils import APIResponse, load_description, validate_callback_url DEFAULT_TRACE_ID = "N/A" @@ -57,18 +64,31 @@ def start_chatbot_session( except ChatbotInputError as e: raise HTTPException(status_code=422, detail=str(e)) - chatbot_session = create_chatbot_session( - session, - organization_id=auth_context.organization_.id, - project_id=project_id, - name=request.name, - contact_ref=request.contact_ref, - config_id=request.config_id, - config_version=request.config_version, - system_prompt=request.system_prompt, - ) + chatbot_ref = request.chatbot_ref + if chatbot_ref is None: + try: + chatbot_ref = generate_chatbot_ref(session, project_id=project_id) + except ChatbotRefGenerationError as e: + raise HTTPException(status_code=503, detail=str(e)) + + try: + chatbot_session = create_chatbot_session( + session, + organization_id=auth_context.organization_.id, + project_id=project_id, + name=request.name, + contact_ref=request.contact_ref, + chatbot_ref=chatbot_ref, + config_id=request.config_id, + config_version=request.config_version, + system_prompt=request.system_prompt, + ) + except ChatbotSessionConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) return APIResponse.success_response( - CreateChatbotSessionResponse(session_id=chatbot_session.id) + CreateChatbotSessionResponse( + session_id=chatbot_session.id, chatbot_ref=chatbot_session.chatbot_ref + ) ) @@ -78,7 +98,7 @@ def start_chatbot_session( response_model=APIResponse[ChatbotSessionSummary], dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], ) -def get_latest_chatbot_session( +def read_latest_chatbot_session( contact_ref: Annotated[ str, Query( @@ -87,24 +107,35 @@ def get_latest_chatbot_session( description="Caller-side identifier of the end user whose latest session to fetch", ), ], + chatbot_ref: Annotated[ + str, + Query( + min_length=1, + max_length=CHATBOT_REF_MAX_LENGTH, + description="Caller-side identifier of the chatbot whose latest session to fetch", + ), + ], session: SessionDep, auth_context: AuthContextDep, ) -> APIResponse[ChatbotSessionSummary]: - chatbot_session = get_latest_chatbot_session_by_contact_ref( + chatbot_session = get_latest_chatbot_session( session, - contact_ref=contact_ref, organization_id=auth_context.organization_.id, project_id=auth_context.project_.id, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, ) if chatbot_session is None: raise HTTPException( - status_code=404, detail="No chatbot session found for this contact_ref" + status_code=404, + detail="No chatbot session found for this contact_ref and chatbot_ref", ) return APIResponse.success_response( ChatbotSessionSummary( session_id=chatbot_session.id, name=chatbot_session.name, contact_ref=chatbot_session.contact_ref, + chatbot_ref=chatbot_session.chatbot_ref, status=chatbot_session.status, completion_status=chatbot_session.completion_status, collected_fields=chatbot_session.collected_fields, @@ -145,6 +176,11 @@ def send_chatbot_session_message( ) if chatbot_session is None: raise HTTPException(status_code=404, detail="Chatbot session not found") + if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE: + raise HTTPException( + status_code=409, + detail="Chatbot session is inactive; start a new session", + ) if request.message is None: if list_chatbot_session_messages(session, session_id=session_id): diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index e0b1fda75..fdb6d9f41 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -1,19 +1,85 @@ import logging +from datetime import datetime from typing import Any from uuid import UUID +from sqlalchemy import update +from sqlalchemy.exc import IntegrityError from sqlmodel import Session, col, select from app.core.util import now from app.models import ( + ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, + ChatbotSessionStatusEnum, ) logger = logging.getLogger(__name__) +CONCURRENT_SESSION_CONFLICT_MESSAGE = ( + "A concurrent session was just created for this contact and chatbot; retry" +) + + +class ChatbotSessionConflictError(Exception): + """Another active session for the same contact + chatbot was committed concurrently.""" + + +def _is_active_session_conflict(error: IntegrityError) -> bool: + constraint_name = getattr( + getattr(error.orig, "diag", None), "constraint_name", None + ) + if constraint_name: + return constraint_name == ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX + return ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX in str(error.orig or error) + + +def _deactivate_active_chatbot_sessions( + session: Session, + *, + organization_id: int, + project_id: int, + contact_ref: str, + chatbot_ref: str, + timestamp: datetime, +) -> int: + """Mark this contact's active sessions of this chatbot inactive; does not commit.""" + scope = ( + col(ChatbotSession.organization_id) == organization_id, + col(ChatbotSession.project_id) == project_id, + col(ChatbotSession.contact_ref) == contact_ref, + col(ChatbotSession.chatbot_ref) == chatbot_ref, + col(ChatbotSession.status) == ChatbotSessionStatusEnum.ACTIVE, + ) + + # Runs first: once the second statement flips status, these rows no longer match. + abandon_in_progress = ( + update(ChatbotSession) + .where( + *scope, + col(ChatbotSession.completion_status) + == ChatbotCompletionStatusEnum.IN_PROGRESS, + ) + .values( + status=ChatbotSessionStatusEnum.INACTIVE, + completion_status=ChatbotCompletionStatusEnum.ABANDONED, + updated_at=timestamp, + ) + ) + abandoned_count = session.exec(abandon_in_progress).rowcount + + deactivate_remaining = ( + update(ChatbotSession) + .where(*scope) + .values(status=ChatbotSessionStatusEnum.INACTIVE, updated_at=timestamp) + ) + deactivated_count = session.exec(deactivate_remaining).rowcount + + return abandoned_count + deactivated_count + def create_chatbot_session( session: Session, @@ -22,16 +88,30 @@ def create_chatbot_session( project_id: int, name: str, contact_ref: str, + chatbot_ref: str, config_id: UUID, config_version: int | None, system_prompt: str, ) -> ChatbotSession: + """Insert a new active session, superseding the contact's active ones for the same chatbot. + + Deactivation and insert share one commit, so a failed insert leaves prior sessions active. + """ timestamp = now() + superseded_count = _deactivate_active_chatbot_sessions( + session, + organization_id=organization_id, + project_id=project_id, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, + timestamp=timestamp, + ) chatbot_session = ChatbotSession( organization_id=organization_id, project_id=project_id, name=name, contact_ref=contact_ref, + chatbot_ref=chatbot_ref, config_id=config_id, config_version=config_version, system_prompt=system_prompt, @@ -39,8 +119,24 @@ def create_chatbot_session( updated_at=timestamp, ) session.add(chatbot_session) - session.commit() + try: + session.commit() + except IntegrityError as e: + session.rollback() + if not _is_active_session_conflict(e): + raise + logger.warning( + f"[create_chatbot_session] {CONCURRENT_SESSION_CONFLICT_MESSAGE} | " + f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, code: 409", + exc_info=True, + ) + raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e session.refresh(chatbot_session) + logger.info( + f"[create_chatbot_session] Session created | session_id: {chatbot_session.id}, " + f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, " + f"superseded_count: {superseded_count}" + ) return chatbot_session @@ -118,19 +214,33 @@ def list_chatbot_session_messages( return list(session.exec(statement).all()) -def get_latest_chatbot_session_by_contact_ref( +def chatbot_ref_exists(session: Session, *, project_id: int, chatbot_ref: str) -> bool: + statement = ( + select(ChatbotSession.id) + .where( + ChatbotSession.project_id == project_id, + ChatbotSession.chatbot_ref == chatbot_ref, + ) + .limit(1) + ) + return session.exec(statement).first() is not None + + +def get_latest_chatbot_session( session: Session, *, - contact_ref: str, organization_id: int, project_id: int, + contact_ref: str, + chatbot_ref: str, ) -> ChatbotSession | None: statement = ( select(ChatbotSession) .where( - ChatbotSession.contact_ref == contact_ref, ChatbotSession.organization_id == organization_id, ChatbotSession.project_id == project_id, + ChatbotSession.contact_ref == contact_ref, + ChatbotSession.chatbot_ref == chatbot_ref, ) .order_by(col(ChatbotSession.inserted_at).desc()) .limit(1) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2c1cbcfc9..f51aa778f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -36,6 +36,8 @@ BatchJobUpdate, ) from .chatbot import ( + ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, + CHATBOT_REF_MAX_LENGTH, CONTACT_REF_MAX_LENGTH, DEFAULT_CHATBOT_NAME, ChatbotCompletionStatusEnum, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index 7114c9fcc..71c808c8f 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -3,7 +3,7 @@ from typing import Any from uuid import UUID, uuid4 -from sqlalchemy import JSON, Column +from sqlalchemy import JSON, Column, Index, text from sqlalchemy.dialects.postgresql import ENUM from sqlmodel import Field, SQLModel @@ -63,6 +63,7 @@ class ChatbotSessionStatusEnum(StrEnum): class ChatbotCompletionStatusEnum(StrEnum): IN_PROGRESS = "in_progress" COMPLETE = "complete" + ABANDONED = "abandoned" class ChatbotTurnResponse(SQLModel): @@ -97,12 +98,32 @@ class ChatbotTurnResponse(SQLModel): ) CONTACT_REF_MAX_LENGTH = 500 +CHATBOT_REF_MAX_LENGTH = 255 +ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX = "uq_chatbot_session_active_contact_chatbot" CHATBOT_NAME_MAX_LENGTH = 255 DEFAULT_CHATBOT_NAME = "Untitled Chatbot" class ChatbotSession(SQLModel, table=True): __tablename__ = "chatbot_session" + __table_args__ = ( + # Serves GET-latest (includes inactive rows the partial index below skips) + # and, via its (project_id, chatbot_ref) prefix, the generated-ref collision check. + Index( + "ix_chatbot_session_project_chatbot_contact", + "project_id", + "chatbot_ref", + "contact_ref", + ), + Index( + ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, + "project_id", + "contact_ref", + "chatbot_ref", + unique=True, + postgresql_where=text("status = 'active'"), + ), + ) id: UUID = Field( default_factory=uuid4, @@ -120,6 +141,13 @@ class ChatbotSession(SQLModel, table=True): "comment": "Caller-side identifier of the end user (e.g. a Glific contact id); opaque to Kaapi" }, ) + chatbot_ref: str = Field( + max_length=CHATBOT_REF_MAX_LENGTH, + nullable=False, + sa_column_kwargs={ + "comment": "Stable chatbot identifier, generated by Kaapi (LLMResponseWithPrompt-<5 hex>) on first session and sent back by the caller for later ones; at most one active session per contact_ref + chatbot_ref" + }, + ) config_id: UUID = Field( foreign_key="config.id", nullable=False, @@ -146,7 +174,7 @@ class ChatbotSession(SQLModel, table=True): sa_column=Column( _SESSION_STATUS_PG_ENUM, nullable=False, - comment="Conversation lifecycle: active or inactive", + comment="Session in use: active, or inactive once superseded by a newer session for the same contact_ref + chatbot_ref", ), ) completion_status: ChatbotCompletionStatusEnum = Field( @@ -155,7 +183,7 @@ class ChatbotSession(SQLModel, table=True): _COMPLETION_STATUS_PG_ENUM, nullable=False, server_default=ChatbotCompletionStatusEnum.IN_PROGRESS.value, - comment="Field collection progress: in_progress, or complete once every required field is gathered", + comment="Field collection progress: in_progress, complete once every required field is gathered, or abandoned when superseded while in_progress", ), ) collected_fields: dict[str, Any] = Field( @@ -249,6 +277,12 @@ class CreateChatbotSessionRequest(SQLModel): max_length=CONTACT_REF_MAX_LENGTH, description="Caller-side identifier of the end user this conversation is with.", ) + chatbot_ref: str | None = Field( + default=None, + min_length=1, + max_length=CHATBOT_REF_MAX_LENGTH, + description="Identifier of an existing chatbot, as returned by a previous create; supersedes this contact's active session for that chatbot. Omit to create a new chatbot with a generated ref.", + ) system_prompt: str = Field( min_length=1, description="Admin-authored flow prompt (goal, fields to collect, rules); stored for the session's lifetime.", @@ -257,12 +291,14 @@ class CreateChatbotSessionRequest(SQLModel): class CreateChatbotSessionResponse(SQLModel): session_id: UUID + chatbot_ref: str class ChatbotSessionSummary(SQLModel): session_id: UUID name: str contact_ref: str + chatbot_ref: str status: ChatbotSessionStatusEnum completion_status: ChatbotCompletionStatusEnum collected_fields: dict[str, Any] diff --git a/backend/app/services/chatbot/chatbot_ref.py b/backend/app/services/chatbot/chatbot_ref.py new file mode 100644 index 000000000..1af3ffa2a --- /dev/null +++ b/backend/app/services/chatbot/chatbot_ref.py @@ -0,0 +1,40 @@ +import logging +import secrets + +from sqlmodel import Session + +from app.crud.chatbot import chatbot_ref_exists +from app.services.chatbot.exceptions import ChatbotRefGenerationError + +logger = logging.getLogger(__name__) + +CHATBOT_REF_PREFIX = "LLMResponseWithPrompt-" +CHATBOT_REF_SUFFIX_LENGTH = 5 +HEX_BASE = 16 +CHATBOT_REF_MAX_ATTEMPTS = 5 + + +def _random_chatbot_ref() -> str: + suffix_value = secrets.randbelow(HEX_BASE**CHATBOT_REF_SUFFIX_LENGTH) + return f"{CHATBOT_REF_PREFIX}{suffix_value:0{CHATBOT_REF_SUFFIX_LENGTH}x}" + + +def generate_chatbot_ref(session: Session, *, project_id: int) -> str: + """Return a chatbot_ref not yet used by any session in the project. + + The suffix space is only ~1M values, and reusing a ref would merge two chatbots' sessions. + """ + for _attempt in range(CHATBOT_REF_MAX_ATTEMPTS): + candidate = _random_chatbot_ref() + if not chatbot_ref_exists( + session, project_id=project_id, chatbot_ref=candidate + ): + return candidate + + error_message = ( + f"[KAAPI] Could not generate an unused chatbot_ref after " + f"{CHATBOT_REF_MAX_ATTEMPTS} attempts; retry, or pass an existing chatbot_ref. " + f"If this persists, contact Kaapi. (code: ChatbotRefGenerationError)" + ) + logger.error(f"[generate_chatbot_ref] {error_message} | project_id: {project_id}") + raise ChatbotRefGenerationError(error_message) diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py index aac653752..fab852789 100644 --- a/backend/app/services/chatbot/exceptions.py +++ b/backend/app/services/chatbot/exceptions.py @@ -2,5 +2,9 @@ class ChatbotInputError(ValueError): """The turn request can't produce a valid model call (caller's fault).""" +class ChatbotRefGenerationError(Exception): + """No unused chatbot_ref could be generated for the project.""" + + class ChatbotProviderError(Exception): """The chat-model provider failed or was unreachable.""" diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 496e08e97..9d8acd14f 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -1,4 +1,5 @@ import asyncio +import logging from typing import Any from uuid import UUID @@ -12,7 +13,11 @@ list_chatbot_session_messages, update_chatbot_session_progress, ) -from app.models import ChatbotMessageRoleEnum, ChatbotSessionMessage +from app.models import ( + ChatbotMessageRoleEnum, + ChatbotSessionMessage, + ChatbotSessionStatusEnum, +) from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn from app.services.chatbot.config import resolve_chatbot_model_settings from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError @@ -20,6 +25,12 @@ from app.services.chatbot.utils import ChatbotModelSettings from app.utils import get_webhook_secret, send_callback +logger = logging.getLogger(__name__) + +SUPERSEDED_SESSION_ERROR = ( + "[KAAPI] Chatbot session was superseded by a newer session; start a new session." +) + def _split_pending_turn( stored_messages: list[ChatbotSessionMessage], @@ -89,6 +100,19 @@ def process_chatbot_session_message( ) if chatbot_session is None: return + # The turn may have been queued before a newer session superseded this one. + if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE: + logger.warning( + f"[process_chatbot_session_message] {SUPERSEDED_SESSION_ERROR} | " + f"session_id: {session_id}, project_id: {project_id}" + ) + _send_failure_webhook( + session_id=session_id, + webhook_url=webhook_url, + webhook_secret=webhook_secret, + error=SUPERSEDED_SESSION_ERROR, + ) + return stored_messages = list_chatbot_session_messages(session, session_id=session_id) if ( @@ -98,6 +122,7 @@ def process_chatbot_session_message( return system_prompt = chatbot_session.system_prompt + chatbot_ref = chatbot_session.chatbot_ref session_status = chatbot_session.status.value collected_fields = dict(chatbot_session.collected_fields) @@ -168,6 +193,7 @@ def process_chatbot_session_message( "success": True, "data": { "session_id": str(session_id), + "chatbot_ref": chatbot_ref, "reply": reply, "status": session_status, "completion_status": completion_status.value, diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index 6dde33189..36d9c9887 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -1,14 +1,17 @@ +import re import socket from typing import Any from uuid import uuid4 import pytest from fastapi.testclient import TestClient -from sqlmodel import Session +from sqlmodel import Session, select from app.api.routes.chatbot import session as session_routes from app.core.config import settings from app.crud.chatbot import ( + CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotSessionConflictError, get_chatbot_session, list_chatbot_session_messages, update_chatbot_session_progress, @@ -18,8 +21,14 @@ ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, + ChatbotSessionStatusEnum, Config, ) +from app.services.chatbot import chatbot_ref as chatbot_ref_module +from app.services.chatbot.chatbot_ref import ( + CHATBOT_REF_PREFIX, + CHATBOT_REF_SUFFIX_LENGTH, +) from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( add_test_chatbot_messages, @@ -41,6 +50,26 @@ def _message_url(session_id: Any) -> str: return f"{SESSIONS_URL}/{session_id}/message" +def _sessions_for_contact(db: Session, contact_ref: str) -> list[ChatbotSession]: + db.expire_all() + return list( + db.exec( + select(ChatbotSession).where(ChatbotSession.contact_ref == contact_ref) + ).all() + ) + + +def _supersede(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession: + config = db.get(Config, chatbot_session.config_id) + assert config is not None + return create_test_chatbot_session( + db, + config=config, + contact_ref=chatbot_session.contact_ref, + chatbot_ref=chatbot_session.chatbot_ref, + ) + + @pytest.fixture def headers(user_api_key: TestAuthContext) -> dict[str, str]: return {"X-API-KEY": user_api_key.key} @@ -99,6 +128,7 @@ def test_creates_session_in_callers_project( "config_id": str(anthropic_config.id), "config_version": 1, "contact_ref": contact_ref, + "chatbot_ref": "chatbot-onboarding", "system_prompt": SYSTEM_PROMPT, }, ) @@ -132,6 +162,7 @@ def test_omitted_name_uses_default( json={ "config_id": str(anthropic_config.id), "contact_ref": f"contact-{random_lower_string()}", + "chatbot_ref": f"chatbot-{random_lower_string()}", "system_prompt": SYSTEM_PROMPT, }, ) @@ -155,6 +186,7 @@ def test_unknown_config_returns_404( json={ "config_id": str(uuid4()), "contact_ref": "contact-1", + "chatbot_ref": "chatbot-1", "system_prompt": SYSTEM_PROMPT, }, ) @@ -178,6 +210,7 @@ def test_non_anthropic_config_returns_422( json={ "config_id": str(openai_config.id), "contact_ref": "contact-1", + "chatbot_ref": "chatbot-1", "system_prompt": SYSTEM_PROMPT, }, ) @@ -186,6 +219,181 @@ def test_non_anthropic_config_returns_422( assert "only supports configs" in resp.json()["error"] +class TestCreateChatbotSessionChatbotRef: + def _create( + self, + client: TestClient, + headers: dict[str, str], + config: Config, + contact_ref: str, + **extra: Any, + ) -> Any: + return client.post( + SESSIONS_URL, + headers=headers, + json={ + "config_id": str(config.id), + "contact_ref": contact_ref, + "system_prompt": SYSTEM_PROMPT, + **extra, + }, + ) + + def test_omitted_chatbot_ref_is_generated_and_stored( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + + resp = self._create(client, headers, anthropic_config, contact_ref) + + assert resp.status_code == 201 + body = resp.json()["data"] + pattern = ( + rf"{re.escape(CHATBOT_REF_PREFIX)}[0-9a-f]{{{CHATBOT_REF_SUFFIX_LENGTH}}}" + ) + assert re.fullmatch(pattern, body["chatbot_ref"]) + [stored] = _sessions_for_contact(db, contact_ref) + assert str(stored.id) == body["session_id"] + assert stored.chatbot_ref == body["chatbot_ref"] + + def test_provided_chatbot_ref_is_used_as_is( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + chatbot_ref = f"Glific Flow {random_lower_string()}" + + resp = self._create( + client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref + ) + + assert resp.status_code == 201 + assert resp.json()["data"]["chatbot_ref"] == chatbot_ref + [stored] = _sessions_for_contact(db, contact_ref) + assert stored.chatbot_ref == chatbot_ref + + def test_reusing_returned_ref_supersedes_previous_session( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + first = self._create(client, headers, anthropic_config, contact_ref).json()[ + "data" + ] + + resp = self._create( + client, + headers, + anthropic_config, + contact_ref, + chatbot_ref=first["chatbot_ref"], + ) + + assert resp.status_code == 201 + second = resp.json()["data"] + assert second["chatbot_ref"] == first["chatbot_ref"] + assert second["session_id"] != first["session_id"] + latest = client.get( + SESSIONS_URL, + headers=headers, + params={"contact_ref": contact_ref, "chatbot_ref": first["chatbot_ref"]}, + ).json()["data"] + assert latest["session_id"] == second["session_id"] + assert latest["status"] == "active" + assert latest["completion_status"] == "in_progress" + statuses = { + str(s.id): (s.status, s.completion_status) + for s in _sessions_for_contact(db, contact_ref) + } + assert statuses == { + first["session_id"]: ( + ChatbotSessionStatusEnum.INACTIVE, + ChatbotCompletionStatusEnum.ABANDONED, + ), + second["session_id"]: ( + ChatbotSessionStatusEnum.ACTIVE, + ChatbotCompletionStatusEnum.IN_PROGRESS, + ), + } + + @pytest.mark.parametrize("chatbot_ref", ["", "x" * 256], ids=["empty", "too-long"]) + def test_invalid_chatbot_ref_returns_422( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + chatbot_ref: str, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + + resp = self._create( + client, headers, anthropic_config, contact_ref, chatbot_ref=chatbot_ref + ) + + assert resp.status_code == 422 + assert _sessions_for_contact(db, contact_ref) == [] + + def test_ref_generation_exhausted_returns_503_and_creates_nothing( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + create_test_chatbot_session( + db, config=anthropic_config, chatbot_ref="LLMResponseWithPrompt-abcde" + ) + # Randomness is the boundary: every draw collides with the ref above. + monkeypatch.setattr( + chatbot_ref_module.secrets, "randbelow", lambda _bound: 0xABCDE + ) + contact_ref = f"contact-{random_lower_string()}" + + resp = self._create(client, headers, anthropic_config, contact_ref) + + assert resp.status_code == 503 + assert "ChatbotRefGenerationError" in resp.json()["error"] + assert _sessions_for_contact(db, contact_ref) == [] + + def test_concurrent_create_conflict_returns_409( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + anthropic_config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + # The real unique-index race is covered in the CRUD tests; there the + # rollback would also discard this fixture's transaction mid-request. + def lose_race(*_args: Any, **_kwargs: Any) -> ChatbotSession: + raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) + + monkeypatch.setattr(session_routes, "create_chatbot_session", lose_race) + + resp = self._create( + client, + headers, + anthropic_config, + f"contact-{random_lower_string()}", + chatbot_ref=f"chatbot-{random_lower_string()}", + ) + + assert resp.status_code == 409 + assert resp.json()["error"] == CONCURRENT_SESSION_CONFLICT_MESSAGE + + class TestGetLatestChatbotSession: def test_returns_contacts_newest_session_in_callers_project( self, @@ -195,24 +403,35 @@ def test_returns_contacts_newest_session_in_callers_project( anthropic_config: Config, ) -> None: contact_ref = f"contact-{random_lower_string()}" + chatbot_ref = f"chatbot-{random_lower_string()}" create_test_chatbot_session( - db, config=anthropic_config, contact_ref=contact_ref + db, + config=anthropic_config, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, ) newer = create_test_chatbot_session( db, config=anthropic_config, name="Onboarding Bot", contact_ref=contact_ref, + chatbot_ref=chatbot_ref, config_version=1, ) - create_test_chatbot_session(db, config=anthropic_config) + create_test_chatbot_session( + db, config=anthropic_config, chatbot_ref=chatbot_ref + ) foreign_config = create_test_config( db, config_blob=build_anthropic_text_config_blob() ) - create_test_chatbot_session(db, config=foreign_config, contact_ref=contact_ref) + create_test_chatbot_session( + db, config=foreign_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + ) resp = client.get( - SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref} + SESSIONS_URL, + headers=headers, + params={"contact_ref": contact_ref, "chatbot_ref": chatbot_ref}, ) assert resp.status_code == 200 @@ -220,6 +439,7 @@ def test_returns_contacts_newest_session_in_callers_project( assert body["session_id"] == str(newer.id) assert body["name"] == "Onboarding Bot" assert body["contact_ref"] == contact_ref + assert body["chatbot_ref"] == chatbot_ref assert body["status"] == "active" assert body["completion_status"] == "in_progress" assert body["collected_fields"] == {} @@ -243,7 +463,10 @@ def test_reports_stored_field_progress( resp = client.get( SESSIONS_URL, headers=headers, - params={"contact_ref": chatbot_session.contact_ref}, + params={ + "contact_ref": chatbot_session.contact_ref, + "chatbot_ref": chatbot_session.chatbot_ref, + }, ) assert resp.status_code == 200 @@ -258,7 +481,10 @@ def test_unknown_contact_returns_404( resp = client.get( SESSIONS_URL, headers=headers, - params={"contact_ref": f"contact-{random_lower_string()}"}, + params={ + "contact_ref": f"contact-{random_lower_string()}", + "chatbot_ref": f"chatbot-{random_lower_string()}", + }, ) assert resp.status_code == 404 @@ -270,6 +496,37 @@ def test_missing_contact_ref_returns_422( assert resp.status_code == 422 + def test_missing_chatbot_ref_returns_422( + self, + client: TestClient, + headers: dict[str, str], + chatbot_session: ChatbotSession, + ) -> None: + resp = client.get( + SESSIONS_URL, + headers=headers, + params={"contact_ref": chatbot_session.contact_ref}, + ) + + assert resp.status_code == 422 + + def test_unknown_chatbot_ref_for_known_contact_returns_404( + self, + client: TestClient, + headers: dict[str, str], + chatbot_session: ChatbotSession, + ) -> None: + resp = client.get( + SESSIONS_URL, + headers=headers, + params={ + "contact_ref": chatbot_session.contact_ref, + "chatbot_ref": f"chatbot-{random_lower_string()}", + }, + ) + + assert resp.status_code == 404 + class TestSendChatbotSessionMessage: def test_stores_user_message_and_queues_turn( @@ -396,3 +653,54 @@ def test_non_https_webhook_url_returns_422( assert "Invalid webhook_url" in resp.json()["error"] assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] assert enqueued == [] + + @pytest.mark.parametrize( + "payload", + [{"message": "Asha"}, {}], + ids=["with-message", "message-omitted"], + ) + def test_inactive_session_returns_409_and_stores_nothing( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + chatbot_session: ChatbotSession, + enqueued: list[dict[str, Any]], + payload: dict[str, str], + ) -> None: + _supersede(db, chatbot_session) + + resp = client.post( + _message_url(chatbot_session.id), + headers=headers, + json={**payload, "webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 409 + assert ( + resp.json()["error"] == "Chatbot session is inactive; start a new session" + ) + assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] + assert enqueued == [] + + def test_inactive_session_in_other_project_returns_404( + self, + client: TestClient, + db: Session, + headers: dict[str, str], + enqueued: list[dict[str, Any]], + ) -> None: + foreign_config = create_test_config( + db, config_blob=build_anthropic_text_config_blob() + ) + foreign_session = create_test_chatbot_session(db, config=foreign_config) + _supersede(db, foreign_session) + + resp = client.post( + _message_url(foreign_session.id), + headers=headers, + json={"message": "Asha", "webhook_url": WEBHOOK_URL}, + ) + + assert resp.status_code == 404 + assert enqueued == [] diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py index b3bdb8fcd..fbd22fa80 100644 --- a/backend/app/tests/crud/test_chatbot.py +++ b/backend/app/tests/crud/test_chatbot.py @@ -1,17 +1,26 @@ +from collections.abc import Iterator +from types import SimpleNamespace from uuid import uuid4 import pytest -from sqlmodel import Session +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select +from app.core.db import engine from app.crud.chatbot import ( + CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotSessionConflictError, + _is_active_session_conflict, + chatbot_ref_exists, create_chatbot_session, create_chatbot_session_message, get_chatbot_session, - get_latest_chatbot_session_by_contact_ref, + get_latest_chatbot_session, list_chatbot_session_messages, update_chatbot_session_progress, ) from app.models import ( + ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, @@ -55,6 +64,7 @@ def test_persists_session_as_active_with_no_collected_fields( project_id=project.id, name="Onboarding Bot", contact_ref=contact_ref, + chatbot_ref="chatbot-onboarding", config_id=config.id, config_version=1, system_prompt="Collect the user's name.", @@ -184,24 +194,32 @@ def test_list_empty_session_returns_empty( assert list_chatbot_session_messages(db, session_id=chatbot_session.id) == [] -class TestGetLatestChatbotSessionByContactRef: +class TestGetLatestChatbotSession: def test_returns_newest_scoped_to_contact_and_project( self, db: Session, config: Config, project: Project ) -> None: contact_ref = f"contact-{random_lower_string()}" - create_test_chatbot_session(db, config=config, contact_ref=contact_ref) - newest = create_test_chatbot_session(db, config=config, contact_ref=contact_ref) - create_test_chatbot_session(db, config=config) + chatbot_ref = f"chatbot-{random_lower_string()}" + create_test_chatbot_session( + db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + ) + newest = create_test_chatbot_session( + db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + ) + create_test_chatbot_session(db, config=config, chatbot_ref=chatbot_ref) other_config = create_test_config( db, config_blob=build_anthropic_text_config_blob() ) - create_test_chatbot_session(db, config=other_config, contact_ref=contact_ref) + create_test_chatbot_session( + db, config=other_config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + ) - latest = get_latest_chatbot_session_by_contact_ref( + latest = get_latest_chatbot_session( db, - contact_ref=contact_ref, organization_id=project.organization_id, project_id=project.id, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, ) assert latest is not None @@ -213,15 +231,61 @@ def test_unknown_contact_returns_none( create_test_chatbot_session(db, config=config) assert ( - get_latest_chatbot_session_by_contact_ref( + get_latest_chatbot_session( db, - contact_ref=f"contact-{random_lower_string()}", organization_id=project.organization_id, project_id=project.id, + contact_ref=f"contact-{random_lower_string()}", + chatbot_ref=f"chatbot-{random_lower_string()}", ) is None ) + def test_ignores_newer_session_of_another_chatbot( + self, db: Session, config: Config, project: Project + ) -> None: + wanted = create_test_chatbot_session(db, config=config) + create_test_chatbot_session(db, config=config, contact_ref=wanted.contact_ref) + + latest = get_latest_chatbot_session( + db, + organization_id=project.organization_id, + project_id=project.id, + contact_ref=wanted.contact_ref, + chatbot_ref=wanted.chatbot_ref, + ) + + assert latest is not None + assert latest.id == wanted.id + + def test_returns_newest_even_when_inactive( + self, db: Session, config: Config, project: Project + ) -> None: + older = create_test_chatbot_session(db, config=config) + newest = create_test_chatbot_session( + db, + config=config, + contact_ref=older.contact_ref, + chatbot_ref=older.chatbot_ref, + ) + # No API path leaves the newest row inactive; set it directly so a + # status filter would surface the older row instead. + newest.status = ChatbotSessionStatusEnum.INACTIVE + db.add(newest) + db.commit() + + latest = get_latest_chatbot_session( + db, + organization_id=project.organization_id, + project_id=project.id, + contact_ref=older.contact_ref, + chatbot_ref=older.chatbot_ref, + ) + + assert latest is not None + assert latest.id == newest.id + assert latest.status == ChatbotSessionStatusEnum.INACTIVE + class TestUpdateChatbotSessionProgress: def test_persists_fields_and_status_and_bumps_updated_at( @@ -280,3 +344,282 @@ def test_unknown_id_returns_none(self, db: Session) -> None: ) is None ) + + +def _reload(db: Session, chatbot_session: ChatbotSession) -> ChatbotSession: + db.expire_all() + stored = db.get(ChatbotSession, chatbot_session.id) + assert stored is not None + return stored + + +class TestCreateChatbotSessionSupersedes: + def test_in_progress_session_becomes_inactive_and_abandoned( + self, db: Session, config: Config + ) -> None: + older = create_test_chatbot_session(db, config=config) + older_updated_at = older.updated_at + + newer = create_test_chatbot_session( + db, + config=config, + contact_ref=older.contact_ref, + chatbot_ref=older.chatbot_ref, + ) + + superseded = _reload(db, older) + assert superseded.status == ChatbotSessionStatusEnum.INACTIVE + assert superseded.completion_status == ChatbotCompletionStatusEnum.ABANDONED + assert superseded.updated_at > older_updated_at + assert _reload(db, newer).status == ChatbotSessionStatusEnum.ACTIVE + + def test_complete_session_becomes_inactive_but_stays_complete( + self, db: Session, config: Config + ) -> None: + older = create_test_chatbot_session(db, config=config) + update_chatbot_session_progress( + db, + session_id=older.id, + collected_fields={"name": "Asha"}, + completion_status=ChatbotCompletionStatusEnum.COMPLETE, + ) + completed_at = _reload(db, older).updated_at + + create_test_chatbot_session( + db, + config=config, + contact_ref=older.contact_ref, + chatbot_ref=older.chatbot_ref, + ) + + superseded = _reload(db, older) + assert superseded.status == ChatbotSessionStatusEnum.INACTIVE + assert superseded.completion_status == ChatbotCompletionStatusEnum.COMPLETE + assert superseded.collected_fields == {"name": "Asha"} + assert superseded.updated_at > completed_at + + def test_other_chatbot_contact_and_project_sessions_stay_active( + self, db: Session, config: Config, project: Project + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + chatbot_ref = f"chatbot-{random_lower_string()}" + other_chatbot = create_test_chatbot_session( + db, config=config, contact_ref=contact_ref + ) + other_contact = create_test_chatbot_session( + db, config=config, chatbot_ref=chatbot_ref + ) + # Same organization, so only the project_id filter keeps it out of scope. + sibling_project = create_test_project( + db, organization_id=project.organization_id + ) + other_project_config = create_test_config( + db, + project_id=sibling_project.id, + config_blob=build_anthropic_text_config_blob(), + ) + other_project = create_test_chatbot_session( + db, + config=other_project_config, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, + ) + untouched = [other_chatbot, other_contact, other_project] + updated_ats = [s.updated_at for s in untouched] + + create_test_chatbot_session( + db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref + ) + + for chatbot_session, updated_at in zip(untouched, updated_ats): + stored = _reload(db, chatbot_session) + assert stored.status == ChatbotSessionStatusEnum.ACTIVE + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS + assert stored.updated_at == updated_at + + def test_already_inactive_session_is_left_unmodified( + self, db: Session, config: Config + ) -> None: + first = create_test_chatbot_session(db, config=config) + refs = {"contact_ref": first.contact_ref, "chatbot_ref": first.chatbot_ref} + second = create_test_chatbot_session(db, config=config, **refs) + first_after_supersede = _reload(db, first).updated_at + + third = create_test_chatbot_session(db, config=config, **refs) + + stored_first = _reload(db, first) + assert stored_first.status == ChatbotSessionStatusEnum.INACTIVE + assert stored_first.completion_status == ChatbotCompletionStatusEnum.ABANDONED + assert stored_first.updated_at == first_after_supersede + assert _reload(db, second).status == ChatbotSessionStatusEnum.INACTIVE + assert _reload(db, third).status == ChatbotSessionStatusEnum.ACTIVE + + +@pytest.fixture +def savepoint_db() -> Iterator[Session]: + # create_chatbot_session rolls back on IntegrityError. Under the shared `db` + # fixture that rollback discards the whole test transaction (rows created + # earlier included), so atomicity is unobservable. Joining via savepoints makes + # its commit/rollback behave as in production while still undoing everything here. + connection = engine.connect() + transaction = connection.begin() + session = Session(bind=connection, join_transaction_mode="create_savepoint") + try: + yield session + finally: + session.close() + transaction.rollback() + connection.close() + + +class TestCreateChatbotSessionFailures: + def test_fk_failure_propagates_and_keeps_prior_session_active( + self, savepoint_db: Session + ) -> None: + config = create_test_config( + savepoint_db, config_blob=build_anthropic_text_config_blob() + ) + prior = create_test_chatbot_session(savepoint_db, config=config) + + with pytest.raises(IntegrityError): + create_chatbot_session( + savepoint_db, + organization_id=prior.organization_id, + project_id=prior.project_id, + name="Onboarding Bot", + contact_ref=prior.contact_ref, + chatbot_ref=prior.chatbot_ref, + config_id=uuid4(), + config_version=None, + system_prompt="Collect the user's name.", + ) + + stored = _reload(savepoint_db, prior) + assert stored.status == ChatbotSessionStatusEnum.ACTIVE + assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS + + def test_active_index_violation_raises_conflict_and_inserts_nothing( + self, savepoint_db: Session + ) -> None: + config = create_test_config( + savepoint_db, config_blob=build_anthropic_text_config_blob() + ) + project = savepoint_db.get(Project, config.project_id) + assert project is not None + unrelated_org_id = create_test_project(savepoint_db).organization_id + contact_ref = f"contact-{random_lower_string()}" + chatbot_ref = f"chatbot-{random_lower_string()}" + # Stands in for a racing request's committed row: the unique index ignores + # organization_id but the supersede UPDATE filters on it, so this row + # survives the UPDATE exactly like one committed after it ran. + racing = ChatbotSession( + organization_id=unrelated_org_id, + project_id=project.id, + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, + config_id=config.id, + system_prompt="racing", + ) + savepoint_db.add(racing) + savepoint_db.commit() + + with pytest.raises(ChatbotSessionConflictError) as exc_info: + create_chatbot_session( + savepoint_db, + organization_id=project.organization_id, + project_id=project.id, + name="Onboarding Bot", + contact_ref=contact_ref, + chatbot_ref=chatbot_ref, + config_id=config.id, + config_version=None, + system_prompt="Collect the user's name.", + ) + + assert str(exc_info.value) == CONCURRENT_SESSION_CONFLICT_MESSAGE + savepoint_db.expire_all() + stored = savepoint_db.exec( + select(ChatbotSession).where( + ChatbotSession.project_id == project.id, + ChatbotSession.chatbot_ref == chatbot_ref, + ) + ).all() + assert [s.id for s in stored] == [racing.id] + assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE + + +class TestIsActiveSessionConflict: + # psycopg always exposes diag.constraint_name; the message fallback guards + # drivers/wrappers that don't, so it's exercised with stand-in errors. + @pytest.mark.parametrize( + ("orig", "expected"), + [ + ( + SimpleNamespace( + diag=SimpleNamespace( + constraint_name=ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX + ) + ), + True, + ), + ( + SimpleNamespace( + diag=SimpleNamespace( + constraint_name="chatbot_session_config_id_fkey" + ) + ), + False, + ), + ( + Exception( + f'duplicate key value violates unique constraint "{ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX}"' + ), + True, + ), + (Exception("violates foreign key constraint"), False), + ], + ids=["diag-match", "diag-other", "message-match", "message-other"], + ) + def test_detects_only_the_active_session_index( + self, orig: object, expected: bool + ) -> None: + error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type] + + assert _is_active_session_conflict(error) is expected + + +class TestChatbotRefExists: + def test_true_for_active_and_inactive_sessions_in_project( + self, db: Session, config: Config, project: Project + ) -> None: + first = create_test_chatbot_session(db, config=config) + create_test_chatbot_session( + db, + config=config, + contact_ref=first.contact_ref, + chatbot_ref=first.chatbot_ref, + ) + inactive_only = create_test_chatbot_session(db, config=config) + create_test_chatbot_session( + db, + config=config, + contact_ref=inactive_only.contact_ref, + chatbot_ref=inactive_only.chatbot_ref, + ) + + assert chatbot_ref_exists( + db, project_id=project.id, chatbot_ref=first.chatbot_ref + ) + assert chatbot_ref_exists( + db, project_id=project.id, chatbot_ref=inactive_only.chatbot_ref + ) + + def test_false_for_ref_used_only_in_another_project( + self, db: Session, config: Config + ) -> None: + chatbot_session = create_test_chatbot_session(db, config=config) + other_project = create_test_project(db) + + assert not chatbot_ref_exists( + db, project_id=other_project.id, chatbot_ref=chatbot_session.chatbot_ref + ) diff --git a/backend/app/tests/services/chatbot/test_chatbot_ref.py b/backend/app/tests/services/chatbot/test_chatbot_ref.py new file mode 100644 index 000000000..3fcbea589 --- /dev/null +++ b/backend/app/tests/services/chatbot/test_chatbot_ref.py @@ -0,0 +1,104 @@ +import logging +import re + +import pytest +from sqlmodel import Session + +from app.models import Config +from app.services.chatbot import chatbot_ref as chatbot_ref_module +from app.services.chatbot.chatbot_ref import ( + CHATBOT_REF_MAX_ATTEMPTS, + CHATBOT_REF_PREFIX, + CHATBOT_REF_SUFFIX_LENGTH, + generate_chatbot_ref, +) +from app.services.chatbot.exceptions import ChatbotRefGenerationError +from app.tests.utils.chatbot import create_test_chatbot_session +from app.tests.utils.test_data import ( + build_anthropic_text_config_blob, + create_test_config, +) + +USED_SUFFIX = 0xABCDE +USED_REF = "LLMResponseWithPrompt-abcde" +FRESH_SUFFIX = 0x1 +FRESH_REF = "LLMResponseWithPrompt-00001" + + +@pytest.fixture +def config(db: Session) -> Config: + return create_test_config(db, config_blob=build_anthropic_text_config_blob()) + + +def _script_randbelow(monkeypatch: pytest.MonkeyPatch, values: list[int]) -> list[int]: + """Replace the CSPRNG with a script; returns the list of bounds it was asked for.""" + bounds: list[int] = [] + remaining = iter(values) + + def fake_randbelow(bound: int) -> int: + bounds.append(bound) + return next(remaining) + + monkeypatch.setattr(chatbot_ref_module.secrets, "randbelow", fake_randbelow) + return bounds + + +class TestGenerateChatbotRef: + def test_ref_is_prefix_plus_lowercase_hex_suffix( + self, db: Session, config: Config + ) -> None: + ref = generate_chatbot_ref(db, project_id=config.project_id) + + pattern = ( + rf"{re.escape(CHATBOT_REF_PREFIX)}[0-9a-f]{{{CHATBOT_REF_SUFFIX_LENGTH}}}" + ) + assert re.fullmatch(pattern, ref) + + def test_small_suffix_is_zero_padded( + self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch + ) -> None: + _script_randbelow(monkeypatch, [FRESH_SUFFIX]) + + assert generate_chatbot_ref(db, project_id=config.project_id) == FRESH_REF + + def test_skips_ref_already_used_in_project( + self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch + ) -> None: + create_test_chatbot_session(db, config=config, chatbot_ref=USED_REF) + _script_randbelow(monkeypatch, [USED_SUFFIX, FRESH_SUFFIX]) + + assert generate_chatbot_ref(db, project_id=config.project_id) == FRESH_REF + + def test_accepts_ref_used_only_in_another_project( + self, db: Session, config: Config, monkeypatch: pytest.MonkeyPatch + ) -> None: + other_config = create_test_config( + db, config_blob=build_anthropic_text_config_blob() + ) + create_test_chatbot_session(db, config=other_config, chatbot_ref=USED_REF) + _script_randbelow(monkeypatch, [USED_SUFFIX]) + + assert generate_chatbot_ref(db, project_id=config.project_id) == USED_REF + + def test_raises_and_logs_after_max_attempts( + self, + db: Session, + config: Config, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + create_test_chatbot_session(db, config=config, chatbot_ref=USED_REF) + # One extra value: a loop that over-runs the cap would return FRESH_REF. + bounds = _script_randbelow( + monkeypatch, [USED_SUFFIX] * CHATBOT_REF_MAX_ATTEMPTS + [FRESH_SUFFIX] + ) + + with caplog.at_level(logging.ERROR, logger=chatbot_ref_module.__name__): + with pytest.raises(ChatbotRefGenerationError) as exc_info: + generate_chatbot_ref(db, project_id=config.project_id) + + assert len(bounds) == CHATBOT_REF_MAX_ATTEMPTS + assert "(code: ChatbotRefGenerationError)" in str(exc_info.value) + [record] = [r for r in caplog.records if r.levelno == logging.ERROR] + assert record.getMessage().startswith("[generate_chatbot_ref]") + assert f"project_id: {config.project_id}" in record.getMessage() diff --git a/backend/app/tests/services/chatbot/test_session.py b/backend/app/tests/services/chatbot/test_session.py index f0a71971b..f238a5e5c 100644 --- a/backend/app/tests/services/chatbot/test_session.py +++ b/backend/app/tests/services/chatbot/test_session.py @@ -129,6 +129,7 @@ def test_first_call_stores_greeting_and_posts_success_webhook( "success": True, "data": { "session_id": str(chatbot_session.id), + "chatbot_ref": chatbot_session.chatbot_ref, "reply": "Hi! What's your name?", "status": "active", "completion_status": "in_progress", @@ -402,6 +403,7 @@ def test_turn_persists_merged_fields_and_completion_and_webhook_reports_them( "success": True, "data": { "session_id": str(chatbot_session.id), + "chatbot_ref": chatbot_session.chatbot_ref, "reply": "Thanks, all set!", "status": "active", "completion_status": "complete", diff --git a/backend/app/tests/utils/chatbot.py b/backend/app/tests/utils/chatbot.py index 2fa5a3772..7137bc766 100644 --- a/backend/app/tests/utils/chatbot.py +++ b/backend/app/tests/utils/chatbot.py @@ -102,6 +102,7 @@ def create_test_chatbot_session( config: Config, name: str = DEFAULT_CHATBOT_NAME, contact_ref: str | None = None, + chatbot_ref: str | None = None, config_version: int | None = None, system_prompt: str = DEFAULT_SYSTEM_PROMPT, ) -> ChatbotSession: @@ -113,6 +114,7 @@ def create_test_chatbot_session( project_id=project.id, name=name, contact_ref=contact_ref or f"contact-{random_lower_string()}", + chatbot_ref=chatbot_ref or f"chatbot-{random_lower_string()}", config_id=config.id, config_version=config_version, system_prompt=system_prompt, diff --git a/backend/app/tests/utils/test_data.py b/backend/app/tests/utils/test_data.py index 34fb4ce50..1c0bb5368 100644 --- a/backend/app/tests/utils/test_data.py +++ b/backend/app/tests/utils/test_data.py @@ -57,21 +57,20 @@ def create_test_organization(db: Session) -> Organization: return create_organization(session=db, org_create=org_in) -def create_test_project(db: Session) -> Project: +def create_test_project(db: Session, organization_id: int | None = None) -> Project: """ - Creates and returns a test project under a newly created test organization. - - Persists both the organization and the project to the database. - + Creates and returns a test project under `organization_id`, or under a newly + created test organization when omitted. """ - org = create_test_organization(db) + if organization_id is None: + organization_id = create_test_organization(db).id name = f"TestProject-{random_lower_string()}" project_description = "This is a test project description." project_in = ProjectCreate( name=name, description=project_description, is_active=True, - organization_id=org.id, + organization_id=organization_id, ) return create_project(session=db, project_create=project_in) diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 4671f7799..eb1ec0f82 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -7,14 +7,15 @@ All paths relative to `backend/app/`. ## Routes - `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`) - `api/routes/chatbot/session.py` - - `POST /chatbot/sessions` (create; validates the config → `{session_id}`) - - `GET /chatbot/sessions?contact_ref=` (latest session for a contact in the caller's project → `ChatbotSessionSummary`, incl. `status`, `completion_status`, `collected_fields`) - - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`) + - `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated) + - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`) + - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive) - All require project scope. ## Tables (SQLModel, `models/chatbot.py`) -- `chatbot_session` (`ChatbotSession`) — `name`, `contact_ref`, `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete = all required fields collected), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Migration `088`. +- `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`. - `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session). +- Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same `contact_ref` + `chatbot_ref` (same transaction as the insert) moves the previous active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued when the session was superseded is skipped by the worker, which posts a failure webhook instead. A partial unique index `uq_chatbot_session_active_contact_chatbot` (`WHERE status = 'active'`) makes a concurrent double-create fail; crud raises `ChatbotSessionConflictError` → 409. - Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`. ## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`) @@ -28,15 +29,16 @@ All paths relative to `backend/app/`. - `services/chatbot/agents/onboarding/types.py` — `OnboardingState`, `FieldExtraction` - `services/chatbot/history.py` — agent-agnostic history plumbing: `summarize_if_needed`, `to_langchain_messages` / `to_message_dicts`, kickoff message, `has_user_turn`, `insert_after_leading_system` - `services/chatbot/types.py` — shared `ChatbotTurnResult`, `ChatbotMessageDict` -- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history, run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook) +- `services/chatbot/session.py` — `process_chatbot_session_message` (Celery worker body: load session + history; an inactive session gets a failure webhook and no turn; otherwise run the turn with the stored `collected_fields`, store the reply and update `collected_fields` / `completion_status`, POST the webhook) - `services/chatbot/config.py` — `resolve_chatbot_model_settings` (saved config + request overrides → `ChatbotModelSettings`; 404 via `ConfigVersionCrud`, 422 for non-Anthropic/non-text/no-model configs) - `services/chatbot/utils.py` — `ChatbotModelSettings`, defaults (`DEFAULT_MAX_OUTPUT_TOKENS`, `DEFAULT_HISTORY_TOKEN_LIMIT`, `FLOW_KEEP_RECENT_MESSAGES`), `get_chat_model` -- `crud/chatbot.py` — `create_chatbot_session`, `get_chatbot_session`, `get_latest_chatbot_session_by_contact_ref`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages` +- `services/chatbot/chatbot_ref.py` — `generate_chatbot_ref` (random `LLMResponseWithPrompt-<5 hex>`, retried against `chatbot_ref_exists` up to `CHATBOT_REF_MAX_ATTEMPTS`, else `ChatbotRefGenerationError`) +- `crud/chatbot.py` — `chatbot_ref_exists`, `create_chatbot_session` (also supersedes prior active sessions via `_deactivate_active_chatbot_sessions`; raises `ChatbotSessionConflictError` on the active-session unique index), `get_chatbot_session`, `get_latest_chatbot_session`, `update_chatbot_session_progress`, `create_chatbot_session_message`, `list_chatbot_session_messages` - `crud/config/version.py` — `ConfigVersionCrud.read_latest` (used when `config_version` is omitted) ## Async - `celery/tasks/job_execution.py` — `run_chatbot_session_message` (enqueued via `celery/utils.py:start_chatbot_session_message`) → `process_chatbot_session_message`. -- Webhook success payload: `{"success": true, "data": {"session_id", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. +- Webhook success payload: `{"success": true, "data": {"session_id", "chatbot_ref", "reply", "status", "completion_status", "collected_fields"}}`; failure: `{"success": false, "error", "data": {"session_id"}}`. ## External - Anthropic via `langchain-anthropic` (`ANTHROPIC_API_KEY` setting) — two calls per turn once the user has spoken (extraction + reply), plus one summary call when history exceeds the token budget. From 3e09675309e422e3112d2e77846e4138d4cbad05 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:52:47 +0530 Subject: [PATCH 09/15] feat: added the feature flag --- .../versions/089_add_chatbot_feature_flag.py | 22 ++++++++++ backend/app/api/routes/chatbot/__init__.py | 10 ++++- backend/app/core/feature_flags/constants.py | 1 + .../app/tests/api/routes/chatbot/conftest.py | 28 +++++++++++++ .../api/routes/chatbot/test_feature_flag.py | 41 +++++++++++++++++++ docs/wiki/modules/chatbot.md | 2 +- 6 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 backend/app/alembic/versions/089_add_chatbot_feature_flag.py create mode 100644 backend/app/tests/api/routes/chatbot/conftest.py create mode 100644 backend/app/tests/api/routes/chatbot/test_feature_flag.py diff --git a/backend/app/alembic/versions/089_add_chatbot_feature_flag.py b/backend/app/alembic/versions/089_add_chatbot_feature_flag.py new file mode 100644 index 000000000..68529d795 --- /dev/null +++ b/backend/app/alembic/versions/089_add_chatbot_feature_flag.py @@ -0,0 +1,22 @@ +"""add CHATBOT feature flag key + +Revision ID: 089 +Revises: 088 +Create Date: 2026-10-23 00:00:00.000000 +""" + +from alembic import op + +revision = "089" +down_revision = "088" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE featureflagkey ADD VALUE IF NOT EXISTS 'CHATBOT'") + + +def downgrade(): + pass diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py index 151b59066..8160b15b0 100644 --- a/backend/app/api/routes/chatbot/__init__.py +++ b/backend/app/api/routes/chatbot/__init__.py @@ -1,8 +1,14 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from app.api.permissions import require_feature from app.api.routes.chatbot.message import router as message_router from app.api.routes.chatbot.session import router as session_router +from app.core.feature_flags import FeatureFlag -router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) +router = APIRouter( + prefix="/chatbot", + tags=["Chatbot"], + dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))], +) router.include_router(message_router) router.include_router(session_router) diff --git a/backend/app/core/feature_flags/constants.py b/backend/app/core/feature_flags/constants.py index 0ecf470e4..7538f814b 100644 --- a/backend/app/core/feature_flags/constants.py +++ b/backend/app/core/feature_flags/constants.py @@ -3,3 +3,4 @@ class FeatureFlag(StrEnum): ASSESSMENT = "ASSESSMENT" + CHATBOT = "CHATBOT" diff --git a/backend/app/tests/api/routes/chatbot/conftest.py b/backend/app/tests/api/routes/chatbot/conftest.py new file mode 100644 index 000000000..8192a6b95 --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/conftest.py @@ -0,0 +1,28 @@ +import pytest +from sqlmodel import Session + +from app.core.feature_flags import FeatureFlag +from app.crud.feature_flag import ( + create_feature_flag, + get_feature_flag, + update_feature_flag, +) +from app.tests.utils.auth import TestAuthContext + + +def set_chatbot_flag(db: Session, auth: TestAuthContext, *, enabled: bool) -> None: + scope = { + "session": db, + "key": FeatureFlag.CHATBOT.value, + "organization_id": auth.organization_id, + "project_id": auth.project_id, + } + if get_feature_flag(**scope) is None: + create_feature_flag(**scope, enabled=enabled) + else: + update_feature_flag(**scope, enabled=enabled) + + +@pytest.fixture(autouse=True) +def chatbot_feature_enabled(db: Session, user_api_key: TestAuthContext) -> None: + set_chatbot_flag(db, user_api_key, enabled=True) diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py new file mode 100644 index 000000000..67824e841 --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py @@ -0,0 +1,41 @@ +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.api.routes.chatbot.conftest import set_chatbot_flag +from app.tests.utils.auth import TestAuthContext + +CHATBOT_URL = f"{settings.API_V1_STR}/chatbot" + + +@pytest.mark.parametrize( + ("method", "path", "kwargs"), + [ + ("post", "/message", {"json": {}}), + ("post", "/sessions", {"json": {}}), + ("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}), + ("post", f"/sessions/{uuid4()}/message", {"json": {}}), + ], +) +def test_chatbot_routes_return_403_when_flag_disabled( + client: TestClient, + db: Session, + user_api_key: TestAuthContext, + method: str, + path: str, + kwargs: dict, +) -> None: + set_chatbot_flag(db, user_api_key, enabled=False) + + resp = client.request( + method, + f"{CHATBOT_URL}{path}", + headers={"X-API-KEY": user_api_key.key}, + **kwargs, + ) + + assert resp.status_code == 403 + assert "CHATBOT" in resp.json()["error"] diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index eb1ec0f82..b1f443873 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -10,7 +10,7 @@ All paths relative to `backend/app/`. - `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated) - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`) - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive) -- All require project scope. +- All require project scope and the `CHATBOT` feature flag (`require_feature` on the `/chatbot` router → 403 when off). Migration `089` only adds the enum value — no rows are seeded, so the flag is off until a superuser creates it per project via `POST /feature-flags` (`enabled: true`). ## Tables (SQLModel, `models/chatbot.py`) - `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`. From c18a3a82de54e8e5d32696aaf36925fef3815103 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:58:19 +0530 Subject: [PATCH 10/15] chore: remove the unused code --- backend/app/api/docs/chatbot/message.md | 9 - backend/app/api/routes/chatbot/__init__.py | 2 - backend/app/api/routes/chatbot/message.py | 58 ------ backend/app/models/__init__.py | 3 - backend/app/models/chatbot.py | 51 ----- .../api/routes/chatbot/test_feature_flag.py | 1 - .../tests/api/routes/chatbot/test_message.py | 180 ------------------ docs/wiki/modules/chatbot.md | 5 +- 8 files changed, 2 insertions(+), 307 deletions(-) delete mode 100644 backend/app/api/docs/chatbot/message.md delete mode 100644 backend/app/api/routes/chatbot/message.py delete mode 100644 backend/app/tests/api/routes/chatbot/test_message.py diff --git a/backend/app/api/docs/chatbot/message.md b/backend/app/api/docs/chatbot/message.md deleted file mode 100644 index 1e189473f..000000000 --- a/backend/app/api/docs/chatbot/message.md +++ /dev/null @@ -1,9 +0,0 @@ -Run one turn of a prompt-driven chatbot conversation and return the full updated transcript. - -The chat model comes from a saved Kaapi config: `config_id` must be a config in the caller's project whose completion is `provider: "anthropic"`, `type: "text"` with `params.model` set. `config_version` picks a version (latest if omitted). The config's `params.effort` sets reasoning effort (provider default if absent) and `params.thinking` overrides the default adaptive thinking. `max_tokens` (default 1024) and `history_token_limit` (default 4000; older turns are summarized above it) are optional per-request overrides. - -There is no server-side session yet: send the admin-authored `system_prompt` on every call, and send the previous response's `messages` back as `history`. The returned `messages` contain the system prompt followed by every user/assistant turn so far, ending with the new assistant reply. Any `system` entries in `history` are ignored in favour of `system_prompt`. - -Each turn also extracts the fields `system_prompt` asks to collect from the whole conversation: `collected_fields` is a flat `{field_name: value}` map (snake_case keys, only values the user actually gave) and `completion_status` is `complete` once nothing required is missing, else `in_progress`. Both are recomputed from the transcript on every call; the opening greeting returns `{}` and `in_progress`. - -On the very first call, send an empty `history` and omit `message` to get the bot's opening greeting. Once `history` is non-empty, `message` is required. Returns 404 if the config or version isn't found in the project, 422 for a missing `message` or a config that isn't a usable Anthropic text config, and 502 for provider failures. diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py index 8160b15b0..5237ac117 100644 --- a/backend/app/api/routes/chatbot/__init__.py +++ b/backend/app/api/routes/chatbot/__init__.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Depends from app.api.permissions import require_feature -from app.api.routes.chatbot.message import router as message_router from app.api.routes.chatbot.session import router as session_router from app.core.feature_flags import FeatureFlag @@ -10,5 +9,4 @@ tags=["Chatbot"], dependencies=[Depends(require_feature(FeatureFlag.CHATBOT))], ) -router.include_router(message_router) router.include_router(session_router) diff --git a/backend/app/api/routes/chatbot/message.py b/backend/app/api/routes/chatbot/message.py deleted file mode 100644 index c5020da48..000000000 --- a/backend/app/api/routes/chatbot/message.py +++ /dev/null @@ -1,58 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException - -from app.api.deps import AuthContextDep, SessionDep -from app.api.permissions import Permission, require_permission -from app.models import ChatbotMessage, ChatbotTurnRequest, ChatbotTurnResponse -from app.services.chatbot.agents.onboarding.graph import run_chatbot_turn -from app.services.chatbot.config import resolve_chatbot_model_settings -from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError -from app.utils import APIResponse, load_description - -router = APIRouter() - - -@router.post( - "/message", - description=load_description("chatbot/message.md"), - response_model=APIResponse[ChatbotTurnResponse], - dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))], -) -async def send_chatbot_message( - request: ChatbotTurnRequest, - session: SessionDep, - auth_context: AuthContextDep, -) -> APIResponse[ChatbotTurnResponse]: - history: list[dict[str, str]] = [] - for entry in request.history: - history.append(entry.model_dump(mode="json")) - - try: - model_settings = resolve_chatbot_model_settings( - session=session, - project_id=auth_context.project_.id, - config_id=request.config_id, - config_version=request.config_version, - max_tokens=request.max_tokens, - history_token_limit=request.history_token_limit, - ) - turn_result = await run_chatbot_turn( - system_prompt=request.system_prompt, - history=history, - user_message=request.message, - model_settings=model_settings, - ) - except ChatbotInputError as e: - raise HTTPException(status_code=422, detail=str(e)) - except ChatbotProviderError as e: - raise HTTPException(status_code=502, detail=str(e)) - - messages: list[ChatbotMessage] = [] - for entry in turn_result.transcript: - messages.append(ChatbotMessage.model_validate(entry)) - return APIResponse.success_response( - ChatbotTurnResponse( - messages=messages, - collected_fields=turn_result.collected_fields, - completion_status=turn_result.completion_status, - ) - ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f51aa778f..0a112ca9f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -41,14 +41,11 @@ CONTACT_REF_MAX_LENGTH, DEFAULT_CHATBOT_NAME, ChatbotCompletionStatusEnum, - ChatbotMessage, ChatbotMessageRoleEnum, ChatbotSession, ChatbotSessionMessage, ChatbotSessionStatusEnum, ChatbotSessionSummary, - ChatbotTurnRequest, - ChatbotTurnResponse, CreateChatbotSessionRequest, CreateChatbotSessionResponse, SendChatbotSessionMessageRequest, diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index 71c808c8f..14db3cae4 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -16,45 +16,6 @@ class ChatbotMessageRoleEnum(StrEnum): ASSISTANT = "assistant" -class ChatbotMessage(SQLModel): - role: ChatbotMessageRoleEnum - content: str - - -class ChatbotTurnRequest(SQLModel): - config_id: UUID = Field( - description="Saved Kaapi config (Anthropic text completion) that supplies the model and reasoning settings.", - ) - config_version: int | None = Field( - default=None, - ge=1, - description="Version of `config_id` to use; omit to use its latest version.", - ) - max_tokens: int | None = Field( - default=None, - gt=0, - description="Cap on output tokens per model call; omit for the server default.", - ) - history_token_limit: int | None = Field( - default=None, - gt=0, - description="Token size of the history above which older turns are summarized; omit for the server default.", - ) - system_prompt: str = Field( - min_length=1, - description="Admin-authored flow prompt (goal, fields to collect, rules).", - ) - history: list[ChatbotMessage] = Field( - default_factory=list, - description="Every message before this turn, as returned by the previous call; empty on the first call.", - ) - message: str | None = Field( - default=None, - min_length=1, - description="The new user message; omit only to get the opening greeting on the first call.", - ) - - class ChatbotSessionStatusEnum(StrEnum): ACTIVE = "active" INACTIVE = "inactive" @@ -66,18 +27,6 @@ class ChatbotCompletionStatusEnum(StrEnum): ABANDONED = "abandoned" -class ChatbotTurnResponse(SQLModel): - messages: list[ChatbotMessage] = Field( - description="Full updated transcript (system prompt + every turn, including the new reply); resend it as `history` next call.", - ) - collected_fields: dict[str, Any] = Field( - description="Flat map of field name -> value the user has provided so far, as extracted from the transcript.", - ) - completion_status: ChatbotCompletionStatusEnum = Field( - description="`complete` once every field the system prompt asks for has been collected, else `in_progress`.", - ) - - _SESSION_STATUS_PG_ENUM = ENUM( ChatbotSessionStatusEnum, name="chatbotsessionstatusenum", diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py index 67824e841..3a6dbf14c 100644 --- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py +++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py @@ -14,7 +14,6 @@ @pytest.mark.parametrize( ("method", "path", "kwargs"), [ - ("post", "/message", {"json": {}}), ("post", "/sessions", {"json": {}}), ("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}), ("post", f"/sessions/{uuid4()}/message", {"json": {}}), diff --git a/backend/app/tests/api/routes/chatbot/test_message.py b/backend/app/tests/api/routes/chatbot/test_message.py deleted file mode 100644 index 54b57250d..000000000 --- a/backend/app/tests/api/routes/chatbot/test_message.py +++ /dev/null @@ -1,180 +0,0 @@ -import anthropic -import httpx -import pytest -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.core.config import settings -from app.models import Config -from app.services.chatbot.agents.onboarding.types import FieldExtraction -from app.tests.utils.auth import TestAuthContext -from app.tests.utils.chatbot import ( - ScriptedChatModel, - extracted_fields, - patch_chat_model, -) -from app.tests.utils.test_data import ( - build_anthropic_text_config_blob, - create_test_config, -) - -MESSAGE_URL = f"{settings.API_V1_STR}/chatbot/message" -SYSTEM_PROMPT = "Collect the user's name and city." - - -@pytest.fixture -def anthropic_config(db: Session, user_api_key: TestAuthContext) -> Config: - return create_test_config( - db, - project_id=user_api_key.project_id, - config_blob=build_anthropic_text_config_blob(), - ) - - -class TestSendChatbotMessage: - def test_returns_updated_transcript( - self, - client: TestClient, - user_api_key: TestAuthContext, - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - patch_chat_model(monkeypatch, ScriptedChatModel(replies=["Nice, which city?"])) - - resp = client.post( - MESSAGE_URL, - headers={"X-API-KEY": user_api_key.key}, - json={ - "config_id": str(anthropic_config.id), - "system_prompt": SYSTEM_PROMPT, - "history": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": "Hi! What's your name?"}, - ], - "message": "Asha", - }, - ) - - assert resp.status_code == 200 - assert resp.json()["data"]["messages"] == [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "assistant", "content": "Hi! What's your name?"}, - {"role": "user", "content": "Asha"}, - {"role": "assistant", "content": "Nice, which city?"}, - ] - - def test_returns_extracted_fields_and_completion_status( - self, - client: TestClient, - user_api_key: TestAuthContext, - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - patch_chat_model( - monkeypatch, - ScriptedChatModel( - replies=["Thanks, Asha from Pune!"], - extractions=[ - FieldExtraction( - collected_fields=extracted_fields( - {"name": "Asha", "city": "Pune"} - ), - missing_fields=[], - is_complete=True, - ) - ], - ), - ) - - resp = client.post( - MESSAGE_URL, - headers={"X-API-KEY": user_api_key.key}, - json={ - "config_id": str(anthropic_config.id), - "system_prompt": SYSTEM_PROMPT, - "history": [ - {"role": "assistant", "content": "Hi! What's your name and city?"} - ], - "message": "Asha, Pune", - }, - ) - - assert resp.status_code == 200 - body = resp.json()["data"] - assert body["collected_fields"] == {"name": "Asha", "city": "Pune"} - assert body["completion_status"] == "complete" - - def test_greeting_returns_no_fields_in_progress( - self, - client: TestClient, - user_api_key: TestAuthContext, - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - patch_chat_model( - monkeypatch, ScriptedChatModel(replies=["Hi! What's your name?"]) - ) - - resp = client.post( - MESSAGE_URL, - headers={"X-API-KEY": user_api_key.key}, - json={ - "config_id": str(anthropic_config.id), - "system_prompt": SYSTEM_PROMPT, - }, - ) - - assert resp.status_code == 200 - body = resp.json()["data"] - assert body["collected_fields"] == {} - assert body["completion_status"] == "in_progress" - - def test_missing_message_with_history_returns_422( - self, - client: TestClient, - user_api_key: TestAuthContext, - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - chat_model = ScriptedChatModel() - patch_chat_model(monkeypatch, chat_model) - - resp = client.post( - MESSAGE_URL, - headers={"X-API-KEY": user_api_key.key}, - json={ - "config_id": str(anthropic_config.id), - "system_prompt": SYSTEM_PROMPT, - "history": [{"role": "assistant", "content": "Hi! What's your name?"}], - }, - ) - - assert resp.status_code == 422 - assert "`message` is required" in resp.json()["error"] - assert chat_model.calls == [] - - def test_provider_failure_returns_502( - self, - client: TestClient, - user_api_key: TestAuthContext, - anthropic_config: Config, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") - patch_chat_model( - monkeypatch, - ScriptedChatModel(error=anthropic.APIConnectionError(request=request)), - ) - - resp = client.post( - MESSAGE_URL, - headers={"X-API-KEY": user_api_key.key}, - json={ - "config_id": str(anthropic_config.id), - "system_prompt": SYSTEM_PROMPT, - "message": "Asha", - }, - ) - - assert resp.status_code == 502 - assert "(code: APIConnectionError)" in resp.json()["error"] diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index b1f443873..00c2d7259 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -1,11 +1,10 @@ # Module: Chatbot -Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Two entry points: a stateless synchronous turn (caller resends `system_prompt` + full `history`) and server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet. +Prompt-driven chatbot ("Beneficiary Info Extractor") over a two-node LangGraph and a LangChain chat model (Anthropic). The admin's free-text `system_prompt` defines the goal, the fields to collect, and the rules; each turn extracts the fields the user has given so far and then replies. The model and reasoning settings come from a saved Kaapi config (`config_id` + optional `config_version`, latest if omitted); only Anthropic text completion configs are supported. Conversations are server-side sessions whose turns run on Celery and reply via webhook. No deep-dive doc yet. All paths relative to `backend/app/`. ## Routes -- `api/routes/chatbot/message.py` — `POST /chatbot/message` (one synchronous stateless turn → `ChatbotTurnResponse{messages, collected_fields, completion_status}`) - `api/routes/chatbot/session.py` - `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated) - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`) @@ -16,7 +15,7 @@ All paths relative to `backend/app/`. - `chatbot_session` (`ChatbotSession`) — `name` (display only), `contact_ref` (end user), `chatbot_ref` (stable chatbot id, generated as `LLMResponseWithPrompt-<5 hex>` on the first create and unique per project; caller sends it back to start new sessions for the same chatbot), `config_id` (FK `config`, RESTRICT) / `config_version`, `system_prompt`, `status` (`ChatbotSessionStatusEnum` active/inactive = session in use), `completion_status` (`ChatbotCompletionStatusEnum` in_progress/complete/abandoned), `collected_fields` (JSON flat `{field_name: value}`), org/project FKs. Composite index `(project_id, chatbot_ref, contact_ref)` for GET-latest and the generated-ref collision check, plus a partial unique index on `(project_id, contact_ref, chatbot_ref) WHERE status = 'active'`. Migration `088`. - `chatbot_session_message` (`ChatbotSessionMessage`) — ordered transcript rows (`role` user/assistant; the prompt lives on the session). - Session lifecycle: one active session per contact + chatbot. `active`+`in_progress` → `active`+`complete` as fields are collected. Creating a new session for the same `contact_ref` + `chatbot_ref` (same transaction as the insert) moves the previous active ones to `inactive`+`complete`, or `inactive`+`abandoned` if still in progress. Inactive sessions reject new messages (409); a turn already queued when the session was superseded is skipped by the worker, which posts a failure webhook instead. A partial unique index `uq_chatbot_session_active_contact_chatbot` (`WHERE status = 'active'`) makes a concurrent double-create fail; crud raises `ChatbotSessionConflictError` → 409. -- Request/response shapes: `ChatbotMessage`, `ChatbotTurnRequest`, `ChatbotTurnResponse`, `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`. +- Request/response shapes: `CreateChatbotSessionRequest/Response`, `ChatbotSessionSummary`, `SendChatbotSessionMessageRequest/Response`. ## Turn flow (onboarding agent, `services/chatbot/agents/onboarding/graph.py`) 1. `extract_fields_node` — skipped until there is a real user turn (opening greeting). Otherwise calls the model with structured output (`FieldExtraction{collected_fields, missing_fields, is_complete}`) over the admin prompt, the previously collected map, and the (summarized) conversation. Uses Anthropic native structured output (`with_structured_output(..., method="json_schema")`) with the config's own thinking/effort, because forced tool use is rejected with thinking on and Opus 5.5 can't disable thinking. Merged in code: `{**prior, **extracted}`; `is_complete` only when nothing is missing. From 440e5c888b5bd67ac15164817e7a4b20fa4195ff Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:30:54 +0530 Subject: [PATCH 11/15] fix: few updates on the get request --- .../api/docs/chatbot/get_latest_session.md | 6 ++- backend/app/api/routes/chatbot/session.py | 14 ++++--- backend/app/crud/chatbot.py | 19 ++++------ backend/app/models/chatbot.py | 1 + .../tests/api/routes/chatbot/test_session.py | 38 +++++++++++++++++-- backend/app/tests/crud/test_chatbot.py | 22 ++++++++++- docs/wiki/modules/chatbot.md | 2 +- 7 files changed, 77 insertions(+), 25 deletions(-) diff --git a/backend/app/api/docs/chatbot/get_latest_session.md b/backend/app/api/docs/chatbot/get_latest_session.md index bbec7bbff..3a3aa41a8 100644 --- a/backend/app/api/docs/chatbot/get_latest_session.md +++ b/backend/app/api/docs/chatbot/get_latest_session.md @@ -1,3 +1,5 @@ -Get the most recently created chatbot session for the given `contact_ref` and `chatbot_ref` in the caller's project. +Get the most recently created chatbot session for the given `contact_ref` in the caller's project, optionally narrowed to one chatbot with `chatbot_ref`. -Both refs are required and matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). The response carries the session's current `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`; `abandoned` if it was superseded while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when the contact has no sessions for this chatbot in this project. +`contact_ref` is required; `chatbot_ref` is optional. Both are matched exactly. `chatbot_ref` is the value returned when the chatbot's first session was created (or the one sent on later creates). Omit it to get the contact's newest session across all chatbots — the response's `chatbot_ref` says which chatbot that is. + +The response carries the session's `system_prompt` (as stored at creation), its current `status` (`active`, or `inactive` once a newer session for the same contact and chatbot was created), its `completion_status` (`in_progress` until every field the system prompt asks for is collected, then `complete`; `abandoned` if it was superseded while still `in_progress`), and the flat `collected_fields` map (`{field_name: value}`) gathered so far. Returns 404 when no matching session exists in this project. diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index 2ab7f2ad1..ace46ec2f 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -107,16 +107,16 @@ def read_latest_chatbot_session( description="Caller-side identifier of the end user whose latest session to fetch", ), ], + session: SessionDep, + auth_context: AuthContextDep, chatbot_ref: Annotated[ - str, + str | None, Query( min_length=1, max_length=CHATBOT_REF_MAX_LENGTH, - description="Caller-side identifier of the chatbot whose latest session to fetch", + description="Chatbot whose latest session to fetch; omit to get the contact's newest session across all chatbots", ), - ], - session: SessionDep, - auth_context: AuthContextDep, + ] = None, ) -> APIResponse[ChatbotSessionSummary]: chatbot_session = get_latest_chatbot_session( session, @@ -128,7 +128,8 @@ def read_latest_chatbot_session( if chatbot_session is None: raise HTTPException( status_code=404, - detail="No chatbot session found for this contact_ref and chatbot_ref", + detail="No chatbot session found for this contact_ref" + + (" and chatbot_ref" if chatbot_ref is not None else ""), ) return APIResponse.success_response( ChatbotSessionSummary( @@ -136,6 +137,7 @@ def read_latest_chatbot_session( name=chatbot_session.name, contact_ref=chatbot_session.contact_ref, chatbot_ref=chatbot_session.chatbot_ref, + system_prompt=chatbot_session.system_prompt, status=chatbot_session.status, completion_status=chatbot_session.completion_status, collected_fields=chatbot_session.collected_fields, diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index fdb6d9f41..0ca902bd1 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -232,17 +232,14 @@ def get_latest_chatbot_session( organization_id: int, project_id: int, contact_ref: str, - chatbot_ref: str, + chatbot_ref: str | None = None, ) -> ChatbotSession | None: - statement = ( - select(ChatbotSession) - .where( - ChatbotSession.organization_id == organization_id, - ChatbotSession.project_id == project_id, - ChatbotSession.contact_ref == contact_ref, - ChatbotSession.chatbot_ref == chatbot_ref, - ) - .order_by(col(ChatbotSession.inserted_at).desc()) - .limit(1) + statement = select(ChatbotSession).where( + ChatbotSession.organization_id == organization_id, + ChatbotSession.project_id == project_id, + ChatbotSession.contact_ref == contact_ref, ) + if chatbot_ref is not None: + statement = statement.where(ChatbotSession.chatbot_ref == chatbot_ref) + statement = statement.order_by(col(ChatbotSession.inserted_at).desc()).limit(1) return session.exec(statement).first() diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index 14db3cae4..2310e2ecb 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -248,6 +248,7 @@ class ChatbotSessionSummary(SQLModel): name: str contact_ref: str chatbot_ref: str + system_prompt: str status: ChatbotSessionStatusEnum completion_status: ChatbotCompletionStatusEnum collected_fields: dict[str, Any] diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index 36d9c9887..55d5c972f 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -417,6 +417,7 @@ def test_returns_contacts_newest_session_in_callers_project( contact_ref=contact_ref, chatbot_ref=chatbot_ref, config_version=1, + system_prompt="Collect the student's name and grade.", ) create_test_chatbot_session( db, config=anthropic_config, chatbot_ref=chatbot_ref @@ -440,6 +441,7 @@ def test_returns_contacts_newest_session_in_callers_project( assert body["name"] == "Onboarding Bot" assert body["contact_ref"] == contact_ref assert body["chatbot_ref"] == chatbot_ref + assert body["system_prompt"] == "Collect the student's name and grade." assert body["status"] == "active" assert body["completion_status"] == "in_progress" assert body["collected_fields"] == {} @@ -496,19 +498,47 @@ def test_missing_contact_ref_returns_422( assert resp.status_code == 422 - def test_missing_chatbot_ref_returns_422( + def test_omitted_chatbot_ref_returns_contacts_newest_session_across_chatbots( self, client: TestClient, + db: Session, headers: dict[str, str], - chatbot_session: ChatbotSession, + anthropic_config: Config, + ) -> None: + contact_ref = f"contact-{random_lower_string()}" + create_test_chatbot_session( + db, + config=anthropic_config, + contact_ref=contact_ref, + chatbot_ref=f"chatbot-{random_lower_string()}", + ) + newest = create_test_chatbot_session( + db, + config=anthropic_config, + contact_ref=contact_ref, + chatbot_ref=f"chatbot-{random_lower_string()}", + ) + + resp = client.get( + SESSIONS_URL, headers=headers, params={"contact_ref": contact_ref} + ) + + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["session_id"] == str(newest.id) + assert body["chatbot_ref"] == newest.chatbot_ref + + def test_omitted_chatbot_ref_unknown_contact_returns_404( + self, client: TestClient, headers: dict[str, str] ) -> None: resp = client.get( SESSIONS_URL, headers=headers, - params={"contact_ref": chatbot_session.contact_ref}, + params={"contact_ref": f"contact-{random_lower_string()}"}, ) - assert resp.status_code == 422 + assert resp.status_code == 404 + assert resp.json()["error"] == "No chatbot session found for this contact_ref" def test_unknown_chatbot_ref_for_known_contact_returns_404( self, diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py index fbd22fa80..6a5b5c0ce 100644 --- a/backend/app/tests/crud/test_chatbot.py +++ b/backend/app/tests/crud/test_chatbot.py @@ -241,6 +241,26 @@ def test_unknown_contact_returns_none( is None ) + def test_without_chatbot_ref_returns_newest_across_chatbots( + self, db: Session, config: Config, project: Project + ) -> None: + older = create_test_chatbot_session(db, config=config) + newest = create_test_chatbot_session( + db, config=config, contact_ref=older.contact_ref + ) + create_test_chatbot_session(db, config=config) + + latest = get_latest_chatbot_session( + db, + organization_id=project.organization_id, + project_id=project.id, + contact_ref=older.contact_ref, + ) + + assert latest is not None + assert latest.id == newest.id + assert latest.chatbot_ref != older.chatbot_ref + def test_ignores_newer_session_of_another_chatbot( self, db: Session, config: Config, project: Project ) -> None: @@ -431,7 +451,7 @@ def test_other_chatbot_contact_and_project_sessions_stay_active( db, config=config, contact_ref=contact_ref, chatbot_ref=chatbot_ref ) - for chatbot_session, updated_at in zip(untouched, updated_ats): + for chatbot_session, updated_at in zip(untouched, updated_ats, strict=True): stored = _reload(db, chatbot_session) assert stored.status == ChatbotSessionStatusEnum.ACTIVE assert stored.completion_status == ChatbotCompletionStatusEnum.IN_PROGRESS diff --git a/docs/wiki/modules/chatbot.md b/docs/wiki/modules/chatbot.md index 00c2d7259..4b482448a 100644 --- a/docs/wiki/modules/chatbot.md +++ b/docs/wiki/modules/chatbot.md @@ -7,7 +7,7 @@ All paths relative to `backend/app/`. ## Routes - `api/routes/chatbot/session.py` - `POST /chatbot/sessions` (create; validates the config; `chatbot_ref` omitted → new chatbot, ref generated by `services/chatbot/chatbot_ref.py:generate_chatbot_ref`; given → existing chatbot, supersedes the contact's active sessions for it → `{session_id, chatbot_ref}`; 409 on a concurrent create, 503 if no unused ref could be generated) - - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact + chatbot in the caller's project → `ChatbotSessionSummary`, incl. `chatbot_ref`, `status`, `completion_status`, `collected_fields`) + - `GET /chatbot/sessions?contact_ref=&chatbot_ref=` (latest session for a contact in the caller's project; `chatbot_ref` optional — omitted means newest across all chatbots → `ChatbotSessionSummary`, incl. `chatbot_ref`, `system_prompt`, `status`, `completion_status`, `collected_fields`) - `POST /chatbot/sessions/{session_id}/message` (stores the user message, enqueues the turn → 202; reply POSTed to `webhook_url`; 409 if the session is inactive) - All require project scope and the `CHATBOT` feature flag (`require_feature` on the `/chatbot` router → 403 when off). Migration `089` only adds the enum value — no rows are seeded, so the flag is off until a superuser creates it per project via `POST /feature-flags` (`enabled: true`). From f44ba822ea2656ca36564826dace3eebb5169736 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:21:30 +0530 Subject: [PATCH 12/15] fix: cleanups --- .../versions/088_add_chatbot_session.py | 7 ------ backend/app/crud/chatbot.py | 25 ++----------------- .../chatbot/agents/onboarding/graph.py | 7 +----- backend/app/services/chatbot/chatbot_ref.py | 5 +--- backend/app/services/chatbot/session.py | 13 +--------- 5 files changed, 5 insertions(+), 52 deletions(-) diff --git a/backend/app/alembic/versions/088_add_chatbot_session.py b/backend/app/alembic/versions/088_add_chatbot_session.py index b7f94ce8b..cd06ed363 100644 --- a/backend/app/alembic/versions/088_add_chatbot_session.py +++ b/backend/app/alembic/versions/088_add_chatbot_session.py @@ -3,13 +3,6 @@ Revision ID: 088 Revises: 087 Create Date: 2026-10-20 00:00:00.000000 - -At most one session per (contact_ref, chatbot_ref) is active: creating a new one -deactivates the rest in crud/chatbot.py, and the partial unique index makes a -concurrent double-create fail instead of leaving two active rows. The plain -composite index stays because the partial one can't serve the GET-latest lookup, -which includes inactive rows; chatbot_ref comes before contact_ref so its prefix -also serves the project-wide uniqueness check on generated chatbot_refs. """ import sqlalchemy as sa diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index 0ca902bd1..df2e1f99b 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -1,4 +1,3 @@ -import logging from datetime import datetime from typing import Any from uuid import UUID @@ -17,8 +16,6 @@ ChatbotSessionStatusEnum, ) -logger = logging.getLogger(__name__) - CONCURRENT_SESSION_CONFLICT_MESSAGE = ( "A concurrent session was just created for this contact and chatbot; retry" ) @@ -93,12 +90,9 @@ def create_chatbot_session( config_version: int | None, system_prompt: str, ) -> ChatbotSession: - """Insert a new active session, superseding the contact's active ones for the same chatbot. - - Deactivation and insert share one commit, so a failed insert leaves prior sessions active. - """ + """Insert a new active session, superseding the contact's active ones for the same chatbot.""" timestamp = now() - superseded_count = _deactivate_active_chatbot_sessions( + _deactivate_active_chatbot_sessions( session, organization_id=organization_id, project_id=project_id, @@ -125,18 +119,8 @@ def create_chatbot_session( session.rollback() if not _is_active_session_conflict(e): raise - logger.warning( - f"[create_chatbot_session] {CONCURRENT_SESSION_CONFLICT_MESSAGE} | " - f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, code: 409", - exc_info=True, - ) raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e session.refresh(chatbot_session) - logger.info( - f"[create_chatbot_session] Session created | session_id: {chatbot_session.id}, " - f"project_id: {project_id}, chatbot_ref: {chatbot_ref}, " - f"superseded_count: {superseded_count}" - ) return chatbot_session @@ -176,11 +160,6 @@ def update_chatbot_session_progress( session.add(chatbot_session) session.commit() session.refresh(chatbot_session) - logger.info( - f"[update_chatbot_session_progress] Session progress saved | " - f"session_id: {session_id}, completion_status: {completion_status.value}, " - f"field_count: {len(collected_fields)}" - ) return chatbot_session diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py index 4fcf3acee..ce52dbfc7 100644 --- a/backend/app/services/chatbot/agents/onboarding/graph.py +++ b/backend/app/services/chatbot/agents/onboarding/graph.py @@ -174,12 +174,7 @@ async def run_chatbot_turn( model_settings: ChatbotModelSettings, collected_fields: dict[str, Any] | None = None, ) -> ChatbotTurnResult: - """Run one stateless turn: update the collected fields, then reply. - - `user_message` may be None only on the very first call (empty `history`), - which yields the bot's opening greeting. `collected_fields` is the map from - earlier turns; it is merged with (and corrected by) this turn's extraction. - """ + """Run one stateless turn: update the collected fields, then reply.""" if user_message is None and history: raise ChatbotInputError( "[KAAPI] `message` is required once `history` is non-empty; omit it only " diff --git a/backend/app/services/chatbot/chatbot_ref.py b/backend/app/services/chatbot/chatbot_ref.py index 1af3ffa2a..97cbaaa7b 100644 --- a/backend/app/services/chatbot/chatbot_ref.py +++ b/backend/app/services/chatbot/chatbot_ref.py @@ -20,10 +20,7 @@ def _random_chatbot_ref() -> str: def generate_chatbot_ref(session: Session, *, project_id: int) -> str: - """Return a chatbot_ref not yet used by any session in the project. - - The suffix space is only ~1M values, and reusing a ref would merge two chatbots' sessions. - """ + """Return a chatbot_ref not yet used by any session in the project.""" for _attempt in range(CHATBOT_REF_MAX_ATTEMPTS): candidate = _random_chatbot_ref() if not chatbot_ref_exists( diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 9d8acd14f..598ee43d5 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -1,5 +1,4 @@ import asyncio -import logging from typing import Any from uuid import UUID @@ -25,8 +24,6 @@ from app.services.chatbot.utils import ChatbotModelSettings from app.utils import get_webhook_secret, send_callback -logger = logging.getLogger(__name__) - SUPERSEDED_SESSION_ERROR = ( "[KAAPI] Chatbot session was superseded by a newer session; start a new session." ) @@ -35,11 +32,7 @@ def _split_pending_turn( stored_messages: list[ChatbotSessionMessage], ) -> tuple[list[ChatbotMessageDict], str | None]: - """Split the stored transcript into (prior history, pending user message). - - The route persists the user's message before enqueueing, so it is the last - stored row; run_chatbot_turn wants it separately from the history before it. - """ + """Split the stored transcript into (prior history, pending user message).""" history: list[ChatbotMessageDict] = [] for stored in stored_messages: # The system prompt lives on the session row and is passed separately. @@ -102,10 +95,6 @@ def process_chatbot_session_message( return # The turn may have been queued before a newer session superseded this one. if chatbot_session.status == ChatbotSessionStatusEnum.INACTIVE: - logger.warning( - f"[process_chatbot_session_message] {SUPERSEDED_SESSION_ERROR} | " - f"session_id: {session_id}, project_id: {project_id}" - ) _send_failure_webhook( session_id=session_id, webhook_url=webhook_url, From daf4b92eb83ddae91c70c9ff2a08d51a62634dbe Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:29:40 +0530 Subject: [PATCH 13/15] fix: refactoring --- .../chatbot/agents/onboarding/graph.py | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/backend/app/services/chatbot/agents/onboarding/graph.py b/backend/app/services/chatbot/agents/onboarding/graph.py index ce52dbfc7..2c1c69893 100644 --- a/backend/app/services/chatbot/agents/onboarding/graph.py +++ b/backend/app/services/chatbot/agents/onboarding/graph.py @@ -166,6 +166,28 @@ def _get_extraction_model(chat_model: BaseChatModel) -> ExtractionModel: return chat_model.with_structured_output(FieldExtraction, method=EXTRACTION_METHOD) +def _to_provider_error(error: Exception, *, model: str) -> ChatbotProviderError: + if isinstance(error, anthropic.APITimeoutError): + return ChatbotProviderError( + "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " + "(code: APITimeoutError)" + ) + if isinstance(error, anthropic.APIConnectionError): + return ChatbotProviderError( + "[KAAPI] Network/DNS issue reaching provider — check connectivity. " + "If persistent, contact Kaapi. (code: APIConnectionError)" + ) + if isinstance(error, anthropic.APIStatusError): + return ChatbotProviderError( + f"[ANTHROPIC] Chat model call failed: {error.message} " + f"(code: {error.status_code}, request_id: {error.request_id})" + ) + return ChatbotProviderError( + f"[KAAPI] Unsupported model configuration for '{model}': {error} " + "— update the config's model, thinking, or effort. (code: ValueError)" + ) + + async def run_chatbot_turn( *, system_prompt: str, @@ -200,27 +222,8 @@ async def run_chatbot_turn( ) # ChatAnthropic.invoke is sync; keep it off the event loop. result = await asyncio.to_thread(graph.invoke, initial_state) - except anthropic.APITimeoutError as e: - raise ChatbotProviderError( - "[KAAPI] Request timed out — retry smaller. If persistent, contact Kaapi. " - "(code: APITimeoutError)" - ) from e - except anthropic.APIConnectionError as e: - raise ChatbotProviderError( - "[KAAPI] Network/DNS issue reaching provider — check connectivity. " - "If persistent, contact Kaapi. (code: APIConnectionError)" - ) from e - except anthropic.APIStatusError as e: - raise ChatbotProviderError( - f"[ANTHROPIC] Chat model call failed: {e.message} (code: {e.status_code}, " - f"request_id: {e.request_id})" - ) from e - except ValueError as e: - error_message = ( - f"[KAAPI] Unsupported model configuration for '{model_settings.model}': " - f"{e} — update the config's model, thinking, or effort. (code: ValueError)" - ) - raise ChatbotProviderError(error_message) from e + except (anthropic.APIConnectionError, anthropic.APIStatusError, ValueError) as e: + raise _to_provider_error(e, model=model_settings.model) from e transcript = to_message_dicts( system_prompt=system_prompt, messages=result["messages"] From ab342629ea02be24f08f4e2396f770c765a48607 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:46:45 +0530 Subject: [PATCH 14/15] fix: update test cases --- backend/app/tests/api/routes/chatbot/test_feature_flag.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/tests/api/routes/chatbot/test_feature_flag.py b/backend/app/tests/api/routes/chatbot/test_feature_flag.py index 3a6dbf14c..747d0f2b7 100644 --- a/backend/app/tests/api/routes/chatbot/test_feature_flag.py +++ b/backend/app/tests/api/routes/chatbot/test_feature_flag.py @@ -1,4 +1,4 @@ -from uuid import uuid4 +from uuid import UUID import pytest from fastapi.testclient import TestClient @@ -9,6 +9,7 @@ from app.tests.utils.auth import TestAuthContext CHATBOT_URL = f"{settings.API_V1_STR}/chatbot" +UNKNOWN_SESSION_ID = UUID("00000000-0000-0000-0000-000000000001") @pytest.mark.parametrize( @@ -16,8 +17,9 @@ [ ("post", "/sessions", {"json": {}}), ("get", "/sessions", {"params": {"contact_ref": "c", "chatbot_ref": "b"}}), - ("post", f"/sessions/{uuid4()}/message", {"json": {}}), + ("post", f"/sessions/{UNKNOWN_SESSION_ID}/message", {"json": {}}), ], + ids=["create_session", "get_latest_session", "send_message"], ) def test_chatbot_routes_return_403_when_flag_disabled( client: TestClient, From 26f70a2f8bccc11e8a09899969e8d58800197ca2 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:56:38 +0530 Subject: [PATCH 15/15] fix: cleanups --- backend/app/api/routes/chatbot/session.py | 2 +- backend/app/core/util.py | 9 ++++ backend/app/crud/chatbot.py | 23 ++------- backend/app/services/chatbot/exceptions.py | 9 ++++ backend/app/services/chatbot/session.py | 24 ++++----- .../tests/api/routes/chatbot/test_session.py | 6 ++- backend/app/tests/core/test_util.py | 37 ++++++++++++++ backend/app/tests/crud/test_chatbot.py | 49 ++----------------- 8 files changed, 79 insertions(+), 80 deletions(-) create mode 100644 backend/app/tests/core/test_util.py diff --git a/backend/app/api/routes/chatbot/session.py b/backend/app/api/routes/chatbot/session.py index ace46ec2f..efa8948e8 100644 --- a/backend/app/api/routes/chatbot/session.py +++ b/backend/app/api/routes/chatbot/session.py @@ -8,7 +8,6 @@ from app.api.permissions import Permission, require_permission from app.celery.utils import start_chatbot_session_message from app.crud.chatbot import ( - ChatbotSessionConflictError, create_chatbot_session, create_chatbot_session_message, get_chatbot_session, @@ -31,6 +30,7 @@ from app.services.chatbot.exceptions import ( ChatbotInputError, ChatbotRefGenerationError, + ChatbotSessionConflictError, ) from app.utils import APIResponse, load_description, validate_callback_url diff --git a/backend/app/core/util.py b/backend/app/core/util.py index 25c06d909..c7470edae 100644 --- a/backend/app/core/util.py +++ b/backend/app/core/util.py @@ -4,6 +4,7 @@ from fastapi import HTTPException from openai import OpenAI +from sqlalchemy.exc import IntegrityError logger = logging.getLogger(__name__) @@ -12,6 +13,14 @@ def now(): return datetime.now(timezone.utc).replace(tzinfo=None) +def is_constraint_violation(error: IntegrityError, constraint_name: str) -> bool: + """True only when *error* was raised by the named constraint or index.""" + violated = getattr(getattr(error.orig, "diag", None), "constraint_name", None) + if violated: + return violated == constraint_name + return constraint_name in str(error.orig or error) + + def raise_from_unknown(error: Exception, status_code=500): logger.warning( 'Unexpected exception "{}": {}'.format( diff --git a/backend/app/crud/chatbot.py b/backend/app/crud/chatbot.py index df2e1f99b..347bf66d8 100644 --- a/backend/app/crud/chatbot.py +++ b/backend/app/crud/chatbot.py @@ -6,7 +6,7 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Session, col, select -from app.core.util import now +from app.core.util import is_constraint_violation, now from app.models import ( ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, ChatbotCompletionStatusEnum, @@ -15,25 +15,12 @@ ChatbotSessionMessage, ChatbotSessionStatusEnum, ) - -CONCURRENT_SESSION_CONFLICT_MESSAGE = ( - "A concurrent session was just created for this contact and chatbot; retry" +from app.services.chatbot.exceptions import ( + CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotSessionConflictError, ) -class ChatbotSessionConflictError(Exception): - """Another active session for the same contact + chatbot was committed concurrently.""" - - -def _is_active_session_conflict(error: IntegrityError) -> bool: - constraint_name = getattr( - getattr(error.orig, "diag", None), "constraint_name", None - ) - if constraint_name: - return constraint_name == ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX - return ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX in str(error.orig or error) - - def _deactivate_active_chatbot_sessions( session: Session, *, @@ -117,7 +104,7 @@ def create_chatbot_session( session.commit() except IntegrityError as e: session.rollback() - if not _is_active_session_conflict(e): + if not is_constraint_violation(e, ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX): raise raise ChatbotSessionConflictError(CONCURRENT_SESSION_CONFLICT_MESSAGE) from e session.refresh(chatbot_session) diff --git a/backend/app/services/chatbot/exceptions.py b/backend/app/services/chatbot/exceptions.py index fab852789..2239f1a16 100644 --- a/backend/app/services/chatbot/exceptions.py +++ b/backend/app/services/chatbot/exceptions.py @@ -1,3 +1,8 @@ +CONCURRENT_SESSION_CONFLICT_MESSAGE = ( + "A concurrent session was just created for this contact and chatbot; retry" +) + + class ChatbotInputError(ValueError): """The turn request can't produce a valid model call (caller's fault).""" @@ -8,3 +13,7 @@ class ChatbotRefGenerationError(Exception): class ChatbotProviderError(Exception): """The chat-model provider failed or was unreachable.""" + + +class ChatbotSessionConflictError(Exception): + """Another active session for the same contact + chatbot was committed concurrently.""" diff --git a/backend/app/services/chatbot/session.py b/backend/app/services/chatbot/session.py index 598ee43d5..e142ad8d4 100644 --- a/backend/app/services/chatbot/session.py +++ b/backend/app/services/chatbot/session.py @@ -1,5 +1,4 @@ import asyncio -from typing import Any from uuid import UUID from fastapi import HTTPException @@ -22,7 +21,7 @@ from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.services.chatbot.types import ChatbotMessageDict from app.services.chatbot.utils import ChatbotModelSettings -from app.utils import get_webhook_secret, send_callback +from app.utils import APIResponse, get_webhook_secret, send_callback SUPERSEDED_SESSION_ERROR = ( "[KAAPI] Chatbot session was superseded by a newer session; start a new session." @@ -63,12 +62,10 @@ def _send_failure_webhook( webhook_secret: str | None, error: str, ) -> None: - payload: dict[str, Any] = { - "success": False, - "error": error, - "data": {"session_id": str(session_id)}, - } - send_callback(webhook_url, payload, webhook_secret) + envelope = APIResponse.failure_response( + error=error, data={"session_id": str(session_id)} + ) + send_callback(webhook_url, envelope.model_dump(exclude_none=True), webhook_secret) return @@ -178,16 +175,15 @@ def process_chatbot_session_message( completion_status=completion_status, ) - payload: dict[str, Any] = { - "success": True, - "data": { + envelope = APIResponse.success_response( + data={ "session_id": str(session_id), "chatbot_ref": chatbot_ref, "reply": reply, "status": session_status, "completion_status": completion_status.value, "collected_fields": turn_result.collected_fields, - }, - } - send_callback(webhook_url, payload, webhook_secret) + } + ) + send_callback(webhook_url, envelope.model_dump(exclude_none=True), webhook_secret) return diff --git a/backend/app/tests/api/routes/chatbot/test_session.py b/backend/app/tests/api/routes/chatbot/test_session.py index 55d5c972f..5501b993a 100644 --- a/backend/app/tests/api/routes/chatbot/test_session.py +++ b/backend/app/tests/api/routes/chatbot/test_session.py @@ -10,8 +10,6 @@ from app.api.routes.chatbot import session as session_routes from app.core.config import settings from app.crud.chatbot import ( - CONCURRENT_SESSION_CONFLICT_MESSAGE, - ChatbotSessionConflictError, get_chatbot_session, list_chatbot_session_messages, update_chatbot_session_progress, @@ -29,6 +27,10 @@ CHATBOT_REF_PREFIX, CHATBOT_REF_SUFFIX_LENGTH, ) +from app.services.chatbot.exceptions import ( + CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotSessionConflictError, +) from app.tests.utils.auth import TestAuthContext from app.tests.utils.chatbot import ( add_test_chatbot_messages, diff --git a/backend/app/tests/core/test_util.py b/backend/app/tests/core/test_util.py new file mode 100644 index 000000000..6e2da7cbd --- /dev/null +++ b/backend/app/tests/core/test_util.py @@ -0,0 +1,37 @@ +from types import SimpleNamespace + +import pytest +from sqlalchemy.exc import IntegrityError + +from app.core.util import is_constraint_violation + +CONSTRAINT = "uq_example_active" + + +class TestIsConstraintViolation: + # psycopg always exposes diag.constraint_name; the message fallback guards + # drivers/wrappers that don't, so it's exercised with stand-in errors. + @pytest.mark.parametrize( + ("orig", "expected"), + [ + (SimpleNamespace(diag=SimpleNamespace(constraint_name=CONSTRAINT)), True), + ( + SimpleNamespace(diag=SimpleNamespace(constraint_name="other_fkey")), + False, + ), + ( + Exception( + f'duplicate key value violates unique constraint "{CONSTRAINT}"' + ), + True, + ), + (Exception("violates foreign key constraint"), False), + ], + ids=["diag-match", "diag-other", "message-match", "message-other"], + ) + def test_matches_only_the_named_constraint( + self, orig: object, expected: bool + ) -> None: + error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type] + + assert is_constraint_violation(error, CONSTRAINT) is expected diff --git a/backend/app/tests/crud/test_chatbot.py b/backend/app/tests/crud/test_chatbot.py index 6a5b5c0ce..7bbb8e490 100644 --- a/backend/app/tests/crud/test_chatbot.py +++ b/backend/app/tests/crud/test_chatbot.py @@ -1,5 +1,4 @@ from collections.abc import Iterator -from types import SimpleNamespace from uuid import uuid4 import pytest @@ -8,9 +7,6 @@ from app.core.db import engine from app.crud.chatbot import ( - CONCURRENT_SESSION_CONFLICT_MESSAGE, - ChatbotSessionConflictError, - _is_active_session_conflict, chatbot_ref_exists, create_chatbot_session, create_chatbot_session_message, @@ -20,7 +16,6 @@ update_chatbot_session_progress, ) from app.models import ( - ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX, ChatbotCompletionStatusEnum, ChatbotMessageRoleEnum, ChatbotSession, @@ -28,6 +23,10 @@ Config, Project, ) +from app.services.chatbot.exceptions import ( + CONCURRENT_SESSION_CONFLICT_MESSAGE, + ChatbotSessionConflictError, +) from app.tests.utils.chatbot import ( add_test_chatbot_messages, create_test_chatbot_session, @@ -568,46 +567,6 @@ def test_active_index_violation_raises_conflict_and_inserts_nothing( assert stored[0].status == ChatbotSessionStatusEnum.ACTIVE -class TestIsActiveSessionConflict: - # psycopg always exposes diag.constraint_name; the message fallback guards - # drivers/wrappers that don't, so it's exercised with stand-in errors. - @pytest.mark.parametrize( - ("orig", "expected"), - [ - ( - SimpleNamespace( - diag=SimpleNamespace( - constraint_name=ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX - ) - ), - True, - ), - ( - SimpleNamespace( - diag=SimpleNamespace( - constraint_name="chatbot_session_config_id_fkey" - ) - ), - False, - ), - ( - Exception( - f'duplicate key value violates unique constraint "{ACTIVE_CHATBOT_SESSION_UNIQUE_INDEX}"' - ), - True, - ), - (Exception("violates foreign key constraint"), False), - ], - ids=["diag-match", "diag-other", "message-match", "message-other"], - ) - def test_detects_only_the_active_session_index( - self, orig: object, expected: bool - ) -> None: - error = IntegrityError("INSERT ...", {}, orig) # type: ignore[arg-type] - - assert _is_active_session_conflict(error) is expected - - class TestChatbotRefExists: def test_true_for_active_and_inactive_sessions_in_project( self, db: Session, config: Config, project: Project