diff --git a/.dockerignore b/.dockerignore index d25f099d..635b6fa2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -36,6 +36,7 @@ venv/ ENV/ env.bak/ venv.bak/ +myenv/ # IDE .vscode/ diff --git a/.env.gui b/.env.gui new file mode 100644 index 00000000..3d4b260e --- /dev/null +++ b/.env.gui @@ -0,0 +1,4 @@ +RELEASE=gui +VERSION=1 +BUILD=1 +FIX=0 diff --git a/.env.llm_orchestration_service b/.env.llm_orchestration_service new file mode 100644 index 00000000..5700e71c --- /dev/null +++ b/.env.llm_orchestration_service @@ -0,0 +1,4 @@ +RELEASE=orchestration +VERSION=1 +BUILD=1 +FIX=0 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..f71218be --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,304 @@ +# BYK-RAG Module - Copilot Instructions + +## Project Overview + +BYK-RAG is a Retrieval-Augmented Generation module for Estonian government digital services (Bürokratt ecosystem). It provides secure, multilingual AI-powered responses by integrating multiple LLM providers, contextual retrieval, and guardrails. + +## Build, Test, and Lint Commands + +### Environment Setup +```bash +# Install Python 3.12.10 and create virtual environment +uv python install 3.12.10 +uv sync --frozen + +# Install pre-commit hooks +uv run pre-commit install +``` + +### Running Services +```bash +# Always use uv run for Python scripts (whether venv is activated or not) +uv run python + +# Start all services with Docker Compose +docker compose up + +# Run FastAPI orchestration service locally +uv run uvicorn src.llm_orchestration_service_api:app --reload +``` + +### Testing +```bash +# Run all tests +uv run pytest + +# Run specific test file +uv run pytest tests/test_query_validator.py -v + +# Run integration tests (requires Docker and secrets) +uv run pytest tests/integration_tests/ -v --tb=short --log-cli-level=INFO + +# Run deepeval tests +uv run pytest tests/deepeval_tests/standard_tests.py -v --tb=short +``` + +### Linting and Formatting +```bash +# Check code formatting (does NOT modify files) +uv run ruff format --check + +# Apply code formatting (SAFE - layout only, no logic changes) +uv run ruff format + +# Check linting issues (manual fixes required) +uv run ruff check . + +# Get explanation for specific lint rule +uv run ruff rule # e.g., ANN204 + +# NEVER use ruff check --fix (can alter logic/control flow) +``` + +### Type Checking +```bash +# Run Pyright type checker (runs on src/ only, not tests/) +uv run pyright +``` + +### Pre-commit Hooks +```bash +# Run all pre-commit hooks manually +uv run pre-commit run --all-files +``` + +## Architecture + +### Core Components + +1. **LLM Orchestration Service** (`src/llm_orchestration_service.py`) + - Central business logic for RAG orchestration + - Coordinates prompt refinement, retrieval, generation, and guardrails + - Integrates with Langfuse for observability + +2. **FastAPI Application** (`src/llm_orchestration_service_api.py`) + - HTTP API layer exposing `/orchestrate` endpoint + - Handles streaming responses and rate limiting + - Request/response validation via Pydantic models + +3. **Contextual Retrieval** (`src/contextual_retrieval/`) + - Implements Anthropic's Contextual Retrieval methodology + - Hybrid search: Vector (semantic) + BM25 (lexical) with RRF fusion + - Multi-query expansion (6 refined queries per user query) + - Qdrant vector database integration + +4. **Prompt Refinement** (`src/prompt_refine_manager/`) + - DSPy-based query expansion + - Generates 5 refined variations + original query + +5. **Response Generation** (`src/response_generator/`) + - DSPy-based response synthesis + - Supports streaming via SSE (Server-Sent Events) + - Uses top-K retrieved chunks (default: 10) + +6. **Guardrails** (`src/guardrails/`) + - NeMo Guardrails integration with DSPy + - Input guardrails (pre-refinement) and output guardrails (post-generation) + - Blocks out-of-scope queries and harmful content + +7. **LLM Manager** (`src/llm_orchestrator_config/llm_manager.py`) + - Multi-provider support: AWS Bedrock, Azure OpenAI, Google Cloud, OpenAI, Anthropic + - HashiCorp Vault integration for secret management + - RSA-2048 encrypted credentials storage + +8. **Vector Indexer** (`src/vector_indexer/`) + - Qdrant collection management + - Embedding generation and indexing + - BM25 index creation + +### Supporting Services (Docker Compose) + +- **Ruuter** (Public/Private): API gateway and routing +- **DataMapper**: Data transformation layer +- **Resql**: PostgreSQL query builder +- **CronManager**: Scheduled jobs (knowledge base sync) +- **Qdrant**: Vector database +- **MinIO**: S3-compatible object storage +- **HashiCorp Vault**: Secret management +- **Grafana Loki**: Log aggregation +- **Langfuse**: LLM observability dashboard + +### Key Data Flow + +``` +User Query + ↓ +Input Guardrails (NeMo Rails) + ↓ +Prompt Refinement (DSPy) → 6 queries + ↓ +Parallel Hybrid Search (each query) + ├─→ Semantic Search (Qdrant, top-40 per query, threshold ≥0.4) + └─→ BM25 Search (top-40 per query) + ↓ +RRF Fusion → Top-K chunks (10 default) + ↓ +Response Generation (DSPy) + ↓ +Output Guardrails (NeMo Rails) + ↓ +Response to User (JSON or SSE stream) +``` + +## Key Conventions + +### Dependency Management + +- **ALWAYS use `uv add `** to add dependencies (never `pip install`) +- **ALWAYS commit both `pyproject.toml` AND `uv.lock`** together +- Use bounded version ranges: `uv add "package>=x.y,` for explanations +- Autofixes can alter control flow/logic unintentionally + +### Formatting (Ruff Formatter) + +- Double quotes for strings +- Spaces for indentation (4 spaces) +- Respects magic trailing commas +- Auto-detects line endings (LF/CRLF) +- Does NOT reformat docstring code blocks +- `uv run ruff format` is SAFE (layout only, no logic changes) + +### DSPy Usage + +- Used for prompt refinement (multi-query expansion) and response generation +- Custom LLM adapters integrate DSPy with NeMo Guardrails +- Optimization modules under `src/optimization/` for tuning prompts/metrics +- Models loaded via `optimized_module_loader.py` for compiled DSPy modules + +### HashiCorp Vault Integration + +- Secrets stored at `secret/users///` +- Each connection has `provider`, `environment`, and provider-specific keys +- RSA-2048 encryption layer BEFORE Vault storage +- GUI encrypts with public key; CronManager decrypts with private key +- Vault unavailable = graceful degradation (fail securely) + +### Logging + +- **loguru** for application logging +- Grafana Loki integration for centralized logs +- Use `logger.info()`, `logger.warning()`, `logger.error()` (NOT `print()`) +- Loki logger available at `grafana-configs/loki_logger.py` + +### Streaming Responses + +- Implemented via Server-Sent Events (SSE) in FastAPI +- `StreamConfig` and `stream_manager` coordinate streaming state +- `stream_response_native()` in response_generator yields tokens +- Timeout handling via `stream_timeout` utility +- Environment-gated: check `STREAMING_ALLOWED_ENVS` + +### Configuration Loading + +- `PromptConfigurationLoader` fetches prompt configs from Ruuter endpoint +- Cache TTL: `PROMPT_CONFIG_CACHE_TTL` +- Custom prompts per user/organization (stored in Vault/database) +- Fallback to defaults if Ruuter unavailable + +### Error Handling + +- `generate_error_id()` creates unique error IDs for tracking +- `log_error_with_context()` for structured error logging +- Localized error messages via `get_localized_message()` (multilingual support) +- Predefined message constants in `llm_orchestrator_constants.py` + +### Testing Conventions + +- Test files under `tests/` (unit, integration, deepeval) +- Integration tests use `testcontainers` for Docker orchestration +- Secrets required for integration tests (Azure OpenAI keys, etc.) +- Mock data in `tests/mocks/` and `tests/data/` + +### CI/CD Checks + +1. **uv-env-check**: Lockfile vs. pyproject.toml consistency +2. **pyright-type-check**: Type checking on src/ (strict mode) +3. **ruff-format-check**: Code formatting compliance +4. **ruff-lint-check**: Linting standards +5. **pytest-integration-check**: Full integration tests (requires secrets) +6. **deepeval-tests**: LLM evaluation metrics +7. **gitleaks-check**: Secret detection (pre-commit + CI) + +### Pre-commit Hooks + +Configured in `.pre-commit-config.yaml`: +- **gitleaks**: Secret scanning +- **uv-lock**: Ensures lockfile consistency + +### Constants and Thresholds + +Key retrieval constants (`src/vector_indexer/constants.py` and contextual retrieval): +- **Semantic search top-K**: 40 per query +- **Semantic threshold**: 0.4 (cosine similarity ≥0.4 = 50-60% alignment) +- **BM25 top-K**: 40 per query +- **Response generation top-K**: 10 chunks (after RRF fusion) +- **Query refinement count**: 5 variations + original = 6 total +- **Search timeout**: 2 seconds per query + +### Docker and Services + +- Use `docker compose` (not `docker-compose`) +- Services communicate via `bykstack` network +- Shared volumes: `shared-volume`, `cron_data` +- Vault agent containers per service (llm, gui, cron) +- Resource limits: CPU and memory constraints defined in docker-compose.yml + +## Important Notes + +- **Python version pinned to 3.12.10** (see `pyproject.toml` and `.python-version`) +- **Line length: 88** (Black-compatible, enforced by Ruff) +- **No print() statements** in production code (use loguru logger) +- **Pydantic for runtime validation** at API boundaries (FastAPI endpoints) +- **Langfuse tracing** for observability (public/secret keys from Vault) +- **Rate limiting** via `RateLimiter` utility (token and request budgets) +- **Cost tracking** via `calculate_total_costs()` and budget tracker +- **Language detection** for multilingual support (Estonian primary) diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md new file mode 100644 index 00000000..b4e54798 --- /dev/null +++ b/.github/skills/code-review/SKILL.md @@ -0,0 +1,4 @@ +--- +name: code-review +description: Make sure all Python coding standards in the pyproject.toml file are followed, and that the code is clean, well-structured, maintainable, and efficient. Provide constructive feedback and suggestions for improvement. +--- diff --git a/.github/workflows/ci-build-image-llm-orchestration-service.yml b/.github/workflows/ci-build-image-llm-orchestration-service.yml new file mode 100644 index 00000000..77cf95c2 --- /dev/null +++ b/.github/workflows/ci-build-image-llm-orchestration-service.yml @@ -0,0 +1,42 @@ +name: Build and publish llm_orchestration_service + +on: + push: + branches: + - wip + paths: + - '.env.llm_orchestration_service' + +jobs: + PackageDeploy: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v2 + + - name: Docker Setup BuildX + uses: docker/setup-buildx-action@v2 + + - name: Load environment variables and set them + run: | + if [ -f .env.llm_orchestration_service ]; then + export $(cat .env.llm_orchestration_service | grep -v '^#' | xargs) + fi + echo "RELEASE=$RELEASE" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "BUILD=$BUILD" >> $GITHUB_ENV + echo "FIX=$FIX" >> $GITHUB_ENV + - name: Set repo + run: | + LOWER_CASE_GITHUB_REPOSITORY=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]') + echo "DOCKER_TAG_CUSTOM=ghcr.io/${LOWER_CASE_GITHUB_REPOSITORY}:$RELEASE-$VERSION.$BUILD.$FIX" >> $GITHUB_ENV + echo "$GITHUB_ENV" + - name: Docker Build + run: | + docker image build --tag $DOCKER_TAG_CUSTOM -f Dockerfile.llm_orchestration_service . + + - name: Log in to GitHub container registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + + - name: Push Docker image to ghcr + run: docker push $DOCKER_TAG_CUSTOM diff --git a/.github/workflows/ci-build-image.yml b/.github/workflows/ci-build-image.yml new file mode 100644 index 00000000..b8588cbf --- /dev/null +++ b/.github/workflows/ci-build-image.yml @@ -0,0 +1,43 @@ +name: Build and publish GUI + +on: + push: + branches: + - wip + paths: + - '.env.gui' + +jobs: + PackageDeploy: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v2 + + - name: Docker Setup BuildX + uses: docker/setup-buildx-action@v2 + + - name: Load environment variables and set them + run: | + if [ -f .env.gui ]; then + export $(cat .env.gui | grep -v '^#' | xargs) + fi + echo "RELEASE=$RELEASE" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "BUILD=$BUILD" >> $GITHUB_ENV + echo "FIX=$FIX" >> $GITHUB_ENV + - name: Set repo + run: | + LOWER_CASE_GITHUB_REPOSITORY=$(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]') + echo "DOCKER_TAG_CUSTOM=ghcr.io/${LOWER_CASE_GITHUB_REPOSITORY}:$RELEASE-$VERSION.$BUILD.$FIX" >> $GITHUB_ENV + echo "$GITHUB_ENV" + - name: Docker Build + run: | + cd GUI + docker image build --tag $DOCKER_TAG_CUSTOM -f Dockerfile.dev . + + - name: Log in to GitHub container registry + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u $ --password-stdin + + - name: Push Docker image to ghcr + run: docker push $DOCKER_TAG_CUSTOM diff --git a/.github/workflows/helm-dependency.yaml b/.github/workflows/helm-dependency.yaml new file mode 100644 index 00000000..92de5c51 --- /dev/null +++ b/.github/workflows/helm-dependency.yaml @@ -0,0 +1,42 @@ +name: Helm Dependency Build + +on: + push: + branches: + - dev + - main + paths: + - 'kubernetes/**' + +jobs: + build-dependencies: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + persist-credentials: true + + - name: Set up Helm + uses: azure/setup-helm@v3 + with: + version: v3.12.0 + + - name: Build Helm dependencies + working-directory: ./kubernetes + run: | + rm -f Chart.lock + helm dependency build + + - name: Commit and push if dependencies updated + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add kubernetes/Chart.lock kubernetes/charts/ + if ! git diff --cached --quiet; then + git commit -m "chore: update Helm dependencies (charts/ and Chart.lock)" + git push + else + echo "No changes to Helm dependencies." + fi diff --git a/.gitignore b/.gitignore index d0dc8cb8..b1f19784 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ datasets logs/ data_sets vault/agent-out +.vscode/ + +# RSA Private Keys - DO NOT COMMIT +vault/keys/rsa_private_key.pem +vault/keys/*.pem.old # RSA Private Keys - DO NOT COMMIT vault/keys/rsa_private_key.pem @@ -17,4 +22,4 @@ vault/keys/*.pem.old # Snyk Security Extension - AI Rules (auto-generated) .github/instructions/snyk_rules.instructions.md # Dynamically created Ruuter health endpoint for tests -DSL/Ruuter.private/rag-search/GET/health.yml +DSL/Ruuter.private/rag-search/GET/health.yml \ No newline at end of file diff --git a/DSL/CronManager/DSL/api_tool_indexer.yml b/DSL/CronManager/DSL/api_tool_indexer.yml new file mode 100644 index 00000000..1db95ad9 --- /dev/null +++ b/DSL/CronManager/DSL/api_tool_indexer.yml @@ -0,0 +1,5 @@ +index_endpoint: + trigger: off + type: exec + command: "/app/scripts/api_tool_indexer.sh" + allowedEnvs: ['endpoint_id', 'service_id', 'name', 'description', 'method', 'url', 'visibility', 'type', 'params'] \ No newline at end of file diff --git a/DSL/CronManager/DSL/service_enrichment.yml b/DSL/CronManager/DSL/service_enrichment.yml new file mode 100644 index 00000000..b422dfc8 --- /dev/null +++ b/DSL/CronManager/DSL/service_enrichment.yml @@ -0,0 +1,5 @@ +enrich_and_index: + trigger: off + type: exec + command: "/app/scripts/service_enrichment.sh" + allowedEnvs: ['service_id', 'name', 'description', 'examples', 'entities', 'ruuter_type', 'current_state', 'is_common'] diff --git a/DSL/CronManager/script/api_tool_indexer.sh b/DSL/CronManager/script/api_tool_indexer.sh new file mode 100644 index 00000000..ed1a27e2 --- /dev/null +++ b/DSL/CronManager/script/api_tool_indexer.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +echo "Starting API Tool Indexer pipeline..." + +# Validate required environment variables +if [ -z "$endpoint_id" ] || [ -z "$name" ] || [ -z "$description" ] || [ -z "$url" ]; then + echo "[ERROR] Missing required environment variables: endpoint_id, name, description, or url" + exit 1 +fi + +PYTHON_SCRIPT="/app/src/api_tool_indexer/main_indexer.py" + +echo "[INFO] Endpoint ID: $endpoint_id" +echo "[INFO] Endpoint Name: $name" + +# Install uv if not found +UV_BIN="/root/.local/bin/uv" +if [ ! -f "$UV_BIN" ]; then + echo "[UV] Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh || { + echo "[ERROR] Failed to install uv" + exit 1 + } +fi + +# Activate Python virtual environment +VENV_PATH="/app/python_virtual_env" +echo "[VENV] Activating virtual environment at: $VENV_PATH" +source "$VENV_PATH/bin/activate" || { + echo "[ERROR] Failed to activate virtual environment" + exit 1 +} + +# Install required packages +echo "[PACKAGES] Installing required packages..." +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "httpx>=0.27.0" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "pydantic>=2.11.7" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "qdrant-client>=1.15.1" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "loguru>=0.7.3" || exit 1 + +echo "[PACKAGES] All packages installed successfully" + +# Set Python path +export PYTHONPATH="/app:/app/src:/app/src/api_tool_indexer:$PYTHONPATH" + +# Verify Python script exists +[ ! -f "$PYTHON_SCRIPT" ] && { echo "[ERROR] Python script not found at $PYTHON_SCRIPT"; exit 1; } + +echo "[FOUND] Python script at: $PYTHON_SCRIPT" + +# Run indexing script with arguments +echo "[STARTING] API Tool indexing processing..." + +# URL decode function using Python +url_decode() { + python3 -c "import sys; from urllib.parse import unquote; print(unquote(sys.argv[1]))" "$1" +} + +# Write JSON arrays to temporary files to avoid bash parsing issues +TEMP_DIR=$(mktemp -d) +PARAMS_FILE="$TEMP_DIR/params.json" + +if [ -n "$params" ]; then + url_decode "$params" > "$PARAMS_FILE" +fi + +# Build Python command arguments array +PYTHON_ARGS=( + "$PYTHON_SCRIPT" + --endpoint-id "$endpoint_id" + --service-id "${service_id:-""}" + --name "$name" + --description "$description" + --url "$url" + --method "${method:-"GET"}" + --visibility "${visibility:-"public"}" + --type "${type:-"custom_endpoint"}" +) + +# Add params file if provided +[ -n "$params" ] && PYTHON_ARGS+=(--params-file "$PARAMS_FILE") + +# Execute Python script +python3 -u "${PYTHON_ARGS[@]}" 2>&1 +PYTHON_EXIT_CODE=$? + +# Cleanup temporary files +rm -rf "$TEMP_DIR" + +# Handle exit codes +if [ $PYTHON_EXIT_CODE -eq 0 ]; then + echo "[SUCCESS] API Tool indexing completed successfully" + exit 0 +else + echo "[ERROR] API Tool indexing failed with exit code: $PYTHON_EXIT_CODE" + exit $PYTHON_EXIT_CODE +fi \ No newline at end of file diff --git a/DSL/CronManager/script/service_enrichment.sh b/DSL/CronManager/script/service_enrichment.sh new file mode 100644 index 00000000..c50a490a --- /dev/null +++ b/DSL/CronManager/script/service_enrichment.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +echo "Starting service data enrichment pipeline..." + +# Validate required environment variables +if [ -z "$service_id" ] || [ -z "$name" ] || [ -z "$description" ]; then + echo "[ERROR] Missing required environment variables: service_id, name, or description" + exit 1 +fi + +PYTHON_SCRIPT="/app/src/intent_data_enrichment/main_enrichment.py" + +echo "[INFO] Service ID: $service_id" +echo "[INFO] Service Name: $name" + +# Install uv if not found +UV_BIN="/root/.local/bin/uv" +if [ ! -f "$UV_BIN" ]; then + echo "[UV] Installing uv..." + curl -LsSf https://astral.sh/uv/install.sh | sh || { + echo "[ERROR] Failed to install uv" + exit 1 + } +fi + +# Activate Python virtual environment +VENV_PATH="/app/python_virtual_env" +echo "[VENV] Activating virtual environment at: $VENV_PATH" +source "$VENV_PATH/bin/activate" || { + echo "[ERROR] Failed to activate virtual environment" + exit 1 +} + +# Install required packages (minimal for Phase 1) +echo "[PACKAGES] Installing required packages..." + +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "httpx>=0.27.0" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "pydantic>=2.11.7" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "qdrant-client>=1.15.1" || exit 1 +"$UV_BIN" pip install --python "$VENV_PATH/bin/python3" "loguru>=0.7.3" || exit 1 + +echo "[PACKAGES] All packages installed successfully" + +# Set Python path +export PYTHONPATH="/app:/app/src:/app/src/intent_data_enrichment:$PYTHONPATH" + +# Verify Python script exists +[ ! -f "$PYTHON_SCRIPT" ] && { echo "[ERROR] Python script not found at $PYTHON_SCRIPT"; exit 1; } + +echo "[FOUND] Python script at: $PYTHON_SCRIPT" + +# Run enrichment script with arguments +echo "[STARTING] Service enrichment processing..." + +# URL decode function using Python +url_decode() { + python3 -c "import sys; from urllib.parse import unquote; print(unquote(sys.argv[1]))" "$1" +} + +# Write JSON arrays to temporary files to avoid bash parsing issues +# Arrays are URL-encoded from Ruuter, need to decode them +TEMP_DIR=$(mktemp -d) +EXAMPLES_FILE="$TEMP_DIR/examples.json" +ENTITIES_FILE="$TEMP_DIR/entities.json" + +if [ -n "$examples" ]; then + url_decode "$examples" > "$EXAMPLES_FILE" +fi + +if [ -n "$entities" ]; then + url_decode "$entities" > "$ENTITIES_FILE" +fi + +# Build Python command arguments array +PYTHON_ARGS=( + "$PYTHON_SCRIPT" + --service-id "$service_id" + --name "$name" + --description "$description" +) + +# Add optional fields +[ -n "$ruuter_type" ] && PYTHON_ARGS+=(--ruuter-type "$ruuter_type") +[ -n "$current_state" ] && PYTHON_ARGS+=(--current-state "$current_state") +[ -n "$is_common" ] && PYTHON_ARGS+=(--is-common "$is_common") +[ -n "$examples" ] && PYTHON_ARGS+=(--examples-file "$EXAMPLES_FILE") +[ -n "$entities" ] && PYTHON_ARGS+=(--entities-file "$ENTITIES_FILE") + +# Execute Python script directly (no eval to avoid parsing issues) +python3 -u "${PYTHON_ARGS[@]}" 2>&1 +PYTHON_EXIT_CODE=$? + +# Cleanup temporary files +rm -rf "$TEMP_DIR" + +# Handle exit codes +if [ $PYTHON_EXIT_CODE -eq 0 ]; then + echo "[SUCCESS] Service enrichment completed successfully" + exit 0 +else + echo "[ERROR] Service enrichment failed with exit code: $PYTHON_EXIT_CODE" + exit $PYTHON_EXIT_CODE +fi diff --git a/DSL/DMapper/rag-search/hbs/bot_responses_to_messages.handlebars b/DSL/DMapper/rag-search/hbs/bot_responses_to_messages.handlebars new file mode 100644 index 00000000..aa023019 --- /dev/null +++ b/DSL/DMapper/rag-search/hbs/bot_responses_to_messages.handlebars @@ -0,0 +1,14 @@ +[ +{{#each data.botMessages}} + { + "chatId": "{{../data.chatId}}", + "content": "{{filterControlCharacters result}}", + "buttons": "[{{#each ../data.buttons}}{\"title\": \"{{#if (eq title true)}}Yes{{else if (eq title false)}}No{{else}}{{{title}}}{{/if}}\",\"payload\": \"{{{payload}}}\"}{{#unless @last}},{{/unless}}{{/each}}]", + "authorTimestamp": "{{../data.authorTimestamp}}", + "authorId": "{{../data.authorId}}", + "authorFirstName": "{{../data.authorFirstName}}", + "authorLastName": "{{../data.authorLastName}}", + "created": "{{../data.created}}" + }{{#unless @last}},{{/unless}} +{{/each}} +] diff --git a/DSL/DMapper/rag-search/lib/helpers.js b/DSL/DMapper/rag-search/lib/helpers.js index 6f5e74f9..7ecbb7c8 100644 --- a/DSL/DMapper/rag-search/lib/helpers.js +++ b/DSL/DMapper/rag-search/lib/helpers.js @@ -168,6 +168,11 @@ export function getAgencyDataAvailable(agencyId) { return (combinedValue % 2) === 0; } +export function filterControlCharacters(str) { + if (typeof str !== "string") return str; + return str.replace(/[\x00-\x1F\x7F]/g, " "); +} + export function json(context) { return JSON.stringify(context); } @@ -269,3 +274,27 @@ export function filterDataByAgency(aggregatedData, startIndex, agencyId, pageSiz return JSON.stringify(result); } + +export function calculateDateDifference(value) { + const { startDate, endDate, outputType } = value; + const sDate = new Date(startDate); + const eDate = new Date(endDate); + const timeDifferenceInSeconds = (eDate.getTime() - sDate.getTime()) / 1000; + + switch (outputType?.toLowerCase()) { + case 'years': + return eDate.getFullYear() - sDate.getFullYear(); + case 'months': + return eDate.getMonth() - sDate.getMonth() + + (12 * (eDate.getFullYear() - sDate.getFullYear())) + case 'hours': + return Math.round(Math.abs(eDate - sDate) / 36e5); + case 'minutes': + return Math.floor(timeDifferenceInSeconds / 60); + case 'seconds': + return timeDifferenceInSeconds; + default: + return Math.round(timeDifferenceInSeconds / (3600 * 24)); + } +} + diff --git a/DSL/Liquibase/changelog/rag-search-script-v1-llm-connections.sql b/DSL/Liquibase/changelog/rag-search-script-v1-llm-connections.sql index 24ce3568..2f901839 100644 --- a/DSL/Liquibase/changelog/rag-search-script-v1-llm-connections.sql +++ b/DSL/Liquibase/changelog/rag-search-script-v1-llm-connections.sql @@ -124,19 +124,12 @@ CREATE INDEX idx_llm_models_platform_id ON llm_models(platform_id); CREATE INDEX idx_embedding_models_platform_id ON embedding_models(platform_id); CREATE TABLE public.agency_sync ( - agency_id VARCHAR(50) PRIMARY KEY, + id VARCHAR(50) PRIMARY KEY, agency_data_hash VARCHAR(255), data_url TEXT, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ); -INSERT INTO public.agency_sync (agency_id, created_at) VALUES -('AGENCY001', NOW()); - -CREATE TABLE public.mock_ckb ( - client_id VARCHAR(50) PRIMARY KEY, - client_data_hash VARCHAR(255) NOT NULL, - signed_s3_url TEXT NOT NULL, - created_at TIMESTAMP NOT NULL DEFAULT NOW() -); \ No newline at end of file +INSERT INTO public.agency_sync (id, created_at) VALUES +('1', NOW()); \ No newline at end of file diff --git a/DSL/Liquibase/changelog/rag-search-script-v5-prompt-config.sql b/DSL/Liquibase/changelog/rag-search-script-v5-prompt-config.sql new file mode 100644 index 00000000..8d29f949 --- /dev/null +++ b/DSL/Liquibase/changelog/rag-search-script-v5-prompt-config.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +-- changeset Erangi Ariyasena:rag-script-v5-changeset1 +CREATE TABLE public.prompt_configuration ( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + prompt TEXT +); + diff --git a/DSL/Liquibase/changelog/rag-search-script-v6-endpoints.sql b/DSL/Liquibase/changelog/rag-search-script-v6-endpoints.sql new file mode 100644 index 00000000..b84d5278 --- /dev/null +++ b/DSL/Liquibase/changelog/rag-search-script-v6-endpoints.sql @@ -0,0 +1,67 @@ +-- liquibase formatted sql + +-- changeset Ruwini:rag-script-v6-changeset1 +CREATE TABLE public.mock_endpoints ( + endpoint_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service_id UUID, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + type VARCHAR(50) DEFAULT 'custom_endpoint', + visibility VARCHAR(20) DEFAULT 'private', + method VARCHAR(10) NOT NULL, + url TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_mock_endpoints_service_id ON public.mock_endpoints(service_id); +CREATE INDEX idx_mock_endpoints_visibility ON public.mock_endpoints(visibility); + +-- changeset Ruwini:rag-script-v6-changeset2 +-- Seed data: 3 test endpoints for development and testing + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_public_holidays', + 'Get public holidays for a specific country within a date range', + 'custom_endpoint', + 'public', + 'GET', + 'https://openholidaysapi.org/PublicHolidays', + '[ + {"name": "countryIsoCode", "type": "string", "required": true, "description": "ISO 3166-1 alpha-2 country code (e.g. EE for Estonia, DE for Germany)"}, + {"name": "languageIsoCode", "type": "string", "required": false, "description": "ISO language code for the response language (e.g. EE for Estonian, EN for English)"}, + {"name": "validFrom", "type": "date", "required": true, "description": "Start date for holiday lookup in YYYY-MM-DD format"}, + {"name": "validTo", "type": "date", "required": true, "description": "End date for holiday lookup in YYYY-MM-DD format"} + ]'::jsonb +); + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_current_weather', + 'Get the current weather conditions for a given city', + 'custom_endpoint', + 'public', + 'GET', + 'https://wttr.in', + '[ + {"name": "city", "type": "string", "required": true, "description": "Name of the city to get weather for (e.g. Tallinn, London, Berlin)"}, + {"name": "format", "type": "string", "required": false, "description": "Response format: j1 for JSON, 1 for one-line summary (default: j1)"} + ]'::jsonb +); + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_exchange_rate', + 'Get the latest currency exchange rate between two currencies', + 'custom_endpoint', + 'public', + 'GET', + 'https://api.frankfurter.app/latest', + '[ + {"name": "from", "type": "string", "required": true, "description": "The base currency code to convert from (e.g. EUR, USD, GBP)"}, + {"name": "to", "type": "string", "required": true, "description": "The target currency code to convert to (e.g. USD, EUR, JPY)"}, + {"name": "amount", "type": "number", "required": false, "description": "The amount to convert (default: 1)"} + ]'::jsonb +); diff --git a/DSL/Liquibase/changelog/rag-search-script-v7-schema-migration.sql b/DSL/Liquibase/changelog/rag-search-script-v7-schema-migration.sql new file mode 100644 index 00000000..5255675e --- /dev/null +++ b/DSL/Liquibase/changelog/rag-search-script-v7-schema-migration.sql @@ -0,0 +1,57 @@ +-- liquibase formatted sql + +-- changeset rag-schema-migration:rag-script-v7-changeset1 +CREATE SCHEMA IF NOT EXISTS rag_search; +GRANT USAGE ON SCHEMA rag_search TO PUBLIC; +-- rollback DROP SCHEMA IF EXISTS rag_search; + +-- changeset rag-schema-migration:rag-script-v7-changeset2 +-- Move RAG module tables from public schema to rag_search schema + +-- v1: LLM connection and inference tables +ALTER TABLE IF EXISTS public.llm_connections SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.inference_results SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.inference_results_references SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.llm_platforms SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.llm_models SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.embedding_platforms SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.embedding_models SET SCHEMA rag_search; + +-- v2: User management tables +ALTER TABLE IF EXISTS public."user" SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.authority SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.user_authority SET SCHEMA rag_search; + +-- v3: Configuration table +ALTER TABLE IF EXISTS public.configuration SET SCHEMA rag_search; + +-- v5: Prompt configuration table +ALTER TABLE IF EXISTS public.prompt_configuration SET SCHEMA rag_search; + +-- v6: Endpoints table +ALTER TABLE IF EXISTS public.mock_endpoints SET SCHEMA rag_search; + +-- v1: Agency sync and mock CKB tables +ALTER TABLE IF EXISTS public.agency_sync SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.mock_ckb SET SCHEMA rag_search; + +-- Grant permissions on all tables and sequences in rag_search schema +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA rag_search TO PUBLIC; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA rag_search TO PUBLIC; + +-- rollback ALTER TABLE IF EXISTS rag_search.llm_connections SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.inference_results SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.inference_results_references SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.llm_platforms SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.llm_models SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.embedding_platforms SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.embedding_models SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search."user" SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.authority SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.user_authority SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.configuration SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.prompt_configuration SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.mock_endpoints SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.agency_sync SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.mock_ckb SET SCHEMA public; +-- rollback DROP SCHEMA IF EXISTS rag_search; diff --git a/DSL/Liquibase/langfuse-init/init-langfuse.sql b/DSL/Liquibase/langfuse-init/init-langfuse.sql new file mode 100644 index 00000000..25a815f1 --- /dev/null +++ b/DSL/Liquibase/langfuse-init/init-langfuse.sql @@ -0,0 +1,4 @@ +SELECT 'CREATE DATABASE "langfuse-db"' +WHERE NOT EXISTS ( + SELECT FROM pg_catalog.pg_database WHERE datname = 'langfuse-db' +)\gexec diff --git a/DSL/Liquibase/master.yml b/DSL/Liquibase/master.yml index a1c31eb1..89474bf9 100644 --- a/DSL/Liquibase/master.yml +++ b/DSL/Liquibase/master.yml @@ -6,4 +6,10 @@ databaseChangeLog: - include: file: changelog/rag-search-script-v3-configuration.sql - include: - file: changelog/rag-search-script-v4-authority-data.xml \ No newline at end of file + file: changelog/rag-search-script-v4-authority-data.xml + - include: + file: changelog/rag-search-script-v5-prompt-config.sql + - include: + file: changelog/rag-search-script-v6-endpoints.sql + - include: + file: changelog/rag-search-script-v7-schema-migration.sql \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog.yaml b/DSL/Liquibase_production/changelog.yaml new file mode 100644 index 00000000..8e0d64de --- /dev/null +++ b/DSL/Liquibase_production/changelog.yaml @@ -0,0 +1,4 @@ +databaseChangeLog: + - includeAll: + path: changelog/ + errorIfMissingOrEmpty: true diff --git a/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.sql b/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.sql new file mode 100644 index 00000000..32b1d490 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.sql @@ -0,0 +1,82 @@ +-- liquibase formatted sql +-- changeset Artsiom Beida:20250424191943 ignore:true +CREATE OR REPLACE FUNCTION copy_row_with_modifications( + table_name_to_copy_from VARCHAR, + id_column_name VARCHAR, + id_column_conversion_expression VARCHAR, + id_to_copy VARCHAR, + modifications VARCHAR[] +) RETURNS VARCHAR LANGUAGE plpgsql AS ' +DECLARE + columns VARCHAR []; + to_select VARCHAR [] := (ARRAY [])::VARCHAR[]; + sql_query VARCHAR; + inserted_id VARCHAR; + modification_columns VARCHAR[] := (ARRAY [])::VARCHAR[]; + schema_name VARCHAR; + table_name_only VARCHAR; +BEGIN + IF position(''.'' in table_name_to_copy_from) > 0 THEN + schema_name := split_part(table_name_to_copy_from, ''.'', 1); + table_name_only := split_part(table_name_to_copy_from, ''.'', 2); + ELSE + schema_name := ''public''; + table_name_only := table_name_to_copy_from; + END IF; + + SELECT ARRAY_AGG(column_name) + INTO columns + FROM information_schema.columns + WHERE table_schema = schema_name + AND table_name = table_name_only + AND column_name <> id_column_name; + + FOR i IN 1..array_length(modifications, 1) BY 3 + LOOP + modification_columns := array_append(modification_columns, modifications[i]); + END LOOP; + + FOR i IN 1..array_length(columns, 1) + LOOP + + IF columns[i] = ANY(modification_columns) + THEN + + FOR j IN 1..array_length(modifications, 1) BY 3 + LOOP + + IF modifications[j] = columns[i] + THEN + to_select := array_append( + to_select, + format( + ''%L%s'', + modifications[j + 2], + modifications[j + 1] + ) + ); + EXIT; + END IF; + + END LOOP; + + ELSE + to_select := array_append(to_select, columns[i]); + END IF; + + END LOOP; + + sql_query := format( + ''INSERT INTO %I.%I(%s) SELECT %s FROM %I.%I WHERE %s = %L%s RETURNING %s::VARCHAR'', + schema_name, table_name_only, array_to_string(columns, '', ''), + array_to_string(to_select, '', ''), + schema_name, table_name_only, + id_column_name, id_to_copy, id_column_conversion_expression, + id_column_name + ); + + EXECUTE sql_query INTO inserted_id; + + RETURN inserted_id; +END; +'; diff --git a/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.xml b/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.xml new file mode 100644 index 00000000..0be2d91c --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250424191943-add-copy-row-with-modifications-function.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20250424191943-rollback.sql b/DSL/Liquibase_production/changelog/20250424191943-rollback.sql new file mode 100644 index 00000000..84afde9d --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250424191943-rollback.sql @@ -0,0 +1,3 @@ +-- liquibase formatted sql +-- changeset Artsiom Beida:20250424191943 ignore:true +DROP FUNCTION copy_row_with_modifications; \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250624225925-initial_schema.sql b/DSL/Liquibase_production/changelog/20250624225925-initial_schema.sql new file mode 100644 index 00000000..3339cf20 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250624225925-initial_schema.sql @@ -0,0 +1,112 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250624225925 ignore:true +-- Initial Migration for Data Collection System +-- Version: 001_initial_schema +-- Created: 2025-01-01 + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE EXTENSION IF NOT EXISTS hstore; + +-- Create custom ENUM types +CREATE TYPE agency_type AS ENUM ('client', 'api'); +CREATE TYPE source_type AS ENUM ('url_to_scrape', 'file', 'api'); +CREATE TYPE source_status_type AS ENUM ('new', 'running', 'finished', 'failed'); +CREATE TYPE source_file_status_type AS ENUM ('scraping', 'cleaning', 'finished', 'not_found', 'failed'); +CREATE TYPE source_file_type AS ENUM ('scraped_file', 'uploaded_file', 'api_file'); + +CREATE TABLE agency ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + base_id UUID NOT NULL DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + sector TEXT, + external_id TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT FALSE, + zipped_data_url TEXT, + zip_dirty BOOLEAN DEFAULT FALSE, + is_zipping BOOLEAN DEFAULT FALSE, + type agency_type NOT NULL DEFAULT 'client', + data_hash TEXT +); + +CREATE TABLE source ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + base_id UUID NOT NULL DEFAULT uuid_generate_v4(), + agency_base_id UUID NOT NULL, + url TEXT, + subsector TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_scraped_at TIMESTAMP WITH TIME ZONE, + next_scrapping_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, + is_deleted BOOLEAN DEFAULT FALSE, + is_stopping BOOLEAN DEFAULT FALSE, + created_by TEXT, + type source_type NOT NULL, + status source_status_type NOT NULL DEFAULT 'running', + update_automatically BOOLEAN, + cron_schedule TEXT +); + + +CREATE TABLE source_run_report ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + base_id UUID NOT NULL DEFAULT uuid_generate_v4(), + agency_base_id UUID NOT NULL, + source_base_id UUID NOT NULL, + agency_name TEXT, + url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + scraping_started_at TIMESTAMP WITH TIME ZONE, + scraping_finished_at TIMESTAMP WITH TIME ZONE, + errors INTEGER DEFAULT 0, + scraping_log_url TEXT, + cleaning_log_url TEXT, + is_deleted BOOLEAN DEFAULT FALSE +); + +CREATE TABLE source_run_page ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + base_id UUID NOT NULL DEFAULT uuid_generate_v4(), + agency_base_id UUID NOT NULL, + source_base_id UUID NOT NULL, + source_run_report_base_id UUID NOT NULL, + url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + scraped_at TIMESTAMP WITH TIME ZONE, + error_type TEXT, + error_message TEXT, + is_deleted BOOLEAN DEFAULT FALSE +); + +CREATE TABLE source_file ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + base_id UUID NOT NULL DEFAULT uuid_generate_v4(), + source_base_id UUID NOT NULL, + agency_base_id UUID NOT NULL, + url TEXT, + page_title TEXT, + original_data_url TEXT, + cleaned_data_url TEXT, + edited_data_url TEXT, + original_metadata_url TEXT, + cleaned_metadata_url TEXT, + edited_metadata_url TEXT, + original_data_hash TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_scraped_at TIMESTAMP WITH TIME ZONE, + originally_scraped TIMESTAMP WITH TIME ZONE, + status source_file_status_type NOT NULL DEFAULT 'cleaning', + type source_file_type NOT NULL, + file_name TEXT, + external_id TEXT, + subsector TEXT, + is_excluded BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE +); \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250624225925-initial_schema.xml b/DSL/Liquibase_production/changelog/20250624225925-initial_schema.xml new file mode 100644 index 00000000..36882c35 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250624225925-initial_schema.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20250624225925-rollback.sql b/DSL/Liquibase_production/changelog/20250624225925-rollback.sql new file mode 100644 index 00000000..531989ae --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250624225925-rollback.sql @@ -0,0 +1,15 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250624225925 ignore:true + +DROP TABLE IF EXISTS source_file CASCADE; +DROP TABLE IF EXISTS source_run_page CASCADE; +DROP TABLE IF EXISTS source_run_report CASCADE; +DROP TABLE IF EXISTS source CASCADE; +DROP TABLE IF EXISTS agency CASCADE; + +-- Drop custom ENUM types +DROP TYPE IF EXISTS source_file_type; +DROP TYPE IF EXISTS source_file_status_type; +DROP TYPE IF EXISTS source_status_type; +DROP TYPE IF EXISTS source_type; +DROP TYPE IF EXISTS agency_type; diff --git a/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.sql b/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.sql new file mode 100644 index 00000000..aaec008c --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.sql @@ -0,0 +1,30 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250730125816 ignore:true + +INSERT INTO agency ( + base_id, + name, + type, + external_id +) VALUES ( + '00000000-0000-0000-0000-000000000000', + 'ARVA', + 'api'::agency_type, + '00000000-0000-0000-0000-000000000000' +); + +INSERT INTO source ( + base_id, + agency_base_id, + url, + type, + status, + next_scrapping_at +) VALUES ( + '00000000-0000-0000-0000-000000000000', + '00000000-0000-0000-0000-000000000000', + 'ARVA', + 'api'::source_type, + 'new'::source_status_type, + NOW() +); \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.xml b/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.xml new file mode 100644 index 00000000..58b396fd --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250730125816-create-arva-api-source.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20250730125816-rollback.sql b/DSL/Liquibase_production/changelog/20250730125816-rollback.sql new file mode 100644 index 00000000..9ba87f82 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250730125816-rollback.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250730125816 ignore:true + +DELETE FROM source +WHERE base_id = '00000000-0000-0000-0000-000000000000'; + +DELETE FROM agency +WHERE base_id = '00000000-0000-0000-0000-000000000000'; diff --git a/DSL/Liquibase_production/changelog/20250806202019-add-indexes.sql b/DSL/Liquibase_production/changelog/20250806202019-add-indexes.sql new file mode 100644 index 00000000..6741a6e8 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250806202019-add-indexes.sql @@ -0,0 +1,28 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250806202019 ignore:true + +-- AGENCY TABLE INDEXES +CREATE INDEX idx_agency_base_deleted_updated ON agency (base_id, is_deleted, updated_at); +CREATE INDEX idx_agency_base_updated_deleted_all ON agency (base_id, updated_at DESC, is_deleted, zip_dirty, is_zipping, type, name, sector); + +-- SOURCE TABLE INDEXES +CREATE INDEX idx_source_base_updated_deleted_agency_url_subsector_type ON source (base_id, updated_at DESC, is_deleted, agency_base_id, url, subsector, type); +CREATE INDEX idx_source_base_updated_deleted_auto_scrapping_status ON source (base_id, updated_at DESC, is_deleted, update_automatically, next_scrapping_at, status); +CREATE INDEX idx_source_base_deleted_updated ON source (base_id, is_deleted, updated_at); +CREATE INDEX idx_source_agency_base_updated_deleted_url_subsector_scraped_status ON source (agency_base_id, base_id, updated_at DESC, is_deleted, url, subsector, last_scraped_at, status); +CREATE INDEX idx_source_type_base_updated_deleted_url_scraped_status ON source (type, base_id, updated_at DESC, is_deleted, url, last_scraped_at, status); + +-- SOURCE_FILE TABLE INDEXES +CREATE INDEX idx_source_file_base_deleted_updated ON source_file (base_id, is_deleted, updated_at); +CREATE INDEX idx_source_file_agency_excluded_deleted ON source_file (agency_base_id, is_excluded, is_deleted); +CREATE INDEX idx_source_file_type_base_updated_deleted_url_title_excluded_status_scraped_external ON source_file (type, base_id, updated_at DESC, is_deleted, url, page_title, is_excluded, status, last_scraped_at, external_id); +CREATE INDEX idx_source_file_type_source_base_updated_deleted_url_title_excluded_status_scraped_external ON source_file (type, source_base_id, base_id, updated_at DESC, is_deleted, url, page_title, is_excluded, status, last_scraped_at, external_id); +CREATE INDEX idx_source_file_type_base_updated_deleted_filename_subsector_excluded_created ON source_file (type, base_id, updated_at DESC, is_deleted, file_name, subsector, is_excluded, created_at); +CREATE INDEX idx_source_file_type_source_base_updated_deleted_filename_subsector_excluded_status_created ON source_file (type, source_base_id, base_id, updated_at DESC, is_deleted, file_name, subsector, is_excluded, status, created_at); + +-- SOURCE_RUN_REPORT TABLE INDEXES +CREATE INDEX idx_source_run_report_base_deleted_updated ON source_run_report (base_id, is_deleted, updated_at); +CREATE INDEX idx_source_run_report_base_updated_deleted_agency_url_errors_started_finished ON source_run_report (base_id, updated_at DESC, is_deleted, agency_name, url, errors, scraping_started_at, scraping_finished_at); + +-- SOURCE_RUN_PAGE TABLE INDEXES +CREATE INDEX idx_source_run_page_report_base_updated_deleted_url_error_type_message_scraped ON source_run_page (source_run_report_base_id, base_id, updated_at DESC, is_deleted, url, error_type, error_message, scraped_at); \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250806202019-add-indexes.xml b/DSL/Liquibase_production/changelog/20250806202019-add-indexes.xml new file mode 100644 index 00000000..f2703ab1 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250806202019-add-indexes.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20250806202019-rollback.sql b/DSL/Liquibase_production/changelog/20250806202019-rollback.sql new file mode 100644 index 00000000..7ac8f25a --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250806202019-rollback.sql @@ -0,0 +1,28 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20250806202019 ignore:true + +-- AGENCY TABLE INDEXES +DROP INDEX IF EXISTS idx_agency_base_deleted_updated; +DROP INDEX IF EXISTS idx_agency_base_updated_deleted_all; + +-- SOURCE TABLE INDEXES +DROP INDEX IF EXISTS idx_source_base_updated_deleted_agency_url_subsector_type; +DROP INDEX IF EXISTS idx_source_base_updated_deleted_auto_scrapping_status; +DROP INDEX IF EXISTS idx_source_base_deleted_updated; +DROP INDEX IF EXISTS idx_source_agency_base_updated_deleted_url_subsector_scraped_status; +DROP INDEX IF EXISTS idx_source_type_base_updated_deleted_url_scraped_status; + +-- SOURCE_FILE TABLE INDEXES +DROP INDEX IF EXISTS idx_source_file_base_deleted_updated; +DROP INDEX IF EXISTS idx_source_file_agency_excluded_deleted; +DROP INDEX IF EXISTS idx_source_file_type_base_updated_deleted_url_title_excluded_status_scraped_external; +DROP INDEX IF EXISTS idx_source_file_type_source_base_updated_deleted_url_title_excluded_status_scraped_external; +DROP INDEX IF EXISTS idx_source_file_type_base_updated_deleted_filename_subsector_excluded_created; +DROP INDEX IF EXISTS idx_source_file_type_source_base_updated_deleted_filename_subsector_excluded_status_created; + +-- SOURCE_RUN_REPORT TABLE INDEXES +DROP INDEX IF EXISTS idx_source_run_report_base_deleted_updated; +DROP INDEX IF EXISTS idx_source_run_report_base_updated_deleted_agency_url_errors_started_finished; + +-- SOURCE_RUN_PAGE TABLE INDEXES +DROP INDEX IF EXISTS idx_source_run_page_report_base_updated_deleted_url_error_type_message_scraped; diff --git a/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.sql b/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.sql new file mode 100644 index 00000000..16813030 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.sql @@ -0,0 +1,29 @@ +-- liquibase formatted sql +-- changeset schema-001:20250821160758 ignore:true + +-- SCHEMA-001: Implement explicit schemas according to Buerokratt ADR +-- Version: SCHEMA-001 + +-- Create schemas based on functional areas +CREATE SCHEMA IF NOT EXISTS agency_management; +CREATE SCHEMA IF NOT EXISTS data_collection; +CREATE SCHEMA IF NOT EXISTS monitoring; + +-- Move tables to their respective schemas +ALTER TABLE IF EXISTS public.agency SET SCHEMA agency_management; +ALTER TABLE IF EXISTS public.source SET SCHEMA data_collection; +ALTER TABLE IF EXISTS public.source_file SET SCHEMA data_collection; +ALTER TABLE IF EXISTS public.source_run_report SET SCHEMA monitoring; +ALTER TABLE IF EXISTS public.source_run_page SET SCHEMA monitoring; + +-- Grant permissions on schemas +GRANT USAGE ON SCHEMA agency_management TO PUBLIC; +GRANT USAGE ON SCHEMA data_collection TO PUBLIC; +GRANT USAGE ON SCHEMA monitoring TO PUBLIC; + +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA agency_management TO PUBLIC; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA data_collection TO PUBLIC; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA monitoring TO PUBLIC; + +-- Revoke CREATE on public schema to prevent future usage +REVOKE CREATE ON SCHEMA public FROM public; \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.xml b/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.xml new file mode 100644 index 00000000..2d254fbb --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250821160758-implement-schema-001.xml @@ -0,0 +1,15 @@ + + + + + SCHEMA-001: Implement explicit schemas according to Buerokratt ADR + + + + + + + \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20250821160758-rollback.sql b/DSL/Liquibase_production/changelog/20250821160758-rollback.sql new file mode 100644 index 00000000..0b151510 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20250821160758-rollback.sql @@ -0,0 +1,18 @@ +-- liquibase formatted sql +-- Rollback script for SCHEMA-001 implementation +-- This script reverts the schema changes and moves tables back to public schema + +-- Move tables back to public schema +ALTER TABLE IF EXISTS agency_management.agency SET SCHEMA public; +ALTER TABLE IF EXISTS data_collection.source SET SCHEMA public; +ALTER TABLE IF EXISTS data_collection.source_file SET SCHEMA public; +ALTER TABLE IF EXISTS monitoring.source_run_report SET SCHEMA public; +ALTER TABLE IF EXISTS monitoring.source_run_page SET SCHEMA public; + +-- Drop schemas (will only succeed if empty) +DROP SCHEMA IF EXISTS monitoring; +DROP SCHEMA IF EXISTS data_collection; +DROP SCHEMA IF EXISTS agency_management; + +-- Restore public schema permissions +GRANT CREATE ON SCHEMA public TO public; \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.sql b/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.sql new file mode 100644 index 00000000..8a857cc7 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20251021071904 ignore:true +-- Add uploaded_by and file_size columns to source_file table + +ALTER TABLE data_collection.source_file +ADD COLUMN uploaded_by TEXT, +ADD COLUMN file_size BIGINT; + +COMMENT ON COLUMN data_collection.source_file.uploaded_by IS 'User/system that uploaded the file (only for uploaded_file type)'; +COMMENT ON COLUMN data_collection.source_file.file_size IS 'File size in bytes'; diff --git a/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.xml b/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.xml new file mode 100644 index 00000000..949c9a63 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20251021071904-add-uploaded-by-and-file-size.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20251021071904-rollback.sql b/DSL/Liquibase_production/changelog/20251021071904-rollback.sql new file mode 100644 index 00000000..b2ec9d51 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20251021071904-rollback.sql @@ -0,0 +1,2 @@ +-- liquibase formatted sql +-- changeset ahmer-mt:20251021071904 ignore:true diff --git a/DSL/Liquibase_production/changelog/20260225-in_review-cleaning_status.sql b/DSL/Liquibase_production/changelog/20260225-in_review-cleaning_status.sql new file mode 100644 index 00000000..0b79e9f1 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260225-in_review-cleaning_status.sql @@ -0,0 +1,15 @@ +-- Add 'in_review' to source_status_type and source_file_status_type enums +-- Add cleaning_status column to source table +-- Rollback included + +-- 1. Add 'in_review' to source_status_type +ALTER TYPE source_status_type ADD VALUE IF NOT EXISTS 'in_review'; + + +-- 2. Add 'in_review' to source_file_status_type +ALTER TYPE source_file_status_type ADD VALUE IF NOT EXISTS 'in_review'; + +-- Rollback +-- Remove cleaning_status column (cannot remove enum values in Postgres easily) +-- To rollback, drop the column only +-- (Manual intervention needed to remove enum values if required) diff --git a/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.sql b/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.sql new file mode 100644 index 00000000..dc71e06a --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.sql @@ -0,0 +1,10 @@ +-- liquibase formatted sql +-- changeset ruwinirathnamalala:20260312182956 ignore:true +-- Create the quality_control_type enum (drop first if exists) + +CREATE TYPE quality_control_type AS ENUM ('basic', 'comprehensive'); + +ALTER TABLE data_collection.source +ADD COLUMN IF NOT EXISTS quality_control quality_control_type DEFAULT NULL; + +COMMENT ON COLUMN data_collection.source.quality_control IS 'Content extraction quality control method: basic, comprehensive, or NULL (fallback only)'; \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.xml b/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.xml new file mode 100644 index 00000000..b9da10b8 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260312182956-add-quality-control.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/20260312182956-rollback.sql b/DSL/Liquibase_production/changelog/20260312182956-rollback.sql new file mode 100644 index 00000000..75a81b79 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260312182956-rollback.sql @@ -0,0 +1,7 @@ +-- liquibase formatted sql +-- changeset ruwinirathnamalala:20260312182956 ignore:true +-- Rollback: Drop the quality_control column and enum type +ALTER TABLE data_collection.source +DROP COLUMN IF EXISTS quality_control; + +DROP TYPE IF EXISTS quality_control_type; diff --git a/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type-rollback.sql b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type-rollback.sql new file mode 100644 index 00000000..94aec7b4 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type-rollback.sql @@ -0,0 +1,6 @@ +-- liquibase formatted sql +-- changeset ruwinirathnamalala:20260318054000 ignore:true +-- Rollback note: PostgreSQL enum values cannot be removed safely in-place. +-- No-op rollback by design. + +SELECT 1; diff --git a/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.sql b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.sql new file mode 100644 index 00000000..8779cebe --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset ruwinirathnamalala:20260318054000 ignore:true +-- Add dedicated source_type enum value for pre-decided URL list sources + +ALTER TYPE source_type ADD VALUE IF NOT EXISTS 'pre_selected_urls'; diff --git a/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.xml b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.xml new file mode 100644 index 00000000..e4885ec7 --- /dev/null +++ b/DSL/Liquibase_production/changelog/20260318054000-add-pre-selected-urls-source-type.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v1-llm-connections.sql b/DSL/Liquibase_production/changelog/rag-search-script-v1-llm-connections.sql new file mode 100644 index 00000000..7d1ced74 --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v1-llm-connections.sql @@ -0,0 +1,135 @@ +-- Schema for LLM Connections +CREATE TABLE llm_connections ( + -- Metadata + id SERIAL PRIMARY KEY, + connection_name VARCHAR(255) NOT NULL DEFAULT '', + connection_status VARCHAR(50) DEFAULT 'active', -- active / inactive + created_at TIMESTAMP DEFAULT NOW(), + environment VARCHAR(50) NOT NULL, + + -- LLM Model Configuration + llm_platform VARCHAR(100) NOT NULL, -- e.g. Azure AI, OpenAI + llm_model VARCHAR(100) NOT NULL, -- e.g. GPT-4o + -- Azure + deployment_name VARCHAR(150), -- for Azure deployments + target_uri TEXT, -- for custom endpoints + api_key TEXT, -- secured api key mocked here + -- AWS Bedrock + secret_key TEXT, + access_key TEXT, + + -- Embedding Model Configuration + embedding_platform VARCHAR(100) NOT NULL, -- e.g. Azure AI, OpenAI + embedding_model VARCHAR(100) NOT NULL, -- e.g. Ada-200-1 + -- Azure + embedding_deployment_name VARCHAR(150), -- for Azure deployments + embedding_target_uri TEXT, -- for custom endpoints + embedding_azure_api_key TEXT, -- secured api key mocked here + -- AWS Bedrock + embedding_secret_key TEXT, + embedding_access_key TEXT, + + -- Budget and Usage Tracking + monthly_budget NUMERIC(12,2) NOT NULL, -- e.g. 1000.00 + used_budget NUMERIC(12,2) DEFAULT 0.00, -- e.g. 250.00 + warn_budget_threshold NUMERIC(5) DEFAULT 80, -- percentage to warn at + stop_budget_threshold NUMERIC(5) DEFAULT 100, -- percentage to stop at + disconnect_on_budget_exceed BOOLEAN DEFAULT TRUE +); + +CREATE TABLE inference_results ( + id SERIAL PRIMARY KEY, + llm_connection_id INT REFERENCES llm_connections(id) ON DELETE CASCADE, + chat_id TEXT, -- optional chat session ID + user_question TEXT NOT NULL, -- raw user input + refined_questions JSONB, -- list of refined questions (LLM-generated) + conversation_history JSONB, -- prior messages (array of {role, content}) + ranked_chunks JSONB, -- retrieved chunks (ranked, with metadata) + embedding_scores JSONB, -- distance scores for each chunk + final_answer TEXT, -- LLM’s final generated answer + environment TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE inference_results_references ( + id SERIAL PRIMARY KEY, + conversation_id INT NOT NULL REFERENCES inference_results(id) ON DELETE CASCADE, + reference_url TEXT NOT NULL +); + +-- Schema for Platform and Model Management + +-- Table for LLM Platforms +CREATE TABLE llm_platforms ( + id SERIAL PRIMARY KEY, + platform_key VARCHAR(50) NOT NULL UNIQUE, -- e.g., 'azure', 'aws' + platform_name VARCHAR(100) NOT NULL -- e.g., 'Azure OpenAI', 'AWS Bedrock' +); + +-- Table for LLM Models +CREATE TABLE llm_models ( + id SERIAL PRIMARY KEY, + platform_id INT NOT NULL REFERENCES llm_platforms(id) ON DELETE CASCADE, + model_key VARCHAR(100) NOT NULL, -- e.g., 'gpt-4o', 'anthropic-claude-3.5-sonnet' + model_name VARCHAR(150) NOT NULL, -- e.g., 'GPT-4o', 'Anthropic Claude 3.5 Sonnet' + UNIQUE(platform_id, model_key) +); + +-- Table for Embedding Platforms +CREATE TABLE embedding_platforms ( + id SERIAL PRIMARY KEY, + platform_key VARCHAR(50) NOT NULL UNIQUE, -- e.g., 'azure', 'aws' + platform_name VARCHAR(100) NOT NULL -- e.g., 'Azure OpenAI', 'AWS Bedrock' + +); + +-- Table for Embedding Models +CREATE TABLE embedding_models ( + id SERIAL PRIMARY KEY, + platform_id INT NOT NULL REFERENCES embedding_platforms(id) ON DELETE CASCADE, + model_key VARCHAR(100) NOT NULL, -- e.g., 'text-embedding-3-large', 'amazon.titan-embed-text-v2:0' + model_name VARCHAR(150) NOT NULL, -- e.g., 'text-embedding-3-large', 'Amazon Titan Text Embeddings V2' + UNIQUE(platform_id, model_key) +); + +-- Insert initial LLM platforms +INSERT INTO llm_platforms (platform_key, platform_name) VALUES +('azure', 'Azure OpenAI'), +('aws', 'AWS Bedrock'); + +-- Insert initial LLM models +INSERT INTO llm_models (platform_id, model_key, model_name) VALUES +-- Azure models +((SELECT id FROM llm_platforms WHERE platform_key = 'azure'), 'gpt-4o-mini', 'GPT-4o-mini'), +((SELECT id FROM llm_platforms WHERE platform_key = 'azure'), 'gpt-4o', 'GPT-4o'), +((SELECT id FROM llm_platforms WHERE platform_key = 'azure'), 'gpt-4.1', 'GPT-4.1'), +-- AWS models +((SELECT id FROM llm_platforms WHERE platform_key = 'aws'), 'anthropic-claude-3.5-sonnet', 'Anthropic Claude 3.5 Sonnet'), +((SELECT id FROM llm_platforms WHERE platform_key = 'aws'), 'anthropic-claude-3.7-sonnet', 'Anthropic Claude 3.7 Sonnet'); + +-- Insert initial embedding platforms +INSERT INTO embedding_platforms (platform_key, platform_name) VALUES +('azure', 'Azure OpenAI'), +('aws', 'AWS Bedrock'); + +-- Insert initial embedding models +INSERT INTO embedding_models (platform_id, model_key, model_name) VALUES +-- Azure embedding models +((SELECT id FROM embedding_platforms WHERE platform_key = 'azure'), 'text-embedding-3-large', 'text-embedding-3-large'), +-- AWS embedding models +((SELECT id FROM embedding_platforms WHERE platform_key = 'aws'), 'amazon.titan-embed-text-v2:0', 'Amazon Titan Text Embeddings V2'); + +-- Add indexes for better performance +CREATE INDEX idx_llm_models_platform_id ON llm_models(platform_id); +CREATE INDEX idx_embedding_models_platform_id ON embedding_models(platform_id); + +CREATE TABLE public.agency_sync ( + id VARCHAR(50) PRIMARY KEY, + agency_data_hash VARCHAR(255), + data_url TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +INSERT INTO public.agency_sync (id, created_at) VALUES +('1', NOW()); diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v2-user-management.sql b/DSL/Liquibase_production/changelog/rag-search-script-v2-user-management.sql new file mode 100644 index 00000000..f47656b8 --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v2-user-management.sql @@ -0,0 +1,41 @@ +-- liquibase formatted sql + +-- changeset Erangi Ariyasena:classifier-script-v3-changeset1 +CREATE TYPE user_status AS ENUM ('active','deleted'); + +-- changeset Erangi Ariyasena:classifier-script-v3-changeset2 +CREATE TABLE public."user" ( + id int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY, + login VARCHAR(50) NOT NULL, + password_hash VARCHAR(60), + first_name VARCHAR(50), + last_name VARCHAR(50), + id_code VARCHAR(50) NOT NULL, + display_name VARCHAR(50), + status user_status, + csa_title VARCHAR, + csa_email VARCHAR, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_pkey PRIMARY KEY (id) +); + +CREATE TABLE public."authority" ( + name VARCHAR(50) PRIMARY KEY +); + +CREATE TABLE public."user_authority" ( + id int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY, + user_id VARCHAR(50) NOT NULL, + authority_name VARCHAR[] NOT NULL, + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_authority_pkey PRIMARY KEY (id) +); + +-- changeset Erangi Ariyasena:classifier-script-v1-changeset3 + +INSERT INTO public."user" (login,password_hash,first_name,last_name,id_code,display_name,status,csa_title,csa_email) +VALUES ('EE30303039914','ok','classifier','test','EE30303039914','classifier','active','Title','classifier.doe@example.com'); + +INSERT INTO public."user_authority" ( user_id, authority_name) +VALUES ('EE30303039914', ARRAY['ROLE_ADMINISTRATOR', 'ROLE_MODEL_TRAINER'] ); + diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v3-configuration.sql b/DSL/Liquibase_production/changelog/rag-search-script-v3-configuration.sql new file mode 100644 index 00000000..cda1bb5e --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v3-configuration.sql @@ -0,0 +1,15 @@ +-- liquibase formatted sql + +-- changeset Erangi Ariyasena:classifier-script-v5-changeset1 +CREATE TABLE public.configuration ( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + key VARCHAR(128), + value VARCHAR(128), + created TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT configuration_pkey PRIMARY KEY (id) +); + +-- changeset Erangi Ariyasena:classifier-script-v5-changeset2 +INSERT INTO public.configuration (key, value) +VALUES ('session_length', '120'); diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v4-authority-data.xml b/DSL/Liquibase_production/changelog/rag-search-script-v4-authority-data.xml new file mode 100644 index 00000000..f15c3d1e --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v4-authority-data.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v5-prompt-config.sql b/DSL/Liquibase_production/changelog/rag-search-script-v5-prompt-config.sql new file mode 100644 index 00000000..8d29f949 --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v5-prompt-config.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +-- changeset Erangi Ariyasena:rag-script-v5-changeset1 +CREATE TABLE public.prompt_configuration ( + id BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY, + prompt TEXT +); + diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v6-endpoints.sql b/DSL/Liquibase_production/changelog/rag-search-script-v6-endpoints.sql new file mode 100644 index 00000000..b84d5278 --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v6-endpoints.sql @@ -0,0 +1,67 @@ +-- liquibase formatted sql + +-- changeset Ruwini:rag-script-v6-changeset1 +CREATE TABLE public.mock_endpoints ( + endpoint_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service_id UUID, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + type VARCHAR(50) DEFAULT 'custom_endpoint', + visibility VARCHAR(20) DEFAULT 'private', + method VARCHAR(10) NOT NULL, + url TEXT NOT NULL, + params JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_mock_endpoints_service_id ON public.mock_endpoints(service_id); +CREATE INDEX idx_mock_endpoints_visibility ON public.mock_endpoints(visibility); + +-- changeset Ruwini:rag-script-v6-changeset2 +-- Seed data: 3 test endpoints for development and testing + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_public_holidays', + 'Get public holidays for a specific country within a date range', + 'custom_endpoint', + 'public', + 'GET', + 'https://openholidaysapi.org/PublicHolidays', + '[ + {"name": "countryIsoCode", "type": "string", "required": true, "description": "ISO 3166-1 alpha-2 country code (e.g. EE for Estonia, DE for Germany)"}, + {"name": "languageIsoCode", "type": "string", "required": false, "description": "ISO language code for the response language (e.g. EE for Estonian, EN for English)"}, + {"name": "validFrom", "type": "date", "required": true, "description": "Start date for holiday lookup in YYYY-MM-DD format"}, + {"name": "validTo", "type": "date", "required": true, "description": "End date for holiday lookup in YYYY-MM-DD format"} + ]'::jsonb +); + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_current_weather', + 'Get the current weather conditions for a given city', + 'custom_endpoint', + 'public', + 'GET', + 'https://wttr.in', + '[ + {"name": "city", "type": "string", "required": true, "description": "Name of the city to get weather for (e.g. Tallinn, London, Berlin)"}, + {"name": "format", "type": "string", "required": false, "description": "Response format: j1 for JSON, 1 for one-line summary (default: j1)"} + ]'::jsonb +); + +INSERT INTO public.mock_endpoints (name, description, type, visibility, method, url, params) +VALUES ( + 'get_exchange_rate', + 'Get the latest currency exchange rate between two currencies', + 'custom_endpoint', + 'public', + 'GET', + 'https://api.frankfurter.app/latest', + '[ + {"name": "from", "type": "string", "required": true, "description": "The base currency code to convert from (e.g. EUR, USD, GBP)"}, + {"name": "to", "type": "string", "required": true, "description": "The target currency code to convert to (e.g. USD, EUR, JPY)"}, + {"name": "amount", "type": "number", "required": false, "description": "The amount to convert (default: 1)"} + ]'::jsonb +); diff --git a/DSL/Liquibase_production/changelog/rag-search-script-v7-schema-migration.sql b/DSL/Liquibase_production/changelog/rag-search-script-v7-schema-migration.sql new file mode 100644 index 00000000..6ece0359 --- /dev/null +++ b/DSL/Liquibase_production/changelog/rag-search-script-v7-schema-migration.sql @@ -0,0 +1,55 @@ +-- liquibase formatted sql + +-- changeset rag-schema-migration:rag-script-v7-changeset1 +CREATE SCHEMA IF NOT EXISTS rag_search; +GRANT USAGE ON SCHEMA rag_search TO PUBLIC; +-- rollback DROP SCHEMA IF EXISTS rag_search; + +-- changeset rag-schema-migration:rag-script-v7-changeset2 +-- Move RAG module tables from public schema to rag_search schema + +-- v1: LLM connection and inference tables +ALTER TABLE IF EXISTS public.llm_connections SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.inference_results SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.inference_results_references SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.llm_platforms SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.llm_models SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.embedding_platforms SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.embedding_models SET SCHEMA rag_search; + +-- v2: User management tables +ALTER TABLE IF EXISTS public."user" SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.authority SET SCHEMA rag_search; +ALTER TABLE IF EXISTS public.user_authority SET SCHEMA rag_search; + +-- v3: Configuration table +ALTER TABLE IF EXISTS public.configuration SET SCHEMA rag_search; + +-- v5: Prompt configuration table +ALTER TABLE IF EXISTS public.prompt_configuration SET SCHEMA rag_search; + +-- v6: Endpoints table +ALTER TABLE IF EXISTS public.mock_endpoints SET SCHEMA rag_search; + +-- v1: Agency sync table +ALTER TABLE IF EXISTS public.agency_sync SET SCHEMA rag_search; + +-- Grant permissions on all tables and sequences in rag_search schema +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA rag_search TO PUBLIC; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA rag_search TO PUBLIC; + +-- rollback ALTER TABLE IF EXISTS rag_search.llm_connections SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.inference_results SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.inference_results_references SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.llm_platforms SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.llm_models SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.embedding_platforms SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.embedding_models SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search."user" SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.authority SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.user_authority SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.configuration SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.prompt_configuration SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.mock_endpoints SET SCHEMA public; +-- rollback ALTER TABLE IF EXISTS rag_search.agency_sync SET SCHEMA public; +-- rollback DROP SCHEMA IF EXISTS rag_search; diff --git a/DSL/Liquibase_production/data/authority.csv b/DSL/Liquibase_production/data/authority.csv new file mode 100644 index 00000000..c110c607 --- /dev/null +++ b/DSL/Liquibase_production/data/authority.csv @@ -0,0 +1,3 @@ +name +ROLE_ADMINISTRATOR +ROLE_MODEL_TRAINER diff --git a/DSL/Liquibase_production/liquibase.properties b/DSL/Liquibase_production/liquibase.properties new file mode 100644 index 00000000..211511c3 --- /dev/null +++ b/DSL/Liquibase_production/liquibase.properties @@ -0,0 +1,6 @@ +changelogFile: changelog.yaml +url: jdbc:postgresql://database:5432/ckb_db +username: byk +password: 01234 +secureParsing: false +liquibase.hub.mode=off \ No newline at end of file diff --git a/DSL/Resql/rag-search/GET/get-prompt-configuration.sql b/DSL/Resql/rag-search/GET/get-prompt-configuration.sql new file mode 100644 index 00000000..e250b65f --- /dev/null +++ b/DSL/Resql/rag-search/GET/get-prompt-configuration.sql @@ -0,0 +1,5 @@ +SELECT + id, + prompt +FROM rag_search.prompt_configuration +LIMIT 1 \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/deactivate-llm-connection-budget-exceed.sql b/DSL/Resql/rag-search/POST/deactivate-llm-connection-budget-exceed.sql index af9da1b0..809a2560 100644 --- a/DSL/Resql/rag-search/POST/deactivate-llm-connection-budget-exceed.sql +++ b/DSL/Resql/rag-search/POST/deactivate-llm-connection-budget-exceed.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET connection_status = 'inactive' WHERE id = :connection_id diff --git a/DSL/Resql/rag-search/POST/delete-llm-connection.sql b/DSL/Resql/rag-search/POST/delete-llm-connection.sql index ca50c8fe..d429c796 100644 --- a/DSL/Resql/rag-search/POST/delete-llm-connection.sql +++ b/DSL/Resql/rag-search/POST/delete-llm-connection.sql @@ -1,2 +1,2 @@ -DELETE FROM llm_connections +DELETE FROM rag_search.llm_connections WHERE id = :connection_id; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/delete-user.sql b/DSL/Resql/rag-search/POST/delete-user.sql index eb8ccade..3244d803 100644 --- a/DSL/Resql/rag-search/POST/delete-user.sql +++ b/DSL/Resql/rag-search/POST/delete-user.sql @@ -1,12 +1,12 @@ WITH active_administrators AS (SELECT user_id - FROM user_authority + FROM rag_search.user_authority WHERE 'ROLE_ADMINISTRATOR' = ANY (authority_name) AND id IN (SELECT max(id) - FROM user_authority + FROM rag_search.user_authority GROUP BY user_id)), delete_user AS ( INSERT -INTO "user" (login, password_hash, first_name, last_name, id_code, display_name, status, created, csa_title, csa_email) +INTO rag_search."user" (login, password_hash, first_name, last_name, id_code, display_name, status, created, csa_title, csa_email) SELECT login, password_hash, first_name, @@ -17,20 +17,20 @@ SELECT login, :created::timestamp with time zone, csa_title, csa_email -FROM "user" +FROM rag_search."user" WHERE id_code = :userIdCode AND status <> 'deleted' - AND id IN (SELECT max(id) FROM "user" WHERE id_code = :userIdCode) + AND id IN (SELECT max(id) FROM rag_search."user" WHERE id_code = :userIdCode) AND (1 < (SELECT COUNT(user_id) FROM active_administrators) OR (1 = (SELECT COUNT(user_id) FROM active_administrators) AND :userIdCode NOT IN (SELECT user_id FROM active_administrators)))), delete_authority AS ( INSERT -INTO user_authority (user_id, authority_name, created) +INTO rag_search.user_authority (user_id, authority_name, created) SELECT :userIdCode as users, ARRAY []::varchar[], :created::timestamp with time zone -FROM user_authority +FROM rag_search.user_authority WHERE 1 < (SELECT COUNT(user_id) FROM active_administrators) OR (1 = (SELECT COUNT(user_id) FROM active_administrators) AND :userIdCode NOT IN (SELECT user_id FROM active_administrators)) GROUP BY users) -SELECT max(status) FROM "user" WHERE id_code = :userIdCode; +SELECT max(status) FROM rag_search."user" WHERE id_code = :userIdCode; diff --git a/DSL/Resql/rag-search/POST/get-agency-id.sql b/DSL/Resql/rag-search/POST/get-agency-id.sql index a2bf5b03..21aa6a08 100644 --- a/DSL/Resql/rag-search/POST/get-agency-id.sql +++ b/DSL/Resql/rag-search/POST/get-agency-id.sql @@ -1,4 +1,4 @@ SELECT - agency_id, + id, agency_data_hash -FROM public.agency_sync; \ No newline at end of file +FROM rag_search.agency_sync; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-all-endpoints.sql b/DSL/Resql/rag-search/POST/get-all-endpoints.sql new file mode 100644 index 00000000..01055a05 --- /dev/null +++ b/DSL/Resql/rag-search/POST/get-all-endpoints.sql @@ -0,0 +1,19 @@ +-- Get all endpoints +-- Used by the API Tool indexing pipeline to bulk-index all endpoints into Qdrant + +SELECT + endpoint_id, + service_id, + name, + description, + type, + visibility, + method, + url, + params, + created_at, + updated_at +FROM + rag_search.mock_endpoints +ORDER BY + created_at ASC diff --git a/DSL/Resql/rag-search/POST/get-configuration.sql b/DSL/Resql/rag-search/POST/get-configuration.sql index f03b322e..f0692baa 100644 --- a/DSL/Resql/rag-search/POST/get-configuration.sql +++ b/DSL/Resql/rag-search/POST/get-configuration.sql @@ -1,5 +1,5 @@ SELECT id, key, value -FROM configuration +FROM rag_search.configuration WHERE key=:key -AND id IN (SELECT max(id) from configuration GROUP BY key) +AND id IN (SELECT max(id) from rag_search.configuration GROUP BY key) AND NOT deleted; diff --git a/DSL/Resql/rag-search/POST/get-embedding-models-by-platform.sql b/DSL/Resql/rag-search/POST/get-embedding-models-by-platform.sql index c0968e58..05de3d6c 100644 --- a/DSL/Resql/rag-search/POST/get-embedding-models-by-platform.sql +++ b/DSL/Resql/rag-search/POST/get-embedding-models-by-platform.sql @@ -5,7 +5,7 @@ SELECT em.platform_id, ep.platform_key, ep.platform_name -FROM embedding_models em -JOIN embedding_platforms ep ON em.platform_id = ep.id +FROM rag_search.embedding_models em +JOIN rag_search.embedding_platforms ep ON em.platform_id = ep.id WHERE (:embedding_platform_key IS NULL OR ep.platform_key = :embedding_platform_key) ORDER BY em.model_name; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-embedding-platforms.sql b/DSL/Resql/rag-search/POST/get-embedding-platforms.sql index 9c90513b..d1fe8abb 100644 --- a/DSL/Resql/rag-search/POST/get-embedding-platforms.sql +++ b/DSL/Resql/rag-search/POST/get-embedding-platforms.sql @@ -2,5 +2,5 @@ SELECT id, platform_key as value, platform_name as label -FROM embedding_platforms +FROM rag_search.embedding_platforms ORDER BY platform_name; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-endpoint-by-id.sql b/DSL/Resql/rag-search/POST/get-endpoint-by-id.sql new file mode 100644 index 00000000..f28c2803 --- /dev/null +++ b/DSL/Resql/rag-search/POST/get-endpoint-by-id.sql @@ -0,0 +1,19 @@ +-- Get a specific endpoint by its UUID +-- Used by the indexing pipeline after creation and by the workflow executor + +SELECT + endpoint_id, + service_id, + name, + description, + type, + visibility, + method, + url, + params, + created_at, + updated_at +FROM + rag_search.mock_endpoints +WHERE + endpoint_id = :endpointId::uuid diff --git a/DSL/Resql/rag-search/POST/get-llm-connection.sql b/DSL/Resql/rag-search/POST/get-llm-connection.sql index a1128dfa..025ab507 100644 --- a/DSL/Resql/rag-search/POST/get-llm-connection.sql +++ b/DSL/Resql/rag-search/POST/get-llm-connection.sql @@ -26,6 +26,6 @@ SELECT embedding_deployment_name, embedding_target_uri, embedding_azure_api_key -FROM llm_connections +FROM rag_search.llm_connections WHERE id = :connection_id AND connection_status <> 'deleted'; diff --git a/DSL/Resql/rag-search/POST/get-llm-connections-paginated.sql b/DSL/Resql/rag-search/POST/get-llm-connections-paginated.sql index faf16001..239e7866 100644 --- a/DSL/Resql/rag-search/POST/get-llm-connections-paginated.sql +++ b/DSL/Resql/rag-search/POST/get-llm-connections-paginated.sql @@ -21,7 +21,7 @@ SELECT WHEN (used_budget::DECIMAL / monthly_budget::DECIMAL) >= (warn_budget_threshold::DECIMAL / 100.0) THEN 'close_to_exceed' ELSE 'within_budget' END AS budget_status -FROM llm_connections +FROM rag_search.llm_connections WHERE connection_status <> 'deleted' AND environment = 'testing' AND (:llm_platform IS NULL OR :llm_platform = '' OR llm_platform = :llm_platform) diff --git a/DSL/Resql/rag-search/POST/get-llm-models-by-platform.sql b/DSL/Resql/rag-search/POST/get-llm-models-by-platform.sql index 861a97db..cb6c9a3f 100644 --- a/DSL/Resql/rag-search/POST/get-llm-models-by-platform.sql +++ b/DSL/Resql/rag-search/POST/get-llm-models-by-platform.sql @@ -5,7 +5,7 @@ SELECT lm.platform_id, lp.platform_key, lp.platform_name -FROM llm_models lm -JOIN llm_platforms lp ON lm.platform_id = lp.id +FROM rag_search.llm_models lm +JOIN rag_search.llm_platforms lp ON lm.platform_id = lp.id AND (:platform_key IS NULL OR lp.platform_key = :platform_key) ORDER BY lm.model_name; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-llm-platforms.sql b/DSL/Resql/rag-search/POST/get-llm-platforms.sql index 2840f3c5..aa42277d 100644 --- a/DSL/Resql/rag-search/POST/get-llm-platforms.sql +++ b/DSL/Resql/rag-search/POST/get-llm-platforms.sql @@ -2,5 +2,5 @@ SELECT id, platform_key as value, platform_name as label -FROM llm_platforms +FROM rag_search.llm_platforms ORDER BY platform_name; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-production-connection-filtered.sql b/DSL/Resql/rag-search/POST/get-production-connection-filtered.sql index 4d5ced01..02ec251d 100644 --- a/DSL/Resql/rag-search/POST/get-production-connection-filtered.sql +++ b/DSL/Resql/rag-search/POST/get-production-connection-filtered.sql @@ -31,7 +31,7 @@ SELECT WHEN (used_budget::DECIMAL / monthly_budget::DECIMAL) >= (warn_budget_threshold::DECIMAL / 100.0) THEN 'close_to_exceed' ELSE 'within_budget' END AS budget_status -FROM llm_connections +FROM rag_search.llm_connections WHERE environment = 'production' AND connection_status <> 'deleted' AND (:llm_platform IS NULL OR :llm_platform = '' OR llm_platform = :llm_platform) diff --git a/DSL/Resql/rag-search/POST/get-production-connection.sql b/DSL/Resql/rag-search/POST/get-production-connection.sql index eca9f970..f5853c4a 100644 --- a/DSL/Resql/rag-search/POST/get-production-connection.sql +++ b/DSL/Resql/rag-search/POST/get-production-connection.sql @@ -19,7 +19,7 @@ SELECT WHEN (used_budget::DECIMAL / monthly_budget::DECIMAL) >= (warn_budget_threshold::DECIMAL / 100.0) THEN 'close_to_exceed' ELSE 'within_budget' END AS budget_status -FROM llm_connections +FROM rag_search.llm_connections WHERE environment = 'production' ORDER BY created_at DESC LIMIT 1; diff --git a/DSL/Resql/rag-search/POST/get-testing-connection.sql b/DSL/Resql/rag-search/POST/get-testing-connection.sql index 93e9149b..3c1c6ae1 100644 --- a/DSL/Resql/rag-search/POST/get-testing-connection.sql +++ b/DSL/Resql/rag-search/POST/get-testing-connection.sql @@ -19,7 +19,7 @@ SELECT WHEN (used_budget::DECIMAL / monthly_budget::DECIMAL) >= (warn_budget_threshold::DECIMAL / 100.0) THEN 'close_to_exceed' ELSE 'within_budget' END AS budget_status -FROM llm_connections +FROM rag_search.llm_connections WHERE environment = 'testing' ORDER BY created_at DESC LIMIT 1; \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-user-role.sql b/DSL/Resql/rag-search/POST/get-user-role.sql index 39a51f4e..926b5afb 100644 --- a/DSL/Resql/rag-search/POST/get-user-role.sql +++ b/DSL/Resql/rag-search/POST/get-user-role.sql @@ -1,10 +1,10 @@ SELECT ua.authority_name AS authorities -FROM "user" u +FROM rag_search."user" u INNER JOIN (SELECT authority_name, user_id - FROM user_authority AS ua + FROM rag_search.user_authority AS ua WHERE ua.id IN (SELECT max(id) - FROM user_authority + FROM rag_search.user_authority GROUP BY user_id)) ua ON u.id_code = ua.user_id WHERE u.id_code = :userIdCode AND status <> 'deleted' - AND id IN (SELECT max(id) FROM "user" WHERE id_code = :userIdCode) + AND id IN (SELECT max(id) FROM rag_search."user" WHERE id_code = :userIdCode) diff --git a/DSL/Resql/rag-search/POST/get-user-with-roles.sql b/DSL/Resql/rag-search/POST/get-user-with-roles.sql index 8ef5044c..f51521f5 100644 --- a/DSL/Resql/rag-search/POST/get-user-with-roles.sql +++ b/DSL/Resql/rag-search/POST/get-user-with-roles.sql @@ -6,10 +6,10 @@ SELECT DISTINCT u.login, u.csa_title, u.csa_email, ua.authority_name AS authorities -FROM "user" u +FROM rag_search."user" u LEFT JOIN (SELECT authority_name, user_id - FROM user_authority AS ua + FROM rag_search.user_authority AS ua WHERE ua.id IN (SELECT max(id) - FROM user_authority + FROM rag_search.user_authority GROUP BY user_id)) ua ON u.id_code = ua.user_id WHERE login = :login; diff --git a/DSL/Resql/rag-search/POST/get-user.sql b/DSL/Resql/rag-search/POST/get-user.sql index 18bef7ff..48c83912 100644 --- a/DSL/Resql/rag-search/POST/get-user.sql +++ b/DSL/Resql/rag-search/POST/get-user.sql @@ -1,5 +1,5 @@ SELECT id_code -FROM "user" +FROM rag_search."user" WHERE id_code = :userIdCode AND status <> 'deleted' - AND id IN (SELECT max(id) FROM "user" WHERE id_code = :userIdCode) \ No newline at end of file + AND id IN (SELECT max(id) FROM rag_search."user" WHERE id_code = :userIdCode) \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/get-users-with-roles-by-role.sql b/DSL/Resql/rag-search/POST/get-users-with-roles-by-role.sql index 50ec5199..a7805495 100644 --- a/DSL/Resql/rag-search/POST/get-users-with-roles-by-role.sql +++ b/DSL/Resql/rag-search/POST/get-users-with-roles-by-role.sql @@ -7,14 +7,14 @@ SELECT u.login, u.csa_email, ua.authority_name AS authorities, CEIL(COUNT(*) OVER() / :page_size::DECIMAL) AS total_pages -FROM "user" u +FROM rag_search."user" u LEFT JOIN ( SELECT authority_name, user_id, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY id DESC) AS rn - FROM user_authority AS ua + FROM rag_search.user_authority AS ua WHERE authority_name && ARRAY [ :roles ]::character varying array AND ua.id IN ( SELECT max(id) - FROM user_authority + FROM rag_search.user_authority GROUP BY user_id ) ) ua ON u.id_code = ua.user_id @@ -22,7 +22,7 @@ WHERE u.status <> 'deleted' AND array_length(authority_name, 1) > 0 AND u.id IN ( SELECT max(id) - FROM "user" + FROM rag_search."user" GROUP BY id_code ) ORDER BY diff --git a/DSL/Resql/rag-search/POST/insert-endpoint.sql b/DSL/Resql/rag-search/POST/insert-endpoint.sql new file mode 100644 index 00000000..1e05f07d --- /dev/null +++ b/DSL/Resql/rag-search/POST/insert-endpoint.sql @@ -0,0 +1,33 @@ +-- Insert a new API endpoint into the endpoints table +-- Returns the generated endpoint_id for use in indexing pipeline + +INSERT INTO rag_search.mock_endpoints ( + service_id, + name, + description, + type, + visibility, + method, + url, + params +) VALUES ( + NULLIF(:serviceId, '')::uuid, + :name, + :description, + :type, + :visibility, + :method, + :url, + :params::jsonb +) RETURNING + endpoint_id, + service_id, + name, + description, + type, + visibility, + method, + url, + params, + created_at, + updated_at diff --git a/DSL/Resql/rag-search/POST/insert-llm-connection.sql b/DSL/Resql/rag-search/POST/insert-llm-connection.sql index 29465ced..5b606b1b 100644 --- a/DSL/Resql/rag-search/POST/insert-llm-connection.sql +++ b/DSL/Resql/rag-search/POST/insert-llm-connection.sql @@ -1,4 +1,4 @@ -INSERT INTO llm_connections ( +INSERT INTO rag_search.llm_connections ( connection_name, llm_platform, llm_model, diff --git a/DSL/Resql/rag-search/POST/insert-prompt-configuration.sql b/DSL/Resql/rag-search/POST/insert-prompt-configuration.sql new file mode 100644 index 00000000..e3486b0d --- /dev/null +++ b/DSL/Resql/rag-search/POST/insert-prompt-configuration.sql @@ -0,0 +1,3 @@ +INSERT INTO rag_search.prompt_configuration (prompt) +VALUES (:prompt) +RETURNING id, prompt diff --git a/DSL/Resql/rag-search/POST/insert-user-role.sql b/DSL/Resql/rag-search/POST/insert-user-role.sql index e2bfe3b4..963e8d64 100644 --- a/DSL/Resql/rag-search/POST/insert-user-role.sql +++ b/DSL/Resql/rag-search/POST/insert-user-role.sql @@ -1,2 +1,2 @@ -INSERT INTO user_authority (user_id, authority_name, created) +INSERT INTO rag_search.user_authority (user_id, authority_name, created) VALUES (:userIdCode, ARRAY [ :roles ], :created::timestamp with time zone); \ No newline at end of file diff --git a/DSL/Resql/rag-search/POST/insert-user.sql b/DSL/Resql/rag-search/POST/insert-user.sql index 0fd7c12b..c30629e7 100644 --- a/DSL/Resql/rag-search/POST/insert-user.sql +++ b/DSL/Resql/rag-search/POST/insert-user.sql @@ -1,2 +1,2 @@ -INSERT INTO "user" (login, first_name, last_name, display_name, password_hash, id_code, status, created, csa_title, csa_email) +INSERT INTO rag_search."user" (login, first_name, last_name, display_name, password_hash, id_code, status, created, csa_title, csa_email) VALUES (:userIdCode, :firstName, :lastName, :displayName, :displayName, :userIdCode, (:status)::user_status, :created::timestamp with time zone, :csaTitle, :csaEmail); diff --git a/DSL/Resql/rag-search/POST/mock-count-active-services.sql b/DSL/Resql/rag-search/POST/mock-count-active-services.sql new file mode 100644 index 00000000..d68d273f --- /dev/null +++ b/DSL/Resql/rag-search/POST/mock-count-active-services.sql @@ -0,0 +1,11 @@ +-- Count active services for tool classifier +-- Used by Service Workflow to determine search strategy: +-- - If count <= 50: Use all services for LLM context +-- - If count > 50: Use Qdrant semantic search for top 20 + +SELECT + COUNT(*) AS active_service_count +FROM + public.services +WHERE + current_state = 'active'; diff --git a/DSL/Resql/rag-search/POST/mock-get-all-active-services.sql b/DSL/Resql/rag-search/POST/mock-get-all-active-services.sql new file mode 100644 index 00000000..5bd981b7 --- /dev/null +++ b/DSL/Resql/rag-search/POST/mock-get-all-active-services.sql @@ -0,0 +1,20 @@ +-- Get all active services for intent detection +-- Used when active_service_count <= 50 +-- Returns all service metadata needed for LLM intent detection + +SELECT + service_id, + name, + description, + ruuter_type, + slot, + entities, + examples, + structure, + endpoints +FROM + public.services +WHERE + current_state = 'active' +ORDER BY + name ASC; diff --git a/DSL/Resql/rag-search/POST/mock-get-data-from-kb.sql b/DSL/Resql/rag-search/POST/mock-get-data-from-kb.sql index 9c9dc1bf..85ee7d12 100644 --- a/DSL/Resql/rag-search/POST/mock-get-data-from-kb.sql +++ b/DSL/Resql/rag-search/POST/mock-get-data-from-kb.sql @@ -2,4 +2,4 @@ SELECT client_id, client_data_hash, signed_s3_url -FROM public.mock_ckb; +FROM rag_search.mock_ckb; diff --git a/DSL/Resql/rag-search/POST/mock-get-service-by-id.sql b/DSL/Resql/rag-search/POST/mock-get-service-by-id.sql new file mode 100644 index 00000000..dbf375ae --- /dev/null +++ b/DSL/Resql/rag-search/POST/mock-get-service-by-id.sql @@ -0,0 +1,24 @@ +-- Get specific service by service_id for validation +-- Used after LLM detects intent to validate the service exists and is active +-- Returns all service details needed to trigger the external service call + +SELECT + id, + service_id, + name, + description, + ruuter_type, + current_state, + is_common, + slot, + entities, + examples, + structure, + endpoints, + created_at, + updated_at +FROM + public.services +WHERE + service_id = :serviceId + AND current_state = 'active'; diff --git a/DSL/Resql/rag-search/POST/reset-llm-connection-used-budget.sql b/DSL/Resql/rag-search/POST/reset-llm-connection-used-budget.sql index 581f0b9c..3f7c3fca 100644 --- a/DSL/Resql/rag-search/POST/reset-llm-connection-used-budget.sql +++ b/DSL/Resql/rag-search/POST/reset-llm-connection-used-budget.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET used_budget = 0.00 WHERE connection_status <> 'deleted' diff --git a/DSL/Resql/rag-search/POST/store-inference-result.sql b/DSL/Resql/rag-search/POST/store-inference-result.sql index 089e92d9..110a937e 100644 --- a/DSL/Resql/rag-search/POST/store-inference-result.sql +++ b/DSL/Resql/rag-search/POST/store-inference-result.sql @@ -1,4 +1,4 @@ -INSERT INTO inference_results ( +INSERT INTO rag_search.inference_results ( chat_id, user_question, refined_questions, diff --git a/DSL/Resql/rag-search/POST/store-production-inference-result.sql b/DSL/Resql/rag-search/POST/store-production-inference-result.sql new file mode 100644 index 00000000..6efa79e6 --- /dev/null +++ b/DSL/Resql/rag-search/POST/store-production-inference-result.sql @@ -0,0 +1,31 @@ +INSERT INTO rag_search.inference_results ( + chat_id, + user_question, + refined_questions, + conversation_history, + ranked_chunks, + embedding_scores, + final_answer, + environment, + created_at +) VALUES ( + :chat_id, + :user_question, + :refined_questions::JSONB, + :conversation_history::JSONB, + :ranked_chunks::JSONB, + :embedding_scores::JSONB, + :final_answer, + :environment, + :created_at::timestamp with time zone +) RETURNING + id, + chat_id, + user_question, + refined_questions, + conversation_history, + ranked_chunks, + embedding_scores, + final_answer, + environment, + created_at; diff --git a/DSL/Resql/rag-search/POST/store-testing-inference-result.sql b/DSL/Resql/rag-search/POST/store-testing-inference-result.sql index faf9a2c3..bc08acba 100644 --- a/DSL/Resql/rag-search/POST/store-testing-inference-result.sql +++ b/DSL/Resql/rag-search/POST/store-testing-inference-result.sql @@ -1,4 +1,4 @@ -INSERT INTO inference_results ( +INSERT INTO rag_search.inference_results ( llm_connection_id, user_question, final_answer, diff --git a/DSL/Resql/rag-search/POST/update-agency-hash.sql b/DSL/Resql/rag-search/POST/update-agency-hash.sql index 38827389..0bf342ec 100644 --- a/DSL/Resql/rag-search/POST/update-agency-hash.sql +++ b/DSL/Resql/rag-search/POST/update-agency-hash.sql @@ -1,11 +1,11 @@ -UPDATE public.agency_sync +UPDATE rag_search.agency_sync SET agency_data_hash = :newAgencyDataHash, data_url = :dataUrl, updated_at = NOW() -WHERE agency_id = :agencyId +WHERE id = :id RETURNING - agency_id, + id, agency_data_hash, data_url, updated_at; diff --git a/DSL/Resql/rag-search/POST/update-llm-connection-environment.sql b/DSL/Resql/rag-search/POST/update-llm-connection-environment.sql index 5b894c99..1e20a1ce 100644 --- a/DSL/Resql/rag-search/POST/update-llm-connection-environment.sql +++ b/DSL/Resql/rag-search/POST/update-llm-connection-environment.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET environment = :environment WHERE id = :connection_id diff --git a/DSL/Resql/rag-search/POST/update-llm-connection-status.sql b/DSL/Resql/rag-search/POST/update-llm-connection-status.sql index f71194aa..644372a9 100644 --- a/DSL/Resql/rag-search/POST/update-llm-connection-status.sql +++ b/DSL/Resql/rag-search/POST/update-llm-connection-status.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET connection_status = :connection_status WHERE id = :connection_id RETURNING diff --git a/DSL/Resql/rag-search/POST/update-llm-connection-used-budget.sql b/DSL/Resql/rag-search/POST/update-llm-connection-used-budget.sql index ba6cd4d4..2f4bd4ec 100644 --- a/DSL/Resql/rag-search/POST/update-llm-connection-used-budget.sql +++ b/DSL/Resql/rag-search/POST/update-llm-connection-used-budget.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET used_budget = used_budget + :usage WHERE id = :connection_id diff --git a/DSL/Resql/rag-search/POST/update-llm-connection.sql b/DSL/Resql/rag-search/POST/update-llm-connection.sql index 3fa7bc66..91f0bacb 100644 --- a/DSL/Resql/rag-search/POST/update-llm-connection.sql +++ b/DSL/Resql/rag-search/POST/update-llm-connection.sql @@ -1,4 +1,4 @@ -UPDATE llm_connections +UPDATE rag_search.llm_connections SET connection_name = :connection_name, llm_platform = :llm_platform, diff --git a/DSL/Resql/rag-search/POST/update-prompt-configuration.sql b/DSL/Resql/rag-search/POST/update-prompt-configuration.sql new file mode 100644 index 00000000..b3ee7972 --- /dev/null +++ b/DSL/Resql/rag-search/POST/update-prompt-configuration.sql @@ -0,0 +1,4 @@ +UPDATE rag_search.prompt_configuration +SET prompt = :prompt +WHERE id = :id +RETURNING id, prompt diff --git a/DSL/Resql/rag-search/POST/update-user.sql b/DSL/Resql/rag-search/POST/update-user.sql index 688e8df7..7a2639d4 100644 --- a/DSL/Resql/rag-search/POST/update-user.sql +++ b/DSL/Resql/rag-search/POST/update-user.sql @@ -1,4 +1,4 @@ -INSERT INTO "user" (id_code, login, password_hash, first_name, last_name, display_name, status, created, csa_title, csa_email) +INSERT INTO rag_search."user" (id_code, login, password_hash, first_name, last_name, display_name, status, created, csa_title, csa_email) SELECT :userIdCode, login, @@ -10,7 +10,7 @@ SELECT :created::timestamp with time zone, :csaTitle, :csaEmail -FROM "user" +FROM rag_search."user" WHERE id = ( - SELECT MAX(id) FROM "user" WHERE id_code = :userIdCode + SELECT MAX(id) FROM rag_search."user" WHERE id_code = :userIdCode ); diff --git a/DSL/Ruuter.private/rag-search/GET/prompt-configuration/get.yml b/DSL/Ruuter.private/rag-search/GET/prompt-configuration/get.yml new file mode 100644 index 00000000..69e513cb --- /dev/null +++ b/DSL/Ruuter.private/rag-search/GET/prompt-configuration/get.yml @@ -0,0 +1,39 @@ +declaration: + call: declare + version: 0.1 + description: "Get prompt configuration" + method: get + accepts: json + returns: json + namespace: rag-search + +get_prompt_configuration: + call: http.get + args: + url: "[#RAG_SEARCH_RESQL]/get-prompt-configuration" + result: prompt_result + next: check_prompt_exists + +check_prompt_exists: + switch: + - condition: "${prompt_result.response.body.length > 0}" + next: transform_response + next: transform_empty_response + +transform_response: + assign: + data: ${prompt_result.response.body} + next: return_success + +transform_empty_response: + assign: + emptyData: [] + next: return_empty + +return_success: + return: ${data} + next: end + +return_empty: + return: ${emptyData} + next: end diff --git a/DSL/Ruuter.private/rag-search/POST/ckb/agency_data_import.yml b/DSL/Ruuter.private/rag-search/POST/ckb/agency_data_import.yml index ba892e5e..9930964a 100644 --- a/DSL/Ruuter.private/rag-search/POST/ckb/agency_data_import.yml +++ b/DSL/Ruuter.private/rag-search/POST/ckb/agency_data_import.yml @@ -1,33 +1,33 @@ -declaration: - call: declare - version: 0.1 - description: "Get agency data information by agency IDs" - method: post - accepts: json - returns: json - namespace: rag-search - allowlist: - body: - - field: agencyIds - type: array - description: "Array of unique institution IDs" +# declaration: +# call: declare +# version: 0.1 +# description: "Get agency data information by agency IDs" +# method: post +# accepts: json +# returns: json +# namespace: rag-search +# allowlist: +# body: +# - field: agencyIds +# type: array +# description: "Array of unique institution IDs" -extractRequestData: - assign: - agencyIds: ${incoming.body.agencyIds || []} - log: "Received request for agency data: ${agencyIds}" +# extractRequestData: +# assign: +# agencyIds: ${incoming.body.agencyIds || []} +# log: "Received request for agency data: ${agencyIds}" -get_agency_data: - call: http.post - args: - url: "[#GLOBAL_CLASSIFIER_RESQL]/mock-get-data-from-kb" - headers: - type: json - body: - agencyIds: ${agencyIds} - result: agency_data_info - next: return_result +# get_agency_data: +# call: http.post +# args: +# url: "[#GLOBAL_CLASSIFIER_RESQL]/mock-get-data-from-kb" +# headers: +# type: json +# body: +# agencyIds: ${agencyIds} +# result: agency_data_info +# next: return_result -return_result: - return: ${agency_data_info.response.body} - next: end \ No newline at end of file +# return_result: +# return: ${agency_data_info.response.body} +# next: end \ No newline at end of file diff --git a/DSL/Ruuter.private/rag-search/POST/llm-connections/edit.yml b/DSL/Ruuter.private/rag-search/POST/llm-connections/edit.yml index 84b375d1..6ae6f10e 100644 --- a/DSL/Ruuter.private/rag-search/POST/llm-connections/edit.yml +++ b/DSL/Ruuter.private/rag-search/POST/llm-connections/edit.yml @@ -119,9 +119,38 @@ check_connection_exists: validate_connection_exists: switch: - condition: "${existing_connection.response.body.length > 0}" - next: update_llm_connection + next: check_deployment_environment next: return_not_found +check_deployment_environment: + switch: + - condition: ${environment == "production" && existing_connection.response.body[0].environment == "testing"} + next: get_existing_production_connection + next: update_llm_connection + +get_existing_production_connection: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/get-production-connection" + result: existing_production_result + next: update_existing_production_to_testing + +update_existing_production_to_testing: + switch: + - condition: ${existing_production_result.response.body && existing_production_result.response.body.length > 0} + next: update_production_connection + next: update_llm_connection + +update_production_connection: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/update-llm-connection-environment" + body: + connection_id: ${existing_production_result.response.body[0].id} + environment: "testing" + result: update_result + next: update_llm_connection + update_llm_connection: call: http.post args: @@ -169,4 +198,4 @@ return_invalid_environment: return_unauthorized: status: 401 return: "error: unauthorized" - next: end + next: end \ No newline at end of file diff --git a/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml new file mode 100644 index 00000000..bd49b4f5 --- /dev/null +++ b/DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml @@ -0,0 +1,83 @@ +declaration: + call: declare + version: 0.1 + description: "Update or insert prompt configuration" + method: post + accepts: json + returns: json + namespace: rag-search + allowlist: + body: + - field: prompt + type: string + description: "Prompt text to save" + +extract_request_data: + assign: + prompt: ${incoming.body.prompt ?? ""} + next: initialize_results + +initialize_results: + assign: + update_result: null + insert_result: null + next: get_existing_prompt + +get_existing_prompt: + call: http.get + args: + url: "[#RAG_SEARCH_RESQL]/get-prompt-configuration" + result: existing_prompt + next: check_if_exists + +check_if_exists: + switch: + - condition: "${existing_prompt.response.body.length > 0}" + next: update_prompt + next: insert_prompt + +update_prompt: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/update-prompt-configuration" + body: + id: ${existing_prompt.response.body[0].id} + prompt: ${prompt} + result: update_result + next: refresh_llm_cache + +insert_prompt: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/insert-prompt-configuration" + body: + prompt: ${prompt} + result: insert_result + next: refresh_llm_cache + +refresh_llm_cache: + call: http.post + args: + url: "[#RAG_SEARCH_PROMPT_REFRESH]" + body: {} + result: refresh_result + next: check_operation_type + on_error: handle_refresh_error + +handle_refresh_error: + log: "Prompt refresh failed, will use TTL cache fallback" + next: check_operation_type + +check_operation_type: + switch: + - condition: "${update_result != null}" + next: return_update_success + next: return_insert_success + +return_update_success: + return: ${update_result.response.body[0]} + next: end + +return_insert_success: + return: ${insert_result.response.body[0]} + next: end \ No newline at end of file diff --git a/DSL/Ruuter.private/rag-search/POST/vault/secret/create.yml b/DSL/Ruuter.private/rag-search/POST/vault/secret/create.yml index 3fa2f460..b6a533d9 100644 --- a/DSL/Ruuter.private/rag-search/POST/vault/secret/create.yml +++ b/DSL/Ruuter.private/rag-search/POST/vault/secret/create.yml @@ -5,7 +5,7 @@ declaration: method: post accepts: json returns: json - namespace: classifier + namespace: rag-search allowlist: body: - field: connectionId diff --git a/DSL/Ruuter.private/rag-search/POST/vault/secret/delete.yml b/DSL/Ruuter.private/rag-search/POST/vault/secret/delete.yml index 7cf146f5..f0a72200 100644 --- a/DSL/Ruuter.private/rag-search/POST/vault/secret/delete.yml +++ b/DSL/Ruuter.private/rag-search/POST/vault/secret/delete.yml @@ -5,7 +5,7 @@ declaration: method: post accepts: json returns: json - namespace: classifier + namespace: rag-search allowlist: body: - field: connectionId diff --git a/DSL/Ruuter.public/rag-search/GET/services/get-services.yml b/DSL/Ruuter.public/rag-search/GET/services/get-services.yml new file mode 100644 index 00000000..01356d94 --- /dev/null +++ b/DSL/Ruuter.public/rag-search/GET/services/get-services.yml @@ -0,0 +1,60 @@ +declaration: + call: declare + version: 0.1 + description: "Get services for intent detection - returns all services if count <= 10, otherwise signals to use semantic search" + method: get + returns: json + namespace: rag-search + +# Step 1: Count active services +count_services: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/mock-count-active-services" + body: {} + result: count_result + next: check_service_count + +# Step 2: Check if count > threshold (10) +check_service_count: + assign: + service_count: ${Number(count_result.response.body[0].active_service_count)} + switch: + - condition: "${service_count > 10}" + next: return_semantic_search_flag + next: fetch_all_services + +# Step 3a: If > 10, return flag for semantic search +return_semantic_search_flag: + assign: + semantic_search_response: + use_semantic_search: true + service_count: ${service_count} + message: "Service count exceeds threshold - use semantic search" + next: return_semantic_search_response + +return_semantic_search_response: + return: ${semantic_search_response} + next: end + +# Step 3b: If <= 10, fetch all services +fetch_all_services: + call: http.post + args: + url: "[#RAG_SEARCH_RESQL]/mock-get-all-active-services" + body: {} + result: services_result + next: return_all_services + +# Step 4: Return all services for LLM +return_all_services: + assign: + all_services_response: + use_semantic_search: false + service_count: ${services_result.response.body.length} + services: ${services_result.response.body} + next: return_all_services_response + +return_all_services_response: + return: ${all_services_response} + next: end diff --git a/DSL/Ruuter.public/rag-search/POST/api-tools/index.yml b/DSL/Ruuter.public/rag-search/POST/api-tools/index.yml new file mode 100644 index 00000000..98de449f --- /dev/null +++ b/DSL/Ruuter.public/rag-search/POST/api-tools/index.yml @@ -0,0 +1,125 @@ +declaration: + call: declare + version: 0.1 + description: "Trigger API Tool endpoint indexing in Qdrant" + method: post + accepts: json + returns: json + namespace: rag-search + allowlist: + body: + - field: endpointId + type: string + description: "Unique endpoint identifier" + - field: serviceId + type: string + description: "Parent service ID" + - field: name + type: string + description: "Endpoint name" + - field: description + type: string + description: "Endpoint description" + - field: method + type: string + description: "HTTP Method" + - field: url + type: string + description: "API URL" + - field: visibility + type: string + description: "public or private" + - field: type + type: string + description: "custom_endpoint" + - field: params + type: array + description: "List of parameters" + +extract_request_data: + assign: + endpoint_id: ${incoming.body.endpointId} + service_id: ${incoming.body.serviceId || ''} + name: ${incoming.body.name} + description: ${incoming.body.description} + method: ${incoming.body.method || 'GET'} + url: ${incoming.body.url} + visibility: ${incoming.body.visibility || 'public'} + type: ${incoming.body.type || 'custom_endpoint'} + params: ${encodeURIComponent(JSON.stringify(incoming.body.params || []))} + next: validate_required_fields + +validate_required_fields: + switch: + - condition: "${!endpoint_id || !name || !description || !url}" + next: return_missing_fields + next: execute_indexing + +return_missing_fields: + assign: + error_data: { + success: false, + error: "MISSING_REQUIRED_FIELDS", + message: "endpointId, name, description, and url are required" + } + next: return_bad_request + +execute_indexing: + call: http.post + args: + url: "[#RAG_SEARCH_CRON_MANAGER]/execute/api_tool_indexer/index_endpoint" + query: + endpoint_id: ${endpoint_id} + service_id: ${service_id} + name: ${name} + description: ${description} + method: ${method} + url: ${url} + visibility: ${visibility} + type: ${type} + params: ${params} + result: indexing_result + on_error: handle_cron_error + next: check_indexing_status + +check_indexing_status: + switch: + - condition: ${200 <= indexing_result.response.statusCodeValue && indexing_result.response.statusCodeValue < 300} + next: assign_success + next: assign_cron_failure + +handle_cron_error: + log: "ERROR: Failed to queue api_tool indexing job - ${indexing_result.error || 'CronManager unreachable'}" + next: assign_cron_failure + +assign_cron_failure: + assign: + response_data: + success: false + error: "INDEXING_QUEUE_FAILED" + message: "Failed to queue indexing job. CronManager may be unavailable." + details: ${indexing_result.error} + next: return_server_error + +assign_success: + assign: + response_data: + success: true + endpoint_id: ${endpoint_id} + message: "API Tool indexing job queued successfully. Processing asynchronously." + next: return_ok + +return_ok: + status: 200 + return: ${response_data} + next: end + +return_bad_request: + status: 400 + return: ${error_data} + next: end + +return_server_error: + status: 500 + return: ${response_data} + next: end \ No newline at end of file diff --git a/DSL/Ruuter.public/rag-search/POST/ckb/agency-data-import.yml b/DSL/Ruuter.public/rag-search/POST/ckb/agency-data-import.yml index 9905b270..79ffb949 100644 --- a/DSL/Ruuter.public/rag-search/POST/ckb/agency-data-import.yml +++ b/DSL/Ruuter.public/rag-search/POST/ckb/agency-data-import.yml @@ -1,32 +1,32 @@ declaration: call: declare version: 0.1 - description: "Get agency data information by agency IDs" + description: "Get agency data from CKB import endpoint" method: post accepts: json returns: json namespace: rag-search - allowlist: - body: - - field: agencyIds - type: array - description: "Array of unique institution IDs" - -extractRequestData: - assign: - agencyIds: ${incoming.body.agencyIds || []} - log: "Received request for agency data: ${agencyIds}" get_agency_data: - call: http.post + call: http.get args: - url: "[#RAG_SEARCH_RESQL]/mock-get-data-from-kb" - headers: - type: json - body: - agencyIds: ${agencyIds} + url: "[#CKB_RUUTER_INTERNAL]/client/data/import" result: agency_data_info next: return_result + on_error: handle_ckb_error + +handle_ckb_error: + assign: + error_response: + success: false + message: "CKB data import failed" + log: "ERROR: Failed to fetch agency data from CKB - ${agency_data_info.error.message}" + next: return_error + +return_error: + status: 500 + return: ${error_response} + next: end return_result: return: ${agency_data_info.response.body} diff --git a/DSL/Ruuter.public/rag-search/POST/data/update.yml b/DSL/Ruuter.public/rag-search/POST/data/update.yml index a3f21ea1..c3e4c83b 100644 --- a/DSL/Ruuter.public/rag-search/POST/data/update.yml +++ b/DSL/Ruuter.public/rag-search/POST/data/update.yml @@ -15,12 +15,12 @@ get_agency_id: next: log_result log_result: - log: ${get_agency_id_result.response.body[0].agencyId} + log: ${get_agency_id_result.response.body[0].id} next: extract_params extract_params: assign: - single_agency_id: ${get_agency_id_result.response.body[0].agencyId} + single_agency_id: ${get_agency_id_result.response.body[0].id} agency_ids: - ${single_agency_id} current_data_hash: ${get_agency_id_result.response.body[0].agencyDataHash} @@ -34,34 +34,65 @@ import_agency_data: call: http.post args: url: "[#RAG_SEARCH_RUUTER_PUBLIC]/ckb/agency-data-import" - body: - agencyIds: ${agency_ids} result: importResult next: log_import_agency_data_response + on_error: handle_import_error + +handle_import_error: + log: "ERROR: Failed to import agency data - ${importResult.error.message}" + assign: + format_res: { + message: "Failed to fetch data from CKB - data synchronization failed", + operationSuccessful: false, + error: "CKB_IMPORT_FAILED" + } + next: return_bad_request log_import_agency_data_response: log: ${JSON.stringify(importResult.response)} + next: check_ckb_response + +check_ckb_response: + switch: + - condition: ${importResult.response.body == null || importResult.response.body.response == null || importResult.response.body.response == "Internal Server Error" || importResult.response.body.response.response == null} + next: return_ckb_error_response + - condition: ${importResult.response.body.response.response.length === 0} + next: return_no_ckb_data next: assign_import_agency_data +return_ckb_error_response: + assign: + format_res: + message: "CKB service returned an error - data synchronization aborted" + operationSuccessful: false + error: "CKB_ERROR" + next: return_bad_request + +return_no_ckb_data: + assign: + error_response: + success: false + message: "Data synchronization failed - CKB agency data not found" + next: return_ckb_data_error + assign_import_agency_data: assign: - ckb_data_hash: ${importResult.response.body.response[0].clientDataHash} - signed_s3_url: ${importResult.response.body.response[0].signedS3Url} + ckb_data_hash: ${importResult.response.body.response.response[0].agencyDataHash} + signed_s3_url: ${importResult.response.body.response.response[0].signedS3Url} next: check_has_match check_has_match: switch: - - condition: ${current_data_hash === importResult.response.body.response[0].clientDataHash} + - condition: ${current_data_hash !== null && current_data_hash === importResult.response.body.response.response[0].agencyDataHash} next: noAgencyData - - condition: true - next: sync_current_hash_with_ckb_latest_hash + next: sync_current_hash_with_ckb_latest_hash sync_current_hash_with_ckb_latest_hash: call: http.post args: url: "[#RAG_SEARCH_RESQL]/update-agency-hash" body: - agencyId: ${single_agency_id} + id: ${single_agency_id} newAgencyDataHash: ${ckb_data_hash} dataUrl: ${signed_s3_url} result: sync_agency_hash_result @@ -105,10 +136,9 @@ assign_success_response: assign_fail_response: assign: - format_res: { - message: "Data synchronization failed", - operationSuccessful: false, - } + format_res: + message: "Data synchronization failed" + operationSuccessful: false next: return_bad_request return_ok: @@ -132,4 +162,8 @@ return_no_sync_needed: status: 200 return: ${response_data} next: end - \ No newline at end of file + +return_ckb_data_error: + status: 404 + return: ${error_response} + next: end \ No newline at end of file diff --git a/DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml b/DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml new file mode 100644 index 00000000..125aa7ff --- /dev/null +++ b/DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml @@ -0,0 +1,34 @@ +declaration: + call: declare + version: 0.1 + description: "Get custom prompt configuration from database" + method: post + accepts: json + returns: json + namespace: rag-search + +get_prompt_configuration: + call: http.get + args: + url: "[#RAG_SEARCH_RESQL]/get-prompt-configuration" + result: prompt_result + next: check_prompt_exists + +check_prompt_exists: + switch: + - condition: "${prompt_result.response.body.length > 0}" + next: return_result + next: return_empty + +return_result: + return: ${prompt_result.response.body[0]} + next: end + +return_empty: + assign: + emptyData: {} + next: return_empty_response + +return_empty_response: + return: ${emptyData} + next: end diff --git a/DSL/Ruuter.public/rag-search/POST/services/enrich.yml b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml new file mode 100644 index 00000000..5748ad59 --- /dev/null +++ b/DSL/Ruuter.public/rag-search/POST/services/enrich.yml @@ -0,0 +1,120 @@ +declaration: + call: declare + version: 0.1 + description: "Enrich service data and index in Qdrant (async via CronManager)" + method: post + accepts: json + returns: json + namespace: rag-search + allowlist: + body: + - field: service_id + type: string + description: "Unique service identifier" + - field: name + type: string + description: "Service name" + - field: description + type: string + description: "Service description" + - field: examples + type: array + description: "Example queries" + - field: entities + type: array + description: "Expected entity names" + - field: ruuter_type + type: string + description: "HTTP method (GET/POST)" + - field: current_state + type: string + description: "Service state (active/inactive/draft)" + - field: is_common + type: boolean + description: "Is common service" + +extract_request_data: + assign: + service_id: ${incoming.body.service_id} + service_name: ${incoming.body.name} + service_description: ${incoming.body.description} + service_examples: ${encodeURIComponent(JSON.stringify(incoming.body.examples) || '[]')} + service_entities: ${encodeURIComponent(JSON.stringify(incoming.body.entities) || '[]')} + service_ruuter_type: ${incoming.body.ruuter_type || 'GET'} + service_current_state: ${incoming.body.current_state || 'draft'} + service_is_common: ${incoming.body.is_common || false} + next: validate_required_fields + +validate_required_fields: + switch: + - condition: "${!service_id || !service_name || !service_description}" + next: return_missing_fields + next: execute_enrichment + +return_missing_fields: + assign: + error_data: { + success: false, + error: "MISSING_REQUIRED_FIELDS", + message: "service_id, name, and description are required" + } + next: return_bad_request + +execute_enrichment: + call: http.post + args: + url: "[#RAG_SEARCH_CRON_MANAGER]/execute/service_enrichment/enrich_and_index" + query: + service_id: ${service_id} + name: ${service_name} + description: ${service_description} + examples: ${service_examples} + entities: ${service_entities} + ruuter_type: ${service_ruuter_type} + current_state: ${service_current_state} + is_common: ${service_is_common} + result: enrichment_result + on_error: handle_cron_error + next: check_enrichment_status + +check_enrichment_status: + switch: + - condition: ${200 <= enrichment_result.response.statusCodeValue && enrichment_result.response.statusCodeValue < 300} + next: assign_success + next: assign_cron_failure + +handle_cron_error: + log: "ERROR: Failed to queue enrichment job - ${enrichment_result.error || 'CronManager unreachable'}" + next: assign_cron_failure + +assign_cron_failure: + assign: + response_data: + success: false + error: "ENRICHMENT_QUEUE_FAILED" + message: "Failed to queue enrichment job. CronManager may be unavailable." + details: ${enrichment_result.error} + next: return_server_error + +assign_success: + assign: + response_data: + success: true + service_id: ${service_id} + message: "Service enrichment job queued successfully. Processing asynchronously." + next: return_ok + +return_ok: + status: 200 + return: ${response_data} + next: end + +return_bad_request: + status: 400 + return: ${error_data} + next: end + +return_server_error: + status: 500 + return: ${response_data} + next: end diff --git a/GUI/.env.development b/GUI/.env.development index 39f5e47a..ae5b1356 100644 --- a/GUI/.env.development +++ b/GUI/.env.development @@ -2,6 +2,6 @@ REACT_APP_RUUTER_API_URL=http://localhost:8086 REACT_APP_RUUTER_PRIVATE_API_URL=http://localhost:8088 REACT_APP_CUSTOMER_SERVICE_LOGIN=http://localhost:3004/et/dev-auth REACT_APP_SERVICE_ID=conversations,settings,monitoring -REACT_APP_NOTIFICATION_NODE_URL=http://localhost:3005 -REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:3005 ws://localhost; +REACT_APP_NOTIFICATION_NODE_URL=http://localhost:4040 +REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:4040 ws://localhost; REACT_APP_ENABLE_HIDDEN_FEATURES=TRUE \ No newline at end of file diff --git a/GUI/Dockerfile.dev b/GUI/Dockerfile.dev index 48b7890e..613b6bc9 100644 --- a/GUI/Dockerfile.dev +++ b/GUI/Dockerfile.dev @@ -1,9 +1,9 @@ FROM node:22.0.0-alpine AS image WORKDIR /app -COPY ./package.json . +COPY ./package.json ./package-lock.json ./ FROM image AS build -RUN npm install --legacy-peer-deps --mode=development +RUN npm ci --legacy-peer-deps COPY . . RUN ./node_modules/.bin/vite build --mode=development diff --git a/GUI/package-lock.json b/GUI/package-lock.json index 436ec9c4..c0f45b17 100644 --- a/GUI/package-lock.json +++ b/GUI/package-lock.json @@ -50,6 +50,7 @@ "react-i18next": "^12.1.1", "react-icons": "^4.10.1", "react-idle-timer": "^5.5.2", + "react-markdown": "^10.1.0", "react-modal": "^3.16.1", "react-redux": "^8.1.1", "react-router-dom": "^6.5.0", @@ -58,6 +59,7 @@ "react-textarea-autosize": "^8.4.0", "reactflow": "^11.4.0", "regexify-string": "^1.0.19", + "remark-gfm": "^4.0.1", "rxjs": "^7.8.1", "timeago.js": "^4.0.2", "usehooks-ts": "^2.9.1", @@ -6847,7 +6849,6 @@ "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, "dependencies": { "@types/ms": "*" } @@ -6855,14 +6856,31 @@ "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } }, "node_modules/@types/geojson": { "version": "7946.0.14", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -6911,11 +6929,19 @@ "@types/lodash": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==", - "dev": true + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/node": { "version": "18.19.34", @@ -7008,6 +7034,12 @@ "@types/node": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", @@ -7711,8 +7743,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/@vitejs/plugin-react": { "version": "3.1.0", @@ -8187,6 +8218,16 @@ "babel-plugin-transform-react-remove-prop-types": "^0.4.24" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8414,6 +8455,16 @@ } ] }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -8427,6 +8478,46 @@ "node": ">=4" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chardet": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", @@ -8624,6 +8715,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -8954,6 +9055,19 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9031,6 +9145,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -9047,6 +9170,19 @@ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -10120,6 +10256,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -10149,6 +10295,12 @@ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -10738,6 +10890,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.3.0.tgz", @@ -10775,6 +10967,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/htmlnano": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-2.1.1.tgz", @@ -10982,6 +11184,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/inquirer": { "version": "8.2.6", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", @@ -11101,6 +11309,30 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -11267,6 +11499,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11326,6 +11568,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-interactive": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", @@ -11393,6 +11645,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -12073,6 +12337,16 @@ "node": ">=8" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12105,6 +12379,16 @@ "node": ">=12" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/material-colors": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", @@ -12120,80 +12404,925 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" - }, - "node_modules/memoize-one": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", - "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mimic-fn": { + "node_modules/mdast-util-gfm-footnote": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "funding": { @@ -12929,6 +14058,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13139,6 +14293,16 @@ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -13353,6 +14517,33 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-modal": { "version": "3.16.1", "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", @@ -13788,6 +14979,72 @@ "jsesc": "bin/jsesc" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remove-accents": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", @@ -14226,6 +15483,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/srcset": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", @@ -14396,6 +15663,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -14429,6 +15710,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -14572,6 +15871,26 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -14834,6 +16153,93 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universal-cookie": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz", @@ -15036,6 +16442,34 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", @@ -15855,6 +17289,16 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/GUI/package.json b/GUI/package.json index 09ab4a81..ec9c4e78 100644 --- a/GUI/package.json +++ b/GUI/package.json @@ -53,6 +53,7 @@ "react-i18next": "^12.1.1", "react-icons": "^4.10.1", "react-idle-timer": "^5.5.2", + "react-markdown": "^10.1.0", "react-modal": "^3.16.1", "react-redux": "^8.1.1", "react-router-dom": "^6.5.0", @@ -61,6 +62,7 @@ "react-textarea-autosize": "^8.4.0", "reactflow": "^11.4.0", "regexify-string": "^1.0.19", + "remark-gfm": "^4.0.1", "rxjs": "^7.8.1", "timeago.js": "^4.0.2", "usehooks-ts": "^2.9.1", diff --git a/GUI/src/App.tsx b/GUI/src/App.tsx index ceb8d83e..5839b180 100644 --- a/GUI/src/App.tsx +++ b/GUI/src/App.tsx @@ -13,6 +13,7 @@ import ViewLLMConnection from 'pages/LLMConnections/ViewLLMConnection'; import UserManagement from 'pages/UserManagement'; import TestLLM from 'pages/TestModel'; import TestProductionLLM from 'pages/TestProductionLLM'; +import PromptConfigurations from 'pages/PromptConfigurations'; const App: FC = () => { const navigate = useNavigate(); @@ -62,6 +63,7 @@ const App: FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/GUI/src/components/FormElements/FormSelect/index.tsx b/GUI/src/components/FormElements/FormSelect/index.tsx index e1187a49..5ad70cd5 100644 --- a/GUI/src/components/FormElements/FormSelect/index.tsx +++ b/GUI/src/components/FormElements/FormSelect/index.tsx @@ -87,8 +87,10 @@ const FormSelect = forwardRef( itemToString, selectedItem, onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { - setSelectedItem(newSelectedItem ?? null); - if (onSelectionChange) onSelectionChange(newSelectedItem ?? null); + if (!disabled) { + setSelectedItem(newSelectedItem ?? null); + if (onSelectionChange) onSelectionChange(newSelectedItem ?? null); + } }, }); @@ -109,7 +111,7 @@ const FormSelect = forwardRef( className={`select__trigger ${ error ? `select__error` : `select__default` }`} - {...getToggleButtonProps()} + {...getToggleButtonProps({ disabled })} > {selectedItem?.label ?? placeholderValue} ( } ); -export default FormSelect; +export default FormSelect; \ No newline at end of file diff --git a/GUI/src/components/FormElements/FormTextarea/index.tsx b/GUI/src/components/FormElements/FormTextarea/index.tsx index b1f23fe1..55ea5cd1 100644 --- a/GUI/src/components/FormElements/FormTextarea/index.tsx +++ b/GUI/src/components/FormElements/FormTextarea/index.tsx @@ -67,6 +67,7 @@ const FormTextarea = forwardRef(( defaultValue={defaultValue} className={textareaAutosizeClasses} aria-label={hideLabel ? label : undefined} + disabled={disabled} onChange={(e) => { if (onChange) onChange(e); handleOnChange(e); diff --git a/GUI/src/components/MainNavigation/index.tsx b/GUI/src/components/MainNavigation/index.tsx index 90dccb4a..070c4b9a 100644 --- a/GUI/src/components/MainNavigation/index.tsx +++ b/GUI/src/components/MainNavigation/index.tsx @@ -25,9 +25,19 @@ const MainNavigation: FC = () => { }, { id: 'llmConnections', - label: t('menu.llmConnections'), - path: '/llm-connections', + label: t('menu.llmConnections._self'), + path: '', icon: , + children: [ + { + label: t('menu.llmConnections.overview'), + path: '/llm-connections', + }, + { + label: t('menu.llmConnections.promptConfigurations'), + path: '/prompt-configurations', + } + ], }, { id: 'testLLM', @@ -37,7 +47,7 @@ const MainNavigation: FC = () => { }, { id: 'testProductionLLM', - label: 'Test Production LLM', + label: t('menu.testProductionLLM'), path: '/test-production-llm', icon: } diff --git a/GUI/src/components/MessageContent/MessageContent.scss b/GUI/src/components/MessageContent/MessageContent.scss index 7b4eea5c..513c579f 100644 --- a/GUI/src/components/MessageContent/MessageContent.scss +++ b/GUI/src/components/MessageContent/MessageContent.scss @@ -1,61 +1,112 @@ .message-content-wrapper { width: 100%; + line-height: 1.6; - .message-text { - margin-bottom: 12px; - line-height: 1.6; + // Markdown text styling + p { + margin: 0 0 12px 0; white-space: pre-wrap; word-wrap: break-word; + + &:last-child { + margin-bottom: 0; + } + } + + // Bold text + .markdown-bold, + strong { + font-weight: 600; + } + + // Ordered lists (for references) + .markdown-list, + ol { + margin: 16px 0 0 0; + padding-left: 20px; + list-style-type: decimal; } - .message-references { - margin-top: 16px; - padding-top: 12px; - border-top: 1px solid rgba(0, 0, 0, 0.1); + // List items + .markdown-list-item, + li { + margin-bottom: 6px; + line-height: 1.5; - .references-title { - display: block; - font-weight: 600; - margin-bottom: 8px; - font-size: 14px; + &:last-child { + margin-bottom: 0; } + } - .references-list { - margin: 0; - padding-left: 20px; - list-style-type: decimal; + // Links + a { + color: #0066cc; + text-decoration: none; + word-break: break-all; + transition: color 0.2s ease; - li { - margin-bottom: 6px; - line-height: 1.5; + &:hover { + color: #0052a3; + text-decoration: underline; + } - &:last-child { - margin-bottom: 0; - } - } + &:visited { + color: #551a8b; + } + } - .reference-link { - color: #0066cc; - text-decoration: none; - word-break: break-all; - transition: color 0.2s ease; + // Inline code + code { + background-color: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; + } - &:hover { - color: #0052a3; - text-decoration: underline; - } + // Code blocks + pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 12px; + border-radius: 6px; + overflow-x: auto; + margin: 12px 0; - &:visited { - color: #551a8b; - } - } + code { + background-color: transparent; + padding: 0; } } + + // Headings + h1, h2, h3, h4, h5, h6 { + margin: 16px 0 8px 0; + font-weight: 600; + } + + // Blockquotes + blockquote { + border-left: 4px solid rgba(0, 0, 0, 0.2); + padding-left: 12px; + margin: 12px 0; + color: rgba(0, 0, 0, 0.7); + } } // Dark mode support .test-production-llm__message--bot { - .message-references { - border-top-color: rgba(255, 255, 255, 0.1); + .message-content-wrapper { + code { + background-color: rgba(255, 255, 255, 0.1); + } + + pre { + background-color: rgba(255, 255, 255, 0.1); + } + + blockquote { + border-left-color: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.7); + } } -} +} \ No newline at end of file diff --git a/GUI/src/components/MessageContent/index.tsx b/GUI/src/components/MessageContent/index.tsx index 63ff7f2a..2b9a48d7 100644 --- a/GUI/src/components/MessageContent/index.tsx +++ b/GUI/src/components/MessageContent/index.tsx @@ -1,4 +1,6 @@ import { FC } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import './MessageContent.scss'; interface MessageContentProps { @@ -6,85 +8,33 @@ interface MessageContentProps { } const MessageContent: FC = ({ content }) => { - // Function to parse and render message content with proper formatting - const renderContent = () => { - // Split by **References:** pattern - const referencesMatch = content.match(/\*\*References:\*\*([\s\S]*)/); - - if (!referencesMatch) { - // No references, return plain content with line breaks - return ( -
- {content.split('\n').map((line, index) => ( - - {line} - {index < content.split('\n').length - 1 &&
} -
- ))} -
- ); - } - - // Split content into main text and references - const mainText = content.substring(0, referencesMatch.index); - const referencesText = referencesMatch[1].trim(); - - // Parse numbered references with URLs - const referenceLines = referencesText - .split('\n') - .filter(line => line.trim()) - .map(line => { - // Match pattern: "1. https://url" or "1. url" - const match = line.match(/^(\d+)\.\s+(https?:\/\/[^\s]+)/); - if (match) { - return { - number: match[1], - url: match[2], - }; - } - return null; - }) - .filter(Boolean); - - return ( -
- {/* Main text */} - {mainText && ( -
- {mainText.split('\n').map((line, index) => ( - - {line} - {index < mainText.split('\n').length - 1 &&
} -
- ))} -
- )} - - {/* References section */} - {referenceLines.length > 0 && ( -
- References: -
    - {referenceLines.map((ref, index) => ( -
  1. - - {ref!.url} - -
  2. - ))} -
-
- )} -
- ); - }; - - return <>{renderContent()}; + return ( + + ); }; -export default MessageContent; +export default MessageContent; \ No newline at end of file diff --git a/GUI/src/components/molecules/LLMConnectionCard/index.tsx b/GUI/src/components/molecules/LLMConnectionCard/index.tsx index 48342e75..26fe8e11 100644 --- a/GUI/src/components/molecules/LLMConnectionCard/index.tsx +++ b/GUI/src/components/molecules/LLMConnectionCard/index.tsx @@ -21,6 +21,10 @@ type LLMConnectionCardProps = { isActive?: boolean; deploymentEnv?: string; budgetStatus?: string; + usedBudget?: number; + monthlyBudget?: number; + stopBudgetThreshold?: number; + disconnectOnBudgetExceed?: boolean; onStatusChange?: (id: number | string, newStatus: boolean) => void; }; @@ -32,6 +36,10 @@ const LLMConnectionCard: FC> = ({ isActive, deploymentEnv, budgetStatus, + usedBudget, + monthlyBudget, + stopBudgetThreshold, + disconnectOnBudgetExceed, onStatusChange, }) => { const { open, close } = useDialog(); @@ -40,6 +48,31 @@ const LLMConnectionCard: FC> = ({ const toast = useToast(); const queryClient = useQueryClient(); + // Format currency + const formatCurrency = (amount?: number): string => { + if (amount === undefined || amount === null) { + return '0,00 €'; + } + + return new Intl.NumberFormat('et-EE', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + }; + + + // Get the relevant budget threshold + const getRelevantBudget = (): number | undefined => { + // here if disconnect on budget exceed is enabled and stop threshold is set, calculate the actual amount from percentage + if (disconnectOnBudgetExceed && stopBudgetThreshold && stopBudgetThreshold > 0 && monthlyBudget) { + return (monthlyBudget * stopBudgetThreshold) / 100; + } + // Otherwise using monthly budget + return monthlyBudget; + }; + const updateStatusMutation = useMutation({ mutationFn: ({ id, status }: { id: string | number; status: 'active' | 'inactive' }) => updateLLMConnectionStatus(id, status), @@ -145,6 +178,16 @@ const LLMConnectionCard: FC> = ({ {model ?? 'N/A'} + {(usedBudget !== undefined || monthlyBudget !== undefined) && ( +
+ + {t('dataModels.budgetUsage')}: + + + {formatCurrency(usedBudget)} / {formatCurrency(getRelevantBudget())} + +
+ )}
{renderDeploymentEnv(deploymentEnv)} {renderBudgetStatus(budgetStatus)} diff --git a/GUI/src/components/molecules/LLMConnectionForm/LLMConnectionForm.scss b/GUI/src/components/molecules/LLMConnectionForm/LLMConnectionForm.scss index c999f4a7..95813268 100644 --- a/GUI/src/components/molecules/LLMConnectionForm/LLMConnectionForm.scss +++ b/GUI/src/components/molecules/LLMConnectionForm/LLMConnectionForm.scss @@ -115,18 +115,16 @@ } .flex-grid { - flex-wrap: wrap; gap: 8px; justify-content: flex-end; button { - flex: 0 1 auto; - - min-width: 60px; - max-width: calc(50% - 4px); - padding: 8px 12px; - font-size: 13px; + flex: 1 1 auto; + padding: 6px 8px; + font-size: 12px; + display: inline-flex; + justify-content: center; } } } @@ -154,12 +152,14 @@ } .flex-grid { - flex-direction: column-reverse; + flex-direction: column; gap: 12px; button { - width: 100%; - min-width: unset; + flex: 0 1 auto; + display: inline-flex; + justify-content: center; + padding: 8px 20px; } } } @@ -172,9 +172,7 @@ button { flex: 1 1 auto; min-width: 70px; - max-width: 200px; - font-size: 14px; - padding: 8px 12px; + padding: 8px 16px; } } } diff --git a/GUI/src/components/molecules/LLMConnectionForm/index.tsx b/GUI/src/components/molecules/LLMConnectionForm/index.tsx index 3662097b..797dd277 100644 --- a/GUI/src/components/molecules/LLMConnectionForm/index.tsx +++ b/GUI/src/components/molecules/LLMConnectionForm/index.tsx @@ -200,7 +200,13 @@ const embeddingModelOptions = toOptions(embeddingModelsData); ( ( ( -

{t('llmConnectionForm.generic.llmApiKey.label') || 'LLM API Key'}

-

{t('llmConnectionForm.generic.llmApiKey.description') || 'The API key of the LLM model'}

- ( - - )} - /> -
- ); + return null; } }; @@ -353,7 +352,13 @@ const embeddingModelOptions = toOptions(embeddingModelsData); ( ( ( -

{t('llmConnectionForm.generic.embeddingApiKey.label') || 'Embedding Model API Key'}

-

{t('llmConnectionForm.generic.embeddingApiKey.description') || 'API key of your embedding model'}

- ( - { - setEmbeddingApiKeyReplaceMode(false); - setValue('embeddingModelApiKey', ''); - }} - endButtonText={t('global.change') || "Change"} - {...field} - /> - )} - /> - - ); + return null; } }; @@ -523,13 +514,20 @@ const embeddingModelOptions = toOptions(embeddingModelsData); ( )} @@ -881,4 +879,4 @@ const embeddingModelOptions = toOptions(embeddingModelsData); ); }; -export default LLMConnectionForm; +export default LLMConnectionForm; \ No newline at end of file diff --git a/GUI/src/hooks/useStreamingResponse.tsx b/GUI/src/hooks/useStreamingResponse.tsx index 211d44f5..fc7204cd 100644 --- a/GUI/src/hooks/useStreamingResponse.tsx +++ b/GUI/src/hooks/useStreamingResponse.tsx @@ -1,5 +1,19 @@ import { useState, useRef, useCallback, useEffect } from 'react'; import axios from 'axios'; +import { ChoiceButton } from 'services/inference'; + +const getNotificationNodeUrl = (): string => { + const value = import.meta.env.REACT_APP_NOTIFICATION_NODE_URL; + if (!value) { + throw new Error( + 'Environment variable REACT_APP_NOTIFICATION_NODE_URL is not defined. ' + + 'Please set it to the base URL of the notification service to enable streaming responses.' + ); + } + return value; +}; +const notificationNodeUrl = getNotificationNodeUrl(); +console.log(notificationNodeUrl); interface StreamingOptions { authorId: string; @@ -8,7 +22,7 @@ interface StreamingOptions { } interface UseStreamingResponseReturn { - startStreaming: (message: string, options: StreamingOptions, onToken: (token: string) => void, onComplete: () => void, onError: (error: string) => void) => Promise; + startStreaming: (message: string, options: StreamingOptions, onToken: (token: string) => void, onComplete: () => void, onError: (error: string) => void, onButtons?: (buttons: ChoiceButton[]) => void) => Promise; stopStreaming: () => void; isStreaming: boolean; } @@ -41,7 +55,8 @@ export const useStreamingResponse = (channelId: string): UseStreamingResponseRet options: StreamingOptions, onToken: (token: string) => void, onComplete: () => void, - onError: (error: string) => void + onError: (error: string) => void, + onButtons?: (buttons: ChoiceButton[]) => void ) => { console.log('[SSE] Starting streaming for channel:', channelId); @@ -50,7 +65,7 @@ export const useStreamingResponse = (channelId: string): UseStreamingResponseRet try { // Step 1: Open SSE connection FIRST - const sseUrl = `https://est-rag-rtc.rootcode.software/notifications-server/sse/stream/${channelId}`; + const sseUrl = `${notificationNodeUrl}/sse/stream/${channelId}`; console.log('[SSE] Connecting to:', sseUrl); const eventSource = new EventSource(sseUrl); @@ -72,6 +87,9 @@ export const useStreamingResponse = (channelId: string): UseStreamingResponseRet } else if (data.type === 'stream_chunk' && data.content) { console.log('[SSE] Token:', data.content); onToken(data.content); + if (data.buttons && data.buttons.length > 0 && onButtons) { + onButtons(data.buttons); + } } else if (data.type === 'stream_end') { console.log('[SSE] Stream ended'); setIsStreaming(false); @@ -102,7 +120,7 @@ export const useStreamingResponse = (channelId: string): UseStreamingResponseRet await new Promise(resolve => setTimeout(resolve, 500)); // Step 3: POST to trigger streaming - const postUrl = `https://est-rag-rtc.rootcode.software/notifications-server/channels/${channelId}/orchestrate/stream`; + const postUrl = `${notificationNodeUrl}/channels/${channelId}/orchestrate/stream`; console.log('[API] Triggering stream:', postUrl); await axios.post(postUrl, { @@ -126,5 +144,4 @@ export const useStreamingResponse = (channelId: string): UseStreamingResponseRet stopStreaming, isStreaming, }; -}; - +}; \ No newline at end of file diff --git a/GUI/src/pages/LLMConnections/index.tsx b/GUI/src/pages/LLMConnections/index.tsx index 2484a82d..0af35601 100644 --- a/GUI/src/pages/LLMConnections/index.tsx +++ b/GUI/src/pages/LLMConnections/index.tsx @@ -15,19 +15,26 @@ import { platforms, trainingStatuses } from 'config/dataModelsConfig'; import LLMConnectionCard from 'components/molecules/LLMConnectionCard'; import { fetchLLMConnectionsPaginated, LLMConnectionFilters, LLMConnection, getProductionConnection, ProductionConnectionFilters } from 'services/llmConnections'; import { llmConnectionsQueryKeys } from 'utils/queryKeys'; +import { useToast } from 'hooks/useToast'; +import { ToastTypes } from 'enums/commonEnums'; +import useStore from 'store'; const LLMConnections: FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const toast = useToast(); - const [pageIndex, setPageIndex] = useState(1); - const [filters, setFilters] = useState({ - pageNumber: 1, - pageSize: 10, - sortBy: 'created_at', - sortOrder: 'desc', - }); + // Use Zustand store for persistent filters + const { + llmConnectionFilters: filters, + llmConnectionPageIndex: pageIndex, + productionConnectionFilters, + setLLMConnectionFilters: setFilters, + setLLMConnectionPageIndex: setPageIndex, + setProductionConnectionFilters, + resetLLMConnectionFilters, + } = useStore(); // Fetch LLM connections using TanStack Query with new paginated endpoint const { data: connectionsResponse, isLoading: isModelDataLoading, error } = useQuery({ @@ -35,17 +42,10 @@ const LLMConnections: FC = () => { queryFn: () => fetchLLMConnectionsPaginated(filters), }); - // Fetch production connection separately with potential filters - const [productionFilters, setProductionFilters] = useState({ - sortBy: 'created_at', - sortOrder: 'desc', - llmPlatform: '', - llmModel: '', - }); - + // Fetch production connection separately with filters from store const { data: productionConnection, isLoading: isProductionLoading } = useQuery({ - queryKey: llmConnectionsQueryKeys.production(productionFilters), - queryFn: () => getProductionConnection(productionFilters), + queryKey: llmConnectionsQueryKeys.production(productionConnectionFilters), + queryFn: () => getProductionConnection(productionConnectionFilters), }); @@ -54,26 +54,35 @@ const LLMConnections: FC = () => { // Update filters when pageIndex changes useEffect(() => { - setFilters(prev => ({ ...prev, pageNumber: pageIndex })); - }, [pageIndex]); + setFilters({ ...filters, pageNumber: pageIndex }); + }, [pageIndex, setFilters]); - // Sync production filters with main filters on component mount + // Sync production filters with main filters useEffect(() => { - setProductionFilters(prev => ({ - ...prev, + setProductionConnectionFilters({ llmPlatform: filters.llmPlatform || '', llmModel: filters.llmModel || '', sortBy: filters.sortBy || 'created_at', sortOrder: filters.sortOrder || 'desc', - })); - }, [filters.llmPlatform, filters.llmModel, filters.sortBy, filters.sortOrder]); + }); + }, [filters.llmPlatform, filters.llmModel, filters.sortBy, filters.sortOrder, setProductionConnectionFilters]); + + // Show toast on error + useEffect(() => { + if (error) { + toast.open({ + type: ToastTypes.ERROR, + title: t('toast.error.title') || 'Error', + message: t('dataModels.errorLoadingConnections') || 'Error loading LLM connections', + }); + } + }, [error, toast, t]); const handleFilterChange = ( name: string, value: string | number | undefined | { name: string; id: string } ) => { let filterUpdate: Partial = {}; - let productionFilterUpdate: Partial = {}; if (name === 'sorting') { // Handle sorting format - no conversion needed, use snake_case directly @@ -84,32 +93,14 @@ const LLMConnections: FC = () => { sortBy: sortBy, sortOrder: sortOrder as 'asc' | 'desc' }; - - productionFilterUpdate = { - sortBy: sortBy, - sortOrder: sortOrder as 'asc' | 'desc' - }; } else { filterUpdate = { [name]: value }; - - // Update production filters for relevant fields - if (name === 'llmPlatform' || name === 'llmModel') { - productionFilterUpdate = { [name]: value as string }; - } } - setFilters((prevFilters) => ({ - ...prevFilters, + setFilters({ + ...filters, ...filterUpdate, - })); - - // Update production filters if relevant - if (Object.keys(productionFilterUpdate).length > 0) { - setProductionFilters((prevFilters) => ({ - ...prevFilters, - ...productionFilterUpdate, - })); - } + }); // Reset to first page when filters change if (name !== 'pageNumber') { @@ -219,24 +210,7 @@ const LLMConnections: FC = () => {
@@ -278,6 +256,10 @@ const LLMConnections: FC = () => { budgetStatus={llmConnection.budgetStatus} platform={llmConnection.llmPlatform} model={llmConnection.llmModel} + usedBudget={llmConnection.usedBudget} + monthlyBudget={llmConnection.monthlyBudget} + stopBudgetThreshold={llmConnection.stopBudgetThreshold} + disconnectOnBudgetExceed={llmConnection.disconnectOnBudgetExceed} /> ); })} @@ -286,12 +268,7 @@ const LLMConnections: FC = () => { ) : !productionConnection ? ( ) : null} - - {(error as any) && ( -
-

Error loading LLM connections. Please try again.

-
- )} + { ); }; -export default LLMConnections; +export default LLMConnections; \ No newline at end of file diff --git a/GUI/src/pages/PromptConfigurations/PromptConfigurations.scss b/GUI/src/pages/PromptConfigurations/PromptConfigurations.scss new file mode 100644 index 00000000..ef833096 --- /dev/null +++ b/GUI/src/pages/PromptConfigurations/PromptConfigurations.scss @@ -0,0 +1,87 @@ +.prompt-configurations { + padding: 2rem; + + .container { + max-width: 1200px; + margin: 0 auto; + } + + .title-container { + margin-bottom: 2rem; + + .title { + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: #1a1a1a; + } + + .subtitle { + font-size: 1rem; + color: #666; + margin: 0; + } + } + + .prompt-form { + background: #fff; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + .toggle-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + + .switch { + margin: 0; + gap: 0; + + &__label { + display: none; + } + + &__button { + width: 65px; + height:auto; + min-width: 44px; + padding: 2px; + gap: 0; + } + + &__on, + &__off { + display: none; + } + + &__thumb { + display: block; + width: 30px; + height: 30px; + background-color: white; + border-radius: 50%; + transition: transform 0.25s ease-out; + transform: translateX(2px); + } + + &__button[aria-checked="true"] .switch__thumb { + transform: translateX(30px); + } + } + } + + .separator { + border-bottom: 1px solid #e0e0e0; + margin-bottom: 1.5rem; + } + + .form-actions { + margin-top: 1.5rem; + display: flex; + justify-content: flex-end; + gap: 1rem; + } + } +} \ No newline at end of file diff --git a/GUI/src/pages/PromptConfigurations/index.tsx b/GUI/src/pages/PromptConfigurations/index.tsx new file mode 100644 index 00000000..181c4e19 --- /dev/null +++ b/GUI/src/pages/PromptConfigurations/index.tsx @@ -0,0 +1,155 @@ +import { FC, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Button, FormTextarea, Switch } from 'components'; +import { ButtonAppearanceTypes, ToastTypes } from 'enums/commonEnums'; +import CircularSpinner from 'components/molecules/CircularSpinner/CircularSpinner'; +import { getPromptConfiguration, savePromptConfiguration, disablePromptConfiguration } from 'services/promptConfiguration'; +import { promptConfigurationQueryKeys } from 'utils/queryKeys'; +import { useToast } from 'hooks/useToast'; +import './PromptConfigurations.scss'; + +const PromptConfigurations: FC = () => { + const { t } = useTranslation(); + const toast = useToast(); + const queryClient = useQueryClient(); + const [promptText, setPromptText] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); + const [isEnabled, setIsEnabled] = useState(false); + + // Fetch prompt configuration + const { data: promptConfig, isLoading } = useQuery({ + queryKey: promptConfigurationQueryKeys.current(), + queryFn: getPromptConfiguration, + }); + + + // Update promptText when data is loaded + useEffect(() => { + if (promptConfig && promptConfig.length > 0 && promptConfig[0].prompt) { + setPromptText(promptConfig[0].prompt); + setIsUpdating(true); + setIsEnabled(true); + } else { + setPromptText(''); + setIsUpdating(promptConfig !== undefined && promptConfig.length > 0); + setIsEnabled(false); + } + }, [promptConfig]); + + // Save prompt mutation + const saveMutation = useMutation({ + mutationFn: savePromptConfiguration, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: promptConfigurationQueryKeys.current() }); + toast.open({ + type: ToastTypes.SUCCESS, + title: t('toast.success.title'), + message: t('promptConfigurations.submitSuccess'), + }); + }, + onError: (error: any) => { + console.error('Error saving prompt:', error); + toast.open({ + type: ToastTypes.ERROR, + title: t('toast.error.title'), + message: t('promptConfigurations.submitError'), + }); + }, + }); + + // Disable prompt mutation (saves empty prompt) + const disableMutation = useMutation({ + mutationFn: disablePromptConfiguration, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: promptConfigurationQueryKeys.current() }); + setPromptText(''); + setIsEnabled(false); + toast.open({ + type: ToastTypes.SUCCESS, + title: t('toast.success.title'), + message: t('promptConfigurations.deleteSuccess'), + }); + }, + onError: (error: any) => { + console.error('Error disabling prompt:', error); + toast.open({ + type: ToastTypes.ERROR, + title: t('toast.error.title'), + message: t('promptConfigurations.deleteError'), + }); + }, + }); + + const handleSubmit = () => { + if (!promptText.trim()) { + return; + } + saveMutation.mutate(promptText); + }; + + const handleToggleChange = (checked: boolean) => { + if (!checked) { + // Disable: save empty prompt to clear configuration + setPromptText(''); + if (isUpdating) { + disableMutation.mutate(); + } + } + setIsEnabled(checked); + }; + + if (isLoading) { + return ; + } + + return ( +
+
+
+
{t('promptConfigurations.title')}
+
+ +
+
+ {t('promptConfigurations.enableToggleLabel')} + +
+
+ + setPromptText(e.target.value)} + maxRows={15} + /> + +
+ +
+
+
+
+ ); +}; + +export default PromptConfigurations; \ No newline at end of file diff --git a/GUI/src/pages/TestModel/TestLLM.scss b/GUI/src/pages/TestModel/TestLLM.scss index 833690d4..3d0c2156 100644 --- a/GUI/src/pages/TestModel/TestLLM.scss +++ b/GUI/src/pages/TestModel/TestLLM.scss @@ -2,6 +2,13 @@ margin-top: 30px; } +.mcq-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + .testModalClassifyButton { text-align: right; margin-top: 20px; diff --git a/GUI/src/pages/TestModel/index.tsx b/GUI/src/pages/TestModel/index.tsx index b6e66e76..2fc116bd 100644 --- a/GUI/src/pages/TestModel/index.tsx +++ b/GUI/src/pages/TestModel/index.tsx @@ -1,12 +1,14 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { Button, FormSelect, FormTextarea, Collapsible } from 'components'; import CircularSpinner from 'components/molecules/CircularSpinner/CircularSpinner'; -import { FC, useState } from 'react'; +import { ComponentPropsWithoutRef, FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import './TestLLM.scss'; import { useDialog } from 'hooks/useDialog'; import { fetchLLMConnectionsPaginated, LegacyLLMConnectionFilters } from 'services/llmConnections'; -import { viewInferenceResult, InferenceRequest, InferenceResponse } from 'services/inference'; +import { viewInferenceResult, InferenceRequest, InferenceResponse, ChoiceButton } from 'services/inference'; import { llmConnectionsQueryKeys } from 'utils/queryKeys'; import { ButtonAppearanceTypes } from 'enums/commonEnums'; @@ -14,6 +16,7 @@ const TestLLM: FC = () => { const { t } = useTranslation(); const { open: openDialog, close: closeDialog } = useDialog(); const [inferenceResult, setInferenceResult] = useState(null); + const [pendingButtons, setPendingButtons] = useState([]); const [testLLM, setTestLLM] = useState({ connectionId: null, text: '', @@ -47,6 +50,7 @@ const TestLLM: FC = () => { mutationFn: (request: InferenceRequest) => viewInferenceResult(request), onSuccess: (data: InferenceResponse) => { setInferenceResult(data?.response); + setPendingButtons(data?.response?.buttons ?? []); }, onError: (error: any) => { console.error('Error getting inference result:', error); @@ -74,13 +78,37 @@ const TestLLM: FC = () => { } }; + const handleButtonClick = (payload: string) => { + if (!testLLM.connectionId) return; + setPendingButtons([]); + inferenceMutation.mutate({ + llmConnectionId: Number(testLLM.connectionId), + message: payload, + }); + }; + const handleChange = (key: string, value: string | number) => { + // Prevent changes while inference is loading + if (inferenceMutation.isLoading) { + return; + } setTestLLM((prev) => ({ ...prev, [key]: value, })); }; + const markdownComponents = { + ol: ({children}: any) => ( +
    + {children} +
+ ), + a: (props: ComponentPropsWithoutRef<"a">) => ( +
+ ), + }; + return (
{isLoadingConnections ? ( @@ -104,6 +132,7 @@ const TestLLM: FC = () => { }} value={testLLM?.connectionId === null ? t('testModels.connectionNotExist') || 'Connection does not exist' : undefined} defaultValue={testLLM?.connectionId ?? undefined} + disabled={inferenceMutation.isLoading} />
@@ -114,6 +143,7 @@ const TestLLM: FC = () => { label="" name="" maxLength={1000} + maxRows={15} onChange={(e) => handleChange('text', e.target.value)} showMaxLength={true} /> @@ -134,10 +164,28 @@ const TestLLM: FC = () => {
Response:
- {inferenceResult.content} + + {inferenceResult.content} +
+ {/* MCQ Buttons */} + {pendingButtons.length > 0 && ( +
+ {pendingButtons.map((btn) => ( + + ))} +
+ )} + {/* Context Section */} { sortedContext && sortedContext?.length > 0 && ( @@ -150,7 +198,9 @@ const TestLLM: FC = () => { Rank {contextItem.rank}
- {contextItem.chunkRetrieved} + + {contextItem.chunkRetrieved} +
))} diff --git a/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss b/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss index 1bd8e0f1..9cb5c00c 100644 --- a/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss +++ b/GUI/src/pages/TestProductionLLM/TestProductionLLM.scss @@ -3,6 +3,13 @@ margin: 0 auto; padding: 2rem; +.mcq-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + &__header { display: flex; justify-content: space-between; @@ -77,6 +84,34 @@ border-radius: 18px 18px 18px 4px; } } + + &--error { + .test-production-llm__message-content { + border-color: #f44336; + background-color: #ffebee; + } + } + } + + &__message-error { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid #ffcdd2; + font-size: 0.85rem; + color: #c62828; + } + + &__message-error-icon { + flex-shrink: 0; + font-size: 1rem; + } + + &__message-error-text { + flex: 1; + line-height: 1.3; } &__message-content { diff --git a/GUI/src/pages/TestProductionLLM/index.tsx b/GUI/src/pages/TestProductionLLM/index.tsx index a9c14935..f29cfcf9 100644 --- a/GUI/src/pages/TestProductionLLM/index.tsx +++ b/GUI/src/pages/TestProductionLLM/index.tsx @@ -1,153 +1,181 @@ -import { FC, useState, useRef, useEffect } from 'react'; +import { FC, useState, useRef, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, FormTextarea, Section } from 'components'; -import { productionInference, ProductionInferenceRequest } from 'services/inference'; +import { Button, FormTextarea } from 'components'; import { useToast } from 'hooks/useToast'; +import { useStreamingResponse } from 'hooks/useStreamingResponse'; +import { ChoiceButton } from 'services/inference'; import './TestProductionLLM.scss'; - +import MessageContent from 'components/MessageContent'; interface Message { id: string; content: string; isUser: boolean; timestamp: string; + hasError?: boolean; + errorMessage?: string; + buttons?: ChoiceButton[]; } const TestProductionLLM: FC = () => { const { t } = useTranslation(); const toast = useToast(); - const [message, setMessage] = useState(''); + const [inputMessage, setInputMessage] = useState(''); const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; + // Generate a unique channel ID for this session + const channelId = useMemo(() => `channel-${Math.random().toString(36).substring(2, 15)}`, []); + const { startStreaming, stopStreaming, isStreaming } = useStreamingResponse(channelId); + // Auto-scroll to bottom useEffect(() => { - scrollToBottom(); + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + // Cleanup incomplete messages on unmount if streaming is active + useEffect(() => { + return () => { + if (isStreaming) { + stopStreaming(); + // Remove incomplete bot messages on unmount + setMessages(prev => prev.filter(msg => msg.isUser || !msg.content.trim() === false)); + } + }; + }, [isStreaming, stopStreaming]); + const handleSendMessage = async () => { - if (!message.trim()) { + if (!inputMessage.trim()) { toast.open({ type: 'warning', - title: t('warningTitle'), - message: t('emptyMessageWarning'), + title: t('testProductionLLM.warningTitle'), + message: t('testProductionLLM.emptyMessageWarning'), }); return; } + const userMessageText = inputMessage.trim(); + + // Add user message const userMessage: Message = { id: `user-${Date.now()}`, - content: message.trim(), + content: userMessageText, isUser: true, timestamp: new Date().toISOString(), }; - // Add user message to chat setMessages(prev => [...prev, userMessage]); - setMessage(''); + setInputMessage(''); setIsLoading(true); - try { - // Hardcoded values as requested - const request: ProductionInferenceRequest = { - chatId: 'test-chat-001', - message: userMessage.content, - authorId: 'test-author-001', - conversationHistory: messages.map(msg => ({ - authorRole: msg.isUser ? 'user' : 'bot', - message: msg.content, - timestamp: msg.timestamp, - })), - url: 'https://test-url.example.com', - }; - - let response; - let attemptCount = 0; - const maxAttempts = 2; - - // Retry logic - while (attemptCount < maxAttempts) { - try { - attemptCount++; - console.log(`Production Inference Attempt ${attemptCount}/${maxAttempts}`); - response = await productionInference(request); - - // If we get a successful response, break out of retry loop - if (!response.status || response.status < 400) { - break; - } - - // If first attempt failed with error status, retry once more - if (attemptCount < maxAttempts && response.status >= 400) { - console.log('Retrying due to error status...'); - continue; - } - } catch (err) { - // If first attempt threw an error, retry once more - if (attemptCount < maxAttempts) { - console.log('Retrying due to exception...'); - continue; - } - throw err; // Re-throw on final attempt - } - } + // Create bot message ID + const botMessageId = `bot-${Date.now()}`; - console.log('Production Inference Response:', response); + // Prepare conversation history (exclude the current user message) + const conversationHistory = messages.map(msg => ({ + authorRole: msg.isUser ? 'user' : 'bot', + message: msg.content, + timestamp: msg.timestamp, + })); - // Create bot response message - let botContent = ''; - let botMessageType: 'success' | 'error' = 'success'; + const streamingOptions = { + authorId: 'test-user-456', + conversationHistory, + url: 'opensearch-dashboard-test', + }; - if (response.status && response.status >= 400) { - // Error response - botContent = response.content || 'An error occurred while processing your request.'; - botMessageType = 'error'; - } else { - // Success response - botContent = response?.response?.content || 'Response received successfully.'; + // Callbacks for streaming + const onToken = (token: string) => { + console.log('[Component] Received token:', token); + + setMessages(prev => { + // Find the bot message + const botMsgIndex = prev.findIndex(msg => msg.id === botMessageId); - if (response.questionOutOfLlmScope) { - botContent += ' (Note: This question appears to be outside the LLM scope)'; + if (botMsgIndex === -1) { + // First token - add the bot message + console.log('[Component] Adding bot message with first token'); + return [ + ...prev, + { + id: botMessageId, + content: token, + isUser: false, + timestamp: new Date().toISOString(), + } + ]; + } else { + // Append token to existing message + console.log('[Component] Appending token to existing message'); + const updated = [...prev]; + updated[botMsgIndex] = { + ...updated[botMsgIndex], + content: updated[botMsgIndex].content + token, + }; + return updated; } - } - - const botMessage: Message = { - id: `bot-${Date.now()}`, - content: botContent, - isUser: false, - timestamp: new Date().toISOString(), - }; + }); + }; - setMessages(prev => [...prev, botMessage]); + const onButtons = (buttons: ChoiceButton[]) => { + setMessages(prev => { + const botMsgIndex = prev.findIndex(msg => msg.id === botMessageId); + if (botMsgIndex === -1) return prev; + const updated = [...prev]; + updated[botMsgIndex] = { ...updated[botMsgIndex], buttons }; + return updated; + }); + }; - // Show toast notification - // toast.open({ - // type: botMessageType, - // title: t('errorOccurred'), - // message: t('errorMessage'), - // }); + const onComplete = () => { + console.log('[Component] Stream completed'); + // Always reset loading state on completion + setIsLoading(false); + }; - } catch (error) { - console.error('Error sending message:', error); + const onError = (error: string) => { + console.error('[Component] Stream error:', error); + // Always reset loading state on error + setIsLoading(false); + + // Handle incomplete bot message + setMessages(prev => { + const botMsgIndex = prev.findIndex(msg => msg.id === botMessageId); + + if (botMsgIndex !== -1) { + const botMessage = prev[botMsgIndex]; + + // If the bot message has content, mark it as errored + if (botMessage.content.trim()) { + const updated = [...prev]; + updated[botMsgIndex] = { + ...botMessage, + hasError: true, + errorMessage: error, + }; + return updated; + } else { + // If no content, remove the empty bot message + return prev.filter(msg => msg.id !== botMessageId); + } + } + + return prev; + }); - const errorMessage: Message = { - id: `error-${Date.now()}`, - content: 'Failed to send message. Please check your connection and try again.', - isUser: false, - timestamp: new Date().toISOString(), - }; - - setMessages(prev => [...prev, errorMessage]); - toast.open({ type: 'error', - title: 'Connection Error', - message: 'Unable to connect to the production LLM service.', + title: t('testProductionLLM.streamingErrorTitle'), + message: error, }); - } finally { + }; + + // Start streaming + try { + await startStreaming(userMessageText, streamingOptions, onToken, onComplete, onError, onButtons); + } catch (error) { + console.error('[Component] Failed to start streaming:', error); + // Reset loading state if streaming fails to start setIsLoading(false); } }; @@ -159,12 +187,69 @@ const TestProductionLLM: FC = () => { } }; + const handleButtonClick = async (title: string, payload: string) => { + if (isLoading || isStreaming) return; + + const userMessage: Message = { + id: `user-${Date.now()}`, + content: title, + isUser: true, + timestamp: new Date().toISOString(), + }; + setMessages(prev => [...prev, userMessage]); + setIsLoading(true); + + const botMessageId = `bot-${Date.now()}`; + const conversationHistory = messages.map(msg => ({ + authorRole: msg.isUser ? 'user' : 'bot', + message: msg.content, + timestamp: msg.timestamp, + })); + const streamingOptions = { + authorId: 'test-user-456', + conversationHistory, + url: 'opensearch-dashboard-test', + }; + + const onToken = (token: string) => { + setMessages(prev => { + const idx = prev.findIndex(m => m.id === botMessageId); + if (idx === -1) return [...prev, { id: botMessageId, content: token, isUser: false, timestamp: new Date().toISOString() }]; + const updated = [...prev]; + updated[idx] = { ...updated[idx], content: updated[idx].content + token }; + return updated; + }); + }; + const onButtons = (buttons: ChoiceButton[]) => { + setMessages(prev => { + const idx = prev.findIndex(m => m.id === botMessageId); + if (idx === -1) return prev; + const updated = [...prev]; + updated[idx] = { ...updated[idx], buttons }; + return updated; + }); + }; + const onComplete = () => setIsLoading(false); + const onError = (error: string) => { + setIsLoading(false); + toast.open({ type: 'error', title: t('testProductionLLM.streamingErrorTitle'), message: error }); + }; + + try { + await startStreaming(payload, streamingOptions, onToken, onComplete, onError, onButtons); + } catch (error) { + console.error('[Component] Failed to start streaming for button click:', error); + setIsLoading(false); + } + }; + const clearChat = () => { setMessages([]); + stopStreaming(); toast.open({ type: 'info', - title: 'Chat Cleared', - message: 'All messages have been cleared.', + title: t('testProductionLLM.chatClearedTitle'), + message: t('testProductionLLM.chatClearedMessage'), }); }; @@ -172,9 +257,9 @@ const TestProductionLLM: FC = () => {
-

{t('Test Production LLM')}

+

{t('testProductionLLM.title')}

@@ -182,8 +267,8 @@ const TestProductionLLM: FC = () => {
{messages.length === 0 && (
-

Welcome to Production LLM Testing

-

Start a conversation by typing a message below.

+

{t('testProductionLLM.welcomeTitle')}

+

{t('testProductionLLM.welcomeSubtitle')}

)} @@ -192,10 +277,35 @@ const TestProductionLLM: FC = () => { key={msg.id} className={`test-production-llm__message ${ msg.isUser ? 'test-production-llm__message--user' : 'test-production-llm__message--bot' + } ${ + msg.hasError ? 'test-production-llm__message--error' : '' }`} >
- {msg.content} + + {!msg.isUser && msg.buttons && msg.buttons.length > 0 && ( +
+ {msg.buttons.map((btn) => ( + + ))} +
+ )} + {msg.hasError && ( +
+ ⚠️ + + {t('testProductionLLM.incompleteMessageError', { defaultValue: 'This message is incomplete due to an error' })} + {msg.errorMessage && `: ${msg.errorMessage}`} + +
+ )}
{new Date(msg.timestamp).toLocaleTimeString()} @@ -220,22 +330,22 @@ const TestProductionLLM: FC = () => {
setMessage(e.target.value)} + value={inputMessage} + onChange={(e) => setInputMessage(e.target.value)} onKeyDown={handleKeyPress} - placeholder="Type your message here... (Press Enter to send, Shift+Enter for new line)" + placeholder={t('testProductionLLM.messagePlaceholder')??""} hideLabel maxRows={4} - disabled={isLoading} + disabled={isLoading || isStreaming} />
diff --git a/GUI/src/services/inference.ts b/GUI/src/services/inference.ts index 44baf696..3dcbeaaa 100644 --- a/GUI/src/services/inference.ts +++ b/GUI/src/services/inference.ts @@ -19,12 +19,18 @@ export interface ProductionInferenceRequest { url: string; } +export interface ChoiceButton { + title: string; + payload: string; +} + export interface InferenceResponse { response: { chatId: number; llmServiceActive: boolean; questionOutOfLlmScope: boolean; content: string; + buttons?: ChoiceButton[]; chunks?: { rank: number, chunkRetrieved: string diff --git a/GUI/src/services/promptConfiguration.ts b/GUI/src/services/promptConfiguration.ts new file mode 100644 index 00000000..a7a77e95 --- /dev/null +++ b/GUI/src/services/promptConfiguration.ts @@ -0,0 +1,30 @@ +import apiDev from './api-dev'; +import { promptConfigurationEndpoints } from 'utils/endpoints'; + +export interface PromptConfiguration { + id: number | null; + prompt: string; +} + +export interface PromptConfigurationResponse { + response: PromptConfiguration[]; +} + +export const getPromptConfiguration = async (): Promise => { + const { data } = await apiDev.get(promptConfigurationEndpoints.GET_PROMPT_CONFIGURATION()); + return data?.response || []; +}; + +export const savePromptConfiguration = async (prompt: string): Promise => { + const { data } = await apiDev.post(promptConfigurationEndpoints.SAVE_PROMPT_CONFIGURATION(), { + prompt, + }); + return data?.response; +}; + +export const disablePromptConfiguration = async (): Promise => { + const { data } = await apiDev.post(promptConfigurationEndpoints.SAVE_PROMPT_CONFIGURATION(), { + prompt: "", + }); + return data?.response; +}; \ No newline at end of file diff --git a/GUI/src/store/index.ts b/GUI/src/store/index.ts index 564d3215..c5fe37db 100644 --- a/GUI/src/store/index.ts +++ b/GUI/src/store/index.ts @@ -1,16 +1,49 @@ import { create } from 'zustand'; import { UserInfo } from 'types/userInfo'; +import { LLMConnectionFilters, ProductionConnectionFilters } from 'services/llmConnections'; interface StoreState { userInfo: UserInfo | null; userId: string; setUserInfo: (info: UserInfo) => void; + llmConnectionFilters: LLMConnectionFilters; + llmConnectionPageIndex: number; + productionConnectionFilters: ProductionConnectionFilters; + setLLMConnectionFilters: (filters: LLMConnectionFilters) => void; + setLLMConnectionPageIndex: (pageIndex: number) => void; + setProductionConnectionFilters: (filters: ProductionConnectionFilters) => void; + resetLLMConnectionFilters: () => void; } +const defaultLLMConnectionFilters: LLMConnectionFilters = { + pageNumber: 1, + pageSize: 10, + sortBy: 'created_at', + sortOrder: 'desc', +}; + +const defaultProductionConnectionFilters: ProductionConnectionFilters = { + sortBy: 'created_at', + sortOrder: 'desc', + llmPlatform: '', + llmModel: '', +}; + const useStore = create((set) => ({ userInfo: null, userId: '', setUserInfo: (data) => set({ userInfo: data, userId: data?.userIdCode || '' }), + llmConnectionFilters: defaultLLMConnectionFilters, + llmConnectionPageIndex: 1, + productionConnectionFilters: defaultProductionConnectionFilters, + setLLMConnectionFilters: (filters) => set({ llmConnectionFilters: filters }), + setLLMConnectionPageIndex: (pageIndex) => set({ llmConnectionPageIndex: pageIndex }), + setProductionConnectionFilters: (filters) => set({ productionConnectionFilters: filters }), + resetLLMConnectionFilters: () => set({ + llmConnectionFilters: defaultLLMConnectionFilters, + llmConnectionPageIndex: 1, + productionConnectionFilters: defaultProductionConnectionFilters, + }), })); -export default useStore; +export default useStore; \ No newline at end of file diff --git a/GUI/src/utils/endpoints.ts b/GUI/src/utils/endpoints.ts index a6b203d8..386db296 100644 --- a/GUI/src/utils/endpoints.ts +++ b/GUI/src/utils/endpoints.ts @@ -34,3 +34,8 @@ export const vaultEndpoints = { CREATE_VAULT_SECRET: (): string => `/rag-search/vault/secret/create`, DELETE_VAULT_SECRET: (): string => `/rag-search/vault/secret/delete`, } + +export const promptConfigurationEndpoints = { + GET_PROMPT_CONFIGURATION: (): string => `/rag-search/prompt-configuration/get`, + SAVE_PROMPT_CONFIGURATION: (): string => `/rag-search/prompt-configuration/save`, +} diff --git a/GUI/src/utils/queryKeys.ts b/GUI/src/utils/queryKeys.ts index e10462e8..ebc3c1a4 100644 --- a/GUI/src/utils/queryKeys.ts +++ b/GUI/src/utils/queryKeys.ts @@ -38,3 +38,8 @@ export const inferenceQueryKeys = { results: () => [...inferenceQueryKeys.all(), 'results'] as const, result: (request: InferenceRequest) => [...inferenceQueryKeys.results(), request] as const, }; + +export const promptConfigurationQueryKeys = { + all: () => ['prompt-configuration'] as const, + current: () => [...promptConfigurationQueryKeys.all(), 'current'] as const, +}; diff --git a/GUI/translations/en/common.json b/GUI/translations/en/common.json index 0341108e..f3d932c7 100644 --- a/GUI/translations/en/common.json +++ b/GUI/translations/en/common.json @@ -62,7 +62,12 @@ "menu": { "userManagement": "User management", "testLLM": "Test LLM", - "llmConnections": "LLM connections" + "testProductionLLM": "Test Production LLM", + "llmConnections": { + "_self": "LLM connections", + "overview": "Overview", + "promptConfigurations": "Prompt Configurations" + } }, "userManagement": { "title": "User management", @@ -122,6 +127,7 @@ "settings": "Settings", "dataModels": "LLM connections", "noModels": "No LLM connections found", + "errorLoadingConnections": "Error loading LLM connections", "createModel": "Create LLM connection", "productionConnections": "Production LLM connection", "otherConnections": "Other LLM connections", @@ -158,6 +164,7 @@ "testing": "Testing", "production": "Production" }, + "budgetUsage": "Budget usage", "budgetStatus": { "withinBudget": "Within budget", "overBudget": "Over budget", @@ -294,6 +301,7 @@ }, "validationMessages": { "connectionNameRequired": "Connection name is required", + "connectionNameMaxLength": "Connection name must not exceed 100 characters", "llmPlatformRequired": "LLM platform is required", "llmModelRequired": "LLM model is required", "embeddingPlatformRequired": "Embedding model platform is required", @@ -322,7 +330,13 @@ "embeddingApiKeyRequired": "Embedding API key is required", "invalidUrl": "Please enter a valid URL starting with http:// or https://", "failedToLoadPlatforms": "Failed to load platforms", - "failedToLoadModels": "Failed to load models" + "failedToLoadModels": "Failed to load models", + "invalidAccessKey": "Access Key cannot contain spaces", + "invalidSecretKey": "Secret Key cannot contain spaces", + "invalidApiKey": "API Key cannot contain spaces", + "invalidEmbeddingAccessKey": "Embedding Access Key cannot contain spaces", + "invalidEmbeddingSecretKey": "Embedding Secret Key cannot contain spaces", + "invalidEmbeddingApiKey": "Embedding API Key cannot contain spaces" }, "buttons": { "deleteConnection": "Delete connection", @@ -342,7 +356,7 @@ "errorDialogMessage": "The connection couldn't be established either due to invalid API credentials or misconfiguration in the deployment platform", "goBackButton": "Go back", "replaceProductionDialogTitle": "Replace production connection", - "replaceProductionDialogMessage": "A production connection \"{connectionName}\" already exists.", + "replaceProductionDialogMessage": "A production connection \"{{connectionName}}\" already exists.", "replaceProductionDialogWarning": "Creating this new production connection will replace the current one. Are you sure you want to proceed?", "cancelButton": "Cancel", "confirmReplaceButton": "Yes, replace production connection" @@ -360,7 +374,7 @@ "goBackButton": "Go back", "confirmEnvironmentChangeTitle": "Confirm production environment change", "confirmEnvironmentChangeMessage": "You are about to change a production connection to testing environment.", - "confirmEnvironmentChangeWarning": "This will affect the current production setup. Are you sure you want to proceed?", + "confirmTestingToProductionEnvironmentChangeMessage": "You are about to change a testing connection to production environment.", "cancelButton": "Cancel", "confirmChangeButton": "Yes, change environment", "cannotDeleteProductionTitle": "Cannot delete production connection", @@ -399,5 +413,35 @@ "aws": "AWS Bedrock", "azure": "Azure OpenAI" } + }, + "testProductionLLM": { + "title": "Test Production LLM", + "clearChat": "Clear Chat", + "welcomeTitle": "Welcome to Production LLM Testing", + "welcomeSubtitle": "Start a conversation by typing a message below.", + "messageLabel": "Message", + "messagePlaceholder": "Type your message here... (Press Enter to send, Shift+Enter for new line)", + "sendButton": "Send", + "sendingButton": "Sending...", + "warningTitle": "Warning", + "emptyMessageWarning": "Please enter a message", + "streamingErrorTitle": "Streaming Error", + "chatClearedTitle": "Chat Cleared", + "chatClearedMessage": "All messages have been cleared." + }, + "promptConfigurations": { + "title": "Prompt Configurations", + "subtitle": "Configure and manage your prompt templates", + "enableToggleLabel": "Enable Custom Prompt Configuration", + "promptLabel": "Prompt Template", + "promptPlaceholder": "Enter your prompt template here...", + "submitButton": "Save", + "updateButton": "Update", + "saving": "Saving...", + "updating": "Updating...", + "submitSuccess": "Prompt configuration saved successfully", + "submitError": "Failed to save prompt configuration. Please try again.", + "deleteSuccess": "Prompt configuration deleted successfully", + "deleteError": "Failed to delete prompt configuration. Please try again." } } \ No newline at end of file diff --git a/GUI/translations/et/common.json b/GUI/translations/et/common.json index bd2d5504..52e76621 100644 --- a/GUI/translations/et/common.json +++ b/GUI/translations/et/common.json @@ -62,7 +62,12 @@ "menu": { "userManagement": "Kasutajate haldus", "testLLM": "Testi mudelit", - "llmConnections": "Mudelite ühendused" + "testProductionLLM": "Testi toodangu mudelit", + "llmConnections": { + "_self": "Mudelite ühendused", + "overview": "Ülevaade", + "promptConfigurations": "Viiba Seaded" + } }, "userManagement": { "title": "Kasutajate haldus", @@ -122,6 +127,7 @@ "settings": "Seaded", "dataModels": "Mudelite ühendused", "noModels": "Mudelite ühendusi ei leitud", + "errorLoadingConnections": "Viga mudeli ühenduste laadimisel", "createModel": "Loo mudeli ühendus", "productionConnections": "Mudel toodangukeskkonnas", "otherConnections": "Muud mudeli ühendused", @@ -158,6 +164,7 @@ "testing": "Testimine", "production": "Toodang" }, + "budgetUsage": "Eelarve kasutamine", "budgetStatus": { "withinBudget": "Eelarve piires", "overBudget": "Eelarve ületatud", @@ -294,6 +301,7 @@ }, "validationMessages": { "connectionNameRequired": "Ühenduse nimi on kohustuslik", + "connectionNameMaxLength": "Ühenduse nimi ei tohi ületada 100 märki", "llmPlatformRequired": "LLM platvorm on kohustuslik", "llmModelRequired": "LLM mudel on kohustuslik", "embeddingPlatformRequired": "Vektor-teisendusmudeli platvorm on kohustuslik", @@ -322,7 +330,13 @@ "embeddingApiKeyRequired": "Vektor-teisenduse API võti on kohustuslik", "invalidUrl": "Palun sisesta kehtiv URL, mis algab http:// või https://", "failedToLoadPlatforms": "Platvormide laadimine ebaõnnestus", - "failedToLoadModels": "Mudelite laadimine ebaõnnestus" + "failedToLoadModels": "Mudelite laadimine ebaõnnestus", + "invalidAccessKey": "Juurdepääsuvõti ei tohi sisaldada tühikuid", + "invalidSecretKey": "Salavõti ei tohi sisaldada tühikuid", + "invalidApiKey": "API võti ei tohi sisaldada tühikuid", + "invalidEmbeddingAccessKey": "Vektor-teisenduse juurdepääsuvõti ei tohi sisaldada tühikuid", + "invalidEmbeddingSecretKey": "Vektor-teisenduse salavõti ei tohi sisaldada tühikuid", + "invalidEmbeddingApiKey": "Vektor-teisenduse API võti ei tohi sisaldada tühikuid" }, "buttons": { "deleteConnection": "Kustuta ühendus", @@ -360,6 +374,7 @@ "goBackButton": "Mine tagasi", "confirmEnvironmentChangeTitle": "Kinnita toodangukeskkonna muutus", "confirmEnvironmentChangeMessage": "Oled toodanguühendust muutmas testimiskeskkonnaks.", + "confirmTestingToProductionEnvironmentChangeMessage": "Oled testimiskeskkonna ühendust muutmas toodangukeskkonnaks.", "confirmEnvironmentChangeWarning": "See mõjutab praegust toodanguseadistust. Kas oled kindel, et soovid jätkata?", "cancelButton": "Tühista", "confirmChangeButton": "Jah, muuda keskkonda", @@ -399,5 +414,35 @@ "aws": "AWS Bedrock", "azure": "Azure OpenAI" } + }, + "testProductionLLM": { + "title": "Testi Tootmise LLM", + "clearChat": "Tühjenda Vestlus", + "welcomeTitle": "Tere tulemast Tootmise LLM Testimisse", + "welcomeSubtitle": "Alusta vestlust, kirjutades allpool sõnumi.", + "messageLabel": "Sõnum", + "messagePlaceholder": "Kirjuta oma sõnum siia... (Vajuta Enter saatmiseks, Shift+Enter uue rea jaoks)", + "sendButton": "Saada", + "sendingButton": "Saatmine...", + "warningTitle": "Hoiatus", + "emptyMessageWarning": "Palun sisesta sõnum", + "streamingErrorTitle": "Voogedastuse Viga", + "chatClearedTitle": "Vestlus Tühjendatud", + "chatClearedMessage": "Kõik sõnumid on tühjendatud." + }, + "promptConfigurations": { + "title": "Viiba Seaded", + "subtitle": "Seadista ja halda oma viiba malle", + "enableToggleLabel": "Luba kohandatud viiba seadistus", + "promptLabel": "Viiba Mall", + "promptPlaceholder": "Sisesta siia oma viiba mall...", + "submitButton": "Salvesta", + "updateButton": "Uuenda", + "saving": "Salvestan...", + "updating": "Uuendan...", + "submitSuccess": "Viiba seadistus salvestati edukalt", + "submitError": "Viiba seadistuse salvestamine ebaõnnestus. Palun proovi uuesti.", + "deleteSuccess": "Viiba seadistus kustutati edukalt", + "deleteError": "Viiba seadistuse kustutamine ebaõnnestus. Palun proovi uuesti." } } \ No newline at end of file diff --git a/GUI/vite.config.ts.timestamp-1773932542024-ee7096644c66a.mjs b/GUI/vite.config.ts.timestamp-1773932542024-ee7096644c66a.mjs new file mode 100644 index 00000000..3ffe5928 --- /dev/null +++ b/GUI/vite.config.ts.timestamp-1773932542024-ee7096644c66a.mjs @@ -0,0 +1,77 @@ +// vite.config.ts +import { defineConfig } from "file:///app/node_modules/vite/dist/node/index.js"; +import react from "file:///app/node_modules/@vitejs/plugin-react/dist/index.mjs"; +import tsconfigPaths from "file:///app/node_modules/vite-tsconfig-paths/dist/index.mjs"; +import svgr from "file:///app/node_modules/vite-plugin-svgr/dist/index.mjs"; +import path from "path"; + +// vitePlugin.js +function removeHiddenMenuItems(str) { + var _a, _b; + const badJson = str.replace("export default [", "[").replace("];", "]"); + const correctJson = badJson.replace(/(['"])?([a-z0-9A-Z_]+)(['"])?:/g, '"$2": '); + const isHiddenFeaturesEnabled = ((_a = process.env.REACT_APP_ENABLE_HIDDEN_FEATURES) == null ? void 0 : _a.toLowerCase().trim()) === "true" || ((_b = process.env.REACT_APP_ENABLE_HIDDEN_FEATURES) == null ? void 0 : _b.toLowerCase().trim()) === "1"; + const json = removeHidden(JSON.parse(correctJson), isHiddenFeaturesEnabled); + const updatedJson = JSON.stringify(json); + return "export default " + updatedJson + ";"; +} +function removeHidden(menuItems, isHiddenFeaturesEnabled) { + var _a; + if (!menuItems) + return menuItems; + const arr = (_a = menuItems == null ? void 0 : menuItems.filter((x) => !x.hidden)) == null ? void 0 : _a.filter((x) => isHiddenFeaturesEnabled || x.hiddenMode !== "production"); + for (const a of arr) { + a.children = removeHidden(a.children, isHiddenFeaturesEnabled); + } + return arr; +} + +// vite.config.ts +var __vite_injected_original_dirname = "/app"; +var vite_config_default = defineConfig({ + envPrefix: "REACT_APP_", + plugins: [ + react(), + tsconfigPaths(), + svgr(), + { + name: "removeHiddenMenuItemsPlugin", + transform: (str, id) => { + if (!id.endsWith("/menu-structure.json")) + return str; + return removeHiddenMenuItems(str); + } + } + ], + base: "/rag-search", + build: { + outDir: "./build", + target: "es2015", + emptyOutDir: true + }, + server: { + headers: { + ...process.env.REACT_APP_CSP && { + "Content-Security-Policy": process.env.REACT_APP_CSP + } + }, + allowedHosts: ["est-rag-rtc.rootcode.software", "localhost", "127.0.0.1"], + proxy: { + "/vault-agent-gui": { + target: "http://vault-agent-gui:8202", + changeOrigin: true, + rewrite: (path2) => path2.replace(/^\/vault-agent-gui/, "") + } + } + }, + resolve: { + alias: { + "~@fontsource": path.resolve(__vite_injected_original_dirname, "node_modules/@fontsource"), + "@": `${path.resolve(__vite_injected_original_dirname, "./src")}` + } + } +}); +export { + vite_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiLCAidml0ZVBsdWdpbi5qcyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9hcHBcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9hcHAvdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL2FwcC92aXRlLmNvbmZpZy50c1wiO2ltcG9ydCB7IGRlZmluZUNvbmZpZyB9IGZyb20gJ3ZpdGUnO1xuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0JztcbmltcG9ydCB0c2NvbmZpZ1BhdGhzIGZyb20gJ3ZpdGUtdHNjb25maWctcGF0aHMnO1xuaW1wb3J0IHN2Z3IgZnJvbSAndml0ZS1wbHVnaW4tc3Zncic7XG5pbXBvcnQgcGF0aCBmcm9tICdwYXRoJztcbmltcG9ydCB7IHJlbW92ZUhpZGRlbk1lbnVJdGVtcyB9IGZyb20gJy4vdml0ZVBsdWdpbic7XG5cbi8vIGh0dHBzOi8vdml0ZWpzLmRldi9jb25maWcvXG5leHBvcnQgZGVmYXVsdCBkZWZpbmVDb25maWcoe1xuICBlbnZQcmVmaXg6ICdSRUFDVF9BUFBfJyxcbiAgcGx1Z2luczogW1xuICAgIHJlYWN0KCksXG4gICAgdHNjb25maWdQYXRocygpLFxuICAgIHN2Z3IoKSxcbiAgICB7XG4gICAgICBuYW1lOiAncmVtb3ZlSGlkZGVuTWVudUl0ZW1zUGx1Z2luJyxcbiAgICAgIHRyYW5zZm9ybTogKHN0ciwgaWQpID0+IHtcbiAgICAgICAgaWYoIWlkLmVuZHNXaXRoKCcvbWVudS1zdHJ1Y3R1cmUuanNvbicpKVxuICAgICAgICAgIHJldHVybiBzdHI7XG4gICAgICAgIHJldHVybiByZW1vdmVIaWRkZW5NZW51SXRlbXMoc3RyKTtcbiAgICAgIH0sXG4gICAgfSxcbiAgXSxcbiAgYmFzZTogJy9yYWctc2VhcmNoJyxcbiAgYnVpbGQ6IHtcbiAgICBvdXREaXI6ICcuL2J1aWxkJyxcbiAgICB0YXJnZXQ6ICdlczIwMTUnLFxuICAgIGVtcHR5T3V0RGlyOiB0cnVlLFxuICB9LFxuICBzZXJ2ZXI6IHtcbiAgICBoZWFkZXJzOiB7XG4gICAgICAuLi4ocHJvY2Vzcy5lbnYuUkVBQ1RfQVBQX0NTUCAmJiB7XG4gICAgICAgICdDb250ZW50LVNlY3VyaXR5LVBvbGljeSc6IHByb2Nlc3MuZW52LlJFQUNUX0FQUF9DU1AsXG4gICAgICB9KSxcbiAgICB9LFxuICAgIGFsbG93ZWRIb3N0czogWydlc3QtcmFnLXJ0Yy5yb290Y29kZS5zb2Z0d2FyZScsICdsb2NhbGhvc3QnLCAnMTI3LjAuMC4xJ10sXG4gICAgcHJveHk6IHtcbiAgICAgICcvdmF1bHQtYWdlbnQtZ3VpJzoge1xuICAgICAgICB0YXJnZXQ6ICdodHRwOi8vdmF1bHQtYWdlbnQtZ3VpOjgyMDInLFxuICAgICAgICBjaGFuZ2VPcmlnaW46IHRydWUsXG4gICAgICAgIHJld3JpdGU6IChwYXRoKSA9PiBwYXRoLnJlcGxhY2UoL15cXC92YXVsdC1hZ2VudC1ndWkvLCAnJyksXG4gICAgICB9LFxuICAgIH0sXG4gIH0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgJ35AZm9udHNvdXJjZSc6IHBhdGgucmVzb2x2ZShfX2Rpcm5hbWUsICdub2RlX21vZHVsZXMvQGZvbnRzb3VyY2UnKSxcbiAgICAgICdAJzogYCR7cGF0aC5yZXNvbHZlKF9fZGlybmFtZSwgJy4vc3JjJyl9YCxcbiAgICB9LFxuICB9LFxufSk7XG4iLCAiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9hcHBcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9hcHAvdml0ZVBsdWdpbi5qc1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vYXBwL3ZpdGVQbHVnaW4uanNcIjtleHBvcnQgZnVuY3Rpb24gcmVtb3ZlSGlkZGVuTWVudUl0ZW1zKHN0cikge1xuICBjb25zdCBiYWRKc29uID0gc3RyLnJlcGxhY2UoJ2V4cG9ydCBkZWZhdWx0IFsnLCAnWycpLnJlcGxhY2UoJ107JywgJ10nKTtcbiAgY29uc3QgY29ycmVjdEpzb24gPSBiYWRKc29uLnJlcGxhY2UoLyhbJ1wiXSk/KFthLXowLTlBLVpfXSspKFsnXCJdKT86L2csICdcIiQyXCI6ICcpO1xuXG4gY29uc3QgaXNIaWRkZW5GZWF0dXJlc0VuYWJsZWQgPSBcbiAgICBwcm9jZXNzLmVudi5SRUFDVF9BUFBfRU5BQkxFX0hJRERFTl9GRUFUVVJFUz8udG9Mb3dlckNhc2UoKS50cmltKCkgPT09ICd0cnVlJyB8fFxuICAgIHByb2Nlc3MuZW52LlJFQUNUX0FQUF9FTkFCTEVfSElEREVOX0ZFQVRVUkVTPy50b0xvd2VyQ2FzZSgpLnRyaW0oKSA9PT0gJzEnO1xuXG4gIGNvbnN0IGpzb24gPSByZW1vdmVIaWRkZW4oSlNPTi5wYXJzZShjb3JyZWN0SnNvbiksIGlzSGlkZGVuRmVhdHVyZXNFbmFibGVkKTtcbiAgXG4gIGNvbnN0IHVwZGF0ZWRKc29uID0gSlNPTi5zdHJpbmdpZnkoanNvbik7XG5cbiAgcmV0dXJuICdleHBvcnQgZGVmYXVsdCAnICsgdXBkYXRlZEpzb24gKyAnOydcbn1cblxuZnVuY3Rpb24gcmVtb3ZlSGlkZGVuKG1lbnVJdGVtcywgaXNIaWRkZW5GZWF0dXJlc0VuYWJsZWQpIHtcbiAgaWYoIW1lbnVJdGVtcykgcmV0dXJuIG1lbnVJdGVtcztcbiAgY29uc3QgYXJyID0gbWVudUl0ZW1zXG4gICAgPy5maWx0ZXIoeCA9PiAheC5oaWRkZW4pXG4gICAgPy5maWx0ZXIoeCA9PiBpc0hpZGRlbkZlYXR1cmVzRW5hYmxlZCB8fCB4LmhpZGRlbk1vZGUgIT09IFwicHJvZHVjdGlvblwiKTtcbiAgZm9yIChjb25zdCBhIG9mIGFycikge1xuICAgIGEuY2hpbGRyZW4gPSByZW1vdmVIaWRkZW4oYS5jaGlsZHJlbiwgaXNIaWRkZW5GZWF0dXJlc0VuYWJsZWQpO1xuICB9XG4gIHJldHVybiBhcnI7XG59XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQThMLFNBQVMsb0JBQW9CO0FBQzNOLE9BQU8sV0FBVztBQUNsQixPQUFPLG1CQUFtQjtBQUMxQixPQUFPLFVBQVU7QUFDakIsT0FBTyxVQUFVOzs7QUNKa0wsU0FBUyxzQkFBc0IsS0FBSztBQUF2TztBQUNFLFFBQU0sVUFBVSxJQUFJLFFBQVEsb0JBQW9CLEdBQUcsRUFBRSxRQUFRLE1BQU0sR0FBRztBQUN0RSxRQUFNLGNBQWMsUUFBUSxRQUFRLG1DQUFtQyxRQUFRO0FBRWhGLFFBQU0sNEJBQ0gsYUFBUSxJQUFJLHFDQUFaLG1CQUE4QyxjQUFjLFlBQVcsWUFDdkUsYUFBUSxJQUFJLHFDQUFaLG1CQUE4QyxjQUFjLFlBQVc7QUFFekUsUUFBTSxPQUFPLGFBQWEsS0FBSyxNQUFNLFdBQVcsR0FBRyx1QkFBdUI7QUFFMUUsUUFBTSxjQUFjLEtBQUssVUFBVSxJQUFJO0FBRXZDLFNBQU8sb0JBQW9CLGNBQWM7QUFDM0M7QUFFQSxTQUFTLGFBQWEsV0FBVyx5QkFBeUI7QUFmMUQ7QUFnQkUsTUFBRyxDQUFDO0FBQVcsV0FBTztBQUN0QixRQUFNLE9BQU0sNENBQ1IsT0FBTyxPQUFLLENBQUMsRUFBRSxZQURQLG1CQUVSLE9BQU8sT0FBSywyQkFBMkIsRUFBRSxlQUFlO0FBQzVELGFBQVcsS0FBSyxLQUFLO0FBQ25CLE1BQUUsV0FBVyxhQUFhLEVBQUUsVUFBVSx1QkFBdUI7QUFBQSxFQUMvRDtBQUNBLFNBQU87QUFDVDs7O0FEeEJBLElBQU0sbUNBQW1DO0FBUXpDLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFdBQVc7QUFBQSxFQUNYLFNBQVM7QUFBQSxJQUNQLE1BQU07QUFBQSxJQUNOLGNBQWM7QUFBQSxJQUNkLEtBQUs7QUFBQSxJQUNMO0FBQUEsTUFDRSxNQUFNO0FBQUEsTUFDTixXQUFXLENBQUMsS0FBSyxPQUFPO0FBQ3RCLFlBQUcsQ0FBQyxHQUFHLFNBQVMsc0JBQXNCO0FBQ3BDLGlCQUFPO0FBQ1QsZUFBTyxzQkFBc0IsR0FBRztBQUFBLE1BQ2xDO0FBQUEsSUFDRjtBQUFBLEVBQ0Y7QUFBQSxFQUNBLE1BQU07QUFBQSxFQUNOLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxJQUNSLFFBQVE7QUFBQSxJQUNSLGFBQWE7QUFBQSxFQUNmO0FBQUEsRUFDQSxRQUFRO0FBQUEsSUFDTixTQUFTO0FBQUEsTUFDUCxHQUFJLFFBQVEsSUFBSSxpQkFBaUI7QUFBQSxRQUMvQiwyQkFBMkIsUUFBUSxJQUFJO0FBQUEsTUFDekM7QUFBQSxJQUNGO0FBQUEsSUFDQSxjQUFjLENBQUMsaUNBQWlDLGFBQWEsV0FBVztBQUFBLElBQ3hFLE9BQU87QUFBQSxNQUNMLG9CQUFvQjtBQUFBLFFBQ2xCLFFBQVE7QUFBQSxRQUNSLGNBQWM7QUFBQSxRQUNkLFNBQVMsQ0FBQ0EsVUFBU0EsTUFBSyxRQUFRLHNCQUFzQixFQUFFO0FBQUEsTUFDMUQ7QUFBQSxJQUNGO0FBQUEsRUFDRjtBQUFBLEVBQ0EsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsZ0JBQWdCLEtBQUssUUFBUSxrQ0FBVywwQkFBMEI7QUFBQSxNQUNsRSxLQUFLLEdBQUcsS0FBSyxRQUFRLGtDQUFXLE9BQU8sQ0FBQztBQUFBLElBQzFDO0FBQUEsRUFDRjtBQUNGLENBQUM7IiwKICAibmFtZXMiOiBbInBhdGgiXQp9Cg== diff --git a/constants.ini b/constants.ini index bc09e038..90c8ddcb 100644 --- a/constants.ini +++ b/constants.ini @@ -7,5 +7,11 @@ RAG_SEARCH_PROJECT_LAYER=rag-search RAG_SEARCH_TIM=http://tim:8085 RAG_SEARCH_CRON_MANAGER=http://cron-manager:9010 RAG_SEARCH_LLM_ORCHESTRATOR=http://llm-orchestration-service:8100/orchestrate +RAG_SEARCH_PROMPT_REFRESH=http://llm-orchestration-service:8100/prompt-config/refresh DOMAIN=localhost -DB_PASSWORD=dbadmin \ No newline at end of file +DB_PASSWORD=dbadmin +RAG_SEARCH_RUUTER_PUBLIC_INTERNAL_SERVICE=http://ruuter:8086/services +SERVICE_DMAPPER_HBS=http://data-mapper:3000/hbs/rag-search +SERVICE_PROJECT_LAYER=services +RAG_SEARCH_LLM_SERVICE=http://llm-orchestration-service:8100 +CKB_RUUTER_INTERNAL=http://ruuter-internal:8089/ckb diff --git a/docker-compose-ec2.yml b/docker-compose-ec2.yml index 26c19068..a9052865 100644 --- a/docker-compose-ec2.yml +++ b/docker-compose-ec2.yml @@ -103,11 +103,11 @@ services: container_name: resql image: resql depends_on: - rag_search_db: + rag-search-db: condition: service_started environment: - sqlms.datasources.[0].name=byk - - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag_search_db:5432/rag-search #For LocalDb Use + - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag-search-db:5432/rag-search #For LocalDb Use # sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://171.22.247.13:5435/byk?sslmode=require - sqlms.datasources.[0].username=postgres - sqlms.datasources.[0].password=dbadmin @@ -128,7 +128,8 @@ services: - REACT_APP_RUUTER_API_URL=https://est-rag-rtc.rootcode.software/ruuter-public - REACT_APP_RUUTER_PRIVATE_API_URL=https://est-rag-rtc.rootcode.software/ruuter-private - REACT_APP_CUSTOMER_SERVICE_LOGIN=https://est-rag-rtc.rootcode.software/authentication-layer/et/dev-auth - - REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:3005 ws://localhost https://vault-agent-gui:8202 https://est-rag-rtc.rootcode.software; + - REACT_APP_NOTIFICATION_NODE_URL=https://est-rag-rtc.rootcode.software/notifications-node + - REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:3005 http://localhost:4040 https://vault-agent-gui:8202 ws://localhost https://est-rag-rtc.rootcode.software; - DEBUG_ENABLED=true - CHOKIDAR_USEPOLLING=true - PORT=3001 @@ -179,6 +180,9 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/tool_classifier:/app/src/tool_classifier + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment + - ./src/api_tool_indexer:/app/src/api_tool_indexer - ./src/utils/decrypt_vault_secrets.py:/app/src/utils/decrypt_vault_secrets.py:ro # Decryption utility (read-only) - cron_data:/app/data - shared-volume:/app/shared # Access to shared resources for cross-container coordination @@ -187,7 +191,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/intent_data_enrichment:/app/src/api_tool_indexer - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 @@ -300,7 +304,7 @@ services: image: docker.io/langfuse/langfuse-worker:3 restart: always depends_on: &langfuse-depends-on - rag_search_db: + rag-search-db: condition: service_healthy minio: condition: service_healthy @@ -367,7 +371,7 @@ services: restart: always depends_on: - langfuse-worker - - rag_search_db + - rag-search-db ports: - 3005:3000 env_file: @@ -461,8 +465,8 @@ services: networks: - bykstack - rag_search_db: - container_name: rag_search_db + rag-search-db: + container_name: rag-search-db image: postgres:14.1 restart: always healthcheck: @@ -480,6 +484,7 @@ services: - 5436:5432 volumes: - rag-search-db:/var/lib/postgresql/data + - ./DSL/Liquibase/langfuse-init/init-langfuse.sql:/docker-entrypoint-initdb.d/init-langfuse.sql:ro networks: - bykstack @@ -625,6 +630,10 @@ services: - ENVIRONMENT=production - VAULT_ADDR=http://vault-agent-llm:8201 # VAULT_TOKEN not set - vault-agent-llm proxy handles authentication + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT} + - REDIS_AUTH=${REDIS_AUTH} + - REDIS_SESSION_DB=${REDIS_SESSION_DB} volumes: - ./src/llm_config_module/config:/app/src/llm_config_module/config:ro - ./src/optimization/optimized_modules:/app/src/optimization/optimized_modules @@ -633,6 +642,7 @@ services: - bykstack depends_on: - vault-agent-llm + - redis healthcheck: test: ["CMD", "curl", "-f", "http://llm-orchestration-service:8100/health"] interval: 30s diff --git a/docker-compose-test.yml b/docker-compose-test.yml index a9cfd5ad..a0c56074 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -2,9 +2,9 @@ services: # === Core Infrastructure === # Shared PostgreSQL database (used by both application and Langfuse) - rag_search_db: + rag-search-db: image: postgres:14.1 - container_name: rag_search_db + container_name: rag-search-db restart: always environment: POSTGRES_USER: postgres @@ -89,11 +89,11 @@ services: container_name: resql image: ghcr.io/buerokratt/resql:v1.3.6 depends_on: - rag_search_db: + rag-search-db: condition: service_started environment: - sqlms.datasources.[0].name=byk - - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag_search_db:5432/rag-search #For LocalDb Use + - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag-search-db:5432/rag-search #For LocalDb Use # sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://171.22.247.13:5435/byk?sslmode=require - sqlms.datasources.[0].username=postgres - sqlms.datasources.[0].password=dbadmin @@ -222,7 +222,7 @@ services: container_name: langfuse-worker restart: always depends_on: - - rag_search_db + - rag-search-db - minio - redis - clickhouse @@ -230,7 +230,7 @@ services: - "127.0.0.1:3030:3030" environment: # Database - DATABASE_URL: postgresql://postgres:dbadmin@rag_search_db:5432/rag-search + DATABASE_URL: postgresql://postgres:dbadmin@rag-search-db:5432/rag-search # Auth & Security (TEST VALUES ONLY - NOT FOR PRODUCTION) # gitleaks:allow - These are test-only hex strings @@ -279,13 +279,13 @@ services: restart: always depends_on: - langfuse-worker - - rag_search_db + - rag-search-db - clickhouse ports: - "3000:3000" environment: # Database - DATABASE_URL: postgresql://postgres:dbadmin@rag_search_db:5432/rag-search + DATABASE_URL: postgresql://postgres:dbadmin@rag-search-db:5432/rag-search # Auth & Security (TEST VALUES ONLY - NOT FOR PRODUCTION) # gitleaks:allow - These are test-only hex strings diff --git a/docker-compose.yml b/docker-compose.yml index 8a9d119e..7fb5b0fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: volumes: - ./DSL:/data - ./DSL/DMapper/rag-search/hbs:/workspace/app/views/rag-search - - ./DSL/DMapper/rag-search/lib:/workspace/app/lib + - ./DSL/DMapper/rag-search/js:/workspace/app/js/rag-search ports: - 3001:3000 networks: @@ -87,7 +87,7 @@ services: volumes: - ./tim-db:/var/lib/postgresql/data ports: - - 9876:5432 + - 9875:5432 networks: - bykstack @@ -103,11 +103,11 @@ services: container_name: resql image: resql depends_on: - rag_search_db: + rag-search-db: condition: service_started environment: - sqlms.datasources.[0].name=byk - - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag_search_db:5432/rag-search #For LocalDb Use + - sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://rag-search-db:5432/rag-search #For LocalDb Use # sqlms.datasources.[0].jdbcUrl=jdbc:postgresql://171.22.247.13:5435/byk?sslmode=require - sqlms.datasources.[0].username=postgres - sqlms.datasources.[0].password=dbadmin @@ -127,7 +127,8 @@ services: - REACT_APP_RUUTER_API_URL=http://localhost:8086 - REACT_APP_RUUTER_PRIVATE_API_URL=http://localhost:8088 - REACT_APP_CUSTOMER_SERVICE_LOGIN=http://localhost:3004/et/dev-auth - - REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:3005 https://vault-agent-gui:8202 ws://localhost https://est-rag-rtc.rootcode.software; + - REACT_APP_NOTIFICATION_NODE_URL=http://localhost:4040 + - REACT_APP_CSP=upgrade-insecure-requests; default-src 'self'; font-src 'self' data:; img-src 'self' data:; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; connect-src 'self' http://localhost:8086 http://localhost:8088 http://localhost:3004 http://localhost:3005 http://localhost:4040 https://vault-agent-gui:8202 ws://localhost https://est-rag-rtc.rootcode.software; - DEBUG_ENABLED=true - CHOKIDAR_USEPOLLING=true - PORT=3001 @@ -178,6 +179,9 @@ services: - ./DSL/CronManager/DSL:/DSL - ./DSL/CronManager/script:/app/scripts - ./src/vector_indexer:/app/src/vector_indexer + - ./src/tool_classifier:/app/src/tool_classifier + - ./src/intent_data_enrichment:/app/src/intent_data_enrichment + - ./src/api_tool_indexer:/app/src/api_tool_indexer - ./src/utils/decrypt_vault_secrets.py:/app/src/utils/decrypt_vault_secrets.py:ro # Decryption utility (read-only) - cron_data:/app/data - shared-volume:/app/shared # Access to shared resources for cross-container coordination @@ -186,7 +190,7 @@ services: - ./.env:/app/.env:ro environment: - server.port=9010 - - PYTHONPATH=/app:/app/src/vector_indexer + - PYTHONPATH=/app:/app/src/vector_indexer:/app/src/intent_data_enrichment:/app/src/api_tool_indexer - VAULT_AGENT_URL=http://vault-agent-cron:8203 ports: - 9010:8080 @@ -247,7 +251,7 @@ services: image: docker.io/langfuse/langfuse-worker:3 restart: always depends_on: &langfuse-depends-on - rag_search_db: + rag-search-db: condition: service_healthy minio: condition: service_healthy @@ -314,7 +318,7 @@ services: restart: always depends_on: - langfuse-worker - - rag_search_db + - rag-search-db ports: - 3005:3000 env_file: @@ -408,8 +412,8 @@ services: networks: - bykstack - rag_search_db: - container_name: rag_search_db + rag-search-db: + container_name: rag-search-db image: postgres:14.1 restart: always healthcheck: @@ -427,6 +431,7 @@ services: - 5436:5432 volumes: - rag-search-db:/var/lib/postgresql/data + - ./DSL/Liquibase/langfuse-init/init-langfuse.sql:/docker-entrypoint-initdb.d/init-langfuse.sql:ro networks: - bykstack @@ -572,14 +577,20 @@ services: - ENVIRONMENT=production - VAULT_ADDR=http://vault-agent-llm:8201 # VAULT_TOKEN not set - vault-agent-llm proxy handles authentication + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT} + - REDIS_AUTH=${REDIS_AUTH} + - REDIS_SESSION_DB=${REDIS_SESSION_DB} volumes: - ./src/llm_config_module/config:/app/src/llm_config_module/config:ro - ./src/optimization/optimized_modules:/app/src/optimization/optimized_modules - llm_orchestration_logs:/app/logs + - ./tests:/app/tests # mount tests directory (excluded from image via .dockerignore) networks: - bykstack depends_on: - vault-agent-llm + - redis healthcheck: test: ["CMD", "curl", "-f", "http://llm-orchestration-service:8100/health"] interval: 30s diff --git a/docs/API_TOOL_CALLING.md b/docs/API_TOOL_CALLING.md new file mode 100644 index 00000000..bdc5c655 --- /dev/null +++ b/docs/API_TOOL_CALLING.md @@ -0,0 +1,770 @@ +# API Tool Calling — Architecture & Implementation + + + +## Overview + +API Tool Calling enables the LLM module to discover and invoke external API endpoints +in response to user queries. Endpoints are registered, semantically indexed in Qdrant, +and retrieved at query time using hybrid search. Once matched, a multi-turn agentic +loop collects all required parameters from the user before the API call is made. + + +| Component | What it does | Status | +|---|---|---| +| **Indexing pipeline** | Takes an endpoint definition → enriches it with LLM context → stores hybrid vectors in Qdrant | ✅ Complete | +| **Tool classifier** | At query time, routes to the best matching endpoint via hybrid search + LLM disambiguation | ✅ Complete | +| **Agentic loop** | Multi-turn parameter collection with session persistence, language-aware clarifying questions, param correction, continuation prompt, and intent-switch detection | ✅ Complete | +| **API caller** | Execute collected params against the real API endpoint, with circuit-breaker protection and localized error handling | ✅ Complete | +| **Response formatter** | Convert raw API JSON into a natural-language answer via DSPy, streamed token-by-token to the GUI | ✅ Complete | + +--- + +## System Components + +``` +Ruuter DSL (/api-tools/index) + ↓ HTTP POST +CronManager (api_tool_indexer job) + ↓ exec +api_tool_indexer.sh (bash) + ↓ python3 +main_indexer.py (indexing pipeline) + ↓ upsert +api_tool_collection (Qdrant) + ↑ query at runtime +APISemanticSearcher (src/tool_classifier/api_semantic_searcher.py) + ↑ called by +ToolClassifier._try_api_tool_classification() + ↓ ClassificationResult(workflow=API_TOOL_CALLING) +APIToolWorkflowExecutor (src/tool_classifier/workflows/api_tool_workflow.py) + ↓ multi-turn param collection +AgenticLoop (src/tool_classifier/agentic_loop.py) + ↓ session state +APIToolSessionStore (Redis, keyed by chat_id, 30-min TTL) + ↓ all params collected +APICaller (src/tool_classifier/api_caller.py) + ↓ raw JSON response +APIResponseFormatterModule (src/tool_classifier/api_response_formatter.py) + ↓ SSE token stream +User (GUI) +``` + +--- + +## Part 1 — Indexing Pipeline + +### Trigger: `POST /rag-search/api-tools/index` + +Defined in [DSL/Ruuter.public/rag-search/POST/api-tools/index.yml](../DSL/Ruuter.public/rag-search/POST/api-tools/index.yml). + +**Request body** (sent from Postman while no UI exists): + +```json +{ + "endpointId": "a3f7c2d1-84e6-4b19-92f3-d51c7e890ab2", + "serviceId": "", + "name": "get_national_holidays", + "description": "Fetch national holidays for a specific country to see when they have public days off.", + "method": "GET", + "url": "https://openholidaysapi.org/PublicHolidays", + "visibility": "public", + "type": "custom_endpoint", + "params": [ + {"name": "countryIsoCode", "type": "string", "required": true, "description": "The 2-letter ISO country code (e.g., EE for Estonia, DE for Germany)"}, + {"name": "languageIsoCode", "type": "string", "required": false, "description": "The 2-letter ISO language code (e.g., ET, EN)"}, + {"name": "validFrom", "type": "date", "required": false, "description": "Start date for the holiday search (YYYY-MM-DD)"}, + {"name": "validTo", "type": "date", "required": false, "description": "End date for the holiday search (YYYY-MM-DD)"} + ] +} +``` + +Ruuter URL-encodes `params` (JSON array → `encodeURIComponent`) and forwards everything to +CronManager via: + +``` +POST http://cron-manager:8080/execute/api_tool_indexer/index_endpoint + ?endpoint_id=...&name=...¶ms=%5B...%5D +``` + +--- + +### CronManager Job: `api_tool_indexer` + +Defined in [DSL/CronManager/DSL/api_tool_indexer.yml](../DSL/CronManager/DSL/api_tool_indexer.yml). + +```yaml +index_endpoint: + trigger: off # Not scheduled — on-demand only + type: exec + command: "/app/scripts/api_tool_indexer.sh" + allowedEnvs: ['endpoint_id', 'service_id', 'name', 'description', + 'method', 'url', 'visibility', 'type', 'params'] +``` + +`trigger: off` means this job is never run on a schedule — it only executes when +CronManager receives an HTTP `POST /execute/api_tool_indexer/index_endpoint` call. +The query params from Ruuter are injected as environment variables for the shell script. + +--- + +### Shell Script: `api_tool_indexer.sh` + +Defined in [DSL/CronManager/script/api_tool_indexer.sh](../DSL/CronManager/script/api_tool_indexer.sh). + +**What it does (in order):** + +1. Validates required env vars (`endpoint_id`, `name`, `description`, `url`) +2. Activates the pre-built Python venv at `/app/python_virtual_env` +3. Installs required packages via `uv pip install` (`httpx`, `pydantic`, `qdrant-client`, `loguru`) +4. Sets `PYTHONPATH` to include `/app/src` +5. URL-decodes the `params` env var back to a JSON array and writes it to a temp file +6. Invokes `main_indexer.py` with CLI args (avoids shell parsing issues with JSON) +7. Cleans up the temp file; exits with the Python exit code + + + +--- + +### Python Indexing Pipeline: `main_indexer.py` + +Defined in [src/api_tool_indexer/main_indexer.py](../src/api_tool_indexer/main_indexer.py). + +**Entry point**: `index_endpoint(endpoint_data: EndpointData) → IndexingResult` + +The pipeline runs 5 sequential steps: + +#### Step 1 — LLM Context Generation + +Builds a structured prompt from the endpoint's name, description, method, URL, and +params, then calls the internal `/generate-context` endpoint via `LLMAPIClient`. + +The `CONTEXT_TEMPLATE` (in `constants.py`) instructs the LLM to generate a rich +semantic description covering: +- What the user wants to accomplish by calling this endpoint +- Key terms and synonyms +- Related concepts and use cases +- Common natural language phrasings +- Response is in the **same language as the description** (Estonian / English / Russian) + +**`embed_text`** is then assembled as: + +``` +{name}. {description}. {enriched_context}. Parameters: {params_summary} +``` + +where `params_summary` is a semicolon-separated one-liner like: +``` +countryIsoCode (string, required): ISO country code; validFrom (date, optional): Start date +``` + +#### Step 2 — Dense Embedding + +`embed_text` is sent to Azure OpenAI `text-embedding-3-large` via `LLMAPIClient.create_embedding()`. +Returns a **3072-dimensional float vector** (cosine similarity space). + +#### Step 3 — Sparse (BM25) Vector + +`embed_text` is tokenised and hashed using `compute_sparse_vector()` from +[src/tool_classifier/sparse_encoder.py](../src/tool_classifier/sparse_encoder.py) +(shared with the tool classifier). + +``` +tokens = regex word-split of lowercase embed_text +index = MD5(token)[:4 bytes] % 50_000 (hash to vocab space) +value = term frequency (collisions are accumulated) +``` + +Returns `SparseVector(indices=[...], values=[...])` — sorted for consistency. + +#### Step 4 — Delete Existing Qdrant Point (idempotent) + +`ApiToolQdrantManager.delete_endpoint_point(endpoint_id)` filters by `endpoint_id` +field in the payload and deletes the point before upserting. This ensures re-indexing +the same endpoint never creates duplicates. + +#### Step 5 — Upsert to Qdrant + +A `PointStruct` is built: + +```python +PointStruct( + id = endpoint_id, # UUID used directly as Qdrant point ID + vector = { + "dense": [v1, v2, ..., v3072], + "sparse": {"indices": [...], "values": [...]}, + }, + payload = { # Stored metadata — no extra DB lookup needed + "endpoint_id": "...", + "name": "get_national_holidays", + "description": "...", + "url": "https://openholidaysapi.org/PublicHolidays", + "method": "GET", + "params": [...], + "enriched_context": "...", + "service_id": "...", + } +) +``` + +Upserted into the `api_tool_collection` Qdrant collection. + +--- + +### Qdrant Collection: `api_tool_collection` + +Created automatically on first run by `ApiToolQdrantManager.ensure_collection()`. + +``` +Collection: api_tool_collection +Vectors: + "dense" → VectorParams(size=3072, distance=COSINE) + "sparse" → SparseVectorParams(index=SparseIndexParams(on_disk=False)) +``` + +One point per endpoint. The full `EnrichedEndpoint` payload is stored so the agentic +loop can execute the API call without an additional database round-trip. + +--- + +### Data Models + +**`EndpointData`** — input to the pipeline (from Postman / DB): + +| Field | Type | Required | Description | +|---|---|---|---| +| `endpoint_id` | UUID | | Unique identifier | +| `name` | str | | snake_case function name | +| `description` | str | | Human-readable purpose | +| `url` | str | | Full target API URL | +| `method` | str | | `GET` or `POST` | +| `params` | List[Dict] | | Parameter schema `[{name, type, required, description}]` | +| `service_id` | UUID | | Parent service group | +| `visibility` | str | | `public` or `private` (default: `public`) | +| `type` | str | | Endpoint type (default: `custom_endpoint`) | + +**`ParamSchema`** — schema for each param: + +| Field | Type | Description | +|---|---|---| +| `name` | str | Parameter name | +| `type` | str | `string`, `date`, `datetime`, `integer`, `boolean`, `number` | +| `required` | bool | Whether the caller must supply this param | +| `description` | str | Human-readable description | + +> **`datetime` type:** normalised to `YYYY-MM-DDTHH:MM:SSZ` by `ParamExtractionModule._validate_param_type()`. Useful for APIs that require ISO 8601 datetime strings (e.g. electricity price endpoints). + +--- + + +## Part 2 — Tool Classifier (Query-Time) + +### Overview + +At query time, `ToolClassifier` in [src/tool_classifier/classifier.py](../src/tool_classifier/classifier.py) the layer by layer execution happens + + +1. **Service search** → `intent_collections` (Qdrant) — existing Bürokratt services +2. **API Tool search** → `api_tool_collection` (Qdrant) — registered API tool endpoints + +API tool search (`_try_api_tool_classification`) is triggered when: +- `SERVICE_WORKFLOW_ENABLED=false` (service workflow disabled globally) +- Dense service search returns no results +- Service cosine score falls below `DENSE_MIN_THRESHOLD` + +It is **always** tried before falling back to Context/RAG. + +--- + +### Component: `APISemanticSearcher` + +Defined in [src/tool_classifier/api_semantic_searcher.py](../src/tool_classifier/api_semantic_searcher.py). + +Instantiated once in `ToolClassifier.__init__()` and reuses the shared Qdrant `httpx.AsyncClient`. + +**Constructor:** + +```python +APISemanticSearcher( + embedding_service=orchestration_service, # generates dense embeddings + qdrant_client=self._qdrant_client, # shared connection pool + disambiguator=None, # optional: inject for testing +) +``` + +**Key constants** (from `constants.py`): + +| Constant | Value | Purpose | +|---|---|---| +| `API_TOOL_COLLECTION` | `api_tool_collection` | Qdrant collection name | +| `API_TOOL_SEARCH_TOP_K` | `5` | Max hybrid results | +| `API_TOOL_MIN_THRESHOLD` | cosine threshold | Below this → no match | +| `API_TOOL_HIGH_CONFIDENCE_THRESHOLD` | cosine threshold | Above this → high confidence | +| `API_TOOL_SCORE_GAP_THRESHOLD` | gap threshold | Minimum lead over runner-up | + +--- + +### Search Flow: `APISemanticSearcher.search()` + +``` +User query + │ + ├─ precomputed_embedding provided? → reuse it (no extra API call) + └─ otherwise → generate dense embedding via embedding_service + │ + ▼ +Step 1: Dense search (api_tool_collection) + → Real cosine similarity scores per endpoint + │ + ├─ No results → return [] + ├─ top_cosine < API_TOOL_MIN_THRESHOLD → return [] + └─ continue + │ + ▼ +Step 2: Hybrid search (dense + sparse/BM25 + RRF) + → Best-ranked results by RRF fusion score + │ Falls back to dense results if hybrid returns nothing + │ + ▼ +Step 3: Annotate confidence for each hybrid result + │ + │ cosine lookup: dense_cosine_map[endpoint_id] + │ └─ fallback: point["cosine_score"] (sparse-driven result) + │ └─ skip if neither available + │ + │ effective_gap = this_cosine − best_other_cosine_in_dense + │ + ├─ i==0 AND cosine ≥ HIGH_THRESHOLD AND effective_gap ≥ GAP_THRESHOLD → "high" + ├─ cosine ≥ MIN_THRESHOLD → "medium" + └─ else → skip + │ + ▼ +Step 4: Resolve to exactly one result + ├─ high-confidence result exists → return immediately + ├─ single medium + large gap → return directly + └─ multiple medium OR small gap → LLM disambiguation + │ + └─ EndpointDisambiguatorModule (DSPy + asyncio.to_thread) + → picks winner or returns None + → None means no match → return [] +``` + +--- + +### Embedding Reuse + +When `ToolClassifier.classify()` already generated a dense embedding for the service +search, it passes it as `precomputed_embedding` to `_try_api_tool_classification`: + +```python +api_tool_result = await self._try_api_tool_classification( + query, request, precomputed_embedding=query_embedding +) +``` + +`APISemanticSearcher.search()` skips the embedding step entirely when this is provided, +saving one embedding API call per request. + +--- + +### LLM Disambiguation: `EndpointDisambiguatorModule` + +Used when multiple medium-confidence endpoints score similarly and no clear winner +can be determined from cosine scores alone. + +- DSPy `Predict` module with `EndpointDisambiguationSignature` +- Inputs: `user_query` + `candidates` (JSON list of `{endpoint_id, name, description, cosine_score}`) +- Output: `best_endpoint_id` — the winning `endpoint_id`, or `"none"` if no match +- Run via `asyncio.to_thread()` to avoid blocking the async event loop +- Understands Estonian, Russian, and English queries + +--- + +### Feature Flag + +API tool calling is gated by `FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED`. +When `false`, `_try_api_tool_classification` returns `None` immediately without +touching Qdrant. + +--- + +### Component: `APIToolWorkflowExecutor` + +Defined in [src/tool_classifier/workflows/api_tool_workflow.py](../src/tool_classifier/workflows/api_tool_workflow.py). + +Handles `WorkflowType.API_TOOL_CALLING` after `ToolClassifier.classify()` has set +`matched_endpoint` in the context dict. + +**Responsibilities:** + +- **Turn 1 (new session):** reads `context["matched_endpoint"]`, creates a new + `APIToolSession` in Redis, runs the first agentic loop turn. +- **Turn 2-N (resume):** loads the existing session from Redis, runs the next turn. +- **Fast path:** if the endpoint has no required params, immediately calls the API + without starting a session. +- **Clarifying question:** when params are still missing, streams the LLM-generated + question token-by-token via SSE. Each token is one `format_sse` frame; the stream + ends with an `END` frame. +- **API call:** when all params are collected, calls `APICaller.call()` then streams + the natural-language answer from `APIResponseFormatterModule.stream_forward()` + token-by-token via SSE. +- **Max turns:** deletes the session and returns `None` to trigger RAG fallback. + +**Streaming architecture:** + +Both clarifying questions and final responses are streamed token-by-token. +`_compute_loop_step()` is the single source of truth — it returns a `_LoopStep` +tagged as `"question"`, `"api_call"`, or `"fallback"`. `execute_streaming()` then +handles each case: + +``` +"question" → iterate step.question_tokens (real DSPy tokens) + → yield format_sse(chat_id, token) per token → yield END + +"api_call" → APICaller.call() [blocking HTTP] + → async for token in APIResponseFormatterModule.stream_forward() + → yield format_sse(chat_id, token) per token → yield END +``` + +--- + +## Part 3 — Agentic Loop (Multi-Turn Parameter Collection) + +### Overview + +Defined in [src/tool_classifier/agentic_loop.py](../src/tool_classifier/agentic_loop.py). + +`AgenticLoop` is **stateless** — it carries no internal state between HTTP requests. +All state is passed in as arguments (loaded from Redis by the workflow executor before +calling `run_turn`) and saved back to Redis inside `run_turn` before returning. + +### Session Model: `APIToolSession` + +Defined in [src/models/session_models.py](../src/models/session_models.py). + +Stored in Redis keyed by `chat_id` with a **30-minute sliding TTL**. + +| Field | Type | Description | +|---|---|---| +| `chat_id` | str | Unique conversation identifier | +| `state` | str | Current state (`collecting_params`, etc.) | +| `selected_endpoint` | dict | Full endpoint payload from Qdrant | +| `collected_params` | dict | Parameters collected so far | +| `turn_count` | int | Number of turns elapsed | +| `max_turns` | int | Max turns before fallback (default: 5) | +| `awaiting_continuation` | bool | True when continuation prompt has been shown | +| `detected_language` | str | Language from first message (`en`, `et`, `ru`) — persisted so all clarifying questions use the same language | +| `original_query` | str | The user’s first message that triggered the session — preserved across turns so the response formatter always receives the full original intent, not just the last short follow-up (e.g. `"from 2026-04-01 to 2026-04-30"`) | + +### Turn Flow + +``` +APIToolWorkflowExecutor._run() + │ + ├─ Load session from Redis (or create new) + │ + └─ AgenticLoop.run_turn( + user_message, conversation_history, + params_schema, collected_params, + turn_count, max_turns, awaiting_continuation, + session_language + ) + │ + ├─ AWAITING_CONTINUATION_DECISION? + │ yes → parse yes/no from user_message + │ yes → clear flag, continue collecting + │ no → return MAX_TURNS_REACHED (RAG fallback) + │ + ├─ ParamExtractionModule.forward() + │ → DSPy extracts params from user_message + conversation_history + │ → uses session_language for all questions + │ → new values OVERWRITE old (allows corrections) + │ + ├─ All required params present? → COMPLETED + │ + ├─ turn_count reached CONTINUATION_TURN (default: 3)? + │ → set awaiting_continuation=True + │ → return AWAITING_CONTINUATION_DECISION + │ → question = localized CONTINUATION_QUESTION (EN/ET/RU) + │ + └─ else → generate clarifying question for next missing param + → return NEEDS_INPUT + │ + └─ Save updated session to Redis +``` + +### Key Behaviours + +**Language persistence:** +The language is detected once from the user's first message and stored in +`APIToolSession.detected_language`. All subsequent clarifying questions and the +continuation prompt are generated in that language, even when follow-up replies +like "yes" or "2026-01-01" are too short to re-detect reliably. + +Supported: `en` (default), `et` (Estonian), `ru` (Russian). + +**Parameter correction:** +If the user says "No, use Russia instead of Estonia", the extractor overwrites the +previously collected `countryIsoCode` value. There is no guard preventing +re-extraction of already-collected params — new values always win. + +**Continuation prompt:** +After `CONTINUATION_TURN` turns without completing, the loop asks the user whether +to continue. If the user says no (or anything not in the yes-list), the session is +abandoned and the request falls back to the RAG workflow. + +Localized continuation questions are defined in +[src/tool_classifier/constants.py](../src/tool_classifier/constants.py): +`CONTINUATION_QUESTION`, `CONTINUATION_QUESTION_ET`, `CONTINUATION_QUESTION_RU`. + +**Constants** (in `src/tool_classifier/constants.py`): + +| Constant | Value | Description | +|---|---|---| +| `CONTINUATION_TURN` | `3` | Turn at which the continuation prompt is shown | + +--- + +## Part 4 — API Caller & Response Formatter + +### Component: `APICaller` + +Defined in [src/tool_classifier/api_caller.py](../src/tool_classifier/api_caller.py). + +Executes the external HTTP request once all required parameters have been collected +by the agentic loop. + +**Supported methods:** `GET` (params → query string) and `POST` (params → JSON body). + +**Timeout:** `API_CALL_TIMEOUT` seconds (from `constants.py`). Overridable per-call. + +**Return type:** `APICallResult` + +| Field | Type | Description | +|---|---|---| +| `success` | bool | `True` for 2xx responses | +| `status_code` | int | HTTP status code; `0` for network/timeout/circuit-breaker failures | +| `response_data` | Any | Parsed JSON on success; raw parsed error body on 4xx; empty string on all other failures | +| `error` | str \| None | Localized user-facing error message on failure; `None` on success | + +**Error handling:** + +| Failure type | `status_code` | `response_data` | `error` field | +|---|---|---|---| +| 4xx (client error, e.g. bad params) | actual code | Raw parsed body (preserved for agentic loop re-prompting) | Localized `CLIENT_ERROR_MESSAGES` | +| 5xx (server error) | actual code | `""` | Localized `SERVICE_UNAVAILABLE_MESSAGES` | +| Timeout | `0` | `""` | Localized `SERVICE_TIMEOUT_MESSAGES` | +| Network error | `0` | `""` | Localized `SERVICE_TIMEOUT_MESSAGES` | +| Redirect not followed | `3xx` | `""` | Localized `REDIRECT_NOT_FOLLOWED_MESSAGES` | +| Circuit breaker open | `0` | `""` | Localized `CIRCUIT_BREAKER_OPEN_MESSAGES` | + +4xx responses do **not** trip the circuit breaker — they indicate bad input, not a +server outage. The agentic loop can re-prompt the user for corrected values. + +**Language-aware errors:** all error messages are localized using `session.detected_language` +(`et`, `en`, `ru`). The message constants are defined in +[src/tool_classifier/constants.py](../src/tool_classifier/constants.py). + +--- + +### Component: `CircuitBreaker` + +Part of `api_caller.py`. One breaker instance per URL, shared across requests for the +lifetime of the `APICaller` instance. + +``` +CLOSED → OPEN: after CIRCUIT_BREAKER_FAILURE_THRESHOLD consecutive server/network failures +OPEN → HALF_OPEN: after CIRCUIT_BREAKER_COOLDOWN_SECONDS +HALF_OPEN → CLOSED: on first successful probe call +HALF_OPEN → OPEN: on first failed probe call +``` + +When OPEN, `call()` returns immediately without making an HTTP request. + +**Constants** (in `src/tool_classifier/constants.py`): + +| Constant | Description | +|---|---| +| `CIRCUIT_BREAKER_FAILURE_THRESHOLD` | Consecutive failures before opening | +| `CIRCUIT_BREAKER_COOLDOWN_SECONDS` | Seconds to wait before probing | + +--- + +### Component: `APIResponseFormatterModule` + +Defined in [src/tool_classifier/api_response_formatter.py](../src/tool_classifier/api_response_formatter.py). + +Converts the raw API JSON response into a natural-language answer using DSPy. +Supports both blocking (`forward`) and streaming (`stream_forward`) execution. + +**DSPy Signature:** `APIResponseFormatterSignature` + +| Input field | Description | +|---|---| +| `user_query` | The user's original question | +| `api_response` | Raw API JSON as a string (truncated to `_MAX_RESPONSE_BYTES` = 50 KB) | +| `endpoint_description` | Short description of what the endpoint does | +| `response_language` | `"English"`, `"Estonian"`, or `"Russian"` — derived from `detected_language` | + +| Output field | Description | +|---|---| +| `formatted_answer` | Clean natural-language answer, no raw JSON or markdown headers | + + + +## Part 5 — Session Management & Intent Switch Detection + +### `APIToolSessionStore` + +Defined in [src/utils/api_tool_session_store.py](../src/utils/api_tool_session_store.py). + +Redis-backed store. Key format: `session:{chat_id}`. TTL resets on every `update()`. + +Operations: `save()`, `get()`, `update()`, `delete()`. + +### Session Lifecycle + +``` +Turn 1: new query matches API tool endpoint + → session CREATED (state=collecting_params) + → clarifying question returned + +Turn 2-N: user replies + → session LOADED → loop runs → session UPDATED + +Final turn: all params collected + → session DELETED + → completed JSON returned + +OR: max turns reached / user says "no" to continuation + → session DELETED + → None returned → RAG fallback +``` + +### Intent Switch Detection + +Defined in `ToolClassifier.classify()` — the session-resume short-circuit block. + +Before resuming an active session, the classifier runs `_try_api_tool_classification()` +on the new message. If it matches a **different** endpoint with sufficient confidence, +the old session is abandoned and the new query starts fresh: + +```python +new_api_match = await self._try_api_tool_classification(query, request) +if ( + new_api_match is not None + and new_api_match.metadata["matched_endpoint"]["name"] != endpoint_name +): + await session_store.delete(request.chatId) + return new_api_match # start new session for different endpoint +``` + +### Test Endpoint Behaviour (`/orchestrate/test`) + +The test endpoint hardcodes `chatId="test-session"` for all requests. Because every +test user shares this ID, any incomplete session would be resumed by the next +unrelated test query. + +**Fix:** the test endpoint deletes `"test-session"` from Redis at the **start** of +every request, before classification runs. This makes each test query a fresh +single-turn request. + +**Trade-off:** multi-turn API tool flows cannot be tested via the test-LLM page. +The session is wiped before turn 2 can use it. To test multi-turn flows, use the +production `/orchestrate/stream` endpoint (which uses unique `chatId` per tab) or +the integration test script. + +--- + +### End-to-End Flow (Query Time) + +``` +Turn 1 — User: "What are the public holidays in Estonia?" + │ + ▼ +ToolClassifier.classify() + │ + ├─ No active session in Redis for this chat_id + ├─ Dense search (intent_collections) → low cosine → below threshold + └─ _try_api_tool_classification() + └─ APISemanticSearcher.search() + ├─ Dense: get_public_holidays cosine=0.87 → high confidence + └─ return [APIToolSearchResult(name="get_public_holidays", ...)] + └─ ClassificationResult(workflow=API_TOOL_CALLING, metadata={matched_endpoint: {...}}) + │ + ▼ +APIToolWorkflowExecutor._run() + ├─ No existing session → create new APIToolSession (turn_count=0, language=en, original_query="What are the public holidays in Estonia?") + └─ AgenticLoop.run_turn(turn_count=0, history=[]) + ├─ ParamExtractionModule: no params in "What are the public holidays in Estonia?" + │ but countryIsoCode=EE can be inferred → extracted + ├─ Missing: validFrom, validTo + └─ NEEDS_INPUT → "Which date range would you like? (validFrom, validTo)" + │ + Session saved to Redis + ▼ +Bot: "Which date range would you like? Please provide validFrom and validTo (YYYY-MM-DD)." + +--- + +Turn 2 — User: "This year, 2026-01-01 to 2026-12-31" + │ + ▼ +ToolClassifier.classify() + ├─ Active session found for chat_id → run intent-switch check + ├─ _try_api_tool_classification("This year, 2026-01-01 to 2026-12-31") + │ → cosine=0.12 < threshold → no new API tool match + └─ Same endpoint → resume session → ClassificationResult(reason=active_session_resume) + │ + ▼ +APIToolWorkflowExecutor._run() + └─ AgenticLoop.run_turn(turn_count=1, collected_params={countryIsoCode: "EE"}) + ├─ ParamExtractionModule: extracts validFrom=2026-01-01, validTo=2026-12-31 + ├─ All required params present + └─ COMPLETED + │ + Session DELETED from Redis + ▼ +APIToolWorkflowExecutor._stream_api_and_format() + ├─ user_query = session.original_query → "What are the public holidays in Estonia?" + ├─ APICaller.call(GET https://openholidaysapi.org/PublicHolidays, params={countryIsoCode,validFrom,validTo}) + │ → status=200, response_data=[{"name": "New Year's Day", ...}, ...] + └─ APIResponseFormatterModule.stream_forward(user_query, api_response, description, language="en") + → DSPy StreamResponse tokens yielded one by one + → format_sse(chat_id, "Here are the public holidays ") ... + → format_sse(chat_id, "END") + │ + ▼ +Bot: "Here are the public holidays in Estonia for 2026:\n- New Year's Day (1 Jan)\n- ..." ← streamed token-by-token +``` + +--- + +## Part 6 — Integration Testing + +### Test Script + +Defined in [tests/api_tool_eval/integration_test_agentic_loop.py](../tests/api_tool_eval/integration_test_agentic_loop.py). + +Runs end-to-end against the live service at `http://localhost:8100` via `/orchestrate`. +Each scenario uses a unique `chatId` (UUID) so sessions are fully isolated. + +```bash +uv run --no-project --with requests python tests/api_tool_eval/integration_test_agentic_loop.py \ + --no-fail-fast \ + --output tests/api_tool_eval/integration-results.json +``` + +### Covered Scenarios + +| # | Scenario | Turns | What it validates | +|---|---|---|---| +| 1 | Single-turn complete | 1 | Vehicle tax with plate number in first message → immediate API call + formatted response | +| 2 | Multi-turn EN | 2 | Public holidays, country extracted turn 1, dates provided turn 2 → API call + formatted response | +| 3 | Multi-turn ET | 2 | School holidays in Estonian → language-aware classification + Estonian response | +| 4 | No-params fast path | 1 | Parliament votings endpoint has no required params → immediate API call without session | +| 5 | Address search | 2 | Two-turn address lookup | +| 6 | Electricity prices | 2 | `datetime` params across two turns | +| 7 | Session isolation | 2 | Two different chat IDs — no param leak between sessions | +| 8 | AWAITING_CONTINUATION → yes | 4+ | User says “yes” at continuation prompt → loop resumes → API call on completion | +| 9 | MAX_TURNS_REACHED | 5+ | User never provides params → falls back to RAG | \ No newline at end of file diff --git a/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md b/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md new file mode 100644 index 00000000..8a67e841 --- /dev/null +++ b/docs/CONTEXT_WORKFLOW_GREETING_DETECTION.md @@ -0,0 +1,323 @@ +# Context Workflow: Greeting Detection and Conversation History Analysis + +## Overview + +The **Context Workflow (Layer 2)** intercepts user queries that can be answered without searching the knowledge base. It handles two categories: + +1. **Greetings** — Detects and responds to social exchanges (hello, goodbye, thanks) in multiple languages +2. **Conversation history references** — Answers follow-up questions that refer to information already discussed in the session + +When the context workflow can answer, a response is returned immediately, bypassing the RAG pipeline entirely. When it cannot answer, the query falls through to the RAG workflow (Layer 3). + +--- + +## Architecture + +### Position in the Classifier Chain + +``` +User Query + ↓ +Layer 1: SERVICE → External API calls + ↓ (cannot handle) +Layer 2: CONTEXT → Greetings + conversation history ←── This document + ↓ (cannot handle) +Layer 3: RAG → Knowledge base retrieval + ↓ (cannot handle) +Layer 4: OOD → Out-of-domain fallback +``` + +### Key Components + +| Component | File | Responsibility | +|-----------|------|----------------| +| `ContextAnalyzer` | `src/tool_classifier/context_analyzer.py` | LLM-based greeting detection and context analysis | +| `ContextWorkflowExecutor` | `src/tool_classifier/workflows/context_workflow.py` | Orchestrates the workflow, handles streaming/non-streaming | +| `ToolClassifier` | `src/tool_classifier/classifier.py` | Invokes `ContextAnalyzer` during classification and routes to `ContextWorkflowExecutor` | +| `greeting_constants.py` | `src/tool_classifier/greeting_constants.py` | Fallback greeting responses for Estonian and English | + +--- + +## Full Request Flow + +``` +User Query + Conversation History + ↓ +ToolClassifier.classify() + ├─ Layer 1 (SERVICE): Embedding-based intent routing + │ └─ If no service tool matches → route to CONTEXT workflow + │ + └─ ClassificationResult(workflow=CONTEXT) + +ToolClassifier.route_to_workflow() + ├─ Non-streaming → ContextWorkflowExecutor.execute_async() + │ ├─ Phase 1: _detect() → context_analyzer.detect_context() [classification only] + │ ├─ If greeting → return greeting OrchestrationResponse + │ ├─ If can_answer → _generate_response_async() → context_analyzer.generate_context_response() + │ └─ Otherwise → return None (RAG fallback) + │ + └─ Streaming → ContextWorkflowExecutor.execute_streaming() + ├─ Phase 1: _detect() → context_analyzer.detect_context() [classification only] + ├─ If greeting → _stream_greeting() async generator + ├─ If can_answer → _create_history_stream() → context_analyzer.stream_context_response() + └─ Otherwise → return None (RAG fallback) +``` + +--- + +## Phase 1: Detection (Classify Only) + +### LLM Task + +Every query is checked against the **most recent 10 conversation turns** using a single LLM call (`detect_context()`). This phase **does not generate an answer** — it only classifies the query and extracts a relevant context snippet for Phase 2. + +The `ContextDetectionSignature` DSPy signature instructs the LLM to: + +1. Detect if the query is a greeting in any supported language +2. Check if the query references something discussed in the last 10 turns +3. If the query can be answered from history, extract the relevant snippet +4. Do **not** generate the final answer here — detection only + +### LLM Output Format + +The LLM returns a JSON object parsed into `ContextDetectionResult`: + +```json +{ + "is_greeting": false, + "can_answer_from_context": true, + "reasoning": "User is asking about tax rate discussed earlier", + "context_snippet": "Bot confirmed the flat rate is 20%, applying equally to all income brackets." +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `is_greeting` | `bool` | Whether the query is a greeting | +| `can_answer_from_context` | `bool` | Whether the query can be answered from conversation history | +| `reasoning` | `str` | Brief explanation of the detection decision | +| `context_snippet` | `str \| null` | Relevant excerpt from history for use in Phase 2, or `null` | + +> **Internal field**: `answered_from_summary` (bool, default `False`) is reserved for future summary-based detection paths. + +### Decision After Phase 1 + +``` +is_greeting=True → Phase 2: return greeting response (no LLM call) +can_answer_from_context=True AND snippet set → Phase 2: generate answer from snippet +Otherwise → Fall back to RAG +``` + +--- + +## Phase 2: Response Generation + +### Non-Streaming (`_generate_response_async`) + +Calls `generate_context_response(query, context_snippet)` which uses `ContextResponseGenerationSignature` to produce a complete answer in a single LLM call. Output guardrails are applied before returning the `OrchestrationResponse`. + +### Streaming (`_create_history_stream` → `stream_context_response`) + +Calls `stream_context_response(query, context_snippet)` which uses DSPy native streaming (`dspy.streamify`) with `ContextResponseGenerationSignature`. Tokens are yielded in real time and passed through NeMo Guardrails before being SSE-formatted. + +--- + +--- + +## Greeting Detection + +### Supported Languages + +| Language | Code | +|----------|------| +| Estonian | `et` | +| English | `en` | + +### Supported Greeting Types + +| Type | Estonian Examples | English Examples | +|------|-------------------|-----------------| +| `hello` | Tere, Hei, Tervist, Moi | Hello, Hi, Hey, Good morning | +| `goodbye` | Nägemist, Tšau | Bye, Goodbye, See you, Good night | +| `thanks` | Tänan, Aitäh, Tänud | Thank you, Thanks | +| `casual` | Tere, Tervist | Hey | + +### Greeting Response Generation + +Greeting detection is handled in **Phase 1 (`detect_context`)**, where the LLM classifies whether the query is a greeting and, if so, identifies the language and greeting type. This phase does **not** generate the final natural-language reply. +In **Phase 2**, `ContextWorkflowExecutor` calls `get_greeting_response(...)`, which returns a response based on predefined static templates in `greeting_constants.py`, ensuring the reply is in the detected language. If greeting detection fails or the greeting type is unsupported, the query falls through to the next workflow layer instead of attempting LLM-based greeting generation. +**Greeting response templates (`greeting_constants.py`):** + +```python +GREETINGS_ET = { + "hello": "Tere! Kuidas ma saan sind aidata?", + "goodbye": "Nägemist! Head päeva!", + "thanks": "Palun! Kui on veel küsimusi, küsi julgelt.", + "casual": "Tere! Mida ma saan sinu jaoks teha?", +} + +GREETINGS_EN = { + "hello": "Hello! How can I help you?", + "goodbye": "Goodbye! Have a great day!", + "thanks": "You're welcome! Feel free to ask if you have more questions.", + "casual": "Hey! What can I do for you?", +} +``` + +The fallback greeting type is determined by keyword matching in `_detect_greeting_type()` — checking for `thank/tänan/aitäh`, `bye/goodbye/nägemist/tšau`, before defaulting to `hello`. + +--- + +## Streaming Support + +The context workflow supports both response modes: + +### Non-Streaming (`execute_async`) + +Returns a complete `OrchestrationResponse` object with the answer as a single string. Output guardrails are applied before the response is returned. + +### Streaming (`execute_streaming`) + +Returns an `AsyncIterator[str]` that yields SSE (Server-Sent Events) chunks. + +**Greeting responses** are yielded as a single SSE chunk followed by `END`. + +**History responses** use DSPy native streaming (`dspy.streamify`) with `ContextResponseGenerationSignature`. Tokens are emitted in real time as they arrive from the LLM, then passed through NeMo Guardrails (`stream_with_guardrails`) before being SSE-formatted. If a guardrail violation is detected in a chunk, streaming stops and the violation message is sent instead. + +**SSE Format:** +``` +data: {"chatId": "abc123", "payload": {"content": "Tere! Kuidas ma"}, "timestamp": "...", "sentTo": []} + +data: {"chatId": "abc123", "payload": {"content": " saan sind aidata?"}, "timestamp": "...", "sentTo": []} + +data: {"chatId": "abc123", "payload": {"content": "END"}, "timestamp": "...", "sentTo": []} +``` + +--- + +## Cost Tracking + +LLM token usage and cost is tracked via `get_lm_usage_since()` and stored in `costs_metric` within the workflow executor. Costs are logged via `orchestration_service.log_costs()` at the end of each execution path. + +Two cost keys are tracked separately: + +```python +costs_metric = { + "context_detection": { + # Phase 1: detect_context() — single LLM call + "total_cost": 0.0012, + "total_tokens": 180, + "total_prompt_tokens": 150, + "total_completion_tokens": 30, + "num_calls": 1, + }, + "context_response": { + # Phase 2: generate_context_response() or stream_context_response() + "total_cost": 0.003, + "total_tokens": 140, + "total_prompt_tokens": 100, + "total_completion_tokens": 40, + "num_calls": 1, + }, +} +``` + +Greeting responses skip Phase 2, so only `"context_detection"` cost is populated. + +--- + +--- + +## Error Handling and Fallback + +| Failure Point | Behaviour | +|---------------|-----------| +| Phase 1 LLM call raises exception | `can_answer_from_context=False` → falls back to RAG | +| Phase 1 returns invalid JSON | Logged as warning, all flags default to `False` → falls back to RAG | +| Phase 2 LLM call raises exception | Logged as error, `_generate_response_async` returns `None` → falls back to RAG | +| Phase 2 returns empty answer | Logged as warning → falls back to RAG | +| Output guardrails fail | Logged as warning, response returned without guardrail check | +| Guardrail violation in streaming | `OUTPUT_GUARDRAIL_VIOLATION_MESSAGE` sent, stream terminated | +| `orchestration_service` unavailable | History streaming skipped → `None` returned → RAG fallback | +| `guardrails_adapter` not a `NeMoRailsAdapter` | Logged as warning → cannot stream → RAG fallback | +| Any unhandled exception in executor | Error logged, `execute_async/execute_streaming` returns `None` → RAG fallback via classifier | + +--- + +## Logging + +Key log entries emitted during a request: + +| Level | Message | When | +|-------|---------|------| +| `INFO` | `CONTEXT WORKFLOW (NON-STREAMING) \| Query: '...'` | `execute_async()` entry | +| `INFO` | `CONTEXT WORKFLOW (STREAMING) \| Query: '...'` | `execute_streaming()` entry | +| `INFO` | `CONTEXT DETECTOR: Phase 1 \| Query: '...' \| History: N turns` | `detect_context()` entry | +| `INFO` | `DETECTION RESULT \| Greeting: ... \| Can Answer: ... \| Has snippet: ...` | Phase 1 LLM response parsed | +| `INFO` | `Detection cost \| Total: $... \| Tokens: N` | After Phase 1 cost tracked | +| `INFO` | `Detection: greeting=... can_answer=...` | After `_detect()` returns in executor | +| `INFO` | `CONTEXT GENERATOR: Phase 2 non-streaming \| Query: '...'` | `generate_context_response()` entry | +| `INFO` | `CONTEXT GENERATOR: Phase 2 streaming \| Query: '...'` | `stream_context_response()` entry | +| `INFO` | `Context response streaming complete (final Prediction received)` | DSPy streaming finished | +| `WARNING` | `[chatId] Phase 2 empty answer — fallback to RAG` | Phase 2 returned no content | +| `WARNING` | `[chatId] Guardrails violation in context streaming` | Violation detected mid-stream | +| `WARNING` | `[chatId] Cannot answer from context — falling back to RAG` | Neither phase could answer | + +--- + +## Data Models + +### `ContextDetectionResult` (Phase 1 output) + +```python +class ContextDetectionResult(BaseModel): + is_greeting: bool # True if query is a greeting + can_answer_from_context: bool # True if query can be answered from last 10 turns + reasoning: str # LLM's brief explanation + answered_from_summary: bool # Reserved; always False in current workflow + context_snippet: Optional[str] # Relevant excerpt for Phase 2 generation, or None +``` + +### `ContextDetectionSignature` (DSPy — Phase 1) + +| Field | Type | Description | +|-------|------|-------------| +| `conversation_history` | Input | Last 10 turns formatted as JSON | +| `user_query` | Input | Current user query | +| `detection_result` | Output | JSON with `is_greeting`, `can_answer_from_context`, `reasoning`, `context_snippet` | + +> Detection only — **no answer generated here**. + +### `ContextResponseGenerationSignature` (DSPy — Phase 2) + +| Field | Type | Description | +|-------|------|-------------| +| `context_snippet` | Input | Relevant excerpt from Phase 1 | +| `user_query` | Input | Current user query | +| `answer` | Output | Natural language response in the same language as the query | + +--- + +## Decision Summary Table + +| Scenario | Phase 1 LLM Calls | Phase 2 LLM Calls | Outcome | +|----------|--------------------|--------------------|---------| +| Greeting detected | 1 (`detect_context`) | 0 (static response) | Context responds (greeting) | +| Follow-up answerable from last 10 turns | 1 (`detect_context`) | 1 (`generate_context_response` or `stream_context_response`) | Context responds | +| Cannot answer from last 10 turns | 1 (`detect_context`) | 0 | Falls back to RAG | +| Phase 1 LLM error / JSON parse failure | — | 0 | Falls back to RAG | +| Phase 2 LLM error or empty answer | 1 | — | Falls back to RAG | + +--- + +## File Reference + +| File | Purpose | +|------|---------| +| `src/tool_classifier/context_analyzer.py` | Core LLM analysis logic (all three steps) | +| `src/tool_classifier/workflows/context_workflow.py` | Workflow executor (streaming + non-streaming) | +| `src/tool_classifier/classifier.py` | Classification layer that invokes context analysis | +| `src/tool_classifier/greeting_constants.py` | Static fallback greeting responses (ET/EN) | +| `tests/test_context_analyzer.py` | Unit tests for `ContextAnalyzer` | +| `tests/test_context_workflow.py` | Unit tests for `ContextWorkflowExecutor` | +| `tests/test_context_workflow_integration.py` | Integration tests for the full classify → route → execute chain | \ No newline at end of file diff --git a/docs/CUSTOM_PROMPT_CONFIGURATION.md b/docs/CUSTOM_PROMPT_CONFIGURATION.md new file mode 100644 index 00000000..8a7f94ef --- /dev/null +++ b/docs/CUSTOM_PROMPT_CONFIGURATION.md @@ -0,0 +1,371 @@ +# Custom Prompt Configuration Flow + +## Overview + +The custom prompt configuration system allows admins to configure prompts via UI that automatically apply to all response generation operations. Changes are cached with a 5-minute TTL and can be immediately refreshed when updated. + +--- + +## Architecture Components + +### 1. **Database Layer** +- **Table**: `public.prompt_configuration` +- **Columns**: `id` (BIGINT), `prompt` (TEXT) +- Stores the custom prompt text configured by admins + +### 2. **Ruuter DSL Endpoints** +- **Get Prompt**: `DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml` + - Fetches prompt from database via Resql + - Returns prompt data or empty object + +- **Save Prompt**: `DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml` + - Updates/inserts prompt in database + - Automatically triggers cache refresh after save + +### 3. **Python Components** +- **PromptConfigurationLoader** (`src/utils/prompt_config_loader.py`) + - HTTP client to fetch prompts via Ruuter + - 5-minute TTL cache with thread safety + - Retry logic (3 attempts, exponential backoff) + - Force refresh capability + +- **LLMOrchestrationService** (`src/llm_orchestration_service.py`) + - Initializes loader at startup + - Formats custom instructions with wrapper tags + - Passes to ResponseGeneratorAgent + +- **ResponseGeneratorAgent** (`src/response_generator/response_generate.py`) + - Accepts `custom_instructions_prefix` parameter + - Prepends custom instructions to user questions + - Applied in both streaming and non-streaming modes + +### 4. **API Endpoints** +- **`POST /orchestrate`** - Standard request flow +- **`POST /orchestrate/test`** - Test request flow +- **`POST /orchestrate/stream`** - Streaming request flow +- **`POST /prompt-config/refresh`** - Force cache refresh + +--- + +## Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ADMIN UPDATES PROMPT IN UI │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Ruuter: save.yml │ +│ 1. Update/Insert in PostgreSQL │ +│ 2. Call POST /prompt-config/refresh │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ FastAPI: /prompt-config/refresh │ +│ - PromptConfigurationLoader.force_refresh() │ +│ - Invalidates cache immediately │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Cache Updated - Ready for Next Request │ +└─────────────────────────────────────────────────────────────────┘ + +╔═════════════════════════════════════════════════════════════════╗ +║ USER SENDS MESSAGE ║ +╚════════════════┬════════════════════════════════════════════════╝ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ FastAPI: /orchestrate, /orchestrate/test, or /orchestrate/stream│ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ LLMOrchestrationService.process_orchestration_request() │ +│ or stream_orchestration_response() │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ _initialize_service_components() │ +│ ↓ │ +│ _safe_initialize_response_generator() │ +│ ↓ │ +│ _initialize_response_generator() │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ _get_custom_instructions_for_response_generation() │ +│ ↓ │ +│ prompt_config_loader.get_custom_instructions() │ +│ - Returns from cache if valid (< 5 min old) │ +│ - OR fetches via Ruuter if expired │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Format custom instructions: │ +│ "[SYSTEM INSTRUCTIONS]\n{prompt}\n\n[USER QUESTION]\n" │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ResponseGeneratorAgent(custom_instructions_prefix=prefix) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ResponseGeneratorAgent.forward() or stream_response() │ +│ - Prepends custom_instructions_prefix to user question │ +│ - Modified question = "{prefix}{user_question}" │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DSPy Predictor receives modified question │ +│ - Custom instructions guide response generation │ +│ - LLM follows configured rules (language, tone, format, etc.) │ +└────────────────┬────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Response returned to user │ +│ - Follows custom prompt configuration │ +│ - Language policy applied │ +│ - Formatting rules applied │ +│ - Safety guidelines applied │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Detailed Step-by-Step Flow + +### **Startup Phase** +1. **Service Initialization** (`LLMOrchestrationService.__init__`) + - Creates `PromptConfigurationLoader` instance + - Warms up cache by calling `get_custom_instructions()` + - Logs success: "Custom prompt configuration loaded at startup (X chars)" + - Logs if not found: "ℹNo custom prompt configuration found - using defaults" + +### **Admin Updates Prompt** +1. **UI Save Action** + - Admin edits prompt text in UI + - Submits save request + +2. **Ruuter Processing** (`save.yml`) + - Checks if prompt exists in database + - Updates existing or inserts new prompt + - Calls `POST /prompt-config/refresh` endpoint + +3. **Cache Invalidation** (`/prompt-config/refresh`) + - `force_refresh()` clears cache immediately + - Fetches new prompt from Ruuter + - Returns success status with prompt length and content hash (no preview for security) + +### **User Request Processing** +1. **Request Received** (Any of 3 endpoints) + - `/orchestrate` - Standard response + - `/orchestrate/test` - Test response + - `/orchestrate/stream` - Streaming response + +2. **Service Components Initialization** + - LLM Manager initialized + - Contextual Retriever initialized + - **Response Generator initialized** ← Custom prompt applied here + +3. **Custom Instructions Loading** + ```python + custom_prefix = self._get_custom_instructions_for_response_generation() + # Returns: "[SYSTEM INSTRUCTIONS]\n{prompt}\n\n[USER QUESTION]\n" + ``` + +4. **Response Generator Creation** + ```python + ResponseGeneratorAgent(custom_instructions_prefix=custom_prefix) + ``` + +5. **Question Modification** + ```python + # In forward() or stream_response() + modified_question = f"{user_question}{custom_instructions_prefix}" + ``` + +6. **LLM Processing** + - DSPy predictor receives modified question + - Custom instructions guide response behavior + - Response generated following configured rules + +--- + +## Cache Behavior + +### **TTL Cache (5 minutes)** +- **Cache Hit**: Returns immediately from memory (fast) +- **Cache Miss**: Fetches via HTTP from Ruuter (slower, ~100-500ms) +- **Stale Fallback**: If fetch fails, returns last known good value + +### **Force Refresh** +- Triggered by admin save action +- Bypasses cache TTL +- Ensures immediate propagation of changes + +### **Thread Safety** +- Uses `threading.Lock()` for concurrent requests +- Single fetch for multiple simultaneous requests +- Cache shared across all requests + +--- + +## Configuration + +### **Constants** (`src/llm_orchestrator_config/llm_ochestrator_constants.py`) +```python +RUUTER_PROMPT_CONFIG_ENDPOINT = ( + "http://ruuter-public:8086/rag-search/llm-connections/prompts/get-prompt" +) +PROMPT_CONFIG_CACHE_TTL = 300 # 5 minutes cache +``` + +### **Environment Variables** (`constants.ini`) +```ini +RAG_SEARCH_PROMPT_REFRESH=http://llm-orchestration-service:8100/prompt-config/refresh +``` + +--- + +## Testing + +### **1. Insert Test Prompt** +```sql +INSERT INTO public.prompt_configuration (id, prompt) +VALUES (1, 'Always respond in Estonian language. Be professional and concise.') +ON CONFLICT (id) DO UPDATE SET prompt = EXCLUDED.prompt; +``` + +### **2. Test via API** +```bash +curl -X POST http://localhost:8100/orchestrate/test \ + -H "Content-Type: application/json" \ + -d '{ + "message": "What is artificial intelligence?", + "environment": "development", + "connectionId": 1 + }' +``` + +### **3. Update Prompt** +```sql +UPDATE public.prompt_configuration +SET prompt = 'Provide concise answers using bullet points. Be helpful and clear.' +WHERE id = 1; +``` + +### **4. Verify Immediate Refresh** +- Check logs for: "Prompt configuration cache refreshed successfully" +- Test same question - response format should change immediately + +### **5. Check Cache Status** +```bash +# Manual refresh (optional) +curl -X POST http://localhost:8100/prompt-config/refresh +``` + +**Response:** +```json +{ + "refreshed": true, + "message": "Prompt configuration refreshed successfully", + "prompt_length": 245, + "content_hash": "a3f5b8c9e1d2f4a6" +} +``` +**Note:** For security, the endpoint returns only the prompt length and a SHA-256 hash (not the actual prompt content). + +--- + +## Key Features + +✅ **TTL Caching** - 5-minute cache reduces database calls +✅ **Immediate Updates** - Admin changes trigger instant refresh +✅ **Graceful Degradation** - If refresh fails, TTL cache continues working +✅ **Thread-Safe** - Multiple concurrent requests handled safely +✅ **Retry Logic** - 3 attempts with exponential backoff for HTTP failures +✅ **Instruction Prepending** - Preserves DSPy optimization compatibility +✅ **Applied Consistently** - Works across all 3 orchestration endpoints +✅ **Applied to ResponseGenerator Only** - Not applied to PromptRefinerAgent + +--- + +## Example + +**Database Prompt:** +``` +Always respond in Estonian language. Be professional and concise. +When answering, prioritize accuracy and cite sources when available. +``` + +**What DSPy Receives:** +``` +[SYSTEM INSTRUCTIONS] +Always respond in Estonian language. Be professional and concise. +When answering, prioritize accuracy and cite sources when available. + +[USER QUESTION] +What is DigiDoc and how can I use it? + +Context: [retrieved documentation chunks...] +``` + +**Expected Response:** +- In Estonian language ✅ +- Professional tone ✅ +- Concise format ✅ +- Citations included ✅ + +--- + +## Files Modified + +| File | Purpose | +|------|---------| +| `src/utils/prompt_config_loader.py` | HTTP loader with caching and retry | +| `src/llm_orchestration_service.py` | Initialize loader, format instructions | +| `src/llm_orchestration_service_api.py` | Refresh endpoint | +| `src/response_generator/response_generate.py` | Accept and apply custom prefix | +| `DSL/Ruuter.public/rag-search/POST/llm-connections/prompts/get-prompt.yml` | Fetch prompt endpoint | +| `DSL/Ruuter.private/rag-search/POST/prompt-configuration/save.yml` | Save with refresh trigger | +| `src/llm_orchestrator_config/llm_ochestrator_constants.py` | Configuration constants | +| `constants.ini` | Refresh endpoint URL | + +--- + +## Troubleshooting + +### **Prompt Not Applied** +- Check logs for: "Custom prompt configuration loaded at startup" +- Verify database has prompt: `SELECT * FROM public.prompt_configuration;` +- Test refresh endpoint: `curl -X POST http://localhost:8100/prompt-config/refresh` + +### **Cache Not Refreshing** +- Check Ruuter save.yml calls refresh endpoint +- Verify `RAG_SEARCH_PROMPT_REFRESH` constant in constants.ini +- Check logs for refresh success/failure + +### **Empty Prompt** +- Check Ruuter endpoint returns correct format +- Verify response unwrapping logic in loader +- Check logs for "No prompt configuration found in database; caching empty result" + +--- + +## Notes + +- Custom prompts apply **only to ResponseGeneratorAgent** (not PromptRefinerAgent) +- PromptRefiner focuses on query optimization for retrieval +- ResponseGenerator needs language policy and interaction style for user-facing content +- This design preserves DSPy optimization compatibility by using instruction prepending instead of signature modification diff --git a/docs/HYBRID_SEARCH_CLASSIFICATION.md b/docs/HYBRID_SEARCH_CLASSIFICATION.md new file mode 100644 index 00000000..1de3f7f5 --- /dev/null +++ b/docs/HYBRID_SEARCH_CLASSIFICATION.md @@ -0,0 +1,422 @@ +# Hybrid Search Classification & Intent Data Enrichment + +> Updated architecture for the Tool Classifier using hybrid search (dense + sparse + RRF) with per-example indexing. +> Replaces the single-embedding approach documented in `TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md`. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Intent Data Enrichment (Indexing)](#intent-data-enrichment-indexing) +3. [Classification Flow (Query Time)](#classification-flow-query-time) +4. [Intent Detection & Entity Extraction](#intent-detection--entity-extraction) +5. [Thresholds & Configuration](#thresholds--configuration) + +--- + +## Architecture Overview + +The system has two phases: + +1. **Indexing (offline):** For each service, create multiple Qdrant points with dense + sparse vectors +2. **Classification (query time):** Two-step search to route queries — dense for relevance, hybrid for service identification + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ INDEXING (Offline) │ +│ │ +│ service_enrichment.sh → main_enrichment.py │ +│ ├─ LLM context generation │ +│ ├─ Per-example: dense embedding + sparse BM25 vector │ +│ ├─ Summary: dense embedding + sparse BM25 vector │ +│ └─ Qdrant upsert (N examples + 1 summary = N+1 points) │ +├─────────────────────────────────────────────────────────────────────┤ +│ CLASSIFICATION (Query Time) │ +│ │ +│ User Query │ +│ ├─ Step 1: Dense search → cosine similarity (relevance check) │ +│ ├─ Step 2: Hybrid search → RRF fusion (service identification) │ +│ └─ Route: HIGH-CONFIDENCE / AMBIGUOUS / CONTEXT-RAG │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Intent Data Enrichment (Indexing) + +### Source Files + +| File | Role | +|------|------| +| `DSL/CronManager/script/service_enrichment.sh` | Entry point — sets environment, runs Python script | +| `src/intent_data_enrichment/main_enrichment.py` | Orchestrates per-example and summary point creation | +| `src/intent_data_enrichment/qdrant_manager.py` | Qdrant collection management, upsert, and deletion | +| `src/intent_data_enrichment/api_client.py` | LLM API calls (context generation, embeddings) | +| `src/intent_data_enrichment/models.py` | `ServiceData`, `EnrichedService`, `EnrichmentResult` data models | +| `src/intent_data_enrichment/constants.py` | `EnrichmentConstants` — API URLs, Qdrant config, vector sizes, LLM prompt template | +| `src/tool_classifier/sparse_encoder.py` | BM25-style sparse vector computation | + +### What Changed: Single Embedding → Per-Example Indexing + +**Before (old):** One point per service from concatenated text. + +**After (new):** N+1 points per service — one per example query, plus one summary. + +Example for a service with 3 examples: +``` +Service "Valuutakursid" → 4 Qdrant points + + Point 0 (example): "Mis suhe on euro ja usd vahel" + dense: 3072-dim embedding of this exact text + sparse: BM25 vector → {euro: 1.0, usd: 1.0, suhe: 1.0, ...} + + Point 1 (example): "Mis on euro ja btc vahetuskurss?" + dense: 3072-dim embedding of this exact text + sparse: BM25 vector → {euro: 1.0, btc: 1.0, vahetuskurss: 1.0, ...} + + Point 2 (example): "euro ja gbp vaheline kurss" + dense: 3072-dim embedding of this exact text + sparse: BM25 vector → {euro: 1.0, gbp: 1.0, kurss: 1.0, ...} + + Point 3 (summary): "Service Name: Valuutakursid\nDescription: ...\nExample Queries: ...\nRequired Entities: ...\nEnriched Context: ..." + dense: 3072-dim embedding of combined text + sparse: BM25 vector of combined text +``` + +### Why Per-Example Indexing? + +- Each example gets its own embedding, matching diverse user phrasings better +- Short example queries aren't diluted by long descriptions +- More examples = wider coverage "net" for query matching +- Sparse vectors enable keyword matching ("EUR", "USD") alongside semantic search + +### Dense vs Sparse Vectors + +| Type | Generation | Strength | +|------|-----------|----------| +| **Dense** (3072-dim) | `text-embedding-3-large` via Azure OpenAI | Semantic similarity — matches paraphrases, cross-language | +| **Sparse** (BM25) | Term frequency hashing (`sparse_encoder.py`) | Keyword overlap — exact token matching ("EUR", "USD", "THB") | + +### Sparse Vector Generation + +```python +# sparse_encoder.py +SPARSE_VOCAB_SIZE = 50_000 + +text = "Mis suhe on euro ja usd vahel" +tokens = re.findall(r"\w+", text.lower()) # ["mis", "suhe", "on", "euro", ...] +# Each token → MD5 hash (first 4 bytes) to index in [0, SPARSE_VOCAB_SIZE), value = term frequency +# Collisions are handled by summing values at the same index +# Output: SparseVector(indices=[hash("mis"), hash("euro"), ...], values=[1.0, 1.0, ...]) +``` + +### Qdrant Collection Schema + +```python +# Collection: "intent_collections" +vectors_config = { + "dense": VectorParams(size=3072, distance=Distance.COSINE) +} +sparse_vectors_config = { + "sparse": SparseVectorParams(index=SparseIndexParams(on_disk=False)) +} +``` + +Each point payload: +```json +{ + "service_id": "common_service_exchange_rate", + "name": "Valuutakursid", + "description": "Kasutaja soovib infot valuutade kohta", + "examples": ["Mis suhe on euro ja usd vahel", "..."], + "entities": ["currency_from", "currency_to"], + "context": "LLM-generated enriched context...", + "point_type": "example", + "example_text": "Mis suhe on euro ja usd vahel" +} +``` + +### Enrichment Pipeline Flow + +``` +service_enrichment.sh + │ + ├─ Parse args: service_id, name, description, examples, entities + │ + ├─ Step 1: LLM context generation (enriched description) + │ + ├─ Step 2: For each example query: + │ ├─ Generate dense embedding (text-embedding-3-large) + │ └─ Generate sparse vector (BM25 term hashing) + │ + ├─ Step 3: Summary point (name + description + examples + entities + LLM context): + │ ├─ Generate dense embedding + │ └─ Generate sparse vector + │ + ├─ Step 4: Delete existing points for this service (idempotent) + │ + └─ Step 5: Bulk upsert N+1 points to Qdrant +``` + +### Summary Point Combined Text Format + +The summary point embeds a structured concatenation: +``` +Service Name: {name} +Description: {description} +Example Queries: {example1} | {example2} | ... +Required Entities: {entity1}, {entity2}, ... +Enriched Context: {LLM-generated context} +``` + +### Service Deletion + +When a service is deactivated, all its points are removed: +```python +qdrant_manager.delete_service_points(service_id) +# Uses payload filter: {"service_id": service_id} +``` + +--- + +## Classification Flow (Query Time) + +### Source Files + +| File | Role | +|------|------| +| `src/tool_classifier/classifier.py` | Two-step search + routing decisions | +| `src/tool_classifier/constants.py` | All thresholds and configuration | +| `src/tool_classifier/sparse_encoder.py` | Query sparse vector generation | +| `src/tool_classifier/workflows/service_workflow.py` | Service execution with 3 routing paths | + +### Step 1: Dense Search — "Is This a Service Query?" + +Queries Qdrant using only the dense vector to get **actual cosine similarity scores** (0.0 – 1.0). + +```python +# classifier.py → _dense_search() +POST /collections/intent_collections/points/query +{ + "query": [0.023, -0.041, ...], # 3072-dim dense vector + "using": "dense", + "limit": 6, # DENSE_SEARCH_TOP_K * 2 (3 * 2 = 6, allows dedup) + "with_payload": true +} +``` + +Results are deduplicated by `service_id` (best score per service), returning up to `DENSE_SEARCH_TOP_K` (3) unique services. + +**Why not use RRF scores?** +Qdrant's RRF uses `1/(1+rank)`, producing fixed scores (0.50, 0.33, 0.25) regardless of actual relevance. A perfect match and a random query both get 0.50 for rank 1. Cosine similarity reflects true semantic closeness. + +### Step 2: Hybrid Search — "Which Service?" + +Only runs if cosine ≥ `DENSE_MIN_THRESHOLD`. Combines dense + sparse search with RRF fusion. +Sparse prefetch is only included if the query produces a non-empty sparse vector. + +```python +# classifier.py → _hybrid_search() +# First checks collection exists and has data (points_count > 0) +POST /collections/intent_collections/points/query +{ + "prefetch": [ + {"query": dense_vector, "using": "dense", "limit": 10}, + {"query": {"indices": [...], "values": [...]}, "using": "sparse", "limit": 10} + ], + "query": {"fusion": "rrf"}, + "limit": 5, + "with_payload": true +} +``` + +> **Note:** Prefetch limit is `HYBRID_SEARCH_TOP_K * 2` (5 * 2 = 10). The sparse prefetch is conditionally added only when `sparse_vector.is_empty()` is False. + +Hybrid results are also deduplicated by `service_id` (best RRF score per service). + +### Routing Decision + +``` +Dense cosine score + gap + │ + ├─ cosine < 0.38 → PATH 1: Skip SERVICE → CONTEXT/RAG + │ + ├─ cosine ≥ 0.40 AND → PATH 2: HIGH-CONFIDENCE SERVICE + │ gap ≥ 0.05 (skip discovery, intent detection on matched service only) + │ + └─ else (0.38 ≤ cosine < 0.40 → PATH 3: AMBIGUOUS SERVICE + OR gap < 0.05) (LLM intent detection on candidates) +``` + +### Path 1: Non-Service Query → CONTEXT/RAG + +Top cosine score below minimum threshold. The query has no meaningful similarity to any indexed service. + +``` +Query: "Miks ID-kaart ei tööta e-teenustes?" +Dense: top cosine=0.29 → below 0.38 → skip SERVICE +→ Routes directly to CONTEXT → RAG (saves ~50-300ms by skipping hybrid search) +``` + +### Path 2: HIGH-CONFIDENCE Service Match + +One service clearly stands out with high cosine and large gap to second result. + +``` +Query: "Palju saan 1 EUR eest THBdes?" +Dense: Valuutakursid (cosine=0.5511), gap=0.2371 +→ 0.5511 ≥ 0.40 AND 0.2371 ≥ 0.05 → HIGH-CONFIDENCE +→ Skips service discovery +→ Runs intent detection + entity extraction on matched service only +→ Entities: {currency_from: EUR, currency_to: THB} +→ Validation: PASSED ✓ +→ Calls service endpoint → Returns response +``` + +### Path 3: AMBIGUOUS Service Match → LLM Confirmation + +Multiple services score similarly or cosine is in the medium range. + +``` +Query: "Mis on täna ilm?" +Dense: Ilmapäring (cosine=0.39), gap=0.03 +→ 0.39 ≥ 0.38 but 0.39 < 0.40 → AMBIGUOUS +→ Runs LLM Intent Detection on top 3 candidates +→ LLM confirms or rejects → falls back to RAG if rejected +``` + +> **Note:** With the current threshold (0.38), the AMBIGUOUS zone (0.38–0.40) is intentionally narrow. +> Most queries resolve cleanly to either NON-SERVICE (<0.38) or HIGH-CONFIDENCE (≥0.40 with gap). + +### Fallback Chain + +Each workflow returns a response or `None` (fallback to next): + +``` +SERVICE (Layer 1) → CONTEXT (Layer 2) → RAG (Layer 3) → OOD (Layer 4) +``` + +--- + +## Intent Detection & Entity Extraction + +### When Does It Run? + +| Path | Intent Detection | Entity Extraction | +|------|-----------------|-------------------| +| HIGH-CONFIDENCE | On 1 service (matched) | Yes — from LLM output | +| AMBIGUOUS | On top candidates (from `top_results`) | Yes — if LLM matches | +| Non-service | Not run | Not run | + +### Intent Detection Module (DSPy) + +**File:** `src/tool_classifier/intent_detector.py` + +The DSPy `IntentDetectionModule` uses `dspy.Predict` (direct prediction) and receives: +- User query +- Candidate services (formatted as JSON with service_id, name, description, required_entities, top 3 examples) +- Conversation history (last 3 turns, formatted as `{authorRole}: {message}`) + +It returns: +```json +{ + "matched_service_id": "common_service_exchange_rate", + "confidence": 0.92, + "entities": { + "currency_from": "EUR", + "currency_to": "THB" + }, + "reasoning": "User wants EUR to THB exchange rate" +} +``` + +### Entity Validation + +**File:** `src/tool_classifier/workflows/service_workflow.py` → `_validate_entities()` + +Extracted entities are validated against the service's schema: + +``` +Schema: ["currency_from", "currency_to"] +Extracted: {"currency_from": "EUR", "currency_to": "THB"} +Result: PASSED ✓ +``` + +- **Missing entities** → sent as empty strings (service validates) +- **Extra entities** → ignored +- **Validation is lenient** — always proceeds, lets the service endpoint validate + +### Entity Transformation + +Entities dict → ordered array matching service schema: + +```python +# Schema: ["currency_from", "currency_to"] +# Dict: {"currency_from": "EUR", "currency_to": "THB"} +# Array: ["EUR", "THB"] +``` + +### Service Endpoint Call + +After entity validation and transformation, the workflow calls the Ruuter active service endpoint: + +```python +# Endpoint: {RUUTER_SERVICE_BASE_URL}/services/active/{clean_service_name} +# Payload: {"chatId": "...", "authorId": "...", "input": ["EUR", "THB"]} +# Response: {"response": [{"content": "..."}]} → extracts content string +``` + +In streaming mode, the service content is wrapped as SSE events and streamed to the client. + +--- + +## Thresholds & Configuration + +All defined in `src/tool_classifier/constants.py`. + +### Classification Thresholds + +| Constant | Value | Description | +|----------|-------|-------------| +| `DENSE_MIN_THRESHOLD` | `0.38` | Minimum cosine to consider any service match. Below → skip SERVICE entirely. Empirically tuned: SERVICE queries score ≥ 0.49, RAG queries ≤ 0.35 — threshold sits in the 0.134 natural gap between the two distributions. | +| `DENSE_HIGH_CONFIDENCE_THRESHOLD` | `0.40` | Cosine for HIGH-CONFIDENCE path. Service queries with correct match score ≥ 0.49 (observed range: 0.49–1.00). Non-service score 0.27–0.35. | +| `DENSE_SCORE_GAP_THRESHOLD` | `0.05` | Required gap between top two services. Prevents false positives when multiple services score similarly. Service gaps: 0.15–0.75, non-service gaps: 0.001–0.029. | + +### Search Configuration + +| Constant | Value | Description | +|----------|-------|-------------| +| `DENSE_SEARCH_TOP_K` | `3` | Unique services from dense search | +| `HYBRID_SEARCH_TOP_K` | `5` | Results from hybrid RRF search | + +### Observed Score Distributions + +Based on empirical testing with 42 Estonian queries (20 SERVICE, 22 RAG): + +| Metric | Service Query (n=20) | Non-Service / RAG Query (n=22) | +|--------|:--------------------:|:------------------------------:| +| Top cosine range | **0.49 – 1.00** | 0.27 – 0.35 | +| Top cosine mean | **0.77** | 0.30 | +| Cosine gap range | **0.15 – 0.75** | 0.001 – 0.029 | +| Cosine gap mean | **0.31** | 0.010 | +| Decision | HIGH-CONFIDENCE (100%) | NON-SERVICE (100%) | + +> **Separation gap:** The lowest SERVICE cosine (0.49) and highest RAG cosine (0.35) are separated by **0.134** — a clean margin with no overlap. The threshold at 0.38 sits centrally in this gap. + +### Performance by Path + +| Path | Latency | LLM Calls | Cost | +|------|:-------:|:---------:|:----:| +| Non-service (below threshold) | ~50ms | 0 | $0 | +| HIGH-CONFIDENCE service | ~100ms | 1 | ~$0.002 | +| AMBIGUOUS service | ~3.5s | 1-2 | ~$0.002–0.004 | +| Legacy (no classifier) | ~4.0s | 2+ | ~$0.004+ | + +> **Note:** Latencies above are classification time only (embedding + Qdrant search), excluding the downstream service call or RAG pipeline. + +### Tuning Recommendations + +- **Adding more services:** Score distributions improve naturally — service queries score higher, non-service score lower. +- **Adding more examples per service:** Diverse phrasings expand the embedding coverage. Aim for 5-8 examples per service covering formal + informal + different word orders. +- **Adjusting thresholds:** Monitor the logs (`Dense search: top=... cosine=...`) and adjust if real-world scores differ from test data. diff --git a/docs/REDIS_SESSION_STORE.md b/docs/REDIS_SESSION_STORE.md new file mode 100644 index 00000000..502f24fe --- /dev/null +++ b/docs/REDIS_SESSION_STORE.md @@ -0,0 +1,180 @@ +# Redis Session Store — Usage Guide + +The `APIToolSessionStore` provides simple async CRUD for persisting agentic loop state +across multiple HTTP requests, keyed by `chat_id` with a 30-minute sliding TTL. + +--- + +## Accessing the Store + +The store is available on `app.state` from any FastAPI endpoint or workflow that receives +the FastAPI `Request` object. + +```python +session_store = request.app.state.session_store # APIToolSessionStore | None +``` + +Always guard against `None` — the service starts even if Redis is down: + +```python +if session_store is None: + # Redis unavailable, handle gracefully (e.g. fall back, log warning) + ... +``` + +--- + +## CRUD Operations + +### Create — `save()` + +Use `save()` to create a new session at the start of a multi-turn workflow. + +```python +from src.models.session_models import APIToolSession + +session = APIToolSession( + chat_id=request.chatId, + state="collecting_params", + selected_endpoint={ + "url": "https://api.example.com/weather", + "method": "GET", + "params_schema": [{"name": "city", "required": True}], + }, + collected_params={}, + turn_count=1, + max_turns=5, +) + +await session_store.save(session) +``` + +`save()` also resets the TTL — so calling it again later also acts as a keep-alive. + +--- + +### Read — `get()` + +Use `get()` to load an existing session. Returns `None` if the session does not exist +or has expired. + +```python +session = await session_store.get(request.chatId) + +if session is None: + # No active session → this is a fresh conversation + ... +else: + print(session.state) # "collecting_params" + print(session.collected_params) # {"city": "Tallinn"} + print(session.turn_count) # 2 +``` + +--- + +### Update — `update()` + +Use `update()` for partial changes — only the fields you pass are modified. +All other fields are preserved. The TTL is reset automatically. + +```python +# Add a newly collected param and increment the turn counter +updated_session = await session_store.update( + request.chatId, + collected_params={"city": "Tallinn"}, + turn_count=session.turn_count + 1, +) + +# Change only the state +await session_store.update(request.chatId, state="ready") +``` + +Returns the updated `APIToolSession`, or `None` if the session was not found. + +--- + +### Delete — `delete()` + +Use `delete()` once the workflow completes (all params collected, API called, or user +abandoned the flow). + +```python +await session_store.delete(request.chatId) +``` + +--- + +### Check Existence — `exists()` + +Use `exists()` when you only need to know whether a session is active, without loading it. + +```python +if await session_store.exists(request.chatId): + # Resume existing session + ... +else: + # Start a new one + ... +``` + +--- + +## Typical Multi-Turn Pattern + +```python +session_store = request.app.state.session_store + +# --- Turn 1 --- +# No session yet → detect endpoint, create session, ask for missing params +session = await session_store.get(request.chatId) +if session is None: + detected_endpoint = ... # endpoint detected from user query + await session_store.save(APIToolSession( + chat_id=request.chatId, + state="collecting_params", + selected_endpoint=detected_endpoint, + turn_count=1, + )) + return "Which city would you like weather for?" + +# --- Turn 2+ --- +# Session exists → merge new params, check if ready +session = await session_store.get(request.chatId) + +# Guard: abandon if turn limit reached +if session.turn_count >= session.max_turns: + await session_store.delete(request.chatId) + return "I was unable to collect all required information. Please try again." + +new_params = extract_params_from_message(request.message, session) +merged_params = {**session.collected_params, **new_params} + +if all_params_collected(session.selected_endpoint, merged_params): + # Call the actual API + result = await call_api(session.selected_endpoint, merged_params) + await session_store.delete(request.chatId) + return result +else: + # Still missing params — update and ask again + await session_store.update( + request.chatId, + collected_params=merged_params, + turn_count=session.turn_count + 1, + ) + return ask_for_next_missing_param(session.selected_endpoint, merged_params) +``` + +--- + +## Session Schema Reference + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `chat_id` | `str` | required | Unique conversation ID | +| `state` | `str` | required | Current state (`"collecting_params"`, `"ready"`, `"completed"`) | +| `selected_endpoint` | `dict \| None` | `None` | The API endpoint to call | +| `collected_params` | `dict` | `{}` | Parameters gathered so far | +| `turn_count` | `int` | `0` | Number of turns elapsed | +| `max_turns` | `int` | `5` | Abandon session after this many turns | + +**Redis key:** `session:{chat_id}` — **TTL:** 30 minutes, sliding (reset on every `save`/`update`) diff --git a/docs/TESTMODEL_SERVICE_WORKFLOW.md b/docs/TESTMODEL_SERVICE_WORKFLOW.md new file mode 100644 index 00000000..e1649a9f --- /dev/null +++ b/docs/TESTMODEL_SERVICE_WORKFLOW.md @@ -0,0 +1,405 @@ +# TestModel Page — Service Workflow Documentation + +This document traces the **service workflow** end-to-end through the **TestModel** UI page. It covers two scenarios: + +1. **Natural-language service detection** — the user types a free-text query that the system classifies as a service. +2. **MCQ button-click** — the user clicks a choice button whose payload is a `#service` command, short-circuiting the NLU pipeline. + +--- + +## Architecture Overview + +``` +┌─────────────┐ POST /rag-search/inference/test ┌─────────────────┐ +│ TestModel │ ──────────────────────────────────────────▷ │ Ruuter (proxy) │ +│ (GUI) │ │ /rag-search/ │ +│ index.tsx │ ◁─────────── JSON response ──────────────── │ inference/test │ +└─────────────┘ └───────┬─────────┘ + │ + POST /orchestrate/test + ▼ + ┌───────────────────────┐ + │ llm_orchestration_ │ + │ service_api.py │ + │ test_orchestrate_ │ + │ llm_request() │ + └───────┬───────────────┘ + │ + OrchestrationRequest (mapped with defaults) + ▼ + ┌───────────────────────┐ + │ llm_orchestration_ │ + │ service.py │ + │ process_orchestration_ │ + │ request() │ + └───────────────────────┘ +``` + +--- + +## Key Files + +| Layer | File | Purpose | +|---|---|---| +| **GUI** | `GUI/src/pages/TestModel/index.tsx` | UI page with connection selector, text input, result display, MCQ buttons | +| **GUI Service** | `GUI/src/services/inference.ts` | `viewInferenceResult()` — POST to `/rag-search/inference/test` | +| **API Layer** | `src/llm_orchestration_service_api.py` | `/orchestrate/test` handler — maps `TestOrchestrationRequest` → `OrchestrationRequest` and calls `process_orchestration_request()` | +| **Orchestration** | `src/llm_orchestration_service.py` | `process_orchestration_request()` — the core pipeline (language detection → `#service` prefix check → query validation → guardrails → classifier → service workflow) | +| **Service Workflow** | `src/tool_classifier/workflows/service_workflow.py` | `ServiceWorkflowExecutor` — service discovery, intent detection, entity extraction, endpoint call, direct step execution | +| **Models** | `src/models/request_models.py` | `OrchestrationRequest`, `OrchestrationResponse`, `TestOrchestrationRequest`, `TestOrchestrationResponse`, `ChoiceButton` | +| **Constants** | `src/tool_classifier/constants.py` | `SERVICE_STEP_PREFIXES`, `RUUTER_SERVICE_BASE_URL`, search thresholds | + +--- + +## Flow 1: Natural-Language Service Detection + +### 1.1 Frontend — User Sends a Message + +The user selects an LLM connection from the dropdown and types a query (e.g., *"My keyboard is not working"*). + +**`TestModel/index.tsx` → `handleSend()`** (line 72): +```tsx +inferenceMutation.mutate({ + llmConnectionId: Number(testLLM.connectionId), + message: testLLM.text, +}); +``` + +**`inference.ts` → `viewInferenceResult()`** (line 50): +```ts +const { data } = await apiDev.post(inferenceEndpoints.VIEW_TEST_INFERENCE_RESULT(), { + connectionId: request.llmConnectionId, + message: request.message, +}); +``` + +This POST goes to `/rag-search/inference/test` (via Ruuter proxy), which maps to the backend `/orchestrate/test` endpoint. + +### 1.2 API Layer — Request Mapping + +**`llm_orchestration_service_api.py` → `test_orchestrate_llm_request()`** (line 313): +- Receives a `TestOrchestrationRequest` (only `message`, `environment`, optional `connectionId`). +- Maps to a full `OrchestrationRequest` with defaults: + +```python +full_request = OrchestrationRequest( + chatId="test-session", + message=request.message, + authorId="test-user", + conversationHistory=[], + url="test-context", + environment=request.environment, + connection_id=str(request.connectionId) if request.connectionId is not None else None, +) +``` + +- Calls `orchestration_service.process_orchestration_request(full_request)`. + +### 1.3 Orchestration Pipeline — Service Detection + +**`llm_orchestration_service.py` → `process_orchestration_request()`** (line 279): + +``` +STEP 0: Language detection → detect_language(request.message) → "en" + +STEP 0.1: Check request.message.startswith(SERVICE_STEP_PREFIXES) + → FALSE (natural language) → skip, continue normally + +STEP 0.5: Query validation → validate_query_basic(request.message) → valid + +STEP 1: Component initialization → LLM manager, guardrails adapter + +STEP 2: Input guardrails check → allowed + +STEP 3: ToolClassifier.classify(query, conversation_history, language) + → Classification(workflow=SERVICE, confidence=0.92) + The classifier uses hybrid search (dense + BM25) against + the intent_collections Qdrant collection. + +STEP 4: route_to_workflow(classification, request, is_streaming=False) + → Routes to ServiceWorkflowExecutor.execute_async() +``` + +### 1.4 ServiceWorkflowExecutor — `execute_async()` + +**`service_workflow.py` → `execute_async()`** (line 699): + +The executor uses **classification metadata** from hybrid search to decide how to proceed. There are three paths: + +| Condition | Path | +|---|---| +| `needs_llm_confirmation == False` | High-confidence match — run intent detection on the single top match only | +| `needs_llm_confirmation == True` | Ambiguous — run intent detection on top-N candidates | +| No metadata | Fall back to full discovery flow (`_log_request_details`) | + +#### Service Discovery (full flow) + +1. **`_call_service_discovery(chat_id)`** — calls `GET http://ruuter-public:8086/rag-search/services/get-services` +2. Checks if `service_count > SERVICE_COUNT_THRESHOLD (10)` → triggers **semantic search** via Qdrant +3. Otherwise uses the services list directly + +#### Intent Detection + +**`_process_intent_detection(services, request, chat_id, context, costs_metric)`**: +1. Calls **`_detect_service_intent()`** → uses `IntentDetectionModule` (DSPy LLM) with: + - The user query + - The candidate services list + - Conversation history +2. Returns matched `service_id`, `confidence`, `entities` +3. **`_validate_detected_service()`** — confirms the matched service exists in the active services list + +#### Entity Extraction & Validation + +```python +service_metadata = self._extract_service_metadata(context, chat_id) +# → {service_id, service_name, entities_dict, entity_schema, ruuter_type, is_common} + +validation_result = self._validate_entities(entities_dict, entity_schema, service_name, chat_id) +# → checks missing, extra, empty entities + +entities_array = self._transform_entities_to_array(entities_dict, entity_schema) +# → ordered list of entity values matching service schema +``` + +#### Service Endpoint Call + +```python +endpoint_url = self._construct_service_endpoint(service_name, chat_id, is_common) +# → "http://ruuter:8086/services/services/active/Klaviatuuri_probleemi_lahendamine" + +service_result = await self._call_service_endpoint( + endpoint_url, http_method, entities_array, chat_id, author_id +) +``` + +**`_call_service_endpoint()`** (line 523): +1. Sends POST/GET to the Ruuter endpoint with payload `{chatId, authorId, input: entities_array}` +2. Ruuter executes the DSL → DMapper produces the response +3. Parses the response: + - Unwraps `{"response": ...}` wrapper + - Extracts `data[0].content` → text content + - Extracts `data[0].buttons` → JSON string or list of `{title, payload}` objects +4. Returns `{"content": str, "buttons": List[Dict]}` + +#### Build Response + +```python +service_buttons = service_result["buttons"] +buttons_list = [ChoiceButton(**b) for b in service_buttons if "title" in b and "payload" in b] + +return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=service_content, + buttons=buttons_list if buttons_list else None, +) +``` + +### 1.5 API Layer — Response Conversion + +Back in `test_orchestrate_llm_request()` (line 382): + +```python +test_response = TestOrchestrationResponse( + llmServiceActive=response.llmServiceActive, + questionOutOfLLMScope=response.questionOutOfLLMScope, + inputGuardFailed=response.inputGuardFailed, + content=response.content, + buttons=response.buttons, # ← forwarded + chunks=None, +) +``` + +### 1.6 Frontend — Display Result + +**`TestModel/index.tsx`**: + +```tsx +// onSuccess callback (line 51-54) +setInferenceResult(data?.response); +setPendingButtons(data?.response?.buttons ?? []); +``` + +- Response text is rendered inside `` (line 166-168) +- MCQ buttons are rendered if `pendingButtons.length > 0` (line 173-186): + +```tsx +{pendingButtons.map((btn) => ( + +))} +``` + +--- + +## Flow 2: MCQ Button Click (Direct Step — Short-Circuit) + +### 2.1 Frontend — Button Click + +When the user clicks a button (e.g., *[Windows]*), `handleButtonClick()` is called (line 81): + +```tsx +const handleButtonClick = (payload: string) => { + if (!testLLM.connectionId) return; + setPendingButtons([]); + inferenceMutation.mutate({ + llmConnectionId: Number(testLLM.connectionId), + message: payload, // e.g. "#service, /POST/services/active/Klaviatuuri_probleemi_lahendamine_mcq_1_0" + }); +}; +``` + +The button **payload** becomes the next message. The same API call (`/rag-search/inference/test` → `/orchestrate/test`) is made. + +### 2.2 Input Sanitizer Safety + +The `#service, /POST/...` payload goes through Pydantic's `validate_and_sanitize_message()` on `OrchestrationRequest.message` (line 64-88 of `request_models.py`). The `InputSanitizer.sanitize_message()` strips HTML tags and normalizes whitespace but leaves `#`, `,`, `/` characters intact. The payload passes through unchanged. + +### 2.3 Orchestration — `#service` Prefix Short-Circuit + +**`process_orchestration_request()`** (line 324-340): + +```python +# STEP 0.1: Multi-step service prefix check (bypass NLU pipeline) +if request.message.startswith(SERVICE_STEP_PREFIXES): + logger.info(f"[{request.chatId}] #service prefix detected - direct step execution") + executor = self._get_service_workflow_executor() + direct_response = await executor.execute_direct_step( + request=request, + time_metric=time_metric, + ) + if direct_response is not None: + log_step_timings(time_metric, request.chatId) + return direct_response +``` + +**`SERVICE_STEP_PREFIXES`** = `("#service,", "#common_service,")` from `constants.py`. + +**`_get_service_workflow_executor()`** (line 264): +- Reuses the existing `tool_classifier.service_workflow` if a ToolClassifier has been initialized +- Otherwise creates a lightweight `ServiceWorkflowExecutor(llm_manager=None, orchestration_service=self)` — no LLM is needed for direct steps + +### 2.4 ServiceWorkflowExecutor — `execute_direct_step()` + +**`service_workflow.py` → `execute_direct_step()`** (line 1000): + +1. **Parse**: `_parse_service_prefix(request.message)` + - Input: `"#service, /POST/services/active/Klaviatuuri_probleemi_lahendamine_mcq_1_0"` + - Splits off prefix → remainder: `/POST/services/active/...` + - Extracts HTTP method: `POST` + - Builds URL: `http://ruuter:8086/services/services/active/Klaviatuuri_probleemi_lahendamine_mcq_1_0` + - Returns: `("POST", "http://ruuter:8086/services/services/active/...")` + +2. **Call endpoint**: `_call_service_endpoint(url, "POST", [], chat_id, author_id)` + - `entities_array=[]` — no entities for MCQ steps + - Same parsing as Flow 1 (extracts `content` + `buttons`) + +3. **Build response**: Same `OrchestrationResponse` construction as Flow 1 + +### What Gets Skipped (Short-Circuit) + +| Skipped Step | Why | +|---|---| +| Query validation | Would reject `#service` as gibberish | +| Component initialization | Expensive (LLM manager, Vault, guardrails) | +| Input guardrails | Would block a machine-generated payload | +| ToolClassifier.classify() | LLM call — unnecessary cost | +| Intent detection LLM | Another LLM call — URL is already known | +| Entity extraction | No natural language entities to extract | +| Semantic search (Qdrant) | No need to find a service | + +### 2.5 Response & Loop + +The response follows the same path back through `test_orchestrate_llm_request()` → frontend. + +- If the response has `buttons` → frontend renders the next set of MCQ buttons +- If the response has `buttons=null` → the MCQ flow is complete, only the final text answer is shown + +--- + +## Data Models + +### Request + +```python +class TestOrchestrationRequest: + message: str + environment: Literal["production", "testing", "development"] + connectionId: Optional[int] +``` + +### Response + +```python +class TestOrchestrationResponse: + llmServiceActive: bool + questionOutOfLLMScope: bool + inputGuardFailed: bool + content: str + buttons: Optional[List[ChoiceButton]] # MCQ buttons + chunks: Optional[List[ChunkInfo]] # RAG context chunks + +class ChoiceButton: + title: str # "Windows" + payload: str # "#service, /POST/services/active/..." +``` + +--- + +## Complete MCQ Sequence Diagram + +``` +User TestModel UI API (/orchestrate/test) ServiceWorkflow Ruuter/DMapper + │ │ │ │ │ + │ types "keyboard │ │ │ │ + │ not working" │ │ │ │ + ├───────────────────▶│ POST inference/test │ │ │ + │ ├────────────────────────▶│ process_orchestration_ │ │ + │ │ │ request() │ │ + │ │ │ startswith(#service)?→NO │ │ + │ │ │ classify()→SERVICE │ │ + │ │ ├───────────────────────────▶│ execute_async() │ + │ │ │ │ intent detect→matched │ + │ │ │ │ _call_service_endpoint │ + │ │ │ ├──────────────────────▶│ + │ │ │ │◁ {content,buttons}─────│ + │ │ │◁─ OrchestrationResponse ───│ │ + │ │◁─ TestOrchResponse ─────│ │ │ + │◁─ render text + │ │ │ │ + │ [Windows] [Mac] │ │ │ │ + │ │ │ │ │ + │ clicks [Windows] │ │ │ │ + ├───────────────────▶│ POST inference/test │ │ │ + │ │ msg="#service,/POST/…" │ │ │ + │ ├────────────────────────▶│ startswith(#service)?→YES │ │ + │ │ │ SKIP classifier+guardrails│ │ + │ │ ├───────────────────────────▶│ execute_direct_step() │ + │ │ │ │ _parse_service_prefix │ + │ │ │ │ _call_service_endpoint │ + │ │ │ ├──────────────────────▶│ + │ │ │ │◁ {content,buttons}─────│ + │ │ │◁─ OrchestrationResponse ───│ │ + │ │◁─ TestOrchResponse ─────│ │ │ + │◁─ render next MCQ │ │ │ │ + │ or final answer │ │ │ │ +``` + +--- + +## Error Handling + +| Error Scenario | Behavior | +|---|---| +| Service discovery fails | `execute_async()` returns `None` → falls back to RAG/context pipeline | +| Intent detection fails | Returns `None` → falls back to RAG/context pipeline | +| `_parse_service_prefix()` fails | `execute_direct_step()` returns `None` → falls through to normal pipeline | +| Service endpoint timeout | `_call_service_endpoint()` returns `None` → falls back | +| Service endpoint HTTP error | Logged, returns `None` → falls back | +| Buttons JSON parsing fails | Logs warning, returns empty buttons list `[]` | diff --git a/docs/TESTPRODUCTIONLLM_SERVICE_WORKFLOW.md b/docs/TESTPRODUCTIONLLM_SERVICE_WORKFLOW.md new file mode 100644 index 00000000..3e4ccfc0 --- /dev/null +++ b/docs/TESTPRODUCTIONLLM_SERVICE_WORKFLOW.md @@ -0,0 +1,862 @@ +# TestProductionLLM Page — Service Workflow & Streaming Documentation + +This document traces the **service workflow** end-to-end through the **TestProductionLLM** UI page. Unlike the TestModel page, this page exclusively uses the **streaming endpoint** (`/orchestrate/stream`). It covers: + +1. **Natural-language service detection** — the user sends a free-text query that is classified as a service, with the response delivered via SSE. +2. **MCQ button-click** — the user clicks a choice button, sending the `#service` payload through the same streaming pipeline but short-circuiting all NLU. +3. **Deep dive into the streaming architecture** — how tokens flow from the backend through the notification server to the browser. + +--- + +## Architecture Overview + +The TestProductionLLM streaming architecture has **three hops**: + +``` +┌──────────────────┐ ┌───────────────────┐ ┌───────────────────────┐ +│ TestProductionLLM│ 1. GET /sse/stream │ Notification │ 3. POST /orchestrate│ LLM Orchestration │ +│ (Browser) │ ◀════════════(SSE)══ │ Server (Node.js) │ /stream │ Service (Python) │ +│ │ │ │ ──────────────────▷ │ │ +│ index.tsx │ 2. POST /channels/ │ │ ◀═══(SSE stream)═══ │ llm_orchestration_ │ +│ useStreaming │ orchestrate/ │ streamingService │ │ service.py │ +│ Response.tsx │ stream │ .js │ │ │ +└──────────────────┘ ──────────────────▷ └───────────────────┘ └───────────────────────┘ +``` + +**Why three hops?** The browser opens a persistent SSE connection to the notification server (Node.js), then triggers the stream via a separate POST. The notification server acts as a relay: it calls the Python backend's `/orchestrate/stream` endpoint, reads the SSE response, parses each `data:` line, and re-emits it to the browser through its own SSE connection with a different message format. + +--- + +## Key Files + +| Layer | File | Purpose | +|---|---|---| +| **GUI Page** | `GUI/src/pages/TestProductionLLM/index.tsx` | Chat-style UI with message history, input area, MCQ button rendering | +| **Streaming Hook** | `GUI/src/hooks/useStreamingResponse.tsx` | `useStreamingResponse()` — manages SSE connection lifecycle, calls notification server | +| **GUI Service** | `GUI/src/services/inference.ts` | `ChoiceButton` interface (shared with TestModel) | +| **Notification Server** | `notification-server/src/server.js` | Express server: `GET /sse/stream/:channelId` and `POST /channels/:channelId/orchestrate/stream` | +| **Streaming Service** | `notification-server/src/streamingService.js` | `createLLMOrchestrationStreamRequest()` — calls backend, parses SSE, re-emits to browser | +| **API Layer** | `src/llm_orchestration_service_api.py` | `/orchestrate/stream` handler — validates, rate-limits, wraps with timeout | +| **Orchestration** | `src/llm_orchestration_service.py` | `stream_orchestration_response()` — the core streaming pipeline | +| **Service Workflow** | `src/tool_classifier/workflows/service_workflow.py` | `execute_streaming()`, `execute_direct_step_streaming()` — SSE generators for service responses | +| **Models** | `src/models/request_models.py` | `OrchestrationRequest`, `ChoiceButton` | + +--- + +## Flow 1: Natural-Language Service Detection (Streaming) + +### 1.1 Frontend — User Sends a Message + +The user types a message and presses Send or Enter. + +**`TestProductionLLM/index.tsx` → `handleSendMessage()`** (line 47): + +1. Adds a user `Message` to state (with id, content, timestamp) +2. Clears input, sets `isLoading=true` +3. Creates a `botMessageId` for the upcoming bot response +4. Builds `conversationHistory` from existing messages +5. Defines callbacks: `onToken`, `onButtons`, `onComplete`, `onError` +6. Calls `startStreaming(userMessageText, streamingOptions, onToken, onComplete, onError, onButtons)` + +### 1.2 Streaming Hook — SSE Connection Setup + +**`useStreamingResponse.tsx` → `startStreaming()`** (line 52): + +This follows a **two-phase protocol**: + +#### Phase 1: Open SSE Connection + +```ts +const sseUrl = `${notificationNodeUrl}/sse/stream/${channelId}`; +const eventSource = new EventSource(sseUrl); +``` + +- `channelId` is a unique session-level ID: `channel-` (generated via `useMemo`, line 28) +- The `EventSource` hits `GET /sse/stream/:channelId` on the notification server +- The server registers this connection with a `sender` function that can push messages back + +#### Phase 2: Trigger the Stream (after 500ms wait) + +```ts +await new Promise(resolve => setTimeout(resolve, 500)); // Wait for SSE to establish + +const postUrl = `${notificationNodeUrl}/channels/${channelId}/orchestrate/stream`; +await axios.post(postUrl, { message, options }); +``` + +#### SSE Message Handling + +The `eventSource.onmessage` handler processes four message types: + +| `data.type` | Action | +|---|---| +| `stream_start` | Sets `isStreaming=true` | +| `stream_chunk` | Calls `onToken(data.content)` — appends token to bot message. If `data.buttons` present → calls `onButtons(data.buttons)` | +| `stream_end` | Closes EventSource, calls `onComplete()` | +| `stream_error` | Closes EventSource, calls `onError(data.error)` | + +### 1.3 Notification Server — Relay + +**`streamingService.js` → `createLLMOrchestrationStreamRequest()`** (line 11): + +1. Finds SSE connections for the channel +2. Constructs the `OrchestrationRequest` payload: + +```js +const orchestrationPayload = { + chatId: channelId, + message: message, + authorId: options.authorId || `user-${channelId}`, + conversationHistory: options.conversationHistory || [], + url: options.url || "sse-stream-context", + environment: "production", // ← hardcoded; streaming is production-only + connection_id: options.connection_id || connectionId +}; +``` + +3. Calls the Python backend: + +```js +const response = await fetch( + `${LLM_ORCHESTRATOR_URL}/orchestrate/stream`, + { method: 'POST', body: JSON.stringify(orchestrationPayload) } +); +``` + +4. Sends `stream_start` to browser +5. Reads the SSE response body as a stream: + +```js +const reader = response.body.getReader(); +while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: true }); + // Parse "data: {json}\n" lines + for (const line of lines) { + const data = JSON.parse(line.slice(6)); // Strip "data: " + const content = data.payload?.content; + const buttons = data.payload?.buttons; + + if (content === "END") { + sender({ type: "stream_end", ... }); // → browser + } else { + sender({ type: "stream_chunk", content, buttons, ... }); // → browser + } + } +} +``` + +> **Key insight**: The notification server **re-formats** the SSE. The backend emits `data: {"chatId":"...","payload":{"content":"token","buttons":[...]},...}\n\n`, while the notification server emits `{"type":"stream_chunk","content":"token","buttons":[...]}` over its own SSE channel. + +### 1.4 Backend API Layer — `/orchestrate/stream` + +**`llm_orchestration_service_api.py` → `stream_orchestrated_response()`** (line 415): + +1. **Environment check**: Streaming is only available for environments in `STREAMING_ALLOWED_ENVS` +2. **Service initialization check**: Verifies `orchestration_service` is available +3. **Rate limiting**: If enabled, estimates tokens and checks per-user rate limits +4. **Timeout wrapper**: + +```python +async def timeout_wrapped_stream(): + async with stream_timeout(StreamConfig.MAX_STREAM_DURATION_SECONDS): + async for chunk in orchestration_service.stream_orchestration_response(request): + yield chunk +``` + +5. Returns `StreamingResponse(timeout_wrapped_stream(), media_type="text/event-stream")` + +### 1.5 Orchestration — `stream_orchestration_response()` + +**`llm_orchestration_service.py` → `stream_orchestration_response()`** (line 532): + +This is an `async generator` that yields SSE-formatted strings. + +``` +STEP 0: Language detection → detect_language(request.message) + +STEP 0.1: Check request.message.startswith(SERVICE_STEP_PREFIXES) + → FALSE (natural language) → skip, continue + +STEP 0.5: Query validation → validate_query_basic() + If invalid → yield format_sse(error_msg) + format_sse("END") + return + +STEP 1: StreamManager context (managed_stream for cleanup tracking) + +STEP 2: Component initialization (LLM manager, guardrails) + +STEP 3: Input guardrails check + If blocked → yield format_sse(violation_msg) + yield format_sse("END") + return + +STEP 4: ToolClassifier.classify() → Classification(workflow=SERVICE) + +STEP 5: route_to_workflow(classification, request, is_streaming=True) + → ServiceWorkflowExecutor.execute_streaming() + → returns AsyncIterator[str] + +STEP 6: Yield all SSE chunks from the iterator: + async for sse_chunk in stream_result: + yield sse_chunk +``` + +### 1.6 ServiceWorkflowExecutor — `execute_streaming()` + +**`service_workflow.py` → `execute_streaming()`** (line 855): + +The service discovery, intent detection, entity extraction, and endpoint call logic is **identical** to `execute_async()` (documented in TestModel flow). The only difference is the response wrapping. + +After `_call_service_endpoint()` returns `{"content": str, "buttons": List[Dict]}`: + +```python +orchestration_service = self.orchestration_service +service_content = service_result["content"] +service_buttons = service_result["buttons"] + +async def service_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse( + chat_id, service_content, service_buttons or None + ) + yield orchestration_service.format_sse(chat_id, "END") + orchestration_service.log_costs(costs_metric) + +return service_stream() +``` + +> **Key insight**: For service workflow responses, the stream yields exactly **2 SSE messages**: the complete service response (with content + buttons) and the `END` marker. There is no token-by-token streaming for service endpoints — the entire response arrives in one chunk. This is because the DMapper/Ruuter response is a pre-formed string, not an LLM generation. + +### 1.7 `format_sse()` — MCQ Button Inclusion + +**`llm_orchestration_service.py` → `format_sse()`** (line 1195): + +```python +def format_sse(self, chat_id: str, content: str, + buttons: Optional[List[Dict[str, Any]]] = None) -> str: + inner_payload: Dict[str, Any] = {"content": content} + if buttons: + inner_payload["buttons"] = buttons + + payload = { + "chatId": chat_id, + "payload": inner_payload, + "timestamp": str(int(datetime.now().timestamp() * 1000)), + "sentTo": [], + } + return f"data: {json_module.dumps(payload)}\n\n" +``` + +**Example SSE output for a service with buttons:** +``` +data: {"chatId":"channel-abc","payload":{"content":"Which operating system?","buttons":[{"title":"Windows","payload":"#service, /POST/..."},{"title":"Mac","payload":"#service, /POST/..."}]},"timestamp":"1711512000000","sentTo":[]} + +data: {"chatId":"channel-abc","payload":{"content":"END"},"timestamp":"1711512000001","sentTo":[]} +``` + +### 1.8 Frontend — Token Rendering & Button Display + +Back in `TestProductionLLM/index.tsx`: + +**`onToken` callback** (line 88): Appends the content to the bot message. For service responses, this is the entire answer text in one chunk (not token-by-token). + +**`onButtons` callback** (line 120): Attaches buttons to the bot message: + +```tsx +const onButtons = (buttons: ChoiceButton[]) => { + setMessages(prev => { + const botMsgIndex = prev.findIndex(msg => msg.id === botMessageId); + if (botMsgIndex === -1) return prev; + const updated = [...prev]; + updated[botMsgIndex] = { ...updated[botMsgIndex], buttons }; + return updated; + }); +}; +``` + +**Button rendering** (line 286-299): Within each bot message: + +```tsx +{!msg.isUser && msg.buttons && msg.buttons.length > 0 && ( +
+ {msg.buttons.map((btn) => ( + + ))} +
+)} +``` + +--- + +## Flow 2: MCQ Button Click (Streaming Direct Step) + +### 2.1 Frontend — Button Click + +**`handleButtonClick()`** (line 190): + +```tsx +const handleButtonClick = async (title: string, payload: string) => { + if (isLoading || isStreaming) return; + + // Add *title* as user message (not the raw payload) + const userMessage: Message = { + id: `user-${Date.now()}`, + content: title, // Shows "Windows" in the chat, not "#service, /POST/..." + isUser: true, + timestamp: new Date().toISOString(), + }; + setMessages(prev => [...prev, userMessage]); + + // Start streaming with the *payload* as the message + await startStreaming(payload, streamingOptions, onToken, onComplete, onError, onButtons); +}; +``` + +> **Note**: Unlike TestModel where the raw payload is shown, TestProductionLLM shows the button **title** to the user and sends the **payload** as the message. This provides a cleaner chat experience. + +### 2.2 Same Streaming Path + +The payload flows through the same streaming pipeline: + +``` +Browser → Notification Server → POST /orchestrate/stream → stream_orchestration_response() +``` + +### 2.3 Orchestration — `#service` Prefix Short-Circuit (Streaming) + +**`stream_orchestration_response()`** (line 585-603): + +```python +# STEP 0.1: Multi-step service prefix check (bypass NLU pipeline) +if request.message.startswith(SERVICE_STEP_PREFIXES): + logger.info(f"[{request.chatId}] #service prefix detected - direct step stream") + executor = self._get_service_workflow_executor() + step_stream = await executor.execute_direct_step_streaming( + request=request, + time_metric=time_metric, + ) + if step_stream is not None: + async for chunk in step_stream: + yield chunk + log_step_timings(time_metric, request.chatId) + return +``` + +This happens **before** the `StreamManager` context, component initialization, guardrails, and classifier. The entire NLU pipeline is bypassed. + +### 2.4 ServiceWorkflowExecutor — `execute_direct_step_streaming()` + +**`service_workflow.py` → `execute_direct_step_streaming()`** (line 1065): + +```python +async def execute_direct_step_streaming(self, request, time_metric=None): + parsed = self._parse_service_prefix(request.message) + if parsed is None: + return None + + http_method, endpoint_url = parsed + + service_result = await self._call_service_endpoint( + endpoint_url, http_method, entities_array=[], chat_id, author_id + ) + if service_result is None: + return None + + service_content = service_result["content"] + service_buttons = service_result["buttons"] + + async def step_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse( + chat_id, service_content, service_buttons or None + ) + yield orchestration_service.format_sse(chat_id, "END") + + return step_stream() +``` + +Again, exactly **2 SSE messages** are yielded. + +--- + +## Deep Dive: Streaming Architecture + +### End-to-End Token Flow + +``` + ┌─────────────────────────────────────────────────────────────────────────┐ + │ Python Backend (FastAPI) │ + │ │ + │ stream_orchestration_response() │ + │ ┌─────────────────────────────────────────┐ │ + │ │ For Service Workflows: │ │ + │ │ │ │ + │ │ yield format_sse(content, buttons) │ ──▷ "data: {json}\n\n" │ + │ │ yield format_sse("END") │ ──▷ "data: {json}\n\n" │ + │ │ │ │ + │ │ For RAG Workflows: │ │ + │ │ yield format_sse(token_1) │ ──▷ "data: {json}\n\n" │ + │ │ yield format_sse(token_2) │ ──▷ "data: {json}\n\n" │ + │ │ ... │ │ + │ │ yield format_sse("END") │ ──▷ "data: {json}\n\n" │ + │ └─────────────────────────────────────────┘ │ + └────────────────────────────┬──────────────────────────────────────────────┘ + │ HTTP Response (chunked transfer encoding) + ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ Notification Server (Node.js) │ + │ │ + │ streamingService.js │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ 1. fetch("/orchestrate/stream", {body: payload}) │ │ + │ │ 2. response.body.getReader() │ │ + │ │ 3. Loop: read() → decode → parse "data:" lines │ │ + │ │ │ │ + │ │ For each parsed SSE line: │ │ + │ │ content = data.payload.content │ │ + │ │ buttons = data.payload.buttons │ │ + │ │ │ │ + │ │ if content == "END": │ │ + │ │ sender({type:"stream_end"}) │ │ + │ │ else: │ │ + │ │ sender({type:"stream_chunk", content, btns}) │ │ + │ └──────────────────────────────────────────────────┘ │ + └────────────────────────────┬─────────────────────────────────────────────┘ + │ SSE (EventSource) + ▼ + ┌──────────────────────────────────────────────────────────────────────────┐ + │ Browser (React) │ + │ │ + │ useStreamingResponse.tsx │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ eventSource.onmessage = (event) => { │ │ + │ │ data = JSON.parse(event.data) │ │ + │ │ │ │ + │ │ "stream_start" → setIsStreaming(true) │ │ + │ │ "stream_chunk" → onToken(data.content) │ │ + │ │ onButtons(data.buttons) │ │ + │ │ "stream_end" → onComplete() │ │ + │ │ "stream_error" → onError(data.error) │ │ + │ │ } │ │ + │ └──────────────────────────────────────────────────┘ │ + │ │ + │ TestProductionLLM/index.tsx │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ onToken: Append content to bot message │ │ + │ │ onButtons: Attach buttons array to bot message │ │ + │ │ onComplete: setIsLoading(false) │ │ + │ └──────────────────────────────────────────────────┘ │ + └──────────────────────────────────────────────────────────────────────────┘ +``` + +### SSE Format Comparison + +| Stage | Format | +|---|---| +| **Backend → Notification Server** | `data: {"chatId":"ch-1","payload":{"content":"text","buttons":[...]},"timestamp":"...","sentTo":[]}\n\n` | +| **Notification Server → Browser** | `data: {"type":"stream_chunk","content":"text","buttons":[...],"streamId":"ch-1","channelId":"ch-1","isComplete":false}\n` | + +### Service vs RAG Streaming Behavior + +| Aspect | Service Workflow | RAG Workflow | +|---|---|---| +| **Number of SSE chunks** | Exactly 2 (content+buttons, then END) | Many (token by token, then END) | +| **`buttons` field** | Present when MCQ | Never present | +| **Token granularity** | Full response in one chunk | Individual tokens (~200 chars buffered) | +| **Why?** | DMapper returns pre-formed text | LLM generates token by token | + +### Connection Lifecycle + +``` +Browser Notification Server Backend + │ │ │ + │ GET /sse/stream/ch-1 │ │ + │ ═══════════(SSE open)═══▷ │ Register connection │ + │ │ │ + │ POST /channels/ch-1/ │ │ + │ orchestrate/stream │ │ + │ ─────────────────────────▷ │ │ + │ │ POST /orchestrate/stream │ + │ │ ─────────────────────────▷ │ + │ │ │ async generator starts + │ │ │ yield SSE chunk + │ ◁═ stream_start ═══ │ ◁═══ data: {...}\n\n ═════ │ + │ ◁═ stream_chunk ═══ │ ◁═══ parse "data:" line ══ │ + │ │ │ yield END + │ ◁═ stream_end ══════ │ ◁══ data: {...END}\n\n ═══ │ + │ │ │ + │ EventSource closes │ Connection cleaned up │ +``` + +### Timeout & Error Handling + +| Layer | Mechanism | Behavior | +|---|---|---| +| **API Layer** | `stream_timeout(MAX_STREAM_DURATION_SECONDS)` | If the entire generator takes too long, a `StreamTimeoutException` is raised and a timeout SSE message is sent | +| **API Layer** | Rate limiter | Per-user request/token limiting; returns 429 SSE error | +| **Orchestration** | `StreamManager.managed_stream()` | Tracks active streams, guarantees cleanup | +| **Notification Server** | `activeConnections.has(connectionId)` check | Stops reading if client disconnected | +| **Notification Server** | Stale request cleanup | Every 5 minutes, clears requests older than 1 hour | +| **Browser** | `eventSource.onerror` | Closes connection, calls `onError` | +| **Browser** | Component unmount cleanup | Calls `stopStreaming()`, closes EventSource | + +--- + +## Complete MCQ Streaming Sequence + +``` +User TestProductionLLM useStreaming Notif Server Backend (stream) ServiceWorkflow + │ │ │ │ │ │ + │ types "keyboard │ │ │ │ │ + │ not working" │ │ │ │ │ + ├───────────────────▶│ handleSendMessage │ │ │ │ + │ ├──────────────────▶│ startStreaming │ │ │ + │ │ │ GET /sse/ │ │ │ + │ │ │ stream/ch-1 │ │ │ + │ │ ├──────════════▶│ register SSE │ │ + │ │ │ (500ms wait) │ │ │ + │ │ │ POST /channels│ │ │ + │ │ │ /ch-1/stream │ │ │ + │ │ ├──────────────▶│ │ │ + │ │ │ │ POST /orchestrate/ │ │ + │ │ │ │ stream │ │ + │ │ │ ├───────────────────▶│ │ + │ │ │ │ │ startswith(#svc)?→NO │ + │ │ │ │ │ classify()→SERVICE │ + │ │ │ │ ├─────────────────────▶│ + │ │ │ │ │ │ execute_streaming() + │ │ │ │ │ │ _call_service_endpoint + │ │ │ │ │◁═ SSE: content+btns ═│ format_sse() + │ │ │ │◁═ parse data: ═════│ │ + │ │ │◁═ stream_chunk│ │ │ + │ │◁═ onToken+onBtns ═│ │ │◁═ SSE: END ══════════│ + │ │ │ │◁═ parse END ═══════│ │ + │ │ │◁═ stream_end ═│ │ │ + │◁ render text + │ onComplete │ │ │ │ + │ [Windows] [Mac] │ │ │ │ │ + │ │ │ │ │ │ + │ clicks [Windows] │ │ │ │ │ + ├───────────────────▶│ handleButtonClick │ │ │ │ + │ │ add "Windows" msg │ │ │ │ + │ ├──────────────────▶│ startStreaming(payload) │ │ + │ │ │ ═══▷ ═══▷ ═══▷ │ │ + │ │ │ │ POST /orch/stream │ │ + │ │ │ ├───────────────────▶│ │ + │ │ │ │ │ startswith(#svc)?→YES│ + │ │ │ │ │ SKIP everything │ + │ │ │ │ ├─────────────────────▶│ + │ │ │ │ │ │ execute_direct_step_ + │ │ │ │ │ │ streaming() + │ │ │ │ │ │ _parse_service_prefix + │ │ │ │ │ │ _call_service_endpoint + │ │ │ │ │◁═ SSE: content+btns ═│ + │ │ │ │◁═ parse ═══════════│ │ + │ │ │◁═ stream_chunk│ │◁═ SSE: END ══════════│ + │ │ │◁═ stream_end ═│ │ │ + │◁ render next MCQ │ onToken+onBtns │ │ │ │ + │ or final answer │ │ │ │ │ +``` + +--- + +## Key Differences from TestModel Page + +| Aspect | TestModel | TestProductionLLM | +|---|---|---| +| **Endpoint** | `/orchestrate/test` (non-streaming) | `/orchestrate/stream` (streaming) | +| **Protocol** | HTTP POST → JSON response | HTTP POST → SSE stream | +| **Relay** | Direct API call (no proxy) | Via Notification Server | +| **Environment** | `testing` (from connection) | `production` (hardcoded) | +| **Chat history** | None (stateless, single request) | Maintains `messages[]` array, sends `conversationHistory` | +| **Button click display** | Shows raw payload as message | Shows button **title** as user message | +| **Response delivery** | Full JSON at once | SSE chunks (but still 1 chunk for services) | +| **LLM connection** | User-selected from dropdown | No selection; uses production config | +| **Service response** | `content` + `buttons` in JSON body | `content` + `buttons` inside SSE `payload` | +| **Chunks/Context** | Shown in collapsible section | Not shown (streaming doesn't return chunks) | + +--- + +## Streaming Payloads Reference + +This section documents the **exact payload shape** at each boundary in the three-hop streaming chain for both streaming flows (natural-language service detection and MCQ button click). + +--- + +### Hop 1 — Browser → Notification Server + +**Endpoint:** `POST /channels/:channelId/orchestrate/stream` + +The browser's `useStreamingResponse.tsx` sends an HTTP POST immediately after the SSE connection is established. + +#### Natural-Language Query Payload +```json +{ + "message": "My keyboard is not working", + "options": { + "authorId": "user-channel-abc123", + "conversationHistory": [ + { + "role": "user", + "content": "My keyboard is not working" + } + ], + "url": "sse-stream-context", + "connection_id": "conn-xyz" + } +} +``` + +#### MCQ Button-Click Payload +```json +{ + "message": "#service, /POST/dmapper/v2/keyboard/os-select", + "options": { + "authorId": "user-channel-abc123", + "conversationHistory": [ + { + "role": "user", + "content": "My keyboard is not working" + }, + { + "role": "assistant", + "content": "Which operating system are you using?" + }, + { + "role": "user", + "content": "Windows" + } + ], + "url": "sse-stream-context", + "connection_id": "conn-xyz" + } +} +``` + +> **Key difference:** For MCQ button clicks, `message` contains the raw `#service, /METHOD/...` payload (not the button title shown to the user). The button title (`"Windows"`) is only stored in the local `messages` state for display purposes. + +--- + +### Hop 2 — Notification Server → Backend + +**Endpoint:** `POST /orchestrate/stream` (Python FastAPI) + +`streamingService.js` maps the incoming request onto the `OrchestrationRequest` model: + +#### Natural-Language Query Payload +```json +{ + "chatId": "channel-abc123", + "message": "My keyboard is not working", + "authorId": "user-channel-abc123", + "conversationHistory": [ + { + "role": "user", + "content": "My keyboard is not working" + } + ], + "url": "sse-stream-context", + "environment": "production", + "connection_id": "conn-xyz" +} +``` + +#### MCQ Button-Click Payload +```json +{ + "chatId": "channel-abc123", + "message": "#service, /POST/dmapper/v2/keyboard/os-select", + "authorId": "user-channel-abc123", + "conversationHistory": [ + { + "role": "user", + "content": "My keyboard is not working" + }, + { + "role": "assistant", + "content": "Which operating system are you using?" + }, + { + "role": "user", + "content": "Windows" + } + ], + "url": "sse-stream-context", + "environment": "production", + "connection_id": "conn-xyz" +} +``` + +> **`environment` is always `"production"`** for this page — it is hardcoded in `streamingService.js` and is not derived from user-selected connections. + +--- + +### Hop 3 — Backend → Notification Server (SSE Stream) + +The Python backend yields SSE-formatted strings. Each line follows the `data: {json}\n\n` format. + +#### Message 1 — Service Response (with buttons) + +Emitted by `format_sse(chat_id, content, buttons)`: + +``` +data: { + "chatId": "channel-abc123", + "payload": { + "content": "Which operating system are you using?", + "buttons": [ + { "title": "Windows", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=windows" }, + { "title": "Mac", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=mac" }, + { "title": "Linux", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=linux" } + ] + }, + "timestamp": "1711512000000", + "sentTo": [] +} + +``` + +#### Message 1 — Service Response (without buttons / final answer) + +``` +data: { + "chatId": "channel-abc123", + "payload": { + "content": "To fix your keyboard on Windows, please try the following steps: ..." + }, + "timestamp": "1711512001000", + "sentTo": [] +} + +``` + +#### Message 2 — END Marker (always the final SSE frame) + +Emitted by `format_sse(chat_id, "END")`: + +``` +data: { + "chatId": "channel-abc123", + "payload": { + "content": "END" + }, + "timestamp": "1711512001001", + "sentTo": [] +} + +``` + +> **Service workflows always yield exactly 2 SSE frames:** the content frame (Message 1) and the END frame (Message 2). RAG workflows yield many content frames (one per token) before the END frame. + +#### Error Frame (e.g. guardrail violation, rate limit, timeout) + +``` +data: { + "chatId": "channel-abc123", + "payload": { + "content": "I'm sorry, I can only assist with topics related to e-government services." + }, + "timestamp": "1711512001000", + "sentTo": [] +} + +``` + +Followed immediately by the END frame. + +--- + +### Hop 4 — Notification Server → Browser (SSE Stream) + +After parsing each `data:` line from the backend, `streamingService.js` re-emits to the browser via its own SSE channel. The format changes: the outer `chatId`/`timestamp` wrapper is dropped and a `type` discriminator is added. + +#### `stream_start` — Sent once, before any backend data + +```json +{ + "type": "stream_start", + "streamId": "channel-abc123", + "channelId": "channel-abc123", + "isComplete": false +} +``` + +#### `stream_chunk` — Sent once per parsed SSE frame (except END) + +With buttons (MCQ step): +```json +{ + "type": "stream_chunk", + "content": "Which operating system are you using?", + "buttons": [ + { "title": "Windows", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=windows" }, + { "title": "Mac", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=mac" }, + { "title": "Linux", "payload": "#service, /POST/dmapper/v2/keyboard/os-select?os=linux" } + ], + "streamId": "channel-abc123", + "channelId": "channel-abc123", + "isComplete": false +} +``` + +Without buttons (final answer or RAG token): +```json +{ + "type": "stream_chunk", + "content": "To fix your keyboard on Windows, please try the following steps: ...", + "streamId": "channel-abc123", + "channelId": "channel-abc123", + "isComplete": false +} +``` + +#### `stream_end` — Sent when backend `payload.content === "END"` + +```json +{ + "type": "stream_end", + "streamId": "channel-abc123", + "channelId": "channel-abc123", + "isComplete": true +} +``` + +#### `stream_error` — Sent on fetch failure or uncaught exception in `streamingService.js` + +```json +{ + "type": "stream_error", + "error": "Failed to connect to LLM orchestration service", + "streamId": "channel-abc123", + "channelId": "channel-abc123", + "isComplete": true +} +``` + +--- + +### Payload Shape Summary + +| Hop | Direction | Content Key | Envelope | +|---|---|---|---| +| **1** | Browser → Notification Server | `message` (string) | `{ message, options: { authorId, conversationHistory, url, connection_id } }` | +| **2** | Notification Server → Backend | `message` (string) | `{ chatId, message, authorId, conversationHistory, url, environment, connection_id }` | +| **3** | Backend → Notification Server | `payload.content` (string), optional `payload.buttons` | `data: { chatId, payload, timestamp, sentTo }\n\n` | +| **4** | Notification Server → Browser | `content` (string), optional `buttons` | `data: { type, content?, buttons?, streamId, channelId, isComplete }\n` | + +--- + +## Error Handling in Streaming Flow + +| Error Scenario | Where Handled | Behavior | +|---|---|---| +| `_parse_service_prefix()` fails | `execute_direct_step_streaming()` | Returns `None` → falls through to normal pipeline | +| Service endpoint timeout/error | `_call_service_endpoint()` | Returns `None` → falls back to RAG | +| StreamTimeout | `timeout_wrapped_stream()` (API layer) | Sends SSE timeout message to client | +| Rate limit exceeded | `stream_orchestrated_response()` (API layer) | Sends SSE error with 429 status | +| Notification server no connections | `createLLMOrchestrationStreamRequest()` | Queues request or returns 404 | +| Browser disconnect mid-stream | `activeConnections.has()` check in loop | Stops reading response body | +| Component unmount during stream | `useEffect` cleanup in hook | Calls `stopStreaming()` → closes EventSource | +| Bot message incomplete on error | `onError` callback in component | Marks message with `hasError: true`, shows error indicator | diff --git a/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md new file mode 100644 index 00000000..ac92abb2 --- /dev/null +++ b/docs/TOOL_CLASSIFIER_AND_SERVICE_WORKFLOW.md @@ -0,0 +1,696 @@ +# Tool Classifier and Service Workflow Architecture + +## Overview + +The Tool Classifier implements a **layer-wise fallback architecture** that routes user queries to the most appropriate workflow handler. The Service Workflow (Layer 1) handles external API/service calls with intelligent intent detection and entity extraction. + +--- + +## Tool Classifier - Layer Architecture + +### Design Pattern: Chain of Responsibility + +The classifier tries each layer sequentially. If a layer returns `None`, it falls back to the next layer: + +``` +Layer 1: SERVICE → External API calls (currency, weather, etc.) +Layer 2: CONTEXT → Greetings, conversation history queries +Layer 3: RAG → Knowledge base retrieval (documents, regulations) +Layer 4: OOD → Out-of-domain fallback (polite rejection) +``` + +### Layer Execution Flow + +```python +# Non-streaming mode +classification = await classifier.classify(query, history, language) +response = await classifier.route_to_workflow(classification, request, is_streaming=False) + +# Streaming mode +classification = await classifier.classify(query, history, language) +stream = await classifier.route_to_workflow(classification, request, is_streaming=True) +async for sse_chunk in stream: + yield sse_chunk +``` + +### Fallback Chain + +Each workflow's `execute_async()` or `execute_streaming()` can return: +- **OrchestrationResponse / AsyncIterator[str]**: Layer handled the query successfully +- **None**: Layer cannot handle → Fallback to next layer + +Example cascading: +``` +Query: "What is VAT rate?" +└─ SERVICE (Layer 1) → No matching service → Returns None + └─ CONTEXT (Layer 2) → Not a greeting → Returns None + └─ RAG (Layer 3) → Found in docs → Returns response ✓ +``` + +--- + +## Service Workflow (Layer 1) - Detailed Architecture + +### Purpose +Handle queries that require calling external services/APIs: +- Currency conversion: "How much is 100 EUR in USD?" +- Weather services: "What's the temperature in Tallinn?" +- Custom Ruuter endpoints: Any service registered in database + +### High-Level Flow + +The service workflow has **3 routing paths** based on classification metadata from hybrid search: + +``` +Classification Result (from classifier.py) +│ +├─ needs_llm_confirmation = False (HIGH-CONFIDENCE) +│ → Skip discovery, run intent detection on matched service only +│ +├─ needs_llm_confirmation = True (AMBIGUOUS) +│ → Run LLM intent detection on top candidate services +│ +└─ No metadata (LEGACY / fallback) + → Full service discovery + optional semantic search + intent detection +``` + +Each path then continues through: +``` +1. Entity Extraction (from LLM output) +↓ +2. Entity Validation (against service schema) +↓ +3. Entity Transformation (Dict → Ordered Array) +↓ +4. Service Endpoint Construction +↓ +5. Service Call (Ruuter endpoint invocation) +``` + +--- + +## Service Discovery (Legacy Path) + +### Method: `_call_service_discovery()` + +Calls Ruuter public endpoint to fetch available services: + +```python +GET {RAG_SEARCH_RUUTER_PUBLIC}/services/get-services +# Default: http://ruuter-public:8086/rag-search/services/get-services +``` + +**Response Structure:** +```json +{ + "response": { + "service_count": 15, + "use_semantic_search": true, + "services": [ + { + "serviceId": "currency_conversion_eur", + "name": "Currency Conversion (EUR Base)", + "description": "Convert EUR to other currencies", + "ruuterType": "POST", + "ruuterUrl": "/currency/convert", + "entities": ["target_currency"], + "examples": [ + "How much is 100 EUR in USD?", + "Convert EUR to JPY" + ] + } + ] + } +} +``` + +### Service Count Threshold Logic + +```python +SERVICE_COUNT_THRESHOLD = 10 + +if service_count <= 10: + # Few services → Use all services for LLM intent detection + services = response["services"] + +elif service_count > 10: + # Many services → Use semantic search to narrow down + services = await _semantic_search_services(query, top_k=10) +``` + +--- + +## Semantic Search (When Many Services) + +### Method: `_semantic_search_services()` + +Uses Qdrant vector database to find relevant services: + +```python +# 1. Generate embedding for user query +embedding = orchestration_service.create_embeddings_for_indexer([query]) + +# 2. Search Qdrant collection +search_payload = { + "vector": query_embedding, + "limit": 10, # Top 10 services (SEMANTIC_SEARCH_TOP_K) + "score_threshold": 0.2, # Minimum similarity (SEMANTIC_SEARCH_THRESHOLD) + "with_payload": True +} + +response = qdrant_client.post( + f"/collections/{QDRANT_COLLECTION}/points/search", + json=search_payload +) +``` + +**Returns:** Top-K most semantically relevant services for intent detection + +--- + +## Intent Detection (LLM-Based) + +### Method: `_detect_service_intent()` + +Uses **DSPy + LLM** to intelligently match user query to a specific service and extract entities. + +### DSPy Module: `IntentDetectionModule` + +**Purpose:** Analyze user query against available services and extract structured information + +**Signature:** +```python +class ServiceIntentDetector(dspy.Signature): + # Inputs + user_query: str # "How much is 100 EUR in USD?" + available_services: str # JSON of service definitions + conversation_context: str # Recent 3 conversation turns + + # Output + intent_result: str # JSON: {matched_service_id, confidence, entities, reasoning} +``` + +### LLM Call Flow + +```python +# 1. Prepare service context +services_formatted = [ + { + "service_id": "currency_conversion_eur", + "name": "Currency Conversion", + "description": "Convert EUR to other currencies", + "required_entities": ["target_currency"], + "examples": ["How much is EUR in USD?", "Convert EUR to JPY"] # Top 3 examples + } +] + +# 2. Prepare conversation context (last 3 turns) +conversation_context = """ +end_user: Hello +backoffice_user: Hi! How can I help? +end_user: How much is 100 EUR in USD? +""" + +# 3. Call DSPy module (uses dspy.Predict, not ChainOfThought) +with self.llm_manager.use_task_local(): + intent_result = intent_module.forward( + user_query="How much is 100 EUR in USD?", + services=services_formatted, + conversation_history=conversation_history + ) +``` + +### LLM Output Format + +The LLM returns structured JSON: + +```json +{ + "matched_service_id": "currency_conversion_eur", + "confidence": 0.95, + "entities": { + "target_currency": "USD" + }, + "reasoning": "User wants to convert EUR to USD, matches currency conversion service" +} +``` + +### Confidence Threshold + +```python +if matched_service_id is None or confidence < 0.7: + # Low confidence → Service workflow returns None → Fallback to Context/RAG + return None +``` + +### Cost Tracking + +Intent detection is an LLM call, so costs are tracked: + +```python +# Before LLM call +history_length_before = len(dspy.settings.lm.history) + +# Call intent detector +intent_result = intent_module.forward(...) + +# After LLM call +usage_info = get_lm_usage_since(history_length_before) +costs_metric["intent_detection"] = usage_info + +# Later: orchestration_service.log_costs(costs_metric) +``` + +--- + +## Entity Extraction + +### From LLM Output + +The LLM extracts entities directly from the user query: + +**User Query:** `"Palju saan 1 EUR eest THBdes?"` +(Estonian: "How much do I get for 1 EUR in THB?") + +**LLM Extraction:** +```json +{ + "entities": { + "target_currency": "THB" + } +} +``` + +### Entity Format + +Entities are extracted as **key-value pairs** where: +- **Key**: Entity name defined in service schema (`target_currency`) +- **Value**: Extracted value from user query (`"THB"`) + +### Multi-Entity Example + +**Service Schema:** +```json +{ + "serviceId": "weather_forecast", + "entities": ["location", "date"] +} +``` + +**User Query:** "What's the weather in Tallinn tomorrow?" + +**LLM Extraction:** +```json +{ + "entities": { + "location": "Tallinn", + "date": "tomorrow" + } +} +``` + +--- + +## Entity Validation + +### Method: `_validate_entities()` + +Validates extracted entities against the service's expected schema. + +### Validation Checks + +#### 1. Missing Entities +Entities required by schema but not extracted by LLM: + +```python +service_schema = ["target_currency", "amount"] +extracted = {"target_currency": "USD"} + +# Missing: "amount" +missing_entities = ["amount"] +``` + +**Strategy:** Send empty string for missing entities (let service validate) + +#### 2. Extra Entities +Entities extracted but not in service schema: + +```python +service_schema = ["target_currency"] +extracted = {"target_currency": "USD", "random_field": "value"} + +# Extra: "random_field" +extra_entities = ["random_field"] +``` + +**Strategy:** Ignore extra entities (not sent to service) + +#### 3. Empty Values +Entities extracted but with empty values: + +```python +extracted = {"target_currency": ""} + +validation_errors = ["Entity 'target_currency' has empty value"] +``` + +**Strategy:** Log warning, proceed anyway (service validates) + +### Validation Result + +```python +{ + "is_valid": True, # Always true (lenient validation) + "missing_entities": ["amount"], # Will send empty strings + "extra_entities": ["random_field"], # Will be ignored + "validation_errors": [ # Warnings only + "Entity 'amount' has empty value" + ] +} +``` + +### Validation Philosophy + +**Lenient Approach:** +- Always returns `is_valid: True` +- Proceeds with partial entities +- Service endpoint validates required parameters +- Avoids false negatives from over-strict validation + +--- + +## Entity Transformation + +### Method: `_transform_entities_to_array()` + +Transforms entity dictionary to **ordered array** matching service schema order. + +### Why Ordered Array? + +Ruuter services expect parameters in specific order: +```python +# Service schema defines order +entities_schema = ["target_currency", "source_currency", "amount"] + +# LLM extraction (unordered dict) +entities_dict = { + "amount": "100", + "target_currency": "USD", + "source_currency": "EUR" +} + +# Transform to ordered array +entities_array = ["USD", "EUR", "100"] +# ↑ ↑ ↑ +# [0] [1] [2] (matches schema order) +``` + +### Transformation Logic + +```python +def _transform_entities_to_array( + self, + entities_dict: Dict[str, str], + entity_order: List[str] +) -> List[str]: + """Transform entity dict to ordered array.""" + if not entity_order: + return [] + return [entities_dict.get(key, "") for key in entity_order] +``` + +### Example + +**Service Schema:** +```json +["target_currency", "base_currency", "amount"] +``` + +**Extracted Entities:** +```json +{ + "target_currency": "JPY", + "amount": "500" +} +``` + +**Transformed Array:** +```python +["JPY", "", "500"] +# ↑ +# Missing "base_currency" → empty string +``` + +--- + +## Service Call (Step 7 — Implemented) + +### Endpoint Construction + +```python +def _construct_service_endpoint(self, service_name: str, chat_id: str) -> str: + # Clean service name: strip whitespace, remove invisible Unicode chars, replace spaces with _ + clean_name = service_name.strip().translate(INVISIBLE_CHAR_TABLE).replace(" ", "_") + return f"{RUUTER_SERVICE_BASE_URL}/services/active/{clean_name}" + # Example: "http://ruuter-public:8086/services/services/active/Currency_Conversion" +``` + +### Payload Construction + +```python +payload = { + "chatId": chat_id, + "authorId": author_id, + "input": entities_array, # ["USD", "EUR", "100"] +} +``` + +### HTTP Call + +```python +async def _call_service_endpoint( + self, endpoint_url, http_method, entities_array, chat_id, author_id +) -> Optional[str]: + async with httpx.AsyncClient(timeout=SERVICE_CALL_TIMEOUT) as client: + if http_method.upper() == "POST": + response = await client.post(endpoint_url, json=payload) + else: + response = await client.get(endpoint_url, params=payload) + + response.raise_for_status() + data = response.json() + + # Ruuter wraps the DSL return value in {"response": ...} + if isinstance(data, dict) and "response" in data: + data = data["response"] + + # DMapper returns a JSON array; each item has a "content" field + if isinstance(data, list) and len(data) > 0: + content = data[0].get("content", "") + return content if content else None +``` + +### Streaming Mode + +In streaming mode, the service content is wrapped as SSE events: + +```python +async def service_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse(chat_id, service_content) + yield orchestration_service.format_sse(chat_id, "END") + orchestration_service.log_costs(costs_metric) +``` + +--- + +## Complete Example Flow + +### User Query +``` +"Palju saan 1 EUR eest THBdes?" +(How much do I get for 1 EUR in THB?) +``` + +### Step-by-Step Execution + +#### 1. Classification (Hybrid Search) +```python +# Dense search finds best service match +# cosine=0.5511, gap=0.2371 +# → HIGH-CONFIDENCE path (needs_llm_confirmation=False) +``` + +#### 2. Intent Detection (LLM Call on matched service only) +```json +{ + "matched_service_id": "currency_conversion_eur", + "confidence": 0.92, + "entities": { + "target_currency": "THB" + }, + "reasoning": "User wants to convert EUR to THB" +} +``` + +#### 3. Entity Extraction +```python +entities_dict = {"target_currency": "THB"} +``` + +#### 4. Entity Validation +```python +validation_result = { + "is_valid": True, + "missing_entities": [], + "extra_entities": [], + "validation_errors": [] +} +``` + +#### 5. Entity Transformation +```python +# Schema: ["target_currency"] +# Dict: {"target_currency": "THB"} +# Array: ["THB"] +entities_array = ["THB"] +``` + +#### 6. Service Call +```python +endpoint_url = "http://ruuter-public:8086/services/services/active/Currency_Conversion" +response = await _call_service_endpoint( + endpoint_url=endpoint_url, + http_method="POST", + entities_array=["THB"], + chat_id="...", + author_id="..." +) +# Returns content string from Ruuter response +``` + +--- + +## Cost Tracking + +Service workflow tracks LLM costs following the RAG workflow pattern: + +```python +# Create costs dict at workflow level +costs_metric: Dict[str, Dict[str, Any]] = {} + +# Intent detection captures costs +intent_result, intent_usage = await _detect_service_intent(...) +costs_metric["intent_detection"] = intent_usage + +# Log costs after workflow completes +orchestration_service.log_costs(costs_metric) +``` + +**Cost Breakdown Logged:** +``` +LLM USAGE COSTS BREAKDOWN: + intent_detection : $0.000120 (1 calls, 450 tokens) +``` + +--- + +## Fallback Behavior + +### When Service Workflow Returns None + +```python +# Scenario 1: No service_id in context after intent detection +if not context.get("service_id"): + return None # Fallback to CONTEXT layer + +# Scenario 2: Service metadata extraction failed +if not service_metadata: + return None # Fallback to CONTEXT layer + +# Scenario 3: Service endpoint call failed +if service_content is None: + return None # Fallback to CONTEXT layer +``` + +### Fallback Chain Result + +``` +Query: "What is VAT?" +└─ SERVICE → No service matches "VAT information" → None + └─ CONTEXT → Not a greeting → None + └─ RAG → Found in knowledge base → Response ✓ +``` + +--- + +## Configuration Constants + +```python +# Ruuter service configuration +RUUTER_BASE_URL = "http://ruuter-private:8086" +RUUTER_SERVICE_BASE_URL = "http://ruuter-public:8086/services" +RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" + +# Service call timeouts +SERVICE_CALL_TIMEOUT = 10 # seconds for external service calls +SERVICE_DISCOVERY_TIMEOUT = 10.0 # seconds for service discovery + +# Service selection thresholds +SERVICE_COUNT_THRESHOLD = 10 # Switch to semantic search if exceeded +MAX_SERVICES_FOR_LLM_CONTEXT = 50 # Max services to pass to LLM + +# Semantic search +QDRANT_COLLECTION = "intent_collections" +SEMANTIC_SEARCH_TOP_K = 10 # Top 10 relevant services +SEMANTIC_SEARCH_THRESHOLD = 0.2 # Minimum similarity score +QDRANT_TIMEOUT = 10.0 # seconds + +# Hybrid search classification (see HYBRID_SEARCH_CLASSIFICATION.md) +DENSE_MIN_THRESHOLD = 0.38 # Minimum cosine to consider service match +DENSE_HIGH_CONFIDENCE_THRESHOLD = 0.40 # Cosine for high-confidence path +DENSE_SCORE_GAP_THRESHOLD = 0.05 # Required gap between top two services +DENSE_SEARCH_TOP_K = 3 # Unique services from dense search +HYBRID_SEARCH_TOP_K = 5 # Results from hybrid RRF search +``` + +--- + +## Key Design Decisions + +### 1. **Lenient Entity Validation** +- Proceeds with partial entities +- Service validates required parameters +- Reduces false negatives + +### 2. **Ordered Entity Arrays** +- Ruuter services expect positional parameters +- Schema defines canonical order +- Missing entities → empty strings + +### 3. **Three Routing Paths** +- **High-confidence**: Hybrid search matched → skip discovery, intent on 1 service +- **Ambiguous**: Moderate match → intent detection on top candidates +- **Legacy**: No classification metadata → full discovery flow + +### 4. **LLM-Based Intent Detection** +- Uses DSPy `dspy.Predict` (not ChainOfThought) for direct prediction +- Intelligent service matching +- Natural language understanding +- Multilingual support (Estonian, English, Russian) + +### 5. **Cost Tracking** +- Follows RAG workflow pattern +- Tracks intent detection LLM costs +- Integrated with budget system + +### 6. **Implemented Service Call** +- Calls Ruuter active service endpoint via httpx +- Handles POST and GET methods +- Parses DMapper response format (`{"response": [{"content": "..."}]}`) +- Cleans service name (invisible chars, whitespace → underscore) + +--- + +## Summary + +The Tool Classifier's layer architecture enables intelligent query routing with graceful fallbacks. The Service Workflow (Layer 1) uses **hybrid search classification** (dense + sparse + RRF) to route queries into 3 paths: high-confidence (skip discovery), ambiguous (LLM confirmation on candidates), or legacy (full discovery). It then uses **LLM-based intent detection** (DSPy Predict) to match user queries to external services, extract entities, validate them against service schemas, transform to ordered arrays, and **call the Ruuter active service endpoint** — all while maintaining comprehensive cost tracking and seamless integration with the broader RAG pipeline. diff --git a/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md new file mode 100644 index 00000000..38d81898 --- /dev/null +++ b/docs/TOOL_CLASSIFIER_EXTENSION_SPEC.md @@ -0,0 +1,1940 @@ +# Tool Classifier Extension - System Specification + +**Version**: 1.0 +**Date**: February 13, 2026 +**Status**: Design Specification + +--- + +## 1. Overview + +This document specifies the extension of the existing RAG Module with a **Tool Classifier** that implements layer-wise workflow routing. The classifier determines whether a user query should be handled by: + +1. **Service Workflow** - External service/API calls +2. **Context Workflow** - Conversation history-based responses +3. **RAG Workflow** - Knowledge base retrieval (existing) +4. **OOD Response** - Out of domain fallback + +### 1.1 Current State + +**Existing Flow:** +``` +User Query → Input Guardrails → Prompt Refiner → Contextual Retrieval → Response Generator → Output Guardrails +``` + +**Entry Points:** +- `POST /orchestrate` - Non-streaming orchestration +- `POST /orchestrate/test` - Testing environment with simplified input +- `POST /orchestrate/stream` - Server-sent events streaming + +### 1.2 Proposed Extension + +**New Flow:** +``` +User Query → Input Guardrails → Tool Classifier → [Service | Context | RAG | OOD] + ↓ + Layer 1: Service Check + ↓ (no match) + Layer 2: Context Check + ↓ (no match) + Layer 3: RAG Retrieval + ↓ (no chunks) + Layer 4: OOD Response +``` + +--- + +## 2. Architecture Changes + +### 2.1 Component Integration + +The Tool Classifier will be integrated into the existing `LLMOrchestrationService` with minimal disruption: + +```python +# Location: src/llm_orchestration_service.py + +def process_orchestration_request(self, request: OrchestrationRequest): + """ + Modified orchestration pipeline with tool classifier. + + Pipeline: + 1. Language Detection (existing) + 2. Query Validation (existing) + 3. Input Guardrails (existing, relocated) + 4. Tool Classifier (NEW) + 5. Workflow Routing (NEW) + """ + + # Existing: Step 0, 0.5 + detected_language = detect_language(request.message) + validation_result = validate_query_basic(request.message) + + # Existing: Component initialization + components = self._initialize_service_components(request) + + # Existing: Step 1 - Input Guardrails (RELOCATED before classifier) + if components["guardrails_adapter"]: + input_blocked = self.handle_input_guardrails(...) + if input_blocked: + return input_blocked + + # NEW: Step 2 - Tool Classifier + classifier_result = self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language + ) + + # NEW: Step 3 - Workflow Routing + if classifier_result.workflow == WorkflowType.SERVICE: + return self._execute_service_workflow(request, classifier_result) + elif classifier_result.workflow == WorkflowType.CONTEXT: + return self._execute_context_workflow(request, classifier_result) + elif classifier_result.workflow == WorkflowType.RAG: + return self._execute_rag_workflow(request, classifier_result) + else: + return self._create_out_of_scope_response(request, detected_language) +``` + +### 2.2 New Components + +| Component | Location | Purpose | +|-----------|----------|---------| +| `ToolClassifier` | `src/tool_classifier/classifier.py` | Main classifier logic | +| `ServiceWorkflowExecutor` | `src/tool_classifier/service_workflow.py` | Service discovery and triggering | +| `ContextWorkflowExecutor` | `src/tool_classifier/context_workflow.py` | LLM-based conversation history analysis | +| `IntentEntityExtractor` | `src/tool_classifier/intent_extractor.py` | LLM-based intent/entity detection | +| `ServiceDiscoveryManager` | `src/tool_classifier/service_discovery.py` | Qdrant semantic search for services | +| `IntentCollectionSync` | `src/tool_classifier/intent_sync_service.py` | Database → Qdrant synchronization | +| `ContextAnalyzer` | `src/tool_classifier/context_analyzer.py` | LLM-based context availability checker | + +### 2.3 LLM Config Module Integration + +The existing LLM Config Module (`src/llm_config_module/`) is reused by the tool classifier for all LLM-based operations. No modifications to the core module are required. + +**Current LLM Config Module Capabilities:** +- **Multi-Provider Support**: Azure OpenAI, AWS Bedrock, OpenAI, Anthropic +- **Vault Integration**: Secure credential management via HashiCorp Vault +- **Connection Management**: Dynamic LLM connection selection based on `connection_id` from requests +- **Usage Tracking**: Token counting and cost calculation across providers + +**Tool Classifier LLM Usage:** + +| Workflow | LLM Operation | Config Usage | Temperature | +|----------|---------------|--------------|-------------| +| **Service (Layer 1)** | Intent & entity extraction | `llm_manager.call_llm_async()` | 0.0 (deterministic) | +| **Context (Layer 2)** | Context availability check | `llm_manager.call_llm_async()` | 0.0 (deterministic) | +| **RAG (Layer 3)** | Response generation | Existing integration | 0.7 (default) | +| **OOD (Layer 4)** | No LLM call | N/A | N/A | + +**Integration Pattern:** + +```python +# Tool classifier workflows use the same LLMManager instance +class ToolClassifier: + def __init__(self, llm_manager: LLMManager, ...): + self.llm_manager = llm_manager # Reuse existing instance + + async def detect_intent(self, query: str, services: List[Service]): + """Use LLM Config Module for intent detection.""" + response = await self.llm_manager.call_llm_async( + prompt=INTENT_DETECTION_PROMPT.format(...), + temperature=0.0, # Deterministic for classification + max_tokens=200 + ) + return parse_intent(response) +``` + +**Configuration Reuse:** +- Same connection selection logic (`connection_id` from `OrchestrationRequest`) +- Same Vault credential retrieval +- Same cost tracking pattern (`get_lm_usage_since()`) +- Same error handling and retry logic +- Same provider-specific implementations + +**No Changes Required**: The LLM Config Module is provider-agnostic and supports all tool classifier LLM calls out of the box. + +--- + +## 3. Layer 1: Service Workflow + +### 3.1 Workflow Logic + +When a user query is received, the system determines if it's a service-related request through the following steps: + +``` +1. Service Count Check → 2. Service Discovery → 3. Intent Detection → 4. Service Validation → 5. Entity Transformation → 6. Service Triggering +``` + +### 3.2 Step-by-Step Implementation + +#### Step 1: Service Count Check + +**Purpose**: Optimize performance based on service catalog size + +```python +# Query: SELECT COUNT(*) FROM services WHERE current_state = 'active' AND deleted = FALSE + +if service_count <= 50: + # Use all services for LLM context + services = get_all_active_services() +else: + # Use semantic search for top 20 most relevant + services = semantic_search_services(user_query, top_k=20) +``` + +**Database Query:** +```sql +SELECT COUNT(*) FROM public.services +WHERE current_state = 'active' AND deleted = FALSE; +``` + +#### Step 2: Semantic Search (When Service Count > 50) + +**Tool**: Qdrant vector database +**Collection**: `intent_collection` +**Vector Dimension**: 3072 (text-embedding-3-large) + +**Search Configuration:** +```python +search_params = { + "collection_name": "intent_collection", + "query_vector": embed_query(user_query), + "limit": 20, + "score_threshold": 0.5, # Higher threshold for service matching +} +``` + +**Output Format:** +```json +[ + { + "service_id": "exchange-rate-001", + "service_name": "ExchangeRateService", + "description": "Provides currency exchange rates", + "entities": ["fromCurrency", "toCurrency"], + "score": 0.87 + }, + ... +] +``` + +#### Step 3: LLM Intent Detection + +**Action**: Call LLM with user query and service context to extract: +- `intent`: Service name to trigger +- `entities`: Key-value pairs of extracted parameters + +**Prompt Template:** +```python +INTENT_DETECTION_PROMPT = """ +You are an intent classifier for government services. Analyze the user query and determine which service should handle the request. + +Available Services: +{service_list} + +User Query: "{user_query}" + +Task: +1. If the query matches a service, extract: + - intent: The exact service name to trigger + - entities: Key-value pairs of required parameters + +2. If NO service matches, respond with: {{"intent": null, "entities": null}} + +Response Format (JSON only, no explanation): +{{"intent": "ServiceName", "entities": {{"param1": "value1", "param2": "value2"}}}} +""" +``` + +**Expected LLM Response:** +```json +{ + "choices": [ + { + "message": { + "content": "{\"intent\": \"ExchangeRateService\", \"entities\": {\"fromCurrency\": \"EUR\", \"toCurrency\": \"USD\"}}" + } + } + ] +} +``` + +**Parsing Logic:** +```python +# Parse LLM response +content = response["choices"][0]["message"]["content"] +parsed = json.loads(content) + +if parsed["intent"] is None: + # No service match - move to Layer 2 (Context Workflow) + return WorkflowType.CONTEXT +``` + +#### Step 4: Service Validation + +**Action**: Validate the detected service against the database + +**Validation Query:** +```sql +SELECT service_id, name, ruuter_type, endpoints, structure, entities +FROM public.services +WHERE service_id = %(detected_service_id)s + AND current_state = 'active' + AND deleted = FALSE; +``` + +**Validation Checks:** +- Service exists in database +- `current_state = 'active'` +- `deleted = FALSE` + +**Failure Handling:** +```python +if not service_exists or not service_active: + logger.warning(f"Service validation failed: {detected_service_id}") + # Fallback to Layer 2 (Context Workflow) + return WorkflowType.CONTEXT +``` + +#### Step 5: Entity Transformation + +**Purpose**: Convert LLM entity object to array format for service payload + +**Input (from LLM):** +```json +{ + "fromCurrency": "EUR", + "toCurrency": "USD" +} +``` + +**Output (for service call):** +```json +["EUR", "USD"] +``` + +**Transformation Logic:** +```python +def transform_entities(entities: Optional[Dict[str, str]], + entity_order: List[str]) -> List[str]: + """ + Transform entity dictionary to ordered array. + + Args: + entities: LLM-extracted entity key-value pairs + entity_order: Expected entity order from service schema + + Returns: + Ordered list of entity values + """ + if not entities or entities is None: + return [] + + # Maintain order defined in service schema + return [entities.get(key, "") for key in entity_order] +``` + +**Example:** +```python +# Service schema defines: entities = ["fromCurrency", "toCurrency"] +transform_entities( + {"fromCurrency": "EUR", "toCurrency": "USD"}, + ["fromCurrency", "toCurrency"] +) +# Output: ["EUR", "USD"] +``` + +#### Step 6: Service Triggering + +**Purpose**: Call the external service endpoint with formatted payload + +**URL Construction:** +```python +# From database field 'endpoints' +base_url = "http://ruuter:8086" # From environment or service config +service_endpoint = f"{base_url}/services/active{service_name}" + +# Example: http://ruuter:8086/services/activeExchangeRateService +``` + +**HTTP Method:** +```python +# Retrieved from database field 'ruuter_type' +method = service.ruuter_type # 'GET' or 'POST' (ENUM) +``` + +**Payload Format:** +```json +{ + "input": ["EUR", "USD"], + "authorId": "user-67890", + "chatId": "chat-12345" +} +``` + +**Implementation:** +```python +async def trigger_service( + service: ServiceRecord, + entities: List[str], + request: OrchestrationRequest +) -> Dict[str, Any]: + """ + Trigger external service via Ruuter. + + Args: + service: Validated service record from database + entities: Transformed entity array + request: Original orchestration request + + Returns: + Service response or error + """ + url = f"{RUUTER_BASE_URL}/services/active{service.name}" + payload = { + "input": entities, + "authorId": request.authorId, + "chatId": request.chatId + } + + try: + if service.ruuter_type == "GET": + response = await http_client.get(url, params=payload, timeout=10) + else: # POST + response = await http_client.post(url, json=payload, timeout=10) + + response.raise_for_status() + return response.json() + + except httpx.TimeoutException: + logger.error(f"Service timeout: {service.service_id}") + raise ServiceTimeoutError() + except httpx.HTTPStatusError as e: + logger.error(f"Service error: {e.response.status_code}") + raise ServiceExecutionError() +``` + +**Response Handling:** + +**Non-Streaming:** +```python +service_response = await trigger_service(service, entities, request) +formatted_content = format_service_response(service_response) + +# Apply output guardrails +if guardrails_adapter: + output_check = await guardrails_adapter.check_output_async(formatted_content) + costs_metric["output_guardrails"] = output_check.usage + + if not output_check.allowed: + logger.warning(f"Service response blocked by guardrails: {output_check.reason}") + return create_guardrail_violation_response(request) + +# Return validated service response +return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=formatted_content +) +``` + +**Streaming:** +```python +service_response = await trigger_service(service, entities, request) +formatted_content = format_service_response(service_response) + +# Apply output guardrails validation +if guardrails_adapter: + output_check = await guardrails_adapter.check_output_async(formatted_content) + costs_metric["output_guardrails"] = output_check.usage + + if not output_check.allowed: + logger.warning(f"Service response blocked by guardrails") + yield format_sse(request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE) + yield format_sse(request.chatId, "END") + return + +# Stream validated response token-by-token +for token in split_into_tokens(formatted_content, chunk_size=5): + yield format_sse(request.chatId, token) + await asyncio.sleep(0.01) # Maintain streaming UX + +yield format_sse(request.chatId, "END") +``` + +### 3.3 Failure Scenarios + +| Scenario | Action | +|----------|--------| +| No intent detected | Move to Layer 2 (Context Workflow) | +| Service validation failed | Move to Layer 2 (Context Workflow) | +| Service call timeout | Return `SERVICE_TIMEOUT_ERROR` message | +| Service returns error | Return `SERVICE_EXECUTION_ERROR` message | +| Entity extraction incomplete | Attempt service call with partial entities, or fallback to Layer 2 | +| Output guardrails blocked | Return `OUTPUT_GUARDRAIL_VIOLATION_MESSAGE` or fallback to Layer 2 | + +### 3.4 Output Guardrails for Service Responses + +**Why Service Responses Need Guardrails:** +- External services may return PII (personal identifiable information) +- Service errors could expose sensitive system details +- Third-party API responses are untrusted content +- Ensures consistent safety across all workflows + +**Integration Pattern:** + +Both non-streaming and streaming modes validate service responses before sending to users: + +```python +# Get service response +service_response = await trigger_service(...) + +# Apply output guardrails (validation-first) +if guardrails_adapter: + output_check = await guardrails_adapter.check_output_async(service_response) + if not output_check.allowed: + # Blocked - return error or fallback + return create_guardrail_violation_response(request) + +# Validated - return/stream to user +return/stream service_response +``` + +--- + +## 4. Layer 2: Context Workflow + +### 4.1 Workflow Logic + +If Layer 1 fails (no service match), use LLM to determine if the query is a greeting or can be answered from conversation history. + +**Trigger Conditions:** +- No service intent detected in Layer 1 +- Query is a greeting (hello, hi, good morning, etc.) **OR** +- Conversation history exists (at least 1 previous turn) and query references it + +### 4.2 Greeting Detection + +Greetings and conversational pleasantries are handled by the Context Workflow to provide natural, friendly responses without triggering service discovery or RAG retrieval. + +**Greeting Patterns (Multilingual):** + +```python +# Estonian greetings +ESTONIAN_GREETINGS = [ + "tere", "tervist", "tere hommikust", "tere päevast", "tere õhtust", + "hei", "hommikust", "õhtust", "päevast", "nägemist", + "tsau", "moi", "moikka" +] + +# English greetings +ENGLISH_GREETINGS = [ + "hello", "hi", "hey", "good morning", "good afternoon", "good evening", + "greetings", "howdy", "morning", "afternoon", "evening" +] + +# Farewell patterns +FAREWELL_PATTERNS = [ + "goodbye", "bye", "see you", "talk to you later", "ttyl", + "nägemist", "head aega", "kuni", "tsau" +] +``` + +**LLM-Based Greeting Detection:** + +Instead of rigid pattern matching, the LLM analyzes whether the query is a greeting or conversational message: + +```python +async def detect_greeting( + query: str, + llm_manager: LLMManager, + language: str +) -> GreetingResult: + """ + Use LLM to detect if query is a greeting/conversational message. + + Args: + query: User's message + llm_manager: LLM manager instance + language: Detected language (et/en) + + Returns: + GreetingResult with is_greeting flag and optional response + """ + prompt = GREETING_DETECTION_PROMPT.format( + user_query=query, + language=language + ) + + response = await llm_manager.call_llm_async( + prompt=prompt, + temperature=0.0, + max_tokens=150 + ) + + content = response["choices"][0]["message"]["content"] + result = json.loads(content) + + return GreetingResult( + is_greeting=result["is_greeting"], + greeting_type=result.get("greeting_type"), # 'hello', 'goodbye', 'thanks', etc. + suggested_response=result.get("suggested_response") + ) +``` + +**Greeting Detection Prompt:** + +```python +GREETING_DETECTION_PROMPT = """ +You are a greeting classifier. Determine if the user's message is a greeting, farewell, or conversational pleasantry. + +User Message: "{user_query}" +Language: {language} + +Task: +1. Identify if this is a greeting/conversational message (hello, hi, goodbye, thanks, etc.) +2. If YES: Classify the type and suggest an appropriate response +3. If NO: Indicate it's not a greeting + +Response Format (JSON only): +{{ + "is_greeting": true/false, + "greeting_type": "hello" | "goodbye" | "thanks" | "casual" | null, + "suggested_response": "friendly response in same language" | null +}} + +Examples of greetings: +- "Tere!" → {"is_greeting": true, "greeting_type": "hello"} +- "Good morning" → {"is_greeting": true, "greeting_type": "hello"} +- "Thanks for your help" → {"is_greeting": true, "greeting_type": "thanks"} +- "What are digital signatures?" → {"is_greeting": false} +""" +``` + +**Response Generation:** + +```python +if greeting_result.is_greeting: + # Use LLM-suggested response or fallback to predefined messages + response = greeting_result.suggested_response or get_default_greeting_response( + greeting_type=greeting_result.greeting_type, + language=language + ) + + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=response + ) +``` + +### 4.3 LLM-Based Context Analysis + +Instead of using regex patterns, we use the LLM to intelligently determine if the query references conversation history and can be answered from it. + +**Conversation Window:** +```python +# Consider last 10 conversation turns (5 user + 5 bot pairs) +CONTEXT_WINDOW_SIZE = 10 + +def get_recent_history(history: List[ConversationItem]) -> List[ConversationItem]: + """Get recent conversation history for context analysis.""" + return history[-CONTEXT_WINDOW_SIZE:] if history else [] +``` + +**LLM Context Check Prompt:** +```python +CONTEXT_CHECK_PROMPT = """ +You are a conversation context analyzer. Analyze if the user's current query can be answered using ONLY the conversation history provided. + +Conversation History: +{conversation_history} + +Current User Query: "{user_query}" + +Task: +1. First check if this is a greeting/conversational message (hi, hello, thanks, goodbye, etc.) +2. If it's a greeting: Provide an appropriate friendly response +3. If NOT a greeting: Determine if the query references or can be answered from the conversation history above +4. If YES: Extract and provide the answer from the conversation history +5. If NO: Indicate that it cannot be answered from conversation history + +Response Format (JSON only, no explanation): +{{ + "is_greeting": true/false, + "can_answer_from_context": true/false, + "answer": "extracted answer from history OR greeting response" OR null, + "reasoning": "brief explanation of why it can/cannot be answered" +}} + +Examples of GREETINGS (handle with friendly response): +- "Tere!" → {"is_greeting": true, "answer": "Tere! Kuidas saan teid aidata?"} +- "Hello" → {"is_greeting": true, "answer": "Hello! How can I help you?"} +- "Thanks!" → {"is_greeting": true, "answer": "You're welcome!"} +- "Good morning" → {"is_greeting": true, "answer": "Good morning! What can I do for you?"} + +Examples of queries that CAN be answered from context: +- "What did you say earlier about that?" +- "Can you repeat that?" +- "What was the rate you mentioned?" +- "Tell me more about what you just said" + +Examples of queries that CANNOT be answered from context: +- Completely new topics +- Requests for real-time data +- Questions requiring external knowledge +""" +``` + +**Implementation:** +```python +async def check_context_availability( + query: str, + conversation_history: List[ConversationItem], + llm_manager: LLMManager +) -> ContextCheckResult: + """ + Use LLM to check if query can be answered from conversation history. + + Args: + query: Current user query + conversation_history: Recent conversation turns + llm_manager: LLM manager for making calls + + Returns: + ContextCheckResult with can_answer flag and optional answer + """ + # Get recent history + recent_history = get_recent_history(conversation_history) + + if not recent_history: + # No conversation history available + return ContextCheckResult( + can_answer_from_context=False, + answer=None, + reasoning="No conversation history available" + ) + + # Format conversation history for prompt + history_text = format_conversation_history(recent_history) + + # Call LLM with structured output request + prompt = CONTEXT_CHECK_PROMPT.format( + conversation_history=history_text, + user_query=query + ) + + try: + response = await llm_manager.call_llm_async( + prompt=prompt, + temperature=0.0, # Deterministic for classification + max_tokens=300 + ) + + # Parse structured JSON response + content = response["choices"][0]["message"]["content"] + result = json.loads(content) + + return ContextCheckResult( + is_greeting=result.get("is_greeting", False), + can_answer_from_context=result["can_answer_from_context"], + answer=result.get("answer"), + reasoning=result.get("reasoning", "") + ) + + except (json.JSONDecodeError, KeyError) as e: + logger.error(f"Failed to parse LLM context check response: {e}") + # Fallback: assume cannot answer from context + return ContextCheckResult( + can_answer_from_context=False, + answer=None, + reasoning="Failed to parse LLM response" + ) + +def format_conversation_history(history: List[ConversationItem]) -> str: + """Format conversation history for LLM prompt.""" + formatted = [] + for i, item in enumerate(history, 1): + role = "User" if item.authorRole == "user" else "Assistant" + formatted.append(f"{i}. {role}: {item.message}") + return "\n".join(formatted) +``` + +**Response Models:** +```python +from pydantic import BaseModel + +class ContextCheckResult(BaseModel): + """Result from LLM context availability check.""" + is_greeting: bool = False + can_answer_from_context: bool + answer: Optional[str] = None + reasoning: str = "" + +class GreetingResult(BaseModel): + """Result from greeting detection.""" + is_greeting: bool + greeting_type: Optional[str] = None # 'hello', 'goodbye', 'thanks', 'casual' + suggested_response: Optional[str] = None +``` + +### 4.3 Workflow Execution + +**Non-Streaming Response:** +```python +async def execute_context_workflow( + request: OrchestrationRequest, + llm_manager: LLMManager, + guardrails_adapter: Optional[NeMoRailsAdapter], + costs_metric: Dict +) -> Optional[OrchestrationResponse]: + """ + Execute context-based response workflow with output guardrails. + + Returns: + OrchestrationResponse with context-based answer or None to fallback to next layer + """ + # Check if query can be answered from conversation history + context_result = await check_context_availability( + query=request.message, + conversation_history=request.conversationHistory, + llm_manager=llm_manager + ) + + # Track costs + costs_metric["context_check"] = get_lm_usage_since(history_before) + + if (context_result.is_greeting or context_result.can_answer_from_context) and context_result.answer: + logger.info( + f"[{request.chatId}] Query answered from context " + f"(greeting: {context_result.is_greeting})" + ) + + # Apply output guardrails validation + if guardrails_adapter: + output_check = await guardrails_adapter.check_output_async( + context_result.answer + ) + costs_metric["output_guardrails"] = output_check.usage + + if not output_check.allowed: + logger.warning( + f"[{request.chatId}] Context response blocked by guardrails: " + f"{output_check.reason}" + ) + return create_guardrail_violation_response(request) + + # Return validated context-based response + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=context_result.answer + ) + + else: + logger.info( + f"[{request.chatId}] Cannot answer from context: {context_result.reasoning}" + ) + # Fallback to Layer 3 (RAG Workflow) + return None # Signal to move to next layer +``` + +**Streaming Response:** +```python +async def execute_context_workflow_streaming( + request: OrchestrationRequest, + llm_manager: LLMManager, + guardrails_adapter: Optional[NeMoRailsAdapter], + costs_metric: Dict +) -> Optional[AsyncIterator[str]]: + """ + Execute context workflow with streaming support and output guardrails. + + Yields: + SSE-formatted strings with validated context-based response + + Returns: + None if cannot answer from context (signals fallback to next layer) + """ + # Check context availability (non-streaming, fast) + context_result = await check_context_availability( + query=request.message, + conversation_history=request.conversationHistory, + llm_manager=llm_manager + ) + + # Track costs + costs_metric["context_check"] = get_lm_usage_since(history_before) + + if (context_result.is_greeting or context_result.can_answer_from_context) and context_result.answer: + logger.info( + f"[{request.chatId}] Validating and streaming context-based response " + f"(greeting: {context_result.is_greeting})" + ) + + # Apply output guardrails validation BEFORE streaming + if guardrails_adapter: + output_check = await guardrails_adapter.check_output_async( + context_result.answer + ) + costs_metric["output_guardrails"] = output_check.usage + + if not output_check.allowed: + logger.warning( + f"[{request.chatId}] Context response blocked by guardrails (streaming)" + ) + yield format_sse(request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE) + yield format_sse(request.chatId, "END") + return + + # Response validated - stream token by token for consistent UX + for token in split_into_tokens(context_result.answer, chunk_size=5): + yield format_sse(request.chatId, token) + await asyncio.sleep(0.01) # Maintain streaming pace + + # Signal completion + yield format_sse(request.chatId, "END") + + else: + logger.info(f"[{request.chatId}] No context match, falling back to RAG") + # Return None to signal fallback to next layer + # Caller will handle RAG workflow + return None + +def split_into_tokens(text: str, chunk_size: int = 5) -> List[str]: + """Split text into token-like chunks for streaming simulation.""" + words = text.split() + tokens = [] + for i in range(0, len(words), chunk_size): + chunk = " ".join(words[i:i + chunk_size]) + tokens.append(chunk + " " if i + chunk_size < len(words) else chunk) + return tokens +``` + +### 4.4 Advantages of LLM-Based Approach + + **No Regex Pattern Maintenance**: LLM understands semantic context references naturally + **Handles Edge Cases**: Can detect implicit references that regex would miss + **Multilingual Support**: Works across Estonian, English, and other languages + **Structured Output**: Consistent JSON format for easy parsing + **Reasoning Transparency**: Includes explanation of decision + **Streaming Compatible**: Fast context check + token-by-token answer delivery + **Greeting Detection**: Automatically handles greetings, farewells, and conversational pleasantries + **Natural Responses**: LLM generates contextually appropriate greeting responses + +### 4.7 Fallback Strategy + +**Fallback to Layer 3 (RAG):** +- If `is_greeting = false` AND `can_answer_from_context = false` +- If LLM response parsing fails +- If conversation history is empty (and not a greeting) +- If output guardrails block the response (fallback to RAG for alternative answer) + +**Error Handling:** +```python +try: + result = await execute_context_workflow( + request, llm_manager, guardrails_adapter, costs_metric + ) + if result: + return result # Context-based answer (validated) + else: + # Move to Layer 3 (RAG) + return await execute_rag_workflow(request, components, costs_metric) +except Exception as e: + logger.error(f"Context workflow failed: {e}") + # Fallback to RAG workflow + return await execute_rag_workflow(request, components, costs_metric) +``` + +**Guardrail Violation Fallback:** +```python +# Option 1: Return error message (current approach) +if not output_check.allowed: + return create_guardrail_violation_response(request) + +# Option 2: Fallback to RAG (alternative approach) +if not output_check.allowed: + logger.warning("Context response blocked, trying RAG workflow") + return await execute_rag_workflow(request, components, costs_metric) +``` + +--- + +## 5. Layer 3: RAG Workflow + +### 5.1 Integration with Existing System + +**Trigger**: When both Layer 1 (Service) and Layer 2 (Context) fail to match + +**Implementation:** +```python +# Reuse existing RAG pipeline +return self._execute_orchestration_pipeline( + request, components, costs_metric, time_metric +) +``` + +**Existing Flow (No Changes Required):** +1. Prompt Refinement +2. Contextual Retrieval (Qdrant + BM25) +3. Rank Fusion (RRF) +4. Response Generation +5. Output Guardrails (validation-first streaming already implemented) + +**Streaming with Output Guardrails (Current Implementation):** +```python +# RAG workflow uses validation-first approach +async for validated_chunk in guardrails_adapter.stream_with_guardrails( + user_message=refined_query, + bot_message_generator=llm_streaming_generator +): + # NeMo buffers tokens (chunk_size=200) + # Validates each buffer before yielding + yield format_sse(chatId, validated_chunk) + +yield format_sse(chatId, "END") +``` + +**Fallback:** +- If no chunks found (`len(relevant_chunks) == 0`) → Layer 4 (OOD) +- If response confidence low → Layer 4 (OOD) + +--- + +## 5.2 Streaming + Output Guardrails Comparison + +### Summary: How Each Workflow Handles Streaming + Validation + +| Workflow | Response Source | Validation Approach | Streaming Method | +|----------|----------------|---------------------|------------------| +| **RAG** | LLM streaming generation | NeMo buffers + validates chunks (chunk_size=200) | `stream_with_guardrails()` wraps bot generator | +| **Service** | External service (complete) | Validate complete response | Stream validated response token-by-token | +| **Context** | LLM structured output (complete) | Validate complete response | Stream validated response token-by-token | +| **OOD** | Fixed message | No validation needed | Stream fixed message token-by-token | + +### Technical Flow for Each Workflow + +#### RAG Workflow (Existing - Validation-First) + +**Non-Streaming:** +```python +response = await response_generator.generate(...) +output_check = await guardrails_adapter.check_output_async(response) +if output_check.allowed: + return OrchestrationResponse(content=response) +``` + +**Streaming:** +```python +# LLM generates via streaming +async def bot_generator(): + async for token in llm.stream(): + yield token + +# NeMo validates in real-time (buffers chunks) +async for validated_chunk in guardrails_adapter.stream_with_guardrails( + user_message=query, + bot_message_generator=bot_generator +): + yield format_sse(chatId, validated_chunk) # Already validated +``` + +#### Service Workflow (New - Validate Then Stream) + +**Non-Streaming:** +```python +service_response = await call_external_service(...) # Complete response +output_check = await guardrails_adapter.check_output_async(service_response) +if output_check.allowed: + return OrchestrationResponse(content=service_response) +else: + return GuardrailViolationResponse() +``` + +**Streaming:** +```python +service_response = await call_external_service(...) # Complete response + +# Validate complete response FIRST +output_check = await guardrails_adapter.check_output_async(service_response) +if not output_check.allowed: + yield format_sse(chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + +# Validated - now stream to client token-by-token +for token in split_into_tokens(service_response, chunk_size=5): + yield format_sse(chatId, token) + await asyncio.sleep(0.01) +yield format_sse(chatId, "END") +``` + +#### Context Workflow (New - Validate Then Stream) + +**Non-Streaming:** +```python +context_result = await llm.check_context(query, history) # Complete answer +if context_result.can_answer_from_context: + output_check = await guardrails_adapter.check_output_async(context_result.answer) + if output_check.allowed: + return OrchestrationResponse(content=context_result.answer) + else: + return GuardrailViolationResponse() +``` + +**Streaming:** +```python +context_result = await llm.check_context(query, history) # Complete answer + +if context_result.can_answer_from_context: + # Validate complete answer FIRST + output_check = await guardrails_adapter.check_output_async(context_result.answer) + if not output_check.allowed: + yield format_sse(chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + + # Validated - stream to client token-by-token + for token in split_into_tokens(context_result.answer, chunk_size=5): + yield format_sse(chatId, token) + await asyncio.sleep(0.01) + yield format_sse(chatId, "END") +``` + +### Key Differences + +**RAG Workflow:** +- **Real-time validation**: LLM generates → NeMo validates chunks → Stream to client +- **Buffered approach**: Tokens buffered in chunks of 200 characters +- **Bi-directional**: Generator feeding into NeMo, NeMo yielding validated chunks +- **Cost**: Inline (no separate validation call) + +**Service/Context Workflows:** +- **Pre-validation**: Get complete response → Validate → Stream to client +- **Complete response**: Already have full text before streaming starts +- **Uni-directional**: Simply chunk and send validated response +- **Cost**: Separate validation call tracked in `costs_metric["output_guardrails"]` +- **UX Consistency**: Simulates streaming to match RAG workflow behavior + +### Why Different Approaches? + +1. **RAG**: LLM streaming is inherently token-by-token, so NeMo can validate in real-time +2. **Service**: External API returns complete response, no streaming generation occurs +3. **Context**: LLM returns structured JSON with complete answer, not streaming + +### Common Pattern: Validation-First + +All three workflows share the **validation-first principle**: +- Content is validated BEFORE reaching the user +- Blocked content never sent to client +- Consistent safety guarantees across all workflows +- Streaming provides smooth UX even with complete responses (Service/Context) + +--- + +## 6. Layer 4: OOD (Out of Domain) Response + +### 6.1 Trigger Conditions + +- No service detected (Layer 1 failed) +- No context match (Layer 2 failed) +- No relevant knowledge chunks (Layer 3 failed) + +### 6.2 Response Generation + +**Return localized OOD message:** +```python +return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=True, # Flag as out of scope + inputGuardFailed=False, + content=get_localized_message(OUT_OF_SCOPE_MESSAGES, detected_language) +) +``` + +**Existing Constants (Reuse):** +```python +# From: src/llm_orchestrator_config/llm_ochestrator_constants.py +OUT_OF_SCOPE_MESSAGES = { + "et": "Vabandust, ma ei suuda sellele küsimusele vastata...", + "en": "I apologize, but I cannot answer this question..." +} +``` + +--- + +## 7. Data Schemas + +### 7.1 Database Schema + +**Table: `services`** + +```sql +-- Location: DSL/Liquibase/changelog/rag-search-script-v6-services.sql + +-- Custom ENUM types +CREATE TYPE ruuter_request_type AS ENUM ('GET', 'POST'); +CREATE TYPE service_state AS ENUM ('active', 'inactive', 'draft'); + +CREATE TABLE public.services ( + -- Primary key + id BIGINT PRIMARY KEY, + + -- Basic service information + name TEXT NOT NULL, -- Service name (e.g., "ExchangeRateService") + description TEXT NOT NULL, -- Human-readable description + service_id TEXT NOT NULL UNIQUE, -- Unique identifier (e.g., "exchange-rate-001") + + -- Service classification + ruuter_type ruuter_request_type DEFAULT 'GET', -- HTTP method: 'GET' or 'POST' + current_state service_state DEFAULT 'draft', -- State: 'active', 'inactive', 'draft' + is_common BOOLEAN NOT NULL DEFAULT FALSE, -- Is this a common/shared service? + deleted BOOLEAN NOT NULL DEFAULT FALSE, -- Soft delete flag + + -- Intent classification data (for LLM) + slot TEXT NOT NULL DEFAULT '', -- Reserved for future use + entities text[] NOT NULL DEFAULT '{}', -- Expected entity names ["entity1", "entity2"] + examples text[] NOT NULL DEFAULT '{}', -- Example queries + + -- Service configuration + structure JSON NOT NULL DEFAULT '{}', -- Service schema/structure + endpoints JSON NOT NULL DEFAULT '[]', -- Endpoint configurations + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance +CREATE UNIQUE INDEX idx_services_service_id ON public.services(service_id); +CREATE INDEX idx_services_active ON public.services(current_state, deleted) + WHERE deleted = FALSE; +CREATE INDEX idx_services_name ON public.services(name); +``` + +**Update Master Changelog:** +```yaml +# Location: DSL/Liquibase/master.yml + +databaseChangeLog: + - include: + file: changelog/rag-search-script-v1-llm-connections.sql + - include: + file: changelog/rag-search-script-v2-user-management.sql + - include: + file: changelog/rag-search-script-v3-configuration.sql + - include: + file: changelog/rag-search-script-v4-authority-data.xml + - include: + file: changelog/rag-search-script-v5-prompt-config.sql + - include: + file: changelog/rag-search-script-v6-services.sql # NEW +``` + +### 7.2 Qdrant Collection Schema + +**Collection Name:** `intent_collection` + +**Configuration:** +```python +{ + "collection_name": "intent_collection", + "vectors_config": { + "size": 3072, # text-embedding-3-large + "distance": "Cosine" + } +} +``` + +**Document Schema:** +```json +{ + "id": "common_service_companies_workforce_taxes", + "name": "Ettevõtte tööjõumaksud", + "description": "Kasutaja soovib infot ettevõtte poolt tasutud tööjõumaksude kohta, näiteks palgamaksud ja sotsiaalmaks.", + "examples": [ + "ettevõtte tasutud tööjõumaksud", + "kui palju maksis ettevõte tööjõumakse", + "firma poolt tasutud tööjõumaksud" + ], + "entities": ["company_name"], + "text_for_embedding": "Kasutaja soovib infot ettevõtte poolt tasutud tööjõumaksude kohta, näiteks palgamaksud ja sotsiaalmaks.\nettevõtte tasutud tööjõumaksud\nkui palju maksis ettevõte tööjõumakse\nfirma poolt tasutud tööjõumaksud", + + "service_id": "common_service_companies_workforce_taxes", + "ruuter_type": "POST", + "current_state": "active" +} +``` + +**Field Mapping:** +| Qdrant Field | Source | Purpose | +|--------------|--------|---------| +| `id` | `services.service_id` | Unique identifier | +| `name` | `services.name` | Service display name | +| `description` | `services.description` | Service description | +| `examples` | `services.examples` | Example queries | +| `entities` | `services.entities` | Expected parameters | +| `text_for_embedding` | Computed | Concatenated text for vector embedding | +| `service_id` | `services.service_id` | Link to database record | +| `ruuter_type` | `services.ruuter_type` | HTTP method | +| `current_state` | `services.current_state` | Service status | + +**Embedding Text Construction:** +```python +def construct_embedding_text(service: ServiceRecord) -> str: + """ + Construct text for embedding from service data. + Format: description + examples (newline-separated) + """ + parts = [service.description] + parts.extend(service.examples) + return "\n".join(parts) +``` + +### 7.3 Database → Qdrant Synchronization + +**Trigger Mechanism:** +```sql +-- PostgreSQL NOTIFY/LISTEN pattern or polling +CREATE OR REPLACE FUNCTION notify_service_change() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + PERFORM pg_notify( + 'service_sync', + json_build_object( + 'action', TG_OP, + 'service_id', NEW.service_id, + 'current_state', NEW.current_state + )::text + ); + ELSIF TG_OP = 'DELETE' THEN + PERFORM pg_notify( + 'service_sync', + json_build_object( + 'action', 'DELETE', + 'service_id', OLD.service_id + )::text + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER service_sync_trigger +AFTER INSERT OR UPDATE OR DELETE ON services +FOR EACH ROW EXECUTE FUNCTION notify_service_change(); +``` + +**Sync Service:** +```python +# Location: src/tool_classifier/intent_sync_service.py + +class IntentCollectionSyncService: + """Synchronizes services table with Qdrant intent_collection.""" + + async def handle_service_change(self, event: Dict): + action = event['action'] + service_id = event['service_id'] + + if action in ['INSERT', 'UPDATE']: + # Fetch service from database + service = await self.db.fetch_service(service_id) + + # Generate embedding + embedding_text = self.construct_embedding_text(service) + embedding_vector = await self.embed(embedding_text) + + # Upsert to Qdrant + await self.qdrant_client.upsert( + collection_name="intent_collection", + points=[{ + "id": service.service_id, + "vector": embedding_vector, + "payload": { + "name": service.name, + "description": service.description, + "examples": service.examples, + "entities": service.entities, + "text_for_embedding": embedding_text, + "service_id": service.service_id, + "ruuter_type": service.ruuter_type, + "current_state": service.current_state + } + }] + ) + + elif action == 'DELETE': + await self.qdrant_client.delete( + collection_name="intent_collection", + points_selector={"points": [service_id]} + ) +``` + +--- + +## 8. Error Messages & Constants + +### 8.1 New Error Messages + +**Location:** `src/llm_orchestrator_config/llm_ochestrator_constants.py` + +```python +# Service Workflow Errors +SERVICE_NOT_FOUND_MESSAGES = { + "et": "Vabandust, ma ei leidnud sobivat teenust teie päringu jaoks.", + "en": "Sorry, I couldn't find a matching service for your request.", +} + +SERVICE_VALIDATION_FAILED_MESSAGES = { + "et": "Teenus ei ole hetkel saadaval.", + "en": "The requested service is currently unavailable.", +} + +SERVICE_TIMEOUT_ERROR_MESSAGES = { + "et": "Teenuse vastus võttis liiga kaua aega. Palun proovige hiljem uuesti.", + "en": "The service took too long to respond. Please try again later.", +} + +SERVICE_EXECUTION_ERROR_MESSAGES = { + "et": "Teenuse kutsumine ebaõnnestus. Palun proovige hiljem uuesti.", + "en": "Service execution failed. Please try again later.", +} + +ENTITY_EXTRACTION_FAILED_MESSAGES = { + "et": "Ma ei suutnud teie päringust vajalikku infot tuvastada.", + "en": "I couldn't extract the required information from your query.", +} + +# Context Workflow Errors +INSUFFICIENT_CONTEXT_MESSAGES = { + "et": "Ma ei leia vastust meie eelmisest vestlusest. Kas saate täpsustada?", + "en": "I can't find the answer in our previous conversation. Can you clarify?", +} + +NO_CONTEXT_AVAILABLE_MESSAGES = { + "et": "Mul pole piisavalt konteksti teie küsimusele vastamiseks.", + "en": "I don't have enough context to answer your question.", +} + +# Greeting Responses +GREETING_HELLO_MESSAGES = { + "et": "Tere! Kuidas saan teid aidata?", + "en": "Hello! How can I help you?", +} + +GREETING_GOODBYE_MESSAGES = { + "et": "Head aega! Kui vajate abi, olen siin.", + "en": "Goodbye! If you need help, I'm here.", +} + +GREETING_THANKS_MESSAGES = { + "et": "Pole tänu väärt! Kas saan veel kuidagi aidata?", + "en": "You're welcome! Can I help you with anything else?", +} + +GREETING_CASUAL_MESSAGES = { + "et": "Tere! Mida te soovite teada?", + "en": "Hi there! What would you like to know?", +} +``` + +**Helper Function for Default Greeting Responses:** + +```python +def get_default_greeting_response(greeting_type: str, language: str) -> str: + """ + Get default greeting response based on type and language. + + Args: + greeting_type: Type of greeting ('hello', 'goodbye', 'thanks', 'casual') + language: Language code ('et', 'en') + + Returns: + Localized greeting response + """ + greeting_map = { + "hello": GREETING_HELLO_MESSAGES, + "goodbye": GREETING_GOODBYE_MESSAGES, + "thanks": GREETING_THANKS_MESSAGES, + "casual": GREETING_CASUAL_MESSAGES + } + + messages = greeting_map.get(greeting_type, GREETING_HELLO_MESSAGES) + return messages.get(language, messages["en"]) +``` + +### 8.2 Reused Constants + +```python +# Already defined - reuse for consistency +OUT_OF_SCOPE_MESSAGE +TECHNICAL_ISSUE_MESSAGE +INPUT_GUARDRAIL_VIOLATION_MESSAGE +OUTPUT_GUARDRAIL_VIOLATION_MESSAGE +``` + +--- + +## 9. API Integration + +### 9.1 Entry Points (No Changes) + +The tool classifier is transparent to API consumers. All existing endpoints continue to work: + +**Non-Streaming:** +```http +POST /orchestrate +Content-Type: application/json + +{ + "chatId": "session-123", + "message": "What is the EUR to USD exchange rate?", + "authorId": "user-456", + "conversationHistory": [], + "url": "https://example.com", + "environment": "production", + "connection_id": "conn-789" +} +``` + +**Streaming:** +```http +POST /orchestrate/stream +Content-Type: application/json + +(Same request body as /orchestrate) +``` + +**Testing:** +```http +POST /orchestrate/test +Content-Type: application/json + +{ + "message": "Convert 100 EUR to USD", + "environment": "testing", + "connectionId": 1 +} +``` + +### 9.2 Response Format (No Changes) + +**Success Response:** +```json +{ + "chatId": "session-123", + "llmServiceActive": true, + "questionOutOfLLMScope": false, + "inputGuardFailed": false, + "content": "The current EUR to USD exchange rate is 1.08." +} +``` + +**Service Workflow Response:** +```json +{ + "chatId": "session-123", + "llmServiceActive": true, + "questionOutOfLLMScope": false, + "inputGuardFailed": false, + "content": "Based on the ExchangeRateService: EUR/USD = 1.0850" +} +``` + +The response format remains unchanged. The workflow selection is internal and transparent to the API consumer. + +--- + +## 10. Implementation Considerations + +### 10.1 Performance Optimization + +**Service Discovery Caching:** +```python +# Cache active service count for 5 minutes +@cached(ttl=300) +async def get_active_service_count() -> int: + return await db.count_active_services() +``` + +**Intent Collection Warm-up:** +```python +# Pre-load intent collection on startup +async def warmup_intent_collection(): + """Ensure intent_collection is ready before processing requests.""" + collection_info = await qdrant_client.get_collection("intent_collection") + logger.info(f"Intent collection ready: {collection_info.points_count} services") +``` + +### 10.2 Monitoring & Analytics + +**Tool Classifier Decisions Table:** +```sql +-- Track classifier decisions for analytics +CREATE TABLE tool_classifier_decisions ( + id SERIAL PRIMARY KEY, + chat_id TEXT NOT NULL, + author_id TEXT, + user_query TEXT NOT NULL, + detected_workflow VARCHAR(20) NOT NULL, -- 'service', 'context', 'rag', 'ood' + classifier_confidence NUMERIC(5,4), + service_id VARCHAR(100), -- If service workflow + execution_time_ms INTEGER, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_classifier_decisions_workflow + ON tool_classifier_decisions(detected_workflow); +``` + +### 10.3 Cost Tracking + +**Add tracking for new LLM calls:** +# Service workflow - intent detection +costs_metric["intent_detection"] = { + "total_prompt_tokens": usage.prompt_tokens, + "total_completion_tokens": usage.completion_tokens, + "total_cost": calculate_cost(usage) +} + +# Context workflow - context availability check +costs_metric["context_check +costs_metric["intent_detection"] = { + "total_prompt_tokens": usage.prompt_tokens, + "total_completion_tokens": usage.completion_tokens, + "total_cost": calculate_cost(usage) +} +``` + +### 10.4 Guardrails Strategy + +**Output Guardrails Application:** +```python +# Apply output guardrails to ALL workflows for consistency +WORKFLOWS_WITH_OUTPUT_GUARDRAILS = [ + WorkflowType.SERVICE, # Check service responses (may contain PII/sensitive data) + WorkflowType.CONTEXT, # Check context-based responses (conversation history may have PII) + WorkflowType.RAG # Existing behavior (knowledge base responses) +] + +# OOD responses skip guardrails (fixed message) +WORKFLOWS_WITHOUT_OUTPUT_GUARDRAILS = [ + WorkflowType.OOD +] +``` + +**Validation-First Approach:** + +All workflows use the **validation-first** approach where content is validated BEFORE streaming to the client: + +1. **RAG Workflow** (existing): + - LLM generates response via streaming + - NeMo buffers tokens (chunk_size=200) + - Each buffer validated before yielding + - Uses `stream_with_guardrails()` method + +2. **Service Workflow** (new): + - External service returns complete response + - Apply output guardrails validation + - Stream validated response token-by-token to client + - Consistent UX with RAG workflow + +3. **Context Workflow** (new): + - LLM returns complete answer from history + - Apply output guardrails validation + - Stream validated response token-by-token to client + - Consistent UX with RAG workflow + +**Streaming + Output Guardrails Integration:** + +```python +# For Service and Context workflows +async def stream_validated_response( + response_text: str, + guardrails_adapter: NeMoRailsAdapter, + request: OrchestrationRequest, + costs_metric: Dict +) -> AsyncIterator[str]: + """ + Apply output guardrails and stream validated response. + + Flow: + 1. Validate complete response with guardrails + 2. If allowed: Stream token-by-token to client + 3. If blocked: Send guardrail violation message + """ + # Check output guardrails (non-streaming validation) + output_check = await guardrails_adapter.check_output_async(response_text) + + # Track costs + costs_metric["output_guardrails"] = output_check.usage + + if not output_check.allowed: + logger.warning(f"[{request.chatId}] Output blocked by guardrails") + # Send violation message + yield format_sse(request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE) + yield format_sse(request.chatId, "END") + return + + # Response validated - stream to client + logger.info(f"[{request.chatId}] Streaming validated response") + for token in split_into_tokens(response_text): + yield format_sse(request.chatId, token) + await asyncio.sleep(0.01) # Maintain streaming pace + + yield format_sse(request.chatId, "END") +``` + +**Utility Function for Token Streaming:** +```python +def split_into_tokens(text: str, chunk_size: int = 5) -> List[str]: + """ + Split text into token-like chunks for streaming simulation. + + Used by Service and Context workflows to provide streaming UX + even though the complete response is already available. + + Args: + text: Complete response text + chunk_size: Number of words per chunk + + Returns: + List of text chunks + """ + words = text.split() + tokens = [] + for i in range(0, len(words), chunk_size): + chunk = " ".join(words[i:i + chunk_size]) + tokens.append(chunk + " " if i + chunk_size < len(words) else chunk) + return tokens +``` + +### 10.5 Streaming Implementation Summary + +| Aspect | RAG Workflow | Service Workflow | Context Workflow | +|--------|--------------|------------------|------------------| +| **Response Type** | Streaming (token-by-token) | Complete (all at once) | Complete (all at once) | +| **Validation Timing** | Real-time (buffered chunks) | Pre-validation | Pre-validation | +| **Guardrail Method** | `stream_with_guardrails()` | `check_output_async()` | `check_output_async()` | +| **Streaming Reason** | Natural (LLM streams) | UX consistency | UX consistency | +| **Token Buffering** | NeMo 200-char chunks | Manual 5-word chunks | Manual 5-word chunks | +| **Cost Tracking** | Inline (timing = 0.0) | Separate call | Separate call | +| **Blocked Handling** | Stop mid-stream | Pre-check, don't stream | Pre-check, don't stream | +| **Client Experience** | Progressive reveal | Progressive reveal | Progressive reveal | + +**Implementation Status:** +- RAG streaming + guardrails: **Already implemented** (production-ready) +- Service streaming + guardrails: **To be implemented** (spec complete) +- Context streaming + guardrails: **To be implemented** (spec complete) + +--- + +## 11. Testing Strategy + +### 11.1 Unit Tests +async def test_context_detection_with_llm(): + query = "What did you say earlier?" + history = [ + ConversationItem(authorRole="bot", message="The EUR to USD rate is 1.08"), + ConversationItem(authorRole="user", message="Thanks") + ] + result = await context_analyzer.check_context_availability(query, history) + assert result.can_answer_from_context == True + assert "1.08" in result.answer + +async def test_context_detection_no_reference(): + query = "What are digital signatures?" + history = [ConversationItem(message="The rate is 1.08", ...)] + result = await context_analyzer.check_context_availability(query, history) + assert result.can_answer_from_context == False + +def test_rag_fallback(): + query = "What are digital signatures?" + result = classifier.classify(query, []) + assert result.workflow == WorkflowType.RAG + +async def test_context_streaming(): + """Test that context workflow supports streaming.""" + query = "What was the rate?" + history = [ConversationItem(message="The rate is 1.08", ...)] + + tokens = [] + async for token in context_workflow.execute_streaming(query, history): + tokens.append(token) + + assert len(tokens) > 0 + assert tokens[-1] == "END" + query = "What did you say earlier?" + history = [ConversationItem(message="The rate is 1.08", ...)] + result = classifier.classify(query, history) + assert result.workflow == WorkflowType.CONTEXT + +def test_rag_fallback(): + query = "What are digital signatures?" + result = classifier.classify(query, []) + assert result.workflow == WorkflowType.RAG +``` + +### 11.2 Integration Tests + +```python +# tests/integration_tests/test_service_workflow.py +async def test_full_service_workflow(): + request = OrchestrationRequest( + message="Convert 100 EUR to USD", + chatId="test-123", + ... + ) + response = await orchestration_service.process_orchestration_request(request) + assert response.llmServiceActive == True + assert "exchange rate" in response.content.lower() +``` + +### 11.3 Load `ContextAnalyzer` with LLM-based context checking +- Create context check prompt template with structured output +- Implement `ContextWorkflowExecutor` with streaming support +- Add conversation history formatting utilities +- Integration tests for context workflow (streaming + non-streaming) +- Cost tracking for context check LLM calls>50 services +locust -f tests/load/test_classifier_load.py --users 100 --spawn-rate 10 +``` + +--- + +## 12. Migration Path + +### 12.1 Phase 1: +- Create database migration for `services` table +- Create Qdrant `intent_collection` +- Relocate input guardrails before tool classifier +- Define error message constants + +### 12.2 Phase 2: +- Implement `ToolClassifier` with rule-based logic +- Implement workflow routing in `LLMOrchestrationService` +- Add classifier decision logging +- Unit tests for classifier + +### 12.3 Phase 3: Service Workflow +- Implement `ServiceDiscoveryManager` (Qdrant semantic search) +- Implement `IntentEntityExtractor` (LLM-based) +- Implement `ServiceWorkflowExecutor` (validation & triggering) +- Implement `IntentCollectionSyncService` (DB → Qdrant) +- Integration tests for service workflow + +### 12.4 Phase 4: Context Workflow +- ✅ ImpleHECK_TEMPERATURE=0.0 # Deterministic for classification +CONTEXT_CHECK_MAX_TOKENS=300tection +- Implement conversation history semantic search +- Implement `ContextWorkflowExecutor` +- Integration tests for context workflow + +### 12.5 Phase 5: Finalization +- Extend output guardrails to service & context workflows +- Implement fallback chain (service → context → rag → ood) +- Add comprehensive error handling +- Performance optimization (caching, async) +- End-to-end testing +- Production deployment + +--- + +## 13. Configuration + +### 13.1 Environment Variables + +```bash +# Service Workflow Configuration +RUUTER_BASE_URL=http://ruuter:8086 +SERVICE_DISCOVERY_TIMEOUT=2 # seconds +SERVICE_CALL_TIMEOUT=10 # seconds +MAX_SERVICES_FOR_LLM_CONTEXT=50 + +# Qdrant Configuration +QDRANT_INTENT_COLLECTION=intent_collection +INTENT_SEARCH_TOP_K=20 +INTENT_SEARCH_THRESHOLD=0.5 + +# Context Workflow Configuration +CONTEXT_WINDOW_SIZE=10 +CONTEXT_CONFIDENCE_THRESHOLD=0.7 +``` + +### 13.2 Feature Flags + +```python +# src/llm_orchestrator_config/feature_flags.py + +class FeatureFlags: + # Enable/disable tool classifier (rollback switch) + TOOL_CLASSIFIER_ENABLED = os.getenv("TOOL_CLASSIFIER_ENABLED", "true").lower() == "true" + + # Enable/disable specific workflows + SERVICE_WORKFLOW_ENABLED = os.getenv("SERVICE_WORKFLOW_ENABLED", "true").lower() == "true" + CONTEXT_WORKFLOW_ENABLED = os.getenv("CONTEXT_WORKFLOW_ENABLED", "true").lower() == "true" + + # Fallback to RAG if tool classifier fails + FALLBACK_TO_RAG_ON_ERROR = True +``` + +--- + +## 14. Rollback Strategy + +### 14.1 Graceful Degradation + +```python +def process_orchestration_request(self, request: OrchestrationRequest): + """Process with tool classifier or fallback to RAG.""" + + if not FeatureFlags.TOOL_CLASSIFIER_ENABLED: + # Fallback: Use existing RAG-only pipeline + logger.info("Tool classifier disabled - using RAG pipeline") + return self._execute_rag_workflow(request, None) + + try: + # New: Tool classifier routing + classifier_result = self.tool_classifier.classify(...) + return self._route_to_workflow(request, classifier_result) + + except Exception as e: + logger.error(f"Tool classifier failed: {e}") + if FeatureFlags.FALLBACK_TO_RAG_ON_ERROR: + logger.info("Falling back to RAG workflow") + return self._execute_rag_workflow(request, None) + raise +``` + +## 15. Success Metrics + +### 15.1 Performance Metrics + +| Metric | Target | Measurement | +|--------|--------|-------------| +| Tool Classifier Latency | < 200ms | p95 response time | +| Service Discovery (>50 services) | < 500ms | Qdrant search + LLM intent | +| Service Call Success Rate | > 95% | Successful service executions | +| Context Match Accuracy | > 80% | Correct context-based responses | +| End-to-End Latency | < 3s | Request to response | + +### 15.2 Quality Metrics + +| Metric | Target | Measurement | +|--------|--------|-------------| +| Workflow Classification Accuracy | > 90% | Manual evaluation sample | +| Service Intent Accuracy | > 85% | Correct service selection | +| Entity Extraction Accuracy | > 90% | Correct entity values | +| False Positive Rate (Service) | < 5% | Incorrect service routing | +| User Satisfaction | > 4.0/5.0 | User feedback surveys | + +--- diff --git a/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md new file mode 100644 index 00000000..38ce1f53 --- /dev/null +++ b/docs/TOOL_CLASSIFIER_SKELETON_USAGE.md @@ -0,0 +1,542 @@ +# Tool Classifier Skeleton - Usage Guide + +**Version**: 1.0 +**Date**: February 17, 2026 +**Status**: Skeleton Implementation + +--- + +## Overview + +This skeleton implements the **framework** for a multi-workflow routing system based on the [TOOL_CLASSIFIER_EXTENSION_SPEC.md](./TOOL_CLASSIFIER_EXTENSION_SPEC.md) specification. + +### Current Status + + **Implemented (Skeleton)**: +- Abstract base classes and interfaces +- Workflow executor skeletons (Service, Context, RAG, OOD) +- Tool classifier with classification and routing logic +- Feature flags for safe deployment +- Integration into LLMOrchestrationService + + **Not Implemented (Separate Tasks)**: +- Service discovery logic (Layer 1) +- Context analysis logic (Layer 2) +- Actual LLM calls in workflows +- Output guardrails integration for new workflows +- Database schema changes + +### Current Behavior + +When `TOOL_CLASSIFIER_ENABLED=false` (default): +- System works exactly as before (RAG-only pipeline) +- No changes to existing functionality + +When `TOOL_CLASSIFIER_ENABLED=true`: +- Classifier routes queries (currently always to RAG) +- Service and Context workflows return `None` (fallback to RAG) +- RAG workflow wraps existing pipeline +- All queries ultimately handled by RAG + +--- + +## Architecture + +### Layer-Wise Workflow Routing + +``` +User Query + ↓ +Input Guardrails + ↓ +Tool Classifier + ↓ +┌────────────────┐ +│ Classification │ +└────────┬───────┘ + ↓ + ┌─────┴──────┐ + │ Routing │ + └─────┬──────┘ + ↓ + ╔═══════════════════════════════════╗ + ║ Layer 1: Service Workflow ║ → (returns None - not implemented) + ╚═══════════════════════════════════╝ + ↓ (fallback) + ╔═══════════════════════════════════╗ + ║ Layer 2: Context Workflow ║ → (returns None - not implemented) + ╚═══════════════════════════════════╝ + ↓ (fallback) + ╔═══════════════════════════════════╗ + ║ Layer 3: RAG Workflow ║ → Handles query (existing pipeline) + ╚═══════════════════════════════════╝ + ↓ + Response to User +``` + +### Component Structure + +``` +src/tool_classifier/ +├── __init__.py # Module exports +├── enums.py # WorkflowType enum +├── models.py # ClassificationResult models +├── base_workflow.py # Abstract BaseWorkflow class +├── classifier.py # Main ToolClassifier +└── workflows/ + ├── __init__.py + ├── service_workflow.py # Layer 1 (skeleton) + ├── context_workflow.py # Layer 2 (skeleton) + ├── rag_workflow.py # Layer 3 (complete) + └── ood_workflow.py # Layer 4 (skeleton) +``` + +### Abstract Base Class Pattern + +The system uses **BaseWorkflow** as an abstract base class to ensure all workflows follow the same contract. + +#### How It Works + +1. **BaseWorkflow defines the contract**: + - Every workflow MUST implement two methods: `execute_async()` and `execute_streaming()` + - Both methods return `Optional[...]` to support the fallback pattern (return `None` → next layer) + - Python's `@abstractmethod` decorator enforces this at instantiation time + +2. **All workflows inherit from BaseWorkflow**: + - ServiceWorkflowExecutor extends BaseWorkflow → implements both methods + - ContextWorkflowExecutor extends BaseWorkflow → implements both methods + - RAGWorkflowExecutor extends BaseWorkflow → implements both methods + - OODWorkflowExecutor extends BaseWorkflow → implements both methods + +3. **Classifier treats all workflows uniformly**: + - The `ToolClassifier.route_to_workflow()` method doesn't need to know which specific workflow it's calling + - It just calls `workflow.execute_async()` or `workflow.execute_streaming()` + - This is **polymorphism** - same interface, different behavior + +4. **Benefits**: + - **Consistency**: All workflows have the same interface + - **Enforcement**: Can't create a workflow without implementing required methods + - **Flexibility**: Easy to add new workflows - just extend BaseWorkflow + - **Testability**: Each workflow can be tested independently + - **Fallback Pattern**: `Optional` return type enables layer chaining + +#### Example Flow + +``` +ToolClassifier needs to execute a workflow + ↓ +Gets workflow object (could be Service, Context, RAG, or OOD) + ↓ +Calls workflow.execute_async(request, context) + ↓ +BaseWorkflow contract guarantees this method exists + ↓ +Each workflow implements its own logic + ↓ +Returns OrchestrationResponse or None (fallback to next layer) +``` + +The abstract class is like a **blueprint** that says: "Any workflow in this system MUST be able to do these two things: execute normally and execute with streaming. I don't care *how* you do it, but you must provide these capabilities." + +--- + +## Feature Flags + +### Environment Variables + +```bash +# Master switch (default: false for safe deployment) +TOOL_CLASSIFIER_ENABLED=false + +# Individual workflow toggles (only apply when classifier enabled) +SERVICE_WORKFLOW_ENABLED=true +CONTEXT_WORKFLOW_ENABLED=true +``` + +### Configuration Class + +```python +from src.llm_orchestrator_config.feature_flags import FeatureFlags + +# Check if classifier is enabled +if FeatureFlags.TOOL_CLASSIFIER_ENABLED: + # Use tool classifier + pass + +# Check specific workflow +if FeatureFlags.is_workflow_enabled("service"): + # Service workflow logic + pass + +# Log current configuration +FeatureFlags.log_configuration() +``` + +--- + +## How It Works + +### 1. Non-Streaming Endpoint (`/orchestrate`) + +#### Current Flow (TOOL_CLASSIFIER_ENABLED=false) + +```python +POST /orchestrate + ↓ +LLMOrchestrationService.process_orchestration_request() + ↓ +Initialize components (LLM, guardrails, retriever, generator) + ↓ +Execute RAG pipeline + ↓ +Return OrchestrationResponse +``` + +#### With Classifier (TOOL_CLASSIFIER_ENABLED=true) + +```python +POST /orchestrate + ↓ +LLMOrchestrationService.process_orchestration_request() + ↓ +Initialize components + ↓ +Tool Classifier Integration: + 1. Initialize ToolClassifier (if first time) + 2. Classify query → ClassificationResult + - Currently always returns: WorkflowType.RAG + 3. Route to workflow: + - ServiceWorkflow.execute_async() → returns None + - ContextWorkflow.execute_async() → returns None + - RAGWorkflow.execute_async() → returns response + ↓ +Return OrchestrationResponse +``` + +### 2. Streaming Endpoint (`/orchestrate/stream`) + +#### Current Flow (TOOL_CLASSIFIER_ENABLED=false) + +```python +POST /orchestrate/stream + ↓ +LLMOrchestrationService.stream_orchestration_response() + ↓ +Initialize components + ↓ +Check input guardrails + ↓ +Refine prompt → Retrieve chunks → Stream through NeMo + ↓ +Yield SSE strings +``` + +#### With Classifier (TOOL_CLASSIFIER_ENABLED=true) + +```python +POST /orchestrate/stream + ↓ +LLMOrchestrationService.stream_orchestration_response() + ↓ +Initialize components + ↓ +Check input guardrails + ↓ +Tool Classifier Integration: + 1. Initialize ToolClassifier (if first time) + 2. Classify query → ClassificationResult + 3. Route to streaming workflow: + - ServiceWorkflow.execute_streaming() → returns None + - ContextWorkflow.execute_streaming() → returns None + - RAGWorkflow.execute_streaming() → yields SSE + ↓ +Yield SSE strings +``` + +### 3. Test Endpoint (`/orchestrate/test`) + +Works identically to `/orchestrate`: +- Converts `TestOrchestrationRequest` → `OrchestrationRequest` +- Routes through classifier (if enabled) +- Converts response back to `TestOrchestrationResponse` + +--- + +## Code Examples + +### Using the Classification System + +```python +from src.tool_classifier import ToolClassifier, WorkflowType, ClassificationResult + +# Initialize classifier +classifier = ToolClassifier( + llm_manager=llm_manager, + orchestration_service=service, +) + +# Classify a query +classification = await classifier.classify( + query="Hello, how are you?", + conversation_history=[], + language="en", +) + +# Check result +print(classification.workflow) # WorkflowType.RAG (in skeleton) +print(classification.confidence) # 1.0 +print(classification.reasoning) # "Default to RAG workflow..." + +# Route to workflow +response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, +) +``` + +### Implementing a Workflow (Example) + +```python +from src.tool_classifier.base_workflow import BaseWorkflow +from models.request_models import OrchestrationRequest, OrchestrationResponse + +class MyCustomWorkflow(BaseWorkflow): + """Custom workflow implementation.""" + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """Handle query in non-streaming mode.""" + + # Check if this workflow can handle the query + can_handle = await self._check_if_applicable(request.message) + + if not can_handle: + # Return None to trigger fallback to next layer + return None + + # Execute workflow logic + result = await self._process_query(request.message) + + # Validate with output guardrails (TODO) + # is_safe = await guardrails.check_output_async(result) + # if not is_safe: + # return None or violation_response + + # Return response + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=result, + ) + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[AsyncIterator[str]]: + """Handle query in streaming mode.""" + + # Check if applicable + can_handle = await self._check_if_applicable(request.message) + + if not can_handle: + return None # Fallback + + # Get complete result + result = await self._process_query(request.message) + + # Validate with guardrails (TODO) + # is_safe = await guardrails.check_output_async(result) + # if not is_safe: + # yield format_sse(chatId, VIOLATION_MESSAGE) + # yield format_sse(chatId, "END") + # return + + # Stream result token-by-token + async def stream_result(): + for chunk in self._split_into_tokens(result): + yield self.format_sse(request.chatId, chunk) + await asyncio.sleep(0.01) + yield self.format_sse(request.chatId, "END") + + return stream_result() +``` + +--- + +## Deployment Strategy + +### Phase 1: Testing (Current State) + +```bash +# Keep classifier disabled +TOOL_CLASSIFIER_ENABLED=false +``` + +**Result**: System works exactly as before (RAG-only) + +### Phase 2: Enable Classifier (No Impact) + +```bash +# Enable classifier (but workflows not implemented) +TOOL_CLASSIFIER_ENABLED=true +SERVICE_WORKFLOW_ENABLED=true +CONTEXT_WORKFLOW_ENABLED=true +``` + +**Result**: +- Classifier runs but always routes to RAG +- Service/Context return `None` → fallback to RAG +- Functionally identical to Phase 1 +- Validates integration works + +### Phase 3: Implement Service Workflow + +1. Implement service discovery logic (separate task) +2. Deploy with `SERVICE_WORKFLOW_ENABLED=true` +3. Monitor service routing behavior +4. Rollback flag if issues occur + +### Phase 4: Implement Context Workflow + +1. Implement context analysis logic (separate task) +2. Deploy with `CONTEXT_WORKFLOW_ENABLED=true` +3. Monitor greeting/context detection +4. Rollback flag if issues occur + +### Phase 5: Production + +All workflows operational, full layer-wise routing active. + +--- + +## Extending the System + +### Adding a New Workflow + +1. **Create Workflow Executor**: + +```python +# src/tool_classifier/workflows/custom_workflow.py + +from src.tool_classifier.base_workflow import BaseWorkflow + +class CustomWorkflowExecutor(BaseWorkflow): + """Your custom workflow.""" + + async def execute_async(self, request, context): + # Implement logic + pass + + async def execute_streaming(self, request, context): + # Implement streaming logic + pass +``` + +2. **Register in Classifier**: + +```python +# src/tool_classifier/enums.py + +class WorkflowType(Enum): + SERVICE = "service" + CONTEXT = "context" + RAG = "rag" + CUSTOM = "custom" # Add new type + OOD = "ood" + +# Update layer order +WORKFLOW_LAYER_ORDER = [ + WorkflowType.SERVICE, + WorkflowType.CONTEXT, + WorkflowType.CUSTOM, # Add to chain + WorkflowType.RAG, + WorkflowType.OOD, +] +``` + +3. **Initialize in ToolClassifier**: + +```python +# src/tool_classifier/classifier.py + +def __init__(self, ...): + # ... existing workflows ... + self.custom_workflow = CustomWorkflowExecutor(...) +``` + +4. **Add Feature Flag**: + +```python +# src/llm_orchestrator_config/feature_flags.py + +CUSTOM_WORKFLOW_ENABLED = ( + os.getenv("CUSTOM_WORKFLOW_ENABLED", "true").lower() == "true" +) +``` + +--- + +## Key Concepts + +### 1. None Return Pattern + +Workflows return `None` when they cannot handle a query: + +```python +if not can_handle: + return None # Triggers fallback to next layer +``` + +This enables the fallback chain: Service → Context → RAG → OOD + +### 2. Validation-First Streaming + +For Service and Context workflows (complete responses): + +```python +# 1. Get complete response +response = await call_service(...) + +# 2. Validate BEFORE streaming +is_safe = await guardrails.check_output_async(response) + +if not is_safe: + yield format_sse(chatId, VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + +# 3. Stream validated response +for chunk in split_into_tokens(response): + yield format_sse(chatId, chunk) +yield format_sse(chatId, "END") +``` + +### 3. Two Execution Methods + +Every workflow implements both: +- `execute_async()` → For `/orchestrate` (returns complete response) +- `execute_streaming()` → For `/orchestrate/stream` (yields SSE strings) + +--- + +## Summary + +This skeleton provides: + + **Complete framework** for multi-workflow routing + **Safe deployment** with feature flags + **Extensible architecture** using OOP patterns + **Backward compatibility** (disabled by default) + **Clear contracts** via abstract base classes + **Documentation** for implementation tasks + +The system is ready for workflow implementation in separate, independent tasks. + +--- diff --git a/env.example b/env.example index 105b9f8e..46f5de0f 100644 --- a/env.example +++ b/env.example @@ -16,9 +16,9 @@ GF_USERS_ALLOW_SIGN_UP=false PORT=3000 POSTGRES_USER=postgres POSTGRES_PASSWORD=dbadmin -POSTGRES_DB=rag-search-langfuse +POSTGRES_DB=rag-search NEXTAUTH_URL=http://localhost:3005 -DATABASE_URL=postgresql://postgres:dbadmin@rag_search_db:5432/rag-search +DATABASE_URL=postgresql://postgres:dbadmin@rag-search-db:5432/langfuse-db SALT=changeme ENCRYPTION_KEY=changeme NEXTAUTH_SECRET=changeme @@ -62,6 +62,7 @@ REDIS_TLS_ENABLED=false REDIS_TLS_CA=/certs/ca.crt REDIS_TLS_CERT=/certs/redis.crt REDIS_TLS_KEY=/certs/redis.key +REDIS_SESSION_DB=1 EMAIL_FROM_ADDRESS= SMTP_CONNECTION_URL= VAULT_ADDR=http://localhost:8200 diff --git a/generate_presigned_url.py b/generate_presigned_url.py index dcd6301e..61028beb 100644 --- a/generate_presigned_url.py +++ b/generate_presigned_url.py @@ -1,6 +1,7 @@ import boto3 from botocore.client import Config from typing import List, Dict +from loguru import logger # Create S3 client for MinIO s3_client = boto3.client( @@ -14,13 +15,13 @@ # List of files to process files_to_process: List[Dict[str, str]] = [ - {"bucket": "ckb", "key": "ID.ee/ID.ee.zip"}, + {"bucket": "ckb", "key": "ID.ee/ID.zip"}, ] # Generate presigned URLs presigned_urls: List[str] = [] -print("Generating presigned URLs...") +logger.info("Generating presigned URLs...") for file_info in files_to_process: try: url = s3_client.generate_presigned_url( @@ -29,11 +30,11 @@ ExpiresIn=24 * 3600, # 4 hours in seconds ) presigned_urls.append(url) - print(f":white_check_mark: Generated URL for: {file_info['key']}") - print(f" URL: {url}") + logger.success(f"Generated URL for: {file_info['key']}") + logger.info(f" URL: {url}") except Exception as e: - print(f":x: Failed to generate URL for: {file_info['key']}") - print(f" Error: {str(e)}") + logger.error(f"Failed to generate URL for: {file_info['key']}") + logger.error(f" Error: {str(e)}") output_file: str = "minio_presigned_urls.txt" @@ -50,14 +51,14 @@ for i, url in enumerate(presigned_urls, 1): f.write(f"URL {i}:\n{url}\n\n") - print(f"\n:white_check_mark: Presigned URLs saved to: {output_file}") - print(f"Total URLs generated: {len(presigned_urls)}") + logger.success(f"Presigned URLs saved to: {output_file}") + logger.info(f"Total URLs generated: {len(presigned_urls)}") # Display the combined URL string for easy copying if presigned_urls: - print("\nCombined URL string (for signedUrls environment variable):") - print("=" * 60) - print("|||".join(presigned_urls)) + logger.info("Combined URL string (for signedUrls environment variable):") + logger.info("=" * 60) + logger.info("|||".join(presigned_urls)) except Exception as e: - print(f":x: Failed to save URLs to file: {str(e)}") + logger.error(f"Failed to save URLs to file: {str(e)}") diff --git a/grafana-configs/loki_logger.py b/grafana-configs/loki_logger.py index e25b340a..e90dd059 100644 --- a/grafana-configs/loki_logger.py +++ b/grafana-configs/loki_logger.py @@ -17,7 +17,7 @@ class LokiLogger: def __init__( self, loki_url: str = "http://loki:3100", service_name: str = "default" - ): + ) -> None: """ Initialize LokiLogger @@ -32,7 +32,7 @@ def __init__( # Set default timeout for all requests self.timeout = 5 - def _send_to_loki(self, level: str, message: str): + def _send_to_loki(self, level: str, message: str) -> None: """Send log entry directly to Loki API""" try: # Create timestamp in nanoseconds (Loki requirement) @@ -78,16 +78,16 @@ def _send_to_loki(self, level: str, message: str): # Also print to console for immediate feedback timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - print(f"[{timestamp}] {level: <8} | {message}") + print(f"[{timestamp}] {level: <8} | {message}") # noqa: T201 - def info(self, message: str): + def info(self, message: str) -> None: self._send_to_loki("INFO", message) - def error(self, message: str): + def error(self, message: str) -> None: self._send_to_loki("ERROR", message) - def warning(self, message: str): + def warning(self, message: str) -> None: self._send_to_loki("WARNING", message) - def debug(self, message: str): + def debug(self, message: str) -> None: self._send_to_loki("DEBUG", message) diff --git a/kubernetes/CONTAINER_REGISTRY_SETUP.md b/kubernetes/CONTAINER_REGISTRY_SETUP.md new file mode 100644 index 00000000..d88f16b6 --- /dev/null +++ b/kubernetes/CONTAINER_REGISTRY_SETUP.md @@ -0,0 +1,35 @@ +# Container Registry Setup Guide + +This guide explains what components need to push to gcr + +## Overview + +The RAG Module consists of multiple container images that need to be pushed to your container registry. Currently, we use ECR for testing, but you should push images to your own registry before deployment. + + + +## Step 1: Build Container Images + +Build all required images from the repository root: + +### **1.1 GUI (Frontend)** + +```bash +cd GUI +docker build -t rag-module/gui:latest -f Dockerfile.dev . +cd .. +``` + +update the GUI helms values image: repository section with actual image + +### **1.2 LLM Orchestration Service** + +```bash +docker build -t rag-module/llm-orchestration-service:latest -f Dockerfile.llm_orchestration_service . +``` +update the LLM Orchestration Service helms values image: repository section with actual image (there are two places to update in this file) + +### **1.3 Authentication Layer** + + + diff --git a/kubernetes/Chart.lock b/kubernetes/Chart.lock new file mode 100644 index 00000000..47418e56 --- /dev/null +++ b/kubernetes/Chart.lock @@ -0,0 +1,84 @@ +dependencies: +- name: database + repository: file://./charts/database + version: 0.1.0 +- name: TIM-database + repository: file://./charts/TIM-database + version: 0.1.0 +- name: resql + repository: file://./charts/Resql + version: 0.1.0 +- name: ruuter-public + repository: file://./charts/Ruuter-Public + version: 0.1.0 +- name: ruuter-private + repository: file://./charts/Ruuter-Private + version: 0.1.0 +- name: data-mapper + repository: file://./charts/DataMapper + version: 0.1.0 +- name: TIM + repository: file://./charts/TIM + version: 0.1.0 +- name: Authentication-Layer + repository: file://./charts/Authentication-Layer + version: 0.1.0 +- name: CronManager + repository: file://./charts/CronManager + version: 0.1.0 +- name: GUI + repository: file://./charts/GUI + version: 0.1.0 +- name: Loki + repository: file://./charts/Loki + version: 0.1.0 +- name: Grafana + repository: file://./charts/Grafana + version: 0.1.0 +- name: S3-Ferry + repository: file://./charts/S3-Ferry + version: 0.1.0 +- name: minio + repository: file://./charts/minio + version: 0.1.0 +- name: Redis + repository: file://./charts/Redis + version: 0.1.0 +- name: Qdrant + repository: file://./charts/Qdrant + version: 0.1.0 +- name: ClickHouse + repository: file://./charts/ClickHouse + version: 0.1.0 +- name: Langfuse-Web + repository: file://./charts/Langfuse-Web + version: 0.1.0 +- name: Langfuse-Worker + repository: file://./charts/Langfuse-Worker + version: 0.1.0 +- name: Vault + repository: file://./charts/Vault + version: 0.1.0 +- name: Vault-Init + repository: file://./charts/Vault-Init + version: 0.1.0 +- name: Vault-Agent-GUI + repository: file://./charts/Vault-Agent-GUI + version: 0.1.0 +- name: Vault-Agent-Cron + repository: file://./charts/Vault-Agent-Cron + version: 0.1.0 +- name: Vault-Agent-LLM + repository: file://./charts/Vault-Agent-LLM + version: 0.1.0 +- name: LLM-Orchestration-Service + repository: file://./charts/LLM-Orchestration-Service + version: 0.1.0 +- name: Liquibase + repository: file://./charts/Liquibase + version: 0.1.0 +- name: Notifications-Node + repository: file://./charts/Notifications-Node + version: 0.1.0 +digest: sha256:48065436f01fcf7277161638c5fabe6c48afbcb1738e559ed03a921cd6a9d260 +generated: "2026-03-19T15:56:59.0549062+05:30" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml new file mode 100644 index 00000000..eb9a316a --- /dev/null +++ b/kubernetes/Chart.yaml @@ -0,0 +1,116 @@ +apiVersion: v2 +name: rag-module +description: Umbrella chart for RAG Module +version: 0.1.0 +type: application + +dependencies: + - name: database + version: 0.1.0 + repository: "file://./charts/database" + condition: database.enabled + - name: TIM-database + version: 0.1.0 + repository: "file://./charts/TIM-database" + condition: TIM-database.enabled + - name: resql + version: 0.1.0 + repository: "file://./charts/Resql" + condition: resql.enabled + - name: ruuter-public + version: 0.1.0 + repository: "file://./charts/Ruuter-Public" + condition: ruuter-public.enabled + - name: ruuter-private + version: 0.1.0 + repository: "file://./charts/Ruuter-Private" + condition: ruuter-private.enabled + - name: data-mapper + version: 0.1.0 + repository: "file://./charts/DataMapper" + condition: data-mapper.enabled + - name: TIM + version: 0.1.0 + repository: "file://./charts/TIM" + condition: TIM.enabled + - name: Authentication-Layer + version: 0.1.0 + repository: "file://./charts/Authentication-Layer" + condition: Authentication-Layer.enabled + - name: CronManager + version: 0.1.0 + repository: "file://./charts/CronManager" + condition: CronManager.enabled + - name: GUI + version: 0.1.0 + repository: "file://./charts/GUI" + condition: GUI.enabled + - name: Loki + version: 0.1.0 + repository: "file://./charts/Loki" + condition: Loki.enabled + - name: Grafana + version: 0.1.0 + repository: "file://./charts/Grafana" + condition: Grafana.enabled + - name: S3-Ferry + version: 0.1.0 + repository: "file://./charts/S3-Ferry" + condition: S3-Ferry.enabled + - name: minio + version: 0.1.0 + repository: "file://./charts/minio" + condition: minio.enabled + - name: Redis + version: 0.1.0 + repository: "file://./charts/Redis" + condition: Redis.enabled + - name: Qdrant + version: 0.1.0 + repository: "file://./charts/Qdrant" + condition: Qdrant.enabled + - name: ClickHouse + version: 0.1.0 + repository: "file://./charts/ClickHouse" + condition: ClickHouse.enabled + - name: Langfuse-Web + version: 0.1.0 + repository: "file://./charts/Langfuse-Web" + condition: Langfuse-Web.enabled + - name: Langfuse-Worker + version: 0.1.0 + repository: "file://./charts/Langfuse-Worker" + condition: Langfuse-Worker.enabled + - name: Vault + version: 0.1.0 + repository: "file://./charts/Vault" + condition: Vault.enabled + - name: Vault-Init + version: 0.1.0 + repository: "file://./charts/Vault-Init" + condition: Vault-Init.enabled + - name: Vault-Agent-GUI + version: 0.1.0 + repository: "file://./charts/Vault-Agent-GUI" + condition: Vault-Agent-GUI.enabled + - name: Vault-Agent-Cron + version: 0.1.0 + repository: "file://./charts/Vault-Agent-Cron" + condition: Vault-Agent-Cron.enabled + - name: Vault-Agent-LLM + version: 0.1.0 + repository: "file://./charts/Vault-Agent-LLM" + condition: Vault-Agent-LLM.enabled + - name: LLM-Orchestration-Service + version: 0.1.0 + repository: "file://./charts/LLM-Orchestration-Service" + condition: LLM-Orchestration-Service.enabled + - name: Liquibase + version: 0.1.0 + repository: "file://./charts/Liquibase" + condition: Liquibase.enabled + - name: Notifications-Node + version: 0.1.0 + repository: "file://./charts/Notifications-Node" + condition: Notifications-Node.enabled + diff --git a/kubernetes/LANGFUSE_SETUP.md b/kubernetes/LANGFUSE_SETUP.md new file mode 100644 index 00000000..6c0f11bd --- /dev/null +++ b/kubernetes/LANGFUSE_SETUP.md @@ -0,0 +1,59 @@ +# Langfuse Setup + +**you can seed secrets in Langfuse-web , Langfuse-worker,clickhouse and database with .env file values** + +## 1. Verify Required Pods + +```bash +kubectl get pods -n your-namespace +``` + +All of the following must be `Running` or `Completed` — Langfuse will not start without them: + +| Pod | Purpose | +|---|---| +| `rag-search-db-0` | PostgreSQL (hosts `rag-search` and `langfuse-db`) | +| `minio-*` | Object storage for Langfuse events/media | +| `redis-*` | Queue backend for Langfuse worker | +| `clickhouse-*` | Analytics DB for Langfuse ingestion | +| `langfuse-worker-*` | Must be `Running` before web starts | +| `langfuse-web-*` | UI + runs DB migrations on first boot | +| `vault` | Secret storage | +| `vault-Init` | unseal vault | + +## 2. Wait for DB Migrations + +On first startup, `langfuse-web` runs database migrations — this takes 1–2 minutes. Watch the logs: + +```bash +kubectl logs -n your-namespace deployment/langfuse-web -f +``` + +Do **not** proceed until the pod is fully `Running`. + +## 3. Access the Dashboard + +```bash +kubectl port-forward -n your-namespace svc/langfuse-web 3005:3005 +``` + +Open **http://localhost:3005**, sign up / log in, then go to **Settings → API Keys → Create new key**. + +> Save both keys — the secret key is only shown once. +> - `pk-lf-...` → Public Key +> - `sk-lf-...` → Secret Key + +## 4. Store Keys in Vault + +```bash +kubectl cp store-langfuse-secrets.sh rag-module/vault-0:/tmp/store-langfuse-secrets.sh + +kubectl exec -n your-namespace vault-0 -- sh -c \ + "LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-YOUR_KEY \ + LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-YOUR_KEY \ + sh /tmp/store-langfuse-secrets.sh" +``` + +Replace `pk-lf-YOUR_KEY` and `sk-lf-YOUR_KEY` with the actual keys from step 3. + +The script stores them at `secret/data/langfuse/config` in Vault, where the LLM Orchestration Service reads them. diff --git a/kubernetes/charts/Authentication-Layer/Chart.yaml b/kubernetes/charts/Authentication-Layer/Chart.yaml new file mode 100644 index 00000000..649d153c --- /dev/null +++ b/kubernetes/charts/Authentication-Layer/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Authentication-Layer +description: Authentication Layer Service for RAG +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Authentication-Layer/templates/deployment-byk-authentication-layer.yaml b/kubernetes/charts/Authentication-Layer/templates/deployment-byk-authentication-layer.yaml new file mode 100644 index 00000000..e3c1c6f9 --- /dev/null +++ b/kubernetes/charts/Authentication-Layer/templates/deployment-byk-authentication-layer.yaml @@ -0,0 +1,34 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + spec: + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.authentication.image.repository }}:{{ .Values.authentication.image.tag }}" + imagePullPolicy: {{ .Values.authentication.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + env: + - name: PORT + value: {{ .Values.authentication.environment.serverPort | quote }} + - name: TIM_SERVICE_URL + value: {{ .Values.authentication.environment.timServiceUrl | quote }} + - name: CORS_ORIGINS + value: {{ .Values.authentication.environment.corsOrigins | quote }} + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Authentication-Layer/templates/ingress-byk-authentication-layer.yaml b/kubernetes/charts/Authentication-Layer/templates/ingress-byk-authentication-layer.yaml new file mode 100644 index 00000000..bf443fd2 --- /dev/null +++ b/kubernetes/charts/Authentication-Layer/templates/ingress-byk-authentication-layer.yaml @@ -0,0 +1,29 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "{{ .Values.release_name }}-ingress" + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + cert-manager.io/cluster-issuer: {{ .Values.ingress.certIssuerName | quote }} + labels: + name: "{{ .Values.release_name }}-ingress" +spec: + rules: + - host: auth.{{ .Values.domain }} + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: "{{ .Values.release_name }}" + port: + number: 3004 + tls: + - hosts: + - auth.{{ .Values.domain }} + secretName: {{ .Values.secretname }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Authentication-Layer/templates/service-byk-authentication-layer.yaml b/kubernetes/charts/Authentication-Layer/templates/service-byk-authentication-layer.yaml new file mode 100644 index 00000000..a17b39d2 --- /dev/null +++ b/kubernetes/charts/Authentication-Layer/templates/service-byk-authentication-layer.yaml @@ -0,0 +1,17 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} + protocol: TCP + name: http + selector: + app: "{{ .Values.release_name }}" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Authentication-Layer/values.yaml b/kubernetes/charts/Authentication-Layer/values.yaml new file mode 100644 index 00000000..544da2b9 --- /dev/null +++ b/kubernetes/charts/Authentication-Layer/values.yaml @@ -0,0 +1,35 @@ +replicas: 1 +enabled: true + + +release_name: "authentication-layer" +domain: "rag.local" # need to set this +secretname: "authentication-layer-tls" + +ingress: + enabled: true + certIssuerName: "letsencrypt-prod" + +# Authentication Layer Configuration +authentication: + image: + repository: "ghcr.io/buerokratt/authentication-layer" # Update with actual auth-layer image repository + tag: "latest" + pullPolicy: Always + + environment: + serverPort: "3004" + timServiceUrl: "http://tim:8085" + corsOrigins: "http://localhost:3001,http://localhost:3003,http://localhost:8086" + +service: + type: ClusterIP + port: 3004 + +resources: + requests: + memory: "10Mi" + cpu: "1m" + limits: + memory: "50Mi" + cpu: "5m" diff --git a/kubernetes/charts/ClickHouse/Chart.yaml b/kubernetes/charts/ClickHouse/Chart.yaml new file mode 100644 index 00000000..60e9ced1 --- /dev/null +++ b/kubernetes/charts/ClickHouse/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ClickHouse +description: ClickHouse analytics database for Langfuse +type: application +version: 0.1.0 +appVersion: "latest" \ No newline at end of file diff --git a/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml b/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml new file mode 100644 index 00000000..78f99697 --- /dev/null +++ b/kubernetes/charts/ClickHouse/templates/deployment-byk-clickhouse.yaml @@ -0,0 +1,88 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: clickhouse +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: clickhouse + spec: + {{- if .Values.securityContext }} + securityContext: + runAsUser: {{ .Values.securityContext.runAsUser }} + runAsGroup: {{ .Values.securityContext.runAsGroup }} + fsGroup: {{ .Values.securityContext.fsGroup }} + {{- end }} + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.clickhouse.registry }}/{{ .Values.images.clickhouse.repository }}:{{ .Values.images.clickhouse.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.httpPort }} + protocol: TCP + - name: native + containerPort: {{ .Values.service.nativePort }} + protocol: TCP + # Non-sensitive env's from values.yaml + env: + - name: CLICKHOUSE_DB + value: "{{ .Values.env.CLICKHOUSE_DB }}" + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.httpPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.httpPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: langfuse-clickhouse-data + mountPath: /var/lib/clickhouse + - name: langfuse-clickhouse-logs + mountPath: /var/log/clickhouse-server + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + {{- if .Values.persistence.enabled }} + volumes: + - name: langfuse-clickhouse-data + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-data" + - name: langfuse-clickhouse-logs + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-logs" + {{- end }} + restartPolicy: Always +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/ClickHouse/templates/pvc-clickhouse.yaml b/kubernetes/charts/ClickHouse/templates/pvc-clickhouse.yaml new file mode 100644 index 00000000..910b761e --- /dev/null +++ b/kubernetes/charts/ClickHouse/templates/pvc-clickhouse.yaml @@ -0,0 +1,37 @@ +{{- if and .Values.enabled .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-data" + labels: + app: "{{ .Values.release_name }}" + component: clickhouse + type: data +spec: + accessModes: + - {{ .Values.persistence.data.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.data.size }} + {{- if .Values.persistence.data.storageClass }} + storageClassName: {{ .Values.persistence.data.storageClass }} + {{- end }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-logs" + labels: + app: "{{ .Values.release_name }}" + component: clickhouse + type: logs +spec: + accessModes: + - {{ .Values.persistence.logs.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.logs.size }} + {{- if .Values.persistence.logs.storageClass }} + storageClassName: {{ .Values.persistence.logs.storageClass }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/ClickHouse/templates/secret.yaml b/kubernetes/charts/ClickHouse/templates/secret.yaml new file mode 100644 index 00000000..984a5dd7 --- /dev/null +++ b/kubernetes/charts/ClickHouse/templates/secret.yaml @@ -0,0 +1,13 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: clickhouse-secrets + labels: + app: "{{ .Values.release_name }}" + component: clickhouse +type: Opaque +stringData: + CLICKHOUSE_USER: "" + CLICKHOUSE_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/ClickHouse/templates/service-byk-clickhouse.yaml b/kubernetes/charts/ClickHouse/templates/service-byk-clickhouse.yaml new file mode 100644 index 00000000..1610d18b --- /dev/null +++ b/kubernetes/charts/ClickHouse/templates/service-byk-clickhouse.yaml @@ -0,0 +1,22 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: clickhouse +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: http + protocol: TCP + port: {{ .Values.service.httpPort }} + targetPort: {{ .Values.service.httpPort }} + - name: native + protocol: TCP + port: {{ .Values.service.nativePort }} + targetPort: {{ .Values.service.nativePort }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/ClickHouse/values.yaml b/kubernetes/charts/ClickHouse/values.yaml new file mode 100644 index 00000000..287c84a5 --- /dev/null +++ b/kubernetes/charts/ClickHouse/values.yaml @@ -0,0 +1,63 @@ +replicas: 1 +enabled: true + +images: + clickhouse: + registry: "docker.io" + repository: "clickhouse/clickhouse-server" + tag: "latest" + +release_name: "clickhouse" + +service: + type: ClusterIP + # ClickHouse HTTP interface port + httpPort: 8123 + # ClickHouse native protocol port + nativePort: 9000 + +# Environment variables +env: + CLICKHOUSE_DB: "default" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: clickhouse-secrets + +# Security context +securityContext: + runAsUser: 101 + runAsGroup: 101 + fsGroup: 101 + +persistence: + enabled: true + data: + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + logs: + storageClass: "" + accessMode: ReadWriteOnce + size: 5Gi + +resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "500m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: true + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + # HTTP endpoint for health check + httpPath: "/ping" \ No newline at end of file diff --git a/kubernetes/charts/CronManager/Chart.yaml b/kubernetes/charts/CronManager/Chart.yaml new file mode 100644 index 00000000..31b14b5f --- /dev/null +++ b/kubernetes/charts/CronManager/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: CronManager +description: CronManager Service for RAG +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/CronManager/templates/configmap-cronmanager-config.yaml b/kubernetes/charts/CronManager/templates/configmap-cronmanager-config.yaml new file mode 100644 index 00000000..a60d8aca --- /dev/null +++ b/kubernetes/charts/CronManager/templates/configmap-cronmanager-config.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.release_name }}-config" + labels: + app: "{{ .Values.release_name }}" +data: + constants.ini: | + + RAG_MODULE_RUUTER_PRIVATE={{ .Values.constants.RAG_MODULE_RUUTER_PRIVATE }} + RAG_MODULE_RUUTER_PUBLIC={{ .Values.constants.RAG_MODULE_RUUTER_PUBLIC }} + RAG_MODULE_RESQL={{ .Values.constants.RAG_MODULE_RESQL }} + RAG_MODULE_TIM={{ .Values.constants.RAG_MODULE_TIM }} + RAG_MODULE_DATAMAPPER={{ .Values.constants.RAG_MODULE_DATAMAPPER }} + \ No newline at end of file diff --git a/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml new file mode 100644 index 00000000..15dc9615 --- /dev/null +++ b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml @@ -0,0 +1,148 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + securityContext: + runAsUser: 0 + runAsGroup: 0 + fsGroup: 0 + initContainers: + - name: git-clone + image: alpine/git:latest + securityContext: + runAsUser: 0 + runAsGroup: 0 + volumeMounts: + - name: dsl + mountPath: /DSL + - name: scripts + mountPath: /app/scripts + - name: vector-indexer + mountPath: /app/src/vector_indexer + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + mkdir -p /app/src/vector_indexer && + mkdir -p /app/scripts && + mkdir -p /DSL && + mkdir -p /app/src/utils + + cp -r /tmp/rag/DSL/CronManager/DSL/* /DSL/ && + cp -r /tmp/rag/DSL/CronManager/script/* /app/scripts/ && + cp -r /tmp/rag/src/vector_indexer/* /app/src/vector_indexer/ && + cp -r /tmp/rag/src/utils/decrypt_vault_secrets.py /app/src/utils/ && + + # Set execute permissions on all shell scripts + chmod +x /app/scripts/*.sh && + echo "Scripts copied and permissions set successfully" + + containers: + {{- if .Values.vaultAgent.enabled }} + # CronManager connects to localhost:8203, never directly to Vault + - name: vault-agent-cron + image: hashicorp/vault:1.20.3 + command: ["vault", "agent", "-config=/agent/config/cron-agent.hcl", "-log-level=info"] + ports: + - name: agent-api + containerPort: 8203 + protocol: TCP + volumeMounts: + - name: vault-agent-config + mountPath: /agent/config + readOnly: true + - name: vault-agent-creds + mountPath: /agent/credentials + readOnly: true + - name: vault-agent-cron-token + mountPath: /agent/cron-token + securityContext: + capabilities: + add: ["IPC_LOCK"] + {{- end }} + - name: "{{ .Values.release_name }}" + image: "{{ .Values.cronmanager.image.registry }}/{{ .Values.cronmanager.image.repository }}:{{ .Values.cronmanager.image.tag }}" + imagePullPolicy: {{ .Values.cronmanager.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.cronmanager.environment.containerPort }} + protocol: TCP + env: + - name: PYTHONPATH + value: {{ .Values.cronmanager.environment.pythonPath | quote }} + {{- if .Values.vaultAgent.enabled }} + # Vault Agent proxy URL (localhost sidecar) + - name: VAULT_AGENT_URL + value: "http://localhost:8203" + {{- end }} + - name: RAG_MODULE_RUUTER_PRIVATE + value: {{ .Values.constants.RAG_MODULE_RUUTER_PRIVATE | quote }} + - name: RAG_MODULE_RESQL + value: {{ .Values.constants.RAG_MODULE_RESQL | quote }} + - name: RAG_MODULE_TIM + value: {{ .Values.constants.RAG_MODULE_TIM | quote }} + - name: UV_VERBOSE + value: "1" + + volumeMounts: + - name: dsl + mountPath: /DSL + - name: cronmanager-data + mountPath: /app/data + - name: scripts + mountPath: /app/scripts + - name: vector-indexer + mountPath: /app/src/vector_indexer + - name: datasets + mountPath: /app/datasets + + volumes: + - name: dsl + emptyDir: {} + - name: scripts + emptyDir: {} + - name: vector-indexer + emptyDir: {} + - name: datasets + emptyDir: {} + - name: cronmanager-data + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-data" + - name: config-volume + configMap: + name: "{{ .Values.release_name }}-config" + {{- if .Values.vaultAgent.enabled }} + # Vault Agent configuration (from Vault-Agent-Cron chart configmap) + - name: vault-agent-config + configMap: + name: vault-agent-cron-config + # Shared AppRole credentials (created by vault-init Job) + - name: vault-agent-creds + persistentVolumeClaim: + claimName: vault-agent-creds + # CronManager-specific token storage (pod-scoped, short-lived) + # Tokens are generated by Vault Agent and destroyed when pod terminates + - name: vault-agent-cron-token + emptyDir: {} + {{- end }} + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/CronManager/templates/pvc-cronmanager.yaml b/kubernetes/charts/CronManager/templates/pvc-cronmanager.yaml new file mode 100644 index 00000000..f278883c --- /dev/null +++ b/kubernetes/charts/CronManager/templates/pvc-cronmanager.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-data" + labels: + app: "{{ .Values.release_name }}" +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/CronManager/templates/service-byk-cronmanager.yaml b/kubernetes/charts/CronManager/templates/service-byk-cronmanager.yaml new file mode 100644 index 00000000..c6d67227 --- /dev/null +++ b/kubernetes/charts/CronManager/templates/service-byk-cronmanager.yaml @@ -0,0 +1,17 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: "{{ .Values.release_name }}" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/CronManager/values.yaml b/kubernetes/charts/CronManager/values.yaml new file mode 100644 index 00000000..df8013cf --- /dev/null +++ b/kubernetes/charts/CronManager/values.yaml @@ -0,0 +1,50 @@ +replicas: 1 +enabled: true +release_name: "cron-manager" + +cronmanager: + image: + registry: ghcr.io + repository: buerokratt/cronmanager + tag: "python-1.2.0" + pullPolicy: IfNotPresent + + environment: + containerPort: "8080" + pythonPath: "/app:/app/src/vector_indexer" + VAULT_ADDR: "http://vault:8200" + +service: + type: ClusterIP + port: 9010 + targetPort: 8080 + +# PVC Configuration +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + +# Service URLs +constants: + RAG_MODULE_RUUTER_PRIVATE: "http://ruuter-private:8088" + RAG_MODULE_RUUTER_PUBLIC: "http://ruuter-public:8086" + RAG_MODULE_RESQL: "http://resql:8082" + RAG_MODULE_TIM: "http://tim:8085" + RAG_MODULE_DATAMAPPER: "http://data-mapper:3000" + +resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "500m" + +podAnnotations: + dsl-checksum: "initial" + +# Vault Agent sidecar configuration +vaultAgent: + enabled: true \ No newline at end of file diff --git a/kubernetes/charts/DataMapper/Chart.yaml b/kubernetes/charts/DataMapper/Chart.yaml new file mode 100644 index 00000000..a39f7550 --- /dev/null +++ b/kubernetes/charts/DataMapper/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: data-mapper +description: A Helm chart for Data Mapper +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml b/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml new file mode 100644 index 00000000..f52cf138 --- /dev/null +++ b/kubernetes/charts/DataMapper/templates/deployment-byk-data-mapper.yaml @@ -0,0 +1,67 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + initContainers: + - name: git-clone + image: alpine/git:latest + volumeMounts: + - name: dsl-lib + mountPath: /workspace/app/lib + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + # mkdir -p /workspace/app/views/rag-search && + mkdir -p /workspace/app/lib && + + # cp -r /tmp/rag/DSL/DMapper/rag-search/hbs/* /workspace/app/views/rag-search && + cp -r /tmp/rag/DSL/DMapper/rag-search/lib/* /workspace/app/lib + + + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.scope.registry }}/{{ .Values.images.scope.repository }}:{{ .Values.images.scope.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - containerPort: {{ .Values.service.port }} + name: http + env: + - name: PORT + value: "{{ .Values.env.PORT }}" + - name: CONTENT_FOLDER + value: "{{ .Values.env.CONTENT_FOLDER }}" + volumeMounts: + - name: dsl-lib + mountPath: /workspace/app/lib + + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + volumes: + - name: dsl-lib + emptyDir: {} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/DataMapper/templates/service-byk-data-mapper.yaml b/kubernetes/charts/DataMapper/templates/service-byk-data-mapper.yaml new file mode 100644 index 00000000..c6d67227 --- /dev/null +++ b/kubernetes/charts/DataMapper/templates/service-byk-data-mapper.yaml @@ -0,0 +1,17 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: "{{ .Values.release_name }}" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/DataMapper/values.yaml b/kubernetes/charts/DataMapper/values.yaml new file mode 100644 index 00000000..3267f3b9 --- /dev/null +++ b/kubernetes/charts/DataMapper/values.yaml @@ -0,0 +1,33 @@ +replicas: 1 +enabled: true +release_name: "data-mapper" + +images: + scope: + registry: "ghcr.io" + repository: "buerokratt/datamapper" + tag: "v2.2.9" + +service: + type: ClusterIP + port: 3001 + targetPort: 3000 + +env: + # DataMapper specific configuration + PORT: "3000" + CONTENT_FOLDER: "/data" + +resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "initial" \ No newline at end of file diff --git a/kubernetes/charts/GUI/Chart.yaml b/kubernetes/charts/GUI/Chart.yaml new file mode 100644 index 00000000..2fb3f331 --- /dev/null +++ b/kubernetes/charts/GUI/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: GUI +description: A Helm chart for GUI in RAG +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/GUI/templates/configmap-vite-config.yaml b/kubernetes/charts/GUI/templates/configmap-vite-config.yaml new file mode 100644 index 00000000..7110554b --- /dev/null +++ b/kubernetes/charts/GUI/templates/configmap-vite-config.yaml @@ -0,0 +1,56 @@ +{{- if .Values.gui.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.gui.release_name }}-vite-config + namespace: rag-module + labels: + app: {{ .Values.gui.release_name }} +data: + vite.config.ts: | + import { defineConfig } from 'vite'; + import react from '@vitejs/plugin-react'; + import tsconfigPaths from 'vite-tsconfig-paths'; + import svgr from 'vite-plugin-svgr'; + import path from 'path'; + import { removeHiddenMenuItems } from './vitePlugin'; + + // https://vitejs.dev/config/ + export default defineConfig({ + envPrefix: 'REACT_APP_', + plugins: [ + react(), + tsconfigPaths(), + svgr(), + { + name: 'removeHiddenMenuItemsPlugin', + transform: (str, id) => { + if(!id.endsWith('/menu-structure.json')) + return str; + return removeHiddenMenuItems(str); + }, + }, + ], + base: '/rag-search', + build: { + outDir: './build', + target: 'es2015', + emptyOutDir: true, + }, + server: { + host: '0.0.0.0', + allowedHosts: [{{- range $index, $host := splitList "," .Values.gui.vite.allowedHosts }}{{ if $index }}, {{ end }}'{{ $host | trim }}'{{- end }}], + headers: { + ...(process.env.REACT_APP_CSP && { + 'Content-Security-Policy': process.env.REACT_APP_CSP, + }), + }, + }, + resolve: { + alias: { + '~@fontsource': path.resolve(__dirname, 'node_modules/@fontsource'), + '@': `${path.resolve(__dirname, './src')}`, + }, + }, + }); +{{- end }} diff --git a/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml new file mode 100644 index 00000000..fe819004 --- /dev/null +++ b/kubernetes/charts/GUI/templates/deployment-byk-gui.yaml @@ -0,0 +1,159 @@ +{{- if .Values.gui.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.gui.release_name }} + labels: + app: {{ .Values.gui.release_name }} +spec: + replicas: {{ .Values.gui.replicas }} + selector: + matchLabels: + app: {{ .Values.gui.release_name }} + template: + metadata: + labels: + app: {{ .Values.gui.release_name }} + + spec: + containers: + # sidecar: GUI connects to localhost:8202 (vault-agent) + {{- if .Values.vaultAgent.enabled }} + - name: vault-agent-gui + image: hashicorp/vault:1.20.3 + command: ["vault", "agent", "-config=/agent/config/gui-agent.hcl", "-log-level=info"] + ports: + - name: agent-api + containerPort: 8202 + protocol: TCP + volumeMounts: + - name: vault-agent-config + mountPath: /agent/config + readOnly: true + - name: vault-agent-creds + mountPath: /agent/credentials + readOnly: true + - name: vault-agent-gui-token + mountPath: /agent/gui-token + securityContext: + capabilities: + add: ["IPC_LOCK"] + # # Health check: Ensure token file exists and is not empty + # livenessProbe: + # exec: + # command: + # - sh + # - -c + # - test -f /agent/gui-token/token && test -s /agent/gui-token/token + # initialDelaySeconds: 10 + # periodSeconds: 10 + # timeoutSeconds: 3 + # failureThreshold: 3 + # readinessProbe: + # exec: + # command: + # - sh + # - -c + # - test -f /agent/gui-token/token && test -s /agent/gui-token/token + # initialDelaySeconds: 5 + # periodSeconds: 5 + # timeoutSeconds: 3 + {{- end }} + - name: {{ .Values.gui.release_name }} + image: "{{ .Values.gui.image.repository }}:{{ .Values.gui.image.tag }}" + imagePullPolicy: {{ .Values.gui.image.pullPolicy }} + ports: + - containerPort: {{ .Values.gui.port }} + protocol: TCP + env: + # Node.js environment configuration + - name: NODE_ENV + value: {{ .Values.gui.nodeEnv | quote }} + - name: PORT + value: {{ .Values.gui.port | quote }} + - name: DEBUG_ENABLED + value: {{ .Values.gui.debugEnabled | quote }} + - name: CHOKIDAR_USEPOLLING + value: "true" + + # React application configuration + - name: REACT_APP_RUUTER_API_URL + value: {{ .Values.gui.services.ruuterPublic | quote }} + - name: REACT_APP_RUUTER_PRIVATE_API_URL + value: {{ .Values.gui.services.ruuterPrivate | quote }} + - name: REACT_APP_EXTERNAL_API_URL + value: {{ .Values.gui.services.datasetGenerator | quote }} + - name: REACT_APP_CUSTOMER_SERVICE_LOGIN + value: {{ printf "%s/et/dev-auth" .Values.gui.services.authenticationLayer | quote }} + - name: REACT_APP_NOTIFICATION_NODE_URL + value: {{ .Values.gui.services.notificationNode | quote }} + - name: REACT_APP_CSP + value: {{ .Values.gui.csp | quote }} + - name: REACT_APP_SERVICE_ID + value: {{ .Values.gui.serviceId | quote }} + - name: REACT_APP_ENABLE_HIDDEN_FEATURES + value: {{ .Values.gui.enableHiddenFeatures | quote | upper }} + + {{- if .Values.vaultAgent.enabled }} + # Vault Agent proxy URL (localhost sidecar) + - name: VAULT_AGENT_URL + value: "http://localhost:8202" + {{- end }} + + # Vite development server configuration + - name: VITE_HOST + value: {{ .Values.gui.vite.host | quote }} + - name: VITE_ALLOWED_HOSTS + value: {{ .Values.gui.vite.allowedHosts | quote }} + + volumeMounts: + - name: vite-config + mountPath: /app/vite.config.ts + subPath: vite.config.ts + + resources: + limits: + cpu: {{ .Values.gui.resources.limits.cpu }} + memory: {{ .Values.gui.resources.limits.memory }} + requests: + cpu: {{ .Values.gui.resources.requests.cpu }} + memory: {{ .Values.gui.resources.requests.memory }} + + # livenessProbe: + # httpGet: + # path: / + # port: {{ .Values.gui.port }} + # initialDelaySeconds: 30 + # periodSeconds: 10 + # timeoutSeconds: 5 + + # readinessProbe: + # httpGet: + # path: / + # port: {{ .Values.gui.port }} + # initialDelaySeconds: 10 + # periodSeconds: 5 + # timeoutSeconds: 3 + + volumes: + - name: vite-config + configMap: + name: {{ .Values.gui.release_name }}-vite-config + {{- if .Values.vaultAgent.enabled }} + # Vault Agent configuration (from Vault-Agent-GUI chart) + - name: vault-agent-config + configMap: + name: vault-agent-gui-config + # Shared AppRole credentials (created by vault-init Job) + - name: vault-agent-creds + persistentVolumeClaim: + claimName: vault-agent-creds + # GUI-specific token storage (pod-scoped, short-lived) + # Tokens are generated by Vault Agent and destroyed when pod terminates + - name: vault-agent-gui-token + emptyDir: {} + {{- end }} + + restartPolicy: Always + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/GUI/templates/ingress-byk-gui.yaml b/kubernetes/charts/GUI/templates/ingress-byk-gui.yaml new file mode 100644 index 00000000..cda59a70 --- /dev/null +++ b/kubernetes/charts/GUI/templates/ingress-byk-gui.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gui-ingress + namespace: rag-module + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/use-regex: "true" +spec: + rules: + - host: {{ .Values.gui.ingress.host }} + http: + paths: + - path: /rag-search + pathType: Prefix + backend: + service: + name: gui + port: + number: 3001 diff --git a/kubernetes/charts/GUI/templates/service-byk-gui.yaml b/kubernetes/charts/GUI/templates/service-byk-gui.yaml new file mode 100644 index 00000000..1a7a35a3 --- /dev/null +++ b/kubernetes/charts/GUI/templates/service-byk-gui.yaml @@ -0,0 +1,15 @@ +{{- if .Values.gui.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.gui.release_name }} +spec: + type: {{ .Values.gui.service.type }} + ports: + - port: {{ .Values.gui.service.port }} + targetPort: {{ .Values.gui.service.targetPort }} + protocol: TCP + name: http + selector: + app: {{ .Values.gui.release_name }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/GUI/values.yaml b/kubernetes/charts/GUI/values.yaml new file mode 100644 index 00000000..03257659 --- /dev/null +++ b/kubernetes/charts/GUI/values.yaml @@ -0,0 +1,71 @@ +gui: + enabled: true + release_name: gui + image: + repository: "ghcr.io/buerokratt/rag-gui" # Update with actual GUI image repository + tag: sha-1331462 + pullPolicy: Always + + # React application configuration + nodeEnv: production + port: 3001 + debugEnabled: true + enableHiddenFeatures: false + + #service URLs + services: + ruuterPublic: "http:///ruuter-public" + ruuterPrivate: "http:///ruuter-private" + authenticationLayer: "http://" + notificationNode: "http://notifications-node:4040" + datasetGenerator: "http://dataset-gen-service:8000" + + # Content Security Policy - Updated for browser access + csp: "default-src 'self'; connect-src 'self' http:///ruuter-public http:///ruuter-private http:// http:// http://notifications-node:4040 http://notifications-node:4040 http://dataset-gen-service:8000 http://dataset-gen-service:8000 http://localhost:* http://localhost:* http://global-classifier.local http://global-classifier.local ws://global-classifier.local ws://global-classifier.local; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;" + + # Service configuration + serviceId: "conversations,settings,monitoring" + + # Vite development server (for development mode) + vite: + host: "0.0.0.0" + allowedHosts: "localhost,127.0.0.1," # Update with actual domain for development access + + # Ingress host + ingress: + host: "" # Update with actual domain + + resources: + limits: + cpu: 200m + memory: 512Mi + requests: + cpu: 50m + memory: 128Mi + + replicas: 1 + + service: + type: ClusterIP + port: 3001 + targetPort: 3001 + +# Vault Agent sidecar configuration +vaultAgent: + enabled: true + + + # ingress: + # enabled: true + # className: nginx + # annotations: + # nginx.ingress.kubernetes.io/rewrite-target: / + # nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + # nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + # nginx.ingress.kubernetes.io/proxy-body-size: "50m" + # hosts: + # - host: rag.local + # paths: + # - path: / + # pathType: Prefix + # tls: [] \ No newline at end of file diff --git a/kubernetes/charts/Grafana/Chart.yaml b/kubernetes/charts/Grafana/Chart.yaml new file mode 100644 index 00000000..0bdeaa7d --- /dev/null +++ b/kubernetes/charts/Grafana/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Grafana +description: A Helm chart for Grafana dashboard and monitoring +type: application +version: 0.1.0 +appVersion: "10.2.0" \ No newline at end of file diff --git a/kubernetes/charts/Grafana/dashboards/grafana-dashboard-deployment.json b/kubernetes/charts/Grafana/dashboards/grafana-dashboard-deployment.json new file mode 100644 index 00000000..a1e469f2 --- /dev/null +++ b/kubernetes/charts/Grafana/dashboards/grafana-dashboard-deployment.json @@ -0,0 +1,167 @@ +{ + "id": null, + "title": "RAG Module Orchestrator", + "tags": ["deployment", "models", "triton"], + "timezone": "browser", + "refresh": "30s", + "time": { + "from": "now-1h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "service_name", + "type": "query", + "label": "Service Name", + "refresh": 1, + "query": "label_values(service)", + "datasource": { + "type": "loki", + "uid": "loki-datasource" + }, + "multi": true, + "includeAll": true, + "allValue": ".*", + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "options": [], + "regex": "", + "sort": 0, + "skipUrlSync": false, + "hide": 0 + }, + { + "name": "log_level", + "type": "custom", + "label": "Log Level", + "multi": true, + "includeAll": true, + "allValue": "ERROR|INFO|WARNING|DEBUG", + "current": { + "selected": true, + "text": "All", + "value": "$__all" + }, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": true + }, + { + "text": "ERROR", + "value": "ERROR", + "selected": false + }, + { + "text": "WARNING", + "value": "WARNING", + "selected": false + }, + { + "text": "INFO", + "value": "INFO", + "selected": false + }, + { + "text": "DEBUG", + "value": "DEBUG", + "selected": false + } + ], + "query": "ERROR,INFO,WARNING,DEBUG", + "queryType": "", + "refresh": 0, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "hide": 0 + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Log Messages Over Time by Level", + "type": "graph", + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "targets": [ + { + "expr": "sum by (service, level) (count_over_time({service=~\"$service_name\", level=~\"$log_level\"}[5m]))", + "refId": "A", + "legendFormat": "{{service}} - {{level}}", + "datasource": { + "type": "loki", + "uid": "loki-datasource" + } + } + ], + "yAxes": [ + { + "label": "Log Count", + "min": 0 + } + ], + "xAxis": { + "show": true + }, + "legend": { + "show": true, + "values": true, + "current": true, + "total": true + }, + "tooltip": { + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "fill": 1, + "linewidth": 2, + "pointradius": 2, + "bars": false, + "lines": true, + "points": false, + "stack": false, + "percentage": false, + "nullPointMode": "null as zero" + }, + { + "id": 2, + "title": "Deployment Logs", + "type": "logs", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 8 + }, + "targets": [ + { + "expr": "{service=~\"$service_name\", level=~\"$log_level\"}", + "refId": "A", + "datasource": { + "type": "loki", + "uid": "loki-datasource" + } + } + ], + "options": { + "showTime": true, + "showLabels": true, + "showCommonLabels": false, + "wrapLogMessage": true, + "sortOrder": "Descending" + } + } + ] +} diff --git a/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml b/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml new file mode 100644 index 00000000..3228eca8 --- /dev/null +++ b/kubernetes/charts/Grafana/templates/configmap-dashboards.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboards + labels: + app: grafana +data: +{{- range $path, $content := .Files.Glob "dashboards/*.json" }} + {{ base $path }}: | +{{ $.Files.Get $path | indent 4 }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Grafana/templates/configmap-grafana.yaml b/kubernetes/charts/Grafana/templates/configmap-grafana.yaml new file mode 100644 index 00000000..c701d665 --- /dev/null +++ b/kubernetes/charts/Grafana/templates/configmap-grafana.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasources + labels: + app: grafana +data: + datasources.yaml: | + apiVersion: 1 + datasources: + {{- range .Values.datasources }} + - name: {{ .name }} + type: {{ .type }} + url: {{ .url }} + access: {{ .access }} + isDefault: {{ .isDefault }} + {{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-providers + labels: + app: grafana +data: + dashboards.yaml: | + apiVersion: 1 + providers: + {{- range .Values.dashboardProviders }} + - name: {{ .name }} + orgId: {{ .orgId }} + folder: '{{ .folder }}' + type: {{ .type }} + disableDeletion: {{ .disableDeletion }} + updateIntervalSeconds: {{ .updateIntervalSeconds }} + allowUiUpdates: {{ .allowUiUpdates }} + options: + path: {{ .options.path }} + {{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Grafana/templates/deployment-grafana.yaml b/kubernetes/charts/Grafana/templates/deployment-grafana.yaml new file mode 100644 index 00000000..d9191db7 --- /dev/null +++ b/kubernetes/charts/Grafana/templates/deployment-grafana.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + spec: + containers: + - name: {{ .Values.release_name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag}}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.port }} + protocol: TCP + # Non-sensitive env's from values.yaml + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + volumeMounts: + - name: datasources + mountPath: /etc/grafana/provisioning/datasources + readOnly: true + - name: dashboard-providers + mountPath: /etc/grafana/provisioning/dashboards + readOnly: true + - name: dashboards + mountPath: /etc/grafana/dashboards + readOnly: true + {{- if .Values.persistence.enabled }} + - name: storage + mountPath: /var/lib/grafana + {{- end }} + livenessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: datasources + configMap: + name: grafana-datasources + - name: dashboard-providers + configMap: + name: grafana-dashboard-providers + - name: dashboards + configMap: + name: grafana-dashboards + {{- if .Values.persistence.enabled }} + - name: storage + persistentVolumeClaim: + claimName: grafana-storage + {{- end }} + \ No newline at end of file diff --git a/kubernetes/charts/Grafana/templates/pvc-grafana.yaml b/kubernetes/charts/Grafana/templates/pvc-grafana.yaml new file mode 100644 index 00000000..23b6f2e5 --- /dev/null +++ b/kubernetes/charts/Grafana/templates/pvc-grafana.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: grafana-storage + labels: + app: grafana +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Grafana/templates/secret.yaml b/kubernetes/charts/Grafana/templates/secret.yaml new file mode 100644 index 00000000..f1748ade --- /dev/null +++ b/kubernetes/charts/Grafana/templates/secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: grafana-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + GF_SECURITY_ADMIN_USER: "" + GF_SECURITY_ADMIN_PASSWORD: "" diff --git a/kubernetes/charts/Grafana/templates/service-grafana.yaml b/kubernetes/charts/Grafana/templates/service-grafana.yaml new file mode 100644 index 00000000..84ff6d0a --- /dev/null +++ b/kubernetes/charts/Grafana/templates/service-grafana.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: {{ .Values.release_name }} \ No newline at end of file diff --git a/kubernetes/charts/Grafana/values.yaml b/kubernetes/charts/Grafana/values.yaml new file mode 100644 index 00000000..bd0d08cf --- /dev/null +++ b/kubernetes/charts/Grafana/values.yaml @@ -0,0 +1,65 @@ +replicas: 1 + +release_name: "grafana" + +image: + repository: grafana/grafana + pullPolicy: IfNotPresent + tag: "10.0.0" + +nameOverride: "" +fullnameOverride: "" + +port: 3000 + +service: + type: ClusterIP + port: 4005 + targetPort: 3000 + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 5Gi + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +# Admin configuration +admin: + enabled: true + +# Datasources configuration +datasources: + - name: Loki + type: loki + url: http://loki:3100 + access: proxy + isDefault: true + +# Dashboard providers +dashboardProviders: + - name: 'default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + +# Environment variables (non-sensitive) +env: + GF_USERS_ALLOW_SIGN_UP: "false" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: grafana-secrets diff --git a/kubernetes/charts/LLM-Orchestration-Service/Chart.yaml b/kubernetes/charts/LLM-Orchestration-Service/Chart.yaml new file mode 100644 index 00000000..1be8ea8c --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: LLM-Orchestration-Service +description: LLM Orchestration Service for RAG Module +version: 0.1.0 +appVersion: "1.0.0" +type: application \ No newline at end of file diff --git a/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml b/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml new file mode 100644 index 00000000..3e0feacb --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml @@ -0,0 +1,191 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration + spec: + {{- if .Values.initContainer.enabled }} + initContainers: + - name: volume-init + image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}" + command: + - sh + - -c + - | + echo "Initializing runtime volumes..." + + # Initialize config volume if empty + if [ ! -d "{{ .Values.volumes.config.mountPath }}" ] || [ -z "$(ls -A {{ .Values.volumes.config.mountPath }})" ]; then + echo "Creating config directory structure..." + mkdir -p {{ .Values.volumes.config.mountPath }} + # Generate initial config files here + # This is where your app would create its runtime config + echo "Config volume initialized" + fi + + # Initialize optimization volume if empty + if [ ! -d "{{ .Values.volumes.optimization.mountPath }}" ] || [ -z "$(ls -A {{ .Values.volumes.optimization.mountPath }})" ]; then + echo "Creating optimization modules directory structure..." + mkdir -p {{ .Values.volumes.optimization.mountPath }} + # This is where your app would create its optimized modules + echo "Optimization volume initialized" + fi + + # Set proper permissions + chmod -R 755 {{ .Values.volumes.config.mountPath }} || true + chmod -R 755 {{ .Values.volumes.optimization.mountPath }} || true + + echo "Volume initialization complete" + volumeMounts: + {{- if .Values.volumes.config.enabled }} + - name: config-volume + mountPath: {{ .Values.volumes.config.mountPath }} + {{- end }} + {{- if .Values.volumes.optimization.enabled }} + - name: optimization-volume + mountPath: {{ .Values.volumes.optimization.mountPath }} + {{- end }} + {{- end }} + + containers: + {{- if .Values.vaultAgent.enabled }} + # sidecar: LLM connects to localhost:8201, never directly to Vault + # Security: Agent enforces llm-orchestration-policy (read-only LLM secrets) + - name: vault-agent-llm + image: hashicorp/vault:1.20.3 + command: ["vault", "agent", "-config=/agent/config/agent.hcl", "-log-level=info"] + ports: + - name: agent-api + containerPort: 8201 + protocol: TCP + volumeMounts: + - name: vault-agent-config + mountPath: /agent/config + readOnly: true + - name: vault-agent-creds + mountPath: /agent/credentials + readOnly: true + - name: vault-agent-llm-token + mountPath: /agent/llm-token + securityContext: + capabilities: + add: ["IPC_LOCK"] + {{- end }} + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.llmOrchestration.repository }}:{{ .Values.images.llmOrchestration.tag }}" + imagePullPolicy: {{ .Values.images.llmOrchestration.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + - name: ENVIRONMENT + value: "{{ .Values.app.environment }}" + - name: PORT + value: "{{ .Values.service.targetPort }}" + + # Vault configuration + {{- if .Values.vaultAgent.enabled }} + # Vault Agent proxy URL (localhost sidecar) + # WHY: LLM reads encrypted API keys through agent, not Vault directly + - name: VAULT_ADDR + value: "http://localhost:8201" + {{- end }} + + # Additional environment variables from values + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: "{{ $value }}" + {{- end }} + + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: "{{ .Values.healthcheck.readinessPath | default .Values.healthcheck.httpPath }}" + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + + volumeMounts: + # Runtime-generated config volume + {{- if .Values.volumes.config.enabled }} + - name: config-volume + mountPath: {{ .Values.volumes.config.mountPath }} + {{- end }} + # Runtime-generated optimization modules + {{- if .Values.volumes.optimization.enabled }} + - name: optimization-volume + mountPath: {{ .Values.volumes.optimization.mountPath }} + {{- end }} + # Persistent logs + {{- if .Values.volumes.logs.enabled }} + - name: logs-volume + mountPath: {{ .Values.volumes.logs.mountPath }} + {{- end }} + + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + + volumes: + # Runtime-generated config volume (PVC) + {{- if .Values.volumes.config.enabled }} + - name: config-volume + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-config" + {{- end }} + # Runtime-generated optimization volume (PVC) + {{- if .Values.volumes.optimization.enabled }} + - name: optimization-volume + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-optimization" + {{- end }} + # Persistent logs (PVC) + {{- if .Values.volumes.logs.enabled }} + - name: logs-volume + persistentVolumeClaim: + claimName: "{{ .Values.release_name }}-logs" + {{- end }} + {{- if .Values.vaultAgent.enabled }} + # Vault Agent configuration (from Vault-Agent-LLM chart configmap) + - name: vault-agent-config + configMap: + name: vault-agent-llm-config + # Shared AppRole credentials (created by vault-init Job) + - name: vault-agent-creds + persistentVolumeClaim: + claimName: vault-agent-creds + # LLM-specific token storage (pod-scoped, short-lived) + # Tokens are generated by Vault Agent and destroyed when pod terminates + - name: vault-agent-llm-token + emptyDir: {} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/LLM-Orchestration-Service/templates/pvc-volumes.yaml b/kubernetes/charts/LLM-Orchestration-Service/templates/pvc-volumes.yaml new file mode 100644 index 00000000..f2be2c30 --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/templates/pvc-volumes.yaml @@ -0,0 +1,61 @@ +{{- if and .Values.enabled .Values.volumes.logs.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-logs" + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration + type: logs +spec: + accessModes: + - {{ .Values.volumes.logs.accessMode }} + resources: + requests: + storage: {{ .Values.volumes.logs.size }} + {{- if .Values.volumes.logs.storageClass }} + storageClassName: {{ .Values.volumes.logs.storageClass }} + {{- end }} +{{- end }} + +--- +{{- if and .Values.enabled .Values.volumes.config.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-config" + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration + type: config +spec: + accessModes: + - {{ .Values.volumes.config.accessMode }} + resources: + requests: + storage: {{ .Values.volumes.config.size }} + {{- if .Values.volumes.config.storageClass }} + storageClassName: {{ .Values.volumes.config.storageClass }} + {{- end }} +{{- end }} + +--- +{{- if and .Values.enabled .Values.volumes.optimization.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: "{{ .Values.release_name }}-optimization" + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration + type: optimization +spec: + accessModes: + - {{ .Values.volumes.optimization.accessMode }} + resources: + requests: + storage: {{ .Values.volumes.optimization.size }} + {{- if .Values.volumes.optimization.storageClass }} + storageClassName: {{ .Values.volumes.optimization.storageClass }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/LLM-Orchestration-Service/templates/service-byk-llm-orchestration.yaml b/kubernetes/charts/LLM-Orchestration-Service/templates/service-byk-llm-orchestration.yaml new file mode 100644 index 00000000..63b9bb62 --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/templates/service-byk-llm-orchestration.yaml @@ -0,0 +1,18 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: llm-orchestration +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/LLM-Orchestration-Service/values.yaml b/kubernetes/charts/LLM-Orchestration-Service/values.yaml new file mode 100644 index 00000000..7e457d22 --- /dev/null +++ b/kubernetes/charts/LLM-Orchestration-Service/values.yaml @@ -0,0 +1,81 @@ +replicas: 1 +enabled: true + +images: + llmOrchestration: + repository: "ghcr.io/buerokratt/llm-orchestration-service" # Update with actual llm-orchestration image repository + tag: "latest" + pullPolicy: "IfNotPresent" + +release_name: "llm-orchestration-service" + +service: + type: ClusterIP + port: 8100 + targetPort: 8100 + +app: + environment: "production" + +# Volume configurations +volumes: + # Runtime-generated config volume (managed by InitContainer + PVC) + config: + enabled: true + mountPath: "/app/src/llm_config_module/config" + size: "1Gi" + accessMode: "ReadWriteOnce" + storageClass: "" + + # Runtime-generated optimization modules (managed by InitContainer + PVC) + optimization: + enabled: true + mountPath: "/app/src/optimization/optimized_modules" + size: "5Gi" + accessMode: "ReadWriteOnce" + storageClass: "" + + # Logs volume (persistent) + logs: + enabled: true + mountPath: "/app/logs" + size: "5Gi" + accessMode: "ReadWriteOnce" + storageClass: "" + + +# InitContainer configuration for runtime volume preparation +initContainer: + enabled: true + image: + repository: "ghcr.io/buerokratt/llm-orchestration-service" # Update with actual llm-orchestration image repository + tag: "latest" + # InitContainer will prepare the runtime volumes + prepareVolumes: true + +resources: + requests: + memory: "256Mi" + cpu: "50m" + limits: + memory: "1Gi" + cpu: "500m" + +healthcheck: + enabled: false + initialDelaySeconds: 40 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + successThreshold: 1 + # LLM orchestration health endpoint + httpPath: "/health" + # Additional readiness checks + readinessPath: "/ready" + +# Vault Agent sidecar configuration +# WHY: LLM Orchestration needs read access to encrypted LLM API keys +# Security: Agent enforces policy - read-only access to LLM secrets +vaultAgent: + enabled: true + diff --git a/kubernetes/charts/Langfuse-Web/Chart.yaml b/kubernetes/charts/Langfuse-Web/Chart.yaml new file mode 100644 index 00000000..041da91b --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Langfuse-Web +description: Langfuse web interface and API for LLM observability +type: application +version: 0.1.0 +appVersion: "3" \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml new file mode 100644 index 00000000..18d14804 --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml @@ -0,0 +1,84 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: langfuse-web +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: langfuse-web + spec: + initContainers: + - name: wait-for-clickhouse + image: busybox:1.35 + command: + - sh + - -c + - | + echo "Waiting for ClickHouse on port 9000..." + until nc -z clickhouse 9000 2>/dev/null; do + echo "ClickHouse not ready yet, retrying in 5s..." + sleep 5 + done + echo "ClickHouse is ready." + - name: wait-for-postgres + image: busybox:1.35 + command: + - sh + - -c + - | + echo "Waiting for PostgreSQL on port 5432..." + until nc -z rag-search-db 5432 2>/dev/null; do + echo "PostgreSQL not ready yet, retrying in 5s..." + sleep 5 + done + echo "PostgreSQL is ready." + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.langfuse_web.registry }}/{{ .Values.images.langfuse_web.repository }}:{{ .Values.images.langfuse_web.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: /api/public/health + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: /api/public/health + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + restartPolicy: Always +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Web/templates/secret.yaml b/kubernetes/charts/Langfuse-Web/templates/secret.yaml new file mode 100644 index 00000000..1c3ae5c1 --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/templates/secret.yaml @@ -0,0 +1,25 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: langfuse-web-secrets + labels: + app: "{{ .Values.release_name }}" + component: langfuse-web +type: Opaque +stringData: + DATABASE_URL: "" + NEXTAUTH_SECRET: "" + ENCRYPTION_KEY: "" + SALT: "" + CLICKHOUSE_MIGRATION_URL: "" + CLICKHOUSE_USER: "" + CLICKHOUSE_PASSWORD: "" + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "" + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "" + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: "" + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "" + LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: "" + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "" + REDIS_AUTH: "" +{{- end }} diff --git a/kubernetes/charts/Langfuse-Web/templates/service-byk-langfuse-web.yaml b/kubernetes/charts/Langfuse-Web/templates/service-byk-langfuse-web.yaml new file mode 100644 index 00000000..9594b424 --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/templates/service-byk-langfuse-web.yaml @@ -0,0 +1,18 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: langfuse-web +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Web/values.yaml b/kubernetes/charts/Langfuse-Web/values.yaml new file mode 100644 index 00000000..6dfaf1cf --- /dev/null +++ b/kubernetes/charts/Langfuse-Web/values.yaml @@ -0,0 +1,97 @@ +replicas: 1 +enabled: true + +images: + langfuse_web: + registry: "docker.io" + repository: "langfuse/langfuse" + tag: "3" + +release_name: "langfuse-web" + +service: + type: ClusterIP + port: 3005 + targetPort: 3000 + +# Environment variables +env: + # Non-sensitive configuration + NEXTAUTH_URL: "http://localhost:3000" + TELEMETRY_ENABLED: "true" + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" + + # ClickHouse configuration (non-sensitive) + CLICKHOUSE_URL: "http://clickhouse:8123" + CLICKHOUSE_CLUSTER_ENABLED: "false" + + # S3/MinIO configuration (non-sensitive) + LANGFUSE_USE_AZURE_BLOB: "false" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto" + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: "langfuse/events/" + + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_MEDIA_UPLOAD_REGION: "auto" + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "langfuse/media/" + + LANGFUSE_S3_BATCH_EXPORT_ENABLED: "false" + LANGFUSE_S3_BATCH_EXPORT_BUCKET: "rag-search" + LANGFUSE_S3_BATCH_EXPORT_PREFIX: "langfuse/exports/" + LANGFUSE_S3_BATCH_EXPORT_REGION: "auto" + LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" + LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" + + # Redis configuration (non-sensitive) + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_TLS_ENABLED: "false" + REDIS_TLS_CA: "" + REDIS_TLS_CERT: "" + REDIS_TLS_KEY: "" + + # Email configuration + EMAIL_FROM_ADDRESS: "" + SMTP_CONNECTION_URL: "" + + # Langfuse initialization (Web-specific) + LANGFUSE_INIT_ORG_ID: "" + LANGFUSE_INIT_ORG_NAME: "" + LANGFUSE_INIT_PROJECT_ID: "" + LANGFUSE_INIT_PROJECT_NAME: "" + LANGFUSE_INIT_PROJECT_PUBLIC_KEY: "" + LANGFUSE_INIT_PROJECT_SECRET_KEY: "" + LANGFUSE_INIT_USER_EMAIL: "" + LANGFUSE_INIT_USER_NAME: "" + LANGFUSE_INIT_USER_PASSWORD: "" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: langfuse-web-secrets + + + +resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "1Gi" + cpu: "500m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Worker/Chart.yaml b/kubernetes/charts/Langfuse-Worker/Chart.yaml new file mode 100644 index 00000000..4117b9c0 --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Langfuse-Worker +description: Langfuse background worker for LLM observability +type: application +version: 0.1.0 +appVersion: "3" \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml b/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml new file mode 100644 index 00000000..1ab3c559 --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/templates/deployment-byk-langfuse-worker.yaml @@ -0,0 +1,65 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: langfuse-worker +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: langfuse-worker + spec: + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.langfuse_worker.registry }}/{{ .Values.images.langfuse_worker.repository }}:{{ .Values.images.langfuse_worker.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - name: worker + containerPort: {{ .Values.service.port }} + protocol: TCP + # Non-sensitive env's from values.yaml + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: /api/public/health + port: {{ .Values.service.port }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: /api/public/health + port: {{ .Values.service.port }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + restartPolicy: Always +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Worker/templates/secret.yaml b/kubernetes/charts/Langfuse-Worker/templates/secret.yaml new file mode 100644 index 00000000..d7ec52bd --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/templates/secret.yaml @@ -0,0 +1,24 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: langfuse-worker-secrets + labels: + app: "{{ .Values.release_name }}" + component: langfuse-worker +type: Opaque +stringData: + DATABASE_URL: "" + ENCRYPTION_KEY: "" + SALT: "" + CLICKHOUSE_MIGRATION_URL: "" + CLICKHOUSE_USER: "" + CLICKHOUSE_PASSWORD: "" + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: "" + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: "" + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: "" + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: "" + LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: "" + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: "" + REDIS_AUTH: "" +{{- end }} diff --git a/kubernetes/charts/Langfuse-Worker/templates/service-byk-langfuse-worker.yaml b/kubernetes/charts/Langfuse-Worker/templates/service-byk-langfuse-worker.yaml new file mode 100644 index 00000000..da32c5c2 --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/templates/service-byk-langfuse-worker.yaml @@ -0,0 +1,18 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: langfuse-worker +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: worker + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Worker/values.yaml b/kubernetes/charts/Langfuse-Worker/values.yaml new file mode 100644 index 00000000..0a7343eb --- /dev/null +++ b/kubernetes/charts/Langfuse-Worker/values.yaml @@ -0,0 +1,84 @@ +replicas: 1 +enabled: true + +images: + langfuse_worker: + registry: "docker.io" + repository: "langfuse/langfuse-worker" + tag: "3" + +release_name: "langfuse-worker" + +service: + type: ClusterIP + port: 3030 + +# Environment variables +env: + # Non-sensitive configuration + NEXTAUTH_URL: "http://localhost:3000" + TELEMETRY_ENABLED: "true" + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" + + # ClickHouse configuration (non-sensitive) + CLICKHOUSE_URL: "http://clickhouse:8123" + CLICKHOUSE_CLUSTER_ENABLED: "false" + + # S3/MinIO configuration (non-sensitive) + LANGFUSE_USE_AZURE_BLOB: "false" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_EVENT_UPLOAD_REGION: "auto" + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: "langfuse/events/" + + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: "rag-search" + LANGFUSE_S3_MEDIA_UPLOAD_REGION: "auto" + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: "langfuse/media/" + + LANGFUSE_S3_BATCH_EXPORT_ENABLED: "false" + LANGFUSE_S3_BATCH_EXPORT_BUCKET: "rag-search" + LANGFUSE_S3_BATCH_EXPORT_PREFIX: "langfuse/exports/" + LANGFUSE_S3_BATCH_EXPORT_REGION: "auto" + LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: "http://minio:9000" + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" + LANGFUSE_INGESTION_QUEUE_DELAY_MS: "" + LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS: "" + + # Redis configuration (non-sensitive) + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_TLS_ENABLED: "false" + REDIS_TLS_CA: "" + REDIS_TLS_CERT: "" + REDIS_TLS_KEY: "" + + # Email configuration + EMAIL_FROM_ADDRESS: "" + SMTP_CONNECTION_URL: "" + +# Reference to Kubernetes Secret +# Sensitive credentials should be set in templates/secret.yaml before deployment +envFrom: + - secretRef: + name: langfuse-worker-secrets + +resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "500m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 \ No newline at end of file diff --git a/kubernetes/charts/Liquibase/Chart.yaml b/kubernetes/charts/Liquibase/Chart.yaml new file mode 100644 index 00000000..78f3d45f --- /dev/null +++ b/kubernetes/charts/Liquibase/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Liquibase +description: A Helm chart for Liquibase for database migrations +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Liquibase/templates/liquibase-job.yaml b/kubernetes/charts/Liquibase/templates/liquibase-job.yaml new file mode 100644 index 00000000..5bb307ac --- /dev/null +++ b/kubernetes/charts/Liquibase/templates/liquibase-job.yaml @@ -0,0 +1,75 @@ +{{- if .Values.enabled }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + backoffLimit: {{ .Values.backoffLimit }} + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + restartPolicy: OnFailure + volumes: + - name: liquibase-repo + emptyDir: {} + initContainers: + - name: git-clone + image: alpine/git:latest + volumeMounts: + - name: liquibase-repo + mountPath: /liquibase-files + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + cp -r /tmp/rag/DSL/Liquibase_production/* /liquibase-files + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.scope.repository }}:{{ .Values.images.scope.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + env: + {{- range .Values.env }} + - name: {{ .name }} + value: "{{ .value }}" + {{- end }} + # Sensitive env's from Kubernetes Secret + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: liquibase-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: liquibase-secrets + key: POSTGRES_PASSWORD + + volumeMounts: + - name: liquibase-repo + mountPath: /liquibase-files + command: ["/bin/sh", "-c"] + args: + - | + echo "--- Listing files in /liquibase-files ---" + ls -R /liquibase-files + cd /liquibase-files + echo "--- Now running Liquibase ---" + liquibase \ + --defaultsFile=/liquibase-files/liquibase.properties \ + --changeLogFile=changelog.yaml \ + --url=jdbc:postgresql://rag-search-db:5432/llm_production \ + --username=$(POSTGRES_USER) \ + --password=$(POSTGRES_PASSWORD) \ + update + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Liquibase/templates/secret.yaml b/kubernetes/charts/Liquibase/templates/secret.yaml new file mode 100644 index 00000000..90b0eb75 --- /dev/null +++ b/kubernetes/charts/Liquibase/templates/secret.yaml @@ -0,0 +1,12 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: liquibase-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + POSTGRES_USER: "" + POSTGRES_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/Liquibase/values.yaml b/kubernetes/charts/Liquibase/values.yaml new file mode 100644 index 00000000..69ed775e --- /dev/null +++ b/kubernetes/charts/Liquibase/values.yaml @@ -0,0 +1,21 @@ +enabled: true +release_name: "component-byk-liquibase" +backoffLimit: 3 + +images: + scope: + repository: "liquibase/liquibase" + tag: "4.33.0" + +env: + - name: LIQUIBASE_URL + value: "jdbc:postgresql://rag-search-db:5432/llm_production" + - name: LIQUIBASE_CHANGELOG_FILE + value: changelog.yaml + + + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "211bdc77c12b" \ No newline at end of file diff --git a/kubernetes/charts/Loki/Chart.yaml b/kubernetes/charts/Loki/Chart.yaml new file mode 100644 index 00000000..570e167c --- /dev/null +++ b/kubernetes/charts/Loki/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Loki +description: A Helm chart for Loki +type: application +version: 0.1.0 +appVersion: "2.9.0" \ No newline at end of file diff --git a/kubernetes/charts/Loki/templates/configmap-loki.yaml b/kubernetes/charts/Loki/templates/configmap-loki.yaml new file mode 100644 index 00000000..ebee18b3 --- /dev/null +++ b/kubernetes/charts/Loki/templates/configmap-loki.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: loki-config + labels: + app: loki +data: + loki.yaml: | +{{ .Values.config | toYaml | indent 4 }} \ No newline at end of file diff --git a/kubernetes/charts/Loki/templates/deployment-loki.yaml b/kubernetes/charts/Loki/templates/deployment-loki.yaml new file mode 100644 index 00000000..7967b8a3 --- /dev/null +++ b/kubernetes/charts/Loki/templates/deployment-loki.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + spec: + containers: + - name: {{ .Values.release_name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag}}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.port }} + protocol: TCP + volumeMounts: + - name: config + mountPath: /etc/loki/local-config.yaml + {{- if .Values.persistence.enabled }} + - name: storage + mountPath: /loki + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: config + configMap: + name: loki-config + {{- if .Values.persistence.enabled }} + - name: storage + persistentVolumeClaim: + claimName: loki-storage + {{- end }} + \ No newline at end of file diff --git a/kubernetes/charts/Loki/templates/pvc-loki.yaml b/kubernetes/charts/Loki/templates/pvc-loki.yaml new file mode 100644 index 00000000..5d505a52 --- /dev/null +++ b/kubernetes/charts/Loki/templates/pvc-loki.yaml @@ -0,0 +1,17 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: loki-storage + labels: + app: loki +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Loki/templates/service-loki.yaml b/kubernetes/charts/Loki/templates/service-loki.yaml new file mode 100644 index 00000000..84158378 --- /dev/null +++ b/kubernetes/charts/Loki/templates/service-loki.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: {{ .Values.release_name }} + + \ No newline at end of file diff --git a/kubernetes/charts/Loki/values.yaml b/kubernetes/charts/Loki/values.yaml new file mode 100644 index 00000000..1b059e44 --- /dev/null +++ b/kubernetes/charts/Loki/values.yaml @@ -0,0 +1,85 @@ +replicas: 1 + +release_name: "loki" + +image: + repository: grafana/loki + pullPolicy: IfNotPresent + tag: "2.9.0" + +nameOverride: "" +fullnameOverride: "" + +port: 3100 + +service: + type: ClusterIP + port: 3100 + targetPort: 3100 + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +# Loki configuration - will be mounted as ConfigMap +config: + auth_enabled: false + + server: + http_listen_port: 3100 + grpc_listen_port: 9096 + + common: + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + instance_addr: 127.0.0.1 + kvstore: + store: inmemory + + query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + + schema_config: + configs: + - from: 2020-10-24 + store: boltdb-shipper + object_store: filesystem + schema: v11 + index: + prefix: index_ + period: 24h + + ruler: + alertmanager_url: http://localhost:9093 + +# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration +# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/ +# +# Statistics help us better understand how Loki is used, and they show us performance +# levels for most users. This helps us prioritize features and documentation. +# For more information on what's sent, look at +# https://github.com/grafana/loki/blob/main/pkg/usagestats/stats.go +# Refer to the buildReport method to see what goes into a report. +# +# If you would like to disable reporting, uncomment the following lines: + analytics: + reporting_enabled: false diff --git a/kubernetes/charts/Qdrant/Chart.yaml b/kubernetes/charts/Qdrant/Chart.yaml new file mode 100644 index 00000000..ec806350 --- /dev/null +++ b/kubernetes/charts/Qdrant/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Qdrant +description: Qdrant vector database for RAG +type: application +version: 0.1.0 +appVersion: "v1.15.1" \ No newline at end of file diff --git a/kubernetes/charts/Qdrant/templates/service-byk-qdrant.yaml b/kubernetes/charts/Qdrant/templates/service-byk-qdrant.yaml new file mode 100644 index 00000000..e0c0e4c6 --- /dev/null +++ b/kubernetes/charts/Qdrant/templates/service-byk-qdrant.yaml @@ -0,0 +1,31 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: qdrant +spec: + type: {{ .Values.service.type }} + {{- if eq .Values.service.type "ClusterIP" }} + {{- if .Values.service.headless }} + clusterIP: None + {{- end }} + {{- end }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: http + protocol: TCP + port: {{ .Values.service.httpPort }} + targetPort: {{ .Values.service.httpPort }} + - name: grpc + protocol: TCP + port: {{ .Values.service.grpcPort }} + targetPort: {{ .Values.service.grpcPort }} + - name: metrics + protocol: TCP + port: {{ .Values.service.metricsPort }} + targetPort: {{ .Values.service.metricsPort }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Qdrant/templates/statefulset-byk-qdrant.yaml b/kubernetes/charts/Qdrant/templates/statefulset-byk-qdrant.yaml new file mode 100644 index 00000000..13d81cb4 --- /dev/null +++ b/kubernetes/charts/Qdrant/templates/statefulset-byk-qdrant.yaml @@ -0,0 +1,82 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: qdrant +spec: + serviceName: "{{ .Values.release_name }}" + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: qdrant + spec: + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.qdrant.registry }}/{{ .Values.images.qdrant.repository }}:{{ .Values.images.qdrant.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.httpPort }} + protocol: TCP + - name: grpc + containerPort: {{ .Values.service.grpcPort }} + protocol: TCP + - name: metrics + containerPort: {{ .Values.service.metricsPort }} + protocol: TCP + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.httpPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.httpPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: qdrant-storage + mountPath: {{ .Values.persistence.mountPath }} + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: qdrant-storage + labels: + app: "{{ .Values.release_name }}" + component: qdrant + spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Qdrant/values.yaml b/kubernetes/charts/Qdrant/values.yaml new file mode 100644 index 00000000..4a83496a --- /dev/null +++ b/kubernetes/charts/Qdrant/values.yaml @@ -0,0 +1,50 @@ +replicas: 1 +enabled: true + +images: + qdrant: + registry: "docker.io" + repository: "qdrant/qdrant" + tag: "v1.15.1" + +release_name: "qdrant" + +service: + type: ClusterIP + # Set to true for headless service (direct pod access) + headless: false + # Qdrant HTTP API port + httpPort: 6333 + # Qdrant gRPC API port + grpcPort: 6334 + # Internal metrics port + metricsPort: 6335 + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 20Gi + mountPath: "/qdrant/storage" + +resources: + requests: + memory: "512Mi" + cpu: "100m" + limits: + memory: "2Gi" + cpu: "1000m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: true + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + # HTTP endpoint for health check + httpPath: "/collections" + + diff --git a/kubernetes/charts/Redis/Chart.yaml b/kubernetes/charts/Redis/Chart.yaml new file mode 100644 index 00000000..cc5354ea --- /dev/null +++ b/kubernetes/charts/Redis/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Redis +description: Redis cache and session store for RAG +type: application +version: 0.1.0 +appVersion: "7" \ No newline at end of file diff --git a/kubernetes/charts/Redis/templates/deployment-byk-redis.yaml b/kubernetes/charts/Redis/templates/deployment-byk-redis.yaml new file mode 100644 index 00000000..f60b6d69 --- /dev/null +++ b/kubernetes/charts/Redis/templates/deployment-byk-redis.yaml @@ -0,0 +1,68 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: redis +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: redis + spec: + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.redis.registry }}/{{ .Values.images.redis.repository }}:{{ .Values.images.redis.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - name: redis + containerPort: {{ .Values.service.port }} + protocol: TCP + {{- if .Values.auth.enabled }} + command: + - redis-server + - --requirepass + - $(REDIS_PASSWORD) + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + {{- if .Values.healthcheck.enabled }} + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + + restartPolicy: Always +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Redis/templates/secret.yaml b/kubernetes/charts/Redis/templates/secret.yaml new file mode 100644 index 00000000..27ad0560 --- /dev/null +++ b/kubernetes/charts/Redis/templates/secret.yaml @@ -0,0 +1,12 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: redis-secrets + labels: + app: "{{ .Values.release_name }}" + component: redis +type: Opaque +stringData: + REDIS_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/Redis/templates/service-byk-redis.yaml b/kubernetes/charts/Redis/templates/service-byk-redis.yaml new file mode 100644 index 00000000..a030f5aa --- /dev/null +++ b/kubernetes/charts/Redis/templates/service-byk-redis.yaml @@ -0,0 +1,18 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: redis +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: redis + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Redis/values.yaml b/kubernetes/charts/Redis/values.yaml new file mode 100644 index 00000000..784f8c1c --- /dev/null +++ b/kubernetes/charts/Redis/values.yaml @@ -0,0 +1,41 @@ +replicas: 1 +enabled: true + +images: + redis: + registry: "docker.io" + repository: "redis" + tag: "7" + +release_name: "redis" + +service: + type: ClusterIP + port: 6379 + +auth: + enabled: true + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: redis-secrets + +# Resource configuration +resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "512Mi" + cpu: "200m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: true + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 \ No newline at end of file diff --git a/kubernetes/charts/Resql/Chart.yaml b/kubernetes/charts/Resql/Chart.yaml new file mode 100644 index 00000000..2de36f8f --- /dev/null +++ b/kubernetes/charts/Resql/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: resql +description: Database abstraction layer for RAG +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml b/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml new file mode 100644 index 00000000..c44dc303 --- /dev/null +++ b/kubernetes/charts/Resql/templates/deployment-byk-resql.yaml @@ -0,0 +1,76 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + volumes: + - name: dsl + emptyDir: {} + initContainers: + - name: git-clone-dsl + image: alpine/git:latest + volumeMounts: + - name: dsl + mountPath: /DSL + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip \ + https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + cp -r /tmp/rag/DSL/Resql/* /DSL/ + + + + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.resql.registry }}/{{ .Values.images.resql.repository }}:{{ .Values.images.resql.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - containerPort: {{ .Values.service.port }} + env: + - name: logging.level.root + value: "{{ .Values.env.LOGGING_LEVEL_ROOT }}" + - name: SQLMS_DATASOURCES_0_NAME + value: "{{ .Values.env.SQLMS_DATASOURCES_0_NAME }}" + - name: SQLMS_DATASOURCES_0_JDBCURL + value: "{{ .Values.env.SQLMS_DATASOURCES_0_JDBCURL }}" + - name: SQLMS_DATASOURCES_0_USERNAME + value: "{{ .Values.env.SQLMS_DATASOURCES_0_USERNAME }}" + # Sensitive env from Kubernetes Secret + - name: SQLMS_DATASOURCES_0_PASSWORD + valueFrom: + secretKeyRef: + name: resql-secrets + key: SQLMS_DATASOURCES_0_PASSWORD + - name: LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BOOT + value: "{{ .Values.env.LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BOOT }}" + - name: SQLMS_SAVED_QUERIES_DIR + value: "/DSL" + volumeMounts: + - name: dsl + mountPath: /DSL + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + +{{- end }} diff --git a/kubernetes/charts/Resql/templates/secret.yaml b/kubernetes/charts/Resql/templates/secret.yaml new file mode 100644 index 00000000..335257b8 --- /dev/null +++ b/kubernetes/charts/Resql/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: resql-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + SQLMS_DATASOURCES_0_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/Resql/templates/service-byk-resql.yaml b/kubernetes/charts/Resql/templates/service-byk-resql.yaml new file mode 100644 index 00000000..3312d10d --- /dev/null +++ b/kubernetes/charts/Resql/templates/service-byk-resql.yaml @@ -0,0 +1,14 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + selector: + app: "{{ .Values.release_name }}" + ports: + - protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Resql/values.yaml b/kubernetes/charts/Resql/values.yaml new file mode 100644 index 00000000..14612577 --- /dev/null +++ b/kubernetes/charts/Resql/values.yaml @@ -0,0 +1,34 @@ +replicas: 1 +enabled: true +images: + resql: + registry: "ghcr.io" + repository: "buerokratt/resql" + tag: "v1.3.4" + +release_name: "resql" + +service: + type: ClusterIP + port: 8082 + +env: + LOGGING_LEVEL_ROOT: "INFO" + SQLMS_DATASOURCES_0_NAME: "byk" + SQLMS_DATASOURCES_0_JDBCURL: "jdbc:postgresql://rag-search-db:5432/llm_production" + SQLMS_DATASOURCES_0_USERNAME: "postgres" + LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_BOOT: "INFO" + JAVA_OPTS: "-Xms1g -Xmx3g" + +resources: + requests: + memory: "1000Mi" + cpu: "50m" + limits: + memory: "4Gi" + cpu: "50m" + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "initial" \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/Chart.yaml b/kubernetes/charts/Ruuter-Private/Chart.yaml new file mode 100644 index 00000000..845f24ec --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ruuter-private +description: A Helm chart for Ruuter Private API Gateway +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml new file mode 100644 index 00000000..6f84c283 --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.release_name }}-constants" + labels: + app: "{{ .Values.release_name }}" +data: + constants.ini: | + [DSL] + RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086/rag-search + RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088/rag-search + RAG_SEARCH_DMAPPER=http://data-mapper:3000 + RAG_SEARCH_RESQL=http://resql:8082/rag-search + RAG_SEARCH_PROJECT_LAYER=rag-search + RAG_SEARCH_TIM=http://tim:8085 + RAG_SEARCH_CRON_MANAGER=http://cron-manager:9010 + RAG_SEARCH_LLM_ORCHESTRATOR=http://llm-orchestration-service:8100/orchestrate + DOMAIN=localhost \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml new file mode 100644 index 00000000..866d3a7d --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/deployment-byk-ruuter-private.yaml @@ -0,0 +1,98 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + initContainers: + - name: git-clone + image: alpine/git:latest + volumeMounts: + - name: dsl + mountPath: /DSL + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + cp -r /tmp/rag/DSL/Ruuter.private/* /DSL/ + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.scope.registry }}/{{ .Values.images.scope.repository }}:{{ .Values.images.scope.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + ports: + - containerPort: {{ .Values.service.port }} + name: http + env: + + - name: application.cors.allowedOrigins + value: "{{ .Values.env.APPLICATION_CORS_ALLOWEDORIGINS }}" + - name: application.httpCodesAllowList + value: "{{ .Values.env.APPLICATION_HTTPCODESALLOWLIST }}" + - name: application.internalRequests.allowedIPs + value: "{{ .Values.env.APPLICATION_INTERNALREQUESTS_ALLOWEDIPS }}" + - name: application.logging.displayRequestContent + value: "{{ .Values.env.APPLICATION_LOGGING_DISPLAYREQUESTCONTENT }}" + - name: application.logging.displayResponseContent + value: "{{ .Values.env.APPLICATION_LOGGING_DISPLAYRESPONSECONTENT }}" + - name: application.logging.printStackTrace + value: "{{ .Values.env.APPLICATION_LOGGING_PRINTSTACKTRACE }}" + - name: application.internalRequests.disabled + value: "{{ .Values.env.APPLICATION_INTERNALREQUESTS_DISABLED }}" + - name: server.port + value: "{{ .Values.env.SERVER_PORT }}" + # Sensitive env from Kubernetes Secret + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ruuter-private-secrets + key: DB_PASSWORD + + + - name: logging.level.root + value: "{{ .Values.env.LOGGING_LEVEL_ROOT }}" + - name: LOG_LEVEL_TIMING + value: "{{ .Values.env.LOG_LEVEL_TIMING }}" + - name: application.DSL.allowedFiletypes + value: "{{ .Values.env.APPLICATION_DSL_ALLOWEDFILETYPES }}" + - name: application.httpResponseSizeLimit + value: "{{ .Values.env.APPLICATION_HTTPRESPONSESIZELIMIT }}" + - name: application.openSearchConfiguration.index + value: "{{ .Values.env.APPLICATION_OPENSEARCHCONFIGURATION_INDEX }}" + volumeMounts: + - name: dsl + mountPath: /DSL + - name: urls-env + mountPath: /app/constants.ini + subPath: constants.ini + + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + volumes: + - name: dsl + emptyDir: {} + - name: urls-env + configMap: + name: "{{ .Values.release_name }}-constants" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/templates/ingress-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/ingress-ruuter-private.yaml new file mode 100644 index 00000000..94655a6d --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/ingress-ruuter-private.yaml @@ -0,0 +1,46 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "{{ .Values.release_name }}-ingress" + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Forwarded-For" + nginx.ingress.kubernetes.io/cors-allow-origin: "{{ .Values.ingress.corsAllowOrigin }}" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + nginx.ingress.kubernetes.io/additional-response-headers: "Access-Control-Allow-Headers: Content-Type" + nginx.ingress.kubernetes.io/cors-expose-headers: "Content-Length, Content-Range" + nginx.ingress.kubernetes.io/rewrite-target: /$1 + # Private Ruuter may need IP whitelisting for security + nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.1/32" + {{- if .Values.ingress.ssl.enabled }} + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + cert-manager.io/cluster-issuer: {{ .Values.ingress.ssl.certIssuerName | quote }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + name: "{{ .Values.release_name }}-ingress" + app: "{{ .Values.release_name }}" +spec: + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: "{{ .Values.release_name }}" + port: + number: {{ .Values.service.port }} + {{- if .Values.ingress.ssl.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.ssl.secretName }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/templates/secret.yaml b/kubernetes/charts/Ruuter-Private/templates/secret.yaml new file mode 100644 index 00000000..1db3b29a --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: ruuter-private-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + DB_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/Ruuter-Private/templates/service-byk-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/service-byk-ruuter-private.yaml new file mode 100644 index 00000000..c6d67227 --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/templates/service-byk-ruuter-private.yaml @@ -0,0 +1,17 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: "{{ .Values.release_name }}" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/values.yaml b/kubernetes/charts/Ruuter-Private/values.yaml new file mode 100644 index 00000000..3a48ac17 --- /dev/null +++ b/kubernetes/charts/Ruuter-Private/values.yaml @@ -0,0 +1,58 @@ +replicas: 1 +enabled: true +release_name: "ruuter-private" + +images: + scope: + registry: "ghcr.io" + repository: "buerokratt/ruuter" + tag: "v2.2.1" + +service: + type: ClusterIP + port: 8088 + targetPort: 8088 + +env: + + APPLICATION_CORS_ALLOWEDORIGINS: "http://gui:3001,http://ruuter-private:8088,http://ruuter-public:8086,http://authentication-layer:3004,http://notifications-node:4040,http://dataset-gen-service:8000,http://localhost:3001" + APPLICATION_HTTPCODESALLOWLIST: "200,201,202,400,401,403,500" + APPLICATION_INTERNALREQUESTS_ALLOWEDIPS: "127.0.0.1" + APPLICATION_LOGGING_DISPLAYREQUESTCONTENT: "true" + APPLICATION_LOGGING_DISPLAYRESPONSECONTENT: "true" + APPLICATION_LOGGING_PRINTSTACKTRACE: "true" + APPLICATION_INTERNALREQUESTS_DISABLED: "true" + + + + LOGGING_LEVEL_ROOT: "INFO" + LOG_LEVEL_TIMING: "INFO" + APPLICATION_DSL_ALLOWEDFILETYPES: ".yml,.yaml,.md,.tmp" + APPLICATION_HTTPRESPONSESIZELIMIT: "2000" + APPLICATION_OPENSEARCHCONFIGURATION_INDEX: "ruuterlog" + SERVER_PORT: "8088" + +resources: + requests: + memory: "1000Mi" + cpu: "50m" + limits: + memory: "2000Mi" + cpu: "50m" + + +ingress: + enabled: false + host: "rag.local" #change this to domain + corsAllowOrigin: "http://localhost:3001,http://localhost:3003,http://localhost:8088,http://localhost:3002,http://localhost:3004,http://localhost:8000" + ssl: + enabled: false + certIssuerName: "letsencrypt-prod" + secretName: "rag-ruuter-private-tls" + annotations: {} + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "initial" + diff --git a/kubernetes/charts/Ruuter-Public/Chart.yaml b/kubernetes/charts/Ruuter-Public/Chart.yaml new file mode 100644 index 00000000..662e775e --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: ruuter-public +description: A Helm chart for Ruuter Public API Gateway +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml new file mode 100644 index 00000000..a6a56c0c --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.release_name }}-constants" + labels: + app: "{{ .Values.release_name }}" +data: + constants.ini: | + [DSL] + RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086/rag-search + RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088/rag-search + RAG_SEARCH_DMAPPER=http://data-mapper:3000 + RAG_SEARCH_RESQL=http://resql:8082/rag-search + RAG_SEARCH_PROJECT_LAYER=rag-search + RAG_SEARCH_TIM=http://tim:8085 + RAG_SEARCH_CRON_MANAGER=http://cron-manager:9010 + RAG_SEARCH_LLM_ORCHESTRATOR=http://llm-orchestration-service:8100/orchestrate + DOMAIN=localhost \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml new file mode 100644 index 00000000..e0814302 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/deployment-byk-ruuter-public.yaml @@ -0,0 +1,97 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + annotations: + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + app: "{{ .Values.release_name }}" + spec: + initContainers: + - name: git-clone + image: alpine/git:latest + volumeMounts: + - name: dsl + mountPath: /DSL + command: + - sh + - -c + - | + git clone --single-branch --depth 1 --branch wip https://github.com/rootcodelabs/RAG-Module /tmp/rag && + + cp -r /tmp/rag/DSL/Ruuter.public/* /DSL/ + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.scope.registry }}/{{ .Values.images.scope.repository }}:{{ .Values.images.scope.tag }}" + ports: + - containerPort: {{ .Values.service.port }} + name: http + env: + - name: application.cors.allowedOrigins + value: "{{ .Values.env.APPLICATION_CORS_ALLOWEDORIGINS }}" + - name: application.httpCodesAllowList + value: "{{ .Values.env.APPLICATION_HTTPCODESALLOWLIST }}" + - name: application.internalRequests.allowedIPs + value: "{{ .Values.env.APPLICATION_INTERNALREQUESTS_ALLOWEDIPS }}" + - name: application.logging.displayRequestContent + value: "{{ .Values.env.APPLICATION_LOGGING_DISPLAYREQUESTCONTENT }}" + - name: application.logging.displayResponseContent + value: "{{ .Values.env.APPLICATION_LOGGING_DISPLAYRESPONSECONTENT }}" + - name: application.logging.printStackTrace + value: "{{ .Values.env.APPLICATION_LOGGING_PRINTSTACKTRACE }}" + - name: application.internalRequests.disabled + value: "{{ .Values.env.APPLICATION_INTERNALREQUESTS_DISABLED }}" + - name: server.port + value: "{{ .Values.env.SERVER_PORT }}" + - name: application.constants.file + value: "/app/constants.ini" + # Sensitive env from Kubernetes Secret + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: ruuter-public-secrets + key: DB_PASSWORD + + - name: logging.level.root + value: "{{ .Values.env.LOGGING_LEVEL_ROOT }}" + - name: LOG_LEVEL_TIMING + value: "{{ .Values.env.LOG_LEVEL_TIMING }}" + - name: application.DSL.allowedFiletypes + value: "{{ .Values.env.APPLICATION_DSL_ALLOWEDFILETYPES }}" + - name: application.httpResponseSizeLimit + value: "{{ .Values.env.APPLICATION_HTTPRESPONSESIZELIMIT }}" + - name: application.openSearchConfiguration.index + value: "{{ .Values.env.APPLICATION_OPENSEARCHCONFIGURATION_INDEX }}" + volumeMounts: + - name: dsl + mountPath: /DSL + - name: urls-env + mountPath: /app/constants.ini + subPath: constants.ini + + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + volumes: + - name: dsl + emptyDir: {} + - name: urls-env + configMap: + name: "{{ .Values.release_name }}-constants" +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Public/templates/ingress-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/ingress-ruuter-public.yaml new file mode 100644 index 00000000..3a1e4c55 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/ingress-ruuter-public.yaml @@ -0,0 +1,45 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "{{ .Values.release_name }}-ingress" + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization,X-Forwarded-For" + nginx.ingress.kubernetes.io/cors-allow-origin: "{{ .Values.ingress.corsAllowOrigin }}" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + nginx.ingress.kubernetes.io/additional-response-headers: "Access-Control-Allow-Headers: Content-Type" + nginx.ingress.kubernetes.io/cors-expose-headers: "Content-Length, Content-Range" + nginx.ingress.kubernetes.io/rewrite-target: /$1 + {{- if .Values.ingress.ssl.enabled }} + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + cert-manager.io/cluster-issuer: {{ .Values.ingress.ssl.certIssuerName | quote }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + name: "{{ .Values.release_name }}-ingress" + app: "{{ .Values.release_name }}" +spec: + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: "{{ .Values.release_name }}" + port: + number: {{ .Values.service.port }} + + {{- if .Values.ingress.ssl.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + secretName: {{ .Values.ingress.ssl.secretName }} + {{- end }} +{{- end }} diff --git a/kubernetes/charts/Ruuter-Public/templates/secret.yaml b/kubernetes/charts/Ruuter-Public/templates/secret.yaml new file mode 100644 index 00000000..e9f76ce1 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: ruuter-public-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + DB_PASSWORD: "" +{{- end }} diff --git a/kubernetes/charts/Ruuter-Public/templates/service-byk-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/service-byk-ruuter-public.yaml new file mode 100644 index 00000000..6e10cd82 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/templates/service-byk-ruuter-public.yaml @@ -0,0 +1,18 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: "{{ .Values.release_name }}" + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Public/values.yaml b/kubernetes/charts/Ruuter-Public/values.yaml new file mode 100644 index 00000000..320d43f6 --- /dev/null +++ b/kubernetes/charts/Ruuter-Public/values.yaml @@ -0,0 +1,54 @@ +replicas: 1 +enabled: true +release_name: "ruuter-public" + +images: + scope: + registry: "ghcr.io" + repository: "buerokratt/ruuter" + tag: v2.2.1 + +service: + type: ClusterIP + port: 8086 + targetPort: 8086 + +env: + APPLICATION_CORS_ALLOWEDORIGINS: "http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" + APPLICATION_HTTPCODESALLOWLIST: "200,201,202,204,400,401,403,500" + APPLICATION_INTERNALREQUESTS_ALLOWEDIPS: "127.0.0.1" + APPLICATION_LOGGING_DISPLAYREQUESTCONTENT: "true" + APPLICATION_LOGGING_DISPLAYRESPONSECONTENT: "true" + APPLICATION_LOGGING_PRINTSTACKTRACE: "true" + APPLICATION_INTERNALREQUESTS_DISABLED: "true" + SERVER_PORT: "8086" + + LOGGING_LEVEL_ROOT: "INFO" + LOG_LEVEL_TIMING: "INFO" + APPLICATION_DSL_ALLOWEDFILETYPES: ".yml,.yaml,.md,.tmp" + APPLICATION_HTTPRESPONSESIZELIMIT: "2000" + APPLICATION_OPENSEARCHCONFIGURATION_INDEX: "ruuterlog" + +resources: + requests: + memory: "1000Mi" + cpu: "50m" + limits: + memory: "2000Mi" + cpu: "50m" + + +ingress: + enabled: true + host: "rag.local" # Change this to domain + corsAllowOrigin: "http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" + ssl: + enabled: false # Set to true for production with proper certificates + certIssuerName: "letsencrypt-prod" + secretName: "rag-ruuter-tls" + + +pullPolicy: IfNotPresent + +podAnnotations: + dsl-checksum: "94b84bb5ff4d" diff --git a/kubernetes/charts/S3-Ferry/Chart.yaml b/kubernetes/charts/S3-Ferry/Chart.yaml new file mode 100644 index 00000000..882054c1 --- /dev/null +++ b/kubernetes/charts/S3-Ferry/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: S3-Ferry +description: A Helm chart for S3-Ferry +type: application +version: 0.1.0 +appVersion: "latest" \ No newline at end of file diff --git a/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml b/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml new file mode 100644 index 00000000..af396e8c --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/deployment-s3.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + spec: + containers: + - name: {{ .Values.release_name }} + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.port }} + protocol: TCP + # Non-sensitive env's from ConfigMap + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: shared + mountPath: /app/shared + - name: cron-data + mountPath: /app/data + {{- end }} + - name: datasets + mountPath: /app/datasets + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + {{- if .Values.persistence.enabled }} + - name: shared + persistentVolumeClaim: + claimName: s3-ferry-shared + - name: cron-data + persistentVolumeClaim: + claimName: s3-ferry-cron-data + {{- end }} + - name: datasets + emptyDir: {} + \ No newline at end of file diff --git a/kubernetes/charts/S3-Ferry/templates/pvc-s3.yaml b/kubernetes/charts/S3-Ferry/templates/pvc-s3.yaml new file mode 100644 index 00000000..f973360c --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/pvc-s3.yaml @@ -0,0 +1,36 @@ +{{- if .Values.persistence.enabled }} +# Shared volume PVC +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: s3-ferry-shared + labels: + app: s3-ferry +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.shared.size }} + +--- +# Cron data PVC +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: s3-ferry-cron-data + labels: + app: s3-ferry +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.cronData.size }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/S3-Ferry/templates/secret.yaml b/kubernetes/charts/S3-Ferry/templates/secret.yaml new file mode 100644 index 00000000..ac341bd8 --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: s3-ferry-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + S3_SECRET_ACCESS_KEY: "" + S3_ACCESS_KEY_ID: "" + GF_SECURITY_ADMIN_USER: "" + GF_SECURITY_ADMIN_PASSWORD: "" diff --git a/kubernetes/charts/S3-Ferry/templates/service-s3.yaml b/kubernetes/charts/S3-Ferry/templates/service-s3.yaml new file mode 100644 index 00000000..84ff6d0a --- /dev/null +++ b/kubernetes/charts/S3-Ferry/templates/service-s3.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + app: {{ .Values.release_name }} \ No newline at end of file diff --git a/kubernetes/charts/S3-Ferry/values.yaml b/kubernetes/charts/S3-Ferry/values.yaml new file mode 100644 index 00000000..6274d414 --- /dev/null +++ b/kubernetes/charts/S3-Ferry/values.yaml @@ -0,0 +1,62 @@ +replicas: 1 + +release_name: "s3-ferry" + +image: + registry: "ghcr.io" + repository: "buerokratt/s3-ferry" + pullPolicy: IfNotPresent + tag: "PRE-ALPHA-1.1.1" + +nameOverride: "" +fullnameOverride: "" + +port: 3000 + +service: + type: ClusterIP + port: 3006 + targetPort: 3000 + + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + outputDatasets: + size: 5Gi + shared: + size: 2Gi + cronData: + size: 3Gi + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +# Environment variables (non-sensitive) +env: + API_CORS_ORIGIN: "*" + API_DOCUMENTATION_ENABLED: "true" + S3_REGION: "eu-west-1" + S3_ENDPOINT_URL: "http://minio:9000" + S3_ENDPOINT_NAME: "minio:9000" + S3_DATA_BUCKET_PATH: "resources" + S3_DATA_BUCKET_NAME: "rag-search" + FS_DATA_DIRECTORY_PATH: "/app" + S3_HEALTH_ENDPOINT: "http://minio:9000/minio/health/live" + MINIO_BROWSER_REDIRECT_URL: "http://localhost:9091" + GF_USERS_ALLOW_SIGN_UP: "false" + PORT: "3000" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: s3-ferry-secrets + + + diff --git a/kubernetes/charts/TIM/Chart.yaml b/kubernetes/charts/TIM/Chart.yaml new file mode 100644 index 00000000..7ac0a745 --- /dev/null +++ b/kubernetes/charts/TIM/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: TIM +description: TIM Authentication Service for RAG +type: application +version: 0.1.0 +appVersion: "1.0" diff --git a/kubernetes/charts/TIM/templates/configmap-byk-tim.yaml b/kubernetes/charts/TIM/templates/configmap-byk-tim.yaml new file mode 100644 index 00000000..58f6986b --- /dev/null +++ b/kubernetes/charts/TIM/templates/configmap-byk-tim.yaml @@ -0,0 +1,56 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: tim-config +data: + application.properties: | + security.oauth2.client.client-id={{ .Values.tim.config.oauth2_client_id }} + security.oauth2.client.client-secret=${OAUTH2_CLIENT_SECRET} + security.oauth2.client.scope={{ .Values.tim.config.oauth2_client_scope }} + security.oauth2.client.registered-redirect-uri=https://tim.{{ .Values.global.domain }}/authenticate + security.oauth2.client.user-authorization-uri={{ .Values.tim.config.oauth2_user_auth_uri }} + security.oauth2.client.access-token-uri={{ .Values.tim.config.oauth2_access_token_uri }} + security.oauth2.resource.jwk.key-set-uri={{ .Values.tim.config.oauth2_jwk_uri }} + security.allowlist.jwt=0.0.0.0/0 + security.cookie.same-site=Lax + frontpage.redirect.url=http://localhost:3004 + + logging.level.root={{ .Values.tim.config.logging_level_root }} + + spring.datasource.url=jdbc:postgresql://tim-postgresql:5432/tim + spring.datasource.username={{ .Values.global.tim_postgresql.auth.username }} + spring.datasource.password=${POSTGRES_PASSWORD} + spring.datasource.driver-class-name=org.postgresql.Driver + spring.liquibase.change-log=classpath:master.xml + + spring.profiles.active={{ .Values.tim.config.spring_profiles_active }} + + # Legacy integration properties + legacy-portal-integration.sessionCookieName={{ .Values.tim.config.legacy_cookie_name }} + legacy-portal-integration.sessionCookieDomain={{ .Values.tim.config.legacy_cookie_domain }} + legacy-portal-integration.taraAuthDeployedOnLegacyDomain=true + legacy-portal-integration.sessionTimeoutMinutes=30 + legacy-portal-integration.requestIpHeader=X-FORWARDED-FOR + legacy-portal-integration.requestIpAttribute=request_ip + legacy-portal-integration.redirectUrlHeader=Referer + legacy-portal-integration.redirectUrlAttribute=url_redirect + legacy-portal-integration.legacyPortalRefererMarker={{ .Values.tim.config.legacy_referer_marker }} + legacy-portal-integration.legacyUrl={{ .Values.tim.config.legacy_url }} + + # JWT configuration + jwt-integration.signature.key-store=classpath:jwtkeystore.jks + jwt-integration.signature.key-store-password=${KEY_STORE_PASSWORD} + jwt-integration.signature.keyStoreType=JKS + jwt-integration.signature.keyAlias=jwtsign + jwt-integration.signature.issuer={{ .Values.tim.config.jwt_issuer }} + jwt-integration.signature.cookieName=JWTTOKEN + + userIPHeaderName=x-forwarded-for + userIPLoggingPrefix=from IP + userIPLoggingMDCkey=userIP + + headers.contentSecurityPolicy=upgrade-insecure-requests;default-src 'self' 'unsafe-inline' 'unsafe-eval' https://tim.{{ .Values.global.domain }} https://admin.{{ .Values.global.domain }} https://ruuter.{{ .Values.global.domain }}/v2/public/ https://ruuter.{{ .Values.global.domain }}/v2/private/ tim ruuter ruuter-private backoffice-login;object-src 'self';script-src 'self' 'unsafe-inline' 'unsafe-eval' https://{{ .Values.global.domain }} https://admin.{{ .Values.global.domain }} https://tim.{{ .Values.global.domain }};connect-src 'self' https://{{ .Values.global.domain }} https://tim.{{ .Values.global.domain }} https://admin.{{ .Values.global.domain }} https://ruuter.{{ .Values.global.domain }}/v2/public/ https://ruuter.{{ .Values.global.domain }}/v2/private/;frame-src 'self';media-src 'none' + cors.allowedOrigins=http://localhost:8086,http://localhost:3004,http://localhost:8085,http://component-byk-ruuter-public:8086,http://global-classifier.local + auth.success.redirect.whitelist=http://localhost:3004/auth/callback,http://localhost:8086,http://global-classifier.local/auth/callback + server.port={{ .Values.tim.service.port }} + jwt.whitelist.period=30000 \ No newline at end of file diff --git a/kubernetes/charts/TIM/templates/deployment-byk-tim.yaml b/kubernetes/charts/TIM/templates/deployment-byk-tim.yaml new file mode 100644 index 00000000..10956879 --- /dev/null +++ b/kubernetes/charts/TIM/templates/deployment-byk-tim.yaml @@ -0,0 +1,46 @@ +{{- if .Values.tim.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.tim.nameOverride | default "tim" }} + labels: + app: {{ .Values.tim.nameOverride | default "tim" }} +spec: + replicas: {{ .Values.tim.replicaCount | default 1 }} + selector: + matchLabels: + app: {{ .Values.tim.nameOverride | default "tim" }} + template: + metadata: + labels: + app: {{ .Values.tim.nameOverride | default "tim" }} + spec: + containers: + - name: {{ .Values.tim.nameOverride | default "tim" }} + image: "{{ .Values.tim.image.repository }}:{{ .Values.tim.image.tag }}" + imagePullPolicy: {{ .Values.tim.image.pullPolicy }} + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: tim-env-secret + key: POSTGRES_PASSWORD + - name: "OAUTH2_CLIENT_SECRET" + valueFrom: + secretKeyRef: + name: "tim-env-secret" + key: "oauth2_client_secret" + - name: "KEY_STORE_PASSWORD" + valueFrom: + secretKeyRef: + name: "tim-env-secret" + key: "jwt_integration_key_store_password" + volumeMounts: + - name: application-properties + mountPath: /workspace/app/src/main/resources/application.properties + subPath: application.properties + volumes: + - name: application-properties + configMap: + name: tim-config +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM/templates/ingress.yaml b/kubernetes/charts/TIM/templates/ingress.yaml new file mode 100644 index 00000000..129ff01c --- /dev/null +++ b/kubernetes/charts/TIM/templates/ingress.yaml @@ -0,0 +1,30 @@ +{{- if and .Values.tim.enabled .Values.tim.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Values.tim.nameOverride | default "tim" }}-ingress + namespace: {{ .Values.namespace }} + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + cert-manager.io/cluster-issuer: letsencrypt-prod + labels: + name: {{ .Values.tim.nameOverride | default "tim" }}-ingress +spec: + rules: + - host: {{ .Values.tim.ingress.host }} + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: {{ .Values.tim.nameOverride | default "tim" }} + port: + number: {{ .Values.tim.service.port }} + tls: + - hosts: + - {{ .Values.tim.ingress.host }} + secretName: {{ .Values.tim.ingress.secretName }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM/templates/secret-byk-tim.yaml b/kubernetes/charts/TIM/templates/secret-byk-tim.yaml new file mode 100644 index 00000000..81fc11a1 --- /dev/null +++ b/kubernetes/charts/TIM/templates/secret-byk-tim.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: tim-env-secret +type: Opaque +stringData: + oauth2_client_secret: "" + jwt_integration_key_store_password: "" + POSTGRES_PASSWORD: "" + diff --git a/kubernetes/charts/TIM/templates/service-byk-tim.yaml b/kubernetes/charts/TIM/templates/service-byk-tim.yaml new file mode 100644 index 00000000..1a1722d3 --- /dev/null +++ b/kubernetes/charts/TIM/templates/service-byk-tim.yaml @@ -0,0 +1,15 @@ +{{- if .Values.tim.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.tim.nameOverride | default "tim" }} + labels: + app: {{ .Values.tim.nameOverride | default "tim" }} +spec: + type: {{ .Values.tim.service.type | default "ClusterIP" }} + ports: + - port: {{ .Values.tim.service.port }} + targetPort: {{ .Values.tim.service.port }} + selector: + app: {{ .Values.tim.nameOverride | default "tim" }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/TIM/values.yaml b/kubernetes/charts/TIM/values.yaml new file mode 100644 index 00000000..daba08c5 --- /dev/null +++ b/kubernetes/charts/TIM/values.yaml @@ -0,0 +1,44 @@ +global: + domain: localhost + tim_postgresql: + auth: + username: tim +tim: + enabled: true + nameOverride: tim + ingress: + enabled: false + host: tim.example.com + secretName: tim-tls + image: + repository: ghcr.io/buerokratt/tim + tag: pre-apha-2.7.1 + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8085 + env: + - POSTGRES_PASSWORD: "tim" + + config: + security_allowlist_jwt: "ruuter-public,ruuter-private,ruuter,ruuter-internal,data-mapper,resql,tim,tim-postgresql,chat-widget,authentication-layer,127.0.0.1,::1" + jwt_issuer: "tim-issuer" + spring_profiles_active: "dev" + logging_level_root: "DEBUG" + legacy_cookie_name: "PHPSESSID" + legacy_cookie_domain: "example.com" + legacy_referer_marker: "NA" + legacy_url: "NA" + oauth2_client_id: "your-client-id" + oauth2_client_scope: "read,write" + oauth2_user_auth_uri: "https://tara-test.ria.ee/oidc/authorize" + oauth2_access_token_uri: "https://tara-test.ria.ee/oidc/token" + oauth2_jwk_uri: "https://tara-test.ria.ee/oidc/jwks" + + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "250m" + memory: "256Mi" \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-Cron/Chart.yaml b/kubernetes/charts/Vault-Agent-Cron/Chart.yaml new file mode 100644 index 00000000..554bf65a --- /dev/null +++ b/kubernetes/charts/Vault-Agent-Cron/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Vault-Agent-Cron +description: Vault Agent configuration for CronManager service (sidecar pattern) +type: application +version: 0.1.0 +appVersion: "1.20.3" diff --git a/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml new file mode 100644 index 00000000..37a7af6e --- /dev/null +++ b/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml @@ -0,0 +1,58 @@ +{{- if .Values.enabled }} +# ConfigMap for CronManager Vault Agent Configuration + +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-agent-cron-config + labels: + app: vault-agent-cron + component: vault-agent +data: + cron-agent.hcl: | + + vault { + address = "http://vault:8200" + retry { + num_retries = 5 + } + } + + # Auto-authentication using AppRole + auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "{{ .Values.agent.credentialsPath }}/cron_role_id" + secret_id_file_path = "{{ .Values.agent.credentialsPath }}/cron_secret_id" + remove_secret_id_file_after_reading = false + } + } + + # Write token to shared volume for agent to use + sink "file" { + config = { + path = "{{ .Values.agent.tokenPath }}/token" + mode = 0640 + } + } + } + + # Caching configuration for CronManager + cache { + default_lease_duration = "{{ .Values.agent.tokenTTL }}" + } + + # API proxy listener - CronManager connects to localhost:{{ .Values.agent.port }} + listener "tcp" { + address = "0.0.0.0:{{ .Values.agent.port }}" + tls_disable = true + } + + # API proxy configuration + api_proxy { + use_auto_auth_token = true + enforce_consistency = "always" + when_inconsistent = "forward" + } +{{- end }} diff --git a/kubernetes/charts/Vault-Agent-Cron/values.yaml b/kubernetes/charts/Vault-Agent-Cron/values.yaml new file mode 100644 index 00000000..2707cac3 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-Cron/values.yaml @@ -0,0 +1,15 @@ +enabled: true + +# Agent configuration +agent: + # Port where agent listens for API requests from CronManager + port: 8203 + + # Token TTL for CronManager + tokenTTL: "30m" + + # Credentials location (shared volume from vault-init) + credentialsPath: "/agent/credentials" + + # Token cache location (separate volume for CronManager) + tokenPath: "/agent/cron-token" diff --git a/kubernetes/charts/Vault-Agent-GUI/Chart.yaml b/kubernetes/charts/Vault-Agent-GUI/Chart.yaml new file mode 100644 index 00000000..31c15899 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-GUI/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Vault-Agent-GUI +description: Vault Agent configuration for GUI service (sidecar pattern) +type: application +version: 0.1.0 +appVersion: "1.20.3" diff --git a/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml new file mode 100644 index 00000000..72ce877b --- /dev/null +++ b/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml @@ -0,0 +1,58 @@ +{{- if .Values.enabled }} +# ConfigMap for GUI Vault Agent Configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-agent-gui-config + labels: + app: vault-agent-gui + component: vault-agent +data: + gui-agent.hcl: | + # Vault Agent Configuration for GUI Service + + vault { + address = "http://vault:8200" + retry { + num_retries = 5 + } + } + + # Auto-authentication using AppRole + auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "{{ .Values.agent.credentialsPath }}/gui_role_id" + secret_id_file_path = "{{ .Values.agent.credentialsPath }}/gui_secret_id" + remove_secret_id_file_after_reading = false + } + } + + # Write token to shared volume for agent to use + sink "file" { + config = { + path = "{{ .Values.agent.tokenPath }}/token" + mode = 0640 + } + } + } + + # Caching configuration for GUI + cache { + default_lease_duration = "{{ .Values.agent.tokenTTL }}" + } + + # API proxy listener - GUI connects to localhost:{{ .Values.agent.port }} + listener "tcp" { + address = "0.0.0.0:{{ .Values.agent.port }}" + tls_disable = true + } + + # API proxy configuration + api_proxy { + use_auto_auth_token = true + enforce_consistency = "always" + when_inconsistent = "forward" + } +{{- end }} diff --git a/kubernetes/charts/Vault-Agent-GUI/values.yaml b/kubernetes/charts/Vault-Agent-GUI/values.yaml new file mode 100644 index 00000000..ad07d7ea --- /dev/null +++ b/kubernetes/charts/Vault-Agent-GUI/values.yaml @@ -0,0 +1,17 @@ +# Vault Agent GUI Configuration + +enabled: true + +# Agent configuration +agent: + # Port where agent listens for API requests from GUI + port: 8202 + + # Token TTL for GUI (short-lived) + tokenTTL: "15m" + + # Credentials location (shared volume from vault-init) + credentialsPath: "/agent/credentials" + + # Token cache location (separate volume for GUI) + tokenPath: "/agent/gui-token" diff --git a/kubernetes/charts/Vault-Agent-LLM/Chart.yaml b/kubernetes/charts/Vault-Agent-LLM/Chart.yaml new file mode 100644 index 00000000..07e7677e --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/Chart.yaml @@ -0,0 +1,7 @@ +apiVersion: v2 +name: Vault-Agent-LLM +description: Vault Agent for LLM Orchestration Service secret injection +type: application +version: 0.1.0 +appVersion: "1.20.3" +dependencies: [] \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml new file mode 100644 index 00000000..08c38ea2 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml @@ -0,0 +1,52 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: vault-agent-llm-config + labels: + app: vault-agent-llm + component: vault-agent +data: + agent.hcl: | + # Vault Agent Configuration for LLM Orchestration Service + + vault { + address = "http://vault:8200" + retry { + num_retries = 5 + } + } + + auto_auth { + method "approle" { + mount_path = "auth/approle" + config = { + role_id_file_path = "/agent/credentials/llm_role_id" + secret_id_file_path = "/agent/credentials/llm_secret_id" + remove_secret_id_file_after_reading = false + } + } + + # Write token to shared volume for agent to use + sink "file" { + config = { + path = "/agent/llm-token/token" + mode = 0640 + } + } + } + + # Caching configuration for LLM (longer TTL) + cache { + default_lease_duration = "1h" + } + + listener "tcp" { + address = "0.0.0.0:8201" + tls_disable = true + } + + api_proxy { + use_auto_auth_token = true + enforce_consistency = "always" + when_inconsistent = "forward" + } diff --git a/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml b/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml new file mode 100644 index 00000000..943597a0 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml @@ -0,0 +1,107 @@ +# DEPRECATED: This standalone deployment is no longer used +# WHY: Vault Agent now runs as a SIDECAR in LLM-Orchestration-Service pod +# This ensures LLM cannot bypass the agent and access Vault directly +# Keeping this file for any future reference +{{- if .Values.deployment.standalone }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} + component: vault-agent-llm +spec: + replicas: {{ .Values.deployment.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + component: vault-agent-llm + template: + metadata: + labels: + app: {{ .Values.release_name }} + component: vault-agent-llm + spec: + {{- if .Values.affinity.enabled }} + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: + - {{ .Values.vault.serviceName }} + topologyKey: kubernetes.io/hostname + {{- end }} + volumes: + {{- if .Values.volumes.agentCredentials.enabled }} + - name: vault-agent-creds + persistentVolumeClaim: + claimName: vault-agent-creds + {{- end }} + {{- if .Values.volumes.agentToken.enabled }} + - name: vault-agent-token + persistentVolumeClaim: + claimName: vault-agent-token + {{- end }} + {{- if .Values.volumes.agentConfig.enabled }} + - name: vault-agent-config + configMap: + name: {{ .Values.release_name }}-config + defaultMode: 0644 + {{- end }} + containers: + - name: vault-agent + image: "{{ .Values.images.vault.registry }}/{{ .Values.images.vault.repository }}:{{ .Values.images.vault.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + command: + - vault + - agent + - -config=/agent/config/agent.hcl + - -log-level=info + env: + - name: VAULT_ADDR + value: {{ .Values.vault.addr | quote }} + - name: VAULT_SKIP_VERIFY + value: "true" + volumeMounts: + {{- if .Values.volumes.agentCredentials.enabled }} + - name: vault-agent-creds + mountPath: {{ .Values.volumes.agentCredentials.mountPath }} + readOnly: true + {{- end }} + {{- if .Values.volumes.agentToken.enabled }} + - name: vault-agent-token + mountPath: {{ .Values.volumes.agentToken.mountPath }} + {{- end }} + {{- if .Values.volumes.agentConfig.enabled }} + - name: vault-agent-config + mountPath: {{ .Values.volumes.agentConfig.mountPath }} + readOnly: true + {{- end }} + {{- if .Values.probes.livenessProbe.enabled }} + livenessProbe: + httpGet: + path: {{ .Values.probes.livenessProbe.httpGet.path }} + port: {{ .Values.probes.livenessProbe.httpGet.port }} + initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }} + {{- end }} + {{- if .Values.probes.readinessProbe.enabled }} + readinessProbe: + httpGet: + path: {{ .Values.probes.readinessProbe.httpGet.path }} + port: {{ .Values.probes.readinessProbe.httpGet.port }} + initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }} + {{- end }} + {{- if .Values.resources }} + resources: +{{ toYaml .Values.resources | indent 10 }} + {{- end }} + securityContext: + capabilities: + add: + - IPC_LOCK +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-LLM/values.yaml b/kubernetes/charts/Vault-Agent-LLM/values.yaml new file mode 100644 index 00000000..93633a95 --- /dev/null +++ b/kubernetes/charts/Vault-Agent-LLM/values.yaml @@ -0,0 +1,102 @@ +enabled: true + +images: + vault: + registry: "docker.io" + repository: "hashicorp/vault" + tag: "1.20.3" + +release_name: "vault-agent-llm" + +# Vault service dependency +vault: + serviceName: "vault" + addr: "http://vault:8200" + +# Pod affinity to ensure co-location with Vault +affinity: + enabled: true + # Ensure this pod is scheduled on same node as Vault pod + colocateWithVault: true + + +# DEPRECATED: Standalone deployment disabled - agent runs as sidecar +deployment: + standalone: false # Set to true only for testing/debugging + replicas: 1 + type: "Deployment" + +# Shared volumes for vault ecosystem +volumes: + agentCredentials: + enabled: true + mountPath: "/agent/credentials" + # Uses same PVC as vault-init + + agentToken: + enabled: true + mountPath: "/agent/out" + # Uses same PVC as vault-init + + agentConfig: + enabled: true + mountPath: "/agent/config" + # ConfigMap for vault agent configuration + +# Vault agent configuration +agent: + enabled: true + config: + # Auto-auth configuration + autoAuth: + method: "kubernetes" + mountPath: "auth/kubernetes" + + # Cache configuration + cache: + enabled: true + + # Template configuration for secret injection + templates: + enabled: true + secrets: + - name: "llm-secrets" + path: "/agent/out/secrets.env" + template: | + {{- with secret "secret/llm-orchestration" -}} + OPENAI_API_KEY={{ .Data.data.openai_api_key }} + ANTHROPIC_API_KEY={{ .Data.data.anthropic_api_key }} + AZURE_OPENAI_API_KEY={{ .Data.data.azure_openai_api_key }} + AZURE_OPENAI_ENDPOINT={{ .Data.data.azure_openai_endpoint }} + OLLAMA_HOST={{ .Data.data.ollama_host }} + VECTOR_DB_HOST={{ .Data.data.vector_db_host }} + VECTOR_DB_PORT={{ .Data.data.vector_db_port }} + VECTOR_DB_COLLECTION={{ .Data.data.vector_db_collection }} + {{- end -}} + +pullPolicy: IfNotPresent + +resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" + +probes: + livenessProbe: + enabled: false + httpGet: + path: "/v1/sys/health" + port: 8200 + initialDelaySeconds: 30 + periodSeconds: 30 + + readinessProbe: + enabled: false + httpGet: + path: "/v1/sys/health" + port: 8200 + initialDelaySeconds: 10 + periodSeconds: 10 \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/Chart.yaml b/kubernetes/charts/Vault-Init/Chart.yaml new file mode 100644 index 00000000..83178bb8 --- /dev/null +++ b/kubernetes/charts/Vault-Init/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Vault-Init +description: Vault initialization job for RAG Module +version: 0.1.0 +appVersion: "1.20.3" +type: application \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/templates/configmap.yaml b/kubernetes/charts/Vault-Init/templates/configmap.yaml new file mode 100644 index 00000000..9cc2b12a --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/configmap.yaml @@ -0,0 +1,349 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.release_name }}-script + labels: + app: {{ .Values.release_name }} + component: vault-init +data: + {{ .Values.initScript.filename }}: | + #!/bin/sh + set -e + + VAULT_ADDR="${VAULT_ADDR:-http://vault:8200}" + UNSEAL_KEYS_FILE="/vault/data/unseal-keys.json" + INIT_FLAG="/vault/data/.initialized" + + echo "=== Vault Initialization Script ===" + + # Wait for Vault to be ready + echo "Waiting for Vault..." + for i in $(seq 1 30); do + if wget -q -O- "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then + echo "Vault is ready" + break + fi + echo "Waiting... ($i/30)" + sleep 2 + done + + # Check if this is first time + if [ ! -f "$INIT_FLAG" ]; then + echo "=== FIRST TIME DEPLOYMENT ===" + + # Initialize Vault + echo "Initializing Vault..." + wget -q -O- --post-data='{"secret_shares":5,"secret_threshold":3}' \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/init" > "$UNSEAL_KEYS_FILE" + + ROOT_TOKEN=$(grep -o '"root_token":"[^"]*"' "$UNSEAL_KEYS_FILE" | cut -d':' -f2 | tr -d '"') + export VAULT_TOKEN="$ROOT_TOKEN" + + # Extract unseal keys + KEY1=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '2p' | tr -d '"') + KEY2=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '3p' | tr -d '"') + KEY3=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '4p' | tr -d '"') + + # Unseal Vault + echo "Unsealing Vault..." + wget -q -O- --post-data="{\"key\":\"$KEY1\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + wget -q -O- --post-data="{\"key\":\"$KEY2\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + wget -q -O- --post-data="{\"key\":\"$KEY3\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + sleep 2 + echo "Vault unsealed" + + # Enable KV v2 + echo "Enabling KV v2 secrets engine..." + wget -q -O- --post-data='{"type":"kv","options":{"version":"2"}}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/mounts/secret" >/dev/null 2>&1 || echo "KV already enabled" + + # Enable AppRole + echo "Enabling AppRole..." + wget -q -O- --post-data='{"type":"approle"}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/auth/approle" >/dev/null 2>&1 || echo "AppRole already enabled" + + # Create GUI policy - Read public encryption key only + echo "Creating gui-policy..." + GUI_POLICY='path "secret/data/encryption/public_key" { capabilities = ["read"] } + path "secret/metadata/encryption/public_key" { capabilities = ["read"] } + path "secret/data/encryption/private_key" { capabilities = ["deny"] } + path "secret/data/llm/*" { capabilities = ["deny"] } + path "secret/data/embeddings/*" { capabilities = ["deny"] }' + + GUI_POLICY_JSON=$(echo "$GUI_POLICY" | jq -Rs '{"policy":.}') + wget -q -O- --post-data="$GUI_POLICY_JSON" \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/policies/acl/gui-policy" >/dev/null + + # Create CronManager policy - Read encryption keys + Write LLM/Embedding secrets + echo "Creating cron-manager-policy..." + CRON_POLICY='path "secret/data/encryption/public_key" { capabilities = ["read"] } + path "secret/metadata/encryption/public_key" { capabilities = ["read"] } + path "secret/data/encryption/private_key" { capabilities = ["read"] } + path "secret/metadata/encryption/private_key" { capabilities = ["read"] } + path "secret/data/llm/connections/*" { capabilities = ["create", "read", "update", "delete"] } + path "secret/metadata/llm/connections/*" { capabilities = ["read", "list", "delete"] } + path "secret/data/embeddings/connections/*" { capabilities = ["create", "read", "update", "delete"] } + path "secret/metadata/embeddings/connections/*" { capabilities = ["read", "list", "delete"] } + path "auth/token/lookup-self" { capabilities = ["read"] }' + + CRON_POLICY_JSON=$(echo "$CRON_POLICY" | jq -Rs '{"policy":.}') + wget -q -O- --post-data="$CRON_POLICY_JSON" \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/policies/acl/cron-manager-policy" >/dev/null + + # Create LLM Orchestration policy - Read LLM/Embedding secrets only + echo "Creating llm-orchestration-policy..." + LLM_POLICY='path "secret/data/llm/connections/*" { capabilities = ["read", "list"] } + path "secret/metadata/llm/connections/*" { capabilities = ["read", "list"] } + path "secret/data/embeddings/connections/*" { capabilities = ["read", "list"] } + path "secret/metadata/embeddings/connections/*" { capabilities = ["read", "list"] } + path "secret/data/encryption/*" { capabilities = ["deny"] } + path "auth/token/lookup-self" { capabilities = ["read"] }' + + LLM_POLICY_JSON=$(echo "$LLM_POLICY" | jq -Rs '{"policy":.}') + wget -q -O- --post-data="$LLM_POLICY_JSON" \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/policies/acl/llm-orchestration-policy" >/dev/null + + # Create GUI AppRole + echo "Creating gui-service AppRole..." + wget -q -O- --post-data='{"token_policies":["gui-policy"],"token_no_default_policy":true,"token_ttl":"15m","token_max_ttl":"1h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/auth/approle/role/gui-service" >/dev/null + + # Create CronManager AppRole + echo "Creating cron-manager-service AppRole..." + wget -q -O- --post-data='{"token_policies":["cron-manager-policy"],"token_no_default_policy":true,"token_ttl":"30m","token_max_ttl":"8h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service" >/dev/null + + # Create LLM Orchestration AppRole + echo "Creating llm-orchestration-service AppRole..." + wget -q -O- --post-data='{"token_policies":["llm-orchestration-policy"],"token_no_default_policy":true,"token_ttl":"1h","token_max_ttl":"24h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service" >/dev/null + + # Ensure credentials directory exists + mkdir -p /agent/credentials + + # Get GUI credentials + echo "Getting GUI credentials..." + GUI_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/gui-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$GUI_ROLE_ID" > /agent/credentials/gui_role_id + + GUI_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/gui-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$GUI_SECRET_ID" > /agent/credentials/gui_secret_id + + # Get CronManager credentials + echo "Getting CronManager credentials..." + CRON_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$CRON_ROLE_ID" > /agent/credentials/cron_role_id + + CRON_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$CRON_SECRET_ID" > /agent/credentials/cron_secret_id + + # Get LLM Orchestration credentials + echo "Getting LLM Orchestration credentials..." + LLM_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$LLM_ROLE_ID" > /agent/credentials/llm_role_id + + LLM_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$LLM_SECRET_ID" > /agent/credentials/llm_secret_id + + # Set secure permissions + chmod 640 /agent/credentials/*_role_id + chmod 640 /agent/credentials/*_secret_id + + # Generate RSA keypair for credential encryption/decryption + echo "Generating RSA keypair for encryption..." + + # Create temp directory for key generation + TEMP_KEY_DIR="/tmp/vault-keys-$$" + mkdir -p "$TEMP_KEY_DIR" + + # Generate private key (RSA-2048) + if ! openssl genrsa -out "$TEMP_KEY_DIR/private.pem" 2048 2>/dev/null; then + echo "ERROR: Failed to generate private key" + exit 1 + fi + + # Extract public key from private key + if ! openssl rsa -in "$TEMP_KEY_DIR/private.pem" -pubout -out "$TEMP_KEY_DIR/public.pem" 2>/dev/null; then + echo "ERROR: Failed to extract public key" + exit 1 + fi + + echo "Keys generated successfully" + + # Read keys and escape for JSON + PRIVATE_KEY=$(cat "$TEMP_KEY_DIR/private.pem" | sed ':a;N;$!ba;s/\n/\\n/g') + PUBLIC_KEY=$(cat "$TEMP_KEY_DIR/public.pem" | sed ':a;N;$!ba;s/\n/\\n/g') + CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + KEY_ID="rsa-keypair-$(date +%s)" + + # Store public key in Vault + echo "Storing public key in Vault..." + wget -q -O- --post-data='{"data":{"key":"'"$PUBLIC_KEY"'","algorithm":"RSA-OAEP","key_size":2048,"key_id":"'"$KEY_ID"'","created_at":"'"$CREATED_AT"'"}}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/secret/data/encryption/public_key" >/dev/null + + # Store private key in Vault + echo "Storing private key in Vault..." + wget -q -O- --post-data='{"data":{"key":"'"$PRIVATE_KEY"'","algorithm":"RSA-OAEP","key_size":2048,"key_id":"'"$KEY_ID"'","created_at":"'"$CREATED_AT"'"}}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/secret/data/encryption/private_key" >/dev/null + + # Clean up temporary files + rm -rf "$TEMP_KEY_DIR" + echo "RSA keypair generated and stored successfully" + + # Store test LLM credentials for testing + echo "Creating test LLM credentials..." + wget -q -O- --post-data='{"data":{"access_key":"TEST_AWS_ACCESS_KEY","secret_key":"TEST_AWS_SECRET_KEY","environment":"production","model":"claude-3"}}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/secret/data/llm/connections/aws_bedrock/production/claude-3" >/dev/null + + # Mark as initialized + touch "$INIT_FLAG" + echo "=== First time setup complete ===" + + + else + echo "=== SUBSEQUENT DEPLOYMENT ===" + + # Check if Vault is sealed + SEALED=$(wget -q -O- "$VAULT_ADDR/v1/sys/seal-status" | grep -o '"sealed":[^,}]*' | cut -d':' -f2) + + if [ "$SEALED" = "true" ]; then + echo "Vault is sealed. Unsealing..." + + # Load unseal keys + KEY1=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '2p' | tr -d '"') + KEY2=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '3p' | tr -d '"') + KEY3=$(grep -o '"keys":\[[^]]*\]' "$UNSEAL_KEYS_FILE" | grep -o '"[^"]*"' | sed -n '4p' | tr -d '"') + + wget -q -O- --post-data="{\"key\":\"$KEY1\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + wget -q -O- --post-data="{\"key\":\"$KEY2\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + wget -q -O- --post-data="{\"key\":\"$KEY3\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/sys/unseal" >/dev/null + + sleep 2 + echo "Vault unsealed" + else + echo "Vault is already unsealed" + fi + + # Get root token + ROOT_TOKEN=$(grep -o '"root_token":"[^"]*"' "$UNSEAL_KEYS_FILE" | cut -d':' -f2 | tr -d '"') + export VAULT_TOKEN="$ROOT_TOKEN" + + # Ensure credentials directory exists + mkdir -p /agent/credentials + + # Always regenerate all secret_ids on restart + echo "Regenerating GUI secret_id..." + GUI_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/gui-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$GUI_SECRET_ID" > /agent/credentials/gui_secret_id + + echo "Regenerating CronManager secret_id..." + CRON_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$CRON_SECRET_ID" > /agent/credentials/cron_secret_id + + echo "Regenerating LLM secret_id..." + LLM_SECRET_ID=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$LLM_SECRET_ID" > /agent/credentials/llm_secret_id + + # Set permissions + chmod 640 /agent/credentials/*_secret_id + + # Ensure role_ids exist + if [ ! -f /agent/credentials/gui_role_id ]; then + echo "Copying GUI role_id..." + GUI_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/gui-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$GUI_ROLE_ID" > /agent/credentials/gui_role_id + chmod 640 /agent/credentials/gui_role_id + fi + + if [ ! -f /agent/credentials/cron_role_id ]; then + echo "Copying CronManager role_id..." + CRON_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$CRON_ROLE_ID" > /agent/credentials/cron_role_id + chmod 640 /agent/credentials/cron_role_id + fi + + if [ ! -f /agent/credentials/llm_role_id ]; then + echo "Copying LLM role_id..." + LLM_ROLE_ID=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$LLM_ROLE_ID" > /agent/credentials/llm_role_id + chmod 640 /agent/credentials/llm_role_id + fi + fi + + echo "=== Vault init complete ===" \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/templates/job.yaml b/kubernetes/charts/Vault-Init/templates/job.yaml new file mode 100644 index 00000000..4c1f9811 --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/job.yaml @@ -0,0 +1,91 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} + component: vault-init +spec: + backoffLimit: {{ .Values.job.backoffLimit }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + component: vault-init + spec: + restartPolicy: {{ .Values.job.restartPolicy }} + {{- if .Values.affinity.enabled }} + affinity: + podAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: + - {{ .Values.vault.serviceName }} + topologyKey: kubernetes.io/hostname + {{- end }} + volumes: + {{- if .Values.volumes.vaultData.enabled }} + - name: vault-data + persistentVolumeClaim: + claimName: vault-storage-{{ .Values.vault.serviceName }}-0 + {{- end }} + {{- if .Values.volumes.agentCredentials.enabled }} + - name: vault-agent-creds + persistentVolumeClaim: + claimName: vault-agent-creds + {{- end }} + {{- if .Values.initScript.enabled }} + - name: init-script + configMap: + name: {{ .Values.release_name }}-script + defaultMode: 0755 + {{- end }} + containers: + - name: vault-init + image: "{{ .Values.images.vault.registry }}/{{ .Values.images.vault.repository }}:{{ .Values.images.vault.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + command: ["/bin/sh", "-c"] + args: + - | + apk add --no-cache curl jq uuidgen openssl + + mkdir -p \ + /agent/credentials \ + /agent/out + + chown -R vault:vault \ + /agent/credentials \ + /agent/out + + chmod 755 \ + /agent/credentials \ + /agent/out + + su vault -s /bin/sh /scripts/{{ .Values.initScript.filename }} + + env: + - name: VAULT_ADDR + value: {{ .Values.vault.addr | quote }} + - name: VAULT_SKIP_VERIFY + value: "true" + volumeMounts: + {{- if .Values.volumes.vaultData.enabled }} + - name: vault-data + mountPath: {{ .Values.volumes.vaultData.mountPath }} + {{- end }} + {{- if .Values.volumes.agentCredentials.enabled }} + - name: vault-agent-creds + mountPath: {{ .Values.volumes.agentCredentials.mountPath }} + {{- end }} + {{- if .Values.initScript.enabled }} + - name: init-script + mountPath: "/scripts" + readOnly: true + {{- end }} + {{- if .Values.resources }} + resources: +{{ toYaml .Values.resources | indent 10 }} + {{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/templates/pvc.yaml b/kubernetes/charts/Vault-Init/templates/pvc.yaml new file mode 100644 index 00000000..ab883aa5 --- /dev/null +++ b/kubernetes/charts/Vault-Init/templates/pvc.yaml @@ -0,0 +1,18 @@ +{{- if .Values.volumes.agentCredentials.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: vault-agent-creds + labels: + app: {{ .Values.release_name }} + component: vault-init +spec: + accessModes: + - {{ .Values.volumes.agentCredentials.accessMode }} + resources: + requests: + storage: {{ .Values.volumes.agentCredentials.size }} + {{- if .Values.volumes.agentCredentials.storageClass }} + storageClassName: {{ .Values.volumes.agentCredentials.storageClass }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/values.yaml b/kubernetes/charts/Vault-Init/values.yaml new file mode 100644 index 00000000..02b392dc --- /dev/null +++ b/kubernetes/charts/Vault-Init/values.yaml @@ -0,0 +1,53 @@ +enabled: true + +images: + vault: + registry: "docker.io" + repository: "hashicorp/vault" + tag: "1.20.3" + +release_name: "vault-init" + +# Vault service dependency +vault: + serviceName: "vault" + addr: "http://vault:8200" + +# Pod affinity to ensure co-location with Vault +affinity: + enabled: true + # Ensure this pod is scheduled on same node as Vault pod + colocateWithVault: true + + +job: + backoffLimit: 3 + restartPolicy: "Never" + + +volumes: + vaultData: + enabled: true + mountPath: "/vault/data" + + agentCredentials: + enabled: true + mountPath: "/agent/credentials" + size: "100Mi" + accessMode: "ReadWriteOnce" + storageClass: "" + +# Init script configuration +initScript: + enabled: true + filename: "vault-init.sh" + +pullPolicy: IfNotPresent + +resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "200m" \ No newline at end of file diff --git a/kubernetes/charts/Vault/Chart.yaml b/kubernetes/charts/Vault/Chart.yaml new file mode 100644 index 00000000..4b6ffec3 --- /dev/null +++ b/kubernetes/charts/Vault/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Vault +description: HashiCorp Vault secrets management for RAG Module +version: 0.1.0 +appVersion: "1.20.3" +type: application \ No newline at end of file diff --git a/kubernetes/charts/Vault/templates/configmap.yaml b/kubernetes/charts/Vault/templates/configmap.yaml new file mode 100644 index 00000000..1e32fd90 --- /dev/null +++ b/kubernetes/charts/Vault/templates/configmap.yaml @@ -0,0 +1,66 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.release_name }}-config" + labels: + app: "{{ .Values.release_name }}" + component: vault +data: + vault.hcl: | + # HashiCorp Vault Server Configuration + # Production-ready configuration for LLM Orchestration Service + + # Storage backend - Raft for high availability + storage "raft" { + path = "/vault/file" + node_id = "vault-node-1" + + # Retry join configuration for clustering (single node for now) + retry_join { + leader_api_addr = "http://vault:8200" + } + } + + # HTTP listener configuration + listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = true + + # Enable CORS for web UI access + cors_enabled = true + cors_allowed_origins = [ + "http://localhost:8200", + "http://vault:8200" + ] + } + + # Cluster listener for HA (required even for single node) + listener "tcp" { + address = "0.0.0.0:8201" + cluster_addr = "http://0.0.0.0:8201" + tls_disable = true + } + + # API and cluster addresses + api_addr = "http://vault:8200" + cluster_addr = "http://vault:8201" + + # Security and performance settings + disable_mlock = false + disable_cache = false + ui = false + + # Default lease and maximum lease durations + default_lease_ttl = "168h" # 7 days + max_lease_ttl = "720h" # 30 days + + # Logging configuration + log_level = "INFO" + log_format = "json" + + # Development settings (remove in production) + # Note: In production, you should not use dev mode + # and should properly initialize and unseal the vault + +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault/templates/service-byk-vault.yaml b/kubernetes/charts/Vault/templates/service-byk-vault.yaml new file mode 100644 index 00000000..b7501f94 --- /dev/null +++ b/kubernetes/charts/Vault/templates/service-byk-vault.yaml @@ -0,0 +1,23 @@ +{{- if .Values.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: vault +spec: + type: {{ .Values.service.type }} + {{- if eq .Values.service.type "ClusterIP" }} + {{- if .Values.service.headless }} + clusterIP: None + {{- end }} + {{- end }} + selector: + app: "{{ .Values.release_name }}" + ports: + - name: http + protocol: TCP + port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault/templates/statefulset-byk-vault.yaml b/kubernetes/charts/Vault/templates/statefulset-byk-vault.yaml new file mode 100644 index 00000000..b68fbbcb --- /dev/null +++ b/kubernetes/charts/Vault/templates/statefulset-byk-vault.yaml @@ -0,0 +1,122 @@ +{{- if .Values.enabled }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: "{{ .Values.release_name }}" + labels: + app: "{{ .Values.release_name }}" + component: vault +spec: + serviceName: "{{ .Values.release_name }}" + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + component: vault + spec: + {{- if .Values.securityContext.enabled }} + securityContext: + runAsNonRoot: {{ .Values.securityContext.runAsNonRoot }} + runAsUser: {{ .Values.securityContext.runAsUser }} + runAsGroup: {{ .Values.securityContext.runAsGroup }} + fsGroup: {{ .Values.securityContext.fsGroup }} + {{- end }} + {{- if .Values.initContainer.enabled }} + initContainers: + - name: vault-init + image: "{{ .Values.initContainer.image.registry }}/{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}" + command: + - sh + - -c + - | + chown -R 100:1000 /vault/file + chmod -R 755 /vault/file + volumeMounts: + - name: vault-storage + mountPath: {{ .Values.persistence.mountPath }} + securityContext: + runAsUser: 0 + {{- end }} + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.images.vault.registry }}/{{ .Values.images.vault.repository }}:{{ .Values.images.vault.tag }}" + imagePullPolicy: {{ .Values.pullPolicy }} + command: + - vault + - server + - -config=/vault/config/vault.hcl + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + - name: cluster + containerPort: 8201 + protocol: TCP + env: + - name: VAULT_ADDR + value: "http://0.0.0.0:{{ .Values.service.targetPort }}" + - name: VAULT_SKIP_VERIFY_CONFIG_PERMISSIONS + value: "true" + {{- if .Values.healthcheck.enabled }} + livenessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + readinessProbe: + httpGet: + path: "{{ .Values.healthcheck.httpPath }}" + port: {{ .Values.service.targetPort }} + initialDelaySeconds: {{ .Values.healthcheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthcheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthcheck.timeoutSeconds }} + failureThreshold: {{ .Values.healthcheck.failureThreshold }} + {{- end }} + volumeMounts: + - name: vault-config + mountPath: /vault/config + readOnly: true + {{- if .Values.persistence.enabled }} + - name: vault-storage + mountPath: {{ .Values.persistence.mountPath }} + {{- end }} + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" + securityContext: + capabilities: + add: + - IPC_LOCK + volumes: + - name: vault-config + configMap: + name: "{{ .Values.release_name }}-config" + {{- if .Values.persistence.enabled }} + volumeClaimTemplates: + - metadata: + name: vault-storage + labels: + app: "{{ .Values.release_name }}" + component: vault + spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault/values.yaml b/kubernetes/charts/Vault/values.yaml new file mode 100644 index 00000000..ffe01dd4 --- /dev/null +++ b/kubernetes/charts/Vault/values.yaml @@ -0,0 +1,71 @@ +replicas: 1 +enabled: true + +images: + vault: + registry: "docker.io" + repository: "hashicorp/vault" + tag: "1.20.3" + +release_name: "vault" + +service: + type: ClusterIP + # Set to true for headless service (direct pod access) + headless: false + port: 8200 + targetPort: 8200 + +persistence: + enabled: true + storageClass: "" + accessMode: ReadWriteOnce + size: 10Gi + mountPath: "/vault/file" + +# Vault configuration +vault: + config: + # File storage backend + storage_file_path: "/vault/file" + # API settings + disable_mlock: true + ui: true + # Network settings + listener_address: "0.0.0.0:8200" + cluster_address: "0.0.0.0:8201" + +resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "512Mi" + cpu: "200m" + +pullPolicy: IfNotPresent + +healthcheck: + enabled: false + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 20 + successThreshold: 1 + # Vault health endpoint + httpPath: "/v1/sys/health" + +securityContext: + enabled: true + runAsNonRoot: false + runAsUser: 0 + runAsGroup: 0 + fsGroup: 0 + +# Init container configuration +initContainer: + enabled: true + image: + registry: "docker.io" + repository: "busybox" + tag: "1.35" \ No newline at end of file diff --git a/kubernetes/charts/database/Chart.lock b/kubernetes/charts/database/Chart.lock new file mode 100644 index 00000000..641f6d08 --- /dev/null +++ b/kubernetes/charts/database/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 12.2.6 +digest: sha256:6f50554d914d878d490c46307f120b87d39854e42f81411b13ffdd23aad21cb6 +generated: "2025-12-02T13:43:50.4497212+05:30" diff --git a/kubernetes/charts/database/Chart.yaml b/kubernetes/charts/database/Chart.yaml new file mode 100644 index 00000000..df3256a4 --- /dev/null +++ b/kubernetes/charts/database/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: database +description: PostgreSQL databases for RAG Module using pure PostgreSQL +type: application +version: 0.1.0 + \ No newline at end of file diff --git a/kubernetes/charts/database/templates/configmap.yaml b/kubernetes/charts/database/templates/configmap.yaml new file mode 100644 index 00000000..777a8571 --- /dev/null +++ b/kubernetes/charts/database/templates/configmap.yaml @@ -0,0 +1,16 @@ +{{- range .Values.databases }} +{{- if .initdbScripts }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .name }}-initdb + labels: + app: {{ .name }} +data: + {{- range $filename, $content := .initdbScripts }} + {{ $filename }}: | + {{- $content | nindent 4 }} + {{- end }} +--- +{{- end }} +{{- end }} diff --git a/kubernetes/charts/database/templates/secret.yaml b/kubernetes/charts/database/templates/secret.yaml new file mode 100644 index 00000000..244a4504 --- /dev/null +++ b/kubernetes/charts/database/templates/secret.yaml @@ -0,0 +1,12 @@ +{{- range .Values.databases }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ .name }}-secret + labels: + app: {{ .name }} +type: Opaque +data: + password: {{ .password | b64enc | quote }} +--- +{{- end }} diff --git a/kubernetes/charts/database/templates/service.yaml b/kubernetes/charts/database/templates/service.yaml new file mode 100644 index 00000000..2a1a5393 --- /dev/null +++ b/kubernetes/charts/database/templates/service.yaml @@ -0,0 +1,34 @@ +{{- range .Values.databases }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .name }} + labels: + app: {{ .name }} +spec: + type: ClusterIP + selector: + app: {{ .name }} + ports: + - name: postgres + port: {{ $.Values.service.port }} + targetPort: {{ $.Values.service.port }} +--- +# Headless service for StatefulSet +apiVersion: v1 +kind: Service +metadata: + name: {{ .name }}-headless + labels: + app: {{ .name }} +spec: + type: ClusterIP + clusterIP: None + selector: + app: {{ .name }} + ports: + - name: postgres + port: {{ $.Values.service.port }} + targetPort: {{ $.Values.service.port }} +--- +{{- end }} diff --git a/kubernetes/charts/database/templates/statefulset.yaml b/kubernetes/charts/database/templates/statefulset.yaml new file mode 100644 index 00000000..02194da9 --- /dev/null +++ b/kubernetes/charts/database/templates/statefulset.yaml @@ -0,0 +1,76 @@ +{{- range .Values.databases }} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ .name }} + labels: + app: {{ .name }} +spec: + serviceName: {{ .name }}-headless + replicas: 1 + selector: + matchLabels: + app: {{ .name }} + template: + metadata: + labels: + app: {{ .name }} + spec: + securityContext: + fsGroup: 999 + terminationGracePeriodSeconds: 30 + containers: + - name: postgresql + image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag }}" + imagePullPolicy: {{ $.Values.image.pullPolicy }} + env: + - name: POSTGRES_USER + value: "{{ .username }}" + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .name }}-secret + key: password + - name: POSTGRES_DB + value: "{{ .db }}" + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + ports: + - name: postgres + containerPort: {{ $.Values.service.port }} + livenessProbe: + tcpSocket: + port: {{ $.Values.service.port }} + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: {{ $.Values.service.port }} + initialDelaySeconds: 15 + periodSeconds: 10 + volumeMounts: + - name: data + mountPath: /var/lib/postgresql/data + {{- if .initdbScripts }} + - name: initdb + mountPath: /docker-entrypoint-initdb.d + {{- end }} + {{- if .initdbScripts }} + volumes: + - name: initdb + configMap: + name: {{ .name }}-initdb + {{- end }} + volumeClaimTemplates: + - metadata: + name: data + spec: + accessModes: {{ toYaml $.Values.persistence.accessModes | nindent 8 }} + resources: + requests: + storage: {{ .storage }} + {{- if $.Values.persistence.storageClass }} + storageClassName: {{ $.Values.persistence.storageClass }} + {{- end }} +--- +{{- end }} diff --git a/kubernetes/charts/database/values.yaml b/kubernetes/charts/database/values.yaml new file mode 100644 index 00000000..cf225272 --- /dev/null +++ b/kubernetes/charts/database/values.yaml @@ -0,0 +1,30 @@ +# Centralized database configuration using pure PostgreSQL +databases: + - name: rag-search-db + username: postgres + password: "{{ ragSearchDB.password }}" + db: llm_production + storage: 8Gi + initdbScripts: + init-langfuse.sql: | + SELECT 'CREATE DATABASE "langfuse-db"' + WHERE NOT EXISTS ( + SELECT FROM pg_catalog.pg_database WHERE datname = 'langfuse-db' + )\gexec + - name: tim-postgresql + username: tim + password: "{{ TIMDB.password }}" + db: tim + storage: 1Gi + +image: + repository: postgres + tag: "14.1" + pullPolicy: IfNotPresent + +service: + port: 5432 + +persistence: + storageClass: "" # specify your own + accessModes: ["ReadWriteOnce"] \ No newline at end of file diff --git a/kubernetes/charts/minio/Chart.yaml b/kubernetes/charts/minio/Chart.yaml new file mode 100644 index 00000000..e2bd6d5b --- /dev/null +++ b/kubernetes/charts/minio/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: minio +description: minio object storage server +type: application +version: 0.1.0 +appVersion: 2.0.0 \ No newline at end of file diff --git a/kubernetes/charts/minio/templates/deployment-minio.yaml b/kubernetes/charts/minio/templates/deployment-minio.yaml new file mode 100644 index 00000000..1ba2cc74 --- /dev/null +++ b/kubernetes/charts/minio/templates/deployment-minio.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: "{{ .Values.release_name }}" +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: "{{ .Values.release_name }}" + template: + metadata: + labels: + app: "{{ .Values.release_name }}" + spec: + initContainers: + - name: create-buckets + image: busybox:latest + command: + - sh + - -c + - | + mkdir -p /data/rag-search/resources/langfuse + mkdir -p /data/rag-search/resources/models + mkdir -p /data/rag-search/resources/datasets + mkdir -p /data/rag-search/resources/qdrant + mkdir -p /data/rag-search/resources/system + echo "Bucket directories created successfully" + volumeMounts: + - name: minio-data + mountPath: /data + containers: + - name: "{{ .Values.release_name }}" + image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}" + command: + - minio + - server + - /data + - --console-address + - :9001 + ports: + - containerPort: {{ .Values.ports.api }} + name: api + protocol: TCP + - containerPort: {{ .Values.ports.console }} + name: console + protocol: TCP + # Non-sensitive env's from values.yaml + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: "{{ $value }}" + {{- end }} + # Sensitive env's from Kubernetes Secret + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} + volumeMounts: + - name: minio-data + mountPath: /data + volumes: + - name: minio-data + persistentVolumeClaim: + claimName: pvc-minio-data + resources: + requests: + memory: "{{ .Values.resources.requests.memory }}" + cpu: "{{ .Values.resources.requests.cpu }}" + limits: + memory: "{{ .Values.resources.limits.memory }}" + cpu: "{{ .Values.resources.limits.cpu }}" \ No newline at end of file diff --git a/kubernetes/charts/minio/templates/ingress-minio.yaml b/kubernetes/charts/minio/templates/ingress-minio.yaml new file mode 100644 index 00000000..390c93aa --- /dev/null +++ b/kubernetes/charts/minio/templates/ingress-minio.yaml @@ -0,0 +1,37 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: "{{ .Values.release_name }}-ingress" + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "10s" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600s" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600s" + nginx.ingress.kubernetes.io/cors-allow-origin: "*" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "Origin, X-Requested-With, Content-Type, Cache-Control, Connection, Accept" + cert-manager.io/cluster-issuer: "letsencrypt-prod-issuer" + labels: + name: "{{ .Values.release_name }}-ingress" +spec: + rules: + - host: "{{ .Values.ingress.host }}" + http: + paths: + - pathType: Prefix + path: "{{ .Values.ingress.path }}" + backend: + service: + name: "{{ .Values.release_name }}" + port: + number: {{ .Values.ports.api }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - "{{ .Values.ingress.host }}" + secretName: "{{ .Values.secretname }}" + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/minio/templates/pvc-minio-data.yaml b/kubernetes/charts/minio/templates/pvc-minio-data.yaml new file mode 100644 index 00000000..2794e301 --- /dev/null +++ b/kubernetes/charts/minio/templates/pvc-minio-data.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: pvc-minio-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.volumes.minio_data.size }} \ No newline at end of file diff --git a/kubernetes/charts/minio/templates/secret.yaml b/kubernetes/charts/minio/templates/secret.yaml new file mode 100644 index 00000000..a3d9c65f --- /dev/null +++ b/kubernetes/charts/minio/templates/secret.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: minio-secrets + labels: + app: "{{ .Values.release_name }}" +type: Opaque +stringData: + MINIO_ROOT_USER: "" + MINIO_ROOT_PASSWORD: "" diff --git a/kubernetes/charts/minio/templates/service-minio.yaml b/kubernetes/charts/minio/templates/service-minio.yaml new file mode 100644 index 00000000..9f52de72 --- /dev/null +++ b/kubernetes/charts/minio/templates/service-minio.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Values.release_name }}" +spec: + selector: + app: "{{ .Values.release_name }}" + ports: + - port: {{ .Values.ports.api }} + targetPort: api + protocol: TCP + name: api + - port: {{ .Values.ports.console }} + targetPort: console + protocol: TCP + name: console \ No newline at end of file diff --git a/kubernetes/charts/minio/values.yaml b/kubernetes/charts/minio/values.yaml new file mode 100644 index 00000000..f2f7c7de --- /dev/null +++ b/kubernetes/charts/minio/values.yaml @@ -0,0 +1,44 @@ +release_name: "minio" + +image: + registry: "docker.io" + repository: "minio/minio" + tag: "latest" + +replicas: 1 + +resources: + requests: + memory: "500Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + +env: + MINIO_BROWSER_REDIRECT_URL: "http://localhost:9001" + +# Reference to Kubernetes Secret +envFrom: + - secretRef: + name: minio-secrets + +volumes: + minio_data: + type: pvc + size: "5Gi" + +ports: + api: 9000 + console: 9001 + +ingress: + enabled: true + host: "domain" + path: "/" + tls: + enabled: true +secretname: "minio-tls" + +istio: + enabled: false \ No newline at end of file diff --git a/kubernetes/dashboard-admin.yaml b/kubernetes/dashboard-admin.yaml new file mode 100644 index 00000000..04855539 --- /dev/null +++ b/kubernetes/dashboard-admin.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: admin-user + namespace: kubernetes-dashboard +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: admin-user +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: +- kind: ServiceAccount + name: admin-user + namespace: kubernetes-dashboard \ No newline at end of file diff --git a/kubernetes/values.yaml b/kubernetes/values.yaml new file mode 100644 index 00000000..febbf783 --- /dev/null +++ b/kubernetes/values.yaml @@ -0,0 +1,84 @@ +# Global configuration for RAG Module +global: + domain: "" # Update with actual domain + namespace: "rag-module" + storageClass: "local-path" + +# Individual service configurations +database: + enabled: true + +resql: + enabled: true + +ruuter-public: + enabled: true + +ruuter-private: + enabled: true + +data-mapper: + enabled: true + +TIM: + enabled: true + +Authentication-Layer: + enabled: true + +CronManager: + enabled: true + +GUI: + enabled: true + +Loki: + enabled: true + +Grafana: + enabled: true + +S3-Ferry: + enabled: true + +minio: + enabled: true + +Redis: + enabled: true + +Qdrant: + enabled: true + +ClickHouse: + enabled: false + +Langfuse-Web: + enabled: false + +Langfuse-Worker: + enabled: false + +Vault: + enabled: true + +Vault-Init: + enabled: true + +Vault-Agent-LLM: + enabled: true + +Vault-Agent-GUI: + enabled: true + +Vault-Agent-Cron: + enabled: true + +LLM-Orchestration-Service: + enabled: true + +Liquibase: + enabled: true + +Notifications-Node: + enabled: true diff --git a/migrate-production-test.sh b/migrate-production-test.sh new file mode 100644 index 00000000..56c0f3b5 --- /dev/null +++ b/migrate-production-test.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Function to parse ini file and extract the value for a given key under a given section +get_ini_value() { + local file=$1 + local key=$2 + awk -F '=' -v key="$key" '$1 == key { gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit }' "$file" +} + +# Get the values from constants.ini +INI_FILE="constants.ini" +DB_PASSWORD=$(get_ini_value "$INI_FILE" "DB_PASSWORD") + +# Target database: llm_production inside the existing rag-search-db container +# Create the database first if it does not exist +docker exec rag-search-db psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='llm_production'" | grep -q 1 \ + || docker exec rag-search-db psql -U postgres -c "CREATE DATABASE llm_production;" + +docker run --rm --network bykstack \ + -v "$(pwd)/DSL/Liquibase_production/changelog":/liquibase/changelog \ + -v "$(pwd)/DSL/Liquibase_production/changelog.yaml":/liquibase/changelog.yaml \ + -v "$(pwd)/DSL/Liquibase_production/liquibase.properties":/liquibase/liquibase.properties \ + -v "$(pwd)/DSL/Liquibase_production/data":/liquibase/data \ + liquibase/liquibase:4.33 \ + --defaultsFile=/liquibase/liquibase.properties \ + --changelog-file=changelog.yaml \ + --url="jdbc:postgresql://rag-search-db:5432/llm_production?user=postgres" \ + --password="$DB_PASSWORD" \ + update diff --git a/migrate.sh b/migrate.sh index c1566981..8089cf1f 100644 --- a/migrate.sh +++ b/migrate.sh @@ -12,4 +12,4 @@ INI_FILE="constants.ini" DB_PASSWORD=$(get_ini_value "$INI_FILE" "DB_PASSWORD") -docker run --rm --network bykstack -v `pwd`/DSL/Liquibase/changelog:/liquibase/changelog -v `pwd`/DSL/Liquibase/master.yml:/liquibase/master.yml -v `pwd`/DSL/Liquibase/data:/liquibase/data liquibase/liquibase:4.33 --defaultsFile=/liquibase/changelog/liquibase.properties --changelog-file=master.yml --url=jdbc:postgresql://rag_search_db:5432/rag-search?user=postgres --password=$DB_PASSWORD update +docker run --rm --network bykstack -v `pwd`/DSL/Liquibase/changelog:/liquibase/changelog -v `pwd`/DSL/Liquibase/master.yml:/liquibase/master.yml -v `pwd`/DSL/Liquibase/data:/liquibase/data liquibase/liquibase:4.33 --defaultsFile=/liquibase/changelog/liquibase.properties --changelog-file=master.yml --url=jdbc:postgresql://rag-search-db:5432/rag-search?user=postgres --password=$DB_PASSWORD update diff --git a/notification-server/src/streamingService.js b/notification-server/src/streamingService.js index 074b0ae9..74e58869 100644 --- a/notification-server/src/streamingService.js +++ b/notification-server/src/streamingService.js @@ -91,6 +91,7 @@ async function createLLMOrchestrationStreamRequest({ channelId, message, options try { const data = JSON.parse(line.slice(6)); // Remove 'data: ' prefix const content = data.payload?.content; + const buttons = data.payload?.buttons; if (!content) continue; @@ -105,14 +106,18 @@ async function createLLMOrchestrationStreamRequest({ channelId, message, options break; } - // Regular token - send to client - sender({ + // Regular token - send to client (include buttons when present) + const chunkMessage = { type: "stream_chunk", content: content, streamId: channelId, channelId, isComplete:false - }); + }; + if (buttons && buttons.length > 0) { + chunkMessage.buttons = buttons; + } + sender(chunkMessage); } catch (parseError) { console.error(`Failed to parse SSE data for channel ${channelId}:`, parseError, line); diff --git a/pyproject.toml b/pyproject.toml index dd8f876c..6e39a1ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "langfuse>=3.8.1", "minio>=7.2.0", "psycopg2-binary>=2.9.11", + "redis[hiredis]>=5.0,<6.0", ] [tool.ruff] @@ -90,6 +91,32 @@ ignore = [] fixable = ["ALL"] unfixable = [] +# Per-file ignores for special cases +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "ANN", # Ignore all missing type annotations (ANN001, ANN201, etc.) + "T201", # Allow print statements +] + +"src/models/request_models.py" = ["N815"] # camelCase fields required for API contract +"src/optimization/optimized_module_loader.py" = ["N815"] # Pydantic model fields +"src/optimization/optimizers/generator_optimizer.py" = ["N815"] # Pydantic model fields +"src/response_generator/response_generate.py" = ["N815", "ANN401"] # Pydantic model fields + DSPy streamify Any type + +# Library interface patterns - legitimate Any usage +"src/contextual_retrieval/contextual_retrieval_api_client.py" = ["ANN401"] # httpx **kwargs pass-through +"src/tool_classifier/workflows/service_workflow.py" = ["ANN401"] # LLMManager passed as Any - dynamic multi-provider LLM interface +"src/guardrails/dspy_nemo_adapter.py" = ["ANN401"] # LangChain LLM interface + DSPy dynamic types +"src/tool_classifier/param_extractor.py" = ["ANN401"] # DSPy streamify Any type +"src/tool_classifier/api_response_formatter.py" = ["ANN401"] # DSPy streamify Any type +"src/llm_orchestrator_config/context_manager.py" = ["ANN401"] # MockResponse with dynamic attributes +"src/optimization/metrics/*.py" = ["ANN401"] # DSPy optimizer trace parameter (internal type) + +"byk-stack-setup/script.py" = ["T201"] # Allow print statements in setup script + + +"src/utils/api_tool_session_store.py" = ["ANN401"] # Dynamic Pydantic model field updates via **kwargs +"src/tool_classifier/workflows/api_tool_workflow.py" = ["ANN401", "N815"] # Dynamic guardrails adapter + orchestration service Any types; camelCase _MinimalRequest field for API contract [tool.ruff.format] # Like Black, use double quotes for strings. diff --git a/src/api_tool_indexer/__init__.py b/src/api_tool_indexer/__init__.py new file mode 100644 index 00000000..61eb9219 --- /dev/null +++ b/src/api_tool_indexer/__init__.py @@ -0,0 +1,24 @@ +""" +API Tool Indexer Module + +This module handles indexing of API endpoint data into Qdrant for semantic search. +Endpoints are enriched with LLM-generated context and stored for tool retrieval. +""" + +__version__ = "1.0.0" + +from api_tool_indexer.models import ( + EndpointData, + EnrichedEndpoint, + IndexingResult, + ParamSchema, +) +from api_tool_indexer.main_indexer import index_endpoint + +__all__ = [ + "ParamSchema", + "EndpointData", + "EnrichedEndpoint", + "IndexingResult", + "index_endpoint", +] diff --git a/src/api_tool_indexer/constants.py b/src/api_tool_indexer/constants.py new file mode 100644 index 00000000..1bdd7b6d --- /dev/null +++ b/src/api_tool_indexer/constants.py @@ -0,0 +1,77 @@ +"""Constants for the API Tool Indexer module.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ApiToolIndexerConstants: + """Constants for the API tool indexing pipeline.""" + + # Qdrant Configuration + COLLECTION_NAME = "api_tool_collection" + DEFAULT_QDRANT_HOST = "qdrant" + DEFAULT_QDRANT_PORT = 6333 + + # Vector Configuration - mirrors intent_collections for consistency + VECTOR_SIZE = 3072 # text-embedding-3-large dimension (Azure) + DISTANCE_METRIC = "Cosine" + + # Named Vector Names (hybrid search: dense + sparse) + DENSE_VECTOR_NAME = "dense" + SPARSE_VECTOR_NAME = "sparse" + + # LLM / Embedding API + DEFAULT_API_BASE_URL = "http://llm-orchestration-service:8100" + DEFAULT_ENVIRONMENT = "production" + DEFAULT_CONNECTION_ID = "gpt-4o-mini" + + # Retry Configuration + MAX_RETRIES = 3 + RETRY_DELAY_BASE = 2 # Exponential backoff base (2^attempt seconds) + REQUEST_TIMEOUT = 60 # seconds + + # Number of example queries generated per endpoint. + # Each example becomes its own Qdrant point so its vector sits in the exact + # language region of the embedding space, enabling short-query matching. + EXAMPLE_QUERY_COUNT = 5 + + # Context Enrichment Template + # Full template goes in chunk_prompt; document_prompt is left empty. + # + # Multi-point indexing strategy: + # - Each example query line is extracted and stored as its own Qdrant point, + # embedded from that individual sentence alone. + # - The prose + all examples combined become one summary point. + # All in the same language as the endpoint description — no bilingual duplication. + CONTEXT_TEMPLATE = """ +{full_endpoint_info} + + +Here is the API endpoint we want to enrich for better semantic search retrieval: + +Name: {name} +Description: {description} +Parameters: {params_summary} + + +Please generate a rich, detailed context that describes this API endpoint comprehensively for semantic search. +Keep the prose context general and country-agnostic. Include information about: +- What the user wants to accomplish by calling this endpoint +- Key terms and synonyms for this action +- Related concepts and use cases +- Common ways users might ask for this functionality in natural language + +IMPORTANT: Generate the prose context and the example questions in the SAME LANGUAGE as the endpoint description above. However, always use the exact section header "Example queries:" in English regardless of language — this is a required machine-readable marker. + +IMPORTANT for example queries: This is a system built for Estonian government digital services (Bürokratt). Ground the examples in an Estonian context — use Estonian cities (Tallinn, Tartu, Pärnu, Narva), Estonian institutions, and Estonia-relevant scenarios. Only use non-Estonian locations if the endpoint is explicitly about comparing or fetching data for multiple countries. + +Then add a section with exactly {example_count} realistic and diverse example questions a real user might ask when they need this endpoint. Cover different phrasings, synonyms, and indirect ways of asking — do not just repeat the description verbatim. + +Example queries: +- +- +- +- +- + +Answer only with the enriched context and example queries — nothing else.""" diff --git a/src/api_tool_indexer/main_indexer.py b/src/api_tool_indexer/main_indexer.py new file mode 100644 index 00000000..5bddceeb --- /dev/null +++ b/src/api_tool_indexer/main_indexer.py @@ -0,0 +1,494 @@ +"""API Endpoint Indexer Pipeline. + +Receives raw API EndpointData, enriches it with LLM-generated context, +creates hybrid embeddings (dense + sparse), and stores the result in Qdrant +api_tool_collection as multiple points per endpoint. + +Multi-point indexing strategy: + - One 'example' point per example query extracted from the LLM context. + Each query is embedded individually so its vector sits in the correct + language region of the embedding space, enabling accurate short-query matching. + - One 'summary' point containing the combined name + description + enriched context. + This handles broad/paraphrased queries that don't match any single example. + +Pipeline steps: + 1. Build LLM prompt from endpoint name, description, and params + 2. Generate context via LLMAPIClient.generate_context() + 3. Parse example query lines from the returned context + 4. Create dense + sparse embeddings per example query (example points) + 5. Create dense + sparse embedding for combined summary text (summary point) + 6. Delete all existing Qdrant points for this endpoint (filter-based, idempotent) + 7. Upsert all points to api_tool_collection + 8. Return IndexingResult +""" + +import re +import sys +import json +import asyncio +import argparse +from typing import List +from loguru import logger + +from api_tool_indexer.constants import ApiToolIndexerConstants +from api_tool_indexer.models import EndpointData, EnrichedEndpoint, IndexingResult +from api_tool_indexer.qdrant_manager import ApiToolQdrantManager + +# Reuse LLMAPIClient from intent_data_enrichment. +from intent_data_enrichment.api_client import LLMAPIClient + +# Reuse sparse encoder from tool_classifier (shared BM25 implementation). +sys.path.insert(0, "/app/src") +try: + from tool_classifier.sparse_encoder import compute_sparse_vector +except ImportError: + try: + from src.tool_classifier.sparse_encoder import compute_sparse_vector + except ImportError: + logger.warning( + "Could not import sparse_encoder from tool_classifier, " + "attempting direct import" + ) + import importlib.util + import os + + module_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "tool_classifier", + "sparse_encoder.py", + ) + if os.path.exists(module_path): + spec = importlib.util.spec_from_file_location("sparse_encoder", module_path) + if spec is not None and spec.loader is not None: + sparse_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sparse_module) + compute_sparse_vector = sparse_module.compute_sparse_vector + else: + raise ImportError( + f"Cannot load spec or loader for sparse_encoder.py at {module_path}" + ) from None + else: + raise ImportError( + f"Cannot find sparse_encoder.py at {module_path}" + ) from None + + +def _build_params_summary(params: list) -> str: + """Build a readable one-liner of params for LLM prompt and embedding. + + Example output: + 'countryIsoCode (string, required): ISO country code; validFrom (date, required): Start date' + + Args: + params: List of param dicts with name, type, required, description keys. + + Returns: + Empty string if no params, otherwise a semicolon-separated summary. + """ + if not params: + return "No parameters required." + + parts: List[str] = [] + for p in params: + name = p.get("name", "unknown") + ptype = p.get("type", "string") + required = "required" if p.get("required", False) else "optional" + description = p.get("description", "") + parts.append(f"{name} ({ptype}, {required}): {description}") + + return "; ".join(parts) + + +async def _generate_context_for_endpoint( + api_client: LLMAPIClient, + endpoint_data: EndpointData, +) -> str: + """Generate a rich LLM context for an API endpoint. + + Args: + api_client: Initialized LLMAPIClient. + endpoint_data: Raw endpoint data from mock_endpoints table. + + Returns: + LLM-generated enriched context string. + + Raises: + RuntimeError: If context generation fails after all retries. + """ + params_summary = _build_params_summary(endpoint_data.params) + + logger.info(f"params_summary : {params_summary}") + + # Escape braces in the URL to prevent str.format() from treating path + # parameter templates like {id} as format placeholders (KeyError). + safe_url = endpoint_data.url.replace("{", "{{").replace("}", "}}") + full_endpoint_info = ( + f"Endpoint: {endpoint_data.name}\n" + f"Method: {endpoint_data.method}\n" + f"URL: {safe_url}\n" + f"Description: {endpoint_data.description}\n" + f"Parameters: {params_summary}" + ) + + context_prompt = ApiToolIndexerConstants.CONTEXT_TEMPLATE.format( + full_endpoint_info=full_endpoint_info, + name=endpoint_data.name, + description=endpoint_data.description, + params_summary=params_summary, + example_count=ApiToolIndexerConstants.EXAMPLE_QUERY_COUNT, + ) + + logger.debug( + "Generated context prompt for endpoint '{}': {} chars", + endpoint_data.endpoint_id, + len(context_prompt), + ) + + # context_type="api_tool" makes context_manager use API_TOOL_CONTEXT_PROMPT, + # which passes chunk_prompt through unmodified so CHUNK_CONTEXT_PROMPT cannot + # override the instructions in CONTEXT_TEMPLATE (e.g. example query generation). + request_data = { + "document_prompt": "", + "chunk_prompt": context_prompt, + "environment": api_client.environment, + "use_cache": False, + "connection_id": api_client.connection_id, + "context_type": "api_tool", + } + + last_error = None + for attempt in range(api_client.max_retries): + try: + logger.info( + f"Generating context for endpoint '{endpoint_data.endpoint_id}' " + f"(attempt {attempt + 1}/{api_client.max_retries})" + ) + + if not api_client.session: + raise RuntimeError("HTTP session not initialized") + + response = await api_client.session.post( + f"{api_client.api_base_url}/generate-context", json=request_data + ) + response.raise_for_status() + result = response.json() + + context = result.get("context", "").strip() + + logger.debug( + "context preview: {}{}", + context[:200].replace("\n", "\\n"), + "..." if len(context) > 200 else "", + ) + + if not context: + raise ValueError("Empty context returned from API") + + logger.success( + f"Context generated for endpoint '{endpoint_data.endpoint_id}': " + f"{len(context)} characters" + ) + return context + + except Exception as e: + last_error = e + logger.warning( + f"Context generation attempt {attempt + 1} failed for " + f"'{endpoint_data.endpoint_id}': {e}" + ) + if attempt < api_client.max_retries - 1: + delay = api_client.retry_delay_base**attempt + logger.info(f"Retrying in {delay}s...") + await asyncio.sleep(delay) + + error_msg = ( + f"Context generation failed for '{endpoint_data.endpoint_id}' " + f"after {api_client.max_retries} attempts: {last_error}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) + + +_EXAMPLE_SECTION_HEADER = re.compile(r"^example queries\s*:", re.IGNORECASE) + + +def _parse_example_queries(context: str) -> List[str]: + """Extract example query lines from the LLM-generated context. + + Scans for the 'Example queries:' section header and collects every + subsequent '- ' line until the section ends. + + Args: + context: Raw LLM-generated context string from generate_context(). + + Returns: + List of example query strings, deduplicated and preserving order. + """ + examples: List[str] = [] + in_section = False + + for line in context.splitlines(): + stripped = line.strip() + if _EXAMPLE_SECTION_HEADER.match(stripped): + in_section = True + continue + if in_section: + if stripped.startswith("- "): + examples.append(stripped[2:].strip()) + elif stripped and not stripped.startswith("#"): + # Non-empty, non-comment line that isn't a list item ends the section + in_section = False + + # Deduplicate preserving order + seen: set[str] = set() + unique: List[str] = [] + for ex in examples: + if ex and ex not in seen: + seen.add(ex) + unique.append(ex) + return unique + + +async def index_endpoint(endpoint_data: EndpointData) -> IndexingResult: + """Index one API endpoint into Qdrant api_tool_collection. + + Creates multiple points per endpoint: + - One 'example' point per parsed example query, embedded from that + individual sentence so the vector sits in the correct language region. + - One 'summary' point embedded from the combined name + description + context. + + Args: + endpoint_data: Raw endpoint data from mock_endpoints table. + + Returns: + IndexingResult indicating success or failure with details. + """ + endpoint_id = endpoint_data.endpoint_id + logger.info( + f"Starting indexing pipeline for endpoint '{endpoint_id}' " + f"(name='{endpoint_data.name}')" + ) + + try: + async with LLMAPIClient( + api_base_url=ApiToolIndexerConstants.DEFAULT_API_BASE_URL, + environment=ApiToolIndexerConstants.DEFAULT_ENVIRONMENT, + connection_id=ApiToolIndexerConstants.DEFAULT_CONNECTION_ID, + max_retries=ApiToolIndexerConstants.MAX_RETRIES, + retry_delay_base=ApiToolIndexerConstants.RETRY_DELAY_BASE, + timeout=ApiToolIndexerConstants.REQUEST_TIMEOUT, + ) as api_client: + # Step 1: Generate LLM enriched context (prose + example queries) + logger.info("Step 1/4: Generating LLM enriched context") + enriched_context = await _generate_context_for_endpoint( + api_client, endpoint_data + ) + + # Step 2: Parse example query lines from the context + example_queries = _parse_example_queries(enriched_context) + if not example_queries: + logger.warning( + f"No example queries parsed from context for endpoint '{endpoint_id}'. " + "The LLM output may not contain an 'Example queries:' section. " + "Only a summary point will be indexed — search accuracy may be reduced." + ) + else: + logger.info( + f"Step 2/4: Parsed {len(example_queries)} example queries from context" + ) + + # Step 3: Embed each example query individually → example points + logger.info( + f"Step 3/4: Creating embeddings for {len(example_queries)} example points" + ) + enriched_points: List[EnrichedEndpoint] = [] + + for i, example in enumerate(example_queries): + logger.debug( + f" Embedding example {i + 1}/{len(example_queries)}: " + f"'{example[:80]}{'...' if len(example) > 80 else ''}'" + ) + ex_embedding = await api_client.create_embedding(example) + ex_sparse = compute_sparse_vector(example) + enriched_points.append( + EnrichedEndpoint( + endpoint_id=endpoint_id, + name=endpoint_data.name, + description=endpoint_data.description, + url=endpoint_data.url, + method=endpoint_data.method, + params=endpoint_data.params, + enriched_context=enriched_context, + service_id=endpoint_data.service_id, + point_type="example", + example_text=example, + embedding=ex_embedding, + sparse_indices=ex_sparse.indices, + sparse_values=ex_sparse.values, + ) + ) + + # Step 4: Embed combined summary text → summary point + logger.info("Step 4/4: Creating summary point embedding") + params_summary = _build_params_summary(endpoint_data.params) + summary_text = ( + f"{endpoint_data.name}. " + f"{endpoint_data.description}. " + f"{enriched_context}. " + f"Parameters: {params_summary}" + ) + summary_embedding = await api_client.create_embedding(summary_text) + + # Sparse vectors are CPU-bound — computed after the HTTP session closes + summary_sparse = compute_sparse_vector(summary_text) + enriched_points.append( + EnrichedEndpoint( + endpoint_id=endpoint_id, + name=endpoint_data.name, + description=endpoint_data.description, + url=endpoint_data.url, + method=endpoint_data.method, + params=endpoint_data.params, + enriched_context=enriched_context, + service_id=endpoint_data.service_id, + point_type="summary", + embedding=summary_embedding, + sparse_indices=summary_sparse.indices, + sparse_values=summary_sparse.values, + ) + ) + + # Qdrant operations — separate block so the connection is always closed + qdrant = ApiToolQdrantManager() + try: + qdrant.connect() + qdrant.ensure_collection() + + # Delete all existing points for this endpoint (filter-based, idempotent) + deleted = qdrant.delete_endpoint_points(endpoint_id) + if not deleted: + logger.error( + f"Failed to delete existing points for endpoint '{endpoint_id}'. " + "Aborting upsert to prevent stale data." + ) + return IndexingResult( + success=False, + endpoint_id=endpoint_id, + message="Qdrant delete failed before upsert", + error="delete_endpoint_points returned False", + ) + + upserted = qdrant.upsert_endpoint_points(enriched_points) + finally: + qdrant.close() + + n_examples = len(example_queries) + if upserted: + logger.success( + f"Endpoint '{endpoint_id}' (name='{endpoint_data.name}') indexed successfully " + f"({n_examples} example points + 1 summary point)" + ) + return IndexingResult( + success=True, + endpoint_id=endpoint_id, + message=( + f"Endpoint '{endpoint_data.name}' indexed successfully into " + f"api_tool_collection " + f"({n_examples} example points + 1 summary point)" + ), + ) + else: + return IndexingResult( + success=False, + endpoint_id=endpoint_id, + message="Qdrant upsert failed", + error="upsert_endpoint_points returned False", + ) + + except Exception as e: + logger.error(f"Indexing pipeline failed for endpoint '{endpoint_id}': {e}") + return IndexingResult( + success=False, + endpoint_id=endpoint_id, + message="Indexing pipeline failed with an unexpected error", + error=str(e), + ) + + +def parse_arguments() -> EndpointData: + """Parse command line arguments into EndpointData model.""" + parser = argparse.ArgumentParser(description="API Tool Indexing") + parser.add_argument("--endpoint-id", type=str, required=True, help="Endpoint ID") + parser.add_argument("--name", type=str, required=True, help="Endpoint name") + parser.add_argument( + "--description", type=str, required=True, help="Endpoint description" + ) + parser.add_argument("--url", type=str, required=True, help="Endpoint URL") + parser.add_argument("--service-id", type=str, default="", help="Parent service ID") + parser.add_argument("--method", type=str, default="GET", help="HTTP method") + parser.add_argument("--visibility", type=str, default="public", help="Visibility") + parser.add_argument( + "--type", type=str, default="custom_endpoint", help="Endpoint type" + ) + parser.add_argument("--params-file", type=str, help="Path to params JSON file") + + args = parser.parse_args() + + # Read and parse JSON array from file + params = [] + if args.params_file: + try: + with open(args.params_file, "r", encoding="utf-8") as f: + content = f.read().strip() + if content: + params = json.loads(content) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.warning(f"Failed to read/parse params file: {e}") + + return EndpointData( + endpoint_id=args.endpoint_id, + service_id=args.service_id, + name=args.name, + description=args.description, + method=args.method, + url=args.url, + visibility=args.visibility, + type=args.type, + params=params, + ) + + +def main() -> int: + """Main entry point for API tool indexing via CLI.""" + logger.info("Starting API Tool indexing pipeline...") + + try: + # Parse arguments + endpoint_data = parse_arguments() + logger.info(f"Endpoint ID: {endpoint_data.endpoint_id}") + logger.info(f"Endpoint Name: {endpoint_data.name}") + logger.info(f"Params count: {len(endpoint_data.params)}") + + # Run indexing pipeline + result = asyncio.run(index_endpoint(endpoint_data)) + + # Log results + if result.success: + logger.success("Indexing completed successfully") + logger.info(f"Endpoint: {result.endpoint_id}") + logger.info(f"Message: {result.message}") + return 0 + else: + logger.error("Indexing failed") + logger.error(f"Endpoint: {result.endpoint_id}") + logger.error(f"Message: {result.message}") + logger.error(f"Error: {result.error}") + return 1 + + except Exception as e: + logger.error(f"Fatal error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/api_tool_indexer/models.py b/src/api_tool_indexer/models.py new file mode 100644 index 00000000..6333d2fd --- /dev/null +++ b/src/api_tool_indexer/models.py @@ -0,0 +1,100 @@ +"""Data models for the API Tool Indexer.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class ParamSchema(BaseModel): + """Schema for a single API endpoint parameter.""" + + name: str = Field(..., description="Parameter name") + type: str = Field( + ..., + description="Parameter type: string, date, integer, boolean, number", + ) + required: bool = Field(..., description="Whether this parameter is required") + description: str = Field( + ..., description="Human-readable description of the parameter" + ) + + +class EndpointData(BaseModel): + """Raw endpoint data fetched from the mock_endpoints table. + + This is the input to the indexing pipeline. + """ + + endpoint_id: str = Field(..., description="UUID of the endpoint") + name: str = Field(..., description="Endpoint name (snake_case)") + description: str = Field(..., description="Human-readable description") + url: str = Field(..., description="Full URL of the external API") + method: str = Field(..., description="HTTP method: GET or POST") + params: List[Dict[str, Any]] = Field( + default_factory=list, + description="List of parameter schemas [{name, type, required, description}]", + ) + service_id: Optional[str] = Field( + default=None, description="Optional parent service UUID" + ) + visibility: str = Field(default="private", description="public or private") + type: str = Field(default="custom_endpoint", description="Endpoint type") + + +class EnrichedEndpoint(BaseModel): + """Enriched endpoint data ready for storage in Qdrant api_tool_collection. + + Multiple points are stored per endpoint: + - One 'example' point per example query — embedded from that sentence alone, + so the vector sits in the correct language region of the embedding space. + - One 'summary' point — embedded from name + description + full enriched context. + + All points share the same endpoint_id payload field so the searcher can + deduplicate results back to a single endpoint after retrieval. + The payload on every point contains all fields needed by the agentic loop + so no additional DB roundtrip is needed after a semantic match. + """ + + endpoint_id: str = Field(..., description="UUID of the endpoint") + name: str = Field(..., description="Endpoint name") + description: str = Field(..., description="Raw description from DB") + url: str = Field(..., description="Full URL of the external API") + method: str = Field(..., description="HTTP method: GET or POST") + params: List[Dict[str, Any]] = Field( + ..., + description="Full params schema — stored in payload for direct use by agentic loop", + ) + enriched_context: str = Field( + ..., description="LLM-generated rich context for better semantic matching" + ) + service_id: Optional[str] = Field(default=None, description="Parent service UUID") + + # Point type — controls which text was embedded for this point + point_type: str = Field( + default="summary", + description="'example' (individual query text) or 'summary' (full context)", + ) + example_text: Optional[str] = Field( + default=None, + description="The example query string for 'example' points; None for 'summary'", + ) + + # Vector fields (populated by indexer pipeline) + embedding: List[float] = Field( + default_factory=list, description="Dense embedding vector (3072-dim)" + ) + sparse_indices: List[int] = Field( + default_factory=list, description="Sparse vector indices (BM25)" + ) + sparse_values: List[float] = Field( + default_factory=list, description="Sparse vector values (BM25)" + ) + + +class IndexingResult(BaseModel): + """Result of a single endpoint indexing operation.""" + + success: bool = Field(..., description="Whether indexing succeeded") + endpoint_id: str = Field(..., description="Endpoint UUID") + message: str = Field(..., description="Result message") + error: Optional[str] = Field(default=None, description="Error message if failed") diff --git a/src/api_tool_indexer/qdrant_manager.py b/src/api_tool_indexer/qdrant_manager.py new file mode 100644 index 00000000..de2fa0e8 --- /dev/null +++ b/src/api_tool_indexer/qdrant_manager.py @@ -0,0 +1,298 @@ +"""Qdrant manager for api_tool_collection with hybrid search support. +for the api_tool_collection used by the API Tool Calling workflow. +""" + +import uuid +from typing import Any, Dict, List, Optional +from loguru import logger +from qdrant_client import QdrantClient +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + FilterSelector, + MatchValue, + PointStruct, + SparseIndexParams, + SparseVector, + SparseVectorParams, + VectorParams, +) + +from api_tool_indexer.constants import ApiToolIndexerConstants +from api_tool_indexer.models import EnrichedEndpoint + +# Error messages +_CLIENT_NOT_INITIALIZED = "Qdrant client not initialized" + + +class ApiToolQdrantManager: + """Manages Qdrant operations for api_tool_collection with hybrid search. + + Multiple points are stored per endpoint: + - One 'example' point per example query + - One 'summary' point for the full combined context + All points share the same endpoint_id payload field for deduplication. + """ + + def __init__( + self, + host: str = ApiToolIndexerConstants.DEFAULT_QDRANT_HOST, + port: int = ApiToolIndexerConstants.DEFAULT_QDRANT_PORT, + collection_name: str = ApiToolIndexerConstants.COLLECTION_NAME, + ) -> None: + self.host = host + self.port = port + self.collection_name = collection_name + self.client: Optional[QdrantClient] = None + + def connect(self) -> None: + """Connect to Qdrant.""" + try: + logger.info(f"Connecting to Qdrant at {self.host}:{self.port}") + self.client = QdrantClient( + host=self.host, + port=self.port, + timeout=30, + prefer_grpc=False, + api_key=None, + ) + logger.success("Successfully connected to Qdrant") + except Exception as e: + logger.error(f"Failed to connect to Qdrant: {e}") + raise + + def ensure_collection(self) -> None: + """Ensure api_tool_collection exists with hybrid vector config. + + The collection uses named vectors: + - 'dense': 3072-dim cosine similarity vectors for semantic matching + - 'sparse': BM25-style sparse vectors for keyword matching + """ + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + try: + collections = self.client.get_collections().collections + collection_names = [col.name for col in collections] + + if self.collection_name in collection_names: + self._validate_existing_collection() + else: + self._create_collection() + + except Exception as e: + logger.error(f"Failed to ensure collection exists: {e}") + raise + + def _validate_existing_collection(self) -> None: + """Validate that the existing API Tool collection has correct hybrid vector config.""" + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + collection_info = self.client.get_collection(self.collection_name) + vectors_config = collection_info.config.params.vectors + + if isinstance(vectors_config, dict): + if ApiToolIndexerConstants.DENSE_VECTOR_NAME in vectors_config: + existing_size = vectors_config[ + ApiToolIndexerConstants.DENSE_VECTOR_NAME + ].size + if existing_size != ApiToolIndexerConstants.VECTOR_SIZE: + logger.error( + f"Collection '{self.collection_name}' has incompatible vector size: " + f"{existing_size} (expected {ApiToolIndexerConstants.VECTOR_SIZE})" + ) + raise RuntimeError( + f"Collection '{self.collection_name}' has incompatible vector size: " + f"{existing_size} (expected {ApiToolIndexerConstants.VECTOR_SIZE}). " + "Delete the collection and re-index all endpoints." + ) + logger.info( + f"Collection '{self.collection_name}' already exists " + f"with correct hybrid vector config (dense: {existing_size}d + sparse)" + ) + else: + # Old collection format (unnamed/single vector) — needs migration + logger.error( + f"Collection '{self.collection_name}' exists but uses old single-vector format. " + "Migration to named vectors (dense + sparse) required." + ) + raise RuntimeError( + f"Collection '{self.collection_name}' uses old single-vector format. " + "Please delete the collection and re-index all endpoints. " + f"Delete with: qdrant.client.delete_collection('{self.collection_name}') " + "or via Qdrant UI/API." + ) + elif vectors_config is not None: + # Direct VectorParams object (old single-vector format) + logger.error( + f"Collection '{self.collection_name}' exists but uses old single-vector format." + ) + raise RuntimeError( + f"Collection '{self.collection_name}' uses old single-vector format. " + "Please delete the collection and re-index all endpoints. " + f"Delete with: qdrant.client.delete_collection('{self.collection_name}') " + "or via Qdrant UI/API." + ) + else: + logger.error( + f"Collection '{self.collection_name}' exists but vector config cannot be determined." + ) + raise RuntimeError( + f"Collection '{self.collection_name}' vector config cannot be determined. " + "Manual intervention required." + ) + + def _create_collection(self) -> None: + """Create api_tool_collection with hybrid vector configuration (dense + sparse).""" + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info( + f"Creating collection '{self.collection_name}' " + f"with hybrid vectors (dense: {ApiToolIndexerConstants.VECTOR_SIZE}d + sparse)" + ) + self.client.create_collection( + collection_name=self.collection_name, + vectors_config={ + ApiToolIndexerConstants.DENSE_VECTOR_NAME: VectorParams( + size=ApiToolIndexerConstants.VECTOR_SIZE, + distance=Distance.COSINE, + ), + }, + sparse_vectors_config={ + ApiToolIndexerConstants.SPARSE_VECTOR_NAME: SparseVectorParams( + index=SparseIndexParams(on_disk=False), + ), + }, + ) + logger.success(f"Collection '{self.collection_name}' created successfully") + + def delete_endpoint_points(self, endpoint_id: str) -> bool: + """Delete all Qdrant points for a given endpoint. + + Uses a payload filter on 'endpoint_id' to remove all example and summary + points belonging to this endpoint. Called before re-indexing to ensure + idempotent updates, and when an endpoint is removed from the DB. + + Args: + endpoint_id: UUID of the endpoint whose points should be deleted. + + Returns: + True if successful, False otherwise. + """ + try: + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info(f"Deleting all points for endpoint '{endpoint_id}' from Qdrant") + self.client.delete( + collection_name=self.collection_name, + points_selector=FilterSelector( + filter=Filter( + must=[ + FieldCondition( + key="endpoint_id", + match=MatchValue(value=endpoint_id), + ) + ] + ) + ), + ) + logger.success( + f"Successfully deleted all points for endpoint '{endpoint_id}'" + ) + return True + + except Exception as e: + logger.error(f"Failed to delete points for endpoint '{endpoint_id}': {e}") + return False + + def upsert_endpoint_points(self, enriched_points: List[EnrichedEndpoint]) -> bool: + """Upsert multiple enriched endpoint points to Qdrant. + + Each point gets a deterministic UUID derived from endpoint_id + index so + upserts are idempotent. All points carry the full endpoint payload so no + additional DB roundtrip is needed after a semantic match. + + Payload fields stored on every point: + endpoint_id, name, description, url, method, params, + enriched_context, service_id, point_type, example_text (example only) + + Args: + enriched_points: List of EnrichedEndpoint instances (examples + summary). + + Returns: + True if all points upserted successfully, False otherwise. + """ + try: + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + if not enriched_points: + logger.warning("No points to upsert") + return True + + endpoint_id = enriched_points[0].endpoint_id + logger.info( + f"Upserting {len(enriched_points)} points for endpoint '{endpoint_id}'" + ) + + points: List[PointStruct] = [] + for idx, enriched in enumerate(enriched_points): + # Deterministic UUID: same input always produces the same point ID + point_id = str( + uuid.uuid5(uuid.NAMESPACE_DNS, f"{enriched.endpoint_id}_{idx}") + ) + + payload: Dict[str, Any] = { + "endpoint_id": enriched.endpoint_id, + "name": enriched.name, + "description": enriched.description, + "url": enriched.url, + "method": enriched.method, + "params": enriched.params, + "enriched_context": enriched.enriched_context, + "service_id": enriched.service_id, + "point_type": enriched.point_type, + } + if enriched.example_text is not None: + payload["example_text"] = enriched.example_text + + vectors: Dict[str, Any] = { + ApiToolIndexerConstants.DENSE_VECTOR_NAME: enriched.embedding, + } + if enriched.sparse_indices: + vectors[ApiToolIndexerConstants.SPARSE_VECTOR_NAME] = SparseVector( + indices=enriched.sparse_indices, + values=enriched.sparse_values, + ) + + points.append(PointStruct(id=point_id, vector=vectors, payload=payload)) + + self.client.upsert( + collection_name=self.collection_name, + points=points, + ) + + n_examples = sum(1 for p in enriched_points if p.point_type == "example") + n_summary = sum(1 for p in enriched_points if p.point_type == "summary") + logger.success( + f"Successfully upserted {len(points)} points for endpoint '{endpoint_id}' " + f"({n_examples} example + {n_summary} summary)" + ) + return True + + except Exception as e: + logger.error( + f"Failed to upsert points for endpoint " + f"'{enriched_points[0].endpoint_id if enriched_points else '?'}': {e}" + ) + return False + + def close(self) -> None: + """Close Qdrant connection.""" + if self.client: + logger.info("Closing Qdrant connection") + self.client.close() diff --git a/src/contextual_retrieval/bm25_search.py b/src/contextual_retrieval/bm25_search.py index 5bde02d0..7ec8ea9a 100644 --- a/src/contextual_retrieval/bm25_search.py +++ b/src/contextual_retrieval/bm25_search.py @@ -5,10 +5,11 @@ when collection data changes. """ -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Set, TYPE_CHECKING from loguru import logger from rank_bm25 import BM25Okapi import re +import asyncio from contextual_retrieval.contextual_retrieval_api_client import get_http_client_manager from contextual_retrieval.error_handler import SecureErrorHandler from contextual_retrieval.constants import ( @@ -19,13 +20,16 @@ ) from contextual_retrieval.config import ConfigLoader, ContextualRetrievalConfig +if TYPE_CHECKING: + from contextual_retrieval.contextual_retrieval_api_client import HTTPClientManager + class SmartBM25Search: """In-memory BM25 search with smart refresh capabilities.""" def __init__( self, qdrant_url: str, config: Optional["ContextualRetrievalConfig"] = None - ): + ) -> None: self.qdrant_url = qdrant_url self._config = config if config is not None else ConfigLoader.load_config() self._http_client_manager = None @@ -33,8 +37,13 @@ def __init__( self.chunk_mapping: Dict[int, Dict[str, Any]] = {} self.last_collection_stats: Dict[str, Any] = {} self.tokenizer_pattern = re.compile(r"\w+") # Simple word tokenizer + # Background refresh state - prevents blocking queries during index rebuild + self._refresh_in_progress: bool = False + self._refresh_lock: asyncio.Lock = asyncio.Lock() + # Strong references to background tasks to prevent premature GC + self._background_tasks: Set[asyncio.Task[None]] = set() - async def _get_http_client_manager(self): + async def _get_http_client_manager(self) -> "HTTPClientManager": """Get the HTTP client manager instance.""" if self._http_client_manager is None: self._http_client_manager = await get_http_client_manager() @@ -103,10 +112,24 @@ async def search_bm25( limit = self._config.search.topk_bm25 try: - # Check if index needs refresh + # Check if index needs refresh (non-blocking: schedule background rebuild, + # current query continues with the existing index to avoid latency). if await self._should_refresh_index(): - logger.info("Collection data changed - refreshing BM25 index") - await self.initialize_index() + # Avoid scheduling multiple concurrent refresh tasks; coalesce while a + # refresh is already in progress. + if not self._refresh_in_progress: + logger.info( + "Collection data changed - scheduling background BM25 refresh " + "(current query uses existing index)" + ) + task = asyncio.create_task(self._background_refresh_index()) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + else: + logger.debug( + "BM25 refresh already in progress; skipping scheduling of a " + "new background refresh task" + ) if not self.bm25_index: logger.error("BM25 index not initialized") @@ -162,6 +185,31 @@ async def search_bm25( logger.error(f"BM25 search failed: {e}") return [] + async def _background_refresh_index(self) -> None: + """ + Rebuild the BM25 index in the background without blocking in-flight queries. + + Uses a lock to ensure only one rebuild runs at a time. If a rebuild is + already in progress when a second collection-change is detected, the + duplicate request is silently discarded — the in-progress rebuild will + capture the latest data anyway. + """ + if self._refresh_in_progress: + logger.debug("BM25 background refresh already running - skipping duplicate") + return + async with self._refresh_lock: + if self._refresh_in_progress: + return + self._refresh_in_progress = True + try: + logger.info("Starting background BM25 index refresh...") + await self.initialize_index() + logger.info("Background BM25 index refresh complete") + except Exception as e: + logger.error(f"Background BM25 refresh failed: {e}") + finally: + self._refresh_in_progress = False + async def _fetch_all_contextual_chunks(self) -> List[Dict[str, Any]]: """Fetch all chunks from contextual collections.""" all_chunks: List[Dict[str, Any]] = [] @@ -311,7 +359,7 @@ def _tokenize_text(self, text: str) -> List[str]: tokens = self.tokenizer_pattern.findall(text.lower()) return tokens - async def close(self): + async def close(self) -> None: """Close HTTP client.""" if self._http_client_manager: await self._http_client_manager.close() diff --git a/src/contextual_retrieval/constants.py b/src/contextual_retrieval/constants.py index 7ca58cb8..b009ebd3 100644 --- a/src/contextual_retrieval/constants.py +++ b/src/contextual_retrieval/constants.py @@ -5,6 +5,8 @@ and other configurable values across the contextual retrieval system. """ +from vector_indexer.constants import ResponseGenerationConstants + class HttpClientConstants: """HTTP client configuration constants.""" @@ -13,7 +15,7 @@ class HttpClientConstants: DEFAULT_FAILURE_THRESHOLD = 5 DEFAULT_RECOVERY_TIMEOUT = 60.0 - # Timeouts (seconds) + # Timeouts in seconds DEFAULT_READ_TIMEOUT = 30.0 DEFAULT_CONNECT_TIMEOUT = 10.0 DEFAULT_WRITE_TIMEOUT = 10.0 @@ -41,7 +43,8 @@ class SearchConstants: # Default search parameters DEFAULT_TOPK_SEMANTIC = 40 DEFAULT_TOPK_BM25 = 40 - DEFAULT_FINAL_TOP_N = 12 + # Final top-N chunks returned after RRF fusion. + DEFAULT_FINAL_TOP_N = ResponseGenerationConstants.DEFAULT_MAX_BLOCKS DEFAULT_SEARCH_TIMEOUT = 2 # Score and quality thresholds diff --git a/src/contextual_retrieval/contextual_retrieval.md b/src/contextual_retrieval/contextual_retrieval.md index f80d6aa4..ce3446c6 100644 --- a/src/contextual_retrieval/contextual_retrieval.md +++ b/src/contextual_retrieval/contextual_retrieval.md @@ -788,7 +788,7 @@ def _initialize_contextual_retriever( #### 2. Request Processing ```python # Main orchestration pipeline -def _execute_orchestration_pipeline(self, request, components, costs_dict): +def _execute_orchestration_pipeline(self, request, components, costs_metric): # Step 1: Refine user prompt refined_output = self._refine_user_prompt(...) diff --git a/src/contextual_retrieval/contextual_retrieval_api_client.py b/src/contextual_retrieval/contextual_retrieval_api_client.py index 3b82e1c1..0de14558 100644 --- a/src/contextual_retrieval/contextual_retrieval_api_client.py +++ b/src/contextual_retrieval/contextual_retrieval_api_client.py @@ -24,7 +24,7 @@ class ServiceResilienceManager: """Service resilience manager with circuit breaker functionality for HTTP requests.""" - def __init__(self, config: Optional["ContextualRetrievalConfig"] = None): + def __init__(self, config: Optional["ContextualRetrievalConfig"] = None) -> None: # Load configuration if not provided if config is None: config = ConfigLoader.load_config() @@ -81,7 +81,7 @@ class HTTPClientManager: _instance: Optional["HTTPClientManager"] = None _lock = asyncio.Lock() - def __init__(self, config: Optional["ContextualRetrievalConfig"] = None): + def __init__(self, config: Optional["ContextualRetrievalConfig"] = None) -> None: """Initialize HTTP client manager.""" # Load configuration if not provided self._config = config if config is not None else ConfigLoader.load_config() @@ -169,7 +169,7 @@ async def get_client( SecureErrorHandler.sanitize_error_message( e, "HTTP client initialization" ) - ) + ) from e return self._client diff --git a/src/contextual_retrieval/contextual_retriever.py b/src/contextual_retrieval/contextual_retriever.py index 8ab5d242..bdb61eb1 100644 --- a/src/contextual_retrieval/contextual_retriever.py +++ b/src/contextual_retrieval/contextual_retriever.py @@ -42,7 +42,8 @@ def __init__( connection_id: Optional[str] = None, config_path: Optional[str] = None, llm_service: Optional["LLMOrchestrationService"] = None, - ): + shared_bm25: Optional[SmartBM25Search] = None, + ) -> None: """ Initialize contextual retriever. @@ -52,6 +53,10 @@ def __init__( connection_id: Optional connection ID config_path: Optional config file path llm_service: Optional LLM service instance (prevents circular dependency) + shared_bm25: Optional pre-warmed SmartBM25Search singleton. When + provided the retriever skips the expensive index-build step during + initialize() and reuses the already-ready index, eliminating the + cold-start latency on the first query. """ self.qdrant_url = qdrant_url self.environment = environment @@ -70,7 +75,14 @@ def __init__( # Initialize components with configuration self.provider_detection = DynamicProviderDetection(qdrant_url, self.config) self.qdrant_search = QdrantContextualSearch(qdrant_url, self.config) - self.bm25_search = SmartBM25Search(qdrant_url, self.config) + # Use the injected pre-warmed singleton when available; create a fresh + # instance only as a fallback (avoids duplicate Qdrant scroll on startup). + self.bm25_search: SmartBM25Search = ( + shared_bm25 + if shared_bm25 is not None + else SmartBM25Search(qdrant_url, self.config) + ) + self._bm25_is_shared: bool = shared_bm25 is not None self.rank_fusion = DynamicRankFusion(self.config) # State @@ -87,10 +99,18 @@ async def initialize(self) -> bool: try: logger.info("Initializing Contextual Retriever...") - # Initialize BM25 index - bm25_success = await self.bm25_search.initialize_index() - if not bm25_success: - logger.warning("BM25 initialization failed - will skip BM25 search") + # If received a pre-warmed shared BM25 index, reuse it directly. + # This is the normal startup path and adds zero latency to the first query. + if self._bm25_is_shared and self.bm25_search.bm25_index is not None: + logger.info( + "Using pre-warmed shared BM25 index - skipping BM25 build " + f"({len(self.bm25_search.chunk_mapping)} chunks ready)" + ) + else: + # No shared index available - build it now (fallback path). + bm25_success = await self.bm25_search.initialize_index() + if not bm25_success: + logger.warning("BM25 initialization failed - will skip BM25 search") self.initialized = True logger.info("Contextual Retriever initialized successfully") @@ -100,7 +120,7 @@ async def initialize(self) -> bool: logger.error(f"Failed to initialize Contextual Retriever: {e}") return False - def _get_session_llm_service(self): + def _get_session_llm_service(self) -> "LLMOrchestrationService": """ Get cached LLM service for current retrieval session. Uses injected service if available, creates new instance as fallback. @@ -120,7 +140,7 @@ def _get_session_llm_service(self): return self._session_llm_service - def _clear_session_cache(self): + def _clear_session_cache(self) -> None: """Clear cached connections at end of retrieval session.""" if self._session_llm_service is not None: logger.debug("Clearing session LLM service cache") @@ -206,18 +226,20 @@ async def retrieve_contextual_chunks( semantic_task, bm25_task, return_exceptions=True ) - # Handle exceptions and assign results - if isinstance(search_results[0], Exception): - logger.error(f"Semantic search failed: {search_results[0]}") + # Handle exceptions and assign results with proper type narrowing + semantic_result = search_results[0] + if isinstance(semantic_result, BaseException): + logger.error(f"Semantic search failed: {semantic_result}") semantic_results = [] else: - semantic_results = search_results[0] + semantic_results = semantic_result - if isinstance(search_results[1], Exception): - logger.error(f"BM25 search failed: {search_results[1]}") + bm25_result = search_results[1] + if isinstance(bm25_result, BaseException): + logger.error(f"BM25 search failed: {bm25_result}") bm25_results = [] else: - bm25_results = search_results[1] + bm25_results = bm25_result else: # Sequential execution semantic_results = await self._semantic_search( @@ -352,7 +374,9 @@ async def _execute_batch_query_searches( self._search_single_query_with_embedding( query, i, embedding, collections, limit ) - for i, (query, embedding) in enumerate(zip(queries, batch_embeddings)) + for i, (query, embedding) in enumerate( + zip(queries, batch_embeddings, strict=True) + ) ] # Execute all searches in parallel @@ -599,7 +623,7 @@ async def health_check(self) -> Dict[str, Any]: return health_status - async def close(self): + async def close(self) -> None: """Clean up resources.""" try: await self.provider_detection.close() diff --git a/src/contextual_retrieval/provider_detection.py b/src/contextual_retrieval/provider_detection.py index de750902..8abb4d18 100644 --- a/src/contextual_retrieval/provider_detection.py +++ b/src/contextual_retrieval/provider_detection.py @@ -7,7 +7,7 @@ - No hardcoded weights or preferences """ -from typing import List, Optional, Dict, Any +from typing import List, Optional, Dict, Any, TYPE_CHECKING from loguru import logger from contextual_retrieval.contextual_retrieval_api_client import get_http_client_manager from contextual_retrieval.error_handler import SecureErrorHandler @@ -18,18 +18,21 @@ ) from contextual_retrieval.config import ConfigLoader, ContextualRetrievalConfig +if TYPE_CHECKING: + from contextual_retrieval.contextual_retrieval_api_client import HTTPClientManager + class DynamicProviderDetection: """Dynamic collection selection without hardcoded preferences.""" def __init__( self, qdrant_url: str, config: Optional["ContextualRetrievalConfig"] = None - ): + ) -> None: self.qdrant_url = qdrant_url self._config = config if config is not None else ConfigLoader.load_config() self._http_client_manager = None - async def _get_http_client_manager(self): + async def _get_http_client_manager(self) -> "HTTPClientManager": """Get the HTTP client manager instance.""" if self._http_client_manager is None: self._http_client_manager = await get_http_client_manager() @@ -212,7 +215,7 @@ async def get_collection_stats(self) -> Dict[str, Any]: return stats - async def close(self): + async def close(self) -> None: """Close HTTP client.""" if self._http_client_manager: await self._http_client_manager.close() diff --git a/src/contextual_retrieval/qdrant_search.py b/src/contextual_retrieval/qdrant_search.py index 2c7d260b..31515f32 100644 --- a/src/contextual_retrieval/qdrant_search.py +++ b/src/contextual_retrieval/qdrant_search.py @@ -5,7 +5,7 @@ existing contextual embeddings created by the vector indexer. """ -from typing import List, Dict, Any, Optional, Protocol +from typing import List, Dict, Any, Optional, Protocol, TYPE_CHECKING from loguru import logger import asyncio from contextual_retrieval.contextual_retrieval_api_client import get_http_client_manager @@ -17,6 +17,9 @@ ) from contextual_retrieval.config import ConfigLoader, ContextualRetrievalConfig +if TYPE_CHECKING: + from contextual_retrieval.contextual_retrieval_api_client import HTTPClientManager + class LLMServiceProtocol(Protocol): """Protocol defining the interface required from LLM service for embedding operations.""" @@ -47,12 +50,12 @@ class QdrantContextualSearch: def __init__( self, qdrant_url: str, config: Optional["ContextualRetrievalConfig"] = None - ): + ) -> None: self.qdrant_url = qdrant_url self._config = config if config is not None else ConfigLoader.load_config() self._http_client_manager = None - async def _get_http_client_manager(self): + async def _get_http_client_manager(self) -> "HTTPClientManager": """Get the HTTP client manager instance.""" if self._http_client_manager is None: self._http_client_manager = await get_http_client_manager() @@ -345,7 +348,7 @@ def get_embeddings_for_queries_batch( logger.error(f"Failed to get batch embeddings: {e}") return None - async def close(self): + async def close(self) -> None: """Close HTTP client.""" if self._http_client_manager: await self._http_client_manager.close() diff --git a/src/contextual_retrieval/rank_fusion.py b/src/contextual_retrieval/rank_fusion.py index c53f89ac..acea0aa2 100644 --- a/src/contextual_retrieval/rank_fusion.py +++ b/src/contextual_retrieval/rank_fusion.py @@ -14,7 +14,7 @@ class DynamicRankFusion: """Dynamic score fusion without hardcoded collection weights.""" - def __init__(self, config: Optional["ContextualRetrievalConfig"] = None): + def __init__(self, config: Optional["ContextualRetrievalConfig"] = None) -> None: """ Initialize rank fusion with configuration. @@ -184,7 +184,7 @@ def _reciprocal_rank_fusion( # Calculate final fused scores fused_results: List[Dict[str, Any]] = [] - for chunk_id, data in chunk_scores.items(): + for data in chunk_scores.values(): chunk = data["chunk"].copy() # Calculate fused RRF score diff --git a/src/guardrails/dspy_nemo_adapter.py b/src/guardrails/dspy_nemo_adapter.py index 630b2657..da2617d7 100644 --- a/src/guardrails/dspy_nemo_adapter.py +++ b/src/guardrails/dspy_nemo_adapter.py @@ -14,6 +14,7 @@ AsyncCallbackManagerForLLMRun, ) from langchain_core.language_models.llms import LLM +from langchain_core.outputs import GenerationChunk from src.guardrails.guardrails_llm_configs import TEMPERATURE, MAX_TOKENS, MODEL_NAME @@ -61,7 +62,7 @@ def _identifying_params(self) -> Dict[str, Any]: "streaming": self.streaming, } - def _get_dspy_lm(self) -> Any: + def _get_dspy_lm(self) -> dspy.LM: """ Get the active DSPy LM from settings. @@ -191,7 +192,7 @@ def _stream( stop: Optional[List[str]] = None, run_manager: Optional[CallbackManagerForLLMRun] = None, **kwargs: Any, - ) -> Iterator[str]: + ) -> Iterator[GenerationChunk]: """ Synchronous streaming via DSPy's native streaming support. @@ -227,7 +228,7 @@ def _stream( if token: if run_manager: run_manager.on_llm_new_token(token) - yield token + yield GenerationChunk(text=token) except Exception as e: logger.error(f"Error in DSPyNeMoLLM._stream: {str(e)}") @@ -239,7 +240,7 @@ async def _astream( stop: Optional[List[str]] = None, run_manager: Optional[AsyncCallbackManagerForLLMRun] = None, **kwargs: Any, - ) -> AsyncIterator[str]: + ) -> AsyncIterator[GenerationChunk]: """ Async streaming using Threaded Producer / Async Consumer pattern. @@ -263,9 +264,9 @@ async def _astream( loop = asyncio.get_running_loop() # Sentinel to mark end of stream - SENTINEL = object() + sentinel = object() - def producer(): + def producer() -> None: """ Synchronous producer running in a thread. Calls DSPy's LM with stream=True and pushes chunks to queue. @@ -288,7 +289,7 @@ def producer(): loop.call_soon_threadsafe(queue.put_nowait, chunk) # Signal completion - loop.call_soon_threadsafe(queue.put_nowait, SENTINEL) + loop.call_soon_threadsafe(queue.put_nowait, sentinel) except Exception as e: # Pass exception to async consumer @@ -304,7 +305,7 @@ def producer(): chunk = await queue.get() # Check for completion - if chunk is SENTINEL: + if chunk is sentinel: break # Check for errors from producer @@ -316,7 +317,7 @@ def producer(): if token: if run_manager: await run_manager.on_llm_new_token(token) - yield token + yield GenerationChunk(text=token) except Exception as e: logger.error(f"Error in DSPyNeMoLLM._astream: {str(e)}") diff --git a/src/guardrails/nemo_rails_adapter.py b/src/guardrails/nemo_rails_adapter.py index ecbd1b33..17f6585e 100644 --- a/src/guardrails/nemo_rails_adapter.py +++ b/src/guardrails/nemo_rails_adapter.py @@ -1,10 +1,11 @@ -from typing import Any, Dict, Optional, AsyncIterator +from typing import Any, Dict, Optional, AsyncIterator, cast, Type import asyncio from loguru import logger from pydantic import BaseModel, Field from nemoguardrails import LLMRails, RailsConfig from nemoguardrails.llm.providers import register_llm_provider +from langchain_core.language_models.llms import BaseLLM from src.llm_orchestrator_config.llm_ochestrator_constants import ( GUARDRAILS_BLOCKED_PHRASES, ) @@ -56,19 +57,24 @@ def __init__( self._rails: Optional[LLMRails] = None self._initialized = False - logger.info(f"Initializing NeMoRailsAdapter for environment: {environment}") + logger.debug(f"NeMoRailsAdapter created for environment: {environment}") def _register_custom_provider(self) -> None: """Register DSPy custom LLM provider with NeMo Guardrails.""" try: from src.guardrails.dspy_nemo_adapter import DSPyLLMProviderFactory - logger.info("Registering DSPy custom LLM provider with NeMo Guardrails") + logger.debug("Registering DSPy custom LLM provider with NeMo Guardrails") - provider_factory = DSPyLLMProviderFactory() - - register_llm_provider("dspy-custom", provider_factory) - logger.info("DSPy custom LLM provider registered successfully") + # NeMo Guardrails' register_llm_provider accepts callable factories at runtime. + # We instantiate DSPyLLMProviderFactory first, then register the instance. + # The factory instance implements __call__ to return DSPyNeMoLLM instances + # (which properly inherit from BaseLLM). This ensures NeMo can call the factory + # without trying to instantiate it with config kwargs that __init__ doesn't accept. + # We use cast to satisfy the type checker while maintaining runtime correctness. + factory = DSPyLLMProviderFactory() + register_llm_provider("dspy-custom", cast(Type[BaseLLM], factory)) + logger.debug("DSPy custom LLM provider registered successfully") except Exception as e: logger.error(f"Failed to register DSPy custom provider: {str(e)}") @@ -80,8 +86,8 @@ def _ensure_initialized(self) -> None: return try: - logger.info( - "Initializing NeMo Guardrails with DSPy LLM and streaming support" + logger.debug( + f"Initializing NeMo Guardrails with DSPy LLM (env={self.environment})" ) from llm_orchestrator_config.llm_manager import LLMManager @@ -100,33 +106,24 @@ def _ensure_initialized(self) -> None: guardrails_loader = get_guardrails_loader() config_path, metadata = guardrails_loader.get_optimized_config_path() - logger.info(f"Loading guardrails config from: {config_path}") + logger.debug(f"Loading guardrails config from: {config_path}") rails_config = RailsConfig.from_path(str(config_path.parent)) rails_config.streaming = True - logger.info("Streaming configuration:") - logger.info(f" Global streaming: {rails_config.streaming}") - - if hasattr(rails_config, "rails") and hasattr(rails_config.rails, "output"): + if metadata.get("optimized", False): + version = metadata.get("version", "unknown") + metrics = metadata.get("metrics", {}) + accuracy = metrics.get("weighted_accuracy", "N/A") if metrics else "N/A" logger.info( - f" Output rails config exists: {rails_config.rails.output}" + f"Guardrails ready: OPTIMIZED config v={version}, " + f"weighted_accuracy={accuracy}, env={self.environment}" ) else: - logger.info(" Output rails config will be loaded from YAML") - - if metadata.get("optimized", False): logger.info( - f"Loaded OPTIMIZED guardrails config (version: {metadata.get('version', 'unknown')})" + f"Guardrails ready: BASE config (no optimization), env={self.environment}" ) - metrics = metadata.get("metrics", {}) - if metrics: - logger.info( - f" Optimization metrics: weighted_accuracy={metrics.get('weighted_accuracy', 'N/A')}" - ) - else: - logger.info("Loaded BASE guardrails config (no optimization)") from src.guardrails.dspy_nemo_adapter import DSPyNeMoLLM @@ -138,18 +135,16 @@ def _ensure_initialized(self) -> None: verbose=False, ) - if ( + if not ( hasattr(self._rails.config, "streaming") and self._rails.config.streaming ): - logger.info("✓ Streaming enabled in NeMo Guardrails configuration") - else: logger.warning( "Streaming not enabled in configuration - this may cause issues" ) self._initialized = True - logger.info("NeMo Guardrails initialized successfully with DSPy LLM") + logger.debug("NeMo Guardrails initialized successfully with DSPy LLM") except Exception as e: logger.error(f"Failed to initialize NeMo Guardrails: {str(e)}") @@ -260,12 +255,17 @@ def _get_input_check_prompt(self, user_input: str) -> str: raise RuntimeError("Rails config not available") # Find the self_check_input prompt - for prompt in self._rails.config.prompts: - if prompt.task == "self_check_input": - # Replace the template variable with actual content - prompt_text = prompt.content.replace("{{ user_input }}", user_input) - logger.debug("Found self_check_input prompt in NeMo config") - return prompt_text + if self._rails.config.prompts: + for prompt in self._rails.config.prompts: + if prompt.task == "self_check_input": + # Ensure content is not None before calling replace + if prompt.content: + # Replace the template variable with actual content + prompt_text = prompt.content.replace( + "{{ user_input }}", user_input + ) + logger.debug("Found self_check_input prompt in NeMo config") + return prompt_text # Fallback if prompt not found in config logger.warning( @@ -503,14 +503,19 @@ def _get_output_check_prompt(self, bot_response: str) -> str: raise RuntimeError("Rails config not available") # Find the self_check_output prompt - for prompt in self._rails.config.prompts: - if prompt.task == "self_check_output": - # Replace the template variable with actual content - prompt_text = prompt.content.replace( - "{{ bot_response }}", bot_response - ) - logger.debug("Found self_check_output prompt in NeMo config") - return prompt_text + if self._rails.config.prompts: + for prompt in self._rails.config.prompts: + if prompt.task == "self_check_output": + # Ensure content is not None before calling replace + if prompt.content: + # Replace the template variable with actual content + prompt_text = prompt.content.replace( + "{{ bot_response }}", bot_response + ) + logger.debug( + "Found self_check_output prompt in NeMo config" + ) + return prompt_text # Fallback if prompt not found in config logger.warning( diff --git a/src/guardrails/optimized_guardrails_loader.py b/src/guardrails/optimized_guardrails_loader.py index 58ba5e65..aef76727 100644 --- a/src/guardrails/optimized_guardrails_loader.py +++ b/src/guardrails/optimized_guardrails_loader.py @@ -19,7 +19,7 @@ class OptimizedGuardrailsLoader: - Falls back to base config if optimization not found """ - def __init__(self, optimized_modules_dir: Optional[Path] = None): + def __init__(self, optimized_modules_dir: Optional[Path] = None) -> None: """ Initialize the guardrails loader. diff --git a/src/guardrails/readme.md b/src/guardrails/readme.md index 0a51315e..7a69e931 100644 --- a/src/guardrails/readme.md +++ b/src/guardrails/readme.md @@ -180,7 +180,7 @@ result.usage = usage_info # Contains: total_cost, tokens, num_calls ### Modified Pipeline in `llm_orchestration_service.py` ```python -costs_dict = { +costs_metric = { "input_guardrails": {...}, # Step 1 "prompt_refiner": {...}, # Step 2 "response_generator": {...}, # Step 4 diff --git a/src/intent_data_enrichment/__init__.py b/src/intent_data_enrichment/__init__.py new file mode 100644 index 00000000..eb197d33 --- /dev/null +++ b/src/intent_data_enrichment/__init__.py @@ -0,0 +1,22 @@ +""" +Data Enrichment Module + +This module handles enrichment of service data before indexing into Qdrant. +Services are enriched with LLM-generated context and stored in intent_collections. +""" + +__version__ = "1.0.0" + +from intent_data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult +from intent_data_enrichment.api_client import LLMAPIClient +from intent_data_enrichment.qdrant_manager import QdrantManager +from intent_data_enrichment.constants import EnrichmentConstants + +__all__ = [ + "ServiceData", + "EnrichedService", + "EnrichmentResult", + "LLMAPIClient", + "QdrantManager", + "EnrichmentConstants", +] diff --git a/src/intent_data_enrichment/api_client.py b/src/intent_data_enrichment/api_client.py new file mode 100644 index 00000000..31ed96e2 --- /dev/null +++ b/src/intent_data_enrichment/api_client.py @@ -0,0 +1,191 @@ +"""API client for LLM Orchestration Service.""" + +import asyncio +import httpx +from typing import List, Optional +from types import TracebackType +from loguru import logger + +from intent_data_enrichment.constants import EnrichmentConstants +from intent_data_enrichment.models import ServiceData + + +class LLMAPIClient: + """Client for calling LLM Orchestration Service endpoints.""" + + def __init__( + self, + api_base_url: str = EnrichmentConstants.DEFAULT_API_BASE_URL, + environment: str = EnrichmentConstants.DEFAULT_ENVIRONMENT, + connection_id: str = EnrichmentConstants.DEFAULT_CONNECTION_ID, + max_retries: int = EnrichmentConstants.MAX_RETRIES, + retry_delay_base: int = EnrichmentConstants.RETRY_DELAY_BASE, + timeout: int = EnrichmentConstants.REQUEST_TIMEOUT, + ) -> None: + self.api_base_url = api_base_url + self.environment = environment + self.connection_id = connection_id + self.max_retries = max_retries + self.retry_delay_base = retry_delay_base + self.timeout = timeout + self.session: Optional[httpx.AsyncClient] = None + + async def __aenter__(self) -> "LLMAPIClient": + """Async context manager entry.""" + self.session = httpx.AsyncClient(timeout=self.timeout) + return self + + async def __aexit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + """Async context manager exit.""" + if self.session: + await self.session.aclose() + + async def generate_context(self, service_data: ServiceData) -> str: + """ + Generate rich context for service using LLM. + + Args: + service_data: Service data to enrich + + Returns: + Generated context string + + Raises: + RuntimeError: If context generation fails after all retries + """ + # Build full service information + full_service_info = f"""Service: {service_data.name} +ID: {service_data.service_id} +Description: {service_data.description} +Examples: {", ".join(service_data.examples)} +Entities: {", ".join(service_data.entities)}""" + + # Build context generation prompt + context_prompt = EnrichmentConstants.CONTEXT_TEMPLATE.format( + full_service_info=full_service_info, + name=service_data.name, + description=service_data.description, + examples=", ".join(service_data.examples), + ) + + request_data = { + "document_prompt": "", # Empty for service enrichment + "chunk_prompt": context_prompt, + "environment": self.environment, + "use_cache": True, + "connection_id": self.connection_id, + } + + last_error = None + for attempt in range(self.max_retries): + try: + logger.info( + f"Generating context for service '{service_data.service_id}' " + f"(attempt {attempt + 1}/{self.max_retries})" + ) + + if not self.session: + raise RuntimeError("HTTP session not initialized") + + response = await self.session.post( + f"{self.api_base_url}/generate-context", json=request_data + ) + response.raise_for_status() + result = response.json() + + context = result.get("context", "").strip() + if not context: + raise ValueError("Empty context returned from API") + + logger.success( + f"Successfully generated context for '{service_data.service_id}': " + f"{len(context)} characters" + ) + return context + + except Exception as e: + last_error = e + logger.warning( + f"Context generation attempt {attempt + 1} failed for " + f"'{service_data.service_id}': {e}" + ) + + if attempt < self.max_retries - 1: + delay = self.retry_delay_base**attempt + logger.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + + # All retries failed + error_msg = ( + f"Context generation failed for '{service_data.service_id}' " + f"after {self.max_retries} attempts: {last_error}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) + + async def create_embedding(self, text: str) -> List[float]: + """ + Create embedding vector for text. + + Args: + text: Text to embed + + Returns: + Embedding vector + + Raises: + RuntimeError: If embedding creation fails after all retries + """ + request_data = { + "texts": [text], + "environment": self.environment, + "connection_id": self.connection_id, + "batch_size": 1, + } + + last_error = None + for attempt in range(self.max_retries): + try: + logger.info( + f"Creating embedding (attempt {attempt + 1}/{self.max_retries})" + ) + + if not self.session: + raise RuntimeError("HTTP session not initialized") + + response = await self.session.post( + f"{self.api_base_url}/embeddings", json=request_data + ) + response.raise_for_status() + result = response.json() + + embeddings = result.get("embeddings", []) + if not embeddings or not embeddings[0]: + raise ValueError("Empty embedding returned from API") + + embedding = embeddings[0] + logger.success( + f"Successfully created embedding: dimension {len(embedding)}" + ) + return embedding + + except Exception as e: + last_error = e + logger.warning(f"Embedding creation attempt {attempt + 1} failed: {e}") + + if attempt < self.max_retries - 1: + delay = self.retry_delay_base**attempt + logger.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + + # All retries failed + error_msg = ( + f"Embedding creation failed after {self.max_retries} attempts: {last_error}" + ) + logger.error(error_msg) + raise RuntimeError(error_msg) diff --git a/src/intent_data_enrichment/constants.py b/src/intent_data_enrichment/constants.py new file mode 100644 index 00000000..f506880a --- /dev/null +++ b/src/intent_data_enrichment/constants.py @@ -0,0 +1,52 @@ +"""Constants for data enrichment service.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class EnrichmentConstants: + """Constants for enrichment pipeline.""" + + # API Configuration + DEFAULT_API_BASE_URL = "http://llm-orchestration-service:8100" + DEFAULT_ENVIRONMENT = "production" + DEFAULT_CONNECTION_ID = "gpt-4o-mini" + + # Retry Configuration + MAX_RETRIES = 3 + RETRY_DELAY_BASE = 2 # Exponential backoff base (2^attempt seconds) + REQUEST_TIMEOUT = 60 # seconds + + # Qdrant Configuration + COLLECTION_NAME = "intent_collections" + DEFAULT_QDRANT_HOST = "qdrant" + DEFAULT_QDRANT_PORT = 6333 + VECTOR_SIZE = 3072 # Azure text-embedding-3-large dimension + DISTANCE_METRIC = "Cosine" + + # Named Vector Configuration (for hybrid search) + DENSE_VECTOR_NAME = "dense" + SPARSE_VECTOR_NAME = "sparse" + + # Context Generation + CONTEXT_TEMPLATE = """ +{full_service_info} + + +Here is the service intent we want to enrich for better search retrieval: + +Name: {name} +Description: {description} +Examples: {examples} + + +Please generate a rich, detailed context that describes this service intent comprehensively for semantic search. +Include information about: +- What the user wants to accomplish +- Key terms and synonyms +- Related concepts +- Common ways users might express this intent + +IMPORTANT: Generate the context in the SAME LANGUAGE as the service description above. If the description is in Estonian, respond in Estonian. If in English, respond in English. If in Russian, respond in Russian. + +Answer only with the enriched context and nothing else.""" diff --git a/src/intent_data_enrichment/main_enrichment.py b/src/intent_data_enrichment/main_enrichment.py new file mode 100644 index 00000000..b96e0d2f --- /dev/null +++ b/src/intent_data_enrichment/main_enrichment.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Service Data Enrichment Script + +This script receives service data, enriches it with LLM-generated context, +creates embeddings (dense + sparse per example), and stores in Qdrant intent_collections. + +Indexing strategy: +- One 'example' point per example query (dense + sparse vectors of the example text) +- One 'summary' point per service (dense + sparse vectors of name + description + context) +""" + +import sys +import json +import argparse +import asyncio +from typing import List +from loguru import logger + +from intent_data_enrichment.models import ServiceData, EnrichedService, EnrichmentResult +from intent_data_enrichment.api_client import LLMAPIClient +from intent_data_enrichment.qdrant_manager import QdrantManager + +# Import sparse encoder from tool_classifier (shared module) +sys.path.insert(0, "/app/src") +try: + from tool_classifier.sparse_encoder import compute_sparse_vector +except ImportError: + # Fallback for local development + try: + from src.tool_classifier.sparse_encoder import compute_sparse_vector + except ImportError: + logger.warning( + "Could not import sparse_encoder from tool_classifier, " + "attempting direct import" + ) + import importlib.util + import os + + # Try to find the module relative to this file + module_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "tool_classifier", + "sparse_encoder.py", + ) + if os.path.exists(module_path): + spec = importlib.util.spec_from_file_location("sparse_encoder", module_path) + if spec is not None and spec.loader is not None: + sparse_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(sparse_module) + compute_sparse_vector = sparse_module.compute_sparse_vector + else: + raise ImportError( + f"Cannot load spec or loader for sparse_encoder.py at {module_path}" + ) from None + else: + raise ImportError( + f"Cannot find sparse_encoder.py at {module_path}" + ) from None + + +def parse_arguments() -> ServiceData: + """Parse command line arguments into ServiceData model.""" + parser = argparse.ArgumentParser(description="Service Data Enrichment") + parser.add_argument("--service-id", type=str, required=True, help="Service ID") + parser.add_argument("--name", type=str, required=True, help="Service name") + parser.add_argument( + "--description", type=str, required=True, help="Service description" + ) + parser.add_argument("--examples-file", type=str, help="Path to examples JSON file") + parser.add_argument("--entities-file", type=str, help="Path to entities JSON file") + parser.add_argument("--ruuter-type", type=str, default="GET", help="Ruuter type") + parser.add_argument( + "--current-state", type=str, default="draft", help="Current state" + ) + parser.add_argument( + "--is-common", + type=str, + choices=["true", "false"], + default="false", + help="Is common service", + ) + + args = parser.parse_args() + + # Read and parse JSON arrays from files + examples = [] + if args.examples_file: + try: + with open(args.examples_file, "r", encoding="utf-8") as f: + content = f.read().strip() + if content: + examples = json.loads(content) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.warning(f"Failed to read/parse examples file: {e}") + + entities = [] + if args.entities_file: + try: + with open(args.entities_file, "r", encoding="utf-8") as f: + content = f.read().strip() + if content: + entities = json.loads(content) + except (FileNotFoundError, json.JSONDecodeError) as e: + logger.warning(f"Failed to read/parse entities file: {e}") + + return ServiceData( + service_id=args.service_id, + name=args.name, + description=args.description, + examples=examples, + entities=entities, + ruuter_type=args.ruuter_type, + current_state=args.current_state, + is_common=args.is_common.lower() == "true", + ) + + +async def enrich_service(service_data: ServiceData) -> EnrichmentResult: + """ + Main enrichment pipeline: generate context, create per-example embeddings, + store in Qdrant with hybrid vectors (dense + sparse). + + Args: + service_data: Service data to enrich + + Returns: + EnrichmentResult with success/failure information + """ + try: + # Step 1: Generate rich context using LLM (unchanged from original) + logger.info("Step 1: Generating rich context with LLM") + async with LLMAPIClient() as api_client: + context = await api_client.generate_context(service_data) + logger.success(f"Context generated: {len(context)} characters") + + # Step 2: Create per-example points (dense + sparse vectors) + logger.info( + f"Step 2: Creating per-example embeddings for " + f"{len(service_data.examples)} examples" + ) + enriched_points: List[EnrichedService] = [] + + for i, example in enumerate(service_data.examples): + logger.info( + f" Creating embeddings for example {i + 1}/{len(service_data.examples)}: " + f"'{example[:80]}...'" + if len(example) > 80 + else f" Creating embeddings for example {i + 1}/{len(service_data.examples)}: " + f"'{example}'" + ) + + # Dense: embed the individual example + dense_embedding = await api_client.create_embedding(example) + + # Sparse: BM25-style term frequencies for the example + sparse_vec = compute_sparse_vector(example) + + enriched_points.append( + EnrichedService( + id=service_data.service_id, + name=service_data.name, + description=service_data.description, + examples=service_data.examples, + entities=service_data.entities, + context=context, + embedding=dense_embedding, + sparse_indices=sparse_vec.indices, + sparse_values=sparse_vec.values, + example_text=example, + point_type="example", + is_common=service_data.is_common or False, + ) + ) + + # Step 3: Create summary point (combined name + description + context) + logger.info("Step 3: Creating summary embedding") + combined_text_parts = [ + f"Service Name: {service_data.name}", + f"Description: {service_data.description}", + ] + + if service_data.examples: + combined_text_parts.append( + f"Example Queries: {' | '.join(service_data.examples)}" + ) + + if service_data.entities: + combined_text_parts.append( + f"Required Entities: {', '.join(service_data.entities)}" + ) + + combined_text_parts.append(f"Enriched Context: {context}") + combined_text = "\n".join(combined_text_parts) + + summary_embedding = await api_client.create_embedding(combined_text) + summary_sparse = compute_sparse_vector(combined_text) + + enriched_points.append( + EnrichedService( + id=service_data.service_id, + name=service_data.name, + description=service_data.description, + examples=service_data.examples, + entities=service_data.entities, + context=context, + embedding=summary_embedding, + sparse_indices=summary_sparse.indices, + sparse_values=summary_sparse.values, + example_text=None, + point_type="summary", + is_common=service_data.is_common or False, + ) + ) + + # Step 4: Delete existing points for this service (idempotent update) + logger.info("Step 4: Removing existing points for idempotent update") + qdrant = QdrantManager() + try: + qdrant.connect() + qdrant.ensure_collection() + + # Delete old points before inserting new ones + deleted = qdrant.delete_service_points(service_data.service_id) + if not deleted: + logger.error( + f"Failed to delete existing points for service_id={service_data.service_id}; " + "aborting upsert to avoid stale data." + ) + success = False + else: + # Step 5: Bulk upsert all points (examples + summary) + logger.info( + f"Step 5: Storing {len(enriched_points)} points in Qdrant " + f"({len(service_data.examples)} examples + 1 summary)" + ) + success = qdrant.upsert_service_points(enriched_points) + finally: + qdrant.close() + + if success: + return EnrichmentResult( + success=True, + service_id=service_data.service_id, + message=( + f"Service '{service_data.name}' enriched and indexed successfully " + f"({len(enriched_points)} points: " + f"{len(service_data.examples)} examples + 1 summary)" + ), + context_length=len(context), + embedding_dimension=len(summary_embedding), + error=None, + ) + else: + return EnrichmentResult( + success=False, + service_id=service_data.service_id, + message="Failed to store in Qdrant", + context_length=None, + embedding_dimension=None, + error="Qdrant upsert operation failed", + ) + + except Exception as e: + logger.error(f"Enrichment pipeline failed: {e}") + return EnrichmentResult( + success=False, + service_id=service_data.service_id, + message="Enrichment pipeline failed", + context_length=None, + embedding_dimension=None, + error=str(e), + ) + + +def main() -> int: + """Main entry point for service enrichment""" + logger.info("Starting service data enrichment pipeline") + + try: + # Parse arguments + service_data = parse_arguments() + logger.info(f"Service ID: {service_data.service_id}") + logger.info(f"Service Name: {service_data.name}") + logger.info(f"Examples: {len(service_data.examples)} provided") + logger.info(f"Entities: {len(service_data.entities)} provided") + + # Run enrichment pipeline + result = asyncio.run(enrich_service(service_data)) + + # Log results + if result.success: + logger.success("Enrichment completed successfully") + logger.info(f"Service: {result.service_id}") + logger.info(f"Message: {result.message}") + logger.info(f"Context Length: {result.context_length} characters") + logger.info(f"Embedding Dimension: {result.embedding_dimension}") + return 0 + else: + logger.error("Enrichment failed") + logger.error(f"Service: {result.service_id}") + logger.error(f"Message: {result.message}") + logger.error(f"Error: {result.error}") + return 1 + + except Exception as e: + logger.error(f"Fatal error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/intent_data_enrichment/models.py b/src/intent_data_enrichment/models.py new file mode 100644 index 00000000..93d9c606 --- /dev/null +++ b/src/intent_data_enrichment/models.py @@ -0,0 +1,62 @@ +"""Data models for service enrichment.""" + +from typing import List, Optional +from pydantic import BaseModel, Field + + +class ServiceData(BaseModel): + """Input service data to be enriched.""" + + service_id: str = Field(..., description="Unique service identifier") + name: str = Field(..., description="Service name") + description: str = Field(..., description="Service description") + examples: List[str] = Field(default_factory=list, description="Example queries") + entities: List[str] = Field( + default_factory=list, description="Expected entity names" + ) + ruuter_type: Optional[str] = Field(default="GET", description="HTTP method") + current_state: Optional[str] = Field(default="draft", description="Service state") + is_common: Optional[bool] = Field(default=False, description="Is common service") + + +class EnrichedService(BaseModel): + """Enriched service data ready for storage. + + Each service produces multiple points in Qdrant: + - One 'example' point per example query (for precise matching) + - One 'summary' point for the combined service description + context + """ + + id: str = Field(..., description="Service ID (maps to service_id)") + name: str = Field(..., description="Service name") + description: str = Field(..., description="Service description") + examples: List[str] = Field(..., description="Example queries") + entities: List[str] = Field(..., description="Expected entity names") + context: str = Field(..., description="Generated rich context") + embedding: List[float] = Field(..., description="Dense embedding vector") + sparse_indices: List[int] = Field( + default_factory=list, description="Sparse vector indices" + ) + sparse_values: List[float] = Field( + default_factory=list, description="Sparse vector values" + ) + example_text: Optional[str] = Field( + default=None, description="The specific example this point represents" + ) + point_type: str = Field( + default="summary", description="Point type: 'example' or 'summary'" + ) + is_common: bool = Field(default=False, description="Is common service") + + +class EnrichmentResult(BaseModel): + """Result of enrichment operation.""" + + success: bool = Field(..., description="Whether enrichment succeeded") + service_id: str = Field(..., description="Service ID") + message: str = Field(..., description="Result message") + context_length: Optional[int] = Field(None, description="Generated context length") + embedding_dimension: Optional[int] = Field( + None, description="Embedding vector dimension" + ) + error: Optional[str] = Field(None, description="Error message if failed") diff --git a/src/intent_data_enrichment/qdrant_manager.py b/src/intent_data_enrichment/qdrant_manager.py new file mode 100644 index 00000000..d5593836 --- /dev/null +++ b/src/intent_data_enrichment/qdrant_manager.py @@ -0,0 +1,301 @@ +"""Qdrant manager for intent collections with hybrid search support.""" + +import uuid +from typing import Optional, List +from loguru import logger +from qdrant_client import QdrantClient +from qdrant_client.models import ( + Distance, + VectorParams, + PointStruct, + SparseVectorParams, + SparseIndexParams, + SparseVector, + Filter, + FieldCondition, + MatchValue, + FilterSelector, +) + +from intent_data_enrichment.constants import EnrichmentConstants +from intent_data_enrichment.models import EnrichedService + +# Error messages +_CLIENT_NOT_INITIALIZED = "Qdrant client not initialized" + + +class QdrantManager: + """Manages Qdrant operations for intent collections with hybrid search.""" + + def __init__( + self, + host: str = EnrichmentConstants.DEFAULT_QDRANT_HOST, + port: int = EnrichmentConstants.DEFAULT_QDRANT_PORT, + collection_name: str = EnrichmentConstants.COLLECTION_NAME, + ) -> None: + self.host = host + self.port = port + self.collection_name = collection_name + self.client: Optional[QdrantClient] = None + + def connect(self) -> None: + """Connect to Qdrant.""" + try: + logger.info(f"Connecting to Qdrant at {self.host}:{self.port}") + self.client = QdrantClient( + host=self.host, + port=self.port, + timeout=30, + prefer_grpc=False, + api_key=None, + ) + logger.success("Successfully connected to Qdrant") + except Exception as e: + logger.error(f"Failed to connect to Qdrant: {e}") + raise + + def ensure_collection(self) -> None: + """Ensure the intent_collections collection exists with hybrid vector config. + + The collection uses named vectors: + - 'dense': 3072-dim cosine similarity vectors for semantic matching + - 'sparse': BM25-style sparse vectors for keyword matching + """ + try: + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + collections = self.client.get_collections().collections + collection_names = [col.name for col in collections] + + if self.collection_name in collection_names: + collection_info = self.client.get_collection(self.collection_name) + vectors_config = collection_info.config.params.vectors + + # Check if collection has the expected named vector configuration + if isinstance(vectors_config, dict): + if EnrichmentConstants.DENSE_VECTOR_NAME in vectors_config: + existing_vector_size = vectors_config[ + EnrichmentConstants.DENSE_VECTOR_NAME + ].size + if existing_vector_size != EnrichmentConstants.VECTOR_SIZE: + logger.error( + f"Collection '{self.collection_name}' has incompatible vector size: " + f"{existing_vector_size} (expected {EnrichmentConstants.VECTOR_SIZE})" + ) + raise RuntimeError( + f"Collection '{self.collection_name}' has incompatible vector size " + f"({existing_vector_size} vs expected {EnrichmentConstants.VECTOR_SIZE}). " + "To recreate the collection, manually delete it first using: " + f"qdrant.client.delete_collection('{self.collection_name}') or via Qdrant UI/API." + ) + logger.info( + f"Collection '{self.collection_name}' already exists " + f"with correct hybrid vector config (dense: {existing_vector_size}d + sparse)" + ) + else: + # Old collection format (unnamed/single vector) — needs migration + logger.error( + f"Collection '{self.collection_name}' exists but uses old single-vector format. " + "Migration to named vectors (dense + sparse) required." + ) + raise RuntimeError( + f"Collection '{self.collection_name}' uses old single-vector format. " + "Please delete the collection and re-index all services. " + f"Delete with: qdrant.client.delete_collection('{self.collection_name}') " + "or via Qdrant UI/API." + ) + elif vectors_config is not None: + # Direct VectorParams object (old single-vector format) + logger.error( + f"Collection '{self.collection_name}' exists but uses old single-vector format." + ) + raise RuntimeError( + f"Collection '{self.collection_name}' uses old single-vector format. " + "Please delete the collection and re-index all services. " + f"Delete with: qdrant.client.delete_collection('{self.collection_name}') " + "or via Qdrant UI/API." + ) + else: + logger.error( + f"Collection '{self.collection_name}' exists but vector config cannot be determined" + ) + raise RuntimeError( + f"Collection '{self.collection_name}' exists but vector config cannot be determined. " + "Manual intervention required." + ) + else: + self._create_collection() + + except Exception as e: + logger.error(f"Failed to ensure collection exists: {e}") + raise + + def _create_collection(self) -> None: + """Create the collection with hybrid vector configuration (dense + sparse).""" + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info( + f"Creating collection '{self.collection_name}' " + f"with hybrid vectors (dense: {EnrichmentConstants.VECTOR_SIZE}d + sparse)" + ) + self.client.create_collection( + collection_name=self.collection_name, + vectors_config={ + EnrichmentConstants.DENSE_VECTOR_NAME: VectorParams( + size=EnrichmentConstants.VECTOR_SIZE, + distance=Distance.COSINE, + ), + }, + sparse_vectors_config={ + EnrichmentConstants.SPARSE_VECTOR_NAME: SparseVectorParams( + index=SparseIndexParams(on_disk=False), + ), + }, + ) + logger.success(f"Collection '{self.collection_name}' created successfully") + + def delete_service_points(self, service_id: str) -> bool: + """Delete all points belonging to a service. + + Used before re-indexing to ensure idempotent updates, and when + a service is deactivated. + + Args: + service_id: Service identifier to delete all points for + + Returns: + True if successful, False otherwise + """ + try: + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + logger.info( + f"Deleting existing points for service '{service_id}' from Qdrant" + ) + + self.client.delete( + collection_name=self.collection_name, + points_selector=FilterSelector( + filter=Filter( + must=[ + FieldCondition( + key="service_id", + match=MatchValue(value=service_id), + ) + ] + ) + ), + ) + + logger.success(f"Successfully deleted points for service '{service_id}'") + return True + + except Exception as e: + logger.error(f"Failed to delete points for service '{service_id}': {e}") + return False + + def upsert_service_points(self, enriched_points: List[EnrichedService]) -> bool: + """Upsert multiple enriched service points to Qdrant. + + Each point contains both dense and sparse vectors for hybrid search. + Points are identified by a deterministic UUID based on service_id + point_index. + + Args: + enriched_points: List of EnrichedService instances (examples + summary) + + Returns: + True if all points upserted successfully, False otherwise + """ + try: + if not self.client: + raise RuntimeError(_CLIENT_NOT_INITIALIZED) + + if not enriched_points: + logger.warning("No points to upsert") + return True + + service_id = enriched_points[0].id + logger.info( + f"Upserting {len(enriched_points)} points for service '{service_id}'" + ) + + from typing import Any, Dict + + points: List[PointStruct] = [] + for idx, enriched_service in enumerate(enriched_points): + # Deterministic UUID based on service_id + index + point_id_source = f"{enriched_service.id}_{idx}" + point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id_source)) + + # Prepare payload + payload = { + "service_id": enriched_service.id, + "name": enriched_service.name, + "description": enriched_service.description, + "examples": enriched_service.examples, + "entities": enriched_service.entities, + "context": enriched_service.context, + "point_type": enriched_service.point_type, + "is_common": enriched_service.is_common, + } + + # Add example_text for example points + if enriched_service.example_text: + payload["example_text"] = enriched_service.example_text + + # Build named vectors (dense always, sparse if present) + vectors: Dict[str, Any] = { + EnrichmentConstants.DENSE_VECTOR_NAME: enriched_service.embedding, + } + if enriched_service.sparse_indices: + vectors[EnrichmentConstants.SPARSE_VECTOR_NAME] = SparseVector( + indices=enriched_service.sparse_indices, + values=enriched_service.sparse_values, + ) + + point = PointStruct( + id=point_id, + vector=vectors, + payload=payload, + ) + + points.append(point) + + # Bulk upsert + self.client.upsert( + collection_name=self.collection_name, + points=points, + ) + + logger.success( + f"Successfully upserted {len(points)} points for service '{service_id}' " + f"({sum(1 for p in enriched_points if p.point_type == 'example')} examples + " + f"{sum(1 for p in enriched_points if p.point_type == 'summary')} summary)" + ) + return True + + except Exception as e: + logger.error(f"Failed to upsert service points: {e}") + return False + + def upsert_service(self, enriched_service: EnrichedService) -> bool: + """Upsert a single enriched service to Qdrant. + + Backward-compatible wrapper that delegates to upsert_service_points. + + Args: + enriched_service: EnrichedService instance + + Returns: + True if successful, False otherwise + """ + return self.upsert_service_points([enriched_service]) + + def close(self) -> None: + """Close Qdrant connection.""" + if self.client: + logger.info("Closing Qdrant connection") + self.client.close() diff --git a/src/llm_orchestration_service.py b/src/llm_orchestration_service.py index 49b307d8..bf98ef1b 100644 --- a/src/llm_orchestration_service.py +++ b/src/llm_orchestration_service.py @@ -1,6 +1,6 @@ """LLM Orchestration Service - Business logic for LLM orchestration.""" -from typing import Optional, List, Dict, Union, Any, AsyncIterator +from typing import Optional, List, Dict, Union, Any, AsyncIterator, TYPE_CHECKING import os import time import asyncio @@ -26,7 +26,6 @@ from src.response_generator.response_generate import ResponseGeneratorAgent from src.response_generator.response_generate import stream_response_native from src.llm_orchestrator_config.llm_ochestrator_constants import ( - OUT_OF_SCOPE_MESSAGE, OUT_OF_SCOPE_MESSAGES, TECHNICAL_ISSUE_MESSAGE, TECHNICAL_ISSUE_MESSAGES, @@ -34,33 +33,50 @@ INPUT_GUARDRAIL_VIOLATION_MESSAGES, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE, OUTPUT_GUARDRAIL_VIOLATION_MESSAGES, + QUERY_VALIDATION_FAILED_MESSAGES, get_localized_message, GUARDRAILS_BLOCKED_PHRASES, TEST_DEPLOYMENT_ENVIRONMENT, STREAM_TOKEN_LIMIT_MESSAGE, PRODUCTION_DEPLOYMENT_ENVIRONMENT, + RUUTER_PROMPT_CONFIG_ENDPOINT, + PROMPT_CONFIG_CACHE_TTL, ) from src.llm_orchestrator_config.stream_config import StreamConfig from src.vector_indexer.constants import ResponseGenerationConstants from src.utils.error_utils import generate_error_id, log_error_with_context -from src.utils.stream_manager import stream_manager +from src.utils.stream_manager import stream_manager, StreamContext from src.utils.cost_utils import calculate_total_costs, get_lm_usage_since + +if TYPE_CHECKING: + from src.llm_orchestrator_config.embedding_manager import EmbeddingManager + from src.llm_orchestrator_config.context_manager import ( + ContextGenerationManager, + ) + from src.llm_orchestrator_config.config.loader import ConfigurationLoader from src.utils.time_tracker import log_step_timings from src.utils.budget_tracker import get_budget_tracker from src.utils.production_store import get_production_store from src.utils.language_detector import detect_language, get_language_name +from src.utils.prompt_config_loader import PromptConfigurationLoader +from src.utils.query_validator import validate_query_basic from src.guardrails import NeMoRailsAdapter, GuardrailCheckResult from src.contextual_retrieval import ContextualRetriever +from src.contextual_retrieval.bm25_search import SmartBM25Search from src.llm_orchestrator_config.exceptions import ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, ) +from src.llm_orchestrator_config.feature_flags import FeatureFlags +from src.tool_classifier import ToolClassifier +from src.tool_classifier.constants import SERVICE_STEP_PREFIXES +from src.tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor class LangfuseConfig: """Configuration for Langfuse integration.""" - def __init__(self): + def __init__(self) -> None: self.langfuse_client: Optional[Langfuse] = None self._initialize_langfuse() @@ -100,8 +116,180 @@ def __init__(self) -> None: """Initialize the orchestration service.""" self.langfuse_config = LangfuseConfig() + # Initialize prompt configuration loader + self.prompt_config_loader = PromptConfigurationLoader( + ruuter_endpoint=RUUTER_PROMPT_CONFIG_ENDPOINT, + cache_ttl_seconds=PROMPT_CONFIG_CACHE_TTL, + max_retries=3, + timeout_seconds=10, + ) + + try: + custom_instructions = self.prompt_config_loader.get_custom_instructions() + if custom_instructions: + logger.info( + f"Custom prompt configuration loaded at startup " + f"({len(custom_instructions)} chars)" + ) + else: + logger.info("ℹNo custom prompt configuration found - using defaults") + except Exception as e: + logger.warning( + f"Failed to load custom prompts at startup: {e}. " + f"Service will continue with default behavior." + ) + + # Initialize tool classifier (lazy initialization - will be created when first needed) + # This allows components to be initialized per-request with proper context + self.tool_classifier = None + + # Redis-backed session store for API Tool Calling agentic loop. + # Set to None here; the FastAPI lifespan injects the live store after + # Redis initialises (app.state.orchestration_service.session_store = ...). + # Workflow executors access it via self.orchestration_service.session_store. + self.session_store: Any = None + + # Shared BM25 search index pre-warmed at startup. + # Populated by _prewarm_shared_bm25() which is called from the FastAPI + # lifespan so it runs inside the async event loop. Until then it is None + # and each ContextualRetriever will build the index on first query (graceful + # degradation path). + self.shared_bm25_search: Optional[SmartBM25Search] = None + + # Initialize shared guardrails adapters at startup (production and testing) + self.shared_guardrails_adapters = ( + self._initialize_shared_guardrails_at_startup() + ) + + # Log feature flag configuration + FeatureFlags.log_configuration() + + def _initialize_shared_guardrails_at_startup(self) -> Dict[str, NeMoRailsAdapter]: + """ + Initialize shared guardrails adapters at startup for production and testing environments. + + Returns: + Dictionary mapping environment names to NeMoRailsAdapter instances. + Empty dict on failure (graceful degradation). + """ + adapters: Dict[str, NeMoRailsAdapter] = {} + + # Initialize adapters for commonly-used environments + environments_to_initialize = ["production", "testing"] + + logger.info(" Initializing shared guardrails at startup...") + total_start_time = time.time() + + for env in environments_to_initialize: + try: + logger.info(f" Initializing guardrails for environment: {env}") + start_time = time.time() + + # Initialize with specific environment and no connection (shared config) + guardrails_adapter = self._initialize_guardrails( + environment=env, + connection_id=None, # Shared configuration, not user-specific + ) + + # Eagerly trigger the full internal initialization (NeMo config + # loading, LLMRails creation, embedding model download) so that + # the first user query is not penalised by the cold-start cost. + # Without this, _ensure_initialized() runs lazily on the first + guardrails_adapter._ensure_initialized() + + elapsed_time = time.time() - start_time + adapters[env] = guardrails_adapter + logger.info( + f" Guardrails for '{env}' fully initialized in {elapsed_time:.3f}s " + f"(NeMo Rails + embedding model loaded)" + ) + + except Exception as e: + logger.error(f" Failed to initialize guardrails for '{env}': {e}") + logger.warning( + f" Service will fall back to per-request initialization for '{env}' environment" + ) + # Continue with other environments - partial success is acceptable + continue + + total_elapsed = time.time() - total_start_time + + if adapters: + logger.info( + f" Shared guardrails initialized for {len(adapters)} environment(s) " + f"in {total_elapsed:.3f}s total" + ) + else: + logger.error( + " Failed to initialize any shared guardrails - " + "service will use per-request initialization (slower)" + ) + + return adapters + + async def _prewarm_shared_bm25(self) -> None: + """ + Pre-warm the shared BM25 index at application startup. + + Must be called from an async context (e.g. FastAPI lifespan) so that + asyncio is available for the HTTP calls to Qdrant. Absorbs the + cold-start latency (fetching all chunks + building BM25Okapi corpus) + at deploy time so that the first real user query is not penalised. + + On any failure the method logs a warning and leaves + self.shared_bm25_search as None — the ContextualRetriever will then + fall back to building the index on the first query (graceful degradation). + """ + qdrant_url = os.getenv("QDRANT_URL", "http://qdrant:6333") + logger.info("Pre-warming shared BM25 index at startup...") + prewarm_start = time.time() + try: + bm25 = SmartBM25Search(qdrant_url=qdrant_url) + success = await bm25.initialize_index() + if success: + self.shared_bm25_search = bm25 + elapsed = time.time() - prewarm_start + logger.info( + f"Shared BM25 index pre-warmed in {elapsed:.2f}s " + f"({len(bm25.chunk_mapping)} chunks indexed)" + ) + else: + logger.warning( + "BM25 pre-warming produced an empty index - " + "index will be built on first query instead" + ) + except Exception as e: + logger.warning( + f"BM25 pre-warming failed: {e} - " + f"index will be built on first query (graceful degradation)" + ) + + async def aclose(self) -> None: + """Release all long-lived async resources held by the service. + + Must be awaited during application shutdown (FastAPI lifespan teardown) + to avoid connection leaks from the ToolClassifier's httpx client. + """ + if self.tool_classifier is not None: + await self.tool_classifier.aclose() + logger.debug("LLMOrchestrationService async resources closed") + + def _get_service_workflow_executor(self) -> ServiceWorkflowExecutor: + """Return the ServiceWorkflowExecutor, reusing the ToolClassifier instance + when available, or creating a lightweight standalone executor otherwise. + + Direct MCQ steps do not invoke any LLM, so llm_manager=None is safe. + orchestration_service=self is needed for format_sse() in the streaming path. + """ + if self.tool_classifier is not None: + return self.tool_classifier.service_workflow + return ServiceWorkflowExecutor( + llm_manager=None, + orchestration_service=self, + ) + @observe(name="orchestration_request", as_type="agent") - def process_orchestration_request( + async def process_orchestration_request( self, request: OrchestrationRequest ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """ @@ -124,8 +312,8 @@ def process_orchestration_request( Raises: Exception: For any processing errors """ - costs_dict: Dict[str, Dict[str, Any]] = {} - timing_dict: Dict[str, float] = {} + costs_metric: Dict[str, Dict[str, Any]] = {} + time_metric: Dict[str, float] = {} try: logger.info( @@ -133,42 +321,177 @@ def process_orchestration_request( f"authorId: {request.authorId}, environment: {request.environment}" ) - # STEP 0: Detect language from user message + # STEP 0: Detect language from user message (with timing) + start_time = time.time() detected_language = detect_language(request.message) language_name = get_language_name(detected_language) + time_metric["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Detected language: {language_name} ({detected_language})" ) # Store detected language in request for use throughout pipeline - request._detected_language = detected_language + # Using setattr for type safety - adds dynamic attribute to Pydantic model instance + setattr(request, "_detected_language", detected_language) # noqa: B010 + + # STEP 0.1: Multi-step service prefix check (bypass NLU pipeline) + if request.message.startswith(SERVICE_STEP_PREFIXES): + logger.info( + f"[{request.chatId}] #service prefix detected - direct step execution" + ) + executor = self._get_service_workflow_executor() + direct_response = await executor.execute_direct_step( + request=request, + time_metric=time_metric, + ) + if direct_response is not None: + log_step_timings(time_metric, request.chatId) + return direct_response + # Parse failed — fall through to normal pipeline + logger.warning( + f"[{request.chatId}] Direct step failed, falling through to normal pipeline" + ) + + # STEP 0.5: Basic Query Validation (before expensive component initialization) + start_time = time.time() + validation_result = validate_query_basic(request.message) + time_metric["query_validation"] = time.time() - start_time + if not validation_result.is_valid: + logger.info( + f"[{request.chatId}] Query validation failed: {validation_result.rejection_reason}" + ) + # Get localized message + validation_msg = get_localized_message( + QUERY_VALIDATION_FAILED_MESSAGES, detected_language + ) + + # Return appropriate response type without initializing components + if request.environment == TEST_DEPLOYMENT_ENVIRONMENT: + return TestOrchestrationResponse( + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=validation_msg, + chunks=None, + ) + else: + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=validation_msg, + ) - # Initialize all service components + # Initialize all service components (only for valid queries, with timing) + start_time = time.time() components = self._initialize_service_components(request) + time_metric["initialization"] = time.time() - start_time - # Execute the orchestration pipeline - response = self._execute_orchestration_pipeline( - request, components, costs_dict, timing_dict - ) + if components["guardrails_adapter"]: + start_time = time.time() + input_blocked_response = await self.handle_input_guardrails( + components["guardrails_adapter"], request, {} + ) + time_metric["input_guardrails_check"] = time.time() - start_time + + if input_blocked_response: + logger.warning( + f"[{request.chatId}] Input blocked before classifier - " + f"saved expensive service discovery" + ) + log_step_timings(time_metric, request.chatId) + return input_blocked_response + else: + logger.info( + f"[{request.chatId}] Guardrails not available - " + f"proceeding without input validation" + ) + + # TOOL CLASSIFIER INTEGRATION + # Route through tool classifier if enabled, otherwise use existing RAG pipeline + if FeatureFlags.TOOL_CLASSIFIER_ENABLED: + try: + logger.info( + f"[{request.chatId}] Tool classifier enabled - routing query" + ) + + # Initialize tool classifier if not already done + if self.tool_classifier is None: + self.tool_classifier = ToolClassifier( + llm_manager=components["llm_manager"], + orchestration_service=self, + ) + logger.info("Tool classifier initialized") + + # Classify query to determine workflow (with timing) + start_time = time.time() + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + request=request, + ) + time_metric["classifier.classify"] = time.time() - start_time + + logger.info( + f"[{request.chatId}] Classification: {classification.workflow.value} " + f"(confidence: {classification.confidence:.2f})" + ) + + # Route to appropriate workflow (with timing) + start_time = time.time() + response = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + time_metric=time_metric, + ) + time_metric["classifier.route"] = time.time() - start_time + + except Exception as classifier_error: + logger.error( + f"[{request.chatId}] Tool classifier error: {classifier_error}", + exc_info=True, + ) + + if FeatureFlags.FALLBACK_TO_RAG_ON_ERROR: + logger.info( + f"[{request.chatId}] Falling back to RAG pipeline due to classifier error" + ) + # Execute existing RAG pipeline as fallback + response = await self._execute_orchestration_pipeline( + request, components, costs_metric, time_metric + ) + else: + raise + else: + # Tool classifier disabled - use existing RAG pipeline + logger.debug( + f"[{request.chatId}] Tool classifier disabled - using RAG pipeline" + ) + response = await self._execute_orchestration_pipeline( + request, components, costs_metric, time_metric + ) # Log final costs and return response - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) # Update budget for the LLM connection self._update_connection_budget( - request.connection_id, costs_dict, request.environment + request.connection_id, costs_metric, request.environment ) if self.langfuse_config.langfuse_client: langfuse = self.langfuse_config.langfuse_client - total_costs = calculate_total_costs(costs_dict) + total_costs = calculate_total_costs(costs_metric) total_input_tokens = sum( - c.get("total_prompt_tokens", 0) for c in costs_dict.values() + c.get("total_prompt_tokens", 0) for c in costs_metric.values() ) total_output_tokens = sum( - c.get("total_completion_tokens", 0) for c in costs_dict.values() + c.get("total_completion_tokens", 0) for c in costs_metric.values() ) langfuse.update_current_generation( @@ -185,7 +508,7 @@ def process_orchestration_request( }, metadata={ "total_calls": total_costs.get("total_calls", 0), - "cost_breakdown": costs_dict, + "cost_breakdown": costs_metric, "chat_id": request.chatId, "author_id": request.authorId, "environment": request.environment, @@ -209,12 +532,12 @@ def process_orchestration_request( } ) langfuse.flush() - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) # Update budget even on error self._update_connection_budget( - request.connection_id, costs_dict, request.environment + request.connection_id, costs_metric, request.environment ) return self._create_error_response(request) @@ -257,19 +580,59 @@ async def stream_orchestration_response( """ # Track costs after streaming completes - costs_dict: Dict[str, Dict[str, Any]] = {} - timing_dict: Dict[str, float] = {} - streaming_start_time = datetime.now() + costs_metric: Dict[str, Dict[str, Any]] = {} + time_metric: Dict[str, float] = {} - # STEP 0: Detect language from user message + # STEP 0: Detect language from user message (with timing) + start_time = time.time() detected_language = detect_language(request.message) language_name = get_language_name(detected_language) + time_metric["language_detection"] = time.time() - start_time logger.info( f"[{request.chatId}] Streaming request - Detected language: {language_name} ({detected_language})" ) # Store detected language in request for use throughout pipeline - request._detected_language = detected_language + # Using setattr for type safety - adds dynamic attribute to Pydantic model instance + setattr(request, "_detected_language", detected_language) # noqa: B010 + + # STEP 0.1: Multi-step service prefix check (bypass NLU pipeline) + if request.message.startswith(SERVICE_STEP_PREFIXES): + logger.info( + f"[{request.chatId}] #service prefix detected - direct step stream" + ) + executor = self._get_service_workflow_executor() + step_stream = await executor.execute_direct_step_streaming( + request=request, + time_metric=time_metric, + ) + if step_stream is not None: + async for chunk in step_stream: + yield chunk + log_step_timings(time_metric, request.chatId) + return + # Parse failed — fall through to normal pipeline + logger.warning( + f"[{request.chatId}] Direct step stream failed, falling through to normal pipeline" + ) + + # Step 0.5: Basic Query Validation (before guardrails, with timing) + start_time = time.time() + validation_result = validate_query_basic(request.message) + time_metric["query_validation"] = time.time() - start_time + if not validation_result.is_valid: + logger.info( + f"[{request.chatId}] Streaming - Query validation failed: {validation_result.rejection_reason}" + ) + # Get localized message + validation_msg = get_localized_message( + QUERY_VALIDATION_FAILED_MESSAGES, detected_language + ) + + # Yield SSE format error + END marker + yield self.format_sse(request.chatId, validation_msg) + yield self.format_sse(request.chatId, "END") + return # Stop processing # Use StreamManager for centralized tracking and guaranteed cleanup async with stream_manager.managed_stream( @@ -281,12 +644,15 @@ async def stream_orchestration_response( f"(environment: {request.environment})" ) - # Initialize all service components + # Initialize all service components (with timing) + start_time = time.time() components = self._initialize_service_components(request) + time_metric["initialization"] = time.time() - start_time - # STEP 1: CHECK INPUT GUARDRAILS (blocking) + # This implements fail-fast principle - block malicious/policy-violating inputs + # before expensive operations (service discovery, LLM calls, streaming setup) logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 1: Checking input guardrails" + f"[{request.chatId}] [{stream_ctx.stream_id}] Checking input guardrails (before classifier)" ) if components["guardrails_adapter"]: @@ -294,502 +660,581 @@ async def stream_orchestration_response( input_check_result = await self._check_input_guardrails_async( guardrails_adapter=components["guardrails_adapter"], user_message=request.message, - costs_dict=costs_dict, + costs_metric=costs_metric, ) - timing_dict["input_guardrails_check"] = time.time() - start_time + time_metric["input_guardrails_check"] = time.time() - start_time if not input_check_result.allowed: logger.warning( - f"[{request.chatId}] [{stream_ctx.stream_id}] Input blocked by guardrails: " - f"{input_check_result.reason}" + f"[{request.chatId}] [{stream_ctx.stream_id}] Input blocked before classifier - " + f"saved expensive service discovery. Reason: {input_check_result.reason}" ) - yield self._format_sse( + yield self.format_sse( request.chatId, INPUT_GUARDRAIL_VIOLATION_MESSAGE ) - yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) + yield self.format_sse(request.chatId, "END") + self.log_costs(costs_metric) + # Log timings before returning (for visibility) + log_step_timings(time_metric, request.chatId) stream_ctx.mark_completed() return + else: + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Guardrails not available - " + f"proceeding without input validation" + ) logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Input guardrails passed " + f"[{request.chatId}] [{stream_ctx.stream_id}] Input guardrails passed" ) - # STEP 2: REFINE USER PROMPT (blocking) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 2: Refining user prompt" - ) + # TOOL CLASSIFIER INTEGRATION (STREAMING) + # Route through tool classifier if enabled, otherwise use existing RAG pipeline + if FeatureFlags.TOOL_CLASSIFIER_ENABLED: + try: + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier enabled - routing query (streaming)" + ) - start_time = time.time() - refined_output, refiner_usage = self._refine_user_prompt( - llm_manager=components["llm_manager"], - original_message=request.message, - conversation_history=request.conversationHistory, - ) - timing_dict["prompt_refiner"] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage + # Initialize tool classifier if not already done + if self.tool_classifier is None: + self.tool_classifier = ToolClassifier( + llm_manager=components["llm_manager"], + orchestration_service=self, + ) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier initialized" + ) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete " - ) + # Classify query to determine workflow + start_time = time.time() + classification = await self.tool_classifier.classify( + query=request.message, + conversation_history=request.conversationHistory, + language=detected_language, + request=request, + ) + time_metric["classifier.classify"] = time.time() - start_time - # STEP 3: RETRIEVE CONTEXT CHUNKS (blocking) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 3: Retrieving context chunks" - ) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Classification: {classification.workflow.value} " + f"(confidence: {classification.confidence:.2f})" + ) - try: - start_time = time.time() - relevant_chunks = await self._safe_retrieve_contextual_chunks( - components["contextual_retriever"], refined_output, request - ) - timing_dict["contextual_retrieval"] = time.time() - start_time - except ( - ContextualRetrieverInitializationError, - ContextualRetrievalFailureError, - ) as e: - logger.warning( - f"[{request.chatId}] [{stream_ctx.stream_id}] Contextual retrieval failed: {str(e)}" - ) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Returning out-of-scope due to retrieval failure" - ) - yield self._format_sse(request.chatId, OUT_OF_SCOPE_MESSAGE) - yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - stream_ctx.mark_completed() - return - - if len(relevant_chunks) == 0: - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] No relevant chunks - out of scope" - ) - detected_lang = getattr(request, "_detected_language", "en") - localized_msg = get_localized_message( - OUT_OF_SCOPE_MESSAGES, detected_lang + # Route to appropriate workflow (streaming) + # route_to_workflow returns AsyncIterator[str] when is_streaming=True + # Inject costs_metric into the classification context so the + # API Tool workflow can append its output guardrail costs. + classification.metadata["costs_metric"] = costs_metric + start_time = time.time() + stream_result = await self.tool_classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + time_metric=time_metric, + ) + time_metric["classifier.route"] = time.time() - start_time + + async for sse_chunk in stream_result: + yield sse_chunk + + # Successfully completed streaming through classifier + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier streaming completed" + ) + + # Log costs and timings + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return # Exit after successful classifier routing + + except Exception as classifier_error: + logger.error( + f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier error: {classifier_error}", + exc_info=True, + ) + + if not FeatureFlags.FALLBACK_TO_RAG_ON_ERROR: + # Don't fallback - raise error + raise + + # Fallback to RAG pipeline below + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Falling back to RAG streaming due to classifier error" + ) + # Continue to existing RAG streaming pipeline below + else: + logger.debug( + f"[{request.chatId}] [{stream_ctx.stream_id}] Tool classifier disabled - using RAG streaming" ) - yield self._format_sse(request.chatId, localized_msg) - yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - stream_ctx.mark_completed() - return - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Retrieved {len(relevant_chunks)} chunks " - ) + # Execute core RAG streaming pipeline + # NOTE: This only executes if tool classifier is disabled or fallback occurred + async for sse_chunk in self._stream_rag_pipeline( + request=request, + components=components, + stream_ctx=stream_ctx, + costs_metric=costs_metric, + time_metric=time_metric, + ): + yield sse_chunk + + # Pipeline completed successfully + return - # STEP 4: QUICK OUT-OF-SCOPE CHECK (blocking) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 4: Checking if question is in scope" + except Exception as e: + error_id = generate_error_id() + stream_ctx.mark_error(error_id) + log_error_with_context( + logger, error_id, "streaming_orchestration", request.chatId, e ) - start_time = time.time() - is_out_of_scope = await components[ - "response_generator" - ].check_scope_quick( - question=refined_output.original_question, - chunks=relevant_chunks, - max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, + yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) + yield self.format_sse(request.chatId, "END") + + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + + # Update budget even on outer exception + self._update_connection_budget( + request.connection_id, costs_metric, request.environment ) - timing_dict["scope_check"] = time.time() - start_time - if is_out_of_scope: - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Question out of scope" - ) - detected_lang = getattr(request, "_detected_language", "en") - localized_msg = get_localized_message( - OUT_OF_SCOPE_MESSAGES, detected_lang + if self.langfuse_config.langfuse_client: + langfuse = self.langfuse_config.langfuse_client + langfuse.update_current_generation( + metadata={ + "error_id": error_id, + "error_type": type(e).__name__, + "streaming": True, + "streaming_failed": True, + "stream_id": stream_ctx.stream_id, + } ) - yield self._format_sse(request.chatId, localized_msg) - yield self._format_sse(request.chatId, "END") - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - stream_ctx.mark_completed() - return + langfuse.flush() - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Question is in scope " - ) + async def _stream_rag_pipeline( + self, + request: OrchestrationRequest, + components: Dict[str, Any], + stream_ctx: StreamContext, + costs_metric: Dict[str, Dict[str, Any]], + time_metric: Dict[str, float], + ) -> AsyncIterator[str]: + """ + Core RAG streaming pipeline without classifier routing. - # STEP 5: STREAM THROUGH NEMO GUARDRAILS (validation-first) - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Step 5: Starting streaming through NeMo Guardrails " - f"(validation-first, chunk_size=200)" - ) + This method contains the RAG pipeline logic that can be called directly + by workflows to avoid infinite recursion when the tool classifier is enabled. - streaming_step_start = time.time() + Pipeline Steps: + 1. Refine user prompt (blocking) + 2. Retrieve context chunks (blocking) + 3. Out-of-scope check (blocking) + 4. Stream through NeMo Guardrails (validation-first) - # Record history length before streaming - lm = dspy.settings.lm - history_length_before = ( - len(lm.history) if lm and hasattr(lm, "history") else 0 - ) + Args: + request: Orchestration request + components: Initialized service components (LLM, retriever, generator, guardrails) + stream_ctx: Stream context for tracking + costs_metric: Dictionary to accumulate costs + time_metric: Dictionary to accumulate timings - async def bot_response_generator() -> AsyncIterator[str]: - """Generator that yields tokens from NATIVE DSPy LLM streaming.""" - async for token in stream_response_native( - agent=components["response_generator"], - question=refined_output.original_question, - chunks=relevant_chunks, - max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, - ): - yield token + Yields: + SSE-formatted strings + """ + streaming_start_time = datetime.now() + detected_language = getattr(request, "_detected_language", "en") - # Create and store bot_generator in stream context for guaranteed cleanup - bot_generator = bot_response_generator() - stream_ctx.bot_generator = bot_generator + # STEP 1: REFINE USER PROMPT (blocking) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 1: Refining user prompt" + ) - # Wrap entire streaming logic in try/except for proper error handling - try: - # Track tokens and accumulated response in stream context - accumulated_response = [] # Track the full response for production storage - - if components["guardrails_adapter"]: - # Use NeMo's stream_with_guardrails helper method - # This properly integrates the external generator with NeMo's validation - chunk_count = 0 - - try: - async for validated_chunk in components[ - "guardrails_adapter" - ].stream_with_guardrails( - user_message=refined_output.original_question, - bot_message_generator=bot_generator, - ): - chunk_count += 1 - - # Estimate tokens (rough approximation: 4 characters = 1 token) - chunk_tokens = len(validated_chunk) // 4 - stream_ctx.token_count += chunk_tokens - - # Accumulate response for production storage - accumulated_response.append(validated_chunk) - - # Check token limit - if ( - stream_ctx.token_count - > StreamConfig.MAX_TOKENS_PER_STREAM - ): - logger.error( - f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded: " - f"{stream_ctx.token_count} > {StreamConfig.MAX_TOKENS_PER_STREAM}" - ) - # Send error message and end stream immediately - yield self._format_sse( - request.chatId, STREAM_TOKEN_LIMIT_MESSAGE - ) - yield self._format_sse(request.chatId, "END") - - # Extract usage and log costs - usage_info = get_lm_usage_since( - history_length_before - ) - costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - stream_ctx.mark_completed() - return # Stop immediately - cleanup happens in finally - - # Check for guardrail violations using blocked phrases - # Match the actual behavior of NeMo Guardrails adapter - is_guardrail_error = False - if isinstance(validated_chunk, str): - # Use the same blocked phrases as the guardrails adapter - blocked_phrases = GUARDRAILS_BLOCKED_PHRASES - chunk_lower = validated_chunk.strip().lower() - # Check if the chunk is primarily a blocked phrase - for phrase in blocked_phrases: - # More robust check: ensure the phrase is the main content - if ( - phrase.lower() in chunk_lower - and len(chunk_lower) - <= len(phrase.lower()) + 20 - ): - is_guardrail_error = True - break - - if is_guardrail_error: - logger.warning( - f"[{request.chatId}] [{stream_ctx.stream_id}] Guardrails violation detected" - ) - # Send the violation message and end stream - yield self._format_sse( - request.chatId, - OUTPUT_GUARDRAIL_VIOLATION_MESSAGE, - ) - yield self._format_sse(request.chatId, "END") - - # Log the violation - logger.warning( - f"[{request.chatId}] [{stream_ctx.stream_id}] Output blocked by guardrails: {validated_chunk}" - ) - - # Extract usage and log costs - usage_info = get_lm_usage_since( - history_length_before - ) - costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - stream_ctx.mark_completed() - return # Cleanup happens in finally - - # Log first few chunks for debugging - if ( - chunk_count - <= ResponseGenerationConstants.DEFAULT_MAX_BLOCKS - ): - logger.debug( - f"[{request.chatId}] [{stream_ctx.stream_id}] Validated chunk {chunk_count}: {repr(validated_chunk)}" - ) - - # Yield the validated chunk to client - yield self._format_sse(request.chatId, validated_chunk) - except GeneratorExit: - # Client disconnected - stream_ctx.mark_cancelled() - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Client disconnected during guardrails streaming" - ) - raise + start_time = time.time() + refined_output, refiner_usage = self._refine_user_prompt( + llm_manager=components["llm_manager"], + original_message=request.message, + conversation_history=request.conversationHistory, + ) + time_metric["prompt_refiner"] = time.time() - start_time + costs_metric["prompt_refiner"] = refiner_usage - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Stream completed successfully " - f"({chunk_count} chunks streamed)" - ) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Prompt refinement complete" + ) - # Send document references before END token - doc_references = self._extract_document_references( - relevant_chunks - ) - if doc_references: - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Sending {len(doc_references)} document references before END" - ) - # Format references as markdown text - refs_text = "\n\n**References:**\n" + "\n".join( - f"{i + 1}. [{ref.document_url}]({ref.document_url})" - for i, ref in enumerate(doc_references) - ) - yield self._format_sse(request.chatId, refs_text) + # STEP 2: RETRIEVE CONTEXT CHUNKS (blocking) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 2: Retrieving context chunks" + ) - yield self._format_sse(request.chatId, "END") + try: + start_time = time.time() + relevant_chunks = await self._safe_retrieve_contextual_chunks( + components["contextual_retriever"], refined_output, request + ) + time_metric["contextual_retrieval"] = time.time() - start_time + except ( + ContextualRetrieverInitializationError, + ContextualRetrievalFailureError, + ) as e: + logger.warning( + f"[{request.chatId}] [{stream_ctx.stream_id}] Contextual retrieval failed: {str(e)}" + ) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Returning out-of-scope due to retrieval failure" + ) + localized_msg = get_localized_message( + OUT_OF_SCOPE_MESSAGES, detected_language + ) + yield self.format_sse(request.chatId, localized_msg) + yield self.format_sse(request.chatId, "END") + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return - else: - # No guardrails - stream directly - logger.warning( - f"[{request.chatId}] [{stream_ctx.stream_id}] Streaming without guardrails validation" - ) - chunk_count = 0 - async for token in bot_generator: - chunk_count += 1 - - # Estimate tokens and check limit - token_estimate = len(token) // 4 - stream_ctx.token_count += token_estimate - - # Accumulate response for production storage - accumulated_response.append(token) - - if ( - stream_ctx.token_count - > StreamConfig.MAX_TOKENS_PER_STREAM - ): - logger.error( - f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded (no guardrails): " - f"{stream_ctx.token_count} > {StreamConfig.MAX_TOKENS_PER_STREAM}" - ) - yield self._format_sse( - request.chatId, STREAM_TOKEN_LIMIT_MESSAGE - ) - yield self._format_sse(request.chatId, "END") - stream_ctx.mark_completed() - return # Stop immediately - cleanup in finally - - yield self._format_sse(request.chatId, token) - - # Send document references before END token - doc_references = self._extract_document_references( - relevant_chunks - ) - if doc_references: - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Sending {len(doc_references)} document references before END" - ) - # Format references as markdown text - refs_text = "\n\n**References:**\n" + "\n".join( - f"{i + 1}. [{ref.document_url}]({ref.document_url})" - for i, ref in enumerate(doc_references) - ) - yield self._format_sse(request.chatId, refs_text) + if len(relevant_chunks) == 0: + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] No relevant chunks - out of scope" + ) + localized_msg = get_localized_message( + OUT_OF_SCOPE_MESSAGES, detected_language + ) + yield self.format_sse(request.chatId, localized_msg) + yield self.format_sse(request.chatId, "END") + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return - yield self._format_sse(request.chatId, "END") + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Retrieved {len(relevant_chunks)} chunks" + ) - # Extract usage information after streaming completes - usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info + # STEP 3: QUICK OUT-OF-SCOPE CHECK (blocking) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 3: Checking if question is in scope" + ) - # Record streaming generation time - timing_dict["streaming_generation"] = ( - time.time() - streaming_step_start - ) - # Mark output guardrails as inline (not blocking) - timing_dict["output_guardrails"] = 0.0 # Inline during streaming + start_time = time.time() + is_out_of_scope = await components["response_generator"].check_scope_quick( + question=refined_output.original_question, + chunks=relevant_chunks, + max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, + ) + time_metric["scope_check"] = time.time() - start_time - # Calculate streaming duration - streaming_duration = ( - datetime.now() - streaming_start_time - ).total_seconds() - logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Streaming completed in {streaming_duration:.2f}s" - ) + if is_out_of_scope: + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Question out of scope" + ) + localized_msg = get_localized_message( + OUT_OF_SCOPE_MESSAGES, detected_language + ) + yield self.format_sse(request.chatId, localized_msg) + yield self.format_sse(request.chatId, "END") + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return - # Log costs and trace - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + logger.info(f"[{request.chatId}] [{stream_ctx.stream_id}] Question is in scope") - # Update budget for the LLM connection - self._update_connection_budget( - request.connection_id, costs_dict, request.environment - ) + # STEP 4: STREAM THROUGH NEMO GUARDRAILS (validation-first) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] RAG Pipeline Step 4: Starting streaming through NeMo Guardrails" + ) - if self.langfuse_config.langfuse_client: - langfuse = self.langfuse_config.langfuse_client - total_costs = calculate_total_costs(costs_dict) - - langfuse.update_current_generation( - model=components["llm_manager"] - .get_provider_info() - .get("model", "unknown"), - usage_details={ - "input": usage_info.get("total_prompt_tokens", 0), - "output": usage_info.get("total_completion_tokens", 0), - "total": usage_info.get("total_tokens", 0), - }, - cost_details={ - "total": total_costs.get("total_cost", 0.0), - }, - metadata={ - "streaming": True, - "streaming_duration_seconds": streaming_duration, - "chunks_streamed": chunk_count, - "cost_breakdown": costs_dict, - "chat_id": request.chatId, - "environment": request.environment, - "stream_id": stream_ctx.stream_id, - }, - ) - langfuse.flush() - - # Store inference data (for production and testing environments) - if request.environment in [ - PRODUCTION_DEPLOYMENT_ENVIRONMENT, - TEST_DEPLOYMENT_ENVIRONMENT, - ]: - try: - await self._store_production_inference_data_async( - request=request, - refined_output=refined_output, - relevant_chunks=relevant_chunks, - accumulated_response="".join(accumulated_response), - ) - except Exception as storage_error: - # Log storage error but don't fail the request + streaming_step_start = time.time() + + # Record history length before streaming + lm = dspy.settings.lm + history_length_before = len(lm.history) if lm and hasattr(lm, "history") else 0 + + async def bot_response_generator() -> AsyncIterator[str]: + """Generator that yields tokens from NATIVE DSPy LLM streaming.""" + async for token in stream_response_native( + agent=components["response_generator"], + question=refined_output.original_question, + chunks=relevant_chunks, + max_blocks=ResponseGenerationConstants.DEFAULT_MAX_BLOCKS, + ): + yield token + + # Create and store bot_generator in stream context for guaranteed cleanup + bot_generator = bot_response_generator() + stream_ctx.bot_generator = bot_generator + + # Wrap entire streaming logic in try/except for proper error handling + try: + # Track tokens and accumulated response in stream context + accumulated_response = [] # Track the full response for production storage + + if components["guardrails_adapter"]: + # Use NeMo's stream_with_guardrails helper method + chunk_count = 0 + + try: + async for validated_chunk in components[ + "guardrails_adapter" + ].stream_with_guardrails( + user_message=refined_output.original_question, + bot_message_generator=bot_generator, + ): + chunk_count += 1 + + # Estimate tokens (rough approximation: 4 characters = 1 token) + chunk_tokens = len(validated_chunk) // 4 + stream_ctx.token_count += chunk_tokens + + # Accumulate response for production storage + accumulated_response.append(validated_chunk) + + # Check token limit + if stream_ctx.token_count > StreamConfig.MAX_TOKENS_PER_STREAM: logger.error( - f"Storage failed for chat_id: {request.chatId}, environment: {request.environment} - {str(storage_error)}" + f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded: " + f"{stream_ctx.token_count} > {StreamConfig.MAX_TOKENS_PER_STREAM}" + ) + yield self.format_sse( + request.chatId, STREAM_TOKEN_LIMIT_MESSAGE ) + yield self.format_sse(request.chatId, "END") + + usage_info = get_lm_usage_since(history_length_before) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return + + # Check for guardrail violations + is_guardrail_error = False + if isinstance(validated_chunk, str): + blocked_phrases = GUARDRAILS_BLOCKED_PHRASES + chunk_lower = validated_chunk.strip().lower() + for phrase in blocked_phrases: + if ( + phrase.lower() in chunk_lower + and len(chunk_lower) <= len(phrase.lower()) + 20 + ): + is_guardrail_error = True + break - # Mark stream as completed successfully - stream_ctx.mark_completed() + if is_guardrail_error: + logger.warning( + f"[{request.chatId}] [{stream_ctx.stream_id}] Guardrails violation detected" + ) + yield self.format_sse( + request.chatId, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE + ) + yield self.format_sse(request.chatId, "END") + usage_info = get_lm_usage_since(history_length_before) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + stream_ctx.mark_completed() + return + + # Yield the validated chunk to client + yield self.format_sse(request.chatId, validated_chunk) except GeneratorExit: - # Client disconnected - mark as cancelled stream_ctx.mark_cancelled() logger.info( - f"[{request.chatId}] [{stream_ctx.stream_id}] Client disconnected" - ) - usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) - - # Update budget even on client disconnect - self._update_connection_budget( - request.connection_id, costs_dict, request.environment + f"[{request.chatId}] [{stream_ctx.stream_id}] Client disconnected during guardrails streaming" ) raise - except Exception as stream_error: - error_id = generate_error_id() - stream_ctx.mark_error(error_id) - log_error_with_context( - logger, - error_id, - "streaming_generation", - request.chatId, - stream_error, - ) - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") - usage_info = get_lm_usage_since(history_length_before) - costs_dict["streaming_generation"] = usage_info - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Stream completed successfully ({chunk_count} chunks)" + ) - # Update budget even on streaming error - self._update_connection_budget( - request.connection_id, costs_dict, request.environment + # Send document references before END token + doc_references = self._extract_document_references(relevant_chunks) + if doc_references: + refs_text = "\n\n**References:**\n" + "\n".join( + f"{i + 1}. [{ref.document_url}]({ref.document_url})" + for i, ref in enumerate(doc_references) ) + yield self.format_sse(request.chatId, refs_text) - except Exception as e: - error_id = generate_error_id() - stream_ctx.mark_error(error_id) - log_error_with_context( - logger, error_id, "streaming_orchestration", request.chatId, e + yield self.format_sse(request.chatId, "END") + + else: + # No guardrails - stream directly + logger.warning( + f"[{request.chatId}] [{stream_ctx.stream_id}] Streaming without guardrails validation" ) + chunk_count = 0 + async for token in bot_generator: + chunk_count += 1 + + token_estimate = len(token) // 4 + stream_ctx.token_count += token_estimate + accumulated_response.append(token) + + if stream_ctx.token_count > StreamConfig.MAX_TOKENS_PER_STREAM: + logger.error( + f"[{request.chatId}] [{stream_ctx.stream_id}] Token limit exceeded (no guardrails)" + ) + yield self.format_sse( + request.chatId, STREAM_TOKEN_LIMIT_MESSAGE + ) + yield self.format_sse(request.chatId, "END") + stream_ctx.mark_completed() + return - yield self._format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) - yield self._format_sse(request.chatId, "END") + yield self.format_sse(request.chatId, token) - self._log_costs(costs_dict) - log_step_timings(timing_dict, request.chatId) + # Send document references before END token + doc_references = self._extract_document_references(relevant_chunks) + if doc_references: + refs_text = "\n\n**References:**\n" + "\n".join( + f"{i + 1}. [{ref.document_url}]({ref.document_url})" + for i, ref in enumerate(doc_references) + ) + yield self.format_sse(request.chatId, refs_text) - # Update budget even on outer exception - self._update_connection_budget( - request.connection_id, costs_dict, request.environment + yield self.format_sse(request.chatId, "END") + + # Extract usage information after streaming completes + usage_info = get_lm_usage_since(history_length_before) + costs_metric["streaming_generation"] = usage_info + + # Record timings + time_metric["streaming_generation"] = time.time() - streaming_step_start + time_metric["output_guardrails"] = 0.0 # Inline during streaming + + # Calculate streaming duration + streaming_duration = (datetime.now() - streaming_start_time).total_seconds() + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Streaming completed in {streaming_duration:.2f}s" + ) + + # Log costs and trace + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + + # Update budget + self._update_connection_budget( + request.connection_id, costs_metric, request.environment + ) + + # Langfuse tracking + if self.langfuse_config.langfuse_client: + langfuse = self.langfuse_config.langfuse_client + total_costs = calculate_total_costs(costs_metric) + + langfuse.update_current_generation( + model=components["llm_manager"] + .get_provider_info() + .get("model", "unknown"), + usage_details={ + "input": usage_info.get("total_prompt_tokens", 0), + "output": usage_info.get("total_completion_tokens", 0), + "total": usage_info.get("total_tokens", 0), + }, + cost_details={"total": total_costs.get("total_cost", 0.0)}, + metadata={ + "streaming": True, + "streaming_duration_seconds": streaming_duration, + "chunks_streamed": chunk_count, + "cost_breakdown": costs_metric, + "chat_id": request.chatId, + "environment": request.environment, + "stream_id": stream_ctx.stream_id, + }, ) + langfuse.flush() - if self.langfuse_config.langfuse_client: - langfuse = self.langfuse_config.langfuse_client - langfuse.update_current_generation( - metadata={ - "error_id": error_id, - "error_type": type(e).__name__, - "streaming": True, - "streaming_failed": True, - "stream_id": stream_ctx.stream_id, - } + # Store inference data (for production and testing environments) + if request.environment in [ + PRODUCTION_DEPLOYMENT_ENVIRONMENT, + TEST_DEPLOYMENT_ENVIRONMENT, + ]: + try: + await self._store_production_inference_data_async( + request=request, + refined_output=refined_output, + relevant_chunks=relevant_chunks, + accumulated_response="".join(accumulated_response), ) - langfuse.flush() + except Exception as storage_error: + logger.error( + f"Storage failed for chat_id: {request.chatId}, environment: {request.environment} - {str(storage_error)}" + ) + + # Mark stream as completed successfully + stream_ctx.mark_completed() + + except GeneratorExit: + # Client disconnected - mark as cancelled + stream_ctx.mark_cancelled() + logger.info( + f"[{request.chatId}] [{stream_ctx.stream_id}] Client disconnected" + ) + usage_info = get_lm_usage_since(history_length_before) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + + # Update budget even on client disconnect + self._update_connection_budget( + request.connection_id, costs_metric, request.environment + ) + raise + except Exception as stream_error: + error_id = generate_error_id() + stream_ctx.mark_error(error_id) + log_error_with_context( + logger, + error_id, + "streaming_generation", + request.chatId, + stream_error, + ) + yield self.format_sse(request.chatId, TECHNICAL_ISSUE_MESSAGE) + yield self.format_sse(request.chatId, "END") + + usage_info = get_lm_usage_since(history_length_before) + costs_metric["streaming_generation"] = usage_info + self.log_costs(costs_metric) + log_step_timings(time_metric, request.chatId) + + # Update budget even on streaming error + self._update_connection_budget( + request.connection_id, costs_metric, request.environment + ) - def _format_sse(self, chat_id: str, content: str) -> str: + def format_sse( + self, + chat_id: str, + content: str, + buttons: Optional[List[Dict[str, Any]]] = None, + ) -> str: """ Format SSE message with exact specification. Args: chat_id: Chat/channel identifier content: Content to send (token, "END", error message, etc.) + buttons: Optional list of choice button dicts for MCQ step responses Returns: SSE-formatted string: "data: {json}\\n\\n" """ + inner_payload: Dict[str, Any] = {"content": content} + if buttons: + inner_payload["buttons"] = buttons + payload: Dict[str, Any] = { "chatId": chat_id, - "payload": {"content": content}, + "payload": inner_payload, "timestamp": str(int(datetime.now().timestamp() * 1000)), "sentTo": [], } @@ -807,10 +1252,22 @@ def _initialize_service_components( environment=request.environment, connection_id=request.connection_id ) - # Initialize Guardrails Adapter (optional) - components["guardrails_adapter"] = self._safe_initialize_guardrails( - request.environment, request.connection_id - ) + if request.environment in self.shared_guardrails_adapters: + logger.info( + f" Using shared guardrails adapter for environment='{request.environment}' " + f"(startup-initialized, zero overhead)" + ) + components["guardrails_adapter"] = self.shared_guardrails_adapters[ + request.environment + ] + else: + logger.warning( + f" Shared guardrails unavailable for environment='{request.environment}', " + f"initializing per-request (slower)" + ) + components["guardrails_adapter"] = self._safe_initialize_guardrails( + request.environment, request.connection_id + ) # Initialize Contextual Retriever (replaces hybrid retriever) components["contextual_retriever"] = self._safe_initialize_contextual_retriever( @@ -917,41 +1374,48 @@ def _log_generator_status(self, components: Dict[str, Any]) -> None: logger.warning(f" Generator: Status check failed - {str(e)}") @observe(name="execute_orchestration_pipeline", as_type="span") - def _execute_orchestration_pipeline( + async def _execute_orchestration_pipeline( self, request: OrchestrationRequest, components: Dict[str, Any], - costs_dict: Dict[str, Dict[str, Any]], - timing_dict: Dict[str, float], - ) -> OrchestrationResponse: - """Execute the main orchestration pipeline with all components.""" - # Step 1: Input Guardrails Check - if components["guardrails_adapter"]: - start_time = time.time() - input_blocked_response = self.handle_input_guardrails( - components["guardrails_adapter"], request, costs_dict - ) - timing_dict["input_guardrails_check"] = time.time() - start_time - if input_blocked_response: - return input_blocked_response + costs_metric: Dict[str, Dict[str, Any]], + time_metric: Dict[str, float], + prefix: str = "", + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: + """Execute the main orchestration pipeline with all components. - # Step 2: Refine user prompt + Args: + request: Orchestration request + components: Initialized service components + costs_metric: Dictionary for cost tracking + time_metric: Dictionary for timing tracking + prefix: Optional prefix for timing keys (e.g., "rag" for workflow namespacing) + """ + # Note: Query validation AND input guardrails check now happen at orchestration level + # (in process_orchestration_request) BEFORE classifier routing for true early rejection. + # This saves ~3.5s on blocked requests by failing fast before expensive workflow operations. + + # Step 1: Refine user prompt start_time = time.time() refined_output, refiner_usage = self._refine_user_prompt( llm_manager=components["llm_manager"], original_message=request.message, conversation_history=request.conversationHistory, ) - timing_dict["prompt_refiner"] = time.time() - start_time - costs_dict["prompt_refiner"] = refiner_usage + timing_key = f"{prefix}.prompt_refiner" if prefix else "prompt_refiner" + time_metric[timing_key] = time.time() - start_time + costs_metric["prompt_refiner"] = refiner_usage - # Step 3: Retrieve relevant chunks using contextual retrieval + # Step 2: Retrieve relevant chunks using contextual retrieval try: start_time = time.time() - relevant_chunks = self._safe_retrieve_contextual_chunks_sync( + relevant_chunks = await self._safe_retrieve_contextual_chunks( components["contextual_retriever"], refined_output, request ) - timing_dict["contextual_retrieval"] = time.time() - start_time + timing_key = ( + f"{prefix}.contextual_retrieval" if prefix else "contextual_retrieval" + ) + time_metric[timing_key] = time.time() - start_time except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -964,7 +1428,7 @@ def _execute_orchestration_pipeline( logger.info("No relevant chunks found - returning out-of-scope response") return self._create_out_of_scope_response(request) - # Step 4: Generate response + # Step 3: Generate response start_time = time.time() generated_response = self._generate_rag_response( llm_manager=components["llm_manager"], @@ -972,22 +1436,33 @@ def _execute_orchestration_pipeline( refined_output=refined_output, relevant_chunks=relevant_chunks, response_generator=components["response_generator"], - costs_dict=costs_dict, + costs_metric=costs_metric, ) - timing_dict["response_generation"] = time.time() - start_time + timing_key = ( + f"{prefix}.response_generation" if prefix else "response_generation" + ) + time_metric[timing_key] = time.time() - start_time - # Step 5: Output Guardrails Check + # Step 4: Output Guardrails Check + # Apply guardrails to all response types for consistent safety across all environments start_time = time.time() - output_guardrails_response = self.handle_output_guardrails( - components["guardrails_adapter"], generated_response, request, costs_dict + output_guardrails_response = await self.handle_output_guardrails( + components["guardrails_adapter"], + generated_response, + request, + costs_metric, + ) + timing_key = ( + f"{prefix}.output_guardrails_check" if prefix else "output_guardrails_check" ) - timing_dict["output_guardrails_check"] = time.time() - start_time + time_metric[timing_key] = time.time() - start_time - # Step 6: Store inference data (for production and testing environments) + # Step 5: Store inference data (for production and testing environments) + # Only store OrchestrationResponse (has chatId), not TestOrchestrationResponse if request.environment in [ PRODUCTION_DEPLOYMENT_ENVIRONMENT, TEST_DEPLOYMENT_ENVIRONMENT, - ]: + ] and isinstance(output_guardrails_response, OrchestrationResponse): try: self._store_production_inference_data( request=request, @@ -1049,17 +1524,17 @@ def _safe_initialize_response_generator( ) return None - def handle_input_guardrails( + async def handle_input_guardrails( self, guardrails_adapter: NeMoRailsAdapter, request: OrchestrationRequest, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> Union[OrchestrationResponse, TestOrchestrationResponse, None]: """Check input guardrails and return blocked response if needed.""" - input_check_result = self._check_input_guardrails( + input_check_result = await self._check_input_guardrails_async( guardrails_adapter=guardrails_adapter, user_message=request.message, - costs_dict=costs_dict, + costs_metric=costs_metric, ) if not input_check_result.allowed: @@ -1103,21 +1578,23 @@ def _safe_retrieve_contextual_chunks_sync( """Synchronous wrapper for _safe_retrieve_contextual_chunks for non-streaming pipeline.""" try: - # Safely execute the async method in the sync context + # Check if there's a running event loop try: asyncio.get_running_loop() - # If we get here, there's a running event loop; cannot block synchronously - raise RuntimeError( + # If we get here, there IS a running event loop; cannot use asyncio.run() + raise ContextualRetrievalFailureError( "Cannot call _safe_retrieve_contextual_chunks_sync from an async context with a running event loop. " "Please use the async version _safe_retrieve_contextual_chunks instead." ) except RuntimeError: - # No running loop, safe to use asyncio.run() - return asyncio.run( - self._safe_retrieve_contextual_chunks( - contextual_retriever, refined_output, request - ) + # No running loop (get_running_loop raised RuntimeError), safe to use asyncio.run() + pass + + return asyncio.run( + self._safe_retrieve_contextual_chunks( + contextual_retriever, refined_output, request ) + ) except ( ContextualRetrieverInitializationError, ContextualRetrievalFailureError, @@ -1172,23 +1649,28 @@ async def _safe_retrieve_contextual_chunks( f"Contextual chunk retrieval failed: {str(retrieval_error)}" ) from retrieval_error - def handle_output_guardrails( + async def handle_output_guardrails( self, guardrails_adapter: Optional[NeMoRailsAdapter], - generated_response: OrchestrationResponse, + generated_response: Union[OrchestrationResponse, TestOrchestrationResponse], request: OrchestrationRequest, - costs_dict: Dict[str, Dict[str, Any]], - ) -> OrchestrationResponse: - """Check output guardrails and handle blocked responses.""" - if ( + costs_metric: Dict[str, Dict[str, Any]], + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: + """Check output guardrails and handle blocked responses for both response types.""" + # Determine if we should run guardrails (same logic for both response types) + should_check_guardrails = ( guardrails_adapter is not None and generated_response.llmServiceActive and not generated_response.questionOutOfLLMScope - ): - output_check_result = self._check_output_guardrails( + ) + + if should_check_guardrails: + # Type assertion: should_check_guardrails guarantees guardrails_adapter is not None + assert guardrails_adapter is not None + output_check_result = await self._check_output_guardrails( guardrails_adapter=guardrails_adapter, assistant_message=generated_response.content, - costs_dict=costs_dict, + costs_metric=costs_metric, ) if not output_check_result.allowed: @@ -1201,13 +1683,23 @@ def handle_output_guardrails( OUTPUT_GUARDRAIL_VIOLATION_MESSAGES, detected_lang ) - return OrchestrationResponse( - chatId=request.chatId, - llmServiceActive=True, - questionOutOfLLMScope=False, - inputGuardFailed=False, - content=localized_msg, - ) + # Return appropriate response type based on original response type + if isinstance(generated_response, TestOrchestrationResponse): + return TestOrchestrationResponse( + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=localized_msg, + chunks=None, + ) + else: + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=localized_msg, + ) logger.info("Output guardrails check passed") else: @@ -1305,7 +1797,7 @@ def _store_production_inference_data( # Store the inference result asynchronously without blocking - def store_async(): + def store_async() -> None: """Run async storage in a new event loop in a separate thread.""" try: loop = asyncio.new_event_loop() @@ -1443,7 +1935,6 @@ def _initialize_guardrails( environment=environment, connection_id=connection_id ) - logger.info("Guardrails adapter initialized successfully") return guardrails_adapter except Exception as e: @@ -1455,7 +1946,7 @@ async def _check_input_guardrails_async( self, guardrails_adapter: NeMoRailsAdapter, user_message: str, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> GuardrailCheckResult: """ Check user input against guardrails and track costs (async version). @@ -1463,7 +1954,7 @@ async def _check_input_guardrails_async( Args: guardrails_adapter: The guardrails adapter instance user_message: The user message to check - costs_dict: Dictionary to store cost information + costs_metric: Dictionary to store cost information Returns: GuardrailCheckResult: Result of the guardrail check @@ -1475,7 +1966,7 @@ async def _check_input_guardrails_async( result = await guardrails_adapter.check_input_async(user_message) # Store guardrail costs - costs_dict["input_guardrails"] = result.usage + costs_metric["input_guardrails"] = result.usage if self.langfuse_config.langfuse_client: langfuse = self.langfuse_config.langfuse_client langfuse.update_current_generation( @@ -1528,7 +2019,7 @@ def _check_input_guardrails( self, guardrails_adapter: NeMoRailsAdapter, user_message: str, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> GuardrailCheckResult: """ Check user input against guardrails and track costs (sync version for non-streaming). @@ -1536,7 +2027,7 @@ def _check_input_guardrails( Args: guardrails_adapter: The guardrails adapter instance user_message: The user message to check - costs_dict: Dictionary to store cost information + costs_metric: Dictionary to store cost information Returns: GuardrailCheckResult: Result of the guardrail check @@ -1547,7 +2038,7 @@ def _check_input_guardrails( result = guardrails_adapter.check_input(user_message) # Store guardrail costs - costs_dict["input_guardrails"] = result.usage + costs_metric["input_guardrails"] = result.usage if self.langfuse_config.langfuse_client: langfuse = self.langfuse_config.langfuse_client langfuse.update_current_generation( @@ -1596,11 +2087,11 @@ def _check_input_guardrails( ) @observe(name="check_output_guardrails", as_type="span") - def _check_output_guardrails( + async def _check_output_guardrails( self, guardrails_adapter: NeMoRailsAdapter, assistant_message: str, - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], ) -> GuardrailCheckResult: """ Check assistant output against guardrails and track costs. @@ -1608,7 +2099,7 @@ def _check_output_guardrails( Args: guardrails_adapter: The guardrails adapter instance assistant_message: The assistant message to check - costs_dict: Dictionary to store cost information + costs_metric: Dictionary to store cost information Returns: GuardrailCheckResult: Result of the guardrail check @@ -1616,10 +2107,10 @@ def _check_output_guardrails( logger.info("Starting output guardrails check") try: - result = guardrails_adapter.check_output(assistant_message) + result = await guardrails_adapter.check_output_async(assistant_message) # Store guardrail costs - costs_dict["output_guardrails"] = result.usage + costs_metric["output_guardrails"] = result.usage if self.langfuse_config.langfuse_client: langfuse = self.langfuse_config.langfuse_client langfuse.update_current_generation( @@ -1669,22 +2160,22 @@ def _check_output_guardrails( usage={}, ) - def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: + def log_costs(self, costs_metric: Dict[str, Dict[str, Any]]) -> None: """ Log cost information for tracking. Args: - costs_dict: Dictionary of costs per component + costs_metric: Dictionary of costs per component """ try: - if not costs_dict: + if not costs_metric: return - total_costs = calculate_total_costs(costs_dict) + total_costs = calculate_total_costs(costs_metric) logger.info("LLM USAGE COSTS BREAKDOWN:") - for component, costs in costs_dict.items(): + for component, costs in costs_metric.items(): logger.info( f" {component:20s}: ${costs.get('total_cost', 0):.6f} " f"({costs.get('num_calls', 0)} calls, " @@ -1738,7 +2229,7 @@ def _log_costs(self, costs_dict: Dict[str, Dict[str, Any]]) -> None: def _update_connection_budget( self, connection_id: Optional[str], - costs_dict: Dict[str, Dict[str, Any]], + costs_metric: Dict[str, Dict[str, Any]], environment: str = "development", ) -> None: """ @@ -1747,7 +2238,7 @@ def _update_connection_budget( Args: connection_id: The LLM connection ID (optional) - costs_dict: Dictionary of costs per component + costs_metric: Dictionary of costs per component environment: The deployment environment (production/testing/development) """ try: @@ -1775,7 +2266,9 @@ def _update_connection_budget( f"Error fetching production connection ID: {str(fetch_error)}" ) - result = budget_tracker.update_budget_from_costs(connection_id, costs_dict) + result = budget_tracker.update_budget_from_costs( + connection_id, costs_metric + ) if result.get("success"): if result.get("budget_exceeded"): @@ -1977,6 +2470,7 @@ def _initialize_contextual_retriever( environment=environment, connection_id=connection_id, llm_service=self, # Inject self to eliminate circular dependency + shared_bm25=self.shared_bm25_search, # Inject pre-warmed BM25 index ) logger.info("Contextual retriever initialized successfully") @@ -2002,9 +2496,14 @@ def _initialize_response_generator( logger.info("Initializing response generator") try: + # Get custom instructions for response generation + custom_prefix = self._get_custom_instructions_for_response_generation() + # Set up DSPy configuration for the response generator with llm_manager.use_task_local(): - response_generator = ResponseGeneratorAgent() + response_generator = ResponseGeneratorAgent( + custom_instructions_prefix=custom_prefix + ) logger.info("Response generator initialized successfully") return response_generator @@ -2013,6 +2512,28 @@ def _initialize_response_generator( logger.error(f"Failed to initialize response generator: {str(e)}") raise + def _get_custom_instructions_for_response_generation(self) -> str: + """ + Get custom prompt instructions for response generation only. + + Note: Applied only to ResponseGeneratorAgent, not PromptRefinerAgent. + PromptRefiner focuses on query optimization for retrieval, while + ResponseGenerator needs to follow language policy and interaction style + for user-facing content. + + Returns: + str: Custom instruction prefix for prepending to questions + """ + try: + custom_prompt = self.prompt_config_loader.get_custom_instructions() + if custom_prompt: + # Format for prepending to questions in ResponseGenerator + return f"[SYSTEM INSTRUCTIONS]\n{custom_prompt}\n\n[USER QUESTION]\n" + return "" + except Exception as e: + logger.error(f"Error retrieving custom instructions: {e}") + return "" + @staticmethod def _format_chunks_for_test_response( relevant_chunks: Optional[List[Dict[str, Union[str, float, Dict[str, Any]]]]], @@ -2103,7 +2624,7 @@ def _generate_rag_response( refined_output: PromptRefinerOutput, relevant_chunks: List[Dict[str, Union[str, float, Dict[str, Any]]]], response_generator: Optional[ResponseGeneratorAgent] = None, - costs_dict: Optional[Dict[str, Dict[str, Any]]] = None, + costs_metric: Optional[Dict[str, Dict[str, Any]]] = None, ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: """ Generate response using retrieved chunks and ResponseGeneratorAgent only. @@ -2111,8 +2632,8 @@ def _generate_rag_response( """ logger.info("Starting RAG response generation") - if costs_dict is None: - costs_dict = {} + if costs_metric is None: + costs_metric = {} # If response generator is not available -> standardized technical issue if response_generator is None: @@ -2170,7 +2691,7 @@ def _generate_rag_response( "num_calls": 0, }, ) - costs_dict["response_generator"] = generator_usage + costs_metric["response_generator"] = generator_usage if self.langfuse_config.langfuse_client: langfuse = self.langfuse_config.langfuse_client langfuse.update_current_generation( @@ -2414,7 +2935,7 @@ def get_available_embedding_models_for_indexer( # Lazy Initialization Helpers for Vector Indexer (Private Methods) # ======================================================================== - def _get_embedding_manager(self): + def _get_embedding_manager(self) -> "EmbeddingManager": """Lazy initialization of EmbeddingManager for vector indexer.""" if not hasattr(self, "_embedding_manager"): from src.llm_orchestrator_config.embedding_manager import EmbeddingManager @@ -2428,7 +2949,7 @@ def _get_embedding_manager(self): return self._embedding_manager - def _get_context_manager(self): + def _get_context_manager(self) -> "ContextGenerationManager": """Lazy initialization of ContextGenerationManager for vector indexer.""" if not hasattr(self, "_context_manager"): from src.llm_orchestrator_config.context_manager import ( @@ -2442,7 +2963,7 @@ def _get_context_manager(self): return self._context_manager - def _get_config_loader(self): + def _get_config_loader(self) -> "ConfigurationLoader": """Lazy initialization of ConfigurationLoader for vector indexer.""" if not hasattr(self, "_config_loader"): from src.llm_orchestrator_config.config.loader import ConfigurationLoader diff --git a/src/llm_orchestration_service_api.py b/src/llm_orchestration_service_api.py index b58eac94..34279c7b 100644 --- a/src/llm_orchestration_service_api.py +++ b/src/llm_orchestration_service_api.py @@ -1,5 +1,6 @@ """LLM Orchestration Service API - FastAPI application.""" +import logging from contextlib import asynccontextmanager from typing import Any, AsyncGenerator, Dict @@ -11,6 +12,12 @@ import uvicorn from llm_orchestration_service import LLMOrchestrationService +from src.utils.redis_client import ( + init_redis_client, + close_redis_client, + check_redis_health, +) +from src.utils.api_tool_session_store import APIToolSessionStore from src.llm_orchestrator_config.llm_ochestrator_constants import ( STREAMING_ALLOWED_ENVS, STREAM_TIMEOUT_MESSAGE, @@ -26,10 +33,11 @@ VALIDATION_GENERIC_ERROR, ) from src.llm_orchestrator_config.stream_config import StreamConfig -from src.llm_orchestrator_config.exceptions import StreamTimeoutException +from src.llm_orchestrator_config.exceptions import StreamTimeoutError from src.utils.stream_timeout import stream_timeout from src.utils.error_utils import generate_error_id, log_error_with_context from src.utils.rate_limiter import RateLimiter +from src.utils.prompt_config_loader import RefreshStatus from models.request_models import ( OrchestrationRequest, OrchestrationResponse, @@ -48,15 +56,28 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Application lifespan manager.""" # Startup logger.info("Starting LLM Orchestration Service API") + + # nemoguardrails.actions.action_dispatcher logs every action it registers + logging.getLogger("nemoguardrails.actions.action_dispatcher").setLevel( + logging.WARNING + ) + logging.getLogger("langfuse").setLevel(logging.ERROR) + try: app.state.orchestration_service = LLMOrchestrationService() logger.info("LLM Orchestration Service initialized successfully") + # Pre-warm shared BM25 index so the first query is never penalised by + # the cold-start cost of scrolling all Qdrant chunks + building the index. + logger.info("Pre-warming shared BM25 index...") + await app.state.orchestration_service._prewarm_shared_bm25() + logger.info("BM25 pre-warming complete") + # Initialize rate limiter if enabled if StreamConfig.RATE_LIMIT_ENABLED: app.state.rate_limiter = RateLimiter( requests_per_minute=StreamConfig.RATE_LIMIT_REQUESTS_PER_MINUTE, - tokens_per_second=StreamConfig.RATE_LIMIT_TOKENS_PER_SECOND, + tokens_per_minute=StreamConfig.RATE_LIMIT_TOKENS_PER_MINUTE, ) logger.info("Rate limiter initialized successfully") else: @@ -66,14 +87,39 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.error(f"Failed to initialize LLM Orchestration Service: {e}") raise + # Initialize Redis session store (non-fatal: service continues without it) + try: + await init_redis_client() + app.state.session_store = APIToolSessionStore() + logger.info("Redis session store initialized successfully") + except Exception as e: + logger.warning(f"Redis session store unavailable, continuing without it: {e}") + app.state.session_store = None + + # Expose session_store on the orchestration service so workflow executors + # (e.g. APIToolWorkflowExecutor) can reach it via self.orchestration_service. + if ( + hasattr(app.state, "orchestration_service") + and app.state.orchestration_service is not None + ): + app.state.orchestration_service.session_store = app.state.session_store + yield # Shutdown logger.info("Shutting down LLM Orchestration Service API") - # Clean up resources if needed - if hasattr(app.state, "orchestration_service"): + if ( + hasattr(app.state, "orchestration_service") + and app.state.orchestration_service is not None + ): + await app.state.orchestration_service.aclose() app.state.orchestration_service = None + try: + await close_redis_client() + except Exception as e: + logger.warning(f"Error closing Redis client during shutdown: {e}") + # Create FastAPI application app = FastAPI( @@ -86,7 +132,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # Custom exception handlers for user-friendly error messages @app.exception_handler(RequestValidationError) -async def validation_exception_handler(request: Request, exc: RequestValidationError): +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> StreamingResponse | JSONResponse: """ Handle Pydantic validation errors with user-friendly messages. @@ -151,7 +199,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE pass # Return SSE format for streaming endpoint - async def validation_error_stream(): + async def validation_error_stream() -> AsyncGenerator[str, None]: error_payload: Dict[str, Any] = { "chatId": chat_id, "payload": {"content": user_message}, @@ -202,7 +250,7 @@ async def pydantic_validation_exception_handler( @app.get("/health") -def health_check(request: Request) -> dict[str, str]: +async def health_check(request: Request) -> dict[str, str]: """Health check endpoint.""" service_status = ( "initialized" @@ -210,10 +258,12 @@ def health_check(request: Request) -> dict[str, str]: and request.app.state.orchestration_service is not None else "not_initialized" ) + redis_status = await check_redis_health() return { "status": "healthy", "service": "llm-orchestration-service", "orchestration_service": service_status, + "redis_session_store": redis_status, } @@ -224,7 +274,7 @@ def health_check(request: Request) -> dict[str, str]: summary="Process LLM orchestration request", description="Processes a user message through the LLM orchestration pipeline", ) -def orchestrate_llm_request( +async def orchestrate_llm_request( http_request: Request, request: OrchestrationRequest, ) -> OrchestrationResponse: @@ -261,8 +311,14 @@ def orchestrate_llm_request( ) # Process the request - response = orchestration_service.process_orchestration_request(request) + response = await orchestration_service.process_orchestration_request(request) + buttons_present = bool(response.buttons) + buttons_count = len(response.buttons) if response.buttons else 0 + logger.info( + f"[orchestrate] buttons in response for chatId {request.chatId}: " + f"present={buttons_present}, count={buttons_count}" + ) logger.info(f"Successfully processed request for chatId: {request.chatId}") return response @@ -276,7 +332,7 @@ def orchestrate_llm_request( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error occurred", - ) + ) from e @app.post( @@ -286,7 +342,7 @@ def orchestrate_llm_request( summary="Process test LLM orchestration request", description="Processes a simplified test message through the LLM orchestration pipeline", ) -def test_orchestrate_llm_request( +async def test_orchestrate_llm_request( http_request: Request, request: TestOrchestrationRequest, ) -> TestOrchestrationResponse: @@ -337,13 +393,26 @@ def test_orchestrate_llm_request( else None, ) + # test-LLM is single-turn only (no conversationHistory, no multi-turn loops). + # Clear any stale API tool session so each request starts fresh and never + # accidentally resumes a parameter-collection loop from a previous test query. + session_store = getattr(http_request.app.state, "session_store", None) + if session_store is not None: + await session_store.delete("test-session") + logger.info(f"This is full request constructed for testing: {full_request}") # Process the request using the same logic - response = orchestration_service.process_orchestration_request(full_request) + response = await orchestration_service.process_orchestration_request( + full_request + ) # If response is already TestOrchestrationResponse (when environment is testing), return it directly if isinstance(response, TestOrchestrationResponse): + buttons_count = len(response.buttons) if response.buttons else 0 + logger.info( + f"[test_orchestrate] buttons present in response: {buttons_count}" + ) logger.info( f"Successfully processed test request for environment: {request.environment}" ) @@ -355,9 +424,9 @@ def test_orchestrate_llm_request( questionOutOfLLMScope=response.questionOutOfLLMScope, inputGuardFailed=response.inputGuardFailed, content=response.content, + buttons=response.buttons, chunks=None, # OrchestrationResponse doesn't have chunks ) - logger.info( f"Successfully processed test request for environment: {request.environment}" ) @@ -373,7 +442,7 @@ def test_orchestrate_llm_request( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal server error occurred", - ) + ) from e @app.post( @@ -385,7 +454,7 @@ def test_orchestrate_llm_request( async def stream_orchestrated_response( http_request: Request, request: OrchestrationRequest, -): +) -> StreamingResponse: """ Stream LLM orchestration response with validation-first guardrails. @@ -425,7 +494,7 @@ async def stream_orchestrated_response( import json as json_module from datetime import datetime - def create_sse_error_stream(chat_id: str, error_message: str): + def create_sse_error_stream(chat_id: str, error_message: str) -> str: """Create SSE format error response.""" from typing import Dict, Any @@ -450,7 +519,7 @@ def create_sse_error_stream(chat_id: str, error_message: str): error_msg = f"Streaming is only available for production environment. Current environment: {request.environment}. Please use /orchestrate endpoint for non-streaming environments." logger.warning(error_msg) - async def env_error_stream(): + async def env_error_stream() -> AsyncGenerator[str, None]: yield create_sse_error_stream(request.chatId, error_msg) return StreamingResponse( @@ -468,7 +537,7 @@ async def env_error_stream(): error_msg = "I apologize, but the service is not available at the moment. Please try again later." logger.error("Orchestration service not found in app state") - async def service_error_stream(): + async def service_error_stream() -> AsyncGenerator[str, None]: yield create_sse_error_stream(request.chatId, error_msg) return StreamingResponse( @@ -486,7 +555,7 @@ async def service_error_stream(): error_msg = "I apologize, but the service is not available at the moment. Please try again later." logger.error("Orchestration service is None") - async def service_none_stream(): + async def service_none_stream() -> AsyncGenerator[str, None]: yield create_sse_error_stream(request.chatId, error_msg) return StreamingResponse( @@ -531,7 +600,7 @@ async def service_none_stream(): ) # Return SSE format with rate limit error - async def rate_limit_error_stream(): + async def rate_limit_error_stream() -> AsyncGenerator[str, None]: yield create_sse_error_stream(request.chatId, error_msg) return StreamingResponse( @@ -547,7 +616,7 @@ async def rate_limit_error_stream(): ) # Wrap streaming response with timeout - async def timeout_wrapped_stream(): + async def timeout_wrapped_stream() -> AsyncGenerator[str, None]: """Generator wrapper with timeout enforcement.""" try: async with stream_timeout(StreamConfig.MAX_STREAM_DURATION_SECONDS): @@ -555,8 +624,8 @@ async def timeout_wrapped_stream(): chunk ) in orchestration_service.stream_orchestration_response(request): yield chunk - except StreamTimeoutException as timeout_exc: - # StreamTimeoutException already has error_id + except StreamTimeoutError as timeout_exc: + # StreamTimeoutError already has error_id log_error_with_context( logger, timeout_exc.error_id, @@ -593,7 +662,7 @@ async def timeout_wrapped_stream(): error_id = generate_error_id() logger.error(f"[{error_id}] Unexpected error in streaming endpoint: {str(e)}") - async def unexpected_error_stream(): + async def unexpected_error_stream() -> AsyncGenerator[str, None]: yield create_sse_error_stream( request.chatId if hasattr(request, "chatId") else "unknown", "I apologize, but I encountered an unexpected issue. Please try again.", @@ -658,7 +727,7 @@ async def create_embeddings(request: EmbeddingRequest) -> EmbeddingResponse: "error": "Embedding creation failed", "retry_after": 30, }, - ) + ) from e @app.post("/generate-context", response_model=ContextGenerationResponse) @@ -679,7 +748,7 @@ async def generate_context_with_caching( except Exception as e: error_id = generate_error_id() log_error_with_context(logger, error_id, "context_generation_endpoint", None, e) - raise HTTPException(status_code=500, detail="Context generation failed") + raise HTTPException(status_code=500, detail="Context generation failed") from e @app.get("/embedding-models") @@ -715,7 +784,128 @@ async def get_available_embedding_models( ) raise HTTPException( status_code=500, detail="Failed to retrieve embedding models" + ) from e + + +@app.post("/prompt-config/refresh") +def refresh_prompt_config(http_request: Request) -> Dict[str, Any]: + """ + Force immediate refresh of prompt configuration cache. + + This endpoint is called by Ruuter after admin updates the prompt configuration + in the database, ensuring the changes are reflected immediately without waiting + for the cache TTL to expire. + + Returns: + Dictionary with refresh status and message + + Raises: + HTTPException (503): If prompt configuration loader is not initialized + HTTPException (404): If no prompt configuration found in database + HTTPException (500): If refresh operation fails + """ + orchestration_service = http_request.app.state.orchestration_service + + # Check if loader is initialized + if not orchestration_service or not hasattr( + orchestration_service, "prompt_config_loader" + ): + error_id = generate_error_id() + logger.error(f"[{error_id}] Prompt configuration loader not initialized") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "error": "Prompt configuration loader not initialized", + "error_id": error_id, + }, + ) + + try: + # Use new method that returns detailed status + refresh_result = ( + orchestration_service.prompt_config_loader.force_refresh_with_status() ) + refresh_status = refresh_result.get("status") + + if refresh_status == RefreshStatus.SUCCESS: + # Success - configuration loaded + logger.info("Prompt configuration refreshed successfully") + return { + "refreshed": True, + "message": refresh_result.get("message"), + "prompt_length": refresh_result.get("length"), + } + + elif refresh_status == RefreshStatus.NOT_FOUND: + # Configuration absent in database + error_id = generate_error_id() + logger.warning(f"[{error_id}] Prompt configuration not found in database") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "error": refresh_result.get("message"), + "error_id": error_id, + }, + ) + + elif refresh_status == RefreshStatus.FETCH_FAILED: + # Upstream service failure (network/HTTP/timeout errors) + error_id = generate_error_id() + had_stale = refresh_result.get("had_stale_cache", False) + + if had_stale: + logger.warning( + f"[{error_id}] Upstream service unavailable, stale cache exists" + ) + # Temporarily unavailable but we have fallback + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "error": "Upstream service temporarily unavailable", + "error_id": error_id, + "message": "Stale configuration available as fallback", + }, + ) + else: + logger.warning( + f"[{error_id}] Upstream service unavailable, no cache exists" + ) + # Service gateway error or timeout + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "error": refresh_result.get("message"), + "error_id": error_id, + "details": refresh_result.get("error"), + }, + ) + + else: + # Unexpected status - should never happen but handle defensively + error_id = generate_error_id() + logger.error(f"[{error_id}] Unexpected refresh status: {refresh_status}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Unexpected error during refresh", + "error_id": error_id, + }, + ) + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + # Unexpected errors during refresh + error_id = generate_error_id() + logger.error(f"[{error_id}] Failed to refresh prompt configuration: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "Failed to refresh prompt configuration", + "error_id": error_id, + }, + ) from e if __name__ == "__main__": diff --git a/src/llm_orchestrator_config/config/loader.py b/src/llm_orchestrator_config/config/loader.py index 96122106..2d9a3f60 100644 --- a/src/llm_orchestrator_config/config/loader.py +++ b/src/llm_orchestrator_config/config/loader.py @@ -163,7 +163,7 @@ def _resolve_vault_secrets(self, config: Dict[str, Any]) -> Dict[str, Any]: raise raise ConfigurationError(f"Failed to resolve vault secrets: {e}") from e - def _initialize_vault_resolver(self, config: Dict[str, Any]): + def _initialize_vault_resolver(self, config: Dict[str, Any]) -> SecretResolver: """Initialize vault secret resolver from configuration. Args: @@ -320,7 +320,10 @@ def _resolve_provider_secrets( raise ConfigurationError(f"Failed to resolve provider secrets: {e}") from e def _merge_config_with_secrets( - self, provider_config: Dict[str, Any], secret: Any, model_name: str + self, + provider_config: Dict[str, Any], + secret: Any, # noqa: ANN401 # Runtime type discrimination via hasattr (Union causes type errors) + model_name: str, ) -> Dict[str, Any]: """Merge provider configuration with secrets from Vault. @@ -488,11 +491,7 @@ def replace_env_var(match: re.Match[str]) -> str: result[str(key)] = substitute_env_vars(value) return result elif isinstance(obj, list): - result_list: List[ConfigValue] = [] - - for item in obj: - result_list.append(substitute_env_vars(item)) - return result_list + return [substitute_env_vars(item) for item in obj] else: return obj diff --git a/src/llm_orchestrator_config/context_manager.py b/src/llm_orchestrator_config/context_manager.py index a14447ec..3bb6e5ac 100644 --- a/src/llm_orchestrator_config/context_manager.py +++ b/src/llm_orchestrator_config/context_manager.py @@ -25,6 +25,10 @@ class ContextGenerationManager: Please give a short succinct context to situate this chunk within the overall document for the purposes of improving search retrieval of the chunk. Answer only with the succinct context and nothing else.""" + # Used by the API Tool Calling indexer — passes the caller's prompt through + # unmodified so CHUNK_CONTEXT_PROMPT cannot override the caller's own instructions. + API_TOOL_CONTEXT_PROMPT = """{chunk_content}""" + def __init__(self, llm_manager: LLMManager) -> None: """Initialize context generation manager.""" self.llm_manager = llm_manager @@ -43,7 +47,7 @@ def generate_context_with_caching( # Prepare the full prompt using Anthropic's format full_prompt = self._prepare_anthropic_prompt( - request.document_prompt, request.chunk_prompt + request.document_prompt, request.chunk_prompt, request.context_type ) # For now, call LLM directly (caching structure ready for future) @@ -103,8 +107,22 @@ def _resolve_model_for_request( logger.error(f"Failed to resolve model for context generation: {e}") raise RuntimeError(f"Model resolution failed: {e}") from e - def _prepare_anthropic_prompt(self, document_prompt: str, chunk_prompt: str) -> str: - """Prepare prompt in Anthropic's exact format.""" + def _prepare_anthropic_prompt( + self, + document_prompt: str, + chunk_prompt: str, + context_type: str = "chunk", + ) -> str: + """Prepare the LLM prompt based on context_type. + + - 'api_tool': returns chunk_prompt as-is using API_TOOL_CONTEXT_PROMPT so the + caller's own instructions (including example-query generation) are not + overridden by CHUNK_CONTEXT_PROMPT's closing directive. + - 'chunk' (default): uses the Anthropic RAG format (document + chunk sections). + """ + if context_type == "api_tool": + return self.API_TOOL_CONTEXT_PROMPT.format(chunk_content=chunk_prompt) + # Format document section document_section = self.DOCUMENT_CONTEXT_PROMPT.format( doc_content=document_prompt @@ -147,13 +165,13 @@ class ContextGeneration(dspy.Signature): # Return a response object with the expected structure class MockResponse: - def __init__(self, content: str, model: str): + def __init__(self, content: str, model: str) -> None: self.content = content self.model = model self.usage = MockUsage(content, prompt) class MockUsage: - def __init__(self, content: str, prompt: str): + def __init__(self, content: str, prompt: str) -> None: self.input_tokens = int(len(prompt.split()) * 1.3) # Rough estimate self.output_tokens = int(len(content.split()) * 1.3) diff --git a/src/llm_orchestrator_config/embedding_manager.py b/src/llm_orchestrator_config/embedding_manager.py index db8e2ac5..6c9bf277 100644 --- a/src/llm_orchestrator_config/embedding_manager.py +++ b/src/llm_orchestrator_config/embedding_manager.py @@ -230,7 +230,7 @@ def _create_dspy_embedder(self, config: Dict[str, Any]) -> dspy.Embedder: except Exception as e: logger.error(f"Failed to create DSPy embedder: {e}") - raise ConfigurationError(f"Could not create embedder: {e}") + raise ConfigurationError(f"Could not create embedder: {e}") from e def _log_embedding_failure( self, diff --git a/src/llm_orchestrator_config/exceptions.py b/src/llm_orchestrator_config/exceptions.py index 5d610636..fce80fa2 100644 --- a/src/llm_orchestrator_config/exceptions.py +++ b/src/llm_orchestrator_config/exceptions.py @@ -1,5 +1,7 @@ """Custom exceptions for the LLM Config Module.""" +from typing import Optional + class LLMConfigError(Exception): """Base exception for LLM configuration errors.""" @@ -49,12 +51,14 @@ class ContextualRetrievalFailureError(ContextualRetrievalError): pass -class StreamTimeoutException(LLMConfigError): +class StreamTimeoutError(LLMConfigError): """Raised when stream duration exceeds maximum allowed time.""" - def __init__(self, message: str = "Stream timeout", error_id: str = None): + def __init__( + self, message: str = "Stream timeout", error_id: Optional[str] = None + ) -> None: """ - Initialize StreamTimeoutException with error tracking. + Initialize StreamTimeoutError with error tracking. Args: message: Human-readable error message @@ -66,19 +70,19 @@ def __init__(self, message: str = "Stream timeout", error_id: str = None): super().__init__(f"[{self.error_id}] {message}") -class StreamSizeLimitException(LLMConfigError): +class StreamSizeLimitError(LLMConfigError): """Raised when stream size limits are exceeded.""" pass # Comprehensive error hierarchy for error boundaries -class StreamException(LLMConfigError): +class StreamError(LLMConfigError): """Base exception for streaming operations with error tracking.""" - def __init__(self, message: str, error_id: str = None): + def __init__(self, message: str, error_id: Optional[str] = None) -> None: """ - Initialize StreamException with error tracking. + Initialize StreamError with error tracking. Args: message: Human-readable error message @@ -91,19 +95,19 @@ def __init__(self, message: str, error_id: str = None): super().__init__(f"[{self.error_id}] {message}") -class ValidationException(StreamException): +class ValidationError(StreamError): """Raised when input or request validation fails.""" pass -class ServiceException(StreamException): +class ServiceError(StreamError): """Raised when external service calls fail (LLM, Qdrant, Vault, etc.).""" pass -class GuardrailException(StreamException): +class GuardrailError(StreamError): """Raised when guardrails processing encounters errors.""" pass diff --git a/src/llm_orchestrator_config/feature_flags.py b/src/llm_orchestrator_config/feature_flags.py new file mode 100644 index 00000000..e5a88f55 --- /dev/null +++ b/src/llm_orchestrator_config/feature_flags.py @@ -0,0 +1,90 @@ +"""Feature flags for tool classifier system.""" + +import os +from loguru import logger + + +class FeatureFlags: + """ + Feature flags for controlling tool classifier and workflow behavior. + + These flags enable safe deployment and gradual rollout of the multi-workflow + system. They can be controlled via environment variables. + + Deployment Strategy: + 1. Start with TOOL_CLASSIFIER_ENABLED=false (use existing RAG only) + 2. Enable classifier with all workflows disabled for testing + 3. Enable workflows one at a time (SERVICE → CONTEXT → etc.) + 4. Monitor and rollback if issues occur + + Environment Variables: + - TOOL_CLASSIFIER_ENABLED: Master switch for classifier (default: false) + - SERVICE_WORKFLOW_ENABLED: Enable Layer 1 service workflow (default: true) + - API_TOOL_CALLING_WORKFLOW_ENABLED: Enable Layer 2 API tool calling workflow (default: true) + - CONTEXT_WORKFLOW_ENABLED: Enable Layer 3 context workflow (default: true) + """ + + # Master switch for tool classifier + # When False: Uses existing RAG-only pipeline (backward compatibility) + # When True: Routes through tool classifier + TOOL_CLASSIFIER_ENABLED = ( + os.getenv("TOOL_CLASSIFIER_ENABLED", "false").lower() == "true" + ) + + # Individual workflow toggles + # These only take effect when TOOL_CLASSIFIER_ENABLED=true + SERVICE_WORKFLOW_ENABLED = ( + os.getenv("SERVICE_WORKFLOW_ENABLED", "true").lower() == "true" + ) + API_TOOL_CALLING_WORKFLOW_ENABLED = ( + os.getenv("API_TOOL_CALLING_WORKFLOW_ENABLED", "true").lower() == "true" + ) + CONTEXT_WORKFLOW_ENABLED = ( + os.getenv("CONTEXT_WORKFLOW_ENABLED", "true").lower() == "true" + ) + + # RAG and OOD workflows are always enabled (no flags) + # RAG is the core fallback, OOD is the final safety net + + # Safety: Fallback to RAG if tool classifier encounters errors + # This ensures service continues working even if classifier fails + FALLBACK_TO_RAG_ON_ERROR = True + + @classmethod + def log_configuration(cls) -> None: + """Log current feature flag configuration (useful for debugging).""" + logger.info("Tool Classifier Feature Flags:") + logger.info(f" TOOL_CLASSIFIER_ENABLED: {cls.TOOL_CLASSIFIER_ENABLED}") + if cls.TOOL_CLASSIFIER_ENABLED: + logger.info(f" SERVICE_WORKFLOW_ENABLED: {cls.SERVICE_WORKFLOW_ENABLED}") + logger.info( + f" API_TOOL_CALLING_WORKFLOW_ENABLED: {cls.API_TOOL_CALLING_WORKFLOW_ENABLED}" + ) + logger.info(f" CONTEXT_WORKFLOW_ENABLED: {cls.CONTEXT_WORKFLOW_ENABLED}") + logger.info(f" FALLBACK_TO_RAG_ON_ERROR: {cls.FALLBACK_TO_RAG_ON_ERROR}") + else: + logger.info(" (Classifier disabled - using RAG-only pipeline)") + + @classmethod + def is_workflow_enabled(cls, workflow_name: str) -> bool: + """ + Check if a specific workflow is enabled. + + Args: + workflow_name: Name of workflow ("service", "api_tool_calling", "context", "rag", "ood") + + Returns: + True if workflow is enabled and classifier is enabled + """ + if not cls.TOOL_CLASSIFIER_ENABLED: + return False + + workflow_flags = { + "service": cls.SERVICE_WORKFLOW_ENABLED, + "api_tool_calling": cls.API_TOOL_CALLING_WORKFLOW_ENABLED, + "context": cls.CONTEXT_WORKFLOW_ENABLED, + "rag": True, # Always enabled + "ood": True, # Always enabled + } + + return workflow_flags.get(workflow_name.lower(), False) diff --git a/src/llm_orchestrator_config/llm_manager.py b/src/llm_orchestrator_config/llm_manager.py index dee7a4e6..70df586e 100644 --- a/src/llm_orchestrator_config/llm_manager.py +++ b/src/llm_orchestrator_config/llm_manager.py @@ -1,6 +1,6 @@ """LLM Manager - Main entry point for the LLM Config Module.""" -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Generator, List, Optional from contextlib import contextmanager import threading @@ -185,7 +185,9 @@ def ensure_global_config(self, provider: Optional[LLMProvider] = None) -> None: self._configured = True @contextmanager - def use_task_local(self, provider: Optional[LLMProvider] = None): + def use_task_local( + self, provider: Optional[LLMProvider] = None + ) -> Generator[None, None, None]: """Use a task/thread-local DSPy LM without reconfiguring globally.""" lm = self.get_dspy_client(provider) with dspy.context(lm=lm): diff --git a/src/llm_orchestrator_config/llm_ochestrator_constants.py b/src/llm_orchestrator_config/llm_ochestrator_constants.py index 61af6963..789ef62a 100644 --- a/src/llm_orchestrator_config/llm_ochestrator_constants.py +++ b/src/llm_orchestrator_config/llm_ochestrator_constants.py @@ -23,6 +23,12 @@ "en": "I apologize, but I'm unable to provide a response as it may violate our usage policies.", } +# Query validation messages - single generic message for all rejection types +# (empty queries, special characters only, too short, repetitive characters) +QUERY_VALIDATION_FAILED_MESSAGES = { + "et": "Palun esitage kehtiv küsimus või sõnum, et ma saaksin teid aidata." +} + # Legacy constants for backward compatibility (English defaults) OUT_OF_SCOPE_MESSAGE = OUT_OF_SCOPE_MESSAGES["en"] TECHNICAL_ISSUE_MESSAGE = TECHNICAL_ISSUE_MESSAGES["en"] @@ -106,9 +112,9 @@ # Helper function to get localized messages -def get_localized_message(message_dict: dict, language_code: str = "en") -> str: +def get_localized_message(message_dict: dict, language_code: str = "et") -> str: """ - Get message in the specified language, fallback to English. + Get message in the specified language, fallback to Estonian. Args: message_dict: Dictionary with language codes as keys @@ -117,10 +123,16 @@ def get_localized_message(message_dict: dict, language_code: str = "en") -> str: Returns: Localized message string """ - return message_dict.get(language_code, message_dict.get("en", "")) + return message_dict.get(language_code, message_dict.get("et", "")) # Service endpoints RAG_SEARCH_RESQL = "http://resql:8082/rag-search" RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" RAG_SEARCH_RUUTER_PRIVATE = "http://ruuter-private:8088/rag-search" + +# Custom Prompt Configuration +RUUTER_PROMPT_CONFIG_ENDPOINT = ( + "http://ruuter-public:8086/rag-search/llm-connections/prompts/get-prompt" +) +PROMPT_CONFIG_CACHE_TTL = 300 # 5 minutes cache diff --git a/src/llm_orchestrator_config/providers/base.py b/src/llm_orchestrator_config/providers/base.py index 03b29512..521d9027 100644 --- a/src/llm_orchestrator_config/providers/base.py +++ b/src/llm_orchestrator_config/providers/base.py @@ -66,13 +66,11 @@ def validate_config(self) -> None: InvalidConfigurationError: If configuration is invalid. """ required_fields = self.get_required_config_fields() - missing_fields: List[str] = [] - - for field in required_fields: - if ( - field not in self.config or not self.config[field] - ): # Check for missing or empty strings/None - missing_fields.append(field) + missing_fields = [ + field + for field in required_fields + if field not in self.config or not self.config[field] + ] if missing_fields: raise InvalidConfigurationError( diff --git a/src/llm_orchestrator_config/stream_config.py b/src/llm_orchestrator_config/stream_config.py index ad193387..84e5edd5 100644 --- a/src/llm_orchestrator_config/stream_config.py +++ b/src/llm_orchestrator_config/stream_config.py @@ -21,8 +21,7 @@ class StreamConfig: # Rate Limiting Configuration RATE_LIMIT_ENABLED: bool = True # Enable/disable rate limiting - RATE_LIMIT_REQUESTS_PER_MINUTE: int = 10 # Max requests per user per minute - RATE_LIMIT_TOKENS_PER_SECOND: int = ( - 100 # Max tokens per user per second (burst control) - ) + RATE_LIMIT_REQUESTS_PER_MINUTE: int = 20 # Max requests per user per minute + RATE_LIMIT_TOKENS_PER_MINUTE: int = 40_000 # Max tokens per user per minute RATE_LIMIT_CLEANUP_INTERVAL: int = 300 # Cleanup old entries every 5 minutes + RATE_LIMIT_TOKEN_WINDOW_SECONDS: int = 60 # Sliding window size for token tracking diff --git a/src/llm_orchestrator_config/vault/secret_resolver.py b/src/llm_orchestrator_config/vault/secret_resolver.py index 4f506d5d..5674c95a 100644 --- a/src/llm_orchestrator_config/vault/secret_resolver.py +++ b/src/llm_orchestrator_config/vault/secret_resolver.py @@ -34,7 +34,7 @@ def __init__( vault_client: Optional[VaultAgentClient] = None, cache_ttl_minutes: int = 5, background_refresh: bool = True, - ): + ) -> None: """Initialize Secret Resolver. Args: @@ -289,7 +289,7 @@ def _get_fallback( def _schedule_background_refresh(self, vault_path: str) -> None: """Schedule background refresh of an expired secret.""" - def refresh_task(): + def refresh_task() -> None: logger.debug(f"Background refresh for {vault_path}") self.refresh_secret(vault_path) diff --git a/src/llm_orchestrator_config/vault/vault_client.py b/src/llm_orchestrator_config/vault/vault_client.py index b0c3a3d6..241f019e 100644 --- a/src/llm_orchestrator_config/vault/vault_client.py +++ b/src/llm_orchestrator_config/vault/vault_client.py @@ -142,10 +142,7 @@ def is_authenticated(self) -> bool: try: # If using proxy mode, skip token checks if not self.use_token_file: - logger.debug( - "Using vault agent proxy - skipping token authentication check" - ) - # Just verify vault is accessible + # Just verify vault is accessible (no token needed with proxy) return self.is_vault_available() # Check token is available @@ -182,27 +179,10 @@ def is_vault_available(self) -> bool: """ try: response = self.client.sys.read_health_status() - logger.debug(f"Vault health response type: {type(response)}") - logger.debug(f"Vault health response: {response}") # For Vault health endpoint, we primarily check the HTTP status code if hasattr(response, "status_code"): - is_available = response.status_code == 200 - logger.debug( - f"Vault health check: status_code={response.status_code}, available={is_available}" - ) - - # Try to get additional details from response body if available - try: - if hasattr(response, "json") and callable(response.json): - health_data = response.json() - logger.debug(f"Vault health details: {health_data}") - except Exception as e: - logger.debug( - f"Could not parse health response body (this is normal): {e}" - ) - - return is_available + return response.status_code == 200 else: # Fallback for non-Response objects (direct dict) if isinstance(response, dict): @@ -291,7 +271,6 @@ def list_secrets(self, path: str) -> Optional[list[str]]: path=path, mount_point=self.mount_point, ) - logger.debug(f"List secrets response: {response}") if response and "data" in response: keys = response["data"].get("keys", []) diff --git a/src/main.py b/src/main.py index 599a6db8..05bbc266 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,6 @@ -def main(): - "" - print("Hello from rag-module!") +def main() -> None: + """Main entry point for the rag-module.""" + pass if __name__ == "__main__": diff --git a/src/models/request_models.py b/src/models/request_models.py index f4a073c5..fd7ab795 100644 --- a/src/models/request_models.py +++ b/src/models/request_models.py @@ -66,19 +66,20 @@ class OrchestrationRequest(BaseModel): def validate_and_sanitize_message(cls, v: str) -> str: """Sanitize and validate user message. - Note: Content safety checks (prompt injection, PII, harmful content) + Note: This validator only handles security/format concerns: + - XSS/HTML sanitization + - Maximum length enforcement + + Query quality validation (empty messages, special chars, etc.) is handled + by the business logic layer (query_validator) with localized error messages. + + Content safety checks (prompt injection, PII, harmful content) are handled by NeMo Guardrails after this validation layer. """ # Sanitize HTML/XSS and normalize whitespace v = InputSanitizer.sanitize_message(v) - # Check if message is empty after sanitization - if not v or len(v.strip()) < 3: - raise ValueError( - "Message must contain at least 3 characters after sanitization" - ) - - # Check length after sanitization + # Check length after sanitization (resource protection) if len(v) > StreamConfig.MAX_MESSAGE_LENGTH: raise ValueError( f"Message exceeds maximum length of {StreamConfig.MAX_MESSAGE_LENGTH} characters" @@ -95,14 +96,14 @@ def validate_conversation_history( from loguru import logger # Limit number of conversation history items - MAX_HISTORY_ITEMS = 100 + max_history_items = 100 - if len(v) > MAX_HISTORY_ITEMS: + if len(v) > max_history_items: logger.warning( - f"Conversation history truncated: {len(v)} -> {MAX_HISTORY_ITEMS} items" + f"Conversation history truncated: {len(v)} -> {max_history_items} items" ) # Truncate to most recent items - v = v[-MAX_HISTORY_ITEMS:] + v = v[-max_history_items:] return v @@ -137,6 +138,16 @@ class DocumentReference(BaseModel): relevance_score: float = Field(..., description="Relevance score (0-1)") +class ChoiceButton(BaseModel): + """A single MCQ choice button returned in an orchestration response.""" + + title: str = Field(..., description="Button label shown to the user") + payload: str = Field( + ..., + description="Routing string sent when the button is clicked (e.g. '#service, /POST/...')", + ) + + class OrchestrationResponse(BaseModel): """Model for LLM orchestration response.""" @@ -149,6 +160,10 @@ class OrchestrationResponse(BaseModel): ..., description="Whether input guard validation failed" ) content: str = Field(..., description="Response content with citations") + buttons: Optional[List[ChoiceButton]] = Field( + default=None, + description="Optional list of choice buttons for MCQ step responses", + ) # New models for embedding and context generation @@ -206,6 +221,12 @@ class ContextGenerationRequest(BaseModel): temperature: float = Field( default=0.1, description="Temperature for response generation", ge=0.0, le=2.0 ) + context_type: Literal["chunk", "api_tool"] = Field( + default="chunk", + description="Controls which prompt template is used. 'chunk' uses the Anthropic " + "RAG template (short succinct context). 'api_tool' passes the prompt through " + "unmodified so the caller's own instructions are respected.", + ) class ContextGenerationResponse(BaseModel): @@ -260,6 +281,10 @@ class TestOrchestrationResponse(BaseModel): ..., description="Whether input guard validation failed" ) content: str = Field(..., description="Response content with citations") + buttons: Optional[List[ChoiceButton]] = Field( + default=None, + description="Optional list of choice buttons for MCQ step responses", + ) chunks: Optional[List[ChunkInfo]] = Field( default=None, description="Retrieved chunks with rank and content" ) diff --git a/src/models/session_models.py b/src/models/session_models.py new file mode 100644 index 00000000..d7950860 --- /dev/null +++ b/src/models/session_models.py @@ -0,0 +1,59 @@ +"""Pydantic models for API tool session state.""" + +from typing import Any +from pydantic import BaseModel, Field + + +class APIToolSession(BaseModel): + """Persisted session state for the API Tool Calling agentic loop. + + Keyed by chat_id in Redis with a sliding 30-minute TTL. + """ + + chat_id: str = Field(..., description="Unique conversation identifier") + state: str = Field( + ..., + description="Current state of the agentic loop (e.g. 'collecting_params', 'ready', 'completed')", + ) + selected_endpoint: dict[str, Any] | None = Field( + default=None, + description="The API endpoint selected for this conversation", + ) + collected_params: dict[str, Any] = Field( + default_factory=dict, + description="Parameters collected from the user so far", + ) + turn_count: int = Field( + default=0, + ge=0, + description="Number of turns elapsed in the agentic loop", + ) + max_turns: int = Field( + default=5, + ge=1, + description="Maximum turns allowed before the session is abandoned", + ) + awaiting_continuation: bool = Field( + default=False, + description=( + "True when the loop has reached the continuation threshold and is waiting " + "for the user to decide whether to continue collecting parameters or exit " + "to the RAG workflow." + ), + ) + detected_language: str = Field( + default="en", + description=( + "Language detected from the user's first message ('en', 'et', 'ru'). " + "Persisted so all subsequent clarifying questions use the same language, " + "even when follow-up messages are too short to reliably re-detect." + ), + ) + original_query: str = Field( + default="", + description=( + "The user's first message that triggered this session. " + "Preserved across turns so the response formatter always receives the " + "full original intent, not just the last short follow-up message." + ), + ) diff --git a/src/optimization/metrics/generator_metrics.py b/src/optimization/metrics/generator_metrics.py index becf64a0..acb0a89f 100644 --- a/src/optimization/metrics/generator_metrics.py +++ b/src/optimization/metrics/generator_metrics.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List import dspy +from dspy.evaluate import SemanticF1 from loguru import logger @@ -21,7 +22,7 @@ class GeneratorMetric: IMPORTANT: DSPy's SemanticF1 expects 'response' fields, not 'answer' fields. """ - def __init__(self, scope_weight: float = 0.5, quality_weight: float = 0.5): + def __init__(self, scope_weight: float = 0.5, quality_weight: float = 0.5) -> None: """ Initialize metric with custom weights. @@ -34,12 +35,15 @@ def __init__(self, scope_weight: float = 0.5, quality_weight: float = 0.5): # Initialize DSPy's native SemanticF1 with decompositional mode # This uses the configured LM to evaluate semantic similarity - self.semantic_f1 = dspy.evaluate.SemanticF1(decompositional=True) + self.semantic_f1 = SemanticF1(decompositional=True) logger.info("Initialized GeneratorMetric with DSPy's native SemanticF1") def __call__( - self, example: dspy.Example, prediction: dspy.Prediction, trace=None + self, + example: dspy.Example, + prediction: dspy.Prediction, + trace: Any | None = None, ) -> float: """ Evaluate generator prediction with combined metric. @@ -97,6 +101,11 @@ def __call__( quality_score = self.semantic_f1(semantic_example, semantic_prediction) + # Ensure quality_score is a float (SemanticF1 returns float) + quality_score = ( + float(quality_score) if quality_score is not None else 0.0 + ) + logger.debug(f"SemanticF1 quality score: {quality_score:.3f}") except Exception as e: @@ -207,7 +216,7 @@ def calculate_generator_stats( metric = GeneratorMetric() # Evaluate each example - for example, prediction in zip(examples, predictions): + for example, prediction in zip(examples, predictions, strict=True): expected_in_scope = example.should_be_in_scope predicted_out_of_scope = getattr(prediction, "questionOutOfLLMScope", None) diff --git a/src/optimization/metrics/guardrails_metrics.py b/src/optimization/metrics/guardrails_metrics.py index 157bb12b..97d4f523 100644 --- a/src/optimization/metrics/guardrails_metrics.py +++ b/src/optimization/metrics/guardrails_metrics.py @@ -23,7 +23,7 @@ class GuardrailsMetric: - Aligns with guardrails' primary purpose: safety """ - def __init__(self, weight_fn: float = 0.0, weight_fp: float = 0.3): + def __init__(self, weight_fn: float = 0.0, weight_fp: float = 0.3) -> None: """ Initialize metric with custom weights. @@ -35,7 +35,10 @@ def __init__(self, weight_fn: float = 0.0, weight_fp: float = 0.3): self.weight_fp = weight_fp def __call__( - self, example: dspy.Example, prediction: dspy.Prediction, trace=None + self, + example: dspy.Example, + prediction: dspy.Prediction, + trace: Any | None = None, ) -> float: """ Evaluate guardrail prediction with safety weighting. @@ -78,7 +81,7 @@ def __call__( def safety_weighted_accuracy( - example: dspy.Example, prediction: dspy.Prediction, trace=None + example: dspy.Example, prediction: dspy.Prediction, trace: Any | None = None ) -> float: """ Convenience function for default safety-weighted accuracy. @@ -105,7 +108,7 @@ def calculate_guardrails_stats( stats = _initialize_stats() - for example, prediction in zip(examples, predictions): + for example, prediction in zip(examples, predictions, strict=True): _update_stats_for_prediction(stats, example, prediction) return _calculate_final_metrics(stats) diff --git a/src/optimization/metrics/refiner_metrics.py b/src/optimization/metrics/refiner_metrics.py index 06b5cf4c..8550d3a6 100644 --- a/src/optimization/metrics/refiner_metrics.py +++ b/src/optimization/metrics/refiner_metrics.py @@ -54,7 +54,7 @@ class RefinerMetric: This is Option B from the recommendations - full LLM judge with reasoning. """ - def __init__(self): + def __init__(self) -> None: """ Initialize the LLM judge metric. @@ -68,7 +68,10 @@ def __init__(self): ) def __call__( - self, example: dspy.Example, prediction: dspy.Prediction, trace=None + self, + example: dspy.Example, + prediction: dspy.Prediction, + trace: Any | None = None, ) -> float: """ Evaluate refinement quality using LLM judge. @@ -197,12 +200,15 @@ class FastRefinerMetric: Trade-off: faster but potentially less accurate. """ - def __init__(self): + def __init__(self) -> None: self.judge = dspy.Predict(SimpleLLMJudge) logger.info("Initialized FastRefinerMetric with simple LLM judge") def __call__( - self, example: dspy.Example, prediction: dspy.Prediction, trace=None + self, + example: dspy.Example, + prediction: dspy.Prediction, + trace: Any | None = None, ) -> float: """Evaluate using fast LLM judge.""" try: @@ -269,7 +275,7 @@ def calculate_refiner_stats( scores = [] refinement_counts = [] - for example, prediction in zip(examples, predictions): + for example, prediction in zip(examples, predictions, strict=True): score = metric(example, prediction) scores.append(score) diff --git a/src/optimization/optimization_scripts/check_paths.py b/src/optimization/optimization_scripts/check_paths.py index 93ff3995..ff05e211 100644 --- a/src/optimization/optimization_scripts/check_paths.py +++ b/src/optimization/optimization_scripts/check_paths.py @@ -7,7 +7,7 @@ from loguru import logger -def get_directory_structure(): +def get_directory_structure() -> tuple[Path, Path]: """Get the directory structure based on script location.""" script_path = Path(__file__).resolve() logger.info(f"This script: {script_path}") @@ -24,7 +24,7 @@ def get_directory_structure(): return optimization_dir, src_dir -def check_key_paths(optimization_dir: Path, src_dir: Path): +def check_key_paths(optimization_dir: Path, src_dir: Path) -> bool: """Check if key paths exist and return overall status.""" paths_to_check: Dict[str, Path] = { "optimized_modules": optimization_dir / "optimized_modules", @@ -46,7 +46,7 @@ def check_key_paths(optimization_dir: Path, src_dir: Path): return all_good -def check_component_files(component_dir: Path, component: str): +def check_component_files(component_dir: Path, component: str) -> None: """Check files for a specific component.""" json_files = list(component_dir.glob("*.json")) module_files = [f for f in json_files if not f.stem.endswith("_results")] @@ -66,7 +66,7 @@ def check_component_files(component_dir: Path, component: str): logger.info(f" Config: {cfg.name}") -def check_optimized_modules(optimization_dir: Path): +def check_optimized_modules(optimization_dir: Path) -> None: """Check optimized module files for all components.""" logger.info("Optimized module files:") for component in ["guardrails", "refiner", "generator"]: @@ -77,7 +77,7 @@ def check_optimized_modules(optimization_dir: Path): logger.warning(f" {component}: Directory not found!") -def main(): +def main() -> None: """Check all paths.""" logger.info("PATH DIAGNOSTIC") diff --git a/src/optimization/optimization_scripts/diagnose_guardrails_loader.py b/src/optimization/optimization_scripts/diagnose_guardrails_loader.py index eac8fd18..28909caf 100644 --- a/src/optimization/optimization_scripts/diagnose_guardrails_loader.py +++ b/src/optimization/optimization_scripts/diagnose_guardrails_loader.py @@ -11,7 +11,7 @@ from src.guardrails.optimized_guardrails_loader import OptimizedGuardrailsLoader -def main(): +def main() -> None: """Run diagnostics.""" logger.info("GUARDRAILS LOADER DIAGNOSTICS") diff --git a/src/optimization/optimization_scripts/extract_guardrails_prompts.py b/src/optimization/optimization_scripts/extract_guardrails_prompts.py index d417e842..8c2654ae 100644 --- a/src/optimization/optimization_scripts/extract_guardrails_prompts.py +++ b/src/optimization/optimization_scripts/extract_guardrails_prompts.py @@ -461,7 +461,7 @@ def generate_optimized_nemo_config( return False -def main(): +def main() -> None: """Main execution.""" logger.info("NEMO GUARDRAILS PROMPT EXTRACTION") logger.info("Extracting optimized prompts from DSPy module to NeMo YAML config") diff --git a/src/optimization/optimization_scripts/inspect_guardrails_optimization.py b/src/optimization/optimization_scripts/inspect_guardrails_optimization.py index 474eb257..f9632da7 100644 --- a/src/optimization/optimization_scripts/inspect_guardrails_optimization.py +++ b/src/optimization/optimization_scripts/inspect_guardrails_optimization.py @@ -7,7 +7,7 @@ from loguru import logger -def main(): +def main() -> None: """Inspect the optimized guardrails module.""" logger.info("INSPECTING OPTIMIZED GUARDRAILS") diff --git a/src/optimization/optimization_scripts/run_all_optimizations.py b/src/optimization/optimization_scripts/run_all_optimizations.py index 40017560..275148f1 100644 --- a/src/optimization/optimization_scripts/run_all_optimizations.py +++ b/src/optimization/optimization_scripts/run_all_optimizations.py @@ -74,7 +74,7 @@ def initialize_llm_manager( def optimize_guardrails_component( - lm: Any, base_save_dir: Path, timestamp: str + lm: dspy.LM, base_save_dir: Path, timestamp: str ) -> Dict[str, Any]: """Run guardrails optimization.""" logger.info("GUARDRAILS OPTIMIZATION") @@ -120,7 +120,7 @@ def optimize_guardrails_component( def optimize_refiner_component( - lm: Any, base_save_dir: Path, timestamp: str + lm: dspy.LM, base_save_dir: Path, timestamp: str ) -> Dict[str, Any]: """Run refiner optimization.""" logger.info("REFINER OPTIMIZATION") @@ -161,7 +161,7 @@ def optimize_refiner_component( def optimize_generator_component( - lm: Any, base_save_dir: Path, timestamp: str + lm: dspy.LM, base_save_dir: Path, timestamp: str ) -> Dict[str, Any]: """Run generator optimization.""" logger.info("GENERATOR OPTIMIZATION") diff --git a/src/optimization/optimization_scripts/split_datasets.py b/src/optimization/optimization_scripts/split_datasets.py index ec1799f6..3316dffa 100644 --- a/src/optimization/optimization_scripts/split_datasets.py +++ b/src/optimization/optimization_scripts/split_datasets.py @@ -180,7 +180,7 @@ def split_generator_dataset( ) -def main(): +def main() -> None: """Main execution function.""" logger.info("Starting DSPy dataset splitting process") diff --git a/src/optimization/optimized_module_loader.py b/src/optimization/optimized_module_loader.py index 2d1cf361..bf3b8a83 100644 --- a/src/optimization/optimized_module_loader.py +++ b/src/optimization/optimized_module_loader.py @@ -24,7 +24,7 @@ class OptimizedModuleLoader: - Module-level caching for performance (singleton pattern) """ - def __init__(self, optimized_modules_dir: Optional[Path] = None): + def __init__(self, optimized_modules_dir: Optional[Path] = None) -> None: """ Initialize the module loader. @@ -284,7 +284,7 @@ def _create_empty_metadata( return metadata @staticmethod - def _get_guardrails_signature(): + def _get_guardrails_signature() -> type[dspy.Signature]: """Get guardrails signature class.""" class GuardrailsChecker(dspy.Signature): @@ -311,7 +311,7 @@ class GuardrailsChecker(dspy.Signature): return GuardrailsChecker @staticmethod - def _get_refiner_signature(): + def _get_refiner_signature() -> type[dspy.Signature]: """Get refiner signature class.""" class PromptRefinerSignature(dspy.Signature): @@ -337,7 +337,7 @@ class PromptRefinerSignature(dspy.Signature): return PromptRefinerSignature @staticmethod - def _get_generator_signature(): + def _get_generator_signature() -> type[dspy.Signature]: """Get generator signature class.""" class ResponseGeneratorSignature(dspy.Signature): diff --git a/src/prompt_refine_manager/prompt_refiner.py b/src/prompt_refine_manager/prompt_refiner.py index b24c275f..5cbe30ec 100644 --- a/src/prompt_refine_manager/prompt_refiner.py +++ b/src/prompt_refine_manager/prompt_refiner.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, Sequence, Optional, Dict, Union, cast, List +import contextlib import logging import dspy from pydantic import BaseModel, Field @@ -215,7 +216,7 @@ def get_module_info(self) -> Dict[str, Any]: """ return self._optimized_metadata.copy() - def _get_adapter_context(self): + def _get_adapter_context(self) -> contextlib.AbstractContextManager[Any]: """Return appropriate adapter context manager.""" if self._use_json_adapter: return dspy.context(adapter=dspy.JSONAdapter()) @@ -223,7 +224,7 @@ def _get_adapter_context(self): def forward( self, - history: Sequence[Dict[str, str]] | Any, + history: Sequence[Dict[str, str]], question: str, n: int | None = None, ) -> list[str]: @@ -296,7 +297,7 @@ def forward( def forward_structured( self, - history: Sequence[Dict[str, str]] | Any, + history: Sequence[Dict[str, str]], question: str, n: int | None = None, ) -> Dict[str, Any]: diff --git a/src/response_generator/response_generate.py b/src/response_generator/response_generate.py index 23aa7442..3dffbfb5 100644 --- a/src/response_generator/response_generate.py +++ b/src/response_generator/response_generate.py @@ -67,7 +67,7 @@ class ScopeChecker(dspy.Signature): def build_context_and_citations( - chunks: List[Dict[str, Any]], use_top_k: int = None + chunks: List[Dict[str, Any]], use_top_k: Optional[int] = None ) -> Tuple[List[str], List[str], bool]: """ Turn retriever chunks -> numbered context blocks and source labels. @@ -124,9 +124,15 @@ class ResponseGeneratorAgent(dspy.Module): Returns a dict: {"answer": str, "questionOutOfLLMScope": bool, "usage": dict} """ - def __init__(self, max_retries: int = 2, use_optimized: bool = True) -> None: + def __init__( + self, + max_retries: int = 2, + use_optimized: bool = True, + custom_instructions_prefix: str = "", + ) -> None: super().__init__() self._max_retries = max(0, int(max_retries)) + self._custom_instructions_prefix = custom_instructions_prefix # Attribute to cache the streamified predictor self._stream_predictor: Optional[Any] = None @@ -238,6 +244,14 @@ async def stream_response( f"Starting NATIVE DSPy streaming for question with {len(chunks)} chunks" ) + # Apply custom instructions while keeping the user question first, if provided + augmented_question = question + if self._custom_instructions_prefix: + augmented_question = f"{question}\n\n{self._custom_instructions_prefix}" + logger.debug( + f"Applied custom instructions after question for streaming ({len(self._custom_instructions_prefix)} chars)" + ) + output_stream = None try: # Build context @@ -254,10 +268,10 @@ async def stream_response( # Get the streamified predictor stream_predictor = self._get_stream_predictor() - # Call the streamified predictor + # Call the streamified predictor with augmented question logger.info("Calling streamified predictor with signature inputs...") output_stream = stream_predictor( - question=question, + question=augmented_question, context_blocks=context_blocks, citations=citation_labels, ) @@ -391,6 +405,14 @@ def forward( logger.info(f"Generating response for question: '{question}'") + # Apply custom instructions while keeping the user question first, if provided + augmented_question = question + if self._custom_instructions_prefix: + augmented_question = f"{question}\n\n{self._custom_instructions_prefix}" + logger.debug( + f"Applied custom instructions after question ({len(self._custom_instructions_prefix)} chars)" + ) + lm = dspy.settings.lm history_length_before = len(lm.history) if lm and hasattr(lm, "history") else 0 @@ -398,7 +420,7 @@ def forward( chunks, use_top_k=max_blocks ) - pred = self._predict_once(question, context_blocks, citation_labels) + pred = self._predict_once(augmented_question, context_blocks, citation_labels) valid = self._validate_prediction(pred) attempts = 0 @@ -407,7 +429,7 @@ def forward( logger.warning(f"Retry attempt {attempts}/{self._max_retries}") pred = self._predictor( - question=question, + question=augmented_question, context_blocks=context_blocks, citations=citation_labels, config={"rollout_id": attempts, "temperature": 0.1}, diff --git a/src/tool_classifier/__init__.py b/src/tool_classifier/__init__.py new file mode 100644 index 00000000..62f24ef6 --- /dev/null +++ b/src/tool_classifier/__init__.py @@ -0,0 +1,33 @@ +""" +Tool Classifier Module - Multi-workflow routing system. + +This module implements a layer-wise workflow routing system that determines +whether a user query should be handled by: +- Layer 1: Service Workflow (external API calls) +- Layer 2: Context Workflow (conversation history/greetings) +- Layer 3: RAG Workflow (knowledge base retrieval) +- Layer 4: OOD Workflow (out-of-domain fallback) +""" + +from tool_classifier.agentic_loop import AgenticLoop +from tool_classifier.api_caller import APICaller +from tool_classifier.api_response_formatter import APIResponseFormatterModule +from tool_classifier.classifier import ToolClassifier +from tool_classifier.enums import AgenticLoopStatus, WorkflowType +from tool_classifier.models import ( + AgenticLoopResult, + APICallResult, + ClassificationResult, +) + +__all__ = [ + "AgenticLoop", + "AgenticLoopResult", + "AgenticLoopStatus", + "APICaller", + "APICallResult", + "APIResponseFormatterModule", + "ClassificationResult", + "ToolClassifier", + "WorkflowType", +] diff --git a/src/tool_classifier/agentic_loop.py b/src/tool_classifier/agentic_loop.py new file mode 100644 index 00000000..733ba6f4 --- /dev/null +++ b/src/tool_classifier/agentic_loop.py @@ -0,0 +1,539 @@ +"""Standalone agentic loop for multi-turn parameter collection.""" + +import asyncio +from typing import Any, Dict, List + +from loguru import logger + +from src.utils.api_tool_session_store import APIToolSessionStore +from tool_classifier.constants import ( + CONTINUATION_QUESTION, + CONTINUATION_QUESTION_ET, + CONTINUATION_QUESTION_RU, + CONTINUATION_TURN, +) +from tool_classifier.enums import AgenticLoopStatus +from tool_classifier.models import AgenticLoopResult +from tool_classifier.param_extractor import ParamExtractionModule + +_CONTINUATION_QUESTIONS: dict[str, str] = { + "en": CONTINUATION_QUESTION, + "et": CONTINUATION_QUESTION_ET, + "ru": CONTINUATION_QUESTION_RU, +} + + +_YES_RESPONSES = frozenset( + { + "yes", + "y", + "jah", + "ja", + "да", + "ok", + "okay", + "sure", + "please", + "continue", + "jätka", + "продолжить", + "absolutely", + } +) + + +class AgenticLoop: + """Stateless multi-turn parameter collection loop. + + Each call to run_turn() represents one user message / one loop iteration. + The loop carries no internal state — all state is passed in as arguments. + Redis persistence (load from session before calling, save inside run_turn) + is handled here so callers only need to act on the returned AgenticLoopResult. + + Typical usage:: + + loop = AgenticLoop( + session_store=app.state.session_store, + param_extractor=ParamExtractionModule(), + ) + + result = await loop.run_turn( + chat_id=request.chatId, + user_message=request.message, + conversation_history=request.conversationHistory, + params_schema=endpoint["params_schema"], + collected_params=session.collected_params, + turn_count=session.turn_count, + max_turns=session.max_turns, + ) + + if result.status == AgenticLoopStatus.COMPLETED: + # All params ready — call the API, then delete session + ... + elif result.status == AgenticLoopStatus.NEEDS_INPUT: + # Session already saved inside run_turn — return question to user + ... + else: # MAX_TURNS_REACHED + # Delete session and fall back gracefully + ... + """ + + def __init__( + self, + session_store: APIToolSessionStore, + param_extractor: ParamExtractionModule, + ) -> None: + """Initialise the loop with an injected session store and param extractor. + + Args: + session_store: Redis-backed store used to persist loop state between + HTTP requests. Injected to allow easy mocking in tests. + param_extractor: DSPy module that extracts parameter values from a + user message. Injected to allow easy mocking in tests. + """ + self._session_store = session_store + self._param_extractor = param_extractor + + async def run_turn( + self, + chat_id: str, + user_message: str, + conversation_history: List[Dict[str, Any]], + params_schema: List[Dict[str, Any]], + collected_params: Dict[str, Any], + turn_count: int, + max_turns: int = 5, + awaiting_continuation: bool = False, + continuation_turn: int = CONTINUATION_TURN, + session_language: str = "en", + ) -> AgenticLoopResult: + """Process one user turn of the parameter-collection loop. + + Steps: + 0. Continuation decision — if ``awaiting_continuation`` is True, detect + whether the user said yes (keep going) or no (fall back to RAG). + A "no" or ambiguous response returns MAX_TURNS_REACHED immediately. + 1. Guard — return MAX_TURNS_REACHED if the turn limit is reached. + 2. Extract — call ParamExtractionModule for newly mentioned params. + 3. Merge — combine prior collected params with newly extracted ones. + Prior values are authoritative (not overwritten by this turn). + 4. Completeness check — if all required params are present, save state + and return COMPLETED. + 5. Incomplete — if this is exactly the ``continuation_turn``, save state + and return AWAITING_CONTINUATION_DECISION with a yes/no question. + Otherwise return NEEDS_INPUT with the clarifying question. + + The returned turn_count is always input turn_count + 1. + Session state is saved automatically on COMPLETED, NEEDS_INPUT, and + AWAITING_CONTINUATION_DECISION. + It is NOT saved on MAX_TURNS_REACHED. It is also generally not saved + on extraction errors, except when a continuation decision was consumed + and the cleared ``awaiting_continuation`` state must be persisted. The + caller is expected to delete the session on MAX_TURNS_REACHED and + extraction errors after handling the failure. + + Args: + chat_id: Unique conversation identifier, used as the Redis session key. + user_message: The user's latest message for this turn. + conversation_history: Recent conversation turns as a list of + ``{"authorRole": str, "message": str}`` dicts. + params_schema: Parameter schema defining what to collect. Each + entry is a dict with at minimum ``name``, ``type``, + ``required``, and ``description`` keys. + collected_params: Parameter values collected in prior turns. + These are treated as authoritative and will not be overwritten. + turn_count: The current turn index (0-based before this call). + max_turns: Maximum turns allowed before the loop is abandoned. + awaiting_continuation: True when the previous turn returned + AWAITING_CONTINUATION_DECISION and we are now processing the + user's yes/no reply. Load this from the persisted session. + continuation_turn: The 1-based turn count at which to ask the + continuation question when params are still missing. + Defaults to ``CONTINUATION_TURN`` (3). + + Returns: + AgenticLoopResult with updated status, collected_params, and + turn_count. + """ + updated_turn_count = turn_count + 1 + + # Step 0 — Continuation decision: user is responding to the yes/no prompt + original_awaiting_continuation = awaiting_continuation + if awaiting_continuation: + wants_to_continue = self._detect_continuation_response(user_message) + if wants_to_continue: + logger.debug( + "AgenticLoop: user chose to continue on turn {} for chat_id={}", + turn_count, + chat_id, + ) + # Reset the flag so normal extraction takes over from here. + awaiting_continuation = False + else: + logger.info( + "AgenticLoop: user chose to exit on turn {} for chat_id={}, " + "falling back to RAG", + turn_count, + chat_id, + ) + return AgenticLoopResult( + status=AgenticLoopStatus.MAX_TURNS_REACHED, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ) + + # Step 1 — Turn limit guard (no session save — caller deletes) + if turn_count >= max_turns: + logger.warning( + "AgenticLoop: max_turns={} reached for chat_id={}, abandoning", + max_turns, + chat_id, + ) + return AgenticLoopResult( + status=AgenticLoopStatus.MAX_TURNS_REACHED, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ) + + # Step 2 — Extract params from the current user message + try: + extraction = await asyncio.to_thread( + self._param_extractor, + user_message, + params_schema, + conversation_history, + collected_params, + session_language, + ) + except Exception as exc: + logger.error( + "AgenticLoop: param extraction failed on turn {} for chat_id={}: {}", + turn_count, + chat_id, + exc, + ) + # If a continuation decision was already consumed this turn, persist the + # updated flag so the next user message is not misread as another + # yes/no continuation response. + if awaiting_continuation != original_awaiting_continuation: + await self._save_session( + chat_id, + collected_params, + updated_turn_count, + awaiting_continuation=awaiting_continuation, + ) + return AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ) + + # Step 3 — Merge: newly extracted values override prior ones so the user + # can correct a value they provided in an earlier turn (e.g. "actually, + # make that Russia instead of Estonia"). Prior values are kept only for + # params the extractor did NOT mention in this turn. + merged_params: Dict[str, Any] = { + **collected_params, + **extraction["extracted_params"], + } + + # Step 4 — Completeness check + required_param_names = { + p["name"] + for p in params_schema + if isinstance(p, dict) and p.get("required", False) + } + all_collected = required_param_names.issubset(merged_params.keys()) + + if all_collected: + logger.debug( + "AgenticLoop: all required params collected on turn {} for chat_id={}", + turn_count, + chat_id, + ) + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=False + ) + return AgenticLoopResult( + status=AgenticLoopStatus.COMPLETED, + collected_params=merged_params, + clarifying_question="", + turn_count=updated_turn_count, + ) + + # Step 5 — Still missing params + logger.debug( + "AgenticLoop: turn {} for chat_id={} — still missing: {}", + turn_count, + chat_id, + extraction["missing_required"], + ) + + # At exactly the continuation threshold, ask whether to keep going. + if updated_turn_count == continuation_turn: + logger.info( + "AgenticLoop: continuation threshold reached on turn {} for chat_id={}", + turn_count, + chat_id, + ) + continuation_q = _CONTINUATION_QUESTIONS.get( + session_language, CONTINUATION_QUESTION + ) + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=True + ) + return AgenticLoopResult( + status=AgenticLoopStatus.AWAITING_CONTINUATION_DECISION, + collected_params=merged_params, + clarifying_question=continuation_q, + turn_count=updated_turn_count, + ) + + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=False + ) + return AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params=merged_params, + clarifying_question=extraction["clarifying_question"], + turn_count=updated_turn_count, + ) + + async def stream_run_turn( + self, + chat_id: str, + user_message: str, + conversation_history: List[Dict[str, Any]], + params_schema: List[Dict[str, Any]], + collected_params: Dict[str, Any], + turn_count: int, + max_turns: int = 5, + awaiting_continuation: bool = False, + continuation_turn: int = CONTINUATION_TURN, + session_language: str = "en", + ) -> tuple[AgenticLoopResult, List[str]]: + """Process one user turn like :meth:`run_turn` but stream clarifying_question tokens. + + Delegates extraction to + :meth:`~param_extractor.ParamExtractionModule.stream_forward` so + ``clarifying_question`` tokens are captured as they arrive from the LLM. + All session management (save/delete) is identical to :meth:`run_turn`. + + Returns: + Tuple of ``(AgenticLoopResult, question_tokens)``. + ``question_tokens`` is the list of streamed token strings for the + clarifying question, or an empty list when no question is needed. + """ + updated_turn_count = turn_count + 1 + + # Step 0 — Continuation decision + original_awaiting_continuation = awaiting_continuation + if awaiting_continuation: + wants_to_continue = self._detect_continuation_response(user_message) + if wants_to_continue: + logger.debug( + "AgenticLoop: user chose to continue on turn {} for chat_id={}", + turn_count, + chat_id, + ) + awaiting_continuation = False + else: + logger.info( + "AgenticLoop: user chose to exit on turn {} for chat_id={}, " + "falling back to RAG", + turn_count, + chat_id, + ) + return ( + AgenticLoopResult( + status=AgenticLoopStatus.MAX_TURNS_REACHED, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ), + [], + ) + + # Step 1 — Turn limit guard + if turn_count >= max_turns: + logger.warning( + "AgenticLoop: max_turns={} reached for chat_id={}, abandoning", + max_turns, + chat_id, + ) + return ( + AgenticLoopResult( + status=AgenticLoopStatus.MAX_TURNS_REACHED, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ), + [], + ) + + # Step 2 — Stream-extract params from the current user message + try: + question_tokens, extraction = await self._param_extractor.stream_forward( + user_message=user_message, + params_schema=params_schema, + conversation_history=conversation_history, + already_collected=collected_params, + session_language=session_language, + ) + except Exception as exc: + logger.error( + "AgenticLoop: stream param extraction failed on turn {} for chat_id={}: {}", + turn_count, + chat_id, + exc, + ) + if awaiting_continuation != original_awaiting_continuation: + await self._save_session( + chat_id, + collected_params, + updated_turn_count, + awaiting_continuation=awaiting_continuation, + ) + return ( + AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params=collected_params, + clarifying_question="", + turn_count=updated_turn_count, + ), + [], + ) + + # Step 3 — Merge + merged_params: Dict[str, Any] = { + **collected_params, + **extraction["extracted_params"], + } + + # Step 4 — Completeness check + required_param_names = { + p["name"] + for p in params_schema + if isinstance(p, dict) and p.get("required", False) + } + all_collected = required_param_names.issubset(merged_params.keys()) + + if all_collected: + logger.debug( + "AgenticLoop: all required params collected on turn {} for chat_id={}", + turn_count, + chat_id, + ) + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=False + ) + return ( + AgenticLoopResult( + status=AgenticLoopStatus.COMPLETED, + collected_params=merged_params, + clarifying_question="", + turn_count=updated_turn_count, + ), + [], + ) + + # Step 5 — Still missing params + logger.debug( + "AgenticLoop: turn {} for chat_id={} — still missing: {}", + turn_count, + chat_id, + extraction["missing_required"], + ) + + if updated_turn_count == continuation_turn: + logger.info( + "AgenticLoop: continuation threshold reached on turn {} for chat_id={}", + turn_count, + chat_id, + ) + continuation_q = _CONTINUATION_QUESTIONS.get( + session_language, CONTINUATION_QUESTION + ) + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=True + ) + words = continuation_q.split(" ") + continuation_tokens = [ + w + " " if i < len(words) - 1 else w for i, w in enumerate(words) + ] + return ( + AgenticLoopResult( + status=AgenticLoopStatus.AWAITING_CONTINUATION_DECISION, + collected_params=merged_params, + clarifying_question=continuation_q, + turn_count=updated_turn_count, + ), + continuation_tokens, + ) + + await self._save_session( + chat_id, merged_params, updated_turn_count, awaiting_continuation=False + ) + return ( + AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params=merged_params, + clarifying_question=extraction["clarifying_question"], + turn_count=updated_turn_count, + ), + question_tokens, + ) + + async def _save_session( + self, + chat_id: str, + collected_params: Dict[str, Any], + turn_count: int, + awaiting_continuation: bool = False, + ) -> None: + """Persist updated loop state to the Redis session store. + + Only updates the fields the loop owns (collected_params, turn_count, + awaiting_continuation). Workflow-owned fields (selected_endpoint, + state, max_turns) are preserved. + A missing or unavailable session is logged but never raises. + """ + try: + if self._session_store is None: + logger.debug( + "AgenticLoop: session store unavailable — skipping save for chat_id={}", + chat_id, + ) + return + await self._session_store.update( + chat_id, + collected_params=collected_params, + turn_count=turn_count, + awaiting_continuation=awaiting_continuation, + ) + except Exception as exc: + logger.error( + "AgenticLoop: failed to save session for chat_id={}: {}", + chat_id, + exc, + ) + + def _detect_continuation_response(self, user_message: str) -> bool: + """Detect whether the user's message indicates they want to continue. + + Checks the normalised (lower-cased, stripped) message against a set of + known affirmative responses in Estonian, English, and Russian. + Any response that is not clearly affirmative is treated as a "no" so + the loop falls back to the RAG workflow. + + Args: + user_message: The raw user message to inspect. + + Returns: + True if the user wants to continue, False otherwise. + """ + normalised = user_message.strip().lower() + return normalised in _YES_RESPONSES diff --git a/src/tool_classifier/api_caller.py b/src/tool_classifier/api_caller.py new file mode 100644 index 00000000..cbf38a4b --- /dev/null +++ b/src/tool_classifier/api_caller.py @@ -0,0 +1,326 @@ +"""API Caller module for executing external HTTP requests with circuit breaker protection.""" + +import json +import time +from dataclasses import dataclass +from typing import Any + +import httpx +from loguru import logger + +from llm_orchestrator_config.llm_ochestrator_constants import get_localized_message +from tool_classifier.constants import ( + API_CALL_TIMEOUT, + CB_STATE_CLOSED, + CB_STATE_HALF_OPEN, + CB_STATE_OPEN, + CIRCUIT_BREAKER_COOLDOWN_SECONDS, + CIRCUIT_BREAKER_FAILURE_THRESHOLD, + CIRCUIT_BREAKER_OPEN_MESSAGES, + CLIENT_ERROR_MESSAGES, + REDIRECT_NOT_FOLLOWED_MESSAGES, + SERVICE_TIMEOUT_MESSAGES, + SERVICE_UNAVAILABLE_MESSAGES, +) +from tool_classifier.models import APICallResult +from src.utils.error_utils import generate_error_id, log_error_with_context + + +@dataclass +class _BreakerState: + """Internal per-URL circuit breaker state.""" + + state: str = CB_STATE_CLOSED + failure_count: int = 0 + last_failure_time: float = 0.0 + probe_in_flight: bool = False + + +class CircuitBreaker: + """ + Per-URL circuit breaker that prevents repeated calls to a failing external API. + + State machine transitions: + CLOSED → OPEN: after ``failure_threshold`` consecutive server/network failures. + OPEN → HALF_OPEN: once ``cooldown_seconds`` have elapsed since the last failure. + HALF_OPEN → CLOSED: on the next successful probe call. + HALF_OPEN → OPEN: on the next failed probe call. + + Each URL maintains its own independent breaker so that one failing API does not + prevent calls to other URLs. + + 4xx (client error) responses do **not** count as failures — they indicate bad + input rather than a server outage and should trigger agentic loop re-prompting + instead of circuit protection. + """ + + def __init__( + self, + failure_threshold: int = CIRCUIT_BREAKER_FAILURE_THRESHOLD, + cooldown_seconds: float = CIRCUIT_BREAKER_COOLDOWN_SECONDS, + ) -> None: + self._failure_threshold = failure_threshold + self._cooldown_seconds = cooldown_seconds + self._breakers: dict[str, _BreakerState] = {} + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_state(self, url: str) -> _BreakerState: + """Return the breaker state for *url*, creating it on first access.""" + if url not in self._breakers: + self._breakers[url] = _BreakerState() + return self._breakers[url] + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def can_execute(self, url: str) -> bool: + """Return True if a request to *url* is currently allowed.""" + breaker = self._get_state(url) + if breaker.state == CB_STATE_CLOSED: + return True + if breaker.state == CB_STATE_OPEN: + if time.time() - breaker.last_failure_time >= self._cooldown_seconds: + breaker.state = CB_STATE_HALF_OPEN + breaker.probe_in_flight = True + logger.info(f"[CircuitBreaker] {url!r} → HALF_OPEN (probe allowed)") + return True + return False + # HALF_OPEN: allow exactly one probe request through; gate subsequent + # concurrent callers until record_success() / record_failure() resolves it. + if breaker.probe_in_flight: + return False + breaker.probe_in_flight = True + return True + + def record_success(self, url: str) -> None: + """Reset the breaker for *url* to CLOSED after a successful call.""" + breaker = self._get_state(url) + if breaker.state != CB_STATE_CLOSED: + logger.info( + f"[CircuitBreaker] {url!r} → CLOSED " + f"(recovered after {breaker.failure_count} failure(s))" + ) + breaker.state = CB_STATE_CLOSED + breaker.failure_count = 0 + breaker.probe_in_flight = False + + def record_failure(self, url: str) -> None: + """Record a server/network failure for *url*. + + Opens the circuit breaker if the failure threshold is reached. + """ + breaker = self._get_state(url) + breaker.failure_count += 1 + breaker.last_failure_time = time.time() + breaker.probe_in_flight = False + if breaker.failure_count >= self._failure_threshold: + if breaker.state != CB_STATE_OPEN: + logger.warning( + f"[CircuitBreaker] {url!r} → OPEN " + f"after {breaker.failure_count} failure(s)" + ) + breaker.state = CB_STATE_OPEN + + def get_state(self, url: str) -> str: + """Return the current circuit breaker state string for *url*.""" + return self._get_state(url).state + + +class APICaller: + """ + Executes external HTTP requests with collected parameters and circuit breaker protection. + + - GET requests map *params* to URL query parameters. + - POST requests map *params* to the JSON request body. + + A per-URL :class:`CircuitBreaker` prevents hammering a failing API: after + ``failure_threshold`` consecutive server-side or network errors the circuit + opens and all subsequent calls for that URL are rejected immediately until + ``cooldown_seconds`` have elapsed. + + 4xx responses do **not** count towards the failure threshold because they + typically indicate bad user input that should be corrected via the agentic + loop rather than a temporary server outage. + """ + + def __init__( + self, + timeout: int = API_CALL_TIMEOUT, + failure_threshold: int = CIRCUIT_BREAKER_FAILURE_THRESHOLD, + cooldown_seconds: float = CIRCUIT_BREAKER_COOLDOWN_SECONDS, + ) -> None: + self._default_timeout = timeout + self._circuit_breaker = CircuitBreaker( + failure_threshold=failure_threshold, + cooldown_seconds=cooldown_seconds, + ) + + async def call( + self, + url: str, + method: str, + params: dict[str, Any], + timeout: int | None = None, + language: str = "et", + ) -> APICallResult: + """Execute an HTTP request and return the structured result. + + Args: + url: Full URL of the external API endpoint. + method: HTTP method — must be ``"GET"`` or ``"POST"`` (case-insensitive). + params: Parameters to send. GET → query string; POST → JSON body. + timeout: Per-call timeout override in seconds. Defaults to the instance + default (``API_CALL_TIMEOUT``). + language: BCP-47 language code for user-facing error messages + (``"et"``, ``"ru"``, ``"en"``). Defaults to Estonian (``"et"``). + + Returns: + :class:`~tool_classifier.models.APICallResult` describing the outcome. + + Raises: + ValueError: If *method* is not ``"GET"`` or ``"POST"``. + """ + method_upper = method.upper() + if method_upper not in ("GET", "POST"): + raise ValueError( + f"Unsupported HTTP method: {method!r}. Only GET and POST are allowed." + ) + + if not self._circuit_breaker.can_execute(url): + logger.warning( + f"[APICaller] Circuit breaker OPEN for {url!r} — rejecting call" + ) + return APICallResult( + success=False, + status_code=0, + response_data="", + error=get_localized_message(CIRCUIT_BREAKER_OPEN_MESSAGES, language), + ) + + effective_timeout = timeout if timeout is not None else self._default_timeout + try: + async with httpx.AsyncClient( + timeout=effective_timeout, follow_redirects=True + ) as client: + if method_upper == "POST": + response = await client.post(url, json=params) + else: + response = await client.get(url, params=params) + return self._handle_response(response, url, language) + + except httpx.TimeoutException as exc: + error_id = generate_error_id() + log_error_with_context( + logger, + error_id, + "api_call_timeout", + None, + exc, + {"url": url, "method": method_upper}, + ) + self._circuit_breaker.record_failure(url) + return APICallResult( + success=False, + status_code=0, + response_data="", + error=get_localized_message(SERVICE_TIMEOUT_MESSAGES, language), + ) + + except httpx.RequestError as exc: + error_id = generate_error_id() + log_error_with_context( + logger, + error_id, + "api_call_network_error", + None, + exc, + {"url": url, "method": method_upper}, + ) + self._circuit_breaker.record_failure(url) + return APICallResult( + success=False, + status_code=0, + response_data="", + error=get_localized_message(SERVICE_TIMEOUT_MESSAGES, language), + ) + + def _handle_response( + self, + response: httpx.Response, + url: str, + language: str, + ) -> APICallResult: + """Parse an HTTP response into an :class:`~tool_classifier.models.APICallResult`.""" + status_code = response.status_code + + if 200 <= status_code < 300: + self._circuit_breaker.record_success(url) + return APICallResult( + success=True, + status_code=status_code, + response_data=self._parse_response_body(response), + error=None, + ) + + if 300 <= status_code < 400: + # Redirect not followed (e.g. redirect limit exceeded before this point). + # Not a server fault — do NOT trip the circuit breaker. + location = response.headers.get("location", "") + logger.warning( + f"[APICaller] Unresolved redirect {status_code} from {url!r} " + f"→ {location!r}" + ) + base_msg = get_localized_message(REDIRECT_NOT_FOLLOWED_MESSAGES, language) + error_msg = base_msg.format( + status_code=status_code, location=location or "unknown" + ) + return APICallResult( + success=False, + status_code=status_code, + response_data="", + error=error_msg, + ) + + if 400 <= status_code < 500: + # Client error — preserve the full raw body in response_data for + # potential future agentic loop re-prompting, but surface a localized + # friendly message to the user via the error field. + # 4xx does NOT trip the circuit breaker. + error_body = self._parse_response_body(response) + raw_msg = error_body if isinstance(error_body, str) else str(error_body) + logger.warning( + f"[APICaller] 4xx response {status_code} from {url!r}: {raw_msg[:200]}" + ) + return APICallResult( + success=False, + status_code=status_code, + response_data=error_body, + error=get_localized_message(CLIENT_ERROR_MESSAGES, language), + ) + + # 5xx — server is misbehaving; trip the circuit breaker. + error_id = generate_error_id() + logger.error( + f"[{error_id}] [APICaller] Server error {status_code} from {url!r}" + ) + self._circuit_breaker.record_failure(url) + return APICallResult( + success=False, + status_code=status_code, + response_data="", + error=get_localized_message(SERVICE_UNAVAILABLE_MESSAGES, language), + ) + + @staticmethod + def _parse_response_body( + response: httpx.Response, + ) -> dict[str, object] | list[object] | str: + """Attempt to parse the response body as JSON; fall back to raw text.""" + try: + return response.json() + except json.JSONDecodeError: + return response.text diff --git a/src/tool_classifier/api_response_formatter.py b/src/tool_classifier/api_response_formatter.py new file mode 100644 index 00000000..c67935fe --- /dev/null +++ b/src/tool_classifier/api_response_formatter.py @@ -0,0 +1,305 @@ +"""API response formatter using DSPy — converts raw JSON API responses to natural language.""" + +import json +from typing import Any, AsyncIterator, Dict, List, Union + +import dspy +import dspy.streaming +from dspy.streaming import StreamListener +from loguru import logger + +from llm_orchestrator_config.llm_ochestrator_constants import get_localized_message + +_MAX_ITEMS: int = 500 +_MAX_RESPONSE_BYTES: int = 50_000 + + +class APIResponseFormatterSignature(dspy.Signature): + """Convert a raw API JSON response into a natural-language answer for the user. + + CRITICAL LANGUAGE RULE: + - ALWAYS write the formatted_answer in the language specified by response_language. + - IGNORE the language of any text inside api_response — the data may contain names or + labels in a different language; the answer must still be in response_language. + - IGNORE the language of user_query for output language decisions — short follow-up + messages are unreliable indicators. Always use response_language. + + Rules: + - Format data in a readable way using bullet points, numbered lists, or natural prose. + Do NOT return raw JSON or wrap content in code blocks. + - If api_response is empty, null, or marked as [EMPTY RESPONSE], respond with a polite + message that no results were found for the query. + - If api_response contains an error field or error status, explain the issue to the user + in a friendly, non-technical way. + - If the data contains more than 20 items, summarize the key highlights rather than + listing every item. Always mention the total count when summarizing. + - Output must be clean text — no markdown headers (##), no code blocks (```), no raw + JSON. The answer must be ready for direct display to the user. + - Be concise but complete. Prioritize the most relevant information for the user's query. + + STRICT ENDING RULE — HIGHEST PRIORITY: + The formatted_answer MUST end immediately after the last data point. It is FORBIDDEN to + append any sentence that: + - offers to provide more details (e.g. "If you need statistics for a specific member...") + - invites the user to ask a follow-up question (e.g. "Let me know if...", "Feel free to ask...") + - mentions that a dataset is large or partial (e.g. "only a sample is shown here") + - suggests the user can specify a name, party, or other filter + The very last character of formatted_answer must be part of the actual data, not a helper offer. + """ + + user_query: str = dspy.InputField( + desc="The user's original question or request, in Estonian, Russian, or English" + ) + api_response: str = dspy.InputField( + desc=( + "The raw JSON response from the API, as a string. " + "May be empty, null, an error, or a large dataset." + ) + ) + endpoint_description: str = dspy.InputField( + desc=( + "A short description of what the API endpoint does " + "(e.g., 'Get public holidays for a country')" + ) + ) + response_language: str = dspy.InputField( + desc=( + "The language to write the answer in, detected from the user's first message: " + "'English', 'Estonian', or 'Russian'. " + "Always use this — do not infer language from api_response content." + ) + ) + + formatted_answer: str = dspy.OutputField( + desc=( + "A clean, natural-language answer derived from the api_response, " + "written entirely in the language specified by response_language. " + "No raw JSON, no code blocks, no markdown headers. " + "MUST end after the last data point. " + "FORBIDDEN: any closing sentence offering more help, inviting follow-up questions, " + "mentioning that the dataset is partial, or suggesting the user specify a name/party." + ) + ) + + +_LANGUAGE_NAMES: Dict[str, str] = {"en": "English", "et": "Estonian", "ru": "Russian"} + +_FORMATTER_ERROR_MESSAGES: Dict[str, str] = { + "et": "Vastuse kuvamine ebaõnnestus. Palun proovige uuesti.", + "ru": "Не удалось отобразить ответ. Пожалуйста, попробуйте ещё раз.", + "en": "I was unable to format the response. Please try again.", +} +"""Localized fallback shown when APIResponseFormatterModule.forward() raises an exception.""" + + +class APIResponseFormatterModule(dspy.Module): + """DSPy Module that converts raw API JSON responses into natural-language answers.""" + + def __init__(self) -> None: + """Initialize formatter with a direct DSPy Predict.""" + super().__init__() + self.formatter = dspy.Predict(APIResponseFormatterSignature) + + def forward( + self, + user_query: str, + api_response: Union[str, Dict[str, Any], List[Any]], + endpoint_description: str, + detected_language: str = "en", + ) -> str: + """Convert a raw API response to a natural-language answer. + + Args: + user_query: The user's original question. + api_response: The raw API response — a JSON string, dict, or list. + endpoint_description: A short description of what the endpoint does. + detected_language: ISO language code from the agentic loop session + ('en', 'et', 'ru'). Defaults to 'en'. This is the authoritative + language for the answer — the LLM will not infer it from the data. + + Returns: + A clean, natural-language answer ready for display to the user. + """ + try: + normalized = self._normalize_response(api_response) + normalized = self._annotate_empty(normalized) + normalized = self._truncate_if_needed(normalized) + response_language = _LANGUAGE_NAMES.get(detected_language, "English") + + result = self.formatter( + user_query=user_query, + api_response=normalized, + endpoint_description=endpoint_description, + response_language=response_language, + ) + return result.formatted_answer # type: ignore[no-any-return] + + except Exception as e: + logger.error( + f"APIResponseFormatterModule.forward failed: {e}", exc_info=True + ) + safe_language = ( + detected_language + if detected_language in _FORMATTER_ERROR_MESSAGES + else "en" + ) + return get_localized_message(_FORMATTER_ERROR_MESSAGES, safe_language) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _get_stream_predictor(self) -> Any: + """Return a fresh streamified predictor for each call. + + A new wrapper is created on every invocation because ``dspy.configure(lm=...)`` + is called per request (each ``LLMManager`` instantiation reconfigures the global + DSPy LM). A cached wrapper retains a stale reference to the old LM patch and + produces a bare ``dspy.Prediction`` instead of ``StreamResponse`` tokens on + subsequent calls. Re-creating the wrapper is cheap (no LLM I/O). + """ + logger.debug( + "APIResponseFormatterModule: creating fresh streamify wrapper " + "for formatted_answer field" + ) + listener = StreamListener(signature_field_name="formatted_answer") + return dspy.streamify(self.formatter, stream_listeners=[listener]) + + async def stream_forward( + self, + user_query: str, + api_response: Union[str, Dict[str, Any], List[Any]], + endpoint_description: str, + detected_language: str = "en", + ) -> AsyncIterator[str]: + """Stream formatted_answer tokens using DSPy native streaming. + Yields individual token strings as they arrive from the LLM. + + Fallback chain: + 1. DSPy ``StreamResponse`` tokens (true token-by-token streaming) + 2. Final ``dspy.Prediction.formatted_answer`` (if streamify yields no tokens) + 3. Blocking ``forward()`` call (if no Prediction was received) + 4. Localized error message on any exception. + + Args: + user_query: The user's original question. + api_response: Raw API response (dict, list, or string). + endpoint_description: Short description of what the endpoint does. + detected_language: ISO code ('en', 'et', 'ru'). Defaults to 'en'. + + Yields: + Token strings from the LLM ``formatted_answer`` field. + """ + safe_language = ( + detected_language + if detected_language in _FORMATTER_ERROR_MESSAGES + else "en" + ) + try: + normalized = self._normalize_response(api_response) + normalized = self._annotate_empty(normalized) + normalized = self._truncate_if_needed(normalized) + response_language = _LANGUAGE_NAMES.get(detected_language, "English") + + stream_predictor = self._get_stream_predictor() + output_stream = stream_predictor( + user_query=user_query, + api_response=normalized, + endpoint_description=endpoint_description, + response_language=response_language, + ) + + stream_started = False + token_count = 0 + async for chunk in output_stream: + if isinstance(chunk, dspy.streaming.StreamResponse): + if chunk.signature_field_name == "formatted_answer": + stream_started = True + token_count += 1 + yield chunk.chunk + elif isinstance(chunk, dspy.Prediction): + # dspy.streamify did not stream individual tokens — yield the + # full answer from the final Prediction as a single frame. + if not stream_started: + answer = getattr(chunk, "formatted_answer", None) + if answer: + logger.info( + "APIResponseFormatterModule.stream_forward: " + "no StreamResponse tokens — yielding full Prediction answer" + ) + stream_started = True + yield answer + + if stream_started and token_count > 0: + logger.debug( + f"APIResponseFormatterModule.stream_forward: streamed {token_count} tokens" + ) + + if not stream_started: + # Last-resort fallback: blocking forward() — covers cases where + # dspy.streamify yields neither StreamResponse nor Prediction. + logger.warning( + "APIResponseFormatterModule.stream_forward: " + "streamify produced no tokens and no Prediction — using blocking forward()" + ) + result = self.forward( + user_query=user_query, + api_response=api_response, + endpoint_description=endpoint_description, + detected_language=detected_language, + ) + yield result + + except Exception as e: + logger.error( + f"APIResponseFormatterModule.stream_forward failed: {e}", exc_info=True + ) + yield get_localized_message(_FORMATTER_ERROR_MESSAGES, safe_language) + + # ------------------------------------------------------------------ + + @staticmethod + def _normalize_response( + api_response: Union[str, Dict[str, Any], List[Any]], + ) -> str: + """Convert dict or list inputs to a JSON string.""" + if isinstance(api_response, (dict, list)): + return json.dumps(api_response, ensure_ascii=False) + return str(api_response) + + @staticmethod + def _annotate_empty(api_response_str: str) -> str: + """Annotate obviously empty responses so the LLM handles them gracefully.""" + try: + parsed = json.loads(api_response_str) + except (json.JSONDecodeError, ValueError): + return api_response_str + + if parsed is None or parsed == [] or parsed == {}: + return "[EMPTY RESPONSE: The API returned no data for this query]" + return api_response_str + + @staticmethod + def _truncate_if_needed(api_response_str: str) -> str: + """Truncate responses that exceed the item count or byte-size limits.""" + try: + parsed = json.loads(api_response_str) + except (json.JSONDecodeError, ValueError): + parsed = None + + if isinstance(parsed, list) and len(parsed) > _MAX_ITEMS: + total = len(parsed) + truncated = json.dumps(parsed[:_MAX_ITEMS], ensure_ascii=False) + api_response_str = ( + f"[NOTE: Response truncated to {_MAX_ITEMS} of {total} total items]\n" + + truncated + ) + + encoded = api_response_str.encode("utf-8") + if len(encoded) > _MAX_RESPONSE_BYTES: + truncated_str = encoded[:_MAX_RESPONSE_BYTES].decode( + "utf-8", errors="ignore" + ) + return truncated_str + "\n[NOTE: Response truncated due to size limit]" + + return api_response_str diff --git a/src/tool_classifier/api_semantic_searcher.py b/src/tool_classifier/api_semantic_searcher.py new file mode 100644 index 00000000..6e62dd31 --- /dev/null +++ b/src/tool_classifier/api_semantic_searcher.py @@ -0,0 +1,660 @@ +"""API Tool Semantic Searcher — hybrid search against api_tool_collection.""" + +import asyncio +import json +from typing import Any, Dict, List, Optional, Protocol, cast + +import dspy +import httpx +from loguru import logger + +from tool_classifier.constants import ( + API_TOOL_COLLECTION, + API_TOOL_HIGH_CONFIDENCE_THRESHOLD, + API_TOOL_MIN_THRESHOLD, + API_TOOL_SCORE_GAP_THRESHOLD, + API_TOOL_SEARCH_TOP_K, + QDRANT_HOST, + QDRANT_PORT, + QDRANT_TIMEOUT, +) +from tool_classifier.sparse_encoder import compute_sparse_vector +from tool_classifier.sparse_encoder import SparseVector + + +class EmbeddingServiceProtocol(Protocol): + """Protocol for any service that can generate text embeddings.""" + + def create_embeddings_for_indexer( + self, + texts: List[str], + environment: str = "production", + connection_id: Optional[str] = None, + batch_size: int = 10, + ) -> Dict[str, Any]: ... + + +class APIToolSearchResult: + """Result from API Tool semantic search.""" + + def __init__( + self, + endpoint_id: str, + name: str, + description: str, + method: str, + url: str, + params: List[Dict], + cosine_score: float, + rrf_score: float, + confidence: str, + ) -> None: + self.endpoint_id = endpoint_id + self.name = name + self.description = description + self.method = method + self.url = url + self.params = params + self.cosine_score = ( + cosine_score # Real dense cosine similarity (used for thresholds) + ) + self.rrf_score = rrf_score # Hybrid RRF fusion score (used for ranking) + self.confidence = confidence # "high", "medium", "none" + + def to_dict(self) -> Dict[str, Any]: + return { + "endpoint_id": self.endpoint_id, + "name": self.name, + "description": self.description, + "method": self.method, + "url": self.url, + "params": self.params, + "cosine_score": round(self.cosine_score, 4), + "rrf_score": round(self.rrf_score, 6), + "confidence": self.confidence, + } + + +class EndpointDisambiguationSignature(dspy.Signature): + """Determine which API endpoint best matches a user query, or none. + + Rules: + - Analyze the user query against the candidate endpoints carefully + - Return the endpoint_id of the best match if one clearly addresses the query + - Return exactly "none" if no endpoint clearly fits — do not guess + - Be conservative — only match when confident + - Understand Estonian, Russian, and English queries + """ + + user_query: str = dspy.InputField( + desc="User's question or request in Estonian, Russian, or English" + ) + candidates: str = dspy.InputField( + desc="JSON list of candidate endpoints: [{endpoint_id, name, description, cosine_score}]" + ) + + best_endpoint_id: str = dspy.OutputField( + desc='The endpoint_id of the best match, or exactly "none" if no endpoint clearly fits' + ) + + +class EndpointDisambiguatorModule(dspy.Module): + """DSPy Module for resolving ambiguous API endpoint candidates via LLM. + + Called when multiple endpoints score in the medium-confidence range and + a clear winner cannot be determined from cosine scores alone. + """ + + def __init__(self) -> None: + """Initialize with a direct DSPy predictor.""" + super().__init__() + self.predictor = dspy.Predict(EndpointDisambiguationSignature) + + def forward( + self, + user_query: str, + candidates: List[Dict[str, Any]], + ) -> Optional[str]: + """Pick the best matching endpoint_id from candidates, or return None. + + Args: + user_query: The user's natural language query. + candidates: List of candidate dicts with endpoint_id, name, + description, and cosine_score. + + Returns: + The winning endpoint_id string, or None if no endpoint clearly fits. + """ + candidates_payload = [ + { + "endpoint_id": c["endpoint_id"], + "name": c["name"], + "description": c["description"], + "cosine_score": round(c["cosine_score"], 4), + } + for c in candidates + ] + candidates_json = json.dumps(candidates_payload, ensure_ascii=False, indent=2) + + try: + result = self.predictor( + user_query=user_query, + candidates=candidates_json, + ) + winner = result.best_endpoint_id.strip() + if winner.lower() == "none": + return None + return winner + except Exception as e: + logger.error( + f"EndpointDisambiguatorModule: Disambiguation failed: {e}", + exc_info=True, + ) + return None + + +class APISemanticSearcher: + """Semantic searcher for API Tool endpoints stored in api_tool_collection. + + Usage: + searcher = APISemanticSearcher( + qdrant_client=shared_httpx_client, + embedding_service=orchestration_service, + ) + results = await searcher.search("What are national holidays in Estonia?") + """ + + def __init__( + self, + embedding_service: EmbeddingServiceProtocol, + qdrant_client: Optional[httpx.AsyncClient] = None, + disambiguator: Optional[EndpointDisambiguatorModule] = None, + ) -> None: + """Initialize the API semantic searcher. + + Args: + embedding_service: Service that generates dense embeddings. + qdrant_client: Optional shared httpx client. If None, creates its own. + disambiguator: Optional DSPy disambiguation module. If None, a default + instance is created. Inject a custom instance for testing. + """ + self.embedding_service = embedding_service + self._disambiguator = ( + disambiguator + if disambiguator is not None + else EndpointDisambiguatorModule() + ) + self._owns_client = qdrant_client is None + + if qdrant_client is not None: + self._qdrant_client = qdrant_client + else: + self._qdrant_client = httpx.AsyncClient( + base_url=f"http://{QDRANT_HOST}:{QDRANT_PORT}", + timeout=QDRANT_TIMEOUT, + limits=httpx.Limits( + max_connections=10, + max_keepalive_connections=5, + ), + ) + + async def aclose(self) -> None: + """Close the httpx client if we own it.""" + if self._owns_client: + await self._qdrant_client.aclose() + + async def search( + self, + query: str, + environment: str = "production", + connection_id: Optional[str] = None, + top_k: int = API_TOOL_SEARCH_TOP_K, + precomputed_embedding: Optional[List[float]] = None, + ) -> List[APIToolSearchResult]: + """Search api_tool_collection for the best matching API endpoints. + + Uses a two-step approach: + 1. Dense search → get real cosine similarity scores + 2. Hybrid search (dense + sparse + RRF) → get best-ranked matches + + Returns endpoints annotated with confidence level: + - "high": cosine >= API_TOOL_HIGH_CONFIDENCE_THRESHOLD AND score gap is large + - "medium": cosine >= API_TOOL_MIN_THRESHOLD but ambiguous + - "none": cosine < API_TOOL_MIN_THRESHOLD (no match) + + Args: + query: Natural language user query. + environment: Environment for embedding model resolution. + connection_id: Optional connection ID for embedding service. + top_k: Maximum number of results to return. + precomputed_embedding: Dense vector already computed upstream (e.g. by + the service classifier). When provided the embedding step is skipped + entirely, saving one embedding API call per request. + + Returns: + List containing exactly one APIToolSearchResult (the resolved best match), + or an empty list if no suitable API tool endpoint was found. + Never returns more than one result — ambiguous medium-confidence candidates + are resolved via LLM disambiguation before returning. + """ + # Step 1: Reuse caller's embedding if provided, otherwise generate a new one + if precomputed_embedding is not None: + logger.debug( + "APISemanticSearcher: reusing precomputed query embedding (no extra API call)" + ) + query_embedding = precomputed_embedding + else: + query_embedding = self._get_query_embedding( + query, environment, connection_id + ) + if query_embedding is None: + logger.error("APISemanticSearcher: Failed to generate query embedding") + return [] + + # Step 2: Dense search → real cosine scores for relevance check + dense_results = await self._dense_search(query_embedding, top_k=top_k) + if not dense_results: + logger.info("APISemanticSearcher: No results from dense search") + return [] + + top_cosine = dense_results[0]["cosine_score"] + second_cosine = ( + dense_results[1]["cosine_score"] if len(dense_results) > 1 else 0.0 + ) + cosine_gap = top_cosine - second_cosine + + logger.info(f"APISemanticSearcher: query={query!r}") + logger.info( + f"APISemanticSearcher: dense top={dense_results[0]['name']} " + f"(cosine={top_cosine:.4f}), gap={cosine_gap:.4f}" + ) + + # Below minimum threshold → no match + if top_cosine < API_TOOL_MIN_THRESHOLD: + logger.info( + f"APISemanticSearcher: cosine {top_cosine:.4f} < " + f"threshold {API_TOOL_MIN_THRESHOLD} — no API tool match" + ) + return [] + + # Step 3: Hybrid search → best-ranked results using dense + sparse + RRF + query_sparse = compute_sparse_vector(query) + hybrid_results = await self._hybrid_search( + query_embedding, query_sparse, top_k=top_k + ) + + # Fall back to dense results if hybrid returns nothing + if not hybrid_results: + hybrid_results = dense_results + + # Build a lookup from endpoint_id → real cosine score from dense results + dense_cosine_map = {r["endpoint_id"]: r["cosine_score"] for r in dense_results} + + # Step 4: Annotate each result with confidence level + results: List[APIToolSearchResult] = [] + for i, point in enumerate(hybrid_results): + endpoint_id = point.get("endpoint_id", "") + + # Prefer cosine from dense search; if this hybrid result was not in the + # dense top-N set, fall back to the cosine carried on the hybrid result + # itself. Skip entirely if no actual cosine score is available. + point_cosine = dense_cosine_map.get(endpoint_id) + if point_cosine is None: + point_cosine = point.get("cosine_score") + if point_cosine is None: + continue # Skip results that do not have an actual cosine score + point_rrf = point.get("rrf_score", 0.0) + + # Compute gap relative to this candidate: its cosine vs the best other + # dense cosine. This is correct even when hybrid re-ranks the top result. + next_best_cosine = next( + ( + r["cosine_score"] + for r in dense_results + if r["endpoint_id"] != endpoint_id + ), + 0.0, + ) + effective_gap = point_cosine - next_best_cosine + + if ( + point_cosine >= API_TOOL_HIGH_CONFIDENCE_THRESHOLD + and effective_gap >= API_TOOL_SCORE_GAP_THRESHOLD + and i == 0 + ): + confidence = "high" + elif point_cosine >= API_TOOL_MIN_THRESHOLD: + confidence = "medium" + else: + continue # Skip results below threshold + + results.append( + APIToolSearchResult( + endpoint_id=endpoint_id, + name=point.get("name", ""), + description=point.get("description", ""), + method=point.get("method", "GET"), + url=point.get("url", ""), + params=point.get("params", []), + cosine_score=point_cosine, + rrf_score=point_rrf, + confidence=confidence, + ) + ) + + # Step 5: Resolve to exactly one result + high_results = [r for r in results if r.confidence == "high"] + if high_results: + logger.info( + f"APISemanticSearcher: high-confidence match → {high_results[0].name!r} " + f"(cosine={high_results[0].cosine_score:.4f})" + ) + return [high_results[0]] + + medium_results = [r for r in results if r.confidence == "medium"] + if not medium_results: + logger.info( + "APISemanticSearcher: no results above threshold — no API tool match" + ) + return [] + + # Single medium result — only return directly if the gap is large enough + # (gap < SCORE_GAP_THRESHOLD means runner-up was close, LLM should validate) + if len(medium_results) == 1 and cosine_gap >= API_TOOL_SCORE_GAP_THRESHOLD: + logger.info( + f"APISemanticSearcher: single medium-confidence match (gap={cosine_gap:.4f}) → " + f"{medium_results[0].name!r} (cosine={medium_results[0].cosine_score:.4f})" + ) + return [medium_results[0]] + + # Multiple ambiguous candidates, OR single candidate with small gap — LLM validates + if len(medium_results) == 1: + logger.info( + f"APISemanticSearcher: single medium result but gap={cosine_gap:.4f} < " + f"{API_TOOL_SCORE_GAP_THRESHOLD} — sending to LLM for validation" + ) + winner_id = await self._disambiguate(query, medium_results) + if winner_id is None: + logger.info( + "APISemanticSearcher: disambiguator rejected all candidates — no API tool match" + ) + return [] + + winner = next((r for r in medium_results if r.endpoint_id == winner_id), None) + if winner is None: + logger.warning( + f"APISemanticSearcher: disambiguator returned unknown " + f"endpoint_id={winner_id!r} — no API tool match" + ) + return [] + + logger.info( + f"APISemanticSearcher: disambiguated winner → {winner.name!r} " + f"(cosine={winner.cosine_score:.4f})" + ) + return [winner] + + async def _disambiguate( + self, + query: str, + candidates: List[APIToolSearchResult], + ) -> Optional[str]: + """Invoke LLM disambiguation on ambiguous medium-confidence candidates. + + Args: + query: The original user query. + candidates: Medium-confidence APIToolSearchResult items to choose between. + + Returns: + The endpoint_id of the winner, or None if the LLM rejects all candidates. + """ + candidate_dicts = [ + { + "endpoint_id": r.endpoint_id, + "name": r.name, + "description": r.description, + "cosine_score": r.cosine_score, + } + for r in candidates + ] + logger.info( + f"APISemanticSearcher: disambiguating {len(candidates)} candidates " + f"for query: {query!r}" + ) + # Run the synchronous DSPy LLM call in a thread pool so it does not + # block the asyncio event loop while waiting for the LLM response. + # cast: asyncio.to_thread infers Prediction from DSPy; forward() returns Optional[str] + winner_id = cast( + Optional[str], + await asyncio.to_thread( + self._disambiguator, + user_query=query, + candidates=candidate_dicts, + ), + ) + if winner_id: + logger.info( + f"APISemanticSearcher: disambiguator picked endpoint_id={winner_id!r}" + ) + else: + logger.info("APISemanticSearcher: disambiguator rejected all candidates") + return winner_id + + def _get_query_embedding( + self, + query: str, + environment: str, + connection_id: Optional[str], + ) -> Optional[List[float]]: + """Generate dense embedding for the query.""" + try: + result = self.embedding_service.create_embeddings_for_indexer( + texts=[query], + environment=environment, + connection_id=connection_id, + batch_size=1, + ) + embeddings = result.get("embeddings", []) + if embeddings: + return embeddings[0] + logger.error("APISemanticSearcher: No embedding returned") + return None + except Exception as e: + logger.error(f"APISemanticSearcher: Embedding generation failed: {e}") + return None + + async def _dense_search( + self, + dense_vector: List[float], + top_k: int, + ) -> List[Dict[str, Any]]: + """Dense-only search on api_tool_collection for real cosine scores. + + Returns deduplicated results by endpoint_id, sorted by cosine score. + """ + try: + search_payload = { + "query": dense_vector, + "using": "dense", + "limit": top_k * 2, + "with_payload": True, + } + + response = await self._qdrant_client.post( + f"/collections/{API_TOOL_COLLECTION}/points/query", + json=search_payload, + ) + + if response.status_code != 200: + logger.error( + f"APISemanticSearcher: Dense search failed " + f"HTTP {response.status_code} — {response.text}" + ) + return [] + + points = response.json().get("result", {}).get("points", []) + if not points: + return [] + + # Deduplicate by endpoint_id, keep best cosine score + endpoint_results: Dict[str, Dict[str, Any]] = {} + for point in points: + payload = point.get("payload", {}) + score = float(point.get("score", 0)) + endpoint_id = payload.get("endpoint_id", "unknown") + + if endpoint_id not in endpoint_results or score > endpoint_results[ + endpoint_id + ].get("cosine_score", 0): + endpoint_results[endpoint_id] = { + "endpoint_id": endpoint_id, + "name": payload.get("name", ""), + "description": payload.get("description", ""), + "method": payload.get("method", "GET"), + "url": payload.get("url", ""), + "params": payload.get("params", []), + "cosine_score": score, + } + + return sorted( + endpoint_results.values(), + key=lambda x: x["cosine_score"], + reverse=True, + ) + + except httpx.TimeoutException: + logger.error( + f"APISemanticSearcher: Dense search timeout after {QDRANT_TIMEOUT}s" + ) + return [] + except Exception as e: + logger.error( + f"APISemanticSearcher: Dense search failed: {e}", exc_info=True + ) + return [] + + async def _hybrid_search( + self, + dense_vector: List[float], + sparse_vector: SparseVector, + top_k: int, + ) -> List[Dict[str, Any]]: + """Hybrid search using dense + sparse + RRF fusion. + + Sends both vectors in a single Qdrant prefetch query. + Returns deduplicated results by endpoint_id, sorted by RRF score. + """ + try: + # Verify collection is non-empty before searching. + # This check is only an optimization: if it fails, continue with the + # actual search request instead of failing closed. + try: + collection_info = await self._qdrant_client.get( + f"/collections/{API_TOOL_COLLECTION}" + ) + if collection_info.status_code == 200: + points_count = ( + collection_info.json().get("result", {}).get("points_count", 0) + ) + if points_count == 0: + logger.info("APISemanticSearcher: api_tool_collection is empty") + return [] + else: + logger.warning( + f"APISemanticSearcher: Could not verify collection: " + f"HTTP {collection_info.status_code}; continuing with search" + ) + except Exception as e: + logger.warning( + f"APISemanticSearcher: Collection verification failed: {e}; " + f"continuing with search" + ) + + # Build prefetch + RRF payload + search_payload: Dict[str, Any] = { + "prefetch": [ + { + "query": dense_vector, + "using": "dense", + "limit": top_k * 2, + }, + ], + "query": {"fusion": "rrf"}, + "limit": top_k, + "with_payload": True, + } + + # Add sparse prefetch only if non-empty + if not sparse_vector.is_empty(): + search_payload["prefetch"].append( + { + "query": sparse_vector.to_dict(), + "using": "sparse", + "limit": top_k * 2, + } + ) + + response = await self._qdrant_client.post( + f"/collections/{API_TOOL_COLLECTION}/points/query", + json=search_payload, + ) + + if response.status_code != 200: + logger.error( + f"APISemanticSearcher: Hybrid search failed " + f"HTTP {response.status_code} — {response.text}" + ) + return [] + + points = response.json().get("result", {}).get("points", []) + if not points: + return [] + + # Deduplicate by endpoint_id, keep best RRF score + endpoint_results: Dict[str, Dict[str, Any]] = {} + for point in points: + payload = point.get("payload", {}) + score = float(point.get("score", 0)) + endpoint_id = payload.get("endpoint_id", "unknown") + + if endpoint_id not in endpoint_results or score > endpoint_results[ + endpoint_id + ].get("rrf_score", 0): + endpoint_results[endpoint_id] = { + "endpoint_id": endpoint_id, + "name": payload.get("name", ""), + "description": payload.get("description", ""), + "method": payload.get("method", "GET"), + "url": payload.get("url", ""), + "params": payload.get("params", []), + "rrf_score": score, + # cosine_score patched in search() from dense results + } + + sorted_results = sorted( + endpoint_results.values(), + key=lambda x: x["rrf_score"], + reverse=True, + ) + + logger.info( + f"APISemanticSearcher: hybrid returned {len(sorted_results)} unique endpoints" + ) + for i, r in enumerate(sorted_results[:3]): + logger.debug( + f" Rank {i + 1}: {r['name']} " + f"(endpoint_id={r['endpoint_id']}, rrf={r['rrf_score']:.6f})" + ) + + return sorted_results + + except httpx.TimeoutException: + logger.error( + f"APISemanticSearcher: Hybrid search timeout after {QDRANT_TIMEOUT}s" + ) + return [] + except Exception as e: + logger.error( + f"APISemanticSearcher: Hybrid search failed: {e}", exc_info=True + ) + return [] diff --git a/src/tool_classifier/base_workflow.py b/src/tool_classifier/base_workflow.py new file mode 100644 index 00000000..23be0e17 --- /dev/null +++ b/src/tool_classifier/base_workflow.py @@ -0,0 +1,126 @@ +"""Abstract base class for workflow executors.""" + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Dict, Optional, Union + +from models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, + TestOrchestrationResponse, +) + + +class BaseWorkflow(ABC): + """ + Abstract base class for all workflow executors. + + This class defines the contract that all workflow implementations must follow. + Each workflow must implement both streaming and non-streaming execution methods. + + Design Pattern: Strategy Pattern + - Each workflow is a concrete strategy for handling queries + - ToolClassifier acts as the context that selects the appropriate strategy + + Workflows: + - ServiceWorkflowExecutor: Handles external service/API calls + - ContextWorkflowExecutor: Handles conversation history and greetings + - RAGWorkflowExecutor: Handles knowledge base retrieval (existing) + - OODWorkflowExecutor: Handles out-of-domain queries + + Return None Pattern: + Workflows return None when they cannot handle a query, triggering + fallback to the next layer in the classification chain. + """ + + @abstractmethod + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[Union[OrchestrationResponse, TestOrchestrationResponse]]: + """ + Execute workflow in non-streaming mode. + + This method is called for the /orchestrate and /orchestrate/test endpoints + which return complete responses in a single HTTP response. + + Args: + request: The orchestration request containing user query and context + context: Workflow-specific metadata from ClassificationResult.metadata + time_metric: Optional dictionary for tracking step execution times + + Returns: + OrchestrationResponse if workflow can handle this query + None if workflow cannot handle (triggers fallback to next layer) + + Example: + # If Service workflow detects no matching service: + return None # Falls back to Context workflow + + # If Service workflow successfully executes: + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content="EUR/USD rate is 1.0850" + ) + """ + pass + + @abstractmethod + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """ + Execute workflow in streaming mode (Server-Sent Events). + + This method is called for the /orchestrate/stream endpoint which yields + response chunks progressively to the client. + + Args: + request: The orchestration request containing user query and context + context: Workflow-specific metadata from ClassificationResult.metadata + time_metric: Optional dictionary for tracking step execution times + + Returns: + AsyncIterator[str] yielding SSE-formatted strings if workflow can handle + None if workflow cannot handle (triggers fallback to next layer) + + SSE Format: + Each yielded string should be formatted as: + 'data: {"chatId": "...", "payload": {"content": "..."}, ...}\\n\\n' + + Streaming Types: + - Real streaming (RAG): LLM generates tokens progressively + - Simulated streaming (Service/Context): Complete response chunked for UX + + Example: + # If Context workflow cannot answer from history: + return None # Falls back to RAG workflow + + # If Context workflow can answer: + async def stream_response(): + # Validate complete response first + answer = "The rate I mentioned was 1.08" + is_safe = await validate_with_guardrails(answer) + + if not is_safe: + yield format_sse(chatId, VIOLATION_MESSAGE) + yield format_sse(chatId, "END") + return + + # Stream validated response token-by-token + for chunk in split_into_chunks(answer): + yield format_sse(chatId, chunk) + await asyncio.sleep(0.01) + + yield format_sse(chatId, "END") + + return stream_response() + """ + pass diff --git a/src/tool_classifier/classifier.py b/src/tool_classifier/classifier.py new file mode 100644 index 00000000..8871954e --- /dev/null +++ b/src/tool_classifier/classifier.py @@ -0,0 +1,997 @@ +"""Main tool classifier for workflow routing with hybrid search classification.""" + +from typing import ( + Any, + AsyncIterator, + Dict, + List, + Literal, + Optional, + Union, + overload, + TYPE_CHECKING, +) +import httpx +from loguru import logger + +from llm_orchestrator_config.llm_manager import LLMManager +from models.request_models import ( + ConversationItem, + OrchestrationRequest, + OrchestrationResponse, + TestOrchestrationResponse, +) +from tool_classifier.base_workflow import BaseWorkflow +from tool_classifier.enums import ( + WorkflowType, + WORKFLOW_DISPLAY_NAMES, + WORKFLOW_LAYER_ORDER, +) +from tool_classifier.models import ClassificationResult +from tool_classifier.constants import ( + QDRANT_HOST, + QDRANT_PORT, + QDRANT_COLLECTION, + QDRANT_TIMEOUT, + HYBRID_SEARCH_TOP_K, + DENSE_SEARCH_TOP_K, + DENSE_MIN_THRESHOLD, + DENSE_HIGH_CONFIDENCE_THRESHOLD, + DENSE_SCORE_GAP_THRESHOLD, +) + +from tool_classifier.sparse_encoder import SparseVector, compute_sparse_vector +from tool_classifier.api_semantic_searcher import APISemanticSearcher + +from tool_classifier.workflows import ( + APIToolWorkflowExecutor, + ServiceWorkflowExecutor, + ContextWorkflowExecutor, + RAGWorkflowExecutor, + OODWorkflowExecutor, +) +from llm_orchestrator_config.feature_flags import FeatureFlags + +if TYPE_CHECKING: + from llm_orchestration_service import LLMOrchestrationService + + +class ToolClassifier: + """ + Main classifier that determines which workflow should handle user queries. + + Uses a two-step search approach for classification: + 1. Dense-only search → real cosine similarity scores for relevance check + 2. Hybrid search (dense + sparse + RRF) → best service identification + + Routing decisions: + - High-confidence service match → SERVICE workflow (skip discovery + intent detection) + - Ambiguous match → SERVICE workflow with LLM confirmation + - No match → CONTEXT/RAG workflow (skip SERVICE entirely) + + Implements a layer-wise filtering approach: + Layer 1: Service Workflow → External API calls + Layer 2: Context Workflow → Conversation history/greetings + Layer 3: RAG Workflow → Knowledge base retrieval + Layer 4: OOD Workflow → Out-of-domain fallback + """ + + def __init__( + self, + llm_manager: LLMManager, + orchestration_service: "LLMOrchestrationService", + ) -> None: + """ + Initialize tool classifier with required dependencies. + + Args: + llm_manager: LLM manager for making LLM calls (intent detection, context check) + orchestration_service: Reference to main orchestration service (for RAG workflow) + """ + self.llm_manager = llm_manager + self.orchestration_service = orchestration_service + + # Shared httpx client for Qdrant queries (connection pooling) + self._qdrant_base_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" + self._qdrant_client = httpx.AsyncClient( + base_url=self._qdrant_base_url, + timeout=QDRANT_TIMEOUT, + limits=httpx.Limits( + max_connections=20, + max_keepalive_connections=10, + ), + ) + + # Initialize workflow executors + self.api_tool_workflow = APIToolWorkflowExecutor( + orchestration_service=orchestration_service, + ) + self.service_workflow = ServiceWorkflowExecutor( + llm_manager=llm_manager, + orchestration_service=orchestration_service, + ) + self.context_workflow = ContextWorkflowExecutor( + llm_manager=llm_manager, + orchestration_service=orchestration_service, + ) + self.rag_workflow = RAGWorkflowExecutor( + orchestration_service=orchestration_service, + ) + self.ood_workflow = OODWorkflowExecutor() + + # API tool semantic searcher - reuses the shared Qdrant client + self.api_tool_searcher = APISemanticSearcher( + embedding_service=orchestration_service, + qdrant_client=self._qdrant_client, + ) + + logger.info( + "Tool classifier initialized with hybrid search classification " + f"(Qdrant: {self._qdrant_base_url})" + ) + + async def aclose(self) -> None: + """Close the shared httpx client and release connection pool resources. + + Must be awaited during application shutdown to avoid connection leaks. + """ + await self._qdrant_client.aclose() + logger.debug("ToolClassifier Qdrant httpx client closed") + + async def classify( + self, + query: str, + conversation_history: List[ConversationItem], + language: str, + request: Optional[OrchestrationRequest] = None, + ) -> ClassificationResult: + """ + Classify a user query using a two-step search approach. + + Step 1: Dense-only search → cosine similarity for relevance check + Step 2: Hybrid search (dense + sparse + RRF) → service identification + + Routing: + - cosine < DENSE_MIN_THRESHOLD AND no ATC match → CONTEXT/RAG + - cosine ≥ HIGH_CONFIDENCE + large gap → SERVICE (no LLM needed) + - ATC match found (when SERVICE misses) → API_TOOL_CALLING + - else → SERVICE with LLM confirmation + + Args: + query: User's query string + conversation_history: List of previous conversation messages + language: Detected language code (e.g., 'en', 'et') + request: Original orchestration request (needed for ATC search + which requires environment and connection_id for embedding). + + Returns: + ClassificationResult indicating which workflow to use + """ + logger.info(f"Classifying query: {query[:100]}...") + + try: + # Pre-classification: if an API tool session already exists for this + # chat_id, the user is responding to a param-collection question. + # Short-circuit directly to API_TOOL_CALLING — no need to re-classify. + if FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED and request is not None: + session_store = getattr( + self.orchestration_service, "session_store", None + ) + if session_store is not None: + existing_session = await session_store.get(request.chatId) + if existing_session is not None: + endpoint_name = ( + existing_session.selected_endpoint.get("name") + if existing_session.selected_endpoint + else "unknown" + ) + + # Before resuming, check if the user's new message is a + # strong match for a DIFFERENT endpoint (intent switch). + # If so, abandon the old session and start fresh rather + # than treating the new query as a param-collection reply. + new_api_match = await self._try_api_tool_classification( + query, request + ) + if ( + new_api_match is not None + and new_api_match.metadata.get("matched_endpoint", {}).get( + "name" + ) + != endpoint_name + ): + logger.info( + f"[{request.chatId}] Intent switch detected: " + f"active session={endpoint_name!r}, " + f"new match={new_api_match.metadata.get('matched_endpoint', {}).get('name')!r} " + f"— abandoning old session" + ) + await session_store.delete(request.chatId) + return new_api_match + + logger.info( + f"[{request.chatId}] Active API tool session found " + f"(endpoint={endpoint_name!r}) " + f"— short-circuiting to API_TOOL_CALLING" + ) + return ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=1.0, + metadata={ + "reason": "active_session_resume", + "matched_endpoint": existing_session.selected_endpoint, + }, + reasoning="Resuming active API tool parameter-collection session", + ) + + if not FeatureFlags.SERVICE_WORKFLOW_ENABLED: + logger.info( + "SERVICE_WORKFLOW_ENABLED=false - skipping standard service search" + ) + api_tool_result = await self._try_api_tool_classification( + query, request + ) + if api_tool_result: + return api_tool_result + logger.info("No API tool match either — routing to CONTEXT/RAG") + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "service_workflow_disabled"}, + reasoning="Service workflow disabled, no ATC match - fallback to Context/RAG", + ) + + # Step 1: Generate dense embedding for query + query_embedding = self._get_query_embedding(query) + if query_embedding is None: + logger.warning( + "Failed to generate query embedding, falling back to CONTEXT/RAG" + ) + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "embedding_generation_failed"}, + reasoning="Could not generate embedding - skip to Context/RAG", + ) + + # Step 2: Dense-only search → get actual cosine similarity scores + dense_results = await self._dense_search( + dense_vector=query_embedding, + top_k=DENSE_SEARCH_TOP_K, + ) + + if not dense_results: + logger.info( + "No dense search results from intent_collections - trying API tools" + ) + api_tool_result = await self._try_api_tool_classification( + query, request, precomputed_embedding=query_embedding + ) + if api_tool_result: + return api_tool_result + logger.info("No API tool match either — routing to CONTEXT/RAG") + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "no_service_match"}, + reasoning="No services matched the query (dense search empty)", + ) + + top_cosine = dense_results[0].get("cosine_score", 0.0) + top_service_name = dense_results[0].get("name", "unknown") + second_cosine = ( + dense_results[1].get("cosine_score", 0.0) + if len(dense_results) > 1 + else 0.0 + ) + cosine_gap = top_cosine - second_cosine + + logger.info( + f"Dense search: top={top_service_name} " + f"(cosine={top_cosine:.4f}), " + f"second={dense_results[1].get('name', 'none') if len(dense_results) > 1 else 'none'} " + f"(cosine={second_cosine:.4f}), " + f"gap={cosine_gap:.4f}" + ) + + # Decision: Is this a service query at all? + if top_cosine < DENSE_MIN_THRESHOLD: + logger.info( + f"Low service relevance (cosine={top_cosine:.4f} < {DENSE_MIN_THRESHOLD}) " + f"— trying API tools before falling to CONTEXT/RAG" + ) + api_tool_result = await self._try_api_tool_classification( + query, request, precomputed_embedding=query_embedding + ) + if api_tool_result: + return api_tool_result + logger.info("No API tool match — routing to CONTEXT/RAG") + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={ + "reason": "below_dense_threshold", + "top_cosine": top_cosine, + "top_service": top_service_name, + }, + reasoning=( + f"Dense cosine {top_cosine:.4f} below threshold " + f"{DENSE_MIN_THRESHOLD} - skip to Context/RAG" + ), + ) + + # Step 3: Hybrid search → identify best service using RRF + query_sparse = compute_sparse_vector(query) + hybrid_results = await self._hybrid_search( + dense_vector=query_embedding, + sparse_vector=query_sparse, + top_k=HYBRID_SEARCH_TOP_K, + ) + + # Use hybrid results for service identification, dense scores for confidence + if not hybrid_results: + # Dense matched but hybrid didn't — use dense results + hybrid_results = dense_results + + top_result = hybrid_results[0] + top_service_id = top_result.get("service_id", "unknown") + top_service_name_hybrid = top_result.get("name", "unknown") + + logger.info( + f"Hybrid search: best service={top_service_name_hybrid} " + f"(service_id={top_service_id})" + ) + + # High confidence: cosine is high AND clear gap to second result + if ( + top_cosine >= DENSE_HIGH_CONFIDENCE_THRESHOLD + and cosine_gap >= DENSE_SCORE_GAP_THRESHOLD + ): + logger.info( + f"HIGH-CONFIDENCE match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f})" + ) + return ClassificationResult( + workflow=WorkflowType.SERVICE, + confidence=min(top_cosine, 1.0), + metadata={ + "matched_service_id": top_service_id, + "matched_service_name": top_service_name_hybrid, + "cosine_score": top_cosine, + "cosine_gap": cosine_gap, + "needs_llm_confirmation": False, + "top_results": hybrid_results[:3], + }, + reasoning=( + f"High-confidence match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f})" + ), + ) + + # Medium confidence: above min threshold but ambiguous + logger.info( + f"AMBIGUOUS match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}, gap={cosine_gap:.4f}) - needs LLM confirmation" + ) + return ClassificationResult( + workflow=WorkflowType.SERVICE, + confidence=0.5, + metadata={ + "matched_service_id": top_service_id, + "matched_service_name": top_service_name_hybrid, + "cosine_score": top_cosine, + "cosine_gap": cosine_gap, + "needs_llm_confirmation": True, + "top_results": hybrid_results[:3], + }, + reasoning=( + f"Ambiguous match: {top_service_name_hybrid} " + f"(cosine={top_cosine:.4f}) - LLM confirmation needed" + ), + ) + + except Exception as e: + logger.error(f"Hybrid classification failed: {e}", exc_info=True) + return ClassificationResult( + workflow=WorkflowType.CONTEXT, + confidence=1.0, + metadata={"reason": "classification_error", "error": str(e)}, + reasoning=f"Classification error - falling back to Context/RAG: {e}", + ) + + def _get_query_embedding(self, query: str) -> Optional[List[float]]: + """Generate dense embedding for a query using the orchestration service. + + Args: + query: Query text to embed + + Returns: + List of floats representing the dense embedding, or None on failure + """ + try: + if not self.orchestration_service: + logger.error("Orchestration service not available for embedding") + return None + + result = self.orchestration_service.create_embeddings_for_indexer( + texts=[query], + environment="production", + batch_size=1, + ) + + embeddings = result.get("embeddings", []) + if embeddings and len(embeddings) > 0: + return embeddings[0] + + logger.error("No embedding returned for query") + return None + + except Exception as e: + logger.error(f"Failed to generate query embedding: {e}") + return None + + async def _dense_search( + self, + dense_vector: List[float], + top_k: int = DENSE_SEARCH_TOP_K, + ) -> List[Dict[str, Any]]: + """Execute dense-only search on Qdrant to get actual cosine similarity scores. + + This is used as a pre-filter: the cosine scores tell us HOW RELEVANT + the top results actually are, unlike RRF scores which are purely rank-based. + + Args: + dense_vector: Dense embedding vector (3072-dim) + top_k: Number of results to return + + Returns: + List of result dicts with service metadata and cosine_score, + deduplicated by service_id (best score per service) + """ + try: + search_payload = { + "query": dense_vector, + "using": "dense", + "limit": top_k * 2, # Get more to allow dedup by service + "with_payload": True, + } + + response = await self._qdrant_client.post( + f"/collections/{QDRANT_COLLECTION}/points/query", + json=search_payload, + ) + + if response.status_code != 200: + logger.error( + f"Qdrant dense search failed: HTTP {response.status_code} - " + f"{response.text}" + ) + return [] + + search_results = response.json() + points = search_results.get("result", {}).get("points", []) + + if not points: + logger.info("No results from dense search") + return [] + + # Deduplicate by service_id (keep best cosine score per service) + service_results: Dict[str, Dict[str, Any]] = {} + for point in points: + payload = point.get("payload", {}) + score = float(point.get("score", 0)) + service_id = payload.get("service_id", "unknown") + + if service_id not in service_results or score > service_results[ + service_id + ].get("cosine_score", 0): + service_results[service_id] = { + "service_id": service_id, + "name": payload.get("name", ""), + "description": payload.get("description", ""), + "examples": payload.get("examples", []), + "entities": payload.get("entities", []), + "context": payload.get("context", ""), + "point_type": payload.get("point_type", "unknown"), + "example_text": payload.get("example_text"), + "cosine_score": score, + } + + # Sort by cosine score descending + sorted_results = sorted( + service_results.values(), + key=lambda x: x["cosine_score"], + reverse=True, + ) + + logger.info( + f"Dense search found {len(sorted_results)} unique services " + f"(top cosine: {sorted_results[0]['cosine_score']:.4f})" + ) + + return sorted_results + + except httpx.TimeoutException: + logger.error(f"Qdrant dense search timeout after {QDRANT_TIMEOUT}s") + return [] + except Exception as e: + logger.error(f"Dense search failed: {e}", exc_info=True) + return [] + + async def _hybrid_search( + self, + dense_vector: List[float], + sparse_vector: SparseVector, + top_k: int = HYBRID_SEARCH_TOP_K, + ) -> List[Dict[str, Any]]: + """Execute hybrid search on Qdrant using prefetch + RRF fusion. + + Sends both dense and sparse vectors in a single Qdrant query, + using the prefetch API for parallel retrieval and RRF for fusion. + + Args: + dense_vector: Dense embedding vector (3072-dim) + sparse_vector: SparseVector with indices and values + top_k: Number of results to return + + Returns: + List of result dicts with service metadata and rrf_score + """ + try: + # Check if collection exists and has data + try: + collection_info = await self._qdrant_client.get( + f"/collections/{QDRANT_COLLECTION}" + ) + if collection_info.status_code == 200: + info = collection_info.json() + points_count = info.get("result", {}).get("points_count", 0) + if points_count == 0: + logger.info("Intent collection is empty - no services indexed") + return [] + else: + logger.warning( + f"Could not verify collection: HTTP {collection_info.status_code}" + ) + return [] + except Exception as e: + logger.warning(f"Could not verify intent collection: {e}") + return [] + + # Build hybrid search payload with prefetch + RRF + search_payload: Dict[str, Any] = { + "prefetch": [ + { + "query": dense_vector, + "using": "dense", + "limit": top_k * 2, + }, + ], + "query": {"fusion": "rrf"}, + "limit": top_k, + "with_payload": True, + } + + # Add sparse prefetch only if sparse vector is non-empty + if not sparse_vector.is_empty(): + search_payload["prefetch"].append( + { + "query": sparse_vector.to_dict(), + "using": "sparse", + "limit": top_k * 2, + } + ) + + response = await self._qdrant_client.post( + f"/collections/{QDRANT_COLLECTION}/points/query", + json=search_payload, + ) + + if response.status_code != 200: + logger.error( + f"Qdrant hybrid search failed: HTTP {response.status_code} - " + f"{response.text}" + ) + return [] + + search_results = response.json() + points = search_results.get("result", {}).get("points", []) + + if not points: + logger.info("No results from hybrid search") + return [] + + # Parse and deduplicate results (group by service_id, keep best score) + service_results: Dict[str, Dict[str, Any]] = {} + for point in points: + payload = point.get("payload", {}) + score = float(point.get("score", 0)) + service_id = payload.get("service_id", "unknown") + + if service_id not in service_results or score > service_results[ + service_id + ].get("rrf_score", 0): + service_results[service_id] = { + "service_id": service_id, + "name": payload.get("name", ""), + "description": payload.get("description", ""), + "examples": payload.get("examples", []), + "entities": payload.get("entities", []), + "context": payload.get("context", ""), + "point_type": payload.get("point_type", "unknown"), + "example_text": payload.get("example_text"), + "rrf_score": score, + } + + # Sort by RRF score descending + sorted_results = sorted( + service_results.values(), + key=lambda x: x["rrf_score"], + reverse=True, + ) + + logger.info( + f"Hybrid search found {len(sorted_results)} unique services " + f"from {len(points)} points" + ) + + for i, r in enumerate(sorted_results[:3]): + logger.debug( + f" Rank {i + 1}: {r['name']} " + f"(service_id={r['service_id']}, " + f"rrf_score={r['rrf_score']:.6f}, " + f"type={r['point_type']})" + ) + + return sorted_results + + except httpx.TimeoutException: + logger.error(f"Qdrant hybrid search timeout after {QDRANT_TIMEOUT}s") + return [] + except Exception as e: + logger.error(f"Hybrid search failed: {e}", exc_info=True) + return [] + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[False] = False, + time_metric: Optional[Dict[str, float]] = None, + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: ... + + @overload + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: Literal[True], + time_metric: Optional[Dict[str, float]] = None, + ) -> AsyncIterator[str]: ... + + async def route_to_workflow( + self, + classification: ClassificationResult, + request: OrchestrationRequest, + is_streaming: bool = False, + time_metric: Optional[Dict[str, float]] = None, + ) -> Union[OrchestrationResponse, TestOrchestrationResponse, AsyncIterator[str]]: + """ + Route request to appropriate workflow based on classification. + + Implements fallback chain: If a workflow returns None, tries the next layer. + This ensures queries always get handled, even if primary workflow fails. + + Args: + classification: Classification result from classify() + request: Original orchestration request + is_streaming: Whether to use streaming mode (for /orchestrate/stream) + time_metric: Optional timing dictionary for workflow step tracking + + Returns: + OrchestrationResponse for non-streaming mode + AsyncIterator[str] for streaming mode + + Fallback Chain: + SERVICE → CONTEXT → RAG → OOD + Each layer returns None if it cannot handle, triggering next layer. + """ + chat_id = request.chatId + workflow_name = WORKFLOW_DISPLAY_NAMES.get( + classification.workflow, classification.workflow.value + ) + + logger.info( + f"[{chat_id}] Routing to {workflow_name} " + f"(streaming: {is_streaming}, confidence: {classification.confidence:.2f})" + ) + + # Get the workflow executor + workflow = self._get_workflow_executor(classification.workflow) + + if is_streaming: + # STREAMING MODE: For /orchestrate/stream endpoint + # Return the async iterator directly + return self._execute_with_fallback_streaming( + workflow=workflow, + request=request, + context=classification.metadata, + start_layer=classification.workflow, + time_metric=time_metric, + ) + else: + # NON-STREAMING MODE: For /orchestrate and /orchestrate/test endpoints + return await self._execute_with_fallback_async( + workflow=workflow, + request=request, + context=classification.metadata, + start_layer=classification.workflow, + time_metric=time_metric, + ) + + def _get_workflow_executor(self, workflow_type: WorkflowType) -> BaseWorkflow: + """Get workflow executor instance for given workflow type.""" + workflow_map = { + WorkflowType.SERVICE: self.service_workflow, + WorkflowType.API_TOOL_CALLING: self.api_tool_workflow, + WorkflowType.CONTEXT: self.context_workflow, + WorkflowType.RAG: self.rag_workflow, + WorkflowType.OOD: self.ood_workflow, + } + return workflow_map[workflow_type] + + def _is_workflow_enabled(self, workflow_type: WorkflowType) -> bool: + """Return True if the given workflow type is enabled via feature flags. + + RAG and OOD are always enabled (they are the safety net fallbacks). + """ + flag_map = { + WorkflowType.SERVICE: FeatureFlags.SERVICE_WORKFLOW_ENABLED, + WorkflowType.API_TOOL_CALLING: FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED, + WorkflowType.CONTEXT: FeatureFlags.CONTEXT_WORKFLOW_ENABLED, + WorkflowType.RAG: True, + WorkflowType.OOD: True, + } + return flag_map.get(workflow_type, True) + + async def _try_api_tool_classification( + self, + query: str, + request: Optional[OrchestrationRequest] = None, + precomputed_embedding: Optional[List[float]] = None, + ) -> Optional[ClassificationResult]: + """Search api_tool_collection and return a ClassificationResult if a match is found. + + Called when intent_collections search yields no usable service match. + + Args: + query: User's query string. + request: Orchestration request (provides environment + connection_id). + When None, defaults to production environment. + precomputed_embedding: Dense embedding vector already computed for this + query by the service search step. When provided, the ATC searcher + reuses it instead of making a second embedding API call. + + Returns: + ClassificationResult with API_TOOL_CALLING workflow if a match is found, + or None if no endpoint matched. + """ + if not FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED: + logger.info("API_TOOL_CALLING_WORKFLOW_ENABLED=false — skipping ATC search") + return None + + environment = request.environment if request else "production" + connection_id = request.connection_id if request else None + + try: + results = await self.api_tool_searcher.search( + query=query, + environment=environment, + connection_id=connection_id, + precomputed_embedding=precomputed_embedding, + ) + if results: + matched = results[0] + logger.info( + f"API tool match: {matched.name!r} " + f"(confidence={matched.confidence}, cosine={matched.cosine_score:.4f})" + ) + return ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=matched.cosine_score, + metadata={"matched_endpoint": matched.to_dict()}, + reasoning=( + f"API tool match: {matched.name} " + f"(cosine={matched.cosine_score:.4f}, confidence={matched.confidence})" + ), + ) + except Exception as e: + logger.error(f"API tool classification failed: {e}", exc_info=True) + return None + + async def _execute_with_fallback_async( + self, + workflow: BaseWorkflow, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + time_metric: Optional[Dict[str, float]] = None, + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: + """ + Execute workflow with fallback to subsequent layers (non-streaming). + + Implementation: + 1. Try primary workflow + 2. If returns None, try next layer in WORKFLOW_LAYER_ORDER + 3. Continue until workflow returns non-None result + 4. OOD workflow always returns result (never None) + + Args: + workflow: Primary workflow executor + request: Orchestration request + context: Workflow context/metadata + start_layer: Starting workflow type + time_metric: Optional timing dictionary for tracking + """ + chat_id = request.chatId + workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) + + logger.info(f"[{chat_id}] Executing {workflow_name} (non-streaming)") + + try: + if self._is_workflow_enabled(start_layer): + result = await workflow.execute_async(request, context, time_metric) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} handled successfully") + return result + + # Implement layer-wise fallback chain + logger.info( + f"[{chat_id}] {workflow_name} returned None, " + f"trying next layer in fallback chain" + ) + else: + logger.info( + f"[{chat_id}] {workflow_name} is disabled via feature flag, " + f"trying next layer in fallback chain" + ) + + # Get the layer order starting from current layer + + current_index = WORKFLOW_LAYER_ORDER.index(start_layer) + remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] + + # Try each subsequent layer in order + for next_layer in remaining_layers: + if not self._is_workflow_enabled(next_layer): + next_name = WORKFLOW_DISPLAY_NAMES.get(next_layer, next_layer.value) + logger.info(f"[{chat_id}] Skipping disabled workflow: {next_name}") + continue + + next_workflow = self._get_workflow_executor(next_layer) + next_name = WORKFLOW_DISPLAY_NAMES.get(next_layer, next_layer.value) + + logger.info( + f"[{chat_id}] Falling back to {next_name} " + f"(Layer {WORKFLOW_LAYER_ORDER.index(next_layer) + 1})" + ) + + result = await next_workflow.execute_async(request, {}, time_metric) + + if result is not None: + logger.info(f"[{chat_id}] {next_name} handled successfully") + return result + + logger.info(f"[{chat_id}] {next_name} returned None, continuing...") + + # This should never happen since RAG/OOD should always return result + raise RuntimeError("All workflows returned None (unexpected)") + + except Exception as e: + logger.error(f"[{chat_id}] Error executing {workflow_name}: {e}") + # Fallback to RAG on error + logger.info(f"[{chat_id}] Falling back to RAG due to error") + rag_result = await self.rag_workflow.execute_async(request, {}, time_metric) + if rag_result is not None: + return rag_result + else: + raise RuntimeError("RAG workflow returned None unexpectedly") from e + + async def _execute_with_fallback_streaming( + self, + workflow: BaseWorkflow, + request: OrchestrationRequest, + context: Dict[str, Any], + start_layer: WorkflowType, + time_metric: Optional[Dict[str, float]] = None, + ) -> AsyncIterator[str]: + """ + Execute workflow with fallback to subsequent layers (streaming). + + Implementation: + 1. Try primary workflow + 2. If returns None, try next layer in WORKFLOW_LAYER_ORDER + 3. Stream from the first workflow that returns non-None + 4. OOD workflow always returns result (never None) + + Args: + workflow: Primary workflow executor + request: Orchestration request + context: Workflow context/metadata + start_layer: Starting workflow type + time_metric: Optional timing dictionary for tracking + """ + chat_id = request.chatId + workflow_name = WORKFLOW_DISPLAY_NAMES.get(start_layer, start_layer.value) + + logger.info(f"[{chat_id}] Executing {workflow_name} (streaming)") + + try: + if self._is_workflow_enabled(start_layer): + result = await workflow.execute_streaming(request, context, time_metric) + + if result is not None: + logger.info(f"[{chat_id}] {workflow_name} streaming started") + async for chunk in result: + yield chunk + return + + # Implement layer-wise fallback chain for streaming + logger.info( + f"[{chat_id}] {workflow_name} returned None, " + f"trying next layer in fallback chain" + ) + else: + logger.info( + f"[{chat_id}] {workflow_name} is disabled via feature flag, " + f"trying next layer in fallback chain" + ) + + # Get the layer order starting from current layer + + current_index = WORKFLOW_LAYER_ORDER.index(start_layer) + remaining_layers = WORKFLOW_LAYER_ORDER[current_index + 1 :] + + # Try each subsequent layer in order + for next_layer in remaining_layers: + if not self._is_workflow_enabled(next_layer): + next_name = WORKFLOW_DISPLAY_NAMES.get(next_layer, next_layer.value) + logger.info(f"[{chat_id}] Skipping disabled workflow: {next_name}") + continue + + next_workflow = self._get_workflow_executor(next_layer) + next_name = WORKFLOW_DISPLAY_NAMES.get(next_layer, next_layer.value) + + layer_number = WORKFLOW_LAYER_ORDER.index(next_layer) + 1 + logger.info( + f"[{chat_id}] Falling back to {next_name} streaming " + f"(Layer {layer_number})" + ) + + result = await next_workflow.execute_streaming(request, {}, time_metric) + + if result is not None: + logger.info(f"[{chat_id}] {next_name} streaming started") + async for chunk in result: + yield chunk + return + + logger.info(f"[{chat_id}] {next_name} returned None, continuing...") + + # This should never happen + raise RuntimeError("All workflows returned None in streaming (unexpected)") + + except Exception as e: + logger.error(f"[{chat_id}] Error executing {workflow_name} streaming: {e}") + # Fallback to RAG on error + logger.info(f"[{chat_id}] Falling back to RAG streaming due to error") + streaming_result = await self.rag_workflow.execute_streaming( + request, {}, time_metric + ) + if streaming_result is not None: + async for chunk in streaming_result: + yield chunk + else: + raise RuntimeError("RAG workflow returned None unexpectedly") from e diff --git a/src/tool_classifier/constants.py b/src/tool_classifier/constants.py new file mode 100644 index 00000000..07185063 --- /dev/null +++ b/src/tool_classifier/constants.py @@ -0,0 +1,228 @@ +"""Constants and configuration for tool classifier module.""" + + +# ============================================================================ +# Qdrant Vector Database Configuration +# ============================================================================ + +QDRANT_HOST = "qdrant" +"""Qdrant server hostname.""" + +QDRANT_PORT = 6333 +"""Qdrant server port.""" + +QDRANT_TIMEOUT = 10.0 +"""Qdrant HTTP client timeout in seconds.""" + + +# ============================================================================ +# Semantic Search Configuration +# ============================================================================ + +QDRANT_COLLECTION = "intent_collections" +"""Qdrant collection name for service intent search.""" + +SEMANTIC_SEARCH_TOP_K = 10 +"""Number of top services to return from semantic search.""" + +SEMANTIC_SEARCH_THRESHOLD = 0.2 +"""Minimum similarity score threshold for semantic search (0.0-1.0). +Lowered from 0.4 to handle broader queries.""" + + +# ============================================================================ +# Ruuter Service Configuration +# ============================================================================ + +RUUTER_BASE_URL = "http://ruuter-private:8086" +"""Base URL for Ruuter private service endpoints.""" + +RUUTER_SERVICE_BASE_URL = "http://ruuter:8086/services" +"""Base URL for Ruuter service endpoints (active services).""" + +RUUTER_COMMON_SERVICE_BASE_URL = "http://ruuter-test:8086/common-services" +"""Base URL for Ruuter common service endpoints. +This is a placeholder test URL — replace with the real URL when available.""" + +RAG_SEARCH_RUUTER_PUBLIC = "http://ruuter-public:8086/rag-search" +"""Public Ruuter endpoint for RAG search service discovery.""" + +SERVICE_CALL_TIMEOUT = 10 +"""Timeout in seconds for external service calls via Ruuter.""" + +SERVICE_DISCOVERY_TIMEOUT = 10.0 +"""Timeout in seconds for service discovery calls.""" + +# ============================================================================ +# Multi-Step Service (MCQ) Configuration +# ============================================================================ + +SERVICE_STEP_PREFIXES = ("#service,", "#common_service,") +"""Tuple of prefixes that identify a button-payload direct-step message. + +When a user clicks an MCQ button, the widget sends the button's payload string +as the next user message. These prefixes identify such machine-generated +commands so the orchestrator can bypass NLU and route directly to the +step endpoint. + +Examples: + "#service, /POST/services/active/application_mcq_step_passport" + "#common_service, /POST/common/some_step" +""" + + +# ============================================================================ +# Service Workflow Thresholds +# ============================================================================ + +MAX_SERVICES_FOR_LLM_CONTEXT = 50 +"""Maximum number of services to send to LLM without semantic filtering. +If service count exceeds this, semantic search is used to filter to top-K.""" + +SERVICE_COUNT_THRESHOLD = 10 +"""Threshold for triggering semantic search. If service count > this value, +semantic search is used instead of sending all services to LLM.""" + + +# ============================================================================ +# Hybrid Search Classification Thresholds +# ============================================================================ + +HYBRID_SEARCH_TOP_K = 5 +"""Number of top results from hybrid search for service identification.""" + +DENSE_SEARCH_TOP_K = 3 +"""Number of top results from dense-only search for relevance scoring.""" + +DENSE_MIN_THRESHOLD = 0.5 +"""Minimum dense cosine similarity to consider a result as a potential match. +Below this → skip SERVICE entirely, go to CONTEXT/RAG. +Note: Multilingual embeddings (Estonian/short queries) typically yield +lower cosine scores (0.25-0.40) than English. Tune based on observed scores.""" + +DENSE_HIGH_CONFIDENCE_THRESHOLD = 0.55 +"""Dense cosine similarity for high-confidence service classification. +Above this AND score gap is large → SERVICE without LLM confirmation.""" + +DENSE_SCORE_GAP_THRESHOLD = 0.05 +"""Cosine score gap (top - second) for high-confidence classification. +Ensures the top result is significantly better than the runner-up.""" + + +# ============================================================================ +# API Tool Collection Search Configuration +# ============================================================================ + +API_TOOL_COLLECTION = "api_tool_collection" +"""Qdrant collection name for API endpoint semantic search.""" + +API_TOOL_SEARCH_TOP_K = 5 +"""Number of top endpoints to return from API tool semantic search.""" + +API_TOOL_MIN_THRESHOLD = 0.40 +"""Minimum dense cosine similarity to consider a result as an API tool match. +Below this → no API tool matched, fall through to other workflows.""" + +API_TOOL_HIGH_CONFIDENCE_THRESHOLD = 0.60 +"""Dense cosine similarity for high-confidence API tool match. +Above this AND score gap is large → route to API Tool Calling without further LLM disambiguation.""" + +API_TOOL_SCORE_GAP_THRESHOLD = 0.05 +"""Cosine score gap (top - second) for high-confidence API tool classification.""" + + +# ============================================================================ +# Agentic Loop — Continuation Threshold +# ============================================================================ + +CONTINUATION_TURN = 3 +"""1-based turn count (after increment) at which the loop asks the user whether +to continue collecting parameters or fall back to the RAG workflow. +Only triggers when required params are still missing at exactly this turn. + +The turn counter is incremented on every run_turn() call, including the +initial call that generates the bot's opening question (before the user +speaks). With CONTINUATION_TURN=3 the conversation looks like: + + run_turn #1 (turn 0→1): initial question — "Which country and date?" + run_turn #2 (turn 1→2): user gives partial answer — bot asks follow-up + run_turn #3 (turn 2→3): user doesn't answer properly → CONTINUATION CHECK +""" + +CONTINUATION_QUESTION = ( + "I still need a bit more information, but we've been at this for a while. " + "Would you like to keep going and answer a few more questions " + "(yes / no)" +) +"""Yes/no question shown to the user when the continuation threshold is reached.""" + +CONTINUATION_QUESTION_ET = ( + "Mul on vaja veel natuke lisateavet, kuid oleme selle kallal juba mõnda aega töötanud. " + "Kas soovite jätkata ja vastata veel mõnele küsimusele? (jah / ei)" +) +"""Estonian version of the continuation question.""" + +CONTINUATION_QUESTION_RU = ( + "Мне нужно ещё немного информации, но мы уже некоторое время занимаемся этим. " + "Хотите ли вы продолжить и ответить ещё на несколько вопросов? (да / нет)" +) +"""Russian version of the continuation question.""" + +# ============================================================================ +# API Caller Configuration +# ============================================================================ + +API_CALL_TIMEOUT = 10 +"""Default timeout in seconds for external API calls made via APICaller.""" + +# Circuit breaker state literals +CB_STATE_CLOSED = "CLOSED" +"""Circuit breaker is CLOSED: routes all requests normally.""" + +CB_STATE_OPEN = "OPEN" +"""Circuit breaker is OPEN: rejects all requests immediately (cooldown active).""" + +CB_STATE_HALF_OPEN = "HALF_OPEN" +"""Circuit breaker is HALF_OPEN: allows one probe request to test recovery.""" + +CIRCUIT_BREAKER_FAILURE_THRESHOLD = 3 +"""Number of consecutive server/network failures before the circuit breaker opens.""" + +CIRCUIT_BREAKER_COOLDOWN_SECONDS = 60.0 +"""Seconds the circuit breaker stays OPEN before transitioning to HALF_OPEN.""" + +# User-facing error messages for API call failures (multilingual: et / ru / en) +SERVICE_UNAVAILABLE_MESSAGES = { + "et": "Teenus on ajutiselt kättesaamatu. Palun proovige hiljem uuesti.", + "ru": "Сервис временно недоступен. Пожалуйста, попробуйте позже.", + "en": "The service is temporarily unavailable. Please try again later.", +} +"""Friendly message returned on 5xx server errors.""" + +SERVICE_TIMEOUT_MESSAGES = { + "et": "Teenuse päring aegus. Palun proovige mõne hetke pärast uuesti.", + "ru": "Запрос к сервису истёк по таймауту. Пожалуйста, попробуйте снова через несколько секунд.", + "en": "The service request timed out. Please try again in a moment.", +} +"""Friendly message returned on timeout or network errors.""" + +CIRCUIT_BREAKER_OPEN_MESSAGES = { + "et": "Teenus on praegu kättesaamatu korduvate vigade tõttu. Palun proovige hiljem uuesti.", + "ru": "Сервис в данный момент недоступен из-за повторяющихся ошибок. Пожалуйста, попробуйте позже.", + "en": "The service is currently unavailable due to repeated failures. Please try again later.", +} +"""Friendly message returned when the circuit breaker is open.""" + +REDIRECT_NOT_FOLLOWED_MESSAGES = { + "et": "Teenus tagastas ümbersuunamise, mida ei järgitud (HTTP {status_code}): {location}", + "ru": "Сервис вернул перенаправление, которое не было выполнено (HTTP {status_code}): {location}", + "en": "The service returned an unresolved redirect (HTTP {status_code}): {location}", +} +"""Friendly message returned when an HTTP 3xx redirect could not be followed.""" + +CLIENT_ERROR_MESSAGES = { + "et": "Teie päringut ei saanud töödelda. Palun kontrollige sisestatud andmeid ja proovige uuesti.", + "ru": "Ваш запрос не удалось обработать. Пожалуйста, проверьте введённые данные и повторите попытку.", + "en": "Your request could not be processed. Please check the provided information and try again.", +} +"""Friendly message returned on 4xx client errors from external API calls.""" diff --git a/src/tool_classifier/context_analyzer.py b/src/tool_classifier/context_analyzer.py new file mode 100644 index 00000000..da4eba1d --- /dev/null +++ b/src/tool_classifier/context_analyzer.py @@ -0,0 +1,1053 @@ +"""Context analyzer for greeting detection and conversation history analysis.""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Dict, List, Optional +import json +import dspy +import dspy.streaming +from dspy.streaming import StreamListener +from loguru import logger +from pydantic import BaseModel, Field + +from src.utils.cost_utils import get_lm_usage_since +from tool_classifier.greeting_constants import get_greeting_response + + +class ContextAnalysisResult(BaseModel): + """Result of context analysis.""" + + is_greeting: bool = Field( + ..., description="Whether the query is a greeting (hello, goodbye, thanks)" + ) + can_answer_from_context: bool = Field( + ..., description="Whether the query can be answered from conversation history" + ) + answer: Optional[str] = Field( + None, description="Generated response (greeting or context-based answer)" + ) + reasoning: str = Field(..., description="Brief explanation of the analysis") + answered_from_summary: bool = Field( + default=False, + description="Whether the answer was derived from a conversation summary (older turns beyond recent 10)", + ) + + +class ContextAnalysisSignature(dspy.Signature): + """Analyze user query for greeting detection and conversation history references. + + This signature instructs the LLM to: + 1. Detect greetings in multiple languages (Estonian, English) + 2. Check if query references conversation history + 3. Generate appropriate responses based on context + + Supported greeting types: + - hello: Tere, Hello, Hi, Hei, Hey, Moi, Good morning, Good afternoon, Good evening + - goodbye: Nägemist, Bye, Goodbye, See you, Good night + - thanks: Tänan, Aitäh, Thank you, Thanks, Much appreciated + - casual: Tervist, Tšau, Moikka + + IMPORTANT — Greeting + Question distinction: + - A message is a greeting ONLY if it contains NOTHING beyond the greeting itself. + - If the message contains a greeting AND a substantive question or request, + set is_greeting to FALSE so the question is answered properly. + + The LLM should respond in the SAME language as the user's query. + """ + + conversation_history: str = dspy.InputField( + desc="Recent conversation history (last 10 turns) formatted as JSON" + ) + user_query: str = dspy.InputField( + desc="Current user query to analyze for greetings or context references" + ) + analysis_result: str = dspy.OutputField( + desc='JSON object with: {"is_greeting": bool, "can_answer_from_context": bool, "answer": str|null, "reasoning": str}. ' + "For greetings, generate a friendly response in the same language. " + "For context references, extract the answer from conversation history if available." + ) + + +class ConversationSummarySignature(dspy.Signature): + """Generate a concise summary of conversation history. + + Summarize the key topics, facts, decisions, and information discussed + in the conversation. Preserve specific details like numbers, names, + dates, and other factual information that might be referenced later. + + The summary should be in the SAME language as the conversation. + """ + + conversation_history: str = dspy.InputField( + desc="Conversation history formatted as JSON to summarize" + ) + summary: str = dspy.OutputField( + desc="Concise summary capturing key topics, facts, and information discussed. " + "Preserve specific details (numbers, names, dates) that could be referenced later." + ) + + +class SummaryAnalysisSignature(dspy.Signature): + """Analyze if a user query can be answered from a conversation summary. + + Given a summary of earlier conversation and the current user query, + determine if the query references information from the summarized conversation. + If yes, generate an appropriate answer based on the summary. + + The response should be in the SAME language as the user's query. + """ + + conversation_summary: str = dspy.InputField( + desc="Summary of earlier conversation history" + ) + user_query: str = dspy.InputField( + desc="Current user query to check against the conversation summary" + ) + analysis_result: str = dspy.OutputField( + desc='JSON object with: {"can_answer_from_context": bool, "answer": str|null, "reasoning": str}. ' + "If the query references information from the summary, extract/generate the answer. " + "If the summary does not contain relevant information, set can_answer_from_context to false." + ) + + +class ContextDetectionResult(BaseModel): + """Result of Phase 1 context detection (classify only, no answer generation).""" + + is_greeting: bool = Field(..., description="Whether the query is a greeting") + greeting_type: str = Field( + default="hello", + description="Type of greeting: hello, goodbye, thanks, or casual", + ) + can_answer_from_context: bool = Field( + ..., description="Whether the query can be answered from conversation history" + ) + reasoning: str = Field(..., description="Brief explanation of the detection") + answered_from_summary: bool = Field( + default=False, + description="Whether summary analysis was used for detection", + ) + # Relevant context snippet extracted for use in Phase 2 generation + context_snippet: Optional[str] = Field( + default=None, + description="The relevant part of history/summary to answer from, for Phase 2", + ) + + +class ContextDetectionSignature(dspy.Signature): + """Detect if a user query is a greeting or can be answered from conversation history. + + Phase 1 (detection only): classify the query WITHOUT generating the answer. + + Supported greeting types: + - hello: Tere, Hello, Hi, Hei, Hey, Moi, Good morning/afternoon/evening + - goodbye: Nägemist, Bye, Goodbye, See you, Good night + - thanks: Tänan, Aitäh, Thank you, Thanks, Much appreciated + - casual: Tervist, Tšau, Moikka + + IMPORTANT — Greeting + Question distinction: + - A message is a greeting ONLY if it contains NOTHING beyond the greeting itself + (e.g. "Hello!", "Tere!", "Thanks!", "Aitäh!"). + - If the message contains a greeting AND a substantive question or request + (e.g. "Hello, how to show uninterest to a policy?", + "Tere, mis on sünnitoetus?", "Hi, what are the tax benefits?"), + set is_greeting to FALSE. The question must be answered via RAG, not a greeting template. + - When in doubt, prefer is_greeting=false so the user's question is answered. + + Do NOT generate the answer here — only detect and extract a relevant context snippet. + """ + + conversation_history: str = dspy.InputField( + desc="Recent conversation history (last 10 turns) formatted as JSON" + ) + user_query: str = dspy.InputField(desc="Current user query to classify") + detection_result: str = dspy.OutputField( + desc='JSON object with: {"is_greeting": bool, "greeting_type": str, "can_answer_from_context": bool, ' + '"reasoning": str, "context_snippet": str|null}. ' + 'greeting_type must be one of: "hello", "goodbye", "thanks", "casual" — ' + 'set it only when is_greeting is true, defaulting to "hello" otherwise. ' + "context_snippet should contain the relevant excerpt from history if can_answer_from_context is true, " + "or null otherwise. Do NOT generate the final answer — only detect and extract. " + "CRITICAL: is_greeting must be false when the message contains a question or request alongside the greeting." + ) + + +class ContextResponseGenerationSignature(dspy.Signature): + """Generate a response to a user query based on conversation history context. + + Phase 2 (generation): given the user query and relevant context, generate a helpful answer. + Respond in the SAME language as the user query. + """ + + context_snippet: str = dspy.InputField( + desc="Relevant excerpt from conversation history or summary that contains the answer" + ) + user_query: str = dspy.InputField(desc="Current user query to answer") + answer: str = dspy.OutputField( + desc="A helpful, natural response to the user query based on the provided context. " + "Respond in the same language as the user query." + ) + + +class ContextAnalyzer: + """ + Analyzer for greeting detection and context-based question answering. + + This class uses an LLM to intelligently detect: + - Greetings in multiple languages (Estonian, English) + - Questions that reference conversation history + - Generate appropriate responses based on context + + Example Usage: + analyzer = ContextAnalyzer(llm_manager) + result = await analyzer.analyze_context( + query="Tere!", + conversation_history=[], + language="et" + ) + # result.is_greeting = True + # result.answer = "Tere! Kuidas ma saan sind aidata?" + """ + + def __init__(self, llm_manager: Any) -> None: # noqa: ANN401 + """ + Initialize the context analyzer. + + Args: + llm_manager: LLM manager instance for making LLM calls + """ + self.llm_manager = llm_manager + self._module: Optional[dspy.Module] = None + self._summary_module: Optional[dspy.Module] = None + self._summary_analysis_module: Optional[dspy.Module] = None + # Phase 1 & 2 modules for two-phase detection+generation flow + self._detection_module: Optional[dspy.Module] = None + self._response_generation_module: Optional[dspy.Module] = None + logger.info("Context analyzer initialized") + + def _format_conversation_history( + self, conversation_history: List[Dict[str, Any]], max_turns: int = 10 + ) -> str: + """ + Format conversation history for LLM consumption. + + Args: + conversation_history: List of conversation items with authorRole, message, timestamp + max_turns: Maximum number of turns to include (default: 10) + + Returns: + Formatted conversation history as JSON string + """ + # Take last N turns + recent_history = ( + conversation_history[-max_turns:] if conversation_history else [] + ) + + # Format as readable JSON + formatted_history = [ + { + "role": item.get("authorRole", "unknown"), + "message": item.get("message", ""), + "timestamp": item.get("timestamp", ""), + } + for item in recent_history + ] + + if not formatted_history: + return "[]" + + return json.dumps(formatted_history, ensure_ascii=False, indent=2) + + @staticmethod + def _merge_cost_dicts( + cost1: Dict[str, Any], cost2: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Merge two cost dictionaries by summing numeric values. + + Args: + cost1: First cost dictionary + cost2: Second cost dictionary + + Returns: + Merged cost dictionary with summed values + """ + return { + "total_cost": cost1.get("total_cost", 0) + cost2.get("total_cost", 0), + "total_tokens": cost1.get("total_tokens", 0) + cost2.get("total_tokens", 0), + "total_prompt_tokens": cost1.get("total_prompt_tokens", 0) + + cost2.get("total_prompt_tokens", 0), + "total_completion_tokens": cost1.get("total_completion_tokens", 0) + + cost2.get("total_completion_tokens", 0), + "num_calls": cost1.get("num_calls", 0) + cost2.get("num_calls", 0), + } + + async def detect_context( + self, + query: str, + conversation_history: List[Dict[str, Any]], + ) -> tuple[ContextDetectionResult, Dict[str, Any]]: + """ + Phase 1: Detect if query is a greeting or can be answered from history. + + Classify-only — no answer generated here. Returns a ContextDetectionResult + with is_greeting/can_answer_from_context flags and a context_snippet for + Phase 2 generation. + + Args: + query: User query to classify + conversation_history: Full conversation history + + Returns: + Tuple of (ContextDetectionResult, cost_dict) + """ + total_turns = len(conversation_history) + logger.info( + f"CONTEXT DETECTOR: Phase 1 | Query: '{query[:100]}' | " + f"History: {total_turns} turns" + ) + + history_length_before = 0 + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception as e: + logger.warning(f"Failed to get LM history length for detection: {e}") + + formatted_history = self._format_conversation_history(conversation_history) + + self.llm_manager.ensure_global_config() + try: + with self.llm_manager.use_task_local(): + if self._detection_module is None: + self._detection_module = dspy.ChainOfThought( + ContextDetectionSignature + ) + response = self._detection_module( + conversation_history=formatted_history, + user_query=query, + ) + + try: + detection_data = json.loads(response.detection_result) + except json.JSONDecodeError: + logger.warning( + f"Failed to parse detection response: {response.detection_result[:100]}" + ) + detection_data = { + "is_greeting": False, + "can_answer_from_context": False, + "reasoning": "Failed to parse detection response", + "context_snippet": None, + } + + result = ContextDetectionResult( + is_greeting=detection_data.get("is_greeting", False), + greeting_type=detection_data.get("greeting_type", "hello"), + can_answer_from_context=detection_data.get( + "can_answer_from_context", False + ), + reasoning=detection_data.get("reasoning", "Detection completed"), + context_snippet=detection_data.get("context_snippet"), + ) + logger.info( + f"DETECTION RESULT | Greeting: {result.is_greeting} | " + f"Can Answer: {result.can_answer_from_context} | " + f"Has snippet: {result.context_snippet is not None}" + ) + + except Exception as e: + logger.error(f"Context detection failed: {e}", exc_info=True) + result = ContextDetectionResult( + is_greeting=False, + can_answer_from_context=False, + reasoning=f"Detection error: {str(e)}", + ) + + cost_dict = get_lm_usage_since(history_length_before) + logger.info( + f"Detection cost | Total: ${cost_dict.get('total_cost', 0):.6f} | " + f"Tokens: {cost_dict.get('total_tokens', 0)}" + ) + return result, cost_dict + + async def detect_context_with_summary_fallback( + self, + query: str, + conversation_history: List[Dict[str, Any]], + ) -> tuple[ContextDetectionResult, Dict[str, Any]]: + """ + Phase 1 with summary fallback: detect if query can be answered from history. + + Implements a 3-step flow: + 1. Check the last 10 turns via detect_context(). + 2. If cannot answer AND total history > 10 turns: + - Generate a concise summary of the older turns (everything before the last 10). + - Check whether the query can be answered from that summary. + 3. If still cannot answer, return can_answer=False (workflow falls back to RAG). + + When the summary path succeeds, the returned ContextDetectionResult has: + - can_answer_from_context=True + - answered_from_summary=True + - context_snippet set to the answer extracted from the summary, so that + Phase 2 (stream_context_response / generate_context_response) can use it + directly as the context for response generation. + + Args: + query: User query to classify + conversation_history: Full conversation history + + Returns: + Tuple of (ContextDetectionResult, cost_dict) + """ + total_turns = len(conversation_history) + + # Step 1: check the most recent 10 turns + result, cost_dict = await self.detect_context( + query=query, conversation_history=conversation_history + ) + + # If already answered or it's a greeting, return immediately + if result.is_greeting or result.can_answer_from_context: + return result, cost_dict + + # Step 2 & 3: if history exceeds 10 turns, try summary-based detection + if total_turns > 10: + logger.info( + f"History has {total_turns} turns (> 10) | " + f"Cannot answer from recent 10 | Attempting summary-based detection" + ) + older_history = conversation_history[:-10] + logger.info(f"Summarizing {len(older_history)} older turns") + + try: + summary, summary_cost = await self._generate_conversation_summary( + older_history + ) + cost_dict = self._merge_cost_dicts(cost_dict, summary_cost) + + if summary: + summary_result, analysis_cost = await self._analyze_from_summary( + query=query, summary=summary + ) + cost_dict = self._merge_cost_dicts(cost_dict, analysis_cost) + + if summary_result.can_answer_from_context and summary_result.answer: + logger.info( + f"DETECTION: Can answer from summary | " + f"Reasoning: {summary_result.reasoning}" + ) + # Surface the summary-derived answer as context_snippet so + # Phase 2 can generate a polished response from it. + return ContextDetectionResult( + is_greeting=False, + can_answer_from_context=True, + reasoning=summary_result.reasoning, + context_snippet=summary_result.answer, + answered_from_summary=True, + ), cost_dict + + logger.info( + "Cannot answer from summary either | Falling back to RAG" + ) + else: + logger.warning( + "Summary generation returned empty | Falling back to RAG" + ) + + except Exception as e: + logger.error(f"Summary-based detection failed: {e}", exc_info=True) + else: + logger.info( + f"History has {total_turns} turns (<= 10) | " + f"No summary needed | Falling back to RAG" + ) + + return result, cost_dict + + @staticmethod + def _yield_in_chunks(text: str, chunk_size: int = 5) -> list[str]: + """Split text into word-group chunks for simulated streaming.""" + words = text.split() + chunks = [] + for i in range(0, len(words), chunk_size): + group = words[i : i + chunk_size] + trailing = " " if i + chunk_size < len(words) else "" + chunks.append(" ".join(group) + trailing) + return chunks + + async def stream_context_response( + self, + query: str, + context_snippet: str, + ) -> AsyncIterator[str]: + """ + Phase 2 (streaming): Stream a generated answer using DSPy native streaming. + + Creates a fresh streamify predictor per call (avoids stale StreamListener + issues that occur when the cached predictor is reused across calls). + + Fallback chain: + 1. DSPy streamify → yield StreamResponse tokens as they arrive. + 2. If no stream tokens received but final Prediction has an answer, + yield it in word-group chunks. + 3. If that is also empty, call generate_context_response() directly + and yield the result in word-group chunks. + + Args: + query: The user query to answer + context_snippet: Relevant context extracted during Phase 1 detection + + Yields: + Token strings as they arrive from the LLM (or simulated chunks) + """ + logger.info(f"CONTEXT GENERATOR: Phase 2 streaming | Query: '{query[:100]}'") + + self.llm_manager.ensure_global_config() + output_stream = None + stream_started = False + prediction_answer: Optional[str] = None + try: + with self.llm_manager.use_task_local(): + # Always create a fresh StreamListener + streamified predictor so that + # the listener's internal state is clean for this call. + answer_listener = StreamListener(signature_field_name="answer") + stream_predictor: Any = dspy.streamify( + dspy.Predict(ContextResponseGenerationSignature), + stream_listeners=[answer_listener], + ) + output_stream = stream_predictor( + context_snippet=context_snippet, + user_query=query, + ) + + async for chunk in output_stream: + if isinstance(chunk, dspy.streaming.StreamResponse): + if chunk.signature_field_name == "answer": + stream_started = True + yield chunk.chunk + elif isinstance(chunk, dspy.Prediction): + logger.info( + "Context response streaming complete (final Prediction received)" + ) + if not stream_started: + # Tokens didn't stream — extract answer from the Prediction + # directly as first fallback before leaving the LM context. + prediction_answer = getattr(chunk, "answer", "") or "" + + except GeneratorExit: + raise + except Exception as e: + logger.error(f"Error during context response streaming: {e}") + raise + finally: + if output_stream is not None: + try: + await output_stream.aclose() + except Exception as cleanup_error: + logger.debug( + f"Error during context stream cleanup: {cleanup_error}" + ) + + if stream_started: + return + + # Fallback 1: answer was in the final Prediction but didn't stream as tokens + if prediction_answer: + logger.warning( + "Stream tokens not received — yielding answer from final Prediction in chunks." + ) + for text_chunk in self._yield_in_chunks(prediction_answer): + yield text_chunk + return + + # Fallback 2: Prediction had no answer either — call generate_context_response + logger.warning( + "No answer from streamify — falling back to generate_context_response." + ) + fallback_answer, _ = await self.generate_context_response( + query=query, context_snippet=context_snippet + ) + if fallback_answer: + for text_chunk in self._yield_in_chunks(fallback_answer): + yield text_chunk + else: + logger.error("All Phase 2 streaming fallbacks exhausted — empty response.") + + async def generate_context_response( + self, + query: str, + context_snippet: str, + ) -> tuple[str, Dict[str, Any]]: + """ + Phase 2 (non-streaming): Generate a complete answer from context snippet. + + Used for non-streaming mode after Phase 1 detection confirms context can answer. + + Args: + query: The user query to answer + context_snippet: Relevant context extracted during Phase 1 detection + + Returns: + Tuple of (answer_text, cost_dict) + """ + logger.info( + f"CONTEXT GENERATOR: Phase 2 non-streaming | Query: '{query[:100]}'" + ) + + history_length_before = 0 + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception as e: + logger.warning(f"Failed to get LM history length for generation: {e}") + + self.llm_manager.ensure_global_config() + answer = "" + try: + with self.llm_manager.use_task_local(): + if self._response_generation_module is None: + self._response_generation_module = dspy.ChainOfThought( + ContextResponseGenerationSignature + ) + response = self._response_generation_module( + context_snippet=context_snippet, + user_query=query, + ) + answer = getattr(response, "answer", "") or "" + logger.info( + f"Context response generated: {len(answer)} chars | " + f"Preview: '{answer[:150]}'" + ) + except Exception as e: + logger.error(f"Context response generation failed: {e}", exc_info=True) + + cost_dict = get_lm_usage_since(history_length_before) + logger.info( + f"Generation cost | Total: ${cost_dict.get('total_cost', 0):.6f} | " + f"Tokens: {cost_dict.get('total_tokens', 0)}" + ) + return answer, cost_dict + + async def _generate_conversation_summary( + self, + older_history: List[Dict[str, Any]], + ) -> tuple[str, Dict[str, Any]]: + """ + Generate a concise summary of older conversation turns. + + Args: + older_history: Conversation turns older than the recent 10 + + Returns: + Tuple of (summary_text, cost_dict) + """ + logger.info(f"SUMMARY GENERATION: Summarizing {len(older_history)} older turns") + + # Track costs + history_length_before = 0 + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception as e: + logger.warning(f"Failed to get LM history length for summary: {e}") + + # Format older history + formatted_history = self._format_conversation_history( + older_history, max_turns=len(older_history) + ) + + # Initialize and run summary module within task-local LLM config + try: + self.llm_manager.ensure_global_config() + with self.llm_manager.use_task_local(): + if self._summary_module is None: + self._summary_module = dspy.ChainOfThought( + ConversationSummarySignature + ) + response = self._summary_module( + conversation_history=formatted_history, + ) + summary = response.summary + logger.info( + f"Summary generated: {len(summary)} chars | " + f"Preview: '{summary[:150]}...'" + ) + except Exception as e: + logger.error(f"Summary generation failed: {e}", exc_info=True) + summary = "" + + cost_dict = get_lm_usage_since(history_length_before) + logger.info( + f"Summary cost | Total: ${cost_dict.get('total_cost', 0):.6f} | " + f"Tokens: {cost_dict.get('total_tokens', 0)}" + ) + + return summary, cost_dict + + async def _analyze_from_summary( + self, + query: str, + summary: str, + ) -> tuple[ContextAnalysisResult, Dict[str, Any]]: + """ + Check if a query can be answered from a conversation summary. + + Args: + query: User query to check + summary: Summary of older conversation turns + + Returns: + Tuple of (ContextAnalysisResult, cost_dict) + """ + logger.info( + f"SUMMARY ANALYSIS: Checking query against summary | Query: '{query[:100]}'" + ) + + # Ensure DSPy is configured and run analysis in a task-local LM context + self.llm_manager.ensure_global_config() + history_length_before = 0 + with self.llm_manager.use_task_local(): + # Track costs + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception as e: + logger.warning( + f"Failed to get LM history length for summary analysis: {e}" + ) + # Initialize summary analysis module if needed + if self._summary_analysis_module is None: + self._summary_analysis_module = dspy.ChainOfThought( + SummaryAnalysisSignature + ) + try: + response = self._summary_analysis_module( + conversation_summary=summary, + user_query=query, + ) + # Parse JSON response + try: + analysis_data = json.loads(response.analysis_result) + except json.JSONDecodeError: + logger.warning( + f"Failed to parse summary analysis response: " + f"{response.analysis_result[:100]}" + ) + analysis_data = { + "can_answer_from_context": False, + "answer": None, + "reasoning": "Failed to parse summary analysis response", + } + can_answer = analysis_data.get("can_answer_from_context", False) + answer = analysis_data.get("answer") + reasoning = analysis_data.get("reasoning", "Summary analysis completed") + logger.debug( + f"Raw summary analysis parsed | " + f"can_answer_from_context={can_answer} | " + f"has_answer={answer is not None}" + ) + # Only mark as answerable when both the LLM flag is True AND an answer exists + can_answer_from_context = bool(can_answer and answer) + result = ContextAnalysisResult( + is_greeting=False, + can_answer_from_context=can_answer_from_context, + answer=answer, + reasoning=reasoning, + answered_from_summary=can_answer_from_context, + ) + logger.info( + "SUMMARY ANALYSIS RESULT | " + f"Can answer from summary: {can_answer} | " + f"Can answer from context: {can_answer_from_context} | " + f"Has answer: {answer is not None} | Reasoning: {reasoning}" + ) + except Exception as e: + logger.error(f"Summary analysis failed: {e}", exc_info=True) + result = ContextAnalysisResult( + is_greeting=False, + can_answer_from_context=False, + answer=None, + reasoning=f"Summary analysis error: {str(e)}", + ) + + cost_dict = get_lm_usage_since(history_length_before) + logger.info( + f"Summary analysis cost | Total: ${cost_dict.get('total_cost', 0):.6f} | " + f"Tokens: {cost_dict.get('total_tokens', 0)}" + ) + + return result, cost_dict + + async def analyze_context( + self, + query: str, + conversation_history: List[Dict[str, Any]], + language: str = "et", + ) -> tuple[ContextAnalysisResult, Dict[str, Any]]: + """ + Analyze if query is a greeting or can be answered from conversation history. + + Implements a 3-step flow: + 1. Analyze recent 10 turns for greetings and history-answerable queries + 2. If cannot answer and total history > 10 turns, generate a summary of older turns + 3. Check if the query can be answered from the summary + 4. If still cannot answer, return cannot-answer result (falls through to RAG) + + Args: + query: User query to analyze + conversation_history: List of conversation items + language: Language code (et, en) for response generation + + Returns: + Tuple of (ContextAnalysisResult, cost_dict) + """ + total_turns = len(conversation_history) + logger.info( + f"CONTEXT ANALYZER: Starting analysis | Query: '{query[:100]}' | " + f"History: {total_turns} turns | Language: {language}" + ) + + # STEP 1: Analyze recent 10 turns (existing behavior) + result, cost_dict = await self._analyze_recent_history( + query=query, + conversation_history=conversation_history, + language=language, + ) + + # If greeting or can answer from recent history, return immediately + if (result.is_greeting or result.can_answer_from_context) and result.answer: + logger.info( + f"Answered from recent history | " + f"Greeting: {result.is_greeting} | From context: {result.can_answer_from_context}" + ) + return result, cost_dict + + # STEP 2 & 3: If history > 10 turns and couldn't answer from recent, try summary + if total_turns > 10: + logger.info( + f"History exceeds 10 turns ({total_turns} total) | " + f"Cannot answer from recent 10 | Attempting summary-based analysis" + ) + + # Get older turns (everything before the last 10) + older_history = conversation_history[:-10] + logger.info(f"Older history: {len(older_history)} turns to summarize") + + try: + # Generate summary of older turns + summary, summary_cost = await self._generate_conversation_summary( + older_history + ) + cost_dict = self._merge_cost_dicts(cost_dict, summary_cost) + + if summary: + # Analyze query against summary + summary_result, analysis_cost = await self._analyze_from_summary( + query=query, + summary=summary, + ) + cost_dict = self._merge_cost_dicts(cost_dict, analysis_cost) + + if summary_result.can_answer_from_context and summary_result.answer: + logger.info( + f"Answered from conversation summary | " + f"Reasoning: {summary_result.reasoning}" + ) + return summary_result, cost_dict + + logger.info( + "Cannot answer from summary either | Falling back to RAG" + ) + else: + logger.warning( + "Summary generation returned empty | Falling back to RAG" + ) + + except Exception as e: + logger.error(f"Summary-based analysis failed: {e}", exc_info=True) + else: + logger.info( + f"History has {total_turns} turns (<= 10) | " + f"No summary needed | Falling back to RAG" + ) + + # Cannot answer from context at all + logger.info( + f"CONTEXT ANALYZER FINAL DECISION | " + f"can_answer_from_context={result.can_answer_from_context} | " + f"is_greeting={result.is_greeting} | " + f"answered_from_summary={result.answered_from_summary} | " + f"has_answer={result.answer is not None} | " + f"action={'RESPOND' if (result.can_answer_from_context or result.is_greeting) and result.answer else 'FALLBACK_TO_RAG'}" + ) + return result, cost_dict + + async def _analyze_recent_history( + self, + query: str, + conversation_history: List[Dict[str, Any]], + language: str = "et", + ) -> tuple[ContextAnalysisResult, Dict[str, Any]]: + """ + Analyze the query against the most recent conversation turns. + + This is the original analysis logic extracted into its own method. + Checks for greetings and history-answerable queries in the last 10 turns. + + Args: + query: User query to analyze + conversation_history: Full conversation history (last 10 will be used) + language: Language code for response generation + + Returns: + Tuple of (ContextAnalysisResult, cost_dict) + """ + logger.info("STEP 1: Analyzing recent history (last 10 turns)") + + # Track LLM history for cost calculation + history_length_before = 0 + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception as e: + logger.warning(f"Failed to get LM history length: {e}") + + # Format conversation history (last 10 turns) + formatted_history = self._format_conversation_history(conversation_history) + + # Ensure LM is configured and use task-local context for DSPy operations + self.llm_manager.ensure_global_config() + try: + with self.llm_manager.use_task_local(): + # Initialize DSPy module if not already done + if self._module is None: + self._module = dspy.ChainOfThought(ContextAnalysisSignature) + # Call LLM for analysis + logger.info( + "Calling LLM for context analysis (greeting/history check)..." + ) + response = self._module( + conversation_history=formatted_history, + user_query=query, + ) + + # Parse the analysis result + analysis_json = response.analysis_result + + # Try to parse JSON response + try: + analysis_data = json.loads(analysis_json) + logger.debug( + f"Raw LLM response parsed | " + f"can_answer_from_context={analysis_data.get('can_answer_from_context')} | " + f"is_greeting={analysis_data.get('is_greeting')} | " + f"has_answer={analysis_data.get('answer') is not None}" + ) + except json.JSONDecodeError: + logger.warning( + f"Failed to parse LLM response as JSON: {analysis_json[:100]}" + ) + # Fallback: treat as cannot answer + analysis_data = { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Failed to parse LLM response", + } + + # Create result object + result = ContextAnalysisResult( + is_greeting=analysis_data.get("is_greeting", False), + can_answer_from_context=analysis_data.get( + "can_answer_from_context", False + ), + answer=analysis_data.get("answer"), + reasoning=analysis_data.get("reasoning", "Analysis completed"), + ) + + logger.info( + f"ANALYSIS RESULT | Greeting: {result.is_greeting} | " + f"Can Answer from Context: {result.can_answer_from_context} | " + f"Answer: {result.answer[:100] if result.answer else None} | " + f"Reasoning: {result.reasoning}" + ) + + # If greeting detected but LLM didn't generate an answer, use fallback + if result.is_greeting and result.answer is None: + greeting_type = self._detect_greeting_type(query) + fallback_answer = get_greeting_response(greeting_type, language) + result = ContextAnalysisResult( + is_greeting=result.is_greeting, + can_answer_from_context=result.can_answer_from_context, + answer=fallback_answer, + reasoning=result.reasoning, + ) + + except Exception as e: + logger.error(f"Context analysis failed: {e}", exc_info=True) + # Fallback result + result = ContextAnalysisResult( + is_greeting=False, + can_answer_from_context=False, + answer=None, + reasoning=f"Analysis error: {str(e)}", + ) + + # Calculate costs + cost_dict = get_lm_usage_since(history_length_before) + logger.info( + f"Cost tracking | Total cost: ${cost_dict.get('total_cost', 0):.6f} | " + f"Tokens: {cost_dict.get('total_tokens', 0)} | " + f"Calls: {cost_dict.get('num_calls', 0)}" + ) + + return result, cost_dict + + def _detect_greeting_type(self, query: str) -> str: + """ + Detect the type of greeting from the query text. + + Args: + query: User query string + + Returns: + Greeting type: 'thanks', 'goodbye', 'casual', or 'hello' (default) + """ + query_lower = query.lower().strip() + thanks_keywords = ["thank", "thanks", "tänan", "aitäh", "tänud"] + goodbye_keywords = ["bye", "goodbye", "nägemist", "tsau", "tšau", "head aega"] + casual_keywords = ["hei", "hey", "moi", "moikka"] + for kw in thanks_keywords: + if kw in query_lower: + return "thanks" + for kw in goodbye_keywords: + if kw in query_lower: + return "goodbye" + for kw in casual_keywords: + if kw in query_lower: + return "casual" + return "hello" + + def get_fallback_greeting_response(self, language: str = "et") -> str: + """ + Get a fallback greeting response without LLM call. + + Used when LLM-based greeting detection fails but we still want + to provide a friendly response. + + Args: + language: Language code (et, en) + + Returns: + Greeting message in the specified language + """ + greetings = { + "et": "Tere! Kuidas ma saan sind aidata?", + "en": "Hello! How can I help you?", + } + return greetings.get(language, greetings["et"]) diff --git a/src/tool_classifier/enums.py b/src/tool_classifier/enums.py new file mode 100644 index 00000000..f734bc7d --- /dev/null +++ b/src/tool_classifier/enums.py @@ -0,0 +1,63 @@ +"""Enumerations and constants for tool classifier system.""" + +from enum import Enum + + +class WorkflowType(Enum): + """ + Workflow types representing different query handling strategies. + + The tool classifier uses a layer-wise approach to determine which + workflow should handle each user query: + + - SERVICE: External service/API calls (Layer 1) + - API_TOOL_CALLING: External API tool calling via agentic loop (Layer 2) + - CONTEXT: Conversation history or greetings (Layer 3) + - RAG: Knowledge base retrieval (Layer 4) + - OOD: Out-of-domain fallback (Layer 5) + """ + + SERVICE = "service" + API_TOOL_CALLING = "api_tool_calling" + CONTEXT = "context" + RAG = "rag" + OOD = "ood" + + +# Layer configuration - defines the order of workflow evaluation +WORKFLOW_LAYER_ORDER = [ + WorkflowType.SERVICE, # Layer 1: Try service first + WorkflowType.API_TOOL_CALLING, # Layer 2: Try API tool calling + WorkflowType.CONTEXT, # Layer 3: Then context + WorkflowType.RAG, # Layer 4: Then RAG + WorkflowType.OOD, # Layer 5: Finally OOD (always succeeds) +] + +# Workflow display names for logging +WORKFLOW_DISPLAY_NAMES = { + WorkflowType.SERVICE: "Service Workflow", + WorkflowType.API_TOOL_CALLING: "API Tool Calling Workflow", + WorkflowType.CONTEXT: "Context Workflow", + WorkflowType.RAG: "RAG Workflow", + WorkflowType.OOD: "Out-of-Domain Workflow", +} + + +class AgenticLoopStatus(str, Enum): + """ + Status values returned by the agentic loop after each turn. + + - COMPLETED: All required parameters have been collected. + - NEEDS_INPUT: One or more required parameters are still missing; + a clarifying question is available for the user. + - MAX_TURNS_REACHED: The turn limit was hit before collection completed; + the caller should fall back gracefully. + - AWAITING_CONTINUATION_DECISION: The continuation threshold has been reached + with params still missing; a yes/no question is returned asking whether to + keep collecting or fall back to the RAG workflow. + """ + + COMPLETED = "completed" + NEEDS_INPUT = "needs_input" + MAX_TURNS_REACHED = "max_turns_reached" + AWAITING_CONTINUATION_DECISION = "awaiting_continuation_decision" diff --git a/src/tool_classifier/greeting_constants.py b/src/tool_classifier/greeting_constants.py new file mode 100644 index 00000000..272d6a4c --- /dev/null +++ b/src/tool_classifier/greeting_constants.py @@ -0,0 +1,40 @@ +"""Constants for greeting responses in multiple languages.""" + +from typing import Dict + +# Estonian greeting responses +GREETINGS_ET: Dict[str, str] = { + "hello": "Tere! Kuidas ma saan sind aidata?", + "goodbye": "Nägemist! Head päeva!", + "thanks": "Palun! Kui on veel küsimusi, küsi julgelt.", + "casual": "Tere! Mida ma saan sinu jaoks teha?", +} + +# English greeting responses +GREETINGS_EN: Dict[str, str] = { + "hello": "Hello! How can I help you?", + "goodbye": "Goodbye! Have a great day!", + "thanks": "You're welcome! Feel free to ask if you have more questions.", + "casual": "Hey! What can I do for you?", +} + +# Language-specific greeting mappings +GREETINGS_BY_LANGUAGE: Dict[str, Dict[str, str]] = { + "et": GREETINGS_ET, + "en": GREETINGS_EN, +} + + +def get_greeting_response(greeting_type: str = "hello", language: str = "et") -> str: + """ + Get a greeting response for a specific type and language. + + Args: + greeting_type: Type of greeting (hello, goodbye, thanks, casual) + language: Language code (et, en) + + Returns: + Greeting message in the specified language + """ + language_greetings = GREETINGS_BY_LANGUAGE.get(language, GREETINGS_EN) + return language_greetings.get(greeting_type, language_greetings["hello"]) diff --git a/src/tool_classifier/intent_detector.py b/src/tool_classifier/intent_detector.py new file mode 100644 index 00000000..a2abb74f --- /dev/null +++ b/src/tool_classifier/intent_detector.py @@ -0,0 +1,133 @@ +"""Service intent detection using DSPy.""" + +import json +from typing import Any, Dict, List, Optional + +import dspy +from loguru import logger + + +class ServiceIntentDetector(dspy.Signature): + """Detect which service matches user intent and extract entities. + + CRITICAL LANGUAGE RULE: + - Understand Estonian, Russian, and English queries + - Extract entities in their original form from the query + + Rules: + - Match user query against available services + - Extract required entity values from the query + - Return valid JSON format strictly + - If no service matches well (confidence < 0.7), return null for matched_service_id + - Be conservative - only match when confident + - Prioritize services whose examples closely match the user query + """ + + user_query: str = dspy.InputField( + desc="User's question/request in Estonian, Russian, or English" + ) + available_services: str = dspy.InputField( + desc="JSON string of available services with id, name, description, entities, examples" + ) + conversation_context: str = dspy.InputField( + desc="Recent conversation history for context (optional, may be empty)" + ) + + intent_result: str = dspy.OutputField( + desc='Valid JSON only: {"matched_service_id": "id_string" or null, "confidence": 0.0-1.0, "entities": {}, "reasoning": "brief explanation"}' + ) + + +class IntentDetectionModule(dspy.Module): + """DSPy Module for service intent detection.""" + + def __init__(self) -> None: + """Initialize intent detection module with Predict (direct prediction).""" + super().__init__() + self.detector = dspy.Predict(ServiceIntentDetector) + + def forward( + self, + user_query: str, + services: List[Dict[str, Any]], + conversation_history: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + """ + Detect service intent using LLM via DSPy. + + Args: + user_query: User's query + services: List of service dicts with serviceId, name, description, entities, examples + conversation_history: Recent messages (optional) + + Returns: + Parsed intent result dict with matched_service_id, confidence, entities, reasoning + """ + # Format services for prompt (keep it concise) + services_formatted = [] + for s in services: + service_entry = { + "service_id": s.get("serviceId", s.get("service_id")), + "name": s.get("name", "Unknown"), + "description": s.get("description", ""), + "required_entities": s.get("entities", []), + "examples": s.get("examples", [])[:3], # Top 3 examples + } + services_formatted.append(service_entry) + + services_json = json.dumps(services_formatted, ensure_ascii=False, indent=2) + + # Format conversation history + if conversation_history: + history_lines = [] + for msg in conversation_history[-3:]: # Last 3 turns + role = msg.get("authorRole", "unknown") + content = msg.get("message", "") + if content: + history_lines.append(f"{role}: {content}") + history_text = "\n".join(history_lines) if history_lines else "(Empty)" + else: + history_text = "(No conversation history)" + + # Call DSPy detector with ChainOfThought + result = None + try: + result = self.detector( + user_query=user_query, + available_services=services_json, + conversation_context=history_text, + ) + + # Parse JSON response + intent_data = json.loads(result.intent_result) + + # Validate structure + if not isinstance(intent_data, dict): + raise ValueError("Intent result is not a dictionary") + + # Ensure required keys exist + intent_data.setdefault("matched_service_id", None) + intent_data.setdefault("confidence", 0.0) + intent_data.setdefault("entities", {}) + intent_data.setdefault("reasoning", "") + + return intent_data + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse intent JSON: {e}") + if result: + logger.error(f"Raw response: {result.intent_result}") + return { + "matched_service_id": None, + "confidence": 0.0, + "entities": {}, + "reasoning": f"JSON parse error: {e}", + } + except Exception as e: + logger.error(f"Intent detection forward failed: {e}", exc_info=True) + return { + "matched_service_id": None, + "confidence": 0.0, + "entities": {}, + "reasoning": f"Detection error: {e}", + } diff --git a/src/tool_classifier/models.py b/src/tool_classifier/models.py new file mode 100644 index 00000000..2f830fd5 --- /dev/null +++ b/src/tool_classifier/models.py @@ -0,0 +1,136 @@ +"""Data models for tool classifier system.""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from pydantic import BaseModel, Field + +from tool_classifier.enums import AgenticLoopStatus, WorkflowType + + +class ClassificationResult(BaseModel): + """ + Result of query classification by the tool classifier. + + This model encapsulates the decision of which workflow should handle + a user query, along with confidence score and metadata. + + Attributes: + workflow: The workflow type that should handle this query + confidence: Confidence score (0.0-1.0) for this classification + metadata: Workflow-specific data (e.g., service_id, intent, entities) + reasoning: Human-readable explanation of why this workflow was chosen + """ + + workflow: WorkflowType = Field( + ..., description="Which workflow should handle this query" + ) + confidence: float = Field( + default=1.0, + ge=0.0, + le=1.0, + description="Confidence score for this classification", + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Workflow-specific data passed to executor" + ) + reasoning: Optional[str] = Field( + default=None, description="Explanation of classification decision" + ) + + +class ServiceWorkflowMetadata(BaseModel): + """ + Metadata specific to Service Workflow execution. + + TODO: Will be populated by service discovery logic with: + - service_id: Identified service to call + - intent: Detected user intent + - entities: Extracted parameters for service call + - confidence: Intent detection confidence + """ + + service_id: Optional[str] = Field( + default=None, description="ID of the service to execute" + ) + intent: Optional[str] = Field( + default=None, description="Detected user intent/service name" + ) + entities: Optional[Dict[str, Any]] = Field( + default=None, description="Extracted entities/parameters" + ) + + +class ContextWorkflowMetadata(BaseModel): + """ + Metadata specific to Context Workflow execution. + + TODO: Will be populated by context analysis logic with: + - is_greeting: Whether query is a greeting + - greeting_type: Type of greeting (hello, goodbye, thanks, etc.) + - can_answer_from_history: Whether conversation history has answer + - relevant_history_indices: Indices of relevant history items + """ + + is_greeting: bool = Field( + default=False, description="Whether this is a greeting/conversational query" + ) + greeting_type: Optional[str] = Field( + default=None, description="Type of greeting: hello, goodbye, thanks, casual" + ) + can_answer_from_history: bool = Field( + default=False, description="Whether conversation history can answer this" + ) + + +@dataclass +class AgenticLoopResult: + """ + Result returned by AgenticLoop.run_turn() after processing one conversation turn. + + Attributes: + status: Outcome of this turn — completed, needs_input, max_turns_reached, + or awaiting_continuation_decision. + collected_params: All parameters collected so far (prior turns + this turn merged). + clarifying_question: Natural-language question to show the user when status is + NEEDS_INPUT or AWAITING_CONTINUATION_DECISION. Empty string for other statuses. + turn_count: Updated turn counter (input turn_count + 1). + """ + + status: AgenticLoopStatus + collected_params: Dict[str, Any] + clarifying_question: str + turn_count: int + + +@dataclass +class APICallResult: + """ + Result returned by APICaller.call() after executing an external HTTP request. + + Attributes: + success: True if the request succeeded (2xx status code). + status_code: HTTP status code returned by the server. 0 when no HTTP response + was received (network error, timeout, or circuit breaker rejection). + response_data: Parsed JSON value on success (dict, list, or scalar); extracted + error body on 4xx; empty string on 5xx, timeout, or network error. + error: Human-readable error message for the user or agentic loop. None when + success is True. Contains the raw API error body for 4xx to support + agentic loop re-prompting; contains a localized friendly message for all + other failure types. + """ + + success: bool + status_code: int + response_data: Any + error: Optional[str] + + @property + def is_client_error(self) -> bool: + """True if the response was a 4xx client error.""" + return 400 <= self.status_code < 500 + + @property + def is_server_error(self) -> bool: + """True if the response was a 5xx server error.""" + return 500 <= self.status_code < 600 diff --git a/src/tool_classifier/param_extractor.py b/src/tool_classifier/param_extractor.py new file mode 100644 index 00000000..26e28f32 --- /dev/null +++ b/src/tool_classifier/param_extractor.py @@ -0,0 +1,489 @@ +"""API parameter extraction using DSPy.""" + +import asyncio +import json +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, TypedDict + +import dspy +import dspy.streaming +from dspy.streaming import StreamListener +from loguru import logger + +_TRUTHY_STRINGS = {"true", "yes", "jah", "1", "on", "õige", "да"} +_FALSY_STRINGS = {"false", "no", "ei", "0", "off", "vale", "нет"} + +_MAX_HISTORY_TURNS = 5 + + +class ParamExtractionResult(TypedDict): + """Return contract for ParamExtractionModule.forward().""" + + extracted_params: Dict[str, Any] + missing_required: List[str] + clarifying_question: str + + +class ParamExtractionSignature(dspy.Signature): + """Extract API parameter values from user message and conversation history. + + CRITICAL LANGUAGE RULE: + - Understand Estonian, English, and Russian input + - Generate clarifying_question in the language specified by session_language + - IGNORE the language of the current user_message for output language decisions — + short follow-up messages ("I'm not sure", "2026-01-01") are unreliable indicators. + Always use session_language. + + Extraction rules: + - Extract values for ALL parameters listed in params_schema that appear in user_message + or conversation_history, regardless of whether they are already in already_collected + - If the user explicitly provides a new or corrected value for a parameter that is + already in already_collected, still extract the new value — it will override the old one + - Only skip extraction for a param if the user has NOT mentioned it at all in this turn + - Validate types: dates must be ISO 8601 (YYYY-MM-DD), integers must be whole numbers, + numbers must be numeric, booleans must be true or false + + missing_required rules: + - List every required parameter (required=true in schema) whose value is absent + AFTER combining already_collected with newly extracted params + - Do NOT list optional parameters as missing + + clarifying_question rules: + - After extraction, check whether ALL required params are now satisfied + (i.e., present in already_collected OR just extracted). + - If ALL required params are satisfied, return the literal string "none". + - If ONE OR MORE required params are still missing, generate ONE friendly question + that asks for ALL of those remaining missing params at once. + On the first turn this may cover many params; on follow-up turns it narrows + to only the params the user has not yet provided. + - Use each missing parameter's description field to phrase the question naturally + (e.g., "Which country and date would you like to use?" not "Provide countryIsoCode and startDate") + - Never expose raw parameter names (camelCase identifiers) to the user + """ + + user_message: str = dspy.InputField( + desc="Current turn message from the user in Estonian, English, or Russian" + ) + conversation_history: str = dspy.InputField( + desc="Recent conversation turns formatted as 'role: message', one per line" + ) + session_language: str = dspy.InputField( + desc=( + "ISO language code for the response language detected from the user's " + "first message: 'en' (English), 'et' (Estonian), 'ru' (Russian). " + "Always generate clarifying_question in this language." + ) + ) + params_schema: str = dspy.InputField( + desc='JSON array of parameter schemas: [{"name": str, "type": str, "required": bool, "description": str}]' + ) + already_collected: str = dspy.InputField( + desc=( + "JSON object of parameter values collected in prior turns: {param_name: value}. " + "Use this as context to understand what has already been provided. " + "If the user explicitly mentions a new value for a param already here, " + "still extract the new value — corrections are allowed." + ) + ) + + extracted_params: str = dspy.OutputField( + desc='Valid JSON object of newly extracted parameters only: {"param_name": value}. Empty object {} if nothing new found.' + ) + missing_required: str = dspy.OutputField( + desc='Valid JSON array of required parameter names still missing after extraction: ["param1", "param2"]. Empty array [] if all required params are satisfied.' + ) + clarifying_question: str = dspy.OutputField( + desc='A single natural-language question that asks for ALL missing parameters at once, or the literal string "none" if all required params are collected.' + ) + + +class ParamExtractionModule(dspy.Module): + """DSPy Module for API parameter extraction from natural language.""" + + def __init__(self) -> None: + """Initialize param extraction module with Predict (direct prediction).""" + super().__init__() + self.extractor = dspy.Predict(ParamExtractionSignature) + + def forward( + self, + user_message: str, + params_schema: List[Dict[str, Any]], + conversation_history: Optional[List[Dict[str, Any]]] = None, + already_collected: Optional[Dict[str, Any]] = None, + session_language: str = "en", + ) -> ParamExtractionResult: + """ + Extract parameter values from user message and conversation history. + + Args: + user_message: Current turn message from the user + params_schema: List of parameter schema dicts with name, type, required, description + conversation_history: Recent conversation messages (optional) + already_collected: Parameter values collected in prior turns (optional) + session_language: Language code detected on turn 0 ('en', 'et', 'ru'). + All clarifying questions will be generated in this language. + + Returns: + ParamExtractionResult with extracted_params, missing_required, clarifying_question + """ + already_collected = already_collected or {} + + history_text = self._format_conversation_history(conversation_history) + params_schema_json = json.dumps(params_schema, ensure_ascii=False) + already_collected_json = json.dumps(already_collected, ensure_ascii=False) + + result = None + try: + result = self.extractor( + user_message=user_message, + conversation_history=history_text, + session_language=session_language, + params_schema=params_schema_json, + already_collected=already_collected_json, + ) + return self._parse_prediction(result, params_schema, already_collected) + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse param extraction JSON: {e}") + if result: + logger.error( + f"Raw extracted_params: {getattr(result, 'extracted_params', None)}" + ) + logger.error( + f"Raw missing_required: {getattr(result, 'missing_required', None)}" + ) + return self._safe_defaults(params_schema, already_collected) + + except Exception as e: + logger.exception(f"Param extraction forward failed: {e}") + return self._safe_defaults(params_schema, already_collected) + + def _get_stream_predictor(self) -> Any: + """Return a fresh streamified predictor for each call. + + See :meth:`~api_response_formatter.APIResponseFormatterModule._get_stream_predictor` + for the rationale — ``dspy.configure(lm=...)`` is called per request so any cached + wrapper becomes stale. Re-creating is cheap (no LLM I/O). + """ + logger.debug( + "ParamExtractionModule: creating fresh streamify wrapper " + "for clarifying_question field" + ) + listener = StreamListener(signature_field_name="clarifying_question") + return dspy.streamify(self.extractor, stream_listeners=[listener]) + + async def stream_forward( + self, + user_message: str, + params_schema: List[Dict[str, Any]], + conversation_history: Optional[List[Dict[str, Any]]] = None, + already_collected: Optional[Dict[str, Any]] = None, + session_language: str = "en", + ) -> tuple[List[str], ParamExtractionResult]: + """Stream clarifying_question tokens while returning the full extraction result. + + Uses the same DSPy streamify pattern as + :meth:`~api_response_formatter.APIResponseFormatterModule.stream_forward`. + Collects ``clarifying_question`` tokens as they arrive from the LLM and + parses ``extracted_params`` / ``missing_required`` from the final + ``dspy.Prediction``. + + If all required params are already collected the question will be ``"none"`` + and the returned token list will be empty. + + Args: + user_message: Current turn message from the user. + params_schema: Parameter schema list. + conversation_history: Recent conversation messages (optional). + already_collected: Parameter values from prior turns (optional). + session_language: Language code (``'en'``, ``'et'``, ``'ru'``). + + Returns: + Tuple of ``(question_tokens, extraction_result)``. + ``question_tokens`` is empty when no clarifying question is needed. + """ + already_collected = already_collected or {} + + history_text = self._format_conversation_history(conversation_history) + params_schema_json = json.dumps(params_schema, ensure_ascii=False) + already_collected_json = json.dumps(already_collected, ensure_ascii=False) + + try: + stream_predictor = self._get_stream_predictor() + output_stream = stream_predictor( + user_message=user_message, + conversation_history=history_text, + session_language=session_language, + params_schema=params_schema_json, + already_collected=already_collected_json, + ) + + tokens: List[str] = [] + prediction: Any = None + + async for chunk in output_stream: + if isinstance(chunk, dspy.streaming.StreamResponse): + if chunk.signature_field_name == "clarifying_question": + tokens.append(chunk.chunk) + elif isinstance(chunk, dspy.Prediction): + prediction = chunk + + if prediction is None: + logger.warning( + "ParamExtractionModule.stream_forward: no Prediction received — " + "falling back to blocking forward()" + ) + result = await asyncio.to_thread( + self.forward, + user_message, + params_schema, + conversation_history, + already_collected, + session_language, + ) + fallback_token = result["clarifying_question"] + return ( + [fallback_token] if fallback_token not in ("", "none") else [], + result, + ) + + result = self._parse_prediction( + prediction, params_schema, already_collected + ) + + # Clear tokens when no question is needed (all params satisfied) + if result["clarifying_question"] in ("", "none"): + tokens = [] + + if tokens: + logger.debug( + f"ParamExtractionModule.stream_forward: streamed {len(tokens)} tokens" + ) + + return tokens, result + + except json.JSONDecodeError as e: + logger.error( + f"ParamExtractionModule.stream_forward failed to parse JSON: {e}" + ) + return [], self._safe_defaults(params_schema, already_collected) + + except Exception as e: + logger.exception(f"ParamExtractionModule.stream_forward failed: {e}") + return [], self._safe_defaults(params_schema, already_collected) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _validate_param_type( + self, value: object, param_type: str + ) -> tuple[bool, object]: + """ + Validate and coerce a raw extracted value to the expected param type. + + Args: + value: Raw value from LLM output + param_type: Expected type from param schema + + Returns: + (is_valid, coerced_value) — coerced_value equals value when is_valid is False + """ + if value is None: + return False, value + + str_value = str(value).strip() + + if param_type == "string": + return True, str_value + + if param_type == "date": + try: + # Accept ISO 8601 date strings; datetime.fromisoformat handles YYYY-MM-DD + parsed = datetime.fromisoformat(str_value) + return True, parsed.date().isoformat() + except (ValueError, TypeError): + return False, value + + if param_type == "datetime": + try: + # Accept ISO 8601 datetime strings with or without timezone suffix. + # Normalise to UTC zone suffix (Z) if none is present. + clean = str_value.replace("Z", "+00:00") + parsed_dt = datetime.fromisoformat(clean) + # Convert timezone-aware datetimes to UTC before formatting. + # Naive datetimes are assumed to already be UTC. + if parsed_dt.tzinfo is not None: + parsed_dt = parsed_dt.astimezone(timezone.utc) + # Re-serialise in the exact format required by the external APIs: + # YYYY-MM-DDTHH:MM:SSZ (no microseconds, UTC Z suffix) + return True, parsed_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + except (ValueError, TypeError): + return False, value + + if param_type == "integer": + try: + # int() already rejects float strings like "3.5" with ValueError + int_val = int(str_value) + return True, int_val + except (ValueError, TypeError): + return False, value + + if param_type == "number": + try: + return True, float(str_value) + except (ValueError, TypeError): + return False, value + + if param_type == "boolean": + lower = str_value.lower() + if lower in _TRUTHY_STRINGS: + return True, True + if lower in _FALSY_STRINGS: + return True, False + return False, value + + # Unknown type — accept as string to avoid silent data loss + logger.warning(f"Unknown param type '{param_type}'; accepting as string") + return True, str_value + + def _format_conversation_history( + self, conversation_history: Optional[List[Dict[str, Any]]] + ) -> str: + """ + Format the last N conversation turns as plain text for the LLM prompt. + + Args: + conversation_history: List of message dicts with authorRole and message keys + + Returns: + Newline-separated string of "role: message" or "(No conversation history)" + """ + if not conversation_history: + return "(No conversation history)" + + history_lines: List[str] = [] + for msg in conversation_history[-_MAX_HISTORY_TURNS:]: + role = msg.get("authorRole", "unknown") + content = msg.get("message", "") + if content: + history_lines.append(f"{role}: {content}") + + return ( + "\n".join(history_lines) if history_lines else "(No conversation history)" + ) + + def _safe_defaults( + self, + params_schema: List[Dict[str, Any]], + already_collected: Dict[str, Any], + ) -> ParamExtractionResult: + """ + Return safe default result when the LLM call or JSON parsing fails. + + All required params not already collected are put in missing_required. + """ + missing_required = [ + p["name"] + for p in params_schema + if isinstance(p, dict) + and p.get("required", False) + and p["name"] not in already_collected + ] + return ParamExtractionResult( + extracted_params={}, + missing_required=missing_required, + clarifying_question="none" if not missing_required else "", + ) + + def _parse_prediction( + self, + result: dspy.Prediction, + params_schema: List[Dict[str, Any]], + already_collected: Dict[str, Any], + ) -> ParamExtractionResult: + """Parse a raw DSPy Prediction into a validated ParamExtractionResult. + + Called by both :meth:`forward` (blocking) and :meth:`stream_forward` (streaming) + so JSON parsing and type-validation logic lives in one place. + + Raises: + json.JSONDecodeError: If ``extracted_params`` or ``missing_required`` is not + valid JSON. + ValueError: If the parsed JSON has the wrong container type. + """ + # Parse extracted_params + extracted_raw = json.loads(result.extracted_params) + if not isinstance(extracted_raw, dict): + raise ValueError("extracted_params is not a JSON object") + + # Parse missing_required + missing_raw = json.loads(result.missing_required) + if not isinstance(missing_raw, list): + raise ValueError("missing_required is not a JSON array") + + clarifying_question = (result.clarifying_question or "").strip() + + # Validate extracted param types against schema + schema_map: Dict[str, Dict[str, Any]] = { + p["name"]: p for p in params_schema if isinstance(p, dict) + } + validated_params: Dict[str, Any] = {} + type_invalid_params: List[str] = [] + + for param_name, raw_value in extracted_raw.items(): + schema_entry = schema_map.get(param_name) + if schema_entry is None: + # Param not in schema — skip silently + continue + param_type = schema_entry.get("type", "string") + is_valid, coerced = self._validate_param_type(raw_value, param_type) + if is_valid: + validated_params[param_name] = coerced + else: + logger.warning( + f"Extracted value for '{param_name}' failed type validation " + f"(expected {param_type}, got {raw_value!r})" + ) + type_invalid_params.append(param_name) + + # Re-derive missing required params after type validation. + # validated_params (current turn) takes precedence over already_collected + # so that explicit user corrections override prior values. + all_collected = {**already_collected, **validated_params} + missing_required: List[str] = [ + p["name"] + for p in params_schema + if isinstance(p, dict) + and p.get("required", False) + and p["name"] not in all_collected + ] + + # Add type-invalid required params back to missing list + for param_name in type_invalid_params: + schema_entry = schema_map.get(param_name) + if ( + schema_entry is not None + and schema_entry.get("required", False) + and param_name not in missing_required + ): + missing_required.append(param_name) + + # Normalise clarifying_question: override with "none" when nothing is missing + if not missing_required: + clarifying_question = "none" + elif clarifying_question.lower() == "none": + # LLM incorrectly returned "none" despite missing params — reset to empty + # string so callers receive a reliable signal that a follow-up is needed. + logger.warning( + "LLM returned clarifying_question='none' but required params are " + f"still missing: {missing_required}. Resetting to empty string." + ) + clarifying_question = "" + + return ParamExtractionResult( + extracted_params=validated_params, + missing_required=missing_required, + clarifying_question=clarifying_question, + ) diff --git a/src/tool_classifier/sparse_encoder.py b/src/tool_classifier/sparse_encoder.py new file mode 100644 index 00000000..06f38a86 --- /dev/null +++ b/src/tool_classifier/sparse_encoder.py @@ -0,0 +1,85 @@ +""" +Sparse vector encoder for BM25-style term frequency vectors. + +Shared module used by both: +- intent_data_enrichment (indexing time) — to create sparse vectors for service examples +- tool_classifier (query time) — to create sparse vectors for user queries + +Uses hash-based indexing compatible with Qdrant's sparse vector format. +""" + +import hashlib +import re +from collections import Counter +from dataclasses import dataclass, field +from typing import List + + +# Hash space for sparse vector indices +# Larger = fewer collisions but more memory; 50K is a good balance for intent classification +SPARSE_VOCAB_SIZE = 50_000 + +# Simple word tokenizer matching the pattern used in contextual_retrieval/bm25_search.py +TOKENIZER_PATTERN = re.compile(r"\w+") + + +@dataclass +class SparseVector: + """Sparse vector representation for Qdrant. + + Attributes: + indices: Sorted list of non-zero dimension indices + values: Corresponding values for each index + """ + + indices: List[int] = field(default_factory=list) + values: List[float] = field(default_factory=list) + + def to_dict(self) -> dict: + """Convert to Qdrant API format.""" + return {"indices": self.indices, "values": self.values} + + def is_empty(self) -> bool: + """Check if the sparse vector has no entries.""" + return len(self.indices) == 0 + + +def compute_sparse_vector(text: str) -> SparseVector: + """Convert text to a sparse vector using term-frequency hashing. + + Tokenizes the input text, counts term frequencies, and maps each token + to a hash-based index in the sparse vector space. This creates a + BM25-compatible representation that Qdrant can use for sparse search. + + Args: + text: Input text to vectorize + + Returns: + SparseVector with hash-based indices and term frequency values + """ + if not text or not text.strip(): + return SparseVector() + + # Tokenize: lowercase and extract word tokens + tokens = TOKENIZER_PATTERN.findall(text.lower()) + if not tokens: + return SparseVector() + + # Count term frequencies + token_counts = Counter(tokens) + + # Hash-based indexing: map each token to an index in [0, SPARSE_VOCAB_SIZE) + # Uses MD5 (first 4 bytes) for deterministic cross-process indices. + # Collisions are handled by summing values at the same index. + hash_counts: dict[int, float] = {} + for token, count in token_counts.items(): + digest = hashlib.md5(token.encode(), usedforsecurity=False).digest() # noqa: S324 + idx = int.from_bytes(digest[:4], "little") % SPARSE_VOCAB_SIZE + # Handle hash collisions by accumulating + hash_counts[idx] = hash_counts.get(idx, 0) + float(count) + + # Sort indices for consistent representation (Qdrant requirement) + sorted_indices = sorted(hash_counts.keys()) + sorted_values = [hash_counts[i] for i in sorted_indices] + + return SparseVector(indices=sorted_indices, values=sorted_values) diff --git a/src/tool_classifier/workflows/__init__.py b/src/tool_classifier/workflows/__init__.py new file mode 100644 index 00000000..01cab0d2 --- /dev/null +++ b/src/tool_classifier/workflows/__init__.py @@ -0,0 +1,15 @@ +"""Workflow executor implementations.""" + +from tool_classifier.workflows.api_tool_workflow import APIToolWorkflowExecutor +from tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor +from tool_classifier.workflows.context_workflow import ContextWorkflowExecutor +from tool_classifier.workflows.rag_workflow import RAGWorkflowExecutor +from tool_classifier.workflows.ood_workflow import OODWorkflowExecutor + +__all__ = [ + "APIToolWorkflowExecutor", + "ServiceWorkflowExecutor", + "ContextWorkflowExecutor", + "RAGWorkflowExecutor", + "OODWorkflowExecutor", +] diff --git a/src/tool_classifier/workflows/api_tool_workflow.py b/src/tool_classifier/workflows/api_tool_workflow.py new file mode 100644 index 00000000..6e18817e --- /dev/null +++ b/src/tool_classifier/workflows/api_tool_workflow.py @@ -0,0 +1,573 @@ +"""API Tool Calling Workflow Executor — Layer 2 of the classification chain.""" + +import asyncio +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Any, + AsyncIterator, + Dict, + List, + Literal, + Optional, + Protocol, + Union, + cast, +) + +from loguru import logger + +from models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, + TestOrchestrationResponse, +) +from models.session_models import APIToolSession +from tool_classifier.agentic_loop import AgenticLoop +from tool_classifier.api_caller import APICaller +from tool_classifier.api_response_formatter import APIResponseFormatterModule +from tool_classifier.base_workflow import BaseWorkflow +from tool_classifier.enums import AgenticLoopStatus +from tool_classifier.param_extractor import ParamExtractionModule +from utils.api_tool_session_store import APIToolSessionStore + +if TYPE_CHECKING: + from guardrails.nemo_rails_adapter import NeMoRailsAdapter + + +class OrchestrationServiceProtocol(Protocol): + """Protocol for orchestration service methods used by this workflow.""" + + def format_sse( + self, + chat_id: str, + content: str, + buttons: Optional[List[Dict[str, Any]]] = None, + ) -> str: + """Format a payload as an SSE message.""" + ... + + async def handle_output_guardrails( + self, + guardrails_adapter: Any, # noqa: ANN401 — NeMoRailsAdapter, avoids circular import + generated_response: Union[OrchestrationResponse, TestOrchestrationResponse], + request: OrchestrationRequest, + costs_metric: Dict[str, Dict[str, Any]], + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: + """Check output guardrails and return (possibly replaced) response.""" + ... + + +@dataclass +class _LoopStep: + """Shared result type from :meth:`APIToolWorkflowExecutor._compute_loop_step`. + + ``kind`` drives both the sync and streaming execution paths: + + * ``"api_call"`` — all params collected; call the external API and format. + * ``"question"`` — agentic loop needs more input; return ``question`` to user. + * ``"fallback"`` — nothing to do; caller should fall back to RAG. + """ + + kind: Literal["api_call", "question", "fallback"] + chat_id: str = "" + endpoint: Dict[str, Any] = field(default_factory=dict) + collected_params: Dict[str, Any] = field(default_factory=dict) + detected_language: str = "en" + user_query: str = "" + question: str = "" + question_tokens: List[str] = field(default_factory=list) + + +class APIToolWorkflowExecutor(BaseWorkflow): + """Executes API Tool Calling workflow (Layer 2). + + Handles queries that matched an API endpoint in api_tool_collection. + + On the first turn for a chat_id the matched endpoint is read from context + (populated by ToolClassifier.classify()). Subsequent turns resume from the + persisted Redis session — context["matched_endpoint"] is ignored once a + session exists. + + The executor manages the agentic loop lifecycle: + - creates the session on turn 1 + - resumes it on turns 2-N + - deletes it on COMPLETED or MAX_TURNS_REACHED + + When all required params are collected (COMPLETED) the executor calls the + external API via :class:`APICaller` and formats the raw response into + natural-language using :class:`APIResponseFormatterModule`. The formatted + answer is returned directly to the user. + """ + + def __init__( + self, orchestration_service: Optional[OrchestrationServiceProtocol] = None + ) -> None: + self.orchestration_service = orchestration_service + self._api_caller = APICaller() + self._formatter = APIResponseFormatterModule() + + # ------------------------------------------------------------------ + # Internal helpers + + # ------------------------------------------------------------------ + + def _get_session_store(self) -> Optional[APIToolSessionStore]: + """Return the session store from the orchestration service, or None.""" + if self.orchestration_service is None: + return None + return getattr(self.orchestration_service, "session_store", None) + + def _get_guardrails_adapter( + self, environment: str, connection_id: Optional[str] = None + ) -> Optional["NeMoRailsAdapter"]: + """Return the NeMoRailsAdapter for *environment*, or None if unavailable.""" + if self.orchestration_service is None: + return None + shared = getattr(self.orchestration_service, "shared_guardrails_adapters", {}) + if environment in shared: + return shared[environment] + # Fallback: per-request initialisation (slower but safe) + safe_init = getattr( + self.orchestration_service, "_safe_initialize_guardrails", None + ) + if safe_init is not None: + return safe_init(environment, connection_id) + return None + + def _build_agentic_loop(self, session_store: APIToolSessionStore) -> AgenticLoop: + """Construct a fresh AgenticLoop for one request.""" + return AgenticLoop( + session_store=session_store, + param_extractor=ParamExtractionModule(), + ) + + @staticmethod + def _required_params(params: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [p for p in params if isinstance(p, dict) and p.get("required", False)] + + async def _execute_api_and_format( + self, + chat_id: str, + endpoint: Dict[str, Any], + collected_params: Dict[str, Any], + user_query: str, + detected_language: str, + ) -> OrchestrationResponse: + """Call the external API with collected params and return a formatted response. + + On success, the raw API response is converted to natural language by + :class:`APIResponseFormatterModule`. + On any failure the localized error message is returned directly to the user. + """ + url = endpoint.get("url", "") + method = endpoint.get("method", "GET") + description = endpoint.get("description", "") + + logger.info( + f"[{chat_id}] APIToolWorkflow: calling API " + f"{method} {url} with params={list(collected_params.keys())}" + ) + + api_result = await self._api_caller.call( + url=url, + method=method, + params=collected_params, + language=detected_language, + ) + + if api_result.success: + logger.info( + f"[{chat_id}] APIToolWorkflow: API call succeeded " + f"(status={api_result.status_code})" + ) + content = await asyncio.to_thread( + self._formatter.forward, + user_query=user_query, + api_response=api_result.response_data, + endpoint_description=description, + detected_language=detected_language, + ) + else: + logger.warning( + f"[{chat_id}] APIToolWorkflow: API call failed " + f"(status={api_result.status_code}, error={api_result.error!r})" + ) + content = api_result.error or "" + + return OrchestrationResponse( + chatId=chat_id, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=content, + ) + + @staticmethod + def _build_question_response(chat_id: str, question: str) -> OrchestrationResponse: + return OrchestrationResponse( + chatId=chat_id, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=question, + ) + + # ------------------------------------------------------------------ + # Core loop handler — shared by async and streaming paths + # ------------------------------------------------------------------ + + async def _compute_loop_step( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> _LoopStep: + """Run one agentic loop turn and return a tagged outcome. + + This is the single source of truth for session management and loop logic. + Both the sync (:meth:`execute_async`) and streaming (:meth:`execute_streaming`) + paths call this method and then handle the result in their own way: + + * ``"api_call"`` → call API + format response (blocking or streaming) + * ``"question"`` → return clarifying question to user + * ``"fallback"`` → no valid state; caller falls back to RAG + """ + chat_id = request.chatId + session_store = self._get_session_store() + + # ── Try to resume an existing session ──────────────────────────── + session: Optional[APIToolSession] = None + if session_store is not None: + session = await session_store.get(chat_id) + + if session is not None: + # Resume path — endpoint comes from persisted session + endpoint = session.selected_endpoint + if endpoint is None: + logger.warning( + f"[{chat_id}] APIToolWorkflow: session has no endpoint — deleting" + ) + if session_store is not None: + await session_store.delete(chat_id) + return _LoopStep(kind="fallback", chat_id=chat_id) + + logger.info( + f"[{chat_id}] APIToolWorkflow: resuming session " + f"(turn={session.turn_count}, endpoint={endpoint.get('name')!r})" + ) + else: + # New-session path — endpoint must come from classifier context + endpoint = context.get("matched_endpoint") + if not endpoint: + logger.warning( + f"[{chat_id}] APIToolWorkflow: no matched_endpoint in context " + f"and no active session — falling back" + ) + return _LoopStep(kind="fallback", chat_id=chat_id) + + params_schema: List[Dict[str, Any]] = endpoint.get("params", []) + + # Fast path: no required params — call API immediately + if not self._required_params(params_schema): + logger.info( + f"[{chat_id}] APIToolWorkflow: endpoint {endpoint.get('name')!r} " + f"has no required params — fast path" + ) + return _LoopStep( + kind="api_call", + chat_id=chat_id, + endpoint=endpoint, + collected_params={}, + detected_language=getattr(request, "_detected_language", "en"), + user_query=request.message, + ) + + # Create a new session before running the first loop turn + if session_store is not None: + new_session = APIToolSession( + chat_id=chat_id, + state="collecting_params", + selected_endpoint=endpoint, + collected_params={}, + turn_count=0, + max_turns=5, + awaiting_continuation=False, + detected_language=getattr(request, "_detected_language", "en"), + original_query=request.message, + ) + await session_store.save(new_session) + session = new_session + else: + logger.warning( + f"[{chat_id}] APIToolWorkflow: Redis unavailable — " + f"running loop without session persistence" + ) + session = APIToolSession( + chat_id=chat_id, + state="collecting_params", + selected_endpoint=endpoint, + collected_params={}, + turn_count=0, + max_turns=5, + awaiting_continuation=False, + detected_language=getattr(request, "_detected_language", "en"), + original_query=request.message, + ) + + # ── Run one loop turn ───────────────────────────────────────────── + if session_store is None: + logger.warning( + f"[{chat_id}] APIToolWorkflow: session store unavailable — " + f"agentic loop running without persistence" + ) + + loop = self._build_agentic_loop(session_store) # type: ignore[arg-type] + + result, question_tokens = await loop.stream_run_turn( + chat_id=chat_id, + user_message=request.message, + conversation_history=( + [] + if session.turn_count == 0 + else [ + {"authorRole": item.authorRole, "message": item.message} + for item in (request.conversationHistory or []) + ] + ), + params_schema=endpoint.get("params", []), + collected_params=session.collected_params, + turn_count=session.turn_count, + max_turns=session.max_turns, + awaiting_continuation=session.awaiting_continuation, + session_language=session.detected_language, + ) + + # ── Translate result into a _LoopStep ───────────────────────────── + if result.status == AgenticLoopStatus.COMPLETED: + logger.info( + f"[{chat_id}] APIToolWorkflow: all params collected " + f"(turns={result.turn_count}, params={list(result.collected_params.keys())})" + ) + if session_store is not None: + await session_store.delete(chat_id) + return _LoopStep( + kind="api_call", + chat_id=chat_id, + endpoint=endpoint, + collected_params=result.collected_params, + detected_language=session.detected_language, + user_query=session.original_query or request.message, + ) + + if result.status == AgenticLoopStatus.MAX_TURNS_REACHED: + logger.info( + f"[{chat_id}] APIToolWorkflow: max turns reached — deleting session" + ) + if session_store is not None: + await session_store.delete(chat_id) + return _LoopStep(kind="fallback", chat_id=chat_id) + + # NEEDS_INPUT or AWAITING_CONTINUATION_DECISION + logger.info( + f"[{chat_id}] APIToolWorkflow: asking for more info " + f"(status={result.status.value}, turn={result.turn_count})" + ) + return _LoopStep( + kind="question", + chat_id=chat_id, + question=result.clarifying_question, + question_tokens=question_tokens, + ) + + async def _run( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + ) -> Optional[OrchestrationResponse]: + """Blocking execution path — delegates to :meth:`_compute_loop_step`.""" + step = await self._compute_loop_step(request, context) + + if step.kind == "fallback": + return None + + if step.kind == "question": + return self._build_question_response(step.chat_id, step.question) + + # api_call — blocking + response = await self._execute_api_and_format( + chat_id=step.chat_id, + endpoint=step.endpoint, + collected_params=step.collected_params, + user_query=step.user_query, + detected_language=step.detected_language, + ) + + # Output guardrails — only on successful LLM-formatted answers + if response.llmServiceActive and self.orchestration_service is not None: + guardrails_adapter = self._get_guardrails_adapter( + request.environment, request.connection_id + ) + response = cast( + OrchestrationResponse, + await self.orchestration_service.handle_output_guardrails( + guardrails_adapter, + response, + request, + {}, + ), + ) + + return response + + # ------------------------------------------------------------------ + # BaseWorkflow interface + # ------------------------------------------------------------------ + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[OrchestrationResponse]: + return await self._run(request, context) + + async def _stream_api_and_format( + self, + chat_id: str, + endpoint: Dict[str, Any], + collected_params: Dict[str, Any], + user_query: str, + detected_language: str, + orchestration_service: OrchestrationServiceProtocol, + request: OrchestrationRequest, + costs_metric: Optional[Dict[str, Any]] = None, + ) -> AsyncIterator[str]: + """Call the external API and stream the formatted response token by token. + + Clarifying questions are short and do not need token streaming; they are + yielded as a single SSE frame. + """ + url = endpoint.get("url", "") + method = endpoint.get("method", "GET") + description = endpoint.get("description", "") + + logger.info( + f"[{chat_id}] APIToolWorkflow (streaming): calling API " + f"{method} {url} with params={list(collected_params.keys())}" + ) + + api_result = await self._api_caller.call( + url=url, + method=method, + params=collected_params, + language=detected_language, + ) + + if api_result.success: + logger.info( + f"[{chat_id}] APIToolWorkflow (streaming): API call succeeded " + f"(status={api_result.status_code}), streaming formatted response" + ) + # Buffer all tokens first, then validate with output guardrails before + # streaming to the client (validate-first approach). + buffered_tokens = [ + token + async for token in self._formatter.stream_forward( + user_query=user_query, + api_response=api_result.response_data, + endpoint_description=description, + detected_language=detected_language, + ) + ] + + full_response = "".join(buffered_tokens) + + # Run output guardrails on the complete response + guardrails_passed = True + if orchestration_service is not None: + guardrails_adapter = self._get_guardrails_adapter( + request.environment, request.connection_id + ) + if guardrails_adapter is not None: + dummy_response = OrchestrationResponse( + chatId=chat_id, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=full_response, + ) + + checked = await orchestration_service.handle_output_guardrails( + guardrails_adapter, + dummy_response, + request, + costs_metric if costs_metric is not None else {}, + ) + if checked.content != full_response: + # Guardrails replaced the content — yield violation message + logger.warning( + f"[{chat_id}] APIToolWorkflow (streaming): " + f"output blocked by guardrails" + ) + yield orchestration_service.format_sse(chat_id, checked.content) + guardrails_passed = False + + if guardrails_passed: + for token in buffered_tokens: + yield orchestration_service.format_sse(chat_id, token) + else: + logger.warning( + f"[{chat_id}] APIToolWorkflow (streaming): API call failed " + f"(status={api_result.status_code}, error={api_result.error!r})" + ) + yield orchestration_service.format_sse(chat_id, api_result.error or "") + + yield orchestration_service.format_sse(chat_id, "END") + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """Streaming mode — run the agentic loop and stream the response token by token. + + Clarifying questions (NEEDS_INPUT / AWAITING_CONTINUATION) are short; they + are emitted as a single SSE frame followed by END — same as before. + + Final API responses (COMPLETED / fast-path) are streamed word-by-word via + DSPy native streaming on the ``formatted_answer`` field. + """ + if self.orchestration_service is None: + logger.error( + f"[{request.chatId}] APIToolWorkflow streaming: orchestration_service not set" + ) + return None + + step = await self._compute_loop_step(request, context) + + if step.kind == "fallback": + return None + + orchestration_service = self.orchestration_service + + if step.kind == "question": + + async def _stream_question() -> AsyncIterator[str]: + for token in step.question_tokens or [step.question]: + yield orchestration_service.format_sse(step.chat_id, token) + yield orchestration_service.format_sse(step.chat_id, "END") + + return _stream_question() + + # api_call — stream the LLM-formatted answer token by token + return self._stream_api_and_format( + chat_id=step.chat_id, + endpoint=step.endpoint, + collected_params=step.collected_params, + user_query=step.user_query, + detected_language=step.detected_language, + orchestration_service=orchestration_service, + request=request, + costs_metric=context.get("costs_metric"), + ) diff --git a/src/tool_classifier/workflows/context_workflow.py b/src/tool_classifier/workflows/context_workflow.py new file mode 100644 index 00000000..f3369a6c --- /dev/null +++ b/src/tool_classifier/workflows/context_workflow.py @@ -0,0 +1,412 @@ +"""Context workflow executor - Layer 2: Conversation history and greetings.""" + +from typing import Any, AsyncIterator, Dict, Optional, cast +import time +import dspy +from loguru import logger + +from src.models.request_models import OrchestrationRequest, OrchestrationResponse +from tool_classifier.base_workflow import BaseWorkflow +from tool_classifier.context_analyzer import ContextAnalyzer, ContextDetectionResult +from tool_classifier.workflows.service_workflow import LLMServiceProtocol +from src.guardrails.nemo_rails_adapter import NeMoRailsAdapter +from src.llm_orchestrator_config.llm_manager import LLMManager +from src.utils.cost_utils import get_lm_usage_since +from src.utils.language_detector import detect_language +from src.llm_orchestrator_config.llm_ochestrator_constants import ( + GUARDRAILS_BLOCKED_PHRASES, + OUTPUT_GUARDRAIL_VIOLATION_MESSAGE, +) + + +class ContextWorkflowExecutor(BaseWorkflow): + """ + Handles greetings and conversation history queries (Layer 2). + + Detects: + - Greetings: "Hello", "Thanks", "Goodbye" (multilingual: Estonian, English) + - History references: "What did you say earlier?", "Can you repeat that?" + + Uses LLM for semantic detection (multilingual), no regex patterns. + + Implementation Strategy: + 1. Detect language from user query + 2. Use ContextAnalyzer (LLM-based) to check if: + - Query is a greeting -> generate friendly response + - Query references conversation history -> extract answer + 3. If can answer -> return response + 4. Otherwise -> return None (fallback to RAG) + + Cost Tracking: + - Tracks LLM costs for context analysis + - Logs via orchestration_service.log_costs() (same as service/RAG workflows) + """ + + def __init__( + self, + llm_manager: LLMManager, + orchestration_service: Optional[LLMServiceProtocol] = None, + ) -> None: + """ + Initialize context workflow executor. + + Args: + llm_manager: LLM manager for context analysis + orchestration_service: Reference to LLMOrchestrationService for cost logging + """ + self.llm_manager = llm_manager + self.orchestration_service = orchestration_service + self.context_analyzer = ContextAnalyzer(llm_manager) + logger.info("Context workflow executor initialized") + + @staticmethod + def _build_history(request: OrchestrationRequest) -> list[Dict[str, Any]]: + return [ + { + "authorRole": item.authorRole, + "message": item.message, + "timestamp": item.timestamp, + } + for item in request.conversationHistory + ] + + async def _detect( + self, + message: str, + history: list[Dict[str, Any]], + time_metric: Dict[str, float], + costs_metric: Dict[str, Dict[str, Any]], + ) -> Optional[ContextDetectionResult]: + """Phase 1: run context detection with summary fallback. + + Checks the last 10 conversation turns first. If the query cannot be + answered from those and the history exceeds 10 turns, falls back to a + summary-based check over the older turns. Returns None on error so the + caller falls through to RAG. + """ + try: + start = time.time() + ( + result, + cost, + ) = await self.context_analyzer.detect_context_with_summary_fallback( + query=message, conversation_history=history + ) + time_metric["context.detection"] = time.time() - start + costs_metric["context_detection"] = cost + return result + except Exception as e: + logger.error(f"Phase 1 detection failed: {e}", exc_info=True) + return None + + def _log_costs(self, costs_metric: Dict[str, Dict[str, Any]]) -> None: + if self.orchestration_service: + self.orchestration_service.log_costs(costs_metric) + + @staticmethod + def _is_guardrail_violation(chunk: str) -> bool: + """Return True if the chunk matches a known guardrail blocked phrase.""" + chunk_lower = chunk.strip().lower() + return any( + phrase.lower() in chunk_lower + and len(chunk_lower) <= len(phrase.lower()) + 20 + for phrase in GUARDRAILS_BLOCKED_PHRASES + ) + + async def _generate_response_async( + self, + request: OrchestrationRequest, + context_snippet: str, + time_metric: Dict[str, float], + costs_metric: Dict[str, Dict[str, Any]], + ) -> Optional[OrchestrationResponse]: + """Non-streaming: Generate response + apply output guardrails.""" + try: + start = time.time() + answer, cost = await self.context_analyzer.generate_context_response( + query=request.message, context_snippet=context_snippet + ) + time_metric["context.generation"] = time.time() - start + costs_metric["context_response"] = cost + except Exception as e: + logger.error(f"Phase 2 generation failed: {e}", exc_info=True) + self._log_costs(costs_metric) + return None + + if not answer: + logger.warning(f"[{request.chatId}] Phase 2 empty answer — fallback to RAG") + self._log_costs(costs_metric) + return None + + response = OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=answer, + ) + if self.orchestration_service: + try: + components = self.orchestration_service._initialize_service_components( + request + ) + response = cast( + OrchestrationResponse, + await self.orchestration_service.handle_output_guardrails( + guardrails_adapter=components.get("guardrails_adapter"), + generated_response=response, + request=request, + costs_metric=costs_metric, + ), + ) + except Exception as e: + logger.warning( + f"[{request.chatId}] Output guardrails check failed: {e}" + ) + self._log_costs(costs_metric) + return response + + async def _stream_history_generator( + self, + chat_id: str, + query: str, + context_snippet: str, + history_length_before: int, + guardrails_adapter: NeMoRailsAdapter, + costs_metric: Dict[str, Dict[str, Any]], + ) -> AsyncIterator[str]: + """Async generator: stream history answer through NeMo Guardrails.""" + bot_generator = self.context_analyzer.stream_context_response( + query=query, context_snippet=context_snippet + ) + orchestration_service = self.orchestration_service + if orchestration_service is None: + return + async for validated_chunk in guardrails_adapter.stream_with_guardrails( + user_message=query, bot_message_generator=bot_generator + ): + if isinstance(validated_chunk, str) and self._is_guardrail_violation( + validated_chunk + ): + logger.warning(f"[{chat_id}] Guardrails violation in context streaming") + yield orchestration_service.format_sse( + chat_id, OUTPUT_GUARDRAIL_VIOLATION_MESSAGE + ) + yield orchestration_service.format_sse(chat_id, "END") + costs_metric["context_response"] = get_lm_usage_since( + history_length_before + ) + orchestration_service.log_costs(costs_metric) + return + yield orchestration_service.format_sse(chat_id, validated_chunk) + yield orchestration_service.format_sse(chat_id, "END") + logger.info(f"[{chat_id}] Context streaming complete") + costs_metric["context_response"] = get_lm_usage_since(history_length_before) + orchestration_service.log_costs(costs_metric) + + async def _create_history_stream( + self, + request: OrchestrationRequest, + context_snippet: str, + costs_metric: Dict[str, Dict[str, Any]], + ) -> Optional[AsyncIterator[str]]: + """Set up guardrails adapter and return the history streaming generator.""" + if not self.orchestration_service: + logger.warning( + f"[{request.chatId}] No orchestration_service — cannot stream with guardrails" + ) + return None + try: + components = self.orchestration_service._initialize_service_components( + request + ) + guardrails_adapter = components.get("guardrails_adapter") + except Exception as e: + logger.error( + f"[{request.chatId}] Failed to initialize components: {e}", + exc_info=True, + ) + self._log_costs(costs_metric) + return None + + if not isinstance(guardrails_adapter, NeMoRailsAdapter): + logger.warning( + f"[{request.chatId}] guardrails_adapter unavailable — cannot stream" + ) + self._log_costs(costs_metric) + return None + + history_length_before = 0 + try: + lm = dspy.settings.lm + if lm and hasattr(lm, "history"): + history_length_before = len(lm.history) + except Exception: + pass + + return self._stream_history_generator( + chat_id=request.chatId, + query=request.message, + context_snippet=context_snippet, + history_length_before=history_length_before, + guardrails_adapter=guardrails_adapter, + costs_metric=costs_metric, + ) + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[OrchestrationResponse]: + """ + Execute context workflow in non-streaming mode (two-phase). + + Phase 1: Detect if query is a greeting or can be answered from history. + Phase 2: Generate response (greetings: pre-built; history: LLM + guardrails). + + Returns: + OrchestrationResponse or None to fallback to RAG + """ + logger.info( + f"[{request.chatId}] CONTEXT WORKFLOW (NON-STREAMING) | " + f"Query: '{request.message[:100]}'" + ) + costs_metric: Dict[str, Dict[str, Any]] = {} + if time_metric is None: + time_metric = {} + + language = detect_language(request.message) + history = self._build_history(request) + + # Check if analysis is pre-computed (e.g. from classifier classify step) + pre_computed = context.get("analysis_result") + if ( + pre_computed is not None + and hasattr(pre_computed, "is_greeting") + and hasattr(pre_computed, "can_answer_from_context") + ): + detection_result: ContextDetectionResult = cast( + ContextDetectionResult, pre_computed + ) + costs_metric.setdefault( + "context_detection", + {"total_cost": 0.0, "total_tokens": 0, "num_calls": 0}, + ) + else: + _detected = await self._detect( + request.message, history, time_metric, costs_metric + ) + if _detected is None: + self._log_costs(costs_metric) + context["costs_dict"] = costs_metric + return None + detection_result = _detected + + logger.info( + f"[{request.chatId}] Detection: greeting={detection_result.is_greeting} " + f"can_answer={detection_result.can_answer_from_context}" + ) + + if detection_result.is_greeting: + from src.tool_classifier.greeting_constants import get_greeting_response + + greeting = get_greeting_response( + greeting_type=detection_result.greeting_type, language=language + ) + self._log_costs(costs_metric) + context["costs_dict"] = costs_metric + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=greeting, + ) + + if ( + detection_result.can_answer_from_context + and detection_result.context_snippet + ): + context["costs_dict"] = costs_metric + return await self._generate_response_async( + request, detection_result.context_snippet, time_metric, costs_metric + ) + + logger.warning( + f"[{request.chatId}] Cannot answer from context — falling back to RAG" + ) + self._log_costs(costs_metric) + context["costs_dict"] = costs_metric + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """ + Execute context workflow in streaming mode (two-phase). + + Phase 1: Detect context (blocking, fast — classification only). + Phase 2: Stream answer through NeMo Guardrails (same pipeline as RAG). + + Returns: + AsyncIterator yielding SSE strings or None to fallback to RAG + """ + logger.info( + f"[{request.chatId}] CONTEXT WORKFLOW (STREAMING) | " + f"Query: '{request.message[:100]}'" + ) + costs_metric: Dict[str, Dict[str, Any]] = {} + if time_metric is None: + time_metric = {} + + language = detect_language(request.message) + history = self._build_history(request) + + detection_result = await self._detect( + request.message, history, time_metric, costs_metric + ) + if detection_result is None: + self._log_costs(costs_metric) + return None + + logger.info( + f"[{request.chatId}] Detection: greeting={detection_result.is_greeting} " + f"can_answer={detection_result.can_answer_from_context}" + ) + + if detection_result.is_greeting: + from src.tool_classifier.greeting_constants import get_greeting_response + + greeting = get_greeting_response( + greeting_type=detection_result.greeting_type, language=language + ) + orchestration_service = self.orchestration_service + if orchestration_service is None: + self._log_costs(costs_metric) + return None + chat_id = request.chatId + + async def _stream_greeting() -> AsyncIterator[str]: + yield orchestration_service.format_sse(chat_id, greeting) + yield orchestration_service.format_sse(chat_id, "END") + orchestration_service.log_costs(costs_metric) + + return _stream_greeting() + + if ( + detection_result.can_answer_from_context + and detection_result.context_snippet + ): + return await self._create_history_stream( + request, detection_result.context_snippet, costs_metric + ) + + logger.warning( + f"[{request.chatId}] Cannot answer from context — falling back to RAG" + ) + self._log_costs(costs_metric) + return None diff --git a/src/tool_classifier/workflows/ood_workflow.py b/src/tool_classifier/workflows/ood_workflow.py new file mode 100644 index 00000000..ed923879 --- /dev/null +++ b/src/tool_classifier/workflows/ood_workflow.py @@ -0,0 +1,134 @@ +"""OOD workflow executor - Layer 4: Out-of-domain fallback.""" + +from typing import Any, AsyncIterator, Dict, Optional +from loguru import logger + +from models.request_models import OrchestrationRequest, OrchestrationResponse +from tool_classifier.base_workflow import BaseWorkflow + + +class OODWorkflowExecutor(BaseWorkflow): + """ + Handles out-of-domain queries that no workflow can answer (Layer 4). + + This is the final fallback in the workflow chain. It returns a polite + "cannot answer" message when: + - No service matches (Layer 1 failed) + - No context match (Layer 2 failed) + - No relevant knowledge chunks (Layer 3 failed) + + Examples of OOD queries: + - "What's the weather today?" (not in scope) + - "Tell me a joke" (not government service) + - Questions with no relevant knowledge + + Implementation Status: SKELETON + Returns None (will implement to return OOD message) + + TODO - Implementation (Simple): + - Return localized OUT_OF_SCOPE_MESSAGE + - Set questionOutOfLLMScope flag to True + - For streaming: chunk message and stream for UX consistency + """ + + def __init__(self) -> None: + """Initialize OOD workflow executor.""" + logger.info("OOD workflow executor initialized (skeleton)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[OrchestrationResponse]: + """ + Execute OOD workflow in non-streaming mode. + + TODO: Implement OOD response: + ```python + from src.llm_orchestrator_config.llm_ochestrator_constants import ( + get_localized_message, + OUT_OF_SCOPE_MESSAGES, + ) + + # Get detected language from request + detected_language = getattr(request, "_detected_language", "en") + + # Get localized message + ood_message = get_localized_message(OUT_OF_SCOPE_MESSAGES, detected_language) + + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=True, # Flag as out of scope + inputGuardFailed=False, + content=ood_message, + ) + ``` + + Args: + request: Orchestration request with user query + context: Unused (OOD doesn't need metadata) + time_metric: Optional timing dictionary for future timing tracking + + Returns: + OrchestrationResponse with OOD message + Never returns None (this is final fallback) + """ + logger.info( + f"[{request.chatId}] OOD workflow execute_async called " + f"(not implemented - returning None for now)" + ) + + # TODO: Implement OOD response logic here + # For now, return None (will be implemented as simple message return) + return None + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """ + Execute OOD workflow in streaming mode. + + TODO: Implement OOD streaming: + ```python + from src.llm_orchestrator_config.llm_ochestrator_constants import ( + get_localized_message, + OUT_OF_SCOPE_MESSAGES, + ) + + # Get localized OOD message + detected_language = getattr(request, "_detected_language", "en") + ood_message = get_localized_message(OUT_OF_SCOPE_MESSAGES, detected_language) + + # Stream message for UX consistency (no guardrails needed - fixed message) + async def stream_ood_message(): + for chunk in split_into_tokens(ood_message, chunk_size=5): + yield self.format_sse(request.chatId, chunk) + await asyncio.sleep(0.01) + yield self.format_sse(request.chatId, "END") + + return stream_ood_message() + ``` + + Note: No output guardrails needed since this is a fixed, safe message. + + Args: + request: Orchestration request with user query + context: Unused (OOD doesn't need metadata) + + Returns: + AsyncIterator yielding SSE strings + Never returns None (this is final fallback) + """ + logger.info( + f"[{request.chatId}] OOD workflow execute_streaming called " + f"(not implemented - returning None for now)" + ) + + # TODO: Implement OOD streaming logic here + # For now, return None (will be implemented as simple message streaming) + return None diff --git a/src/tool_classifier/workflows/rag_workflow.py b/src/tool_classifier/workflows/rag_workflow.py new file mode 100644 index 00000000..9b3c588f --- /dev/null +++ b/src/tool_classifier/workflows/rag_workflow.py @@ -0,0 +1,198 @@ +"""RAG workflow executor - Layer 3: Knowledge base retrieval.""" + +from typing import Any, AsyncIterator, Dict, Optional, Union, cast, TYPE_CHECKING +from loguru import logger + +from models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, + TestOrchestrationResponse, +) +from src.utils.stream_manager import StreamContext +from tool_classifier.base_workflow import BaseWorkflow + +if TYPE_CHECKING: + from llm_orchestration_service import LLMOrchestrationService + + +class RAGWorkflowExecutor(BaseWorkflow): + """ + Wrapper for existing RAG (Retrieval-Augmented Generation) workflow (Layer 3). + + This workflow handles queries that require searching the knowledge base + and generating responses based on retrieved chunks. It uses the existing + RAG pipeline: + 1. Prompt refinement + 2. Contextual retrieval (Qdrant + BM25) + 3. Rank fusion (RRF) + 4. Response generation + 5. Output guardrails (validation-first streaming) + + Examples of RAG queries: + - "What are digital signatures?" + - "How do I register a company?" + - "Explain tax regulations" + + Implementation Status: COMPLETE + This is a thin wrapper that delegates to existing LLMOrchestrationService methods. + + No TODO - Just wraps existing pipeline: + - Non-streaming: Calls _execute_orchestration_pipeline() + - Streaming: Calls existing streaming logic with NeMo guardrails + + Note: If no relevant chunks found, returns OOD response (not None) + """ + + def __init__(self, orchestration_service: "LLMOrchestrationService") -> None: + """ + Initialize RAG workflow executor. + + Args: + orchestration_service: Reference to LLMOrchestrationService + for calling existing RAG pipeline + """ + self.orchestration_service = orchestration_service + logger.info("RAG workflow executor initialized (wrapper)") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[Union[OrchestrationResponse, TestOrchestrationResponse]]: + """ + Execute RAG workflow in non-streaming mode. + + Delegates to existing LLMOrchestrationService._execute_orchestration_pipeline() + which handles: + - Prompt refinement + - Chunk retrieval (Qdrant + BM25) + - Response generation + - Output guardrails + + Args: + request: Orchestration request with user query + context: May contain pre-initialized "components" to avoid duplicate init + time_metric: Optional timing dictionary from parent (for unified tracking) + + Returns: + OrchestrationResponse with RAG-generated answer + Never returns None (handles OOD internally) + """ + logger.info(f"[{request.chatId}] Executing RAG workflow (non-streaming)") + + # Initialize components needed for RAG pipeline + costs_metric: Dict[str, Any] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} + + # Reuse components from context if available, otherwise initialize + components = context.get("components") + if components is None: + components = self.orchestration_service._initialize_service_components( + request + ) + + # Call existing RAG pipeline with "rag" prefix for namespacing + response = await self.orchestration_service._execute_orchestration_pipeline( + request=request, + components=components, + costs_metric=costs_metric, + time_metric=time_metric, + prefix="rag", + ) + + # Log costs (timing is logged by parent orchestration service) + self.orchestration_service.log_costs(costs_metric) + + return response + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """ + Execute RAG workflow in streaming mode. + + Coroutine that returns an AsyncIterator so callers can safely use + ``await workflow.execute_streaming(...)`` and then iterate over the + returned stream without hitting a TypeError from awaiting an async + generator. + + Delegates to existing streaming pipeline which handles: + - Prompt refinement (blocking) + - Chunk retrieval (blocking) + - Streaming through NeMo guardrails (validation-first) + - Real-time token validation + + The existing implementation uses NeMo's stream_with_guardrails which: + - Buffers tokens (chunk_size=200) + - Validates each buffer before yielding + - Provides true validation-first streaming + + Args: + request: Orchestration request with user query + context: May contain pre-initialized "components" and "stream_ctx" + time_metric: Optional timing dictionary from parent (for unified tracking) + + Returns: + AsyncIterator yielding SSE-formatted strings + Never returns None (handles OOD internally) + """ + logger.info(f"[{request.chatId}] Executing RAG workflow (streaming)") + + # Initialize tracking dictionaries + costs_metric: Dict[str, Any] = {} + # Use parent time_metric or create new one + if time_metric is None: + time_metric = {} + + # Get components from context if provided, otherwise initialize + components = context.get("components") + if components is None: + components = self.orchestration_service._initialize_service_components( + request + ) + + # Get stream context from context if provided, otherwise create minimal tracking + stream_ctx = context.get("stream_ctx") + if stream_ctx is None: + + class MinimalStreamContext: + """Minimal stream context for RAG workflow when called directly.""" + + def __init__(self, chat_id: str) -> None: + self.stream_id = f"rag-{chat_id}" + self.token_count = 0 + self.bot_generator = None + + def mark_completed(self) -> None: + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context + pass + + def mark_cancelled(self) -> None: + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context + pass + + def mark_error(self, error_id: str) -> None: + # Intentionally empty: lifecycle tracking is handled by the orchestration service, not this minimal context + pass + + stream_ctx = MinimalStreamContext(request.chatId) + + # Return an inner async generator so this method stays a coroutine. + # This avoids the TypeError when callers do ``await execute_streaming(...)``. + async def _stream() -> AsyncIterator[str]: + async for sse_chunk in self.orchestration_service._stream_rag_pipeline( + request=request, + components=components, + stream_ctx=cast(StreamContext, stream_ctx), + costs_metric=costs_metric, + time_metric=time_metric, + ): + yield sse_chunk + + return _stream() diff --git a/src/tool_classifier/workflows/service_workflow.py b/src/tool_classifier/workflows/service_workflow.py new file mode 100644 index 00000000..a021aba9 --- /dev/null +++ b/src/tool_classifier/workflows/service_workflow.py @@ -0,0 +1,1127 @@ +"""Service workflow executor - Layer 1: External service/API calls.""" + +import json +from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, Union + +import dspy +import httpx +from loguru import logger + +from llm_orchestrator_config.llm_manager import LLMManager +from src.guardrails.nemo_rails_adapter import NeMoRailsAdapter + +from src.utils.cost_utils import get_lm_usage_since + +from models.request_models import ( + ChoiceButton, + OrchestrationRequest, + OrchestrationResponse, + TestOrchestrationResponse, +) +from tool_classifier.base_workflow import BaseWorkflow +from tool_classifier.constants import ( + MAX_SERVICES_FOR_LLM_CONTEXT, + QDRANT_COLLECTION, + QDRANT_HOST, + QDRANT_PORT, + QDRANT_TIMEOUT, + RAG_SEARCH_RUUTER_PUBLIC, + RUUTER_COMMON_SERVICE_BASE_URL, + RUUTER_SERVICE_BASE_URL, + SEMANTIC_SEARCH_THRESHOLD, + SEMANTIC_SEARCH_TOP_K, + SERVICE_CALL_TIMEOUT, + SERVICE_COUNT_THRESHOLD, + SERVICE_DISCOVERY_TIMEOUT, + SERVICE_STEP_PREFIXES, +) +from tool_classifier.intent_detector import IntentDetectionModule +import time + + +class LLMServiceProtocol(Protocol): + """Protocol defining interface for LLM service embedding operations.""" + + def create_embeddings_for_indexer( + self, + texts: List[str], + environment: str = "production", + connection_id: Optional[str] = None, + batch_size: int = 10, + ) -> Dict[str, Any]: + """Create embeddings for text inputs using the configured embedding model. + + Args: + texts: List of text strings to embed + environment: Environment for model resolution + connection_id: Optional connection ID for service selection + batch_size: Number of texts to process in each batch + + Returns: + Dictionary containing embeddings list and metadata + """ + ... + + def format_sse( + self, + chat_id: str, + content: str, + buttons: Optional[List[Dict[str, Any]]] = None, + ) -> str: + """Format content as SSE message. + + Args: + chat_id: Chat/channel identifier + content: Content to send (token, "END", error message, etc.) + buttons: Optional list of choice button dicts for MCQ step responses + + Returns: + SSE-formatted string: "data: {json}\\n\\n" + """ + ... + + def log_costs(self, costs_metric: Dict[str, Dict[str, Any]]) -> None: + """Log cost information for tracking. + + Args: + costs_metric: Dictionary of costs per component + """ + ... + + def _initialize_service_components( + self, request: OrchestrationRequest + ) -> Dict[str, Any]: + """Initialize and return service components dictionary.""" + ... + + async def handle_output_guardrails( + self, + guardrails_adapter: Optional[NeMoRailsAdapter], + generated_response: Union[OrchestrationResponse, TestOrchestrationResponse], + request: OrchestrationRequest, + costs_metric: Dict[str, Dict[str, Any]], + ) -> Union[OrchestrationResponse, TestOrchestrationResponse]: + """Apply output guardrails to the generated response.""" + ... + + +class ServiceWorkflowExecutor(BaseWorkflow): + """Executes external service calls via Ruuter endpoints (Layer 1).""" + + def __init__( + self, + llm_manager: Optional[LLMManager] = None, + orchestration_service: Optional[LLMServiceProtocol] = None, + ) -> None: + """Initialize service workflow executor.""" + self.llm_manager = llm_manager + self.orchestration_service = orchestration_service + + async def _semantic_search_services( + self, + query: str, + request: OrchestrationRequest, + chat_id: str, + top_k: int = SEMANTIC_SEARCH_TOP_K, + ) -> Optional[List[Dict[str, Any]]]: + """Search services using semantic search via Qdrant. + + Creates a new httpx.AsyncClient per request to ensure proper resource cleanup. + This is safe and efficient since semantic search is infrequent (only when many services exist). + """ + if not self.orchestration_service: + logger.error( + f"[{chat_id}] Semantic search unavailable: orchestration service not provided" + ) + return None + + try: + embedding_result = self.orchestration_service.create_embeddings_for_indexer( + texts=[query], + environment=request.environment, + connection_id=request.connection_id, + batch_size=1, + ) + + embeddings = embedding_result.get("embeddings", []) + if not embeddings or len(embeddings) == 0: + logger.error(f"[{chat_id}] No embedding returned for query") + return None + + query_embedding = embeddings[0] + + qdrant_url = f"http://{QDRANT_HOST}:{QDRANT_PORT}" + async with httpx.AsyncClient( + base_url=qdrant_url, timeout=QDRANT_TIMEOUT + ) as client: + try: + collection_info = await client.get( + f"/collections/{QDRANT_COLLECTION}" + ) + if collection_info.status_code == 200: + info = collection_info.json() + points_count = info.get("result", {}).get("points_count", 0) + if points_count == 0: + logger.error(f"[{chat_id}] Collection is empty") + return None + except Exception as e: + logger.warning(f"[{chat_id}] Could not verify collection: {e}") + + search_payload = { + "vector": query_embedding, + "limit": top_k, + "score_threshold": SEMANTIC_SEARCH_THRESHOLD, + "with_payload": True, + } + + response = await client.post( + f"/collections/{QDRANT_COLLECTION}/points/search", + json=search_payload, + ) + + if response.status_code != 200: + logger.error( + f"[{chat_id}] Qdrant search failed: HTTP {response.status_code}" + ) + return None + + search_results = response.json() + points = search_results.get("result", []) + + if len(points) == 0: + logger.warning( + f"[{chat_id}] No services matched (threshold={SEMANTIC_SEARCH_THRESHOLD})" + ) + return None + + services: List[Dict[str, Any]] = [] + for point in points: + payload = point.get("payload", {}) + score = float(point.get("score", 0)) + + service = { + "serviceId": payload.get("service_id"), + "service_id": payload.get("service_id"), + "name": payload.get("name"), + "description": payload.get("description"), + "examples": payload.get("examples", []), + "entities": payload.get("entities", []), + "similarity_score": score, + } + services.append(service) + + logger.info( + f"[{chat_id}] Found {len(services)} services via semantic search" + ) + return services + + except Exception as e: + logger.error(f"[{chat_id}] Semantic search failed: {e}", exc_info=True) + return None + + async def _call_service_discovery(self, chat_id: str) -> Optional[Dict[str, Any]]: + """Call Ruuter endpoint to get services for intent detection.""" + endpoint = f"{RAG_SEARCH_RUUTER_PUBLIC}/services/get-services" + + try: + async with httpx.AsyncClient(timeout=SERVICE_DISCOVERY_TIMEOUT) as client: + response = await client.get(endpoint) + response.raise_for_status() + data = response.json() + return data + except httpx.TimeoutException: + logger.error( + f"[{chat_id}] Service discovery timeout after {SERVICE_DISCOVERY_TIMEOUT}s" + ) + return None + except httpx.HTTPStatusError as e: + logger.error( + f"[{chat_id}] Service discovery HTTP error: {e.response.status_code}" + ) + return None + except Exception as e: + logger.error(f"[{chat_id}] Service discovery failed: {e}", exc_info=True) + return None + + async def _detect_service_intent( + self, + user_query: str, + services: List[Dict[str, Any]], + conversation_history: List[Any], + chat_id: str, + ) -> tuple[Optional[Dict[str, Any]], Dict[str, Any]]: + """Use DSPy + LLMManager to detect service intent and extract entities. + + Returns: + Tuple of (intent_result, usage_info): + - intent_result: Intent detection result dict (or None on error) + - usage_info: Cost and token usage information + """ + try: + if self.llm_manager: + self.llm_manager.ensure_global_config() + else: + logger.error(f"[{chat_id}] LLM Manager not available") + return None, {} + + lm = dspy.settings.lm + history_length_before = ( + len(lm.history) if lm and hasattr(lm, "history") else 0 + ) + + intent_module = IntentDetectionModule() + history_dicts = [ + {"authorRole": msg.authorRole, "message": msg.message} + for msg in conversation_history + if hasattr(msg, "authorRole") and hasattr(msg, "message") + ] + + with self.llm_manager.use_task_local(): + intent_result = intent_module.forward( + user_query=user_query, + services=services, + conversation_history=history_dicts, + ) + + usage_info = get_lm_usage_since(history_length_before) + + return intent_result, usage_info + + except Exception as e: + logger.error(f"[{chat_id}] Intent detection failed: {e}", exc_info=True) + return None, {} + + def _validate_detected_service( + self, + matched_service_id: str, + services: List[Dict[str, Any]], + chat_id: str, + ) -> Optional[Dict[str, Any]]: + """Validate that detected service exists in active services list.""" + for service in services: + service_id = service.get("serviceId", service.get("service_id")) + if service_id == matched_service_id: + return service + + logger.warning( + f"[{chat_id}] Service validation failed: '{matched_service_id}' not found" + ) + return None + + async def _process_intent_detection( + self, + services: List[Dict[str, Any]], + request: OrchestrationRequest, + chat_id: str, + context: Dict[str, Any], + costs_metric: Dict[str, Dict[str, Any]], + ) -> None: + """Detect intent, validate service, and populate context. + + This helper method encapsulates the common logic of: + 1. Calling intent detection (LLM) + 2. Tracking costs + 3. Validating matched service + 4. Populating context with service metadata + + Args: + services: List of services to match against + request: Orchestration request + chat_id: Chat ID for logging + context: Context dict to populate with results + costs_metric: Dictionary to track LLM costs + """ + intent_result, intent_usage = await self._detect_service_intent( + user_query=request.message, + services=services, + conversation_history=request.conversationHistory, + chat_id=chat_id, + ) + costs_metric["intent_detection"] = intent_usage + + if intent_result and intent_result.get("matched_service_id"): + service_id = intent_result["matched_service_id"] + logger.info(f"[{chat_id}] Matched: {service_id}") + + validated_service = self._validate_detected_service( + matched_service_id=service_id, + services=services, + chat_id=chat_id, + ) + + if validated_service: + context["service_id"] = service_id + context["confidence"] = intent_result.get("confidence", 0.0) + context["entities"] = intent_result.get("entities", {}) + context["service_data"] = validated_service + + def _extract_service_metadata( + self, context: Dict[str, Any], chat_id: str + ) -> Optional[Dict[str, Any]]: + """Extract service and entity metadata from context.""" + service_id = context.get("service_id") + if not service_id: + logger.error(f"[{chat_id}] Missing service_id in context") + return None + + service_data = context.get("service_data") + if not service_data: + logger.error(f"[{chat_id}] Missing service_data in context") + return None + + entities_dict = context.get("entities", {}) + entity_schema = service_data.get("entities", []) or [] + service_name = service_data.get("name", service_id) + ruuter_type = service_data.get("ruuter_type", "POST") + is_common = bool(service_data.get("is_common", False)) + + return { + "service_id": service_id, + "service_name": service_name, + "entities_dict": entities_dict, + "entity_schema": entity_schema, + "ruuter_type": ruuter_type, + "is_common": is_common, + "service_data": service_data, + } + + def _validate_entities( + self, + extracted_entities: Dict[str, str], + service_schema: List[str], + service_name: str, + chat_id: str, + ) -> Dict[str, Any]: + """ + Validate extracted entities against service schema. + + Args: + extracted_entities: Entity key-value pairs from LLM + service_schema: Expected entity keys from database + service_name: Service name for logging + chat_id: For logging + + Returns: + Dict with validation results: + - is_valid: Overall validation status + - missing_entities: List of schema entities not extracted + - extra_entities: List of extracted entities not in schema + - validation_errors: List of error messages + """ + missing_entities = [] + extra_entities = [] + validation_errors = [] + + # Check for missing entities (in schema but not extracted) + for schema_key in service_schema: + if schema_key not in extracted_entities: + missing_entities.append(schema_key) + elif extracted_entities[schema_key] == "": + # Entity extracted but value is empty + validation_errors.append(f"Entity '{schema_key}' has empty value") + + # Check for extra entities (extracted but not in schema) + extra_entities = [ + entity_key + for entity_key in extracted_entities + if entity_key not in service_schema + ] + + is_valid = True + + return { + "is_valid": is_valid, + "missing_entities": missing_entities, + "extra_entities": extra_entities, + "validation_errors": validation_errors, + } + + def _transform_entities_to_array( + self, entities_dict: Dict[str, str], entity_order: List[str] + ) -> List[str]: + """Transform entity dictionary to ordered array based on service schema.""" + if not entity_order: + return [] + return [entities_dict.get(key, "") for key in entity_order] + + _INVISIBLE_CHAR_TABLE = str.maketrans( + "", "", "\u2060\u200b\u200c\u200d\ufeff\u00ad\u200e\u200f" + ) + + @staticmethod + def _parse_service_prefix( + payload: str, + ) -> Optional[tuple[str, str]]: + """Parse a ``#service`` or ``#common_service`` button-payload into an ``(http_method, url)`` tuple. + + Extracts the HTTP method and resource path from payloads of the form + ``"#service, /POST/services/active/"`` and appends the path to + the appropriate base URL (``RUUTER_COMMON_SERVICE_BASE_URL`` for + ``#common_service`` prefixes, ``RUUTER_SERVICE_BASE_URL`` otherwise). + Returns ``None`` for any malformed input. + """ + stripped = payload.strip() + + # Identify and remove the prefix + matched_prefix: Optional[str] = None + for prefix in SERVICE_STEP_PREFIXES: + if stripped.startswith(prefix): + matched_prefix = prefix + break + + if matched_prefix is None: + return None + + # Remainder after prefix, e.g. " /POST/services/active/foo" + remainder = stripped[len(matched_prefix) :].strip() + + # Must start with '/' followed by the HTTP method + if not remainder.startswith("/"): + return None + + # Split into segments: ['', 'POST', 'services', 'active', 'foo'] + segments = remainder.split("/") + # segments[0] == '' (empty before leading /); + # segments[1] == HTTP method; segments[2:] == resource path parts + if len(segments) < 3: # noqa: PLR2004 + return None + + http_method = segments[1].upper() + if not http_method.isalpha(): + return None + + resource_path = "/" + "/".join(segments[2:]) + base_url = ( + RUUTER_COMMON_SERVICE_BASE_URL + if matched_prefix.startswith("#common_service") + else RUUTER_SERVICE_BASE_URL + ) + full_url = f"{base_url}{resource_path}" + + return (http_method, full_url) + + def _construct_service_endpoint( + self, service_name: str, chat_id: str, is_common: bool = False + ) -> str: + """Construct the full service endpoint URL for Ruuter. + + Args: + service_name: Name of the service to call. + chat_id: Chat ID for logging. + is_common: When True, routes to the common-service Ruuter base URL. + """ + clean_name = ( + service_name.strip().translate(self._INVISIBLE_CHAR_TABLE).replace(" ", "_") + ) + base_url = ( + RUUTER_COMMON_SERVICE_BASE_URL if is_common else RUUTER_SERVICE_BASE_URL + ) + service_type = "common" if is_common else "regular" + logger.debug( + f"[{chat_id}] Routing to {service_type} service base URL: {base_url}" + ) + return f"{base_url}/services/active/{clean_name}" + + async def _call_service_endpoint( + self, + endpoint_url: str, + http_method: str, + entities_array: List[str], + chat_id: str, + author_id: str, + ) -> Optional[Dict[str, Any]]: + """Call the Ruuter active service endpoint and extract response content. + + Args: + endpoint_url: Full URL of the active service endpoint + http_method: HTTP method (POST/GET) + entities_array: Ordered entity values for the service + chat_id: Chat session ID + author_id: Author/user ID + + Returns: + Dict with "content" (str) and "buttons" (List[Dict]) keys, or None on failure. + """ + payload = { + "chatId": chat_id, + "authorId": author_id, + "input": entities_array, + } + + try: + async with httpx.AsyncClient(timeout=SERVICE_CALL_TIMEOUT) as client: + if http_method.upper() == "POST": + response = await client.post(endpoint_url, json=payload) + else: + response = await client.get(endpoint_url, params=payload) + + response.raise_for_status() + data = response.json() + + # Ruuter wraps the DSL return value in {"response": ...} + # The inner value is the DMapper array from bot_responses_to_messages + if isinstance(data, dict) and "response" in data: + data = data["response"] + # "buttons" is a JSON-encoded string, not a sub-array. + if isinstance(data, list) and len(data) > 0: + item = data[0] + content = item.get("content", "") + raw_buttons = item.get("buttons", "[]") or "[]" + + if isinstance(raw_buttons, str): + try: + buttons = json.loads(raw_buttons) + except json.JSONDecodeError: + logger.warning( + f"[{chat_id}] Failed to parse buttons JSON string: {raw_buttons}" + ) + buttons = [] + elif isinstance(raw_buttons, list): + buttons = raw_buttons + else: + buttons = [] + + if not content: + logger.warning( + f"[{chat_id}] Service response missing 'content' field" + ) + logger.info( + f"[{chat_id}] Service endpoint returned content " + f"({len(content)} chars, {len(buttons)} buttons)" + ) + logger.debug(f"[{chat_id}] Parsed buttons: {buttons}") + return {"content": content, "buttons": buttons} + + logger.warning( + f"[{chat_id}] Unexpected service response format: {type(data)}" + ) + return None + + except httpx.TimeoutException: + logger.error( + f"[{chat_id}] Service endpoint timeout after {SERVICE_CALL_TIMEOUT}s: " + f"{endpoint_url}" + ) + return None + except httpx.HTTPStatusError as e: + logger.error( + f"[{chat_id}] Service endpoint HTTP error: " + f"{e.response.status_code} for {endpoint_url}" + ) + return None + except Exception as e: + logger.error( + f"[{chat_id}] Service endpoint call failed: {e}", + exc_info=True, + ) + return None + + async def _log_request_details( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + mode: str, + costs_metric: Dict[str, Dict[str, Any]], + ) -> None: + """Log request details and perform service discovery. + + Args: + request: The orchestration request + context: Workflow context dictionary + mode: Execution mode ("streaming" or "non-streaming") + costs_metric: Dictionary to accumulate cost tracking information + """ + chat_id = request.chatId + logger.info(f"[{chat_id}] SERVICE WORKFLOW ({mode}): {request.message}") + + discovery_result = await self._call_service_discovery(chat_id) + + if discovery_result: + response_data = discovery_result.get("response", {}) + use_semantic = response_data.get("use_semantic_search", False) + service_count = response_data.get("service_count", 0) + + if isinstance(service_count, str): + try: + service_count = int(service_count) + except (ValueError, TypeError): + service_count = 0 + + services_from_ruuter = response_data.get("services", []) + + if service_count > SERVICE_COUNT_THRESHOLD: + use_semantic = True + + if use_semantic: + services = await self._semantic_search_services( + query=request.message, + request=request, + chat_id=chat_id, + top_k=SEMANTIC_SEARCH_TOP_K, + ) + + if not services: + logger.warning(f"[{chat_id}] Semantic search failed") + + if services_from_ruuter: + services = services_from_ruuter + elif service_count <= MAX_SERVICES_FOR_LLM_CONTEXT: + fallback_result = await self._call_service_discovery(chat_id) + if fallback_result: + fallback_data = fallback_result.get("response", {}) + services = fallback_data.get("services", []) + else: + services = [] + else: + logger.error(f"[{chat_id}] Too many services ({service_count})") + services = [] + + if services: + await self._process_intent_detection( + services=services, + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + else: + services = response_data.get("services", []) + + if services: + await self._process_intent_detection( + services=services, + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + else: + logger.warning(f"[{chat_id}] Service discovery failed") + + async def execute_async( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[OrchestrationResponse]: + """Execute service workflow in non-streaming mode. + + Uses classification metadata from hybrid search: + - needs_llm_confirmation=False: Skip discovery + intent detection, use matched service + - needs_llm_confirmation=True: Run LLM intent detection on candidate services only + - No metadata: Fall back to original discovery flow + + Args: + request: Orchestration request + context: Workflow context + time_metric: Optional timing dictionary for unified tracking + """ + + chat_id = request.chatId + + costs_metric: Dict[str, Dict[str, Any]] = {} + if time_metric is None: + time_metric = {} + + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + matched_service_name = context.get("matched_service_name") + cosine_score = context.get("cosine_score", 0.0) + + logger.info( + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" + ) + + top_results = context.get("top_results", []) + if top_results: + matched = top_results[0] + + start_time = time.time() + await self._process_intent_detection( + services=[matched], + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + time_metric["service.intent_detection"] = time.time() - start_time + + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + + elif needs_llm_confirmation is True: + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" + ) + + start_time = time.time() + if top_results: + await self._process_intent_detection( + services=top_results, + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + time_metric["service.intent_detection"] = time.time() - start_time + + else: + start_time = time.time() + await self._log_request_details( + request, context, mode="non-streaming", costs_metric=costs_metric + ) + time_metric["service.discovery"] = time.time() - start_time + + if not context.get("service_id"): + logger.info(f"[{chat_id}] No service matched, falling back") + return None + + start_time = time.time() + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + return None + + logger.info( + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" + ) + + validation_result = self._validate_entities( + extracted_entities=service_metadata["entities_dict"], + service_schema=service_metadata["entity_schema"], + service_name=service_metadata["service_name"], + chat_id=chat_id, + ) + time_metric["service.entity_validation"] = time.time() - start_time + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" + ) + + entities_array = self._transform_entities_to_array( + entities_dict=service_metadata["entities_dict"], + entity_order=service_metadata["entity_schema"], + ) + + context["entities_array"] = entities_array + context["validation_result"] = validation_result + + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], + chat_id=chat_id, + is_common=service_metadata["is_common"], + ) + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + start_time = time.time() + service_result = await self._call_service_endpoint( + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + chat_id=chat_id, + author_id=request.authorId, + ) + time_metric["service.endpoint_call"] = time.time() - start_time + + if self.orchestration_service: + self.orchestration_service.log_costs(costs_metric) + + if service_result is None: + logger.warning(f"[{chat_id}] Service endpoint call failed, falling back") + return None + + service_content = service_result["content"] + service_buttons = service_result["buttons"] + buttons_list = [ + ChoiceButton(**b) + for b in service_buttons + if "title" in b and "payload" in b + ] + + return OrchestrationResponse( + chatId=request.chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=service_content, + buttons=buttons_list if buttons_list else None, + ) + + async def execute_streaming( + self, + request: OrchestrationRequest, + context: Dict[str, Any], + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """Execute service workflow in streaming mode. + + Uses classification metadata from hybrid search (same as execute_async). + + Args: + request: Orchestration request + context: Workflow context + time_metric: Optional timing dictionary for unified tracking + """ + + chat_id = request.chatId + + costs_metric: Dict[str, Dict[str, Any]] = {} + if time_metric is None: + time_metric = {} + + needs_llm_confirmation = context.get("needs_llm_confirmation") + + if needs_llm_confirmation is False: + matched_service_name = context.get("matched_service_name") + cosine_score = context.get("cosine_score", 0.0) + + logger.info( + f"[{chat_id}] High-confidence service match: " + f"{matched_service_name} (score={cosine_score:.4f})" + ) + + top_results = context.get("top_results", []) + if top_results: + matched = top_results[0] + + start_time = time.time() + await self._process_intent_detection( + services=[matched], + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + time_metric["service.intent_detection"] = time.time() - start_time + + if not context.get("service_data"): + context["service_id"] = matched.get("service_id") + context["service_data"] = matched + + elif needs_llm_confirmation is True: + top_results = context.get("top_results", []) + logger.info( + f"[{chat_id}] Ambiguous match: " + f"running intent detection on {len(top_results)} candidates" + ) + + start_time = time.time() + if top_results: + await self._process_intent_detection( + services=top_results, + request=request, + chat_id=chat_id, + context=context, + costs_metric=costs_metric, + ) + time_metric["service.intent_detection"] = time.time() - start_time + + else: + start_time = time.time() + await self._log_request_details( + request, context, mode="streaming", costs_metric=costs_metric + ) + time_metric["service.discovery"] = time.time() - start_time + + if not context.get("service_id"): + logger.info(f"[{chat_id}] No service matched, falling back") + return None + + service_metadata = self._extract_service_metadata(context, chat_id) + if not service_metadata: + return None + + logger.info( + f"[{chat_id}] Service: {service_metadata['service_name']}, " + f"entities: {service_metadata['entities_dict']}" + ) + + validation_result = self._validate_entities( + extracted_entities=service_metadata["entities_dict"], + service_schema=service_metadata["entity_schema"], + service_name=service_metadata["service_name"], + chat_id=chat_id, + ) + + if validation_result["missing_entities"]: + logger.warning( + f"[{chat_id}] Missing entities: {validation_result['missing_entities']}" + ) + + entities_array = self._transform_entities_to_array( + entities_dict=service_metadata["entities_dict"], + entity_order=service_metadata["entity_schema"], + ) + + context["entities_array"] = entities_array + context["validation_result"] = validation_result + + endpoint_url = self._construct_service_endpoint( + service_name=service_metadata["service_name"], + chat_id=chat_id, + is_common=service_metadata["is_common"], + ) + context["endpoint_url"] = endpoint_url + context["http_method"] = service_metadata["ruuter_type"] + + service_result = await self._call_service_endpoint( + endpoint_url=endpoint_url, + http_method=service_metadata["ruuter_type"], + entities_array=entities_array, + chat_id=chat_id, + author_id=request.authorId, + ) + + if service_result is None: + logger.warning(f"[{chat_id}] Service endpoint call failed, falling back") + return None + + if self.orchestration_service is None: + raise RuntimeError("Orchestration service not initialized for streaming") + + orchestration_service = self.orchestration_service + service_content = service_result["content"] + service_buttons = service_result["buttons"] + + async def service_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse( + chat_id, service_content, service_buttons or None + ) + yield orchestration_service.format_sse(chat_id, "END") + orchestration_service.log_costs(costs_metric) + + return service_stream() + + async def execute_direct_step( + self, + request: OrchestrationRequest, + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[OrchestrationResponse]: + """Execute a direct service step from a #service button payload. + + Bypasses discovery, intent detection, and entity extraction entirely. + The endpoint URL and HTTP method are parsed directly from the payload + string embedded in the button click. + + Args: + request: Orchestration request whose message is a #service payload. + time_metric: Optional timing dictionary for unified tracking. + + Returns: + OrchestrationResponse with content and buttons, or None on failure. + """ + chat_id = request.chatId + if time_metric is None: + time_metric = {} + + parsed = self._parse_service_prefix(request.message) + if parsed is None: + logger.warning( + f"[{chat_id}] Failed to parse #service prefix: {request.message}" + ) + return None + + http_method, endpoint_url = parsed + logger.info(f"[{chat_id}] DIRECT STEP: {endpoint_url}") + + start_time = time.time() + service_result = await self._call_service_endpoint( + endpoint_url=endpoint_url, + http_method=http_method, + entities_array=[], + chat_id=chat_id, + author_id=request.authorId, + ) + time_metric["service.direct_step"] = time.time() - start_time + + if service_result is None: + logger.warning( + f"[{chat_id}] Direct step endpoint call failed: {endpoint_url}" + ) + return None + + service_content = service_result["content"] + service_buttons = service_result["buttons"] + buttons_list = [ + ChoiceButton(**b) + for b in service_buttons + if "title" in b and "payload" in b + ] + + return OrchestrationResponse( + chatId=chat_id, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content=service_content, + buttons=buttons_list if buttons_list else None, + ) + + async def execute_direct_step_streaming( + self, + request: OrchestrationRequest, + time_metric: Optional[Dict[str, float]] = None, + ) -> Optional[AsyncIterator[str]]: + """Execute a direct service step and return an SSE stream. + + Same logic as execute_direct_step but wraps the response in an SSE + async generator suitable for the streaming endpoint. + + Args: + request: Orchestration request whose message is a #service payload. + time_metric: Optional timing dictionary for unified tracking. + + Returns: + AsyncIterator yielding SSE-formatted strings, or None on failure. + """ + chat_id = request.chatId + if time_metric is None: + time_metric = {} + + parsed = self._parse_service_prefix(request.message) + if parsed is None: + logger.warning( + f"[{chat_id}] Failed to parse #service prefix: {request.message}" + ) + return None + + http_method, endpoint_url = parsed + logger.info(f"[{chat_id}] DIRECT STEP (stream): {endpoint_url}") + + start_time = time.time() + service_result = await self._call_service_endpoint( + endpoint_url=endpoint_url, + http_method=http_method, + entities_array=[], + chat_id=chat_id, + author_id=request.authorId, + ) + time_metric["service.direct_step"] = time.time() - start_time + + if service_result is None: + logger.warning( + f"[{chat_id}] Direct step endpoint call failed: {endpoint_url}" + ) + return None + + if self.orchestration_service is None: + raise RuntimeError("Orchestration service not initialized for streaming") + + orchestration_service = self.orchestration_service + service_content = service_result["content"] + service_buttons = service_result["buttons"] + + async def step_stream() -> AsyncIterator[str]: + yield orchestration_service.format_sse( + chat_id, service_content, service_buttons or None + ) + yield orchestration_service.format_sse(chat_id, "END") + + return step_stream() diff --git a/src/utils/api_tool_session_store.py b/src/utils/api_tool_session_store.py new file mode 100644 index 00000000..3a06e5ec --- /dev/null +++ b/src/utils/api_tool_session_store.py @@ -0,0 +1,207 @@ +"""Redis-backed session store for the API Tool Calling agentic loop.""" + +from typing import Any, Optional + +from fastapi import HTTPException, Request, status +from loguru import logger +from redis import WatchError + +from src.models.session_models import APIToolSession +from src.utils.redis_client import get_redis_client + +_SESSION_KEY_PREFIX = "session:" +_SESSION_TTL_SECONDS = 1800 # 30 minutes, sliding +_UPDATE_MAX_RETRIES = 3 + + +def _key(chat_id: str) -> str: + return f"{_SESSION_KEY_PREFIX}{chat_id}" + + +_VALID_SESSION_FIELDS = frozenset(APIToolSession.model_fields) + + +class APIToolSessionStore: + """CRUD store for API tool agentic-loop sessions backed by Redis. + + All operations are async and safe to call from FastAPI handlers. + The TTL is reset (sliding expiry) on every save() and update(). + """ + + async def get(self, chat_id: str) -> Optional[APIToolSession]: + """Retrieve a session by chat_id. + + Returns: + The deserialized session, or None if not found / Redis unavailable. + """ + client = get_redis_client() + if client is None: + logger.warning( + "[SessionStore] Redis unavailable - get({}) skipped", chat_id + ) + return None + + try: + raw = await client.get(_key(chat_id)) + if raw is None: + return None + return APIToolSession.model_validate_json(raw) + except Exception as exc: + logger.error("[SessionStore] get({}) failed: {}", chat_id, exc) + return None + + async def save(self, session: APIToolSession) -> None: + """Persist a session (full replace) and reset the TTL. + + Args: + session: The session object to persist. + """ + client = get_redis_client() + if client is None: + logger.warning( + "[SessionStore] Redis unavailable - save({}) skipped", session.chat_id + ) + return + + try: + await client.set( + _key(session.chat_id), + session.model_dump_json(), + ex=_SESSION_TTL_SECONDS, + ) + logger.debug("[SessionStore] Session saved for chat_id={}", session.chat_id) + except Exception as exc: + logger.error("[SessionStore] save({}) failed: {}", session.chat_id, exc) + + async def update(self, chat_id: str, **fields: Any) -> Optional[APIToolSession]: + """Atomically update a session using optimistic locking (WATCH/MULTI/EXEC). + + Uses Redis WATCH to detect concurrent modifications. If a conflicting + write is detected, the operation retries up to ``_UPDATE_MAX_RETRIES`` times. + + Args: + chat_id: The conversation to update. + **fields: Field names and new values to merge into the session. + + Returns: + The updated session, or None if the session does not exist or Redis is unavailable. + + Raises: + ValueError: If any of the provided field names are not valid + ``APIToolSession`` attributes. + """ + unknown = set(fields) - _VALID_SESSION_FIELDS + if unknown: + raise ValueError(f"Unknown session fields: {unknown}") + + client = get_redis_client() + if client is None: + logger.warning( + "[SessionStore] Redis unavailable - update({}) skipped", chat_id + ) + return None + + key = _key(chat_id) + + for attempt in range(_UPDATE_MAX_RETRIES): + try: + async with client.pipeline(transaction=True) as pipe: + await pipe.watch(key) + + raw = await pipe.get(key) + if raw is None: + await pipe.unwatch() + logger.warning( + "[SessionStore] update({}) - session not found, skipping", + chat_id, + ) + return None + + session = APIToolSession.model_validate_json(raw) + updated = session.model_copy(update=fields) + + pipe.multi() + pipe.set(key, updated.model_dump_json(), ex=_SESSION_TTL_SECONDS) + await pipe.execute() + + logger.debug( + "[SessionStore] Session updated for chat_id={}", chat_id + ) + return updated + + except WatchError: + logger.debug( + "[SessionStore] update({}) - concurrent modification detected, " + "retrying (attempt {}/{})", + chat_id, + attempt + 1, + _UPDATE_MAX_RETRIES, + ) + continue + except Exception as exc: + logger.error("[SessionStore] update({}) failed: {}", chat_id, exc) + return None + + logger.error( + "[SessionStore] update({}) - exhausted {} retries due to concurrent writes", + chat_id, + _UPDATE_MAX_RETRIES, + ) + return None + + async def delete(self, chat_id: str) -> None: + """Remove a session from Redis. + + Args: + chat_id: The conversation whose session should be deleted. + """ + client = get_redis_client() + if client is None: + logger.warning( + "[SessionStore] Redis unavailable - delete({}) skipped", chat_id + ) + return + + try: + await client.delete(_key(chat_id)) + logger.debug("[SessionStore] Session deleted for chat_id={}", chat_id) + except Exception as exc: + logger.error("[SessionStore] delete({}) failed: {}", chat_id, exc) + + async def exists(self, chat_id: str) -> bool: + """Check whether a session exists for the given chat_id. + + Returns: + True if the session key exists in Redis, False otherwise. + """ + client = get_redis_client() + if client is None: + return False + + try: + return bool(await client.exists(_key(chat_id))) + except Exception as exc: + logger.error("[SessionStore] exists({}) failed: {}", chat_id, exc) + return False + + +def require_session_store(request: Request) -> APIToolSessionStore: + """FastAPI dependency that guarantees a live session store. + + Use as a dependency on any endpoint that requires multi-turn session + state. Returns HTTP 503 immediately when Redis is unavailable instead + of letting the request silently degrade. + """ + store: Optional[APIToolSessionStore] = getattr( + request.app.state, "session_store", None + ) + if store is None: + logger.error( + "[SessionStore] Session store unavailable — returning 503 for {}", + request.url.path, + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Session store is currently unavailable. Please try again later.", + ) + return store diff --git a/src/utils/budget_tracker.py b/src/utils/budget_tracker.py index 134b034e..48507de3 100644 --- a/src/utils/budget_tracker.py +++ b/src/utils/budget_tracker.py @@ -11,7 +11,7 @@ class BudgetTracker: """Handles budget updates for LLM connections.""" - def __init__(self): + def __init__(self) -> None: """Initialize the budget tracker with Resql and Ruuter endpoints.""" # Use Resql directly for budget updates self.resql_base = RAG_SEARCH_RESQL @@ -186,26 +186,26 @@ def update_budget( return {"success": False, "reason": "unexpected_error", "error": str(e)} def update_budget_from_costs( - self, connection_id: Optional[str], costs_dict: Dict[str, Dict[str, Any]] + self, connection_id: Optional[str], costs_metric: Dict[str, Dict[str, Any]] ) -> Dict[str, Any]: """ Update budget from a costs dictionary containing component costs. Args: connection_id: The LLM connection ID (optional) - costs_dict: Dictionary of component costs with total_cost values + costs_metric: Dictionary of component costs with total_cost values Returns: Dictionary containing the response from the update endpoint """ # Calculate total cost from all components total_cost = 0.0 - for component_costs in costs_dict.values(): + for component_costs in costs_metric.values(): total_cost += component_costs.get("total_cost", 0.0) logger.debug( f"Total cost calculated from components: ${total_cost:.6f} " - f"(components: {list(costs_dict.keys())})" + f"(components: {list(costs_metric.keys())})" ) return self.update_budget(connection_id, total_cost) diff --git a/src/utils/connection_id_fetcher.py b/src/utils/connection_id_fetcher.py index 903ad0b5..f9e17cca 100644 --- a/src/utils/connection_id_fetcher.py +++ b/src/utils/connection_id_fetcher.py @@ -23,7 +23,7 @@ class ConnectionIdFetcher: and production store services. """ - def __init__(self): + def __init__(self) -> None: """Initialize the connection ID fetcher with endpoints.""" # Use Resql directly for consistent performance self.resql_base = RAG_SEARCH_RESQL @@ -34,12 +34,14 @@ def __init__(self): # Thread-safe lock for cache access self._cache_lock = threading.Lock() - def _extract_connection_id_from_response(self, data: Any) -> Optional[int]: + def _extract_connection_id_from_response( + self, data: dict[str, Any] | list[Any] + ) -> Optional[int]: """ Extract connection ID from API response data. Args: - data: The JSON response data + data: The JSON response data (dict or list) Returns: The connection ID as integer, or None if not found @@ -200,7 +202,7 @@ async def fetch_connection_id_async(self, environment: str) -> Optional[int]: logger.error(f"Error fetching {environment} connection ID: {str(e)}") return None - def clear_cache(self, environment: Optional[str] = None): + def clear_cache(self, environment: Optional[str] = None) -> None: """ Clear the connection ID cache. diff --git a/src/utils/decrypt_vault_secrets.py b/src/utils/decrypt_vault_secrets.py index e6c8bf5e..6a47507f 100644 --- a/src/utils/decrypt_vault_secrets.py +++ b/src/utils/decrypt_vault_secrets.py @@ -140,7 +140,8 @@ def main() -> NoReturn: logger.debug("Decryption successful, outputting plaintext to stdout") # Output to stdout only (for shell script capture) - print(plaintext) + # Logger writes to stderr, print to stdout - intentional separation for piping + print(plaintext) # noqa: T201 sys.exit(0) diff --git a/src/utils/error_utils.py b/src/utils/error_utils.py index 4d873b85..8f679ae4 100644 --- a/src/utils/error_utils.py +++ b/src/utils/error_utils.py @@ -3,7 +3,7 @@ from datetime import datetime import random import string -from typing import Optional, Dict, Any, Any as LoggerType +from typing import Optional, Dict, Any def generate_error_id() -> str: @@ -22,7 +22,7 @@ def generate_error_id() -> str: def log_error_with_context( - logger: LoggerType, + logger: Any, # noqa: ANN401 - loguru logger type is complex, use Any error_id: str, stage: str, chat_id: Optional[str], diff --git a/src/utils/input_sanitizer.py b/src/utils/input_sanitizer.py index 36270381..b0bd146f 100644 --- a/src/utils/input_sanitizer.py +++ b/src/utils/input_sanitizer.py @@ -57,6 +57,8 @@ def strip_html_tags(text: str) -> str: if not text: return text + text = html.unescape(text) + # First pass: Remove dangerous tags and their content for tag in InputSanitizer.DANGEROUS_TAGS: # Remove opening tag, content, and closing tag @@ -74,9 +76,6 @@ def strip_html_tags(text: str) -> str: # Third pass: Remove all remaining HTML tags text = re.sub(r"<[^>]+>", "", text) - # Unescape HTML entities (e.g., < -> <) - text = html.unescape(text) - return text @staticmethod diff --git a/src/utils/language_detector.py b/src/utils/language_detector.py index ba289c96..db79988a 100644 --- a/src/utils/language_detector.py +++ b/src/utils/language_detector.py @@ -70,7 +70,6 @@ def detect_language(text: str) -> LanguageCode: "võib", "olen", "oled", - "see", "seda", "jah", "või", @@ -83,6 +82,14 @@ def detect_language(text: str) -> LanguageCode: "nagu", "oli", "mis", + # Estonian greeting words + "tere", + "hei", + "tervist", + "tänan", + "aitäh", + "nägemist", + "moi", ] # Tokenize and check for Estonian markers diff --git a/src/utils/production_store.py b/src/utils/production_store.py index f0f30fef..69026035 100644 --- a/src/utils/production_store.py +++ b/src/utils/production_store.py @@ -22,7 +22,7 @@ class ProductionInferenceStore: Service for storing production inference results via Ruuter endpoint. """ - def __init__(self): + def __init__(self) -> None: """Initialize the production inference store with Ruuter configuration.""" self.store_endpoint = f"{RAG_SEARCH_RUUTER_PUBLIC}/inference/results/store" self.timeout = 10 # seconds @@ -55,7 +55,7 @@ def _create_payload( } def _handle_response_data( - self, response_data: Any, chat_id: str, environment: str + self, response_data: dict[str, Any] | list[Any], chat_id: str, environment: str ) -> Dict[str, Any]: """Handle and validate response data from the API.""" # Handle nested response structure from Ruuter: {"response": {"data": {...}}} diff --git a/src/utils/prompt_config_loader.py b/src/utils/prompt_config_loader.py new file mode 100644 index 00000000..bd977657 --- /dev/null +++ b/src/utils/prompt_config_loader.py @@ -0,0 +1,421 @@ +""" +Prompt configuration loader with HTTP client, caching, and retry logic. +""" + +import requests +from typing import Optional, Dict, Any +import time +import threading +from enum import Enum +from loguru import logger + + +class PromptConfigLoadError(Exception): + """Raised when all retry attempts to load prompt configuration fail.""" + + pass + + +class RefreshStatus(Enum): + """Status of a refresh operation.""" + + SUCCESS = "success" # Configuration loaded successfully + NOT_FOUND = "not_found" # Configuration absent in database + FETCH_FAILED = "fetch_failed" # Network/HTTP/upstream errors + + +class PromptConfigurationLoader: + """ + Loads custom prompt configurations from Ruuter endpoint. + + Features: + - HTTP-based loading via Ruuter + - 5-minute TTL cache (configurable) + - 3-attempt retry with exponential backoff + - Thread-safe caching + - Graceful degradation with stale cache fallback + """ + + def __init__( + self, + ruuter_endpoint: str, + cache_ttl_seconds: int = 300, + max_retries: int = 3, + timeout_seconds: int = 10, + ) -> None: + """ + Initialize prompt configuration loader. + + Args: + ruuter_endpoint: Full URL to Ruuter endpoint + cache_ttl_seconds: Cache TTL in seconds (default: 300 = 5 minutes) + max_retries: Maximum retry attempts on failure (default: 3) + timeout_seconds: HTTP request timeout (default: 10) + """ + self.ruuter_endpoint = ruuter_endpoint + self.cache_ttl_seconds = cache_ttl_seconds + self.max_retries = max_retries + self.timeout_seconds = timeout_seconds + + # Cache storage + self._cached_prompt: Optional[str] = None + self._cache_timestamp: Optional[float] = None + self._cache_lock = threading.Lock() + self._cache_condition = threading.Condition(self._cache_lock) + self._fetch_in_progress = False + + # Statistics for monitoring + self._cache_hits = 0 + self._cache_misses = 0 + self._load_failures = 0 + self._last_error: Optional[str] = None + + logger.info( + f"PromptConfigurationLoader initialized: " + f"endpoint={ruuter_endpoint}, ttl={cache_ttl_seconds}s, retries={max_retries}" + ) + + def get_custom_instructions(self) -> str: + """ + Get custom prompt configuration (cached or fresh). + + Uses fine-grained locking with thundering herd prevention: + - Quick cache check under lock + - Release lock during slow network I/O + - Only one thread fetches, others wait + - Re-acquire lock to update cache + + Returns: + str: Custom instruction text, or empty string if unavailable + """ + # Step 1: Quick cache check under lock + with self._cache_condition: + # Check cache validity + if self._is_cache_valid(): + self._cache_hits += 1 + logger.debug( + f"Prompt config cache HIT " + f"(age: {self._get_cache_age():.1f}s, " + f"hits: {self._cache_hits}, misses: {self._cache_misses})" + ) + return self._cached_prompt or "" + + # Cache miss/expired + self._cache_misses += 1 + logger.info( + f"Prompt config cache MISS - loading from Ruuter " + f"(cache age: {self._get_cache_age():.1f}s)" + ) + + # Thundering herd prevention: if another thread is fetching, wait + while self._fetch_in_progress: + logger.debug("Another thread is fetching, waiting...") + self._cache_condition.wait() # Release lock and wait + # After waking up, check if cache was updated + if self._is_cache_valid(): + logger.debug("Cache updated by another thread") + return self._cached_prompt or "" + + # We're the first one, mark fetch in progress + self._fetch_in_progress = True + + # Step 2: Fetch WITHOUT holding lock (allows concurrent cache reads) + prompt_text = None + fetch_error = None + try: + prompt_text = self._load_from_ruuter_with_retry() + + except PromptConfigLoadError as e: + fetch_error = e + logger.error(f"Failed to fetch prompt configuration after retries: {e}") + + except Exception as e: + fetch_error = e + logger.error(f"Unexpected error loading prompt configuration: {e}") + + # Step 3: Update cache and notify waiters (lock re-acquired) + with self._cache_condition: + try: + if prompt_text: + # Success - update cache + self._cached_prompt = prompt_text + self._cache_timestamp = time.time() + self._last_error = None + logger.info( + f"Prompt configuration loaded successfully " + f"({len(prompt_text)} chars)" + ) + return prompt_text + + elif prompt_text is None and fetch_error is None: + # Prompt field not found - cache empty result to avoid repeated loads + logger.warning( + "Prompt field not found in database; caching empty result" + ) + self._cached_prompt = "" + self._cache_timestamp = time.time() + self._last_error = None + return "" + + else: + # Fetch failed - handle error + self._load_failures += 1 + self._last_error = str(fetch_error) + logger.error( + f"Failed to fetch prompt configuration " + f"(total failures: {self._load_failures})" + ) + # Fallback to stale cache or empty string + if self._cached_prompt: + logger.warning( + f"Using stale cache due to fetch failure (age: {self._get_cache_age():.1f}s)" + ) + return self._cached_prompt or "" + + finally: + # Always clear in-progress flag and notify waiting threads + self._fetch_in_progress = False + self._cache_condition.notify_all() # Wake up all waiting threads + + def _is_cache_valid(self) -> bool: + """Check if cache is within TTL window.""" + if self._cached_prompt is None or self._cache_timestamp is None: + return False + + age = time.time() - self._cache_timestamp + return age < self.cache_ttl_seconds + + def _get_cache_age(self) -> float: + """Get cache age in seconds.""" + if self._cache_timestamp is None: + return float("inf") + return time.time() - self._cache_timestamp + + def _load_from_ruuter_with_retry(self) -> Optional[str]: + """ + Load configuration from Ruuter with exponential backoff retry. + + Retry strategy: + - Attempt 1: 0s wait + - Attempt 2: 1s wait + - Attempt 3: 2s wait + + Returns: + Optional[str]: Prompt text if found, None if configuration is empty/not found + + Raises: + PromptConfigLoadError: If all retry attempts fail due to HTTP/network errors + """ + for attempt in range(1, self.max_retries + 1): + try: + logger.debug( + f"Calling Ruuter endpoint " + f"(attempt {attempt}/{self.max_retries}): {self.ruuter_endpoint}" + ) + + response = requests.post( + self.ruuter_endpoint, + json={}, # Empty POST body + timeout=self.timeout_seconds, + headers={"Content-Type": "application/json"}, + ) + + # Check HTTP status + if response.status_code == 200: + data = response.json() + + # Handle response format - Ruuter wraps response in 'response' key + prompt = "" + + # Unwrap Ruuter's response wrapper if present + if isinstance(data, dict) and "response" in data: + logger.debug("Unwrapping 'response' key") + data = data["response"] + + # Now extract prompt from the unwrapped data + prompt = None # None means field doesn't exist (not found) + + if isinstance(data, list) and len(data) > 0: + # Array format: [{"id": 1, "prompt": "..."}] + first_elem_keys = ( + list(data[0].keys()) if isinstance(data[0], dict) else [] + ) + logger.debug( + f"Extracting from list, first element keys: {first_elem_keys}" + ) + # Check if 'prompt' KEY exists (distinguish from missing field) + if isinstance(data[0], dict) and "prompt" in data[0]: + prompt = data[0].get("prompt", "").strip() + elif isinstance(data, dict): + # Dict format: {"id": 1, "prompt": "..."} + logger.debug(f"Extracting from dict, keys: {list(data.keys())}") + # Check if 'prompt' KEY exists (distinguish from missing field) + if "prompt" in data: + prompt = data.get("prompt", "").strip() + else: + logger.warning( + f"Unexpected data type: {type(data).__name__}, structure not recognized" + ) + + # Distinguish between: exists and empty ("") vs doesn't exist (None) + if prompt is not None: + # Field exists - valid state (even if empty) + logger.debug( + f"Loaded prompt on attempt {attempt} ({len(prompt)} chars)" + ) + return prompt # Return actual value (may be empty string) + else: + # Field doesn't exist - configuration not found + logger.warning( + f"Prompt field not found or missing (attempt {attempt})" + ) + return None + + else: + logger.warning( + f"HTTP {response.status_code} on attempt {attempt}: " + f"{response.text[:200]}" + ) + + except requests.exceptions.Timeout: + logger.warning( + f"Request timeout on attempt {attempt} " + f"(timeout: {self.timeout_seconds}s)" + ) + + except requests.exceptions.ConnectionError as e: + logger.warning(f"Connection error on attempt {attempt}: {str(e)[:100]}") + + except requests.exceptions.RequestException as e: + logger.warning(f"Request error on attempt {attempt}: {str(e)[:100]}") + + except (ValueError, KeyError) as e: + logger.error(f"Invalid response format on attempt {attempt}: {e}") + + except Exception as e: + logger.error(f"Unexpected error on attempt {attempt}: {e}") + + # Wait before retry (except on last attempt) + if attempt < self.max_retries: + wait_time = 2 ** (attempt - 1) # 1s, 2s + logger.debug(f"Retrying in {wait_time}s...") + time.sleep(wait_time) + + # All retries failed - raise exception to distinguish from "not found" + error_msg = ( + f"All {self.max_retries} attempts failed to load prompt configuration" + ) + logger.error(error_msg) + raise PromptConfigLoadError(error_msg) + + def force_refresh(self) -> bool: + """ + Force immediate cache refresh. + + Returns: + bool: True if fresh data was successfully loaded, False otherwise + """ + status_dict = self.force_refresh_with_status() + return status_dict["status"] == RefreshStatus.SUCCESS + + def force_refresh_with_status(self) -> Dict[str, Any]: + """ + Force immediate cache refresh and return detailed status. + + Returns: + Dict with keys: + - status: RefreshStatus enum value + - message: Human-readable message + - error: Error message (if status != SUCCESS) + """ + logger.info("Forcing prompt configuration cache refresh") + + # Track state before refresh + had_cached_value = self._cached_prompt is not None + + with self._cache_condition: + # Invalidate both timestamp and cached value so that a failed refresh + # cannot fall back to a stale prompt and be misreported as success. + self._cache_timestamp = None + self._cached_prompt = None + + # Attempt fresh load + prompt_text = None + fetch_error = None + try: + prompt_text = self._load_from_ruuter_with_retry() + + except PromptConfigLoadError as e: + fetch_error = e + logger.error(f"Failed to fetch prompt configuration after retries: {e}") + + except Exception as e: + fetch_error = e + logger.error(f"Unexpected error loading prompt configuration: {e}") + + # Determine status and update cache + with self._cache_condition: + if prompt_text is not None: + # Success - field exists + self._cached_prompt = prompt_text + self._cache_timestamp = time.time() + self._last_error = None + logger.info( + f"Prompt configuration refreshed successfully ({len(prompt_text)} chars)" + ) + return { + "status": RefreshStatus.SUCCESS, + "message": "Prompt configuration refreshed successfully", + "length": len(prompt_text), + } + + elif prompt_text is None and fetch_error is None: + # No configuration found (prompt field missing in response from Ruuter) + self._cached_prompt = "" + self._cache_timestamp = time.time() + self._last_error = None + logger.warning("Prompt field not found in database") + return { + "status": RefreshStatus.NOT_FOUND, + "message": "Prompt field not found in database", + "error": None, + } + + else: + # Fetch failed (network/HTTP/timeout errors) + self._load_failures += 1 + self._last_error = str(fetch_error) + logger.error(f"Failed to fetch prompt configuration: {fetch_error}") + + # Do NOT cache empty result on failure - let next call retry + # Only keep stale cache if it existed before + if had_cached_value: + logger.warning("Keeping stale cache due to fetch failure") + + return { + "status": RefreshStatus.FETCH_FAILED, + "message": "Failed to refresh configuration due to upstream service error", + "error": str(fetch_error), + "had_stale_cache": had_cached_value, + } + + def get_cache_stats(self) -> Dict[str, Any]: + """Get cache statistics for monitoring.""" + with self._cache_condition: + return { + "cache_hits": self._cache_hits, + "cache_misses": self._cache_misses, + "load_failures": self._load_failures, + "cache_age_seconds": ( + round(self._get_cache_age(), 2) if self._is_cache_valid() else None + ), + "has_cached_value": self._cached_prompt is not None, + "cache_valid": self._is_cache_valid(), + "cached_prompt_length": ( + len(self._cached_prompt) if self._cached_prompt else 0 + ), + "last_error": self._last_error, + "ruuter_endpoint": self.ruuter_endpoint, + "cache_ttl_seconds": self.cache_ttl_seconds, + "fetch_in_progress": self._fetch_in_progress, + } diff --git a/src/utils/query_validator.py b/src/utils/query_validator.py new file mode 100644 index 00000000..98766f78 --- /dev/null +++ b/src/utils/query_validator.py @@ -0,0 +1,112 @@ +"""Basic query validation for empty/meaningless inputs. + +This module provides lightweight, rule-based validation to reject syntactically +invalid queries before they reach expensive LLM-based processing stages. + +Validation checks (all syntactic, NO semantic): +- Empty or whitespace-only messages +- Messages containing only special characters/punctuation (including unicode) +- Messages with too few meaningful characters (< 2) +- Messages with only repetitive characters (e.g., "aaaa", "????") +- Emoji-only messages + +Out of scope for this module: +- Semantic validation (greetings, chitchat, intent detection) +- Language quality checks +- Content policy checks (handled by guardrails) + +Design decisions: +- Numbers are considered valid (e.g., "123" passes validation) +- Mixed alphanumeric with punctuation is valid (e.g., "ab!" passes) +- Unicode punctuation is treated same as ASCII punctuation +- Emojis are not considered meaningful characters +""" + +import re +from typing import Optional +from pydantic import BaseModel + + +class QueryValidationResult(BaseModel): + """Result of basic query validation. + + Attributes: + is_valid: True if query passes all validation checks + rejection_reason: Optional reason code if validation fails + (empty, special_chars_only, too_short, repetitive) + """ + + is_valid: bool + rejection_reason: Optional[str] = None + + +def validate_query_basic(query: str) -> QueryValidationResult: + """ + Validate query for basic syntactic issues (NOT semantic). + + This is a fast, rule-based check that runs before expensive operations + like guardrails or prompt refinement. It only catches obvious syntactic + issues, not semantic problems. + + Args: + query: User's input message to validate + + Returns: + QueryValidationResult with is_valid flag and optional rejection_reason + + Examples: + Valid queries: + >>> validate_query_basic("How to apply for benefits?") + QueryValidationResult(is_valid=True, rejection_reason=None) + >>> validate_query_basic("hi") + QueryValidationResult(is_valid=True, rejection_reason=None) + >>> validate_query_basic("123") + QueryValidationResult(is_valid=True, rejection_reason=None) + >>> validate_query_basic("ab!") + QueryValidationResult(is_valid=True, rejection_reason=None) + + Invalid queries: + >>> validate_query_basic("...") + QueryValidationResult(is_valid=False, rejection_reason='special_chars_only') + >>> validate_query_basic("") + QueryValidationResult(is_valid=False, rejection_reason='empty') + >>> validate_query_basic("????") + QueryValidationResult(is_valid=False, rejection_reason='repetitive') + >>> validate_query_basic("a") + QueryValidationResult(is_valid=False, rejection_reason='too_short') + >>> validate_query_basic("😀😀😀") + QueryValidationResult(is_valid=False, rejection_reason='special_chars_only') + """ + # Trim whitespace + query = query.strip() + + # Check 1: Empty query + if not query: + return QueryValidationResult(is_valid=False, rejection_reason="empty") + + # Check 2: Only special characters/punctuation (including unicode and emojis) + # Remove all alphanumeric characters (letters and numbers in any language) + # If nothing remains or only punctuation/symbols/emojis, reject + alphanumeric_pattern = re.compile(r"[\w]", re.UNICODE) + has_alphanumeric = bool(alphanumeric_pattern.search(query)) + + if not has_alphanumeric: + # No letters or numbers found - only punctuation/symbols/emojis + return QueryValidationResult( + is_valid=False, rejection_reason="special_chars_only" + ) + + # Check 3: Too short (< 2 meaningful characters) + # Extract alphanumeric characters (letters + numbers, unicode-aware) + meaningful_chars = alphanumeric_pattern.findall(query) + if len(meaningful_chars) < 2: + return QueryValidationResult(is_valid=False, rejection_reason="too_short") + + # Check 4: Only repetitive characters (e.g., "aaaa", "????", "111") + # If all meaningful characters are the same (case-insensitive), likely spam + unique_chars = {c.lower() for c in meaningful_chars} + if len(unique_chars) == 1: + return QueryValidationResult(is_valid=False, rejection_reason="repetitive") + + # Passed all checks - query is syntactically valid + return QueryValidationResult(is_valid=True) diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py index 4b88d9d7..074a0f6f 100644 --- a/src/utils/rate_limiter.py +++ b/src/utils/rate_limiter.py @@ -1,8 +1,8 @@ -"""Rate limiter for streaming endpoints with sliding window and token bucket algorithms.""" +"""Rate limiter for streaming endpoints with sliding window algorithms.""" import time from collections import defaultdict, deque -from typing import Dict, Deque, Tuple, Optional, Any +from typing import Dict, Deque, Optional, Any from threading import Lock from loguru import logger @@ -31,11 +31,11 @@ class RateLimitResult(BaseModel): class RateLimiter: """ - In-memory rate limiter with sliding window (requests/minute) and token bucket (tokens/second). + In-memory rate limiter using sliding windows for both requests and tokens. Features: - Sliding window for request rate limiting (e.g., 10 requests per minute) - - Token bucket for burst control (e.g., 100 tokens per second) + - Sliding window for token rate limiting (e.g., 40,000 tokens per minute) - Per-user tracking with authorId - Automatic cleanup of old entries to prevent memory leaks - Thread-safe operations @@ -43,7 +43,7 @@ class RateLimiter: Usage: rate_limiter = RateLimiter( requests_per_minute=10, - tokens_per_second=100 + tokens_per_minute=40_000, ) result = rate_limiter.check_rate_limit( @@ -59,28 +59,32 @@ class RateLimiter: def __init__( self, requests_per_minute: int = StreamConfig.RATE_LIMIT_REQUESTS_PER_MINUTE, - tokens_per_second: int = StreamConfig.RATE_LIMIT_TOKENS_PER_SECOND, + tokens_per_minute: int = StreamConfig.RATE_LIMIT_TOKENS_PER_MINUTE, cleanup_interval: int = StreamConfig.RATE_LIMIT_CLEANUP_INTERVAL, - ): + token_window_seconds: int = StreamConfig.RATE_LIMIT_TOKEN_WINDOW_SECONDS, + ) -> None: """ Initialize rate limiter. Args: requests_per_minute: Maximum requests per user per minute (sliding window) - tokens_per_second: Maximum tokens per user per second (token bucket) + tokens_per_minute: Maximum tokens per user per minute (sliding window) cleanup_interval: Seconds between automatic cleanup of old entries + token_window_seconds: Sliding window size in seconds for token tracking """ self.requests_per_minute = requests_per_minute - self.tokens_per_second = tokens_per_second + self.tokens_per_minute = tokens_per_minute self.cleanup_interval = cleanup_interval + self.token_window_seconds = token_window_seconds + # Scale the per-minute limit to the actual window size so the + # sliding-window comparison is consistent regardless of window length. + self.tokens_per_window = int(tokens_per_minute * token_window_seconds / 60) # Sliding window: Track request timestamps per user - # Format: {author_id: deque([timestamp1, timestamp2, ...])} self._request_history: Dict[str, Deque[float]] = defaultdict(deque) - # Token bucket: Track token consumption per user - # Format: {author_id: (last_refill_time, available_tokens)} - self._token_buckets: Dict[str, Tuple[float, float]] = {} + # Sliding window: Track token usage per user + self._token_history: Dict[str, Deque[tuple[float, int]]] = defaultdict(deque) # Thread safety self._lock = Lock() @@ -91,7 +95,7 @@ def __init__( logger.info( f"RateLimiter initialized - " f"requests_per_minute: {requests_per_minute}, " - f"tokens_per_second: {tokens_per_second}" + f"tokens_per_minute: {tokens_per_minute}" ) def check_rate_limit( @@ -121,7 +125,7 @@ def check_rate_limit( if not request_result.allowed: return request_result - # Check 2: Token bucket (tokens per second) + # Check 2: Sliding window (tokens per minute) if estimated_tokens > 0: token_result = self._check_token_limit( author_id, estimated_tokens, current_time @@ -186,12 +190,11 @@ def _check_token_limit( current_time: float, ) -> RateLimitResult: """ - Check token bucket limit. + Check sliding window token limit. - Token bucket algorithm: - - Bucket refills at constant rate (tokens_per_second) - - Burst allowed up to bucket capacity - - Request denied if insufficient tokens + Sliding window algorithm: + - Track cumulative tokens consumed within the window + - Reject if adding estimated tokens would exceed the limit Args: author_id: User identifier @@ -201,38 +204,42 @@ def _check_token_limit( Returns: RateLimitResult for token limit check """ - bucket_capacity = self.tokens_per_second - - # Get or initialize bucket for user - if author_id not in self._token_buckets: - # New user - start with full bucket - self._token_buckets[author_id] = (current_time, bucket_capacity) - - last_refill, available_tokens = self._token_buckets[author_id] - - # Refill tokens based on time elapsed - time_elapsed = current_time - last_refill - refill_amount = time_elapsed * self.tokens_per_second - available_tokens = min(bucket_capacity, available_tokens + refill_amount) - - # Check if enough tokens available - if available_tokens < estimated_tokens: - # Calculate time needed to refill enough tokens - tokens_needed = estimated_tokens - available_tokens - retry_after = int(tokens_needed / self.tokens_per_second) + 1 + token_history = self._token_history[author_id] + window_start = current_time - self.token_window_seconds + + # Remove entries outside the sliding window + while token_history and token_history[0][0] < window_start: + token_history.popleft() + + # Sum tokens consumed in the current window + current_token_usage = sum(tokens for _, tokens in token_history) + + # Check if adding this request would exceed the scaled window limit + if current_token_usage + estimated_tokens > self.tokens_per_window: + # Calculate retry_after based on oldest entry in window + if token_history: + oldest_timestamp = token_history[0][0] + retry_after = ( + int(oldest_timestamp + self.token_window_seconds - current_time) + 1 + ) + else: + retry_after = 1 logger.warning( f"Token rate limit exceeded for {author_id} - " - f"needed: {estimated_tokens}, available: {available_tokens:.0f} " - f"(retry after {retry_after}s)" + f"needed: {estimated_tokens}, " + f"current_usage: {current_token_usage}/{self.tokens_per_window} " + f"(window: {self.token_window_seconds}s, " + f"rate: {self.tokens_per_minute}/min, " + f"retry after {retry_after}s)" ) return RateLimitResult( allowed=False, retry_after=retry_after, limit_type="tokens", - current_usage=int(bucket_capacity - available_tokens), - limit=self.tokens_per_second, + current_usage=current_token_usage, + limit=self.tokens_per_window, ) return RateLimitResult(allowed=True) @@ -254,20 +261,9 @@ def _record_request( # Record request timestamp for sliding window self._request_history[author_id].append(current_time) - # Deduct tokens from bucket - if tokens_consumed > 0 and author_id in self._token_buckets: - last_refill, available_tokens = self._token_buckets[author_id] - - # Refill before deducting - time_elapsed = current_time - last_refill - refill_amount = time_elapsed * self.tokens_per_second - available_tokens = min( - self.tokens_per_second, available_tokens + refill_amount - ) - - # Deduct tokens - available_tokens -= tokens_consumed - self._token_buckets[author_id] = (current_time, available_tokens) + # Record token usage for sliding window + if tokens_consumed > 0: + self._token_history[author_id].append((current_time, tokens_consumed)) def _cleanup_old_entries(self, current_time: float) -> None: """ @@ -294,23 +290,25 @@ def _cleanup_old_entries(self, current_time: float) -> None: for author_id in users_to_remove: del self._request_history[author_id] - # Clean up token buckets (remove entries inactive for 5 minutes) - inactive_threshold = current_time - 300 - buckets_to_remove: list[str] = [] + # Clean up token history (remove entries outside window + inactive users) + token_window_start = current_time - self.token_window_seconds + token_users_to_remove: list[str] = [] - for author_id, (last_refill, _) in self._token_buckets.items(): - if last_refill < inactive_threshold: - buckets_to_remove.append(author_id) + for author_id, token_history in self._token_history.items(): + while token_history and token_history[0][0] < token_window_start: + token_history.popleft() + if not token_history: + token_users_to_remove.append(author_id) - for author_id in buckets_to_remove: - del self._token_buckets[author_id] + for author_id in token_users_to_remove: + del self._token_history[author_id] self._last_cleanup = current_time - if users_to_remove or buckets_to_remove: + if users_to_remove or token_users_to_remove: logger.debug( f"Cleaned up {len(users_to_remove)} request histories and " - f"{len(buckets_to_remove)} token buckets" + f"{len(token_users_to_remove)} token histories" ) def get_stats(self) -> Dict[str, Any]: @@ -323,9 +321,9 @@ def get_stats(self) -> Dict[str, Any]: with self._lock: return { "total_users_tracked": len(self._request_history), - "total_token_buckets": len(self._token_buckets), + "total_token_histories": len(self._token_history), "requests_per_minute_limit": self.requests_per_minute, - "tokens_per_second_limit": self.tokens_per_second, + "tokens_per_minute_limit": self.tokens_per_minute, "last_cleanup": self._last_cleanup, } @@ -339,7 +337,7 @@ def reset_user(self, author_id: str) -> None: with self._lock: if author_id in self._request_history: del self._request_history[author_id] - if author_id in self._token_buckets: - del self._token_buckets[author_id] + if author_id in self._token_history: + del self._token_history[author_id] logger.info(f"Reset rate limits for user: {author_id}") diff --git a/src/utils/redis_client.py b/src/utils/redis_client.py new file mode 100644 index 00000000..960a9752 --- /dev/null +++ b/src/utils/redis_client.py @@ -0,0 +1,105 @@ +"""Redis async connection manager for session store.""" + +import os +from typing import Any, Optional + +import redis.asyncio as aioredis +from loguru import logger + + +_redis_client: Optional[aioredis.Redis] = None # type: ignore[type-arg] + + +def _is_tls_enabled() -> bool: + return os.getenv("REDIS_TLS_ENABLED", "false").lower() == "true" + + +def _build_redis_url() -> str: + """Build Redis URL from environment variables.""" + host = os.getenv("REDIS_HOST", "redis") + port = os.getenv("REDIS_PORT", "6379") + password = os.getenv("REDIS_AUTH", "") + db = os.getenv("REDIS_SESSION_DB", "1") + scheme = "rediss" if _is_tls_enabled() else "redis" + + if password: + return f"{scheme}://:{password}@{host}:{port}/{db}" + return f"{scheme}://{host}:{port}/{db}" + + +def _build_tls_kwargs() -> dict[str, Any]: + """Return SSL keyword arguments for ``from_url()`` when TLS is enabled.""" + if not _is_tls_enabled(): + return {} + + kwargs: dict[str, Any] = {"ssl_cert_reqs": "required"} + + ca = os.getenv("REDIS_TLS_CA") + if ca: + kwargs["ssl_ca_certs"] = ca + + cert = os.getenv("REDIS_TLS_CERT") + if cert: + kwargs["ssl_certfile"] = cert + + key = os.getenv("REDIS_TLS_KEY") + if key: + kwargs["ssl_keyfile"] = key + + return kwargs + + +async def init_redis_client() -> aioredis.Redis: + """Initialize the singleton async Redis client. + + Uses db=1 (REDIS_SESSION_DB) to isolate session data from Langfuse (db=0). + Should be called once during FastAPI lifespan startup. + """ + global _redis_client + + url = _build_redis_url() + tls_kwargs = _build_tls_kwargs() + _redis_client = aioredis.from_url( + url, + encoding="utf-8", + decode_responses=True, + **tls_kwargs, + ) + + # Verify connectivity + await _redis_client.ping() + logger.info( + "Redis session store connected (db={})", os.getenv("REDIS_SESSION_DB", "1") + ) + return _redis_client + + +async def close_redis_client() -> None: + """Close the Redis client connection pool gracefully.""" + global _redis_client + + if _redis_client is not None: + await _redis_client.aclose() + _redis_client = None + logger.info("Redis session store connection closed") + + +def get_redis_client() -> Optional[aioredis.Redis]: + """Return the initialized Redis client, or None if not initialized.""" + return _redis_client + + +async def check_redis_health() -> str: + """Check Redis connectivity for the health endpoint. + + Returns: + "connected" if Redis is reachable, "disconnected" otherwise. + """ + client = get_redis_client() + if client is None: + return "not_configured" + try: + await client.ping() + return "connected" + except Exception: + return "disconnected" diff --git a/src/utils/stream_manager.py b/src/utils/stream_manager.py index e52660e2..e12296ea 100644 --- a/src/utils/stream_manager.py +++ b/src/utils/stream_manager.py @@ -8,7 +8,7 @@ from pydantic import BaseModel, Field, ConfigDict from src.llm_orchestrator_config.stream_config import StreamConfig -from src.llm_orchestrator_config.exceptions import StreamException +from src.llm_orchestrator_config.exceptions import StreamError from src.utils.error_utils import generate_error_id @@ -118,7 +118,7 @@ def __new__(cls) -> "StreamManager": cls._instance = super().__new__(cls) return cls._instance - def __init__(self): + def __init__(self) -> None: """Initialize the stream manager.""" if not hasattr(self, "_initialized"): self._streams: Dict[str, StreamContext] = {} @@ -276,9 +276,7 @@ async def managed_stream( f"Stream creation rejected for chatId={chat_id}, authorId={author_id}: {error_msg}", extra={"error_id": error_id}, ) - raise StreamException( - f"Cannot create stream: {error_msg}", error_id=error_id - ) + raise StreamError(f"Cannot create stream: {error_msg}", error_id=error_id) # Register the stream ctx = await self.register_stream(chat_id, author_id) diff --git a/src/utils/stream_timeout.py b/src/utils/stream_timeout.py index de071df4..3278b7bb 100644 --- a/src/utils/stream_timeout.py +++ b/src/utils/stream_timeout.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import AsyncIterator -from src.llm_orchestrator_config.exceptions import StreamTimeoutException +from src.llm_orchestrator_config.exceptions import StreamTimeoutError @asynccontextmanager @@ -16,7 +16,7 @@ async def stream_timeout(seconds: int) -> AsyncIterator[None]: seconds: Maximum duration in seconds Raises: - StreamTimeoutException: When timeout is exceeded + StreamTimeoutError: When timeout is exceeded Example: async with stream_timeout(300): @@ -27,6 +27,6 @@ async def stream_timeout(seconds: int) -> AsyncIterator[None]: async with asyncio.timeout(seconds): yield except asyncio.TimeoutError as e: - raise StreamTimeoutException( + raise StreamTimeoutError( f"Stream exceeded maximum duration of {seconds} seconds" ) from e diff --git a/src/utils/time_tracker.py b/src/utils/time_tracker.py index 5b6d8dea..619030d7 100644 --- a/src/utils/time_tracker.py +++ b/src/utils/time_tracker.py @@ -5,23 +5,31 @@ def log_step_timings( - timing_dict: Dict[str, float], chat_id: Optional[str] = None + time_metric: Dict[str, float], chat_id: Optional[str] = None ) -> None: """ Log all step timings in a clean format. Args: - timing_dict: Dictionary containing step names and their execution times + time_metric: Dictionary containing step names and their execution times chat_id: Optional chat ID for context """ - if not timing_dict: + if not time_metric: return + # Parent/composite timings that should be hidden from logs + # These are aggregate timings that already include their sub-steps + parent_timings = {"classifier.route"} + prefix = f"[{chat_id}] " if chat_id else "" logger.info(f"{prefix}STEP EXECUTION TIMES:") total_time = 0.0 - for step_name, elapsed_time in timing_dict.items(): + for step_name, elapsed_time in time_metric.items(): + # Skip parent/composite timings entirely + if step_name in parent_timings: + continue + # Special handling for inline streaming guardrails if step_name == "output_guardrails" and elapsed_time < 0.001: logger.info(f" {step_name:25s}: (inline during streaming)") diff --git a/src/vector_indexer/api_client.py b/src/vector_indexer/api_client.py index c8542c94..fa4f2d29 100644 --- a/src/vector_indexer/api_client.py +++ b/src/vector_indexer/api_client.py @@ -4,6 +4,7 @@ from typing import List, Dict, Any, Optional, Union import httpx from loguru import logger +from typing_extensions import Self from vector_indexer.config.config_loader import VectorIndexerConfig @@ -11,14 +12,14 @@ class LLMOrchestrationAPIClient: """Client for calling LLM Orchestration Service API endpoints.""" - def __init__(self, config: VectorIndexerConfig): + def __init__(self, config: VectorIndexerConfig) -> None: self.config = config self.session = httpx.AsyncClient( timeout=config.api_timeout, limits=httpx.Limits(max_connections=10, max_keepalive_connections=5), ) - async def __aenter__(self): + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -53,8 +54,10 @@ async def generate_context_batch( # Create semaphore to limit concurrent requests (max_concurrent_chunks_per_doc = 5) semaphore = asyncio.Semaphore(self.config.max_concurrent_chunks_per_doc) - async def generate_context_with_semaphore(chunk_content: str) -> str: - async with semaphore: + async def generate_context_with_semaphore( + chunk_content: str, sem: asyncio.Semaphore = semaphore + ) -> str: + async with sem: return await self._generate_context_with_retry( document_content, chunk_content ) @@ -191,6 +194,6 @@ async def health_check(self) -> bool: logger.debug(f"Health check failed: {e}") return False - async def close(self): + async def close(self) -> None: """Close the HTTP session.""" await self.session.aclose() diff --git a/src/vector_indexer/constants.py b/src/vector_indexer/constants.py index c4f38100..d40b4627 100644 --- a/src/vector_indexer/constants.py +++ b/src/vector_indexer/constants.py @@ -100,10 +100,11 @@ class ProcessingConstants: class ResponseGenerationConstants: """Constants for response generation and context retrieval.""" - # Top-K blocks for response generation - # This controls how many of the retrieved chunks are used - # for generating the final response - DEFAULT_MAX_BLOCKS = 5 # Maximum context blocks to use in response generation + # Controls both: + # 1. How many chunks the contextual retriever returns after RRF fusion + # 2. How many context blocks the response generator feeds to the LLM + # Change this value to adjust both retrieval and generation together. + DEFAULT_MAX_BLOCKS = 5 MIN_BLOCKS_REQUIRED = 3 # Minimum blocks required for valid response @@ -122,27 +123,27 @@ class LoggingConstants: PROGRESS_REPORT_INTERVAL = 10 # Report every N documents -def GET_S3_FERRY_PAYLOAD( - destinationFilePath: str, - destinationStorageType: str, - sourceFilePath: str, - sourceStorageType: str, -) -> dict[str, str]: # noqa: N802 +def get_s3_ferry_payload( + destination_file_path: str, + destination_storage_type: str, + source_file_path: str, + source_storage_type: str, +) -> dict[str, str]: """ Generate S3Ferry payload for file transfer operations. Args: - destinationFilePath: Path where file should be stored - destinationStorageType: "S3" or "FS" (filesystem) - sourceFilePath: Path of source file - sourceStorageType: "S3" or "FS" (filesystem) + destination_file_path: Path where file should be stored + destination_storage_type: "S3" or "FS" (filesystem) + source_file_path: Path of source file + source_storage_type: "S3" or "FS" (filesystem) Returns: dict: Payload for S3Ferry API """ return { - "destinationFilePath": destinationFilePath, - "destinationStorageType": destinationStorageType, - "sourceFilePath": sourceFilePath, - "sourceStorageType": sourceStorageType, + "destinationFilePath": destination_file_path, + "destinationStorageType": destination_storage_type, + "sourceFilePath": source_file_path, + "sourceStorageType": source_storage_type, } diff --git a/src/vector_indexer/contextual_processor.py b/src/vector_indexer/contextual_processor.py index a6c12672..b225cf30 100644 --- a/src/vector_indexer/contextual_processor.py +++ b/src/vector_indexer/contextual_processor.py @@ -20,7 +20,7 @@ def __init__( api_client: LLMOrchestrationAPIClient, config: VectorIndexerConfig, error_logger: ErrorLogger, - ): + ) -> None: self.api_client = api_client self.config = config self.error_logger = error_logger @@ -70,7 +70,9 @@ async def process_document( contextual_chunks: List[ContextualChunk] = [] valid_contextual_contents: List[str] = [] - for i, (base_chunk, context) in enumerate(zip(base_chunks, contexts)): + for i, (base_chunk, context) in enumerate( + zip(base_chunks, contexts, strict=True) + ): if isinstance(context, Exception): self.error_logger.log_context_generation_failure( document.document_hash, i, str(context), self.config.max_retries @@ -136,7 +138,7 @@ async def process_document( # Step 5: Add embeddings to chunks for chunk, embedding in zip( - contextual_chunks, embeddings_response["embeddings"] + contextual_chunks, embeddings_response["embeddings"], strict=True ): chunk.embedding = embedding chunk.embedding_model = embeddings_response["model_used"] diff --git a/src/vector_indexer/diff_identifier/diff_detector.py b/src/vector_indexer/diff_identifier/diff_detector.py index a2e5f9bc..46edd3dd 100644 --- a/src/vector_indexer/diff_identifier/diff_detector.py +++ b/src/vector_indexer/diff_identifier/diff_detector.py @@ -16,7 +16,7 @@ class DiffDetector: """Main orchestrator for diff identification.""" - def __init__(self, config: DiffConfig): + def __init__(self, config: DiffConfig) -> None: self.config = config self.version_manager = VersionManager(config) @@ -109,7 +109,7 @@ async def get_changed_files(self) -> DiffResult: except Exception as fallback_error: raise DiffError( f"Both diff identification and fallback failed: {fallback_error}", e - ) + ) from fallback_error async def mark_files_processed( self, @@ -207,7 +207,7 @@ async def mark_files_processed( ) except Exception as e: - raise DiffError(f"Failed to mark files as processed: {str(e)}", e) + raise DiffError(f"Failed to mark files as processed: {str(e)}", e) from e async def _handle_first_run(self) -> DiffResult: """ @@ -240,7 +240,7 @@ async def _handle_first_run(self) -> DiffResult: ) except Exception as e: - raise DiffError(f"First run setup failed: {str(e)}", e) + raise DiffError(f"First run setup failed: {str(e)}", e) from e def create_diff_config() -> DiffConfig: @@ -321,4 +321,4 @@ def create_diff_config() -> DiffConfig: return config except Exception as e: - raise DiffError(f"Failed to create diff configuration: {str(e)}", e) + raise DiffError(f"Failed to create diff configuration: {str(e)}", e) from e diff --git a/src/vector_indexer/diff_identifier/diff_models.py b/src/vector_indexer/diff_identifier/diff_models.py index 6ec31619..728265fd 100644 --- a/src/vector_indexer/diff_identifier/diff_models.py +++ b/src/vector_indexer/diff_identifier/diff_models.py @@ -96,7 +96,7 @@ class DiffConfig(BaseModel): class DiffError(Exception): """Custom exception for diff identification errors.""" - def __init__(self, message: str, cause: Optional[Exception] = None): + def __init__(self, message: str, cause: Optional[Exception] = None) -> None: self.message = message self.cause = cause super().__init__(self.message) diff --git a/src/vector_indexer/diff_identifier/s3_ferry_client.py b/src/vector_indexer/diff_identifier/s3_ferry_client.py index 28481f5c..bebb7464 100644 --- a/src/vector_indexer/diff_identifier/s3_ferry_client.py +++ b/src/vector_indexer/diff_identifier/s3_ferry_client.py @@ -3,44 +3,45 @@ import asyncio import json import time -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional import requests from loguru import logger +from typing_extensions import Self from diff_identifier.diff_models import DiffConfig, DiffError -from constants import GET_S3_FERRY_PAYLOAD +from constants import get_s3_ferry_payload class S3Ferry: """Client for interacting with S3Ferry service.""" - def __init__(self, url: str): + def __init__(self, url: str) -> None: self.url = url def transfer_file( self, - destinationFilePath: str, - destinationStorageType: str, - sourceFilePath: str, - sourceStorageType: str, + destination_file_path: str, + destination_storage_type: str, + source_file_path: str, + source_storage_type: str, ) -> requests.Response: """ Transfer file using S3Ferry service. Args: - destinationFilePath: Path where file should be stored - destinationStorageType: "S3" or "FS" (filesystem) - sourceFilePath: Path of source file - sourceStorageType: "S3" or "FS" (filesystem) + destination_file_path: Path where file should be stored + destination_storage_type: "S3" or "FS" (filesystem) + source_file_path: Path of source file + source_storage_type: "S3" or "FS" (filesystem) Returns: requests.Response: Response from S3Ferry service """ - payload = GET_S3_FERRY_PAYLOAD( - destinationFilePath, - destinationStorageType, - sourceFilePath, - sourceStorageType, + payload = get_s3_ferry_payload( + destination_file_path, + destination_storage_type, + source_file_path, + source_storage_type, ) response = requests.post(self.url, json=payload) @@ -55,11 +56,11 @@ class S3FerryClient: This client only needs to know the S3Ferry URL and metadata paths. """ - def __init__(self, config: DiffConfig): + def __init__(self, config: DiffConfig) -> None: self.config = config self.s3_ferry = S3Ferry(config.s3_ferry_url) - async def __aenter__(self): + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -99,10 +100,10 @@ async def upload_metadata(self, metadata: Dict[str, Any]) -> bool: response = await asyncio.to_thread( self._retry_with_backoff, lambda: self.s3_ferry.transfer_file( - destinationFilePath=self.config.metadata_s3_path, - destinationStorageType="S3", - sourceFilePath=s3ferry_source_path, - sourceStorageType="FS", + destination_file_path=self.config.metadata_s3_path, + destination_storage_type="S3", + source_file_path=s3ferry_source_path, + source_storage_type="FS", ), ) @@ -125,7 +126,7 @@ async def upload_metadata(self, metadata: Dict[str, Any]) -> bool: await asyncio.to_thread(self._cleanup_temp_file, temp_file_path) except Exception as e: - raise DiffError(f"Failed to upload metadata: {str(e)}", e) + raise DiffError(f"Failed to upload metadata: {str(e)}", e) from e async def download_metadata(self) -> Optional[Dict[str, Any]]: """ @@ -149,10 +150,10 @@ async def download_metadata(self) -> Optional[Dict[str, Any]]: response = await asyncio.to_thread( self._retry_with_backoff, lambda: self.s3_ferry.transfer_file( - destinationFilePath=s3ferry_dest_path, - destinationStorageType="FS", - sourceFilePath=self.config.metadata_s3_path, - sourceStorageType="S3", + destination_file_path=s3ferry_dest_path, + destination_storage_type="FS", + source_file_path=self.config.metadata_s3_path, + source_storage_type="S3", ), ) @@ -184,7 +185,9 @@ async def download_metadata(self) -> Optional[Dict[str, Any]]: await asyncio.to_thread(self._cleanup_temp_file, temp_file_path) except json.JSONDecodeError as e: - raise DiffError(f"Failed to parse downloaded metadata JSON: {str(e)}", e) + raise DiffError( + f"Failed to parse downloaded metadata JSON: {str(e)}", e + ) from e except Exception as e: # Don't raise for file not found - it's expected on first run logger.warning(f"Failed to download metadata (may be first run): {str(e)}") @@ -255,12 +258,14 @@ def _cleanup_temp_file(self, file_path: str) -> None: except Exception as cleanup_error: logger.warning(f"Failed to cleanup temp file {file_path}: {cleanup_error}") - def _retry_with_backoff(self, operation: Any) -> requests.Response: + def _retry_with_backoff( + self, operation: Callable[[], requests.Response] + ) -> requests.Response: """ Retry an operation with exponential backoff. Args: - operation: Operation to retry + operation: Operation to retry (callable that returns Response) Returns: Response from the operation @@ -292,7 +297,7 @@ def _retry_with_backoff(self, operation: Any) -> requests.Response: raise DiffError( f"Operation failed after {self.config.max_retries} attempts: {str(e)}", e, - ) + ) from e delay = min(1 * (2**attempt), self.config.max_delay_seconds) time.sleep(delay) diff --git a/src/vector_indexer/diff_identifier/version_manager.py b/src/vector_indexer/diff_identifier/version_manager.py index 8ef23db9..d7df5a83 100644 --- a/src/vector_indexer/diff_identifier/version_manager.py +++ b/src/vector_indexer/diff_identifier/version_manager.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Dict, List, Optional, Set, Any from loguru import logger +from typing_extensions import Self from diff_identifier.diff_models import ( DiffConfig, @@ -19,11 +20,11 @@ class VersionManager: """Manages DVC operations and version tracking.""" - def __init__(self, config: DiffConfig): + def __init__(self, config: DiffConfig) -> None: self.config = config self.datasets_path = Path(config.datasets_path) - async def __aenter__(self): + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -99,7 +100,7 @@ async def initialize_dvc(self) -> None: logger.info("DVC initialized successfully") except Exception as e: - raise DiffError(f"Failed to initialize DVC: {str(e)}", e) + raise DiffError(f"Failed to initialize DVC: {str(e)}", e) from e async def get_processed_files_metadata(self) -> Optional[VersionState]: """ @@ -133,7 +134,9 @@ async def get_processed_files_metadata(self) -> Optional[VersionState]: ) except Exception as e: - raise DiffError(f"Failed to get processed files metadata: {str(e)}", e) + raise DiffError( + f"Failed to get processed files metadata: {str(e)}", e + ) from e async def update_processed_files_metadata( self, @@ -255,7 +258,9 @@ async def update_processed_files_metadata( except DiffError: raise except Exception as e: - raise DiffError(f"Failed to update processed files metadata: {str(e)}", e) + raise DiffError( + f"Failed to update processed files metadata: {str(e)}", e + ) from e def scan_current_files(self) -> Dict[str, str]: """ @@ -304,7 +309,7 @@ def scan_current_files(self) -> Dict[str, str]: return files_map except Exception as e: - raise DiffError(f"Failed to scan current files: {str(e)}", e) + raise DiffError(f"Failed to scan current files: {str(e)}", e) from e def identify_comprehensive_changes( self, current_files: Dict[str, str], processed_state: Optional[VersionState] @@ -462,7 +467,7 @@ async def commit_dvc_changes(self) -> None: logger.info("DVC commit completed successfully") except Exception as e: - raise DiffError(f"Failed to commit DVC changes: {str(e)}", e) + raise DiffError(f"Failed to commit DVC changes: {str(e)}", e) from e async def _run_dvc_command(self, command: List[str]) -> str: """ @@ -519,4 +524,4 @@ async def _run_dvc_command(self, command: List[str]) -> str: raise raise DiffError( f"Failed to run DVC command {' '.join(command)}: {str(e)}", e - ) + ) from e diff --git a/src/vector_indexer/document_loader.py b/src/vector_indexer/document_loader.py index 5558a1fc..9e03b290 100644 --- a/src/vector_indexer/document_loader.py +++ b/src/vector_indexer/document_loader.py @@ -4,6 +4,8 @@ import json from pathlib import Path from typing import List +from urllib.parse import urlparse + from loguru import logger from vector_indexer.config.config_loader import VectorIndexerConfig @@ -20,10 +22,19 @@ class DocumentLoadError(Exception): class DocumentLoader: """Handles document discovery and loading from datasets folder.""" - def __init__(self, config: VectorIndexerConfig): + def __init__(self, config: VectorIndexerConfig) -> None: self.config = config self.datasets_path = Path(config.dataset_base_path) + @staticmethod + def _is_valid_url(url: str) -> bool: + """Validate that a URL has a proper scheme and network location.""" + try: + parsed = urlparse(url) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + except Exception: + return False + def discover_all_documents(self) -> List[DocumentInfo]: """ Optimized document discovery using pathlib.glob for better performance. @@ -88,22 +99,44 @@ def discover_all_documents(self) -> List[DocumentInfo]: # Check metadata file exists metadata_file = hash_dir / self.config.metadata_file - if metadata_file.exists(): - documents.append( - DocumentInfo( - document_hash=content_hash, # Use content hash consistently - cleaned_txt_path=str(cleaned_file), - source_meta_path=str(metadata_file), - dataset_collection=collection_name, - ) + if not metadata_file.exists(): + logger.warning( + f"Skipping document in {hash_dir.name}: " + f"missing {self.config.metadata_file}" ) - logger.debug( - f"Found document: {content_hash[:12]}... in collection: {collection_name}" + continue + + # Validate source_url before accepting the document + try: + with open(metadata_file, "r", encoding="utf-8") as mf: + meta = json.load(mf) + source_url = meta.get("source_url") or "" + except Exception as e: + logger.warning( + f"Skipping document in {hash_dir.name}: " + f"failed to read metadata: {e}" ) - else: + continue + + if not self._is_valid_url(source_url): logger.warning( - f"Skipping document in {hash_dir.name}: missing {self.config.metadata_file}" + f"Skipping document in {hash_dir.name}: " + f"invalid source_url '{source_url}'" + ) + continue + + documents.append( + DocumentInfo( + document_hash=content_hash, # Use content hash consistently + cleaned_txt_path=str(cleaned_file), + source_meta_path=str(metadata_file), + dataset_collection=collection_name, ) + ) + logger.debug( + f"Found document: {content_hash[:12]}... " + f"in collection: {collection_name}" + ) logger.info(f"Discovered {len(documents)} documents for processing") return documents diff --git a/src/vector_indexer/error_logger.py b/src/vector_indexer/error_logger.py index a17a46b2..1d11cba1 100644 --- a/src/vector_indexer/error_logger.py +++ b/src/vector_indexer/error_logger.py @@ -12,12 +12,12 @@ class ErrorLogger: """Enhanced error logging with file-based failure tracking.""" - def __init__(self, config: VectorIndexerConfig): + def __init__(self, config: VectorIndexerConfig) -> None: self.config = config self._ensure_log_directories() self._setup_logging() - def _ensure_log_directories(self): + def _ensure_log_directories(self) -> None: """Create log directories if they don't exist.""" for log_file in [ self.config.failure_log_file, @@ -26,7 +26,7 @@ def _ensure_log_directories(self): ]: Path(log_file).parent.mkdir(parents=True, exist_ok=True) - def _setup_logging(self): + def _setup_logging(self) -> None: """Setup loguru logging with file output.""" logger.remove() # Remove default handler @@ -48,7 +48,7 @@ def _setup_logging(self): def log_document_failure( self, document_hash: str, error: str, retry_count: int = 0 - ): + ) -> None: """Log document processing failure.""" if not self.config.log_failures: return @@ -73,7 +73,7 @@ def log_document_failure( def log_chunk_failure( self, document_hash: str, chunk_index: int, error: str, retry_count: int - ): + ) -> None: """Log individual chunk processing failure.""" if not self.config.log_failures: return @@ -99,7 +99,7 @@ def log_chunk_failure( def log_context_generation_failure( self, document_hash: str, chunk_index: int, error: str, retry_count: int - ): + ) -> None: """Log context generation failure.""" if not self.config.log_failures: return @@ -123,7 +123,9 @@ def log_context_generation_failure( f"Context generation failed for chunk {chunk_index} in document {document_hash}: {error}" ) - def log_embedding_failure(self, document_hash: str, error: str, retry_count: int): + def log_embedding_failure( + self, document_hash: str, error: str, retry_count: int + ) -> None: """Log embedding creation failure.""" if not self.config.log_failures: return @@ -145,7 +147,7 @@ def log_embedding_failure(self, document_hash: str, error: str, retry_count: int logger.error(f"Embedding creation failed for document {document_hash}: {error}") - def log_processing_stats(self, stats: ProcessingStats): + def log_processing_stats(self, stats: ProcessingStats) -> None: """Log final processing statistics.""" try: stats_dict = stats.model_dump() @@ -169,7 +171,9 @@ def log_processing_stats(self, stats: ProcessingStats): except Exception as e: logger.error(f"Failed to write stats log: {e}") - def log_progress(self, completed: int, total: int, current_document: str = ""): + def log_progress( + self, completed: int, total: int, current_document: str = "" + ) -> None: """Log processing progress.""" percentage = (completed / total * 100) if total > 0 else 0 if current_document: diff --git a/src/vector_indexer/main_indexer.py b/src/vector_indexer/main_indexer.py index ab376e8a..45ce5ff6 100644 --- a/src/vector_indexer/main_indexer.py +++ b/src/vector_indexer/main_indexer.py @@ -30,7 +30,7 @@ class VectorIndexer: def __init__( self, config_path: Optional[str] = None, signed_url: Optional[str] = None - ): + ) -> None: # Load configuration self.config_path = ( config_path or "src/vector_indexer/config/vector_indexer_config.yaml" @@ -339,7 +339,7 @@ async def _process_single_document( self.error_logger.log_document_failure(doc_info.document_hash, str(e)) raise - def _log_final_summary(self): + def _log_final_summary(self) -> None: """Log final processing summary.""" logger.info("VECTOR INDEXER PROCESSING COMPLETE") @@ -379,7 +379,7 @@ async def run_health_check(self) -> bool: async with QdrantManager(self.config) as qdrant_manager: # Test basic Qdrant connectivity by trying to list collections try: - qdrant_url = getattr(self.config, "qdrant_url") + qdrant_url = self.config.qdrant_url response = await qdrant_manager.client.get( f"{qdrant_url}/collections" ) @@ -436,7 +436,7 @@ async def run_health_check(self) -> bool: return False # NOTE: Don't close API client here - it will be used by main processing - async def cleanup(self): + async def cleanup(self) -> None: """Clean up resources.""" try: await self.api_client.close() @@ -616,7 +616,7 @@ async def _execute_cleanup_operations( return total_deleted - def _cleanup_datasets(self): + def _cleanup_datasets(self) -> None: """Remove datasets folder after processing.""" try: datasets_path = Path(self.config.dataset_base_path) @@ -630,7 +630,7 @@ def _cleanup_datasets(self): # Non-critical error - don't fail the entire process -async def main(): +async def main() -> int: """Main entry point for the vector indexer.""" # Parse command line arguments diff --git a/src/vector_indexer/qdrant_manager.py b/src/vector_indexer/qdrant_manager.py index be9dc923..08664652 100644 --- a/src/vector_indexer/qdrant_manager.py +++ b/src/vector_indexer/qdrant_manager.py @@ -4,6 +4,7 @@ from loguru import logger import httpx import uuid +from typing_extensions import Self from vector_indexer.config.config_loader import VectorIndexerConfig from vector_indexer.models import ContextualChunk @@ -18,7 +19,7 @@ class QdrantOperationError(Exception): class QdrantManager: """Manages Qdrant vector database operations for contextual chunks.""" - def __init__(self, config: VectorIndexerConfig): + def __init__(self, config: VectorIndexerConfig) -> None: self.config = config self.qdrant_url: str = getattr(config, "qdrant_url", "http://localhost:6333") self.client = httpx.AsyncClient(timeout=30.0) @@ -40,7 +41,7 @@ def __init__(self, config: VectorIndexerConfig): }, } - async def __aenter__(self): + async def __aenter__(self) -> Self: """Async context manager entry.""" return self @@ -53,7 +54,7 @@ async def __aexit__( """Async context manager exit.""" await self.client.aclose() - async def ensure_collections_exist(self): + async def ensure_collections_exist(self) -> None: """Create collections if they don't exist.""" logger.info("Ensuring Qdrant collections exist") @@ -62,7 +63,7 @@ async def ensure_collections_exist(self): async def _create_collection_if_not_exists( self, collection_name: str, collection_config: Dict[str, Any] - ): + ) -> None: """Create a collection if it doesn't exist.""" try: @@ -108,7 +109,7 @@ async def _create_collection_if_not_exists( logger.error(f"Error ensuring collection {collection_name} exists: {e}") raise - async def store_chunks(self, chunks: List[ContextualChunk]): + async def store_chunks(self, chunks: List[ContextualChunk]) -> None: """ Store contextual chunks in appropriate Qdrant collection. @@ -135,7 +136,7 @@ async def store_chunks(self, chunks: List[ContextualChunk]): async def _store_chunks_in_collection( self, collection_name: str, chunks: List[ContextualChunk] - ): + ) -> None: """Store chunks in specific collection.""" logger.debug(f"Storing {len(chunks)} chunks in collection {collection_name}") @@ -450,7 +451,7 @@ async def delete_chunks_by_document_hash( ) raise QdrantOperationError( f"Failed to delete chunks by document hash: {str(e)}" - ) + ) from e async def delete_chunks_by_file_path( self, collection_name: str, file_path: str @@ -523,7 +524,7 @@ async def delete_chunks_by_file_path( logger.error(f"Failed to delete chunks for file {file_path}: {e}") raise QdrantOperationError( f"Failed to delete chunks by file path: {str(e)}" - ) + ) from e async def get_chunks_for_document( self, collection_name: str, document_hash: str @@ -591,6 +592,6 @@ async def delete_collection(self, collection_name: str) -> bool: logger.error(f"Error deleting collection {collection_name}: {e}") return False - async def close(self): + async def close(self) -> None: """Close the HTTP client.""" await self.client.aclose() diff --git a/tests/api_tool_eval/batch_index.py b/tests/api_tool_eval/batch_index.py new file mode 100644 index 00000000..0aa0555a --- /dev/null +++ b/tests/api_tool_eval/batch_index.py @@ -0,0 +1,105 @@ +""" +Batch Indexer — sends all endpoints from test-endpoints.json to POST /api-tools/index. + +Usage: + python batch_index.py + python batch_index.py --ruuter-url http://localhost:8086 +""" + +import argparse +import json +import time +from pathlib import Path + +import requests + +ENDPOINTS_FILE = Path(__file__).parent / "test-endpoints.json" +DEFAULT_RUUTER_URL = "http://localhost:8086" +INDEX_ENDPOINT = "/rag-search/api-tools/index" + + +def index_endpoint(ruuter_url: str, endpoint: dict) -> dict: + """Send a single endpoint spec to the indexing API.""" + url = f"{ruuter_url}{INDEX_ENDPOINT}" + try: + response = requests.post(url, json=endpoint, timeout=60) + return { + "status_code": response.status_code, + "body": response.json() if response.content else {}, + "ok": 200 <= response.status_code < 300, + } + except requests.exceptions.Timeout: + return {"status_code": 408, "body": {"error": "Timeout"}, "ok": False} + except requests.exceptions.ConnectionError as e: + return {"status_code": 503, "body": {"error": str(e)}, "ok": False} + except Exception as e: + return {"status_code": 500, "body": {"error": str(e)}, "ok": False} + + +def main(): + parser = argparse.ArgumentParser( + description="Batch index API endpoints into Qdrant" + ) + parser.add_argument( + "--ruuter-url", + default=DEFAULT_RUUTER_URL, + help=f"Base URL of Ruuter public (default: {DEFAULT_RUUTER_URL})", + ) + parser.add_argument( + "--delay", + type=float, + default=1.0, + help="Seconds to wait between indexing requests (default: 1.0)", + ) + args = parser.parse_args() + + # Load endpoints + if not ENDPOINTS_FILE.exists(): + print(f"ERROR: {ENDPOINTS_FILE} not found") + return + + with open(ENDPOINTS_FILE, encoding="utf-8") as f: + endpoints = json.load(f) + + print(f"\n{'=' * 60}") + print(f"Batch Indexer — {len(endpoints)} endpoints") + print(f"Target: {args.ruuter_url}{INDEX_ENDPOINT}") + print(f"{'=' * 60}\n") + + results = {"success": [], "failed": []} + + for i, endpoint in enumerate(endpoints, 1): + name = endpoint.get("name", "unknown") + endpoint_id = endpoint.get("endpointId", "?") + print(f"[{i:02d}/{len(endpoints)}] Indexing: {name} ({endpoint_id[:8]}...)") + + result = index_endpoint(args.ruuter_url, endpoint) + + if result["ok"]: + print(f" Success (HTTP {result['status_code']})") + results["success"].append(name) + else: + print( + f" Failed (HTTP {result['status_code']}) — {result['body']}" + ) + results["failed"].append(name) + + # Delay between requests to avoid overloading the embedding service + if i < len(endpoints): + time.sleep(args.delay) + + # Summary + print(f"\n{'=' * 60}") + print("INDEXING COMPLETE") + print(f"{'=' * 60}") + print(f" Success: {len(results['success'])}/{len(endpoints)}") + print(f" Failed: {len(results['failed'])}/{len(endpoints)}") + if results["failed"]: + print("\nFailed endpoints:") + for name in results["failed"]: + print(f" - {name}") + print("\nNext step: run python eval_search.py to test retrieval accuracy") + + +if __name__ == "__main__": + main() diff --git a/tests/api_tool_eval/endpoints.json b/tests/api_tool_eval/endpoints.json new file mode 100644 index 00000000..52d1f981 --- /dev/null +++ b/tests/api_tool_eval/endpoints.json @@ -0,0 +1,223 @@ +[ + { + "endpointId": "a3f7c2d1-84e6-4b19-92f3-d51c7e890ab2", + "name": "get_national_holidays", + "description": "Fetch national holidays for a specific country to see when they have public days off.", + "url": "https://openholidaysapi.org/PublicHolidays", + "method": "GET", + "params": [ + { "name": "countryIsoCode", "type": "string", "required": true, "description": "The 2-letter ISO country code (e.g., EE for Estonia, DE for Germany)" }, + { "name": "languageIsoCode", "type": "string", "required": false, "description": "The 2-letter ISO language code for the response (e.g., ET, EN)" }, + { "name": "validFrom", "type": "date", "required": false, "description": "Start date for the holiday search (YYYY-MM-DD)" }, + { "name": "validTo", "type": "date", "required": false, "description": "End date for the holiday search (YYYY-MM-DD)" } + ] + }, + { + "endpointId": "c6d092b4-f518-4a37-b9e7-120e83a7d64f", + "name": "get_unemployment_rate", + "description": "Fetch the official unemployment rate statistics for Estonia from the national statistics office.", + "url": "https://andmed.stat.ee/api/v1/en/stat/TT330", + "method": "POST", + "params": [ + { "name": "Näitaja", "type": "string", "required": true, "description": "The specific statistical indicator to query (e.g., unemployment rate)" }, + { "name": "Sugu", "type": "string", "required": false, "description": "Gender filter (e.g., Male, Female, Total)" }, + { "name": "Vanuserühm", "type": "string", "required": false, "description": "Age group category filter (e.g., 15-24, 25-49)" }, + { "name": "Vaatlusperiood", "type": "string", "required": true, "description": "The observation period (e.g., year or quarter) to get data for" } + ] + }, + { + "endpointId": "b8e41f09-3c72-4d85-ae16-7f924d10c53e", + "name": "get_current_electricity_price", + "description": "Fetch the current real-time electricity and energy market price for Estonia.", + "url": "https://dashboard.elering.ee/api/nps/price/EE/current", + "method": "GET", + "params": [] + }, + { + "endpointId": "d1e2f3a4-b5c6-7890-abcd-ef1234567890", + "name": "get_weather_forecast", + "description": "Fetch hourly or daily weather forecast for any location including temperature, precipitation, and wind speed.", + "url": "https://api.open-meteo.com/v1/forecast", + "method": "GET", + "params": [ + { "name": "latitude", "type": "float", "required": true, "description": "Geographic latitude of the location (e.g., 59.4370 for Tallinn)" }, + { "name": "longitude", "type": "float", "required": true, "description": "Geographic longitude of the location (e.g., 24.7536 for Tallinn)" }, + { "name": "hourly", "type": "string", "required": false, "description": "Hourly weather variables to include (e.g., temperature_2m, precipitation, windspeed_10m)" }, + { "name": "daily", "type": "string", "required": false, "description": "Daily weather variables to include (e.g., temperature_2m_max, precipitation_sum)" }, + { "name": "forecast_days", "type": "integer", "required": false, "description": "Number of forecast days (1-16)" } + ] + }, + { + "endpointId": "e2f3a4b5-c6d7-8901-bcde-f12345678901", + "name": "get_exchange_rates", + "description": "Fetch the latest foreign currency exchange rates relative to a base currency.", + "url": "https://api.frankfurter.app/latest", + "method": "GET", + "params": [ + { "name": "base", "type": "string", "required": false, "description": "Base currency code to convert from (e.g., EUR, USD). Defaults to EUR." }, + { "name": "symbols", "type": "string", "required": false, "description": "Comma-separated list of target currency codes to include (e.g., USD,GBP,SEK)" } + ] + }, + { + "endpointId": "f3a4b5c6-d7e8-9012-cdef-123456789012", + "name": "get_country_information", + "description": "Fetch detailed country information including population, capital city, languages, currency, and geographic data.", + "url": "https://restcountries.com/v3.1/name", + "method": "GET", + "params": [ + { "name": "country", "type": "string", "required": true, "description": "Full or partial name of the country (e.g., Estonia, Germany, Finland)" }, + { "name": "fields", "type": "string", "required": false, "description": "Comma-separated fields to return (e.g., name,capital,population,currencies)" } + ] + }, + { + "endpointId": "a4b5c6d7-e8f9-0123-defa-234567890123", + "name": "get_ip_geolocation", + "description": "Fetch geographic location information for an IP address including country, city, region, and coordinates.", + "url": "http://ip-api.com/json", + "method": "GET", + "params": [ + { "name": "ip", "type": "string", "required": true, "description": "The IP address to geolocate (e.g., 88.196.123.45)" }, + { "name": "fields", "type": "string", "required": false, "description": "Comma-separated fields to return (e.g., country,city,lat,lon,isp)" } + ] + }, + { + "endpointId": "b5c6d7e8-f9a0-1234-efab-345678901234", + "name": "get_school_holidays", + "description": "Fetch official school holidays and term breaks for a specific country and school year.", + "url": "https://openholidaysapi.org/SchoolHolidays", + "method": "GET", + "params": [ + { "name": "countryIsoCode", "type": "string", "required": true, "description": "The 2-letter ISO country code (e.g., EE for Estonia)" }, + { "name": "languageIsoCode", "type": "string", "required": false, "description": "The 2-letter ISO language code for the response (e.g., ET, EN)" }, + { "name": "validFrom", "type": "date", "required": false, "description": "Start date for the school holiday search (YYYY-MM-DD)" }, + { "name": "validTo", "type": "date", "required": false, "description": "End date for the school holiday search (YYYY-MM-DD)" } + ] + }, + { + "endpointId": "c6d7e8f9-a0b1-2345-fabc-456789012345", + "name": "get_current_time_by_timezone", + "description": "Fetch the current time, date, UTC offset, and daylight saving time status for any timezone.", + "url": "https://worldtimeapi.org/api/timezone", + "method": "GET", + "params": [ + { "name": "timezone", "type": "string", "required": true, "description": "IANA timezone name (e.g., Europe/Tallinn, Europe/Helsinki, UTC)" } + ] + }, + { + "endpointId": "d7e8f9a0-b1c2-3456-abcd-567890123456", + "name": "get_air_quality", + "description": "Fetch real-time air quality measurements including PM2.5, PM10, NO2, ozone, and AQI index for a location.", + "url": "https://api.openaq.org/v2/latest", + "method": "GET", + "params": [ + { "name": "city", "type": "string", "required": false, "description": "City name to filter air quality sensors (e.g., Tallinn, Tartu)" }, + { "name": "country", "type": "string", "required": false, "description": "2-letter ISO country code to filter sensors (e.g., EE)" }, + { "name": "parameter", "type": "string", "required": false, "description": "Pollutant to filter by (e.g., pm25, pm10, no2, o3)" }, + { "name": "limit", "type": "integer", "required": false, "description": "Maximum number of results to return" } + ] + }, + { + "endpointId": "e8f9a0b1-c2d3-4567-bcde-678901234567", + "name": "get_address_geocoding", + "description": "Search for a geographic address and return its latitude, longitude, and structured address components.", + "url": "https://nominatim.openstreetmap.org/search", + "method": "GET", + "params": [ + { "name": "q", "type": "string", "required": true, "description": "Free-form address or place name to search for (e.g., Viru 4, Tallinn)" }, + { "name": "format", "type": "string", "required": false, "description": "Response format: json, xml, geojson. Default: json" }, + { "name": "countrycodes", "type": "string", "required": false, "description": "Comma-separated ISO country codes to restrict search (e.g., ee for Estonia)" }, + { "name": "limit", "type": "integer", "required": false, "description": "Maximum number of results to return (default: 10)" } + ] + }, + { + "endpointId": "f9a0b1c2-d3e4-5678-cdef-789012345678", + "name": "get_word_definition", + "description": "Fetch the definition, phonetics, synonyms, antonyms, and usage examples for an English word.", + "url": "https://api.dictionaryapi.dev/api/v2/entries/en", + "method": "GET", + "params": [ + { "name": "word", "type": "string", "required": true, "description": "The English word to look up the definition for (e.g., ephemeral, resilient)" } + ] + }, + { + "endpointId": "a0b1c2d3-e4f5-6789-defa-890123456789", + "name": "get_gdp_statistics", + "description": "Fetch annual GDP and economic growth rate statistics for any country from the World Bank open data API.", + "url": "https://api.worldbank.org/v2/country/indicator/NY.GDP.MKTP.CD", + "method": "GET", + "params": [ + { "name": "country", "type": "string", "required": true, "description": "ISO 2-letter country code (e.g., EE for Estonia, FI for Finland)" }, + { "name": "format", "type": "string", "required": false, "description": "Response format: json or xml. Default: json" }, + { "name": "date", "type": "string", "required": false, "description": "Year or year range to filter (e.g., 2020 or 2015:2023)" }, + { "name": "per_page", "type": "integer", "required": false, "description": "Number of records per page (default: 50)" } + ] + }, + { + "endpointId": "b1c2d3e4-f5a6-7890-efab-901234567890", + "name": "get_population_data", + "description": "Fetch annual population count and growth statistics for any country from the World Bank.", + "url": "https://api.worldbank.org/v2/country/indicator/SP.POP.TOTL", + "method": "GET", + "params": [ + { "name": "country", "type": "string", "required": true, "description": "ISO 2-letter country code (e.g., EE for Estonia)" }, + { "name": "format", "type": "string", "required": false, "description": "Response format: json or xml. Default: json" }, + { "name": "date", "type": "string", "required": false, "description": "Year or year range (e.g., 2020 or 2010:2023)" } + ] + }, + { + "endpointId": "c2d3e4f5-a6b7-8901-fabc-012345678901", + "name": "get_electricity_price_history", + "description": "Fetch historical electricity market prices for Estonia over a specified date range.", + "url": "https://dashboard.elering.ee/api/nps/price", + "method": "GET", + "params": [ + { "name": "start", "type": "datetime", "required": true, "description": "Start datetime for the price history query (ISO 8601 format, e.g., 2024-01-01T00:00:00Z)" }, + { "name": "end", "type": "datetime", "required": true, "description": "End datetime for the price history query (ISO 8601 format, e.g., 2024-01-31T23:59:59Z)" } + ] + }, + { + "endpointId": "e4f5a6b7-c8d9-0123-bcde-234567890123", + "name": "get_public_transport_stops", + "description": "Fetch list of public transport stops in Estonia including buses, trams, and trains with their GPS coordinates.", + "url": "https://peatus.ee/gtfs/stops.txt", + "method": "GET", + "params": [] + }, + { + "endpointId": "f5a6b7c8-d9e0-1234-cdef-345678901234", + "name": "get_average_salary_statistics", + "description": "Fetch official average gross and net salary statistics for Estonia by sector, region, or time period from Statistics Estonia.", + "url": "https://andmed.stat.ee/api/v1/en/stat/PA5321", + "method": "POST", + "params": [ + { "name": "Aasta", "type": "string", "required": true, "description": "Year of the salary data to retrieve (e.g., 2023, 2022)" }, + { "name": "Tegevusala", "type": "string", "required": false, "description": "Industry or economic sector to filter by (e.g., total economy, manufacturing, IT)" }, + { "name": "Maakond", "type": "string", "required": false, "description": "Estonian county or region to filter by (e.g., Harju, Tartu)" } + ] + }, + { + "endpointId": "a6b7c8d9-e0f1-2345-defa-456789012345", + "name": "get_estonian_company_info", + "description": "Fetch official company registration details from the Estonian Business Registry including name, registration number, address, legal status, and board members.", + "url": "https://ariregister.rik.ee/api/v1/company", + "method": "GET", + "params": [ + { "name": "reg_code", "type": "string", "required": false, "description": "Company registration number to look up (e.g., 10000000)" }, + { "name": "name", "type": "string", "required": false, "description": "Company name or partial name to search for" }, + { "name": "status", "type": "string", "required": false, "description": "Filter by company status: active, liquidated, bankrupt" } + ] + }, + { + "endpointId": "b7c8d9e0-f1a2-3456-efab-567890123456", + "name": "get_reverse_geocoding", + "description": "Convert GPS coordinates (latitude and longitude) into a human-readable street address or place name.", + "url": "https://nominatim.openstreetmap.org/reverse", + "method": "GET", + "params": [ + { "name": "lat", "type": "float", "required": true, "description": "Latitude of the location to reverse geocode (e.g., 59.4370 for Tallinn)" }, + { "name": "lon", "type": "float", "required": true, "description": "Longitude of the location to reverse geocode (e.g., 24.7536 for Tallinn)" }, + { "name": "format", "type": "string", "required": false, "description": "Response format: json or xml. Default: json" }, + { "name": "zoom", "type": "integer", "required": false, "description": "Level of detail for the address (3=country, 10=city, 18=building)" } + ] + } +] \ No newline at end of file diff --git a/tests/api_tool_eval/eval_search.py b/tests/api_tool_eval/eval_search.py new file mode 100644 index 00000000..95c99792 --- /dev/null +++ b/tests/api_tool_eval/eval_search.py @@ -0,0 +1,430 @@ +""" +Retrieval Evaluation Script — tests semantic search accuracy across 40+ queries. + +Usage: + python eval_search.py + python eval_search.py --ruuter-url http://localhost:8086 + python eval_search.py --output results.json # also save detailed JSON results + +What it does: + 1. Sends each query to POST /rag-search/api-tools/search + 2. Checks if the top result matches the expected endpoint name + 3. Prints a detailed pass/fail table + 4. Outputs accuracy %, average cosine score, and a list of failures +""" + +import argparse +import json +import time +from pathlib import Path +from typing import Optional + +import requests + +DEFAULT_RUUTER_URL = "http://localhost:8086" +SEARCH_ENDPOINT = "/rag-search/api-tools/search" + +# ============================================================================ +# Evaluation Dataset — aligned with test-endpoints.json (15 endpoints) +# Format: (query, expected_endpoint_name or None for "no match expected") +# ============================================================================ +EVAL_QUERIES = [ + # --- get_public_holidays --- + ("What are the public holidays in Estonia this year?", "get_public_holidays"), + ("List official public holidays in Estonia for 2025", "get_public_holidays"), + ("When are the national public holidays in Estonia?", "get_public_holidays"), + ( + "Show me all public days off in Estonia between January and June", + "get_public_holidays", + ), + ("What are the official non-working days in Estonia?", "get_public_holidays"), + # Estonian + ("Millised on Eesti riigipühad sel aastal?", "get_public_holidays"), + ("Millal on Eestis ametlikud riigipühad 2025. aastal?", "get_public_holidays"), + ("Näita mulle Eesti riigipühi jaanuarist juunini", "get_public_holidays"), + # --- get_school_holidays --- + ("When are the school holidays in Estonia?", "get_school_holidays"), + ("What are the school term breaks in Estonia this year?", "get_school_holidays"), + ("When does school summer break start in Estonia in 2025?", "get_school_holidays"), + ( + "Show me school holiday periods in Estonia for spring 2025", + "get_school_holidays", + ), + # Estonian + ("Millal on Eesti koolide koolivaheajad?", "get_school_holidays"), + ("Millal algab koolide suvepuhkus Eestis 2025. aastal?", "get_school_holidays"), + ("Näita mulle kevadise koolivaheaja aegu Eestis", "get_school_holidays"), + # --- get_electricity_prices --- + ("What are the electricity market prices in Estonia?", "get_electricity_prices"), + ( + "Show me electricity prices for the past week in Estonia", + "get_electricity_prices", + ), + ( + "Fetch energy market prices between January and March 2025", + "get_electricity_prices", + ), + ( + "What was the electricity spot price in Estonia last month?", + "get_electricity_prices", + ), + # Estonian + ("Millised on elektrituruhinnad Eestis?", "get_electricity_prices"), + ("Näita elektrihindu eelmise nädala kohta Eestis", "get_electricity_prices"), + ("Mis oli elektrihind Eestis eelmisel kuul?", "get_electricity_prices"), + # --- get_vehicle_tax_info --- + ("Calculate vehicle tax for registration number 123ABC", "get_vehicle_tax_info"), + ( + "How much is the vehicle tax for my car with plate 456XYZ?", + "get_vehicle_tax_info", + ), + ("What is the car tax based on my registration number?", "get_vehicle_tax_info"), + # Estonian + ("Arvuta sõidukimaks registreerimisnumbri 123ABC alusel", "get_vehicle_tax_info"), + ("Kui suur on minu auto maks numbrimärgi 456XYZ järgi?", "get_vehicle_tax_info"), + ( + "Mis on mootorsõidukimaks minu auto registreerimisnumbri alusel?", + "get_vehicle_tax_info", + ), + # --- get_parliament_votings --- + ( + "Show me the latest parliament voting records in Estonia", + "get_parliament_votings", + ), + ("What did the Riigikogu vote on recently?", "get_parliament_votings"), + ( + "Retrieve parliamentary voting decisions from the Estonian parliament", + "get_parliament_votings", + ), + ("What laws were voted on in the Estonian parliament?", "get_parliament_votings"), + # Estonian + ("Näita Riigikogu viimaseid hääletusprotokolle", "get_parliament_votings"), + ("Mille üle hääletas Riigikogu hiljuti?", "get_parliament_votings"), + ("Milliseid seadusi hääletati Eesti parlamendis?", "get_parliament_votings"), + # --- get_parliament_participation_stats --- + ( + "How often do Estonian parliament members attend sessions?", + "get_parliament_participation_stats", + ), + ( + "Show me parliament member attendance statistics", + "get_parliament_participation_stats", + ), + ( + "Which MPs have the best attendance record in the Riigikogu?", + "get_parliament_participation_stats", + ), + # Estonian + ( + "Kui tihti osalevad Riigikogu liikmed istungitel?", + "get_parliament_participation_stats", + ), + ( + "Näita Riigikogu liikmete kohaloleku statistikat", + "get_parliament_participation_stats", + ), + ( + "Millistel saadikutel on Riigikogu parim kohalolekurekord?", + "get_parliament_participation_stats", + ), + # --- get_initiatives --- + ("Show me a list of active citizen initiatives in Estonia", "get_initiatives"), + ("What public initiatives are currently available?", "get_initiatives"), + ("List all citizen initiatives on rahvaalgatus.ee", "get_initiatives"), + # Estonian + ("Näita mulle aktiivsete kodanike algatuste nimekirja Eestis", "get_initiatives"), + ("Millised rahvaalgatused on praegu saadaval?", "get_initiatives"), + ("Loetle kõik algatused rahvaalgatus.ee lehel", "get_initiatives"), + # --- get_initiative_details --- + ("Get details about citizen initiative with ID abc123", "get_initiative_details"), + ( + "Show me more information about a specific public initiative", + "get_initiative_details", + ), + ("Fetch the details of initiative ID xyz789", "get_initiative_details"), + # Estonian + ("Too andmed kodanike algatuse ID abc123 kohta", "get_initiative_details"), + ("Näita mulle üksikasju konkreetse rahvaalgatuse kohta", "get_initiative_details"), + ("Too algatuse ID xyz789 üksikasjad", "get_initiative_details"), + # --- get_initiative_events --- + ( + "What are the latest events related to citizen initiatives?", + "get_initiative_events", + ), + ("Show me updates and events for public initiatives", "get_initiative_events"), + ( + "Are there any new events for citizen initiatives in Estonia?", + "get_initiative_events", + ), + # Estonian + ( + "Millised on viimased kodanike algatustega seotud sündmused?", + "get_initiative_events", + ), + ("Näita rahvaalgatuste uuendusi ja sündmusi", "get_initiative_events"), + ("Kas Eestis on uusi sündmusi kodanike algatuste kohta?", "get_initiative_events"), + # --- search_address --- + ("Search for the address Viru 4 in Tallinn", "search_address"), + ("Find the location of Kadriorg Park in Tallinn", "search_address"), + ("Look up an address or place name in Estonia", "search_address"), + ("Search for a street address in Tartu", "search_address"), + # Estonian + ("Otsi aadressi Viru 4 Tallinnas", "search_address"), + ("Leia Kadrioru pargi asukoht Tallinnas", "search_address"), + ("Otsi tänavaaadress Tartus", "search_address"), + # --- get_population_statistics --- + ("What is the population of Estonia?", "get_population_statistics"), + ("Show me population statistics data for Estonia", "get_population_statistics"), + ("Fetch demographic statistics for Estonia", "get_population_statistics"), + ( + "What is the population breakdown by age group in Estonia?", + "get_population_statistics", + ), + # Estonian + ("Milline on Eesti rahvaarv?", "get_population_statistics"), + ("Näita mulle Eesti rahvastikustatistika andmeid", "get_population_statistics"), + ( + "Milline on Eesti rahvastiku jaotus vanuserühmade kaupa?", + "get_population_statistics", + ), + # --- get_economic_statistics --- + ("Show me economic statistics for Estonia", "get_economic_statistics"), + ("What is the GDP and economic output of Estonia?", "get_economic_statistics"), + ( + "Fetch economic data for Estonia from the statistics office", + "get_economic_statistics", + ), + # Estonian + ("Näita mulle Eesti majandusstatistikat", "get_economic_statistics"), + ("Mis on Eesti SKP ja majanduslik toodang?", "get_economic_statistics"), + ("Too majandusandmed Eesti statistikaametist", "get_economic_statistics"), + # --- get_labor_statistics --- + ("What is the unemployment rate in Estonia?", "get_labor_statistics"), + ("Show me labor and employment statistics for Estonia", "get_labor_statistics"), + ("How many people are employed in Estonia?", "get_labor_statistics"), + ("Fetch workforce and jobless statistics for Estonia", "get_labor_statistics"), + # Estonian + ("Milline on töötuse määr Eestis?", "get_labor_statistics"), + ("Näita mulle Eesti tööjõu ja tööhõive statistikat", "get_labor_statistics"), + ("Kui palju inimesi töötab Eestis?", "get_labor_statistics"), + # --- get_current_weather --- + ("What is the current weather in Tallinn?", "get_current_weather"), + ("Show me the current weather conditions in Estonia", "get_current_weather"), + ( + "What is the temperature right now at the Tallinn weather station?", + "get_current_weather", + ), + # Estonian + ("Milline on praegune ilm Tallinnas?", "get_current_weather"), + ("Näita mulle praeguseid ilmastikuolusid Eestis", "get_current_weather"), + ("Mis on praegune temperatuur Tallinna ilmajaamas?", "get_current_weather"), + # --- get_weather_forecast --- + ("What is the weather forecast for Tallinn tomorrow?", "get_weather_forecast"), + ("Show me the upcoming weather forecast for Tartu", "get_weather_forecast"), + ("What will the weather be like in Estonia next week?", "get_weather_forecast"), + ( + "Give me a weather forecast for the next few days in Estonia", + "get_weather_forecast", + ), + # Estonian + ("Milline on ilmaprognoos Tallinnas homme?", "get_weather_forecast"), + ("Näita mulle Tartu eelseisvat ilmaprognoosi", "get_weather_forecast"), + ("Milline on ilm Eestis järgmisel nädalal?", "get_weather_forecast"), + # --- NEGATIVE queries — should return NO matching results --- + ("Who is the Prime Minister of Estonia?", None), + ("What is the best restaurant in Tallinn?", None), + ("Tell me a random fact about Estonia", None), + ("What is the meaning of life?", None), + ("Book me a flight to London", None), + ("Can you translate this text to Estonian?", None), + ("What are the visa requirements to visit Estonia?", None), + ("How do I apply for an Estonian e-Residency?", None), + ("What is the history of Tallinn Old Town?", None), + ("Give me a poem about Estonia", None), + # Estonian negatives + ("Kes on Eesti peaminister?", None), + ("Mis on parim restoran Tallinnas?", None), + ("Mis on elu mõte?", None), +] + + +def search(ruuter_url: str, query: str, top_k: int = 3) -> Optional[dict]: + """Send a search query and return the parsed response.""" + url = f"{ruuter_url}{SEARCH_ENDPOINT}" + try: + response = requests.post( + url, + json={"query": query, "top_k": top_k, "environment": "production"}, + timeout=30, + ) + if response.status_code == 200: + body = response.json() + # Handle Ruuter wrapper: body may be {"response": {...}} + return body.get("response", body) + return None + except Exception: + return None + + +def evaluate(ruuter_url: str, delay: float = 0.5) -> list: + """Run all evaluation queries and return results.""" + results = [] + for query, expected in EVAL_QUERIES: + response = search(ruuter_url, query) + + if response is None: + results.append( + { + "query": query, + "expected": expected, + "got": "ERROR", + "cosine_score": None, + "rrf_score": None, + "confidence": None, + "pass": False, + "error": "Request failed", + } + ) + time.sleep(delay) + continue + + top_results = response.get("results", []) + top = top_results[0] if top_results else None + + got_name = top["name"] if top else None + cosine_score = top["cosine_score"] if top else None + rrf_score = top["rrf_score"] if top else None + confidence = top["confidence"] if top else None + + # Determine pass/fail + if expected is None: + # Negative query: should return no HIGH confidence result + passed = got_name is None or confidence != "high" + else: + passed = got_name == expected + + results.append( + { + "query": query, + "expected": expected, + "got": got_name, + "cosine_score": cosine_score, + "rrf_score": rrf_score, + "confidence": confidence, + "pass": passed, + } + ) + + time.sleep(delay) + + return results + + +def print_report(results: list) -> None: + """Print evaluation results table and summary.""" + pass_icon = "✅" + fail_icon = "❌" + + print(f"\n{'=' * 100}") + print(f"{'RETRIEVAL EVALUATION REPORT':^100}") + print(f"{'=' * 100}") + print( + f"{'#':<4} {'Query':<48} {'Expected':<28} {'Got':<28} {'Cosine':>7} {'RRF':>9} {'Result'}" + ) + print(f"{'-' * 100}") + + for i, r in enumerate(results, 1): + query = r["query"][:46] + ".." if len(r["query"]) > 46 else r["query"] + expected = (r["expected"] or "(none)")[:26] + got = (r["got"] or "(none)")[:26] + cosine = ( + f"{r['cosine_score']:.4f}" if r["cosine_score"] is not None else " - " + ) + rrf = f"{r['rrf_score']:.6f}" if r["rrf_score"] is not None else " - " + verdict = pass_icon if r["pass"] else fail_icon + + # Highlight negative query failures + if r["expected"] is None and r["got"] is not None and r["confidence"] == "high": + verdict = fail_icon + " FALSE POSITIVE" + + print( + f"{i:<4} {query:<48} {expected:<28} {got:<28} {cosine:>7} {rrf:>9} {verdict}" + ) + + # Summary stats + total = len(results) + passed = sum(1 for r in results if r["pass"]) + failed = total - passed + + positives = [r for r in results if r["expected"] is not None] + negatives = [r for r in results if r["expected"] is None] + positive_pass = sum(1 for r in positives if r["pass"]) + negative_pass = sum(1 for r in negatives if r["pass"]) + + scores = [ + r["cosine_score"] + for r in results + if r["cosine_score"] is not None and r["pass"] + ] + avg_cosine = sum(scores) / len(scores) if scores else 0.0 + rrf_scores = [ + r["rrf_score"] for r in results if r["rrf_score"] is not None and r["pass"] + ] + avg_rrf = sum(rrf_scores) / len(rrf_scores) if rrf_scores else 0.0 + + print(f"\n{'=' * 100}") + print("SUMMARY") + print(f"{'=' * 100}") + print(f" Overall Accuracy: {passed}/{total} ({100 * passed / total:.1f}%)") + print( + f" Positive Queries: {positive_pass}/{len(positives)} ({100 * positive_pass / len(positives):.1f}%)" + ) + print( + f" Negative Queries: {negative_pass}/{len(negatives)} ({100 * negative_pass / len(negatives):.1f}%)" + ) + print( + f" Avg Cosine (correct): {avg_cosine:.4f} (threshold: min={0.40}, high={0.60})" + ) + print(f" Avg RRF (correct): {avg_rrf:.6f}") + print("\n Target: >90% overall accuracy, avg cosine >0.55") + + if failed > 0: + print(f"\n FAILURES ({failed}):") + for r in results: + if not r["pass"]: + print(f" '{r['query']}'") + print( + f" Expected: {r['expected']} | Got: {r['got']} | Cosine: {r['cosine_score']} | RRF: {r['rrf_score']}" + ) + + print(f"{'=' * 100}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Evaluate semantic search retrieval accuracy" + ) + parser.add_argument("--ruuter-url", default=DEFAULT_RUUTER_URL) + parser.add_argument( + "--delay", type=float, default=0.5, help="Seconds between requests" + ) + parser.add_argument( + "--output", type=str, default=None, help="Save results to JSON file" + ) + args = parser.parse_args() + + print(f"\nStarting evaluation — {len(EVAL_QUERIES)} queries") + print(f"Target: {args.ruuter_url}{SEARCH_ENDPOINT}\n") + + results = evaluate(args.ruuter_url, args.delay) + print_report(results) + + if args.output: + output_path = Path(args.output) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + print(f"Detailed results saved to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/tests/api_tool_eval/integration-results.json b/tests/api_tool_eval/integration-results.json new file mode 100644 index 00000000..d5e3192e --- /dev/null +++ b/tests/api_tool_eval/integration-results.json @@ -0,0 +1,191 @@ +[ + { + "name": "1 — Single-turn complete (vehicle tax with reg number)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "Calculate vehicle tax for registration number 123ABC", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"get_vehicle_tax_info\"}, \"collected_params\": {\"registrationNumber\": \"123ABC\"}}", + "passed": true, + "note": "collected_params={'registrationNumber': '123ABC'}" + } + ] + }, + { + "name": "2 — Multi-turn EN (public holidays, params across 2 turns)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "What are the public holidays in Estonia this year?", + "response_content": "Could you please specify the start date and end date for the period you want to know about the public holidays in Estonia? For example, you can provide the beginning and ending dates in the format YYYY-MM-DD.", + "passed": true, + "note": "" + }, + { + "turn": 2, + "message_sent": "EE, from 2025-01-01 to 2025-12-31", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"get_public_holidays\"}, \"collected_params\": {\"validFrom\": \"2025-01-01\", \"validTo\": \"2025-12-31\", \"countryIsoCode\": \"EE\"}}", + "passed": true, + "note": "collected_params={'validFrom': '2025-01-01', 'validTo': '2025-12-31', 'countryIsoCode': 'EE'}" + } + ] + }, + { + "name": "4 — No-params fast-path (parliament votings)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "Show me the latest parliament voting records in Estonia", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"get_parliament_votings\"}, \"collected_params\": {}}", + "passed": true, + "note": "endpoint={'name': 'get_parliament_votings'}, collected_params={}" + } + ] + }, + { + "name": "5 — Multi-turn (address search, 2 turns)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "Search for an address in Estonia", + "response_content": "What is the address or place name you would like to search for in Estonia?", + "passed": true, + "note": "" + }, + { + "turn": 2, + "message_sent": "Viru 4, Tallinn", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"search_address\"}, \"collected_params\": {\"address\": \"Viru 4, Tallinn\"}}", + "passed": true, + "note": "collected_params={'address': 'Viru 4, Tallinn'}" + } + ] + }, + { + "name": "6 — Multi-turn (electricity prices, datetime params)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "What are the electricity market prices in Estonia?", + "response_content": "Could you please specify the start and end time period for which you would like to see the electricity market prices? Please provide both dates in ISO 8601 format (YYYY-MM-DDTHH:MM:SS).", + "passed": true, + "note": "" + }, + { + "turn": 2, + "message_sent": "From 2025-01-01T00:00:00 to 2025-01-07T23:59:59", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"get_electricity_prices\"}, \"collected_params\": {\"start\": \"2025-01-01T00:00:00\", \"end\": \"2025-01-07T23:59:59\"}}", + "passed": true, + "note": "collected_params={'start': '2025-01-01T00:00:00', 'end': '2025-01-07T23:59:59'}" + } + ] + }, + { + "name": "7 — Session isolation (no param leak across flows)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "Calculate vehicle tax for registration number 777XYZ", + "response_content": "{\"status\": \"params_collected\", \"endpoint\": {\"name\": \"get_vehicle_tax_info\"}, \"collected_params\": {\"registrationNumber\": \"777XYZ\"}}", + "passed": true, + "note": "" + }, + { + "turn": 2, + "message_sent": "What are the public holidays in Estonia this year?", + "response_content": "For which period would you like to see the public holidays? Please specify the start and end dates (in format YYYY-MM-DD).", + "passed": true, + "note": "Correctly started new session and asked for params" + } + ] + }, + { + "name": "8 — AWAITING_CONTINUATION_DECISION (yes → loop resumes)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "I want to see public holidays", + "response_content": "Millise riigi ja millise ajavahemiku (algus- ja lõppkuupäev, kujul YYYY-MM-DD) riigipühasid soovite näha?", + "passed": true, + "note": "" + }, + { + "turn": 2, + "message_sent": "I'm not sure", + "response_content": "Millise riigi ja millise ajavahemiku (algus- ja lõppkuupäev, kujul YYYY-MM-DD) riigipühasid soovite näha?", + "passed": true, + "note": "" + }, + { + "turn": 3, + "message_sent": "I don't know", + "response_content": "Mul on vaja veel natuke lisateavet, kuid oleme selle kallal juba mõnda aega töötanud. Kas soovite jätkata ja vastata veel mõnele küsimusele, või eelistaksite peatuda ja saada üldise vastuse? (jah / ei)", + "passed": true, + "note": "Got continuation prompt" + }, + { + "turn": 4, + "message_sent": "yes", + "response_content": "Millise riigi kahetähelist ISO koodi (nt EE, LV) ning millist algus- ja lõppkuupäeva (kujul YYYY-MM-DD) soovite riigipühade nägemiseks kasutada?", + "passed": true, + "note": "Loop resumed correctly after 'yes'" + } + ] + }, + { + "name": "9 — MAX_TURNS_REACHED (loop exhausted, falls back to RAG)", + "passed": true, + "error": "", + "turns": [ + { + "turn": 1, + "message_sent": "I want to see public holidays", + "response_content": "Millise riigi ja kuupäevavahemiku (algus- ja lõppkuupäev formaadis YYYY-MM-DD) riigipühi soovite näha?", + "passed": true, + "note": "Still in loop" + }, + { + "turn": 2, + "message_sent": "hmm not sure", + "response_content": "Millise riigi ja kuupäevavahemiku (algus- ja lõppkuupäev formaadis YYYY-MM-DD) riigipühi soovite näha?", + "passed": true, + "note": "Still in loop" + }, + { + "turn": 3, + "message_sent": "I have no idea", + "response_content": "Mul on vaja veel natuke lisateavet, kuid oleme selle kallal juba mõnda aega töötanud. Kas soovite jätkata ja vastata veel mõnele küsimusele, või eelistaksite peatuda ja saada üldise vastuse? (jah / ei)", + "passed": true, + "note": "Still in loop" + }, + { + "turn": 4, + "message_sent": "yes", + "response_content": "Millise riigi ja millise kuupäevavahemiku (algus- ja lõppkuupäev formaadis YYYY-MM-DD) riigipühi soovite näha?", + "passed": true, + "note": "Still in loop" + }, + { + "turn": 5, + "message_sent": "I still don't know", + "response_content": "Millise riigi ja millise kuupäevavahemiku (algus- ja lõppkuupäev formaadis YYYY-MM-DD) riigipühi soovite näha?", + "passed": true, + "note": "Correctly fell back to RAG/OOD after max turns" + } + ] + } +] \ No newline at end of file diff --git a/tests/api_tool_eval/integration_test_agentic_loop.py b/tests/api_tool_eval/integration_test_agentic_loop.py new file mode 100644 index 00000000..f24eae75 --- /dev/null +++ b/tests/api_tool_eval/integration_test_agentic_loop.py @@ -0,0 +1,805 @@ +""" +Integration Test — Agentic Loop Multi-Turn Parameter Collection +============================================================== + +Tests the full end-to-end agentic loop via the /orchestrate endpoint. + +Scenarios covered: + 1. Single-turn complete — all params in first message (vehicle tax) + 2. Multi-turn (EN) — no params upfront, answered across 2 turns (public holidays) + 3. Multi-turn (ET) — same flow in Estonian (school holidays) + 4. No-params fast-path — endpoint with no required params (parliament votings) + 5. Address search — single required param, 2-turn + 6. Electricity prices — 2 required datetime params, 2-turn + 7. Session isolation — after completing one flow, a NEW query for the same chatId must NOT reuse old session values + 8. AWAITING_CONTINUATION_DECISION — hits continuation threshold, user says "yes", loop resumes + 9. MAX_TURNS_REACHED → loop falls back to RAG/OOD, does NOT return collected_params JSON + +Usage: + # Service running locally on port 8100 + uv run python tests/api_tool_eval/integration_test_agentic_loop.py + + # Against a different host/port + uv run python tests/api_tool_eval/integration_test_agentic_loop.py --url http://localhost:8100 + + # Keep going even after failures + uv run python tests/api_tool_eval/integration_test_agentic_loop.py --no-fail-fast + + # Save results to JSON + uv run python tests/api_tool_eval/integration_test_agentic_loop.py --output results-integration.json +""" + +import argparse +import json +import sys +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +import requests + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +DEFAULT_URL = "http://localhost:8100" +ORCHESTRATE_ENDPOINT = "/orchestrate" +ENVIRONMENT = "production" +AUTHOR_ID = "integration-test-user" +REQUEST_TIMEOUT = 30 # seconds per turn + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def make_chat_id(label: str) -> str: + """Unique chatId per test run so Redis sessions never collide across runs.""" + return f"integration-test-{label}-{uuid.uuid4().hex[:8]}" + + +def send_turn( + base_url: str, + chat_id: str, + message: str, + history: List[Dict[str, str]], + connection_id: Optional[str] = None, +) -> Dict[str, Any]: + """POST one turn to /orchestrate and return the parsed JSON response.""" + payload: Dict[str, Any] = { + "chatId": chat_id, + "message": message, + "authorId": AUTHOR_ID, + "conversationHistory": history, + "url": "integration-test", + "environment": ENVIRONMENT, + } + if connection_id: + payload["connection_id"] = connection_id + + resp = requests.post( + f"{base_url}{ORCHESTRATE_ENDPOINT}", + json=payload, + timeout=REQUEST_TIMEOUT, + ) + resp.raise_for_status() + return resp.json() + + +def append_to_history( + history: List[Dict[str, str]], + user_message: str, + bot_response: str, +) -> List[Dict[str, str]]: + """Return an updated conversation history list.""" + ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return history + [ + {"authorRole": "user", "message": user_message, "timestamp": ts}, + {"authorRole": "bot", "message": bot_response, "timestamp": ts}, + ] + + +def is_completed(content: str) -> bool: + """Return True if the response is a params-collected JSON payload.""" + try: + data = json.loads(content) + return "collected_params" in data and "endpoint" in data + except (json.JSONDecodeError, TypeError): + return False + + +def is_clarifying_question(content: str) -> bool: + """Return True if the response looks like a clarifying question (not JSON).""" + try: + json.loads(content) + return False # valid JSON → completed or error + except (json.JSONDecodeError, TypeError): + return bool(content.strip()) + + +# --------------------------------------------------------------------------- +# Result tracking +# --------------------------------------------------------------------------- + + +@dataclass +class TurnResult: + turn: int + message_sent: str + response_content: str + passed: bool + note: str = "" + + +@dataclass +class ScenarioResult: + name: str + passed: bool + turns: List[TurnResult] = field(default_factory=list) + error: str = "" + + +# --------------------------------------------------------------------------- +# Test scenarios +# --------------------------------------------------------------------------- + + +def scenario_1_single_turn_vehicle_tax(base_url: str) -> ScenarioResult: + """ + Scenario 1: Single-turn complete + -------------------------------- + User provides the required param (registrationNumber) in the first message. + Expected: response is immediately a completed JSON with collected_params. + """ + name = "1 — Single-turn complete (vehicle tax with reg number)" + chat_id = make_chat_id("s1") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + message = "Calculate vehicle tax for registration number 123ABC" + resp = send_turn(base_url, chat_id, message, history) + content = resp.get("content", "") + + passed = is_completed(content) + if passed: + data = json.loads(content) + collected = data.get("collected_params", {}) + passed = collected.get("registrationNumber") == "123ABC" + note = f"collected_params={collected}" + else: + note = f"Expected completed JSON, got: {content[:120]}" + + turns.append(TurnResult(1, message, content, passed, note)) + return ScenarioResult(name, passed, turns) + + +def scenario_2_multiturn_public_holidays_en(base_url: str) -> ScenarioResult: + """ + Scenario 2: Multi-turn — public holidays (English) + --------------------------------------------------- + Turn 1: vague query — bot asks for country + date range + Turn 2: user provides all params — bot returns completed JSON + """ + name = "2 — Multi-turn EN (public holidays, params across 2 turns)" + chat_id = make_chat_id("s2") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + # Turn 1 + msg1 = "What are the public holidays in Estonia this year?" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + + turn1_pass = is_clarifying_question(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + turn1_pass, + "Expected clarifying question" if not turn1_pass else "", + ) + ) + + if not turn1_pass: + return ScenarioResult( + name, False, turns, "Turn 1 did not return a clarifying question" + ) + + # Turn 2 + history = append_to_history(history, msg1, content1) + msg2 = "EE, from 2025-01-01 to 2025-12-31" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + + turn2_pass = is_completed(content2) + note2 = "" + if turn2_pass: + data = json.loads(content2) + collected = data.get("collected_params", {}) + expected_keys = {"countryIsoCode", "validFrom", "validTo"} + missing = expected_keys - collected.keys() + turn2_pass = not missing + note2 = ( + f"collected_params={collected}" + if not missing + else f"missing keys: {missing}" + ) + else: + note2 = f"Expected completed JSON, got: {content2[:120]}" + + turns.append(TurnResult(2, msg2, content2, turn2_pass, note2)) + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +def scenario_3_multiturn_school_holidays_et(base_url: str) -> ScenarioResult: + """ + Scenario 3: Multi-turn — school holidays (Estonian) + ---------------------------------------------------- + Turn 1: Estonian query, no params + Turn 2: provides date range in Estonian + """ + name = "3 — Multi-turn ET (school holidays in Estonian)" + chat_id = make_chat_id("s3") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + msg1 = "Millal on Eesti koolide koolivaheajad 2025. aastal?" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + + turn1_pass = is_clarifying_question(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + turn1_pass, + "Expected clarifying question" if not turn1_pass else "", + ) + ) + + if not turn1_pass: + return ScenarioResult( + name, False, turns, "Turn 1 did not return a clarifying question" + ) + + history = append_to_history(history, msg1, content1) + msg2 = "EE, alguskuupäev 2025-01-01, lõppkuupäev 2025-12-31" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + + turn2_pass = is_completed(content2) + note2 = "" + if turn2_pass: + data = json.loads(content2) + collected = data.get("collected_params", {}) + expected_keys = {"countryIsoCode", "validFrom", "validTo"} + missing = expected_keys - collected.keys() + turn2_pass = not missing + note2 = ( + f"collected_params={collected}" + if not missing + else f"missing keys: {missing}" + ) + else: + note2 = f"Expected completed JSON, got: {content2[:120]}" + + turns.append(TurnResult(2, msg2, content2, turn2_pass, note2)) + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +def scenario_4_no_params_fast_path(base_url: str) -> ScenarioResult: + """ + Scenario 4: No-params fast-path (parliament votings) + ----------------------------------------------------- + Endpoint has no required params → should return completed JSON on turn 1 + without asking any clarifying questions. + """ + name = "4 — No-params fast-path (parliament votings)" + chat_id = make_chat_id("s4") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + message = "Show me the latest parliament voting records in Estonia" + resp = send_turn(base_url, chat_id, message, history) + content = resp.get("content", "") + + passed = is_completed(content) + note = "" + if passed: + data = json.loads(content) + note = f"endpoint={data.get('endpoint')}, collected_params={data.get('collected_params')}" + else: + note = f"Expected fast-path completed JSON, got: {content[:120]}" + + turns.append(TurnResult(1, message, content, passed, note)) + return ScenarioResult(name, passed, turns) + + +def scenario_5_address_search(base_url: str) -> ScenarioResult: + """ + Scenario 5: Address search — single required param + --------------------------------------------------- + Turn 1: vague — "search for an address" → bot asks which address + Turn 2: user provides address → completed + """ + name = "5 — Multi-turn (address search, 2 turns)" + chat_id = make_chat_id("s5") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + msg1 = "Search for an address in Estonia" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + + turn1_pass = is_clarifying_question(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + turn1_pass, + "Expected clarifying question" if not turn1_pass else "", + ) + ) + + if not turn1_pass: + return ScenarioResult( + name, False, turns, "Turn 1 did not return a clarifying question" + ) + + history = append_to_history(history, msg1, content1) + msg2 = "Viru 4, Tallinn" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + + turn2_pass = is_completed(content2) + note2 = "" + if turn2_pass: + data = json.loads(content2) + collected = data.get("collected_params", {}) + turn2_pass = "address" in collected + note2 = f"collected_params={collected}" + else: + note2 = f"Expected completed JSON, got: {content2[:120]}" + + turns.append(TurnResult(2, msg2, content2, turn2_pass, note2)) + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +def scenario_6_electricity_prices(base_url: str) -> ScenarioResult: + """ + Scenario 6: Electricity prices — 2 required datetime params + ------------------------------------------------------------ + Turn 1: "What are the electricity prices?" → bot asks for start/end + Turn 2: user provides both datetimes → completed + """ + name = "6 — Multi-turn (electricity prices, datetime params)" + chat_id = make_chat_id("s6") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + msg1 = "What are the electricity market prices in Estonia?" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + + turn1_pass = is_clarifying_question(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + turn1_pass, + "Expected clarifying question" if not turn1_pass else "", + ) + ) + + if not turn1_pass: + return ScenarioResult( + name, False, turns, "Turn 1 did not return a clarifying question" + ) + + history = append_to_history(history, msg1, content1) + msg2 = "From 2025-01-01T00:00:00 to 2025-01-07T23:59:59" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + + turn2_pass = is_completed(content2) + note2 = "" + if turn2_pass: + data = json.loads(content2) + collected = data.get("collected_params", {}) + expected_keys = {"start", "end"} + missing = expected_keys - collected.keys() + turn2_pass = not missing + note2 = ( + f"collected_params={collected}" + if not missing + else f"missing keys: {missing}" + ) + else: + note2 = f"Expected completed JSON, got: {content2[:120]}" + + turns.append(TurnResult(2, msg2, content2, turn2_pass, note2)) + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +def scenario_7_session_isolation(base_url: str) -> ScenarioResult: + """ + Scenario 7: Session isolation after completion + ----------------------------------------------- + Uses the SAME chatId across two separate API tool flows to verify that + completing one flow does not leak params into the next query. + + Flow: + Turn 1: "Calculate vehicle tax for 777XYZ" → COMPLETED (session deleted) + Turn 2: "What are public holidays in Estonia?" → NEW session, asks for params + (MUST NOT immediately complete with registrationNumber=777XYZ) + """ + name = "7 — Session isolation (no param leak across flows)" + chat_id = make_chat_id("s7") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + # First flow — complete it + msg1 = "Calculate vehicle tax for registration number 777XYZ" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + + flow1_ok = is_completed(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + flow1_ok, + "First flow should complete immediately" if not flow1_ok else "", + ) + ) + + if not flow1_ok: + return ScenarioResult( + name, False, turns, "First flow did not complete — cannot test isolation" + ) + + # Second flow on the same chatId — must start fresh + history = append_to_history(history, msg1, content1) + msg2 = "What are the public holidays in Estonia this year?" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + + # It must NOT immediately return collected_params (that would mean param leak) + turn2_pass = is_clarifying_question(content2) + note2 = ( + "Correctly started new session and asked for params" + if turn2_pass + else f"BAD: returned completed JSON immediately (param leak?): {content2[:150]}" + ) + turns.append(TurnResult(2, msg2, content2, turn2_pass, note2)) + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + + +def scenario_8_awaiting_continuation(base_url: str) -> ScenarioResult: + """ + Scenario 8: AWAITING_CONTINUATION_DECISION flow + ------------------------------------------------ + The loop hits CONTINUATION_TURN (3) without all params collected. + The bot must ask a yes/no "keep going?" question. + + We then answer "yes" to continue — the bot should resume asking for the + remaining params (not immediately complete and not fall back to RAG). + + Uses get_public_holidays which has 3 required params (countryIsoCode, + validFrom, validTo). We deliberately give unhelpful answers on turns 2 and 3 + to reach the continuation threshold. + + Turn flow (CONTINUATION_TURN=3, max_turns=5): + run_turn #1 (turn 0→1): opening question + run_turn #2 (turn 1→2): unhelpful reply → another clarifying question + run_turn #3 (turn 2→3): still unhelpful → AWAITING_CONTINUATION_DECISION + run_turn #4 (turn 3→4): user says "yes" → loop resumes, asks again + """ + name = "8 — AWAITING_CONTINUATION_DECISION (yes → loop resumes)" + chat_id = make_chat_id("s8") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + # Turn 1 — trigger the flow with no params + msg1 = "I want to see public holidays" + resp1 = send_turn(base_url, chat_id, msg1, history) + content1 = resp1.get("content", "") + t1_pass = is_clarifying_question(content1) + turns.append( + TurnResult( + 1, + msg1, + content1, + t1_pass, + "Expected opening clarifying question" if not t1_pass else "", + ) + ) + if not t1_pass: + return ScenarioResult( + name, False, turns, "Turn 1 did not return a clarifying question" + ) + + # Turn 2 — deliberately unhelpful + history = append_to_history(history, msg1, content1) + msg2 = "I'm not sure" + resp2 = send_turn(base_url, chat_id, msg2, history) + content2 = resp2.get("content", "") + t2_pass = is_clarifying_question(content2) and not is_completed(content2) + turns.append( + TurnResult( + 2, + msg2, + content2, + t2_pass, + "Expected follow-up clarifying question" if not t2_pass else "", + ) + ) + if not t2_pass: + return ScenarioResult( + name, False, turns, "Turn 2 did not return a clarifying question" + ) + + # Turn 3 — still unhelpful → should trigger continuation check + history = append_to_history(history, msg2, content2) + msg3 = "I don't know" + resp3 = send_turn(base_url, chat_id, msg3, history) + content3 = resp3.get("content", "") + # Continuation question contains "yes" or "no" and is not a completed JSON + is_continuation_prompt = not is_completed(content3) and ( + "yes" in content3.lower() + or "no" in content3.lower() + or "jah" in content3.lower() + ) + t3_pass = is_continuation_prompt + turns.append( + TurnResult( + 3, + msg3, + content3, + t3_pass, + "Expected yes/no continuation question" + if not t3_pass + else "Got continuation prompt", + ) + ) + if not t3_pass: + return ScenarioResult( + name, False, turns, "Turn 3 did not trigger continuation check" + ) + + # Turn 4 — user says "yes" → loop should resume with another clarifying question + history = append_to_history(history, msg3, content3) + msg4 = "yes" + resp4 = send_turn(base_url, chat_id, msg4, history) + content4 = resp4.get("content", "") + # After "yes" the bot must ask for params again, not complete + t4_pass = is_clarifying_question(content4) and not is_completed(content4) + note4 = ( + "Loop resumed correctly after 'yes'" + if t4_pass + else f"Expected resumed clarifying question, got: {content4[:150]}" + ) + turns.append(TurnResult(4, msg4, content4, t4_pass, note4)) + + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +def scenario_9_max_turns_reached(base_url: str) -> ScenarioResult: + """ + Scenario 9: MAX_TURNS_REACHED — loop falls back to RAG/OOD + ----------------------------------------------------------- + Keep giving unhelpful answers through the continuation "yes" and beyond + until max_turns (5) is exhausted. The final response must NOT be a + params-collected JSON — it should be a natural-language RAG/OOD answer. + + Turn flow (CONTINUATION_TURN=3, max_turns=5): + run_turn #1 (turn 0→1): opening question + run_turn #2 (turn 1→2): unhelpful → clarifying question + run_turn #3 (turn 2→3): unhelpful → AWAITING_CONTINUATION_DECISION + run_turn #4 (turn 3→4): "yes" → loop resumes, asks again + run_turn #5 (turn 4→5): unhelpful → MAX_TURNS_REACHED → fallback + """ + name = "9 — MAX_TURNS_REACHED (loop exhausted, falls back to RAG)" + chat_id = make_chat_id("s9") + history: List[Dict[str, str]] = [] + turns: List[TurnResult] = [] + + unhelpful_replies = [ + "I want to see public holidays", # turn 1 — trigger + "hmm not sure", # turn 2 — still missing + "I have no idea", # turn 3 — continuation check + "yes", # turn 4 — continue + "I still don't know", # turn 5 — max turns + ] + + last_content = "" + for i, msg in enumerate(unhelpful_replies, start=1): + resp = send_turn(base_url, chat_id, msg, history) + content = resp.get("content", "") + history = append_to_history(history, msg, content) + + if i < len(unhelpful_replies): + # Intermediate turns: should be asking questions or continuation prompt + intermediate_pass = not is_completed(content) + turns.append( + TurnResult( + i, + msg, + content, + intermediate_pass, + "Still in loop" + if intermediate_pass + else f"Unexpectedly completed at turn {i}", + ) + ) + if not intermediate_pass: + return ScenarioResult( + name, False, turns, f"Loop completed unexpectedly at turn {i}" + ) + else: + last_content = content + + # Final turn: must NOT be params-collected JSON (loop fell back to RAG/OOD) + final_pass = not is_completed(last_content) and bool(last_content.strip()) + note = ( + "Correctly fell back to RAG/OOD after max turns" + if final_pass + else f"BAD: got params-collected JSON after max turns: {last_content[:150]}" + ) + turns.append( + TurnResult( + len(unhelpful_replies), + unhelpful_replies[-1], + last_content, + final_pass, + note, + ) + ) + + overall = all(t.passed for t in turns) + return ScenarioResult(name, overall, turns) + + +SCENARIOS = [ + scenario_1_single_turn_vehicle_tax, + scenario_2_multiturn_public_holidays_en, + scenario_4_no_params_fast_path, + scenario_5_address_search, + scenario_6_electricity_prices, + scenario_7_session_isolation, + scenario_8_awaiting_continuation, + scenario_9_max_turns_reached, +] + + +def run_all( + base_url: str, fail_fast: bool = True +) -> Tuple[List[ScenarioResult], int, int]: + results: List[ScenarioResult] = [] + passed = 0 + failed = 0 + + print(f"\n{'=' * 70}") + print(f" Agentic Loop Integration Tests | {base_url}") + print(f"{'=' * 70}\n") + + for fn in SCENARIOS: + print(f"Running: {fn.__name__} ...", flush=True) + try: + result = fn(base_url) + except requests.exceptions.ConnectionError: + result = ScenarioResult( + fn.__name__, False, error="Connection refused — is the service running?" + ) + except requests.exceptions.Timeout: + result = ScenarioResult( + fn.__name__, False, error=f"Request timed out after {REQUEST_TIMEOUT}s" + ) + except Exception as exc: + result = ScenarioResult(fn.__name__, False, error=str(exc)) + + results.append(result) + status_icon = "✅" if result.passed else "❌" + print(f" {status_icon} {result.name}") + + for t in result.turns: + turn_icon = " ✓" if t.passed else " ✗" + print(f" {turn_icon} Turn {t.turn}: {t.message_sent[:60]!r}") + if t.note: + print(f" → {t.note}") + if not t.passed: + print(f" Response: {t.response_content[:200]}") + + if result.error: + print(f" ERROR: {result.error}") + + if result.passed: + passed += 1 + else: + failed += 1 + if fail_fast: + print("\n⚠ Stopping early (--no-fail-fast to continue)\n") + break + + print() + + print(f"{'=' * 70}") + print(f" Results: {passed} passed, {failed} failed / {len(results)} run") + print(f"{'=' * 70}\n") + + return results, passed, failed + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser(description="Agentic loop integration tests") + parser.add_argument( + "--url", + default=DEFAULT_URL, + help=f"Base URL of the orchestration service (default: {DEFAULT_URL})", + ) + parser.add_argument( + "--no-fail-fast", + action="store_true", + help="Continue running all scenarios even after a failure", + ) + parser.add_argument( + "--output", + help="Optional path to save detailed JSON results", + ) + args = parser.parse_args() + + results, passed, failed = run_all( + base_url=args.url, + fail_fast=not args.no_fail_fast, + ) + + if args.output: + output_data = [ + { + "name": r.name, + "passed": r.passed, + "error": r.error, + "turns": [ + { + "turn": t.turn, + "message_sent": t.message_sent, + "response_content": t.response_content, + "passed": t.passed, + "note": t.note, + } + for t in r.turns + ], + } + for r in results + ] + with open(args.output, "w", encoding="utf-8") as f: + json.dump(output_data, f, ensure_ascii=False, indent=2) + print(f"Results saved to {args.output}\n") + + sys.exit(0 if failed == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/tests/api_tool_eval/results.json b/tests/api_tool_eval/results.json new file mode 100644 index 00000000..7a8e8715 --- /dev/null +++ b/tests/api_tool_eval/results.json @@ -0,0 +1,578 @@ +[ + { + "query": "What are the public holidays in Estonia this year?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6562, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "List official public holidays in Estonia for 2025", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.7149, + "rrf_score": 0.7, + "confidence": "high", + "pass": true + }, + { + "query": "When are the national public holidays in Estonia?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.631, + "rrf_score": 0.7, + "confidence": "high", + "pass": true + }, + { + "query": "Show me all public days off in Estonia between January and June", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.5639, + "rrf_score": 0.75, + "confidence": "medium", + "pass": true + }, + { + "query": "What are the official non-working days in Estonia?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6714, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "When are the school holidays in Estonia?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.7116, + "rrf_score": 0.75, + "confidence": "high", + "pass": true + }, + { + "query": "What are the school term breaks in Estonia this year?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.706, + "rrf_score": 0.7, + "confidence": "high", + "pass": true + }, + { + "query": "When does school summer break start in Estonia in 2025?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.5987, + "rrf_score": 0.642857, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me school holiday periods in Estonia for spring 2025", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.591, + "rrf_score": 0.7, + "confidence": "medium", + "pass": true + }, + { + "query": "What are the electricity market prices in Estonia?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6814, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Show me electricity prices for the past week in Estonia", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6186, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "Fetch energy market prices between January and March 2025", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.5003, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "What was the electricity spot price in Estonia last month?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.641, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Calculate vehicle tax for registration number 123ABC", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.6402, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "How much is the vehicle tax for my car with plate 456XYZ?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.4854, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the car tax based on my registration number?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.5449, + "rrf_score": 0.7, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me the latest parliament voting records in Estonia", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.5909, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What did the Riigikogu vote on recently?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.5407, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Retrieve parliamentary voting decisions from the Estonian parliament", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.7032, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What laws were voted on in the Estonian parliament?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.4812, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "How often do Estonian parliament members attend sessions?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.559, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me parliament member attendance statistics", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.5782, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "Which MPs have the best attendance record in the Riigikogu?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.7401, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me a list of active citizen initiatives in Estonia", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.6301, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What public initiatives are currently available?", + "expected": "get_initiatives", + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": false + }, + { + "query": "List all citizen initiatives on rahvaalgatus.ee", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.6211, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Get details about citizen initiative with ID abc123", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.6135, + "rrf_score": 1.0, + "confidence": "high", + "pass": true + }, + { + "query": "Show me more information about a specific public initiative", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.5031, + "rrf_score": 0.75, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch the details of initiative ID xyz789", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.6288, + "rrf_score": 1.0, + "confidence": "high", + "pass": true + }, + { + "query": "What are the latest events related to citizen initiatives?", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.5195, + "rrf_score": 0.333333, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me updates and events for public initiatives", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.5383, + "rrf_score": 0.642857, + "confidence": "medium", + "pass": true + }, + { + "query": "Are there any new events for citizen initiatives in Estonia?", + "expected": "get_initiative_events", + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": false + }, + { + "query": "Search for the address Viru 4 in Tallinn", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.5366, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Find the location of Kadriorg Park in Tallinn", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.4156, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Look up an address or place name in Estonia", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.5359, + "rrf_score": 0.642857, + "confidence": "medium", + "pass": true + }, + { + "query": "Search for a street address in Tartu", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.609, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "What is the population of Estonia?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.4635, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me population statistics data for Estonia", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5567, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch demographic statistics for Estonia", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.6124, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the population breakdown by age group in Estonia?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.4676, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me economic statistics for Estonia", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.5485, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the GDP and economic output of Estonia?", + "expected": "get_economic_statistics", + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": false + }, + { + "query": "Fetch economic data for Estonia from the statistics office", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.6043, + "rrf_score": 1.0, + "confidence": "high", + "pass": true + }, + { + "query": "What is the unemployment rate in Estonia?", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5585, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me labor and employment statistics for Estonia", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5584, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "How many people are employed in Estonia?", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5152, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch workforce and jobless statistics for Estonia", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5649, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the current weather in Tallinn?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.7829, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me the current weather conditions in Estonia", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.6238, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What is the temperature right now at the Tallinn weather station?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.7113, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the weather forecast for Tallinn tomorrow?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.7875, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me the upcoming weather forecast for Tartu", + "expected": "get_weather_forecast", + "got": "get_current_weather", + "cosine_score": 0.7177, + "rrf_score": 0.5, + "confidence": "high", + "pass": false + }, + { + "query": "What will the weather be like in Estonia next week?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.5939, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Give me a weather forecast for the next few days in Estonia", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.5743, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Who is the Prime Minister of Estonia?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the best restaurant in Tallinn?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Tell me a random fact about Estonia", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the meaning of life?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Book me a flight to London", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Can you translate this text to Estonian?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What are the visa requirements to visit Estonia?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "How do I apply for an Estonian e-Residency?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the history of Tallinn Old Town?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Give me a poem about Estonia", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + } +] \ No newline at end of file diff --git a/tests/api_tool_eval/test-endpoints.json b/tests/api_tool_eval/test-endpoints.json new file mode 100644 index 00000000..39460869 --- /dev/null +++ b/tests/api_tool_eval/test-endpoints.json @@ -0,0 +1,158 @@ +[ + { + "endpointId": "1f9c2a11-3c6e-4a91-8b77-0b2e7c1a1001", + "name": "get_public_holidays", + "description": "Too riiklikud pühad konkreetse riigi kohta etteantud ajavahemikus.", + "url": "https://openholidaysapi.org/PublicHolidays", + "method": "GET", + "params": [ + { "name": "countryIsoCode", "type": "string", "required": true, "description": "Kahetäheline ISO riigikood (nt EE, LV)" }, + { "name": "validFrom", "type": "date", "required": true, "description": "Alguskuupäev (YYYY-MM-DD)" }, + { "name": "validTo", "type": "date", "required": true, "description": "Lõppkuupäev (YYYY-MM-DD)" }, + { "name": "languageIsoCode", "type": "string", "required": false, "description": "Valikuline keelekood (nt ET, EN)" } + ] + }, + { + "endpointId": "1f9c2a11-3c6e-4a91-8b77-0b2e7c1a1002", + "name": "get_school_holidays", + "description": "Too koolivaheaegade andmed konkreetse riigi kohta etteantud ajavahemikus.", + "url": "https://openholidaysapi.org/SchoolHolidays", + "method": "GET", + "params": [ + { "name": "countryIsoCode", "type": "string", "required": true, "description": "Kahetäheline ISO riigikood" }, + { "name": "validFrom", "type": "date", "required": true, "description": "Alguskuupäev (YYYY-MM-DD)" }, + { "name": "validTo", "type": "date", "required": true, "description": "Lõppkuupäev (YYYY-MM-DD)" } + ] + }, + { + "endpointId": "2a7b3c22-9d5f-4b1e-9c44-1d3a9e220003", + "name": "get_electricity_prices", + "description": "Too elektrituruhinnad etteantud ajavahemiku kohta.", + "url": "https://dashboard.elering.ee/api/nps/price", + "method": "GET", + "params": [ + { "name": "start", "type": "datetime", "required": true, "description": "Alguskuup\u00e4ev ja -aeg UTC formaadis (YYYY-MM-DDTHH:MM:SSZ). N\u00e4ide: 2025-01-01T00:00:00Z. Kui kasutaja annab ainult kuup\u00e4eva, kasuta alguseks T00:00:00Z." }, + { "name": "end", "type": "datetime", "required": true, "description": "L\u00f5ppkuup\u00e4ev ja -aeg UTC formaadis (YYYY-MM-DDTHH:MM:SSZ). N\u00e4ide: 2025-01-31T23:59:59Z. Kui kasutaja annab ainult kuup\u00e4eva, kasuta l\u00f5puks T23:59:59Z." } + ] + }, + { + "endpointId": "3b8d4e33-7f6a-4c2d-a911-2c4b8f330004", + "name": "get_vehicle_tax_info", + "description": "Arvuta sõidukimaks registreerimisnumbri alusel.", + "url": "https://avalik.emta.ee/msm-public/v1/vehicle-tax", + "method": "POST", + "params": [ + { "name": "registrationNumber", "type": "string", "required": true, "description": "Sõiduki registreerimisnumber" } + ] + }, + { + "endpointId": "4c9e5f44-1a2b-4d3e-b822-3d5c9f440005", + "name": "get_parliament_votings", + "description": "Too Riigikogu hääletusprotokolid ja otsused.", + "url": "https://api.riigikogu.ee/api/votings", + "method": "GET", + "params": [ + { "name": "startDate", "type": "date", "required": true, "description": "Alguskuupäev (YYYY-MM-DD)" }, + { "name": "endDate", "type": "date", "required": true, "description": "Lõppkuupäev (YYYY-MM-DD)" }, + { "name": "lang", "type": "string", "required": false, "description": "Vastuse keel (ET, EN, RU). Täidetakse automaatselt kasutaja keele põhjal." } + ] + }, + { + "endpointId": "4c9e5f44-1a2b-4d3e-b822-3d5c9f440006", + "name": "get_parliament_participation_stats", + "description": "Too Riigikogu liikmete kohaloleku ja osalemise statistika.", + "url": "https://api.riigikogu.ee/api/statistics/participations/plenary", + "method": "GET", + "params": [ + { "name": "startDate", "type": "date", "required": true, "description": "Alguskuupäev (YYYY-MM-DD)" }, + { "name": "endDate", "type": "date", "required": true, "description": "Lõppkuupäev (YYYY-MM-DD)" }, + { "name": "lang", "type": "string", "required": false, "description": "Vastuse keel (ET, EN, RU). Täidetakse automaatselt kasutaja keele põhjal." } + ] + }, + { + "endpointId": "5d0f6a55-2b3c-4e4f-c933-4e6d0a550007", + "name": "get_initiatives", + "description": "Too kodanike rahvaalgatuste nimekiri.", + "url": "https://rahvaalgatus.ee/initiatives", + "method": "GET", + "params": [ + { "name": "page", "type": "number", "required": false, "description": "Lehekülje number lehekülgede kaupa" } + ] + }, + { + "endpointId": "5d0f6a55-2b3c-4e4f-c933-4e6d0a550008", + "name": "get_initiative_details", + "description": "Too üksikasjalikku teavet konkreetse kodanike rahvaalgatuse kohta. Algatuse ID lisatakse tee osana alusURL-ile (nt /initiatives/123).", + "url": "https://rahvaalgatus.ee/initiatives", + "method": "GET", + "params": [ + { "name": "id", "type": "string", "required": true, "description": "Algatuse kordumatu identifikaator — lisatakse tee osana URL-ile" } + ] + }, + { + "endpointId": "5d0f6a55-2b3c-4e4f-c933-4e6d0a550009", + "name": "get_initiative_events", + "description": "Too kodanike rahvaalgatustega seotud sündmused ja uuendused.", + "url": "https://rahvaalgatus.ee/initiative-events", + "method": "GET", + "params": [] + }, + { + "endpointId": "6e1a7b66-3c4d-5f5a-d044-5f7e1b660010", + "name": "search_address", + "description": "Otsi aadresse või asukohti märksõnapäringu abil.", + "url": "https://inaadress.maaamet.ee/inaadress/gazetteer", + "method": "GET", + "params": [ + { "name": "address", "type": "string", "required": true, "description": "Aadressi või kohanime otsingupäring" } + ] + }, + { + "endpointId": "7f2b8c77-4d5e-6a6b-e155-6a8f2c770011", + "name": "get_population_statistics", + "description": "Too rahvastikustatistika andmed struktureeritud päringu abil.", + "url": "https://andmed.stat.ee/api/v1/en/stat/IA021", + "method": "POST", + "params": [ + { "name": "query", "type": "object", "required": true, "description": "JSON-päringu keha andmestiku filtrite ja mõõtmetega" } + ] + }, + { + "endpointId": "7f2b8c77-4d5e-6a6b-e155-6a8f2c770012", + "name": "get_economic_statistics", + "description": "Too majandusstatistika andmed struktureeritud päringu abil.", + "url": "https://andmed.stat.ee/api/v1/en/stat/LE27", + "method": "POST", + "params": [ + { "name": "query", "type": "object", "required": true, "description": "JSON-päringu keha andmestiku filtrite ja mõõtmetega" } + ] + }, + { + "endpointId": "7f2b8c77-4d5e-6a6b-e155-6a8f2c770013", + "name": "get_labor_statistics", + "description": "Too tööjõu ja tööhõive statistika andmed struktureeritud päringu abil.", + "url": "https://andmed.stat.ee/api/v1/en/stat/TT330", + "method": "POST", + "params": [ + { "name": "query", "type": "object", "required": true, "description": "JSON-päringu keha andmestiku filtrite ja mõõtmetega" } + ] + }, + { + "endpointId": "8a3c9d88-5e6f-7b7c-f266-7b9a3d880014", + "name": "get_current_weather", + "description": "Too praegused ja kombineeritud ilmaandmed.", + "url": "https://publicapi.envir.ee/v1/combinedWeatherData", + "method": "GET", + "params": [ + { "name": "station", "type": "string", "required": false, "description": "Ilmajaama identifikaator" } + ] + }, + { + "endpointId": "8a3c9d88-5e6f-7b7c-f266-7b9a3d880015", + "name": "get_weather_forecast", + "description": "Too ilmaprognoos tulevaste perioodide kohta.", + "url": "https://ilmmicroservice.envir.ee/api/forecasts", + "method": "GET", + "params": [] + } +] \ No newline at end of file diff --git a/tests/api_tool_eval/test-results.json b/tests/api_tool_eval/test-results.json new file mode 100644 index 00000000..6965e2cc --- /dev/null +++ b/tests/api_tool_eval/test-results.json @@ -0,0 +1,1010 @@ +[ + { + "query": "What are the public holidays in Estonia this year?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6777, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "List official public holidays in Estonia for 2025", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6234, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "When are the national public holidays in Estonia?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.5978, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me all public days off in Estonia between January and June", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.5606, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What are the official non-working days in Estonia?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6518, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Millised on Eesti riigip\u00fchad sel aastal?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6896, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Millal on Eestis ametlikud riigip\u00fchad 2025. aastal?", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.6969, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita mulle Eesti riigip\u00fchi jaanuarist juunini", + "expected": "get_public_holidays", + "got": "get_public_holidays", + "cosine_score": 0.5552, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "When are the school holidays in Estonia?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.6782, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What are the school term breaks in Estonia this year?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.646, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "When does school summer break start in Estonia in 2025?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.6939, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Show me school holiday periods in Estonia for spring 2025", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.5997, + "rrf_score": 0.666667, + "confidence": "medium", + "pass": true + }, + { + "query": "Millal on Eesti koolide koolivaheajad?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.7407, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Millal algab koolide suvepuhkus Eestis 2025. aastal?", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.6872, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita mulle kevadise koolivaheaja aegu Eestis", + "expected": "get_school_holidays", + "got": "get_school_holidays", + "cosine_score": 0.6195, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What are the electricity market prices in Estonia?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6332, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me electricity prices for the past week in Estonia", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6837, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Fetch energy market prices between January and March 2025", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.432, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What was the electricity spot price in Estonia last month?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6716, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Millised on elektrituruhinnad Eestis?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6218, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita elektrihindu eelmise n\u00e4dala kohta Eestis", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.7159, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Mis oli elektrihind Eestis eelmisel kuul?", + "expected": "get_electricity_prices", + "got": "get_electricity_prices", + "cosine_score": 0.6676, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Calculate vehicle tax for registration number 123ABC", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.653, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "How much is the vehicle tax for my car with plate 456XYZ?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.6316, + "rrf_score": 1.0, + "confidence": "high", + "pass": true + }, + { + "query": "What is the car tax based on my registration number?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.5134, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "Arvuta s\u00f5idukimaks registreerimisnumbri 123ABC alusel", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.8383, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Kui suur on minu auto maks numbrim\u00e4rgi 456XYZ j\u00e4rgi?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.824, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Mis on mootors\u00f5idukimaks minu auto registreerimisnumbri alusel?", + "expected": "get_vehicle_tax_info", + "got": "get_vehicle_tax_info", + "cosine_score": 0.6378, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "Show me the latest parliament voting records in Estonia", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.6093, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What did the Riigikogu vote on recently?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.7187, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Retrieve parliamentary voting decisions from the Estonian parliament", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.6544, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What laws were voted on in the Estonian parliament?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.5905, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita Riigikogu viimaseid h\u00e4\u00e4letusprotokolle", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.6515, + "rrf_score": 0.642857, + "confidence": "medium", + "pass": true + }, + { + "query": "Mille \u00fcle h\u00e4\u00e4letas Riigikogu hiljuti?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.7685, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "Milliseid seadusi h\u00e4\u00e4letati Eesti parlamendis?", + "expected": "get_parliament_votings", + "got": "get_parliament_votings", + "cosine_score": 0.6675, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "How often do Estonian parliament members attend sessions?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.5247, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me parliament member attendance statistics", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.5584, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Which MPs have the best attendance record in the Riigikogu?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.7052, + "rrf_score": 0.75, + "confidence": "high", + "pass": true + }, + { + "query": "Kui tihti osalevad Riigikogu liikmed istungitel?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.6471, + "rrf_score": 0.7, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita Riigikogu liikmete kohaloleku statistikat", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.7678, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Millistel saadikutel on Riigikogu parim kohalolekurekord?", + "expected": "get_parliament_participation_stats", + "got": "get_parliament_participation_stats", + "cosine_score": 0.631, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Show me a list of active citizen initiatives in Estonia", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.6595, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What public initiatives are currently available?", + "expected": "get_initiatives", + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": false + }, + { + "query": "List all citizen initiatives on rahvaalgatus.ee", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.6632, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita mulle aktiivsete kodanike algatuste nimekirja Eestis", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.6846, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "Millised rahvaalgatused on praegu saadaval?", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.7545, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Loetle k\u00f5ik algatused rahvaalgatus.ee lehel", + "expected": "get_initiatives", + "got": "get_initiatives", + "cosine_score": 0.5893, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Get details about citizen initiative with ID abc123", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.5825, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me more information about a specific public initiative", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.4889, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch the details of initiative ID xyz789", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.5759, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Too andmed kodanike algatuse ID abc123 kohta", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.5205, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita mulle \u00fcksikasju konkreetse rahvaalgatuse kohta", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.6357, + "rrf_score": 0.666667, + "confidence": "medium", + "pass": true + }, + { + "query": "Too algatuse ID xyz789 \u00fcksikasjad", + "expected": "get_initiative_details", + "got": "get_initiative_details", + "cosine_score": 0.4259, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "What are the latest events related to citizen initiatives?", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.5164, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me updates and events for public initiatives", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.5302, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Are there any new events for citizen initiatives in Estonia?", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.5956, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Millised on viimased kodanike algatustega seotud s\u00fcndmused?", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.7619, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita rahvaalgatuste uuendusi ja s\u00fcndmusi", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.7217, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Kas Eestis on uusi s\u00fcndmusi kodanike algatuste kohta?", + "expected": "get_initiative_events", + "got": "get_initiative_events", + "cosine_score": 0.6662, + "rrf_score": 0.642857, + "confidence": "high", + "pass": true + }, + { + "query": "Search for the address Viru 4 in Tallinn", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.6338, + "rrf_score": 0.833333, + "confidence": "high", + "pass": true + }, + { + "query": "Find the location of Kadriorg Park in Tallinn", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.4442, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Look up an address or place name in Estonia", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.5577, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Search for a street address in Tartu", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.6088, + "rrf_score": 1.0, + "confidence": "high", + "pass": true + }, + { + "query": "Otsi aadressi Viru 4 Tallinnas", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.7736, + "rrf_score": 0.75, + "confidence": "high", + "pass": true + }, + { + "query": "Leia Kadrioru pargi asukoht Tallinnas", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.4523, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Otsi t\u00e4navaaadress Tartus", + "expected": "search_address", + "got": "search_address", + "cosine_score": 0.6469, + "rrf_score": 0.75, + "confidence": "high", + "pass": true + }, + { + "query": "What is the population of Estonia?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.4761, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me population statistics data for Estonia", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.4796, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch demographic statistics for Estonia", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5258, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the population breakdown by age group in Estonia?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5764, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Milline on Eesti rahvaarv?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5107, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita mulle Eesti rahvastikustatistika andmeid", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5269, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "Milline on Eesti rahvastiku jaotus vanuser\u00fchmade kaupa?", + "expected": "get_population_statistics", + "got": "get_population_statistics", + "cosine_score": 0.5995, + "rrf_score": 0.666667, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me economic statistics for Estonia", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.5219, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the GDP and economic output of Estonia?", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.4211, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch economic data for Estonia from the statistics office", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.5652, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita mulle Eesti majandusstatistikat", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.5082, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Mis on Eesti SKP ja majanduslik toodang?", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.5431, + "rrf_score": 0.833333, + "confidence": "medium", + "pass": true + }, + { + "query": "Too majandusandmed Eesti statistikaametist", + "expected": "get_economic_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.4921, + "rrf_score": 0.7, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the unemployment rate in Estonia?", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5905, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Show me labor and employment statistics for Estonia", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5207, + "rrf_score": 0.333333, + "confidence": "medium", + "pass": true + }, + { + "query": "How many people are employed in Estonia?", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.4705, + "rrf_score": 0.333333, + "confidence": "medium", + "pass": true + }, + { + "query": "Fetch workforce and jobless statistics for Estonia", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.5505, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Milline on t\u00f6\u00f6tuse m\u00e4\u00e4r Eestis?", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.6245, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita mulle Eesti t\u00f6\u00f6j\u00f5u ja t\u00f6\u00f6h\u00f5ive statistikat", + "expected": "get_labor_statistics", + "got": "get_labor_statistics", + "cosine_score": 0.556, + "rrf_score": 0.666667, + "confidence": "medium", + "pass": true + }, + { + "query": "Kui palju inimesi t\u00f6\u00f6tab Eestis?", + "expected": "get_labor_statistics", + "got": "get_economic_statistics", + "cosine_score": 0.465, + "rrf_score": 0.5, + "confidence": "medium", + "pass": false + }, + { + "query": "What is the current weather in Tallinn?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.7102, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me the current weather conditions in Estonia", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.5532, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "What is the temperature right now at the Tallinn weather station?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.7024, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Milline on praegune ilm Tallinnas?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.8075, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "N\u00e4ita mulle praeguseid ilmastikuolusid Eestis", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.5993, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Mis on praegune temperatuur Tallinna ilmajaamas?", + "expected": "get_current_weather", + "got": "get_current_weather", + "cosine_score": 0.8916, + "rrf_score": 0.533333, + "confidence": "high", + "pass": true + }, + { + "query": "What is the weather forecast for Tallinn tomorrow?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.7875, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Show me the upcoming weather forecast for Tartu", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.6849, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "What will the weather be like in Estonia next week?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.6047, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Give me a weather forecast for the next few days in Estonia", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.5652, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Milline on ilmaprognoos Tallinnas homme?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.8612, + "rrf_score": 0.666667, + "confidence": "high", + "pass": true + }, + { + "query": "N\u00e4ita mulle Tartu eelseisvat ilmaprognoosi", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.77, + "rrf_score": 0.5, + "confidence": "medium", + "pass": true + }, + { + "query": "Milline on ilm Eestis j\u00e4rgmisel n\u00e4dalal?", + "expected": "get_weather_forecast", + "got": "get_weather_forecast", + "cosine_score": 0.6499, + "rrf_score": 0.5, + "confidence": "high", + "pass": true + }, + { + "query": "Who is the Prime Minister of Estonia?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the best restaurant in Tallinn?", + "expected": null, + "got": "search_address", + "cosine_score": 0.4746, + "rrf_score": 1.0, + "confidence": "medium", + "pass": true + }, + { + "query": "Tell me a random fact about Estonia", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the meaning of life?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Book me a flight to London", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Can you translate this text to Estonian?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What are the visa requirements to visit Estonia?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "How do I apply for an Estonian e-Residency?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "What is the history of Tallinn Old Town?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Give me a poem about Estonia", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Kes on Eesti peaminister?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Mis on parim restoran Tallinnas?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + }, + { + "query": "Mis on elu m\u00f5te?", + "expected": null, + "got": null, + "cosine_score": null, + "rrf_score": null, + "confidence": null, + "pass": true + } +] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..e26acfc9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Pytest configuration for test discovery and imports.""" + +import sys +from pathlib import Path + +# Add the project root to Python path so tests can import from src +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +# Add src directory to Python path for direct module imports +src_dir = project_root / "src" +sys.path.insert(0, str(src_dir)) + +# Add models directory (sibling to src) for backward compatibility +models_dir = project_root / "models" +if models_dir.exists(): + sys.path.insert(0, str(models_dir.parent)) diff --git a/tests/data/classification_test_queries.json b/tests/data/classification_test_queries.json new file mode 100644 index 00000000..28bb4814 --- /dev/null +++ b/tests/data/classification_test_queries.json @@ -0,0 +1,266 @@ +[ + { + "query": "Mitu töötajat on ettevõttes Bolt?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_employees", + "language": "et" + }, + { + "query": "Kui palju inimesi töötab firmas Tallink?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_employees", + "language": "et" + }, + { + "query": "Mis on Swedbanki töötajate arv?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_employees", + "language": "et" + }, + { + "query": "Kui palju töötajaid on ettevõttel Eesti Energia?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_employees", + "language": "et" + }, + { + "query": "Mis on ettevõtte aasta käive?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_revenue", + "language": "et" + }, + { + "query": "Kui suur on firma käive?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_revenue", + "language": "et" + }, + { + "query": "Kui palju maksis ettevõte tööjõumakse?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_workforce_taxes", + "language": "et" + }, + { + "query": "Kui palju maksis ettevõte riiklikke makse?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_national_taxes", + "language": "et" + }, + { + "query": "Kes on firma tegelikud kasusaajad?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_beneficiaries", + "language": "et" + }, + { + "query": "Mis on ettevõtte kontaktandmed?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_companies_contactdetails", + "language": "et" + }, + { + "query": "Millal on selle aasta koolivaheajad?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_school_holiday", + "language": "et" + }, + { + "query": "Mis olid viimaste NBA mängude tulemused?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_nba_results", + "language": "et" + }, + { + "query": "Mis on euro ja dollari vahetuskurss?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_exchange_rate", + "language": "et" + }, + { + "query": "Mis on viis viimast avalikku algatust?", + "expected_category": "SERVICE", + "expected_service_id": "common_teenus_citizien_initiative", + "language": "et" + }, + { + "query": "Mis on hetkel populaarsemad rahvaalgatused?", + "expected_category": "SERVICE", + "expected_service_id": "common_teenus_citizien_initiative_popular", + "language": "et" + }, + { + "query": "Kui palju kasvasid tarbija hinnad eelmisel aastal?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_CPI", + "language": "et" + }, + { + "query": "Mis ilm on Tallinnas?", + "expected_category": "SERVICE", + "expected_service_id": "common_teenus_ilm", + "language": "et" + }, + { + "query": "Kas Narvas on ilus ilm?", + "expected_category": "SERVICE", + "expected_service_id": "common_teenus_ilm", + "language": "et" + }, + { + "query": "Mis on ööpäeva odavaim elektri hind?", + "expected_category": "SERVICE", + "expected_service_id": "common_teenus_nordpool2", + "language": "et" + }, + { + "query": "Kus leida diiselkütuse hinnaindeks?", + "expected_category": "SERVICE", + "expected_service_id": "common_service_CPI", + "language": "et" + }, + { + "query": "Miks ID-kaart ei tööta e-teenustes, kuigi DigiDoc4 loeb kaardi andmed sisse?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas Safari brauseris vahemälu tühjendada, kui ID-kaardiga sisselogimine ei tööta?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas kontrollida ja lubada ID-kaardi jaoks vajalikke laiendusi Firefoxi brauseris?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida teha, kui Firefoxis puudub või ei tööta Web eID või PKCS11 loader laiendus?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas anda dokumendile digiallkiri DigiDoc4 abil Windows 10 või Windows 11 arvutis?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas allkirjastada mitu faili korraga DigiDoc4-s mobiil-ID abil?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida teha, kui mu telefon koos Mobiil-IDga on kadunud või varastatud?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas peatada ja hiljem taastada Mobiil-ID sertifikaadid?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas allkirjastada dokument DigiDoc rakenduses mobiil-ID abil samm-sammult?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida pean kontrollima enne, kui annan DigiDocis dokumendile mobiil-IDga digiallkirja ja kuidas see pärast salvestada?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas siseneda e-teenustesse mobiil-ID abil samm-sammult?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida teha, kui mobiil-IDga sisselogimisel kontrollkoodid ei kattu või küsitakse ootamatult PIN-koodi?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida pean tegema, kui mu ID-kaart või mobiiltelefon (Mobiil-ID) on kadunud või varastatud?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas peatada ja hiljem taastada ID-kaardi ja Mobiil-ID sertifikaadid?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas siseneda e-teenustesse mobiil-ID abil samm-sammult?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida teha, kui mobiil-IDga sisselogimisel kontrollkood ei kattu või küsitakse ootamatult PIN-koodi?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kust saab alla laadida ja paigaldada ametliku ID-kaardi tarkvara (DigiDoc4 ja Web eID) Windowsi, macOS-i ja mobiili jaoks?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Milliseid rakendusi on vaja ID-kaardi ja digiallkirja kasutamiseks Androidi ja iPhone’i telefonis?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Milleks on ID-kaardi sertifikaadid ja mis vahe on PIN1- ja PIN2-sertifikaadil?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Mida teha, kui mu ID-kaart või mobiil-ID on kadunud ja kuidas sertifikaate peatada?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas allkirjastada dokumente mobiil-ID abil RIA DigiDoc rakenduses samm-sammult?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas salvestada ja jagada DigiDocis allkirjastatud dokumendiümbrik ning lisada korraga mitu faili?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas anda digiallkiri dokumentidele DigiDoc4 rakenduses mobiil-ID abil Windows 10 või 11 arvutis?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + }, + { + "query": "Kuidas allkirjastada mitu faili korraga DigiDoc4-s ja kontrollida mobiil-ID kontrollkoodi?", + "expected_category": "RAG", + "expected_service_id": "", + "language": "et" + } +] diff --git a/tests/deepeval_tests/red_team_tests.py b/tests/deepeval_tests/red_team_tests.py index 04139139..50f50582 100644 --- a/tests/deepeval_tests/red_team_tests.py +++ b/tests/deepeval_tests/red_team_tests.py @@ -41,7 +41,7 @@ class ComprehensiveResultCollector: """Collects comprehensive test results during execution.""" - def __init__(self): + def __init__(self) -> None: self.results: dict[str, Any] = { "total_tests": 0, "passed_tests": 0, @@ -124,7 +124,7 @@ def calculate_vulnerability_scores(self): vulnerability_scores[vuln_name]["passed"] += 1 # Calculate scores - for vuln_name, counts in vulnerability_scores.items(): + for counts in vulnerability_scores.values(): counts["score"] = ( counts["passed"] / counts["total"] if counts["total"] > 0 else 0.0 ) @@ -167,7 +167,7 @@ class TestRAGSystemRedTeaming: """Comprehensive red teaming test suite - all tests in one place.""" @classmethod - def setup_class(cls): + def setup_class(cls) -> None: """Setup comprehensive test class with all attacks and vulnerabilities.""" print("Setting up comprehensive RAG security testing...") @@ -358,7 +358,7 @@ def _test_attack_category( attack_type: str, failed_assertions: List[str], language: str = "en", - ): + ) -> None: """Test a specific category of attacks against vulnerabilities.""" print(f"\n--- {category_name} ---") category_start = datetime.datetime.now() diff --git a/tests/deepeval_tests/standard_tests.py b/tests/deepeval_tests/standard_tests.py index a30e2845..6d8c9bd3 100644 --- a/tests/deepeval_tests/standard_tests.py +++ b/tests/deepeval_tests/standard_tests.py @@ -20,7 +20,7 @@ class StandardResultCollector: """Collects test results during execution for report generation.""" - def __init__(self): + def __init__(self) -> None: self.results = { "total_tests": 0, "passed_tests": 0, @@ -111,7 +111,7 @@ class TestRAGSystem: """Test suite for RAG system evaluation using DeepEval metrics.""" @classmethod - def setup_class(cls): + def setup_class(cls) -> None: """Setup test class with metrics and test data.""" print("Setting up TestRAGSystem...") diff --git a/tests/integration_tests/conftest.py b/tests/integration_tests/conftest.py index 333771a2..1f6bd647 100644 --- a/tests/integration_tests/conftest.py +++ b/tests/integration_tests/conftest.py @@ -23,7 +23,7 @@ def __init__( token_path: Path = Path("test-vault/agent-out/token"), mount_point: str = "secret", timeout: int = 10, - ): + ) -> None: self.vault_url = vault_url self.token_path = token_path self.mount_point = mount_point @@ -76,7 +76,7 @@ def get_secret(self, path: str) -> dict: class RAGStackTestContainers: """Manages test containers for RAG stack including Vault, Qdrant, Langfuse, and LLM orchestration service""" - def __init__(self, compose_file_name: str = "docker-compose-test.yml"): + def __init__(self, compose_file_name: str = "docker-compose-test.yml") -> None: self.project_root = Path(__file__).parent.parent.parent self.compose_file_path = self.project_root / compose_file_name self.compose: Optional[DockerCompose] = None @@ -491,7 +491,7 @@ def _run_database_migration(self) -> None: "liquibase/liquibase:4.33", "--defaultsFile=/liquibase/changelog/liquibase.properties", "--changelog-file=master.yml", - "--url=jdbc:postgresql://rag_search_db:5432/rag-search?user=postgres", + "--url=jdbc:postgresql://rag-search-db:5432/rag-search?user=postgres", "--password=dbadmin", "update", ], @@ -541,7 +541,7 @@ def _run_database_migration(self) -> None: "liquibase/liquibase:4.33", "--defaultsFile=/liquibase/changelog/liquibase.properties", "--changelog-file=master.yml", - "--url=jdbc:postgresql://rag_search_db:5432/rag-search?user=postgres", + "--url=jdbc:postgresql://rag-search-db:5432/rag-search?user=postgres", "--password=dbadmin", "update", ], @@ -1012,12 +1012,12 @@ def orchestration_client(rag_stack: RAGStackTestContainers) -> Any: session.headers.update( {"Content-Type": "application/json", "Accept": "application/json"} ) - setattr(session, "base_url", rag_stack.get_orchestration_service_url()) + session.base_url = rag_stack.get_orchestration_service_url() return session @pytest.fixture(scope="session") -def minio_client(rag_stack): +def minio_client(rag_stack: RAGStackTestContainers) -> Minio: """Create MinIO client connected to test instance.""" client = Minio( "localhost:9000", @@ -1029,7 +1029,7 @@ def minio_client(rag_stack): @pytest.fixture(scope="session") -def qdrant_client(rag_stack): +def qdrant_client(rag_stack: RAGStackTestContainers) -> QdrantClient: """Create Qdrant client connected to test instance.""" client = QdrantClient(host="localhost", port=6333) return client @@ -1150,7 +1150,7 @@ def qdrant_collections(): @pytest.fixture(scope="session") -def llm_orchestration_url(rag_stack): +def llm_orchestration_url(rag_stack: RAGStackTestContainers) -> str: """ URL for the LLM orchestration service. @@ -1161,7 +1161,7 @@ def llm_orchestration_url(rag_stack): @pytest.fixture(scope="session") -def vault_client(rag_stack): +def vault_client(rag_stack: RAGStackTestContainers): """Create Vault client connected to test instance using root token (dev mode).""" vault_url = rag_stack.get_vault_url() @@ -1191,7 +1191,7 @@ def get_secret(self, path: str, mount_point: str = "secret") -> dict: @pytest.fixture(scope="session") -def postgres_client(rag_stack): +def postgres_client(rag_stack: RAGStackTestContainers): """Create PostgreSQL client connected to test database.""" import psycopg2 @@ -1226,7 +1226,7 @@ def setup_agency_sync_schema(postgres_client): try: cursor.execute(""" CREATE TABLE IF NOT EXISTS public.agency_sync ( - agency_id VARCHAR(255) PRIMARY KEY, + id VARCHAR(255) PRIMARY KEY, agency_data_hash VARCHAR(255), data_url TEXT, created_at TIMESTAMP DEFAULT NOW(), @@ -1272,7 +1272,7 @@ def ruuter_private_client(rag_stack: RAGStackTestContainers): {"Content-Type": "application/json", "Accept": "application/json"} ) # Ruuter Private runs on port 8088 in test environment - setattr(session, "base_url", "http://localhost:8088") + session.base_url = "http://localhost:8088" return session @@ -1296,7 +1296,7 @@ def ruuter_public_client(rag_stack: RAGStackTestContainers): {"Content-Type": "application/json", "Accept": "application/json"} ) # Ruuter Public runs on port 8088 in test environment - setattr(session, "base_url", "http://localhost:8086") + session.base_url = "http://localhost:8086" return session diff --git a/tests/integration_tests/test_indexing.py b/tests/integration_tests/test_indexing.py index b134e5be..08c14f5e 100644 --- a/tests/integration_tests/test_indexing.py +++ b/tests/integration_tests/test_indexing.py @@ -212,9 +212,9 @@ async def test_indexing_pipeline_e2e( # Insert agency_sync record with initial hash cursor.execute( """ - INSERT INTO public.agency_sync (agency_id, agency_data_hash, data_url) + INSERT INTO public.agency_sync (id, agency_data_hash, data_url) VALUES (%s, %s, %s) - ON CONFLICT (agency_id) DO UPDATE + ON CONFLICT (id) DO UPDATE SET agency_data_hash = EXCLUDED.agency_data_hash """, ("test_agency", "initial_hash_000", ""), diff --git a/tests/mocks/dummy_llm_orchestrator.py b/tests/mocks/dummy_llm_orchestrator.py index 12332f92..db4ebf0f 100644 --- a/tests/mocks/dummy_llm_orchestrator.py +++ b/tests/mocks/dummy_llm_orchestrator.py @@ -9,7 +9,7 @@ class MockQdrantRetriever: """Mock implementation of Qdrant vector database with predefined test data.""" - def __init__(self): + def __init__(self) -> None: self.knowledge_base: Dict[str, List[str]] = { "pension": [ "In 2021, the pension will become more flexible. People will be able to choose the most suitable time for their retirement, partially withdraw their pension or stop payment of their pension if they wish, in effect creating their own personal pension plan.", @@ -170,7 +170,7 @@ def retrieve(self, query: str, top_k: int = 3) -> List[str]: class DummyLLMOrchestrator: """Main orchestrator that handles the complete RAG pipeline.""" - def __init__(self, provider: str = "anthropic"): + def __init__(self, provider: str = "anthropic") -> None: self.provider = provider self.retriever = MockQdrantRetriever() diff --git a/tests/test_agentic_loop.py b/tests/test_agentic_loop.py new file mode 100644 index 00000000..80226e53 --- /dev/null +++ b/tests/test_agentic_loop.py @@ -0,0 +1,781 @@ +"""Unit tests for the AgenticLoop module.""" + +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tool_classifier.agentic_loop import AgenticLoop +from tool_classifier.enums import AgenticLoopStatus +from tool_classifier.param_extractor import ParamExtractionResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_CHAT_ID = "test-chat-1" + +_SCHEMA_TWO_REQUIRED: List[Dict[str, Any]] = [ + { + "name": "countryIsoCode", + "type": "string", + "required": True, + "description": "Country", + }, + { + "name": "validFrom", + "type": "date", + "required": True, + "description": "Start date", + }, +] + +_SCHEMA_ONE_OPTIONAL: List[Dict[str, Any]] = [ + { + "name": "limit", + "type": "integer", + "required": False, + "description": "Max results", + }, +] + +_SCHEMA_EMPTY: List[Dict[str, Any]] = [] + +_HISTORY: List[Dict[str, Any]] = [ + {"authorRole": "user", "message": "Get me public holidays"}, + {"authorRole": "bot", "message": "Which country?"}, +] + + +def _make_session_store_mock() -> AsyncMock: + """Return an AsyncMock standing in for APIToolSessionStore.""" + mock = AsyncMock() + mock.update = AsyncMock(return_value=None) + return mock + + +def _make_extractor_mock(result: ParamExtractionResult) -> MagicMock: + """Return a MagicMock whose __call__() returns the given ParamExtractionResult.""" + mock = MagicMock(return_value=result) + return mock + + +def _make_loop( + extractor_mock: MagicMock, + session_store_mock: AsyncMock | None = None, +) -> AgenticLoop: + """Convenience factory that wires up AgenticLoop with mocked dependencies.""" + return AgenticLoop( + session_store=session_store_mock or _make_session_store_mock(), + param_extractor=extractor_mock, + ) + + +def _extraction( + extracted: Dict[str, Any], + missing: List[str], + question: str, +) -> ParamExtractionResult: + return ParamExtractionResult( + extracted_params=extracted, + missing_required=missing, + clarifying_question=question, + ) + + +# --------------------------------------------------------------------------- +# Turn limit guard +# --------------------------------------------------------------------------- + + +class TestMaxTurnsReached: + @pytest.mark.asyncio + async def test_max_turns_reached_when_turn_count_equals_max(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hello", + conversation_history=_HISTORY, + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=5, + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + extractor_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_max_turns_reached_when_turn_count_exceeds_max(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hello", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"validFrom": "2026-01-01"}, + turn_count=10, + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + assert result.collected_params == {"validFrom": "2026-01-01"} + extractor_mock.assert_not_called() + + @pytest.mark.asyncio + async def test_turn_count_incremented_on_max_turns(self) -> None: + loop = _make_loop(_make_extractor_mock(_extraction({}, [], "none"))) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hi", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=5, + max_turns=5, + ) + + assert result.turn_count == 6 + + +# --------------------------------------------------------------------------- +# COMPLETED status +# --------------------------------------------------------------------------- + + +class TestCompleted: + @pytest.mark.asyncio + async def test_completed_when_extractor_finds_last_param(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"validFrom": "2026-01-01"}, [], "none") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="January 2026", + conversation_history=_HISTORY, + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE"}, + turn_count=2, + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.COMPLETED + assert result.collected_params == { + "countryIsoCode": "EE", + "validFrom": "2026-01-01", + } + assert result.clarifying_question == "" + + @pytest.mark.asyncio + async def test_completed_when_no_required_params_in_schema(self) -> None: + extractor_mock = _make_extractor_mock(_extraction({}, [], "none")) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="list all", + conversation_history=[], + params_schema=_SCHEMA_ONE_OPTIONAL, + collected_params={}, + turn_count=0, + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.COMPLETED + + @pytest.mark.asyncio + async def test_completed_with_empty_schema(self) -> None: + extractor_mock = _make_extractor_mock(_extraction({}, [], "none")) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="go", + conversation_history=[], + params_schema=_SCHEMA_EMPTY, + collected_params={}, + turn_count=0, + ) + + assert result.status == AgenticLoopStatus.COMPLETED + + +# --------------------------------------------------------------------------- +# NEEDS_INPUT status +# --------------------------------------------------------------------------- + + +class TestNeedsInput: + @pytest.mark.asyncio + async def test_needs_input_when_params_still_missing(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction( + {"countryIsoCode": "EE"}, + ["validFrom"], + "From which date?", + ) + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=_HISTORY, + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + assert result.clarifying_question == "From which date?" + + @pytest.mark.asyncio + async def test_needs_input_when_extractor_finds_nothing(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="I want holidays", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + assert result.clarifying_question == "Which country and date?" + + +# --------------------------------------------------------------------------- +# Param merging +# --------------------------------------------------------------------------- + + +class TestParamMerging: + @pytest.mark.asyncio + async def test_new_params_merged_with_prior_params(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"validFrom": "2026-01-01"}, [], "none") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="January 2026", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE"}, + turn_count=1, + ) + + assert result.collected_params == { + "countryIsoCode": "EE", + "validFrom": "2026-01-01", + } + + @pytest.mark.asyncio + async def test_prior_params_not_overwritten_by_extractor(self) -> None: + # Extractor tries to update countryIsoCode, but prior value is authoritative + extractor_mock = _make_extractor_mock( + _extraction({"countryIsoCode": "LV"}, [], "none") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Latvia", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE", "validFrom": "2026-01-01"}, + turn_count=2, + ) + + # Prior "EE" must not be overwritten by newly "extracted" "LV" + assert result.collected_params["countryIsoCode"] == "EE" + assert result.status == AgenticLoopStatus.COMPLETED + + @pytest.mark.asyncio + async def test_empty_extraction_preserves_prior_params(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["validFrom"], "From which date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="not sure", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE"}, + turn_count=0, + ) + + assert result.collected_params == {"countryIsoCode": "EE"} + + +# --------------------------------------------------------------------------- +# Turn count +# --------------------------------------------------------------------------- + + +class TestTurnCount: + @pytest.mark.asyncio + async def test_turn_count_incremented_on_needs_input(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hello", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + ) + + assert result.turn_count == 4 + + @pytest.mark.asyncio + async def test_turn_count_incremented_on_completed(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"countryIsoCode": "EE", "validFrom": "2026-01-01"}, [], "none") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia, January 2026", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + ) + + assert result.turn_count == 1 + + +# --------------------------------------------------------------------------- +# Session persistence +# --------------------------------------------------------------------------- + + +class TestSessionPersistence: + @pytest.mark.asyncio + async def test_session_saved_on_needs_input(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"countryIsoCode": "EE"}, ["validFrom"], "From which date?") + ) + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + ) + + store_mock.update.assert_awaited_once_with( + _CHAT_ID, + collected_params={"countryIsoCode": "EE"}, + turn_count=1, + awaiting_continuation=False, + ) + + @pytest.mark.asyncio + async def test_session_saved_on_completed(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"validFrom": "2026-01-01"}, [], "none") + ) + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="January 2026", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE"}, + turn_count=2, + ) + + store_mock.update.assert_awaited_once_with( + _CHAT_ID, + collected_params={"countryIsoCode": "EE", "validFrom": "2026-01-01"}, + turn_count=3, + awaiting_continuation=False, + ) + + @pytest.mark.asyncio + async def test_session_not_saved_on_max_turns(self) -> None: + extractor_mock = _make_extractor_mock(_extraction({}, [], "none")) + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hi", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=5, + max_turns=5, + ) + + store_mock.update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_session_not_saved_on_extractor_error(self) -> None: + extractor_mock = MagicMock() + extractor_mock.side_effect = RuntimeError("LLM timeout") + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=1, + ) + + store_mock.update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_session_save_failure_does_not_raise(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + store_mock = _make_session_store_mock() + store_mock.update.side_effect = RuntimeError("Redis unavailable") + loop = _make_loop(extractor_mock, store_mock) + + # Should not raise even if Redis save fails + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hi", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + @pytest.mark.asyncio + async def test_extractor_exception_returns_needs_input(self) -> None: + extractor_mock = MagicMock() + extractor_mock.side_effect = RuntimeError("LLM timeout") + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"validFrom": "2026-01-01"}, + turn_count=1, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + assert result.clarifying_question == "" + # Prior collected_params preserved on error + assert result.collected_params == {"validFrom": "2026-01-01"} + assert result.turn_count == 2 + + @pytest.mark.asyncio + async def test_extractor_called_with_correct_arguments(self) -> None: + extractor_mock = _make_extractor_mock( + _extraction({"countryIsoCode": "EE"}, ["validFrom"], "From which date?") + ) + + with patch("tool_classifier.agentic_loop.asyncio.to_thread") as mock_to_thread: + # Make to_thread call the function synchronously so we can inspect args + async def fake_to_thread(fn: Any, *args: Any, **kwargs: Any) -> Any: + return fn(*args, **kwargs) + + mock_to_thread.side_effect = fake_to_thread + + loop = _make_loop(extractor_mock) + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=_HISTORY, + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"validFrom": "2026-01-01"}, + turn_count=1, + ) + + extractor_mock.assert_called_once_with( + "Estonia", + _SCHEMA_TWO_REQUIRED, + _HISTORY, + {"validFrom": "2026-01-01"}, + ) + + +# --------------------------------------------------------------------------- +# Continuation decision (turn-3 yes/no prompt) +# --------------------------------------------------------------------------- + + +class TestContinuationDecision: + @pytest.mark.asyncio + async def test_continuation_question_asked_at_threshold(self) -> None: + """AWAITING_CONTINUATION_DECISION is returned on exactly continuation_turn=3.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="I don't know", + conversation_history=_HISTORY, + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=2, # updated_turn_count == 3 == continuation_turn + max_turns=5, + ) + + assert result.status == AgenticLoopStatus.AWAITING_CONTINUATION_DECISION + assert result.clarifying_question != "" + assert result.turn_count == 3 + + @pytest.mark.asyncio + async def test_continuation_not_asked_before_threshold(self) -> None: + """Normal NEEDS_INPUT before the continuation threshold.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hmm", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, # updated_turn_count == 1, below threshold + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + @pytest.mark.asyncio + async def test_continuation_not_asked_after_threshold(self) -> None: + """Normal NEEDS_INPUT after the continuation threshold (user already continued).""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="still not sure", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, # updated_turn_count == 4, past threshold + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + @pytest.mark.asyncio + async def test_user_yes_resets_flag_and_continues(self) -> None: + """When awaiting_continuation=True and user says 'yes', loop continues.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="yes", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + max_turns=5, + awaiting_continuation=True, + ) + + # Should continue normally — NEEDS_INPUT (params still missing after "yes") + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + @pytest.mark.asyncio + async def test_user_estonian_yes_continues(self) -> None: + """Estonian 'jah' is recognised as an affirmative response.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Mis riik?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="jah", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + max_turns=5, + awaiting_continuation=True, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + @pytest.mark.asyncio + async def test_user_no_returns_max_turns_reached(self) -> None: + """When awaiting_continuation=True and user says 'no', RAG fallback is triggered.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="no", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + max_turns=5, + awaiting_continuation=True, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + assert result.clarifying_question == "" + + @pytest.mark.asyncio + async def test_user_estonian_no_exits(self) -> None: + """Estonian 'ei' triggers RAG fallback.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Mis riik?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="ei", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + awaiting_continuation=True, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + + @pytest.mark.asyncio + async def test_ambiguous_response_exits(self) -> None: + """An ambiguous response while awaiting continuation defaults to exit.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="I'm not sure what to do", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + awaiting_continuation=True, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + + @pytest.mark.asyncio + async def test_exit_preserves_collected_params(self) -> None: + """Collected params are returned unchanged when the user chooses to exit.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["validFrom"], "Which date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="no", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "EE"}, + turn_count=3, + awaiting_continuation=True, + ) + + assert result.status == AgenticLoopStatus.MAX_TURNS_REACHED + assert result.collected_params == {"countryIsoCode": "EE"} + + @pytest.mark.asyncio + async def test_session_saved_with_awaiting_continuation_true(self) -> None: + """Session is persisted with awaiting_continuation=True at the threshold.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="no idea", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=2, + ) + + store_mock.update.assert_awaited_once_with( + _CHAT_ID, + collected_params={}, + turn_count=3, + awaiting_continuation=True, + ) + + @pytest.mark.asyncio + async def test_session_not_saved_on_user_exit(self) -> None: + """Session is NOT saved when the user chooses to exit (caller deletes it).""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode"], "Which country?") + ) + store_mock = _make_session_store_mock() + loop = _make_loop(extractor_mock, store_mock) + + await loop.run_turn( + chat_id=_CHAT_ID, + user_message="no", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=3, + awaiting_continuation=True, + ) + + store_mock.update.assert_not_awaited() diff --git a/tests/test_api_caller.py b/tests/test_api_caller.py new file mode 100644 index 00000000..2c4d9a4c --- /dev/null +++ b/tests/test_api_caller.py @@ -0,0 +1,548 @@ +"""Unit tests for the APICaller and CircuitBreaker modules.""" + +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from src.tool_classifier.api_caller import APICaller, CircuitBreaker +from src.tool_classifier.constants import ( + CB_STATE_CLOSED, + CB_STATE_HALF_OPEN, + CB_STATE_OPEN, + CIRCUIT_BREAKER_OPEN_MESSAGES, + SERVICE_TIMEOUT_MESSAGES, + SERVICE_UNAVAILABLE_MESSAGES, +) +from src.tool_classifier.models import APICallResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_URL = "http://api.example.com/endpoint" +_URL_B = "http://api.other.com/resource" +_GET_PARAMS = {"country": "EE", "year": "2024"} +_POST_PARAMS = {"firstName": "Test", "lastName": "User"} + + +def _make_response( + status_code: int, + json_data: dict | None = None, + text_data: str = "", +) -> MagicMock: + """Build a mock httpx.Response.""" + mock_response = MagicMock() + mock_response.status_code = status_code + if json_data is not None: + mock_response.json.return_value = json_data + else: + mock_response.json.side_effect = json.JSONDecodeError("No JSON", "", 0) + mock_response.text = text_data + return mock_response + + +def _make_client( + response: MagicMock | None = None, + side_effect: Exception | None = None, +) -> AsyncMock: + """Build a mock httpx.AsyncClient usable as an async context manager.""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + if side_effect is not None: + mock_client.get.side_effect = side_effect + mock_client.post.side_effect = side_effect + elif response is not None: + mock_client.get.return_value = response + mock_client.post.return_value = response + return mock_client + + +# --------------------------------------------------------------------------- +# GET request behaviour +# --------------------------------------------------------------------------- + + +class TestGetRequest: + @pytest.mark.asyncio + async def test_get_sends_params_as_query_parameters(self) -> None: + response = _make_response(200, json_data={"holidays": []}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", _GET_PARAMS) + + mock_client.get.assert_called_once_with(_URL, params=_GET_PARAMS) + mock_client.post.assert_not_called() + assert result.success is True + assert result.status_code == 200 + assert result.response_data == {"holidays": []} + assert result.error is None + + @pytest.mark.asyncio + async def test_get_method_case_insensitive(self) -> None: + response = _make_response(200, json_data={"ok": True}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "get", _GET_PARAMS) + + mock_client.get.assert_called_once() + assert result.success is True + + +# --------------------------------------------------------------------------- +# POST request behaviour +# --------------------------------------------------------------------------- + + +class TestPostRequest: + @pytest.mark.asyncio + async def test_post_sends_params_as_json_body(self) -> None: + response = _make_response(200, json_data={"created": True}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "POST", _POST_PARAMS) + + mock_client.post.assert_called_once_with(_URL, json=_POST_PARAMS) + mock_client.get.assert_not_called() + assert result.success is True + assert result.response_data == {"created": True} + + @pytest.mark.asyncio + async def test_post_method_case_insensitive(self) -> None: + response = _make_response(201, json_data={"id": 42}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "post", _POST_PARAMS) + + mock_client.post.assert_called_once() + assert result.success is True + assert result.status_code == 201 + + +# --------------------------------------------------------------------------- +# Successful response handling +# --------------------------------------------------------------------------- + + +class TestSuccessResponse: + @pytest.mark.asyncio + async def test_non_json_response_returns_raw_text(self) -> None: + response = _make_response(200, text_data="plain text response") + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}) + + assert result.success is True + assert result.response_data == "plain text response" + + @pytest.mark.asyncio + async def test_uses_custom_timeout_over_instance_default(self) -> None: + response = _make_response(200, json_data={}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ) as mock_class: + await APICaller(timeout=5).call(_URL, "GET", {}, timeout=30) + + mock_class.assert_called_once_with(timeout=30, follow_redirects=True) + + @pytest.mark.asyncio + async def test_uses_instance_default_timeout_when_no_override(self) -> None: + response = _make_response(200, json_data={}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ) as mock_class: + await APICaller(timeout=15).call(_URL, "GET", {}) + + mock_class.assert_called_once_with(timeout=15, follow_redirects=True) + + @pytest.mark.asyncio + async def test_empty_params_dict_accepted(self) -> None: + response = _make_response(200, json_data={"result": "ok"}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}) + + assert result.success is True + + +# --------------------------------------------------------------------------- +# 4xx client errors +# --------------------------------------------------------------------------- + + +class TestClientErrors: + @pytest.mark.asyncio + async def test_400_returns_error_with_body(self) -> None: + error_body = {"error": "Invalid date format", "code": "INVALID_PARAM"} + response = _make_response(400, json_data=error_body) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "POST", {"date": "bad"}) + + assert result.success is False + assert result.status_code == 400 + assert result.is_client_error is True + assert result.is_server_error is False + assert result.response_data == error_body + assert result.error is not None + assert "Invalid date format" in result.error or str(error_body) in result.error + + @pytest.mark.asyncio + async def test_404_with_plain_text_body(self) -> None: + response = _make_response(404, text_data="Not Found") + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {"id": "999"}) + + assert result.success is False + assert result.status_code == 404 + assert result.is_client_error is True + assert result.response_data == "Not Found" + assert result.error == "Not Found" + + @pytest.mark.asyncio + async def test_422_with_plain_text_body(self) -> None: + response = _make_response(422, text_data="Unprocessable Entity") + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "POST", {}) + + assert result.success is False + assert result.status_code == 422 + assert result.response_data == "Unprocessable Entity" + + @pytest.mark.asyncio + async def test_4xx_does_not_trip_circuit_breaker(self) -> None: + """4xx client errors must not increment the circuit breaker failure count.""" + caller = APICaller(failure_threshold=3) + response = _make_response(400, json_data={"error": "bad param"}) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + for _ in range(5): + await caller.call(_URL, "POST", {}) + + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_CLOSED + + +# --------------------------------------------------------------------------- +# 5xx server errors +# --------------------------------------------------------------------------- + + +class TestServerErrors: + @pytest.mark.asyncio + async def test_500_returns_friendly_message_default_language(self) -> None: + response = _make_response(500) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}) + + assert result.success is False + assert result.status_code == 500 + assert result.is_server_error is True + assert result.is_client_error is False + assert result.error == SERVICE_UNAVAILABLE_MESSAGES["et"] + + @pytest.mark.asyncio + async def test_503_returns_localized_message(self) -> None: + response = _make_response(503) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}, language="en") + + assert result.success is False + assert result.error == SERVICE_UNAVAILABLE_MESSAGES["en"] + + @pytest.mark.asyncio + async def test_5xx_trips_circuit_breaker(self) -> None: + caller = APICaller(failure_threshold=3) + response = _make_response(500) + mock_client = _make_client(response) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + for _ in range(3): + await caller.call(_URL, "GET", {}) + + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_OPEN + + +# --------------------------------------------------------------------------- +# Timeout errors +# --------------------------------------------------------------------------- + + +class TestTimeoutErrors: + @pytest.mark.asyncio + async def test_read_timeout_returns_friendly_message(self) -> None: + mock_client = _make_client(side_effect=httpx.ReadTimeout("timed out")) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}) + + assert result.success is False + assert result.status_code == 0 + assert result.error == SERVICE_TIMEOUT_MESSAGES["et"] + + @pytest.mark.asyncio + async def test_connect_timeout_returns_localized_message(self) -> None: + mock_client = _make_client(side_effect=httpx.ConnectTimeout("timeout")) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}, language="en") + + assert result.error == SERVICE_TIMEOUT_MESSAGES["en"] + + @pytest.mark.asyncio + async def test_timeout_trips_circuit_breaker(self) -> None: + caller = APICaller(failure_threshold=2) + mock_client = _make_client(side_effect=httpx.ReadTimeout("timeout")) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + await caller.call(_URL, "GET", {}) + await caller.call(_URL, "GET", {}) + + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_OPEN + + +# --------------------------------------------------------------------------- +# Network errors +# --------------------------------------------------------------------------- + + +class TestNetworkErrors: + @pytest.mark.asyncio + async def test_connect_error_returns_friendly_message(self) -> None: + mock_client = _make_client(side_effect=httpx.ConnectError("connection refused")) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + result = await APICaller().call(_URL, "GET", {}) + + assert result.success is False + assert result.status_code == 0 + assert result.error == SERVICE_TIMEOUT_MESSAGES["et"] + + @pytest.mark.asyncio + async def test_network_error_trips_circuit_breaker(self) -> None: + caller = APICaller(failure_threshold=2) + mock_client = _make_client(side_effect=httpx.ConnectError("refused")) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ): + await caller.call(_URL, "GET", {}) + await caller.call(_URL, "GET", {}) + + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_OPEN + + +# --------------------------------------------------------------------------- +# CircuitBreaker unit tests +# --------------------------------------------------------------------------- + + +class TestCircuitBreaker: + def test_open_after_threshold_failures(self) -> None: + cb = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0) + + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_CLOSED + + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_CLOSED + + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_OPEN + + def test_open_breaker_blocks_execution(self) -> None: + cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=60.0) + cb.record_failure(_URL) + assert cb.can_execute(_URL) is False + + def test_transitions_to_half_open_after_cooldown(self) -> None: + cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=30.0) + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_OPEN + + future_time = time.time() + 31 + with patch("tool_classifier.api_caller.time.time", return_value=future_time): + assert cb.can_execute(_URL) is True + assert cb.get_state(_URL) == CB_STATE_HALF_OPEN + + def test_half_open_to_closed_on_success(self) -> None: + cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=0.0) + cb.record_failure(_URL) + cb.can_execute(_URL) # Cooldown elapsed → HALF_OPEN + assert cb.get_state(_URL) == CB_STATE_HALF_OPEN + + cb.record_success(_URL) + assert cb.get_state(_URL) == CB_STATE_CLOSED + + def test_half_open_to_open_on_failure(self) -> None: + cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=0.0) + cb.record_failure(_URL) + cb.can_execute(_URL) # Cooldown elapsed → HALF_OPEN + assert cb.get_state(_URL) == CB_STATE_HALF_OPEN + + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_OPEN + + def test_independent_breakers_per_url(self) -> None: + cb = CircuitBreaker(failure_threshold=1, cooldown_seconds=60.0) + cb.record_failure(_URL) + + assert cb.get_state(_URL) == CB_STATE_OPEN + assert cb.get_state(_URL_B) == CB_STATE_CLOSED + + def test_success_resets_failure_count(self) -> None: + cb = CircuitBreaker(failure_threshold=3) + cb.record_failure(_URL) + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_CLOSED + + cb.record_success(_URL) + + # The failure count was reset; two more failures should NOT open the breaker + cb.record_failure(_URL) + cb.record_failure(_URL) + assert cb.get_state(_URL) == CB_STATE_CLOSED + + @pytest.mark.asyncio + async def test_open_circuit_rejects_without_making_http_call(self) -> None: + """Verify no HTTP request is issued when the circuit breaker is OPEN.""" + caller = APICaller(failure_threshold=1) + mock_client = _make_client(_make_response(500)) + + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", return_value=mock_client + ) as mock_class: + await caller.call(_URL, "GET", {}) # Trip the breaker + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_OPEN + mock_class.reset_mock() + + result = await caller.call(_URL, "GET", {}) # Should be rejected + + mock_class.assert_not_called() + assert result.success is False + assert result.error == CIRCUIT_BREAKER_OPEN_MESSAGES["et"] + + @pytest.mark.asyncio + async def test_independent_breakers_per_url_on_full_caller(self) -> None: + caller = APICaller(failure_threshold=1) + + # Fail url_a once → open its breaker + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", + return_value=_make_client(_make_response(500)), + ): + await caller.call(_URL, "GET", {}) + assert caller._circuit_breaker.get_state(_URL) == CB_STATE_OPEN + + # url_b should still work + with patch( + "tool_classifier.api_caller.httpx.AsyncClient", + return_value=_make_client(_make_response(200, json_data={"data": "ok"})), + ): + result_b = await caller.call(_URL_B, "GET", {}) + assert result_b.success is True + assert caller._circuit_breaker.get_state(_URL_B) == CB_STATE_CLOSED + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + @pytest.mark.asyncio + async def test_invalid_method_put_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="Unsupported HTTP method"): + await APICaller().call(_URL, "PUT", {}) + + @pytest.mark.asyncio + async def test_invalid_method_delete_raises_value_error(self) -> None: + with pytest.raises(ValueError, match="Unsupported HTTP method"): + await APICaller().call(_URL, "DELETE", {}) + + def test_api_call_result_is_client_error_true_for_4xx(self) -> None: + result = APICallResult( + success=False, status_code=404, response_data="", error="Not found" + ) + assert result.is_client_error is True + assert result.is_server_error is False + + def test_api_call_result_is_server_error_true_for_5xx(self) -> None: + result = APICallResult( + success=False, status_code=503, response_data="", error="error" + ) + assert result.is_server_error is True + assert result.is_client_error is False + + def test_api_call_result_neither_client_nor_server_error_on_success(self) -> None: + result = APICallResult( + success=True, status_code=200, response_data={"ok": True}, error=None + ) + assert result.is_client_error is False + assert result.is_server_error is False + + def test_api_call_result_status_code_zero_is_not_client_or_server_error( + self, + ) -> None: + result = APICallResult( + success=False, status_code=0, response_data="", error="timeout" + ) + assert result.is_client_error is False + assert result.is_server_error is False diff --git a/tests/test_api_response_formatter.py b/tests/test_api_response_formatter.py new file mode 100644 index 00000000..a0ba12c1 --- /dev/null +++ b/tests/test_api_response_formatter.py @@ -0,0 +1,354 @@ +"""Unit tests for APIResponseFormatterModule — DSPy JSON-to-natural-language formatter.""" + +import json +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import dspy +import pytest + +from src.tool_classifier.api_response_formatter import APIResponseFormatterModule + + +@pytest.fixture(autouse=True) +def mock_dspy_lm() -> Generator[MagicMock, None, None]: + """Mock DSPy LM to prevent 'No LM is loaded' errors during tests.""" + mock_lm = MagicMock() + mock_lm.history = [] + with patch("dspy.settings") as mock_settings: + mock_settings.lm = mock_lm + dspy.configure(lm=mock_lm) + yield mock_lm + + +def _make_mock_result(formatted_answer: str) -> MagicMock: + """Build a mock DSPy Predict result with the formatted_answer attribute.""" + mock_result = MagicMock() + mock_result.formatted_answer = formatted_answer + return mock_result + + +# --------------------------------------------------------------------------- +# Initialisation +# --------------------------------------------------------------------------- + + +class TestAPIResponseFormatterModuleInit: + """APIResponseFormatterModule should initialise with the correct attributes.""" + + def test_module_has_formatter_attribute(self) -> None: + module = APIResponseFormatterModule() + assert hasattr(module, "formatter") + + def test_formatter_is_dspy_predict(self) -> None: + module = APIResponseFormatterModule() + assert isinstance(module.formatter, dspy.Predict) + + +# --------------------------------------------------------------------------- +# Basic formatting +# --------------------------------------------------------------------------- + + +class TestSimpleFormatting: + """forward() should return the LLM's formatted answer for valid JSON responses.""" + + def test_format_simple_json_response(self) -> None: + """A valid JSON response should be passed to LLM and its answer returned.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result( + "The public holidays are: New Year, Independence Day." + ) + api_response = ( + '{"holidays": [{"name": "New Year"}, {"name": "Independence Day"}]}' + ) + + with patch.object(module, "formatter", return_value=mock_result): + result = module.forward( + user_query="What are the public holidays?", + api_response=api_response, + endpoint_description="Get public holidays for a country", + ) + + assert result == "The public holidays are: New Year, Independence Day." + + def test_predictor_called_with_correct_fields(self) -> None: + """forward() must call formatter with all three expected keyword arguments.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Answer") + api_response = '{"status": "ok"}' + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + module.forward( + user_query="Is the service running?", + api_response=api_response, + endpoint_description="Get service status", + ) + + mock_formatter.assert_called_once() + call_kwargs = mock_formatter.call_args.kwargs + assert "user_query" in call_kwargs + assert "api_response" in call_kwargs + assert "endpoint_description" in call_kwargs + assert "response_language" in call_kwargs + + def test_dict_input_converted_to_string(self) -> None: + """A dict api_response should be JSON-serialised before passing to the LLM.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("The count is 42.") + api_response_dict = {"count": 42, "items": ["a", "b"]} + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="How many items?", + api_response=api_response_dict, + endpoint_description="Get item count", + ) + + assert result == "The count is 42." + call_kwargs = mock_formatter.call_args.kwargs + assert isinstance(call_kwargs["api_response"], str) + parsed = json.loads(call_kwargs["api_response"]) + assert parsed == api_response_dict + + +# --------------------------------------------------------------------------- +# Empty response handling +# --------------------------------------------------------------------------- + + +class TestEmptyResponseHandling: + """forward() should annotate empty responses so the LLM handles them gracefully.""" + + def test_format_empty_list_response(self) -> None: + """An empty list '[]' should be annotated and passed to the LLM.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("No results were found for your query.") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="What holidays exist?", + api_response="[]", + endpoint_description="Get public holidays", + ) + + assert result == "No results were found for your query." + call_kwargs = mock_formatter.call_args.kwargs + assert "EMPTY RESPONSE" in call_kwargs["api_response"] + + def test_format_empty_dict_response(self) -> None: + """An empty dict '{}' should be annotated before passing to the LLM.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("There is no data available.") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="Show me the data", + api_response="{}", + endpoint_description="Fetch data", + ) + + assert result == "There is no data available." + call_kwargs = mock_formatter.call_args.kwargs + assert "EMPTY RESPONSE" in call_kwargs["api_response"] + + def test_format_null_response(self) -> None: + """A 'null' JSON response should be annotated before passing to the LLM.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("No information was returned.") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="What is the result?", + api_response="null", + endpoint_description="Get result", + ) + + assert result == "No information was returned." + call_kwargs = mock_formatter.call_args.kwargs + assert "EMPTY RESPONSE" in call_kwargs["api_response"] + + +# --------------------------------------------------------------------------- +# Error response handling +# --------------------------------------------------------------------------- + + +class TestErrorResponseHandling: + """forward() should pass API error responses to the LLM without modification.""" + + def test_format_error_response(self) -> None: + """An error JSON response should be forwarded to the LLM as-is.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Sorry, the requested resource was not found.") + api_response = '{"error": "not found", "code": 404}' + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="Get my record", + api_response=api_response, + endpoint_description="Get record by ID", + ) + + assert result == "Sorry, the requested resource was not found." + # Error response is not empty — must NOT have the EMPTY RESPONSE annotation + call_kwargs = mock_formatter.call_args.kwargs + assert "EMPTY RESPONSE" not in call_kwargs["api_response"] + + +# --------------------------------------------------------------------------- +# Large response truncation +# --------------------------------------------------------------------------- + + +class TestLargeResponseTruncation: + """forward() should truncate responses that exceed the item limit before calling the LLM.""" + + def test_format_large_response_truncation(self) -> None: + """A list with more than 500 items should be truncated and annotated with a NOTE.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Here is a summary of the large dataset.") + large_response = [{"id": i, "name": f"item_{i}"} for i in range(600)] + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + result = module.forward( + user_query="List all items", + api_response=large_response, + endpoint_description="Get all items", + ) + + assert result == "Here is a summary of the large dataset." + call_kwargs = mock_formatter.call_args.kwargs + assert "NOTE" in call_kwargs["api_response"] + assert "500" in call_kwargs["api_response"] + assert "600" in call_kwargs["api_response"] + + def test_list_within_limit_not_truncated(self) -> None: + """A list with exactly 500 items should NOT be truncated.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Here are the items.") + exact_response = [{"id": i} for i in range(500)] + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + module.forward( + user_query="List items", + api_response=exact_response, + endpoint_description="Get items", + ) + + call_kwargs = mock_formatter.call_args.kwargs + assert "NOTE" not in call_kwargs["api_response"] + + +# --------------------------------------------------------------------------- +# Language handling +# --------------------------------------------------------------------------- + + +class TestLanguageHandling: + """forward() must map detected_language codes to display names for the LLM.""" + + @pytest.mark.parametrize( + "language_code, expected_display", + [ + ("en", "English"), + ("et", "Estonian"), + ("ru", "Russian"), + ], + ) + def test_detected_language_mapped_to_display_name( + self, language_code: str, expected_display: str + ) -> None: + """Each ISO code must be forwarded to the LLM as its full display name.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Answer") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + module.forward( + user_query="Test query", + api_response='{"key": "value"}', + endpoint_description="Test endpoint", + detected_language=language_code, + ) + + call_kwargs = mock_formatter.call_args.kwargs + assert call_kwargs["response_language"] == expected_display + + def test_unknown_language_code_defaults_to_english(self) -> None: + """An unrecognised language code must fall back to 'English'.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Answer") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + module.forward( + user_query="Test", + api_response='{"key": "value"}', + endpoint_description="Test", + detected_language="fr", # unsupported code + ) + + call_kwargs = mock_formatter.call_args.kwargs + assert call_kwargs["response_language"] == "English" + + def test_default_language_is_english(self) -> None: + """When detected_language is omitted, response_language must be 'English'.""" + module = APIResponseFormatterModule() + mock_result = _make_mock_result("Answer") + + with patch.object( + module, "formatter", return_value=mock_result + ) as mock_formatter: + module.forward( + user_query="Test", + api_response='{"key": "value"}', + endpoint_description="Test", + # detected_language not passed — should default to 'en' + ) + + call_kwargs = mock_formatter.call_args.kwargs + assert call_kwargs["response_language"] == "English" + + +# --------------------------------------------------------------------------- +# Resilience / error handling +# --------------------------------------------------------------------------- + + +class TestResilienceHandling: + """forward() should return a safe fallback message if the LLM call fails.""" + + def test_forward_handles_prediction_error(self) -> None: + """If the DSPy predictor raises an exception, a safe fallback string is returned.""" + module = APIResponseFormatterModule() + + with patch.object( + module, "formatter", side_effect=RuntimeError("LLM unavailable") + ): + result = module.forward( + user_query="What are the holidays?", + api_response='{"holidays": []}', + endpoint_description="Get public holidays", + ) + + assert isinstance(result, str) + assert len(result) > 0 diff --git a/tests/test_api_tool_session_store.py b/tests/test_api_tool_session_store.py new file mode 100644 index 00000000..eb54a2fe --- /dev/null +++ b/tests/test_api_tool_session_store.py @@ -0,0 +1,412 @@ +"""Unit tests for APIToolSessionStore and session models.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import ValidationError + +from src.models.session_models import APIToolSession +from src.utils.api_tool_session_store import ( + APIToolSessionStore, + _key, + require_session_store, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_session(**kwargs) -> APIToolSession: + defaults = { + "chat_id": "test-chat-1", + "state": "collecting_params", + "selected_endpoint": {"url": "https://example.com", "method": "GET"}, + "collected_params": {"countryIsoCode": "EE"}, + "turn_count": 1, + "max_turns": 5, + } + defaults.update(kwargs) + return APIToolSession(**defaults) + + +def _make_redis_mock() -> AsyncMock: + mock = AsyncMock() + mock.get = AsyncMock(return_value=None) + mock.set = AsyncMock() + mock.delete = AsyncMock() + mock.exists = AsyncMock(return_value=0) + mock.ping = AsyncMock(return_value=True) + return mock + + +# --------------------------------------------------------------------------- +# Session model tests +# --------------------------------------------------------------------------- + + +class TestAPIToolSession: + def test_defaults(self): + session = APIToolSession(chat_id="abc", state="collecting_params") + assert session.collected_params == {} + assert session.turn_count == 0 + assert session.max_turns == 5 + assert session.selected_endpoint is None + + def test_serialization_roundtrip(self): + session = _make_session() + json_str = session.model_dump_json() + restored = APIToolSession.model_validate_json(json_str) + assert restored == session + + def test_turn_count_must_be_non_negative(self): + with pytest.raises(ValidationError): + APIToolSession(chat_id="x", state="s", turn_count=-1) + + def test_max_turns_must_be_at_least_one(self): + with pytest.raises(ValidationError): + APIToolSession(chat_id="x", state="s", max_turns=0) + + +# --------------------------------------------------------------------------- +# APIToolSessionStore.get +# --------------------------------------------------------------------------- + + +class TestSessionStoreGet: + @pytest.mark.asyncio + async def test_get_returns_none_when_key_missing(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.get = AsyncMock(return_value=None) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.get("missing-chat") + + assert result is None + + @pytest.mark.asyncio + async def test_get_returns_session_when_key_exists(self): + store = APIToolSessionStore() + session = _make_session() + redis_mock = _make_redis_mock() + redis_mock.get = AsyncMock(return_value=session.model_dump_json()) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.get(session.chat_id) + + assert result is not None + assert result.chat_id == session.chat_id + assert result.state == session.state + + @pytest.mark.asyncio + async def test_get_returns_none_when_redis_unavailable(self): + store = APIToolSessionStore() + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=None + ): + result = await store.get("any-chat") + + assert result is None + + +# --------------------------------------------------------------------------- +# APIToolSessionStore.save +# --------------------------------------------------------------------------- + + +class TestSessionStoreSave: + @pytest.mark.asyncio + async def test_save_calls_redis_set_with_correct_key_and_ttl(self): + store = APIToolSessionStore() + session = _make_session() + redis_mock = _make_redis_mock() + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + await store.save(session) + + redis_mock.set.assert_awaited_once() + call_args = redis_mock.set.call_args + assert call_args[0][0] == _key(session.chat_id) + assert call_args[1]["ex"] == 1800 + + @pytest.mark.asyncio + async def test_save_skips_when_redis_unavailable(self): + store = APIToolSessionStore() + session = _make_session() + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=None + ): + # Should not raise + await store.save(session) + + +# --------------------------------------------------------------------------- +# APIToolSessionStore.update (partial merge) +# --------------------------------------------------------------------------- + + +class TestSessionStoreUpdate: + @pytest.mark.asyncio + async def test_update_rejects_unknown_fields(self): + store = APIToolSessionStore() + + with pytest.raises(ValueError, match="Unknown session fields"): + await store.update("chat-1", sate="ready") # typo: sate != state + + @pytest.mark.asyncio + async def test_update_merges_fields_and_resets_ttl(self): + store = APIToolSessionStore() + original = _make_session(turn_count=1, collected_params={"a": "1"}) + + pipe_mock = AsyncMock() + pipe_mock.get = AsyncMock(return_value=original.model_dump_json()) + pipe_mock.watch = AsyncMock() + pipe_mock.unwatch = AsyncMock() + pipe_mock.multi = MagicMock() + pipe_mock.set = MagicMock() + pipe_mock.execute = AsyncMock(return_value=[True]) + pipe_mock.__aenter__ = AsyncMock(return_value=pipe_mock) + pipe_mock.__aexit__ = AsyncMock(return_value=False) + + redis_mock = _make_redis_mock() + redis_mock.pipeline = MagicMock(return_value=pipe_mock) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.update( + original.chat_id, + turn_count=2, + collected_params={"a": "1", "b": "2"}, + ) + + assert result is not None + assert result.turn_count == 2 + assert result.collected_params == {"a": "1", "b": "2"} + # Unchanged field preserved + assert result.state == original.state + + @pytest.mark.asyncio + async def test_update_returns_none_when_session_missing(self): + store = APIToolSessionStore() + + pipe_mock = AsyncMock() + pipe_mock.get = AsyncMock(return_value=None) + pipe_mock.watch = AsyncMock() + pipe_mock.unwatch = AsyncMock() + pipe_mock.__aenter__ = AsyncMock(return_value=pipe_mock) + pipe_mock.__aexit__ = AsyncMock(return_value=False) + + redis_mock = _make_redis_mock() + redis_mock.pipeline = MagicMock(return_value=pipe_mock) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.update("ghost-chat", turn_count=3) + + assert result is None + + @pytest.mark.asyncio + async def test_update_resets_ttl(self): + store = APIToolSessionStore() + session = _make_session() + + pipe_mock = AsyncMock() + pipe_mock.get = AsyncMock(return_value=session.model_dump_json()) + pipe_mock.watch = AsyncMock() + pipe_mock.unwatch = AsyncMock() + pipe_mock.multi = MagicMock() + pipe_mock.set = MagicMock() + pipe_mock.execute = AsyncMock(return_value=[True]) + pipe_mock.__aenter__ = AsyncMock(return_value=pipe_mock) + pipe_mock.__aexit__ = AsyncMock(return_value=False) + + redis_mock = _make_redis_mock() + redis_mock.pipeline = MagicMock(return_value=pipe_mock) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + await store.update(session.chat_id, state="ready") + + # pipe.set() should have been called with ex=1800 + pipe_mock.set.assert_called_once() + set_call = pipe_mock.set.call_args + assert set_call[1]["ex"] == 1800 + + +# --------------------------------------------------------------------------- +# APIToolSessionStore.delete +# --------------------------------------------------------------------------- + + +class TestSessionStoreDelete: + @pytest.mark.asyncio + async def test_delete_calls_redis_delete(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + await store.delete("chat-to-delete") + + redis_mock.delete.assert_awaited_once_with(_key("chat-to-delete")) + + @pytest.mark.asyncio + async def test_delete_skips_when_redis_unavailable(self): + store = APIToolSessionStore() + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=None + ): + await store.delete("any-chat") # Should not raise + + +# --------------------------------------------------------------------------- +# APIToolSessionStore.exists +# --------------------------------------------------------------------------- + + +class TestSessionStoreExists: + @pytest.mark.asyncio + async def test_exists_returns_true_when_key_present(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.exists = AsyncMock(return_value=1) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.exists("chat-123") + + assert result is True + + @pytest.mark.asyncio + async def test_exists_returns_false_when_key_absent(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.exists = AsyncMock(return_value=0) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.exists("chat-123") + + assert result is False + + @pytest.mark.asyncio + async def test_exists_returns_false_when_redis_unavailable(self): + store = APIToolSessionStore() + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=None + ): + result = await store.exists("any-chat") + + assert result is False + + +# --------------------------------------------------------------------------- +# Graceful error handling +# --------------------------------------------------------------------------- + + +class TestSessionStoreErrorHandling: + @pytest.mark.asyncio + async def test_get_returns_none_on_redis_error(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.get = AsyncMock(side_effect=ConnectionError("timeout")) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.get("chat-xyz") + + assert result is None + + @pytest.mark.asyncio + async def test_save_does_not_raise_on_redis_error(self): + store = APIToolSessionStore() + session = _make_session() + redis_mock = _make_redis_mock() + redis_mock.set = AsyncMock(side_effect=ConnectionError("timeout")) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + await store.save(session) # Should not raise + + @pytest.mark.asyncio + async def test_delete_does_not_raise_on_redis_error(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.delete = AsyncMock(side_effect=ConnectionError("timeout")) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + await store.delete("chat-xyz") # Should not raise + + @pytest.mark.asyncio + async def test_exists_returns_false_on_redis_error(self): + store = APIToolSessionStore() + redis_mock = _make_redis_mock() + redis_mock.exists = AsyncMock(side_effect=ConnectionError("timeout")) + + with patch( + "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + ): + result = await store.exists("chat-xyz") + + assert result is False + + +# --------------------------------------------------------------------------- +# require_session_store dependency +# --------------------------------------------------------------------------- + + +class TestRequireSessionStore: + def test_returns_store_when_available(self): + store = APIToolSessionStore() + mock_request = MagicMock() + mock_request.app.state.session_store = store + mock_request.url.path = "/api-tool/invoke" + + result = require_session_store(mock_request) + assert result is store + + def test_raises_503_when_store_is_none(self): + from fastapi import HTTPException + + mock_request = MagicMock() + mock_request.app.state.session_store = None + mock_request.url.path = "/api-tool/invoke" + + with pytest.raises(HTTPException) as exc_info: + require_session_store(mock_request) + + assert exc_info.value.status_code == 503 + + def test_raises_503_when_state_attr_missing(self): + from fastapi import HTTPException + + mock_request = MagicMock(spec=["app", "url"]) + mock_request.app = MagicMock(spec=["state"]) + mock_request.app.state = MagicMock(spec=[]) # no session_store attr + mock_request.url.path = "/api-tool/invoke" + + with pytest.raises(HTTPException) as exc_info: + require_session_store(mock_request) + + assert exc_info.value.status_code == 503 diff --git a/tests/test_context_analyzer.py b/tests/test_context_analyzer.py new file mode 100644 index 00000000..094b8a47 --- /dev/null +++ b/tests/test_context_analyzer.py @@ -0,0 +1,979 @@ +"""Unit tests for context analyzer - greeting detection and context analysis.""" + +import pytest +from collections.abc import Generator +from unittest.mock import MagicMock, patch +import json +import dspy + +from src.tool_classifier.context_analyzer import ( + ContextAnalyzer, +) +from src.tool_classifier.greeting_constants import get_greeting_response + + +@pytest.fixture(autouse=True) +def mock_dspy_lm() -> Generator[MagicMock, None, None]: + """Mock DSPy LM to prevent 'No LM is loaded' errors.""" + mock_lm = MagicMock() + mock_lm.history = [] + with patch("dspy.settings") as mock_settings: + mock_settings.lm = mock_lm + # Configure DSPy with mock LM + dspy.configure(lm=mock_lm) + yield mock_lm + + +class TestContextAnalyzerInit: + """Test ContextAnalyzer initialization.""" + + def test_init_creates_analyzer(self) -> None: + """ContextAnalyzer should initialize with LLM manager.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + assert analyzer.llm_manager is llm_manager + assert analyzer._module is None + assert analyzer._summary_module is None + assert analyzer._summary_analysis_module is None + + +class TestConversationHistoryFormatting: + """Test conversation history formatting.""" + + def test_format_empty_history(self) -> None: + """Empty history should return empty JSON array.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + result = analyzer._format_conversation_history([]) + + assert result == "[]" + + def test_format_single_turn(self) -> None: + """Single conversation turn should be formatted correctly.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + history = [ + { + "authorRole": "user", + "message": "Hello", + "timestamp": "2024-01-01T12:00:00", + } + ] + + result = analyzer._format_conversation_history(history) + parsed = json.loads(result) + + assert len(parsed) == 1 + assert parsed[0]["role"] == "user" + assert parsed[0]["message"] == "Hello" + + def test_format_multiple_turns(self) -> None: + """Multiple conversation turns should be formatted correctly.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + history = [ + { + "authorRole": "user", + "message": "What is tax?", + "timestamp": "2024-01-01T12:00:00", + }, + { + "authorRole": "bot", + "message": "Tax is a mandatory financial charge.", + "timestamp": "2024-01-01T12:00:01", + }, + { + "authorRole": "user", + "message": "Thank you", + "timestamp": "2024-01-01T12:00:02", + }, + ] + + result = analyzer._format_conversation_history(history) + parsed = json.loads(result) + + assert len(parsed) == 3 + assert parsed[0]["role"] == "user" + assert parsed[1]["role"] == "bot" + assert parsed[2]["role"] == "user" + + def test_format_truncates_to_max_turns(self) -> None: + """History should be truncated to last 10 turns.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Create 15 turns + history = [ + { + "authorRole": "user" if i % 2 == 0 else "bot", + "message": f"Message {i}", + "timestamp": f"2024-01-01T12:00:{i:02d}", + } + for i in range(15) + ] + + result = analyzer._format_conversation_history(history, max_turns=10) + parsed = json.loads(result) + + assert len(parsed) == 10 + # Should have last 10 turns (indices 5-14) + assert parsed[0]["message"] == "Message 5" + assert parsed[-1]["message"] == "Message 14" + + +class TestGreetingDetection: + """Test greeting detection functionality.""" + + @pytest.mark.asyncio + async def test_detect_estonian_greeting(self) -> None: + """Should detect Estonian greeting 'Tere' and generate response.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Mock DSPy module response + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": True, + "can_answer_from_context": False, + "answer": "Tere! Kuidas ma saan sind aidata?", + "reasoning": "User said hello in Estonian", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, cost_dict = await analyzer.analyze_context( + query="Tere!", + conversation_history=[], + language="et", + ) + + assert result.is_greeting is True + assert result.can_answer_from_context is False + assert "Tere" in result.answer + assert cost_dict["total_cost"] == 0.001 + + @pytest.mark.asyncio + async def test_detect_english_greeting(self) -> None: + """Should detect English greeting 'Hello' and generate response.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Mock DSPy module response + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": True, + "can_answer_from_context": False, + "answer": "Hello! How can I help you?", + "reasoning": "User said hello in English", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, cost_dict = await analyzer.analyze_context( + query="Hello!", + conversation_history=[], + language="en", + ) + + assert result.is_greeting is True + assert "Hello" in result.answer or "hello" in result.answer.lower() + + @pytest.mark.asyncio + async def test_detect_goodbye(self) -> None: + """Should detect goodbye greeting.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": True, + "can_answer_from_context": False, + "answer": "Goodbye! Have a great day!", + "reasoning": "User said goodbye", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="Bye!", + conversation_history=[], + language="en", + ) + + assert result.is_greeting is True + + @pytest.mark.asyncio + async def test_detect_thanks(self) -> None: + """Should detect thank you greeting.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": True, + "can_answer_from_context": False, + "answer": "You're welcome! Feel free to ask if you have more questions.", + "reasoning": "User said thank you", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="Thank you!", + conversation_history=[], + language="en", + ) + + assert result.is_greeting is True + + +class TestContextBasedAnswering: + """Test context-based question answering.""" + + @pytest.mark.asyncio + async def test_answer_from_conversation_history(self) -> None: + """Should extract answer from conversation history when query references it.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + history = [ + { + "authorRole": "user", + "message": "What is the tax rate?", + "timestamp": "2024-01-01T12:00:00", + }, + { + "authorRole": "bot", + "message": "The tax rate is 20%.", + "timestamp": "2024-01-01T12:00:01", + }, + ] + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": True, + "answer": "I mentioned that the tax rate is 20%.", + "reasoning": "User is asking about previously mentioned tax rate", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.002, + "total_tokens": 100, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What was the rate you mentioned?", + conversation_history=history, + language="en", + ) + + assert result.is_greeting is False + assert result.can_answer_from_context is True + assert result.answer is not None + assert "20%" in result.answer + + @pytest.mark.asyncio + async def test_cannot_answer_from_context(self) -> None: + """Should return cannot answer when query doesn't reference history.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + history = [ + { + "authorRole": "user", + "message": "What is the weather?", + "timestamp": "2024-01-01T12:00:00", + }, + ] + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Query is about taxes, not previous weather discussion", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.002, + "total_tokens": 100, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What is the tax rate?", + conversation_history=history, + language="en", + ) + + assert result.is_greeting is False + assert result.can_answer_from_context is False + assert result.answer is None + + +class TestErrorHandling: + """Test error handling in context analyzer.""" + + @pytest.mark.asyncio + async def test_handles_llm_json_parse_error(self) -> None: + """Should handle invalid JSON response from LLM gracefully.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Mock DSPy module to return invalid JSON + mock_response = MagicMock() + mock_response.analysis_result = "Invalid JSON response" + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="Hello", + conversation_history=[], + language="en", + ) + + # Should fallback to safe default + assert result.is_greeting is False + assert result.can_answer_from_context is False + assert result.answer is None + assert "Failed to parse" in result.reasoning + + @pytest.mark.asyncio + async def test_handles_llm_exception(self) -> None: + """Should handle LLM call exceptions gracefully.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Mock DSPy module to raise exception + with patch.object( + dspy, + "ChainOfThought", + return_value=MagicMock(side_effect=Exception("LLM error")), + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.0, + "total_tokens": 0, + "num_calls": 0, + } + + result, _ = await analyzer.analyze_context( + query="Hello", + conversation_history=[], + language="en", + ) + + # Should fallback to safe default + assert result.is_greeting is False + assert result.can_answer_from_context is False + assert result.answer is None + assert "error" in result.reasoning.lower() + + +class TestFallbackGreeting: + """Test fallback greeting responses.""" + + def test_fallback_estonian_greeting(self) -> None: + """Should return Estonian fallback greeting.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + response = analyzer.get_fallback_greeting_response("et") + + assert "Tere" in response + + def test_fallback_english_greeting(self) -> None: + """Should return English fallback greeting.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + response = analyzer.get_fallback_greeting_response("en") + + assert "Hello" in response or "hello" in response + + def test_fallback_unknown_language_defaults_to_estonian(self) -> None: + """Should default to Estonian for unknown language codes.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + response = analyzer.get_fallback_greeting_response("xx") + + assert "Tere" in response or "tere" in response.lower() + + +class TestGreetingConstants: + """Test greeting constants and helper functions.""" + + def test_get_estonian_hello(self) -> None: + """Should return Estonian hello greeting.""" + response = get_greeting_response("hello", "et") + assert "Tere" in response + + def test_get_english_goodbye(self) -> None: + """Should return English goodbye greeting.""" + response = get_greeting_response("goodbye", "en") + assert "Goodbye" in response or "goodbye" in response + + def test_get_estonian_thanks(self) -> None: + """Should return Estonian thanks greeting.""" + response = get_greeting_response("thanks", "et") + assert "Palun" in response + + def test_unknown_greeting_type_defaults_to_hello(self) -> None: + """Should default to hello for unknown greeting types.""" + response = get_greeting_response("unknown", "en") + assert "Hello" in response or "hello" in response + + +def _make_history(num_turns: int) -> list[dict[str, str]]: + """Helper to create a conversation history with the specified number of turns.""" + return [ + { + "authorRole": "user" if i % 2 == 0 else "bot", + "message": f"Message {i}", + "timestamp": f"2024-01-01T12:00:{i:02d}", + } + for i in range(num_turns) + ] + + +class TestCostMerging: + """Test cost dictionary merging.""" + + def test_merge_cost_dicts(self) -> None: + """Should sum all numeric values from two cost dicts.""" + cost1 = { + "total_cost": 0.001, + "total_tokens": 50, + "total_prompt_tokens": 30, + "total_completion_tokens": 20, + "num_calls": 1, + } + cost2 = { + "total_cost": 0.002, + "total_tokens": 100, + "total_prompt_tokens": 60, + "total_completion_tokens": 40, + "num_calls": 1, + } + + merged = ContextAnalyzer._merge_cost_dicts(cost1, cost2) + + assert merged["total_cost"] == pytest.approx(0.003) + assert merged["total_tokens"] == 150 + assert merged["total_prompt_tokens"] == 90 + assert merged["total_completion_tokens"] == 60 + assert merged["num_calls"] == 2 + + def test_merge_cost_dicts_with_empty(self) -> None: + """Should handle merging with an empty cost dict.""" + cost1 = { + "total_cost": 0.001, + "total_tokens": 50, + "total_prompt_tokens": 30, + "total_completion_tokens": 20, + "num_calls": 1, + } + + merged = ContextAnalyzer._merge_cost_dicts(cost1, {}) + + assert merged["total_cost"] == 0.001 + assert merged["total_tokens"] == 50 + assert merged["num_calls"] == 1 + + +class TestConversationSummary: + """Test conversation summary generation.""" + + @pytest.mark.asyncio + async def test_generate_summary_from_older_turns(self) -> None: + """Should generate summary from older conversation turns.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + older_history = _make_history(6) + + mock_response = MagicMock() + mock_response.summary = "User discussed messages 0-5 about various topics." + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + summary, cost_dict = await analyzer._generate_conversation_summary( + older_history + ) + + assert summary == "User discussed messages 0-5 about various topics." + assert cost_dict["total_cost"] == 0.001 + + @pytest.mark.asyncio + async def test_generate_summary_handles_exception(self) -> None: + """Should return empty string when summary generation fails.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + with patch.object( + dspy, + "ChainOfThought", + return_value=MagicMock(side_effect=Exception("LLM error")), + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.0, + "total_tokens": 0, + "num_calls": 0, + } + + summary, _ = await analyzer._generate_conversation_summary( + _make_history(5) + ) + + assert summary == "" + + @pytest.mark.asyncio + async def test_analyze_from_summary_can_answer(self) -> None: + """Should answer from summary when information is available.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "can_answer_from_context": True, + "answer": "The tax rate discussed earlier was 20%.", + "reasoning": "Summary contains tax rate information", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.002, + "total_tokens": 100, + "num_calls": 1, + } + + result, cost_dict = await analyzer._analyze_from_summary( + query="What was the tax rate?", + summary="User asked about tax. Bot replied: tax rate is 20%.", + ) + + assert result.can_answer_from_context is True + assert result.answered_from_summary is True + assert result.answer is not None + assert "20%" in result.answer + + @pytest.mark.asyncio + async def test_analyze_from_summary_cannot_answer(self) -> None: + """Should return cannot answer when summary doesn't contain relevant info.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "can_answer_from_context": False, + "answer": None, + "reasoning": "Summary does not contain information about weather", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.002, + "total_tokens": 100, + "num_calls": 1, + } + + result, _ = await analyzer._analyze_from_summary( + query="What is the weather?", + summary="User discussed tax rates and filing.", + ) + + assert result.can_answer_from_context is False + assert result.answered_from_summary is False + assert result.answer is None + + @pytest.mark.asyncio + async def test_analyze_from_summary_handles_exception(self) -> None: + """Should return safe fallback when summary analysis fails.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + with patch.object( + dspy, + "ChainOfThought", + return_value=MagicMock(side_effect=Exception("LLM error")), + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.0, + "total_tokens": 0, + "num_calls": 0, + } + + result, _ = await analyzer._analyze_from_summary( + query="test", summary="test summary" + ) + + assert result.can_answer_from_context is False + assert result.answered_from_summary is False + assert result.answer is None + + +class TestSummaryFlow: + """Test the full analyze_context flow with summary logic.""" + + @pytest.mark.asyncio + async def test_short_history_skips_summary(self) -> None: + """With <= 10 turns, should use recent history only, no summary.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Cannot answer from recent history, but only 8 turns - should NOT trigger summary + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Cannot answer from context", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What is digital signature?", + conversation_history=_make_history(8), + language="en", + ) + + # Should not answer (no summary triggered for <= 10 turns) + assert result.can_answer_from_context is False + assert result.answered_from_summary is False + assert result.answer is None + + @pytest.mark.asyncio + async def test_long_history_answers_from_recent(self) -> None: + """With > 10 turns, if recent 10 can answer, should not trigger summary.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Can answer from recent history + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": True, + "answer": "The rate is 20%.", + "reasoning": "Found in recent history", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What was the rate?", + conversation_history=_make_history(15), + language="en", + ) + + assert result.can_answer_from_context is True + assert result.answered_from_summary is False + assert result.answer == "The rate is 20%." + + @pytest.mark.asyncio + async def test_long_history_answers_from_summary(self) -> None: + """With > 10 turns, if recent can't answer but summary can, should return summary answer.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Step 1: Recent history cannot answer + recent_response = MagicMock() + recent_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Not in recent history", + } + ) + + # Step 2: Summary generation + summary_response = MagicMock() + summary_response.summary = ( + "User asked about tax rates. Bot said the tax rate is 20%." + ) + + # Step 3: Summary analysis can answer + summary_analysis_response = MagicMock() + summary_analysis_response.analysis_result = json.dumps( + { + "can_answer_from_context": True, + "answer": "Based on our earlier discussion, the tax rate is 20%.", + "reasoning": "Found tax rate in conversation summary", + } + ) + + # Chain of Thought is called 3 times: recent analysis, summary gen, summary analysis + call_count = 0 + mock_modules = [ + MagicMock(return_value=recent_response), + MagicMock(return_value=summary_response), + MagicMock(return_value=summary_analysis_response), + ] + + def chain_of_thought_factory(*args: object, **kwargs: object) -> MagicMock: + nonlocal call_count + module = mock_modules[call_count] + call_count += 1 + return module + + with patch.object(dspy, "ChainOfThought", side_effect=chain_of_thought_factory): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, cost_dict = await analyzer.analyze_context( + query="What was the tax rate we discussed?", + conversation_history=_make_history(15), + language="en", + ) + + assert result.can_answer_from_context is True + assert result.answered_from_summary is True + assert result.answer is not None + assert "20%" in result.answer + # Costs should be merged from all 3 calls + assert cost_dict["num_calls"] == 3 + + @pytest.mark.asyncio + async def test_long_history_falls_to_rag(self) -> None: + """With > 10 turns, if neither recent nor summary can answer, should fall through.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + # Step 1: Recent history cannot answer + recent_response = MagicMock() + recent_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Not in recent history", + } + ) + + # Step 2: Summary generation + summary_response = MagicMock() + summary_response.summary = "User discussed weather and greetings." + + # Step 3: Summary analysis cannot answer + summary_analysis_response = MagicMock() + summary_analysis_response.analysis_result = json.dumps( + { + "can_answer_from_context": False, + "answer": None, + "reasoning": "Summary does not contain tax information", + } + ) + + call_count = 0 + mock_modules = [ + MagicMock(return_value=recent_response), + MagicMock(return_value=summary_response), + MagicMock(return_value=summary_analysis_response), + ] + + def chain_of_thought_factory(*args: object, **kwargs: object) -> MagicMock: + nonlocal call_count + module = mock_modules[call_count] + call_count += 1 + return module + + with patch.object(dspy, "ChainOfThought", side_effect=chain_of_thought_factory): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What is the tax rate?", + conversation_history=_make_history(15), + language="en", + ) + + # Should not be able to answer -> falls to RAG + assert result.can_answer_from_context is False + assert result.answered_from_summary is False + assert result.answer is None + + @pytest.mark.asyncio + async def test_answered_from_summary_flag_is_false_for_recent(self) -> None: + """The answered_from_summary flag should be False for recent history answers.""" + llm_manager = MagicMock() + analyzer = ContextAnalyzer(llm_manager) + + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": True, + "answer": "The answer from recent history.", + "reasoning": "Found in recent conversation", + } + ) + + with patch.object( + dspy, "ChainOfThought", return_value=MagicMock(return_value=mock_response) + ): + with patch( + "src.tool_classifier.context_analyzer.get_lm_usage_since" + ) as mock_cost: + mock_cost.return_value = { + "total_cost": 0.001, + "total_tokens": 50, + "num_calls": 1, + } + + result, _ = await analyzer.analyze_context( + query="What did you say?", + conversation_history=_make_history(5), + language="en", + ) + + assert result.answered_from_summary is False diff --git a/tests/test_context_workflow.py b/tests/test_context_workflow.py new file mode 100644 index 00000000..9a6d7e7d --- /dev/null +++ b/tests/test_context_workflow.py @@ -0,0 +1,700 @@ +"""Unit tests for context workflow executor.""" + +import pytest +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, MagicMock, patch +import dspy + +from src.tool_classifier.workflows.context_workflow import ContextWorkflowExecutor +from src.tool_classifier.context_analyzer import ContextDetectionResult +from src.models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, + ConversationItem, +) + + +@pytest.fixture +def mock_dspy_lm() -> Generator[MagicMock, None, None]: + """Mock DSPy LM to prevent 'No LM is loaded' errors.""" + mock_lm = MagicMock() + mock_lm.history = [] + with patch("dspy.settings") as mock_settings: + mock_settings.lm = mock_lm + # Configure DSPy with mock LM + dspy.configure(lm=mock_lm) + yield mock_lm + + +@pytest.fixture +def mock_orchestration_service() -> MagicMock: + """Create mock orchestration service for streaming tests.""" + import json as _json + import time as _time + + service = MagicMock() + + def _format_sse_impl(chat_id: str, content: str) -> str: + payload = { + "chatId": chat_id, + "payload": {"content": content}, + "timestamp": int(_time.time() * 1000), + } + return f"data: {_json.dumps(payload)}\n\n" + + service.format_sse = _format_sse_impl + service.log_costs = MagicMock() + return service + + +@pytest.fixture +def llm_manager() -> MagicMock: + """Create mock LLM manager.""" + return MagicMock() + + +@pytest.fixture +def context_workflow( + llm_manager: MagicMock, + mock_orchestration_service: MagicMock, + mock_dspy_lm: MagicMock, +) -> ContextWorkflowExecutor: + """Create ContextWorkflowExecutor instance.""" + return ContextWorkflowExecutor( + llm_manager, orchestration_service=mock_orchestration_service + ) + + +@pytest.fixture +def sample_request() -> OrchestrationRequest: + """Create sample orchestration request.""" + return OrchestrationRequest( + chatId="test-chat-123", + message="Hello!", + authorId="test-user", + conversationHistory=[], + url="https://example.com", + environment="testing", + connection_id="test-connection", + ) + + +class TestContextWorkflowInit: + """Test context workflow initialization.""" + + def test_init_creates_workflow(self, llm_manager: MagicMock) -> None: + """ContextWorkflowExecutor should initialize with LLM manager.""" + workflow = ContextWorkflowExecutor(llm_manager) + + assert workflow.llm_manager is llm_manager + assert workflow.context_analyzer is not None + + +class TestExecuteAsyncGreeting: + """Test execute_async with greeting queries.""" + + @pytest.mark.asyncio + async def test_execute_async_greeting_estonian( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should handle Estonian greeting and return response.""" + sample_request.message = "Tere!" + + # Mock context analyzer + mock_analysis = ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Greeting detected", + context_snippet=None, + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + ) as mock_detect: + mock_detect.return_value = ( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ) + context_dict = {} + response = await context_workflow.execute_async( + sample_request, context_dict + ) + + assert response is not None + assert isinstance(response, OrchestrationResponse) + assert response.chatId == "test-chat-123" + assert "Tere" in response.content + assert response.llmServiceActive is True + assert response.questionOutOfLLMScope is False + assert response.inputGuardFailed is False + + # Check cost tracking + assert "costs_dict" in context_dict + assert "context_detection" in context_dict["costs_dict"] + + @pytest.mark.asyncio + async def test_execute_async_greeting_english( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should handle English greeting and return response.""" + sample_request.message = "Hello!" + + mock_analysis = ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="English greeting detected", + context_snippet=None, + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + ) as mock_detect: + mock_detect.return_value = ( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ) + response = await context_workflow.execute_async(sample_request, {}) + + assert response is not None + assert "Hello" in response.content or "hello" in response.content.lower() + + +class TestExecuteAsyncContextBased: + """Test execute_async with context-based queries.""" + + @pytest.mark.asyncio + async def test_execute_async_context_answer( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should answer from conversation history when possible.""" + # Add conversation history + sample_request.conversationHistory = [ + ConversationItem( + authorRole="user", + message="What is the tax rate?", + timestamp="2024-01-01T12:00:00", + ), + ConversationItem( + authorRole="bot", + message="The tax rate is 20%.", + timestamp="2024-01-01T12:00:01", + ), + ] + sample_request.message = "What was the rate you mentioned?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Referring to previous conversation about tax rate", + context_snippet="The tax rate is 20%.", + ) + + with ( + patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.002, "total_tokens": 100, "num_calls": 1}, + ), + ), + patch.object( + context_workflow.context_analyzer, + "generate_context_response", + new_callable=AsyncMock, + return_value=( + "The tax rate is 20%.", + {"total_cost": 0.003, "num_calls": 1}, + ), + ), + ): + response = await context_workflow.execute_async(sample_request, {}) + + assert response is not None + assert "20%" in response.content + + @pytest.mark.asyncio + async def test_execute_async_cannot_answer_from_context( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return None when cannot answer from context (fallback to RAG).""" + sample_request.message = "What is digital signature?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Query requires knowledge base search", + context_snippet=None, + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await context_workflow.execute_async(sample_request, {}) + + assert response is None + + @pytest.mark.asyncio + async def test_execute_async_answer_is_none( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return None when can_answer_from_context=True but context_snippet is absent.""" + mock_analysis = ContextDetectionResult( + is_greeting=False, + can_answer_from_context=True, + context_snippet=None, # No snippet → cannot generate answer + reasoning="No relevant snippet found in history", + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await context_workflow.execute_async(sample_request, {}) + + assert response is None + + +class TestExecuteAsyncErrorHandling: + """Test error handling in execute_async.""" + + @pytest.mark.asyncio + async def test_execute_async_handles_analyzer_exception( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return None when context analyzer raises exception.""" + with patch.object( + context_workflow.context_analyzer, + "detect_context", + side_effect=Exception("Analysis failed"), + ): + response = await context_workflow.execute_async(sample_request, {}) + + assert response is None + + +class TestExecuteStreaming: + """Test execute_streaming functionality.""" + + @pytest.mark.asyncio + async def test_execute_streaming_greeting( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should stream greeting response.""" + sample_request.message = "Hello!" + + mock_analysis = ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Greeting detected", + context_snippet=None, + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + stream_gen = await context_workflow.execute_streaming(sample_request, {}) + + assert stream_gen is not None + + # Collect streamed chunks + chunks = [chunk async for chunk in stream_gen] + + # Should have multiple chunks + END marker + assert len(chunks) > 1 + + # Last chunk should be END marker + last_chunk = chunks[-1] + assert "END" in last_chunk + + # All chunks should be valid SSE format + for chunk in chunks: + assert chunk.startswith("data: ") + assert chunk.endswith("\n\n") + + @pytest.mark.asyncio + async def test_execute_streaming_context_answer( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should stream context-based answer.""" + sample_request.message = "What did you say earlier?" + sample_request.conversationHistory = [ + ConversationItem( + authorRole="bot", + message="The rate is 20%.", + timestamp="2024-01-01T12:00:00", + ), + ] + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Referring to previous message", + context_snippet="I mentioned that the rate is 20%.", + ) + + async def _fake_history_stream( + *args: object, **kwargs: object + ) -> AsyncGenerator[str, None]: + yield context_workflow.orchestration_service.format_sse( + sample_request.chatId, "I mentioned that the rate is 20%." + ) + yield context_workflow.orchestration_service.format_sse( + sample_request.chatId, "END" + ) + + with ( + patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.002, "total_tokens": 100, "num_calls": 1}, + ), + ), + patch.object( + context_workflow, + "_create_history_stream", + new_callable=AsyncMock, + return_value=_fake_history_stream(), + ), + ): + stream_gen = await context_workflow.execute_streaming(sample_request, {}) + + assert stream_gen is not None + + chunks = [chunk async for chunk in stream_gen] + + assert len(chunks) > 0 + # Verify END marker + assert "END" in chunks[-1] + + @pytest.mark.asyncio + async def test_execute_streaming_cannot_answer( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return None when cannot answer (fallback to RAG).""" + sample_request.message = "What is digital signature?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + can_answer_from_context=False, + reasoning="Requires knowledge base", + ) + + with patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + stream_gen = await context_workflow.execute_streaming(sample_request, {}) + + assert stream_gen is None + + @pytest.mark.asyncio + async def test_execute_streaming_handles_exception( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return None when analyzer raises exception.""" + with patch.object( + context_workflow.context_analyzer, + "detect_context", + side_effect=Exception("Analysis failed"), + ): + stream_gen = await context_workflow.execute_streaming(sample_request, {}) + + assert stream_gen is None + + +class TestCostTracking: + """Test cost tracking functionality.""" + + @pytest.mark.asyncio + async def test_cost_tracking_in_context_dict( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should track costs in context dictionary.""" + mock_analysis = ContextDetectionResult( + is_greeting=True, + can_answer_from_context=False, + reasoning="Greeting", + ) + + cost_dict = { + "total_cost": 0.0015, + "total_tokens": 75, + "total_prompt_tokens": 50, + "total_completion_tokens": 25, + "num_calls": 1, + } + + with patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=(mock_analysis, cost_dict), + ): + context_dict = {} + await context_workflow.execute_async(sample_request, context_dict) + + assert "costs_dict" in context_dict + assert "context_detection" in context_dict["costs_dict"] + assert context_dict["costs_dict"]["context_detection"]["total_cost"] == 0.0015 + assert context_dict["costs_dict"]["context_detection"]["total_tokens"] == 75 + + +class TestLanguageDetection: + """Test language detection integration.""" + + @pytest.mark.asyncio + async def test_detects_estonian_language( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should detect Estonian language from query.""" + sample_request.message = "Tere! Kuidas läheb?" + + mock_analysis = ContextDetectionResult( + is_greeting=True, + can_answer_from_context=False, + reasoning="Estonian greeting", + ) + + with ( + patch.object( + context_workflow.context_analyzer, "detect_context" + ) as mock_detect, + patch( + "src.tool_classifier.greeting_constants.get_greeting_response" + ) as mock_greeting, + ): + mock_detect.return_value = ( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ) + mock_greeting.return_value = "Tere! Kuidas ma saan sind aidata?" + + await context_workflow.execute_async(sample_request, {}) + + # Verify Estonian language was used for greeting response + mock_greeting.assert_called_with(greeting_type="hello", language="et") + + @pytest.mark.asyncio + async def test_detects_english_language( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should detect English language from query.""" + sample_request.message = "Hello! How are you?" + + mock_analysis = ContextDetectionResult( + is_greeting=True, + can_answer_from_context=False, + reasoning="English greeting", + ) + + with ( + patch.object( + context_workflow.context_analyzer, "detect_context" + ) as mock_detect, + patch( + "src.tool_classifier.greeting_constants.get_greeting_response" + ) as mock_greeting, + ): + mock_detect.return_value = ( + mock_analysis, + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ) + mock_greeting.return_value = "Hello! How can I help you?" + + await context_workflow.execute_async(sample_request, {}) + + # Verify English language was used for greeting response + mock_greeting.assert_called_with(greeting_type="hello", language="en") + + +class TestExecuteAsyncSummaryBased: + """Test execute_async with summary-based answers.""" + + @pytest.mark.asyncio + async def test_execute_async_summary_answer( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should return response when answer comes from conversation summary.""" + sample_request.message = "What was the tax rate we discussed earlier?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Found in conversation summary", + context_snippet="Based on our earlier discussion, the tax rate is 20%.", + answered_from_summary=True, + ) + + with ( + patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.005, "total_tokens": 200, "num_calls": 3}, + ), + ), + patch.object( + context_workflow.context_analyzer, + "generate_context_response", + new_callable=AsyncMock, + return_value=( + "Based on our earlier discussion, the tax rate is 20%.", + {"total_cost": 0.003, "num_calls": 1}, + ), + ), + ): + response = await context_workflow.execute_async(sample_request, {}) + + assert response is not None + assert isinstance(response, OrchestrationResponse) + assert "20%" in response.content + assert response.llmServiceActive is True + + @pytest.mark.asyncio + async def test_execute_streaming_summary_answer( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should stream summary-based answer correctly.""" + sample_request.message = "What was the tax rate we discussed earlier?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Found in conversation summary", + context_snippet="Based on our earlier discussion, the tax rate is 20%.", + answered_from_summary=True, + ) + + async def _fake_summary_stream( + *args: object, **kwargs: object + ) -> AsyncGenerator[str, None]: + yield context_workflow.orchestration_service.format_sse( + sample_request.chatId, "The tax rate is 20%." + ) + yield context_workflow.orchestration_service.format_sse( + sample_request.chatId, "END" + ) + + with ( + patch.object( + context_workflow.context_analyzer, + "detect_context", + return_value=( + mock_analysis, + {"total_cost": 0.005, "total_tokens": 200, "num_calls": 3}, + ), + ), + patch.object( + context_workflow, + "_create_history_stream", + new_callable=AsyncMock, + return_value=_fake_summary_stream(), + ), + ): + stream_gen = await context_workflow.execute_streaming(sample_request, {}) + + assert stream_gen is not None + + chunks = [chunk async for chunk in stream_gen] + + # Should have multiple chunks + END marker + assert len(chunks) > 1 + assert "END" in chunks[-1] + + @pytest.mark.asyncio + async def test_pre_computed_summary_analysis( + self, + context_workflow: ContextWorkflowExecutor, + sample_request: OrchestrationRequest, + ) -> None: + """Should use pre-computed summary analysis from classifier.""" + sample_request.message = "What was the tax rate?" + + mock_analysis = ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Found in summary", + context_snippet="The tax rate is 20%.", + answered_from_summary=True, + ) + + # Pre-computed analysis (from classifier) + context = {"analysis_result": mock_analysis} + + with patch.object( + context_workflow.context_analyzer, + "generate_context_response", + new_callable=AsyncMock, + return_value=( + "The tax rate is 20%.", + {"total_cost": 0.003, "num_calls": 1}, + ), + ): + response = await context_workflow.execute_async(sample_request, context) + + assert response is not None + assert "20%" in response.content diff --git a/tests/test_context_workflow_integration.py b/tests/test_context_workflow_integration.py new file mode 100644 index 00000000..a11a7f48 --- /dev/null +++ b/tests/test_context_workflow_integration.py @@ -0,0 +1,878 @@ +"""Integration tests for context workflow. + +Tests the full classify -> route -> execute chain with real component wiring. +Only the LLM layer (dspy) and RAG orchestration service are mocked. + +These tests verify: +- ToolClassifier.classify() correctly routes greetings to CONTEXT workflow +- ToolClassifier.route_to_workflow() executes the context workflow end-to-end +- Fallback from CONTEXT to RAG when context cannot answer +- Streaming mode for context workflow responses +- Cost tracking propagation through the classify -> execute chain +- Error resilience (LLM failures, JSON parse errors) +""" + +import pytest +from collections.abc import AsyncGenerator, Generator +from contextlib import AbstractContextManager +from unittest.mock import AsyncMock, MagicMock, patch +import json +import dspy + +from src.tool_classifier.classifier import ToolClassifier +from src.tool_classifier.context_analyzer import ContextDetectionResult +from src.tool_classifier.models import ClassificationResult +from src.models.request_models import ( + OrchestrationRequest, + OrchestrationResponse, + ConversationItem, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def mock_dspy_lm() -> Generator[MagicMock, None, None]: + """Mock DSPy LM to prevent 'No LM is loaded' errors.""" + mock_lm = MagicMock() + mock_lm.history = [] + with patch("dspy.settings") as mock_settings: + mock_settings.lm = mock_lm + # Configure DSPy with mock LM + dspy.configure(lm=mock_lm) + yield mock_lm + + +@pytest.fixture +def mock_orchestration_service() -> MagicMock: + """Create mock orchestration service for RAG workflow fallback.""" + import json as _json + import time as _time + + service = MagicMock() + + # Non-streaming RAG fallback returns a valid response + async def mock_execute_pipeline(**kwargs: object) -> OrchestrationResponse: + return OrchestrationResponse( + chatId=kwargs["request"].chatId, + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content="RAG fallback answer.", + ) + + service._execute_orchestration_pipeline = AsyncMock( + side_effect=mock_execute_pipeline + ) + service._initialize_service_components = MagicMock(return_value={}) + service._log_costs = MagicMock() + service.log_costs = MagicMock() + + def _format_sse_impl(chat_id: str, content: str) -> str: + payload = { + "chatId": chat_id, + "payload": {"content": content}, + "timestamp": int(_time.time() * 1000), + } + return f"data: {_json.dumps(payload)}\n\n" + + service.format_sse = _format_sse_impl + + # Streaming RAG fallback + async def mock_stream_pipeline(**kwargs: object) -> AsyncGenerator[str, None]: + yield 'data: {"chatId":"test","payload":{"content":"RAG stream"}}\n\n' + yield 'data: {"chatId":"test","payload":{"content":"END"}}\n\n' + + service._stream_rag_pipeline = mock_stream_pipeline + + return service + + +@pytest.fixture +def llm_manager() -> MagicMock: + """Create mock LLM manager.""" + return MagicMock() + + +@pytest.fixture +def classifier( + llm_manager: MagicMock, mock_orchestration_service: MagicMock +) -> ToolClassifier: + """Create a real ToolClassifier with real workflow executors.""" + return ToolClassifier( + llm_manager=llm_manager, + orchestration_service=mock_orchestration_service, + ) + + +def _make_request( + message: str, + chat_id: str = "integration-test-chat", + history: list | None = None, +) -> OrchestrationRequest: + """Helper to build an OrchestrationRequest.""" + return OrchestrationRequest( + chatId=chat_id, + message=message, + authorId="test-user", + conversationHistory=history or [], + url="https://example.com", + environment="testing", + connection_id="test-conn", + ) + + +def _mock_dspy_greeting(answer_text: str) -> AbstractContextManager[MagicMock]: + """Return a patch context manager that makes dspy return a greeting analysis.""" + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": True, + "can_answer_from_context": False, + "answer": answer_text, + "reasoning": "Greeting detected", + } + ) + return patch( + "dspy.ChainOfThought", + return_value=MagicMock(return_value=mock_response), + ) + + +def _mock_dspy_context_answer( + answer_text: str, reasoning: str = "History reference" +) -> AbstractContextManager[MagicMock]: + """Return a patch that makes dspy return a context-based answer.""" + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": True, + "answer": answer_text, + "reasoning": reasoning, + } + ) + return patch( + "dspy.ChainOfThought", + return_value=MagicMock(return_value=mock_response), + ) + + +def _mock_dspy_no_match() -> AbstractContextManager[MagicMock]: + """Return a patch that makes dspy indicate neither greeting nor context match.""" + mock_response = MagicMock() + mock_response.analysis_result = json.dumps( + { + "is_greeting": False, + "can_answer_from_context": False, + "answer": None, + "reasoning": "Requires knowledge base search", + } + ) + return patch( + "dspy.ChainOfThought", + return_value=MagicMock(return_value=mock_response), + ) + + +def _patch_cost_utils() -> AbstractContextManager[MagicMock]: + """Patch cost tracking to avoid dspy settings dependency. + + Patches at both possible module paths to handle Python's module identity + behaviour when src/ is on sys.path (module may be loaded as either + ``tool_classifier.context_analyzer`` or ``src.tool_classifier.context_analyzer``). + """ + cost_return = { + "total_cost": 0.001, + "total_tokens": 50, + "total_prompt_tokens": 30, + "total_completion_tokens": 20, + "num_calls": 1, + } + + import sys + + # Determine which module key is actually loaded at runtime + if "tool_classifier.context_analyzer" in sys.modules: + target = "tool_classifier.context_analyzer.get_lm_usage_since" + else: + target = "src.tool_classifier.context_analyzer.get_lm_usage_since" + + return patch(target, return_value=cost_return) + + +# --------------------------------------------------------------------------- +# Integration: classify -> route -> execute (non-streaming) +# --------------------------------------------------------------------------- + + +class TestClassifyAndRouteGreeting: + """Test full classify -> route chain for greeting queries.""" + + @pytest.mark.asyncio + async def test_greeting_classify_returns_context_workflow( + self, classifier: ToolClassifier + ) -> None: + """classify() should return CONTEXT workflow for greeting queries. + + With the hybrid-search classifier, classify() uses Qdrant to detect + service queries. When no service matches (or embedding fails in tests), + it falls back to CONTEXT. The analysis_result is produced later inside + the context workflow executor during route_to_workflow. + """ + with ( + _mock_dspy_greeting("Tere! Kuidas ma saan sind aidata?"), + _patch_cost_utils(), + ): + result = await classifier.classify( + query="Tere!", + conversation_history=[], + language="et", + ) + + # Hybrid classifier routes non-service queries to CONTEXT + assert result.workflow.value == "context" + # analysis_result is now populated during route_to_workflow, not classify + assert result.metadata is not None + + @pytest.mark.asyncio + async def test_greeting_end_to_end_non_streaming( + self, classifier: ToolClassifier + ) -> None: + """Full chain: classify greeting -> route to context workflow -> get response.""" + with _mock_dspy_greeting("Hello! How can I help you?"), _patch_cost_utils(): + classification = await classifier.classify( + query="Hello!", + conversation_history=[], + language="en", + ) + + request = _make_request("Hello!") + with patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Greeting detected", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert response.chatId == "integration-test-chat" + assert "Hello" in response.content + assert response.llmServiceActive is True + assert response.questionOutOfLLMScope is False + + @pytest.mark.asyncio + async def test_estonian_greeting_end_to_end( + self, classifier: ToolClassifier + ) -> None: + """Full chain for Estonian greeting.""" + with ( + _mock_dspy_greeting("Tere! Kuidas ma saan sind aidata?"), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="Tere!", + conversation_history=[], + language="et", + ) + + request = _make_request("Tere!") + with patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Estonian greeting detected", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "Tere" in response.content + + @pytest.mark.asyncio + async def test_goodbye_end_to_end(self, classifier: ToolClassifier) -> None: + """Full chain for goodbye greeting.""" + with _mock_dspy_greeting("Goodbye! Have a great day!"), _patch_cost_utils(): + classification = await classifier.classify( + query="Goodbye!", + conversation_history=[], + language="en", + ) + + request = _make_request("Goodbye!") + with patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=True, + greeting_type="goodbye", + can_answer_from_context=False, + reasoning="Goodbye detected", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "Goodbye" in response.content + + @pytest.mark.asyncio + async def test_thanks_end_to_end(self, classifier: ToolClassifier) -> None: + """Full chain for thanks greeting.""" + with ( + _mock_dspy_greeting("You're welcome! Feel free to ask more."), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="Thank you!", + conversation_history=[], + language="en", + ) + + request = _make_request("Thank you!") + with patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=True, + greeting_type="thanks", + can_answer_from_context=False, + reasoning="Thanks detected", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "welcome" in response.content.lower() + + +class TestClassifyAndRouteContextAnswer: + """Test full classify -> route chain for context-based answers.""" + + @pytest.mark.asyncio + async def test_context_answer_end_to_end(self, classifier: ToolClassifier) -> None: + """Full chain: classify history query -> route to context -> get answer.""" + history = [ + ConversationItem( + authorRole="user", + message="What is the tax rate?", + timestamp="2024-01-01T12:00:00", + ), + ConversationItem( + authorRole="bot", + message="The tax rate is 20%.", + timestamp="2024-01-01T12:00:01", + ), + ] + + with ( + _mock_dspy_context_answer("I mentioned the tax rate is 20%."), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="What was the rate?", + conversation_history=history, + language="en", + ) + + request = _make_request("What was the rate?", history=history) + with ( + patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Tax rate referenced in history", + context_snippet="The tax rate is 20%.", + ), + {"total_cost": 0.002, "total_tokens": 100, "num_calls": 1}, + ), + ), + patch.object( + classifier.context_workflow.context_analyzer, + "generate_context_response", + new_callable=AsyncMock, + return_value=( + "I mentioned the tax rate is 20%.", + {"total_cost": 0.003, "num_calls": 1}, + ), + ), + ): + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert classification.workflow.value == "context" + assert isinstance(response, OrchestrationResponse) + assert "20%" in response.content + + @pytest.mark.asyncio + async def test_context_answer_with_long_history( + self, classifier: ToolClassifier + ) -> None: + """Should pass last 10 turns to the analyzer even with longer history.""" + history = [ + ConversationItem( + authorRole="user" if i % 2 == 0 else "bot", + message=f"Message {i}", + timestamp=f"2024-01-01T12:00:{i:02d}", + ) + for i in range(15) + ] + + with ( + _mock_dspy_context_answer("Based on our conversation, here's the answer."), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="What did we discuss?", + conversation_history=history, + language="en", + ) + + request = _make_request("What did we discuss?", history=history) + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert classification.workflow.value == "context" + assert isinstance(response, OrchestrationResponse) + assert response.content is not None + + +# --------------------------------------------------------------------------- +# Integration: fallback from CONTEXT to RAG +# --------------------------------------------------------------------------- + + +class TestContextToRAGFallback: + """Test that context workflow falls back to RAG when it cannot answer.""" + + @pytest.mark.asyncio + async def test_classify_defaults_to_rag_when_no_context_match( + self, classifier: ToolClassifier, mock_orchestration_service: MagicMock + ) -> None: + """When context analyzer can't answer, the full route chain ends at RAG. + + With the hybrid-search classifier, classify() returns CONTEXT for + non-service queries. The RAG fallback is triggered inside + route_to_workflow when the context workflow returns None. + """ + with _mock_dspy_no_match(), _patch_cost_utils(): + classification = await classifier.classify( + query="What is a digital signature?", + conversation_history=[], + language="en", + ) + + # Classifier routes non-service queries to CONTEXT first + assert classification.workflow.value == "context" + + # Full route: context can't answer → falls back to RAG + request = _make_request("What is a digital signature?") + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "RAG" in response.content + + @pytest.mark.asyncio + async def test_fallback_to_rag_end_to_end( + self, classifier: ToolClassifier, mock_orchestration_service: MagicMock + ) -> None: + """Full chain: context can't answer -> falls back to RAG -> gets RAG response.""" + with _mock_dspy_no_match(), _patch_cost_utils(): + classification = await classifier.classify( + query="What is a digital signature?", + conversation_history=[], + language="en", + ) + + # Hybrid classifier routes to CONTEXT first; RAG is via fallback + assert classification.workflow.value == "context" + + request = _make_request("What is a digital signature?") + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + # RAG mock returns "RAG fallback answer." + assert "RAG" in response.content + + @pytest.mark.asyncio + async def test_context_workflow_returns_none_triggers_rag_fallback( + self, classifier: ToolClassifier, mock_orchestration_service: MagicMock + ) -> None: + """When context workflow returns None during routing, RAG fallback is used.""" + # Force classification to CONTEXT but with an analysis that will produce None + no_answer_analysis = ContextDetectionResult( + is_greeting=False, + can_answer_from_context=False, + answer=None, + reasoning="Cannot answer", + ) + + # Use the WorkflowType from the same module path the classifier uses + from tool_classifier.enums import WorkflowType as _WorkflowType + + forced_classification = ClassificationResult( + workflow=_WorkflowType.CONTEXT, + confidence=0.95, + metadata={"analysis_result": no_answer_analysis}, + reasoning="Forced for test", + ) + + request = _make_request("Something that context can't answer") + response = await classifier.route_to_workflow( + classification=forced_classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + # Should have fallen through to RAG + assert "RAG" in response.content + + +# --------------------------------------------------------------------------- +# Integration: streaming mode +# --------------------------------------------------------------------------- + + +class TestStreamingIntegration: + """Test the full classify -> route -> stream chain.""" + + @pytest.mark.asyncio + async def test_streaming_greeting_end_to_end( + self, classifier: ToolClassifier + ) -> None: + """Full chain: classify greeting -> route streaming -> collect SSE chunks.""" + with _mock_dspy_greeting("Hello! How can I help you?"), _patch_cost_utils(): + classification = await classifier.classify( + query="Hello!", + conversation_history=[], + language="en", + ) + + request = _make_request("Hello!") + with patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=True, + greeting_type="hello", + can_answer_from_context=False, + reasoning="Greeting detected", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ): + stream = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + ) + + # Collect chunks inside the mock context so the dspy patch is active + # when the async generator body executes (lazy evaluation). + chunks = [chunk async for chunk in stream] + + # Should have content chunks + END marker + assert len(chunks) >= 2 + for chunk in chunks: + assert chunk.startswith("data: ") + assert chunk.endswith("\n\n") + + # Last chunk should contain END + last_payload = json.loads(chunks[-1][6:-2]) + assert last_payload["payload"]["content"] == "END" + + # Reconstruct content from non-END chunks + content_parts = [] + for chunk in chunks[:-1]: + payload = json.loads(chunk[6:-2]) + content_parts.append(payload["payload"]["content"]) + full_content = "".join(content_parts) + assert "Hello" in full_content + + @pytest.mark.asyncio + async def test_streaming_context_answer_end_to_end( + self, classifier: ToolClassifier + ) -> None: + """Full chain: classify history query -> route streaming -> collect answer.""" + history = [ + ConversationItem( + authorRole="bot", + message="The deadline is March 31st.", + timestamp="2024-01-01T12:00:00", + ), + ] + + async def _mock_history_stream() -> AsyncGenerator[str, None]: + yield 'data: {"chatId":"integration-test-chat","payload":{"content":"The deadline is March 31st."}}\n\n' + yield 'data: {"chatId":"integration-test-chat","payload":{"content":"END"}}\n\n' + + with ( + _mock_dspy_context_answer("The deadline is March 31st."), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="When is the deadline?", + conversation_history=history, + language="en", + ) + + request = _make_request("When is the deadline?", history=history) + with ( + patch.object( + classifier.context_workflow.context_analyzer, + "detect_context_with_summary_fallback", + new_callable=AsyncMock, + return_value=( + ContextDetectionResult( + is_greeting=False, + greeting_type="hello", + can_answer_from_context=True, + reasoning="Deadline referenced in history", + context_snippet="The deadline is March 31st.", + ), + {"total_cost": 0.001, "total_tokens": 50, "num_calls": 1}, + ), + ), + patch.object( + classifier.context_workflow, + "_create_history_stream", + new_callable=AsyncMock, + return_value=_mock_history_stream(), + ), + ): + stream = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + ) + + chunks = [chunk async for chunk in stream] + + assert len(chunks) >= 2 + last_payload = json.loads(chunks[-1][6:-2]) + assert last_payload["payload"]["content"] == "END" + + @pytest.mark.asyncio + async def test_streaming_fallback_to_rag( + self, classifier: ToolClassifier, mock_orchestration_service: MagicMock + ) -> None: + """Streaming: context can't answer -> falls back to RAG streaming.""" + # Force classification to CONTEXT with no answer + no_answer_analysis = ContextDetectionResult( + is_greeting=False, + can_answer_from_context=False, + answer=None, + reasoning="Cannot answer", + ) + + from tool_classifier.enums import WorkflowType as _WorkflowType + + forced_classification = ClassificationResult( + workflow=_WorkflowType.CONTEXT, + confidence=0.95, + metadata={"analysis_result": no_answer_analysis}, + reasoning="Forced for test", + ) + + request = _make_request("Something needing RAG") + stream = await classifier.route_to_workflow( + classification=forced_classification, + request=request, + is_streaming=True, + ) + + chunks = [chunk async for chunk in stream] + + # Should have received RAG streaming output + assert len(chunks) >= 1 + + +# --------------------------------------------------------------------------- +# Integration: cost tracking across the chain +# --------------------------------------------------------------------------- + + +class TestCostTrackingIntegration: + """Test that cost data flows through the full classify -> execute chain.""" + + @pytest.mark.asyncio + async def test_costs_propagated_through_classification( + self, classifier: ToolClassifier + ) -> None: + """Cost dict from context analysis should be tracked during workflow execution. + + With the hybrid-search classifier, costs are tracked inside the context + workflow executor (execute_async/execute_streaming), not in classify(). + The cost dict is stored in the workflow's internal context dictionary. + """ + with _mock_dspy_greeting("Hello!"), _patch_cost_utils(): + classification = await classifier.classify( + query="Hello!", + conversation_history=[], + language="en", + ) + + # Verify classify succeeded and routes to CONTEXT + assert classification.workflow.value == "context" + + # Execute the workflow to trigger cost tracking + request = _make_request("Hello!") + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + # Verify workflow ran successfully (costs tracked internally) + assert isinstance(response, OrchestrationResponse) + assert response.chatId == "integration-test-chat" + + +# --------------------------------------------------------------------------- +# Integration: error resilience +# --------------------------------------------------------------------------- + + +class TestErrorResilience: + """Test that errors in context analysis gracefully fall back to RAG.""" + + @pytest.mark.asyncio + async def test_llm_exception_falls_back_to_rag( + self, classifier: ToolClassifier + ) -> None: + """If context analyzer LLM call raises, the route chain falls back to RAG. + + With the hybrid-search classifier, classify() returns CONTEXT for + non-service queries. When the context workflow LLM call raises, the + context workflow returns None and route_to_workflow falls back to RAG. + """ + with ( + patch( + "dspy.ChainOfThought", + return_value=MagicMock(side_effect=Exception("LLM unavailable")), + ), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="Hello!", + conversation_history=[], + language="en", + ) + + # classify() returns CONTEXT (non-service query) + assert classification.workflow.value == "context" + + # Full route: context LLM fails → falls back to RAG gracefully + request = _make_request("Hello!") + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "RAG" in response.content + + @pytest.mark.asyncio + async def test_json_parse_error_falls_back_to_rag( + self, classifier: ToolClassifier + ) -> None: + """If LLM returns invalid JSON, the route chain falls back to RAG. + + JSON parse failure causes context analysis to return is_greeting=False, + answer=None. The context workflow then returns None and the fallback + chain routes to RAG. + """ + mock_response = MagicMock() + mock_response.analysis_result = "not valid json at all" + + with ( + patch( + "dspy.ChainOfThought", + return_value=MagicMock(return_value=mock_response), + ), + _patch_cost_utils(), + ): + classification = await classifier.classify( + query="Hello!", + conversation_history=[], + language="en", + ) + + # classify() returns CONTEXT (non-service query) + assert classification.workflow.value == "context" + + # Full route: JSON parse fails → context returns None → RAG fallback + request = _make_request("Hello!") + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert "RAG" in response.content diff --git a/tests/test_direct_step_executor.py b/tests/test_direct_step_executor.py new file mode 100644 index 00000000..95bc582e --- /dev/null +++ b/tests/test_direct_step_executor.py @@ -0,0 +1,262 @@ +"""Unit tests for ServiceWorkflowExecutor direct step executor methods. + +Tests execute_direct_step() (non-streaming) and +execute_direct_step_streaming() (SSE) which handle #service button payloads +for multi-step MCQ flows. +""" + +from typing import Any, Dict, List +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.models.request_models import OrchestrationRequest +from src.tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor + + +def _make_request( + message: str = "#service, /POST/services/active/mcq_step_1", + chat_id: str = "test-chat-123", + author_id: str = "user-456", +) -> OrchestrationRequest: + """Build a minimal OrchestrationRequest for testing.""" + return OrchestrationRequest( + chatId=chat_id, + authorId=author_id, + message=message, + url="https://test.example.com", + environment="production", + connection_id=None, + conversationHistory=[], + ) + + +def _make_executor( + orchestration_service: Any = None, +) -> ServiceWorkflowExecutor: + """Build a ServiceWorkflowExecutor with a stubbed LLM manager.""" + return ServiceWorkflowExecutor( + llm_manager=MagicMock(), + orchestration_service=orchestration_service, + ) + + +SAMPLE_CONTENT = "Which year was your passport issued?" +SAMPLE_BUTTONS: List[Dict[str, Any]] = [ + { + "title": "2023", + "payload": "#service, /POST/services/active/mcq_year_2023", + }, + { + "title": "2024", + "payload": "#service, /POST/services/active/mcq_year_2024", + }, +] + + +class TestExecuteDirectStep: + """Tests for execute_direct_step (non-streaming).""" + + @pytest.mark.asyncio + async def test_returns_response_with_content_and_buttons(self) -> None: + """Valid prefix + successful endpoint → full OrchestrationResponse.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock( + return_value={"content": SAMPLE_CONTENT, "buttons": SAMPLE_BUTTONS} + ) + + result = await executor.execute_direct_step(_make_request()) + + assert result is not None + assert result.content == SAMPLE_CONTENT + assert result.buttons is not None + assert len(result.buttons) == 2 + assert result.buttons[0].title == "2023" + assert ( + result.buttons[0].payload == "#service, /POST/services/active/mcq_year_2023" + ) + assert result.buttons[1].title == "2024" + assert ( + result.buttons[1].payload == "#service, /POST/services/active/mcq_year_2024" + ) + + @pytest.mark.asyncio + async def test_endpoint_returns_none(self) -> None: + """Endpoint failure → method returns None.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock(return_value=None) + + result = await executor.execute_direct_step(_make_request()) + + assert result is None + + @pytest.mark.asyncio + async def test_invalid_prefix_returns_none(self) -> None: + """Unparseable message → returns None without calling the endpoint.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock() + + result = await executor.execute_direct_step( + _make_request(message="Hello, I need help") + ) + + assert result is None + executor._call_service_endpoint.assert_not_called() + + @pytest.mark.asyncio + async def test_empty_buttons_sets_none(self) -> None: + """No buttons in response → buttons field is None, not empty list.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock( + return_value={"content": "Final answer.", "buttons": []} + ) + + result = await executor.execute_direct_step(_make_request()) + + assert result is not None + assert result.buttons is None + + @pytest.mark.asyncio + async def test_entities_array_is_empty(self) -> None: + """Direct steps pass an empty entities_array to the endpoint.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock( + return_value={"content": "ok", "buttons": []} + ) + + await executor.execute_direct_step(_make_request()) + + call_kwargs = executor._call_service_endpoint.call_args + assert call_kwargs.kwargs["entities_array"] == [] + + @pytest.mark.asyncio + async def test_time_metric_populated(self) -> None: + """time_metric['service.direct_step'] is set after the call.""" + executor = _make_executor() + executor._call_service_endpoint = AsyncMock( + return_value={"content": "ok", "buttons": []} + ) + time_metric: Dict[str, float] = {} + + await executor.execute_direct_step(_make_request(), time_metric=time_metric) + + assert "service.direct_step" in time_metric + assert time_metric["service.direct_step"] >= 0 + + @pytest.mark.asyncio + async def test_buttons_without_required_keys_filtered(self) -> None: + """Buttons missing 'title' or 'payload' are silently dropped.""" + bad_buttons = [ + {"title": "Good", "payload": "#service, /POST/ok"}, + {"title": "No payload field"}, + {"payload": "#service, /POST/no_title"}, + ] + executor = _make_executor() + executor._call_service_endpoint = AsyncMock( + return_value={"content": "text", "buttons": bad_buttons} + ) + + result = await executor.execute_direct_step(_make_request()) + + assert result is not None + assert result.buttons is not None + assert len(result.buttons) == 1 + assert result.buttons[0].title == "Good" + + +class TestExecuteDirectStepStreaming: + """Tests for execute_direct_step_streaming (SSE).""" + + @pytest.mark.asyncio + async def test_yields_content_and_end(self) -> None: + """Valid prefix → yields exactly 2 SSE chunks (content, END).""" + mock_sse = MagicMock() + mock_sse.format_sse = MagicMock(side_effect=["sse_content", "sse_end"]) + + executor = _make_executor(orchestration_service=mock_sse) + executor._call_service_endpoint = AsyncMock( + return_value={"content": SAMPLE_CONTENT, "buttons": SAMPLE_BUTTONS} + ) + + stream = await executor.execute_direct_step_streaming(_make_request()) + + assert stream is not None + chunks = [chunk async for chunk in stream] + assert chunks == ["sse_content", "sse_end"] + + @pytest.mark.asyncio + async def test_format_sse_called_with_buttons(self) -> None: + """format_sse receives content and buttons on first call, 'END' on second.""" + mock_sse = MagicMock() + mock_sse.format_sse = MagicMock(return_value="data: ...\n\n") + + executor = _make_executor(orchestration_service=mock_sse) + executor._call_service_endpoint = AsyncMock( + return_value={"content": SAMPLE_CONTENT, "buttons": SAMPLE_BUTTONS} + ) + + stream = await executor.execute_direct_step_streaming(_make_request()) + assert stream is not None + _ = [chunk async for chunk in stream] + + calls = mock_sse.format_sse.call_args_list + assert len(calls) == 2 + # First call: content + buttons + assert calls[0].args == ("test-chat-123", SAMPLE_CONTENT, SAMPLE_BUTTONS) + # Second call: END marker + assert calls[1].args == ("test-chat-123", "END") + + @pytest.mark.asyncio + async def test_endpoint_returns_none(self) -> None: + """Endpoint failure → returns None (no stream).""" + mock_sse = MagicMock() + executor = _make_executor(orchestration_service=mock_sse) + executor._call_service_endpoint = AsyncMock(return_value=None) + + result = await executor.execute_direct_step_streaming(_make_request()) + + assert result is None + + @pytest.mark.asyncio + async def test_invalid_prefix_returns_none(self) -> None: + """Unparseable message → returns None without calling the endpoint.""" + mock_sse = MagicMock() + executor = _make_executor(orchestration_service=mock_sse) + executor._call_service_endpoint = AsyncMock() + + result = await executor.execute_direct_step_streaming( + _make_request(message="just a normal question") + ) + + assert result is None + executor._call_service_endpoint.assert_not_called() + + @pytest.mark.asyncio + async def test_no_orchestration_service_raises(self) -> None: + """Missing orchestration_service → RuntimeError.""" + executor = _make_executor(orchestration_service=None) + executor._call_service_endpoint = AsyncMock( + return_value={"content": "ok", "buttons": []} + ) + + with pytest.raises(RuntimeError, match="not initialized for streaming"): + await executor.execute_direct_step_streaming(_make_request()) + + @pytest.mark.asyncio + async def test_time_metric_populated(self) -> None: + """time_metric['service.direct_step'] is set in streaming path.""" + mock_sse = MagicMock() + mock_sse.format_sse = MagicMock(return_value="data: ...\n\n") + + executor = _make_executor(orchestration_service=mock_sse) + executor._call_service_endpoint = AsyncMock( + return_value={"content": "ok", "buttons": []} + ) + time_metric: Dict[str, float] = {} + + await executor.execute_direct_step_streaming( + _make_request(), time_metric=time_metric + ) + + assert "service.direct_step" in time_metric + assert time_metric["service.direct_step"] >= 0 diff --git a/tests/test_input_sanitizer.py b/tests/test_input_sanitizer.py new file mode 100644 index 00000000..ad129f50 --- /dev/null +++ b/tests/test_input_sanitizer.py @@ -0,0 +1,125 @@ +"""Unit tests for InputSanitizer — focused on #service prefix safety. + +Validates that strip_html_tags() and sanitize_message() leave the +#service, /POST/... routing prefix characters (#, comma, /) untouched, +so that prefix detection logic in downstream handlers can always match. +""" + +import pytest + +from src.utils.input_sanitizer import InputSanitizer + + +class TestSanitizeMessageServicePrefix: + """Primary passthrough: #service, /METHOD/... payloads must survive sanitization unchanged.""" + + def test_exact_service_prefix_passthrough(self) -> None: + """The canonical #service prefix must survive sanitization bit-for-bit identical.""" + msg = "#service, /POST/services/active/foo" + assert InputSanitizer.sanitize_message(msg) == msg + + @pytest.mark.parametrize( + "msg", + [ + "#service, /POST/services/active/foo", + "#service, /GET/services/list", + "#service, /DELETE/services/active/foo", + "#service, /PUT/services/active/foo", + "#service, /PATCH/services/active/foo", + "#service, /POST/services/active/foo?status=true", + "#service, /POST/services/active/foo?a=1&b=2", + "#service, /POST/services/active/foo#anchor", + ], + ) + def test_service_prefix_variants_passthrough(self, msg: str) -> None: + """All #service, /METHOD/... variants must pass through unmodified.""" + assert InputSanitizer.sanitize_message(msg) == msg + + +class TestSanitizeMessageHtmlStripping: + """Confirms HTML IS stripped while #service prefix characters survive. + + These tests prove the sanitizer is active (not a no-op) and that it + surgically removes only HTML constructs, leaving #, comma, and / intact. + """ + + def test_bold_tags_stripped_prefix_survives(self) -> None: + result = InputSanitizer.sanitize_message( + "#service, /POST/services/active/foo" + ) + assert result == "#service, /POST/services/active/foo" + + def test_script_tag_content_stripped_path_survives(self) -> None: + """Dangerous foo" + ) + assert result == "#service, /POST/foo" + + def test_entity_encoded_script_tag_stripped_path_survives(self) -> None: + """Entity-encoded