From 897e466e1bad28faed202cf8d1a0cc1a2061b2a5 Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Wed, 12 Aug 2026 10:40:08 +0530 Subject: [PATCH 01/10] added new queue for this --- .claude/conventions/celery.md | 36 +++++++++++++++-------- .env.example | 3 ++ backend/app/celery/celery_app.py | 17 +++++++++-- backend/app/celery/tasks/job_execution.py | 28 +++++++++++------- docker-compose.dev.yml | 18 +++++++++++- docker-compose.staging.yml | 25 +++++++++++++++- docker-compose.yml | 18 +++++++++++- docs/wiki/services.md | 4 +-- 8 files changed, 118 insertions(+), 31 deletions(-) diff --git a/.claude/conventions/celery.md b/.claude/conventions/celery.md index f83413f0a..d7feaa6cb 100644 --- a/.claude/conventions/celery.md +++ b/.claude/conventions/celery.md @@ -1,10 +1,14 @@ # Celery task conventions (`app/celery/tasks/`) Authoritative conventions for Celery tasks in kaapi-backend. Tasks live in `app/celery/tasks/`. -Celery uses RabbitMQ as broker. There is a **single `default` queue** declared with -`x-max-priority=10`; tasks are ordered by a per-task `priority` (higher drains first, FIFO within -a band). Read `app/celery/tasks/job_execution.py` before writing — it shows the full pattern -(decorator + timeout + OTel propagation + delegation to a service). +Celery uses RabbitMQ as broker. There are **two queues**, each declared with `x-max-priority=10`: +`default` (everything except fast-eval burst tasks) and `evaluations` (only +`run_evaluation_fast_chunk` / `run_evaluation_fast_aggregate` — physically isolated on their own +worker pool so a fast-eval burst can't occupy `default`'s worker slots and delay higher-priority +LLM jobs; priority only reorders queued-not-yet-claimed messages, it can't preempt a task a +worker already started). Within each queue, tasks are ordered by a per-task `priority` (higher +drains first, FIFO within a band). Read `app/celery/tasks/job_execution.py` before writing — it +shows the full pattern (decorator + timeout + OTel propagation + delegation to a service). ## Canonical decorator stack @@ -30,17 +34,23 @@ def run_my_job(self, project_id: int, job_id: str, trace_id: str, **kwargs): `_set_trace`, `_run_with_otel_parent`, and `gevent_timeout` already exist in this module / `app/celery/utils.py` — reuse them, don't reinvent. -## Priority choice — be explicit +## Queue and priority choice — be explicit -All tasks share the one `default` queue; set `priority` to place the task in the right band. Match +Only `run_evaluation_fast_chunk` and `run_evaluation_fast_aggregate` use `queue="evaluations"` +(fast-eval burst fan-out — many chunk tasks fired at once). Everything else uses +`queue="default"`. Don't move other eval-adjacent tasks (e.g. `run_evaluation_batch_submission`, +`send_eval_completion_notification`) onto `evaluations` — they're one-shot/fire-and-forget, no +burstiness to isolate. + +Within whichever queue the task belongs to, set `priority` to place it in the right band. Match the bands already in use (see the `job_execution.py` module docstring): -| Priority | When | -|---|---| -| `9` | User-blocking, interactive — LLM call / chain / response jobs | -| `6` | Fast evaluation | -| `2` | Default — doctransform, collections, STT/TTS evaluation, assessment | -| `1` | Notifications and other fire-and-forget background work | +| Priority | Queue | When | +|---|---|---| +| `9` | `default` | User-blocking, interactive — LLM call / chain / response jobs | +| `6` | `evaluations` | Fast evaluation chunk/aggregate | +| `2` | `default` | Default — doctransform, collections, STT/TTS evaluation, assessment | +| `1` | `default` | Notifications and other fire-and-forget background work | Pick the band that matches the task's user-facing urgency; document the choice in a comment if it's not obvious. `task_inherit_parent_priority=True` is set, so a task enqueued from another task @@ -49,7 +59,7 @@ inherits its priority unless you override it. ## Hard rules - **`bind=True`** so you have `self` (the task instance) for retries, IDs, etc. -- **Always pass `queue="default"` and an explicit `priority`** — there is only one queue; the priority is what matters. +- **Always pass an explicit `queue` (`"default"` or `"evaluations"`) and `priority`.** Only fast-eval chunk/aggregate tasks use `"evaluations"`; everything else uses `"default"`. - **Pass `trace_id` explicitly** as a parameter and call `_set_trace(trace_id)` first thing. This wires `asgi_correlation_id` so logs from inside the task match the originating request. - **Wrap the work in `_run_with_otel_parent(self, lambda: ...)`** so OpenTelemetry parent context propagates from the enqueueing process. - **Delegate to a service.** The task body should be a thin shim over `app/services//`. No DB queries, no external HTTP, no business logic inside the task itself. diff --git a/.env.example b/.env.example index 5a041fe26..446e5476c 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ REDIS_PASSWORD= # Celery Configuration # Leave CELERY_WORKER_CONCURRENCY empty to auto-detect CPU cores, or set to specific number (e.g., number_of_cores * 2) CELERY_WORKER_CONCURRENCY= +# Dedicated pool for the "evaluations" queue (fast-eval chunk/aggregate tasks), +# isolated from CELERY_WORKER_CONCURRENCY so an eval burst can't starve LLM-call tasks. +CELERY_EVAL_WORKER_CONCURRENCY=2 CELERY_WORKER_MAX_TASKS_PER_CHILD=1000 CELERY_WORKER_MAX_MEMORY_PER_CHILD=200000 CELERY_TASK_SOFT_TIME_LIMIT=300 diff --git a/backend/app/celery/celery_app.py b/backend/app/celery/celery_app.py index e0ddf8dcd..ec5d505f6 100644 --- a/backend/app/celery/celery_app.py +++ b/backend/app/celery/celery_app.py @@ -167,6 +167,9 @@ def initialize_worker(**_) -> None: ) # Define exchanges and queues with priority +DEFAULT_QUEUE = "default" +EVALUATIONS_QUEUE = "evaluations" + default_exchange = Exchange("default", type="direct") # Celery configuration using environment variables @@ -174,13 +177,21 @@ def initialize_worker(**_) -> None: # Queue configuration with priority support task_queues=( Queue( - "default", + DEFAULT_QUEUE, + exchange=default_exchange, + routing_key=DEFAULT_QUEUE, + queue_arguments={"x-max-priority": 10}, + ), + # Routing key must differ per queue on the shared direct exchange, or + # messages fan out to both. + Queue( + EVALUATIONS_QUEUE, exchange=default_exchange, - routing_key="default", + routing_key=EVALUATIONS_QUEUE, queue_arguments={"x-max-priority": 10}, ), ), - task_default_queue="default", + task_default_queue=DEFAULT_QUEUE, # Enable priority support task_inherit_parent_priority=True, worker_prefetch_multiplier=settings.CELERY_WORKER_PREFETCH_MULTIPLIER, diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py index 000c6025a..b55703074 100644 --- a/backend/app/celery/tasks/job_execution.py +++ b/backend/app/celery/tasks/job_execution.py @@ -1,14 +1,22 @@ -"""Celery task definitions for the single priority `default` queue. +"""Celery task definitions, split across two priority queues. -All tasks share one queue (`default`, declared with `x-max-priority=10`) and are -ordered by the per-task `priority`: +`default` (every task below except the two fast-eval ones), ordered by the +per-task `priority`: - 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job) - 6 Fast evaluation (run_evaluation_fast_chunk, run_evaluation_fast_aggregate) + 9 LLM call + LLM chain (run_llm_job, run_llm_chain_job, run_response_job, + run_guardrails_job) + 6 Prompt improvement (run_prompt_improvement) 2 Everything else (doctransform, collections, STT/TTS evaluation, assessment) 1 Notifications (send_eval_completion_notification) -Higher priority drains first; within the same priority, delivery is FIFO. +`evaluations` (priority 6): run_evaluation_fast_chunk and +run_evaluation_fast_aggregate only. A fast eval fans out into many chunk tasks +at once; priority only reorders messages still waiting in a queue, so on a +shared queue that burst occupies every worker slot and delays priority-9 LLM +jobs. A separate queue with its own worker pool makes the isolation physical. + +Both queues are declared with `x-max-priority=10`. Higher priority drains first; +within the same priority, delivery is FIFO. """ import logging @@ -19,7 +27,7 @@ from opentelemetry import trace from opentelemetry.propagate import extract -from app.celery.celery_app import celery_app +from app.celery.celery_app import EVALUATIONS_QUEUE, celery_app from app.celery.utils import gevent_timeout from app.core.config import settings @@ -246,7 +254,7 @@ def run_evaluation_batch_submission( ) -# Priority 6 (fast-eval tier): user-blocking interactive prompt iteration in the +# Priority 6: user-blocking interactive prompt iteration in the # evaluation domain, above default batch work but below core LLM call/chain jobs. @celery_app.task(bind=True, queue="default", priority=6) @gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_prompt_improvement") @@ -370,7 +378,7 @@ def run_tts_result_processing( ) -@celery_app.task(bind=True, queue="default", priority=6) +@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=6) @gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_evaluation_fast_chunk") def run_evaluation_fast_chunk( self: Task, @@ -400,7 +408,7 @@ def run_evaluation_fast_chunk( ) -@celery_app.task(bind=True, queue="default", priority=6) +@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=6) @gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_evaluation_fast_aggregate") def run_evaluation_fast_aggregate( self: Task, eval_run_id: int, trace_id: str = DEFAULT_TRACE_ID diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f9e3b3e1c..d87e75d4f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -57,4 +57,20 @@ services: required: true - path: .env.secrets required: false - command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info"] + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "default"] + + celery_worker_eval: + image: "${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG:-latest}" + container_name: celery-worker-eval + restart: always + build: + context: ./backend + depends_on: + backend: + condition: service_healthy + env_file: + - path: .env + required: true + - path: .env.secrets + required: false + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "evaluations", "--concurrency=${CELERY_EVAL_WORKER_CONCURRENCY:-2}"] diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index bbcece6bb..edc7ca7de 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -66,7 +66,7 @@ services: required: true - path: .env.secrets required: false - command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info"] + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "default"] logging: driver: awslogs options: @@ -74,3 +74,26 @@ services: awslogs-group: /ec2/kaapi-staging awslogs-stream: celery-worker awslogs-create-group: "true" + + celery_worker_eval: + image: "${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG:-latest}" + container_name: celery-worker-eval + restart: always + build: + context: ./backend + depends_on: + backend: + condition: service_healthy + env_file: + - path: .env + required: true + - path: .env.secrets + required: false + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "evaluations", "--concurrency=${CELERY_EVAL_WORKER_CONCURRENCY:-2}"] + logging: + driver: awslogs + options: + awslogs-region: ap-south-1 + awslogs-group: /ec2/kaapi-staging + awslogs-stream: celery-worker-eval + awslogs-create-group: "true" diff --git a/docker-compose.yml b/docker-compose.yml index 5d50e205b..5cdaf63d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -146,7 +146,23 @@ services: environment: REDIS_HOST: redis RABBITMQ_HOST: rabbitmq - command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info"] + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "default"] + + celery_worker_eval: + image: "${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG:-latest}" + container_name: celery-worker-eval + restart: always + build: + context: ./backend + depends_on: + backend: + condition: service_healthy + env_file: + - .env + environment: + REDIS_HOST: redis + RABBITMQ_HOST: rabbitmq + command: ["uv", "run", "celery", "-A", "app.celery.celery_app", "worker", "--loglevel=info", "-Q", "evaluations", "--concurrency=${CELERY_EVAL_WORKER_CONCURRENCY:-2}"] celery_flower: image: "${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG:-latest}" diff --git a/docs/wiki/services.md b/docs/wiki/services.md index 09e9c936b..06bf45492 100644 --- a/docs/wiki/services.md +++ b/docs/wiki/services.md @@ -10,7 +10,7 @@ FastAPI (backend/app/main.py, api/routes/*) │ ├── Postgres (SQLModel, core/db.py, alembic migrations) ├── Celery workers ── RabbitMQ (broker) + Redis (results) - │ └── app/celery/tasks/job_execution.py, priority queues + │ └── app/celery/tasks/job_execution.py, 2 queues (default, evaluations) + priority bands ├── Object storage core/cloud/storage.py ├── Langfuse core/langfuse/langfuse.py (traces, scores) ├── Sentry core/sentry_filters.py @@ -25,7 +25,7 @@ FastAPI (backend/app/main.py, api/routes/*) | Process | Entry | Notes | |---|---|---| | API server | `fastapi run app/main.py` | routes in `app/api/routes/` | -| Celery worker | `app/celery/` config | async jobs, priority queues | +| Celery worker | `app/celery/` config | `default` queue (most tasks) + dedicated `evaluations` queue/pool (fast-eval chunk/aggregate, isolated so eval bursts can't delay LLM jobs), priority bands within each | | Celery beat / cron | `app/api/routes/cron.py` + `crud/evaluations/cron.py` | batch polling for eval/assessment runs | ## Environment From eb616a19aa94b7af92c98a96f2f9eb2c9bed5c86 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 02/10] 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 99cee7875..8b9fc7342 100644 --- a/backend/app/api/docs/openapi_config.py +++ b/backend/app/api/docs/openapi_config.py @@ -27,6 +27,10 @@ "name": "LLM", "description": "Large Language Model inference and interaction endpoints", }, + { + "name": "Chatbot", + "description": "Prompt-driven conversational chatbot turns", + }, { "name": "Evaluation", "description": "Dataset upload, running evaluations, listing datasets as well as evaluations", @@ -89,6 +93,7 @@ "Collections", "Config Management", "LLM", + "Chatbot", "Evaluation", "Fine Tuning", "Model Evaluation", 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 caf23c32b..cbf5f98f6 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -60,6 +60,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 675933ab5..779da0eac 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -216,21 +216,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]] @@ -271,6 +270,7 @@ dependencies = [ { name = "indic-nlp-library" }, { name = "jinja2" }, { name = "jiwer" }, + { name = "langchain-anthropic" }, { name = "langfuse" }, { name = "langgraph" }, { name = "langgraph-checkpoint-postgres" }, @@ -343,6 +343,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" }, @@ -1584,6 +1585,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" @@ -1628,6 +1642,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" @@ -1668,11 +1708,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]] @@ -1904,11 +1944,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" }, @@ -1919,9 +1974,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]] @@ -4622,6 +4677,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 9bc299d34..f6fb3646b 100644 --- a/docs/wiki/INDEX.md +++ b/docs/wiki/INDEX.md @@ -21,6 +21,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 073d65540..643985f70 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 19d4923b4..eab9471f1 100644 --- a/docs/wiki/modules/platform.md +++ b/docs/wiki/modules/platform.md @@ -40,3 +40,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 5a65515ba7b67f8a54b84ed01681bcc5c07c0812 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:34:36 +0530 Subject: [PATCH 03/10] fix: update the global model config migration --- .../087_seed_anthropic_text_model_configs.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 backend/app/alembic/versions/087_seed_anthropic_text_model_configs.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..86e0aff00 --- /dev/null +++ b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py @@ -0,0 +1,60 @@ +"""seed anthropic text model_config rows + +Revision ID: 087 +Revises: 086 +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 = "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') + """ + ) From 6f2ae62aa29695eae8f4371de7f3f0a6a0da0c8a Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:54:22 +0530 Subject: [PATCH 04/10] fix: cleanups --- backend/app/api/routes/chatbot.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/backend/app/api/routes/chatbot.py b/backend/app/api/routes/chatbot.py index 454f4853b..fb10f91b7 100644 --- a/backend/app/api/routes/chatbot.py +++ b/backend/app/api/routes/chatbot.py @@ -1,5 +1,3 @@ -import logging - from fastapi import APIRouter, Depends, HTTPException from app.api.deps import AuthContextDep, SessionDep @@ -13,12 +11,9 @@ 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"), @@ -52,10 +47,6 @@ async def send_chatbot_message( 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] = [] From 1b4f9ee19675b120f4c6b41c4605cd1e2218dbfb Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:02:37 +0530 Subject: [PATCH 05/10] fix: remove the unwanted comments --- .../086_seed_anthropic_text_model_configs.py | 15 --------------- .../087_seed_anthropic_text_model_configs.py | 15 --------------- 2 files changed, 30 deletions(-) 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 index 915218568..0fbcf76d3 100644 --- a/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py +++ b/backend/app/alembic/versions/086_seed_anthropic_text_model_configs.py @@ -3,21 +3,6 @@ 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 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 index 86e0aff00..1d864a12f 100644 --- a/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py +++ b/backend/app/alembic/versions/087_seed_anthropic_text_model_configs.py @@ -3,21 +3,6 @@ Revision ID: 087 Revises: 086 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 From ef7202ca3994b0e8dc41cede67c24cdfcb5ec7b8 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:25:29 +0530 Subject: [PATCH 06/10] chore: cleanups --- backend/app/api/routes/chatbot/__init__.py | 6 ++ .../routes/{chatbot.py => chatbot/message.py} | 9 +- backend/app/models/chatbot.py | 1 - backend/app/services/chatbot/config.py | 31 +----- backend/app/services/chatbot/engine.py | 97 +------------------ backend/app/services/chatbot/exceptions.py | 6 ++ backend/app/services/chatbot/types.py | 10 ++ backend/app/services/chatbot/utils.py | 13 +-- 8 files changed, 31 insertions(+), 142 deletions(-) create mode 100644 backend/app/api/routes/chatbot/__init__.py rename backend/app/api/routes/{chatbot.py => chatbot/message.py} (91%) create mode 100644 backend/app/services/chatbot/exceptions.py create mode 100644 backend/app/services/chatbot/types.py diff --git a/backend/app/api/routes/chatbot/__init__.py b/backend/app/api/routes/chatbot/__init__.py new file mode 100644 index 000000000..d427b0256 --- /dev/null +++ b/backend/app/api/routes/chatbot/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from app.api.routes.chatbot.message import router as message_router + +router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) +router.include_router(message_router) diff --git a/backend/app/api/routes/chatbot.py b/backend/app/api/routes/chatbot/message.py similarity index 91% rename from backend/app/api/routes/chatbot.py rename to backend/app/api/routes/chatbot/message.py index fb10f91b7..9a5368258 100644 --- a/backend/app/api/routes/chatbot.py +++ b/backend/app/api/routes/chatbot/message.py @@ -3,15 +3,12 @@ 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.services.chatbot.engine import run_chatbot_turn +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError from app.utils import APIResponse, load_description -router = APIRouter(prefix="/chatbot", tags=["Chatbot"]) +router = APIRouter() @router.post( diff --git a/backend/app/models/chatbot.py b/backend/app/models/chatbot.py index c06a6540b..3797fe43e 100644 --- a/backend/app/models/chatbot.py +++ b/backend/app/models/chatbot.py @@ -34,7 +34,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).", diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 9f3d4fc0f..45553fbd5 100644 --- a/backend/app/services/chatbot/config.py +++ b/backend/app/services/chatbot/config.py @@ -10,7 +10,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 +19,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 +33,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 +46,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 +76,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 +85,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 +93,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 +109,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/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 From 6e162a009179c2e434b35b3e6fbf6c8963bbd624 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:32:42 +0530 Subject: [PATCH 07/10] fix: cleanups --- backend/app/services/chatbot/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/services/chatbot/config.py b/backend/app/services/chatbot/config.py index 45553fbd5..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 From 27767f8cf5ec82906cd0463b0d89faa821094763 Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Wed, 21 Oct 2026 13:04:30 +0530 Subject: [PATCH 08/10] feat(celery): raise fast-eval tasks to priority 9 on evaluations queue Top band of the dedicated queue; future eval-domain tasks take lower bands. Co-Authored-By: Claude Fable 5.1 --- .claude/conventions/celery.md | 2 +- backend/app/celery/tasks/job_execution.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.claude/conventions/celery.md b/.claude/conventions/celery.md index d7feaa6cb..f649a550c 100644 --- a/.claude/conventions/celery.md +++ b/.claude/conventions/celery.md @@ -48,7 +48,7 @@ the bands already in use (see the `job_execution.py` module docstring): | Priority | Queue | When | |---|---|---| | `9` | `default` | User-blocking, interactive — LLM call / chain / response jobs | -| `6` | `evaluations` | Fast evaluation chunk/aggregate | +| `9` | `evaluations` | Fast evaluation chunk/aggregate (top band of this queue; new eval tasks pick a lower band) | | `2` | `default` | Default — doctransform, collections, STT/TTS evaluation, assessment | | `1` | `default` | Notifications and other fire-and-forget background work | diff --git a/backend/app/celery/tasks/job_execution.py b/backend/app/celery/tasks/job_execution.py index 1421558ce..e8cf6bacb 100644 --- a/backend/app/celery/tasks/job_execution.py +++ b/backend/app/celery/tasks/job_execution.py @@ -10,7 +10,8 @@ 2 Everything else (doctransform, collections, STT/TTS evaluation, assessment) 1 Notifications (send_eval_completion_notification) -`evaluations` (priority 6): run_evaluation_fast_chunk and +`evaluations` (priority 9, top band of this queue; later eval tasks pick lower +bands): run_evaluation_fast_chunk and run_evaluation_fast_aggregate only. A fast eval fans out into many chunk tasks at once; priority only reorders messages still waiting in a queue, so on a shared queue that burst occupies every worker slot and delays priority-9 LLM @@ -441,7 +442,7 @@ def run_tts_result_processing( ) -@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=6) +@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=9) @gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_evaluation_fast_chunk") def run_evaluation_fast_chunk( self: Task, @@ -472,7 +473,7 @@ def run_evaluation_fast_chunk( ) -@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=6) +@celery_app.task(bind=True, queue=EVALUATIONS_QUEUE, priority=9) @gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_evaluation_fast_aggregate") def run_evaluation_fast_aggregate( self: Task, eval_run_id: int, trace_id: str = DEFAULT_TRACE_ID From aeb8401adc734f40660d032c2f459c98547700d0 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:31:34 +0530 Subject: [PATCH 09/10] fix: update the ecs deployment script --- .github/workflows/deploy-staging-ecs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-staging-ecs.yml b/.github/workflows/deploy-staging-ecs.yml index 028807d3e..319d34a0b 100644 --- a/.github/workflows/deploy-staging-ecs.yml +++ b/.github/workflows/deploy-staging-ecs.yml @@ -33,16 +33,16 @@ jobs: - name: Build and Push Docker Image env: REGISTRY: ${{ steps.login-ecr.outputs.registry }} - REPOSITORY: ${{ vars.AWS_RESOURCE_PREFIX }}-staging-repo + REPOSITORY: ${{ vars.AWS_RESOURCE_PREFIX }}-backend-repo run: | docker build --build-arg GIT_SHA=${{ github.sha }} -t $REGISTRY/$REPOSITORY:latest ./backend docker push $REGISTRY/$REPOSITORY:latest - name: Run database migrations env: - CLUSTER: ${{ vars.AWS_RESOURCE_PREFIX }}-staging-cluster - SERVICE: ${{ vars.AWS_RESOURCE_PREFIX }}-staging-service - TASK_DEF: ${{ vars.AWS_RESOURCE_PREFIX }}-staging-task + CLUSTER: ${{ vars.AWS_RESOURCE_PREFIX }}-backend-cluster + SERVICE: ${{ vars.AWS_RESOURCE_PREFIX }}-backend-celery-service + TASK_DEF: ${{ vars.AWS_RESOURCE_PREFIX }}-backend-celery run: | CONTAINER_NAME=$(aws ecs describe-task-definition --task-definition $TASK_DEF --query 'taskDefinition.containerDefinitions[0].name' --output text) From 0e722901ecafde9a2e57d6990d631545cd801044 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:09:26 +0530 Subject: [PATCH 10/10] fix: update the test cases --- .../app/tests/api/routes/chatbot/__init__.py | 0 .../tests/api/routes/chatbot/test_message.py | 65 ++++ backend/app/tests/crud/config/test_version.py | 54 ++++ .../app/tests/services/chatbot/__init__.py | 0 .../app/tests/services/chatbot/test_config.py | 185 +++++++++++ .../app/tests/services/chatbot/test_engine.py | 302 ++++++++++++++++++ .../app/tests/services/chatbot/test_utils.py | 65 ++++ backend/app/tests/utils/test_data.py | 13 + 8 files changed, 684 insertions(+) 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/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_utils.py 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..2bd2059b9 --- /dev/null +++ b/backend/app/tests/api/routes/chatbot/test_message.py @@ -0,0 +1,65 @@ +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 import engine +from app.tests.services.chatbot.test_engine import ScriptedChatModel +from app.tests.utils.auth import TestAuthContext +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(), + ) + + +def _patch_chat_model( + monkeypatch: pytest.MonkeyPatch, chat_model: ScriptedChatModel +) -> None: + # The Anthropic SDK is the external boundary; config resolution stays on the real DB. + monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model) + + +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?"}, + ] 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/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..94cc9928c --- /dev/null +++ b/backend/app/tests/services/chatbot/test_engine.py @@ -0,0 +1,302 @@ +from collections.abc import Sequence +from typing import Any + +import anthropic +import httpx +import pytest +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from pydantic import Field + +from app.services.chatbot import engine +from app.services.chatbot.engine import run_chatbot_turn +from app.services.chatbot.exceptions import ChatbotInputError, ChatbotProviderError +from app.services.chatbot.utils import ChatbotModelSettings + +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") + + +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: + monkeypatch.setattr(engine, "get_chat_model", lambda _settings: chat_model) + + +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_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/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,